Skip to content

feat(guards): resolve gate expressions, scope dead-output to its workflow (#341, #342) - #364

Merged
m2ux merged 3 commits into
mainfrom
fix/341-342-guard-reach
Jul 30, 2026
Merged

feat(guards): resolve gate expressions, scope dead-output to its workflow (#341, #342)#364
m2ux merged 3 commits into
mainfrom
fix/341-342-guard-reach

Conversation

@m2ux

@m2ux m2ux commented Jul 30, 2026

Copy link
Copy Markdown
Owner

The server PR #343 routes: two guard blind spots, each a check that went one way only. Plus an encoding fix that made one of these files unreviewable and the other un-greppable.

Merge this before #360. #343 sequences it first because #342 is currently dropping real entries out of the debt ledger and reporting them closed, so any corpus measurement taken before it lands understates by an unknown amount. #360's own binding-fidelity numbers carry that caveat until this merges.

#341 — a gate expression was counted as consumption and never resolved

#327 taught the guard that a step when and a validate target consume the names they read, which stopped a gated value reading as dead. It only went one way: those names never reached the read-resolution check, so a gate naming a value nothing produces was accepted, evaluated against an absent bag name, and never fired. That is the #324 A2 class — "a checkpoint gate can never fire" — in the one construct the guard read but did not validate.

Feeding those names to resolution makes the extractor load-bearing, which is why #327 stopped short. It now parses clauses instead of harvesting identifiers:

Shape Reads Why
analysis_type == completion analysis_type An unquoted right operand is shaped exactly like an identifier. Harvesting every word would read completion as a bag name nothing can ever produce.
issue_platform == 'jira' && issue_skipped != true both One read per clause.
missing_prerequisites.length == 0 missing_prerequisites Dotted path reduced to its bag head.
agents_md_read agents_md_read A bare clause is a truthiness read.
gh.auth.status == 0 An environment namespace, not the bag.
planning_folder_path.writable == true planning_folder_path …and dotted-ness cannot discriminate the two, which is why probes are named rather than inferred.

The exemption list #341 asked to be re-derived was re-derived from the corpus: all 17 validate expressions and 37 distinct when expressions were enumerated, and the two traps above are what that survey found. EXPRESSION_LITERALS is gone — a literal list was adequate while a false name only marked consumption and cost nothing.

Findings report at their enclosing step rather than by line: the walk reads parsed YAML, which carries no line numbers, and a validate target sits inside actions[] where the step id is already out of scope.

#342 — dead-output resolved consumers across workflow boundaries

An output in workflow A read as consumed when an unrelated workflow B happened to read a name of the same spelling, and B has no address for A's op, so it cannot bind it. Consumer resolution is now scoped to the declaring workflow, with two deliberate allowances:

  • meta — the universal library every workflow binds ad hoc. This was the shared-op-return-contract rationale carried per entry; it is now a scoping rule.
  • Real cross-workflow reach, taken from the bind sites themselves. remediate-vuln borrowing a work-package op can consume that op's outputs, so those findings stay closed.

R2 falls out of R1, exactly as the issue predicted — both entries b41aaacc pruned are back:

Site Output
workflow-design/techniques/apply-audit-fixes.md fixes_applied
workflow-design/techniques/yaml-authoring.md yaml_file

And the masking was broader than the two: scope_summary declared in two different summarize-scope.md (prism-audit and prism-evaluate), sub_agent_output in two execute-sub-agent.md (cicd and substrate), plus eight more across the workflow-designworkflow-authoring duplicate-basename surface #321's additive migration created. Both directions, as #342 warned.

R3 — a stale entry now names what satisfies it. "No longer occurs" had two causes a reader could not tell apart: the seam was closed, or the guard stopped seeing it. Deleting the entry on the wrong one is precisely how b41aaacc forced real debt out of the ledger, and it presented as progress.

The first-run triage: 31 findings

24 dead-output seams the masking had hidden, and 7 gate expressions reading values nothing declares. All classified fix-later under the two rationales #336 already owns — terminal-product-unconsumed and undeclared-seed.

Why fix-later rather than live-bug, which #341 asks for anything real: none is newly broken. Every one runs today because the executing agent improvises the value at the point of use, which is the undeclared-seed class by definition — #336's largest, at 61 findings, with a plan already sequenced behind #342. Marking them live-bug would hold the guard red on pre-existing debt that #343 deliberately schedules last.

Two are worth taking first in #336 R1, being a precondition and a safety gate rather than routing flags:

  • work-packages/activities/01-scope-assessment.yamlvalidate target: agents_md_read, a bare truthiness gate meant to block until AGENTS.md is read, on a value nothing sets.
  • work-package/activities/09-lean-coding-audit.yamlvalidate target: safety_floor, whose correctly-shaped twin ponytail already models as safety_floor_cleared, with a checkpoint producing it.

The other five: worker_yielded_checkpoint gating four steps of meta's dispatch loop — already recorded as pre-existing drift in the #348 planning register alongside matched_session, worker_result and user_selection — and jira_cloud_id, which comes from an Atlassian tool call rather than the bag.

#341 R2 — the duplicated site checks

deploy-docs.yml drops check:site and check:svg. Both are registry entries check:all runs in verify.yml, which triggers on pull_request: with no path filter, so every PR touching src/** or site/** ran them twice and the answer to "did the site checks pass?" was split across two workflows. The git diff --exit-code -- site freshness check stays — that one is about generated-page drift, not a registry guard.

Encoding: one of these files could not be diffed, the other could not be grepped

check-binding-fidelity and guard-protocol each joined a composite key on a literal NUL byte. The delimiter is sound — no field content can collide with it — but written as a raw byte it makes the file binary to every text tool, and each fails silently and differently:

  • grep prints nothing and exits 1, so searching a file for a symbol it does contain reports absence. This is how a search for expressionNames — the function Guard reach: a gate expression is counted as consumption but never resolved #341 quotes from that very file — came back empty.
  • git sniffs the first 8000 bytes. guard-protocol's byte sits at 2439, so every diff of it rendered as "Binary files differ" and it could not be reviewed line by line. check-binding-fidelity's at 28832 fell outside the window and diffed normally. Same defect, opposite visibility, neither announced.

Written as \u0000 the runtime value is identical — the binding-fidelity report is byte-for-byte unchanged across that edit — and both sources are text again.

check-source-encoding registers the class as a 19th guard so it cannot return. It earned its place twice over: it found a literal NUL in its own header on first run, and then caught one in this PR's new deadOutputSatisfier key — where the set side had the raw byte and the lookup side a space, so the satisfier lookup would never have matched. That is a real bug in #342 R3, found by the guard rather than by review.

Two stale claims on the quality-system page

Dropping the deploy-docs steps made one of them false in this same branch, so the AP-129 sweep caught it: that workflow no longer runs the site guards, it rebuilds the generated regions and fails on drift. The page now says so, and names the property that made the copies redundant — verify has no path filter, so every PR gets the whole suite.

The other predates this branch and is worth fixing while here: #327 retired the committed baselines and the --update-baseline flag, and the page still documented both, so a reader would reach for a flag no guard has. It now describes the triage model that replaced them — a verdict and a named rationale per finding, no re-snapshot because classification is a judgement, and a stale entry reported so a seam cannot leave the ledger by going quiet.

Verification

  • 19/19 guards pass, binding-fidelity at 196 triaged, 0 live, 0 untriaged, 0 stale.
  • 769 tests pass. One failure, session-crypto's EACCES case, is pre-existing and environmental — verified by running it on a clean origin/main checkout, where it fails identically: the sandbox returns ENOENT from mkdir / where the test asserts EACCES.
  • npx tsc --noEmit clean.
  • npm run check:delta --base origin/main: no new findings. Every guard reports SAME; source-encoding reports [PROTOCOL] because the base tree fails it — the raw NUL bytes this branch fixes — while head passes. That is the attribution evidence for the whole change.
  • CI green: typecheck, tests and the full guard sweep (4m22s), site freshness, and the image build. Note the session-crypto case passes in CI, which confirms the local failure is the sandbox's mkdir / behaviour and nothing else.
  • +10 tests. expressionReads is exported and pinned against every shape the corpus carries — left operand only, conjunction split, dotted head, bare truthiness, probe exemption — because a name this extractor invents is a finding nobody can fix, and a name it drops is a gate that can never fire. Plus a regression test naming the two entries b41aaacc pruned, so re-masking them fails a test rather than reading as progress.
  • guard-registry's repo-scope assertion gains source-encoding.

corpusSha now names the corpus this triage was taken against (6bc46faf). It had drifted behind the gitlink, and check-delta materialises the base corpus from it.

Not in scope

Broadening the expression scanner into a general condition evaluator, per #341 — the structured condition: form is already covered through the variable: key scan, and this is specifically the two string-expression forms. Renaming the duplicated techniques or accelerating workflow-design's retirement, per #342 — the duplication is #321's accepted cost and the guard should be correct in its presence. And #336's burn-down itself, which #343 sequences after this and after #360.

🤖 Generated with Claude Code

m2ux added 3 commits July 30, 2026 15:06
…d the class

check-binding-fidelity and guard-protocol each joined a composite key on a
literal NUL byte. The delimiter choice is sound — no field content can
collide with it — but written as a raw byte it makes the file binary to
every text tool, and each tool fails silently and differently.

grep prints nothing and exits 1, so searching a file for a symbol it does
contain reports absence. git sniffs the first 8000 bytes: guard-protocol's
byte sits at 2439, so every diff of it rendered as "Binary files differ"
and the file could not be reviewed line by line, while
check-binding-fidelity's at 28832 fell outside the window and diffed
normally. Same defect, opposite visibility, neither announced.

Written as the \u0000 escape the runtime value is identical — the
binding-fidelity report is byte-for-byte unchanged across the edit — and
the sources are text again.

check-source-encoding registers the class so it cannot return. It found a
literal NUL in its own header on first run, which is the shape of mistake
it exists to catch.

CLAUDE.md and AGENTS.md carry the refreshed index stats: the graph was
three days and eighteen merges stale, which no tool reported.
…flow

Two blind spots in check-binding-fidelity, each a check that went one way
only.

A gate expression counted as consumption and was never resolved (#341).
#327 taught the guard that a step `when` and a `validate` target consume
the names they read, which stopped a gated value reading as dead. Nothing
checked the other direction, so a gate naming a value nothing produces was
accepted, evaluated against an absent bag name, and never fired — the #324
A2 class in the one construct the guard read but did not validate.

Resolution makes the extractor load-bearing, so it now parses clauses
rather than harvesting identifiers. It takes the left operand of each
comparison and a bare clause read for truthiness, never the right side: an
unquoted right operand is shaped exactly like an identifier, and
`analysis_type == completion` would otherwise read `completion` as a bag
name nothing can ever produce. Environment namespaces are named, because
dotted-ness cannot separate them — `gh.auth.status` asks the GitHub CLI
while `planning_folder_path.writable` is a real bag name carrying a probed
field. Sites report at their enclosing step, since the walk reads parsed
YAML and a validate target sits inside actions[] where the step id is out
of scope.

dead-output resolved a consumer by bare name across the whole corpus
(#342), so an output in one workflow read as consumed when an unrelated
workflow happened to read a name of the same spelling. Consumer resolution
is now scoped to the declaring workflow, with meta's library ops reachable
from anywhere and cross-workflow reach taken from the bind sites
themselves, so a borrowed op's outputs still close. And a stale entry names
what now satisfies it: "no longer occurs" had two causes a reader could not
tell apart, and deleting the entry on the wrong one is how b41aaac forced
real debt out of the ledger.

The first run surfaced 31 findings, triaged: 24 dead-output seams the
masking had hidden — including both entries b41aaac pruned, so #342 R2
falls out of R1 as its issue predicted — and 7 gate expressions reading
values nothing declares. All are the two classes #336 already owns,
undeclared-seed and terminal-product-unconsumed, and none is newly broken:
each runs today because the executing agent improvises the value. Two are
worth taking first there, being a precondition and a safety gate rather
than routing flags: work-packages' agents_md_read, and work-package's
safety_floor, whose correctly-shaped twin ponytail already models as
safety_floor_cleared with a checkpoint producing it.

deploy-docs drops its check:site and check:svg steps. Both are registry
entries check:all runs in verify.yml, which has no path filter, so every
PR touching src or site ran them twice. The generated-page drift check
stays — that one is not a registry guard.

corpusSha now names the corpus this triage was taken against; it had
drifted behind the gitlink, and check-delta materialises the base corpus
from it.
…aced baselines

Two claims on the quality-system page were false.

Dropping the duplicated site checks from deploy-docs made one of them
false in this same branch: that workflow no longer runs the site guards,
it rebuilds the generated regions and fails on drift. The guards run in
verify, which has no path filter, so the page now says that — the property
that made the deploy-docs copies redundant is the one worth stating.

The other predates this branch. #327 retired the committed baselines and
the --update-baseline flag, and the page still described both, so a reader
would reach for a flag no guard has. It now describes the triage model
that replaced them: a verdict and a named rationale per finding, no
re-snapshot because classification is a judgement, and a stale entry
reported so a seam cannot leave the ledger by going quiet.
@m2ux
m2ux merged commit acf8e5a into main Jul 30, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant