Files
gstack/docs/ship-policy.md
T
cadbd0af15 v1.91.116.0 feat: field list 2 wave B: gstack-ship-queue, approval classes, state-machine review, pregate timing check, update-check main moves (#3235)
* fix(capy): the install trailer says behind=<n> and its fix, so a stale install shows in every relayed trailer (field list 2 item 13, #2378)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(capy): a lease records its holder's start identity, so a pid reused after a wake is reclaimed; a skipped session-start upgrade ends fail behind, never ok (GBRA-105, GBRA-106)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): stale needs proof (unreadable or malformed identities keep the lease), ps identity is timezone-independent, and reclaim never deletes a concurrent renewal

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(autoplan): enforced round cap and spend budget, deferred close, public export without machine paths, opt-in durable checkpoints (field list 2 W5-W7)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): one directory lock for publish, prune and reclaim; reclaim rereads the record judged stale, so a renewal is never deleted or hidden

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): the directory lock names its holder; broken only when the holder is dead or its pid reused, unlock removes only its own lock, touch exits 3 under a live holder

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): breakers serialize on .lock.break and reread the lock before removing it; gstack-skill-start reports a lease the held lock kept out

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(lease): name the double-crash limit of breaking a dead lock

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(capy): --for review-voice install profile, no-Drive bootstrap command, reviewer prompt trailer (field list 2 W11)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 89bf556c11)

* test: pin review-voice row, helpers row and the no-Drive fallback in existing install tests

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 7f6c70459a)

* test: the DRIVER template names the reviewer trailer relay (pregate lane)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 2545d9a6d2)

* test(context-budget): capy skill eager ceiling for the review-voice subagent paragraph (W11; ratchet protocol, capture value)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(eval-plan): prereg diagnostics dry run (W8) and .gstack/eval-models.json lint (W9), WIP

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit b509096218)

* feat(eval-plan): W8/W9 tests, docs/eval-models.md, troubleshooting anchors, /eval-plan steps

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 9335e45de2)

* test(eval-plan): eval-plan/SKILL.md names only defined codes and accepted flags

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit a7a974d268)

* test(review-voice): the launch line points at the verdict's bun-only directory (#3233's launch contract)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(third-party-actions): the Aside drive-offer detector matches the product name, not the English idiom (paid run 38988927989 tpa-absent-linux false positive)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): export refuses artifact paths outside the run before writing; a deferred close keeps earlier undecided findings; reopening CEO grants Eng its rerun (review of #3234)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(restamp): gate-ahead keeps the lane CHANGELOG entry when both PRs took the same tentative stamp (GBRA-102 diagnosis fixture)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 43c75a6bb6)

* test(tree-receipt): a real gbrain stamp pair is same-modulo-stamps under gbrain committed policy (W1 acceptance)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 6cf53f9fed)

* feat(ship-policy): gate_command, full_e2e and min_gstack_version; unknown keys stay fail-closed; validate prints introducing versions

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 878a7123d7)

* feat(ship-queue): gstack-ship-queue ready, stamp, verify and gate-ahead (WIP, tests next)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 409a5957ad)

* test(ship-queue): stamp journal and resume, verify from a separate clone, gate-ahead attempts, ready

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit ade554087b)

* docs(ship-policy): Run a shared merge queue; new keys and compatibility rule; troubleshooting anchors; /ship pointer

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 7fc73e8892)

* fix(ship): shared-queue pointer rides the show-effective ship: line (ship skeleton has 2 bytes of parity headroom)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 5b33d4a015)

* test(windows): curate the shared-queue tests out of the Windows subset (shebang spawns)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 8deee14728)

* test(fixtures): register the gbrain stamp-pair fixture as free-only

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 563a07aa03)

* test(ship-queue): receipt digest contract names lib/ship-queue.ts for its pregate lane

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 501637c258)

* feat(pregate): timing check flags elapsed-time bounds and sleep-then-assert in added test lines (W4)

lib/pregate/checks/timing.ts prints TIMING_ASSERTION: <file>:<line> <pattern>
and a TIMING_ASSERTIONS summary; gstack-timing-ok: <reason> in any comment
syntax on the line or the line above escapes one. It warns
(PREGATE_TIMING_ASSERTION): measured on gstack (9 of 47 test-touching
commits, 263 hits in the tree) and gbrain (37 of 95 commits; 31 of 38 sites
of the five timing-flake fixes), blocking would stop a fifth to two fifths
of test changes. Adds the /review checklist rule and the /investigate
timing-flake playbook.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit a3b86b10b2)

* feat(update-check): an equal VERSION with a moved main is an upgrade, via the compare API (W12)

When VERSION matches but ls-remote's main SHA differs from this checkout's
HEAD, bin/gstack-update-check asks the GitHub compare API (status, ahead_by)
through the existing receipted curl and prints
UPGRADE_AVAILABLE <ver> <ver>+<n> <sha>; a diverged or unreadable compare is
CHECK_FAILED (unknown), and behind/identical is up to date. Never fetches.

The cache gains an IDENTITY line (head, compared remote SHA, upstream): an
outcome replays only for the same HEAD and upstream, a same-remote refresh
reuses it without another compare, and a git checkout ignores a legacy
one-line cache (non-git installs keep the one-line format). The snooze is
keyed by the SHA. gstack-upgrade's question, snooze note and What's New step
handle the four-field line; gstack-skill-end reports it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 421e66d2a4)

* fix(pregate): treat planning and evidence markdown as an empty lane

A docs-only diff with no .gstack/pregate.json failed lanes as
PREGATE_NO_LANE. Planning and evidence Markdown is now a docs/no-tests
empty lane. Unmapped source files still fail closed.

Co-authored-by: Nick <surlymo@apache.org>
(cherry picked from commit e8793e59e8)

* fix(pregate): keep test-read markdown on its test lane

Unmarked Markdown that no test depends on is a docs/no-tests empty lane.
A file a test reads, including a fixture, still selects that test through
the import graph. Unmapped source and unreferenced test-tree files still
fail closed.

Co-authored-by: Nick <surlymo@apache.org>
(cherry picked from commit 7aabbddf3d)

* test(pregate): reviewer edges for the docs/no-tests empty lane (#3215)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 2fb3fa8613)

* test(pregate): requires-remote on a gbrain-shaped repo (ci:ubicloud, UBI_OWNER) (W10)

A base pregate.json with gbrain's dependencies and remote_gate
"bun run ci:ubicloud" (a package script whose stub needs UBICLOUD_API_KEY,
names VMs from UBI_OWNER and prints PREGATE_REMOTE). Under 8 cores the test
tier is one obligation naming the command; --remote passes the caller's
UBI_OWNER and key through and clears it on the report; without the key the
gate's own error shows and the obligation stays uncleared. #3190's path
needed no change.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 4d69ff02df)

* test(pregate): the ship receipt counts the timing check (7 checks)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit cb03ef67e7)

* test(context-budget): gstack-upgrade eager ceiling for the W12 equal-version upgrade question (ratchet protocol, capture value)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(approval-classes): gstack-approval-classes and ship-receipt citation enforcement (W2, WIP)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 847c7dae3d)

* feat(approval-classes): tests, docs, /review and /ship wiring (W2)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 269bd5b8b5)

* feat(review): state-machines specialist and gstack-state-matrix round-trip check with a captured #6792 regression (W3)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 1a634979c6)

* fix(review-army): keep the --simplification force flag pinned; approval test timeouts

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 789d392bce)

* test(free-fixtures): register the state-matrix-6792 fixture

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 1bacef296d)

* test(state-matrix): cover state-machines NEVER_GATE in gstack-specialist-stats

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 859063d56f)

* test(fixtures): register the pregate-timing fixtures as free-only (W4)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship-queue): full_e2e needs bound gate evidence in ready and verify, CI reads every check-run page and commit status, next: quotes its paths (review #3235 P1-1, P1-5, P2-1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): timing reads a native diff and never passes an unread one

The timing check's git diff honored diff.external, textconv and
diff.noprefix, so a configured driver hid every added line and the check
passed. Run it with --no-ext-diff --no-textconv and explicit a/ b/
prefixes, and report a git diff that fails or does not finish as
incomplete (PREGATE_INCOMPLETE), which a warn downgrade does not soften.

Review of #3235, sibling of P1-4 (lib/pregate/checks/timing.ts:189).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(approval-classes): only affirmative APPROVED evidence counts, explicit tokens are authoritative, native diffs fail closed (review #3235 P1-2, P1-3, P1-4)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(state-matrix): detect reads the native diff, ignoring diff.external and textconv (review #3235 P1-4)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(e2e): review-army-state-matrix behavior case on the #6792 fixture (field list 2, W3)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(review-army): tighten the State Machines dispatch line so /ship stays under its parity size ratio with main's #3231 review-army text

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(approval-classes): only top-level approval lines count; quoted, fenced, indented-code and commented text never does (review #3235 r2)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(approval-classes): approval evidence is the first non-blank line only, at column 0 (review #3235 r3)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(autoplan-budget): bind the reservation expectation to lib/autoplan-budget's default estimate (test-of-test ratchet)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: keep gstack-upgrade under its size budget, exempt state-matrix's fetch hint, fit the periodic plan's 37 slices

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): the docs observer accepts the spawned-prefixed gstack-skill-start --help read

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(windows): curate capy-review-voice out of the Windows subset (POSIX launcher contract)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: changelog entry for field list 2 wave A

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: stamp v1.91.115.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: the scope-signal clarity check follows helper-printed signals; the hermetic update-check fixture writes the identity cache

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* refactor: move the release-path result codes to lib/result-codes-release.ts (module-size ratchet); fit the periodic plan's 38 slices

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: changelog entry for field list 2 wave B

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: stamp v1.91.116.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: copy lib/result-codes-release.ts wherever a fixture copies lib/result-codes.ts; the scope-signal check requires the helper's computed emission

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: state-matrix stats test resolves its slug under its own state root (home containment)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(windows): curate pregate-docs-lane-review out of the Windows subset (runBin shebang, like the other pregate fixtures)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(windows): curate pregate-remote-gbrain out of the Windows subset (runBin shebang, like pregate-remote)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Nick <surlymo@apache.org>
2026-10-17 03:32:16 +00:00

579 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# The repo ship policy: `.gstack/ship-policy.json`
`/ship` reads one machine-readable file per repository that says how releases
are stamped and merged: the bump level, the queue mode, the repo's own release
tool, the paths where version mentions are rewritten, the CHANGELOG, a paired
mirror, the history policy and the preregistered evals. Without the file
nothing changes: every key has a default that reproduces today's behavior.
The file is **read from the committed copy on the base branch**
(`origin/<base>`), never from the PR's working tree, so a PR cannot smuggle a
command into its own gate. **File presence never authorizes execution:**
`gstack-restamp` runs `release_tool` only under `/ship` or with
`--allow-repo-commands`; standalone it lists the commands and exits 3 with
`repo commands not executed (pass --allow-repo-commands)`. A PR that
introduces or changes the policy runs under the reviewed bootstrap path
(`--policy-from head --allow-repo-commands`) and its receipt says
`policy: head (unreviewed)`.
Tools: `gstack-ship-policy init|validate|show-effective|check-history`,
`gstack-restamp`, `gstack-tree-receipt`, `gstack-ship-receipt`,
`gstack-ship-queue` ([shared merge queue](#run-a-shared-merge-queue)),
`gstack-evidence bundle|ancestor`. They share the exit table (`0` ok, `1`
fail, `2` usage, `3` refused or needs a flag) and end every error line with a
code from [troubleshooting.md](troubleshooting.md#queue-aware-ship-policy-restamp-tree-receipt-ship-receipt).
<a id="schema-v1"></a>
## Schema v1
`gstack-ship-policy init` writes every key at its default; `init --explain`
prints this table. JSON carries no comments, so examples live in
`.gstack/ship-policy.example.json`. Unknown keys are errors, and there is no
extension mechanism: a key from a newer gstack, or a misspelled one, fails
closed (`POLICY_INVALID`) instead of being ignored. Every key has an
introducing version (`init --explain` and `validate` print it). A key newer
than 1.91.98.0 set to a non-default value requires `min_gstack_version` at
least at its introducing version, and a gstack older than `min_gstack_version`
refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
`MIN_READER: gstack <version>`, the floor every lane reading the policy needs.
| Key | Values | Default | Meaning |
|---|---|---|---|
| `bump` | `patch` \| `ask` | `ask` | `patch` skips the MINOR/MAJOR prompt in `/ship` Step 12 and `gstack-restamp --next` bumps PATCH; `ask` keeps the prompt. |
| `queue_mode` | `claim-at-open` \| `stamp-at-merge` | `claim-at-open` | See [stamp-at-merge](#stamp-at-merge). `claim-at-open` is today's queue (`gstack-next-version` claims a slot when the PR opens). |
| `release_tool` | command or `null` | `null` | The repo's own release command (`bun run release:restamp`). Runs in an isolated staging worktree under the [release-tool contract](#release-tool-contract). |
| `release_outputs` | globs | `[]` | Paths the release tool may write beyond the version files, lockfiles, CHANGELOG and `stamp_paths`. Anything else is `RESTAMP_OUTPUT_OUTSIDE_ALLOWED`. |
| `stamp_paths` | globs | `[]` | Where exact `(was X)`, `since: X` and `vX` mentions of the branch's tentative version are rewritten on a restamp. |
| `changelog` | path or `null` | `CHANGELOG.md` | Re-headed on a restamp: the PR's `## [Unreleased]` (or old-version) section moves to the top as `## [<version>] - <date>`. |
| `mirror` | relative path or `null` | `null` | A paired checkout to restamp identically (tier 2: validated and confined today, not yet run). |
| `history` | `merge-only` \| `squash-ok` | `squash-ok` | `merge-only` makes `/ship` refuse a squash or rebase plan; `gstack-ship-policy check-history` reports a rewritten branch (`HISTORY_POLICY_VIOLATION`). |
| `preregistration_shas` | commit ids | `[]` | Each must be an ancestor of HEAD before push: `gstack-evidence ancestor --from-policy`; the ship receipt records the result. |
| `stamp_sensitive_gates` | evidence labels | `[]` | Gates that read VERSION or generated bytes (gstack's own `tests` lane does, through the agents-digest freshness test). The tree receipt never marks them reusable. |
| `pr_count` | `{ default_pr_count, split_requires_owner }` | `{1, true}` | The PR-count row in plan reviews. |
| `maintainers` | GitHub logins | `[]` | Maintainer set for `/review` contributor mode; empty means repository push permission. |
| `stamp_files` | globs | `[]` | The repo's stamp rule: when set, only these files may differ by a stamp (list the version file, manifests, plugin trees and the files holding the PR's own `since:` rows). It applies before every other stamp rule, so a doc `vX` mention outside it is a difference. |
| `stamp_patterns` | literal templates | `[]` | At most 16 templates of at most 128 characters, each with exactly one `{version}` slot (`"version": "{version}"`, `since: '{version}'`, `<!-- x-stamp: {version} -->`); matched literally at a version boundary, never as a regex. The restamp writer and the tree receipt use them beside the built-in forms. |
| `restamp_command` | command or `null` | `null` | The repo's own restamp, run first in the staging worktree; see [restamp_command](#restamp-command). |
| `migrations` | `{ dir, id }` or `null` | `null` | The migration directory and a regex with exactly one capture group for the number in a file name (`"^v(\\d+)-"`); `gstack-pregate regate` reports `MIGRATION_COLLISION`. |
| `gate_command` | command or `null` | `null` | The repo's full pre-merge gate. `gstack-ship-queue gate-ahead` and `ready` print it for the caller to run (in the background or in CI); gstack never executes it. Needs `min_gstack_version`. |
| `full_e2e` | `true` \| `false` | `false` | `true`: every merge needs a passing full-gate result bound to a tree equal to the merging one modulo stamps; `gstack-ship-queue ready`, `stamp` and `verify` refuse without one (`QUEUE_GATE_MISSING`): a gate-ahead record, or the lane's `--gate-exit 0 --gate-log <file>` from its run on the gated head. Needs `min_gstack_version`. |
| `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. |
Containment, checked by `validate` and again at write time: paths and globs are
repo-relative with no `..` segment, under 256 characters, at most 64 per list,
and a glob's regex expansion under 512 characters; `release_tool` is under 512
characters with no newline; a matched file that resolves through a symlink to
outside the repository is refused (`POLICY_CONTAINMENT`). A mirror may start
with `..` (a sibling checkout) but has no interior `..`; mirror writes are
confined to that directory.
## gstack's own files
gstack's policy (`.gstack/ship-policy.json`, tracked beside `generated.json`
and `pregate.json` under the otherwise ignored `.gstack/`) runs the repo on
stamp-at-merge: the version is allocated once, when the PR is next in the queue,
every bump is PATCH, and `/review` reads the maintainer set from the file. It
names the one release tool `gstack-version-bump --regen-digest` used to
hard-code:
```json
{
"bump": "patch",
"queue_mode": "stamp-at-merge",
"release_tool": "bun scripts/gen-agents-digest.ts",
"release_outputs": ["agents-digest/gstack-AGENTS.md"],
"stamp_paths": [],
"changelog": "CHANGELOG.md",
"history": "squash-ok",
"stamp_sensitive_gates": ["tests"]
}
```
`.gstack/ship-policy.example.json` shows every key set the way a serial-merge
repo with a release script, docs mentions and a mirror would set them.
### Queue-side procedure for gstack
Who does what under `stamp-at-merge`, for the PR author and for the thread
running the merge queue:
1. **Open.** The branch carries a tentative stamp of the base's version plus
PATCH (`gstack-version-bump write --version <base+patch> --regen-digest`)
and its CHANGELOG section headed with that version directly under
`# Changelog`; `## [Unreleased]` is also accepted. Nothing is claimed and
`gstack-next-version` is not consulted; the tentative version is only the
guess that makes the common case free. CI runs on this head as usual.
2. **Register.** The queue records the PR and its intended order.
3. **Stamp when next.** With every check green on the current head and
`origin/main` at the SHA the queue assumed, the PR owner (never the queue)
runs on the branch:
`gstack-restamp --next --base main --allow-repo-commands --pr <n> --expect-base <main-sha> --expect-order <pr,...>`.
When the base has not moved past the tentative stamp it prints
`RESTAMP: X -> X (noop)` and `FILES: (none)`, and the green head is the
merge candidate with no further CI. When the base moved, it stamps base +
PATCH, re-heads the CHANGELOG above the base's top entry, reruns the
release tool, prints `STAMP_VERSION: <X>` and the stamp diff, and refuses
with `QUEUE_STALE` (nothing written) when the base or order moved after
`--expect-base` was read. The owner commits and pushes the stamp commit,
lets CI run on it, and reports `READY #<n> <sha> STAMP_VERSION=<X>`.
4. **Merge.** The queue verifies on that exact SHA: `VERSION` equals
`STAMP_VERSION`, the CHANGELOG's top heading is `[<X>]` and the heading
below it is the base's current version, every check is green on that SHA,
and, when a stamp commit was pushed, it changed only `VERSION`,
`package.json`, `CHANGELOG.md` and the release outputs
(`gstack-tree-receipt --gated <green-head> --current <sha>` prints the
per-file verdict). It then squash-merges with the merge API's expected-head
`sha` set to that SHA, so a late push is rejected by the server. "VERSION
above main" is replaced by "VERSION equals the restamp's STAMP_VERSION on
the SHA being merged, and that version is base + PATCH at merge time".
5. **Not next.** A PR waiting behind another may gate ahead locally with
`gstack-restamp --after <predecessor-pr>`; it pushes nothing. When the
predecessor lands, the waiting PR merges `origin/main` in (never a rebase),
keeping its own CHANGELOG section above the newly merged one, and step 3
runs.
Until this policy is on `origin/main`, `gstack-restamp --next` reads the base's
`bump: ask` and falls back to a MICRO bump; pass `--bump patch` on the first
PR stamped after the flip if its base predates it.
<a id="run-a-shared-merge-queue"></a>
## Run a shared merge queue
`gstack-ship-queue` strings `gstack-restamp`, `gstack-tree-receipt` and the
ship receipt into the coordinator protocol, so nobody proves a stamp diff by
hand. Lanes own their PRs and never merge; the coordinator owns the order and
every merge. Each step ends in a line the other side acts on, then
`GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>`. Nothing
here pushes.
**1. Lane: report ready (unstamped).** After the gate and pregate pass on the
pushed head:
```bash
gstack-pregate run --allow-repo-commands
git push
gstack-ship-queue ready --pr 42
```
```
READY #42 <head> STAMP_VERSION=unstamped
pr: https://github.com/<owner>/<repo>/pull/42
head: <head>
version: unstamped
base: origin/main@<sha>
ci: green at <head12> (passed=31 failed=0 pending=0 skipped=2)
PREGATE: pass tree=<tree12> head=<head12> checks=6
gate: command="<gate_command>" full_e2e=no gate-ahead=none
SHIP_RECEIPT: pr=42 head=<head12> …
```
It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD
is not the PR's pushed head, CI at that exact head is not green, or the pregate
result is not a current pass. Send the block as printed.
**2. Lane behind another PR: gate ahead.** Gate the tree the queue will have
once the predecessor lands, while it is still open:
```bash
git fetch origin pull/41/head
gstack-ship-queue gate-ahead --after 41 --pr 42 --predecessor-ref FETCH_HEAD --allow-repo-commands
```
prints `GATE_AHEAD: pr=42 attempt=<id> gated=<sha> tree=<tree> merge_tree=<tree>
predecessor=#41@<sha12> version=<N+1>`, the attempt's own `worktree:`, its
`record:` file, `GATE_COMMAND:` (the policy's `gate_command`) and the exact
`next:` line. Run the gate there, in the background or in CI, then bind it:
```bash
(cd <worktree> && <gate command>) > gate.log 2>&1
gstack-ship-queue gate-ahead --record --pr 42 --attempt <id> --exit $? --log gate.log
```
`GATE_RECORD: pr=42 attempt=<id> exit=0 gated_tree=<tree> log_sha256=<sha256>
current=yes`. The record keeps the log and removes only that attempt's
worktree, and refuses (`GATE_AHEAD_ATTEMPT_INVALID`) when the worktree no
longer holds the gated commit. If the predecessor moves, prepare a new
attempt; attempts never share a worktree, the newest is current, and
recording an older one later prints `current=no (superseded by <id>)`.
`gate-ahead --list --pr 42` shows them all. Send the record file with READY.
**3. Coordinator: call the slot.** Name the version, the base sha and the
order: "#42 next at 1.2.7.0, base `<sha>`, order 41,42".
**4. Lane: stamp.** A gate-ahead lane first merges `origin/main` (with the
predecessor in it) into its branch, never a rebase. Then:
```bash
gstack-ship-queue stamp --pr 42 --version 1.2.7.0 --expect-gated <gated> \
--expect-base <sha> --expect-order 41,42 --allow-repo-commands
```
`<gated>` is the head the gate ran on: the pushed head the lane gated, or the
record's `gated=` after a gate-ahead. The preflight refuses before anything is
written unless the tree to stamp equals the gated one modulo stamps, the base
is `<sha>` and, under `full_e2e`, a passing gate result is bound to the gated
head. Then it runs the restamp, commits, reruns the tree receipt and prints
```
QUEUE_STAMP: 42 1.2.7.0 head=<sha> gated=<sha> tree=same-modulo-stamps receipt=<sha256>
```
Each stage is journaled in `.gstack/tmp/ship-queue/stamp-42.json`; the
identical command resumes at the first unfinished stage, or returns the stored
`QUEUE_STAMP` when it already finished. A refusal prints
`QUEUE_STAMP_REFUSED: 42 head=<sha> committed=no|yes <sha> stage=<stage>`
and the coded line (`QUEUE_STALE` carries a `recompute:` command). Push the
stamp commit, wait for CI on it, run `ready` again
(`READY #42 <head> STAMP_VERSION=1.2.7.0`) and send it with the
`QUEUE_STAMP` line.
**5. Coordinator: verify, then merge.** From the coordinator's own clone:
```bash
gstack-ship-queue verify --pr 42 --gated <gated> --receipt <sha256> --expect-head <head> [--gate-exit 0 --gate-log <file>]
gstack-ship-queue verify --pr 42 --record <gate-ahead record> --receipt <sha256> --expect-head <head>
```
It fetches `refs/pull/42/head` and reruns the tree receipt there, so
`QUEUE_STAMP` is never a pasted claim: `VERIFY: 42 1.2.7.0 head=<sha>
gated=<sha> tree=same-modulo-stamps receipt=<same sha256>`. Under `full_e2e`,
`--gated` also needs the lane's passing gate log from its run on that head
(`--gate-exit 0 --gate-log <file>`), else `QUEUE_GATE_MISSING`. With a gate-ahead
record, whose gated commit never left the lane's machine, it first recomputes
the merge with `git merge-tree --write-tree <branch head> <predecessor head>`
and compares it with the recorded merge tree, rebuilds the gated tree from
the recorded stamp patch and compares it with the recorded `gated_tree`,
proves that patch is stamps only, checks the predecessor did not move, and
prints `merge_tree:`, `gated_tree:` and `gate:` lines. The last line is
`MERGE_CONDITION: sha=<head>`: merge with that expected head. Any
`QUEUE_VERIFY_MISMATCH` means do not merge.
**Rolling out `gate_command` and `full_e2e`.** Commit them (with
`min_gstack_version` at their introducing version, which `init --explain`
prints) only after every lane's gstack is at least that version, the crew's
install floor (`GSTACK_MIN_REVISION`). A lane on an older gstack refuses the
policy by name rather than reading it differently.
<a id="ship"></a>
## What `/ship` reads from it
`/ship` Step 3 runs `show-effective --base <base>` and follows its `ship:` lines,
which carry the workflow consequences the skill prose would otherwise spell out:
| Line | Effect |
|---|---|
| `ship: history merge-only: …` | never rebase, squash or force-push; `check-history --plan <merge\|squash\|rebase>` refuses a rewrite plan (`HISTORY_POLICY_VIOLATION`, exit 3) |
| `ship: queue_mode stamp-at-merge: …` | Step 12 items 1–4 are replaced by `gstack-restamp --next --base <base> --pr <n> --expect-base <sha>` when the PR is next, `--after <predecessor-pr>` when it is not; the restamp's own `next:` line says what follows (NEW_VERSION, CHANGELOG entry text only, the merge's expected head) |
| `ship: shared merge queue: …` | in a coordinator-run queue, `gstack-ship-queue ready`, `gate-ahead` and `stamp` replace the restamp calls; see [Run a shared merge queue](#run-a-shared-merge-queue) |
| `ship: gate-ahead: …` | with `.gstack/tmp/gate-ahead.json` present, Step 16 reuses only the lanes `gstack-tree-receipt … --json > .gstack/tmp/tree-receipt.json` marks reusable; stamp-sensitive and not-eligible lanes run live; `--force-gate <reason>` is the coordinator's call |
| `ship: bump patch: …` | Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size |
| `ship: preregistration: …` | `gstack-evidence ancestor --from-policy --base <base>` must print only `ANCESTOR: ok` before push |
| `ship: release_tool: …` | the command runs only inside `gstack-restamp` (staging worktree, outputs confined to `release_outputs`) |
The PR body's first section is the ship receipt (`gstack-ship-receipt write … --body`),
written by the pr-body section of `/ship` after the body is composed.
<a id="stamp-at-merge"></a>
## `queue_mode: stamp-at-merge`
Under `stamp-at-merge` nothing is claimed when a PR opens. The stamp happens
when the PR is next, from the base branch's version at that moment, through
the repo's own release tool, across every stamp location at once:
```bash
gstack-restamp --next --base main --allow-repo-commands --pr 42 \
--expect-base <sha of origin/main the queue assumed> --expect-order 40,41,42
```
prints `ship, next in queue at <version>`, `STAMP_VERSION: <version>`, one
line per stage, the diff limited to stamp files, `MERGE_CONDITION: sha=<head>`
(pass it to the merge API's expected-head `sha` so a check-to-merge race is
rejected by the server) and the `GSTACK_RESULT:` line. `claims: ignored
(stamp-at-merge)` says the legacy claim reader was not consulted. When the base
or the order moved, the result is `QUEUE_STALE` (exit 3) with the exact
`recompute` command; nothing is written.
Stages, each journaled in `.gstack/tmp/restamp-journal.json`:
0. **restamp_command** (only when the policy sets it): the repo's own restamp, see [restamp_command](#restamp-command).
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version.
2. **release_tool**: the policy command, under the contract below.
3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under `<X>`, above its predecessor's entry. Duplicate headings (`CHANGELOG_DUPLICATE_HEADING`) and a top entry that is not `<X>` (`CHANGELOG_TOP_MISMATCH`) are errors.
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. `X` is the branch's own tentative stamp (its VERSION when it moved past base), else `--was <X>`, else the version an unstamped branch would have taken next (`base + bump`); mentions of a version the base never released (not a CHANGELOG heading at base, not a tag) are rewritten too, so a queue reorder that moves the base under a branch still finds its `since:` fields. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
5. **mirror**: tier 2; declared and validated, reported as not run.
6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage).
Idempotent: a second run with the same version is `noop`. `--dry-run` prints
the full diff and writes nothing (not even the journal). `--version <X>` stamps
exactly X; `--bump` overrides the level; `--queue-mode` overrides the policy.
### Gate-ahead: `--after <pr>`
A reorder is cheap only when the gate can run before the predecessor merges.
`gstack-restamp --after <pr> --predecessor-ref <ref>` merges HEAD and the
predecessor's head in a throwaway worktree (`.gstack/tmp/gate-ahead-worktree`),
resolving only version-file and CHANGELOG-ordering conflicts, stamps N+1
relative to the predecessor's version there, commits, and records
`.gstack/tmp/gate-ahead.json` (`predecessor_head`, `branch_head`,
`gated_head`, `gated_tree`, `version`, `evidence_branch_key`). The PR branch's
history is never touched, so squash-history PRs stay clean. The gate runs in
that worktree; `gstack-evidence bundle` there exports the evidence.
<a id="release-tool-contract"></a>
### The release-tool contract
The release tool runs in an isolated staging worktree seeded from the working
tree's content fingerprint (`gstack-wtree`), with the repo's ignored
`node_modules` linked in. Its outputs are enumerated: the version files,
lockfiles, the CHANGELOG, `stamp_paths` and `release_outputs`. A write outside
that set refuses the whole restamp before anything reaches the branch
(`RESTAMP_OUTPUT_OUTSIDE_ALLOWED`); a non-zero exit is
`RESTAMP_RELEASE_TOOL_FAILED`. Preimages and postimages of every write are
recorded in the journal. Pinning the command string does not freeze its
implementation (`bun run release:restamp` still runs the PR's package script),
so the receipt names the policy revision, the command, its working directory
and the trust scope (`policy: base origin/main@<sha>` or `head (unreviewed)`).
<a id="restamp-command"></a>
### `restamp_command`: the repo owns the stamp
A repo whose own tool knows its stamp locations sets `restamp_command`.
`gstack-restamp` runs it as the first stage inside its staging worktree, a
clean checkout of HEAD, with the allocated version as the last argument and in
`GSTACK_RESTAMP_VERSION`. The caller's HEAD, index and worktree must be clean
first (`RESTAMP_CONFLICT`, nothing written); the generic path keeps its
working-tree fingerprint seed. Anything the command stages or commits in the
staging worktree is folded back into plain edits; then output confinement
applies as for the release tool (`RESTAMP_OUTPUT_OUTSIDE_ALLOWED`), the
version file must hold the target version (`RESTAMP_VERSION_SOURCE`), and a
non-zero exit is `RESTAMP_RELEASE_TOOL_FAILED`. In every failure nothing
reaches the branch. gstack's version writer is skipped; the CHANGELOG and
`stamp_paths` stages still run (a no-op when the tool already did the work).
Read from the base branch and run only with `--allow-repo-commands`, like
`release_tool`. A tool that fetches, merges the base branch or needs a version
of its own choosing cannot run here; give it a mode that takes the version and
writes only stamps.
<a id="tree-receipt"></a>
## The tree receipt
```bash
gstack-tree-receipt --from .gstack/tmp/gate-ahead.json --predecessor-pr 41 --bundle evidence-bundle.json
gstack-tree-receipt --gated <sha> --current HEAD --json
```
Compares two **committed heads** (a working-tree fingerprint can name objects
unreachable once the gate VM is gone) and prints the gated and current heads,
both tree ids, the version pair, the predecessor the gate assumed, the diff
limited to stamp files, one line per file with its rule and verdict, one line
per gate, then two verdict lines:
```
TREE: same-modulo-stamps | changed (<residual paths>)
gate-reuse: eligible (reusable: tests; rerun: digest) | not-eligible (<what changed>) — fix: <rerun command> (GATE_REUSE_NOT_ELIGIBLE)
```
Hunks are classified per file type: the version file (old → new only), the
manifest (`version` key only), npm and bun lockfiles (root `version` keys
only, where `gstack-version-bump` writes; a dependency entry is `changed`),
the CHANGELOG (a heading move or re-heading, compared as an order-independent
multiset of entries), `stamp_paths` and `release_outputs` (every
removed/added pair must be an exact `(was X)` / `since: X` / `vX` rewrite with
X the gated version and the replacement the current one, so a dependency pin
that happens to equal the old version is `changed`; `stamp_patterns`
templates count as stamp forms too). With `stamp_files` set, a file outside it
is `changed (stamp_files: not in the repo's stamp_files allowlist)` whatever
its diff. Any other byte is a change.
Equivalence is decided per gate. A lane in `stamp_sensitive_gates` always
`rerun`s; a verified lane is `reusable` only when the tree is same-modulo-stamps
and the execution identity in the bundle equals the current one: Bun and Node
versions, OS and arch, every lockfile's identity (root version normalized
away), the `.github/workflows` tree id, runtime pin files and
`package.json#engines`, and the runner image when either side knows it. Without
a bundle the identity is unknown and the receipt is `not-eligible`. When the
recorded predecessor head is no longer the predecessor PR's head (checked
through `--predecessor-ref` or `gh pr view --predecessor-pr`) the receipt
prints `PREDECESSOR MOVED <old> -> <new> — fix: gstack-restamp --after <pr>
again (RESTAMP_PREDECESSOR_MOVED)` and refuses `eligible`.
`/ship` Step 16 consults the receipt; when it says `eligible`, a repeated full
gate needs `--force-gate <reason>`, and the coordinator makes the call.
### The evidence bundle
`gstack-evidence` is machine-local by design, so the portable form is a bundle:
```bash
gstack-evidence bundle --out evidence-bundle.json --label tests --label e2e --require tests,e2e \
--selection tests='bun run test' --run-url tests=https://github.com/.../actions/runs/1
```
A bundle binds to a committed head (`repo.commit`, `repo.tree`) and carries the
comparison material (version source, CHANGELOG path, `stamp_paths`,
`release_outputs`), the execution identity above, the required-lane manifest,
and per lane the command, `cmd_sha256`, exit, timestamp, executed selection,
run URL, log hash and whether it is `verified` (produced by the ledger on a
clean committed tree at this head) or not (dirty tree, other commit, or an
`--import`ed assertion). A fresh coordinator machine reads it with
`gstack-tree-receipt --bundle` after the gate VM is gone; the receipt refuses
reuse when the bundle was produced on another tree or lacks the identity
(`EVIDENCE_IDENTITY_UNKNOWN`).
<a id="ship-receipt"></a>
## The ship receipt
The handoff record is a fenced block, identifiers first, validated against
`gstack-artifact schema ship-receipt`:
````
```gstack-ship-receipt
{ "schema_version": 1, "pr": 42, "head": "<sha>", "version": "1.2.7.0", "base": "main",
"gated_tree": "<tree>", "predecessor": "<sha>", "tree": "same-modulo-stamps",
"gate_reuse": "eligible (reusable: tests; rerun: digest)", "gate": { "tests": { "pass": 10, "fail": 0, "skip": 1 } },
"ci": { "checks": 3, "failed": 0 }, "spend_usd": 0.42, "session_kind": "unattended",
"artifacts_consumed": "1/1", "run": "<run id>", "thread": "<thread id>", "policy": "base origin/main@<sha>" }
```
````
`gstack-ship-receipt write --pr <n> --receipt <tree-receipt.json> --gate '<json>' --ci '<json>' --body <pr-body.md>`
makes it the first section of the PR body (`/ship` Step 18) and prints
`SHIP_RECEIPT: pr=… head=… version=…` for the first line of the completion
message. `/land-and-deploy` reads it with `gstack-ship-receipt read --pr <n>
--json` (`RECEIPT_MISSING`, `RECEIPT_INVALID`).
### The adoption census (P7)
```bash
gstack-ship-receipt census --repo garrytan/gstack --since v1.91.70.0 --cohort threads.txt
```
reads merged PRs since the tag through `gh`, parses each receipt, deduplicates
by `run` and `thread`, and prints `CENSUS: … unattended=<n> consumed=<n>
adoption=<consumed threads>/<cohort> durable=yes`. The cohort file lists one
thread id per line (threads that never invoke gstack count in the
denominator); without it the denominator is the threads the receipts name.
## History policy and preregistration (C7)
Under `history: merge-only`, `/ship` refuses a squash or rebase plan and merges
the base branch instead: `gstack-ship-policy check-history --plan squash`
prints `HISTORY: refused (…) (HISTORY_POLICY_VIOLATION)` with exit 3, and
without `--plan` it prints `HISTORY: ok` or `HISTORY: rewritten …
(HISTORY_POLICY_VIOLATION)` (exit 1) when the pushed branch is no longer an
ancestor of HEAD. `gstack-evidence ancestor
--from-policy --base main` prints one `ANCESTOR: ok|missing <sha>` line per
`preregistration_shas` entry (`PREREGISTRATION_NOT_ANCESTOR`, exit 1), and the
ship receipt records the result in `preregistration`.
## Contributor mode
`/review` runs `gstack-contributor-mode detect` at Step 1 (decision D10). The PR
author is outside the maintainer set when `maintainers` in `.gstack/ship-policy.json`
on the base branch is non-empty and does not list the login (`source=policy`);
with an empty list, when `gh api repos/<owner>/<repo>/collaborators/<login>/permission`
reports less than push permission (`source=collaborators`, an egress receipt
`contributor-mode` precedes the call); when neither is available the mode is off
and `CONTRIBUTOR_SOURCE_UNAVAILABLE` names what was missing. `--contributor`
forces it on (`source=flag`). `CODEOWNERS` is file ownership, not maintenance
authority, and is never read. The printed line is
`CONTRIBUTOR_MODE: on|off author=<login> source=flag|policy|collaborators|off`,
followed when on by the evidence-first instructions: a table per change
(evidence value, risk surfaces, `accept` / `supersede` / `decline`), where
`accept` keeps a direct path for a correct outside implementation judged by the
same bar as a maintainer change, `supersede` rewrites from our own plan only after
a plain-language approval, and the credit lines (`Contributed by @handle`,
`Co-Authored-By:`, `Supersedes #N`) appear only when a diagnosis or a test carried
over. `gstack-contributor-mode instructions` prints the same text without detecting.
## Banned terms
`.gstack/banned-terms.json` lists terms the repository forbids outside named
locations: `terms` (a string, or `{ "term", "allowed_paths", "reason" }`) and a
top-level `allowed_paths` that every term inherits. Globs follow the same
containment rules as the ship policy (repo-relative, no `..`, under 256
characters). `gstack-banned-terms check --base <ref>` scans the lines the branch
adds since its merge base (or whole files with `--files <csv>`), reading the term
list from the committed copy on the base by default (`--policy-from head` reads
the working tree). Each finding is one line,
`BANNED_TERM: "<term>" <file>:<line> allowed: <locations> — <text>`, and the
last line is `BANNED_TERMS: ok | found=<n> | none` (`none`: the repository keeps
no list). Exit 0 for ok or none, 1 when found, 3 when the file is invalid
(`BANNED_TERMS_INVALID`, also from `validate`). `/review` turns each finding into
a Step 4 finding and `/document-release` into a documentation finding;
`gstack-banned-terms init` writes a commented example.
<a id="approval-classes"></a>
## Approval classes
`.gstack/approval-classes.json` names the changes that need the owner's
approval before they ship: upgrade-time data rewrites, trust or visibility
changes, existing-user default changes, data repairs, anything the owner wants
cited. Each entry in `classes` has a `name` (lowercase, dashes) and at least
one matcher: `paths` (globs of changed files), `patterns` (literal text in an
added line, case-sensitive) or `migrations: true` (the files the ship policy's
`migrations` key describes). `allowed_paths` exempts files, such as tests;
`description` is for people. Globs follow the ship policy's containment rules.
Classes come only from the copy committed on `origin/<base>`, so a branch can
neither declare nor drop its own; `gstack-approval-classes validate` checks the
working-tree copy before you commit it, and `init` writes an example.
`gstack-approval-classes check --base <ref>` scans the branch's changes since
the merge base and prints one `APPROVAL_CLASS: <class> <file>:<line>` line per
hit, one `APPROVAL_SCOPE: <class>@<digest12>` line per declared class with hits,
and `APPROVAL_CLASSES: ok | found=<n> | none` last (exit 0, 1 when found, 3 when
the base file is invalid or `origin/<base>` does not resolve). Built-in
detectors only report: the ship policy's migration files, when no class sets
`migrations: true`, print as `(built-in, report-only)` and need no citation.
The digest covers the class's effects: each matched file with its blob at the
scanned head, and each matched added line. The scan reads git's native diff
(`--no-ext-diff --no-textconv`), so a configured external diff driver or text
conversion cannot hide a line; when the content diff cannot be read, or its
added lines disagree with git's own count, `check` and the receipt refuse with
`APPROVAL_SOURCE_UNAVAILABLE` instead of passing. Adding, removing or editing an
effect changes the token, so an approval given earlier no longer binds.
**Enforcement.** `gstack-ship-receipt write` refuses with
`APPROVAL_CITATION_MISSING`, writing nothing, unless each declared class with
hits has a line in the `--body` file (or an `--approval` value) of the form
```
Approved by <owner> <YYYY-MM-DD> via <ref> [for <class>@<digest12>[,<class>@<digest12>]]
```
that resolves and binds:
- A plan sha (7 to 64 hex characters) must match the plan of an autoplan run
passed with `--approval-run <run dir>` whose gate is APPROVED, and the plan
must name the scope token, or the class and every effect path.
- A GitHub comment or review URL must be written by a `maintainers` login from
the base's ship policy that equals the cited owner. A review must be in
state `APPROVED`; a comment-only, dismissed or changes-requested review is
not an approval. The evidence is the body's first non-blank line only. It
must start at column 0 with `Approved` or `Approve` (any case, optionally
in `**` bold; no list marker, quote or indent) and name the token, or the
class and every effect path. Nothing later in the body counts, so a
rejection that quotes or nests an example approval approves nothing. For
example, a comment whose first line is
`Approved data-repair@0123456789ab: preview-first, never at upgrade.`
verifies; one that opens with any other line does not.
- A token is authoritative. Evidence that names the class with a different
digest approved earlier effects: it does not bind, and it never falls back
to matching the class and paths.
- Anything else (a thread code, a chat message, a URL gstack cannot read) is
recorded `citation: unverified` for the coordinator, and only when the line
attests which scope it covers with `for <class>@<digest12>` matching the
current token.
The receipt's `approval` object records the source, `status` (`checked`, or
`unavailable` when `origin/<base>` does not resolve), one record per class
(scope token, effects, `citation: verified | unverified`, owner, date, ref,
`binding: token | paths | attested`, evidence) and any report-only hits; the
`SHIP_RECEIPT:` line ends with `approval=verified`, `approval=unverified:<classes>`,
`approval=none` or `approval=unchecked`. A repository without the file gets no
`approval` object unless a built-in detector reported.
A data repair is a class to declare, and /review checks its shape: explicit,
preview-first, and never reachable from an upgrade or migration path.