You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 048f389
Browse filesBrowse the repository at this point in the historyBrowse files
feat(pm/fleet-write): the relay token carries workflows write on every stroke, spent by automerge_enable on a pull that changes a workflow file (#22827)
Fixes#22708
Clause-②: no
**Ruling A** on the card (comment 6104822731; maintainer 「22708 A」): the
fleet relay's App token carries `workflows: write` on every stroke, so a
seat may enqueue a pull that changes `.github/workflows/**`. This PR is
that change, and it is itself the last workflow-file pull a human
enqueues: it changes `fleet-write.yml`, so the token it widens cannot
land it.
**Who wrote it, and under which rule.** The PM seat itself (skills seat
2, `session_011u73oxZ5X95qrARPTeoU6v`), not an os-dev, under the PM red
line's one exception: a maintainer's explicit per-PR authorization of an
internal-tooling PR. The authorization, verbatim, from the maintainer in
this seat's session chat at 2026-10-11T10:52Z: 「22708 你可以处理:执行
#22708 裁决 A:按该卡开发报告(评论 6106463015)里 deviations
D1、D2 的说明,修改
scripts/pm/fleet-write/ops.mjs、.github/workflows/fleet-write.yml、scripts/pm/fleet-write/validate.mjs。跑
validate.mjs、execute.mjs、dispatch.mjs 的 --self-test 和相关 gates,然后开 draft
PR。」 The first dev's attempt (report 6106463015) was stopped by the
session's permission classifier at the `validate.mjs` pins; its measured
premises and its prepared `ops.mjs` + `fleet-write.yml` patch are used
here unchanged.
## What changed
- `scripts/pm/fleet-write/ops.mjs`: `PERMISSIONS` gains `workflows:
'write'` between `contents` and `metadata` (the order the mint's literal
list and the pins compare); the `automerge_enable` row gains
`alsoSpends: Object.freeze(['workflows'])`; the header's permission
paragraph, the `PERMISSIONS` JSDoc, the `alsoSpends` field doc and the
transfer section's "one widening" sentence are updated, and the lowered
human floor is recorded beside the grant with the ruling's comment id.
`STROKE_SCOPED_PERMISSIONS` is unchanged: the grant is literal, not per
stroke.
- `.github/workflows/fleet-write.yml`: the mint step declares the
literal `permission-workflows: write` after `permission-contents`, with
a ONE-consumer comment; the header's grant list and the two "four
grants" sentences read five.
- `scripts/pm/fleet-write/validate.mjs`: the alsoSpends pin expects
`[['pr_create', ['issues']], ['automerge_enable', ['workflows']]]`; the
map pin and the token-grants pin carry the fifth grant; two new cases in
the refused-by-construction battery (floor 20 to 22): `workflows` is
spent by `automerge_enable` alone as `alsoSpends` and is no row's
`permission`; `PERMISSIONS` minus `metadata` equals the union of every
row's `permission` and `alsoSpends`, both exactly `actions / contents /
issues / pull-requests / workflows`. The `tokenPermissionsOf` doc
comment says five.
- `.claude/skills/pm-dispatch/references/landing-operations.md:67` (Tier
S; one line for one line, 117 bytes; the file stays 101/101): diagnostic
step ③ now reads the token as carrying `workflows: write` by the ruling,
so a refusal there is a grant drift (the App's accepted permissions),
not a missing grant.
- ⛔ No new gate. `.claude/skills/pm-dispatch/SKILL.md:263` (the
human-floor sentence) is untouched.
## Verification (head `db16cfc7d`; base `b7cd1af9d`, `origin/main` when
the worktree was cut)
- Red-before control: with only `PERMISSIONS` widened and the pins at
base, `node scripts/pm/fleet-write/validate.mjs --self-test` exits 1, "3
of 128 case(s) failed" (the alsoSpends pin, the map pin, the
token-grants pin). After the pins: 130 cases pass across 10 batteries.
`execute.mjs --self-test` 111 cases and `dispatch.mjs --self-test` 216
cases, exit 0; `pnpm check:pm-fleet-write-validate`, `-execute`,
`-dispatch` exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` from the worktree with no paths derived 61
commands at `b7cd1af9d`; every one ran alone with its exit captured
before any pipe (the tool's documented idiom); the reconciliation line:
`✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 61 run, 0
NOT-MEASURED (a DERIVED zero — all 61 recorded an exit code and none of
them is 3).`
- `pnpm --filter @objectstack/lint run check:doc-formula-expressions`
first answered exit 3 (PREREQUISITE NOT MET: `@objectstack/formula` and
`@objectstack/lint` unbuilt); the `@objectstack/lint...` closure was
built under the verify lock (VERDICT command-exit 0, 141 s) and the
rerun exits 0. `pnpm check:pm-dispatch-gates` (the long battery) exit 0.
- `check-skill-line-ratchet`: `landing-operations.md is 101 lines
(ceiling 101; headroom 0)`. `check:pm-skill-id-lint`, `check:nul-bytes`,
`check:pm-governed-merges`, `check:pm-governed-prose`,
`check:skill-frame-sync`, `check:required-contexts`,
`check-self-test-wired`, `check-self-test-workflow-commands`,
`check-whole-set-label-write`, `check-scripts-symbol-anchors`,
`check-ci-filter-parity` exit 0 (all in the 61).
- No package source changes, so no package build or test is owed beyond
the gates. Changeset: none; `scripts/pm/**`, `.github/**` and
`.claude/**` ship in no package's `files[]`, so the PR takes
`skip-changeset`.
## Premises (report 6106463015, re-read on this base)
- A1: `actions/create-github-app-token@v3` declares the
`permission-workflows` input (`action.yml:143`, read at the tag ref; the
sha read was refused by the proxy).
- A3: `execute.mjs` has no token-grant surface (`PERMISSIONS` imported
at `:164`, used nowhere), so the pin lives in `validate.mjs`.
- `REFUSED_PATH_FAMILIES` carries `/\/git\//` and `/\/contents\//`, so
the wider token reaches no file write through any row; the
refused-by-construction battery pins that over every path every op can
spell.
## Risk the maintainer checks before this merges (NOT MEASURED from the
seat)
The mint step requests `permission-workflows: write` on EVERY relay
stroke. If the objectstack-fleet App's registration, or the organization
installation's accepted permissions, lacks **Workflows: Read and
write**, `actions/create-github-app-token` fails on every stroke: every
relay write stops, not only this class of pull. `/app/**` is unreachable
from seat egress, so this cannot be read here. Check: the App's
Repository permissions show Workflows = Read and write, and the
installation on `objectstack-ai` has accepted that change. The first
relay stroke after the merge is the measurement; if the mint fails,
revert this PR.
## Acceptance notes
- NOT MEASURED, owed on #22708 after the merge: the first pull the
widened token enqueues.
- `pr_update_branch` on a pull whose base brought workflow-file changes
may also need `workflows` (the platform's merge commit changes workflow
files on the head); the literal grant covers it either way, so no
`alsoSpends` beyond the ruling's row.
- `scripts/pm/fleet-write/execute.mjs:164` imports `PERMISSIONS` and
never uses it (a dead import; noted, not filed).
## 维护者速读(草稿)
**改了什么**:车队中继(fleet-write)铸出的 App 令牌在每一笔写上常驻 `workflows:
write`;`automerge_enable` 行登记它花费这项授权(改 `.github/workflows/**` 的 PR
入队需要);validate 自测钉死:授权集与 workflow 字面输入一致、`workflows`
只由该行花费、授权集不比各行所花更宽。另把 `landing-operations.md` 里「token 缺 workflows
权限」那条诊断改成「令牌常驻该权限,仍拒即授权漂移」。不加门禁。
**为什么改**:你裁了 A(「22708 A」):以后改 CI 工作流文件的 PR 由席位中继入队,不再每张等人点一下。本 PR
自己也改了工作流文件,所以它是最后一张需要人点入队的。
**风险与代价(含回滚)**:最大风险在 App 侧:铸币会在每一笔申请 `permission-workflows: write`;若 App
注册或组织安装没接受 Workflows 读写权限,每一笔中继写都在铸币处失败,整个中继停摆,不只这一类 PR。席位测不了这项(到不了
`/app/**`)。回滚 = revert 本
PR。代价:令牌权限面永久多一项(可改写工作流文件的能力),安全/权限边界的人工地板为这一项降低,出处已写在代码旁。
**席位意见**:(席位在评论里定稿)
**你要做的**:两步。① 先确认 objectstack-fleet App 的 Repository permissions 里
Workflows 为 Read and write,且组织安装已接受;② 然后点一次 ready +
auto-merge(或入队)。合并后第一笔中继写就是实测,失败即 revert。
---
_Generated by [Claude
Code](https://claude.ai/code/session_011u73oxZ5X95qrARPTeoU6v)_
Co-authored-by: Claude <noreply@anthropic.com>
'the fields: request_id shape and cap, the one organization, the session id': 8,
455
455
'the actions: count, the closed op list, closed keys per op, typed values': 30,
456
456
'the platform ceilings: ten top-level properties and under 64KB, pinned with their source': 6,
457
-
'refused by construction: no row reaches a merge, a review, a ref, contents, a workflow, a release or an org endpoint': 20,
457
+
'refused by construction: no row reaches a merge, a review, a ref, contents, a workflow, a release or an org endpoint': 22,
458
458
'the normalised payload: only judged keys travel': 3,
459
459
'the CLI: a file, the environment, GitHub outputs, and the exit ladder': 10,
460
460
'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,
@@ -633,14 +633,18 @@ export async function selfTest() {
633
633
t("⛔ exactly ONE descriptor reads an earlier answer — pr_create's assignees leg: a POST to the created pull's issue-assignees path at the placeholder the executor fills from the create's number, carrying the logins the answer must list",legs.map((r)=>[r.op,r.verb,r.path,r.fromAnswer,r.body,r.assigned]),[['pr_create','POST',`/repos/${TARGET_OWNER}/objectstack/issues/${answerPlaceholder('number')}/assignees`,'number',{assignees: ['u']},['u']]]);
634
634
t('…and it is never the FIRST request of its action — the answer it reads exists before it is sent',OP_NAMES.filter((op)=>{consta={ op };for(constkof[...OPS[op].required, ...OPS[op].optional])a[k]=sample[k];returnOPS[op].requests(a,'o/r')[0].fromAnswer!==undefined;}),[]);
635
635
t('…its placeholder is the one spelling the executor fills, spelled once, and no path without fromAnswer carries a brace',[answerPlaceholder('number'),everyRequest.filter((r)=>r.fromAnswer===undefined&&/[{}]/.test(r.path)).map((r)=>`${r.op}${r.path}`)],['{number}',[]]);
636
-
t('every grant a row ALSO spends is a literal mint input of PERMISSIONS, never a stroke-scoped one — so the mint widens nothing for it; pr_create\'s issues write is the one such row',[OP_NAMES.flatMap((op)=>OPS[op].alsoSpends??[]).every((name)=>nameinPERMISSIONS&&!STROKE_SCOPED_PERMISSIONS.includes(name)),OP_NAMES.filter((op)=>OPS[op].alsoSpends).map((op)=>[op,[...OPS[op].alsoSpends]])],[true,[['pr_create',['issues']]]]);
636
+
t('every grant a row ALSO spends is a literal mint input of PERMISSIONS, never a stroke-scoped one — so the mint widens nothing for it; pr_create\'s issues write and automerge_enable\'s workflows write are the two such rows',[OP_NAMES.flatMap((op)=>OPS[op].alsoSpends??[]).every((name)=>nameinPERMISSIONS&&!STROKE_SCOPED_PERMISSIONS.includes(name)),OP_NAMES.filter((op)=>OPS[op].alsoSpends).map((op)=>[op,[...OPS[op].alsoSpends]])],[true,[['pr_create',['issues']],['automerge_enable',['workflows']]]]);
637
637
t('pr_create without assignees is the one create request it always was — no second leg',OPS.pr_create.requests({op: 'pr_create',title: 't',head: 'h',base: 'b'},'o/r').length,1);
638
638
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]]);
639
639
t('every op names a permission the token is narrowed to',OP_NAMES.every((op)=>OPS[op].permissioninPERMISSIONS));
640
640
// The permission map, pinned entry by entry: the auto-merge mutations need `contents: write` on the
641
641
// App token (measured live: without it, "Resource not accessible by integration"), and NOTHING else may
642
-
// spend it — the table has no contents op, so the grant reaches exactly two mutations.
643
-
t('the map is exactly issues/pull-requests/contents/actions write and metadata read',PERMISSIONS,{issues: 'write','pull-requests': 'write',contents: 'write',metadata: 'read',actions: 'write'});
642
+
// spend it — the table has no contents op, so the grant reaches exactly two mutations. `workflows: write`
643
+
// is the maintainer's ruling (comment 6104822731): a literal grant on every stroke, spent by the
644
+
// `automerge_enable` row alone (`alsoSpends`) when the pull it arms changes a workflow file.
645
+
t('the map is exactly issues/pull-requests/contents/workflows/actions write and metadata read',PERMISSIONS,{issues: 'write','pull-requests': 'write',contents: 'write',workflows: 'write',metadata: 'read',actions: 'write'});
646
+
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'],[]]);
647
+
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/workflows',[Object.keys(PERMISSIONS).filter((name)=>name!=='metadata').sort(),[...newSet(OP_NAMES.flatMap((op)=>[OPS[op].permission, ...(OPS[op].alsoSpends??[])]))].sort()],[['actions','contents','issues','pull-requests','workflows'],['actions','contents','issues','pull-requests','workflows']]);
644
648
t('the two auto-merge rows spend contents',[OPS.automerge_enable.permission,OPS.automerge_disable.permission],['contents','contents']);
645
649
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']);
646
650
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))));
@@ -783,7 +787,7 @@ export async function selfTest() {
783
787
t('…and actions is spent by the workflow_dispatch row and NOTHING else',OP_NAMES.filter((op)=>OPS[op].permission==='actions'),['workflow_dispatch']);
t('the token grants: a comment stroke carries the four literal grants and no actions; a workflow_dispatch stroke adds actions write',[tokenPermissionsOf(plain),tokenPermissionsOf(run)],[{issues: 'write','pull-requests': 'write',contents: 'write',metadata: 'read'},{issues: 'write','pull-requests': 'write',contents: 'write',metadata: 'read',actions: 'write'}]);
790
+
t('the token grants: a comment stroke carries the five literal grants and no actions; a workflow_dispatch stroke adds actions write',[tokenPermissionsOf(plain),tokenPermissionsOf(run)],[{issues: 'write','pull-requests': 'write',contents: 'write',workflows: 'write',metadata: 'read'},{issues: 'write','pull-requests': 'write',contents: 'write',workflows: 'write',metadata: 'read',actions: 'write'}]);
787
791
t('…and the GitHub output spells that as permission_actions=write for the run stroke, EMPTY for every other — the mint action skips an empty input',[githubOutputLines(run).split('\n').at(-2),githubOutputLines(plain).split('\n').at(-2),[...STROKE_SCOPED_PERMISSIONS]],['permission_actions=write','permission_actions=',['actions']]);
788
792
// The fence on the grant: every path every row can spell, against the actions families — the dispatch call is the ONE
789
793
// actions path reached; and a POSITIVE CONTROL that the families would catch what actions: write otherwise reaches.
0 commit comments