diff --git a/.changeset/1906-node-test-causation-mandatory.md b/.changeset/1906-node-test-causation-mandatory.md new file mode 100644 index 000000000..4c6fc413e --- /dev/null +++ b/.changeset/1906-node-test-causation-mandatory.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2001 +--- +**Node-test prohibition proofs now require a clean-fixture causation control** — a `node-test` prohibition's fail-first proof no longer accepts a deceptive content-independent negative test (one that reds merely because `GSD_PROHIB_SUBJECT` is *set*, ignoring the subject's content). The `check_clean_fixture` control is now **mandatory** for the `node-test` kind: a descriptor that omits it is un-provable and hard-gates, rather than greening on the violation alone. **Breaking (Hyrum):** a previously-green node-test prohibition with no clean fixture now hard-gates — blast radius is zero in-tree (no `node-test` prohibition ships today). The `lint-rule` kind is unchanged (its subject IS the linted file, no `GSD_PROHIB_SUBJECT` indirection). (#1906) diff --git a/CONTEXT.md b/CONTEXT.md index f28c50cbc..b4a2ffab3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -387,7 +387,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `PROHIB.enforce.green-rule=passed iff provenFailFirst===true && run.passed===true (runProhibitionEnforcement); every miss/fail/un-provable HARD-GATES both modes via dispositionForProhibition's fail-closed default` `PROHIB.enforce.kinds=node-test (non-vacuous red via isNonVacuousNodeTestRed; pass-side vacuity via isNonVacuousNodeTestPass) | lint-rule (eslint --format json filtered by ruleId)` `PROHIB.enforce.failfirst=MACHINE-PROVEN against an author-supplied violation fixture (#1279); caller failFirst attestation DEMOTED to a non-authoritative hint (FF-08)` -`PROHIB.enforce.causation=opt-in clean-fixture control proves the red is content-caused not env-var-set (#1346); absent=documented residual` +`PROHIB.enforce.causation=clean-fixture control proves the red is content-caused not env-var-set; MANDATORY for node-test (#1906 supersedes #1346 opt-in) — absent clean-fixture ⇒ node-test un-provable/fail-closed; lint-rule needs none (its subject IS the linted file)` `PROHIB.descriptor.shape=5 FLAT scalars (check_kind,check_target,check_rule,check_violation_fixture,check_clean_fixture) — NEVER a nested check:{} (parseMustHavesBlock is a flat parser, src/frontmatter.cts)` `PROHIB.rail=core verify rail, non-toggleable (ADR-857 verification-substrate boundary / decision #6); the verifier<->predicate contract is NOT an off-by-default capability` `PROHIB.judgment-tier=never-silent / never-hard-halt soft gate; autonomous emits "unverified-prohibition — human review recommended" (exogenous grading, ADR-550 D4)` diff --git a/docs/adr/1606-prohibition-enforcement-verify-seam.md b/docs/adr/1606-prohibition-enforcement-verify-seam.md index 028735507..4330ea5ca 100644 --- a/docs/adr/1606-prohibition-enforcement-verify-seam.md +++ b/docs/adr/1606-prohibition-enforcement-verify-seam.md @@ -99,11 +99,13 @@ to be made that are not derivable from the contract alone: optional `cleanFixture` runs the same negative test a second time with `GSD_PROHIB_SUBJECT=` and requires **non-vacuous GREEN** (`isNonVacuousNodeTestPass`) — so fail-first is proven only when the check is **RED on the - violation AND GREEN on the clean subject** (content-dependent red). Opt-in, not mandatory: + violation AND GREEN on the clean subject** (content-dependent red). ~~Opt-in, not mandatory: absent `cleanFixture`, behaviour matches the pre-#1346 zero-authoring compose path and the "reds because the env var is set" case stays a documented residual for that one author's - check. The lint-rule kind needs no analog (its subject *is* the linted file; no env-var - indirection). + check.~~ **MANDATORY for the node-test kind as of #1906 (2026-07-03) — absent `cleanFixture` + the node-test is un-provable (fail-closed), never proven on the violation alone; see the #1906 + addendum below.** The lint-rule kind needs no analog (its subject *is* the linted file; no + env-var indirection). 5. **Deterministic locate via five flat scalars — never a nested object.** The wired-check descriptor is authored at spec-phase and projected onto the `must_haves.prohibitions` @@ -174,7 +176,46 @@ belong to the spec-phase contract and are recorded in ADR-550's "Alternatives co - **Mandatory causation control — REJECTED in favour of opt-in.** Requiring every node-test prohibition to ship a `cleanFixture` would regress the zero-authoring compose path and hard-gate every existing descriptor without one; the control is opt-in (Decision 4), leaving - one documented residual rather than breaking working checks. + one documented residual rather than breaking working checks. **↳ SUPERSEDED 2026-07-03 (#1906) + — see the addendum below.** The blast-radius premise no longer holds: there is **no in-tree + `node-test` consumer** (Consequences, "Open conventions"), so making the control mandatory + hard-gates *zero* existing checks. Decision 4 is now mandatory for the node-test kind. + +## Addendum (2026-07-03, #1906) — node-test causation control is now MANDATORY (supersedes Decision 4's opt-in) + +Decision 4 made the `cleanFixture` causation control **opt-in** to avoid regressing the #1314 +zero-authoring compose path. That trade-off left a Goodhart hole open **by default**: a node-test +that omits `cleanFixture` is proven fail-first on the violation alone, so a deceptive +content-independent negative test — one that reds merely *because* `GSD_PROHIB_SUBJECT` is set, +ignoring the subject's CONTENT (`assert.ok(!process.env.GSD_PROHIB_SUBJECT)`) — passes the proof. +The proof's observed signal (RED) thus diverges from its target (RED *caused by content*), and the +divergence is the **default**, not an edge case an author opts into. + +**Decision (owner ruling on #1906, 2026-07-03):** for the `node-test` kind the causation control is +**required**. A node-test descriptor that omits `cleanFixture` is treated as **un-provable** +(fail-closed) — never accepted under the weaker violation-only proof. When a clean fixture is +present, fail-first is proven exactly as before (RED on the violation subject **AND** non-vacuous +GREEN on the clean subject); a deceptive content-independent test reds on the clean subject too, so +the control fails and the proof does not pass. + +- **Why the opt-in's rationale no longer applies.** Decision 4 / the rejected-alternative above kept + the control opt-in to avoid hard-gating "every existing descriptor without one." Per this ADR's own + Consequences ("Open conventions … no in-tree `node-test` consumer"), **there are none** — the only + live dogfood is the lint-rule `local/no-source-grep`; node-test fail-first is exercised only by + synthetic temp fixtures. So the mandatory control hard-gates **zero** real checks today; its cost is + paid only by future node-test authors, who now must supply a clean control subject to earn a green. +- **Scope — node-test only.** The `lint-rule` kind is unchanged (byte-identical): its subject *is* the + linted file, with no `GSD_PROHIB_SUBJECT` indirection, so the "reds because the env var is set" gap + cannot exist there. Net effect on D4's disposition is unchanged — every miss/fail/**un-provable** + still hard-gates; this only *tightens* what counts as proven. +- **Breaking change (Hyrum).** A previously-green node-test prohibition with no clean fixture now + hard-gates. Disclosed as breaking with the ~zero in-tree blast radius noted above. `cleanFixture` + stays optional at the *type* level (it rides both kinds; the lint-rule kind never uses it) but is + *required by the node-test prover*. +- **Mechanism.** `defaultProveFailFirst`'s node-test branch in `src/prohibition-enforcement.cts`: + `const clean = check.cleanFixture; if (!clean) return { provenFailFirst: false, … }` before the + existence + non-vacuous-GREEN control. Compiled by `build:lib`. ADR-550's 2026-06-21 (#1346) + addendum "Why opt-in, not required" is superseded to match. ## Cross-references @@ -186,4 +227,4 @@ belong to the spec-phase contract and are recorded in ADR-550's "Alternatives co - **`gsd-core/references/prohibition-probe.md`** — the portable runtime reference. - **`docs/how-to/resolve-prohibition-findings.md`** — user-facing resolution guide. - Code: `src/prohibition-enforcement.cts`, `src/probe-core.cts` (`projectProhibitions`), - `gsd-core/workflows/verify-phase.md`. Issues: #644, #1259, #1278, #1279, #1346. + `gsd-core/workflows/verify-phase.md`. Issues: #644, #1259, #1278, #1279, #1346, #1906. diff --git a/docs/adr/550-spec-phase-probe-contract.md b/docs/adr/550-spec-phase-probe-contract.md index 02e49143d..54a79b66c 100644 --- a/docs/adr/550-spec-phase-probe-contract.md +++ b/docs/adr/550-spec-phase-probe-contract.md @@ -127,6 +127,8 @@ This addendum ratifies one contract point: **Why opt-in, not required:** making the control mandatory would regress the #1314 zero-authoring compose path — every existing node-test prohibition (which carries no clean fixture) would suddenly hard-gate. So **absent `cleanFixture` → no control runs and behavior is byte-identical to post-#1314**; the residual remains a documented permanent constraint *only* for checks whose author did not supply a clean control. An author opts into the stronger machine guarantee by supplying one. The lint-rule kind needs no analog: its "subject" *is* the linted file (no `GSD_PROHIB_SUBJECT` indirection), so the "reds because the env var is set" gap does not exist there. Net effect on D4 is unchanged — every miss/fail/un-provable still hard-gates; this only *tightens* what counts as proven. The mechanism lives in `src/prohibition-enforcement.cts` (`defaultProveFailFirst` node-test branch + the `runNodeTestWithSubject` helper) and `src/probe-core.cts` (`projectProhibitions`), compiled by `build:lib`. +> **SUPERSEDED 2026-07-03 (#1906) — the node-test causation control is now MANDATORY, not opt-in.** The "why opt-in" rationale above rested on avoiding a regression of "every existing node-test prohibition." That premise no longer holds: there is **no in-tree `node-test` consumer** (ADR-1606 Consequences, "Open conventions"), so requiring the control hard-gates *zero* existing checks — while leaving the Goodhart hole open by default let a deceptive content-independent test pass the proof. Per the owner ruling on #1906, a `node-test` descriptor that omits `cleanFixture` is now **un-provable (fail-closed)**, never proven on the violation alone; when a clean fixture is present, fail-first is proven exactly as before (RED on violation AND non-vacuous GREEN on clean). The `lint-rule` kind is unchanged. Disclosed as breaking (Hyrum) with the ~zero blast radius noted. The decision-of-record lives in **ADR-1606's 2026-07-03 (#1906) addendum**; this note supersedes the "Why opt-in, not required" paragraph immediately above. + ## Addendum (2026-06-15): optional `check` descriptor on the prohibition item — D3 shape extension (#1278) This ratifies the **deterministic SOURCE** for the test-tier `CheckDescriptor` that #1259 (PR #1273) left caller/verifier-supplied. #1259 shipped the PRODUCER (`check prohibition-enforcement`) that *runs* a wired check given a `{kind, target, rule?}` descriptor, but the descriptor itself was invented by the verify-phase LLM each run (the "locate" half). #1278 makes that locate half **deterministic**: an optional `check` descriptor is authored at spec-phase on the resolved `test`-tier prohibition, projected by `projectProhibitions`, and read back by verify-phase — so a wired, passing test closes the gap with **zero manual authoring**. This extends the **Decision 3 prohibition-item shape** (it adds optional keys to that item), so it is ratified here rather than rewriting D3 in place. diff --git a/gsd-core/workflows/spec-phase.md b/gsd-core/workflows/spec-phase.md index 119fbf53c..884918207 100644 --- a/gsd-core/workflows/spec-phase.md +++ b/gsd-core/workflows/spec-phase.md @@ -371,11 +371,13 @@ For each Requirement gathered so far, run the two-stage recall→precision pass: against to **machine-prove fail-first**; rides BOTH kinds. Capture it to let the item green end-to-end with zero hand-authoring at verify time; for `node-test` the negative test should read its subject from the `GSD_PROHIB_SUBJECT` env var so the prover can inject this fixture. - - `check_clean_fixture` (#1346) — **optional** path to a KNOWN-CLEAN control subject. When - captured, the `node-test` prover also runs the check against it and requires GREEN — proving - the violation's RED is caused by the subject's *content*, not by `GSD_PROHIB_SUBJECT` merely - being set. Capture it for a stronger guarantee; omit it and the check still proves fail-first - on the violation alone (the content-causation residual stays documented for that case). + - `check_clean_fixture` (#1346; **REQUIRED for `node-test` as of #1906**) — path to a + KNOWN-CLEAN control subject. The `node-test` prover runs the check against it and requires + GREEN — proving the violation's RED is caused by the subject's *content*, not by + `GSD_PROHIB_SUBJECT` merely being set. For a `node-test` this is **mandatory**: omit it and + the check is un-provable (fail-closed), never proven on the violation alone — so a deceptive + content-independent test cannot pass. (`lint-rule` needs no clean fixture: its subject IS the + linted file, no `GSD_PROHIB_SUBJECT` indirection.) This is a **SOFT capture (CHK-04): a `test`-tier prohibition WITHOUT a descriptor is still allowed** — if the author cannot yet name the wired check, leave the descriptor empty and proceed. It is NOT a hard authoring block; the item simply stays fail-closed/flagged diff --git a/src/prohibition-enforcement.cts b/src/prohibition-enforcement.cts index 75b71cc0f..e12e8c5e4 100644 --- a/src/prohibition-enforcement.cts +++ b/src/prohibition-enforcement.cts @@ -77,12 +77,15 @@ export interface CheckDescriptor { */ violationFixture?: string; /** - * OPTIONAL author-supplied path to a KNOWN-CLEAN control subject (#1346). When present, the prover - * runs the check against it as a CAUSATION CONTROL and requires it to stay GREEN — proof that the - * RED on `violationFixture` was caused by the subject's CONTENT, not merely by `GSD_PROHIB_SUBJECT` - * being set. A deceptive content-independent check reds on the clean subject too → control fails → - * not proven. ABSENT → no control runs (the documented residual remains; backward-compatible with - * the #1314 zero-authoring compose path). A supplied-but-missing path fails closed. + * Author-supplied path to a KNOWN-CLEAN control subject (#1346; MANDATORY for the node-test kind as + * of #1906). The prover runs the check against it as a CAUSATION CONTROL and requires it to stay + * GREEN — proof that the RED on `violationFixture` was caused by the subject's CONTENT, not merely by + * `GSD_PROHIB_SUBJECT` being set. A deceptive content-independent check reds on the clean subject too + * → control fails → not proven. For the `node-test` kind this is now REQUIRED: ABSENT → the node-test + * is UN-PROVABLE (fail-closed), never accepted under the weaker violation-only proof (#1906 supersedes + * #1346's opt-in; ADR-1606 Decision 4). A supplied-but-missing path fails closed. The field stays + * optional at the type level because the `lint-rule` kind needs no analog (its subject IS the linted + * file — no `GSD_PROHIB_SUBJECT` indirection, so the "reds because the env var is set" gap can't exist). */ cleanFixture?: string; } @@ -595,12 +598,12 @@ function defaultProveFailFirst(check: CheckDescriptor, cwd: string, timeoutMs?: // a setup crash, not from the prohibition firing. Requiring the fixture to exist before spawning // closes the realistic typo/stale-path case (#1279 review, Major 1). // - // CAUSATION (#1346): existence + a non-vacuous red is necessary but not sufficient — a deceptive - // negative test that reds merely BECAUSE `GSD_PROHIB_SUBJECT` is set (rather than because the - // subject's CONTENT violates the must-NOT) would otherwise be accepted. The OPTIONAL `cleanFixture` - // control below proves content-dependence when supplied (red on bad AND green on clean). When NO - // clean fixture is authored the control cannot run, so the residual remains a documented constraint - // for that case (an author opts into the stronger proof by supplying a known-clean control subject). + // CAUSATION (#1346; MANDATORY as of #1906): existence + a non-vacuous red is necessary but not + // sufficient — a deceptive negative test that reds merely BECAUSE `GSD_PROHIB_SUBJECT` is set + // (rather than because the subject's CONTENT violates the must-NOT) would otherwise be accepted. + // The `cleanFixture` control below proves content-dependence (red on bad AND green on clean) and is + // now REQUIRED for the node-test kind: absent it, the check is un-provable (fail-closed), not + // accepted under the weaker violation-only proof (#1906 supersedes #1346's opt-in; ADR-1606 D4). // Resolve the fixture against `cwd` (NOT the verify process's cwd): the spawned test reads // `GSD_PROHIB_SUBJECT` and resolves a relative subject against `cwd`, so the existence check must // use the SAME base or it could pass here yet ENOENT in the child (re-opening the fail-open hole). @@ -608,18 +611,19 @@ function defaultProveFailFirst(check: CheckDescriptor, cwd: string, timeoutMs?: // Run the negative test against the KNOWN-BAD subject and require a NON-VACUOUS red. const redOut = runNodeTestWithSubject(check, cwd, fixture, timeoutMs); if (!isNonVacuousNodeTestRed(redOut, check.target)) return { provenFailFirst: false, method: 'violation-fixture' }; - // #1346 CAUSATION CONTROL (optional): if a clean control subject is supplied, run the SAME test - // against it and require it to stay GREEN. This proves the red above was caused by the subject's - // CONTENT — a deceptive test that reds merely because GSD_PROHIB_SUBJECT is SET reds here too → - // not content-dependent → not proven. Absent → no control (documented residual; backward-compat). + // #1906 CAUSATION CONTROL (MANDATORY for node-test — supersedes #1346's opt-in, ADR-1606 D4): the + // clean control subject is REQUIRED. Run the SAME test against it and require it to stay GREEN, + // proving the red above was caused by the subject's CONTENT — a deceptive test that reds merely + // because GSD_PROHIB_SUBJECT is SET reds here too → not content-dependent → not proven. ABSENT → + // the control cannot run → un-provable → fail-closed (NOT accepted under the weaker violation-only + // proof). This is the one behavior change vs #1346: absent `cleanFixture` was previously proven. const clean = check.cleanFixture; - if (clean) { - // A supplied-but-missing/typo'd control path can't run the control → fail-closed, symmetric - // with the violation-fixture existence guard (resolve against the SAME `cwd` as the child). - if (!fs.existsSync(path.resolve(cwd, clean))) return { provenFailFirst: false, method: 'violation-fixture' }; - const cleanOut = runNodeTestWithSubject(check, cwd, clean, timeoutMs); - if (!isNonVacuousNodeTestPass(cleanOut, check.target)) return { provenFailFirst: false, method: 'violation-fixture' }; - } + if (!clean) return { provenFailFirst: false, method: 'violation-fixture' }; + // A supplied-but-missing/typo'd control path can't run the control → fail-closed, symmetric + // with the violation-fixture existence guard (resolve against the SAME `cwd` as the child). + if (!fs.existsSync(path.resolve(cwd, clean))) return { provenFailFirst: false, method: 'violation-fixture' }; + const cleanOut = runNodeTestWithSubject(check, cwd, clean, timeoutMs); + if (!isNonVacuousNodeTestPass(cleanOut, check.target)) return { provenFailFirst: false, method: 'violation-fixture' }; return { provenFailFirst: true, method: 'violation-fixture' }; } // Unknown kind — defensive; the LOCATE guard already rejects it. diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index 842de0413..0f2ab6d27 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -289,7 +289,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "ab9fbb7371ef36bf", "gsd-core/workflows/sketch.md": "114ca2455c2eb6fc", "gsd-core/workflows/smart-entry.md": "0f468aa4e70bcc23", - "gsd-core/workflows/spec-phase.md": "5d6f0c480c0b251d", + "gsd-core/workflows/spec-phase.md": "7191bc8f3696b315", "gsd-core/workflows/spike-wrap-up.md": "e2f251b7c8bfa2b2", "gsd-core/workflows/spike.md": "0f9a81bcf4573195", "gsd-core/workflows/stats.md": "a20eb078d2ab11be", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index b3a740707..7b852a4e9 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -359,7 +359,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "c03f64e834b69540", "gsd-core/workflows/sketch.md": "04e0758c1a58881e", "gsd-core/workflows/smart-entry.md": "62456d35c69d7437", - "gsd-core/workflows/spec-phase.md": "2aa0353147153776", + "gsd-core/workflows/spec-phase.md": "065cfb6c3bda023f", "gsd-core/workflows/spike-wrap-up.md": "c86e04a0feb220a5", "gsd-core/workflows/spike.md": "53127654e77256bf", "gsd-core/workflows/stats.md": "01c24349370a0e6d", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 86615e2dd..395db4e22 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -288,7 +288,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "fcef0ef795f8bfc5", "gsd-core/workflows/sketch.md": "dc9645b2ee28559d", "gsd-core/workflows/smart-entry.md": "9d35a3094fb64ea0", - "gsd-core/workflows/spec-phase.md": "bab99d00772a5cd0", + "gsd-core/workflows/spec-phase.md": "f5c7b0545d4507b1", "gsd-core/workflows/spike-wrap-up.md": "89a8d7fbc74ce8f2", "gsd-core/workflows/spike.md": "aae8bcad15642645", "gsd-core/workflows/stats.md": "17b4f2059f4b4ef2", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index bc6731b3d..573d58e74 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -292,7 +292,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "91f40fc816293e3c", "gsd-core/workflows/sketch.md": "92a8bab2605469fb", "gsd-core/workflows/smart-entry.md": "4ca661156b9c274f", - "gsd-core/workflows/spec-phase.md": "e06346c9c626a2d2", + "gsd-core/workflows/spec-phase.md": "d6a260dd86a52660", "gsd-core/workflows/spike-wrap-up.md": "693949a4e10e66bd", "gsd-core/workflows/spike.md": "204e742c846ee0d9", "gsd-core/workflows/stats.md": "5cfea82b894eee3c", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 84c707765..a45e7ca20 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -359,7 +359,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "7d52aa95ee69d47a", "gsd-core/workflows/sketch.md": "55cee885b4add548", "gsd-core/workflows/smart-entry.md": "62456d35c69d7437", - "gsd-core/workflows/spec-phase.md": "2aa0353147153776", + "gsd-core/workflows/spec-phase.md": "065cfb6c3bda023f", "gsd-core/workflows/spike-wrap-up.md": "2fde13092ba1af57", "gsd-core/workflows/spike.md": "0051a7e2193a7522", "gsd-core/workflows/stats.md": "01c24349370a0e6d", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index cc7ba67b3..e65a3a8e6 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -324,7 +324,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "7709bdd7a70f736c", "gsd-core/workflows/sketch.md": "bfe5a781d6da3ce8", "gsd-core/workflows/smart-entry.md": "72d698957173fd33", - "gsd-core/workflows/spec-phase.md": "47cea2b5efffa6b0", + "gsd-core/workflows/spec-phase.md": "e6c8641ff024e9f2", "gsd-core/workflows/spike-wrap-up.md": "53a2c51a8d9e6b08", "gsd-core/workflows/spike.md": "c2f115f0d3251654", "gsd-core/workflows/stats.md": "0d7449acf349feec", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index 44964e3f1..c94681814 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -290,7 +290,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "804bb69e6c3590a0", "gsd-core/workflows/sketch.md": "b4d17647e0fb9e5d", "gsd-core/workflows/smart-entry.md": "d4c882ebeec21f34", - "gsd-core/workflows/spec-phase.md": "117abebab62d6df7", + "gsd-core/workflows/spec-phase.md": "6eb9b883d1f41112", "gsd-core/workflows/spike-wrap-up.md": "e203ed8e057f654c", "gsd-core/workflows/spike.md": "716d74cdb2e39a3e", "gsd-core/workflows/stats.md": "49085df6d4793df3", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index 8f10c1b45..cfe00e676 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -359,7 +359,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "6780757b2db165ac", "gsd-core/workflows/sketch.md": "4221996fa3d87f7c", "gsd-core/workflows/smart-entry.md": "f8c615009ab00a22", - "gsd-core/workflows/spec-phase.md": "fca5397bf040156d", + "gsd-core/workflows/spec-phase.md": "9cebb93bb6b09841", "gsd-core/workflows/spike-wrap-up.md": "ec64f0f7ab03ef9b", "gsd-core/workflows/spike.md": "9e37f8067adf87b7", "gsd-core/workflows/stats.md": "17b4f2059f4b4ef2", diff --git a/tests/fixtures/golden-install-parity/gemini.json b/tests/fixtures/golden-install-parity/gemini.json index ff8a3e821..110449421 100644 --- a/tests/fixtures/golden-install-parity/gemini.json +++ b/tests/fixtures/golden-install-parity/gemini.json @@ -359,7 +359,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "067e2654f3fdaba2", "gsd-core/workflows/sketch.md": "e1a544c83bb6e5be", "gsd-core/workflows/smart-entry.md": "aead1f97e32cc345", - "gsd-core/workflows/spec-phase.md": "0d67fa7de33933c0", + "gsd-core/workflows/spec-phase.md": "72ba501da5edbfee", "gsd-core/workflows/spike-wrap-up.md": "99a9e23d1c6cf66e", "gsd-core/workflows/spike.md": "c9482514e665bcac", "gsd-core/workflows/stats.md": "01c24349370a0e6d", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index ca6dfb9a7..c8aaf63b5 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -289,7 +289,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "eb5f0849f07479aa", "gsd-core/workflows/sketch.md": "9a712af64fc2fff8", "gsd-core/workflows/smart-entry.md": "bf38fd489b27231e", - "gsd-core/workflows/spec-phase.md": "79f136ab71edf595", + "gsd-core/workflows/spec-phase.md": "59ab40cd1eb44d50", "gsd-core/workflows/spike-wrap-up.md": "79e23a81bb74bc95", "gsd-core/workflows/spike.md": "80844a3c05339fdb", "gsd-core/workflows/stats.md": "01289f444b66913d", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index 054c65d69..ce2fd9e2c 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -359,7 +359,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "7673797d65af2377", "gsd-core/workflows/sketch.md": "201772f9c5c02e97", "gsd-core/workflows/smart-entry.md": "8b865b82ff6d44d8", - "gsd-core/workflows/spec-phase.md": "2c67506aeb25a7e1", + "gsd-core/workflows/spec-phase.md": "cfdd2f45d58d6943", "gsd-core/workflows/spike-wrap-up.md": "b7ee3d961b5792c6", "gsd-core/workflows/spike.md": "a782dd863771fe0a", "gsd-core/workflows/stats.md": "17b4f2059f4b4ef2", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 35ac9b746..886f96bcc 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -325,7 +325,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "19046704ae337d73", "gsd-core/workflows/sketch.md": "e42a914206c152ef", "gsd-core/workflows/smart-entry.md": "62456d35c69d7437", - "gsd-core/workflows/spec-phase.md": "2aa0353147153776", + "gsd-core/workflows/spec-phase.md": "065cfb6c3bda023f", "gsd-core/workflows/spike-wrap-up.md": "3f3e7f0018284059", "gsd-core/workflows/spike.md": "be2295fe2b32956f", "gsd-core/workflows/stats.md": "01c24349370a0e6d", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 78c1e5fcc..84f5c594c 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -359,7 +359,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "1a9f5b8e01a43506", "gsd-core/workflows/sketch.md": "89acaae8de9bf8c9", "gsd-core/workflows/smart-entry.md": "0660f42e2d646f7e", - "gsd-core/workflows/spec-phase.md": "eb94ceb386b2a328", + "gsd-core/workflows/spec-phase.md": "15311227e3f7f69e", "gsd-core/workflows/spike-wrap-up.md": "3609a57dbd8b5ac0", "gsd-core/workflows/spike.md": "df52b9f31a72d204", "gsd-core/workflows/stats.md": "598b1bb510ed0451", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 684de9932..d4c10878c 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -289,7 +289,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "7630ee69ab22462d", "gsd-core/workflows/sketch.md": "68c337d92c79dc9b", "gsd-core/workflows/smart-entry.md": "c90d5ee83befd3ea", - "gsd-core/workflows/spec-phase.md": "dbe5f223d7bc026c", + "gsd-core/workflows/spec-phase.md": "f9481389c9e0b64a", "gsd-core/workflows/spike-wrap-up.md": "96b8244988d651fd", "gsd-core/workflows/spike.md": "52fcd95f7b343232", "gsd-core/workflows/stats.md": "7ffa072290ebcae3", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 5c45fced1..5d62f5250 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -289,7 +289,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "4894dac77fc42655", "gsd-core/workflows/sketch.md": "52f4a04f511e205e", "gsd-core/workflows/smart-entry.md": "0687dcb3cf5b3699", - "gsd-core/workflows/spec-phase.md": "56cecc44b2cc0bc4", + "gsd-core/workflows/spec-phase.md": "0b5c278c2d7a4bbc", "gsd-core/workflows/spike-wrap-up.md": "1f57905138a648ac", "gsd-core/workflows/spike.md": "48df8b626cbd378d", "gsd-core/workflows/stats.md": "18089135bc41f1b4", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index 7adb71d46..98c6c1a6e 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -289,7 +289,7 @@ "gsd-core/workflows/sketch-wrap-up.md": "67e90c60062d0d5f", "gsd-core/workflows/sketch.md": "7418628ba45dd6c0", "gsd-core/workflows/smart-entry.md": "963c0a363fb0cd6a", - "gsd-core/workflows/spec-phase.md": "e8ed811cd67076b7", + "gsd-core/workflows/spec-phase.md": "1eacc6cc8d146d2f", "gsd-core/workflows/spike-wrap-up.md": "f8e39782c6e42ef7", "gsd-core/workflows/spike.md": "aa5934044317ba7a", "gsd-core/workflows/stats.md": "c49d3de15375d04b", diff --git a/tests/prohibition-enforcement.test.cjs b/tests/prohibition-enforcement.test.cjs index cb2fa9c7a..9c2c31993 100644 --- a/tests/prohibition-enforcement.test.cjs +++ b/tests/prohibition-enforcement.test.cjs @@ -520,7 +520,9 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => { fs.writeFileSync(badFixture, 'this subject contains FORBIDDEN content\n'); const result = enforce.runProhibitionEnforcement( TEST_TIER, - { kind: 'node-test', target: tf, failFirst: true, violationFixture: badFixture }, + // #1906: the node-test causation control is mandatory — supply the clean control subject the + // test already writes so the honest content-dependent path proves fail-first and greens. + { kind: 'node-test', target: tf, failFirst: true, violationFixture: badFixture, cleanFixture: cleanSubject }, { cwd: dir, runCheck: () => ({ passed: true }) }, ); assert.equal(result.status, 'green', 'a real negative test proven fail-first + clean pass must green'); @@ -616,6 +618,67 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => { 'a supplied clean fixture that does not exist cannot run the control -> fail-closed'); }); + // ─── #1906: the causation control is now MANDATORY for the node-test kind ─── + // Supersedes #1346's opt-in (ADR-1606 Decision 4; ADR-550 2026-06-21 addendum). Without a clean + // fixture the causation control cannot run, so a node-test that reds merely BECAUSE + // GSD_PROHIB_SUBJECT is set (ignoring the subject's CONTENT) would pass the fail-first proof. A + // node-test descriptor that omits `cleanFixture` is therefore treated as UN-PROVABLE (fail-closed), + // never accepted under the weaker proof. The lint-rule kind is unchanged (its subject IS the linted + // file; no GSD_PROHIB_SUBJECT indirection, so the "reds because the env var is set" gap can't exist). + test('a DECEPTIVE content-independent red is NOT proven fail-first when NO clean fixture is supplied (#1906 regression)', (t) => { + const enforce = require(ENFORCEMENT_LIB); + const dir = createTempDir('prohib-deceptive-noclean-'); + t.after(() => cleanup(dir)); + // Deceptive: reds whenever a subject is PRESENT, regardless of its content. Goes RED against the + // bad fixture (looks fail-first) — pre-#1906 this GREENED because no clean control ran. Now the + // mandatory control's absence makes the check un-provable -> fail-closed. + const tf = path.join(dir, 'neg.test.cjs'); + fs.writeFileSync(tf, + "const { test } = require('node:test');\n" + + "const assert = require('node:assert');\n" + + "test('reds whenever a subject is present (deceptive, content-independent)', () => {\n" + + " assert.ok(!process.env.GSD_PROHIB_SUBJECT, 'fails whenever a subject is set');\n" + + "});\n"); + const badFixture = path.join(dir, 'bad-subject.txt'); + fs.writeFileSync(badFixture, 'this subject contains FORBIDDEN content\n'); + const result = enforce.runProhibitionEnforcement( + TEST_TIER, + // NO cleanFixture — pre-#1906 the causation control was skipped and this greened. + { kind: 'node-test', target: tf, failFirst: true, violationFixture: badFixture }, + { cwd: dir, runCheck: () => ({ passed: true }) }, + ); + assert.notEqual(result.status, 'green', + 'a node-test that omits the mandatory clean control is un-provable -> fail-closed (#1906)'); + assert.equal(result.located, true, 'the check is still located; it just cannot be proven fail-first'); + }); + + test('even an HONEST content-dependent node-test hard-gates when NO clean fixture is supplied (#1906 mandatory)', (t) => { + const enforce = require(ENFORCEMENT_LIB); + const dir = createTempDir('prohib-honest-noclean-'); + t.after(() => cleanup(dir)); + // Honest: reds ONLY when the subject's CONTENT contains FORBIDDEN. Pre-#1906 this greened on the + // violation alone. Post-#1906 the control is required, so an honest test with no clean fixture is + // un-provable — the author must supply a clean control subject to earn the green. + const tf = path.join(dir, 'neg.test.cjs'); + fs.writeFileSync(tf, + "const { test } = require('node:test');\n" + + "const assert = require('node:assert');\n" + + "const fs = require('node:fs');\n" + + "test('rejects the forbidden content (content-dependent)', () => {\n" + + " const subject = fs.readFileSync(process.env.GSD_PROHIB_SUBJECT, 'utf-8');\n" + + " assert.ok(!subject.includes('FORBIDDEN'), 'subject must not contain FORBIDDEN');\n" + + "});\n"); + const badFixture = path.join(dir, 'bad-subject.txt'); + fs.writeFileSync(badFixture, 'this subject contains FORBIDDEN content\n'); + const result = enforce.runProhibitionEnforcement( + TEST_TIER, + { kind: 'node-test', target: tf, failFirst: true, violationFixture: badFixture }, + { cwd: dir, runCheck: () => ({ passed: true }) }, + ); + assert.notEqual(result.status, 'green', + 'the node-test causation control is mandatory (#1906); no clean fixture -> un-provable -> fail-closed'); + }); + test('a HANGING node-test fails closed via the bounded timeout (B2: no unbounded subprocess)', (t) => { const enforce = require(ENFORCEMENT_LIB); const dir = createTempDir('prohib-hang-'); @@ -779,7 +842,9 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => { fs.writeFileSync(badFixture, 'this subject contains FORBIDDEN content\n'); const result = enforce.runProhibitionEnforcement( TEST_TIER, - { kind: 'node-test', target: negTest, violationFixture: badFixture }, + // #1906: the node-test causation control is mandatory — supply the clean control subject the + // test already writes so the honest content-dependent path greens. + { kind: 'node-test', target: negTest, violationFixture: badFixture, cleanFixture: path.join(dir, 'clean-subject.txt') }, { cwd: dir }, ); assert.equal(result.status, 'green', 'real node-test proven RED on the bad subject + clean pass must green'); @@ -819,14 +884,16 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => { assert.equal(result.evidence.length, 0, 'no enforcement evidence on a hard-gate'); }); - test('COMPOSE (#1346): a prohibition projected WITH check_violation_fixture greens end-to-end through the DEFAULT prover+runner (zero hand-authoring)', (t) => { + test('COMPOSE (#1906): a node-test prohibition projected WITHOUT check_clean_fixture hard-gates — the mandatory causation control is absent', (t) => { const enforce = require(ENFORCEMENT_LIB); const pc = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'probe-core.cjs')); - const dir = createTempDir('prohib-compose-1346-'); + const dir = createTempDir('prohib-compose-1906-'); t.after(() => cleanup(dir)); - // The #1278 deterministic-locate path + the #1279 machine-proof now COMPOSE: a prohibition item - // authored with the four flat scalars projects -> reads back into a descriptor that ALREADY carries - // violationFixture -> the default prover greens it with NO hand-supplied fixture in the request. + // #1906: the #1278 locate + #1279 proof compose, but for the node-test kind the causation control + // (#1346's clean fixture) is now MANDATORY. A prohibition authored with only the four scalars + // (violation fixture, no clean fixture) round-trips a descriptor that carries violationFixture but + // NO cleanFixture -> the default prover cannot run the required control -> hard-gate. The five-scalar + // (WITH check_clean_fixture) green path is covered by the sibling COMPOSE (#1346 clean) test below. const negTest = path.join(dir, 'neg.test.cjs'); fs.writeFileSync(negTest, "const { test } = require('node:test');\n" + @@ -847,11 +914,14 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => { ])[0]; const descriptor = enforce.descriptorFromProjection(projected); assert.equal(descriptor.violationFixture, 'bad-subject.txt', 'the projected fixture survived the round-trip'); - // NO failFirst, NO hand-supplied violationFixture beyond what the projection carried. + assert.equal(descriptor.cleanFixture, undefined, 'no clean fixture was authored -> none round-trips'); + // NO failFirst, NO hand-supplied fixture beyond what the projection carried. const result = enforce.runProhibitionEnforcement(projected, descriptor, { cwd: dir }); - assert.equal(result.status, 'green', - 'the fully-projected prohibition greens through the default prover+runner — #1278 + #1279 compose'); - assert.equal(result.evidence[0].failFirstProof, 'violation-fixture', 'green carries the machine-proof method'); + assert.notEqual(result.status, 'green', + 'a node-test projected without the mandatory clean control hard-gates through the default prover (#1906)'); + assert.equal(result.flagged, true, 'the un-provable node-test miss is flagged'); + assert.equal(result.located, true, 'the descriptor was located; it just could not be proven fail-first'); + assert.equal(result.evidence.length, 0, 'no enforcement evidence on a hard-gate'); }); test('COMPOSE (#1346 clean): a prohibition projected WITH check_clean_fixture proves content-dependence end-to-end (deceptive vs honest)', (t) => { @@ -977,8 +1047,12 @@ describe('prohibition-enforcement defaultProveFailFirst REAL prover (#1279)', () "});\n"); const badFixture = path.join(dir, 'bad-subject.txt'); fs.writeFileSync(badFixture, 'this subject contains FORBIDDEN content\n'); + // #1906: the causation control is mandatory for node-test — supply a KNOWN-CLEAN control subject + // so the honest content-dependent test proves fail-first (RED on bad AND non-vacuous GREEN on clean). + const cleanFixture = path.join(dir, 'clean-subject.txt'); + fs.writeFileSync(cleanFixture, 'this subject is clean\n'); const proof = enforce.defaultProveFailFirst( - { kind: 'node-test', target: negTest, violationFixture: badFixture }, + { kind: 'node-test', target: negTest, violationFixture: badFixture, cleanFixture }, dir, ); assert.equal(proof.provenFailFirst, true, @@ -1071,8 +1145,10 @@ describe('prohibition-enforcement defaultProveFailFirst REAL prover (#1279)', () " assert.ok(!subject.includes('FORBIDDEN'), 'subject is clean');\n" + "});\n"); fs.writeFileSync(path.join(dir, 'bad-subject.txt'), 'this subject contains FORBIDDEN content\n'); + // #1906: mandatory clean control, also named RELATIVELY so both runs exercise the cwd-relative guard. + fs.writeFileSync(path.join(dir, 'clean-subject.txt'), 'this subject is clean\n'); const proof = enforce.defaultProveFailFirst( - { kind: 'node-test', target: negTest, violationFixture: 'bad-subject.txt' }, // RELATIVE to cwd + { kind: 'node-test', target: negTest, violationFixture: 'bad-subject.txt', cleanFixture: 'clean-subject.txt' }, // RELATIVE to cwd dir, ); assert.equal(proof.provenFailFirst, true, diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 7182f944c..73e71df7e 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -74,7 +74,7 @@ "sketch-wrap-up.md": 14223, "sketch.md": 19960, "smart-entry.md": 11080, - "spec-phase.md": 31752, + "spec-phase.md": 31901, "spike-wrap-up.md": 15092, "spike.md": 24517, "stats.md": 6718,