Route the SDLC control plane on read facts, grade verification three-valued, and gate merge on it (Refs #3065) - #3123
valorengels wants to merge 19 commits into
Conversation
…Refs #3065) The parity fixture gains the two shapes on which the runners actually disagreed and which it could not previously express: a timeout (FAIL at 120s in the canonical runner vs a non-blocking SKIP at 30s in validate_build) and an expectation neither grammar reads. validate_build now takes its bound and its timeout disposition from the canonical runner, and UNEVALUATED blocks the exit code where SKIP did not.
A sweep of docs/plans/ found eleven live rows writing a trailing gloss on exit code N / output > N. Anchoring those would have turned working rows into blocking UNEVALUATED for a change nobody asked for, so the pre-existing forms keep prefix semantics and only the forms this lane adds are anchored. Verified against main: the only grading change across active plans is 'exit 0', which previously graded false.
…efs #3065) Covers task 1 of docs/plans/sdlc-control-plane-asserted-facts.md (Cluster E): - duplicate-row readback stability: two real AgentSession rows sharing one session_id, six consecutive binds onto a specific row, all must succeed. Deterministically red on the pre-fix filter(session_id=...)[0] readback. - adopted candidates survive all three release sites (save failure, raising readback, readback mismatch) and both adopt shapes (live-lock reuse and supervised-signal self-recognition); the live lease stays owned. - minted candidates are still released at the save and readback sites, so provenance gating does not become never-release. - structural: the bind path calls query.get and never query.filter. Also stubs query.get on the multi-lineage integration readback mock, whose four concurrent-contention cases modelled the old session_id-index readback.
Task 8: group (e) reads the _verification_outcomes aggregate, checks it fresh against the PR head fail-closed, and refuses on any FAIL/UNEVALUATED row by name. Reconciles a concurrent duplicate implementation of the same task in this worktree.
…3065) Task 8. Group (e) reads the recorded `_verification_outcomes` aggregate -- never re-executing a plan-authored command (PM ruling, 2026-09-03) -- and: - refuses on any FAIL or UNEVALUATED row, naming the row and its reason. This is the #3080 / ba092a0 owner ruling made machine-readable; it lived only in plan prose and PR #3080 merged straight past it. - checks the aggregate's freshness against the PR's current head first, via head_sha_of_record vs resolve_pr_head_sha, fail-closed on all three dispositions: match grades, mismatch refuses with VERIFICATION_OUTCOMES_STALE_REASON without reading the cached PASS, and a missing/unparseable head_sha or unresolvable PR head refuses. Deliberately stricter than group (c)'s recorded_at fallback, which exists only for records predating #2769. - keeps three branches distinguishable: no plan document and no recorded aggregate are REPORTED; a present aggregate that cannot be shown fresh is ENFORCED. The plan document is resolved through `tracking:` frontmatter (lane_identity.find_plan_path, scoped to the predicate's explicit repo_root), never by filename -- the tests' plan file is deliberately named something other than the lane slug. VERIFICATION_OUTCOMES_STALE_REASON is defined in tools/merge_predicate.py, where the predicate consumes it; the unread copy in agent/verification_parser.py is removed so there is one definition, not two.
…arm, guard reconciliation Tasks 2, 3 and 4 of docs/plans/sdlc-control-plane-asserted-facts.md. Task 2 — decisions carry their evidence (Cluster D). Blocked(NO_RULE) now carries `decision_inputs`: the stage_states and meta it decided on, plus an `unrecorded_dispatch` signal, surfaced through sdlc-tool next-skill's JSON. The batch reported a NO_RULE on a state row 5 has owned since c1e9919 and the report could be neither confirmed nor refuted, because the payload carried nothing. detect_unrecorded_dispatch names all three "no confirming record" shapes — no record at all, a record naming another skill, and a router slot never confirmed by a stage entry — so a skipped `dispatch record` surfaces now instead of four turns later as a G4 oscillation block blamed on the wrong cause. next-skill still persists nothing; this is a read. Task 3 — G3's redirect ladder is complete. Adds the /do-docs arm (G3_REDIRECT_REASON_DOCS_PENDING) for REVIEW complete + APPROVED + DOCS pending, which previously fell to the else and was sent back to a /do-pr-review it had already passed. Arm 1 now requires a recorded APPROVED verdict rather than the REVIEW marker alone, matching the #1932 gap-(c) gate rows 9 and 10 already apply — a completed REVIEW marker with no verdict is an unearned marker, not evidence of approval, and must not fast-path to /do-merge. Task 4 — guards are reconciled against the selected dispatch (the keystone). Guards ran at :2246-2248, the table at :2250-2255, and nothing re-validated in between, so G3 constrained a suggestion but never a decision. reconcile_dispatch re-runs the guard list with the table's selection as the proposed skill. Bounded by construction: exactly one pass on the selection, at most one on the resulting redirect, then a Blocked(RECONCILE_DEADLOCK) carrying both the selected row and the vetoing guard. Never a third pass. A guard naming the skill already chosen is agreement, not a veto, and does not trigger a block. Row 2b's predicate is NOT edited — #1639 made it marker-agnostic deliberately; it is constrained from outside. On the #2771/#2334 shipped-lane shape the router now answers /do-docs where main answers /do-plan-critique. The guards are not pure and reconciliation runs them twice. This is a stated invariant, written into reconcile_dispatch's docstring: guard_g5_artifact_hash_cache mutates record["artifact_hash"] in place and logs a WARNING on legacy-hash migration, and double invocation is idempotent ONLY because stage_states is passed by reference. A defensive copy would re-run the migration. Tests assert the WARNING fires exactly once per decide call and that the guards receive the caller's own objects by identity. A raising guard during reconciliation is deliberately not caught, preserving the existing asymmetry (rule predicates are try/except-wrapped at :2260-2263, guards at 1083-1086 are not). Swallowing a bug into a NO_RULE would misreport it as a routing hole. Evidence fields are compare=False on both Dispatch and Blocked: evidence is not identity, and without it attaching evidence would silently redefine equality for every caller comparing against an expected Dispatch(...). That keeps tests/unit/test_sdlc_router.py purely additive — every pre-existing assertion, including the bfa4a6f / d9cf29d / 3c689f2 regression floor, is unchanged. Demonstrated red against main (#2658), all four executed: - Blocked has no decision_inputs field at all - shipped lane routes /do-plan-critique instead of /do-docs - docs-pending falls to the else, producing a redundant /do-pr-review - REVIEW+DOCS completed with no verdict fast-paths to /do-merge Verified: 561 passed, 0 failed across tests/unit/test_sdlc_router.py, test_sdlc_verdict.py, test_sdlc_router_oscillation.py, test_sdlc_router_reconciliation.py, test_sdlc_next_skill.py, sdlc_router_decision/ and test_sdlc_dispatch.py. ruff check and ruff format clean on both touched source files. Refs #3065
…#3065) resolve_branch_truth answers found / absent / indeterminate from the PR head SHA (via tools/pr_head_resolver.resolve_pr_head_sha, never a bare gh read) matched against git ls-remote --heads origin. It replaces _check_branch_pushed, whose two-valued answer gave a wrong-but-present recorded slug, a genuinely unpushed branch, and an unreachable remote the same fail-closed /do-patch. G8 may fail closed on absent only; indeterminate is reported and deferred. An infra error in _verify_stage_artifacts now reports itself as indeterminate instead of being indistinguishable from a clean verification. The PATCH check's old 'no recorded PR number -> no-op' proxy is replaced by a MERGE-completed skip, since a lane with a PR whose head matches nothing is now indeterminate. agent/session_runner/runner.py stops deciding from an empty context: both decide_next_dispatch callers now assemble it through build_decision_context.
…Refs #3065) repair_lane_slug corrects a recorded slug that branch truth contradicts. It fires only where a fail-closed decision is about to be taken on the recorded name, and only on a UNIQUE git ls-remote --heads origin match against the PR head SHA resolved through tools/pr_head_resolver.resolve_pr_head_sha. Zero and two-or-more matches leave the record alone, matching _adopt_from_pr's ambiguity discipline; one matcher (_match_pr_head_to_lane_branches) now serves both so that discipline cannot drift between them. _record_slug_if_empty is deliberately not reused: its no-overwrite behavior IS the defect. The repair re-reads the recorded value under the slug lock immediately before writing, so a concurrent repair converges to a no-op rather than a second write (Race 2), and a record that moved to a third value is left untouched. Every correction files its justification on the ledger. Rung 1 is unchanged: ordinary reads still return the recorded slug. The module docstring's 'a wrong adoption could never be corrected' claim is replaced.
#3065) Branch truth resolving to a branch other than the one the recorded slug names IS the proof repair_lane_slug is gated on, and the G8 PATCH check is where that proof is produced. Wire the two together so a slug proven wrong is corrected then, rather than left to confuse every future tick and every other consumer of the recorded identity. Best-effort and non-fatal; repair_lane_slug re-adjudicates uniqueness itself, so a stale read here cannot force a bad write.
Both poles: a FOUND branch that disagrees with the recorded slug calls repair_lane_slug with the issue number and resolved repo; a FOUND branch that agrees must not call it at all.
… (Refs #3065) New feature doc for reconciliation: why a guard must see the selected dispatch and not only a caller's proposal, the at-most-two-pass bound, the by-reference invariant that keeps the impure G5 guard idempotent, and how to read a Blocked carrying two verdicts. machine-readable-dod gains the expanded expectation grammar, the anchored-vs- prefix-matched split, and the UNEVALUATED outcome with its build-gate/merge-gate consumer split. The pipe-escape rule is now reachable from the expectation list instead of only from the anti-criteria section. PLAN_TEMPLATE carries the grammar, three-valued grading, and the escape rule inline, with a sample anti-criterion that actually demonstrates BRE alternation (doubled backslash) — proven two-pole through the parser rather than asserted. CLAUDE.md is deliberately unedited: reconciliation changes no caller-visible router contract, and that file is regex-parsed into worker prompts. The features README sort fix on the two Telegram rows is pre-existing drift on main, not from this lane; the sort validator blocks any edit to the file until it is corrected.
…efs #3065) Principle 9 already carried the head-SHA rule for one gate. The merge predicate now reads a second recorded fact under the same rule: a plan's Verification rows grade three-valued, a FAIL or UNEVALUATED row refuses the merge, and the recorded aggregate is trusted only while its stamped head SHA matches the PR head. The build-vs-ship asymmetry is stated where callers will look for it: UNEVALUATED may let a build proceed and may never let a lane ship. That ruling previously lived only in one plan's prose, which is how PR #3080 merged past it. Confined to principle 9; `## Work Completion Criteria` is untouched because it is regex-parsed into worker system prompts and asserted byte-for-byte.
Review: Changes RequestedMulti-judge consensus ( This is strong work. The router half is more carefully reasoned than most things that land here: the reconciliation step is bounded by construction, its by-reference invariant is documented at the one place a later contributor would break it, and the The two blockers are both instances of the shape this lane exists to eliminate. Blocker 1 is a gate structurally incapable of firing. The Blocker 2 is a live regression, measured on both sides. The new positional column contract turns I ran the plan's Ruling on the two questions you raised. (1) One PR where the plan recommended two: accepted. A predecessor build interleaved the commits before this session resumed, so splitting means rewriting history rather than sequencing work, and you named it instead of bundling it quietly. (2) The Rubric
Pre-Verdict Checklist
Blockers
Tech Debt
Nits
Miscellaneous
Acknowledged Deferrals (verified)
Verification ResultsReproduced independently on the PR head, not credited from the PR body: All 29 rows pass, including the six documentation-cascade file assertions, the six named pytest suites, the four ANTI scope rows, and both ruff rows. The PR body's narrow-scope test counts (526 + 130 + 181 = 837 passed) were not reproduced in this environment and are recorded as unverified rather than as satisfied. Note on Risk 7, which the plan raises and this review inherits: the grep rows in this table are graded by the very runner this PR modifies. The ScreenshotsNot applicable — no UI files in the diff. |
… (Refs #3065) Both blockers from PR #3123's review, and both were instances of the defect class this lane exists to remove: a gate structurally incapable of firing. record_verification_outcomes had zero production callers. Neither runner called it, so the merge predicate's verification check always took its "no aggregate" branch and was reported-not-enforced for every lane. scripts/validate_build.py now carries the write behind --record-outcomes/--repo/--issue/--pr. REVIEW is the recording stage and BUILD deliberately is not. The record is only worth reading when stamped with the PR head SHA it was graded against, and BUILD grades before the lane has a PR; recording there would have written an unanchored aggregate that the predicate refuses, blocking every lane with a reason no lane could clear. docs/sdlc/do-pr-review.md invokes the script with the flag, docs/sdlc/do-build.md without it. To record what a run graded without running every command twice, this runner now delegates execution to run_checks and derives its report shape from the results, rather than carrying a parallel execution loop. That is the convergence the plan asked for: the two runners can no longer drift on what a check did, only on how it is printed. The check-table column contract searched only indices 1 and 2, which rejected the leading-index shape (| # | Check | Command | Expected |) that live plans already use — docs/plans/overclaim-guard-greps-whole-worktree.md went from 30 executable checks to 0 checks and 2 malformed rows. The contract now locates an Expected column immediately after a Command column with at least one column ahead of it, which still rejects both false positives the docstring names, and takes the check's name from the column before Command so an index column does not become the name. CLAUDE.md's new sentence claimed UNEVALUATED "may let a build proceed", which contradicted the three artifacts in this branch that say it blocks. Restated as what is actually true: each consumer owns its disposition, and the build gate blocks today.
Review: Changes RequestedRound 2, on head Both round-1 blockers are fixed, and I verified them rather than crediting the commit message.
The three new blockers are all in the arming of the gate, not in its design. The staleness worry I brought into this review was wrong, and I want that on the record. I predicted that recording at REVIEW, then letting I ran the plan's One process note for whoever patches this: a Rubric
Pre-Verdict Checklist
Blockers
Tech Debt
Nits
Miscellaneous
Acknowledged Deferrals (verified)
Review Delta (vs prior review on HEAD
|
… twin (Refs #3065) Round-2 review returned three blockers, all in the arming of the gate rather than its design. The writer's argv parser accepted a flag as a value. The documented invocation interpolates shell variables, so an empty one collapsed the argument list and the naive reading took the next flag: an empty issue number raised ValueError after the summary had printed and rewrote the exit code the checks own, an empty repo wrote a real ledger row under a repo named "--issue" and reported success, and a flag ahead of the plan path exited 0 having run no checks at all. _parse_argv now rejects a value that is itself a flag, takes the plan path from the first non-flag positional, and reports an unparseable --issue/--pr instead of raising through the report. All three modes have tests. read_verification_outcomes failed open. It was the one read in the merge path that fetched the blocking evidence, and the predicate could not tell an error from an absence, so a store blip converted a recorded FAIL into an unenforced pass -- inverted against every neighbouring group. Absence still returns None; anything that prevents an answer now raises VerificationOutcomesUnavailableError and the predicate refuses on it. Group (e) was a merge refusal no dispatch rule could see. Row 8g is its routing-side twin: a blocking or unfresh aggregate routes to /do-pr-review, which re-records it, instead of dead-ending on a merge the predicate is certain to refuse until G4 blocks the lane. Its dispositions mirror the predicate's so the two cannot drift, and a test asserts that agreement across the whole CheckOutcome enum. Also from the review, in blast radius: the per-check bound gets a lever (VERIFICATION_TIMEOUT_S, --timeout) now that a timeout is a durable merge refusal rather than a non-blocking skip; merge-troubleshooting.md documents both new refusals and their recovery; the REVIEW invocation is pinned by test rather than by prose alone; the trailing-gloss rule is symmetric across >, >= and == , which unblocks three live rows in other plans; G4's block carries the evidence Cluster D exists to attach; and four tests stop leaking real issue locks into the shared Redis. Plan verification table: 29 PASS, 0 FAIL, 0 UNEVALUATED.
Review: Changes RequestedRound 3, on head Two of round 2's three blockers are genuinely fixed, and I reproduced all three failure modes rather than crediting the commit message.
I also re-verified the round-2 tech debt marked fixed: TD 1's doc-pinning assertions really do read I ran the plan's The blocker that matters most is blocker 3 again, and it is the same defect with the fix in place. Row 8g is well written and its dispositions are right. It is also structurally inert in production, for the same reason the code block two files over already documents about The other two blockers are blast radius. This gate arms for every lane in the repo on merge, and I verified two populations that go from mergeable to refused with no self-healing route. Rubric
Pre-Verdict Checklist
Blockers
Tech Debt
Nits
Miscellaneous
Acknowledged Deferrals (verified)
Review Delta (vs prior review on HEAD
|
…gate (Refs #3065) Round 3 found the gate armed but aimed wrong in three places. Row 8g never fired. Both router callers take stage_states from query_enriched()["stages"], which filters to ALL_STAGES and threads exactly two underscore keys back in. _verification_outcomes was not one of them, so the rule returned False for every real lane while 626 tests passed over it -- every 8g test hand-built its payload with the key already injected. The key is now threaded, and a seam test drives row 8g from a real query_enriched payload, so deleting it from the tuple fails a test rather than silently disarming the rule. Verified by removing it and watching the test fail. A plan with no verification table recorded a 0-row UNEVALUATED, which the predicate then refused forever. Four of the twenty live plans are in that shape. Absence of a contract is not a failed contract: the runner now declines to record when the plan declares no check table, and says so, which is what Risk 8 claimed the design already did. Two in-flight PRs were about to be refused on expectation forms the grammar did not cover -- output `N`, output is N, and a gloss set off from exit N by a comma. All three are legitimate authoring. The gloss rule is now stated once as a rule: a gloss delimited by a comma or a paren/backtick span is ignored, a bare trailing word stays ambiguous and UNEVALUATED. `no output` is accepted as an exact synonym of `empty output`. Regraded every Expected cell in docs/plans against origin/main's grammar across eight (exit, output) samples: 58 differences, and zero on any cell the old grammar actually recognized. Old code returned a bare False on fallthrough, so its "FAIL" on an unrecognized form was a lie rather than a verdict; every difference is that lie being corrected. Also: the blocking set is defined once and shared, so the router and the predicate cannot drift on which outcomes hold a PR; a non-positive timeout is rejected at both entry points; do-merge.md points at the recovery section; and the troubleshooting remedy names the step where plan edits land on main while the lane grades its own worktree. Carried tech debt and nits are tracked in #3125. Plan verification table: 29 PASS, 0 FAIL, 0 UNEVALUATED, exit 0.
|
Sequencing decision from the owner, recorded so this doesn't sit at 6 days again. The 3-call-site fix for #3249/#3260 lands first. It edits This PR is not closed. After that lands, it needs a real rebase onto it and a re-justification of what remains: the two changes are the same idea at different scales (neither should decide from evidence it did not actually read), so some of these 6999 lines across 38 files may be subsumed. Specifically worth re-checking on rebase: whether this PR's routing changes preserve the absent- No review effort should go into this PR in its current conflicted state. Refs #3065, #3249, #3260. |
Refs #3065
Implements
docs/plans/sdlc-control-plane-asserted-facts.md(revision r2, verdict READY TO BUILD WITH CONCERNS, accepted). The through-line: the SDLC control plane repeatedly decided from a subset of the state it could actually read, and then could not show what it decided from.What changes
The router routes on facts it read, and shows them (
agent/sdlc_router.py,tools/sdlc_next_skill.py)reconcile_dispatchre-runs the guard list against the skill the dispatch table selected, so a guard veto constrains the decision that is about to ship rather than only a proposal a caller happened to supply. Bounded to one pass on the selection and one on its redirect; a non-converging veto returnsBlocked(RECONCILE_DEADLOCK)carrying both verdicts instead of iterating into G4's oscillation cap several turns later./do-docsarm (G3_REDIRECT_REASON_DOCS_PENDING), and arm 1 now requires a recorded APPROVED verdict rather than the REVIEW marker alone.Blocked(NO_RULE)and dispatches carrydecision_inputs— thestage_statesandmetathe router decided from, plus an unrecorded-previous-dispatch signal. Both arecompare=False: evidence is not identity.resolve_branch_truthanswers "which pushed branch holds this lane's work?" three-valued (found / absent / indeterminate) for both router callers, throughresolve_pr_head_shaand never a bareghread. G8 dispatches only on absent and steps aside on indeterminate.tools/lane_identity.py). Ordinary reads still return the recorded slug.The verification runner can say "I could not evaluate this" (
agent/verification_parser.py,scripts/validate_build.py)passed: boolis replaced by a three-valuedCheckOutcome(PASS/FAIL/UNEVALUATED) — removal, not addition.UNEVALUATEDcovers timeouts, runner exceptions, unrecognized expectation forms, and command cells with no backticked span, each carrying a reason, and never renders as[FAIL].exit N,prints \N`,>= N,> N,== N, andempty output`. Newer forms are anchored; the three pre-existing forms stay prefix-matched so the trailing-gloss idiom in live plans keeps working.anyof the first three headers beingCommand; a non-check table emits aSkippedTablediagnostic instead of having its second column executed as a shell command.validate_build.pyand the parser converge on one timeout bound and one disposition, with timeout and malformed rows added to the parity fixture so a divergence can actually be caught.This closes the substance of #2791, #3022, and #2901, all three of which were closed as consolidated with no fix and all of which reproduced on main.
The merge predicate can see a gate the plan declared (
tools/merge_predicate.py)_verification_outcomesin the ledger's existingstage_statesJSON; the predicate reads it. Recorded state only — the gate never re-executes plan-authored commands.FAILorUNEVALUATEDrow refuses the merge and names the row. This makes the Render /ask-me questions as native Telegram polls, and stop nudging a session past its own question #3080 /ba092a06downer ruling machine-readable; it previously lived only in plan prose, which is how PR Render /ask-me questions as native Telegram polls, and stop nudging a session past its own question #3080 merged past it.UNEVALUATEDpause and proceed, the merge predicate may not. NoGATE:marker, no new frontmatter key, no fourth plan grammar.session-ensurestops destroying the lease it was asked to grant (tools/sdlc_session_ensure.py)Readback is by primary key rather than re-querying and taking
[0], candidate provenance is tracked explicitly instead of being inferred from an overwrittenreuse_run_id, and all three release sites are gated onprovenance == minted.release_issue_lockis unchanged — it was correct; the bug was what it was handed.Round 2 patch — the three blockers
Round 2 returned CHANGES REQUESTED on three blockers, all in the arming of the gate rather than its design. All three are fixed on
76e8d3055.1. The writer's argv parser accepted a flag as a value (
scripts/validate_build.py). The documented invocation interpolates shell variables, so an empty one collapsed the argument list and the naive reading took the following flag._parse_argvnow rejects a value that is itself a flag, takes the plan path from the first non-flag positional, and reports an unparseable--issue/--pron its own line rather than raising through the report. All three reproduced modes are pinned by tests, and re-verified live on this head:$ISSUE_NUMBER→--issue --pr 77ValueErrorafter the summary, exit code rewrittenARGS: ignoring --issue,RECORD: skipped, exit unchanged$TARGET_REPO→--repo --issue 4242--issue, reported successARGS: ignoring --repo,RECORD: skipped, no write--record-outcomes plan.md(flag first)The variables in
docs/sdlc/do-pr-review.md:72are quoted.2.
read_verification_outcomesfailed open (agent/verification_parser.py). It was the one read in the merge path that fetched the blocking evidence, and the predicate could not tell an error from an absence — so a store blip converted a recordedFAILinto an unenforced pass, inverted against every neighbouring group in the same module. Genuine absence still returnsNone; anything that prevents an answer raisesVerificationOutcomesUnavailableError, and_check_verification_outcomesfailed.appends on that branch while keeping absence as a note.3. Group (e) was a merge refusal no dispatch rule could see (
agent/sdlc_router.py). Row 8g is its routing-side twin, ordered immediately before row 10 so it preempts only the merge dispatch: a blocking or unfresh aggregate routes to/do-pr-review, which re-runs § 4.5 and re-records, instead of dead-ending on a merge the predicate is certain to refuse until G4 blocks the lane. Its dispositions mirror the predicate's exactly, andtest_router_and_predicate_agree_on_the_blocking_setasserts that agreement across the wholeCheckOutcomeenum so the two sides cannot drift.Review items also fixed (in blast radius)
TestRecordingIsWiredIntoTheReviewStage), not only by tests that patch the writer. Deleting the flag from the addendum now fails a test.docs/sdlc/merge-troubleshooting.mdgains a Verification Outcomes Hold the PR section naming both new refusals, theFAILvsUNEVALUATEDdistinction, and the--record-outcomesre-run.DEFAULT_TIMEOUT_Sgets a lever:VERIFICATION_TIMEOUT_Sand a--timeoutflag wired into the parameter the function already accepted. This is what resolved the reviewer's oneUNEVALUATEDrow; it was contention, as they diagnosed.### Risk 8naming the open edge honestly: the gate fires only on a recorded aggregate, absence is not enforced, and the § 4.5 trigger is skill prose. The reviewer's accepted rationale for not failing closed on absence is recorded there rather than left implicit.>,>=, and==: theoutput-prefixed spellings are prefix-matched (the live authoring idiom), the bare spellings stay anchored. This unblocks three real rows in other lanes' plans (durability-m1-fence-canary.md:1012,watch-skill-video-scoping-controls.md:606-607) that a blocking gate would otherwise have held. Pinned verbatim as regression cases.test_verification_parser.py:561is corrected to describe what the test actually pins.None-checked unpack at thecheck_column_indicesseam; deaddocs_status !=conjunct removed;RECONCILE_DEADLOCKdocumented; G4'sBlockednow carriesdecision_inputs(the one blocked verdict Cluster D names as its motivation, previously the only one with no evidence — swept to four moreBlockedsites while there); parenthesized!rbinding; and the four tests that leaked real issue locks into the shared Redis now release them in atry/finallyfixture viarelease_issue_lock.Deferred, with reasons
These are all carried, pre-existing on
main, and structural rather than mechanical — each wants its own lane rather than an edit inside a merge-gate PR:session_runner/runner.py:1415runs up to five network round trips on the event loopnext_skill) never changesdecision.allow.repair_lane_slugreachable from a read-intent pathallow_repairthrough_verify_stage_artifacts(_live)changes a signature two callers share.patch_claimeddropped thepr_identifiableconjunctsdlc_next_skillchange, not a gate change.reuse_run_idrather than_CandidateProvenancemain. This PR introduces the primitive that expresses it; using it is a behavior change to lease acquisition and deserves its own test round.branch_exists,_CandidateProvenanceas aStrEnum,object-typed parametersHappy to file these as a single follow-up issue if the reviewer prefers them tracked rather than listed.
Round 3 patch — reachability and blast radius
Round 3 confirmed two of round 2's three blockers fixed, and returned three more: one was round 2's blocker 3 incompletely fixed, and two were the consequences of the gate arming for real. All three are fixed on
a9d411beb.1. Row 8g was structurally inert. The rule was correct in isolation but never fired. Both router callers take
stage_statesfromquery_enriched()["stages"], which filters toALL_STAGESand then threads exactly two underscore keys back in —_verification_outcomeswas not one of them. So the rule returnedFalsefor every production lane while 626 tests passed over it, because every row-8g test hand-built its payload with the key already injected. The one-line thread-through is attools/sdlc_stage_query.py, and the important half is the seam test: it drives row 8g from a realquery_enrichedpayload. I verified it is load-bearing by deleting the key from the tuple and watching the test fail withKeyError: '_verification_outcomes', then restoring it.This is the same class of gap TD 1 closed on the writer side, and the fix has the same shape.
2. A plan with no verification table recorded a 0-row
UNEVALUATEDand was refused forever. 4 of the 20 live plans are in that shape. The governing principle: absence of a contract is not a failed contract. A plan that declares no check table has declared no gate, so the runner now declines to record and says so — which is what the plan's own### Risk 8claimed the design already did. The state is distinguishable from a 0-row aggregate and from an unreadable one. The exit code is untouched.3. Two in-flight PRs would have been refused on legitimate authoring.
promise-gate-recorded-obligations.md(PR #3093) andunblock-dependency-bumps-coupled-set-gate.md(PR #3089) carried four cells the widened grammar still did not cover:output `0`,output is 0, andexit 0, JSON"compatible": true. The gloss rule is now stated once as a rule — a gloss set off by a delimiter (a comma, or a paren/backtick span) is ignored; a bare trailing word stays ambiguous andUNEVALUATED— which gradesexit 0, JSON ...while leavingexit 0 maybecorrectly unevaluated.no outputis accepted as an exact synonym ofempty output. Both plans now grade 0 ungradeable.The regrade, re-run after extending the grammar
Every distinct
Expectedcell indocs/plans/(50 cells across 20 plans), graded underorigin/main's grammar and this branch's, across 8(exit, output)samples — 400 gradings per side:The raw flip count is misleading until you separate the two, so this run does it mechanically.
origin/main'sevaluate_expectationreturns a bareFalseon fallthrough, so itsFAILon an unrecognized form was never a verdict — it was the #2901/#3022 defect this PR exists to fix, a gate saying "your code is wrong" when it meant "my grader is wrong". A cell the old grammar genuinely recognized must returnTruefor at least one sample; zero such cells changed. Every one of the 58 differences is on a cell the old grammar never accepted.Five cells remain ungradeable, all in two plans with no open PR (
agent_wiki.md,watch-skill-video-scoping-controls.md):ok,pass,watcher line 67 skips,contains `[^a-z0-9`, andsingle scalar == 0 (...). The first three are genuinely unusable expectation cells rather than a grammar gap —UNEVALUATEDreporting that the grader could not answer is the correct outcome, and guessing at them is exactly what this module must not do. Noted in #3125 as an authoring pass owed before either lane ships.Review items also fixed
docs/sdlc/do-merge.mdgains a Verification Outcomes section pointing at the recovery inmerge-troubleshooting.md, so the MERGE-stage agent can reach the remedy without a lucky grep."$PLAN_PATH"no longer produces a rawIsADirectoryError(Path("")isPath("."), which.exists()accepts); the guard is now.is_file().--timeoutorVERIFICATION_TIMEOUT_Sis rejected at both entry points.--timeout 0previously turned all 29 rows intoUNEVALUATED, and since the recorder writes regardless of exit code, a stray env value would have persisted an aggregate that held the lane.mainand never travel in the PR, but/do-pr-reviewgrades"$PLAN_PATH"in the lane worktree, so anExpected-cell fix must reach the branch before re-running or the row refuses again and reads as though the fix failed.except ... : raiseis removed;_parse_argvrejects single-dash values (--issue -5no longer yields a negative issue number), reports repeated flags and dropped positionals instead of swallowing them, and honors--helpanywhere in argv.{FAIL, UNEVALUATED}locally and compared the router to itself, so a drift in the predicate could not have failed it. The blocking set is now defined once asBLOCKING_OUTCOMESinagent/verification_parser.py— the only place both sides can reach, since the router may not importtools/— and both consumers read it.Carried deferrals now tracked
Filed as #3125, as the reviewer asked: TD 5-8 (event-loop network calls in the session runner,
repair_lane_slugplacement, thepr_identifiableconjunct, the lease-heartbeat provenance gate) and carried nits 5-7, plus the five ungradeable cells above. All are pre-existing onmainand structural enough to want their own lane.Verification
scripts/validate_build.pyagainst the plan, ona9d411beb: 29 PASS, 0 FAIL, 0 UNEVALUATED, 0 SKIP, exit 0.Narrow-scoped tests across every touched suite (
test_sdlc_router,test_sdlc_stage_query,test_verification_parser,test_validate_build,test_merge_predicate,sdlc_session_ensure/,test_sdlc_next_skill): 677 passed, 0 failed.ruff check .andruff format --check .clean across all 1445 files.Merge order
This PR must merge after #3089 and #3093. It arms a gate that grades their plans, and although both now grade clean, landing the gate first would put two in-flight lanes behind a mechanism they have never been reviewed against. Both were still OPEN at the time of writing.
Superseded: round-2 verification numbers
scripts/validate_build.pyagainst the plan, on76e8d3055: 29 PASS, 0 FAIL, 0 UNEVALUATED, 0 SKIP, exit 0.Round 2 reproduced 28/0/1 — the one
UNEVALUATEDwasVerdict unit tests passtiming out at the 120s bound under concurrent load, which the reviewer correctly diagnosed as contention (43s standalone) and filed as TD 3. Re-running with the--timeoutlever this patch adds returns the row to PASS. The reviewer was right to record the previous body's "29 PASS" as unverified at that head; the number is reproduced here, and this is a live demonstration of the lever rather than a raised constant.Tests, narrow-scoped to the diff:
test_sdlc_router.py,test_verification_parser.py,test_validate_build.py,test_merge_predicate.py,tests/unit/sdlc_session_ensure/= 455 passed, 0 failed. Ruff check and format clean on every touched file.Two things the reviewer should rule on
docs/features/README.mdcarries a two-row sort fix that is not this lane's work. Two Telegram rows were already out of alphabetical order onmain, and the sort validator blocks any edit to that file until corrected. The swap is mechanical and content-free.Refs #3065, notCloses— this lane closes the batch residual, not all 51 consolidated members (#2491's pipeline-graph duplication is the largest survivor). Ratified by the PM 2026-09-03.