Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
87 changes: 87 additions & 0 deletions .abcd/development/release-gate/README.md
Original file line number Diff line number Diff line change
@@ -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/<commit-sha>/<gate>.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 <sha>
--require-gate <name>…`. `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.
75 changes: 75 additions & 0 deletions .abcd/development/release-gate/brief-surface-crosscheck.js
Original file line number Diff line number Diff line change
@@ -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: [<repo-relative paths>],
// 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-<goos>-<arch>)
and run it (\`abcd --help\` and \`abcd <verb> --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 }
18 changes: 18 additions & 0 deletions .abcd/development/release-gate/receipt.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"_comment": "Example semantic-pass receipt (a Verification Summary Attestation). One file per gate, stored at .abcd/work/reviews/<commit-sha>/<gate>.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": []
}
4 changes: 3 additions & 1 deletion .abcd/record-lint.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
47 changes: 47 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sha>/ (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=<tag> stamps `abcd version` via -ldflags -X …internal/core.Version.
# The binaries built here are the ones checksummed, attested, and uploaded
Expand Down
19 changes: 19 additions & 0 deletions cmd/record-lint/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import (
func main() {
configPath := flag.String("config", "", "path to record-lint.json (default: <root>/.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
Expand All @@ -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)
Expand All @@ -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()
Expand Down
Loading
Loading