Skip to content

feat(pm): advisory_read relays an advisory's text sealed to a one-time key the seat alone holds - #29536

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-fubg69
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-fubg69

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

domain:skills#1 · huangyiirene · session_01J8VVAnMUWA9rufXzFaEh4J

Requested by Irene · project thread
Part of #29420

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:中继新增一个只读动作 advisory_read。席位进程每读一次就在内存里现生成一对一次性 X25519 密钥,只把公钥随请求送出;中继用本笔独有的 repository-advisories: read 令牌读公告,当场用这把公钥封装(只用 Node 自带 crypto),只把密文经运行通知回传;席位用内存里的私钥解开,加 --reveal 才把明文印到自己的 stdout。
  • 为什么改:席位账号直读公告被平台拒绝(403),而公开的运行日志里不能出现公告的任何字段。封装之后,公告文本只在持有私钥的那个席位进程里是明文,私钥从不落盘、不出进程。
  • 风险与回滚:只动 scripts/pm/fleet-write/ 四个脚本、两份技能参考各一行和行数棘轮,不发布任何包,不改工作流;回滚即 revert 本 PR。公开运行上看得到的只有状态码、字节数、分片数和密文本身。
  • 先看:scripts/pm/fleet-write/execute.mjs 的 advisoryReadAction(先过闸再封装再分片,封装失败什么都不发);scripts/pm/fleet-write/ops.mjs 的 sealAdvisoryRead 和 advisory_read 行(行上 ⚖ 注写明发送者闸的两种选择);scripts/pm/fleet-write/dispatch.mjs 的 armAdvisoryReads 与 --reveal。
  • 两分钟验证:node scripts/pm/fleet-write/execute.mjs --self-test,预期打印 259 个用例在 22 组里全部通过,其中 advisory_read 一组 17 例,标记扫描覆盖 19 次运行无一命中。
  • 需要你做的:裁定发送者闸。保留默认 collaborator(推荐:发送者必须在该公告的协作者名单里),或把行上的 senderGate 改为 target-write(对目标仓有写权限即可)。

What changes

  • scripts/pm/fleet-write/ops.mjs: the advisory_read row and everything it needs.

    • The row: requires ghsa_id and public_key, nothing optional, travels alone, spends repository-advisories at its own grant: 'read', and carries senderGate: 'collaborator' with a ⚖ comment recording the choice for the maintainer. Its one request is a GET of the advisory, and it carries an advisoryRead marker so the executor takes the sealed path.
    • The public_key field kind (x25519): base64 SPKI DER of exactly 60 characters, the X25519 prefix pinned by a regular expression. A trial derivation refuses low-order points.
    • sealAdvisoryRead / openAdvisoryRead, Node crypto only:
      • X25519 ECDH with a fresh ephemeral key per seal;
      • HKDF-SHA256, whose info binds a scheme label and both raw public keys;
      • AES-256-GCM with a 12-byte nonce and a 16-byte tag;
      • associated data of request id, action index and advisory id, so a sealed answer opens for the read that asked for it and no other;
      • a one-byte version, and the envelope in base64url (61 bytes of overhead);
      • the shared secret and the derived key are zeroed after use, and an all-zero secret is refused;
      • the open uses a strict UTF-8 decoder.
    • The channel: advisoryReadParts cuts the sealed text into at most 10 notice parts of 4,000 characters each, inside the runner's measured caps (10 notices a step, messages cut at 4,096 characters). That is 40,000 sealed characters, or 29,939 bytes of answer.
    • The fences (advisoryReadFenceFailures) cover the read itself, the advisory, and the sender. An unknown gate value fails closed.
    • The public-surface texts: advisoryReadCall prints a placeholder path where every other row prints its real one. The landed and over-cap texts carry byte and part counts alone.
  • scripts/pm/fleet-write/validate.mjs: the x25519 kind's refusal describes the value by type and byte count and never echoes it. Grants are now per row (OPS[op].grant, falling back to the permission's default level), so a read's stroke mints repository-advisories=read and every other advisory stroke still mints write. The stroke's token-permission map gives write precedence when two rows meet, which alone already prevents. A new battery holds 28 cases.

  • scripts/pm/fleet-write/execute.mjs: advisoryReadAction runs these steps in order:

    1. ONE GET; a call that threw before any answer (rest's status 0) is FAILED saying the request did not reach the platform, row status not reached, with no HTTP status, no grant remedy and never the exception's message;
    2. the fences, with zero emitted on any failure;
    3. the picked fields as JSON;
    4. the seal, where a throw or a non-base64url result is a FAILED action with one fixed sentence and no part;
    5. the parts, where none means over cap and the action fails on byte counts alone;
    6. one notice per part.

    The row and the summary carry the placeholder call, the status, and the counts. A new battery holds 17 cases.

  • scripts/pm/fleet-write/dispatch.mjs: arming and opening at the seat.

    • armAdvisoryReads makes one key pair per read in this process and puts the public half into the action. A public_key supplied in the actions file is refused (exit 2) without being echoed.
    • openRunAdvisoryReads reassembles the run's notice parts and opens them with the private half. A read that does not open is UNVERIFIED (exit 6).
    • --reveal prints the opened text to stdout, and nothing else ever prints it. --reveal without a read is exit 2.
    • --json carries state, byte count, field names and parts, never text.
    • A new battery holds 17 cases, including an end-to-end run through the real executor.
  • .claude/skills/pm-dispatch/references/rest-channel.md: one line naming advisory_read.

  • .claude/skills/pm-dispatch/references/security-findings.md: one line pointing a seat's advisory read at the relay.

  • scripts/pm/check-skill-line-ratchet.mjs: those two rows, 81 → 82 and 55 → 56, the measured delta of +1 each.

Why

A seat's own account is refused when it reads an advisory, and the relay's run is public. Without a seal, any read through the relay puts the advisory's text on the run's log and annotations. The card's design keeps the text in plaintext only inside the seat process that asked: the private key exists for one read, in memory, and is never written anywhere.

Tests

Self-test origin/main 8e505db this head (453d4e1, main merged)
validate.mjs --self-test 275 cases / 15 batteries 303 / 16 (+28, the new battery)
execute.mjs --self-test 242 / 21 259 / 22 (+17)
dispatch.mjs --self-test 298 / 26 315 / 27 (+17)

Each new battery raises its file's battery floor by one.

The card's pinned cases each have a case:

  • A malformed ghsa_id or public_key is refused before any request. Validate covers wrong length, wrong prefix, not base64, a low-order point and a non-string, each refused without an echo. The executor's refused payloads make zero requests.
  • advisory_read beside another action is refused. This holds beside a comment, beside another advisory row, and for two reads together.
  • Only this stroke's token carries the grant. The read stroke mints the five literal grants plus repository-advisories=read, never write and never actions. Every other stroke mints no advisory grant. Both are checked through the validator's real process output.
  • A GET that never reached the platform says so. A call that throws, its message carrying the id and the marker, is FAILED with row status not reached: no "HTTP 0", no grant remedy, and the exception's message printed nowhere.
  • A failed encryption sends nothing back and fails. A seal that throws, carrying the marker in its message, and a seal that returns the plaintext unsealed are both FAILED, with no part, no notice, and the exception's message printed nowhere.
  • The decrypted text matches the GET byte for byte. This holds for a single part and for a long answer cut into parts.
  • ⛔ The marker test. A marker is planted in the summary, the description, the vulnerabilities and every other field of the answer but its id and state. It is absent from every log line, row, summary and annotation across 19 executor runs (landed, long, every fence, both gates, a GET that never reached the platform, a thrown seal, over cap, refused payloads). It is absent from the sealed answer's characters and its decoded bytes, while the opened text carries it as a positive control. An id sweep likewise finds neither the requested nor the answered advisory id on any public line.

Ablations. Every mutation was re-run at head 453d4e1 (after the merge of main) with scripts/ablation-replace.mjs, in a clean worktree, against the committed blobs: ops ce0656b3454b, validate 8ea5abcdd17a, execute c328a12cd284, dispatch 51075987d172. In every run the anchor read 1 → 0, the self-test exited 1, and the restore was proved (blob == HEAD, git diff HEAD empty). The worktree had 0 changed files afterwards.

# Mutation Self-test Red cases
A1 the seal returns the plaintext as base64url (identity) execute 3 of 259: byte for byte, "the sealed answer ALONE carries none of the marker", the long answer
A2 the landed row appends the answer's summary execute 2 of 259: the landed row, THE MARKER TEST
A3 the row prints the real path with the id execute 3 of 259: the landed row, the fence ids, "NO advisory id"
A4 the x25519 refusal echoes the value validate 1 of 303: "NO refusal echoes the value"
A4b same mutation execute 2 of 259: THE MARKER TEST, the real-process validator case
A5 the sender fence opens for any gate execute 1 of 259: the collaborator default gate
A5b same mutation validate 3 of 303: the collaborator gate, the read fence, the fence sentence
A6 the row's grant raised from read to write validate 4 of 303: the rows, the row, alone, the grant fence
A7 the opened text printed without --reveal dispatch 1 of 315: "the CLI without --reveal … printed NOWHERE"
A8 a failed seal falls back to the unsealed plaintext execute 1 of 259: "a failed encryption sends NOTHING back and fails"
A10 alone: true removed from the row validate 4 of 303: the rows pin, the row, the alone fence and its message
A11 the status-0 branch removed (S3) execute 1 of 259: "a GET that never reached the platform … is FAILED saying so"

eslint on the four relay scripts and the ratchet: exit 0.

Acceptance notes

  • ⚖ The sender gate is the maintainer's ruling. The default is collaborator, which the card recommends: the sender must appear, matched case-insensitively, in the advisory's own collaborating_users. Teams are not read, so a sender who reaches the advisory only through a team is refused. The alternative is target-write, set by changing the row's senderGate: write on the target repository is enough, the same gate every other relay row uses. Both are tested, and a value that is neither fails closed. The default holds until the ruling.
  • The grant is documented but unmeasured. The row mints repository-advisories: read. GitHub documents this GET under the read grant, but only the write grant's read was measured (run 41415522766, recorded in the card). The first live read is the measurement of record. If the platform refuses there, the action fails carrying a remedy that says raising the row to write is the maintainer's call, never a retry under a wider token. The live read is not part of this PR.
  • The public run shows lengths. It carries the HTTP status, the answer's byte count, the sealed character count and the part count. That is a size signal, and the card accepts it. No id, state, title or field crosses.
  • There is a capacity limit. An answer over 29,939 bytes is a FAILED action naming byte counts alone, and nothing is emitted. Raising it would mean more steps or another channel.
  • The workflow is unchanged, and its stale comment is its own PR. The workflow's permission-repository-advisories input already passes whatever level the validator writes. Its comment near line 154 of .github/workflows/fleet-write.yml is now stale: it still says "THREE consumers" and names permission_repository_advisories=write. The file is Tier H and outside this card's surface, so the fix is a later PR of its own.
  • S2: the second of fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505 and this PR to land owns one reconciliation in execute.mjs. The rule was that the second to land either keeps advisoryPlatformText imported or moves the read's refused arm onto fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505's advisoryRefusalCause, then re-runs node scripts/pm/fleet-write/execute.mjs --self-test on the merged tree. fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505 landed first, so this PR merged main (merge commit 453d4e1, no rebase) and resolved it there.
    • The import is back. fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505 had dropped it, so advisoryPlatformText is imported again.
    • The read's refused arm stays on the allowlist. It quotes a platform message only when ADVISORY_PLATFORM_MESSAGES names it. advisoryRefusalCause withholds a message only when it quotes prose the run already knows, and a refused read knows none: its payload is the id and the key, and no advisory was read. So the move would print whatever message the platform sent, and the read battery's 404-with-marker fixture would reach the row.
    • The merged tree passes. On it, execute.mjs --self-test is 259 cases across 22 batteries, and validate and dispatch are unchanged at 303 and 315.
  • Part of, not Fixes. The first live read, with the maintainer, and the sender-gate ruling are acts on the card after this merges, so pm/fleet-write: an advisory_read relay op that returns an advisory only as ciphertext, encrypted to a one-time key the seat generates per read #29420 stays open after this PR merges.
  • No key is stored. The private half lives only in the dispatching process's memory (a KeyObject in a Map). No key and no plaintext is written to disk, env, the payload or GitHub. No dependency was added.
  • Two commits carry a model-named trailer. 7b0268e and 453d4e1 end with a Co-Authored-By naming the model, against the model-free trailer rule (check:commit-card-trailers --range origin/main..HEAD flags both). This clone had no pre-push hook installed, so nothing refused the push. The branch is not claude/issue-*, which makes it shared under AGENTS.md §3, so it was not rewritten. The squash message is taken from this body, so neither trailer lands on main.
  • No changeset, skip-changeset. scripts/pm/** and .claude/** do not publish.

Verification

dispatch-gates at 453d4e1: 47 derived, 47 run, all exit 0

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 47 families from the 7 changed paths (1,262 changed lines against merge base 8e505db, under the 3,000 human-merge threshold). Each was run as printed and recorded with its exit code before any pipe. Reconciliation: "✓ dispatch-gates --ran: 47 derived famil(ies) accounted for — 47 run, 0 NOT-MEASURED (a DERIVED zero — all 47 recorded an exit code and none of them is 3)."

node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-declaration-mirrors.mjs :: exit 0
node scripts/check-declaration-mirrors.mjs --self-test :: exit 0
node scripts/check-self-test-wired.mjs :: exit 0
node scripts/check-self-test-wired.mjs --self-test :: exit 0
node scripts/check-self-test-workflow-commands.mjs :: exit 0
node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0
node scripts/check-whole-set-label-write.mjs :: exit 0
node scripts/check-whole-set-label-write.mjs --self-test :: exit 0
node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0
node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0
node scripts/pm/check-harness-current.mjs --self-test :: exit 0
pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0
pnpm check:agent-test-spelling :: exit 0
pnpm check:bash32-floor :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:cli-command-ids :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:entry-guard :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:parse-guard :: exit 0
pnpm check:pm-close-cards :: exit 0
pnpm check:pm-dispatch-gates :: exit 0
pnpm check:pm-fleet-write-dispatch :: exit 0
pnpm check:pm-fleet-write-execute :: exit 0
pnpm check:pm-fleet-write-validate :: exit 0
pnpm check:pm-governed-merges :: exit 0
pnpm check:pm-issue-create :: exit 0
pnpm check:pm-issue-transfer :: exit 0
pnpm check:pm-label-write :: exit 0
pnpm check:pm-post-stamped :: exit 0
pnpm check:pm-record-recognisers :: exit 0
pnpm check:pm-skill-id-lint :: exit 0
pnpm check:pm-skill-ratchet :: exit 0
pnpm check:pm-write-pace :: exit 0
pnpm check:pnpm-filter-targets :: exit 0
pnpm check:pnpm-flag-spellings :: exit 0
pnpm check:ratchet-remedy-authority :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:scripts-symbol-anchors :: exit 0
pnpm check:skill-frame-sync :: exit 0
pnpm check:watch-hint-literal :: exit 0

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic (not touched).
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes (eslint on the changed scripts: exit 0; the repo-wide run is CI's).
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset (nothing publishes here).
  • 6. packages/spec touched: no.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 section excepted, as the dispatch protocol requires).

Dispatched by domain:skills#1 · huangyiirene · session_01J8VVAnMUWA9rufXzFaEh4J; dev = card thread session_01J8VVAng9QxNxoEzyWkqbW6.

🤖 Generated with Claude Code

https://claude.ai/code/session_01J8VVAng9QxNxoEzyWkqbW6


Generated by Claude Code

@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Nov 2, 2026
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation labels Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:skills#1 · huangyiirene · session_01J8VVAnMUWA9rufXzFaEh4J

Contract-review-claim: session_01J8VVAnMUWA9rufXzFaEh4J · head d2ee86c · 2026-11-02T18:12Z

The seat review runs at CONTRACT_REVIEW_TIER in an isolated subagent (the seat session is not at that tier).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:skills#1 · huangyiirene · session_01J8VVAnMUWA9rufXzFaEh4J

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d2ee86ca4d9a420001996f2d8df79e78762b4f0e
Local-runs: probe — on a detached worktree of the head (merge-base with origin/main 28d0dbb; origin/main at 5651647; none of the seven files moved on main since): node scripts/pm/fleet-write/validate.mjs --self-test (303 cases, 16 batteries, exit 0), execute.mjs --self-test (253/22, exit 0), dispatch.mjs --self-test (315/27, exit 0), scripts/pm/rerun-failed.mjs --self-test (31/5, exit 0); the same three on the origin/main checkout for the base reading (275/15, 237/21, 298/26); thirteen repo gates run as --self-test then bare (check-skill-line-ratchet, check-skill-id-lint, check-scripts-symbol-anchors, check-nul-bytes, check-entry-guard, check-parse-guard, check-skill-frame-sync, check-watch-hint-literal, check-ratchet-remedy-authority, check-governed-prose, check-self-test-wired, check-declaration-mirrors, all exit 0; check-doc-authoring exit 3, prerequisite only: no node_modules in the throwaway worktree, so ESLint and that gate are read from CI); one reviewer-written decrypt of a sealAdvisoryRead envelope from the header's format description alone, without openAdvisoryRead; fourteen ablation runs (A2, A3, A4, A4b, A5, A5b, A7, A8 redone from the dev's table; R1 to R5 the reviewer's), each through scripts/ablation-replace.mjs against the committed blob, with its anchor hit once, the blob changed, and the restore proved (git hash-object equal to the head blob, git diff HEAD empty, git status --porcelain empty); a git merge-tree --write-tree of this head against PR #29505's head 3f3345a for the conflict reading; the worktree was removed after the runs and the main working tree was not touched (porcelain 0 before and after)
Scope: .claude/skills/pm-dispatch/references/rest-channel.md and .claude/skills/pm-dispatch/references/security-findings.md (the two governed hits, one line each) + the files their hunks and the claim assert: scripts/pm/check-skill-line-ratchet.mjs (the two rows), scripts/pm/fleet-write/ops.mjs, validate.mjs, execute.mjs and dispatch.mjs (the row, the key kind, the seal and open, the fences, the parts, the seat side, and their self-tests) + read, not reviewed: .github/workflows/fleet-write.yml (the mint step's permission-repository-advisories input and the executor step's sender env), scripts/pm/write-pace.mjs (paceWrite / noteResponse, unchanged), scripts/pm/label-write.mjs (classifyHttp), PR #29505's head (the overlap on execute.mjs), card #29420 with comments 6579749290 (claim), 6580486949 (triage) and 6584655262 (dev report, read as input), PR comment 6584684169 (the seat's review claim)

Reviewer: an isolated contract reviewer at CONTRACT_REVIEW_TIER, read-only on GitHub, fed the card body and comments, the PR body, the net diff against the merge-base, the head's check-runs, contract-review.md and review-checklist.md, and the shape of cr15/cr17. The dev report was read as input, not as conclusions; every count below is this review's own measurement. This record carries no finding, no vulnerability mechanism, no advisory id and no advisory title; the self-test fixtures use made-up ids in GitHub's shape and generic marker sentences, and none is repeated here. No environment variable value is printed anywhere in this record.

① Derived judgments

The card's six change items, the five pinned cases, the marker test and the PR's claims, one at a time:

  1. File surface. Five commits (d031f75, 80392c9, b01f92a, 1bae3e4, d2ee86c; commit author Claude; PR author objectstack-fleet[bot], head repo = base repo, draft, base main). git diff 28d0dbbf d2ee86ca --stat, the REST file listing and changed_files: 7 agree on exactly: rest-channel.md +1/−0, security-findings.md +1/−0, check-skill-line-ratchet.mjs +10/−2, dispatch.mjs +356/−21, execute.mjs +239/−5, ops.mjs +368/−16, validate.mjs +196/−23 (1,171/67, under the 3,000 human-merge line). The blobs at head are the four the PR body names (ops 7d2ba294, validate 8ea5abcd, execute 9c70c7cd, dispatch 51075987). No NUL byte in any of the seven. The claim's file surface is exactly these seven; .github/workflows/fleet-write.yml is untouched, as the claim required. On surface.
  2. The row and the grant, least privilege. OPS.advisory_read is permission: 'repository-advisories', grant: 'read', senderGate: 'collaborator', required: ['ghsa_id', 'public_key'], optional: [], alone: true, one request: a GET of one advisory's path carrying an advisoryRead descriptor. tokenPermissionsOf now carries a level per stroke-scoped permission (levelOf = the row's grant, else PERMISSIONS' level; write wins when two rows meet, which alone makes unreachable), so a read stroke's $GITHUB_OUTPUT line is permission_repository_advisories=read and permission_actions= is empty; every other advisory stroke still mints write; every non-advisory stroke still writes an empty value. The validator's self-test pins all of that through the real process (the cliRuns[0].output case), and the grant ledger case pins that advisory_read is the only row naming a grant, on a stroke-scoped permission, never above the table's level. The strokeErrors text changed from a hard-coded write to the row's grant, which for every existing row is the same string. The dev's A6 (read raised to write) and A10 (alone removed) were not re-run here; they are not leak paths and the row pin covers both. Correct.
  3. The public_key field, validated strictly and never echoed. advisoryReadKeyFailure: a string, exactly 60 characters, the X25519 SPKI prefix pinned by ADVISORY_READ_PUBLIC_KEY_SHAPE with the canonical pad character set (the last group's spare bits clear, so one spelling), parsed as SPKI DER, asymmetricKeyType === 'x25519', then a trial derivation against a fresh private key with an all-zero or thrown result refused as a low-order point; every reason is a fixed sentence. The validator's refusal is must be one X25519 public key, base64 SPKI DER of exactly 60 characters — got <describeQuietly(value)> that <why>, and describeQuietly is type and byte count alone. Measured: the validator's battery refuses 29 malformed shapes (non-strings, short, long, unpadded, padded with a space, base64url alphabet, a spare bit set, Ed25519, X448, PEM, raw key in two encodings, DER in hex, the seven low-order points of the curve) and the echo sweep finds no 16-character run of any of them in any refusal. Ablation A4 (the refusal prints JSON.stringify(value)) reds 1 of 303 (the no-echo case); A4b reds 2 of 253 (the marker test and the real-process case). Correct.
  4. The crypto. sealAdvisoryRead: a fresh X25519 pair per seal (generateKeyPairSync inside the function), diffieHellman with the recipient, HKDF-SHA256 with an empty salt and an info of the scheme label plus both raw public keys (empty salt is RFC 5869's optional form and the info binds the two parties, which is the right thing to bind), a 32-byte AES-256-GCM key, a random 12-byte nonce, a 16-byte tag, setAAD(aad) with advisoryReadAad = label, request id, action index and advisory id; the envelope is one version byte ‖ ephemeral raw key ‖ nonce ‖ ciphertext ‖ tag in unpadded base64url (61 bytes of overhead, pinned); the shared secret and the AES key are zeroed in finally; an all-zero secret throws before any key is derived. openAdvisoryRead refuses non-canonical base64url, a short box, another version, and any decryption failure with one fixed sentence; it decodes with a fatal UTF-8 decoder; it never exports the private key (diffieHellman({ privateKey }) and createPublicKey(privateKey) read the KeyObject). The reviewer's independent decrypt from the header's description alone opens a sealed envelope to the exact plaintext, finds a different ephemeral key and nonce on a second seal of the same text, finds no marker in the sealed characters or decoded bytes, and fails under a different AAD — so the documented format is the implemented one. Failures never fall back: a thrown seal and a seal that returns the plaintext are each a FAILED action with ADVISORY_READ_SEAL_FAILED, zero parts, and the exception's message printed nowhere; a result that is not a base64url string is the same FAILED. Ablations: A8 (a thrown seal falls back to the plaintext as base64url) reds 1 of 253; R2 (the exception's message becomes the row's reason) reds 2 of 253 including the marker test; R5 (AAD dropped from seal and open together) reds the "opens for its own read alone" case in validate (1 of 303) and in execute (1 of 253), each on the other-action and other-advisory arms exactly. The key pair is one-time: armAdvisoryReads makes one per read action per process, keeps the private half in a Map local to main, and the dispatch self-test reads its own source to pin that no privateKey.export( exists above the self-test, the file's one node:fs import is readFileSync, and no module-level key map exists. Sound.
  5. The leak surface, read against the code rather than the tests. On the read path the executor's row is { call: advisoryReadCall(repo), status, result } where the call is the path with {ghsa_id} left in as a placeholder, result is advisoryReadLandedText (three counts) or FAILED: <why>; why is the fence text (fixed sentences; the sender fence prints the dispatcher's login through advisoryPrintable with the login shape, never an answered value; the advisory fence says "names another advisory", never which), plus advisoryPlatformText(detail) which prints the platform's message only when ADVISORY_PLATFORM_MESSAGES lists it, plus ADVISORY_READ_ACCESS_REMEDY; or ADVISORY_READ_SEAL_FAILED; or advisoryReadOverCapText (two counts). The log line is ✓/✗ action i op: <call> -> HTTP s · <result>, the step summary's table prints call, status and result, stoppedAt.why is the same why, and the only other emission is ::notice title=fleet-write advisory_read::fleet-write action=i op=advisory_read part=k/n sealed=<chunk> per part. detail on a status: 0 transport failure is the fetch error's text, which the allow-list does not print. No path prints the id, the state, the summary, the description, a vulnerability or any other answered field. Measured: the marker sweep covers 18 executor runs (landed, long, both gates and the unknown gate, the read, advisory and sender fences, a thrown seal, an unsealed seal, over cap, seven refused payloads) plus four real processes, and finds the marker and both ids on no log line, row, summary, annotation, pace-log line, stdout, stderr, GITHUB_OUTPUT or step summary, with the opened text as the positive control. Ablations: A2 (the landed row appends the answer's summary) reds 2 of 253 (the landed row, the marker test); A3 (the row prints the real path) reds 3 (the landed row, the fence rows, the id sweep); R1 (the sealed branch made unreachable, so the GET falls through the generic row printer) reds 12 of 253 including the marker test and the id sweep, which is the whole point of the branch; R4 (the advisory fence names the answered id) reds 1 of 303 and R4b reds 2 of 253 (the fence rows, the id sweep). Holds on every path this review could reach.
  6. The sender gate, enforced in the runner and out of the payload's reach. The executor's existing target-write gate (GET /repos/{o}/{r}/collaborators/{login}/permission) runs before any action on every stroke; the read then judges ADVISORY_READ_FENCES with sender from FLEET_WRITE_SENDER, which the workflow sets to github.event.sender.login (read, outside the surface), and gate from OPS.advisory_read.senderGate, a default parameter on a frozen table; the only override is deps.advisoryRead, which main() never passes (it passes { log } alone), and the validator refuses any key on the action other than ghsa_id and public_key by name (senderGate, sender, private_key, reveal and the answer's field names among the pinned refusals). collaborator requires the sender's login, compared case-insensitively, in the advisory's own collaborating_users; teams are deliberately not read; target-write returns null (the first gate already held); any other value, including empty or absent, fails closed. Ablation A5 (the collaborator gate opened without reading the list) reds 1 of 253 (the stranger with write on the target); A5b reds 3 of 303 (the sender fence, the read-fence ordering, the fence sentence). Enforced.
  7. Chunking and reassembly, correct and capped. advisoryReadParts cuts the sealed text into 4,000-character parts, at most 10, returning null for empty or over 40,000 characters; 40,000 base64url characters is 30,000 envelope bytes, less 61 of overhead, so 29,939 plaintext bytes — the validator's largest/overOne case seals exactly 29,939 and 29,940 bytes and gets 10 parts and null. Each notice message is prefix action=i op=op part=k/n sealed=<chunk> under the runner's 4,096-character cut (pinned at the longest chunk). The parser is anchored, accepts only ADVISORY_READ_OPS (derived from the table as the rows requiring public_key, so exactly one today), 1 ≤ part ≤ of ≤ 10, a base64url chunk of 1 to 4,000 characters, nothing trailing. assembleAdvisoryReads requires one of per action, every part 1..n present, a repeated part identical, and two texts under one number a refusal; it never picks between answers. An over-cap answer is a FAILED action with counts alone and zero parts (pinned). The dispatch's end-to-end case runs the real executor, turns its notice lines into annotations, and opens them byte for byte; six unverified shapes (no key in this process, no part, a missing part, a tampered part, parts sealed to another seat's key, unreadable annotations) are each unverified, exit 6, plaintext: null, nothing printed. Correct.
  8. The seat side, --reveal as the one door. main() arms the keys before packing; a public_key already in the actions file is refused (exit 2) with a sentence that never echoes it; --reveal on a stroke with no read is exit 2 before anything is sent; after sendFleetWrite the opened text is written with console.log to stdout only inside if (opts.reveal); advisoryReadLine on stderr carries counts and the top-level field names of the opened JSON; --json's advisory_reads carries state, bytes, fields, parts and why, never text; unverifiedText reads readBack and runs rows only. The proxy re-exec child inherits stdio and arms its own pair from the actions file, so the plaintext still lands only on the seat's own stdout. Ablations: A7 (the text printed without --reveal) reds 1 of 315; R3 (--json carries the text) reds 2 of 315. Correct.
  9. Self-tests and gates. Base vs head, both measured here: validate 275/15 → 303/16 (+28, one battery), execute 237/21 → 253/22 (+16, one battery), dispatch 298/26 → 315/27 (+17, one battery); each file's battery floor rose by one; rerun-failed 31/5 unchanged. Every battery name is pinned, every case names a battery, and the handshake flag is set after the verdict line. The thirteen repo gates listed under Local-runs are green on the head; check-doc-authoring and ESLint could not run in the throwaway worktree and are read from CI (Lint · docs & code guards and Lint · ESLint, both success on this head). Existing ops are unaffected: the 275, 237 and 298 pre-existing cases all still pass inside the head counts, the annotationCommand dispatch checks sealed first and ghsaId second so existing notes take their old branch, readRunAnnotations adds a reads array beside the two it had, and REFUSED_PATH_FAMILIES is unchanged (comment only). Correct.
  10. The ratchet rows and the two one-line additions, measured. rest-channel.md is 81 lines at the merge-base and 82 at head; security-findings.md 55 and 56; the ceilings move 81 → 82 and 55 → 56, each with a comment citing the card's ruling text; check-skill-line-ratchet.mjs is green on the head (self-test and run). The two added lines are 115 and 113 bytes, inside the 120-byte line form the references keep; each is one rule naming advisory_read, the one-time key, the ciphertext-only return and --reveal, and neither names an advisory or a mechanism. check-skill-id-lint is clean. Correct.
  11. The PR body against the head. Every count in the Tests table reproduces (base and head); the eight ablation readings redone here reproduce exactly (A2 2, A3 3, A4 1, A4b 2, A5 1, A5b 3, A7 1, A8 1); the 29,939-byte cap, the 61-byte overhead, the 60-character key, the ten parts of 4,000 and the 18-run marker sweep all measure as written. Clause-②: no and Metadata-protocol: none are line-initial in the body; skip-changeset is right (scripts/pm and .claude publish nothing; Check Changeset success); labels documentation, size/xl, skip-changeset; no assignee, no reviewer requested, no review submitted. One body matter is S1 below.
  12. The serial overlap with fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505 (open, non-draft, mergeable_state: clean, head 3f3345a, also editing execute.mjs). git merge-tree --write-tree d2ee86ca 3f3345a8 conflicts in execute.mjs at two places, both mechanical: the ops.mjs import block (this head adds its fourteen names where fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505 adds ADVISORY_STATE_SHAPE), and the SELF_TEST_BATTERIES literal (this head adds the advisory_read row after the advisory rows' row, whose count fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505 raises 27 → 30). One semantic hazard rides on the first: fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505 removes advisoryPlatformText from execute.mjs's import list (it no longer has a caller there), while this head adds a new caller in advisoryReadAction; a union resolution that keeps fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505's deletion leaves a bare reference, and the read fence's refused cases (the 403 and 404 arms of the fence case) would throw at run time. S2.
  13. No real finding anywhere: the fixtures' ids are made-up shapes, the markers are generic sentences, the weakness ids are category ids; the card, the PR body, the dev report and this record name no advisory and no mechanism.

② Semver level

None. No published package, no packages/spec/src/**, no TypeScript; scripts/pm tooling and two governed rule lines. skip-changeset is the right declaration for this repository and Check Changeset is success on the head. Clause-②: no and Metadata-protocol: none are correct.

③ Boundary flags

  • Dev open_questions (the sender gate: keep collaborator or switch to target-write): a maintainer's ruling, correctly left open on the row's ⚖ comment and the PR body. This review's reading: the default is the card's own recommendation, it is the narrower of the two, it is enforced in the runner on the advisory's own answer and cannot be moved by the payload, the alternative is one value on the row and is tested, and an unknown value fails closed. Nothing blocks landing under the default; the ruling can land as a one-line follow-up either way.
  • Dev out-of-scope (b), the read grant documented but unmeasured until the first live read: accepted. The failure path is a FAILED action carrying ADVISORY_READ_ACCESS_REMEDY, which puts the raise to the maintainer and forbids a re-send under a wider token; nothing in the code widens on its own. The first live read is the measurement of record and is a post-merge act (S1).
  • Dev out-of-scope (a), .github/workflows/fleet-write.yml's comment near line 154 still says "THREE consumers" and names permission_repository_advisories=write: confirmed by reading the file at origin/main; the input itself passes whatever level the validator writes, so behaviour does not depend on it. Outside the claim's surface and Tier H, so correctly not touched here; folded into S1's follow-up.
  • fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505 overlap: see ①.12 and S2. The dev's claim said "no open PR touches these files" at claim time, which was true then; fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505 opened later on the same file.
  • Independence: PR author objectstack-fleet[bot], commit author Claude with the model-free trailer pair; no off-relay author note is owed.
  • Premise: holds. origin/main 28d0dbb carries no advisory_read row (the base self-tests enumerate four advisory rows), and the card's 403 reading is the live run recorded on feat(pm/fleet-write): a relay op that adds named accounts to one repository security advisory, carrying no advisory text (Tier H) #29160.

④ Design judgment

Per hits file, against the card's scenario (a seat that cannot read an advisory under its own account reads it through the public relay run without any field of it reaching a public surface):

  • .claude/skills/pm-dispatch/references/rest-channel.md and security-findings.md: one rule each, pointing a seat's advisory read at the relay op and naming the three things a reader must know (one-time key in the seat process, ciphertext back, --reveal to print). They supersede the earlier "reads under its own account" sentence without rewriting it, the ratchet moves by the measured delta only, and the lines stay inside the form. Holds.
  • scripts/pm/fleet-write/ops.mjs: the hybrid scheme is the right shape for a one-way channel from a public run to one process: an ephemeral-static X25519 agreement gives forward secrecy per answer, HKDF with both keys in the info and GCM with the request id, action index and advisory id as AAD bind the envelope to exactly one read, and refusing low-order points at validation and at seal closes the one X25519 misuse. Picking ADVISORY_READ_FIELDS rather than the whole answer keeps the plaintext under the channel cap and out of the kilobytes of URLs in collaborating_users; the placeholder call is the one way a row can print its request without its id. Recording the sender gate on the row with both settings tested is the correct way to leave a ruling open. Holds.
  • scripts/pm/fleet-write/validate.mjs: a per-row grant on a stroke-scoped permission, with write precedence and alone making mixtures unreachable, is the minimal change that lets one row mint lower than its siblings without a second table. The x25519 kind reuses the quiet describer the other advisory kinds already use. Holds.
  • scripts/pm/fleet-write/execute.mjs: fence first, then seal, then cut, then emit, with every failure returning before any part exists, is the order the card asked for and the order the ablations confirm matters (R1 shows what the generic path would print). Keeping the read out of rest()'s paced path is right (a GET). Holds; S3 is a wording matter on the transport-failure arm.
  • scripts/pm/fleet-write/dispatch.mjs: deriving ADVISORY_READ_OPS from the table, refusing a supplied key, keying the private halves by action index in a Map that dies with main, and making the opened text reachable only through --reveal on stdout are each the narrow choice. Treating any reassembly ambiguity as unverified rather than picking is correct. Holds.

Must-fix: none.

Should-fix:

  • S1 (PR body, the seat's call before landing, no new head): the body says Fixes #29420, but the card's fourth acceptance box (one live read in session with the maintainer after merge, reported by run link only) is by its own wording a post-merge act, and the card also carries the sender-gate ruling. Under review-checklist's Fixes/Part-of rule, either switch to Part of #29420 and arrange the close after the live read, or file the follow-up card and name it in the body. That card should also carry: the first live read as the grant's measurement of record; the sender-gate ruling once the maintainer gives it; the stale comment near line 154 of .github/workflows/fleet-write.yml (Tier H, its own PR); and the advisoryPlatformText question in S2.
  • S2 (whichever of fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505 and feat(pm): advisory_read relays an advisory's text sealed to a one-time key the seat alone holds #29536 lands second, scoped re-check): the two textual conflicts in execute.mjs are mechanical, but the merge must keep advisoryPlatformText imported (or move advisoryReadAction's refused arm onto fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505's advisoryRefusalCause with an empty known, since a read sends no prose), and must re-run execute.mjs --self-test on the merged tree — its 403 and 404 fence cases are the ones that would catch a bare reference.
  • S3 (execute.mjs, cosmetic): on status: 0 (fetch threw) advisoryReadAction treats the answer as refused and appends ADVISORY_READ_ACCESS_REMEDY, whose grant wording does not describe a transport failure, and the read fence says "answered HTTP 0". Branch on status === 0 to say the request did not reach the platform. No leak: the transport error's text is not printed (the allow-list excludes it).

Check-runs at record time (2026-11-02T18:31Z): 46 check-runs on d2ee86c, all completed, 26 success, 20 skipped, 0 failure, 0 in progress. Success: Lint & Repo Gates, TypeScript Type Check, Test Core (and shards 1/8, 2/8), Governed Surface Queue Guard, Test UI, Lint · ESLint, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · tooling self-tests, Lint · scoped · slot lookup & corpus, Lint · scoped · query-options erasure, Lint · PM dispatch-gates, Lint · CI, release & engine gates, Type Check · workspace / source gates / debt ledger / consumer gates, PR-body guards, Check Changeset, Check PR Size, Dogfood Regression Gate, Plan Test Core shards, Auto Label, filter. Skipped by path filter: Build Core, Build Docs, Build UI Deps, Lint UI, Type Check UI, Build & E2E UI, Live E2E UI, Dogfood Verify CLI, Temporal Conformance, UI Published Artifacts and the opt-in lanes. Combined status pending with zero statuses (statuses API; no check-run is pending). mergeable_state clean, draft. Governed Tier S (.claude/** only): this record is the landing record once the owning seat reads it; the queue remains the seat's path.

Implemented-by: claude/project-thread-fubg69
Reviewed-by: session_01J8VVAnMUWA9rufXzFaEh4J

VERDICT: PASS with should-fixes (S1 PR-body Fixes/Part-of and the follow-up card; S2 the advisoryPlatformText import across the #29505 merge; S3 status-0 wording)


Generated by Claude Code

… not, not "HTTP 0"

On status 0 (`rest`'s word for a call that threw before any answer) the read
row now says the request did not reach the platform, with the row status
`not reached`. It no longer prints "HTTP 0" or appends the read-grant remedy,
because the platform judged nothing. The exception's message is still printed
nowhere, since it can carry the path and so the id. A self-test case pins
this, and the run joins the marker and id sweep (19 runs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8VVAng9QxNxoEzyWkqbW6
Conflicts in scripts/pm/fleet-write/execute.mjs, resolved as follows:
- the ops import list takes both sides: advisory_read's names and #29505's
  ADVISORY_STATE_SHAPE;
- the battery map keeps #29505's advisory rows count and advisory_read's row;
- advisoryPlatformText is imported again. #29505 moved the other advisory
  rows onto advisoryRefusalCause and dropped the import, but advisory_read's
  refused arm still quotes only the messages ADVISORY_PLATFORM_MESSAGES names
  (review S2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8VVAng9QxNxoEzyWkqbW6
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:skills#1 · huangyiirene · session_01J8VVAnMUWA9rufXzFaEh4J

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 453d4e1108a4a2f5cf126c1c09c79cec51acec91
Local-runs: probe — on a detached worktree of the head (merge-base with origin/main 8e505db, which is #29505's squash commit; origin/main at 03797bd, thirteen commits on, none of the seven files moved): node scripts/pm/fleet-write/validate.mjs --self-test (303 cases, 16 batteries, exit 0), execute.mjs --self-test (259/22, exit 0), dispatch.mjs --self-test (315/27, exit 0), scripts/pm/write-pace.mjs --self-test (118/12, exit 0); the same three with scripts/pm/fleet-write/ checked out from 8e505db for the base column (275/15, 242/21, 298/26), then restored (blob equal to head, porcelain 0); git merge-tree --write-tree 7b0268e2 8e505db8 to replay the merge, git diff of the replayed tree against the head, and four -U0 interdiffs of execute.mjs (each side's hunks before and after the merge); eleven repo gates run as --self-test then bare (check-skill-id-lint, check-scripts-symbol-anchors, check-nul-bytes, check-entry-guard, check-parse-guard, check-skill-frame-sync, check-watch-hint-literal, check-ratchet-remedy-authority, check-governed-prose, check-self-test-wired, check-declaration-mirrors, all exit 0) plus check-skill-line-ratchet (--self-test 164 cases, bare exit 0, three declared moves, ceilings down 496); scripts/check-commit-card-trailers.mjs --range 8e505db8..453d4e11 (exit 1, see ①.11); scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (47 families derived from the seven paths against merge-base 8e505db, with the tool's stale-tree warning since origin/main has moved); two ablations through scripts/ablation-replace.mjs against the committed execute.mjs blob (the dev's A11 redone and the reviewer's R-S2), each with its anchor hit once, the blob changed, and the restore proved (git hash-object equal to the head blob, git diff HEAD empty, porcelain 0); no node_modules in the throwaway worktree, so ESLint and check-doc-authoring are read from CI; the worktree was removed after the runs and the main working tree was not touched (porcelain 0 before and after)
Scope: delta review of two commits over the same seven files cr20 passed on d2ee86c: 7b0268e (cr20's S3: scripts/pm/fleet-write/execute.mjs +16/−4 and scripts/pm/fleet-write/ops.mjs +11, one new case) and 453d4e1, an ordinary two-parent merge of origin/main at 8e505db (PR #29505, merged, which edited execute.mjs and scripts/pm/write-pace.mjs) resolving two textual conflicts in execute.mjs and re-importing advisoryPlatformText (cr20's S2); + read, not reviewed: #29505's execute.mjs hunks as they stand on the merged tree (the refusal cause, the quote check, the status-0 wording, the runner-403 header note), write-pace.mjs (blob-identical to origin/main), the repository's merge settings and main's rulesets (for the squash question), the three PR-body guards runs on this head, the PR body as the seat replaced it; cr20 (PR comment 6585067427) as the baseline; the dev's merge statement in the merge commit and the PR body (read as input)

Reviewer: an isolated contract reviewer at CONTRACT_REVIEW_TIER, read-only on GitHub, fed cr20's PASS on d2ee86c, the dev's two commits, the replayed merge, the PR body, the head's check-runs, and the shape of cr16. This record judges only what moved since d2ee86c; cr20's judgments stand where the blobs are unchanged, and every count below is this review's own measurement. The record carries no finding, no vulnerability mechanism, no advisory id and no advisory title; the fixtures use made-up ids in GitHub's shape and generic marker sentences, none repeated here. No environment variable value is printed anywhere in this record.

① Derived judgments

The two commits, one claim at a time:

  1. The commit shape. Seven commits on the PR: cr20's five, then 7b0268e (parent d2ee86c) and the merge 453d4e1 (first parent 7b0268e, second parent 8e505db). The timeline shows no force-push and no review; the PR is still draft, base main, head repo the base repo, author objectstack-fleet[bot], labels documentation, size/xl, skip-changeset unchanged, no assignee, no requested reviewer. 8e505db is on origin/main, so the merge-base of head and origin/main is 8e505db, and none of the seven files moved on main after it. Correct.
  2. What the merge brought in. git diff 7b0268e2 453d4e11 and git diff 28d0dbbf 8e505db8 (main's advance over the branch's old base) touch the same 96 files, 3,376 insertions each, 207 against 208 deletions: the one line main deleted and the merge did not is advisoryPlatformText, in execute.mjs's import list (point 5). git diff 8e505db8 453d4e11 --stat is exactly the PR's seven files, +1,195/−67 (1,262 lines; the PR body, the REST file listing and changed_files: 7 all agree). Nothing outside the seven files is re-applied. Correct.
  3. Blob identity. Between 7b0268e and 453d4e1 six of the seven files are blob-identical (rest-channel.md, security-findings.md, check-skill-line-ratchet.mjs, ops.mjs, validate.mjs, dispatch.mjs); only execute.mjs changed in the merge. Against cr20's head, validate.mjs (8ea5abcd) and dispatch.mjs (51075987) are unchanged; ops.mjs moved 7d2ba294 → ce0656b3 by S3 alone (two constants and a header paragraph), execute.mjs 9c70c7cd → b296bccf (S3) → c328a12c (the merge). The four head blobs are the four the PR body's ablation table names. write-pace.mjs at head equals origin/main's blob. So cr20's ①.2 to ①.4, ①.7, ①.8 and ①.10 apply unchanged; ①.5, ①.6 and ①.9 are re-read below where execute.mjs moved. Correct.
  4. The replay. git merge-tree --write-tree 7b0268e2 8e505db8 exits 1 with execute.mjs the only conflicted path, at exactly the two regions cr20 ①.12 predicted: the ops.mjs import block (this branch's sixteen advisoryRead* names, now including S3's two constants, against fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505's ADVISORY_STATE_SHAPE) and the SELF_TEST_BATTERIES literal (this branch's advisory-rows row at 27 plus the advisory_read row at 17, against fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505's advisory-rows row at 32). git diff <replayed tree> 453d4e11 is execute.mjs alone, 2 insertions and 8 deletions: the six conflict-marker lines and the stale 27-row removed, both import lists kept, the 32-row and the 17-row kept, and one line added, advisoryPlatformText, in the import list. That is the dev's stated resolution and nothing else. Correct.
  5. The interdiffs. PR side (28d0dbb → 7b0268e against 8e505db → 453d4e1, -U0, headers stripped): one line differs, + advisoryPlatformText, present only after the merge. Main side (28d0dbb → 8e505db against 7b0268e → 453d4e1): one line differs, - advisoryPlatformText, present only before. No hunk of either side was lost; the only deviation from a pure union is the re-import, which is S2. The counts reconcile: main's execute self-test is 242/21 (cr20's 237 plus fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505's five, its advisory-rows battery 27 → 32) and the read battery adds 17, so 259/22; the floor comment reads "21 → 22 with advisory_read's: one battery, one row". Correct.
  6. S3, fixed (7b0268e). advisoryReadAction now returns before the fences when r.status === 0: { ok: false, status: ADVISORY_READ_UNREACHED_STATUS, why: ADVISORY_READ_UNREACHED, notes: [] }. The two constants are in ops.mjs: a fixed sentence saying the advisory's GET did not reach the platform, no grant, advisory or sender was judged, nothing was sealed or emitted, re-send with a fresh key; and the row status not reached. On that branch r.detail (the fetch error's text) is never read, so the exception's message has no path to a row, a log line, the summary or stoppedAt.why; the failed-read log line prints call -> why with no HTTP at all, and the row's status column carries the string. The new case serves a GET that throws with the fixture's id and marker in its message and pins: exit FAILED, calls seen [PERM, A_GET], zero annotations, zero notices, the row ['not reached', 'FAILED: <sentence>'], no HTTP 0, no ADVISORY_READ_ACCESS_REMEDY, no fetch failed on any surface; the marker sweep rises 18 → 19 runs and the id sweep covers the run. Ablation A11 (the branch deleted): anchor hit once, blob c328a12c → 5ddfee50, 1 of 259 red (the new case alone; the runs under test print HTTP 0 with the grant remedy again, as cr20 described), restore proved. Exactly what cr20 S3 asked for, and the leak property is unchanged. Correct.
  7. S2, the dev's argument, judged on the code. advisoryRefusalCause(answer, known = []) prints the platform's message (flattened, capped at 200) and its documentation_url (shape-checked, capped) and withholds them only when quotesKnown finds a 16-character run of a known string in them; with known empty, quotesKnown is false for every input and the function is a capped pass-through of whatever the platform's body said. For a refused read, known would be empty by construction: the payload carries an id and a public key (neither is prose, and advisoryProseOf drops both shapes), and no advisory has been read, so there is nothing to withhold against. advisoryPlatformText prints one of six whole listed strings or nothing. The dev's reading is therefore right, and cr20's S2 alternative ("onto advisoryRefusalCause with an empty known") was not a safe alternative; this record withdraws it. Measured: the reviewer's R-S2 (the read's refused arm moved onto advisoryRefusalCause(r) with no known) reds 2 of 259 — the fence case (the 404 fixture's message, which is prose, reaches the row) and THE MARKER TEST (the marker on a row) — restored to the head blob. So the allowlist is the correct resolution, and the read's refused row can print: the fence sentences (fixed; the sender fence prints the dispatcher's login through advisoryPrintable; the advisory fence says "names another advisory", never which), one of six listed platform strings, the read remedy, and nothing else — never advisory prose, never either id (the row's call is the placeholder path), never the public key (it is passed to the seal alone and appears in no text), and never a secret (scrub(read.why, [token]) on the row and on stoppedAt). Both fence fixtures are pinned on the merged tree: the 403 prints (the platform: Resource not accessible by integration) plus the READ remedy; the 404's prose is not quoted. Sound.
  8. fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505 intact on the merged tree, and the two ops disjoint. Its five new cases in the advisory-rows battery (32) pass inside the 259: the unlisted message printed as the cause with its documentation URL (the measured defect it fixed), a quoting URL dropped (percent-encoded included), a missing message read as "cause unread", the cap and the one-line flattening, a quoted run straddling a pipe judged raw, and the status-0 wording in advisoryRefusalCause; its marker sweep over 46 runs is green. rest() was not touched by fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505, so detail reaches the read's advisoryPlatformText(r.detail) as cr20 judged it; the runner-403 reading (proxyAnswered) lives in write-pace.mjs, blob-identical to main, 118/12 green, and is referenced once in execute.mjs's header on both sides. The read path is entered only on req.advisoryRead; fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505's advisoryPreflight and confirmAdvisory are the POST and PATCH rows' paths; neither alters what the other admits or prints. All 242 of main's execute cases pass inside the head's 259. Correct.
  9. Self-tests and gates. Base (8e505db) versus head, both measured here: validate 275/15 → 303/16, execute 242/21 → 259/22, dispatch 298/26 → 315/27; each file's battery floor rose by one; write-pace 118/12 unchanged. Eleven repo gates and the ratchet gate are green on the head. dispatch-gates --commands derives 47 families from the seven paths, the count the PR body claims; the 47 runs themselves were the dev's under pnpm, which this worktree could not run, and Lint · PM dispatch-gates, Lint · ESLint and Lint · docs & code guards are success on the head. Correct.
  10. The ratchet rows. rest-channel.md 81 → 82 and security-findings.md 55 → 56 against 8e505db; the rows read 82 and 56; the gate is green on the head with the same three declared moves and ceilings down 496. Unchanged from cr20 (the blobs are identical). Correct.
  11. The PR body against the head. Part of #29420, Clause-②: no and Metadata-protocol: none are line-initial; the body carries no GitHub closing keyword before #29420 anywhere, so neither the PR nor the squash message closes the card. The Tests table reproduces in both columns; the four ablation blobs are the head's; A11's "1 of 259" reproduces; the 17-case battery, the 19-run sweep, 1,262 changed lines and 47 families all measure as written. The S2 note states the argument this record upholds in ①.7, and the S3 item describes the branch as built. The stale comment near line 154 of .github/workflows/fleet-write.yml is named as a later Tier H PR (the file is untouched here). The trailer note: check-commit-card-trailers --range 8e505db8..453d4e11 exits 1 naming exactly 7b0268e and 453d4e1, each for a model-named Co-Authored-By; cr20's five commits carry the model-free pair. On the squash: MERGE_METHOD = 'SQUASH' in ops.mjs, and the automerge_enable mutation sends pullRequestId and mergeMethod alone (no commit headline or body); the repository allows squash only (allow_merge_commit and allow_rebase_merge false), with squash_merge_commit_title: PR_TITLE and squash_merge_commit_message: PR_BODY; main's merge-queue rule is merge_method: SQUASH; and the gate's own remedy text records that the squash message is taken from the body. So the squash commit on main carries the PR title and this body, and neither trailer lands. skip-changeset is still right (Check Changeset skipped on the label). Correct.
  12. The PR-body guards history on this head. Three runs: 18:42 success on the body as it stood at the merge; 18:56 failure, check:partof-closing-keyword, on an intermediate body that carried Part of #29420 beside a closing-keyword phrase naming the same card; 18:58 success on the body updated 18:57:58, which rewords that sentence ("stays open"). The failed run remains in the head's check-run list; mergeable_state read unstable at this review's first read and clean at its last. Nothing in the code moved. Correct.
  13. No real finding anywhere: the two commits, the merge, the body and this record name no advisory and no mechanism; the new fixture's thrown message carries the made-up id and the generic marker, and the case proves both print nowhere.

② Semver level

None, unchanged from cr20. No published package, no packages/spec/src/**, no TypeScript; the delta is scripts/pm tooling and main's own commits. Clause-②: no and Metadata-protocol: none stand; Check Changeset is skipped on the skip-changeset label, the ordinary reading for this repository.

③ Boundary flags

  • The dev's merge statement (two conflicts, both sides taken, the import back, S2 kept on the allowlist, 259/22 on the merged tree): confirmed by the replay, the interdiffs, the blob reading and the runs. No flag.
  • cr20's three should-fixes: S1 disposed by Part of #29420 and the acceptance notes naming the post-merge acts (the first live read, the sender-gate ruling, the Tier H comment); S2 disposed by the allowlist, which this record judges the stronger choice (①.7); S3 fixed (①.6). Nothing re-raised.
  • The two model-named trailers: the gate flags them and the body says so; the branch is shared under AGENTS.md §3 and was not rewritten; the squash takes the body, so main is unaffected (①.11). Accepted as the body states it. The clone lacking the pre-push hook is a process note for the card, not a PR matter.
  • One failed check-run on the head is the superseded 18:56 PR-body guards run (①.12); its 18:58 re-run is success and the current body carries no closing keyword. Not a code matter.
  • origin/main is thirteen commits past the merge-base; none of the seven files moved, and the merge queue re-tests the squash against main's tip. No re-merge is owed for this review.
  • Not re-measured here, by design of a delta review: the reviewer-written decrypt and the eleven other ablations of cr20 (ops.mjs's seal and open are unchanged apart from two added string constants; validate.mjs and dispatch.mjs are blob-identical).
  • Independence: PR author objectstack-fleet[bot], commit author Claude; no off-relay author note is owed.
  • Premise: holds. fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505 landed first (merged 18:36Z), so this PR is "the second to land" of cr20's S2 and owns the reconciliation; it did it on the merge, as S2 allowed.

④ Design judgment

Per hits file, only execute.mjs and ops.mjs moved in substance:

  • scripts/pm/fleet-write/execute.mjs: judging status 0 before the fences, with a fixed sentence and a string row status, is the right place and the right shape: the platform judged nothing, so no HTTP number and no grant remedy belong on the row, and returning before r.detail is ever read removes the transport text's one path to a surface rather than filtering it. Keeping the read's refused arm on the whole-string allowlist while the write rows moved to advisoryRefusalCause is the correct asymmetry: the write rows have prose to withhold against (their own body and the preflight's answer), the read has none, and a withholding filter with nothing to withhold is a pass-through. The cost is accepted knowingly: a live read refused with a message outside the six prints its status and the remedy with no cause; if the first live read lands there, the remedy is one more whole string on ADVISORY_PLATFORM_MESSAGES, never a move to the cause printer. The merge took both sides whole and added one import. Holds.
  • scripts/pm/fleet-write/ops.mjs: two constants beside the seal-failed sentence, each fixed text, the status a short string the summary prints in the number's column. Holds.
  • The other five files are the blobs cr20 judged. Holds.

Must-fix: none.

Should-fix: none.

Check-runs at record time (2026-11-02T19:11Z): 49 check-runs on 453d4e1, all completed, 26 success, 22 skipped, 1 failure, 0 in progress. The one failure is the superseded PR-body guards run of 18:56 (①.12); its 18:58 re-run on the current body is success. Success: Lint & Repo Gates, TypeScript Type Check, Test Core (and shards 1/8, 2/8), Governed Surface Queue Guard, Test UI, Lint · ESLint, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · tooling self-tests, Lint · scoped · slot lookup & corpus, Lint · scoped · query-options erasure, Lint · PM dispatch-gates, Lint · CI, release & engine gates, Type Check · workspace / source gates / debt ledger / consumer gates, PR-body guards (twice), Check PR Size, Dogfood Regression Gate, Plan Test Core shards, Auto Label, filter. Skipped by path filter or label: Build Core, Build Docs, Build UI Deps, Lint UI, Type Check UI, Build & E2E UI, Live E2E UI, Dogfood Verify CLI, Temporal Conformance, UI Published Artifacts, Check Changeset, the opt-in lanes and the re-triggered Auto Label, Check PR Size and Check Changeset runs. Combined status pending with zero statuses (statuses API). mergeable_state clean, draft. Governed Tier S (.claude/** only, blobs unchanged since cr20): this record is the landing record once the owning seat reads it; the queue remains the seat's path.

Implemented-by: claude/project-thread-fubg69
Reviewed-by: session_01J8VVAnMUWA9rufXzFaEh4J

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:skills#1 · huangyiirene · session_01J8VVAnMUWA9rufXzFaEh4J

维护者速读

  • 改了什么: 中继新增 advisory_read:车队 App 在中继运行器里读取一条安全公告,用席位为这一次读取临时生成的公钥加密后返回(X25519 + HKDF-SHA256 + AES-256-GCM,每次新的临时密钥和随机数),明文只在发起读取的席位进程里、且要显式 --reveal 才打印。公开的运行日志、摘要、注释里只有密文分片。读取单独一笔,令牌只带 repository-advisories: read;触发者必须是仓库协作者(门槛可一值切换)。
  • 为什么改: 席位账号读公告被 403,中继日志又是公开的;不加密就读,正文会进日志。
  • 风险与回滚: 不改工作流;只动四个中继脚本和两行文档;可整 PR revert。任何失败都不会退回明文。
  • 需要你做的: 无需审批(受治理面 Tier S,本席落地)。合入后需要和你在会话里约一次现场读取;触发门槛(协作者还是写权限)仍在 Irene 的决策卡上,若选写权限,另起一行改动。
  • 席位意见: ACCEPT。复核 PASS(6585067427),合并 main 与 S3 之后的增量复核 PASS(6586058913)。

席位核对 — ACCEPT · domain:skills#1 · head 453d4e1108a4a2f5cf126c1c09c79cec51acec91

  1. 形态: draft → main,Part of #29420(合入后的现场读取与门槛裁决留在卡上);skip-changeset;作者 objectstack-fleet[bot];改动正好是认领时的 7 个文件,+1195/−67。
  2. 自测(复核在新 head 上跑): validate 303/16、execute 259/22、dispatch 315/27;读取电池 17 例,标记与编号扫描 19 次 0 泄漏;复核方独立解密验证了封装格式,自做消融逐个变红。fix(pm): the relay reads no runner 403 as the egress proxy's, and a refused advisory row names its cause #29505 的行为在合并后的树上完好。
  3. check-runs(钉 head 453d4e11): success 26 / skipped 22;唯一的 failure 是 18:56 那次 PR-body guards,针对的是已被替换的中间正文,18:58 对现正文的重跑为 success。check-governed-merges:受治理面 Tier S,低于 3000 行人合线。
  4. 接受的偏差: 两个提交带了含模型名的 Co-Authored-By 尾注;中继以 SQUASH 合并、提交信息取 PR 正文,尾注不会进 main,分支不重写。fleet-write.yml 第 154 行附近注释过时(Tier H),留待单独 PR。
  5. 落地条款: 受治理 Tier S。复核即达档记录;本席 pr_ready + automerge_enable。

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant