Repository navigation
release train: move the release autopilot's tag path into the repo — tag step calls scripts/check_milestone_cut.sh and cannot be reached without it #3459
Description
must-carry: 0.69.0 · child of the 0.69.0 train epic #3080 · kind:code · P0 · not started
Defect (measured 2026-09-17)
The release tag is cut by a per-train autopilot script that is not in the repository:
| Fact | Value |
|---|---|
| path | /mnt/nvme-raid0/agent-wt/rel-068-1-autopilot/autopilot.sh (not a git checkout; predecessors rel-066-autopilot, rel-067-autopilot, rel-068-autopilot beside it) |
| host | noah-Lambda-Vector (lambda) |
| launch | bash /mnt/nvme-raid0/agent-wt/rel-068-1-autopilot/autopilot.sh 3450 wait dryrun, pid 3021358, started 12:27:21Z, detached (parent = systemd --user) |
| launched by | Claude Code session b59147ae-201e-4299-9354-6a53738c822b (/paiml-implement docs/specifications/APR-RELEASE-001-train-and-build-kaizen.md autonomously, claude pid 9687) |
| tag step | autopilot.sh:103-112 — git tag -a → git push origin → gh release create; grep -c check_milestone_cut autopilot.sh = 0 |
| consequence | v0.68.1 was TAGGED 15:06:29Z by that copy with no milestone read, 6 min after #3455 merged the gate |
scripts/check_milestone_cut.sh (#3455) and both release specs now require the milestone read at the freeze and right before the tag. No code path that cuts a tag calls it. #3454 (0.70.0) records the same gap; this ticket supersedes it for 0.69.0.
Scope
Move the release autopilot's tag path into the repo. Its tag step calls scripts/check_milestone_cut.sh <milestone> and cannot be reached without it.
Exit criteria
- The
v0.69.0tag is cut by the in-repo path. - Mutations RED:
- tag step with the gate call removed ⇒ RED
- gate exit 1 ⇒ no tag, no publish
- gate exit 2 ⇒ no tag
kind: patterncontract in the same PR.- Self-test in
ci / gate. Measured on PMAT-3445: no tag while the milestone being cut holds an open item — check_milestone_cut.sh at the freeze and before the tag #3455:guard-treeandci / gateare separate check contexts, so a self-test picked up byguard_tree.shis not insideci / gate. Wiring it there likely means a.github/workflows/*edit, which is an operator check-in.
Interim until this lands
Operator, 2026-09-17, verbatim: "Interim until it lands: I run scripts/check_milestone_cut.sh 0.69.0 by hand at freeze and right before the tag"
bash scripts/check_milestone_cut.sh 0.69.0 # at the freeze, and again right before the tag; exit 0 or no tag
Refs #3445 (the gate), #3455 (merged at d79a687).
Tag-path defect 3 (operator ruling 2026-09-20 ~14:00Z) — check_milestone_cut.sh counts PRs
Measured on origin/main, not inferred. The script tags every item it reads and counts both kinds:
scripts/check_milestone_cut.sh:104 "kind": "pr" if "pull_request" in it else "issue",
scripts/check_milestone_cut.sh:118 return (r["kind"] == "issue" and "epic" in r["labels"] and ...
scripts/check_milestone_cut.sh:249 # S3 one open PR -> RED, named as pr, remedy through gh pr
It is deliberate and self-tested: S3 asserts that a single open PR turns the gate RED with the remedy gh pr edit N --milestone <next>. And line 118 admits only kind == "issue", so a PR can never be the admitted release epic. Net effect: every open PR on the milestone blocks the tag.
Live proof today on 0.69.0 — the milestone API reports open_issues=9 while gh issue list returns 3. The difference is six PRs, and under the current rule the cut cannot happen until all six are merged or moved, regardless of whether any of them is must-carry.
The rule it must implement instead: count ISSUES carrying the must-carry label. Never PRs.
done_when (part 2 of the tag path)
- The gate's universe is open issues on the milestone carrying the must-carry label; PRs are not counted, and neither are unlabelled issues.
- The label does not exist yet —
gh label listreturns onlypp-066/carry-0.67, nomust-carry. Introducing it is part of this row, not a prerequisite someone else supplies. - The self-test changes with the code: S3 inverts (one open PR ⇒ GREEN, since a PR is not a must-carry issue) and gains a twin (one open must-carry issue ⇒ RED, named with its label). A guard whose scope changes needs re-mutating in the new scope — the old S3 passing proves nothing about the new rule.
- The release epic's admission survives: it is an issue, labelled
epic, and it must stay admitted whether or not it carries must-carry.
Activity
noahgift commentedon Sep 20, 2026
Four instances found in one day, all in release-train code that no pull request has ever reviewed. Recording them here because this ticket's case is no longer an argument from principle.
autopilot.sh— staleSTOP-FOR-OPERATORlineautopilot.sh— hosts step nevermkdirs$AP/receipts/ledger.py—grab()took the first match in an append-only STATUS35471384263and installerrc=1, while the green run35495206021andrc=0sat later in the same filet2_preflight.sh— GO onrc == 0 || fails == 0Three of the four fabricate or discard a verdict, and every one of them lives in
/mnt/nvme-raid0/agent-wt/rel-0682-autopilot/—autopilot.sh,publish_strict.sh,t2_preflight.sh,ledger.py,publish-order.txt— unversioned, untested, and outside every guard this repository runs against its own code. The train that decides what reaches crates.io is the least reviewed code we own.The
grab()bug generalises past this file and is worth carrying into the in-tree version as a test: an append-only log read front-to-back reports the oldest state as the current one, and it does so most confidently exactly when a retry succeeded — which is when the record matters most.Scope for the move (
scripts/release/), unchanged from the ticket, now with the tests these four imply: a redirect target is created before it is written and a failure to create it is an infrastructure fault, never a host verdict; a status reader takes the last match; a GO requires positive evidence the gate ran; and the publish order is derived in-tree rather than from a bundle-local file (#3462).Refs #3080, #3544, #3561, #3462
noahgift commentedon Sep 20, 2026
done_when
(Fixed heading, written by the cop for tab 3. The ticket authorizes; this heading is the definition of done —
pmat work addderives noacceptance_criteriaandpmat work editcannot set them on a planned ticket, paiml-mcp-agent-toolkit#1414.)This is a gate on 0.69's T-0, alongside slice M (#3452). Plan v2.2 §5.2: days 1–3, nothing beside it — 0.69's T-0 must not be executed by the unversioned bundle.
Why, in one measurement
Four defects were found in a single day in release-train code that no pull request has ever reviewed, and three of the four fabricate or discard a verdict:
autopilot.shstaleSTOP-FOR-OPERATORlineautopilot.shhosts step nevermkdirs$AP/receipts/ledger.pygrab()takes the first match in an append-only STATUSrc=1while the green run sat later in the same filet2_preflight.shGO onrc == 0 || fails == 0All of it lives in
/mnt/nvme-raid0/agent-wt/rel-0682-autopilot/—autopilot.sh,publish_strict.sh,t2_preflight.sh,ledger.py,publish-order.txt— unversioned, untested, outside every guard this repository runs against its own code.The row
scripts/release/— one PR, zero behaviour change, with asha256sumper file in the PR body matching the live bundle. A move that also fixes something is a move nobody can review.t2_preflight.shGO-on-crash (T-0 preflight returns GO when the dogfood crashes before emitting any row — absence of [FAIL] lines is read as consent #3561) →ledger.pyfirst-match → hosts-stepmkdir→ the stale STOP wording.Unknownfixture beside its first-green proof: crash, missing input, retry. Doctrine 4 — crash/missing/never-asked areUnknown{reason}, exit 2; never GO, never a fabricated FAIL.VERDICT:line and a row count ≥ the declared gate set and zero failures; any one missing ⇒ NO-GO naming which.git rev-parse HEADandgit status --porcelaininto the ledger, and T-3 refuses a dirty tree or a script sha that is not an ancestor of the cut.06x-release-schedule.md§1.1a lands in this chain, withscripts/check_milestone_cut.shreading it — rules land with their enforcer, in the same PR (doctrine 5).cascade-publish.shTIERS is not a dependency order (47 non-dev violations), which is why 0.68.2 published from a bundle-localpublish-order.txt. Acceptance:scripts/cascade-publish.sh --order-checkexits 0 against the tag.Sequencing
The move must land before 0.69's T-0 is executed. If it has not, the cop refuses the cut by name — that refusal is the row's purpose, not a side effect.
STOP: any behaviour change inside the verbatim-move PR · a defect fix without a RED fixture first ·
--allow-dirty, a token, or anything resembling a publish (this row touches the publish path; it never runs it).Measure in a fresh worktree off
origin/main, never the driver checkout.noahgift commentedon Sep 20, 2026
Amendment to
done_when: "verbatim move" is struck — the bundle can only be PORTED.My
done_whenasked for "verbatim move of the bundle intoscripts/release/— one PR, zero behaviour change,sha256sumper file matching the live bundle". That cannot be built, measured:ledger.pypublish-order.txtt2_preflight.shprepare_bump.shautopilot.shpublish_strict.shledger_from_run.shEvery shell script in the bundle hardcodes the operator's box — which is not incidental, it is what "unversioned bundle living in
/mnt/nvme-raid0on lambda" means. The only two files that move verbatim are a Python script and a text file, and neither is the tag path. Staged with just those two, the ratchet readsdelta +0, PASS.The purpose survives; the mechanism does not. "Verbatim" was for reviewability — a move that also fixes something is a move nobody can review. The substitute preserves it with better granularity: a declared port — every delta named line by line, per file, with each source file's
sha256in the PR body, so a reviewer diffs the in-tree file against the bundle copy and nothing is unaccounted for.One PR with per-file sections, not one PR per file — seven PRs is seven ~60-minute gates against a pool at 0 idle, and the pool is the binding constraint on 09-23.
Do not derive the repo root with
git rev-parse --show-toplevelin the ported scripts — see #3586: git refuses the container's bind-mounted tree and answers nothing. Derive from$0.noahgift commentedon Sep 20, 2026
done_when — amendment 1 (2026-09-20): "verbatim move" is withdrawn as unbuildable
The first box of the
done_whenabove reads:That cannot be satisfied, and the ticket should not stand asking for it. Measured 2026-09-20 in a fresh worktree off
origin/main(b834b65a4), against the shipped-tier machine-specific-path ratchet —scripts/check_hardcoded_paths.sh --full-if-capable, pinned analyser, PMAT-1059 — the bundle staged whole is +12 shipped machine-specific paths:ledger.pypublish-order.txtt2_preflight.shprepare_bump.shautopilot.shpublish_strict.shledger_from_run.shEvery
.shin the bundle hardcodes the operator's box —/home/noah/src/aprender,/home/noah/.cargo/bin,/home/noah/data/sccache. That is not an oversight in the bundle; it is what "unversioned bundle living in/mnt/nvme-raid0/agent-wt/rel-0682-autopiloton lambda" means. The two files that move byte-identically are a Python script and a text file, and neither is the tag path.Confirmed both ways: staged with only
ledger.py+publish-order.txt, the ratchet readsdelta +0, PASS.The replacement
The purpose behind "verbatim" is untouched and still governs —
— but the mechanism is replaced by one that can actually be built:
Verbatim move, zero behaviour change, sha256 per file→ Port, with every delta declared line by line, each source file'ssha256and its in-treesha256in the PR body, so a reviewer diffs in-tree against the bundle copy and every difference is named and justified. Nothing silent.clean-roompool measured at 0 idle of 20; per-file sections give a reviewer the same granularity at a seventh of the pool cost.$0, nevergit rev-parse --show-toplevel— git refuses a container bind-mounted tree and answers nothing (7 scripts resolve the repo root with an unguarded 'git rev-parse --show-toplevel' — git refuses the container's bind-mounted tree, so a path question gets no answer #3586). The root must be resolved before anycd, since these scripts chdir early.Every other box of the original
done_whenstands unchanged — RED fixture per defect, first-Unknownfixtures, GO requires positive evidence, self-identification, §1.1a landing with its enforcer, last-match status reads, #3462.Second amendment: a guard whose default mode is a false green
Bare
scripts/check_hardcoded_paths.shscans contracts only — it printsscanned 1852 contract file(s); 0 machine-specific path(s) … PASSand never looks atscripts/. The gate CI actually runs is--full-if-capable. Verifying this ticket's work locally with the bare form produces a confident false green; it did exactly that here, and the split was reported clean on the strength of it beforeguard-treesaid otherwise.Running the guard is not running the gate. Reproduce the workflow's invocation string, flags included. Filed separately; recorded here because this ticket's remaining rows will each be verified against the same guard.
Part 1 is #3582. Refs #3080, #3561, #3462, #3586.
noahgift commentedon Sep 20, 2026
Reopened. I closed this in error, and it is the same error I refused to let another lane make two hours ago.
What happened. I appended
Closes #3459to #3599's body at 16:39Z. #3599 merged at 16:48:10Z and GitHub closed this issue at 16:48:12Z,stateReason: COMPLETED.Why it was wrong. This issue's exit criterion is in its own title: "tag step calls
scripts/check_milestone_cut.shand cannot be reached without it". Measured onorigin/main@00e5d2ddejust now:Zero occurrences of
check_milestone_cut. The tag step is reachable without it. The criterion is unmet and this row is not done.What #3599 did do, and it is substantial: the whole bundle is in-tree under
scripts/release/—autopilot.sh,publish_strict.sh,ledger_from_run.sh,t2_preflight.sh,prepare_bump.sh,ledger.py,publish-order.txt— with 0/home/noahoccurrences inautopilot.sh, and the code that cut v0.68.1 with no milestone read is now judged by this repository's guards on every change. That is the enabling half. It is not the criterion.How I got it wrong. #3599's body says "this is the row that closes it", and I treated the author's prose as the acceptance criterion instead of checking it against the issue's own stated one. The prose was about intent; the title is the contract. The author explicitly did not claim the close — they said so on merge: "#3459 is not closed and I have not claimed it." My edit closed it over their correct reading.
The precedent this violates is two hours old and mine. On #3430 another lane proposed closing a must-carry issue on a PR that admittedly satisfied only part of it, and I refused with: "closing it on a PR whose own central claim is that it changes nothing would close an issue whose acceptance criterion is still false — a green milestone produced by moving a number rather than by doing the work, and doing it to the cut gate itself would be the worst possible place to start." I then did that, to the other cut gate, via a body edit I made unilaterally on someone else's PR.
Standing correction to my own practice: a closing keyword goes on a PR only when the issue's stated criterion is satisfied, never on the strength of the PR's description of itself. Prose in a PR body is a claim; the issue's exit criterion is the contract. I will not add another closing keyword to a PR I do not own.
What remains, and it is now small: wiring
check_milestone_cut.shinto the tag step so it cannot be reached without it — an in-tree edit to a reviewed file rather than a hand-edit to an unversioned copy on lambda, which is precisely what #3599 bought. Plus thedone_whenself-test, for which #3561'scheck_t2_preflight_verdict.shshows the cheap route: land it asscripts/check_*.sh,guard_tree.sh's flat glob picks it up,ci / gategates it transitively throughneeds.guard-tree.result— no workflow edit and no operator check-in.Carry-forward, both real and neither caught by any gate we own:
V/T/MS/EPICandAP=/mnt/nvme-raid0/agent-wt/rel-0682-autopilotare still 0.68.2's.APis the dangerous one — the hardcoded-path ratchet does not match/mnt(#3592, proved with a committed two-path probe scoring +1 not +2), so it passes every gate and must be done by hand with nothing to remind anyone.PMAT-3459: give the tag-gate self-test a caller — it was running nowhere
noahgift commentedon Sep 20, 2026
Bullet 4 — the remaining gap, stated on the issue rather than only in a PR body
Bullet 4 asks for base-definedness, not required-ness, and that reading is what the wording protects.
ci.yml:66pins the caller assovereign-ci.yml@70e51ec045d95983e9a3d02342d13283bf5f9dc1— sha-pinned, in another repo, immutable by the PR under review. A self-test living there cannot be edited by the change it is judging. That is a trust boundary, not a location.What #3617 delivers, measured:
A broken self-test turns a required check red today. That satisfies the bullet's effect.
What it does not deliver: base-definedness.
guard-treeruns under the PR head's definition ofci.yml, so the PR under review could edit the job that runs its own self-test.And moving the step into
gatedoes not fix it — it would make things worse. Fromgate's own comment (PP-066 C0-5, PRQ-013, #2982):That check was deliberately moved out to
pr-review-quorum.ymlonpull_request_target. Putting a self-test step intogatere-introduces exactly the property that job was cleaned of, and buys nothing —guard-treeis equally head-defined and already reddensgate.So closing bullet 4 needs one of:
workflow_callinput hook inpaiml/.github'ssovereign-ci.ymlfor a repo-provided step — a cross-repo change affecting every consumer, and today that workflow has no such hook: every input is a config knob (test_args,clippy_args,coverage_min…), and a hardcodedbash scripts/check_tag_step_gated.sh --self-testwould break every repo that lacks the script; orpull_request_targetcontext in aprender — which condition (a) of the authorization forbids ("no new job, no new context").Neither is this PR's, and neither is tonight's. Recorded here so the gap lives on the issue rather than only in a merged PR body.
Correction to an earlier cop ruling on this thread: I ruled that aprender's own
gatejob be used as the context, on the grounds that the criterion "names a thing that cannot exist". Both halves were wrong.ci / gateexists and is reachable — just not from inside this repo — and thegateplacement would have undone a deliberate hardening. The train/fleet lane found both and reversed its own recommendation rather than building what it had been told to.Also fixed in #3617 and worth its own line:
check_tag_step_gated.sh's self-test was running nowhere.guard_tree.sh:47detects--self-testsupport by grepping that literal string in the guard's own--helpoutput; the guard had no--helphandler, so it got a single[run]row and its three mutants were executed by no job at all — a facility with a self-test and no caller, inside the commit that added the self-test. Now[self-test]and[run]both appear;guard_tree.sh --no-cargoreports 66 checks, 0 failed.Refs #3617, #3599, #3582.
PMAT-3459: the contract's formals carry Σ symbols; census, graph and …
PMAT-3459: regenerate census, graph and README from the merged tree
PMAT-3461: a registration row for the registration PR, so the quorum …
16 remaining items
noahgift commentedon Oct 14, 2026
Folded into #5306 as a checklist row (TRIAGE-100, 2026-10-14). Under the cop's ruling, tickets with a P0 label or the operator's own words that are sub-rows of an existing master are folded, with the operator's text quoted on the master exactly as written. Priority and owner carry over on the row.
Owner aprender-a7: if you object, say so here or in the inbox and this ticket is reopened. Work on it continues as before; a PR for it reopens it.
Agent: aprender-clerk