Files
msd-core/docs/adr/550-spec-phase-probe-contract.md
Rezolv e50ead7ad2 enhance(verify-phase): deterministic auto-locate of the prohibition check descriptor (#1278) (#1301)
* test(1278): RED-first descriptor parity + fail-closed guards + CHK-07 byte-stability (wave 1)

- CHK-03 (RED): extend PROB-14 parity in prohibition-probe.schema.test.cjs to carry the flat
  check_kind/check_target/check_rule scalars through project->write->parseMustHavesBlock; the
  non-droppable check_kind-presence assertion is the load-bearing RED trigger (fails because
  projectProhibitions strips check_* on the current build).
- CHK-07 (GREEN forward-guard): probe-core.test.cjs pins descriptor-less byte-stability +
  dispositionForProhibition fail-closed policy, with a t.todo marker forward-locking plan 01-02.
- CHK-06 (RED): prohibition-enforcement.test.cjs asserts descriptorFromProjection export +
  fail-closed on absent/partial/unknown descriptors via the projection adapter (RED until 01-03).
- No src/*.cts or .cjs edits; no new test files; lint-test-file-count clean.

* feat(1278): add optional flat-scalar check descriptor fields to Prohibition interface (wave 2)

- check_kind?/check_target?/check_rule? mirror CheckDescriptor.kind/target/rule (minus caller-attested failFirst, #1279)
- optional so existing Prohibition consumers compile unchanged

* feat(1278): project check descriptor as flat scalars in projectProhibitions (wave 2)

- emit check_kind/check_target (+ check_rule only for lint-rule with a rule) when descriptor well-formed
- under-specified/descriptor-less items project byte-identically (CHK-07); flat scalars ride existing parseMustHavesBlock continuation-KV path (no parser rewrite)
- add CHK-02 probe-core unit cases pinning the projection
- turns CHK-03 parity test GREEN; dispositionForProhibition untouched

* feat(1278): descriptorFromProjection read-back adapter feeds fail-closed locate (wave 3)

- Add descriptorFromProjection(projected) -> CheckDescriptor | null to
  src/prohibition-enforcement.cts: renames the projected flat scalars
  check_kind/check_target/check_rule -> {kind,target,rule?}, or null when
  the descriptor is absent/non-object (no check_kind key).
- failFirst is NEVER sourced from the projection (stays caller-attested; #1279).
- rule is set only when check_rule is a non-empty string; the adapter does NOT
  re-validate kind/target/rule — an under-specified descriptor reconstructs to
  one the EXISTING runProhibitionEnforcement LOCATE guard rejects (located:false,
  never green). The merged #1259 guard stays the single source of fail-closed truth.
- Turns the RED CHK-06 fail-closed tests (plan 01-01) GREEN end-to-end; CHK-03 /
  CHK-07 stay green. CheckDescriptor type, locate guard, dispositionForProhibition,
  and parseMustHavesBlock are unchanged (additive +36/-0).

* feat(1278): verify-phase locates prohibition check from projected descriptor (wave 3)

- request.check kind/target/rule sourced from projected check_kind/check_target/check_rule via descriptorFromProjection, not verifier invention (CHK-05)
- replaces the #1278 author-supplied / tracked-follow-up note with the delivered deterministic-locate behavior
- preserves fail-closed routing: absent/partial descriptor -> never green, hard-gate in both modes
- failFirst stays a verify-time caller attestation; #1279 bounds the remaining fail-first proof

* feat(1278): spec-phase captures wired-check descriptor on test-tier resolution (wave 3)

- Step 5.6 'Keep it' / verification: test path captures check_kind/check_target/check_rule, projected onto must_haves.prohibitions for verify-phase deterministic locate (CHK-04)
- SOFT capture: a test-tier prohibition without a descriptor is still allowed (no hard authoring block); stays fail-closed/flagged downstream
- --auto captures only an unambiguous descriptor, never fabricates a check path
- failFirst NOT captured at spec-phase (verify-time attestation; #1279)
- PROB-06 soft-gate + text-mode (PROB-09) behavior unchanged

* chore(1278): re-baseline workflow size for grown verify-phase + spec-phase prose (wave 3)

- spec-phase.md 28438 -> 30343 (+1905), verify-phase.md 35362 -> 36498 (+1136)
- regenerated via npm run size:baseline (no hand-picked numbers); growth is the #1278 deterministic-locate + descriptor-capture prose
- workflow-size-budget guard green (122/122)

* docs(1278): ratify optional check descriptor in dated ADR-550 addendum + type:Changed changeset

- Append dated 2026-06-15 ADR-550 addendum ratifying the D3 prohibition-item
  shape extension (optional flat-scalar check_kind/check_target/check_rule)
- Document flat-scalar rationale, deterministic projection/read-back,
  fail-closed on partial/invalid/absent, #1279/policy out-of-scope
- Add .changeset/1278-prohibition-check-descriptor.md (type: Changed)

* docs(1278): document optional check descriptor in prohibition-probe reference + FEATURES

- Add 'Optional wired-check descriptor (deterministic locate, #1278)' section
  to the prohibition-probe reference (flat-scalar keys, projection/read-back,
  fail-closed + backward-compat, failFirst stays attested)
- Add deterministic prohibition-check descriptor source entry to FEATURES.md
- No CONTEXT.md glossary change: descriptor reuses existing wired-check /
  verification:test vocabulary, no new glossary term introduced

* fix(1278): pass packaging gates — changeset pr field + retired slash-form fix

- Add required pr: 1278 to changeset (lint:changeset MISSING_PR hard requirement;
  plan's 'omit if unknown' was inaccurate — issue number per #1259 convention,
  updated to real PR number when opened) [Rule 3 - blocking]
- Fix retired /gsd-spec-phase -> /gsd:spec-phase at verify-phase.md:83 (wave-3
  prose; caught by slash-namespace invariant #3443/bug-2543, blocked CHK-09
  full-suite-green) [Rule 1 - bug]
- size:baseline + INVENTORY manifest verified in-sync post-build (no diff)

* docs(1278): add check descriptor + descriptorFromProjection to CONTEXT.md prohibition glossary

* fix(1278): harden descriptorFromProjection round-trip (numeric-coercion + stray-rule) per review

- MD-01/LW-01: narrow projected scalars to primitives + String()-coerce, so a
  numeric-looking check_target (parseMustHavesBlock coerces ^\d+$ to number)
  reconstructs as a string and locates instead of silently un-locating; no
  as-string type-lie, satisfies no-base-to-string.
- LW-02: attach rule only for the lint-rule kind (drop a stray node-test rule).
- LW-03: document the optional check_* keys in the reference Output schema.
RED->GREEN tests added in prohibition-enforcement.test.cjs.

* chore(1278): set changeset pr to 1301

* test(1278): add fast-check property for the check-descriptor round-trip + fail-closed (trek-e review)

RULESET.TESTS.property-based-testing: the projectProhibitions -> render ->
parseMustHavesBlock -> descriptorFromProjection chain is a bijective/transformation
contract. Adds 2 fc properties to tests/probe-core.property.test.cjs (no new file;
ratchet stays at 2 for probe-core):
- well-formed descriptors survive the round-trip across the full string domain
  incl. the numeric-coercion case (target/rule reconstruct as strings);
- under-specified/invalid descriptors (absent / target-less / rule-less /
  unknown-kind) are always fail-closed (never green, flagged, unlocated).
Stability is asserted at the descriptorFromProjection layer (the raw parse step is
intentionally lossy for numeric scalars; the shared parser is unchanged).

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-06-16 00:01:03 -04:00

23 KiB
Raw Blame History

ADR 550: spec-phase probe pattern and prohibition contract [Accepted]

  • Status: Accepted
  • Date: 2026-06-03

Provenance. Drafted during triage of #644 (prohibition probe), hardened in a /grill-with-docs session, and extended with a probe-core seam designed in an /improve-codebase-architecture grill (Decision 7). Endorsed by the maintainer and by the #644 author (who also authored the edge-probe, #550 / PR #584). This ADR lands on PR #584 alongside the probe-core extraction that implements Decision 7; #644 (the prohibition probe itself) remains blocked until #584 merges and then implements the prohibition adapter. "What exists today" is verified against next + the PR-#584 head as of 2026-06-03.

Lineage. The judgment-tier contract (Decision 4) was reached independently from two directions: from the domain model (truths are unenforced positive-observables, so a values prohibition cannot be a silent pass) and from the author's "verifier-abstention" (N17) calibration experiment (on irreducible values rules the verifier is confidently wrong — ~0.93 confidence, ECE 0.81 — so confidence-gating cannot help; the only honest move is abstain-and-flag). That parked experiment folds into this ADR as the verify-time half of judgment-tier rather than needing a separate feature.

Context

spec-phase produces SPEC.md from an interview. Two features add probes — soft-gate steps that surface spec gaps before code is written: #550 / PR #584 (edge-probe) for data-shape edges, and #644 (prohibition probe) for values/safety "must-NOT" constraints. Both use identical three-layer packaging and a recall→precision protocol, and both want confirmed findings to reach SPEC.md and the plan contract.

Grounding the design against the codebase surfaced the decisive facts:

  • truths are positive, observable assertions. gsd-planner derives them as "observable truths (3-7, user perspective)"; verify-phase checks each by "determine if the codebase enables it" — a presence/wiring check. A prohibition is the inverse and cannot be confirmed that way.
  • truths are not mechanically verified. verify.cjs iterates artifacts and key_links but has no truths handler. A prohibition parked in truths would inherit that non-enforcement — a negative wearing a positive's clothing.
  • must_haves has no schema/type — a convention parsed by parseMustHavesBlock (blocks truths/artifacts/key_links), read in ≥6 places.
  • The SPEC→plan lift is LLM reasoning in gsd-planner's derive_must_haves, not mechanical extraction.
  • Prohibitions already exist in GSD only as narrative prose (<scope_reduction_prohibition>, "MUST NOT loop"), never as structured, verifiable data.
  • The edge-probe's load-bearing logic is a deep module on the live path. src/edge-probe.cts (~297 lines) is compiled to bin/lib/edge-probe.cjs and invoked by spec-phase Step 5.5 at runtime. ~120 of its lines are a generic resolution model (status lifecycle, validation, merge/rollup, CLI); only the Shape/SHAPE_CUES/TAXONOMY/classifyShape/proposeEdges cluster is edge-specific. The prohibition probe is the second adapter of that model — making the seam real (one adapter is hypothetical; two is real).
  • No prior ADR governs probes, soft gates, or must_haves evolution.

The values/safety class #644 targets ("must not become a guilt mechanic") is frequently not reducible to a deterministic test — which is why it needs an explicit verification contract rather than truth-style limbo.

Decision

  1. Probe packaging (three layers). Every spec-phase probe ships as: (a) a portable, dependency-free reference core under references/<probe>.md (taxonomy + protocol + output schema); (b) a spec-phase.md step run as a soft gate (write-anyway-with-flags), with explicit --auto and text-mode (non-Claude, no AskUserQuestion) handling, mirroring the ambiguity gate; (c) tests + docs.

  2. Two-stage protocol. Recall (adversarial over-generation) → precision (classifier dropping routine engineering items), surfacing a short confirmable list. Dismissals require a non-empty reason.

  3. Prohibition home & representation. Confirmed prohibitions live primarily as SPEC.md acceptance criteria (negative criteria). They MAY be projected into an optional must_haves.prohibitions: sibling block (alongside truths/artifacts/key_links) when they must survive into the plan contract. truths is left untouched — no polarity field is added. Each prohibition item carries statement, the orthogonal status + verification of Decision 7, and (when dismissed) a non-empty reason.

  4. Tiered verification. Each prohibition declares verification: test | judgment.

    • test-tier (reducible to a deterministic assertion) → a negative test following regression-must-fail-first + negative-proof discipline. Hard gate in both interactive and autonomous modes.
    • judgment-tier (irreducible values/safety rule) → mode-dependent soft-gate-with-flags: interactive verify requires explicit human resolution of each item; autonomous verify records an LLM-judge verdict marked non-authoritative and emits a prominent "unverified prohibition — human review recommended" flag in SUMMARY/verdict. Never a silent pass; never a hard halt of AFK runs. Autonomous completion reads "complete with N flagged prohibitions."
    • Optional future input: cross-tier (or cross-model) disagreement MAY feed the judgment-tier flag as a cheap blind-spot signal. This is a breadcrumb, not a dependency — the flag stands without it.
  5. The CI-testable surface is the contract, not the classifier. The recall/precision/tier-assignment stages are LLM behavior and are not claimed as deterministic CI coverage (validated by offline batteries; in CI only by source-grep-under-// allow-test-rule:-exemption over reference/workflow prose, which is the product). CI deterministically tests the contract: (a) parse + validate an item (statement present; status ∈ {resolved, dismissed, unresolved}; verification in the probe's allowed set; dismissed items carry a non-empty reason); (b) round-trip / projection between SPEC.md and must_haves.prohibitions:; (c) a DEFECT.GENERATIVE-FIX parity assertion across template ↔ parser ↔ planner; (d) test-tier items are provably wired into verify-phase and cannot be silently skipped. A test that asserts the LLM's judgment is vacuous and is rejected per RULESET.TESTS.delete-bad-tests. (Scope: on #584 only (a) ships — it is the edge adapter's parse+validate contract test (tests/probe-core.test.cjs). (b)–(d) require the must_haves.prohibitions: block, projection, and verify-phase tiering and are #644 scope, as the Consequences section records.)

  6. Ownership seam with security tooling. The probe owns bespoke product/values prohibitions only. When precision classifies an item as canon security/compliance (OWASP/GDPR/fairness/prototype-pollution/path-traversal), it does not mint a SPEC prohibition; it emits a one-line breadcrumb ("possible canon-security concern X — owned by /gsd:secure-phase / eslint") and stops. Canon checks are referred, not duplicated — keeping the surfaced list short (#644's ~2–3-item goal).

  7. probe-core is the seam; probes are adapters. The shared resolution model is extracted into src/probe-core.cts; edge-probe.cts refactors onto it as the first adapter and the prohibition probe is born on it as the second. Sub-decisions, each chosen against both probes:

    • 7a — Orthogonal model. The item carries two orthogonal dimensions: status: resolved | dismissed | unresolved (resolution lifecycle, shared) and verification: <probe-defined> | null (edge: explicit | backstop; prohibition: test | judgment). This replaces edge-probe's shipped status: covered | dismissed | backstop | unresolved, which smuggled a verification fact (backstop) into a lifecycle enum. Migration is mechanical (covered → {resolved, explicit}, backstop → {resolved, backstop}, others straight across); coverage.resolved is preserved count-for-count — it remains the closed set (resolved + dismissed status = applicable − unresolved), so every fixture's count is unchanged, while coverage.byVerification carries the per-tier resolved-status breakdown; the 6 edge fixtures are re-generated on #584. Done now (one adapter) rather than against a fixture-locked enum later.
    • 7b — Core ingests already-proposed items. The seam is analyzeCoverage(items, resolutions?, validators), not (requirements, proposeFn, …). The probes have different deterministic surfaces — edge = deterministic propose (proposeEdges) + LLM resolve; prohibition = LLM propose (adversarial recall) + deterministic validate/merge/rollup — so core MUST NOT assume propose is deterministic. proposeEdges stays in the edge adapter; candidate-parsing stays in the prohibition adapter.
    • 7c — Hybrid typing. Generic type params on the exported interfaces for adapter-authoring DX, but the load-bearing enforcement is injected runtime validators ({ categories, verification, requiredFieldsByVerification }), because the probe runs as a CLI over JSON where TS types are erased. The contract test (Decision 5) pins the validators, not the types.
    • 7d — Rollup carries a verification tally. CoverageReport.coverage gains byVerification: { <tier>: count }, computed generically in core over items that have a verification set. Verify-phase reads byVerification.judgment as Decision 4's denominator without re-scanning. (Unresolved items carry no tier and are counted by status.)
    • 7e — One bin per probe. Each probe ships its own compiled bin (edge-probe.cjs, prohibition-probe.cjs) calling a shared runProbeCli(...). A single dispatcher CLI is deferred as an independent follow-on: unlike the enum, it is pure invocation plumbing with no migration debt, so it does not justify enlarging the #584 blast radius.

    Indicative probe-core surface:

    export type Status = 'resolved' | 'dismissed' | 'unresolved';
    export interface Item<TCat extends string = string, TVer extends string = string> {
      requirement_id: string; category: TCat; status: Status;
      verification: TVer | null; resolution: string | null; reason: string | null;
    }
    export interface CoverageReport {
      items: Item[];
      coverage: { applicable: number; resolved: number; unresolved: number; byVerification: Record<string, number> };
    }
    export interface ProbeValidators {
      categories: ReadonlySet<string>; verification: ReadonlySet<string>;
      requiredFieldsByVerification?: Record<string, ReadonlyArray<keyof Item>>;
    }
    export function analyzeCoverage(items: Item[], resolutions: Resolution[] | undefined, v: ProbeValidators): CoverageReport;
    export function validateResolution(r: Resolution, items: Item[], v: ProbeValidators): void;
    export function validateItems(items: Item[], v: ProbeValidators): void;
    export function runProbeCli(opts: { ingest: (argv: string[]) => { items: Item[]; resolutions?: Resolution[] }; validators: ProbeValidators }): never;
    

Consequences

  • Positive: truths keeps its positive-observable semantics; prohibitions get a first-class home with a real verification lifecycle; CI is honest (tests the contract, never fakes the model's judgment as green); no fake-green and no gutted autonomy; the secure-phase boundary is explicit and de-duplicated; the resolution model lives in one place, so the third probe is nearly free and verification/status cannot drift across the ≥6 must_haves read sites.
  • Costs / required work (on #584): extract probe-core.cts and refactor edge-probe.cts onto it; re-cut the status enum into status × verification and re-generate the 6 edge fixtures. On the #644 PR: add the prohibition reference + the must_haves.prohibitions: block and its parseMustHavesBlock extension; the DEFECT.GENERATIVE-FIX parity assertion across template ↔ parser ↔ planner (owned by the #644 author); extend the SUMMARY/verdict schema with the unverified-prohibition flag; teach verify-phase the test-tier wiring and judgment-tier mode-dependent handling. A deterministic projectProhibitions() in probe-core is recommended so the parity assertion tests a function's round-trip rather than a prompt; every existing must_haves read site must tolerate the new block (absence = no prohibitions).
  • Docs debt: spec-phase is undocumented in FEATURES.md/COMMANDS.md despite shipping in v1.38; the docs-required gate bites both probes. PR #584 establishes the spec-phase docs section.
  • Glossary: probe family, probe-core, verification tier, and bespoke vs canon prohibition are added to CONTEXT.md when #584 merges (not while the contract is pre-merge); this ADR is their interim home.
  • Sequencing: PR #584 lands ADR 550 + probe-core + the edge-probe.cts refactor + enum re-cut + fixture re-gen + the spec-phase docs section. #644 then adds the prohibition adapter (reference + prohibitions: block/parser/parity + tiered verification). #644 stays blocked until #584 merges.

Cross-reference: ADR-857 capability boundary (2026-06-12)

The capability system (ADR-857) classifies predicate-generation as core verification substrate, not an off-by-default Feature Capability — settled before ADR-857's phase-6 (Migrate) freezes the core/plug-in line. This pins where the probe family sits relative to the loop:

  • The verifier↔predicate contract — the verifier always expects must-NOT-have / edge predicates and grades exogenously against them — is core and non-toggleable. Verifier reach = spec reach: the predicates are the reach of the spec the verifier verifies, so the contract cannot live in an off-by-default plug-in without making core reliability a function of an optional capability (ADR-857 decision #6 blast-radius rule). ADR-857's produces/consumes artifact flow (its decision #6) is the internal rail carrying predicates into the core verifier; in this ADR's terms that rail is the SPEC.md ↔ must_haves representation/projection (Decision 3, round-tripped per Decision 5).
  • "Exogenous grading" is the verify-time half of this ADR's judgment-tier (Decision 4): the autonomous verifier records an LLM-judge verdict marked non-authoritative and flags "unverified prohibition — human review recommended" — grading against the spec's explicit predicates, never a self-graded restatement (the prose-drift / self-graded-review failure modes reproduced on #664). The judgment-tier soft-gate is the contract's "never a silent pass" guarantee at the core boundary.
  • The probe adapters are the core-default generator — edge-probe's classifyShape/proposeEdges and the prohibition probe's adversarial LLM-propose, the surfaces that propose predicates — default-on and non-removable, but independently versionable. The classifier's measured recall gap (the prose→shape under-fire on terse prose) is the reason they stay their own modules under this ADR rather than being folded into the slow core rail. probe-core is not the generator: per Decision 7b it ingests already-proposed items, and its deterministic validators are the contract's CI-testable surface (Decision 5) — so probe-core sits on the contract side, the adapters on the generator side. ADR-857 phase 6 wires these modules onto the core predicate rail; it does not relabel them as a capabilities/edge-probe/ plug-in.
  • Decision 5's rule — the CI-testable surface is the contract, not the classifier — extends to ADR-857's core rail: the deterministic conformance test is the contract shape the verifier consumes, never the LLM's judgment.

Addendum (2026-06-12; updated 2026-06-15): test-tier disposition — fail-closed safety half (#644) + enforcement half LANDED (#1259)

Decision 4 describes the test-tier as a "Hard gate in both interactive and autonomous modes." The #644 implementation revises that to a fail-closed-now / deferred-enforcement resolution (the "B-with-guard" maintainer decision of 2026-06-12), so the architecture-of-record matches the shipped code:

  • A well-formed but unwired test-tier prohibition resolves via dispositionForProhibition() to { status: 'unverified', flagged: true } — provably never green without explicit evidence (REQ-PROHIB-06). This is the load-bearing safety half and it holds today.
  • The negative-test enforcement mechanism — locating the wired mechanical check, running it for a genuine non-vacuous pass, and building the enforcementEvidence that flips a passing test-tier item green — landed in #1259 as the deterministic check prohibition-enforcement sub-command (authored as src/prohibition-enforcement.cts, compiled by build:lib to the gitignored gsd-core/bin/lib/prohibition-enforcement.cjs). It accepts BOTH wired-check kinds — a node --test negative test (requiring a real reported test, not the empty file node --test would count as one passing "test") OR a lint/AST rule run through the project flat config as eslint --format json filtered by ruleId (so plugin rules like local/* load — bare --rule cannot) — and is anchored on the in-tree local/no-source-grep rule (dogfooding the existing must-NOT proof, ADR-550 D4; the #644 corpus had zero authored test-tier prohibitions, so no contrived consumer was minted). A passing wired check disposes green; a missing, non-attested, or genuinely-non-passing check hard-gates (flagged, non-green) in both interactive and autonomous modes.
  • Honest scope — failFirst is caller-ATTESTED, not machine-proven (tracked follow-up). What #1259 lands is the execution + non-vacuous-pass half: the producer requires the caller to attest failFirst: true and requires the check to genuinely run and pass. It does not yet independently prove the check fails-on-violation (the literal regression-must-fail-first property), because cheap proof of that at verify time needs running the check against a known violation fixture — deferred as a follow-up (#1279; the descriptor auto-locate half is #1278). Until then the red-first property rests on caller attestation, surfaced transparently in the evidence record. This closes the permanent-gaps_found dead-end with a genuinely-executed gate without overclaiming machine-proven fail-first.

Net effect on D4: the guarantee ("a test-tier prohibition is never a silent pass") was preserved through the fail-closed-now half and is now joined by the genuine-execution half — a test-tier prohibition with a passing, non-vacuous wired check can reach green/passed, and a missing/failing one hard-gates. The previously-unreachable green branch in dispositionForProhibition() is reachable from the live pipeline, and the fail-closed default backs every miss/fail. The one remaining gap to D4's literal intent — machine-proven fail-first — is documented above as a tracked follow-up. The decision also lives in src/probe-core.cts comments, src/prohibition-enforcement.cts, verify-phase.md, and the #644 / #1259 changesets.

This enforcement seam is the concrete instance of ADR-857 open-question §147 — the deferred "deterministic CI conformance test for the verifier↔predicate contract." Per D6 it lands on the core verify rail (non-toggleable substrate), never in capabilities/: the verifier consuming a contract-shaped, deterministic predicate is core, not an opt-in capability.

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.

  1. The D3 item shape gains OPTIONAL flat-scalar keys. Alongside statement, status+verification, and the dismissed-only reason, a resolved test-tier prohibition MAY carry check_kind (node-test | lint-rule), check_target, and check_rule (lint-rule only). All three are optional; a descriptor-less prohibition parses and disposes byte-identically to today (backward compatibility, CHK-07).

  2. Flat scalars — NOT a nested check:{} object (load-bearing). The representation is three flat scalar keys, never a nested object. The shared parseMustHavesBlock (src/frontmatter.cts:252) is a flat parser: continuation lines under a list item are handled only as nested ARRAYS or scalar key: value pairs, and the reconstructFrontmatter serializer is deliberately lossy for nested object-lists. A nested check:{} would flatten its keys into the parent item and mangle the round-trip. We follow the #644 precedent — no parser rewrite, no parseMustHavesBlock change — so the truths/artifacts/key_links shared-parser readers stay regression-free (the untouched frontmatter suite is the proof).

  3. Deterministic projection + read-back. projectProhibitions (src/probe-core.cts) emits the scalar keys ONLY for a well-formed descriptor (valid check_kind + non-empty check_target; check_rule only on the lint-rule path). descriptorFromProjection (src/prohibition-enforcement.cts) reads them back into a { kind: check_kind, target: check_target, rule?: check_rule } CheckDescriptor to build the producer request. The CheckDescriptor type itself is unchanged; failFirst is NOT sourced from the projection — it stays a verify-time caller attestation. verify-phase locates from the projection, so a wired passing test needs no hand-authored descriptor.

  4. Fail-closed on partial / invalid / absent descriptor. A lint-rule descriptor missing rule, an unknown kind, or an absent descriptor on a test-tier prohibition MUST fall through to the producer's existing fail-closed locate ("no well-formed check descriptor is locatable → fail-closed") — never a silent green. The producer's locate semantics from #1259 are unchanged.

  5. Out of scope (unchanged boundaries). Machine-proven fail-first (a violation-fixture / RuleTester-invalid proof replacing the failFirst caller attestation) stays tracked as #1279. The dispositionForProhibition green/fail-closed policy is untouched. No new check kinds are added.

Net effect on D3: the prohibition-item shape is extended with three optional, backward-compatible flat-scalar keys that give the test-tier locate a deterministic spec-phase source; the contract's CI-testable surface (D5) gains the projection round-trip parity (CHK-03), the fail-closed guard (CHK-06), and the byte-stable backward-compat fixture (CHK-07). The decision also lives in src/probe-core.cts / src/prohibition-enforcement.cts comments, the verify-phase.md / spec-phase.md prose, and the #1278 changeset.