Problem
#327 taught check-binding-fidelity to treat two expression forms as consumption sites: a validate action's target and a step's when. Before that, an output whose only consumer was one of those gates read as dead — the opposite of dead, since a gate is the one place a value is enforced. That fix cleared three false positives.
It only went one way. Those same names are counted as consumption, but they are never checked for resolution:
// scripts/check-binding-fidelity.ts
for (const name of expressionNames(o.target)) expressionConsumes.push({ rel, name });
for (const name of expressionNames(o.when)) expressionConsumes.push({ rel, name });
...
for (const v of expressionConsumes) add(v.name, v.rel); // feeds dead-output only
expressionConsumes reaches collectConsumedSites and nothing else. The reads array — the one the read-resolution check walks — is populated only from {token} interpolations and structured variable: keys.
So a gate expression naming a value nothing produces passes silently. when: has_typo == true or target: verdict_taht_never_existed != false is accepted, the condition evaluates against an absent bag name, and the gate never fires. That is the #324 A2 defect class — "a checkpoint gate can never fire" — in the one construct the guard now reads but does not validate.
It is also a class the corpus can grow into at any time, because the two fixes #327 made in this area both added validate targets: three verdicts gained action: validate gates. Every one of those is a hand-typed identifier the guard will not check.
Evidence
Reproduce by adding a nonsense name to any gate expression in the corpus and running npm run check:binding — the guard reports nothing. Compare with the same name in a {token}, which is reported as read-resolution.
The asymmetry is visible in the source: expressionNames has three call sites, none of them reads.push.
Requirements
R1 — Resolve gate-expression names against the same scope as a {token} · S
Feed expressionConsumes into the read-resolution check as well, so a when or validate target naming an unproducible value is a finding.
Two things to get right, and they are the reason #327 did not just do this:
- A gate expression is not the same namespace as an interpolation.
missing_prerequisites.length == 0 reads a field of a collection; signing.configured == true reads a nested object. The expression scanner already strips operators and literals (true, false, null, length), and the head-name split handles dotted paths — but the exemption list was written for consumption-marking, where a false name costs nothing. Used for resolution it becomes load-bearing, so it needs a pass over the shapes actually in the corpus — 33 step when expressions and 47 validate actions across the activity files.
- Expect a first-run finding set. Some of those 80 sites may already name values nothing produces — which is the point, but it means R1 lands with a triage step rather than a green run. Classify anything real as
live-bug in scripts/binding-fidelity-triage.json; the guard reports live bugs, so they cannot be parked.
R2 — Drop the duplicated site checks from deploy-docs.yml · S
deploy-docs.yml runs npm run check:site and npm run check:svg on pull requests. Both are now registry entries that npm run check:all runs in verify.yml, so every PR touching src/** or site/** runs them twice.
Harmless but wasteful, and it splits the answer to "did the site checks pass?" across two workflows. verify.yml runs on every PR with no path filter, so deploy-docs.yml can keep its build-and-deploy job and drop the two guard steps. Leave its git diff --exit-code -- site freshness check where it is — that one is about generated-page drift, not a registry guard.
Not in scope
Broadening the expression scanner into a general condition evaluator. The structured condition: form is already covered through the variable: key scan; this is specifically about the two string-expression forms.
Related
🤖 Generated with Claude Code
Problem
#327 taught
check-binding-fidelityto treat two expression forms as consumption sites: avalidateaction'stargetand a step'swhen. Before that, an output whose only consumer was one of those gates read as dead — the opposite of dead, since a gate is the one place a value is enforced. That fix cleared three false positives.It only went one way. Those same names are counted as consumption, but they are never checked for resolution:
expressionConsumesreachescollectConsumedSitesand nothing else. Thereadsarray — the one the read-resolution check walks — is populated only from{token}interpolations and structuredvariable:keys.So a gate expression naming a value nothing produces passes silently.
when: has_typo == trueortarget: verdict_taht_never_existed != falseis accepted, the condition evaluates against an absent bag name, and the gate never fires. That is the #324 A2 defect class — "a checkpoint gate can never fire" — in the one construct the guard now reads but does not validate.It is also a class the corpus can grow into at any time, because the two fixes #327 made in this area both added validate targets: three verdicts gained
action: validategates. Every one of those is a hand-typed identifier the guard will not check.Evidence
Reproduce by adding a nonsense name to any gate expression in the corpus and running
npm run check:binding— the guard reports nothing. Compare with the same name in a{token}, which is reported asread-resolution.The asymmetry is visible in the source:
expressionNameshas three call sites, none of themreads.push.Requirements
R1 — Resolve gate-expression names against the same scope as a
{token}· SFeed
expressionConsumesinto the read-resolution check as well, so awhenorvalidate targetnaming an unproducible value is a finding.Two things to get right, and they are the reason #327 did not just do this:
missing_prerequisites.length == 0reads a field of a collection;signing.configured == truereads a nested object. The expression scanner already strips operators and literals (true,false,null,length), and the head-name split handles dotted paths — but the exemption list was written for consumption-marking, where a false name costs nothing. Used for resolution it becomes load-bearing, so it needs a pass over the shapes actually in the corpus — 33 stepwhenexpressions and 47validateactions across the activity files.live-buginscripts/binding-fidelity-triage.json; the guard reports live bugs, so they cannot be parked.R2 — Drop the duplicated site checks from
deploy-docs.yml· Sdeploy-docs.ymlrunsnpm run check:siteandnpm run check:svgon pull requests. Both are now registry entries thatnpm run check:allruns inverify.yml, so every PR touchingsrc/**orsite/**runs them twice.Harmless but wasteful, and it splits the answer to "did the site checks pass?" across two workflows.
verify.ymlruns on every PR with no path filter, sodeploy-docs.ymlcan keep its build-and-deploy job and drop the two guard steps. Leave itsgit diff --exit-code -- sitefreshness check where it is — that one is about generated-page drift, not a registry guard.Not in scope
Broadening the expression scanner into a general condition evaluator. The structured
condition:form is already covered through thevariable:key scan; this is specifically about the two string-expression forms.Related
workflow-selection's checkpoint gate could never fire because its output had no consumer. Same failure, reached from the other side.fix-later. Distinct: that issue is corpus content, this one is guard reach.🤖 Generated with Claude Code