fix(#1906): require node-test clean-fixture causation control (#2001)

* fix(#1906): require node-test clean-fixture causation control

The node-test fail-first proof accepted a deceptive content-independent
negative test — one that reds merely because GSD_PROHIB_SUBJECT is set,
ignoring the subject's content — whenever no cleanFixture was supplied,
because #1346's causation control was opt-in. The proof's observed signal
(RED) thus diverged from its target (RED caused by content) by default.

Make the causation control mandatory for the node-test kind: a descriptor
that omits cleanFixture is 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 violation AND non-vacuous GREEN on clean).
The lint-rule kind is unchanged (its subject IS the linted file; no
GSD_PROHIB_SUBJECT indirection).

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; only the lint-rule local/no-source-grep dogfood).

Supersedes ADR-1606 Decision 4 / ADR-550 #1346 addendum's opt-in.

Closes #1906

Claude-Session: https://claude.ai/code/session_017vYn26e3nkDNxcpty1ciPJ

* docs(#1906): supersede the #1346 opt-in causation control (mandatory for node-test)

Record the node-test mandatory-causation-control supersede across the
governing surfaces:

- ADR-1606 (the enforcement decision-of-record): addendum + Decision 4
  annotated + the "Mandatory causation control — REJECTED" alternative
  flipped to accepted (premise no longer holds: zero in-tree node-test
  consumers).
- ADR-550: the 2026-06-21 #1346 "Why opt-in, not required" paragraph
  marked SUPERSEDED, pointing at ADR-1606.
- spec-phase.md: check_clean_fixture is now REQUIRED for node-test
  (was "optional").
- CONTEXT.md: PROHIB.enforce.causation predicate updated.

Regenerated the shipped-artifact cascade from the spec-phase.md edit
(+149 B, well under the 40960 cap): 16 golden-install-parity fixtures
and the workflow size baseline.

Refs #1906

Claude-Session: https://claude.ai/code/session_017vYn26e3nkDNxcpty1ciPJ
This commit is contained in:
Rezolv
2026-07-03 23:21:55 -04:00
committed by GitHub
parent 5ae4ea4c84
commit 55604e9124
24 changed files with 194 additions and 64 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,