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 new file mode 100644 index 00000000..56f3e088 --- /dev/null +++ b/.abcd/development/release-gate/README.md @@ -0,0 +1,87 @@ +# 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) + +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) + +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 **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. 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). + +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. Full forgery-prevention would need host-side signing at receipt +> production — a later step if the threat model warrants it. + +## 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 } 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..84648583 100644 --- a/.abcd/record-lint.json +++ b/.abcd/record-lint.json @@ -23,7 +23,9 @@ "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"]}, + "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/.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 2808560b..56c0f998 100644 --- a/internal/core/lint/config.go +++ b/internal/core/lint/config.go @@ -78,6 +78,63 @@ 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"` + // 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"` +} + +// 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. diff --git a/internal/core/lint/lint.go b/internal/core/lint/lint.go index 88cab573..4977a632 100644 --- a/internal/core/lint/lint.go +++ b/internal/core/lint/lint.go @@ -5,10 +5,12 @@ package lint import ( + "encoding/json" "os" "path/filepath" "regexp" "sort" + "strconv" "strings" ) @@ -41,10 +43,26 @@ 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-]+))?$`) - specIDRe = regexp.MustCompile(`^spc-`) - supersededRe = regexp.MustCompile(`^itd-\d+`) - intentBuckets = map[string]bool{ + 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._-]+$`) + // 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, } @@ -163,6 +181,28 @@ 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...) + } + + // 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 } @@ -544,6 +584,287 @@ 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 +} + +// 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 dc7c57c6..c8892aa0 100644 --- a/internal/core/lint/lint_test.go +++ b/internal/core/lint/lint_test.go @@ -474,6 +474,296 @@ 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") + } +} + +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)) + } +} + +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 {