From 32b7ca47493ffb4c75840cd3989e75e033494c4c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 02:14:59 +0000 Subject: [PATCH 1/2] feat(pm): relay deletes a closed mailbox's claude/mailbox-* head, and mailbox-open --close sends it The relay gains mailbox_branch_delete, its one ref op: a DELETE of one refs/heads/claude/mailbox-* ref, refused unless the pull it names is closed, carries pm:mailbox, has that branch as its head on this repository, and no open pull has that head. The fences are judged by the executor on the pull's own answers immediately before the DELETE, and the delete lands only when the ref reads back absent. REFUSED_PATH_FAMILIES keeps refusing every other /git/ path; contents: write is already a literal grant on every stroke, so the workflow is untouched. mailbox-open --close appends the delete to its close stroke when the head it reads is claude/mailbox-*, so a cloud seat's mailbox leaves no branch. Claude-Session: https://claude.ai/code/session_016WV4cwSv11c8FvyR8ZuWhv Co-authored-by: Claude --- scripts/pm/fleet-write/execute.mjs | 110 +++++++++++++++++++++++++--- scripts/pm/fleet-write/ops.mjs | 62 ++++++++++++---- scripts/pm/fleet-write/validate.mjs | 47 +++++++++--- scripts/pm/mailbox-open.mjs | 36 ++++++--- 4 files changed, 211 insertions(+), 44 deletions(-) diff --git a/scripts/pm/fleet-write/execute.mjs b/scripts/pm/fleet-write/execute.mjs index 574bce17884..5f0e6d5d99b 100644 --- a/scripts/pm/fleet-write/execute.mjs +++ b/scripts/pm/fleet-write/execute.mjs @@ -190,6 +190,16 @@ * read the parent and ⛔ never re-send. Every read is an unpaced GET, and the * one write is paced like every write verb. * + * ## The mailbox branch delete — `mailbox_branch_delete`, fenced on the PULL and read back + * + * The fences (`ops.mjs` header, "The one row that deletes a ref") are facts + * about the pull, so they are AUTHORITATIVE here (`mailboxBranchPreflight`): + * the pull's own `GET` and the open pulls on `{owner}:{branch}`, the last two + * reads before the DELETE, each fence that fails named, the DELETE NOT SENT. + * The 204 is not the landing: `confirmMailboxBranch` reads the ref back, and + * only an absent ref lands — a ref already gone (the DELETE's 422) is an + * idempotent landing, a ref still there is a FAILED action. + * * ## The advisory rows that write — fenced on the ADVISORY, printing none of its text * * `ops.mjs`'s header section "The rows that write a security advisory" @@ -467,6 +477,7 @@ import { GOVERNED_REVIEW_FILES_PAGE_SIZE, GOVERNED_REVIEW_OPS, LOGIN_SHAPE, + MAILBOX_LABEL, OPS, PERMISSIONS, PR_LANDED_STATE, @@ -893,6 +904,42 @@ export async function confirmSubIssue(req, answer, pre, payload, api, t, { sleep return { ok: false, why: `${platform} accepted, but ${last} ${windowMs} ms later — UNCONFIRMED: read #${parent}'s sub-issues before anything else, ⛔ do not re-send` }; } +/** + * The mailbox branch delete's fences on the reads (header): `[]` to send, else one sentence per fence that fails — + * an unread pull is one, judged alone. `pull` and `open` are `rest` answers. Pure. + */ +export function mailboxBranchFenceFailures({ repo, branch, pull, open }) { + if (pull?.status !== 200 || !pull.json || typeof pull.json !== 'object') return [`${pull?.call} -> HTTP ${pull?.status}: the pull could not be read`]; + const pr = pull.json; + const out = []; + if (pr.state !== 'closed') out.push(`the pull is ${pr.state}, not closed`); + if (!labelNames(pr.labels).includes(MAILBOX_LABEL)) out.push(`the pull does not carry ${MAILBOX_LABEL}`); + if (pr.head?.ref !== branch) out.push(`the pull's head is ${pr.head?.ref ?? 'unread'}, not ${branch}`); + if (String(pr.head?.repo?.full_name ?? '').toLowerCase() !== repo.toLowerCase()) out.push(`the pull's head lives on ${pr.head?.repo?.full_name ?? 'no repository'}, not ${repo}`); + if (open?.status !== 200 || !Array.isArray(open.json)) out.push(`${open?.call} -> HTTP ${open?.status}: the open pulls on ${branch} could not be read`); + else if (open.json.length) out.push(`open pull(s) ${open.json.map((p) => `#${p?.number}`).join(', ')} have ${branch} as head — the DELETE would close them`); + return out; +} + +/** The fences, AUTHORITATIVE (header): the two reads, then `{}` to send or `{ fail }`, the DELETE NOT SENT. Never throws. */ +export async function mailboxBranchPreflight(req, payload, api, t) { + const { pull, branch } = req.mailboxBranch; + const repo = payload.repo; + const pr = await rest(api, `/repos/${repo}/pulls/${pull}`, {}, t); + const open = await rest(api, `/repos/${repo}/pulls?state=open&head=${encodeURIComponent(`${repo.split('/')[0]}:${branch}`)}`, {}, t); + const failures = mailboxBranchFenceFailures({ repo, branch, pull: pr, open }); + return failures.length ? { fail: `REFUSED at the relay's fence on #${pull}'s head ${branch}, nothing sent — ${failures.join('; ')}` } : {}; +} + +/** Did the delete LAND? The ref read back absent (header); a ref still there, or unread, is FAILED. Never throws. */ +export async function confirmMailboxBranch(req, answer, payload, api, t) { + const { branch } = req.mailboxBranch; + const back = await rest(api, `/repos/${payload.repo}/git/ref/heads/${branch}`, {}, t); + const platform = `HTTP ${answer.status}${answer.detail ? ` — ${answer.detail}` : ''}`; + if (back.status !== 404) return { ok: false, why: `${platform}, but ${back.call} answered HTTP ${back.status}: the branch ${back.status === 200 ? 'is still there' : 'could not be read back'} — read it before anything else` }; + return { ok: true, why: '', idempotent: !(answer.status >= 200 && answer.status < 300), result: `deleted ${branch} (read back absent)` }; +} + /** * The advisory's fences, AUTHORITATIVE (header: "The advisory rows that write"): its own GET, judged against * `ADVISORY_FENCES` — for `advisory_collaborators_add` an open advisory (`ADVISORY_OPEN_STATES`), for @@ -1424,8 +1471,8 @@ export async function executeFleetWrite({ payload: raw, sender, token, api = DEF break; } const req = filled.req; - // The failed-jobs re-run (header): its fences on the run and its pull, judged HERE on the reads before the POST — refused, NOT SENT. - const pre = req.rerunFailed ? await rerunFailedPreflight(req, payload, api, t) : null; + // The failed-jobs re-run and the mailbox branch delete (header): their fences, judged HERE on the reads before the write — refused, NOT SENT. + const pre = req.rerunFailed ? await rerunFailedPreflight(req, payload, api, t) : req.mailboxBranch ? await mailboxBranchPreflight(req, payload, api, t) : null; if (pre?.fail) { const why = scrub(pre.fail, [token]); rows.push({ action: i + 1, op: action.op, call: `${req.verb} ${req.path}`, status: 'not sent', result: `FAILED: ${why}` }); @@ -1510,15 +1557,18 @@ export async function executeFleetWrite({ payload: raw, sender, token, api = DEF // The failed-jobs re-run is confirmed by reading the run back (header): attempt 2, its failed jobs queued or in progress. // The hierarchy link is confirmed by reading the parent's list back (header): the child's id listed, or no longer. // The advisory rows are confirmed by their own answer (header): an id, every login sent, the state asked for — never by resultOf. + // The mailbox branch delete is confirmed by reading the ref back (header): absent, or it is FAILED. const landed = req.updateBranch ? await confirmUpdateBranch(req, r, payload, api, t, ub) : req.rerunFailed ? await confirmRerunFailed(req, r, pre, payload, api, t, rr) - : sub - ? await confirmSubIssue(req, r, sub, payload, api, t, si) - : adv || req.advisoryReport || req.advisoryCreate - ? confirmAdvisory(req, r, adv) - : requestLanded(req, r); + : req.mailboxBranch + ? await confirmMailboxBranch(req, r, payload, api, t) + : sub + ? await confirmSubIssue(req, r, sub, payload, api, t, si) + : adv || req.advisoryReport || req.advisoryCreate + ? confirmAdvisory(req, r, adv) + : requestLanded(req, r); const call = req.graphql ? `POST /graphql ${req.graphql.mutation}` : r.call; rows.push({ action: i + 1, op: action.op, call, status: r.status, result: landed.ok ? (landed.idempotent ? 'already absent (idempotent)' : scrub(landed.result ?? resultOf(req, r.json, r.status), [token])) : `FAILED: ${scrub(landed.why, [token])}` }); if (!landed.ok) { @@ -1593,11 +1643,13 @@ const SELF_TEST_BATTERIES = Object.freeze({ "the run_rerun_failed: the five fences judged HERE on the run and its pull, read immediately before ONE bodiless POST — the pull's current head by its own GET, never the run's embedded copy, and a run naming no pull refused by name; each refused with the POST not sent and accepted on the measured run; a run, pull, baseline or fence that fails is zero writes; the 201 confirmed only by reading attempt 2 back with its failed jobs queued or in progress, naming their job ids; attempt 2 never seen, a failed job completed, or a job list unread is FAILED; no annotation": 21, "the hierarchy link: sub_issue_add and sub_issue_remove read the parent's and the child's own answers, then the child's parent (add) or the parent's whole list (remove), immediately before ONE write whose body is the child's REST id from its own answer — never its number, never replace_parent; a pull, a transferred or unread card, a child under another parent, one the parent does not list, or a read that is neither answer is zero writes; a child already under this parent is a no-op landed on the list; the answer confirmed only when the parent's list reads the child back (or no longer does) inside the window; a refused write, a list that never settles or never reads whole is FAILED; no annotation, and the rows ride a stroke beside other ops": 20, "the advisory rows: advisory_report and advisory_create are ONE POST of their body each, landed on a 2xx carrying an id — a draft's reading state draft — that the row and a notice annotation name; advisory_collaborators_add, advisory_versions and advisory_state read the advisory first and send ONE PATCH — every existing login kept with the missing ones added, an all-present list a no-op, the versions entries alone, or the one state a legal transition reaches, the reopen and the publish among them — or nothing at all on a fence: an unread, other or wrong-state advisory, an illegal move, a list the versions may not replace or a publish whose versions are not set; each lands only when its answer shows the result, a publish only read back as published with the versions it was sent, and a landed publish alone is annotated with its id; ⛔ the marker in a report's or a draft's summary, description and packages and in every advisory answer's summary, description and vulnerabilities reaches no log line, row, step summary or annotation": 41, + "the mailbox branch delete: the pull's own answer and the open pulls on its head read immediately before ONE DELETE of the ref under the App token — an open, unlabelled, other-headed or forked pull, an open pull on that head, or a read that does not answer is zero writes with every failing fence named; the DELETE lands only when the ref reads back absent, a ref already gone an idempotent landing and one still there FAILED; and mailbox-open's close stroke — comment, close, delete — leaves no branch, while a head outside claude/mailbox-* sends no DELETE": 7, "advisory_read: ONE GET after the sender gate and nothing written; the read, advisory and sender fences judged on that answer — target-write by the maintainer's ruling, a sender off the advisory's collaborators sealed for, one without write refused before the GET, any other gate closed — then the answer sealed to the seat's key and emitted as notice parts the reader parses back, opening byte for byte for that key and read alone; a failed or unsealed seal and an over-cap answer FAILED with nothing emitted; ⛔ the marker in the summary, description and vulnerabilities, and the advisory's id, reach no log line, row, step summary or annotation, and the sealed answer alone carries no marker": 18, }); // Raised 17 → 18 with the governed-review follow-up battery, 18 → 19 with the run_rerun_failed battery, 19 → 20 with the -// hierarchy link battery, 20 → 21 with the advisory rows' battery, 21 → 22 with advisory_read's: one battery, one row. -const SELF_TEST_BATTERY_FLOOR = 22; +// hierarchy link battery, 20 → 21 with the advisory rows' battery, 21 → 22 with advisory_read's, 22 → 23 with the mailbox +// branch delete's: one battery, one row. +const SELF_TEST_BATTERY_FLOOR = 23; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -2457,6 +2509,46 @@ export async function selfTest() { t('⛔ the parser is the reader\'s, imported — this file spells no pattern for the message of its own', [readReal(SELF_PATH, 'utf8').includes(['number=', '(['].join('')), typeof parseRelayAnnotation], [false, 'function']); } + // ── the mailbox branch delete ─────────────────────────────────────────── + battery("the mailbox branch delete: the pull's own answer and the open pulls on its head read immediately before ONE DELETE of the ref under the App token — an open, unlabelled, other-headed or forked pull, an open pull on that head, or a read that does not answer is zero writes with every failing fence named; the DELETE lands only when the ref reads back absent, a ref already gone an idempotent landing and one still there FAILED; and mailbox-open's close stroke — comment, close, delete — leaves no branch, while a head outside claude/mailbox-* sends no DELETE"); + { + const PR = 30141; + const BR = 'claude/mailbox-ui-3'; + const PULL = `GET /repos/${REPO}/pulls/${PR}`; + const OPEN = `GET /repos/${REPO}/pulls`; + const DEL = `DELETE /repos/${REPO}/git/refs/heads/${BR}`; + const BACK = `GET /repos/${REPO}/git/ref/heads/${BR}`; + const box = (o = {}) => ({ number: PR, state: 'closed', labels: [{ name: 'pm:mailbox' }, { name: 'pinned' }], head: { ref: BR, repo: { full_name: REPO } }, ...o }); + const good = { ...allowed, [PULL]: { status: 200, json: box() }, [OPEN]: { status: 200, json: [] }, [DEL]: { status: 204, json: null }, [BACK]: { status: 404, json: { message: 'Not Found' } } }; + const drop = base([{ op: 'mailbox_branch_delete', pull: PR, branch: BR }]); + const ok = await run(drop, good); + t('the good case: the pull, then the open pulls on its head, then ONE DELETE of the ref under the App token, then the ref read back absent — exit 0, the row naming it deleted', [ok.exit, ok.seen.slice(1).map((s) => s.call), writes(ok.seen).map((s) => [s.call, s.body, s.auth]), ok.rows[0].result, ok.annotations], [EXIT_OK, [PULL, OPEN, DEL, BACK], [[DEL, null, `Bearer ${TOKEN}`]], `deleted ${BR} (read back absent)`, []]); + const urls = []; + await mailboxBranchPreflight(OPS.mailbox_branch_delete.requests({ op: 'mailbox_branch_delete', pull: PR, branch: BR }, REPO)[0], { repo: REPO }, 'https://api.test', { token: TOKEN, fetch: async (url) => (urls.push(url), { status: 200, headers: new Headers(), json: async () => [] }) }); + t("the open-pulls read is filtered to this repository's owner and the branch — an unfiltered or mis-owned read would answer for other heads", urls[1], `https://api.test/repos/${REPO}/pulls?state=open&head=objectstack-ai%3Aclaude%2Fmailbox-ui-3`); + const fences = [ + [{ [PULL]: { status: 200, json: box({ state: 'open' }) } }, 'the pull is open, not closed'], + [{ [PULL]: { status: 200, json: box({ labels: [{ name: 'pinned' }] }) } }, 'does not carry pm:mailbox'], + [{ [PULL]: { status: 200, json: box({ head: { ref: 'claude/mailbox-ui-2', repo: { full_name: REPO } } }) } }, "the pull's head is claude/mailbox-ui-2, not claude/mailbox-ui-3"], + [{ [PULL]: { status: 200, json: box({ head: { ref: BR, repo: { full_name: 'someone/objectstack' } } }) } }, "head lives on someone/objectstack, not objectstack-ai/objectstack"], + [{ [OPEN]: { status: 200, json: [{ number: 30999 }] } }, 'open pull(s) #30999 have claude/mailbox-ui-3 as head — the DELETE would close them'], + [{ [PULL]: { status: 404, json: { message: 'Not Found' } } }, 'the pull could not be read'], + [{ [OPEN]: { status: 503, json: { message: 'down' } } }, 'the open pulls on claude/mailbox-ui-3 could not be read'], + ]; + const fenced = await Promise.all(fences.map(async ([over, phrase]) => { const r = await run(drop, { ...good, ...over }); return [r.exit, writes(r.seen).length, r.rows[0]?.status, r.rows[0]?.result.includes(phrase)]; })); + t('⛔ each fence refuses with the DELETE NOT SENT and names itself: an open pull, no pm:mailbox, another head, a forked head, an open pull on the head, an unread pull, an unread open-pulls list', fenced, fences.map(() => [EXIT_ACTION_FAILED, 0, 'not sent', true])); + const kept = await run(drop, { ...good, [BACK]: { status: 200, json: { ref: `refs/heads/${BR}` } } }); + t('⛔ a 204 whose ref still reads back is a FAILED action, never a landing', [kept.exit, kept.rows[0].status, kept.rows[0].result.includes('the branch is still there')], [EXIT_ACTION_FAILED, 204, true]); + const gone = await run(drop, { ...good, [DEL]: { status: 422, json: { message: 'Reference does not exist' } } }); + t('a ref already gone — the DELETE answers 422, the ref reads back absent — is an idempotent landing', [gone.exit, gone.rows[0].result], [EXIT_OK, 'already absent (idempotent)']); + const { planClose } = await import('../mailbox-open.mjs'); + const closing = { ...good, [`POST /repos/${REPO}/issues/${PR}/comments`]: { status: 201, json: { id: 9 } }, [`PATCH /repos/${REPO}/issues/${PR}`]: { status: 200, json: { number: PR, state: 'closed' } } }; + const closed = await run(base(planClose({ repo: REPO, pull: PR, head: BR }).actions), closing); + t("⭐ after close, no branch: mailbox-open's close stroke sends the comment, the close and then the DELETE, reads the pull only after closing it, and ends on the ref read back absent — exit 0", [closed.exit, writes(closed.seen).map((s) => s.call), closed.seen.map((s) => s.call).indexOf(PULL) > closed.seen.map((s) => s.call).indexOf(`PATCH /repos/${REPO}/issues/${PR}`), closed.seen.at(-1).call, closed.rows.at(-1).result], [EXIT_OK, [`POST /repos/${REPO}/issues/${PR}/comments`, `PATCH /repos/${REPO}/issues/${PR}`, DEL], true, BACK, `deleted ${BR} (read back absent)`]); + const other = await run(base(planClose({ repo: REPO, pull: PR, head: 'claude/project-thread-mvsts5' }).actions), closing); + t('…while a head outside claude/mailbox-* — a thread branch the seat still works on — is closed and left: no DELETE, nothing under /git/', [other.exit, writes(other.seen).map((s) => s.call), other.seen.some((s) => s.call.includes('/git/'))], [EXIT_OK, [`POST /repos/${REPO}/issues/${PR}/comments`, `PATCH /repos/${REPO}/issues/${PR}`], false]); + } + // ── the advisory rows that write ──────────────────────────────────────── battery("the advisory rows: advisory_report and advisory_create are ONE POST of their body each, landed on a 2xx carrying an id — a draft's reading state draft — that the row and a notice annotation name; advisory_collaborators_add, advisory_versions and advisory_state read the advisory first and send ONE PATCH — every existing login kept with the missing ones added, an all-present list a no-op, the versions entries alone, or the one state a legal transition reaches, the reopen and the publish among them — or nothing at all on a fence: an unread, other or wrong-state advisory, an illegal move, a list the versions may not replace or a publish whose versions are not set; each lands only when its answer shows the result, a publish only read back as published with the versions it was sent, and a landed publish alone is annotated with its id; ⛔ the marker in a report's or a draft's summary, description and packages and in every advisory answer's summary, description and vulnerabilities reaches no log line, row, step summary or annotation"); { diff --git a/scripts/pm/fleet-write/ops.mjs b/scripts/pm/fleet-write/ops.mjs index c611b2a3b7d..e42709eab9f 100644 --- a/scripts/pm/fleet-write/ops.mjs +++ b/scripts/pm/fleet-write/ops.mjs @@ -30,7 +30,7 @@ * * An op is only what this table names. There is no row for a merge of a pull, * a review submission, a direct branch or ref write (`/git/**`, `/branches/**`, - * `/contents/**`), a workflow-file write, a run cancel or delete, a whole-run + * `/contents/**`) but one mailbox branch's DELETE (below), a workflow-file write, a run cancel or delete, a whole-run * or single-job re-run, a cache or artifact write, a release, or any * organization / administration endpoint, so a payload naming one is refused * at the validator with zero writes — the `--self-test` of `validate.mjs` pins @@ -55,6 +55,24 @@ * 422. How the executor tells that 422 from the "nothing to merge" one, and * how it confirms a 202, is `execute.mjs`'s (`confirmUpdateBranch`). * + * ## The one row that deletes a ref — `mailbox_branch_delete` + * + * A cloud seat opens its mailbox on a branch it pushed (`mailbox-open.mjs + * --head`) and cannot delete it, so the relay does, by the maintainer's ruling + * (comment 6674408912, letter A), which opens this ONE row and nothing else. + * `DELETE /repos/{o}/{r}/git/refs/heads/{branch}` is `contents: write`, + * server-to-server, no additional permission (the ledger cited below): a + * grant every stroke already carries for the auto-merge rows, so the mint + * widens nothing. The row creates no ref and writes no content: the `/git/` + * family of `REFUSED_PATH_FAMILIES` refuses every path but exactly one + * `refs/heads/` ref of `MAILBOX_BRANCH_SHAPE` (`claude/mailbox-*`), held there + * before the dispatch too. The fences are facts about the PULL, so the + * executor judges them on the pull's own `GET` and the open pulls on that + * head, the last reads before the DELETE (`mailboxBranchPreflight`): the pull + * is closed, carries `pm:mailbox`, its head is `branch` on this repository, + * and no OPEN pull has that head (the DELETE would close it). Anything else is + * refused, the DELETE not sent. It lands only when the ref reads back absent. + * * ## The rows whose LATER requests read the FIRST's answer — `pr_create` with `assignees` or `labels` * * A pull request's assignees and labels are written on its issue endpoints, @@ -151,10 +169,10 @@ * auto-merge mutations (`enablePullRequestAutoMerge` / * `disablePullRequestAutoMerge`) additionally require `contents: write` — * measured on the first live use, where a token without it answered - * "Resource not accessible by integration" — and those two rows are the ONLY - * spenders of it: the table has no contents op (no direct file, ref or branch - * write; `pr_update_branch` is the platform's own merge, `pull_requests: write` - * in that ledger); arming auto-merge on a pull that changes + * "Resource not accessible by integration" — and beside those two rows only + * `mailbox_branch_delete`'s one ref DELETE (above) spends it: no row writes a + * file or creates or moves a ref (`pr_update_branch` is the platform's own + * merge, `pull_requests: write` in that ledger); arming auto-merge on a pull that changes * `.github/workflows/**` also requires `workflows: write` — measured: the * relay's `automerge_enable` on such a pull answered "refusing to allow a * GitHub App to create or update workflow … without `workflows` permission" — @@ -165,7 +183,7 @@ * the maintainer's own ruling, comment 6104822731 (「22708 A」), so this grant * is not a drift. The grant also reaches workflow-file writes through * `/contents/**` and `/git/**`; `REFUSED_PATH_FAMILIES` refuses both for every - * row. The workflow-run dispatch (`workflow_dispatch`) and the failed-jobs + * row, the one mailbox ref DELETE aside (above). The workflow-run dispatch (`workflow_dispatch`) and the failed-jobs * re-run (`run_rerun_failed`), both below, are `actions: write`, the two rows * spending it; the five advisory rows that write (`advisory_report`, * `advisory_create`, `advisory_collaborators_add`, `advisory_versions` and @@ -1710,6 +1728,10 @@ export const MAX_LABEL_BYTES = 50; /** GitHub's own cap on a login. */ export const MAX_LOGIN_CHARS = 39; export const MAX_REF_BYTES = 255; +/** The one branch name `mailbox_branch_delete` takes — `claude/mailbox-` and a lower-case slug, no `/` (header). */ +export const MAILBOX_BRANCH_SHAPE = /^claude\/mailbox-[a-z0-9]+(?:[._-][a-z0-9]+)*$/; +/** The label a mailbox PR carries — `mailbox-open.mjs` re-exports it, so it has one spelling. */ +export const MAILBOX_LABEL = 'pm:mailbox'; /** Names per list-valued key; ten assignees is the platform's own cap, twenty labels is ours. */ export const MAX_LABELS_PER_ACTION = 20; export const MAX_LOGINS_PER_ACTION = 10; @@ -1773,7 +1795,8 @@ export const TARGET_REPO_SHAPE = new RegExp(`^${TARGET_OWNER}/[A-Za-z0-9_.-]+$`) * the wrong key may be an advisory's prose, and a refusal reaches the public * run log. `named` is an `enum`'s values refused BY NAME, each with its * reason. A `str` refusal never echoes, so `summary` and `description` need - * neither. + * neither; `shape` on a `str` holds it to a pattern, named by `what` + * (`branch`, `mailbox_branch_delete`'s, alone). */ export const FIELDS = Object.freeze({ issue: Object.freeze({ kind: 'int' }), @@ -1783,6 +1806,7 @@ export const FIELDS = Object.freeze({ title: Object.freeze({ kind: 'str', max: MAX_TITLE_BYTES }), head: Object.freeze({ kind: 'str', max: MAX_REF_BYTES }), base: Object.freeze({ kind: 'str', max: MAX_REF_BYTES }), + branch: Object.freeze({ kind: 'str', max: MAX_REF_BYTES, shape: MAILBOX_BRANCH_SHAPE, what: 'a claude/mailbox-* branch — claude/mailbox- and a lower-case slug' }), expected_head_sha: Object.freeze({ kind: 'sha' }), labels: Object.freeze({ kind: 'list', max: MAX_LABELS_PER_ACTION, itemMax: MAX_LABEL_BYTES }), assignees: Object.freeze({ kind: 'list', max: MAX_LOGINS_PER_ACTION, itemMax: MAX_LOGIN_CHARS }), @@ -1817,10 +1841,10 @@ export const FIELDS = Object.freeze({ /** * The App permissions the relay token is narrowed to — the union of every * row's `permission` and `alsoSpends`, plus the sender gate's read. - * `contents: write` is spent by the two auto-merge rows alone (GitHub's - * requirement for those mutations); no row writes a file, a ref or a branch - * itself — `pr_update_branch`'s merge is the platform's, under - * `pull-requests`. `workflows: write` is spent by the `automerge_enable` row + * `contents: write` is spent by the two auto-merge rows (GitHub's + * requirement for those mutations) and `mailbox_branch_delete`'s one ref + * DELETE (header); no row writes a file or creates or moves a ref — + * `pr_update_branch`'s merge is the platform's, under `pull-requests`. `workflows: write` is spent by the `automerge_enable` row * alone (`alsoSpends`: a pull changing `.github/workflows/**` is enqueued only * by a token carrying it), a literal grant on every stroke by the * maintainer's ruling, comment 6104822731 (header). `actions: write` is spent @@ -2017,7 +2041,10 @@ export function transferRemedy(source, target) { * execute.mjs); an `assigned` descriptor lists the logins its * answer's `assignees` must carry, and a `labelled` one the * names its answer (the label set) must carry, for the request - * to have landed (`requestLanded`, execute.mjs). + * to have landed (`requestLanded`, execute.mjs); a + * `mailboxBranch` descriptor carries the pull and the branch + * the executor fences before the DELETE and reads back after + * it (`mailboxBranchPreflight`, execute.mjs). */ export const OPS = Object.freeze({ comment: Object.freeze({ @@ -2154,6 +2181,14 @@ export const OPS = Object.freeze({ optional: Object.freeze([]), requests: (a) => [{ verb: 'POST', path: '/graphql', graphql: { mutation: 'disablePullRequestAutoMerge', query: PR_MUTATIONS.automerge_disable, pull: a.pull, landed: PR_LANDED_STATE.automerge_disable } }], }), + // The one row that deletes a ref — see the header. `branch` is held to `MAILBOX_BRANCH_SHAPE` before the dispatch; + // the pull's fences are the executor's, on its own answers immediately before the DELETE; the ref is read back absent. + mailbox_branch_delete: Object.freeze({ + permission: 'contents', + required: Object.freeze(['pull', 'branch']), + optional: Object.freeze([]), + requests: (a, repo) => [{ verb: 'DELETE', path: `/repos/${repo}/git/refs/heads/${a.branch}`, mailboxBranch: { pull: a.pull, branch: a.branch } }], + }), // The one row whose token reaches a second repository — see the header. // `issue`, never `pull`: a pull request does not transfer. transfer: Object.freeze({ @@ -2334,7 +2369,8 @@ export function governedReviewRequests(repo, pull, { reviewers = [], assignees = export const REFUSED_PATH_FAMILIES = Object.freeze([ /\/merge$/, /\/pulls\/[^/]+\/reviews/, - /\/git\//, + // Every `/git/` path but ONE `refs/heads/` ref of `MAILBOX_BRANCH_SHAPE` — `mailbox_branch_delete`'s DELETE (header). + new RegExp(`/git/(?!refs/heads/${MAILBOX_BRANCH_SHAPE.source.slice(1, -1)}$)`), /\/branches\//, /\/contents\//, // Every `actions/*` path but the dispatch call (`WORKFLOW_DISPATCH_PATH_SHAPE`) and the failed-jobs diff --git a/scripts/pm/fleet-write/validate.mjs b/scripts/pm/fleet-write/validate.mjs index 8d66a4c7a65..d55b6eff0a3 100644 --- a/scripts/pm/fleet-write/validate.mjs +++ b/scripts/pm/fleet-write/validate.mjs @@ -157,6 +157,8 @@ import { CLIENT_PAYLOAD_MAX_TOP_LEVEL, EVENT_TYPE_MAX_CHARS, FIELDS, + MAILBOX_BRANCH_SHAPE, + MAILBOX_LABEL, MAX_ACTIONS, MAX_REQUEST_ID_CHARS, OPS, @@ -331,6 +333,7 @@ export function validateField(name, value, spec, where) { if (typeof value !== 'string') return `${at} must be a string, got ${typeof value}`; if (value.length === 0) return `${at} must not be empty`; if (bytes(value) > spec.max) return `${at} is ${bytes(value)} bytes, over its cap of ${spec.max}`; + if (spec.shape && !spec.shape.test(value)) return `${at} must be ${spec.what}, got a string of ${value.length} character(s) that is not one — not echoed`; return null; } if (spec.kind === 'list') { @@ -1004,7 +1007,8 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the fields: request_id shape and cap, the one organization, the session id': 8, 'the actions: count, the closed op list, closed keys per op, typed values': 30, 'the platform ceilings: ten top-level properties and under 64KB, pinned with their source': 6, - 'refused by construction: no row reaches a merge, a review, a ref, contents, a workflow, a release or an org endpoint': 22, + 'refused by construction: no row reaches a merge, a review, a ref, contents, a workflow, a release or an org endpoint': 24, + "the mailbox_branch_delete row: pull and a claude/mailbox-* branch and nothing else, any other name refused before the dispatch and never echoed; ONE bodiless DELETE of that one ref, carrying the pull and the branch to the executor's fences; contents write, a literal grant, so the mint widens nothing; and it rides the close stroke": 6, 'the normalised payload: only judged keys travel': 3, 'the CLI: a file, the event file, GitHub outputs, and the exit ladder': 12, 'the transfer row: an issue never a pull, a target from the governed roster never the source, a stroke of its own to one target, labels never created': 12, @@ -1018,8 +1022,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ "advisory_read: ghsa_id and public_key alone — the key its own kind, an X25519 key in base64 SPKI DER; a malformed key or id refused before any request and never echoed; ONE GET of one advisory; a stroke of its own whose token alone carries repository-advisories READ; the seal opening byte for byte for its own read and key alone, the parts inside the runner's caps, the sender gate's one setting with every other closed, and the fences, printing no id, state or field": 28, }); // Raised 11 → 12 with the run_rerun_failed battery, 12 → 13 with the hierarchy rows' battery, 13 → 15 with the event -// file's battery and the advisory rows' battery, 15 → 16 with advisory_read's: one battery, one row. -const SELF_TEST_BATTERY_FLOOR = 16; +// file's battery and the advisory rows' battery, 15 → 16 with advisory_read's, 16 → 17 with mailbox_branch_delete's: one +// battery, one row. +const SELF_TEST_BATTERY_FLOOR = 17; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -1164,6 +1169,7 @@ export async function selfTest() { pr_draft: { pull: 1 }, automerge_enable: { pull: 1 }, automerge_disable: { pull: 1 }, + mailbox_branch_delete: { pull: 1, branch: 'claude/mailbox-ui-3' }, transfer: { issue: 1, target_repo: `${TARGET_OWNER}/objectui` }, sub_issue_add: { issue: 1, sub_issue: 2 }, sub_issue_remove: { issue: 1, sub_issue: 2 }, @@ -1198,7 +1204,7 @@ export async function selfTest() { battery('refused by construction: no row reaches a merge, a review, a ref, contents, a workflow, a release or an org endpoint'); { const sample = { - issue: 7, pull: 7, comment_id: 7, body: 'b', title: 't', head: 'h', base: 'main', labels: ['a b'], assignees: ['u'], reviewers: ['u'], team_reviewers: ['t'], state: 'closed', state_reason: 'completed', expected_head_sha: SHA, target_repo: `${TARGET_OWNER}/objectui`, + issue: 7, pull: 7, comment_id: 7, body: 'b', title: 't', head: 'h', base: 'main', branch: 'claude/mailbox-ui-3', labels: ['a b'], assignees: ['u'], reviewers: ['u'], team_reviewers: ['t'], state: 'closed', state_reason: 'completed', expected_head_sha: SHA, target_repo: `${TARGET_OWNER}/objectui`, workflow: WORKFLOW_DISPATCH_ALLOWLIST[0], ref: WORKFLOW_DISPATCH_REF, run_id: 7, sub_issue: 8, ghsa_id: GHSA, collaborators: ['u'], summary: 's', description: 'd', severity: 'low', cwe_ids: ['CWE-79'], advisory_state: 'closed', packages: ['p'], versions: [VERSION], public_key: READER.spki, }; @@ -1216,6 +1222,9 @@ export async function selfTest() { t("the governed-review follow-up runs after exactly pr_ready — derived from the rows, never listed; ⛔ pr_create is OFF it, the draft open summons nobody and a human is asked at the seat's hand-off (pr_request_reviewers after ACCEPT + a same-head PASS) — and its two writes are a POST to requested_reviewers carrying requested and a POST to the additive issue-assignees endpoint carrying assigned, both in the family pin above", [GOVERNED_REVIEW_OPS, OPS.pr_create.governedReview ?? null, everyRequest.filter((r) => r.governedReview).map((r) => [r.op, r.verb, r.path, r.requested ?? null, r.assigned ?? null])], [['pr_ready'], null, [['pr_ready', 'POST', `/repos/${TARGET_OWNER}/objectstack/pulls/7/requested_reviewers`, ['u'], null], ['pr_ready', 'POST', `/repos/${TARGET_OWNER}/objectstack/issues/7/assignees`, null, ['u']]]]); t('…an empty list spells no request, and a governedReview row is never a row whose spelled requests change: pr_create and pr_ready keep the request lists they always had', [governedReviewRequests('o/r', 7), OPS.pr_create.requests({ op: 'pr_create', title: 't', head: 'h', base: 'b' }, 'o/r').length, OPS.pr_ready.requests({ op: 'pr_ready', pull: 7 }, 'o/r').length], [[], 1, 1]); t('⛔ no request path reaches a refused family', everyRequest.filter((r) => REFUSED_PATH_FAMILIES.some((re) => re.test(r.path))).map((r) => `${r.op} ${r.path}`), []); + t("⛔ the ONE request any row sends under /git/ is mailbox_branch_delete's DELETE of one claude/mailbox-* ref — no create, no move, no other ref", everyRequest.filter((r) => r.path.includes('/git/')).map((r) => [r.op, r.verb, r.path]), [['mailbox_branch_delete', 'DELETE', `/repos/${TARGET_OWNER}/objectstack/git/refs/heads/claude/mailbox-ui-3`]]); + const gitTails = ['refs', 'refs/heads/main', 'refs/heads/claude/project-thread-x', 'refs/heads/mailbox/os-zhuang/triage-1', 'refs/heads/claude/mailbox-ui-3/x', 'refs/heads/claude/mailbox-', 'refs/heads/claude/mailbox-UI-3', 'refs/heads/claude/mailbox-a..b', 'refs/tags/claude/mailbox-ui-3', 'ref/heads/claude/mailbox-ui-3', 'matching-refs/heads/claude', 'trees', 'blobs', 'commits', 'tags']; + t('positive control: every other /git/ path — a ref create, another or a nested ref, a tag, a tree, blob or commit — and /contents/ are still caught, while the one mailbox ref is caught by none', [gitTails.filter((tail) => !REFUSED_PATH_FAMILIES.some((re) => re.test(`/repos/o/r/git/${tail}`))), REFUSED_PATH_FAMILIES.some((re) => re.test('/repos/o/r/contents/a')), REFUSED_PATH_FAMILIES.filter((re) => re.test('/repos/o/r/git/refs/heads/claude/mailbox-ui-3')).length], [[], true, 0]); t('⛔ the table\'s one PUT is pr_update_branch\'s update-branch, an action endpoint — no PUT reaches /labels or /assignees, the whole-set replaces', [everyRequest.filter((r) => r.verb === 'PUT').map((r) => `${r.op} ${r.path}`), everyRequest.some((r) => r.verb === 'PUT' && /\/(labels|assignees)$/.test(r.path))], [[`pr_update_branch /repos/${TARGET_OWNER}/objectstack/pulls/7/update-branch`], false]); t("every verb is one of POST, PATCH, PUT, DELETE — but advisory_read's GET of one advisory, the one GET any row sends, which writes nothing", [everyRequest.filter((r) => r.verb !== 'GET').every((r) => ['POST', 'PATCH', 'PUT', 'DELETE'].includes(r.verb)), everyRequest.filter((r) => r.verb === 'GET').map((r) => `${r.op} ${r.path}`)], [true, [`advisory_read /repos/${TARGET_OWNER}/objectstack/security-advisories/${GHSA}`]]); t('pr_update_branch spends pull-requests — where GitHub\'s ledger lists update-branch, server-to-server, no additional permission — and carries the sha in its body', [OPS.pr_update_branch.permission, OPS.pr_update_branch.requests({ op: 'pr_update_branch', pull: 7, expected_head_sha: SHA }, 'o/r')[0].body], ['pull-requests', { expected_head_sha: SHA }]); @@ -1233,16 +1242,16 @@ export async function selfTest() { t('labels_remove is one directed DELETE per name, URL-encoded, idempotent on 404', OPS.labels_remove.requests({ op: 'labels_remove', issue: 1, labels: ['a b', 'c'] }, 'o/r').map((r) => [r.verb, r.path, r.idempotent404]), [['DELETE', '/repos/o/r/issues/1/labels/a%20b', true], ['DELETE', '/repos/o/r/issues/1/labels/c', true]]); t('every op names a permission the token is narrowed to', OP_NAMES.every((op) => OPS[op].permission in PERMISSIONS)); // The permission map, pinned entry by entry: the auto-merge mutations need `contents: write` on the - // App token (measured live: without it, "Resource not accessible by integration"), and NOTHING else may - // spend it — the table has no contents op, so the grant reaches exactly two mutations. `workflows: write` + // App token (measured live: without it, "Resource not accessible by integration"), and beside them only + // mailbox_branch_delete's one ref DELETE spends it (comment 6674408912) — no file write. `workflows: write` // is the maintainer's ruling (comment 6104822731): a literal grant on every stroke, spent by the // `automerge_enable` row alone (`alsoSpends`) when the pull it arms changes a workflow file. t('the map is exactly issues/pull-requests/contents/workflows/actions/repository-advisories write and metadata read', PERMISSIONS, { issues: 'write', 'pull-requests': 'write', contents: 'write', workflows: 'write', metadata: 'read', actions: 'write', 'repository-advisories': 'write' }); t('workflows is spent by automerge_enable alone, as alsoSpends, and is no row\'s permission', [OP_NAMES.filter((op) => (OPS[op].alsoSpends ?? []).includes('workflows')), OP_NAMES.filter((op) => OPS[op].permission === 'workflows')], [['automerge_enable'], []]); t('nothing wider: every PERMISSIONS key but metadata (the sender gate\'s read) is spent by some row as its permission or an alsoSpends, and the two sets are exactly actions/contents/issues/pull-requests/repository-advisories/workflows', [Object.keys(PERMISSIONS).filter((name) => name !== 'metadata').sort(), [...new Set(OP_NAMES.flatMap((op) => [OPS[op].permission, ...(OPS[op].alsoSpends ?? [])]))].sort()], [['actions', 'contents', 'issues', 'pull-requests', 'repository-advisories', 'workflows'], ['actions', 'contents', 'issues', 'pull-requests', 'repository-advisories', 'workflows']]); t('the two auto-merge rows spend contents', [OPS.automerge_enable.permission, OPS.automerge_disable.permission], ['contents', 'contents']); - t('⛔ …and no other row does: contents reaches the two auto-merge mutations and nothing else', OP_NAMES.filter((op) => OPS[op].permission === 'contents').sort(), ['automerge_disable', 'automerge_enable']); - t('every row spending contents is a GraphQL auto-merge mutation, never a REST path', OP_NAMES.filter((op) => OPS[op].permission === 'contents').every((op) => OPS[op].requests({ op, pull: 1 }).every((r) => r.graphql && /PullRequestAutoMerge$/.test(r.graphql.mutation)))); + t('⛔ …and beside them mailbox_branch_delete alone: contents reaches the two auto-merge mutations and one ref DELETE', OP_NAMES.filter((op) => OPS[op].permission === 'contents').sort(), ['automerge_disable', 'automerge_enable', 'mailbox_branch_delete']); + t('every row spending contents is a GraphQL auto-merge mutation but mailbox_branch_delete, whose one REST request is a DELETE of a head ref', OP_NAMES.filter((op) => OPS[op].permission === 'contents').map((op) => OPS[op].requests({ op, pull: 1, branch: 'claude/mailbox-x' }, 'o/r').map((r) => (r.graphql ? /PullRequestAutoMerge$/.test(r.graphql.mutation) : `${r.verb} ${r.path}`))), [[true], [true], ['DELETE /repos/o/r/git/refs/heads/claude/mailbox-x']]); const repoRoot = resolve(SELF_PATH, '../../../..'); t('the relay files the table declares are on disk, repo-relative, and this file is one of them', [RELAY_FILES.filter((f) => !existsSync(resolve(repoRoot, f))), RELAY_FILES.includes('scripts/pm/fleet-write/validate.mjs')], [[], true]); // The workflow's mint step declares the SAME map: every grant outside `STROKE_SCOPED_PERMISSIONS` as a literal @@ -1258,6 +1267,22 @@ export async function selfTest() { t('…and each stroke-scoped grant as an input reading the validator\'s permission_ output, and nothing else that way', scopedInputs, STROKE_SCOPED_PERMISSIONS.map((name) => [name, name.replace(/-/g, '_')])); } + // ── the mailbox_branch_delete row ───────────────────────────────────────── + battery("the mailbox_branch_delete row: pull and a claude/mailbox-* branch and nothing else, any other name refused before the dispatch and never echoed; ONE bodiless DELETE of that one ref, carrying the pull and the branch to the executor's fences; contents write, a literal grant, so the mint widens nothing; and it rides the close stroke"); + { + const REPO = `${TARGET_OWNER}/objectstack`; + const drop = (branch, extra = {}) => fixturePayload({ actions: [{ op: 'mailbox_branch_delete', pull: 30141, branch, ...extra }] }); + t('the shape is claude/mailbox- and a lower-case slug, and the label is pm:mailbox', [MAILBOX_BRANCH_SHAPE.source, MAILBOX_LABEL], ['^claude\\/mailbox-[a-z0-9]+(?:[._-][a-z0-9]+)*$', 'pm:mailbox']); + t('a claude/mailbox-* branch is accepted — a seat slug with a dot among them', [validatePayload(drop('claude/mailbox-ui-3')).ok, validatePayload(drop('claude/mailbox-repo-www.objectos.ai-2')).ok], [true, true]); + const outside = ['claude/project-thread-mvsts5', 'mailbox/os-zhuang/triage-1', 'claude/mailbox-', 'claude/mailbox-UI-3', 'claude/mailbox-ui/3', 'claude/mailbox-a..b', 'claude/mailbox-ui-3/', 'claude/mailbox-ui-3.', ' claude/mailbox-ui-3', 'main', 'refs/heads/claude/mailbox-ui-3', 'claude/mailbox-x/../../contents/a']; + t("⛔ every other name is refused before the dispatch, naming the shape and never echoing the value: another thread's branch, a local seat's mailbox/ branch, an empty slug, upper case, a slash, a dot pair, a trailing slash or dot, a space, main, a qualified ref, a path escape", outside.filter((b) => validatePayload(drop(b)).errors?.join() !== `actions[0].branch must be ${FIELDS.branch.what}, got a string of ${b.length} character(s) that is not one — not echoed`), []); + t('closed keys: pull and branch both required, a non-integer pull and any other key refused', [refuses(fixturePayload({ actions: [{ op: 'mailbox_branch_delete', branch: 'claude/mailbox-ui-3' }] }), 'pull'), refuses(fixturePayload({ actions: [{ op: 'mailbox_branch_delete', pull: 1 }] }), 'branch'), refuses(drop('claude/mailbox-ui-3', { pull: '1' }), 'positive integer'), refuses(drop('claude/mailbox-ui-3', { sha: SHA }), 'actions[0].sha is not a key `mailbox_branch_delete` takes')], [true, true, true, true]); + const req = OPS.mailbox_branch_delete.requests({ op: 'mailbox_branch_delete', pull: 30141, branch: 'claude/mailbox-ui-3' }, REPO); + t("ONE DELETE of that one ref, no body, carrying the pull and the branch to the executor's fences — and no refused family reaches it", [req.length, req[0].verb, req[0].path, 'body' in req[0], req[0].mailboxBranch, REFUSED_PATH_FAMILIES.filter((re) => re.test(req[0].path)).length], [1, 'DELETE', `/repos/${REPO}/git/refs/heads/claude/mailbox-ui-3`, false, { pull: 30141, branch: 'claude/mailbox-ui-3' }, 0]); + const close = validatePayload(fixturePayload({ actions: [{ op: 'comment', issue: 30141, body: 'b' }, { op: 'issue_patch', issue: 30141, state: 'closed' }, { op: 'mailbox_branch_delete', pull: 30141, branch: 'claude/mailbox-ui-3' }] })); + t('contents write, a literal grant, no alsoSpends, not alone: the close stroke — comment, close, delete — is accepted and its token is the five literal grants, nothing stroke-scoped', [OPS.mailbox_branch_delete.permission, STROKE_SCOPED_PERMISSIONS.includes('contents'), OPS.mailbox_branch_delete.alsoSpends ?? null, OPS.mailbox_branch_delete.alone ?? false, close.ok, close.ok ? tokenPermissionsOf(close.payload) : null, close.ok ? outputLine(close.payload, 'permission_actions') : null], ['contents', false, null, false, true, { issues: 'write', 'pull-requests': 'write', contents: 'write', workflows: 'write', metadata: 'read' }, 'permission_actions=']); + } + // ── the normalised payload ──────────────────────────────────────────────── battery('the normalised payload: only judged keys travel'); { @@ -1344,7 +1369,7 @@ export async function selfTest() { comment: { issue: 1, body: 'b' }, comment_edit: { comment_id: 1, body: 'b' }, labels_add: { issue: 1, labels: ['a'] }, labels_remove: { issue: 1, labels: ['a'] }, assign: { issue: 1, assignees: ['u'] }, unassign: { issue: 1, assignees: ['u'] }, issue_patch: { issue: 1, state: 'closed' }, issue_create: { title: 't', body: 'b' }, pr_create: { title: 't', head: 'h', base: 'main' }, pr_request_reviewers: { pull: 1, reviewers: ['u'] }, pr_update_branch: { pull: 1, expected_head_sha: SHA }, pr_ready: { pull: 1 }, pr_draft: { pull: 1 }, - automerge_enable: { pull: 1 }, automerge_disable: { pull: 1 }, workflow_dispatch: { workflow: WORKFLOW_DISPATCH_ALLOWLIST[0], ref: WORKFLOW_DISPATCH_REF }, + automerge_enable: { pull: 1 }, automerge_disable: { pull: 1 }, mailbox_branch_delete: { pull: 1, branch: 'claude/mailbox-ui-3' }, workflow_dispatch: { workflow: WORKFLOW_DISPATCH_ALLOWLIST[0], ref: WORKFLOW_DISPATCH_REF }, run_rerun_failed: { run_id: 1 }, sub_issue_add: { issue: 1, sub_issue: 2 }, sub_issue_remove: { issue: 1, sub_issue: 2 }, advisory_report: { summary: 's', description: 'd' }, advisory_create: { summary: 's', description: 'd', packages: ['p'] }, advisory_collaborators_add: { ghsa_id: GHSA, collaborators: ['u'] }, advisory_versions: { ghsa_id: GHSA, versions: [VERSION] }, advisory_state: { ghsa_id: GHSA, advisory_state: 'draft' }, advisory_read: { ghsa_id: GHSA, public_key: READER.spki }, @@ -1501,7 +1526,7 @@ export async function selfTest() { comment: { issue: 1, body: 'b' }, comment_edit: { comment_id: 1, body: 'b' }, labels_add: { issue: 1, labels: ['a'] }, labels_remove: { issue: 1, labels: ['a'] }, assign: { issue: 1, assignees: ['u'] }, unassign: { issue: 1, assignees: ['u'] }, issue_patch: { issue: 1, state: 'closed' }, issue_create: { title: 't', body: 'b' }, pr_create: { title: 't', head: 'h', base: 'main' }, pr_request_reviewers: { pull: 1, reviewers: ['u'] }, pr_update_branch: { pull: 1, expected_head_sha: SHA }, pr_ready: { pull: 1 }, pr_draft: { pull: 1 }, - automerge_enable: { pull: 1 }, automerge_disable: { pull: 1 }, transfer: { issue: 1, target_repo: `${TARGET_OWNER}/objectui` }, + automerge_enable: { pull: 1 }, automerge_disable: { pull: 1 }, mailbox_branch_delete: { pull: 1, branch: 'claude/mailbox-ui-3' }, transfer: { issue: 1, target_repo: `${TARGET_OWNER}/objectui` }, sub_issue_add: { issue: 1, sub_issue: 2 }, sub_issue_remove: { issue: 1, sub_issue: 2 }, advisory_report: { summary: 's', description: 'd' }, advisory_create: { summary: 's', description: 'd', packages: ['p'] }, advisory_collaborators_add: { ghsa_id: GHSA, collaborators: ['u'] }, advisory_versions: { ghsa_id: GHSA, versions: [VERSION] }, advisory_state: { ghsa_id: GHSA, advisory_state: 'draft' }, advisory_read: { ghsa_id: GHSA, public_key: READER.spki }, @@ -1768,7 +1793,7 @@ export async function selfTest() { comment: { issue: 1, body: 'b' }, comment_edit: { comment_id: 1, body: 'b' }, labels_add: { issue: 1, labels: ['a'] }, labels_remove: { issue: 1, labels: ['a'] }, assign: { issue: 1, assignees: ['u'] }, unassign: { issue: 1, assignees: ['u'] }, issue_patch: { issue: 1, state: 'closed' }, issue_create: { title: 't', body: 'b' }, pr_create: { title: 't', head: 'h', base: 'main' }, pr_request_reviewers: { pull: 1, reviewers: ['u'] }, pr_update_branch: { pull: 1, expected_head_sha: SHA }, pr_ready: { pull: 1 }, pr_draft: { pull: 1 }, - automerge_enable: { pull: 1 }, automerge_disable: { pull: 1 }, transfer: { issue: 1, target_repo: `${TARGET_OWNER}/objectui` }, + automerge_enable: { pull: 1 }, automerge_disable: { pull: 1 }, mailbox_branch_delete: { pull: 1, branch: 'claude/mailbox-ui-3' }, transfer: { issue: 1, target_repo: `${TARGET_OWNER}/objectui` }, sub_issue_add: { issue: 1, sub_issue: 2 }, sub_issue_remove: { issue: 1, sub_issue: 2 }, workflow_dispatch: { workflow: WORKFLOW_DISPATCH_ALLOWLIST[0], ref: WORKFLOW_DISPATCH_REF }, run_rerun_failed: { run_id: 1 }, }; diff --git a/scripts/pm/mailbox-open.mjs b/scripts/pm/mailbox-open.mjs index 560327c1c6c..6ee573f6909 100644 --- a/scripts/pm/mailbox-open.mjs +++ b/scripts/pm/mailbox-open.mjs @@ -76,7 +76,8 @@ * nothing else is sent. `--head ` names a branch that already * exists instead (a thread's own pushed branch, as #25392 used) and skips * the push; it must be ahead of ``, because GitHub refuses a pull - * with no commits. + * with no commits. A cloud seat names it `claude/mailbox-`, the one + * head `--close` deletes. * ③ ONE relay stroke: `pr_create` (draft is forced by the relay) with labels * `pm:mailbox`, `pinned` and `skip-changeset`, sent through * `fleet-write/dispatch.mjs` as `objectstack-fleet[bot]`. `pinned` is @@ -94,7 +95,12 @@ * * `--close ` reads the pull first and REFUSES (exit 4) unless it is open * and carries `pm:mailbox`, so a mistyped number can never close a real PR; - * then one stroke: the closing `comment` and `issue_patch state: closed`. + * then one stroke: the closing `comment`, `issue_patch state: closed` and, + * when the head read is `claude/mailbox-*`, `mailbox_branch_delete` of it — + * the relay's one ref op (ruling 6674408912), fenced there on the closed pull — + * so a cloud seat's mailbox leaves no branch. Any other head is left: a + * thread's own branch is not the mailbox's, and a local seat deletes its + * `mailbox/…` branch with git. * * ## CI on an empty diff — exempt where a label can exempt it, red by design elsewhere * @@ -138,6 +144,7 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from '../invoked-as.mjs'; +import { MAILBOX_BRANCH_SHAPE, MAILBOX_LABEL } from './fleet-write/ops.mjs'; import { validateStroke } from './fleet-write/validate.mjs'; import { ghApi, PER_PAGE, readAllPages } from './gh-pages.mjs'; @@ -150,7 +157,7 @@ export const EXIT_USAGE = 2; export const EXIT_PREREQUISITE = 3; export const EXIT_REFUSED = 4; -export const MAILBOX_LABEL = 'pm:mailbox'; +export { MAILBOX_LABEL }; /** Appended to every search query that enumerates open PRs for landing. */ export const LEDGER_EXCLUSION = `-label:${MAILBOX_LABEL}`; export const MAILBOX_BRANCH_PREFIX = 'mailbox/'; @@ -285,12 +292,15 @@ export function planOpen({ repo, seat, login, head = null, base = 'main', sessio return { kind: 'open', repo, seat: s, login, branch, base, push: head === null, actions, errors: validateStroke(repo, actions) }; } -export function planClose({ repo, pull }) { +/** `head` is the pull's head as read live; a `claude/mailbox-*` one is deleted in the same stroke, after the close (header). */ +export function planClose({ repo, pull, head = null }) { + const branch = typeof head === 'string' && MAILBOX_BRANCH_SHAPE.test(head) ? head : null; const actions = [ { op: 'comment', issue: pull, body: 'Mailbox closed: the seat handed over or switched account. The successor opens its own with `scripts/pm/mailbox-open.mjs`.\n\n---\n_Generated by [Claude Code](https://claude.ai/code)_' }, { op: 'issue_patch', issue: pull, state: 'closed' }, + ...(branch ? [{ op: 'mailbox_branch_delete', pull, branch }] : []), ]; - return { kind: 'close', repo, pull, actions, errors: validateStroke(repo, actions) }; + return { kind: 'close', repo, pull, branch, actions, errors: validateStroke(repo, actions) }; } const labelNames = (pr) => (Array.isArray(pr?.labels) ? pr.labels.map((l) => (typeof l === 'string' ? l : l?.name)) : []); @@ -353,7 +363,7 @@ export function planText(plan) { lines.push(` ④ read back the open pull on ${plan.branch} and print its number`); } else { lines.push(` ① read #${plan.pull}; refuse unless it is open and carries ${MAILBOX_LABEL}`); - lines.push(' ② one relay stroke (fleet-write/dispatch.mjs), the actions file below'); + lines.push(' ② one relay stroke (fleet-write/dispatch.mjs), the actions file below — plus mailbox_branch_delete when the head read is claude/mailbox-*'); } lines.push('--- actions file ---', JSON.stringify(plan.actions, null, 2)); return lines.join('\n'); @@ -409,7 +419,7 @@ async function openLive(plan, opts) { } const pushed = run('bash', [join(PM_DIR, 'with-fleet.sh'), '--kind', 'git push', '--', 'git', 'push', 'origin', `${sha.out}:refs/heads/${plan.branch}`]); if (!pushed.ok) { - console.error(`✗ mailbox-open: the push of ${plan.branch} was refused (with-fleet exit ${pushed.status}). A cloud seat cannot push it — pass --head . A legacy branch ${MAILBOX_BRANCH_PREFIX}${plan.login} refuses it too: git nests no ref under an existing one, so delete that branch once its mailbox is closed. No PR was opened.`); + console.error(`✗ mailbox-open: the push of ${plan.branch} was refused (with-fleet exit ${pushed.status}). A cloud seat cannot push it — push claude/mailbox-${plan.seat.slug} itself and pass --head claude/mailbox-${plan.seat.slug} (--close deletes that name). A legacy branch ${MAILBOX_BRANCH_PREFIX}${plan.login} refuses it too: git nests no ref under an existing one, so delete that branch once its mailbox is closed. No PR was opened.`); return EXIT_PREREQUISITE; } } else { @@ -444,8 +454,9 @@ async function closeLive(plan, opts) { console.error(`✗ mailbox-open: ${refusal}. Nothing was sent.`); return EXIT_REFUSED; } - const code = sendStroke(plan, opts); - if (code === EXIT_OK) console.log(`✓ mailbox #${plan.pull} closed on ${plan.repo}.`); + const live = planClose({ repo: plan.repo, pull: plan.pull, head: read.value.head?.ref }); + const code = sendStroke(live, opts); + if (code === EXIT_OK) console.log(`✓ mailbox #${plan.pull} closed on ${plan.repo}; ${live.branch ? `its branch ${live.branch} deleted` : `its head ${read.value.head?.ref ?? '(none)'} left — the relay deletes a claude/mailbox-* head alone`}.`); return code; } @@ -493,7 +504,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'arguments: the two modes and every refusal': 14, 'the seat: canonical key, ref slug, and the per-seat branch': 11, 'the open plan: branch, labels, body, and the relay validator': 11, - 'the close plan: comment then close, judged by the validator': 3, + 'the close plan: comment then close, then the delete of a claude/mailbox-* head, judged by the validator': 5, 'whose mailbox a pull is: the seat on the body line or the head, a legacy login, neither': 8, "finding a seat's open mailbox: by seat, never by login": 10, 'refusing to close what is not an open mailbox': 4, @@ -574,10 +585,13 @@ async function selfTest() { const pe = planOpen({ repo: 'objectstack-ai/objectstack', seat: 'Epic:#27293#1', login: 'sunhaolin' }); t('⭐ an epic seat\'s plan: its branch, its Mailbox-of line, its To: line, and the validator accepts it', [pe.branch, pe.actions[0].title, pe.actions[0].body.split('\n')[0], pe.actions[0].body.includes('the line `To: epic:#27293#1`'), pe.errors], ['mailbox/sunhaolin/epic-27293-1', 'pm:mailbox — epic:#27293#1 · sunhaolin', 'Mailbox-of: epic:#27293#1 · sunhaolin', true, []]); - battery('the close plan: comment then close, judged by the validator'); + battery('the close plan: comment then close, then the delete of a claude/mailbox-* head, judged by the validator'); const c = planClose({ repo: 'o/r', pull: 25392 }); t('comment first, then issue_patch closed', c.actions.map((a) => [a.op, a.issue, a.state ?? null]), [['comment', 25392, null], ['issue_patch', 25392, 'closed']]); t('the relay validator accepts the stroke', c.errors, []); + const cd = planClose({ repo: 'objectstack-ai/objectstack', pull: 30141, head: 'claude/mailbox-ui-3' }); + t('⭐ a claude/mailbox-* head is deleted in the SAME stroke, last — after the close the relay fences on — and the validator accepts it', [cd.branch, cd.actions.map((a) => a.op), cd.actions[2], cd.errors], ['claude/mailbox-ui-3', ['comment', 'issue_patch', 'mailbox_branch_delete'], { op: 'mailbox_branch_delete', pull: 30141, branch: 'claude/mailbox-ui-3' }, []]); + t('⛔ any other head is left — a thread branch, a local seat\'s mailbox/ branch, a look-alike, none read', ['claude/project-thread-mvsts5', 'mailbox/os-zhuang/triage-1', 'claude/mailbox-UI-3', 'claude/mailbox-ui-3/x', null].map((head) => planClose({ repo: 'o/r', pull: 1, head })).map((x) => [x.branch, x.actions.length]), [[null, 2], [null, 2], [null, 2], [null, 2], [null, 2]]); t('the comment carries the bare footer under its rule line', c.actions[0].body.endsWith('\n\n---\n_Generated by [Claude Code](https://claude.ai/code)_'), true); battery('whose mailbox a pull is: the seat on the body line or the head, a legacy login, neither'); From 83464d0baa3a07a2c1dcf46c7eb582375a617aae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 03:05:22 +0000 Subject: [PATCH 2/2] fix(pm): mailbox_branch_delete fences on the ref's sha, and a refused close-stroke delete names its bare re-send Contract review should-fixes on #30782: - execute.mjs: mailboxBranchPreflight reads GET /git/ref/heads/{branch} as the last read before the DELETE and refuses unless the ref points at the closed pull's head.sha, so the op deletes that PR's head and never a commit pushed after the close. A ref already absent fences nothing (the DELETE's 422 lands idempotent). Self-test: a moved ref and an unread ref each refused with nothing sent; the good case reads the ref before the DELETE. - mailbox-open.mjs: when a --close stroke that carried the delete fails, closeRemedy prints the one bare mailbox_branch_delete action to send through fleet-write/dispatch.mjs, since --close refuses a closed pull. Self-test pins the action and that the validator accepts it. - The fence list now lives only in ops.mjs's header; execute.mjs and mailbox-open.mjs point at it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016WV4cwSv11c8FvyR8ZuWhv --- scripts/pm/fleet-write/execute.mjs | 49 ++++++++++++++++-------------- scripts/pm/fleet-write/ops.mjs | 16 ++++++---- scripts/pm/mailbox-open.mjs | 24 +++++++++++---- 3 files changed, 54 insertions(+), 35 deletions(-) diff --git a/scripts/pm/fleet-write/execute.mjs b/scripts/pm/fleet-write/execute.mjs index 5f0e6d5d99b..e97c68b0856 100644 --- a/scripts/pm/fleet-write/execute.mjs +++ b/scripts/pm/fleet-write/execute.mjs @@ -190,15 +190,10 @@ * read the parent and ⛔ never re-send. Every read is an unpaced GET, and the * one write is paced like every write verb. * - * ## The mailbox branch delete — `mailbox_branch_delete`, fenced on the PULL and read back + * ## The mailbox branch delete — `mailbox_branch_delete` * - * The fences (`ops.mjs` header, "The one row that deletes a ref") are facts - * about the pull, so they are AUTHORITATIVE here (`mailboxBranchPreflight`): - * the pull's own `GET` and the open pulls on `{owner}:{branch}`, the last two - * reads before the DELETE, each fence that fails named, the DELETE NOT SENT. - * The 204 is not the landing: `confirmMailboxBranch` reads the ref back, and - * only an absent ref lands — a ref already gone (the DELETE's 422) is an - * idempotent landing, a ref still there is a FAILED action. + * Its fences and its landing are listed once, in `ops.mjs`'s header ("The one + * row that deletes a ref"); they are AUTHORITATIVE here, each that fails named. * * ## The advisory rows that write — fenced on the ADVISORY, printing none of its text * @@ -906,9 +901,9 @@ export async function confirmSubIssue(req, answer, pre, payload, api, t, { sleep /** * The mailbox branch delete's fences on the reads (header): `[]` to send, else one sentence per fence that fails — - * an unread pull is one, judged alone. `pull` and `open` are `rest` answers. Pure. + * an unread pull is one, judged alone. `pull`, `open` and `ref` are `rest` answers; a 404 ref fences nothing. Pure. */ -export function mailboxBranchFenceFailures({ repo, branch, pull, open }) { +export function mailboxBranchFenceFailures({ repo, branch, pull, open, ref }) { if (pull?.status !== 200 || !pull.json || typeof pull.json !== 'object') return [`${pull?.call} -> HTTP ${pull?.status}: the pull could not be read`]; const pr = pull.json; const out = []; @@ -918,16 +913,20 @@ export function mailboxBranchFenceFailures({ repo, branch, pull, open }) { if (String(pr.head?.repo?.full_name ?? '').toLowerCase() !== repo.toLowerCase()) out.push(`the pull's head lives on ${pr.head?.repo?.full_name ?? 'no repository'}, not ${repo}`); if (open?.status !== 200 || !Array.isArray(open.json)) out.push(`${open?.call} -> HTTP ${open?.status}: the open pulls on ${branch} could not be read`); else if (open.json.length) out.push(`open pull(s) ${open.json.map((p) => `#${p?.number}`).join(', ')} have ${branch} as head — the DELETE would close them`); + const at = ref?.status === 200 && ref.json?.ref === `refs/heads/${branch}` ? ref.json.object?.sha : undefined; + if (ref?.status !== 404 && typeof at !== 'string') out.push(`${ref?.call} -> HTTP ${ref?.status}: the ref ${branch} could not be read`); + else if (ref?.status !== 404 && at !== pr.head?.sha) out.push(`${branch} is at ${at.slice(0, 12)}, not the pull's head ${String(pr.head?.sha ?? 'unread').slice(0, 12)} — a commit pushed after the close is not the mailbox's`); return out; } -/** The fences, AUTHORITATIVE (header): the two reads, then `{}` to send or `{ fail }`, the DELETE NOT SENT. Never throws. */ +/** The fences, AUTHORITATIVE (header): the three reads, then `{}` to send or `{ fail }`, the DELETE NOT SENT. Never throws. */ export async function mailboxBranchPreflight(req, payload, api, t) { const { pull, branch } = req.mailboxBranch; const repo = payload.repo; const pr = await rest(api, `/repos/${repo}/pulls/${pull}`, {}, t); const open = await rest(api, `/repos/${repo}/pulls?state=open&head=${encodeURIComponent(`${repo.split('/')[0]}:${branch}`)}`, {}, t); - const failures = mailboxBranchFenceFailures({ repo, branch, pull: pr, open }); + const ref = await rest(api, `/repos/${repo}/git/ref/heads/${branch}`, {}, t); + const failures = mailboxBranchFenceFailures({ repo, branch, pull: pr, open, ref }); return failures.length ? { fail: `REFUSED at the relay's fence on #${pull}'s head ${branch}, nothing sent — ${failures.join('; ')}` } : {}; } @@ -1643,7 +1642,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ "the run_rerun_failed: the five fences judged HERE on the run and its pull, read immediately before ONE bodiless POST — the pull's current head by its own GET, never the run's embedded copy, and a run naming no pull refused by name; each refused with the POST not sent and accepted on the measured run; a run, pull, baseline or fence that fails is zero writes; the 201 confirmed only by reading attempt 2 back with its failed jobs queued or in progress, naming their job ids; attempt 2 never seen, a failed job completed, or a job list unread is FAILED; no annotation": 21, "the hierarchy link: sub_issue_add and sub_issue_remove read the parent's and the child's own answers, then the child's parent (add) or the parent's whole list (remove), immediately before ONE write whose body is the child's REST id from its own answer — never its number, never replace_parent; a pull, a transferred or unread card, a child under another parent, one the parent does not list, or a read that is neither answer is zero writes; a child already under this parent is a no-op landed on the list; the answer confirmed only when the parent's list reads the child back (or no longer does) inside the window; a refused write, a list that never settles or never reads whole is FAILED; no annotation, and the rows ride a stroke beside other ops": 20, "the advisory rows: advisory_report and advisory_create are ONE POST of their body each, landed on a 2xx carrying an id — a draft's reading state draft — that the row and a notice annotation name; advisory_collaborators_add, advisory_versions and advisory_state read the advisory first and send ONE PATCH — every existing login kept with the missing ones added, an all-present list a no-op, the versions entries alone, or the one state a legal transition reaches, the reopen and the publish among them — or nothing at all on a fence: an unread, other or wrong-state advisory, an illegal move, a list the versions may not replace or a publish whose versions are not set; each lands only when its answer shows the result, a publish only read back as published with the versions it was sent, and a landed publish alone is annotated with its id; ⛔ the marker in a report's or a draft's summary, description and packages and in every advisory answer's summary, description and vulnerabilities reaches no log line, row, step summary or annotation": 41, - "the mailbox branch delete: the pull's own answer and the open pulls on its head read immediately before ONE DELETE of the ref under the App token — an open, unlabelled, other-headed or forked pull, an open pull on that head, or a read that does not answer is zero writes with every failing fence named; the DELETE lands only when the ref reads back absent, a ref already gone an idempotent landing and one still there FAILED; and mailbox-open's close stroke — comment, close, delete — leaves no branch, while a head outside claude/mailbox-* sends no DELETE": 7, + "the mailbox branch delete: the pull's own answer, the open pulls on its head and the ref read immediately before ONE DELETE of the ref under the App token — an open, unlabelled, other-headed or forked pull, an open pull on that head, a ref moved off the pull's head, or a read that does not answer is zero writes with every failing fence named; the DELETE lands only when the ref reads back absent, a ref already gone an idempotent landing and one still there FAILED; and mailbox-open's close stroke — comment, close, delete — leaves no branch, while a head outside claude/mailbox-* sends no DELETE": 7, "advisory_read: ONE GET after the sender gate and nothing written; the read, advisory and sender fences judged on that answer — target-write by the maintainer's ruling, a sender off the advisory's collaborators sealed for, one without write refused before the GET, any other gate closed — then the answer sealed to the seat's key and emitted as notice parts the reader parses back, opening byte for byte for that key and read alone; a failed or unsealed seal and an over-cap answer FAILED with nothing emitted; ⛔ the marker in the summary, description and vulnerabilities, and the advisory's id, reach no log line, row, step summary or annotation, and the sealed answer alone carries no marker": 18, }); // Raised 17 → 18 with the governed-review follow-up battery, 18 → 19 with the run_rerun_failed battery, 19 → 20 with the @@ -2510,7 +2509,7 @@ export async function selfTest() { } // ── the mailbox branch delete ─────────────────────────────────────────── - battery("the mailbox branch delete: the pull's own answer and the open pulls on its head read immediately before ONE DELETE of the ref under the App token — an open, unlabelled, other-headed or forked pull, an open pull on that head, or a read that does not answer is zero writes with every failing fence named; the DELETE lands only when the ref reads back absent, a ref already gone an idempotent landing and one still there FAILED; and mailbox-open's close stroke — comment, close, delete — leaves no branch, while a head outside claude/mailbox-* sends no DELETE"); + battery("the mailbox branch delete: the pull's own answer, the open pulls on its head and the ref read immediately before ONE DELETE of the ref under the App token — an open, unlabelled, other-headed or forked pull, an open pull on that head, a ref moved off the pull's head, or a read that does not answer is zero writes with every failing fence named; the DELETE lands only when the ref reads back absent, a ref already gone an idempotent landing and one still there FAILED; and mailbox-open's close stroke — comment, close, delete — leaves no branch, while a head outside claude/mailbox-* sends no DELETE"); { const PR = 30141; const BR = 'claude/mailbox-ui-3'; @@ -2518,29 +2517,33 @@ export async function selfTest() { const OPEN = `GET /repos/${REPO}/pulls`; const DEL = `DELETE /repos/${REPO}/git/refs/heads/${BR}`; const BACK = `GET /repos/${REPO}/git/ref/heads/${BR}`; - const box = (o = {}) => ({ number: PR, state: 'closed', labels: [{ name: 'pm:mailbox' }, { name: 'pinned' }], head: { ref: BR, repo: { full_name: REPO } }, ...o }); - const good = { ...allowed, [PULL]: { status: 200, json: box() }, [OPEN]: { status: 200, json: [] }, [DEL]: { status: 204, json: null }, [BACK]: { status: 404, json: { message: 'Not Found' } } }; + const HEAD = 'a'.repeat(40); + const at = (sha) => ({ status: 200, json: { ref: `refs/heads/${BR}`, object: { sha, type: 'commit' } } }); + const box = (o = {}) => ({ number: PR, state: 'closed', labels: [{ name: 'pm:mailbox' }, { name: 'pinned' }], head: { ref: BR, sha: HEAD, repo: { full_name: REPO } }, ...o }); + const good = { ...allowed, [PULL]: { status: 200, json: box() }, [OPEN]: { status: 200, json: [] }, [DEL]: { status: 204, json: null }, [BACK]: [at(HEAD), { status: 404, json: { message: 'Not Found' } }] }; const drop = base([{ op: 'mailbox_branch_delete', pull: PR, branch: BR }]); const ok = await run(drop, good); - t('the good case: the pull, then the open pulls on its head, then ONE DELETE of the ref under the App token, then the ref read back absent — exit 0, the row naming it deleted', [ok.exit, ok.seen.slice(1).map((s) => s.call), writes(ok.seen).map((s) => [s.call, s.body, s.auth]), ok.rows[0].result, ok.annotations], [EXIT_OK, [PULL, OPEN, DEL, BACK], [[DEL, null, `Bearer ${TOKEN}`]], `deleted ${BR} (read back absent)`, []]); + t("the good case: the pull, the open pulls on its head, the ref at the pull's head, then ONE DELETE of the ref under the App token, then the ref read back absent — exit 0, the row naming it deleted", [ok.exit, ok.seen.slice(1).map((s) => s.call), writes(ok.seen).map((s) => [s.call, s.body, s.auth]), ok.rows[0].result, ok.annotations], [EXIT_OK, [PULL, OPEN, BACK, DEL, BACK], [[DEL, null, `Bearer ${TOKEN}`]], `deleted ${BR} (read back absent)`, []]); const urls = []; await mailboxBranchPreflight(OPS.mailbox_branch_delete.requests({ op: 'mailbox_branch_delete', pull: PR, branch: BR }, REPO)[0], { repo: REPO }, 'https://api.test', { token: TOKEN, fetch: async (url) => (urls.push(url), { status: 200, headers: new Headers(), json: async () => [] }) }); t("the open-pulls read is filtered to this repository's owner and the branch — an unfiltered or mis-owned read would answer for other heads", urls[1], `https://api.test/repos/${REPO}/pulls?state=open&head=objectstack-ai%3Aclaude%2Fmailbox-ui-3`); const fences = [ [{ [PULL]: { status: 200, json: box({ state: 'open' }) } }, 'the pull is open, not closed'], [{ [PULL]: { status: 200, json: box({ labels: [{ name: 'pinned' }] }) } }, 'does not carry pm:mailbox'], - [{ [PULL]: { status: 200, json: box({ head: { ref: 'claude/mailbox-ui-2', repo: { full_name: REPO } } }) } }, "the pull's head is claude/mailbox-ui-2, not claude/mailbox-ui-3"], - [{ [PULL]: { status: 200, json: box({ head: { ref: BR, repo: { full_name: 'someone/objectstack' } } }) } }, "head lives on someone/objectstack, not objectstack-ai/objectstack"], + [{ [PULL]: { status: 200, json: box({ head: { ref: 'claude/mailbox-ui-2', sha: HEAD, repo: { full_name: REPO } } }) } }, "the pull's head is claude/mailbox-ui-2, not claude/mailbox-ui-3"], + [{ [PULL]: { status: 200, json: box({ head: { ref: BR, sha: HEAD, repo: { full_name: 'someone/objectstack' } } }) } }, "head lives on someone/objectstack, not objectstack-ai/objectstack"], + [{ [BACK]: at('b'.repeat(40)) }, `${BR} is at bbbbbbbbbbbb, not the pull's head aaaaaaaaaaaa — a commit pushed after the close is not the mailbox's`], + [{ [BACK]: { status: 503, json: { message: 'down' } } }, 'the ref claude/mailbox-ui-3 could not be read'], [{ [OPEN]: { status: 200, json: [{ number: 30999 }] } }, 'open pull(s) #30999 have claude/mailbox-ui-3 as head — the DELETE would close them'], [{ [PULL]: { status: 404, json: { message: 'Not Found' } } }, 'the pull could not be read'], [{ [OPEN]: { status: 503, json: { message: 'down' } } }, 'the open pulls on claude/mailbox-ui-3 could not be read'], ]; const fenced = await Promise.all(fences.map(async ([over, phrase]) => { const r = await run(drop, { ...good, ...over }); return [r.exit, writes(r.seen).length, r.rows[0]?.status, r.rows[0]?.result.includes(phrase)]; })); - t('⛔ each fence refuses with the DELETE NOT SENT and names itself: an open pull, no pm:mailbox, another head, a forked head, an open pull on the head, an unread pull, an unread open-pulls list', fenced, fences.map(() => [EXIT_ACTION_FAILED, 0, 'not sent', true])); - const kept = await run(drop, { ...good, [BACK]: { status: 200, json: { ref: `refs/heads/${BR}` } } }); + t('⛔ each fence refuses with the DELETE NOT SENT and names itself: an open pull, no pm:mailbox, another head, a forked head, an open pull on the head, a ref moved past the head, an unread ref, an unread pull, an unread open-pulls list', fenced, fences.map(() => [EXIT_ACTION_FAILED, 0, 'not sent', true])); + const kept = await run(drop, { ...good, [BACK]: at(HEAD) }); t('⛔ a 204 whose ref still reads back is a FAILED action, never a landing', [kept.exit, kept.rows[0].status, kept.rows[0].result.includes('the branch is still there')], [EXIT_ACTION_FAILED, 204, true]); - const gone = await run(drop, { ...good, [DEL]: { status: 422, json: { message: 'Reference does not exist' } } }); - t('a ref already gone — the DELETE answers 422, the ref reads back absent — is an idempotent landing', [gone.exit, gone.rows[0].result], [EXIT_OK, 'already absent (idempotent)']); + const gone = await run(drop, { ...good, [BACK]: { status: 404, json: { message: 'Not Found' } }, [DEL]: { status: 422, json: { message: 'Reference does not exist' } } }); + t('a ref already gone — absent before (no sha to fence), the DELETE answering 422, absent after — is an idempotent landing', [gone.exit, gone.rows[0].result], [EXIT_OK, 'already absent (idempotent)']); const { planClose } = await import('../mailbox-open.mjs'); const closing = { ...good, [`POST /repos/${REPO}/issues/${PR}/comments`]: { status: 201, json: { id: 9 } }, [`PATCH /repos/${REPO}/issues/${PR}`]: { status: 200, json: { number: PR, state: 'closed' } } }; const closed = await run(base(planClose({ repo: REPO, pull: PR, head: BR }).actions), closing); diff --git a/scripts/pm/fleet-write/ops.mjs b/scripts/pm/fleet-write/ops.mjs index e42709eab9f..e646a70149b 100644 --- a/scripts/pm/fleet-write/ops.mjs +++ b/scripts/pm/fleet-write/ops.mjs @@ -66,12 +66,16 @@ * widens nothing. The row creates no ref and writes no content: the `/git/` * family of `REFUSED_PATH_FAMILIES` refuses every path but exactly one * `refs/heads/` ref of `MAILBOX_BRANCH_SHAPE` (`claude/mailbox-*`), held there - * before the dispatch too. The fences are facts about the PULL, so the - * executor judges them on the pull's own `GET` and the open pulls on that - * head, the last reads before the DELETE (`mailboxBranchPreflight`): the pull - * is closed, carries `pm:mailbox`, its head is `branch` on this repository, - * and no OPEN pull has that head (the DELETE would close it). Anything else is - * refused, the DELETE not sent. It lands only when the ref reads back absent. + * before the dispatch too. The fences, listed HERE only, are facts about the + * pull and the ref, judged by the executor on the pull's own `GET`, the open + * pulls on that head and the ref, the last three reads before the DELETE + * (`mailboxBranchPreflight`): the pull is closed, carries `pm:mailbox`, its + * head is `branch` on this repository, no OPEN pull has that head (the DELETE + * would close it), and the ref points at the pull's `head.sha` (a commit pushed + * after the close is not the mailbox's; a ref already absent fences nothing). + * Anything else is refused, the DELETE not sent. It lands only when the ref + * reads back absent (`confirmMailboxBranch`): a ref already gone (the DELETE's + * 422) is an idempotent landing, one still there FAILED. * * ## The rows whose LATER requests read the FIRST's answer — `pr_create` with `assignees` or `labels` * diff --git a/scripts/pm/mailbox-open.mjs b/scripts/pm/mailbox-open.mjs index 6ee573f6909..45f9ad37de7 100644 --- a/scripts/pm/mailbox-open.mjs +++ b/scripts/pm/mailbox-open.mjs @@ -96,11 +96,12 @@ * `--close ` reads the pull first and REFUSES (exit 4) unless it is open * and carries `pm:mailbox`, so a mistyped number can never close a real PR; * then one stroke: the closing `comment`, `issue_patch state: closed` and, - * when the head read is `claude/mailbox-*`, `mailbox_branch_delete` of it — - * the relay's one ref op (ruling 6674408912), fenced there on the closed pull — - * so a cloud seat's mailbox leaves no branch. Any other head is left: a - * thread's own branch is not the mailbox's, and a local seat deletes its - * `mailbox/…` branch with git. + * when the head read is `claude/mailbox-*`, `mailbox_branch_delete` of it (the + * relay's one ref op, ruling 6674408912, fenced as `ops.mjs`'s header lists), + * so a cloud seat's mailbox leaves no branch. A delete the relay refuses + * leaves the pull closed, which `--close` then refuses, so the failure line + * prints the one bare action to send (`closeRemedy`). Any other head is left: + * a thread's branch is not the mailbox's; a local seat deletes `mailbox/…`. * * ## CI on an empty diff — exempt where a label can exempt it, red by design elsewhere * @@ -342,6 +343,13 @@ export function legacyMailboxesOf(pulls, login) { return openMailboxes(pulls).filter((m) => m.owner.slug === null && String(m.owner.login ?? '').toLowerCase() === want).map((m) => m.pr); } +/** A failed close stroke that carried the delete: the bare re-send `--close` can no longer make (header), or null. Pure. */ +export function closeRemedy(plan) { + if (!plan.branch) return null; + const action = JSON.stringify([{ op: 'mailbox_branch_delete', pull: plan.pull, branch: plan.branch }]); + return `if the stroke stopped at mailbox_branch_delete, #${plan.pull} is closed and --close refuses a re-run: once the refusal named above no longer holds, send the delete alone — node scripts/pm/fleet-write/dispatch.mjs --repo ${plan.repo} --actions-file FILE --json, FILE holding ${action}`; +} + /** Why `pr` may not be closed as a mailbox, or null when it may. Pure. */ export function closeRefusal(pr, pull) { if (!pr || typeof pr !== 'object') return `#${pull} could not be read`; @@ -457,6 +465,7 @@ async function closeLive(plan, opts) { const live = planClose({ repo: plan.repo, pull: plan.pull, head: read.value.head?.ref }); const code = sendStroke(live, opts); if (code === EXIT_OK) console.log(`✓ mailbox #${plan.pull} closed on ${plan.repo}; ${live.branch ? `its branch ${live.branch} deleted` : `its head ${read.value.head?.ref ?? '(none)'} left — the relay deletes a claude/mailbox-* head alone`}.`); + else if (closeRemedy(live)) console.error(`✗ mailbox-open: ${closeRemedy(live)}`); return code; } @@ -504,7 +513,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'arguments: the two modes and every refusal': 14, 'the seat: canonical key, ref slug, and the per-seat branch': 11, 'the open plan: branch, labels, body, and the relay validator': 11, - 'the close plan: comment then close, then the delete of a claude/mailbox-* head, judged by the validator': 5, + 'the close plan: comment then close, then the delete of a claude/mailbox-* head, judged by the validator': 7, 'whose mailbox a pull is: the seat on the body line or the head, a legacy login, neither': 8, "finding a seat's open mailbox: by seat, never by login": 10, 'refusing to close what is not an open mailbox': 4, @@ -592,6 +601,9 @@ async function selfTest() { const cd = planClose({ repo: 'objectstack-ai/objectstack', pull: 30141, head: 'claude/mailbox-ui-3' }); t('⭐ a claude/mailbox-* head is deleted in the SAME stroke, last — after the close the relay fences on — and the validator accepts it', [cd.branch, cd.actions.map((a) => a.op), cd.actions[2], cd.errors], ['claude/mailbox-ui-3', ['comment', 'issue_patch', 'mailbox_branch_delete'], { op: 'mailbox_branch_delete', pull: 30141, branch: 'claude/mailbox-ui-3' }, []]); t('⛔ any other head is left — a thread branch, a local seat\'s mailbox/ branch, a look-alike, none read', ['claude/project-thread-mvsts5', 'mailbox/os-zhuang/triage-1', 'claude/mailbox-UI-3', 'claude/mailbox-ui-3/x', null].map((head) => planClose({ repo: 'o/r', pull: 1, head })).map((x) => [x.branch, x.actions.length]), [[null, 2], [null, 2], [null, 2], [null, 2], [null, 2]]); + const bare = JSON.parse(closeRemedy(cd).split('FILE holding ')[1]); + t('a failed close stroke that carried the delete names the bare re-send — dispatch.mjs, the one action — and the validator accepts it', [closeRemedy(cd).includes('--close refuses a re-run'), closeRemedy(cd).includes('node scripts/pm/fleet-write/dispatch.mjs --repo objectstack-ai/objectstack'), bare, validateStroke('objectstack-ai/objectstack', bare)], [true, true, [cd.actions[2]], []]); + t('…and a close stroke with no delete names none', closeRemedy(c), null); t('the comment carries the bare footer under its rule line', c.actions[0].body.endsWith('\n\n---\n_Generated by [Claude Code](https://claude.ai/code)_'), true); battery('whose mailbox a pull is: the seat on the body line or the head, a legacy login, neither');