Adds the spec-time prohibition probe (spec-phase Step 5.6) — the second adapter of the probe-core resolution model. Surfaces unwritten must-NOT constraints as negative SPEC acceptance criteria with test/judgment verification tiers; fail-closed at verify time. Per ADR-550. Closes #644.
17 KiB
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-docssession, and extended with aprobe-coreseam designed in an/improve-codebase-architecturegrill (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 theprobe-coreextraction 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 againstnext+ 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:
truthsare positive, observable assertions.gsd-plannerderives them as "observable truths (3-7, user perspective)";verify-phasechecks each by "determine if the codebase enables it" — a presence/wiring check. A prohibition is the inverse and cannot be confirmed that way.truthsare not mechanically verified.verify.cjsiteratesartifactsandkey_linksbut has notruthshandler. A prohibition parked intruthswould inherit that non-enforcement — a negative wearing a positive's clothing.must_haveshas no schema/type — a convention parsed byparseMustHavesBlock(blockstruths/artifacts/key_links), read in ≥6 places.- The SPEC→plan lift is LLM reasoning in
gsd-planner'sderive_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 tobin/lib/edge-probe.cjsand invoked byspec-phaseStep 5.5 at runtime. ~120 of its lines are a generic resolution model (status lifecycle, validation, merge/rollup, CLI); only theShape/SHAPE_CUES/TAXONOMY/classifyShape/proposeEdgescluster 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_havesevolution.
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
-
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) aspec-phase.mdstep run as a soft gate (write-anyway-with-flags), with explicit--autoand text-mode (non-Claude, noAskUserQuestion) handling, mirroring the ambiguity gate; (c) tests + docs. -
Two-stage protocol. Recall (adversarial over-generation) → precision (classifier dropping routine engineering items), surfacing a short confirmable list. Dismissals require a non-empty reason.
-
Prohibition home & representation. Confirmed prohibitions live primarily as
SPEC.mdacceptance criteria (negative criteria). They MAY be projected into an optionalmust_haves.prohibitions:sibling block (alongsidetruths/artifacts/key_links) when they must survive into the plan contract.truthsis left untouched — nopolarityfield is added. Each prohibition item carriesstatement, the orthogonalstatus+verificationof Decision 7, and (when dismissed) a non-emptyreason. -
Tiered verification. Each prohibition declares
verification: test | judgment.test-tier (reducible to a deterministic assertion) → a negative test followingregression-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.
-
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 (statementpresent;status ∈ {resolved, dismissed, unresolved};verificationin the probe's allowed set; dismissed items carry a non-emptyreason); (b) round-trip / projection betweenSPEC.mdandmust_haves.prohibitions:; (c) aDEFECT.GENERATIVE-FIXparity 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 perRULESET.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 themust_haves.prohibitions:block, projection, and verify-phase tiering and are #644 scope, as the Consequences section records.) -
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). -
probe-coreis the seam; probes are adapters. The shared resolution model is extracted intosrc/probe-core.cts;edge-probe.ctsrefactors 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) andverification: <probe-defined> | null(edge:explicit | backstop; prohibition:test | judgment). This replaces edge-probe's shippedstatus: 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.resolvedis preserved count-for-count — it remains the closed set (resolved+dismissedstatus =applicable − unresolved), so every fixture's count is unchanged, whilecoverage.byVerificationcarries the per-tierresolved-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.proposeEdgesstays 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.coveragegainsbyVerification: { <tier>: count }, computed generically in core over items that have a verification set. Verify-phase readsbyVerification.judgmentas Decision 4's denominator without re-scanning. (Unresolved items carry no tier and are counted bystatus.) - 7e — One bin per probe. Each probe ships its own compiled bin (
edge-probe.cjs,prohibition-probe.cjs) calling a sharedrunProbeCli(...). 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-coresurface: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; - 7a — Orthogonal model. The item carries two orthogonal dimensions:
Consequences
- Positive:
truthskeeps 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 andverification/statuscannot drift across the ≥6must_havesread sites. - Costs / required work (on #584): extract
probe-core.ctsand refactoredge-probe.ctsonto it; re-cut the status enum intostatus × verificationand re-generate the 6 edge fixtures. On the #644 PR: add the prohibition reference + themust_haves.prohibitions:block and itsparseMustHavesBlockextension; theDEFECT.GENERATIVE-FIXparity assertion across template ↔ parser ↔ planner (owned by the #644 author); extend the SUMMARY/verdict schema with theunverified-prohibitionflag; teach verify-phase the test-tier wiring and judgment-tier mode-dependent handling. A deterministicprojectProhibitions()inprobe-coreis recommended so the parity assertion tests a function's round-trip rather than a prompt; every existingmust_havesread site must tolerate the new block (absence = no prohibitions). - Docs debt:
spec-phaseis undocumented inFEATURES.md/COMMANDS.mddespite 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, andbespoke vs canon prohibitionare added toCONTEXT.mdwhen #584 merges (not while the contract is pre-merge); this ADR is their interim home. - Sequencing: PR #584 lands ADR 550 +
probe-core+ theedge-probe.ctsrefactor + 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/consumesartifact flow (its decision #6) is the internal rail carrying predicates into the core verifier; in this ADR's terms that rail is theSPEC.md↔must_havesrepresentation/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'sclassifyShape/proposeEdgesand 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-coreis not the generator: per Decision 7b it ingests already-proposed items, and its deterministic validators are the contract's CI-testable surface (Decision 5) — soprobe-coresits 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 acapabilities/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): test-tier disposition — fail-closed now, heavy enforcement deferred
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 viadispositionForProhibition()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 heavy negative-test enforcement mechanism — a contrived
test-tier consumer that mechanically runs the negative test — is deferred to a follow-up, because the #644 corpus is entirelyjudgment-tier and wiring a synthetic test-tier consumer now would be gold-plating. The fail-closed disposition guards the gap in the meantime: the gate cannot silently pass, it can only reportunverified/flagged until enforcement lands.
Net effect on D4: the guarantee ("a test-tier prohibition is never a silent pass") is preserved exactly; what is deferred is only the mechanical-pass half of the gate, not the never-green safety property. This addendum supersedes the unqualified "hard gate" wording of Decision 4 for test-tier items until the enforcement follow-up lands. The decision also lives in src/probe-core.cts comments, verify-phase.md, and the #644 changeset.