The lander must accept each owner-approved gate change before a PR merges (GST-2491) (#4304)

* Let an owner recorded approval close a gate finding in unattended ship (GST-2491)

An unattended /ship logs every gate finding open and nothing could close it, so a PR with an owner-approved gate change could never finish. gstack-approvals gate records the owner disposition outside the run; it closes only that fingerprint on a review row whose range holds the head, and finish, the receipt, the PR body and READY all carry it.

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

* Keep the pinned review brief unchanged; document the resume steps for a dispositioned gate finding (GST-2491)

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

* Bind a gate disposition to its gate change and show it as kept to the rest of /ship (GST-2491)

A disposition now names the finding's gate_id as well as its fingerprint, so a later loosening of the same line stays open. gstack-review-read shows a closed gate finding as kept with the disposition's approval id, so the existing --blocking-open, --gate-open, draft and Gate edits rules carry it without prose changes, and an unreadable approval record applies no disposition instead of failing the reader.

Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: s11/outside-voice#1 reader=outside-voice
Review-Finding: s11/outside-voice#2 reader=outside-voice
Review-Finding: s11/adversarial-native#1 reader=adversarial-native
Review-Finding: s11/adversarial-native#2 reader=adversarial-native
Review-Finding: s11/adversarial-native#4 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* List the Gate disposition line in the receipt and prose troubleshooting entries (GST-2491)

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

* Require the lander to accept each owner gate disposition before merging (GST-2491)

Land and a new read-only verify --pin re-derive the dispositions the approval store applies at the pin and merge only with --accept-gate-disposition for each one. The gate writer refuses while any worktree holds an open /ship run on the branch, under a lock; a row counts only in the writer's full shape with its digest; receipt write needs --body when a disposition applies; ready and land cross-check the receipt and body against the store. Supersedes #4299.

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

* Regenerate the result-code anchors for the gate-disposition refusal (GST-2491)

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

* Always record the receipt's gate dispositions and refuse a store that disagrees at merge (GST-2491)

The receipt always carries gate_dispositions (an empty list when none apply) and write refuses on an unreadable store or a git error. Land, verify and ready refuse a receipt id the merge-time store lacks, naming the likely wrong state root or mirror, and a field-less receipt beside an applied row. A git ancestry error while deriving dispositions refuses instead of reading as not applied.

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

* Count a disposition whose head a rewrite removed as not applied, and expect the receipt to end with gate_dispositions (GST-2491)

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

* Trim and check the gate disposition recorder name, and let a parked run's ended lease stop blocking it (GST-2491)

Review-Finding: s11/outside-voice#3 reader=outside-voice
Review-Finding: s11/outside-voice#2 reader=outside-voice
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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-12 01:49:08 +00:00
1 parent 842fef1f95
commit b1f78301db
28 files changed
+1050 -38

No files matched your search

+98
View File
@@ -1718,6 +1718,103 @@ Pre-Landing Review and the PR body, `gstack-ship-receipt write` records them as
`ruled_notes` (each with its `head`) and refuses a `--body` that does not carry each
line word for word, and the READY block prints each as `RULED_NOTE:`.
<a id="gate-dispositions"></a>
**Gate dispositions (GST-2491).** A ruling never closes a gate finding (a loosened
gate the review logged with a `gate` tag and the hunk identity `gate_id`). In an
interactive session the human answers `review-gate-disposition`; in an unattended
one `gstack-review-log` logs every gate finding `open`, and the log stays so. When
the owner approved the gate change, the owner's answer goes through the approval
record instead, recorded outside the lane's /ship run at the stop head, one finding
per row:
```bash
gstack-approvals gate --finding '<the gate finding's fingerprint>' --gate-id <its gate_id> --head <stop head> \
--reason '<the gate change, one line>' --words '<the owner's exact words>' --by '<who recorded>'
```
The row is an approved `gstack-approvals` row with scope `gate_disposition
<fingerprint> <gate_id> <head>`, ask `gate_disposition <fingerprint> <gate_id> at
<head>: <reason>` and the owner's words in `words` (`lib/gate-dispositions.ts`).
`gate` refuses a missing `--gate-id` (or a literal `undefined` or `null`), empty
`--words`, a reason, words or `--by` with a newline or control character (each is
trimmed first), a `--head` that is not a commit, a `--by` naming gstack, a row that
`dispositions` would not list (a head that is not 40 hex, for one), and any call
while a /ship run is open on the branch in any worktree of the repository: its
marker counts while its lease is open, the same test preflight uses, so a run parked
before finish stops counting once its 120-minute lease ends. It finds those worktrees
through the git common dir, and it checks and appends under a lock there that
/ship's preflight also takes to write its marker. `add` refuses the scope and
`--source gate`, so `gate` is the only writer. A row counts only when it has every
field `gate` writes: an approval-shaped id that its hash reproduces, `source: gate`,
`pending: none`, the store's own branch, `date`, `recorded_at`, `by`, one-line
`words` and a `digest` over those fields. The digest catches a row that was edited
or typed by hand. It isn't authentication: anyone who can read the code can compute
it. A gate finding with no `gate_id` is never closed, and a row gstack wrote is
ignored. A
disposition closes a gate finding only on a review row that carries that exact
fingerprint and `gate_id` and whose `range_base..commit` holds the head (a first
pass, with no `range_base`: its own commit), so a later, different loosening of the
same line (a new `gate_id`) stays open. Anything else closes nothing, and a
review-log field (`kept`, `reason`, `disposition`, `gate_dispositions` or any claim
the lane writes on its row) never makes one. `gstack-review-read` shows each closed
finding as `kept` with `reason: owner disposition <id>: <reason>` and
`disposition: <id>`, lists the ids in the row's `gate_dispositions` and grades the
row as if the findings were kept, so /ship's gate rules (`--blocking-open`,
`--gate-open`, the draft state, the `Gate edits:` counts) read them as kept once it
reads the row again. As with rulings, a row derived from a findings file
(`status_source: derived`) is not cleared. Nothing is hidden: finish prints each as
`GATE_DISPOSITION: <fingerprint> [gate <gate_id>] at <head12> by <who> at <time>:
<reason> — "<words>" (approval <id>)`, ending `(… not applied)` when no review row's
range holds it; `gstack-approvals dispositions` prints one `Gate disposition: …`
line each for the PR body; `gstack-ship-receipt write` records them as
`gate_dispositions` and refuses a `--body` that does not carry each line word for
word; and the READY block prints each as `GATE_DISPOSITION:`
([troubleshooting](troubleshooting.md#gate-finding-open-unattended)).
<a id="gate-disposition-acceptance"></a>
**Who authorizes a disposition.** A lane is a same-user agent, so it can still forge
a well-formed disposition row in its own approval store. What authorizes a
disposition is the lander's explicit acceptance: `gstack-ship-queue land --pin`
merges one only when the lander passes `--accept-gate-disposition <approval id>`
for it.
Land works the dispositions out again at the pin from the PR branch's approval
store: the local record and its mirror. A lander on another machine reads the
mirror the owner recorded into, so set `GSTACK_APPROVALS_DIR` to that directory
when it isn't the default. Land counts every disposition whose head the pin
contains. In a full clone, a row whose head commit is gone (the branch was
rewritten and garbage-collected, or the name reused) doesn't apply, because the
pin's history is all there and can't contain it. A pin missing from the clone
refuses, and so does a shallow clone that can't prove a row's ancestry: deepen it
with `git fetch --unshallow` and rerun. It never trusts the receipt or the PR body for this, because the lane
writes both. It prints each one as a `GATE_DISPOSITION:` line, then
`GATE_DISPOSITIONS: applied=<ids> accepted=<ids> store=<file>`. It refuses with
[`QUEUE_GATE_DISPOSITION`](troubleshooting.md#queue-gate-disposition), and merges
nothing, when an applied disposition isn't accepted, an accepted id isn't applied,
the receipt's `gate_dispositions` leave one out, change one or add one, or the PR
body lacks its `Gate disposition:` line. The review rows stay on the lane's
machine, so land can't tell which finding a disposition closed. It asks the lander
to accept every disposition the store holds for the pin, which is never fewer.
`gstack-ship-queue verify --pr <n> --pin <sha> [--accept-gate-disposition <id>]...`
runs the same check read-only (`pinAcceptance`, the function land calls), for a
lander that merges some other way, such as a REST merge pinned to the head. It
exits 0 only when every disposition is accepted. `ready` prints the same lines and
refuses the same receipt and body mismatches, but asks for no acceptance.
`gstack-ship-receipt write` refuses to write a receipt while a disposition applies
at HEAD unless `--body` carries each `Gate disposition:` line.
The receipt always records `gate_dispositions`, as an empty list when none
applies, and write refuses, writing nothing, when the store or git can't be read.
Land, verify and ready refuse when the receipt's set and the store's set at the pin
differ either way. A receipt id the store lacks usually means the lander reads
another `GSTACK_STATE_ROOT` or approvals mirror than the owner recorded into, or
that the row was removed. A git error while checking a head refuses too. A receipt
with no `gate_dispositions` field was written before receipts recorded it, and it
lands only when the store applies nothing. The receipt is written by the lane, like
every other gate signal it carries. The lander's acceptance guards against a
mistaken or missing store, not against a lane that falsifies its own receipt.
<a id="test-lane-exceptions"></a>
**Test-lane exceptions (GST-1595).** A lane `accept` carries from CI refuses with
`inventory_uncovered` when an inventory file is in no run's `tree.json`; when bun
@@ -2512,6 +2609,7 @@ release-file rows):
| the PR head is the pin | [`QUEUE_LAND_PIN_MOVED`](troubleshooting.md#queue-land-pin-moved) |
| the PR body's /ship receipt is bound to the pin | `QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE` |
| the receipt's `base` is the queue's base | [`QUEUE_RECEIPT_BASE_MISMATCH`](troubleshooting.md#queue-receipt-base-mismatch) |
| every owner gate disposition the approval store applies at the pin is accepted with `--accept-gate-disposition <id>`, every accepted id is applied, and the receipt and PR body list exactly those ([gate dispositions](#gate-disposition-acceptance)) | [`QUEUE_GATE_DISPOSITION`](troubleshooting.md#queue-gate-disposition) |
| a READY note from `ready` exists at the pin | `QUEUE_NOT_READY` |
| no freeze is active (with `--repair`: one is, and its repair slot is free or already this pin; a backstop freeze left from before `release_gate` only warns; see [direct freeze](#direct-freeze)) and no land lock is held | [`QUEUE_TRAIN_FROZEN`](troubleshooting.md#queue-train-frozen), `QUEUE_NOT_READY`, `QUEUE_LAND_LOCKED` |
| VERSION, the `package.json` version, the CHANGELOG and `.gstack/version-path` are unchanged since the merge base | [`QUEUE_LAND_STAMPED`](troubleshooting.md#queue-land-stamped) |
+61 -1
View File
@@ -3727,9 +3727,12 @@ results, prereg or eval path, and names no money, security, secret or auth topic
needs the reader again (GST-1195). With `--body`, write also refuses, writing
nothing, a body that does not carry each line the receipt requires as one line,
word for word: every `Ruled note: …` line `gstack-approvals rulings` prints,
every `Gate disposition: …` line `gstack-approvals dispositions` prints (and while
a gate disposition applies at HEAD, write refuses to run without `--body`),
finish's `MECHANICAL:` lines as `Mechanical retake: …`, and accept's
`TEST_LANE_EXCEPTION:` lines as `Test-lane exception: …`
([ruled notes](ship-policy.md#ruled-notes),
[gate dispositions](ship-policy.md#gate-dispositions),
[test-lane exceptions](ship-policy.md#test-lane-exceptions)).
**Fix.** Regenerate it with `gstack-ship-receipt write`; never hand-edit the
@@ -5777,6 +5780,33 @@ doesn't describe what would merge. Nothing was merged.
**Fix.** The lane runs /ship and `ready --pr <n> --no-screen` at the new head
and sends a new READY; land that pin.
<a id="queue-gate-disposition"></a>
### `QUEUE_GATE_DISPOSITION`
**Meaning.** The approval store holds an owner gate disposition that applies at the
pin, and `land --pin` or `verify --pin` wasn't told to accept it. Or an
`--accept-gate-disposition` id names no disposition the store applies there. Or the
receipt's `gate_dispositions`, or the PR body's `Gate disposition:` lines, differ
from the store (`ready` refuses those too). Nothing was merged. The
`GATE_DISPOSITION:` lines above the refusal are what the store holds at the pin;
the receipt and the body aren't trusted for this, because the lane writes them.
A receipt id the store lacks usually means this lander reads another
`GSTACK_STATE_ROOT` or approvals mirror than the owner recorded into, or that the row
was removed after the receipt was written. A pin missing from the clone, a shallow
clone that can't prove whether a row's head is in the pin's history, and any other
git error while checking a head also refuse here. In a full clone, a row whose head
commit is gone after a rewrite and gc just doesn't apply.
**Fix.** Read each `GATE_DISPOSITION:` line: the fingerprint, gate id, head, who
recorded it, the reason and the owner's words. If the owner did approve that gate
change, rerun with `--accept-gate-disposition <approval id>` for each one. If not,
don't accept it; the lane restores the gate. When the store can't be read, or holds
none while the receipt lists some, point `GSTACK_APPROVALS_DIR` at the directory the
owner recorded into. In a shallow clone, run `git fetch --unshallow` and rerun.
When the receipt or the body is what differs, the lane reruns
/ship and `ready` at the pin. See
[gate dispositions](ship-policy.md#gate-disposition-acceptance).
<a id="queue-land-checks"></a>
### `QUEUE_LAND_CHECKS`
@@ -6860,6 +6890,36 @@ PR whose older receipt lost its parent branch, or a depth, targeted or exception
refusal, run /ship on the new head. When `--head` is not the checked-out HEAD,
check it out and carry there.
<a id="gate-finding-open-unattended"></a>
### An unattended /ship stops on an open gate finding
**Meaning.** The review logged a loosened gate as a gate finding, and an unattended
session cannot answer `review-gate-disposition`, so `gstack-review-log` stored it
`"action":"open","actor":"unattended"` and the repeat decision printed `NEXT: stop
(a blocking finding is still open …)`. A ruling (`gstack-approvals rule`) never
closes a gate finding, and nothing the lane writes on its review row does either.
**Fix.** When the owner approved the gate change, record the owner's answer at the
stop head the review read, with the `fingerprint` and `gate_id` from the review row,
while no /ship run is open on that branch in any worktree (a parked run's marker stops
counting once its 120-minute lease ends, as it does for preflight):
```bash
gstack-approvals gate --finding '<fingerprint>' --gate-id <gate_id> --head <stop head> \
--reason '<the gate change, one line>' --words '<the owner's exact words>' --by '<who recorded>'
```
Then resume the parked /ship at the same head with no push, starting by reading
`gstack-review-read` again: the finding now reads `kept` with its `disposition`, the
row reads `CURRENT`, so the repeat decision has no `--blocking-open` for it, Step 17
leaves it out of `--gate-open` and the PR is no draft on its account. finish prints
`GATE_DISPOSITION: …`, and the PR body carries the `Gate disposition: …` line
`gstack-approvals dispositions` prints. When the owner did not approve it, restore
the gate. The lander then accepts each disposition by its approval id:
`gstack-ship-queue land --pr <n> --pin <sha> --accept-gate-disposition <id>`, or
`gstack-ship-queue verify` with the same flags before a merge that doesn't go
through land. See [gate dispositions](ship-policy.md#gate-dispositions).
<a id="fix-check-open"></a>
### `FIX_CHECK_OPEN`
@@ -7027,7 +7087,7 @@ against `gate.passed: 41`) with a different value. The receipt is written from
the run; the prose was typed, so a reader of the body would learn a number the
run did not produce. The `PROSE:` line names each field with both values. The
`## Approvals` table `gstack-approvals` renders, the `Review-loop exception: …`,
`Documentation exception: …`, `Ruled note: …`, `Mechanical retake: …` and
`Documentation exception: …`, `Ruled note: …`, `Gate disposition: …`, `Mechanical retake: …` and
`Test-lane exception: …` lines the receipt requires, and the inside of a
closed code fence are never read as claims.