fix(run): classify portfolio-mode flags by allowlist so a new flag fails closed

The portfolio entrance refused unsupported flags from a hard-coded BLOCKLIST:
--inbox, --out, --outbox, --run-id, --value-report, --live-dry-run. That
construction fails OPEN. A flag added to the parser later and forgotten in the
list is accepted, does nothing, and says nothing — the operator's flag is a
claim the run does not back (§1). MAF's report mode already used an allowlist;
the divergence was raised as an open question and the operator decided it this
session in favour of fail-closed.

unsupported_flags_given() now reports every flag GIVEN that the allowlist does
not name. "Given" is measured against the parser's own default, so it needs no
knowledge of which flags exist — that is what keeps it correct for flags added
after it was written, including store_true switches.

Load-bearing (§11), detach-proven twice (before and after ruff format, restored
from a copy): swapping the membership test back to a hard-coded refusal list
turns test_a_flag_nobody_classified_is_refused RED, while every CLI-level
refusal test stays green — they only exercise flags a blocklist already names,
so they do not cover this seam. The other direction is covered too: a run
passing all fourteen honoured flags still exits 0, and the allowlist entries
are checked against the CLI's own --help so a rename cannot leave a dead entry.

612 -> 624 passed, ruff + mypy --strict clean. README states the allowlist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQu2xxwedckjU56byu1aUG
This commit is contained in:
Kjell Tore Guttormsen 2026-07-25 15:33:30 +02:00
commit fc4a536e09
3 changed files with 194 additions and 18 deletions

View file

@ -531,6 +531,51 @@ def execute_dry_run(
return 0
# The dest names the PORTFOLIO pass honours — an ALLOWLIST, deliberately.
# Classification is required: a flag added to the parser later and named here
# nowhere is REFUSED in portfolio mode rather than accepted and then ignored.
# The blocklist this replaces failed OPEN — a forgotten flag no-opped in
# silence, which is a claim the run does not back (§1).
_PORTFOLIO_SUPPORTED_DESTS: frozenset[str] = frozenset(
{
"portfolio", # the mode itself
"verdict_dir", # portfolio-level expert inbox, read before each fold (K3)
"ledger", # read by the pre-spend goal check
"goals", # ditto — both are consulted BEFORE the portfolio branch
"max_rounds", # §8 caps, shared by every project under one meter
"max_tokens",
"max_budget_usd_per_call",
"max_debate_rounds",
"max_attempts",
"top_k",
"notify_console", # K10 sinks: the portfolio pass emits events too
"notify_file",
"notify_webhook",
"allow_webhook_egress",
}
)
def unsupported_flags_given(
parser: argparse.ArgumentParser,
args: argparse.Namespace,
*,
supported: frozenset[str],
) -> list[str]:
"""Flags the operator actually GAVE that ``supported`` does not name.
"Given" is measured against the parser's own default, so this needs no
knowledge of which flags exist which is the point: the answer stays
correct for flags added after it was written. Returns the flag spellings,
sorted, for a deterministic error message.
"""
return sorted(
"--" + dest.replace("_", "-")
for dest, value in vars(args).items()
if dest not in supported and value != parser.get_default(dest)
)
def main(
argv: list[str] | None = None,
*,
@ -625,24 +670,16 @@ def main(
# the outbox names its pairs by run_id, which a portfolio pass has none of.
# Every flag that would therefore do nothing is refused rather than
# silently ignored (§1) — a flag that quietly no-ops is a false claim.
unsupported = [
name
for name, value in (
("--inbox", args.inbox),
("--out", args.out),
("--outbox", args.outbox),
("--run-id", args.run_id),
("--value-report", args.value_report),
)
if value is not None
]
if args.live_dry_run:
unsupported.append("--live-dry-run")
# ALLOWLIST (never a blocklist): the refusal covers flags nobody has
# classified yet, so a new one fails closed instead of failing quiet.
unsupported = unsupported_flags_given(parser, args, supported=_PORTFOLIO_SUPPORTED_DESTS)
if unsupported:
parser.error(
f"--portfolio does not support {', '.join(sorted(unsupported))}: the "
"portfolio pass persists nothing and has no run_id of its own. Run the "
"projects individually with --bundle to file per-run artifacts."
f"--portfolio does not support {', '.join(unsupported)}: the portfolio "
"pass persists nothing and has no run_id of its own, and it honours only "
"the flags it is known to act on — anything else is refused rather than "
"silently ignored. Run the projects individually with --bundle to file "
"per-run artifacts."
)
# fail-fast (§10 spirit): a run persisted to the outbox MUST carry an explicit