diff --git a/.abcd/development/release-gate/README.md b/.abcd/development/release-gate/README.md index a8c7cd5..7af349f 100644 --- a/.abcd/development/release-gate/README.md +++ b/.abcd/development/release-gate/README.md @@ -42,7 +42,9 @@ against the exact commit to be tagged: 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. + `surface_coverage` `record-lint` rule and already runs in CI. Its scope and + depth are pinned by [`manifest.json`](manifest.json) — see *Pinned inputs and + tiered depth* below. ## Recording the semantic verdict @@ -77,6 +79,60 @@ filename: the `.json` at `.abcd/work/reviews//` must carry being copied across every gate's path to satisfy them all — each gate needs its own receipt from its own detector. +## Pinned inputs and tiered depth + +The cross-check runs against a **committed input manifest**, +[`manifest.json`](manifest.json), which pins the reproducibility inputs: the +17-document brief-doc list, the two directions (A: brief→surface, B: +surface→brief), the checker count (22 = 17 brief docs + 5 surfaces), and the +prompt (context plus both direction templates, with the prompt's own +`sha256`). Two honest runs of the same tier therefore mean the same thing — +the maintainer no longer chooses the scope per run. The +[`brief-surface-crosscheck.js`](brief-surface-crosscheck.js) detector consumes +this manifest as its input rather than composing an ad-hoc list. + +Depth is **tiered by the release's impact class**: + +- **`full`** — both directions over the whole brief-doc list — is required for a + **feature (additive) or breaking** release. +- **`shallow`** — Direction B only — is sufficient for a **patch (fix/internal)** + release. + +The impact class is derived at gate time from the shipped records — the same +signal the version itself derives from (changelog-driven versioning, adr-37). +The version *number* alone cannot carry it while abcd is pre-1.0: an additive +feature and a fix both bump the patch component at 0.x, so only the records' +`impact` frontmatter tells a feature release apart from a patch one. The gate +reads the strongest impact in the cut between the newest release tag older than +the current CHANGELOG version and `HEAD`. + +A manifest-era receipt therefore carries two further fields alongside the ones +above: + +- **`manifestHash`** — the `sha256` of `manifest.json` the run pinned its inputs + against. +- **`tier`** — `full` or `shallow`, the depth the run used. + +`receipt_gate` adds three **procedural** refusals once the manifest exists in the +release's content tree: + +1. `manifestHash` does not equal `manifest.json`'s actual hash — the run's pinned + inputs are not this release's. +2. `tier` is insufficient for the release's impact class — a shallow receipt on a + feature or breaking release. +3. A `failing` entry carries no `disposition` — an un-triaged finding. + +None of these judges a finding's **content or severity**. Confirmed findings +route to the maintainer, whose PROMOTE with recorded dispositions is the gate +(verifier-selects-gates-decide); the gate does not hard-block on a confirmed +major and keeps no never-worse ratchet across tiers. + +**Era gating.** The manifest's presence in the armed content tree is the era +marker. The three refusals above arm only when `manifest.json` is present at the +gate's armed commit, so receipts written before the manifest existed — the +receipts committed under `.abcd/work/reviews/` for earlier releases — stay valid +for their own commits, judged by the checks that predate the manifest. + 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 diff --git a/.abcd/development/release-gate/brief-surface-crosscheck.js b/.abcd/development/release-gate/brief-surface-crosscheck.js index 8a923cf..9a46910 100644 --- a/.abcd/development/release-gate/brief-surface-crosscheck.js +++ b/.abcd/development/release-gate/brief-surface-crosscheck.js @@ -9,8 +9,15 @@ // 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 }] }. +// Invoke with args = the pinned input manifest +// (.abcd/development/release-gate/manifest.json): { briefDocs, surfaces, prompt, +// ... }. The manifest is the reproducibility anchor (iss-122) — the doc list, +// the directions, the checker count, and the prompt (context + both direction +// templates) are fixed there rather than composed ad hoc here, so two honest +// runs of the same tier mean the same thing. The receipt echoes the manifest's +// sha256 as manifestHash and the depth it ran at as tier; receipt_gate refuses a +// receipt whose manifestHash mismatches or whose tier is too shallow for the +// release's impact class. export const meta = { name: 'iss35-brief-surface-crosscheck', description: 'Bidirectional brief↔surface reconciliation detector (iss-35 semantic gate)', @@ -35,31 +42,18 @@ const FINDINGS = { }, 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.` +// Prompt text comes from the manifest, not from here: the context and both +// direction templates are pinned in manifest.json (with their own promptHash) so +// the prompt cannot drift between runs. The templates carry ${...} placeholders +// as literal text; fill them per checker. +const CTX = input.prompt.context +const fill = (tmpl, subs) => + Object.entries(subs).reduce((s, [k, v]) => s.split('${' + k + '}').join(v), tmpl) 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.`, + `${CTX}\n\n${fill(input.prompt.directionA, { doc })}`, { 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).`, + `${CTX}\n\n${fill(input.prompt.directionB, { 's.name': s.name, 's.kind': s.kind, 's.probe': s.probe })}`, { label: `surface:${s.name}`, phase: 'CheckSurface', schema: FINDINGS })) const all = (await parallel([...briefFindings, ...surfFindings])) .filter(Boolean) diff --git a/.abcd/development/release-gate/manifest.json b/.abcd/development/release-gate/manifest.json new file mode 100644 index 0000000..20193c2 --- /dev/null +++ b/.abcd/development/release-gate/manifest.json @@ -0,0 +1,74 @@ +{ + "schemaVersion": 1, + "detector": "iss35-brief-surface-crosscheck", + "_comment": "Pinned input manifest for the iss-35 brief<->surface cross-check (iss-122). This is the reproducibility anchor: two honest runs of the same tier mean the same thing because the doc list, the directions, the checker count, and the prompt are fixed here rather than chosen per run. A receipt echoes this file's sha256 as manifestHash; receipt_gate refuses a receipt whose manifestHash does not match this file's actual hash. Compute the hash with `printf 'sha256:'; shasum -a 256 .abcd/development/release-gate/manifest.json | cut -d' ' -f1`.", + "tiers": { + "full": "both directions over the full brief-doc list; required for a feature (additive) or breaking release", + "shallow": "Direction B only (surface->brief); sufficient for a patch (fix/internal) release" + }, + "directions": [ + { + "id": "A", + "name": "brief->surface", + "description": "per brief doc: verify every surface claim against the shipped binary/tree" + }, + { + "id": "B", + "name": "surface->brief", + "description": "per real surface: find its documented home in the brief" + } + ], + "briefDocs": [ + ".abcd/development/brief/04-surfaces/01-ahoy.md", + ".abcd/development/brief/04-surfaces/02-disembark.md", + ".abcd/development/brief/04-surfaces/03-embark.md", + ".abcd/development/brief/04-surfaces/04-launch.md", + ".abcd/development/brief/04-surfaces/05-intent.md", + ".abcd/development/brief/04-surfaces/06-capture.md", + ".abcd/development/brief/04-surfaces/07-memory.md", + ".abcd/development/brief/04-surfaces/08-abcd.md", + ".abcd/development/brief/04-surfaces/09-reflect.md", + ".abcd/development/brief/04-surfaces/10-docs.md", + ".abcd/development/brief/04-surfaces/11-history.md", + ".abcd/development/brief/04-surfaces/12-version.md", + ".abcd/development/brief/04-surfaces/13-consult.md", + ".abcd/development/brief/04-surfaces/14-ingest.md", + ".abcd/development/brief/04-surfaces/15-prepare-this-repo.md", + ".abcd/development/brief/04-surfaces/16-audit.md", + ".abcd/development/brief/05-internals/08-skills.md" + ], + "surfaces": [ + { + "name": "CLI verb tree", + "kind": "binary", + "probe": "build bin/abcd--; run `abcd --help` and `abcd --help`" + }, + { + "name": "plugin command surface", + "kind": "commands", + "probe": "list commands/abcd/*.md (README excepted)" + }, + { + "name": "plugin agent surface", + "kind": "agents", + "probe": "list agents/*.md (README excepted)" + }, + { + "name": "hook entrypoints", + "kind": "hooks", + "probe": "read hooks/hooks.json" + }, + { + "name": "skills surface", + "kind": "skills", + "probe": "list skills/ (abcd ships zero skills; the directory is empty or absent)" + } + ], + "checkerCount": 22, + "promptHash": "sha256:6b4a388e596e159a04f73417af8c2d08699968dd01031b9c08aeac65596d8064", + "prompt": { + "context": "Repo root: the current working directory — use repo-relative paths\nthroughout, never absolute local paths. Ground truth is the SHIPPED surface,\nverified empirically: build the binary (make build produces bin/abcd--)\nand run it (`abcd --help` and `abcd --help`), and list commands/abcd/\nand skills/. abcd currently ships ZERO skills — the whole /abcd: surface is\ncommands under commands/abcd/ (ahoy, capture, consult, ingest, prepare-this-repo,\ndocs, history, launch, memory, version); skills/ is empty or absent. The brief's\nsurface chapters are .abcd/development/brief/04-surfaces/*.md and\n05-internals/08-skills.md. Report DISCREPANCIES ONLY — where record and reality\ndisagree, or one side is missing. A brief row explicitly marked staged (its\nStatus column is \"staged\") / probe-only / later-phase is NOT a discrepancy; an\nunmarked claim about a surface that does not exist IS. Do not fix anything.", + "directionA": "Direction A. Read ${doc} fully. Extract every checkable claim\nabout the shipped surface (verbs, sub-verbs, flags, skill names, counts,\nfile layouts, \"abcd ships N ...\" statements) and verify each against\nreality. Return item=\"${doc}\" and the discrepancy list.", + "directionB": "Direction B. The real surface \"${s.name}\" (${s.kind}) exists:\ninspect it (${s.probe}). Search the brief's surface chapters for its\ndocumented home (grep .abcd/development/brief/). If no brief row documents\nit — or the brief documents it under a wrong name/shape — that is a\ndiscrepancy. Return item=\"${s.name}\" and the discrepancy list (empty if\nproperly documented)." + } +} diff --git a/.abcd/development/release-gate/receipt.example.json b/.abcd/development/release-gate/receipt.example.json index b610e11..eb2b867 100644 --- a/.abcd/development/release-gate/receipt.example.json +++ b/.abcd/development/release-gate/receipt.example.json @@ -1,16 +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.", + "_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, judgeModel is a pinned snapshot (never a floating alias), and policy.detector names this gate. Once the pinned input manifest (manifest.json) exists in the release's content tree — the era marker — three further PROCEDURAL checks arm: manifestHash must equal manifest.json's actual sha256; tier must be sufficient for the release's impact class (a shallow receipt is refused on a feature/breaking release); and every entry in failing must carry a disposition. The gate never judges a finding's content or severity — confirmed findings route to the maintainer, whose PROMOTE with recorded dispositions is the gate. 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", + "timeVerified": "2026-07-25T12:00:00Z", "verificationResult": "PROMOTE", "judgeModel": "claude-opus-4-8", + "tier": "full", + "manifestHash": "sha256:c74d40824c9d39689715b735f894b6bc4ea08c2891c510b34f4c8f1a7742d6f8", "policy": { "detector": "iss35-brief-surface-crosscheck", "version": "1", - "promptHash": "sha256:…", + "promptHash": "sha256:6b4a388e596e159a04f73417af8c2d08699968dd01031b9c08aeac65596d8064", "briefVersion": "…" }, "categories": { "false-claim": 0, "undocumented-surface": 0, "fictional-layout": 0, "criterion-violation": 0, "stale-count": 0 }, diff --git a/.abcd/work/issues/open/iss-130-manifest-deletion-disarms-crosscheck-refusals.md b/.abcd/work/issues/open/iss-130-manifest-deletion-disarms-crosscheck-refusals.md new file mode 100644 index 0000000..4bbaf8c --- /dev/null +++ b/.abcd/work/issues/open/iss-130-manifest-deletion-disarms-crosscheck-refusals.md @@ -0,0 +1,12 @@ +--- +schema_version: 1 +id: "iss-130" +slug: "manifest-deletion-disarms-crosscheck-refusals" +severity: "minor" +category: "tech-debt" +source: "impl-review" +found_during: "iss-122 implementation review (2026-07-24 run queue, burst 6)" +found_at: ".abcd/development/release-gate/manifest.json" +--- + +era-gating tradeoff residue from iss-122: deleting release-gate/manifest.json from the content tree disarms the three new procedural refusals (manifest presence IS the era marker, per the decided design F) — the backstop belongs in release.yml (require the manifest to exist for any release newer than v0.4.0), which is CI config and needs maintainer sign-off; until then a committer who removes the manifest silently reverts the gate to pre-manifest rules \ No newline at end of file diff --git a/.abcd/work/issues/open/iss-131-receipt-gate-hardening-nitpicks.md b/.abcd/work/issues/open/iss-131-receipt-gate-hardening-nitpicks.md new file mode 100644 index 0000000..5c7025f --- /dev/null +++ b/.abcd/work/issues/open/iss-131-receipt-gate-hardening-nitpicks.md @@ -0,0 +1,12 @@ +--- +schema_version: 1 +id: "iss-131" +slug: "receipt-gate-hardening-nitpicks" +severity: "nitpick" +category: "tech-debt" +source: "impl-review" +found_during: "iss-122 reviews (2026-07-24 run queue, burst 6)" +found_at: "internal/core/lint/lint.go" +--- + +gate-hardening nitpicks from the iss-122 reviews, none blocking: (1) the manifest read in receipt_gate uses unbounded os.ReadFile where the sibling convention is fsutil.ReadGuarded (trusted committed file, self-DoS only — but it is the unhardened sibling the bughunt spine says to sweep); (2) receipt.example.json's manifestHash is not test-pinned against manifest.json so a manifest edit silently stales the example; (3) receipt parsing tolerates duplicate JSON keys (Go last-wins) — an audit-legibility gap under the attestation trust model, not an escalation \ No newline at end of file diff --git a/.abcd/work/issues/open/iss-122-iss35-crosscheck-scope-and-depth-unpinned.md b/.abcd/work/issues/resolved/iss-122-iss35-crosscheck-scope-and-depth-unpinned.md similarity index 72% rename from .abcd/work/issues/open/iss-122-iss35-crosscheck-scope-and-depth-unpinned.md rename to .abcd/work/issues/resolved/iss-122-iss35-crosscheck-scope-and-depth-unpinned.md index 0d77bae..500e47b 100644 --- a/.abcd/work/issues/open/iss-122-iss35-crosscheck-scope-and-depth-unpinned.md +++ b/.abcd/work/issues/resolved/iss-122-iss35-crosscheck-scope-and-depth-unpinned.md @@ -7,6 +7,8 @@ category: "tech-debt" source: "agent-observation" found_during: "v0.4.0 release gate" found_at: ".abcd/development/release-gate/brief-surface-crosscheck.js" +resolution: "Pinned the iss-35 crosscheck gate's scope and depth: a committed input manifest (manifest.json) fixes the 17 brief docs, both directions, 22 checkers, and the prompt hash; receipt_gate gains additive tier and manifestHash fields and three procedural refusals (manifestHash mismatch, tier insufficient for the release's impact class, undispositioned finding), all era-gated on the manifest's presence so historical receipts stay valid. Impact class derived at gate time from the shipped records (changelog-driven versioning), since the version number alone cannot tell a pre-1.0 feature from a fix." +impact: internal --- The iss35 crosscheck's scope and depth are unpinned, so the gate is not reproducible: v0.3.0's receipt records zero findings four days ago while a full-depth run (17 brief docs, both directions, 22 checkers) returns 102 discrepancies, and the receipt's own promptHash field is the literal 'no-pinned-prompt' admission. The maintainer choosing briefDocs per run means two honest runs of the same gate can disagree by two orders of magnitude; the gate needs a pinned input manifest and depth so a PROMOTE means the same thing every release. diff --git a/internal/core/lint/lint.go b/internal/core/lint/lint.go index ea54ff1..8cea811 100644 --- a/internal/core/lint/lint.go +++ b/internal/core/lint/lint.go @@ -5,6 +5,8 @@ package lint import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "os" "path/filepath" @@ -17,6 +19,7 @@ import ( "github.com/REPPL/abcd-cli/internal/core/changelog" "github.com/REPPL/abcd-cli/internal/core/frontmatter" + "github.com/REPPL/abcd-cli/internal/core/launch" "github.com/REPPL/abcd-cli/internal/fsutil" ) @@ -35,6 +38,24 @@ const ( severityWarn = "warn" ) +// The two crosscheck depths a receipt can declare (iss-122). "full" runs both +// directions over the whole brief-doc list; "shallow" runs Direction B only. A +// full receipt satisfies any release; a shallow one satisfies only a patch +// (fix/internal) release. +const ( + releaseTierFull = "full" + releaseTierShallow = "shallow" +) + +// releaseGateManifestPath is the committed input manifest that pins the iss-35 +// crosscheck's scope and depth (iss-122). Its PRESENCE in the content tree is +// the era marker: receipts written before the manifest existed are judged by the +// pre-manifest rules, and the manifest-era refusals (manifestHash, tier, and +// findings-disposition) arm only when this file is present at the armed commit. +// The path is fixed rather than config-driven so a committer cannot silence the +// new refusals by blanking a config field. +const releaseGateManifestPath = ".abcd/development/release-gate/manifest.json" + var ( // Inline markdown link: [text](target). Also matches the link part of an // image (![alt](src)), which resolves the same way. @@ -714,6 +735,19 @@ type receipt struct { Policy struct { Detector string `json:"detector"` } `json:"policy"` + // Tier and ManifestHash are the two manifest-era fields (iss-122), read only + // when the committed release-gate manifest exists. Tier is the crosscheck depth + // the run used ("full" or "shallow"); ManifestHash is the sha256 the run pinned + // its inputs against, which must equal the committed manifest's actual hash. + Tier string `json:"tier"` + ManifestHash string `json:"manifestHash"` + // Failing is the findings the run recorded. It already existed in the receipt + // schema (an informational VSA field); the manifest-era gate now READS it to + // require that each finding carries a disposition. Only the disposition is + // inspected — the gate never judges a finding's content or severity. + Failing []struct { + Disposition string `json:"disposition"` + } `json:"failing"` } // checkReceiptGate is the fail-closed, release-time verification of the semantic @@ -746,6 +780,29 @@ func checkReceiptGate(repoRoot string, cfg RuleConfig) ([]Finding, error) { return failClosed("receipt_gate is enabled but lists no required gates; the release gate fails closed"), nil } + // Manifest era (iss-122): the committed input manifest pins the crosscheck's + // scope and depth, so once it exists a receipt must echo its hash and a + // sufficient tier, and any recorded finding must carry a disposition. Its + // presence in the content tree is the era marker — a receipt/commit that + // predates the manifest is judged by the pre-manifest rules only. Read it from + // repoRoot, the checked-out content tree the gate is armed against. + manifestBytes, manifestErr := os.ReadFile(filepath.Join(repoRoot, releaseGateManifestPath)) + var manifestEra bool + var expectedManifestHash, requiredTier string + switch { + case manifestErr == nil: + manifestEra = true + expectedManifestHash = hashManifest(manifestBytes) + requiredTier = requiredReleaseTier(repoRoot) + case os.IsNotExist(manifestErr): + manifestEra = false + default: + // The manifest exists but cannot be read — never fall through to the + // pre-manifest rules on an I/O error, which would silently drop the new + // refusals; fail closed. + return failClosed("receipt_gate cannot read the release-gate manifest " + releaseGateManifestPath + ": " + manifestErr.Error()), nil + } + var out []Finding add := func(rel, msg string) { out = append(out, Finding{ @@ -789,11 +846,124 @@ func checkReceiptGate(repoRoot string, cfg RuleConfig) ([]Finding, error) { } if strings.TrimSpace(r.JudgeModel) == "" { add(rel, "'"+gate+"' receipt pins no judge model; a floating judge is not auditable") + continue + } + if !manifestEra { + // Pre-manifest era: the receipt is judged by the rules above only, so a + // historical receipt that carries no tier/manifestHash/disposition stays + // valid for its own commit. + continue + } + // Manifest-era refusals — PROCEDURAL only (iss-122). None of these judge a + // finding's content or severity: confirmed findings route to the maintainer, + // whose PROMOTE with recorded dispositions is the gate. + if r.ManifestHash != expectedManifestHash { + add(rel, "'"+gate+"' receipt manifestHash '"+r.ManifestHash+"' does not match the committed release-gate manifest ("+expectedManifestHash+"); the run's pinned inputs are not this release's") + continue + } + if !tierSufficient(r.Tier, requiredTier) { + add(rel, "'"+gate+"' receipt tier '"+r.Tier+"' is insufficient for a "+requiredTier+"-tier release; run the crosscheck at "+requiredTier+" depth") + continue + } + for _, f := range r.Failing { + if strings.TrimSpace(f.Disposition) == "" { + add(rel, "'"+gate+"' receipt carries a finding with no recorded disposition; every finding must record a disposition before release") + break + } } } return out, nil } +// hashManifest renders the release-gate manifest's canonical hash — sha256 over +// its exact committed bytes, the same convention the receipt's promptHash and +// the binary provenance use. Rendered as "sha256:" so a receipt's +// manifestHash is reproducible with `shasum -a 256`. +func hashManifest(data []byte) string { + sum := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// tierSufficient reports whether a receipt's declared crosscheck depth covers +// the depth this release requires. A full run satisfies any release; a shallow +// run satisfies only a shallow (patch) release; an empty or unrecognised tier is +// never sufficient (fail closed — an unpinned depth is not auditable). +func tierSufficient(receiptTier, requiredTier string) bool { + switch receiptTier { + case releaseTierFull: + return true + case releaseTierShallow: + return requiredTier == releaseTierShallow + default: + return false + } +} + +// requiredReleaseTier is the crosscheck depth this release must have run at, +// derived from the release's impact class: an additive or breaking release +// requires the full crosscheck; a fix/internal (patch) release is covered by the +// shallow Direction-B pass. When the impact cannot be classified at gate time, +// it returns the strict tier (full) so only a full receipt passes — fail closed, +// never a silent shallow pass. +func requiredReleaseTier(repoRoot string) string { + impact, ok := releaseImpact(repoRoot) + if !ok { + return releaseTierFull + } + if impact == changelog.ImpactAdditive || impact == changelog.ImpactBreaking { + return releaseTierFull + } + return releaseTierShallow +} + +// releaseImpact classifies the release currently at HEAD by the strongest impact +// among the records it shipped — the same signal the version itself is derived +// from (changelog-driven versioning, adr-37), read at gate time from git state +// release.yml already provides. The version NUMBER alone cannot carry this +// pre-1.0: an additive feature and a fix both bump the patch component while the +// repo is 0.x, so only the shipped records' impact frontmatter distinguishes a +// feature release from a patch release. The base is the newest release tag +// strictly older than the current CHANGELOG version, so the cut is exactly this +// release. ok=false means the impact could not be read (no version, no prior +// tag, or git unavailable) and the caller must fall back to the strict tier. +func releaseImpact(repoRoot string) (changelog.Impact, bool) { + cur, ok, err := changelog.LatestChangelogVersion(repoRoot) + if err != nil || !ok { + return "", false + } + tags, err := launch.GitExistingTags(repoRoot) + if err != nil { + return "", false + } + var base launch.Semver + haveBase := false + for _, t := range tags { + if !launch.CoreGreater(cur, t) { + continue // t is the current release or newer, not a prior base + } + if !haveBase || launch.CoreGreater(t, base) { + base, haveBase = t, true + } + } + if !haveBase { + return "", false + } + records, err := changelog.ShippedSince(repoRoot, base.Tag()) + if err != nil { + return "", false + } + // An added record with a missing or malformed impact ranks below every real + // impact, so it would silently pull the cut's max down to internal and let a + // shallow receipt through. The version derivation refuses such a cut upstream + // (changelog.Derive, RefusalUnlabelledRecord); honour the same invariant here + // so the tier gate is fail-closed on its own — an unclassifiable cut falls back + // to the strict full tier rather than being read as a patch. + if len(records.UnlabelledAdded()) > 0 { + return "", false + } + return records.Impact(), true +} + // 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 diff --git a/internal/core/lint/receipt_manifest_test.go b/internal/core/lint/receipt_manifest_test.go new file mode 100644 index 0000000..cf61650 --- /dev/null +++ b/internal/core/lint/receipt_manifest_test.go @@ -0,0 +1,178 @@ +package lint + +import ( + "path/filepath" + "testing" + + "github.com/REPPL/abcd-cli/internal/gittest" +) + +// manifestReceipt builds a manifest-era receipt: a PROMOTE for commit, bound to +// detector, carrying the two iss-122 fields (tier, manifestHash) and a raw +// findings array (failingJSON, e.g. `[]` or `[{"disposition":"accepted"}]`). +func manifestReceipt(commit, detector, tier, manifestHash, failingJSON string) string { + return `{ + "subject": {"digest": {"gitCommit": "` + commit + `"}}, + "verifier": {"id": "maintainer"}, + "verificationResult": "PROMOTE", + "judgeModel": "claude-opus-4-8", + "policy": {"detector": "` + detector + `", "version": "1"}, + "tier": "` + tier + `", + "manifestHash": "` + manifestHash + `", + "failing": ` + failingJSON + ` +}` +} + +func runReceiptGate(t *testing.T, root string, cfg RuleConfig) []Finding { + t.Helper() + fs, err := Lint(Config{Rules: map[string]RuleConfig{"receipt_gate": cfg}}, root) + if err != nil { + t.Fatal(err) + } + return fs +} + +// The manifest-era refusals fire only when the committed manifest is present in +// the content tree, and none of them judges a finding's content. Run in a plain +// temp dir (no git), so the release impact cannot be classified and the required +// tier fails closed to "full". +func TestReceiptGateManifestEra(t *testing.T) { + root := t.TempDir() + const sha = "0123456789abcdef0123456789abcdef01234567" + const gate = "iss35-brief-surface-crosscheck" + reviews := filepath.Join(".abcd", "work", "reviews") + + manifestBody := `{"schemaVersion":1,"detector":"` + gate + `","checkerCount":22}` + "\n" + writeFile(t, root, releaseGateManifestPath, manifestBody) + goodHash := hashManifest([]byte(manifestBody)) + + cfg := RuleConfig{Enabled: true, Severity: severityBlocker, ReceiptsDir: reviews, Commit: sha, RequiredGates: []string{gate}} + put := func(body string) { writeFile(t, root, filepath.Join(reviews, sha, gate+".json"), body) } + + // (d) A fully conforming full-tier receipt with the right manifest hash and no + // findings → clean. + put(manifestReceipt(sha, gate, releaseTierFull, goodHash, `[]`)) + if n := countRule(runReceiptGate(t, root, cfg), "receipt_gate"); n != 0 { + t.Fatalf("(d) conforming full receipt should be clean, got %d", n) + } + + // (a) A receipt whose manifestHash does not match the committed manifest → BLOCK. + put(manifestReceipt(sha, gate, releaseTierFull, "sha256:deadbeef", `[]`)) + if n := countRule(runReceiptGate(t, root, cfg), "receipt_gate"); n != 1 { + t.Fatalf("(a) manifestHash mismatch should fire once, got %d", n) + } + + // (b, refuse half) A shallow receipt on a release whose required tier is full + // (impact unclassifiable here → strict full) → BLOCK. + put(manifestReceipt(sha, gate, releaseTierShallow, goodHash, `[]`)) + if n := countRule(runReceiptGate(t, root, cfg), "receipt_gate"); n != 1 { + t.Fatalf("(b) shallow receipt on a full-tier release should fire once, got %d", n) + } + + // A blank/unknown tier is never sufficient → BLOCK. + put(manifestReceipt(sha, gate, "", goodHash, `[]`)) + if n := countRule(runReceiptGate(t, root, cfg), "receipt_gate"); n != 1 { + t.Fatalf("empty tier should fire once, got %d", n) + } + + // (c) A finding with NO recorded disposition → BLOCK. + put(manifestReceipt(sha, gate, releaseTierFull, goodHash, `[{"where":"04-abcd.md:10"}]`)) + if n := countRule(runReceiptGate(t, root, cfg), "receipt_gate"); n != 1 { + t.Fatalf("(c) undispositioned finding should fire once, got %d", n) + } + + // (c) The SAME finding WITH a recorded disposition (any content) → clean. + put(manifestReceipt(sha, gate, releaseTierFull, goodHash, `[{"where":"04-abcd.md:10","disposition":"accepted: staged row"}]`)) + if n := countRule(runReceiptGate(t, root, cfg), "receipt_gate"); n != 0 { + t.Fatalf("(c) dispositioned finding should be clean, got %d", n) + } +} + +// (e) Era gating: with NO committed manifest, a receipt that carries no tier, no +// manifestHash, and an undispositioned finding is judged by the pre-manifest +// rules only — none of the new refusals fire. This is what keeps v0.3.0's and +// v0.4.0's historical receipts valid for their own (pre-manifest) commits. +func TestReceiptGatePreManifestEra(t *testing.T) { + root := t.TempDir() + const sha = "0123456789abcdef0123456789abcdef01234567" + const gate = "iss35-brief-surface-crosscheck" + reviews := filepath.Join(".abcd", "work", "reviews") + + // No manifest file written → pre-manifest era. + body := `{ + "subject": {"digest": {"gitCommit": "` + sha + `"}}, + "verificationResult": "PROMOTE", + "judgeModel": "claude-opus-4-8", + "policy": {"detector": "` + gate + `"}, + "failing": [{"where":"x"}] +}` + writeFile(t, root, filepath.Join(reviews, sha, gate+".json"), body) + cfg := RuleConfig{Enabled: true, Severity: severityBlocker, ReceiptsDir: reviews, Commit: sha, RequiredGates: []string{gate}} + if n := countRule(runReceiptGate(t, root, cfg), "receipt_gate"); n != 0 { + t.Fatalf("(e) a pre-manifest receipt must pass the old rules with no new refusals, got %d", n) + } +} + +// (b, both halves) The required tier is derived from the release's impact class, +// read from the shipped records. A fix-only release (patch) accepts a shallow +// receipt; an additive release (feature) refuses it and needs a full one. This +// is the pre-1.0 case the version number alone cannot decide. +func TestReceiptGateTierByImpact(t *testing.T) { + const gate = "iss35-brief-surface-crosscheck" + reviews := ".abcd/work/reviews" + manifestBody := `{"schemaVersion":1,"detector":"` + gate + `","checkerCount":22}` + "\n" + goodHash := hashManifest([]byte(manifestBody)) + + // build a released repo whose cut since v0.3.0 carries exactly one record of + // the given impact, then arm the gate against HEAD. + build := func(t *testing.T, impact string) (root, sha string) { + r := gittest.NewRepo(t) + r.Write("CHANGELOG.md", "# Changelog\n\n## [0.3.0] - 2026-01-01\n\n### Added\n\n- base\n") + r.Commit("base 0.3.0") + r.Git("tag", "v0.3.0") + // The release cut: a resolved issue carrying the impact, a newer CHANGELOG + // heading, the pinned manifest, and the receipt. + r.Record(".abcd/work/issues/resolved/iss-900-x.md", "iss-900", impact) + r.Write("CHANGELOG.md", "# Changelog\n\n## [0.3.1] - 2026-02-01\n\n### Fixed\n\n- x\n\n## [0.3.0] - 2026-01-01\n\n### Added\n\n- base\n") + r.Write(releaseGateManifestPath, manifestBody) + r.Commit("release 0.3.1") + return r.Root(), r.Git("rev-parse", "HEAD") + } + + t.Run("fix release accepts shallow", func(t *testing.T) { + root, sha := build(t, "fix") + cfg := RuleConfig{Enabled: true, Severity: severityBlocker, ReceiptsDir: reviews, Commit: sha, RequiredGates: []string{gate}} + writeFile(t, root, filepath.Join(reviews, sha, gate+".json"), manifestReceipt(sha, gate, releaseTierShallow, goodHash, `[]`)) + if n := countRule(runReceiptGate(t, root, cfg), "receipt_gate"); n != 0 { + t.Fatalf("a shallow receipt on a fix (patch) release should be clean, got %d", n) + } + }) + + t.Run("additive release refuses shallow, accepts full", func(t *testing.T) { + root, sha := build(t, "additive") + cfg := RuleConfig{Enabled: true, Severity: severityBlocker, ReceiptsDir: reviews, Commit: sha, RequiredGates: []string{gate}} + path := filepath.Join(reviews, sha, gate+".json") + + writeFile(t, root, path, manifestReceipt(sha, gate, releaseTierShallow, goodHash, `[]`)) + if n := countRule(runReceiptGate(t, root, cfg), "receipt_gate"); n != 1 { + t.Fatalf("a shallow receipt on an additive (feature) release should fire once, got %d", n) + } + + writeFile(t, root, path, manifestReceipt(sha, gate, releaseTierFull, goodHash, `[]`)) + if n := countRule(runReceiptGate(t, root, cfg), "receipt_gate"); n != 0 { + t.Fatalf("a full receipt on an additive release should be clean, got %d", n) + } + }) + + // An added record whose impact cannot be read must NOT down-tier the release to + // a patch: the cut is unclassifiable, so the gate falls back to the strict full + // tier and a shallow receipt is refused (fail-closed within the gate). + t.Run("unlabelled added record fails closed to full", func(t *testing.T) { + root, sha := build(t, "notavalidimpact") + cfg := RuleConfig{Enabled: true, Severity: severityBlocker, ReceiptsDir: reviews, Commit: sha, RequiredGates: []string{gate}} + writeFile(t, root, filepath.Join(reviews, sha, gate+".json"), manifestReceipt(sha, gate, releaseTierShallow, goodHash, `[]`)) + if n := countRule(runReceiptGate(t, root, cfg), "receipt_gate"); n != 1 { + t.Fatalf("a shallow receipt on an unclassifiable cut should fire once, got %d", n) + } + }) +}