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:
parent
2c1317bdb5
commit
fc4a536e09
3 changed files with 194 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue