#4631·gsd-core

Epic: gates resolve scope and read evidence through shared seams, and fail closed when they find nothing

Author: trek-eCreated Sep 11, 2026Updated Sep 21, 2026
Labelsarea: workflowtype: choreepic

An approved epic does not approve its children — each child is its own issue before code.

Epic: gates resolve scope and read evidence through shared seams, and fail closed when they find nothing

The deliverable is the seam, not the individual fixes. The issues absorbed below are evidence that one piece of behavior has no single owner. They are the symptom list, not the work list. Repairing them where they were reported — N fixes for N issues — leaves the missing owner still missing, and the next occurrence is already being written somewhere else in the tree. An implementation that does that has not closed this epic, even with every symptom gone and CI green.

Closing this epic means three things land together: one resolveEvaluationScope(), evidence read through the document seams that already exist, and an Evidence type separating "none" from "could not read" exists; the duplicate implementations are deleted rather than kept in sync, because two copies that agree today are the same defect as two that disagree; and a lint rejecting any gate that ships without a positive control proving it can go red fails on a reintroduced copy, demonstrated by watching it go red. The first two without the third un-consolidate quietly as soon as someone who has not read this issue touches the area.

A defect that cannot be expressed through the seam is a finding about the seam's design and belongs in this issue as a comment — one special case is how the previous consolidation grew its second copy.

This is the shape that worked in #1372 (markdown sectionizer), #2143 (table, bounded mutation, fail-loud) and #2121 (phase identifier): each closed its bug class with a seam, a migration and an anti-divergence guard, none of them by fixing the reported symptoms. A first pass at #4634 proposed four independent fixes and no seam, which is the failure mode this scoping exists to prevent. Splitting delivery across several PRs is expected and welcome; splitting it so that the seam never lands is not.

Eight open confirmed-bug issues are one defect: a gate hand-rolls a narrow derivation of what it should look at, the derivation comes back empty, and "empty" is reported as "pass." Every one of them is a blocking or advisory gate that returned green while the thing it exists to catch was present.

This is the most expensive family on the tracker, because these are the gates the whole workflow trusts. #4563's gate should have blocked and did not — for five consecutive waves.

Three arms, one remedy

(a) The evaluation scope is derived per gate, and the derivations are wrong.

Issue Scope it computes Consequence
#3926 DIFF_BASE..HEAD — base derived from the phase's own commits, tip unconditionally HEAD every commit landed outside the phase joins the review scope: a correct 20-file scope became 248
#4563 git diff --name-only HEAD~1 HEAD — a one-commit window the last commit of a wave is the SUMMARY/docs commit, so hasUiFiles is false however much frontend the wave changed
#4498 discards the fully-qualified phase dir it is handed, re-resolves the basename token without workstream scope in a project where two workstreams both have a phase 14, a blocking onError: halt gate stops UAT on the wrong phase

#4563's own source carries the admission in a comment above the defect: "Known limitation: multi-plan waves may need the wave-start commit for full coverage." It bites single-plan waves too, because one plan is several commits.

(b) The evidence parser recognises one syntactic form out of several.

Issue Form it matches Form it misses
#4562 files_modified:\s*\[([^\]]{0,8000})\] — inline array only a YAML block sequencewhich is what gsd-planner itself emits. fmMatch is null, zero files inspected, block: false
#4541 grep -c … == 0 ! grep -q …, the equally common negative-gate form carrying the identical hazard
#4259 git log and --grep= on one physical line ([^\n]* both sides) a backslash-continued derivation — semantically identical, zero scan hits, assertion passes

#4562 is the sharpest illustration of why this epic exists in the form it does: the repo already owns a markdown/structured-document seam (#1372 src/markdown-sectionizer.cts, #2143 src/markdown-table.cts) built precisely so that "the parser missed structure X" would stop recurring — and this gate hand-rolled a regex instead of using it. A real frontmatter read handles both sequence forms for free.

(c) The outcome report does not reflect the outcome.

  • #4031scripts/run-tests.cjs passes --test-force-exit to every chunk by default; tests execute and their results are then silently discarded before the report, at rc=0. Every reported test count is a lower bound. (Same vacuous-green class as the closed #409.)
  • #4176gsd-ui-auditor's sole screenshot path misreads a redirecting dev server as absent, prints "Screenshots captured" when every capture failed, and never tries the port fallback its own next line documents.

The seam this epic delivers

  1. resolveEvaluationScope(unit) — one owner for "what commit range and file set does a gate evaluate for this plan / wave / phase," including workstream scope. Arm (a) is three call sites independently getting this wrong; there should be one implementation and three callers.
  2. Evidence is read through the existing document seams, not per-gate regex. Arm (b) is three hand-rolled parsers where markdown-sectionizer / markdown-table / a real frontmatter reader already exist.
  3. An Evidence result type that distinguishes none from could-not-read. A gate that could not evaluate its input is not a gate that passed. This is the backstop that makes arms (a) and (b) non-fatal even when a new input shape appears: the gate halts and says so instead of going quietly green.

The ratchet

Every gate ships a positive control proving it can fail. This is already house doctrine for drift guards; this epic makes it structural for all gates — a gate module with no test that drives it red is itself a lint failure. A vacuous gate then cannot be added, and the eight below could not have shipped. Pair with an allowlist drain (#4446 pattern) for the gates that exist today.

Absorbed issues — evidence of the missing seam, NOT a work list

Issue Defect Arm
#3926 code-review Tier-3 diffs to HEAD instead of the phase's own commits (248 files vs 20), and silently downgrades --depth=deep to standard scope
#4563 ui.safety-gate measures hasUiFiles over HEAD~1..HEAD, passing vacuously on every multi-commit wave scope
#4498 api-coverage.verify-pre discards the phase dir it is given and resolves the token cross-workstream scope
#4562 verify.schema-drift parses files_modified as an inline array only; block-sequence plans give it an empty file set evidence
#4541 verify plan-structure's self-invalidating-gate scan misses the ! grep negative-gate form evidence
#4259 T6 docs-parity site scan requires git log and --grep= on one physical line evidence
#4031 run-tests.cjs defaults --test-force-exit on; executed tests are dropped from the report at rc=0 reporting
#4176 gsd-ui-auditor prints "Screenshots captured" when every capture failed reporting

Related but housed elsewhere: #4354 (a blocking gate passing on a file outside the project) is the same symptom via unconfined path acceptance and belongs to the path-predicate epic.

Done when

  • No gate computes its own commit range or file set; all route through resolveEvaluationScope
  • No gate parses structured plan/document content with a bespoke regex
  • Every gate distinguishes "no findings" from "could not read", and the latter never passes
  • Every gate has a positive control that drives it red; lint fails a gate without one
  • Reported test counts are exact, not a lower bound (#4031)
  • Failing-first regression per absorbed issue

Non-goals

  • Repairing the absorbed issues individually at their existing call sites without building the seam. That is the pattern that produced them, and it leaves the epic open.

  • Changing any gate's policy (what it forbids) — this epic changes how gates determine scope, read evidence, and report

  • Reverting --test-force-exit's original purpose (the #1051/#869 Windows hang); the requirement is an exact report, not removal of the flag

Generated with Claude Code