Skip to content

fix(pm): fleet-write derives the seat session from the container; the workflow’s Actions state is the kill switch; size-routed direct for post-stamped - #19760

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-19755-fleet-write-derive-session
Sep 23, 2026
Merged

os-support-ai merged 1 commit into
mainfrom
claude/issue-19755-fleet-write-derive-session

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #19755

What changed

scripts/pm/** only — the fleet's own write tools, not on the governed register.

  1. The seat session is derived from the container. sessionSource(env) in fleet-write/dispatch.mjs reads OS_FLEET_SESSION first (explicit wins, validation unchanged; an explicit value that is malformed is refused, never replaced). When it is absent, the container's CLAUDE_CODE_REMOTE_SESSION_ID — cse_ID — is read and becomes session_ID; malformed or absent gives null as before. The route's reason names which source was read, and --route now prints the session and its source. The container variable's name is CONTAINER_SESSION_ENV in fleet-write/ops.mjs, beside SESSION_ENV, with its shape CONTAINER_SESSION_SHAPE beside SESSION_SHAPE.
  2. Condition ③ reads the workflow's Actions state. Relay live = the file on main (today's read) AND GET /repos/{board}/actions/workflows/fleet-write.yml answering 200 with state === 'active'. Any other state, status or no answer is not live, the reason naming it — disabled_manually verbatim when that is the state, described as the maintainer's kill switch. Both reads are cached once per process and reached only after ① and ②. Explicit OS_FLEET_TRANSPORT=dispatch against a disabled workflow is a prerequisite refusal (exit 3), never a silent fall-back.
  3. Unchanged: the two ceilings, the no-run fall-back under auto, the op table, the executor, the workflow file, every allow rule. Every --help line and header comment that told a seat to export OS_FLEET_SESSION (dispatch.mjs, label-write.mjs, issue-create.mjs, with-fleet.sh) now says a cloud seat's session is read from the container and OS_FLEET_SESSION overrides it (a local checkout, a test). No documented spelling carries a prefix.
  4. Self-tests for the derivation (well-formed / malformed / absent / explicit wins / malformed explicit never replaced / blank explicit counts as absent / the reason names the source), the state read (active / disabled_manually / another state / a 200 without a state / 404 / 500 / no answer / cached once / reached only after the file read / strict refusal), and the CLI (--dry-run and --route with the container variable alone). The dispatch and with-fleet CLI cases clear the inherited container variable, so both batteries hold inside a cloud container.
  5. Size-routed fall-back under auto (addendum, item 6). sizeRoute(route, body) in post-stamped.mjs measures the RENDERED body in UTF-8 bytes against MAX_BODY_BYTES (60,000; the platform's client_payload cap is 64 KB). Over it under auto, THAT write takes direct and the transport line becomes one line naming the bytes, the cap and the identity used (the seat's own user, not objectstack-fleet[bot]). Explicit OS_FLEET_TRANSPORT=dispatch over the cap is refused (exit 3) naming the bytes. A route already direct, or already carrying an error, is untouched; a refused op, a failed run and UNCONFIRMED keep their register; label-write, issue-create, close-cards are not size-routed; no compression codec (pinned by import shape).

Verification record

--route in this cloud container, with NO OS_FLEET_SESSION set (the seat's own id from the claude-code-remote get_session tool, field ccr.id, is session_01GnJon4xkRvphn4w28xx3An; the container carries CLAUDE_CODE_REMOTE_SESSION_ID=cse_01GnJon4xkRvphn4w28xx3An — same tail). Angle-bracket placeholders in the pasted reason are spelled ID here for the body sanitizer.

$ node scripts/pm/fleet-write/dispatch.mjs --route
{"requested":"auto","transport":"dispatch","failed":null,"session":"session_01GnJon4xkRvphn4w28xx3An","session_source":"CLAUDE_CODE_REMOTE_SESSION_ID","reason":"OS_FLEET_TRANSPORT is auto → dispatch: CCR_AGENT_PROXY_ENABLED=1 (a cloud seat container, whose proxy replaces the Authorization header), the session was derived from the container's CLAUDE_CODE_REMOTE_SESSION_ID (cse_ID → session_ID; OS_FLEET_SESSION is absent), and the relay workflow .github/workflows/fleet-write.yml is on objectstack-ai/objectstack@main (GET answered 200) and its Actions state is active (GET /repos/objectstack-ai/objectstack/actions/workflows/fleet-write.yml answered 200)","error":null}
exit 0

Before this change the same command in the same container answered "transport":"direct","failed":"session".

Size route, live dry runs in this container (node --use-env-proxy scripts/pm/post-stamped.mjs --comment=19755 --file=… --dry-run; nothing written). A 60,159-byte rendered body:

post-stamped: transport direct — OS_FLEET_TRANSPORT is auto → direct for THIS write: the body is 60,159 UTF-8 bytes, 159 over the relay's 60,000-byte body cap (a repository_dispatch client_payload is capped by the platform at 64 KB), so it cannot take the relay. Written DIRECT as the seat's own user (the token this process holds), not as objectstack-fleet[bot]. ⛔ Not compressed: a body this size is the thing to shrink.

A small body from the same tree: post-stamped: transport dispatch — … the session was derived from the container's CLAUDE_CODE_REMOTE_SESSION_ID … its Actions state is active …. The disabled-workflow case is proven in the dispatch self-test's fake platform; the live workflow was not touched.

Batteries, each run alone with its exit captured before any pipe (worktree at 6470e4e):

command exit verdict line
pnpm check:pm-fleet-write-validate 0 69 cases pass across 7 batteries
pnpm check:pm-fleet-write-execute 0 48 cases pass across 9 batteries
pnpm check:pm-fleet-write-dispatch 0 90 cases pass across 10 batteries (was 64 across 9 at c120dbd)
pnpm check:pm-with-fleet 0 32 cases pass (was 31; floor 28 → 29)
pnpm check:pm-post-stamped 0 609 cases pass across 21 batteries (was 596 across 20)
pnpm check:pm-label-write 0 88 cases pass across 10 batteries
pnpm check:pm-issue-create 0 42 cases pass across 6 batteries
pnpm check:pm-close-cards 0 111 cases pass across 12 batteries
pnpm check:pm-write-pace 0 109 cases pass across 12 batteries

npx eslint --no-inline-config on the five changed .mjs files: exit 0, no findings.

Gates — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on the edited tree derived 38 families (6 paths vs merge base c120dbd; 449 changed lines, under the human-merge threshold). Each was run alone with its exit code captured before any pipe; all 38 exited 0. --ran with the codes recorded: 38 derived famil(ies) accounted for — 38 run, 0 NOT-MEASURED (a DERIVED zero — all 38 recorded an exit code and none of them is 3). Repo-wide pnpm lint is CI's run; the changed files were linted directly (above).

Changeset

None: scripts/pm/** publishes nothing from any released package, so skip-changeset applies. This dispatch forbids label writes, so the label is the seat's to apply.

Acceptance notes

  • post-stamped --dry-run skips the proxy re-exec by design ("that path makes no request"), yet the route resolution on a dry run does read the relay, so in a cloud container a dry run without --use-env-proxy reads condition ③ as HTTP 401 and reports direct. Pre-existing (the read existed before this change; it was simply never reached without a session). Observation only, not filed — a live run re-arms first and is unaffected.
  • close-cards.mjs and the sibling tools' self-test fixtures still spell OS_FLEET_SESSION is absent as a fixture error string; those are fixtures, not documented spellings, and were left as they are.
  • .claude/skills/pm-dispatch/references/rest-channel.md still describes the old opt-in sentence; that path is on the governed register and outside this card's scripts/pm/** scope.

Generated by Claude Code

Dispatching seat (director, summon #27): skip-changeset applied by the seat — scripts/pm/** publishes nothing; the card's Claim: naming this branch is on #19755. Written 2026-09-23T00:48Z.

…reads the relay workflow's Actions state

The seats' allow rules are literal command prefixes, so a write spelled with
a leading OS_FLEET_SESSION=… assignment matched none of them and waited on a
permission it never got. sessionSource() now reads OS_FLEET_SESSION first
(explicit wins, validation unchanged) and, when it is absent, derives the
session from the container's CLAUDE_CODE_REMOTE_SESSION_ID (cse_<id> →
session_<id>); the route's reason names which source was read, and --route
prints the session and its source.

Condition ③ (relay live) is now the workflow file on main AND a 200 from
GET /repos/{board}/actions/workflows/fleet-write.yml with state active; any
other state (disabled_manually verbatim — the maintainer's kill switch),
status or no answer is not live, named. Cached once per process, reached only
after ① and ②; an explicit OS_FLEET_TRANSPORT=dispatch against a disabled
workflow is a prerequisite refusal, never a silent fall-back.

post-stamped gains the size route: a rendered body over MAX_BODY_BYTES under
auto takes direct for THAT write with one printed line naming the bytes, the
cap and the identity; explicit dispatch over the cap is refused naming the
bytes. Nothing else re-routes and there is no compression codec.

Every --help line and header comment that told a seat to export
OS_FLEET_SESSION now says the session is read from the container and the
variable overrides it. Self-tests cover the derivation, the state read and the
size route; the with-fleet and dispatch CLI cases clear the inherited
container variable so they hold inside cloud containers too.

Claude-Session: https://claude.ai/code/session_01GnJon4xkRvphn4w28xx3An
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 23, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 23, 2026 00:51
@os-support-ai
os-support-ai added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit ed4b655 Sep 23, 2026
42 of 44 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-19755-fleet-write-derive-session branch September 23, 2026 01:19
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… the relay can arm auto-merge (objectstack-ai#19767)

Fixes objectstack-ai#19762

## What changed

Touches `.github/workflows/**`, so this is a draft the maintainer merges
by hand.

1. **The mint step grants `permission-contents: write`.** In
`.github/workflows/fleet-write.yml`,
`actions/create-github-app-token@v2` gains `permission-contents: write`
beside the three existing `permission-*` inputs, with a comment naming
the one consumer — GitHub's `enablePullRequestAutoMerge` /
`disablePullRequestAutoMerge` mutations require `contents: write` on the
token (measured on the relay's first live `automerge_enable`: "Resource
not accessible by integration") — and stating that the op table has no
contents op, so the grant is spent only by those two mutations. The
header's permission list says the same.
2. **The op table spends it truthfully.** `PERMISSIONS` in
`scripts/pm/fleet-write/ops.mjs` gains `contents: 'write'`; the
`automerge_enable` and `automerge_disable` rows carry `permission:
'contents'`, so the validator's permission read stays a true statement
of what each row spends. The header paragraph on permissions is updated.
3. **Self-tests** (`validate.mjs --self-test`, the
refused-by-construction battery, 9 → 14 pinned cases): the map is
exactly `issues` / `pull-requests` / `contents` write and `metadata`
read; the two auto-merge rows spend `contents`; no other row does; every
`contents` spender is an auto-merge GraphQL mutation, never a REST path;
and the workflow's `permission-*` inputs, read from the file on disk,
mirror `PERMISSIONS` exactly — a grant added on one side without the
other reds.
4. **Nothing else.** No new op, no change to the sender gate, caps,
ceilings, the executor or the four write tools.

## Verification record

**Batteries, each run alone with its exit captured before any pipe**
(worktree at 182209b, base `origin/main` dc1b986):

| command | exit | verdict line |
|---|---|---|
| `pnpm check:pm-fleet-write-validate` | 0 | 74 cases pass across 7
batteries (was 69) |
| `pnpm check:pm-fleet-write-execute` | 0 | 48 cases pass across 9
batteries |
| `pnpm check:pm-fleet-write-dispatch` | 0 | 64 cases pass across 9
batteries |
| `pnpm check:pm-with-fleet` | 0 | 31 cases pass |
| `pnpm check:pm-post-stamped` | 0 | 596 cases pass across 20 batteries
|
| `pnpm check:pm-label-write` | 0 | 88 cases pass across 10 batteries |
| `pnpm check:pm-issue-create` | 0 | 42 cases pass across 6 batteries |
| `pnpm check:pm-close-cards` | 0 | 111 cases pass across 12 batteries |
| `pnpm check:pm-write-pace` | 0 | 109 cases pass across 12 batteries |
| `pnpm check:workflow-status-functions` | 0 | OK (39 workflow files) |
| `pnpm check:workflow-step-name-quoting` | 0 | OK (39 workflow files +
2 composite actions) |
| `pnpm check:required-contexts` | 0 | OK |

`npx eslint --no-inline-config` on the two changed `.mjs` files: exit 0.

**Gates** — `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` on the edited tree derived 54
families (3 paths vs merge base dc1b986; 52 changed lines, under the
human-merge threshold). Each was run alone with its exit code captured
before any pipe; all 54 exited 0. `--ran` with the codes recorded: `54
derived famil(ies) accounted for — 54 run, 0 NOT-MEASURED (a DERIVED
zero — all 54 recorded an exit code and none of them is 3)`. Repo-wide
`pnpm lint` is CI's run; the changed files were linted directly (above).

The live grant itself cannot be proven before merge (a workflow change
runs only once it is on `main`); the card's post-merge verification is
the director seat's: a two-action relay request (`pr_draft` +
`pr_ready`, or `automerge_enable` on the next queued PR) reading back
`enabledBy` = `objectstack-fleet[bot]`.

## Changeset

None: `.github/workflows/**` and `scripts/pm/**` publish nothing from
any released package, so `skip-changeset` applies. This dispatch forbids
label writes, so the label is the seat's to apply.

## Acceptance notes

- Branch cut from `origin/main` at dc1b986, not on top of the objectstack-ai#19755
branch; PR objectstack-ai#19760 is still landing and is not touched here. Both edit
`scripts/pm/fleet-write/ops.mjs` in disjoint regions (the session
constants there, the permission map and two rows here), so no textual
conflict is expected once objectstack-ai#19760 merges; if one appears, this branch
rebases onto `main`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01GnJon4xkRvphn4w28xx3An)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…the App identity (objectstack-ai#19770)

Part of objectstack-ai#19739

Clause-②: no

## What changed

One file, `.claude/skills/pm-dispatch/references/rest-channel.md`,
section 〈通道边界〉: one paragraph of nine rule lines stating how the App
identity is reached.

- `objectstack-fleet[bot]` is reached through the `fleet-write` relay;
the four write tools (`post-stamped`, `label-write`, `issue-create`,
`close-cards`) and `with-fleet.sh --via auto` share one selector.
- `dispatch` only when three conditions hold —
`CCR_AGENT_PROXY_ENABLED=1`, a session id, a live relay — otherwise
`direct`, with one printed line.
- The session id is derived from the container's
`CLAUDE_CODE_REMOTE_SESSION_ID`; `OS_FLEET_SESSION` overrides it for
local checkouts and tests only.
- ⛔ Never prefix a command with it: the allow rules are literal
prefixes, a prefixed write drops onto the classifier and the seat
stalls.
- Live = the workflow file on `main` AND its Actions state `active`; the
maintainer disabling it in the Actions UI turns the whole fleet back to
`direct`.
- The dispatch goes to objectstack with the session token; the run
executes on the runner with a per-run App token narrowed to the target
repo.
- The closed op list lives in `scripts/pm/fleet-write/ops.mjs`; reads
stay direct.
- A `post-stamped` body over 60,000 bytes takes `direct` for that one
write, with one printed line, as the seat's own user.
- A failed run (`post-stamped`/`close-cards` 4,
`label-write`/`issue-create` 5) and UNCONFIRMED (6) are read, ⛔ never
retried.

Spellings are taken from the objectstack-ai#19755 branch
`claude/issue-19755-fleet-write-derive-session` at 6470e4e (PR objectstack-ai#19760):
`sessionSource` in `scripts/pm/fleet-write/dispatch.mjs`,
`CONTAINER_SESSION_ENV` in `ops.mjs`, `relayLive`'s Actions-state read,
the `with-fleet.sh --via auto` paragraph, and `sizeRoute` /
`MAX_BODY_BYTES` in `post-stamped.mjs`. Everything else is read from
`main` at 544767d (`.github/workflows/fleet-write.yml`: `repositories:`
= the validated target, `permission-issues: write`,
`permission-pull-requests: write`, `permission-metadata: read`).

**One correction to the card's item 3 wording.** The failed-run exit is
not `4` fleet-wide. Each tool books it in its own register on that
branch: `post-stamped` 4 (`EXIT_NOT_STORED`, via `relayExitFor`),
`close-cards` 4 (`EXIT_HALF_WRITE`, and the same 4 for an unconfirmed
close), `label-write` and `issue-create` 5 (`exitForResult` →
`EXIT_PLATFORM_REFUSAL`). UNCONFIRMED is 6 in every tool that takes the
relay. The paragraph states the registers as they are on that branch;
the rule — read, never retry — is the same in all four.

## Line budget — ceiling 82, headroom 0, paid in place (+9 / −9)

Nine lines are retired. Each is a restatement; the line that keeps the
rule is named beside it (line numbers on `main` at 544767d).

| retired (`rest-channel.md` line) | the rule survives at |
|---|---|
| 9 — closed session gate: repo-scoped 403, no `gh`, MCP and git fine |
`platform-readings.md` 108 (the 403 shape), 114–115 (git first, MCP
third), 142 (no `gh` in the container) |
| 10 — run one repo-scoped probe before choosing a channel |
`platform-readings.md` 107, 127 |
| 13 — a rate-limit refusal binds to the refused identity; switching
channels is a retry | `platform-readings.md` 122, 125 |
| 14 — the other side is a way out only under a different identity (`GET
/user` vs the refused id) | `platform-readings.md` 125 (the `GET /user`
test), 96 (one identity inside a seat) |
| 38 — label/assignee writes go through `label-write.mjs` (four steps,
read-back) | `SKILL.md` 394; `os-dev.md` 304; the tool's own header |
| 44 — every write carries `Content-Type: application/json`; the 415 and
its discriminant | `platform-readings.md` 119–120 |
| 54 — never MCP `update_pull_request`; ready/draft and auto-merge take
the ccr routes | `.claude/settings.json` deny list; `SKILL.md` 610; the
ccr rows at 48 and 52 of this file |
| 60 — a REST-gated seat falls back to one targeted MCP `search_issues`
| `platform-readings.md` 115, 211 |
| 72 — whether auto-merge goes through the queue is answered by trying
the action, not a field | `platform-readings.md` 9; rows 73–75 of this
file keep the three criteria |

No line was re-wrapped; every line is ≤ 120 bytes
(`check:pm-skill-ratchet` green on the head).

## Landing order

⛔ This PR lands only after objectstack-ai#19755 (PR objectstack-ai#19760) merges: the paragraph
states what that PR makes true (the container-derived session, the
Actions-state half of the liveness read, the size route). Not enqueued
by this dispatch. Tier S (`.claude/**`), `CONTRACT_REVIEW_TIER` — the
director seat renders the `## Contract review` of record.
`skip-changeset`: nothing published from any released package; this
dispatch forbids label writes, so the label is the seat's to apply.

## Verification

Gates derived by `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` on the edited tree at 9f49c33;
each run alone, exit captured before any pipe (`cmd > log 2>&1; ec=$?`).

| command | exit | verdict line |
|---|---|---|
| `node scripts/check-closing-keyword-parity.mjs` | 0 | •
packages/spec/CHANGELOG.md -- 6080503 bytes exceeds the sweep's
2097152-byte cutoff for UNREGISTERED files |
| `node scripts/check-closing-keyword-parity.mjs --self-test` | 0 | ✓
check-closing-keyword-parity --self-test: 40 assertions, 5 mutations of
the shipped parsers each driven to r |
| `node scripts/check-comment-mask-corpus.mjs` | 0 | ✓ comment-mask
corpus sweep [scripts/js-comment-mask.mjs]: 7011 files, 0 disagree, 0
unparseable, 92.9s (compa |
| `node scripts/pm/check-harness-current.mjs --self-test` | 0 |
check-harness-current --self-test: all 26 cases passed. |
| `pnpm --filter @objectstack/lint run check:doc-formula-expressions` |
0 | objectstack-ai#11673). |
| `pnpm check:agent-test-spelling` | 0 | which drives this same sweep
RED over a temp tree on disk. |
| `pnpm check:cross-package-test-inputs` | 0 | OK: 29 package(s) read
outside themselves, all declared, and turbo.json hashes every declared
glob (6 of them |
| `pnpm check:doc-authoring` | 0 | ✓ doc authoring guard:
sibling-package prose ids hold the baseline — 819 pinned site(s) across
231 file(s), 90 |
| `pnpm check:driver-memory-census` | 0 | check-driver-memory-census: OK
— every declaration is ledgered, every ledger entry is live, and every
ruled fi |
| `pnpm check:gitlink-declared` | 0 | check-gitlink-declared: OK (9246
index entries -- 0 gitlink(s) at mode 160000; no .gitmodules in the
index, so |
| `pnpm check:nul-bytes` | 0 | check-nul-bytes: OK (scanned 9239 text
file(s) -- 9239 tracked, 0 untracked-not-ignored; skipped 7 binary; no |
| `pnpm check:pm-governed-merges` | 0 | live: the real generator
declared 9 output(s) and certified this tree |
| `pnpm check:pm-skill-id-lint` | 0 | ✓ check-skill-id-lint: 30 file(s)
clean (pattern /#[0-9]{3,}/g). |
| `pnpm check:pm-skill-ratchet` | 0 | ✓ check-skill-line-ratchet:
declared cross-file moves: 1, total ceilings down 9 lines. |
| `pnpm check:refd-timer-probe` | 0 | 1 code site(s), all inside the
approved module, which is present and still reads it. |
| `pnpm check:skill-frame-sync` | 0 | binding sentence present in it; 1
count mention(s) agree; 77 markdown files scanned for undeclared copies.
|
| `pnpm check:watch-hint-literal` | 0 | ✓ check-watch-hint-literal: 71
declaration(s) across 4 rostered name(s) -- ROOT_DIR_WATCH_HINTS 47,
ROOT_FILE_ |

Also run on this tree, named by the dispatch and not in the derived
list: `pnpm check:skill-frame-freshness` exit 0 · `pnpm
check:pm-governed-prose` exit 0. `--ran` reconciliation: ✓
dispatch-gates --ran: 17 derived famil(ies) accounted for — 17 run, 0
NOT-MEASURED (a DERIVED zero — all 17 recorded an exit code and none of
them is 3).

## Acceptance notes

- `scripts/pm/with-fleet.sh --read -- true` in this cloud container (a
probe of the direct path before opening the PRs): the mint `POST
/app/installations/{id}/access_tokens` is refused by the egress proxy
(HTTP 403 "not permitted through this proxy"), `classifyHttp` read that
403 as `ratelimit`, and `write-pace` wrote a 30-minute STOP MARKER for
the App token's key (`f665bb8f6447`). A proxy path refusal is not a
back-off signal; the marker blocks nothing this session writes (the
session token's key `f07d7417ce88` carries no marker), but it would
block a seat that can mint. Reported on the card for the seat to file;
not fixed here (`scripts/pm/**`, outside this diff).
- `objectstack-ai#19755 remains open`; its PR objectstack-ai#19760 is the head this text was read
from. If that branch changes the reason text, exit registers or the cap
before it merges, this paragraph is re-read against the merged text
before the contract review.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XPUwWGnmky4PXPofNsf2w7)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant