diff --git a/.changeset/1279-machine-proven-fail-first.md b/.changeset/1279-machine-proven-fail-first.md index 4e1ef7f2e..3459c44f7 100644 --- a/.changeset/1279-machine-proven-fail-first.md +++ b/.changeset/1279-machine-proven-fail-first.md @@ -5,6 +5,6 @@ pr: 1314 **Test-tier prohibition fail-first is now MACHINE-PROVEN, not caller-attested** — the deferred literal `regression-must-fail-first` property of ADR-550 Decision 4 (the gap #1259 / PR #1273 left as a tracked follow-up) has landed. The `check prohibition-enforcement` producer (`src/prohibition-enforcement.cts`, compiled by `build:lib` to the gitignored `gsd-core/bin/lib/prohibition-enforcement.cjs`) no longer trusts the caller's `failFirst` attestation: before a clean, non-vacuous pass can dispose a `test`-tier prohibition green, the new `defaultProveFailFirst` prover independently RUNS the wired check against a KNOWN VIOLATION and confirms it goes RED. Attestation is gone from the green AND (`passed = proof.provenFailFirst === true && run.passed === true`); any other outcome — passes-on-violation, can't-prove, throws, times out, or no violation source — **hard-gates in BOTH interactive and autonomous modes** (ADR-550 D4 / D3). The evidence record gains a `failFirstProof` field recording HOW fail-first was proven (FF-07). A caller can no longer green a toothless check. -The violation is sourced from a new descriptor field, **`CheckDescriptor.violationFixture`** — an author-supplied path to a known-bad subject. For a `lint-rule` the prover lints that fixture and requires the rule id to appear in the JSON report (the rule must have teeth); for a `node-test` the prover spawns the negative test with the subject injected through the **`GSD_PROHIB_SUBJECT`** env convention and requires a NON-VACUOUS red — `# fail >= 1` AND a failing test named distinctly from the file (`isNonVacuousNodeTestRed`), so a violation fixture that merely CRASHES the test at load is not mistaken for the negative assertion firing red (symmetric with the clean-pass non-vacuity guard). The node-test prover also requires the `violationFixture` to EXIST (resolved against `cwd`) before spawning — a missing/typo'd path fail-CLOSES rather than letting an honest test's ENOENT crash forge a green (symmetric with the lint path's file-result guard). A documented residual — proving the red was *caused by* the violation rather than by the env merely being set — and threading a `check_violation_fixture` scalar through the #1278 projection so the deterministic path can green end-to-end are both tracked as follow-up **#1346**. The `lint-rule` path is fully shippable now and is dogfooded against the in-tree `local/no-source-grep` rule; the `node-test` path ships its mechanism (a fixture-bearing descriptor IS machine-proven) and is exercised by SYNTHETIC temp fixtures — there is no live in-tree `node --test` prohibition to dogfood. `CheckDescriptor.failFirst` is **DEMOTED, not removed** (FF-08): it is kept as a non-authoritative hint so the #1259 route-JSON shape and the `CheckDescriptor` type stay backward-compatible mid-migration, but no path greens on it alone. The green/fail-closed policy in `src/probe-core.cts` (`dispositionForProhibition`, reads only `evidence.length > 0`) is untouched; the evidence array shape is additive. This closes ADR-550's D5d follow-up — see the dated 2026-06-15 ADR-550 addendum. (#1279) +The violation is sourced from a new descriptor field, **`CheckDescriptor.violationFixture`** — an author-supplied path to a known-bad subject. For a `lint-rule` the prover lints that fixture and requires the rule id to appear in the JSON report (the rule must have teeth); for a `node-test` the prover spawns the negative test with the subject injected through the **`GSD_PROHIB_SUBJECT`** env convention and requires a NON-VACUOUS red — `# fail >= 1` AND a failing test named distinctly from the file (`isNonVacuousNodeTestRed`), so a violation fixture that merely CRASHES the test at load is not mistaken for the negative assertion firing red (symmetric with the clean-pass non-vacuity guard). The node-test prover also requires the `violationFixture` to EXIST (resolved against `cwd`) before spawning — a missing/typo'd path fail-CLOSES rather than letting an honest test's ENOENT crash forge a green (symmetric with the lint path's file-result guard). The deterministic spec→verify path composes end-to-end: a fourth flat scalar **`check_violation_fixture`** is projected by `projectProhibitions` and read back by `descriptorFromProjection` (rides both kinds), so a prohibition authored with all four `check_*` scalars machine-proves fail-first and greens through the projection alone — zero hand-authoring at verify time (#1278 + #1279 + #1346; round-trip pinned by a fast-check property + CHK-03(D) + an end-to-end COMPOSE capstone). One documented residual remains under **#1346**: the node-test proof confirms the fixture exists and the check reds, but cannot generically prove the red was *caused by* the subject's content rather than by the env merely being set. The `lint-rule` path is fully shippable now and is dogfooded against the in-tree `local/no-source-grep` rule; the `node-test` path ships its mechanism (a fixture-bearing descriptor IS machine-proven) and is exercised by SYNTHETIC temp fixtures — there is no live in-tree `node --test` prohibition to dogfood. `CheckDescriptor.failFirst` is **DEMOTED, not removed** (FF-08): it is kept as a non-authoritative hint so the #1259 route-JSON shape and the `CheckDescriptor` type stay backward-compatible mid-migration, but no path greens on it alone. The green/fail-closed policy in `src/probe-core.cts` (`dispositionForProhibition`, reads only `evidence.length > 0`) is untouched; the evidence array shape is additive. This closes ADR-550's D5d follow-up — see the dated 2026-06-15 ADR-550 addendum. (#1279) **PR-review flag — `GSD_PROHIB_SUBJECT` + `violationFixture` are PROPOSED, renamable conventions.** Both are net-new surface with ZERO live in-tree consumers (no in-tree node-test prohibition yet; node-test proof runs only on synthetic test fixtures, the real dogfood stays the lint-rule). They are forward-looking scaffolding, so a later rename — or replacing the env var with an argv — is a mechanical, zero-migration find/replace. Surfacing them here so the maintainer can **ratify, rename, or replace them at PR review** with no migration cost, exactly as #1278's ADR addendum was reviewed at PR time. The `failFirst` demotion is likewise open to weighing outright removal; the keep-as-hint rationale is recorded in the ADR addendum. diff --git a/docs/adr/550-spec-phase-probe-contract.md b/docs/adr/550-spec-phase-probe-contract.md index 8ccb994df..805dd8251 100644 --- a/docs/adr/550-spec-phase-probe-contract.md +++ b/docs/adr/550-spec-phase-probe-contract.md @@ -113,9 +113,9 @@ This addendum ratifies three contract points: Net effect on D4: the *guarantee* ("a `test`-tier prohibition is never a silent pass") was preserved at every step — fail-closed-now (#644), genuine-execution (#1259), and now **machine-proven fail-first (#1279)**. A `test`-tier prohibition reaches `green`/`passed` ONLY when the wired check both genuinely, non-vacuously passes AND is independently proven to fail on a violation; every miss/fail/un-provable hard-gates. The decision also lives in `src/prohibition-enforcement.cts` comments, `gsd-core/references/prohibition-probe.md`, `gsd-core/workflows/verify-phase.md`, and the #1279 changeset. -**Review corrections (#1314 maintainer review) — two soundness items, tracked:** -- **node-test fixture-existence guard (was fail-OPEN).** The node-test prover originally guarded only `if (!fixture)`. A missing/typo'd/stale `violationFixture` path made `GSD_PROHIB_SUBJECT` point at a non-existent file; an honest negative test then threw ENOENT *inside its callback* — a failing test named distinctly from the file — which `isNonVacuousNodeTestRed` accepted as proof, **forging a green from a setup crash** (asymmetric with the lint-rule path, which fail-CLOSES on `< 1` file result). Fixed by requiring `fs.existsSync(fixture)` before spawning (symmetric fail-closed). **Documented residual (#1346):** existence is necessary but not sufficient — a deceptive test that reds merely *because* `GSD_PROHIB_SUBJECT` is set (not because the subject's CONTENT violates) is still accepted; proving causation generically for an arbitrary author-supplied test is not possible, so it is recorded as a constraint, not implied-solved. -- **`violationFixture` has no projection source (#1278 ↔ #1279 do not yet compose).** `descriptorFromProjection` reconstructs only `{ kind, target, rule? }`; the projected `check_*` scalars carry **no** `violationFixture`. Since green now *requires* a fixture, a prohibition wired purely through the #1278 deterministic path **always hard-gates (fail-closed, safe)** until a `check_violation_fixture` scalar is threaded through — tracked as **#1346**. Until then green requires a hand-supplied `violationFixture`; `verify-phase.md` now states this explicitly rather than implying the projected path produces greens. +**Review corrections (#1314 maintainer review) — two soundness items:** +- **node-test fixture-existence guard (was fail-OPEN) — FIXED.** The node-test prover originally guarded only `if (!fixture)`. A missing/typo'd/stale `violationFixture` path made `GSD_PROHIB_SUBJECT` point at a non-existent file; an honest negative test then threw ENOENT *inside its callback* — a failing test named distinctly from the file — which `isNonVacuousNodeTestRed` accepted as proof, **forging a green from a setup crash** (asymmetric with the lint-rule path, which fail-CLOSES on `< 1` file result). Fixed by requiring `fs.existsSync(path.resolve(cwd, fixture))` before spawning (symmetric fail-closed; resolved against the producer's `cwd` to match the child's resolution). **Documented residual (#1346):** existence is necessary but not sufficient — a deceptive test that reds merely *because* `GSD_PROHIB_SUBJECT` is set (not because the subject's CONTENT violates) is still accepted; proving causation generically for an arbitrary author-supplied test is not possible, so it is recorded as a constraint, not implied-solved. +- **`violationFixture` projection source (#1278 ↔ #1279 now COMPOSE) — DELIVERED.** Initially `descriptorFromProjection` reconstructed only `{ kind, target, rule? }` and the projection carried no fixture, so a prohibition wired purely through the deterministic path always hard-gated. This PR threads a **fourth flat scalar `check_violation_fixture`** through `projectProhibitions` + `descriptorFromProjection` (rides both kinds; mirrors `CheckDescriptor.violationFixture`). A prohibition authored with all four scalars now **machine-proves fail-first and greens end-to-end through the projection alone** (zero hand-authoring) — the round-trip is pinned by a fast-check property + CHK-03(D) + an end-to-end COMPOSE capstone. Fail-closed is preserved: a descriptor with no `check_violation_fixture` (or a blank one) projects absent and hard-gates. The remaining work under #1346 is now just the node-test causation residual above. ## Addendum (2026-06-15): optional `check` descriptor on the prohibition item — D3 shape extension (#1278) diff --git a/gsd-core/references/prohibition-probe.md b/gsd-core/references/prohibition-probe.md index 28eec5228..fa32c7f3b 100644 --- a/gsd-core/references/prohibition-probe.md +++ b/gsd-core/references/prohibition-probe.md @@ -151,38 +151,39 @@ Splitting these axes keeps the lifecycle enum free of a verification fact and le prohibition adapter declare `test | judgment` without forking the shared lifecycle enum that the edge-probe's `explicit | backstop` also uses. -## Optional wired-check descriptor (deterministic locate, #1278) +## Optional wired-check descriptor (deterministic locate + machine-proof, #1278 + #1346) A `resolved`/`test`-tier prohibition MAY carry an **optional `check` descriptor** that names the wired mechanical check, so verify-phase locates it deterministically instead of inventing `{kind, target, rule}` each run. The descriptor is captured at spec-phase (soft / optional — the author wires it when the negative test or lint rule already exists) and is represented as -**three flat scalar keys** on the `must_haves.prohibitions` item — never a nested `check: {}` +**four flat scalar keys** on the `must_haves.prohibitions` item — never a nested `check: {}` object: - `check_kind` — `node-test` | `lint-rule` (which producer mechanism runs the check). - `check_target` — the test file (`node-test`) or the file the rule runs against (`lint-rule`). - `check_rule` — the `ruleId` to filter on, **lint-rule only** (absent for `node-test`). +- `check_violation_fixture` — path to a KNOWN-BAD subject the #1279 prover runs the check against to + machine-prove fail-first (rides BOTH kinds; for `node-test` it is injected via `GSD_PROHIB_SUBJECT`). The flat-scalar shape is load-bearing: the shared `parseMustHavesBlock` is a flat parser and a nested object would flatten/mangle the round-trip (ADR-550 2026-06-15 addendum; #644 "no parser rewrite" precedent). `projectProhibitions` emits these keys **only for a well-formed descriptor** -(valid `check_kind` + non-empty `check_target`; `check_rule` only on the lint-rule path), and -verify-phase reads them back via `descriptorFromProjection` into the `CheckDescriptor` handed to -`check prohibition-enforcement`. This closes the **locate** half with **zero manual descriptor -authoring**. Note the projection carries **no `violationFixture`** (`descriptorFromProjection` -reconstructs only `{ kind, target, rule? }`); because #1279 machine-proves fail-first against a -fixture, a prohibition wired purely through this projected path **hard-gates (fail-closed)** until a -`check_violation_fixture` scalar is threaded through — tracked as **#1346**. Until then green needs a -hand-supplied `violationFixture`. +(valid `check_kind` + non-empty `check_target`; `check_rule` only on the lint-rule path; +`check_violation_fixture` only when non-empty), and verify-phase reads them back via +`descriptorFromProjection` into the `CheckDescriptor` handed to `check prohibition-enforcement`. This +closes **both** the locate (#1278) and the machine-proof-fixture (#1346) halves with **zero manual +descriptor authoring**: a prohibition authored with all four scalars greens end-to-end through the +projection alone. **Fail-closed + backward-compat.** A partial descriptor (`lint-rule` missing `check_rule`), an -unknown `check_kind`, or an **absent** descriptor on a test-tier prohibition falls through to the -producer's existing fail-closed locate — never a silent green. A prohibition with no descriptor -parses and disposes byte-identically to today. `failFirst` is **not** sourced from the -descriptor and is now **demoted** (machine-proven fail-first DELIVERED in #1279 — no path greens on -attestation alone, FF-08); the `dispositionForProhibition` policy is unchanged. The field that gates -green and is **not yet projected** is `violationFixture` (follow-up #1346). +unknown `check_kind`, an **absent** descriptor, OR a descriptor with **no `check_violation_fixture`** +falls through to the producer's fail-closed paths (`located: false`, or located-but-unprovable) — +never a silent green. A prohibition with no descriptor parses and disposes byte-identically to today. +`failFirst` is **not** sourced from the descriptor and is **demoted** (machine-proven fail-first +DELIVERED in #1279 — no path greens on attestation alone, FF-08); the `dispositionForProhibition` +policy is unchanged. Residual (tracked **#1346**): the node-test proof confirms the fixture exists and +the check goes RED, but cannot generically prove the red was *caused by* the subject's content. ## Output schema diff --git a/gsd-core/workflows/spec-phase.md b/gsd-core/workflows/spec-phase.md index 7a6d49bde..22ec44c2b 100644 --- a/gsd-core/workflows/spec-phase.md +++ b/gsd-core/workflows/spec-phase.md @@ -365,11 +365,16 @@ For each Requirement gathered so far, run the two-stage recall→precision pass: - `check_target` — the negative-test file path (for `node-test`), or the path to lint (for `lint-rule`). - `check_rule` — the eslint rule id (e.g. `local/no-source-grep`); `lint-rule` only. + - `check_violation_fixture` (#1346) — path to a KNOWN-BAD subject the wired check is run + 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. 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 - downstream (an absent/partial descriptor → `descriptorFromProjection` null/under-specified - → producer fail-closed locate, never green). Do NOT capture `failFirst` here — it is a + downstream (an absent/partial descriptor — or one with no `check_violation_fixture` — + → `descriptorFromProjection` null/under-specified/fixture-less → producer fail-closed + locate-or-unprovable, never green). Do NOT capture `failFirst` here — it is a verify-time caller attestation, not a spec-authored field (#1279). - **Dismiss (reason)** → mark `dismissed` with a REQUIRED non-empty reason (PROB-05). The reason string is the audit trail; silence is not a valid dismissal. @@ -390,8 +395,8 @@ For each Requirement gathered so far, run the two-stage recall→precision pass: written (test or judgment tier); otherwise leave `unresolved`. **`--auto` NEVER auto-dismisses a prohibition** — a wrong dismissal is the exact silent failure this probe eliminates (PROB-06, the load-bearing safety property). On a `test`-tier auto-resolution, capture the `check_kind` / -`check_target` / `check_rule` descriptor **only when a wired check is unambiguous**; otherwise -leave it empty — `--auto` NEVER fabricates a check path (a wrong locate is re-validated and +`check_target` / `check_rule` / `check_violation_fixture` descriptor **only when a wired check is unambiguous**; otherwise +leave it empty — `--auto` NEVER fabricates a check path or fixture (a wrong locate is re-validated and fails closed at the producer, but a fabricated path is still noise to avoid). Log: `[auto] prohibitions: R resolved, U unresolved`. @@ -402,8 +407,8 @@ runs identically for non-Claude / text-mode hosts. Populate the `## Prohibitions` section of SPEC.md from the resolved prohibitions (each `resolved`/`test` row is a checkable negative acceptance criterion; `resolved`/`judgment` rows route to judgment review; `⚠ UNRESOLVED` rows are flagged as assumptions). A -`resolved`/`test` row ALSO carries its captured `check_kind` / `check_target` / `check_rule` -descriptor when present (so the projection feeds `verify-phase`'s deterministic locate, #1278); +`resolved`/`test` row ALSO carries its captured `check_kind` / `check_target` / `check_rule` / +`check_violation_fixture` descriptor when present (so the projection feeds `verify-phase`'s deterministic locate + machine-proof, #1278 + #1346); a `test` row with no captured descriptor is still valid — it stays fail-closed/flagged downstream rather than blocking authoring. diff --git a/gsd-core/workflows/verify-phase.md b/gsd-core/workflows/verify-phase.md index 5789c45af..c8335ddc9 100644 --- a/gsd-core/workflows/verify-phase.md +++ b/gsd-core/workflows/verify-phase.md @@ -70,17 +70,17 @@ Aggregate all must_haves across plans for phase-level verification. **Prohibitions (`must_haves.prohibitions`, ADR-550 D3 — the must-NOT sibling block):** When a plan carries `must_haves.prohibitions`, extract each `{ statement, status, verification }` item and route it by `verification` tier in verdict assembly (ADR-550 D4, "B-with-guard", 2026-06-12 maintainer decision). These are NEGATIVE checks (the must-NOT must NOT have happened), distinct from positive `truths`: - **judgment-tier → mode-dependent soft-gate.** Interactive verify defers each item to the end-of-phase human checkpoint (`human_verify_mode: end-of-phase`). Autonomous verify records a NON-AUTHORITATIVE LLM-judge verdict + a prominent `unverified-prohibition — human review recommended` flag (autonomous completion reads "complete with N flagged prohibitions"). NEVER a silent pass; NEVER a hard halt of an AFK run. -- **test-tier → ENFORCED via `check prohibition-enforcement` (green on pass, hard-gate on miss/fail).** Accept the `verification: test` value (the SPEC↔must_haves.prohibitions projection contract holds — no forced schema change later). For each test-tier item, the verifier builds `request.check` **DETERMINISTICALLY from the projected descriptor** — it does NOT invent `{ kind, target, rule }`. Read the flat scalar keys `check_kind` / `check_target` / `check_rule` off the `must_haves.prohibitions` item and reconstruct the `CheckDescriptor` via the `descriptorFromProjection` adapter in `prohibition-enforcement` (`descriptorFromProjection(projectedItem)` → `{ kind: check_kind, target: check_target, rule?: check_rule }`). Supply a `violationFixture` (a path to a KNOWN-BAD subject) in the request — it is the field that gates **green**, and it is **NOT sourced from the projection today** (the projected `check_*` scalars carry no fixture; threading a `check_violation_fixture` scalar through is the tracked follow-up **#1346**). So with projection alone the item **hard-gates fail-closed** — green currently requires a hand-supplied `violationFixture`. Do NOT rely on `failFirst`: it is DEMOTED (#1279) and greens nothing on its own. Invoke the producer (CLI surface unchanged): +- **test-tier → ENFORCED via `check prohibition-enforcement` (green on pass, hard-gate on miss/fail).** Accept the `verification: test` value (the SPEC↔must_haves.prohibitions projection contract holds — no forced schema change later). For each test-tier item, the verifier builds `request.check` **DETERMINISTICALLY from the projected descriptor** — it does NOT invent `{ kind, target, rule }`. Read the flat scalar keys `check_kind` / `check_target` / `check_rule` / `check_violation_fixture` off the `must_haves.prohibitions` item and reconstruct the `CheckDescriptor` via the `descriptorFromProjection` adapter in `prohibition-enforcement` (`descriptorFromProjection(projectedItem)` → `{ kind: check_kind, target: check_target, rule?: check_rule, violationFixture?: check_violation_fixture }`). The `violationFixture` (a path to a KNOWN-BAD subject) is the field that gates **green** and it is **now projected** (`check_violation_fixture`, #1346) — so a prohibition authored with all four scalars greens through the projection alone, **zero hand-authoring at verify time**. Do NOT rely on `failFirst`: it is DEMOTED (#1279) and greens nothing on its own; an item with no projected fixture hard-gates fail-closed. Invoke the producer (CLI surface unchanged): ```bash gsd_run check prohibition-enforcement ``` - where `` carries `{ prohibition, check, mode }` — `check` being the wired mechanical-check descriptor `{ kind: 'node-test' | 'lint-rule', target, rule?, violationFixture, failFirst? }`, with `kind`/`target`/`rule` now sourced from the projected `check_*` scalars (not author/verifier invention — #1278). For `node-test`, `target` (from `check_target`) is the negative-test file path; for `lint-rule`, `target` is the PATH to lint and `rule` (from `check_rule`) is the eslint rule id (e.g. `local/no-source-grep`) — both required (a lint-rule without `rule` is not a valid wired check). `violationFixture` is the author-supplied path to a KNOWN-BAD subject the producer runs the check against to **machine-prove fail-first** (for `node-test`, injected via the `GSD_PROHIB_SUBJECT` env convention — #1279); `failFirst` is a DEMOTED, non-authoritative hint kept only for backward route-JSON shape (no path greens on it alone — FF-08). The producer LOCATES the wired check from the projection, **machine-proves it is fail-first** by running it against the violation and confirming it goes RED, RUNS it for a genuine non-vacuous pass, builds `enforcementEvidence`, and emits the `dispositionForProhibition()` verdict (#1259 + #1278 + #1279, ADR-550 D5d). Fail-first is **machine-proven, not caller-attested** — absent a provable violation the producer fails closed, never falling back to attestation. Route the result by its typed fields: + where `` carries `{ prohibition, check, mode }` — `check` being the wired mechanical-check descriptor `{ kind: 'node-test' | 'lint-rule', target, rule?, violationFixture, failFirst? }`, with `kind`/`target`/`rule`/`violationFixture` now sourced from the projected `check_*` scalars (not author/verifier invention — #1278 + #1346). For `node-test`, `target` (from `check_target`) is the negative-test file path; for `lint-rule`, `target` is the PATH to lint and `rule` (from `check_rule`) is the eslint rule id (e.g. `local/no-source-grep`) — both required (a lint-rule without `rule` is not a valid wired check). `violationFixture` (from `check_violation_fixture`) is the path to a KNOWN-BAD subject the producer runs the check against to **machine-prove fail-first** (for `node-test`, injected via the `GSD_PROHIB_SUBJECT` env convention — #1279); `failFirst` is a DEMOTED, non-authoritative hint kept only for backward route-JSON shape (no path greens on it alone — FF-08). The producer LOCATES the wired check from the projection, **machine-proves it is fail-first** by running it against the violation and confirming it goes RED, RUNS it for a genuine non-vacuous pass, builds `enforcementEvidence`, and emits the `dispositionForProhibition()` verdict (#1259 + #1278 + #1279, ADR-550 D5d). Fail-first is **machine-proven, not caller-attested** — absent a provable violation the producer fails closed, never falling back to attestation. Route the result by its typed fields: - **`status: 'green'`, `flagged: false`** (a genuinely-passing wired negative test / lint rule, `located: true`, non-empty `evidence`) → the item is satisfiable → it can reach **passed**. - **missing, non-attested, or genuinely-non-passing check** (`located: false` OR `status: 'unverified'`, `flagged: true`) → **hard-gate**: disposes flagged-unverified, NEVER green, routing to `gaps_found` in BOTH interactive and autonomous modes (a failing mechanical check blocks even AFK; ADR-550 D4 / D3). The deterministic fail-closed default backing every miss/fail is `dispositionForProhibition()` in probe-core (`status: 'unverified'`, `flagged: true` on empty `enforcementEvidence`). - > **Descriptor source — deterministic locate (#1278, DELIVERED).** The `check` descriptor's `{ kind, target, rule }` is now sourced **deterministically from the projected `check_kind` / `check_target` / `check_rule` scalars** on the `must_haves.prohibitions` item (authored at `/gsd:spec-phase`, projected by `projectProhibitions`, read back via the `descriptorFromProjection` adapter). So the **locate** half closes with **zero manual descriptor authoring** — the verifier no longer invents `{ kind, target, rule }` (removing the spoofable invent-at-verify-time surface; ADR-857 §147 exogenous grading). **Fail-closed is preserved:** an item with NO projected descriptor — or a PARTIAL one (e.g. a `lint-rule` missing `check_rule`) — makes `descriptorFromProjection` return `null` / an under-specified descriptor, which falls through to the producer's existing fail-closed LOCATE (`located: false`) → flagged-unverified, NEVER green, in BOTH modes. **Important — the projection does NOT yet carry a `violationFixture`** (`descriptorFromProjection` reconstructs only `{ kind, target, rule? }`). Because #1279 now machine-proves fail-first against a `violationFixture`, a prohibition wired **purely through this projected path always hard-gates** (fail-closed, safe) until a `check_violation_fixture` scalar is threaded through — the **tracked follow-up #1346**. Until then a green requires the verifier to hand-supply `violationFixture`; `failFirst` is demoted and greens nothing (#1279, DELIVERED). + > **Descriptor source — deterministic locate + machine-proof compose (#1278 + #1346, DELIVERED).** The `check` descriptor's `{ kind, target, rule, violationFixture }` is now sourced **deterministically from the projected `check_kind` / `check_target` / `check_rule` / `check_violation_fixture` scalars** on the `must_haves.prohibitions` item (authored at `/gsd:spec-phase`, projected by `projectProhibitions`, read back via the `descriptorFromProjection` adapter). So both halves close with **zero manual descriptor authoring** — the verifier neither invents the locate (#1278) nor hand-supplies the violation fixture (#1346): a prohibition authored with all four scalars machine-proves fail-first and greens end-to-end through the projection alone (removing the spoofable invent-at-verify-time surface; ADR-857 §147 exogenous grading). **Fail-closed is preserved:** an item with NO projected descriptor, a PARTIAL one (e.g. a `lint-rule` missing `check_rule`), OR a descriptor with **no `check_violation_fixture`** makes `descriptorFromProjection` return `null` / an under-specified or fixture-less descriptor, which falls through to the producer's fail-closed paths (`located: false`, or located-but-unprovable) → flagged-unverified, NEVER green, in BOTH modes. `failFirst` is demoted and greens nothing on its own (#1279, FF-08). Residual (tracked **#1346**): the node-test proof confirms the fixture exists and the check goes RED, but cannot generically prove the red was *caused by* the subject's content vs the env merely being set. **Option B: Use Success Criteria from ROADMAP.md** diff --git a/src/probe-core.cts b/src/probe-core.cts index 7a924fe94..bbac605c9 100644 --- a/src/probe-core.cts +++ b/src/probe-core.cts @@ -299,6 +299,10 @@ export interface Prohibition { check_kind?: 'node-test' | 'lint-rule'; check_target?: string; check_rule?: string; + // Optional 4th flat scalar (#1346): the path to a KNOWN-BAD subject the #1279 prover runs the check + // against to MACHINE-PROVE fail-first. Projected only alongside a well-formed descriptor; absent -> + // the producer hard-gates (green requires a fixture). Mirrors `CheckDescriptor.violationFixture`. + check_violation_fixture?: string; } /** @@ -376,6 +380,13 @@ export function projectProhibitions( if (kind === 'lint-rule' && typeof p.check_rule === 'string' && p.check_rule.trim() !== '') { entry.check_rule = String(p.check_rule); } + // `check_violation_fixture` (#1346) rides BOTH kinds — it's what the #1279 prover machine-proves + // fail-first against. Emit ONLY a non-empty fixture (a blank one projects absent so green still + // hard-gates downstream — never a partial green); meaningless without the descriptor, so it lives + // inside this well-formed-descriptor branch. + if (typeof p.check_violation_fixture === 'string' && p.check_violation_fixture.trim() !== '') { + entry.check_violation_fixture = String(p.check_violation_fixture); + } } out.push(entry); } diff --git a/src/prohibition-enforcement.cts b/src/prohibition-enforcement.cts index e07980cc4..6a5e5e2fa 100644 --- a/src/prohibition-enforcement.cts +++ b/src/prohibition-enforcement.cts @@ -90,7 +90,8 @@ export interface CheckDescriptor { * - `null`/`undefined`/non-object input -> `null`. * - `check_kind` ABSENT -> `null` (no descriptor -> producer locates nothing -> fail-closed). * - `check_kind` present -> `{ kind: check_kind, target: check_target }`, adding `rule: check_rule` - * ONLY when `check_rule` is a non-empty string. + * ONLY when `check_rule` is a non-empty string, and `violationFixture: check_violation_fixture` + * ONLY when that scalar is a non-empty string (#1346 — composes #1278 locate with #1279 proof). * - `failFirst` is NEVER sourced from the projection — it stays a verify-time caller attestation * (#1279 machine-proves it; out of scope here). The returned descriptor carries no `failFirst`. * - The adapter does NOT strictly validate kind/target/rule: it faithfully reconstructs whatever @@ -121,6 +122,12 @@ export function descriptorFromProjection( const rule = scalar(projected.check_rule); if (rule.trim().length > 0) descriptor.rule = rule; } + // `violationFixture` (#1346) rides BOTH kinds — reconstruct it from `check_violation_fixture` so the + // deterministic #1278 locate path and the #1279 machine-proof COMPOSE: a projected fixture lets the + // default prover green end-to-end with zero hand-authoring. Absent/blank -> no fixture -> the prover + // hard-gates (fail-closed; green requires a fixture), never fabricated. + const fixture = scalar(projected.check_violation_fixture); + if (fixture.trim().length > 0) descriptor.violationFixture = fixture; return descriptor; } diff --git a/tests/probe-core.property.test.cjs b/tests/probe-core.property.test.cjs index 003ea4858..1a41d855c 100644 --- a/tests/probe-core.property.test.cjs +++ b/tests/probe-core.property.test.cjs @@ -193,6 +193,7 @@ function renderProhibitionsDoc(entries) { if (e.check_kind !== undefined) lines.push(` check_kind: ${e.check_kind}`); if (e.check_target !== undefined) lines.push(` check_target: ${e.check_target}`); if (e.check_rule !== undefined) lines.push(` check_rule: ${e.check_rule}`); + if (e.check_violation_fixture !== undefined) lines.push(` check_violation_fixture: ${e.check_violation_fixture}`); } lines.push('---', '', 'Body.', ''); return lines.join('\n'); @@ -216,19 +217,20 @@ const pathScalarArb = fc.array(fc.constantFrom(...PATH_CHARS), { minLength: 1, m const numericScalarArb = fc.nat({ max: 9999999 }).map(String); const targetArb = fc.oneof(pathScalarArb, numericScalarArb); -// A fully well-formed descriptor item (resolved test-tier); node-test carries no rule. +// A fully well-formed descriptor item (resolved test-tier); node-test carries no rule. The +// violation fixture (#1346) rides BOTH kinds and exercises the numeric-coercion path too. const wellFormedArb = KIND_ARB.chain((kind) => - fc.record({ target: targetArb, rule: pathScalarArb }).map(({ target, rule }) => { - const item = { ...BASE_TIER, check_kind: kind, check_target: target }; + fc.record({ target: targetArb, rule: pathScalarArb, fixture: targetArb }).map(({ target, rule, fixture }) => { + const item = { ...BASE_TIER, check_kind: kind, check_target: target, check_violation_fixture: fixture }; if (kind === 'lint-rule') item.check_rule = rule; - return { item, kind, target, rule: kind === 'lint-rule' ? rule : undefined }; + return { item, kind, target, rule: kind === 'lint-rule' ? rule : undefined, fixture }; }), ); describe('probe-core property: #1278 check-descriptor round-trip is deterministic across the full string domain', () => { test('a well-formed descriptor survives project -> render -> parse -> descriptorFromProjection (incl. numeric coercion); target/rule reconstruct as strings', () => { fc.assert( - fc.property(wellFormedArb, ({ item, kind, target, rule }) => { + fc.property(wellFormedArb, ({ item, kind, target, rule, fixture }) => { const projected = pc.projectProhibitions([item]); if (projected[0].check_kind !== kind) return false; // projector emits the descriptor const reparsed = fm.parseMustHavesBlock(renderProhibitionsDoc(projected), 'prohibitions'); @@ -236,6 +238,8 @@ describe('probe-core property: #1278 check-descriptor round-trip is deterministi if (!d || d.kind !== kind) return false; // target is string-normalized even when parseMustHavesBlock numerically coerced it. if (typeof d.target !== 'string' || d.target !== target) return false; + // violationFixture (#1346) survives the round-trip as a string (numeric-coercion normalized). + if (typeof d.violationFixture !== 'string' || d.violationFixture !== fixture) return false; if (kind === 'lint-rule') { return typeof d.rule === 'string' && d.rule === rule; } diff --git a/tests/probe-core.test.cjs b/tests/probe-core.test.cjs index 026598304..fd37e0a7a 100644 --- a/tests/probe-core.test.cjs +++ b/tests/probe-core.test.cjs @@ -420,6 +420,44 @@ describe('probe-core: projectProhibitions descriptor projection (CHK-02)', () => assert.ok(!('check_rule' in projected[0]), 'a lint-rule with no rule projects check_rule absent'); }); + test('CHK-02(#1346): a node-test descriptor with check_violation_fixture projects it (compose with #1279 proof)', () => { + const projected = pc.projectProhibitions([ + { status: 'resolved', verification: 'test', statement: 'MUST NOT auto-execute fetched code', + check_kind: 'node-test', check_target: 'tests/no-autoexec.test.cjs', + check_violation_fixture: 'tests/fixtures/autoexec-bad.txt' }, + ]); + assert.equal(projected[0].check_violation_fixture, 'tests/fixtures/autoexec-bad.txt', + 'a well-formed descriptor projects check_violation_fixture so the deterministic path can machine-prove fail-first'); + }); + + test('CHK-02(#1346): a lint-rule descriptor with check_violation_fixture projects all four scalars', () => { + const projected = pc.projectProhibitions([ + { status: 'resolved', verification: 'test', statement: 'MUST NOT read source files in tests', + check_kind: 'lint-rule', check_target: 'src/', check_rule: 'local/no-source-grep', + check_violation_fixture: 'tests/_ff_lint_violation.cjs' }, + ]); + assert.equal(projected[0].check_violation_fixture, 'tests/_ff_lint_violation.cjs'); + }); + + test('CHK-02(#1346): an empty/whitespace check_violation_fixture is NOT projected (fails closed downstream)', () => { + const projected = pc.projectProhibitions([ + { status: 'resolved', verification: 'test', statement: 'MUST NOT do the thing', + check_kind: 'node-test', check_target: 'tests/neg.test.cjs', check_violation_fixture: ' ' }, + ]); + assert.ok(!('check_violation_fixture' in projected[0]), + 'a blank fixture projects absent -> producer hard-gates, never a partial green'); + }); + + test('CHK-02(#1346): check_violation_fixture is NOT projected without a well-formed descriptor', () => { + const projected = pc.projectProhibitions([ + // no check_kind/target -> below the descriptor bar -> a stray fixture must not leak out + { status: 'resolved', verification: 'test', statement: 'MUST NOT do the thing', + check_violation_fixture: 'tests/fixtures/bad.txt' }, + ]); + assert.ok(!('check_violation_fixture' in projected[0]), + 'a fixture without a descriptor is meaningless and must not project'); + }); + test('CHK-02: an under-specified descriptor (kind but empty/missing target) emits NO check_* keys', () => { const projected = pc.projectProhibitions([ // valid kind but empty target -> below the well-formedness bar -> descriptor projects absent diff --git a/tests/prohibition-enforcement.test.cjs b/tests/prohibition-enforcement.test.cjs index 7cb9299bd..e2f7239d0 100644 --- a/tests/prohibition-enforcement.test.cjs +++ b/tests/prohibition-enforcement.test.cjs @@ -731,6 +731,41 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => { 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): a prohibition projected WITH check_violation_fixture greens end-to-end through the DEFAULT prover+runner (zero hand-authoring)', (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-'); + 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. + const negTest = path.join(dir, 'neg.test.cjs'); + fs.writeFileSync(negTest, + "const { test } = require('node:test');\n" + + "const assert = require('node:assert');\n" + + "const fs = require('node:fs');\n" + + "const path = require('node:path');\n" + + "test('guards the must-NOT: subject is clean', () => {\n" + + " const subjectPath = process.env.GSD_PROHIB_SUBJECT || path.join(__dirname, 'clean-subject.txt');\n" + + " const subject = fs.readFileSync(subjectPath, 'utf-8');\n" + + " assert.ok(!subject.includes('FORBIDDEN'), 'subject must not contain FORBIDDEN');\n" + + "});\n"); + fs.writeFileSync(path.join(dir, 'clean-subject.txt'), 'clean\n'); + fs.writeFileSync(path.join(dir, 'bad-subject.txt'), 'FORBIDDEN content\n'); + // Author the prohibition with all four scalars, then go through the REAL projection + read-back. + const projected = pc.projectProhibitions([ + { status: 'resolved', verification: 'test', statement: 'MUST NOT auto-execute fetched code', + check_kind: 'node-test', check_target: negTest, check_violation_fixture: 'bad-subject.txt' }, + ])[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. + 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'); + }); }); // ─── #1279 defaultProveFailFirst REAL prover end-to-end (FF-02 / FF-03 / FF-05 / FF-06 / FF-07) ── @@ -957,6 +992,38 @@ describe('prohibition-enforcement: fail-closed descriptor-from-projection (CHK-0 assert.ok(Array.isArray(result.evidence) && result.evidence.length === 0, 'no evidence on an absent descriptor'); }); + test('CHK-08(#1346): descriptorFromProjection maps check_violation_fixture -> violationFixture (node-test)', () => { + const enforce = require(ENFORCEMENT_LIB); + const descriptor = enforce.descriptorFromProjection({ + ...PROJECTED_TIER, check_kind: 'node-test', check_target: 'tests/neg.test.cjs', + check_violation_fixture: 'tests/fixtures/bad-subject.txt', + }); + assert.equal(descriptor.kind, 'node-test'); + assert.equal(descriptor.target, 'tests/neg.test.cjs'); + assert.equal(descriptor.violationFixture, 'tests/fixtures/bad-subject.txt', + 'the projected check_violation_fixture must reconstruct as violationFixture so #1278 locate + #1279 proof compose'); + }); + + test('CHK-08(#1346): descriptorFromProjection maps check_violation_fixture -> violationFixture (lint-rule)', () => { + const enforce = require(ENFORCEMENT_LIB); + const descriptor = enforce.descriptorFromProjection({ + ...PROJECTED_TIER, check_kind: 'lint-rule', check_target: 'src/', check_rule: 'local/no-source-grep', + check_violation_fixture: 'tests/_ff_lint_violation.cjs', + }); + assert.equal(descriptor.kind, 'lint-rule'); + assert.equal(descriptor.rule, 'local/no-source-grep'); + assert.equal(descriptor.violationFixture, 'tests/_ff_lint_violation.cjs'); + }); + + test('CHK-08(#1346): no check_violation_fixture -> descriptor carries no violationFixture (fail-closed: green needs a fixture)', () => { + const enforce = require(ENFORCEMENT_LIB); + const descriptor = enforce.descriptorFromProjection({ + ...PROJECTED_TIER, check_kind: 'node-test', check_target: 'tests/neg.test.cjs', + }); + assert.equal(descriptor.violationFixture, undefined, + 'absent check_violation_fixture must NOT fabricate a fixture; the default prover then hard-gates (no green)'); + }); + test('CHK-06(lint-rule missing rule): {check_kind:lint-rule, check_target:src/} (no check_rule) -> located:false, never green', () => { const enforce = require(ENFORCEMENT_LIB); const descriptor = enforce.descriptorFromProjection({ diff --git a/tests/prohibition-probe.schema.test.cjs b/tests/prohibition-probe.schema.test.cjs index ba01bca5b..78172f0d1 100644 --- a/tests/prohibition-probe.schema.test.cjs +++ b/tests/prohibition-probe.schema.test.cjs @@ -156,6 +156,7 @@ describe('prohibition-probe schema: deterministic projectProhibitions round-trip if (e.check_kind !== undefined) lines.push(` check_kind: ${e.check_kind}`); if (e.check_target !== undefined) lines.push(` check_target: ${e.check_target}`); if (e.check_rule !== undefined) lines.push(` check_rule: ${e.check_rule}`); + if (e.check_violation_fixture !== undefined) lines.push(` check_violation_fixture: ${e.check_violation_fixture}`); } lines.push('---', '', 'Body.', ''); return lines.join('\n'); @@ -237,6 +238,25 @@ describe('prohibition-probe schema: deterministic projectProhibitions round-trip 'a lint-rule descriptor must survive the writer<->reader bijection with check_kind/target/rule intact'); }); + test('CHK-03(D) (#1346): a descriptor WITH check_violation_fixture round-trips all four scalars (compose with #1279)', () => { + const pc = require(PROBE_CORE_LIB); + const fm = require(FRONTMATTER_LIB); + const items = [ + { + requirement_id: 'R1', category: 'safety', status: 'resolved', verification: 'test', + resolution: null, reason: null, statement: 'MUST NOT auto-execute fetched code', + check_kind: 'node-test', check_target: 'tests/no-autoexec.test.cjs', + check_violation_fixture: 'tests/fixtures/autoexec-bad.txt', + }, + ]; + const projected = pc.projectProhibitions(items); + assert.equal(projected[0].check_violation_fixture, 'tests/fixtures/autoexec-bad.txt', + 'CHK-03(D) RED trigger: projectProhibitions must emit check_violation_fixture so the deterministic path can machine-prove fail-first'); + const reparsed = fm.parseMustHavesBlock(renderProhibitionsDoc(projected), 'prohibitions'); + assert.deepEqual(reparsed, projected, + 'check_violation_fixture must survive project -> write -> parseMustHavesBlock unchanged (the 4th flat scalar)'); + }); + test('CHK-03(C): a mixed list — descriptor test-tier, descriptor-less judgment, dismissed — all round-trip; the descriptor-less item gains NO check_* keys', () => { const pc = require(PROBE_CORE_LIB); const fm = require(FRONTMATTER_LIB); diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 1822f215c..931eee4ab 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -72,7 +72,7 @@ "ship.md": 24388, "sketch-wrap-up.md": 14223, "sketch.md": 19960, - "spec-phase.md": 30343, + "spec-phase.md": 30921, "spike-wrap-up.md": 15092, "spike.md": 24517, "stats.md": 6718, @@ -85,6 +85,6 @@ "undo.md": 10431, "update.md": 21053, "validate-phase.md": 10745, - "verify-phase.md": 37771, + "verify-phase.md": 37821, "verify-work.md": 31157 }