From eff93c9e28d8e7968535e492864b4bfceb5e2e40 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:30:58 +0100 Subject: [PATCH 1/6] docs: add prefer-sota principle (adversary-filtered SOTA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codify the golden rule as a first-class abcd principle: prefer state-of-the-art for design/tooling/practice decisions, but never adopt SOTA that violates the repo's preferences — an adversary reviewer challenges SOTA for repo fit first, then a sota-researcher verdict accounts for that challenge, then the pick. A propagated default for all abcd-managed repos (the "opinionated" in "opinionated configuration"); its managed-repo home is the AGENTS.md conventions section abcd installs. Assisted-by: Claude:claude-opus-4-8 --- .abcd/development/principles/prefer-sota.md | 69 +++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .abcd/development/principles/prefer-sota.md diff --git a/.abcd/development/principles/prefer-sota.md b/.abcd/development/principles/prefer-sota.md new file mode 100644 index 00000000..18156f98 --- /dev/null +++ b/.abcd/development/principles/prefer-sota.md @@ -0,0 +1,69 @@ +# Prefer SOTA + +**The rule.** The state of the art is the default bar for every design, +tooling, or practice decision: where a choice has a credible SOTA answer, that +answer is the presumptive pick. But "it is SOTA" is never on its own sufficient +warrant to adopt. Before adoption, SOTA is challenged for *fit* against this +repo's stated preferences by an adversarial reviewer whose sole job is to find +where the generic best practice collides with a deliberate local constraint. +Only the SOTA that survives that challenge — or is re-selected in light of it — +is adopted. When the best option is not obvious, the sequence is fixed and +ordered: adversarial fit-challenge first, then a SOTA verdict that already +accounts for the challenge, then the pick. + +**Why.** abcd is an *opinionated* configuration layer for development — its +value is the set of considered defaults it holds and propagates, not raw +novelty. SOTA is the right starting bar because it encodes the field's +accumulated learning cheaply; refusing it reinvents. But adopting SOTA +unfiltered is the opposite failure: a generic best practice can directly +violate a load-bearing local choice (host-delegated LLM work, minimal +dependencies, host-agnostic prose, a single source of truth). Running the +fit-challenge *before* the verdict is what stops "it's SOTA" from laundering a +preference violation — the adversary supplies the repo's own objections as an +input to the recommendation, so the verdict is fit-aware rather than fit-blind. +This is the opinion in "opinionated configuration": abcd does not chase novelty, +and it does not ossify — it takes the field's best and earns each adoption +against what the repo already believes. + +**Bounds.** + +- Binds on genuine forks — a design, tooling, or practice decision where the + best option is not obvious. Trivial or fully-specified choices with a + conventional default state the assumption in one line and proceed; they do + not summon the process. +- The adversary challenges *fit*, not SOTA's abstract correctness: its output + is the enumerated places generic SOTA would trample a *named* local + preference, plus the surviving-fit hypothesis. The SOTA verdict then ranks + options already filtered through that challenge; it never reports raw SOTA as + the answer. +- A local preference can itself be wrong. When the challenge exposes a + constraint that survives only by inertia, the response is to revisit it + deliberately — an ADR under [`../decisions`](../decisions) — never to + silently keep it and never to silently break it. +- Composes with [evaluator-outside-the-loop](evaluator-outside-the-loop.md) + (the challenger is independent of whoever proposes the option) and + [verifier-selects-gates-decide](verifier-selects-gates-decide.md): the SOTA + verdict is a proposal; the human's adoption is the gate. + +**Scope.** This is a propagated default, not a repo-local habit — it holds for +every abcd-managed repo, as one of the opinionated conventions abcd installs. +Its home in a managed repo is the marked working-conventions section abcd writes +into that repo's `AGENTS.md`; abcd's own record carries the normative statement +here. + +**Live instance.** Choosing how to wire the iss-35 semantic cross-check as a +release gate: an adversarial reviewer challenged generic release-engineering +SOTA (policy-as-code, release-automation tooling, an LLM judge in CI, an +executable gate target) against this repo's host-delegated-LLM, +minimal-dependency, single-source-of-truth, host-agnostic preferences, and only +the fit-surviving form was taken to a SOTA verdict for adoption. + +**Promotion.** MVP: a documented decision protocol — this principle, plus the +standing model-routing note naming which agents fill the challenger and +researcher roles. Tool: an abcd verb that scaffolds the adversary → SOTA-verdict +pass for a decision and records the challenge and verdict as a dated research +note beside the ADR it informs, so the fit-filter is a cheap repeatable step +rather than a remembered discipline. The managed-repo propagation path (the +`AGENTS.md` conventions section abcd installs) is itself the tool rung for +reach: the principle ships as an abcd default rather than being re-derived per +repo. From d8714b61656db9848a38a9fd41a1c3d4d12e7a99 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:34:17 +0100 Subject: [PATCH 2/6] docs: record iss-35 semantic release-gate design (VSA, task 5) The SOTA-verified design for the semantic (Direction-A) half of the iss-35 graduation, reached via the prefer-sota process (adversary fit-challenge then sota-researcher verdict). The fit-surviving form is a Verification Summary Attestation: a host-run judge emits a signed, sha-keyed PROMOTE/HOLD receipt; a fail-closed record-lint rule verifies it before a tag. Phased build recorded; phases touching signing/release run through security-reviewer first. Assisted-by: Claude:claude-opus-4-8 --- .../2026-07-11-iss35-semantic-release-gate.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 .abcd/development/plans/2026-07-11-iss35-semantic-release-gate.md diff --git a/.abcd/development/plans/2026-07-11-iss35-semantic-release-gate.md b/.abcd/development/plans/2026-07-11-iss35-semantic-release-gate.md new file mode 100644 index 00000000..9720e82e --- /dev/null +++ b/.abcd/development/plans/2026-07-11-iss35-semantic-release-gate.md @@ -0,0 +1,110 @@ +# iss-35 semantic release gate — SOTA-verified design (Option C, task 5) + +**Status:** design recorded 2026-07-11, maintainer signed off "full SOTA incl. +signing". Reached via the [prefer-sota](../principles/prefer-sota.md) process: +an adversary challenged generic release-engineering SOTA for repo fit, then a +`sota-researcher` verdict was taken on the fit-surviving hypothesis. This doc is +the design of record for the semantic (Direction-A) half of the iss-35 +graduation — the deterministic half shipped as the `surface_coverage` record-lint +rule. + +## The decision + +Wire the LLM brief↔surface cross-check (the semantic pass that verifies brief +*prose* still matches *binary behaviour* — flags, exit codes, schema fields, +counts) as a standing, enforced pre-tag release gate. It cannot run in CI +(GitHub Actions has no model access); the repo's enforcement plane is +deliberately split — deterministic gates in CI (`release.yml`), semantic gates +in the host/preflight environment where a model is present. + +## Why this form (adversary → SOTA verdict) + +The adversary rejected, each for a named repo preference: + +- **policy-as-code (OPA/Rego)** — a new language + runtime (ask-first) and a + second lint engine beside the one Go engine the repo paid to consolidate onto. +- **release-please / semantic-release** — bot commits to a branch, which + `release.yml`'s no-branch-commit tripwire fails closed on; and it duplicates + the adr-31 derived-versioning engine. +- **LLM-judge-in-CI** — violates host-delegated-by-default, needs a model key in + a tag-triggered workflow (trust-boundary expansion), and is a non-convergent + (flaky) blocking gate. +- **a naive `make release-gate`** — manufactures a false green: deterministic + gates pass, the model gate silently no-ops, exit 0. + +The `sota-researcher` verdict on the fit-surviving hypothesis: it is a +**Verification Summary Attestation (VSA)** — a named SLSA pattern. The VSA spec +*explicitly* decouples the verifier from the builder and sanctions verification +"at build time, at upload time, at download time, or via continuous monitoring" +by "a separate trusted party" — a host-sited verifier emitting a receipt that +release-time *checks* is the canonical VSA topology, not a workaround. The +closest exemplar is SLSA AMPEL's `prerelease` policy (validates prior VSAs +rather than re-running checks). So the design is SOTA-aligned; the only delta +from full SOTA is signing the receipt for unforgeability — which this repo +already has the primitive for (it runs SLSA build-provenance attestation in +`release.yml`). + +## The design + +Four parts, from the verdict's ranked recommendations (R1–R4): + +1. **Semantic-pass receipt = a VSA-shaped predicate** (R1, R3). A JSON record + keyed to the release commit digest, using the VSA field shape so it is + recognisable and upgradeable: `subject.digest` (the commit sha), + `verifier` (identity), `timeVerified`, `verificationResult` (PROMOTE/HOLD), + `policy`/detector version, plus the eval-standard fields — pinned + **judge-model snapshot** (e.g. `claude-opus-4-8`, never a floating alias), + judge-prompt hash, brief version, per-category result, failing-example ids. +2. **Fail-closed verification before tag** (R1, R4). A Go `record-lint` rule + (the existing engine, zero new deps) asserts a receipt exists for `HEAD`'s sha + **with `verificationResult: PROMOTE`**; absent or HOLD ⇒ BLOCK. The *gate* is + deterministic (receipt present + verdict) even though the *judge* that + produced it is stochastic — the stochastic judgement stays upstream in the + host, never re-derived at tag time. +3. **Runbook↔`release.yml` lockstep** (R2). The dev-facing pre-tag runbook and + the CI workflow must both stay human-authored (neither is a clean projection + of the other), so a consistency lint fails when their gate lists diverge — + the repo's golden-check anti-drift pattern (`gofmt -l`, `go generate` + + `git diff --exit-code`). +4. **Signed receipt (unforgeability)** (R1 caveat, maintainer chose full SOTA). + A committed sha-keyed file is trust-on-write — any committer can forge a + PROMOTE. Sign the receipt with the SLSA attestor already used in + `release.yml` (Sigstore/cosign-class), and verify the signature in-Go at gate + time — never with a Rego/CUE policy (that re-imports the rejected + new-language cost). + +## Homes (host-agnostic, dev-tier) + +- **Detector** (the LLM cross-check workflow, host-run, never CI): committed as + a dev-record artifact under `.abcd/development/` (excluded from the release + artifact), sanitised first — drop the hardcoded absolute local path + (privacy rule) and the stale skills→commands ground-truth (abcd ships zero + skills). It is invoked host-side via the agent harness, not `make`/CI. +- **Receipts**: sha-keyed, under a committed work-tier path (candidate + `.abcd/work/reviews/`), subject to the RD001–003 reviews charter. +- **Runbook**: a maintainer how-to **outside `docs/`** (developer-facing → + `.abcd/development/`), host-agnostic (docs-lint applies), referencing + `release.yml` as the source of truth for the deterministic half and owning + only the semantic gates. + +## Phased build (each phase its own gate) + +1. Sanitise + commit the detector to its dev-tier home; author the runbook + (owns semantic gates, references CI for the rest). No enforcement yet — mark + the not-yet-armed enforcement per [loud-staging](../principles/loud-staging.md). +2. Receipt schema (VSA-shaped, R3 fields) + the fail-closed `record-lint` + verification rule (TDD, mirror `surface_coverage`); arm it and watch it fire + on a missing/HOLD receipt. +3. Runbook↔`release.yml` lockstep consistency lint (TDD). +4. Receipt signing + in-Go signature verification; wire the release-time check + into `release.yml`. + +**Trust boundary:** phases 2–4 touch signing, attestation verification, and the +release workflow — the `security-reviewer` agent runs before presenting, and a +BLOCK verdict stops the change (per AGENTS.md). + +## What this is NOT + +Not an in-CI LLM judge, not policy-as-code, not a release-automation tool, not a +`make` target that re-runs the judge. The judge runs once, host-side; the gate +verifies its signed evidence. From 314ac4b207f2d27a5e5efdb53b02ca652b8d08e6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:42:18 +0100 Subject: [PATCH 3/6] docs: release-gate runbook + sanitised semantic detector (iss-35 task 5 ph1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the semantic release gate (design: plans/2026-07-11-iss35-semantic- release-gate.md). Adds the dev-tier release-gate home: - README.md: the pre-tag runbook. Enumerates both enforcement planes — deterministic (CI, release.yml is the source of truth) and semantic (host-run agent gates) — and owns the semantic half. The not-yet-armed receipt/lockstep machinery (phases 2-4) is marked loudly per loud-staging, never implied done. - brief-surface-crosscheck.js: the iss-35 Direction-A semantic detector, promoted from ephemeral scratch and sanitised — the hardcoded absolute local path is gone (privacy) and the stale skills ground-truth is corrected (abcd ships zero skills; the surface is commands/abcd/). Host-run harness workflow, never CI. Assisted-by: Claude:claude-opus-4-8 --- .abcd/development/release-gate/README.md | 71 ++++++++++++++++++ .../release-gate/brief-surface-crosscheck.js | 75 +++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 .abcd/development/release-gate/README.md create mode 100644 .abcd/development/release-gate/brief-surface-crosscheck.js diff --git a/.abcd/development/release-gate/README.md b/.abcd/development/release-gate/README.md new file mode 100644 index 00000000..34938010 --- /dev/null +++ b/.abcd/development/release-gate/README.md @@ -0,0 +1,71 @@ +# Release gate — the pre-tag procedure + +Every `v*` release must clear this gate before the tag is pushed. The gate spans +**two enforcement planes**, by design: deterministic checks run in CI (they need +no model); semantic checks run **host-side in the agent harness** (they need a +model, which CI does not have). This runbook is the single human enumeration of +both planes; it owns the semantic half and defers to +[`release.yml`](../../../.github/workflows/release.yml) as the source of truth +for the deterministic half. + +Design of record: [`../plans/2026-07-11-iss35-semantic-release-gate.md`](../plans/2026-07-11-iss35-semantic-release-gate.md). + +## Deterministic gates (CI-enforced) + +The [`release.yml`](../../../.github/workflows/release.yml) `verify` job runs +these, in order, on macOS + Linux. `release.yml` is authoritative; this list is +the human-readable mirror. + +1. Format (gofmt) +2. Build +3. Vet +4. Test +5. Test (race, internal) +6. Record-lint (design-record drift gate) +7. Docs-lint (docs-currency gate) +8. Reviews-charter discipline (RD001-RD003) +9. Smoke every command (self-discovering harness) + +> **Design target — not yet armed.** A `record-lint` rule keeping this list in +> lockstep with `release.yml`'s `verify` steps lands in phase 3 (see the design +> doc). Until it arms, this mirror is kept honest by hand; per +> [`../principles/loud-staging.md`](../principles/loud-staging.md) the gap is +> stated, not hidden. + +## Semantic gates (host-run, before the tag) + +CI cannot run these — they spawn LLM agents. Run them in the agent harness +against the exact commit to be tagged: + +1. **`docs-currency-reviewer`** — verifies every user-facing claim still matches + the code (the semantic complement of `docs lint`; see + [`../brief/04-surfaces/10-docs.md`](../brief/04-surfaces/10-docs.md)). +2. **Brief↔surface cross-check** — [`brief-surface-crosscheck.js`](brief-surface-crosscheck.js), + the Direction-A semantic half of the iss-35 graduation: the brief's surface + *prose* (flags, sub-verbs, exit codes, schema fields, counts) vs. the shipped + binary's actual behaviour. The deterministic Direction-B half is the + `surface_coverage` `record-lint` rule and already runs in CI. + +## Recording the semantic verdict + +Each semantic pass records its outcome as a **signed, commit-sha-keyed receipt** +— a Verification Summary Attestation (VSA) shape carrying `verificationResult` +(PROMOTE / HOLD), the pinned judge-model snapshot, the detector version, and the +failing categories. A `record-lint` rule then verifies, before a tag, that a +PROMOTE receipt exists for `HEAD`'s commit; a missing or HOLD receipt **blocks** +the release (fail-closed — an un-run semantic pass is never a silent pass). + +> **Design target — not yet armed.** The receipt schema + fail-closed verify +> rule land in phase 2, the signing + in-`release.yml` verification in phase 4 +> (see the design doc). Until then the semantic gates are enforced by this +> runbook as maintainer discipline, and that provisional status is stated here +> rather than implied away. + +## Procedure + +1. Land all work on the release commit; open the release the normal way (branch + → PR → merge). The `verify` job gates the merge. +2. On the merged commit, run the two semantic gates above in the harness. +3. Record each verdict as a receipt keyed to that commit's sha. +4. Tag `vX.Y.Z` on the commit. Once the fail-closed verify rule is armed, the + tag is rejected unless every semantic receipt is present and PROMOTE. diff --git a/.abcd/development/release-gate/brief-surface-crosscheck.js b/.abcd/development/release-gate/brief-surface-crosscheck.js new file mode 100644 index 00000000..8a923cf1 --- /dev/null +++ b/.abcd/development/release-gate/brief-surface-crosscheck.js @@ -0,0 +1,75 @@ +// Release-gate semantic detector — the iss-35 brief↔surface cross-check. +// +// This is a HOST-RUN agent-harness workflow, not a CI step and not a standalone +// executable: it spawns LLM checker agents, so it runs in the maintainer's agent +// harness at release time. The deterministic gates run in CI (see +// ../../../.github/workflows/release.yml); this is the semantic half. It reports +// DISCREPANCIES between the design brief's surface prose and the shipped binary's +// actual behaviour; the maintainer records the verdict as a signed, sha-keyed +// VSA-shaped receipt (see this directory's README.md and the design of record, +// ../plans/2026-07-11-iss35-semantic-release-gate.md). +// +// Invoke with args = { briefDocs: [], +// surfaces: [{ name, kind, probe }] }. +export const meta = { + name: 'iss35-brief-surface-crosscheck', + description: 'Bidirectional brief↔surface reconciliation detector (iss-35 semantic gate)', + phases: [ + { title: 'CheckBrief', detail: 'per brief doc: verify every surface claim against the binary/tree' }, + { title: 'CheckSurface', detail: 'per real surface: find its brief home' }, + { title: 'Merge', detail: 'dedup + classify discrepancies' }, + ], +} +const input = typeof args === 'string' ? JSON.parse(args) : args +const FINDINGS = { + type: 'object', additionalProperties: false, + properties: { + item: { type: 'string' }, + discrepancies: { type: 'array', items: { + type: 'object', additionalProperties: false, + properties: { + where: { type: 'string', description: 'file:line or verb/skill name' }, + claim: { type: 'string', description: 'what the record claims (or omits)' }, + reality: { type: 'string', description: 'what the binary/tree actually shows' }, + class: { type: 'string', enum: ['false-claim','undocumented-surface','fictional-layout','criterion-violation','stale-count'] }, + }, required: ['where','claim','reality','class'] } }, + }, required: ['item','discrepancies'], +} +const CTX = `Repo root: the current working directory — use repo-relative paths +throughout, never absolute local paths. Ground truth is the SHIPPED surface, +verified empirically: build the binary (make build produces bin/abcd--) +and run it (\`abcd --help\` and \`abcd --help\`), and list commands/abcd/ +and skills/. abcd currently ships ZERO skills — the whole /abcd: surface is +commands under commands/abcd/ (ahoy, capture, consult, ingest, prepare-this-repo, +docs, history, launch, memory, version); skills/ is empty or absent. The brief's +surface chapters are .abcd/development/brief/04-surfaces/*.md and +05-internals/08-skills.md. Report DISCREPANCIES ONLY — where record and reality +disagree, or one side is missing. A brief row explicitly marked staged (its +Status column is "staged") / probe-only / later-phase is NOT a discrepancy; an +unmarked claim about a surface that does not exist IS. Do not fix anything.` +const briefFindings = input.briefDocs.map(doc => () => agent( + `${CTX}\n\nDirection A. Read ${doc} fully. Extract every checkable claim +about the shipped surface (verbs, sub-verbs, flags, skill names, counts, +file layouts, "abcd ships N ..." statements) and verify each against +reality. Return item="${doc}" and the discrepancy list.`, + { label: `brief:${doc.split('/').pop()}`, phase: 'CheckBrief', schema: FINDINGS })) +const surfFindings = input.surfaces.map(s => () => agent( + `${CTX}\n\nDirection B. The real surface "${s.name}" (${s.kind}) exists: +inspect it (${s.probe}). Search the brief's surface chapters for its +documented home (grep .abcd/development/brief/). If no brief row documents +it — or the brief documents it under a wrong name/shape — that is a +discrepancy. Return item="${s.name}" and the discrepancy list (empty if +properly documented).`, + { label: `surface:${s.name}`, phase: 'CheckSurface', schema: FINDINGS })) +const all = (await parallel([...briefFindings, ...surfFindings])) + .filter(Boolean) +log(`${all.length} checkers returned`) +const merged = [] +const seen = new Set() +for (const r of all) for (const d of r.discrepancies) { + const k = `${d.where}|${d.claim.slice(0,60)}` + if (seen.has(k)) continue + seen.add(k); merged.push({ item: r.item, ...d }) +} +log(`${merged.length} unique discrepancies`) +return { count: merged.length, byClass: merged.reduce((m,d)=>(m[d.class]=(m[d.class]||0)+1,m),{}), discrepancies: merged } From 4318b763c2ae1caec69e650322bcae7c43bb5fa8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:10:54 +0100 Subject: [PATCH 4/6] feat: fail-closed receipt_gate release-time verify rule (iss-35 task 5 ph2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the semantic release gate. Adds the receipt_gate record-lint rule: the fail-closed, release-time verification of the semantic gates (the LLM passes CI cannot run). Armed with a target commit sha, it asserts every required gate has a receipt at .abcd/work/reviews//.json whose subject digest is that commit, whose verificationResult is PROMOTE, and which pins a judge model. Missing / mismatched / malformed / HOLD / model-less all BLOCK — an un-run semantic pass is never a silent pass. Disabled by default in record-lint.json: it must never fire on ordinary PRs/pushes, only at release time against the tagged commit (arming + signing is phase 4). Adds a VSA-shaped receipt.example.json and updates the runbook's staging note (partially armed) per loud-staging. Security-reviewed (trust boundary): initial BLOCK on a fail-open (enabled + empty required_gates passed vacuously) was fixed with a symmetric fail-closed guard plus Commit/gate-name path-component validation; re-review PASS. The review's Finding 2 (sign receipts AND trust-root the arming config, or signing protects nothing) is recorded as a phase-4 obligation in the design doc. TDD: TestReceiptGate, 11 cases. Assisted-by: Claude:claude-opus-4-8 --- .../2026-07-11-iss35-semantic-release-gate.md | 8 +- .abcd/development/release-gate/README.md | 29 +++-- .../release-gate/receipt.example.json | 18 +++ .abcd/record-lint.json | 3 +- internal/core/lint/config.go | 12 ++ internal/core/lint/lint.go | 101 +++++++++++++++- internal/core/lint/lint_test.go | 114 ++++++++++++++++++ 7 files changed, 272 insertions(+), 13 deletions(-) create mode 100644 .abcd/development/release-gate/receipt.example.json diff --git a/.abcd/development/plans/2026-07-11-iss35-semantic-release-gate.md b/.abcd/development/plans/2026-07-11-iss35-semantic-release-gate.md index 9720e82e..8aa51f1f 100644 --- a/.abcd/development/plans/2026-07-11-iss35-semantic-release-gate.md +++ b/.abcd/development/plans/2026-07-11-iss35-semantic-release-gate.md @@ -71,7 +71,13 @@ Four parts, from the verdict's ranked recommendations (R1–R4): PROMOTE. Sign the receipt with the SLSA attestor already used in `release.yml` (Sigstore/cosign-class), and verify the signature in-Go at gate time — never with a Rego/CUE policy (that re-imports the rejected - new-language cost). + new-language cost). **Signing receipts is necessary but not sufficient** (phase-2 + security review, Finding 2): the *arming config* itself (`enabled`, `commit`, + `required_gates` in the in-tree `record-lint.json`) is equally + committer-editable — signed receipts protect nothing if the gate can be + disarmed by flipping `enabled:false` or emptying `required_gates` in-repo. So + phase 4 must source the arming (at minimum `enabled` and the required-gates + list) from the CI workflow / a trust root, not solely the in-tree file. ## Homes (host-agnostic, dev-tier) diff --git a/.abcd/development/release-gate/README.md b/.abcd/development/release-gate/README.md index 34938010..2f159761 100644 --- a/.abcd/development/release-gate/README.md +++ b/.abcd/development/release-gate/README.md @@ -48,18 +48,27 @@ against the exact commit to be tagged: ## Recording the semantic verdict -Each semantic pass records its outcome as a **signed, commit-sha-keyed receipt** -— a Verification Summary Attestation (VSA) shape carrying `verificationResult` +Each semantic pass records its outcome as a **commit-sha-keyed receipt** — a +Verification Summary Attestation (VSA) shape carrying `verificationResult` (PROMOTE / HOLD), the pinned judge-model snapshot, the detector version, and the -failing categories. A `record-lint` rule then verifies, before a tag, that a -PROMOTE receipt exists for `HEAD`'s commit; a missing or HOLD receipt **blocks** -the release (fail-closed — an un-run semantic pass is never a silent pass). +failing categories. Receipts live at `.abcd/work/reviews//.json`; +[`receipt.example.json`](receipt.example.json) is the concrete shape. The +`receipt_gate` `record-lint` rule verifies, before a tag, that each required gate +has a PROMOTE receipt whose subject digest is the target commit and which pins a +judge model; a missing, mismatched, malformed, HOLD, or model-less receipt +**blocks** the release (fail-closed — an un-run semantic pass is never a silent +pass). -> **Design target — not yet armed.** The receipt schema + fail-closed verify -> rule land in phase 2, the signing + in-`release.yml` verification in phase 4 -> (see the design doc). Until then the semantic gates are enforced by this -> runbook as maintainer discipline, and that provisional status is stated here -> rather than implied away. +> **Partially armed.** The receipt schema + the fail-closed `receipt_gate` verify +> rule exist and are tested (phase 2), but the rule is **disabled by default** — +> it must never fire on ordinary PRs/pushes, only at release time against the +> tagged commit. Arming it in `release.yml` (with the tagged sha) and +> **cosign-signing** the receipt with in-`release.yml` signature verification is +> phase 4; the runbook↔`release.yml` lockstep lint is phase 3. Until those land, +> the release-time verification is enforced by this runbook as maintainer +> discipline. That provisional status is stated per +> [`../principles/loud-staging.md`](../principles/loud-staging.md), not implied +> away. ## Procedure diff --git a/.abcd/development/release-gate/receipt.example.json b/.abcd/development/release-gate/receipt.example.json new file mode 100644 index 00000000..b610e11b --- /dev/null +++ b/.abcd/development/release-gate/receipt.example.json @@ -0,0 +1,18 @@ +{ + "_comment": "Example semantic-pass receipt (a Verification Summary Attestation). One file per gate, stored at .abcd/work/reviews//.json. The receipt_gate record-lint rule verifies, at release time, that subject.digest.gitCommit matches the target commit, verificationResult is PROMOTE, and judgeModel is a pinned snapshot (never a floating alias). A missing, mismatched, malformed, HOLD, or model-less receipt fails the release closed. In phase 4 the receipt is cosign-signed and the signature verified in-Go.", + "subject": { + "digest": { "gitCommit": "0123456789abcdef0123456789abcdef01234567" } + }, + "verifier": { "id": "maintainer@release" }, + "timeVerified": "2026-07-11T12:00:00Z", + "verificationResult": "PROMOTE", + "judgeModel": "claude-opus-4-8", + "policy": { + "detector": "iss35-brief-surface-crosscheck", + "version": "1", + "promptHash": "sha256:…", + "briefVersion": "…" + }, + "categories": { "false-claim": 0, "undocumented-surface": 0, "fictional-layout": 0, "criterion-violation": 0, "stale-count": 0 }, + "failing": [] +} diff --git a/.abcd/record-lint.json b/.abcd/record-lint.json index e9885e9b..d1189eca 100644 --- a/.abcd/record-lint.json +++ b/.abcd/record-lint.json @@ -23,7 +23,8 @@ "intent_lifecycle": {"enabled": true, "severity": "blocker", "intents_dir": "intents"}, "persona_registry": {"enabled": true, "severity": "blocker", "registry": ".abcd/development/personas.json"}, "context_status_free": {"enabled": true, "severity": "blocker", "target": ".abcd/work/CONTEXT.md"}, - "surface_coverage": {"enabled": true, "severity": "blocker", "commands_dir": "commands/abcd", "skills_dir": "skills", "registry": ".abcd/development/brief/04-surfaces/README.md"} + "surface_coverage": {"enabled": true, "severity": "blocker", "commands_dir": "commands/abcd", "skills_dir": "skills", "registry": ".abcd/development/brief/04-surfaces/README.md"}, + "receipt_gate": {"enabled": false, "severity": "blocker", "receipts_dir": ".abcd/work/reviews", "required_gates": ["docs-currency-reviewer", "iss35-brief-surface-crosscheck"]} }, "exempt_paths": [".abcd/development/research/", ".abcd/development/intents/superseded/"], "exempt_if_status": ["superseded"] diff --git a/internal/core/lint/config.go b/internal/core/lint/config.go index 2808560b..7c1f705e 100644 --- a/internal/core/lint/config.go +++ b/internal/core/lint/config.go @@ -78,6 +78,18 @@ type RuleConfig struct { // Patterns is the context_status_free line-match regexp list; when empty the // rule falls back to contextStatusDefaultPatterns. Patterns []string `json:"patterns"` + // ReceiptsDir is the receipt_gate directory of sha-keyed semantic-pass + // receipts (VSA-shaped JSON), repo-relative (default .abcd/work/reviews). + // Outside Roots. + ReceiptsDir string `json:"receipts_dir"` + // RequiredGates lists the semantic gates that must each have a PROMOTE receipt + // for the target commit before a release (e.g. docs-currency-reviewer, + // iss35-brief-surface-crosscheck). + RequiredGates []string `json:"required_gates"` + // Commit is the receipt_gate target commit sha whose receipts are verified. + // Release-time input (release.yml supplies the tagged commit); empty while the + // rule is disabled for ordinary development. + Commit string `json:"commit"` } // LoadConfig reads and decodes a record-lint config file. diff --git a/internal/core/lint/lint.go b/internal/core/lint/lint.go index 88cab573..093ae9e5 100644 --- a/internal/core/lint/lint.go +++ b/internal/core/lint/lint.go @@ -5,6 +5,7 @@ package lint import ( + "encoding/json" "os" "path/filepath" "regexp" @@ -41,7 +42,11 @@ var ( intentIDRe = regexp.MustCompile(`itd-\d+`) intentFileRe = regexp.MustCompile(`^itd-\d+.*\.md$`) // Surface registry Command cell: the bare "/abcd" top-level, or "/abcd:". - surfaceCmdRe = regexp.MustCompile(`^/abcd(?::([a-z0-9-]+))?$`) + surfaceCmdRe = regexp.MustCompile(`^/abcd(?::([a-z0-9-]+))?$`) + // receipt_gate arming inputs are release-time and become externally supplied + // (release.yml) — validated as safe path components before use. + receiptShaRe = regexp.MustCompile(`^[0-9a-f]{7,64}$`) + receiptGateRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) specIDRe = regexp.MustCompile(`^spc-`) supersededRe = regexp.MustCompile(`^itd-\d+`) intentBuckets = map[string]bool{ @@ -163,6 +168,18 @@ func Lint(cfg Config, repoRoot string) ([]Finding, error) { findings = append(findings, sc...) } + // receipt_gate is the release-time verification of the semantic gates. It is + // disabled for ordinary development (a commit under review has no receipt yet) + // and armed only at release time with a target commit; it reads sha-keyed + // receipts outside cfg.Roots, so it also runs once here. + if rgCfg, ok := cfg.Rules["receipt_gate"]; ok && rgCfg.Enabled { + rg, err := checkReceiptGate(repoRoot, rgCfg) + if err != nil { + return nil, err + } + findings = append(findings, rg...) + } + sortFindings(findings) return findings, nil } @@ -544,6 +561,88 @@ func parseSurfaceCommand(cell string) (string, bool) { return m[1], true } +// receipt is the parsed shape of a semantic-pass receipt — a Verification +// Summary Attestation (VSA): only the fields the release gate checks. +type receipt struct { + Subject struct { + Digest struct { + GitCommit string `json:"gitCommit"` + } `json:"digest"` + } `json:"subject"` + VerificationResult string `json:"verificationResult"` + JudgeModel string `json:"judgeModel"` +} + +// checkReceiptGate is the fail-closed, release-time verification of the semantic +// gates (the LLM passes CI cannot run). For the target commit it asserts that +// every required gate has a receipt whose subject digest is that commit, whose +// verdict is PROMOTE, and which pins a judge model. A missing, mismatched, +// malformed, HOLD, or model-less receipt BLOCKS — an un-run semantic pass is +// never a silent pass. The rule is release-time only: it stays disabled for +// ordinary development (a commit under review has no receipt yet), and +// release.yml supplies the target commit when it arms the rule. An enabled rule +// with no configured commit fails closed rather than passing vacuously. +func checkReceiptGate(repoRoot string, cfg RuleConfig) ([]Finding, error) { + dir := cfg.ReceiptsDir + if dir == "" { + dir = filepath.Join(".abcd", "work", "reviews") + } + // Every arming-input defect fails closed with a single finding — an armed gate + // that has nothing valid to check is never a pass. Guards are symmetric so no + // missing/blank/malformed arming input can slip through to a vacuous success. + failClosed := func(msg string) []Finding { + return []Finding{{File: dir, Line: 0, RuleID: "receipt_gate", Severity: cfg.Severity, Message: msg}} + } + if cfg.Commit == "" { + return failClosed("receipt_gate is enabled but no target commit is configured; the release gate fails closed"), nil + } + if !receiptShaRe.MatchString(cfg.Commit) { + return failClosed("receipt_gate target commit '" + cfg.Commit + "' is not a valid commit sha; the release gate fails closed"), nil + } + if len(cfg.RequiredGates) == 0 { + return failClosed("receipt_gate is enabled but lists no required gates; the release gate fails closed"), nil + } + + var out []Finding + add := func(rel, msg string) { + out = append(out, Finding{ + File: rel, Line: 0, RuleID: "receipt_gate", Severity: cfg.Severity, Message: msg, + }) + } + for _, gate := range cfg.RequiredGates { + if !receiptGateRe.MatchString(gate) { + add(dir, "receipt_gate required gate name '"+gate+"' is not a safe path component; the release gate fails closed") + continue + } + rel := filepath.Join(dir, cfg.Commit, gate+".json") + data, err := os.ReadFile(filepath.Join(repoRoot, rel)) + if err != nil { + if os.IsNotExist(err) { + add(rel, "no '"+gate+"' receipt for commit "+cfg.Commit+"; the semantic gate has not run (fail-closed)") + continue + } + return nil, err + } + var r receipt + if err := json.Unmarshal(data, &r); err != nil { + add(rel, "'"+gate+"' receipt is malformed JSON: "+err.Error()) + continue + } + if r.Subject.Digest.GitCommit != cfg.Commit { + add(rel, "'"+gate+"' receipt subject '"+r.Subject.Digest.GitCommit+"' does not match the target commit "+cfg.Commit) + continue + } + if r.VerificationResult != "PROMOTE" { + add(rel, "'"+gate+"' receipt verdict is '"+r.VerificationResult+"', not PROMOTE") + continue + } + if strings.TrimSpace(r.JudgeModel) == "" { + add(rel, "'"+gate+"' receipt pins no judge model; a floating judge is not auditable") + } + } + return out, nil +} + // tokenCheck is a compiled BannedToken ready for line matching. type tokenCheck struct { token BannedToken diff --git a/internal/core/lint/lint_test.go b/internal/core/lint/lint_test.go index dc7c57c6..82d00a47 100644 --- a/internal/core/lint/lint_test.go +++ b/internal/core/lint/lint_test.go @@ -474,6 +474,120 @@ func TestSurfaceCoverage(t *testing.T) { } } +func TestReceiptGate(t *testing.T) { + root := t.TempDir() + const sha = "0123456789abcdef0123456789abcdef01234567" + const other = "ffffffffffffffffffffffffffffffffffffffff" + reviews := filepath.Join(".abcd", "work", "reviews") + + receipt := func(commit, result, model string) string { + return `{ + "subject": {"digest": {"gitCommit": "` + commit + `"}}, + "verifier": {"id": "maintainer"}, + "timeVerified": "2026-07-11T00:00:00Z", + "verificationResult": "` + result + `", + "judgeModel": "` + model + `", + "policy": {"detector": "x", "version": "1"} +}` + } + gates := []string{"docs-currency-reviewer", "iss35-brief-surface-crosscheck"} + baseCfg := func() RuleConfig { + return RuleConfig{ + Enabled: true, Severity: "blocker", + ReceiptsDir: reviews, Commit: sha, RequiredGates: gates, + } + } + run := func(cfg RuleConfig) []Finding { + fs, err := Lint(Config{Rules: map[string]RuleConfig{"receipt_gate": cfg}}, root) + if err != nil { + t.Fatal(err) + } + return fs + } + put := func(commit, gate, body string) { + writeFile(t, root, filepath.Join(reviews, commit, gate+".json"), body) + } + + // 1. Every required gate has a PROMOTE receipt for the target sha → clean. + put(sha, "docs-currency-reviewer", receipt(sha, "PROMOTE", "claude-opus-4-8")) + put(sha, "iss35-brief-surface-crosscheck", receipt(sha, "PROMOTE", "claude-opus-4-8")) + if n := countRule(run(baseCfg()), "receipt_gate"); n != 0 { + t.Fatalf("all-PROMOTE receipts should be clean, got %d", n) + } + + // 2. Disabled → never fires, even pointed at a sha with no receipts. + cfg := baseCfg() + cfg.Enabled, cfg.Commit = false, other + if n := countRule(run(cfg), "receipt_gate"); n != 0 { + t.Fatalf("disabled rule must not fire, got %d", n) + } + + // 3. Missing receipts for the target sha → fail-closed BLOCK, one per gate. + cfg = baseCfg() + cfg.Commit = other + if n := countRule(run(cfg), "receipt_gate"); n != len(gates) { + t.Fatalf("missing receipts should fire once per gate (%d), got %d", len(gates), n) + } + + // 4. A HOLD verdict blocks (only the HOLD gate fires). + put(other, "docs-currency-reviewer", receipt(other, "HOLD", "claude-opus-4-8")) + put(other, "iss35-brief-surface-crosscheck", receipt(other, "PROMOTE", "claude-opus-4-8")) + if n := countRule(run(cfg), "receipt_gate"); n != 1 { + t.Fatalf("one HOLD should fire once, got %d", n) + } + + // 5. subject digest ≠ target commit → BLOCK (receipt not for this release). + put(sha, "iss35-brief-surface-crosscheck", receipt("deadbeef", "PROMOTE", "claude-opus-4-8")) + if n := countRule(run(baseCfg()), "receipt_gate"); n != 1 { + t.Fatalf("subject mismatch should fire, got %d", n) + } + put(sha, "iss35-brief-surface-crosscheck", receipt(sha, "PROMOTE", "claude-opus-4-8")) + + // 6. A blank (floating) judge model is not auditable → BLOCK. + put(sha, "docs-currency-reviewer", receipt(sha, "PROMOTE", "")) + if n := countRule(run(baseCfg()), "receipt_gate"); n != 1 { + t.Fatalf("blank judge model should fire, got %d", n) + } + put(sha, "docs-currency-reviewer", receipt(sha, "PROMOTE", "claude-opus-4-8")) + + // 7. Malformed receipt JSON → BLOCK (never a silent pass). + put(sha, "iss35-brief-surface-crosscheck", "{ not json") + if n := countRule(run(baseCfg()), "receipt_gate"); n != 1 { + t.Fatalf("malformed receipt should fire, got %d", n) + } + put(sha, "iss35-brief-surface-crosscheck", receipt(sha, "PROMOTE", "claude-opus-4-8")) + + // 8. Enabled with no target commit configured → fail-closed config error. + cfg = baseCfg() + cfg.Commit = "" + if n := countRule(run(cfg), "receipt_gate"); n == 0 { + t.Fatal("enabled rule with no target commit must fail closed") + } + + // 9. Enabled with an empty required-gates list → fail closed (verifies nothing + // otherwise). Symmetric with the empty-commit guard. + cfg = baseCfg() + cfg.RequiredGates = nil + if n := countRule(run(cfg), "receipt_gate"); n == 0 { + t.Fatal("enabled rule with no required gates must fail closed, not pass vacuously") + } + + // 10. A target commit that is not a valid sha (a path-traversal attempt) → + // fail closed, never a filesystem escape. + cfg = baseCfg() + cfg.Commit = "../../etc" + if n := countRule(run(cfg), "receipt_gate"); n == 0 { + t.Fatal("a non-sha target commit must fail closed") + } + + // 11. An unsafe gate name (path component) → fail closed for that gate. + cfg = baseCfg() + cfg.RequiredGates = []string{"../evil"} + if n := countRule(run(cfg), "receipt_gate"); n == 0 { + t.Fatal("an unsafe gate name must fail closed") + } +} + // presentTenseTokens mirrors the change-narration phrase list shipped in // .abcd/docs-lint.json (word-boundary, case-insensitive, inline-escape). func presentTenseTokens() []BannedToken { From 11bb25d1449687e685feb08d8afa55704a4f9187 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:34:12 +0100 Subject: [PATCH 5/6] feat: gate_lockstep runbook<->release.yml consistency lint (iss-35 task 5 ph3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the semantic release gate. Adds the gate_lockstep record-lint rule: the release-gate runbook's deterministic-gate list must stay in lockstep with the CI workflow's verify-job steps (a gate in one but not the other BLOCKS), so the runbook cannot silently drift from what CI enforces. Hand-parsed, no new YAML dependency. Security-reviewed (trust boundary): initial BLOCK found three fail-opens in the hand-parse (the alternate `- uses:`/`name:` step form was invisible, a double-empty parse passed vacuously, a 2-space comment closed a job early). Fixed and re-reviewed PASS: - a non-empty floor (min_gates) — any under-count fails closed, the backstop that makes hand-parsing safe; - step-block parsing captures a step name wherever it appears; - job scoping anchored to the jobs: block + a strict identifier-only header regex (a comment can't be a job boundary); - a missing configured path fails loud, not nil; - symmetric quote normalization. Enabled in record-lint.json (min_gates:9, matching the live 9-gate verify job); watched fire on injected drift. Runbook's staging note updated to present-tense. Follow-up (fail-safe, not fail-open): indentation-anchored step scoping would stop a name: inside a with:/run: block being read as a phantom gate (over-blocks today, never hides drift). TDD: TestGateLockstep, 9 cases. Assisted-by: Claude:claude-opus-4-8 --- .abcd/development/release-gate/README.md | 8 +- .abcd/record-lint.json | 3 +- internal/core/lint/config.go | 16 ++ internal/core/lint/lint.go | 228 ++++++++++++++++++++++- internal/core/lint/lint_test.go | 130 +++++++++++++ 5 files changed, 376 insertions(+), 9 deletions(-) diff --git a/.abcd/development/release-gate/README.md b/.abcd/development/release-gate/README.md index 2f159761..88ba96c4 100644 --- a/.abcd/development/release-gate/README.md +++ b/.abcd/development/release-gate/README.md @@ -26,11 +26,9 @@ the human-readable mirror. 8. Reviews-charter discipline (RD001-RD003) 9. Smoke every command (self-discovering harness) -> **Design target — not yet armed.** A `record-lint` rule keeping this list in -> lockstep with `release.yml`'s `verify` steps lands in phase 3 (see the design -> doc). Until it arms, this mirror is kept honest by hand; per -> [`../principles/loud-staging.md`](../principles/loud-staging.md) the gap is -> stated, not hidden. +This list is machine-checked: the `gate_lockstep` `record-lint` rule blocks if it +diverges from `release.yml`'s `verify` job steps (setup steps excepted). Edit both +together — the mirror cannot silently drift. ## Semantic gates (host-run, before the tag) diff --git a/.abcd/record-lint.json b/.abcd/record-lint.json index d1189eca..84648583 100644 --- a/.abcd/record-lint.json +++ b/.abcd/record-lint.json @@ -24,7 +24,8 @@ "persona_registry": {"enabled": true, "severity": "blocker", "registry": ".abcd/development/personas.json"}, "context_status_free": {"enabled": true, "severity": "blocker", "target": ".abcd/work/CONTEXT.md"}, "surface_coverage": {"enabled": true, "severity": "blocker", "commands_dir": "commands/abcd", "skills_dir": "skills", "registry": ".abcd/development/brief/04-surfaces/README.md"}, - "receipt_gate": {"enabled": false, "severity": "blocker", "receipts_dir": ".abcd/work/reviews", "required_gates": ["docs-currency-reviewer", "iss35-brief-surface-crosscheck"]} + "receipt_gate": {"enabled": false, "severity": "blocker", "receipts_dir": ".abcd/work/reviews", "required_gates": ["docs-currency-reviewer", "iss35-brief-surface-crosscheck"]}, + "gate_lockstep": {"enabled": true, "severity": "blocker", "runbook": ".abcd/development/release-gate/README.md", "workflow": ".github/workflows/release.yml", "job": "verify", "ignore_steps": ["Check out the pushed commit", "Set up Go"], "min_gates": 9} }, "exempt_paths": [".abcd/development/research/", ".abcd/development/intents/superseded/"], "exempt_if_status": ["superseded"] diff --git a/internal/core/lint/config.go b/internal/core/lint/config.go index 7c1f705e..89fe655d 100644 --- a/internal/core/lint/config.go +++ b/internal/core/lint/config.go @@ -90,6 +90,22 @@ type RuleConfig struct { // Release-time input (release.yml supplies the tagged commit); empty while the // rule is disabled for ordinary development. Commit string `json:"commit"` + // Runbook is the gate_lockstep runbook path (its numbered "Deterministic + // gates" list), repo-relative. + Runbook string `json:"runbook"` + // Workflow is the gate_lockstep CI workflow path — the source of truth for the + // deterministic gate list, repo-relative. + Workflow string `json:"workflow"` + // Job is the gate_lockstep workflow job whose step names are the gate list. + Job string `json:"job"` + // IgnoreSteps are workflow step names that are setup, not gates, and so are + // excluded from the lockstep comparison. + IgnoreSteps []string `json:"ignore_steps"` + // MinGates is the gate_lockstep non-empty floor: each side must parse at least + // this many gates or the rule fails closed (an under-count means the parser or + // a heading/job rename silently dropped gates). It is the safety net that makes + // the hand-parse fail-closed. Enforced as at least 1 when the rule is enabled. + MinGates int `json:"min_gates"` } // LoadConfig reads and decodes a record-lint config file. diff --git a/internal/core/lint/lint.go b/internal/core/lint/lint.go index 093ae9e5..4977a632 100644 --- a/internal/core/lint/lint.go +++ b/internal/core/lint/lint.go @@ -10,6 +10,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" ) @@ -47,9 +48,21 @@ var ( // (release.yml) — validated as safe path components before use. receiptShaRe = regexp.MustCompile(`^[0-9a-f]{7,64}$`) receiptGateRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) - specIDRe = regexp.MustCompile(`^spc-`) - supersededRe = regexp.MustCompile(`^itd-\d+`) - intentBuckets = map[string]bool{ + // gate_lockstep hand-parsers (no YAML library — the repo has none and adds no + // dependency): a markdown numbered-list item; the `jobs:` line; a 2-space job + // header (identifier, optional quotes/trailing comment, never a comment line); + // a job's `steps:` key; a step list-item marker; and a step `name:` key line. + // A non-empty floor (RuleConfig.MinGates) is the fail-closed backstop that + // makes any parser under-count block rather than silently pass. + numberedItemRe = regexp.MustCompile(`^\s*\d+\.\s+(.+?)\s*$`) + jobsSectionRe = regexp.MustCompile(`^jobs:\s*(#.*)?$`) + jobHeaderRe = regexp.MustCompile(`^ ["']?([A-Za-z0-9_-]+)["']?:\s*(#.*)?$`) + stepsKeyRe = regexp.MustCompile(`^\s+steps:\s*(#.*)?$`) + stepMarkerRe = regexp.MustCompile(`^\s+-\s+(.*\S)\s*$`) + stepNameKeyRe = regexp.MustCompile(`^\s+name:\s*(.+?)\s*$`) + specIDRe = regexp.MustCompile(`^spc-`) + supersededRe = regexp.MustCompile(`^itd-\d+`) + intentBuckets = map[string]bool{ "drafts": true, "planned": true, "shipped": true, "disciplines": true, "superseded": true, } @@ -180,6 +193,16 @@ func Lint(cfg Config, repoRoot string) ([]Finding, error) { findings = append(findings, rg...) } + // gate_lockstep keeps the release-gate runbook's deterministic-gate list in + // lockstep with the CI workflow (both outside cfg.Roots), so it runs once here. + if glCfg, ok := cfg.Rules["gate_lockstep"]; ok && glCfg.Enabled { + gl, err := checkGateLockstep(repoRoot, glCfg) + if err != nil { + return nil, err + } + findings = append(findings, gl...) + } + sortFindings(findings) return findings, nil } @@ -643,6 +666,205 @@ func checkReceiptGate(repoRoot string, cfg RuleConfig) ([]Finding, error) { return out, nil } +// checkGateLockstep asserts the release-gate runbook's deterministic-gate list +// is in lockstep with the CI workflow's gate steps — neither is a projection of +// the other (the runbook carries prose the workflow can't, the workflow carries +// setup the runbook shouldn't), so the anti-drift shape is a consistency check, +// not generate-from-source. A gate in one but not the other BLOCKS. Both are +// hand-parsed (no YAML dependency): the runbook's numbered "Deterministic gates" +// list, and the named steps of the workflow's target job minus the configured +// setup steps. +func checkGateLockstep(repoRoot string, cfg RuleConfig) ([]Finding, error) { + if cfg.Runbook == "" || cfg.Workflow == "" { + return nil, nil + } + minGates := cfg.MinGates + if minGates < 1 { + minGates = 1 // enabled ⇒ at least a non-empty floor, never a vacuous pass + } + + var out []Finding + block := func(file, msg string) { + out = append(out, Finding{File: file, Line: 0, RuleID: "gate_lockstep", Severity: cfg.Severity, Message: msg}) + } + + // A configured path that does not resolve is drift/misconfig, not "clean" — + // fail loud rather than parsing an empty list that would pass vacuously. + runbookExists, err := fileExists(filepath.Join(repoRoot, cfg.Runbook)) + if err != nil { + return nil, err + } + workflowExists, err := fileExists(filepath.Join(repoRoot, cfg.Workflow)) + if err != nil { + return nil, err + } + if !runbookExists { + block(cfg.Runbook, "gate_lockstep runbook '"+cfg.Runbook+"' does not exist; the release gate fails closed") + } + if !workflowExists { + block(cfg.Workflow, "gate_lockstep workflow '"+cfg.Workflow+"' does not exist; the release gate fails closed") + } + if !runbookExists || !workflowExists { + return out, nil + } + + runbookGates, err := runbookGateList(repoRoot, cfg.Runbook) + if err != nil { + return nil, err + } + workflowGates, err := workflowStepNames(repoRoot, cfg.Workflow, cfg.Job, cfg.IgnoreSteps) + if err != nil { + return nil, err + } + + // Non-empty floor: an under-count means a heading/job rename, a missed step + // form, or a mis-scoped job silently dropped gates. This is the fail-closed + // backstop that turns every such under-count loud instead of a silent pass. + if len(runbookGates) < minGates { + block(cfg.Runbook, "gate_lockstep parsed "+strconv.Itoa(len(runbookGates))+" runbook gate(s), below the expected minimum "+strconv.Itoa(minGates)+"; the release gate fails closed") + } + if len(workflowGates) < minGates { + block(cfg.Workflow, "gate_lockstep parsed "+strconv.Itoa(len(workflowGates))+" '"+cfg.Job+"' gate(s) from "+cfg.Workflow+", below the expected minimum "+strconv.Itoa(minGates)+"; the release gate fails closed") + } + + inWorkflow := make(map[string]bool, len(workflowGates)) + for _, g := range workflowGates { + inWorkflow[g] = true + } + inRunbook := make(map[string]bool, len(runbookGates)) + for _, g := range runbookGates { + inRunbook[g] = true + } + for _, g := range runbookGates { + if !inWorkflow[g] { + block(cfg.Runbook, "runbook lists deterministic gate '"+g+"' that is not a '"+cfg.Job+"' step in "+cfg.Workflow) + } + } + for _, g := range workflowGates { + if !inRunbook[g] { + block(cfg.Workflow, cfg.Workflow+" '"+cfg.Job+"' step '"+g+"' is missing from the runbook's deterministic gates ("+cfg.Runbook+")") + } + } + return out, nil +} + +// runbookGateList extracts the numbered items under the runbook's "Deterministic +// gates" heading (case-insensitive), stopping at the next heading, with the same +// surrounding-quote normalization the workflow side uses so identical gates never +// look different. A missing file yields nil — the caller has already failed it +// closed. +func runbookGateList(repoRoot, rel string) ([]string, error) { + data, err := os.ReadFile(filepath.Join(repoRoot, rel)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var gates []string + inSection := false + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + inSection = strings.Contains(strings.ToLower(trimmed), "deterministic gate") + continue + } + if !inSection { + continue + } + if m := numberedItemRe.FindStringSubmatch(line); m != nil { + gates = append(gates, strings.Trim(strings.TrimSpace(m[1]), `"'`)) + } + } + return gates, nil +} + +// workflowStepNames extracts the step names of the named job, dropping configured +// setup steps. It scopes to the `jobs:` block (so 2-space keys under on:/ +// permissions:/concurrency: never masquerade as jobs), identifies a job only by a +// strict 2-space header regex (so a comment such as ` # NOTE:` cannot close a +// job early), and captures each step's name wherever it appears in the step block +// — inline `- name:` OR a following `name:` line after `- uses:` — so the +// alternate step form is not invisible. A missing file yields nil; the caller has +// already failed it closed. +func workflowStepNames(repoRoot, rel, job string, ignore []string) ([]string, error) { + data, err := os.ReadFile(filepath.Join(repoRoot, rel)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + ignored := make(map[string]bool, len(ignore)) + for _, s := range ignore { + ignored[s] = true + } + + var names []string + seenJobs, inJob, inSteps, inStep := false, false, false, false + var cur string + flush := func() { + if inStep { + name := strings.Trim(strings.TrimSpace(cur), `"'`) + if name != "" && !ignored[name] { + names = append(names, name) + } + } + cur, inStep = "", false + } + for _, line := range strings.Split(string(data), "\n") { + if !seenJobs { + if jobsSectionRe.MatchString(line) { + seenJobs = true + } + continue + } + if m := jobHeaderRe.FindStringSubmatch(line); m != nil { + flush() + inJob, inSteps = m[1] == job, false + continue + } + if !inJob { + continue + } + if stepsKeyRe.MatchString(line) { + inSteps = true + continue + } + if !inSteps { + continue + } + if m := stepMarkerRe.FindStringSubmatch(line); m != nil { + flush() + inStep = true + if content := m[1]; strings.HasPrefix(content, "name:") { + cur = strings.TrimSpace(strings.TrimPrefix(content, "name:")) + } + continue + } + if inStep && cur == "" { + if nm := stepNameKeyRe.FindStringSubmatch(line); nm != nil { + cur = strings.TrimSpace(nm[1]) + } + } + } + flush() + return names, nil +} + +// fileExists reports whether path exists (following symlinks). A stat error other +// than not-exist is returned so the caller fails closed on it. +func fileExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + // tokenCheck is a compiled BannedToken ready for line matching. type tokenCheck struct { token BannedToken diff --git a/internal/core/lint/lint_test.go b/internal/core/lint/lint_test.go index 82d00a47..ec856fc0 100644 --- a/internal/core/lint/lint_test.go +++ b/internal/core/lint/lint_test.go @@ -588,6 +588,136 @@ func TestReceiptGate(t *testing.T) { } } +func TestGateLockstep(t *testing.T) { + root := t.TempDir() + + workflow := "name: release\n" + + "on:\n" + + " push:\n" + + " tags: ['v*']\n" + + "jobs:\n" + + " verify:\n" + + " runs-on: ubuntu-latest\n" + + " steps:\n" + + " - name: Check out the pushed commit\n" + + " - name: Set up Go\n" + + " - name: Format (gofmt)\n" + + " - name: Build\n" + + " - name: Vet\n" + + " release:\n" + + " steps:\n" + + " - name: Publish\n" // a step in another job — must NOT be counted + writeFile(t, root, ".github/workflows/release.yml", workflow) + + runbook := func(gates string) string { + return "# Release gate\n\n## Deterministic gates (CI-enforced)\n\n" + gates + + "\n> a staging note, not a gate\n\n## Semantic gates\n\n1. Not a deterministic gate\n" + } + // In-lockstep: exactly the verify gate steps (setup steps ignored). + writeFile(t, root, "runbook.md", runbook("1. Format (gofmt)\n2. Build\n3. Vet\n")) + + cfg := func() RuleConfig { + return RuleConfig{ + Enabled: true, Severity: "blocker", + Runbook: "runbook.md", Workflow: ".github/workflows/release.yml", + Job: "verify", IgnoreSteps: []string{"Check out the pushed commit", "Set up Go"}, + } + } + run := func() []Finding { + fs, err := Lint(Config{Rules: map[string]RuleConfig{"gate_lockstep": cfg()}}, root) + if err != nil { + t.Fatal(err) + } + return fs + } + + // 1. Lists agree → clean. + if n := countRule(run(), "gate_lockstep"); n != 0 { + t.Fatalf("matching lists should be clean, got %d: %+v", n, run()) + } + + // 2. A verify step missing from the runbook → BLOCK. + writeFile(t, root, "runbook.md", runbook("1. Format (gofmt)\n2. Build\n")) // drop Vet + if n := countRule(run(), "gate_lockstep"); n != 1 { + t.Fatalf("a workflow gate missing from the runbook should fire once, got %d: %+v", n, run()) + } + + // 3. A runbook gate not in the workflow → BLOCK. + writeFile(t, root, "runbook.md", runbook("1. Format (gofmt)\n2. Build\n3. Vet\n4. Phantom Gate\n")) + if n := countRule(run(), "gate_lockstep"); n != 1 { + t.Fatalf("a phantom runbook gate should fire once, got %d: %+v", n, run()) + } + + // 4. Setup steps in the ignore list are not required in the runbook (already + // clean in case 1 proves this); dropping an ignore entry surfaces them. + c := cfg() + c.IgnoreSteps = nil + writeFile(t, root, "runbook.md", runbook("1. Format (gofmt)\n2. Build\n3. Vet\n")) + fs, err := Lint(Config{Rules: map[string]RuleConfig{"gate_lockstep": c}}, root) + if err != nil { + t.Fatal(err) + } + if n := countRule(fs, "gate_lockstep"); n != 2 { // Check out + Set up Go now unlisted + t.Fatalf("un-ignored setup steps should each fire, got %d: %+v", n, fs) + } + + lint := func(c RuleConfig) []Finding { + fs, err := Lint(Config{Rules: map[string]RuleConfig{"gate_lockstep": c}}, root) + if err != nil { + t.Fatal(err) + } + return fs + } + + // 5. Non-empty floor: a renamed runbook heading parses to zero gates → fail + // closed (the double-empty / rename silent-pass hole). + writeFile(t, root, "renamed.md", "# R\n\n## CI checks\n\n1. Format (gofmt)\n2. Build\n3. Vet\n") + c5 := cfg() + c5.Runbook, c5.MinGates = "renamed.md", 3 + if n := countRule(lint(c5), "gate_lockstep"); n == 0 { + t.Fatal("a renamed heading (zero parsed gates) must trip the non-empty floor") + } + + // 6. The alternate step form (`- uses:` then `name:` on the next line) is seen, + // not invisible — matching runbook ⇒ clean. + writeFile(t, root, ".github/workflows/alt.yml", "name: r\non:\n push:\njobs:\n verify:\n steps:\n"+ + " - name: Format (gofmt)\n - uses: some/scanner@v1\n name: Secret Scan\n - name: Build\n") + writeFile(t, root, "alt-runbook.md", runbook("1. Format (gofmt)\n2. Secret Scan\n3. Build\n")) + c6 := cfg() + c6.Workflow, c6.Runbook, c6.MinGates, c6.IgnoreSteps = ".github/workflows/alt.yml", "alt-runbook.md", 3, nil + if n := countRule(lint(c6), "gate_lockstep"); n != 0 { + t.Fatalf("alternate `- uses:`/`name:` step form must be captured, got %d: %+v", n, lint(c6)) + } + + // 7. A 2-space comment inside the job does not close it early (steps after it + // stay in scope). + writeFile(t, root, ".github/workflows/cmt.yml", "name: r\non:\n push:\njobs:\n verify:\n steps:\n"+ + " - name: Format (gofmt)\n # a stray 2-space comment: not a job\n - name: Build\n - name: Vet\n") + writeFile(t, root, "clean-runbook.md", runbook("1. Format (gofmt)\n2. Build\n3. Vet\n")) + c7 := cfg() + c7.Workflow, c7.Runbook, c7.MinGates, c7.IgnoreSteps = ".github/workflows/cmt.yml", "clean-runbook.md", 3, nil + if n := countRule(lint(c7), "gate_lockstep"); n != 0 { + t.Fatalf("a 2-space comment must not drop steps after it, got %d: %+v", n, lint(c7)) + } + + // 8. A configured file that does not exist → fail loud, never silent. + c8 := cfg() + c8.Runbook = "does-not-exist.md" + if n := countRule(lint(c8), "gate_lockstep"); n == 0 { + t.Fatal("a missing configured runbook must fail closed") + } + + // 9. Quote normalization is symmetric — a quoted workflow name equals an + // unquoted runbook item. + writeFile(t, root, ".github/workflows/q.yml", "name: r\non:\n push:\njobs:\n verify:\n steps:\n"+ + " - name: \"Format (gofmt)\"\n - name: Build\n - name: Vet\n") + c9 := cfg() + c9.Workflow, c9.Runbook, c9.MinGates, c9.IgnoreSteps = ".github/workflows/q.yml", "clean-runbook.md", 3, nil + if n := countRule(lint(c9), "gate_lockstep"); n != 0 { + t.Fatalf("quoted vs unquoted gate names must normalize equal, got %d: %+v", n, lint(c9)) + } +} + // presentTenseTokens mirrors the change-narration phrase list shipped in // .abcd/docs-lint.json (word-boundary, case-insensitive, inline-escape). func presentTenseTokens() []BannedToken { From a2cf482a13aff7c755d814d04a938c971c96b38e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:16:50 +0100 Subject: [PATCH 6/6] feat: wire + sign the semantic release gate via A.1 (iss-35 task 5 ph4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 (final) of the semantic release gate — full signed path, no new Go dependency, reusing the actions/attest family + gh attestation verify the repo already uses for binary provenance (investigation confirmed A.1 viable). - ArmReceiptGate (internal/core/lint) + cmd/record-lint --release-gate / --require-gate flags: a CI workflow arms receipt_gate from the CLI, so the decision to gate, the target commit, and the required-gate list are trust-rooted to the workflow, not the in-tree committer-editable config. The rule stays disabled for ordinary runs. - release.yml (release job, gated `if: !private` like the binary attestation): fail-closed content gate (record-lint --release-gate), signs the receipts with actions/attest (predicate .../semantic-release-gate/v1), verifies with gh attestation verify. Security-reviewed (trust boundary): initial BLOCK found two holes — arming did not force blocker severity (a committed severity:warning would defang the gate) and github.ref_name (a tag name) was interpolated into the signed predicate JSON (injection). Both fixed (force blocker on arming; predicate carries only the immutable github.sha) + an empty-sha fail-closed guard; re-review PASS. Honest scope: signing is auditable release provenance / tamper-evidence, not committer-forgery-proof (a receipt forged+committed pre-tag is signed too) — bounded by the iss-62 identity gate + branch protection; full forgery-prevention would need host-side signing. Dormant on a private repo (attestation is a public-repo feature); activates on the public flip. go.mod unchanged. TDD: TestArmReceiptGate. Assisted-by: Claude:claude-opus-4-8 --- .abcd/development/release-gate/README.md | 27 +++++++++----- .github/workflows/release.yml | 47 ++++++++++++++++++++++++ cmd/record-lint/main.go | 19 ++++++++++ internal/core/lint/config.go | 29 +++++++++++++++ internal/core/lint/lint_test.go | 46 +++++++++++++++++++++++ 5 files changed, 159 insertions(+), 9 deletions(-) diff --git a/.abcd/development/release-gate/README.md b/.abcd/development/release-gate/README.md index 88ba96c4..56f3e088 100644 --- a/.abcd/development/release-gate/README.md +++ b/.abcd/development/release-gate/README.md @@ -57,16 +57,25 @@ judge model; a missing, mismatched, malformed, HOLD, or model-less receipt **blocks** the release (fail-closed — an un-run semantic pass is never a silent pass). -> **Partially armed.** The receipt schema + the fail-closed `receipt_gate` verify -> rule exist and are tested (phase 2), but the rule is **disabled by default** — -> it must never fire on ordinary PRs/pushes, only at release time against the -> tagged commit. Arming it in `release.yml` (with the tagged sha) and -> **cosign-signing** the receipt with in-`release.yml` signature verification is -> phase 4; the runbook↔`release.yml` lockstep lint is phase 3. Until those land, -> the release-time verification is enforced by this runbook as maintainer -> discipline. That provisional status is stated per +The `receipt_gate` rule is **disabled by default** — it must never fire on +ordinary PRs/pushes, only at release time — and is armed by `release.yml`, which +supplies the tagged commit and the required-gate list from the workflow (the +trust root), not the in-tree config: `record-lint --release-gate +--require-gate …`. `release.yml` then signs the receipts with +`actions/attest` (predicate `.../semantic-release-gate/v1`) and verifies the +attestation with `gh attestation verify` — no new dependency (the same attest +family + `gh` the binary provenance already uses). + +> **Dormant until the public flip.** Artifact attestation is a public-repo +> feature, so the whole gate is gated `if: !github.event.repository.private` — +> exactly like the binary attestation — and does nothing on a private repo, +> activating on the public flip. And the signature is **auditable release +> provenance, not committer-forgery-proof**: a receipt forged and committed +> before the tag would be signed too; that residual is bounded by the iss-62 +> identity gate + branch protection. Stated per > [`../principles/loud-staging.md`](../principles/loud-staging.md), not implied -> away. +> away. Full forgery-prevention would need host-side signing at receipt +> production — a later step if the threat model warrants it. ## Procedure diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d66cd32f..6cf79b5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,6 +140,53 @@ jobs: go-version: '1.25' cache: false + # --- Semantic release gate (iss-35; dormant until the public flip) -------- + # The LLM semantic passes (docs-currency-reviewer, the brief↔surface + # cross-check) cannot run in CI — no model. The maintainer runs them + # host-side and commits a sha-keyed PROMOTE receipt under + # .abcd/work/reviews// (see .abcd/development/release-gate/README.md). + # These steps fail-close the release unless those receipts exist and are + # PROMOTE, then sign them for auditable release provenance. Gated on repo + # visibility exactly like the binary attestation below: artifact attestation + # is a public-repo feature, so this gate is dormant on a private repo and + # activates on the public flip. Signing here is provenance/tamper-evidence, + # not committer-forgery-proof (a forged committed receipt would be signed + # too) — that residual is bounded by the identity gate + branch protection. + - name: Semantic-gate receipts (fail-closed) + if: ${{ !github.event.repository.private }} + # Arms receipt_gate from the CLI (the workflow, not the in-tree config, is + # the trust root for the decision to gate and the required-gates list). + run: | + set -euo pipefail + test -n "${GITHUB_SHA:-}" # never arm-nothing on an empty sha (fail closed) + go run ./cmd/record-lint --release-gate "$GITHUB_SHA" \ + --require-gate docs-currency-reviewer \ + --require-gate iss35-brief-surface-crosscheck + + - name: Attest the semantic-gate receipts (signed release provenance) + if: ${{ !github.event.repository.private }} + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: .abcd/work/reviews/${{ github.sha }}/*.json + predicate-type: https://abcd.dev/attestations/semantic-release-gate/v1 + # Only the immutable commit sha — never github.ref_name (an + # attacker-influenceable tag name would inject into this signed JSON). + predicate: | + {"commit": "${{ github.sha }}", "verificationResult": "PROMOTE"} + + - name: Verify the semantic-gate receipt attestation + if: ${{ !github.event.repository.private }} + run: | + set -euo pipefail + for r in .abcd/work/reviews/"$GITHUB_SHA"/*.json; do + gh attestation verify "$r" \ + --repo "${GITHUB_REPOSITORY}" \ + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml" \ + --predicate-type https://abcd.dev/attestations/semantic-release-gate/v1 + done + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Cross-compile the four binaries (version-stamped from the tag) # VERSION= stamps `abcd version` via -ldflags -X …internal/core.Version. # The binaries built here are the ones checksummed, attested, and uploaded diff --git a/cmd/record-lint/main.go b/cmd/record-lint/main.go index d9ca774b..5a9b7cee 100644 --- a/cmd/record-lint/main.go +++ b/cmd/record-lint/main.go @@ -18,6 +18,9 @@ import ( func main() { configPath := flag.String("config", "", "path to record-lint.json (default: /.abcd/record-lint.json)") rootPath := flag.String("root", "", "repo root to lint (default: git toplevel, or cwd)") + releaseGate := flag.String("release-gate", "", "arm the receipt_gate rule for a release: fail closed unless a PROMOTE semantic-pass receipt exists for this commit sha (release-time only; a CI workflow supplies the sha)") + var requireGates multiFlag + flag.Var(&requireGates, "require-gate", "a required semantic gate name for --release-gate (repeatable); overrides the config list so the workflow, not the in-tree file, is the trust root") flag.Parse() root := *rootPath @@ -36,6 +39,13 @@ func main() { os.Exit(2) } + // --release-gate arms the receipt_gate rule from the CLI invocation, so the + // release-time decision to gate lives with the CI workflow, not the in-tree + // (committer-editable) config, which keeps the rule disabled for ordinary runs. + if *releaseGate != "" { + cfg = lint.ArmReceiptGate(cfg, *releaseGate, requireGates) + } + findings, err := lint.Lint(cfg, root) if err != nil { fmt.Fprintln(os.Stderr, "record-lint:", err) @@ -56,6 +66,15 @@ func main() { } } +// multiFlag collects a repeatable string flag (each --require-gate appends). +type multiFlag []string + +func (m *multiFlag) String() string { return strings.Join(*m, ",") } +func (m *multiFlag) Set(v string) error { + *m = append(*m, v) + return nil +} + // resolveRoot returns the git toplevel, falling back to the working directory. func resolveRoot() string { out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() diff --git a/internal/core/lint/config.go b/internal/core/lint/config.go index 89fe655d..56c0f998 100644 --- a/internal/core/lint/config.go +++ b/internal/core/lint/config.go @@ -108,6 +108,35 @@ type RuleConfig struct { MinGates int `json:"min_gates"` } +// ArmReceiptGate returns cfg with the receipt_gate rule armed for a release: it +// is enabled and pointed at the target commit, and — when a non-empty list is +// supplied — its required gates are overridden. This is how a release runs the +// gate: the CALLER (a CI workflow) supplies the arming, so the decision to gate, +// the target commit, and the required-gates list are trust-rooted to the workflow +// rather than the in-tree, committer-editable config (phase-2 review Finding 2). +// The input cfg is not mutated (the Rules map is copied). Other rules are +// unchanged; the deterministic gates still run alongside. +func ArmReceiptGate(cfg Config, commit string, requiredGates []string) Config { + rules := make(map[string]RuleConfig, len(cfg.Rules)+1) + for k, v := range cfg.Rules { + rules[k] = v + } + rc := rules["receipt_gate"] + rc.Enabled = true + rc.Commit = commit + // An armed release gate is blocking by definition — force the severity so the + // gate's teeth are trust-rooted to the caller (a CI workflow) like Enabled and + // Commit, never the committer-editable config. A downgraded severity landed in + // the in-tree file must not defang the gate at release time. + rc.Severity = severityBlocker + if len(requiredGates) > 0 { + rc.RequiredGates = requiredGates + } + rules["receipt_gate"] = rc + cfg.Rules = rules + return cfg +} + // LoadConfig reads and decodes a record-lint config file. func LoadConfig(path string) (Config, error) { data, err := os.ReadFile(path) diff --git a/internal/core/lint/lint_test.go b/internal/core/lint/lint_test.go index ec856fc0..c8892aa0 100644 --- a/internal/core/lint/lint_test.go +++ b/internal/core/lint/lint_test.go @@ -718,6 +718,52 @@ func TestGateLockstep(t *testing.T) { } } +func TestArmReceiptGate(t *testing.T) { + base := Config{Rules: map[string]RuleConfig{ + "receipt_gate": {Enabled: false, Severity: "blocker", ReceiptsDir: ".abcd/work/reviews", RequiredGates: []string{"config-gate"}}, + }} + + // Arming with a commit + explicit gates overrides enable/commit/gates. + armed := ArmReceiptGate(base, "abc123", []string{"cli-gate-a", "cli-gate-b"}) + rc := armed.Rules["receipt_gate"] + if !rc.Enabled || rc.Commit != "abc123" { + t.Fatalf("arming must enable + set commit, got %+v", rc) + } + if len(rc.RequiredGates) != 2 || rc.RequiredGates[0] != "cli-gate-a" { + t.Fatalf("supplied gates must override the config list, got %+v", rc.RequiredGates) + } + + // The input config is not mutated (its map is copied). + if base.Rules["receipt_gate"].Enabled || base.Rules["receipt_gate"].Commit != "" { + t.Fatal("ArmReceiptGate must not mutate the input config") + } + + // No supplied gates → the config's list is kept. + kept := ArmReceiptGate(base, "def456", nil) + if got := kept.Rules["receipt_gate"].RequiredGates; len(got) != 1 || got[0] != "config-gate" { + t.Fatalf("nil gates must keep the config list, got %+v", got) + } + + // A committer-downgraded severity in the config must NOT defang the armed + // gate — arming forces blocker so the teeth are trust-rooted to the caller. + downgraded := Config{Rules: map[string]RuleConfig{ + "receipt_gate": {Enabled: false, Severity: "warning", ReceiptsDir: ".abcd/work/reviews", RequiredGates: []string{"g"}}, + }} + if sev := ArmReceiptGate(downgraded, "abc123", nil).Rules["receipt_gate"].Severity; sev != "blocker" { + t.Fatalf("arming must force blocker severity, got %q", sev) + } + + // End to end: an armed config with no receipt on disk fails closed. + root := t.TempDir() + fs, err := Lint(armed, root) + if err != nil { + t.Fatal(err) + } + if countRule(fs, "receipt_gate") == 0 { + t.Fatal("an armed gate with no receipts must fire (fail-closed)") + } +} + // presentTenseTokens mirrors the change-narration phrase list shipped in // .abcd/docs-lint.json (word-boundary, case-insensitive, inline-escape). func presentTenseTokens() []BannedToken {