Skip to content

fix(runtime)!: an app-authored body may not read the stored-metadata tables; it reaches them through the metadata API only (#21594) - #21660

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21594-body-read-refusal
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21594-body-read-refusal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21594

Clause-②: yes (narrowing)

An app-authored body (a sandboxed hook, action or job body) may no longer read the stored-metadata tables (sys_metadata, sys_metadata_history). Every read verb answers the body boundary's PERMISSION_DENIED / 403 before it runs, with a prescription naming the metadata API's read route. With the binding and write refusals already landed, an app-authored body now reaches these tables through the metadata API only. This is the maintainer's ruling, letter B (record 5974479930).

An action body is not handed a family row either. The /actions door loads an action's subject record before it dispatches. When that record is a family row, the call now answers the same 403 before the body runs, instead of handing the body the row as ctx.record. That covers an action declared on a family table and an object-less action addressed under one. The contract review's flag 2 raised this path; it was measured first, at the doors (below).

The handler contexts (A2) are ruled A by the maintainer (record 5978653398, 「同意」 on director batch #276). Ruling B covers sandboxed app bodies only; app host handlers registered with registerAction keep the projected read. This PR leaves them exactly as they were: their ctx.api, ctx.engine.find and subject record are unchanged. C (a host-code trust model at the engine entry) is not taken and not filed.

The census's cloud leg is answered. The same ruling record cites triage's reading 5976321402: zero app-authored readers of the family in objectstack-ai/cloud, neither bodies nor host handlers. That reading is triage's, not this container's.

What changed

  • packages/runtime/src/stored-metadata-body-boundary.ts. Adds storedMetadataBodyReadRefusal(object, verb). It uses the write refusal's own envelope (STORED_METADATA_BODY_BOUNDARY_CODE / _STATUS, PERMISSION_DENIED / 403), so there is no new error code. Its prescription names the read route: GET /api/v1/meta/:type/:name, and .../history for versions. The binding and write refusals' text is byte-unchanged; the shared constructor takes the prescription as a defaulted parameter.
  • packages/runtime/src/stored-metadata-reader-seam.ts. Adds a body READ layer, refuseStoredMetadataBodyReads, on the same derive walk as the write layer: object(), sudo(), withRunAs(), a transaction(fn) callback and beginTransaction(). It refuses exactly the read verbs the seam serves (find, findOne, count, aggregate), before the verb runs and before its query is looked at. So a refused read gives the same answer whatever its filter, sort, grouping, search or projection names: no rows, and no oracle. The write layer (refuseStoredMetadataBodyWrites) is unchanged in code. It sits over the read layer and passes reads down to it; only its comments now say so.
  • packages/runtime/src/sandbox/body-runner.ts. buildSandboxApi is the one place every body face gets its API. It is now refuseStoredMetadataBodyWrites(refuseStoredMetadataBodyReads(source)).
  • The subject record (this round). stored-metadata-body-boundary.ts adds storedMetadataBodySubjectRecordRefusal(object, action). It uses the same envelope (PERMISSION_DENIED / 403, operation record) and the same read prescription. sandbox/body-runner.ts consults it first in actionBodyRunnerFactory's bound handler, before the sandbox context is built and before the body runs. That handler is the one point every action body passes through to run, whichever door bound it, and the one place the handler is known to be a body. The subject is the door-stamped routed object (params.objectName, which both action doors write after the caller's params), else the declared object. A record is handed when the call carries a record id or a non-empty record. A family-routed call with neither hands the body nothing and runs.
  • Deleted: the read-side serve for bodies. serveStoredMetadataReadsThrough no longer wraps a body's API in buildSandboxApi. With every family read and write refused first, it served a body nothing. It is removed, not kept beside the refusal. Nothing else became dead: the seam's projection, evaluate refusals, default-search narrowing and write-return serve still serve the host-handler contexts (ruling A keeps them). No exported symbol is deleted or renamed.
  • packages/runtime/src/action-execution.ts. Comment only (buildActionApi): an action body's API is built over this context, and the body layers refuse first.
  • .changeset/21594-body-family-read-refusal.md. @objectstack/runtime minor, BREAKING (narrowing), exactly one ADR-0087 marker (not-required (no-migration-prescription)), in the shape of the evaluate-refusal changeset. It states the route, and which earlier entries of this release it supersedes for bodies. This round adds a Subject record bullet, and names a host handler's subject record among the unchanged.
  • scripts/engine-double-contract.pinned.json. One row for the new unit pin's double, whose findOne routes through the engine's own predicate (check-engine-double-contract.mjs --write).

Measured first

A1. Census of app-authored readers (the stop condition): 0 readers

  • objectstack examples/** at 15fe567c9c:
    • Family table names in every tracked file: 3 hits, all prose: the app-showcase changelog (2) and one code comment in app-showcase/src/system/connectors/index.ts.
    • Indirect spellings: SystemObjectName.METADATA, STORED_METADATA_BODY_OBJECTS and isStoredMetadataBodyObject have 0 hits. A non-literal .object(...) argument has 0 hits.
    • Literal .object(...) targets: four showcase_* objects.
    • Non-literal engine reads: three, in host seed code on the engine directly (bind-position-sets.ts ×2, seed-approval-demo.ts). Each is called only with non-family objects.
    • Population: 14 files carry a body: key; 5 touch ctx.api, ctx.engine or registerAction.
  • hotcrm at 4054ec2680 (a public shallow clone, read only, 924 tracked files):
    • Family table names: 5 hits, all prose or test comments. None is in src/** or apps/**.
    • Non-literal .object(...) reads: 7, in hook bodies. Each is bound to a local list of crm_* objects or to the hook's own object.
    • Literal targets: 21 distinct, all crm_* or non-family sys_* objects.
    • Host action handlers: 0. Its registerAction appears in a comment only.
  • cloud (objectstack-ai/cloud): NOT MEASURED from this container (private, unreachable). Answered by triage's reading 5976321402, as the ruling record 5978653398 cites: zero app-authored readers.

Flag 2 (this round). The subject record: can a body be handed a family row?

Measured at the doors on the head before the fix (d5b890226c), with a temporary probe that was not committed. Each body only returned what it was handed as ctx.record; the probe recorded classes (keys present, body column type, hash form), never values.

  • The app manifest / defineStack / os validate. A strict defineStack (the default) refuses an action whose objectName is a family table: STACK_CROSS_REFERENCE_INVALID, because the table is not an object the stack defines. That is a generic cross-reference check, not the family boundary. defineStack(…, { strict: false }) accepts it: os validate answers valid: true, exit 0, with placement warnings only. An object-less body action is accepted in both modes. Measured with bin/run-dev.js validate --json.
  • Install-local (POST /api/v1/marketplace/install-local). The package with a family-bound action and an object-less body action installs (200, success: true). It binds both handlers, sys_metadata:… and global:…, through the runtime's one binder. Handed a door-shaped context carrying a family row, each body received it. Measured in @objectstack/cloud-connection against the built runtime.
  • The /meta action save door. PUT /meta/action/:name with objectName set to a family table answers 200. Once bound, POST /actions/sys_metadata/:name/:id handed its body the row.
  • Boot (an AppPlugin over a JSON bundle, the composition os start --artifact builds). Both actions bind.
  • The invoke doors.
    • REST /actions, as the administrator. The body received the door-served family row on both tables: the body column as its projection and the hash in keyed form, with no stored credential. That held for an action declared on the table, for an object-less body action addressed under it, and for the /meta-declared action. No family declaration is needed for the object-less route; any installed object-less body action can be addressed under a family table. Reachable.
    • REST /actions, as a member. 404 RECORD_NOT_FOUND: the door's own subject load, under the caller's read scope, stops it, and the body never runs.
    • MCP run_action. Not reached. The door refuses an action on any sys_* object before the record load, and resolves an object-less action only under its own key. The composed kernel's metadata service lists no standalone action, so this door is pinned at unit level.
  • A host handler under the same route received the projected row (unchanged, ruling A).
  • An ordinary object's action received its row (unchanged).

Reachable, so it is refused in this card. The seam is the action body's own handler, not the door: the doors dispatch body and host handlers alike and cannot tell them apart at the prefetch. A binding-time refusal, the parallel of the hook-binding refusal, would not see the object-less route, so it is not the only coherent seam; it would also be insufficient. After the fix, on f5d575d072, the same probe gave:

  • REST /actions, administrator: 403 PERMISSION_DENIED naming the metadata API, for every family case (both tables, the family-bound action, the object-less route, the /meta-declared action).
  • Host control and ordinary control: 200, unchanged.
  • Family-routed call with no record: 200, nothing handed.
  • Member: still 404 at the door.
  • Install-local's bound handlers: PERMISSION_DENIED / 403.

A2. Which seam contexts carry app-authored code

  • ① The sandboxed body (buildSandboxApi).
    • Faces and doors: hook bodies on every data door that fires hooks; action bodies on REST /actions, MCP run_action and an engine execute; job bodies on the job scheduler.
    • Authorship: always app-authored (a code bundle, an installed artifact or the metadata door). The write refusal reaches it.
    • Here: refused.
  • ② ctx.engine.find and ③ ctx.api of an action handler (buildActionEngineFacade, buildActionApi).
    • Doors: built at two only, REST /actions (domains/actions.ts) and MCP run_action (action-execution.ts).
    • Platform registrants: in packages/**, the only registerAction callers are the two body runners (the objectql metadata-service bind and app-artifact-handlers.ts). Their handlers are sandboxed bodies, which never see ② and get ③ only as the source the body layers wrap.
    • App registrants: host-code handlers come from apps. examples/app-todo registers 8 host handlers from its onEnable(ctx) through ctx.ql; hotcrm registers none.
    • Reach of the write refusal: it does not reach these handlers, as pinned by its own unit case ("the read seam ALONE — a host code handler's ctx.api — keeps its family writes").
    • Verdict: ruled A (5978653398). Left served, unchanged.
  • Platform internal readers through any seam context: 0. Every platform reader of the family in packages/** reads through the engine or a driver directly. That covers the metadata protocol, the objectql plugin, the core translation fallback, the flow credential channel and the CLI. None reads through a body API or a handler context. Pinned unaffected:
    • the generic data door;
    • the metadata API (the route the refusal prescribes);
    • the engine's own read of the stored form;
    • handler ② / ③, still served projected and keyed.

A3. The envelope

  • Reuse: the read refusal rides the same envelope, PERMISSION_DENIED / 403, with object and operation set. There is no new error code.
  • Verbs: every read verb the seam serves is refused (find, findOne, count, aggregate). Search is a query shape on a read, and is refused with it.
  • Pin: ten query shapes × four verbs give one answer per verb.

A4. The deletion and the ledgers

  • Deleted: the body serve wrap only. No exported runtime symbol is deleted or renamed. Two module exports are added (refuseStoredMetadataBodyReads, storedMetadataBodyReadRefusal); neither is on the package entry.
  • Ledger grep: the touched symbols across packages/spec/liveness/**, every *.ledger.* file, scripts/engine-double-contract.pinned.json, content/docs and docs. One hit: a liveness note in hook.json that cites buildSandboxApi reading ctx.api from the engine context. That is still true, and the function keeps its name.
  • packages/metadata-protocol: no edit. No symbol there is left without a consumer. The seam still consumes each one for the handler contexts, as counted in the report.
  • Docs: git grep over hand-written content/docs and published skills/ found no page saying a body can read the family's tables. Zero hits, so nothing was touched.

A5. Reverse verification (ablation)

  • Mutation: made with scripts/ablation-replace.mjs in WRAP mode on packages/runtime/src/stored-metadata-reader-seam.ts, from the committed state (9c87884191).
  • Anchor: if (BODY_FAMILY_READS.has(prop)) {, hit ×1 → ×0. Replaced by if (false && BODY_FAMILY_READS.has(prop)) {, ×0 → ×1.
  • Blob: 76eabe5afe1a → c56dca3ae3e6.
  • Expected direction: red, the usual one. Observed: red.
  • Result: 18 red, 86 green, over six files.
    • Red: every read-refusal pin. That is 10 in the new unit file and the 8 body cases of the reader-contexts integration pin.
    • What the red showed: with the refusal ablated, the action bodies fell through to the handler-served read. The hook body's insert landed carrying the stored form, so the refusal is now the hook face's only guard, as the ruling's deletion intends.
    • Green, unchanged: every write and hook-binding pin (stored-metadata-body-writes.test.ts, stored-metadata-body-boundary.test.ts, stored-metadata-body-boundary.pin.test.ts), the reader-seam unit file, the handler ② / ③ cases and the platform-reader controls.
  • Restore: blob after restore == HEAD blob (76eabe5afe1a), git diff HEAD empty, git status --porcelain empty.
  • Second leg (this round), the subject-record refusal.
    • Mutation: on packages/runtime/src/sandbox/body-runner.ts at committed 5e8fd37d78. Anchor if (subjectRefusal) throw subjectRefusal;, hit ×1 → ×0. Replaced by if (false && subjectRefusal) throw subjectRefusal;, ×0 → ×1.
    • Blob: 522b737bc800 → 3eb2d928aba3.
    • Expected direction: red. Observed: red, 7 failed and 114 passed over the same six files.
    • Red: the 5 unit subject-record refusal cases, and 2 integration cases (the administrator's family-bound and object-less routes; the /meta-declared action).
    • Green: every control (the host handler, the ordinary record, the family-routed call with no record, the member's door-level 404), the MCP-door pins, every read-layer pin, and every write and hook-binding pin.
    • Restore: blob == HEAD (522b737bc800), git diff HEAD empty, git status --porcelain empty.

The handler contexts (A2): ruled A

Ruling 5978653398, letter A (maintainer 「同意」 on director batch #276): ruling B covers sandboxed app bodies only, and app host handlers registered with registerAction keep the projected read. The record's stated cost: the reader-context seam's projection and narrowing code stays, for host handlers. The analysis that went to the maintainer is kept below.

Question. Should the read refusal also reach an action handler's ctx.api and ctx.engine.find, the host code an app registers with registerAction? Or do those stay outside, the same context set as the write refusal?

  • A (recommended): keep them outside, the same set as the write refusal.
    • Host code holds the engine itself: it registers its handlers through ctx.ql in onEnable. A refusal on its ctx.api would declare a boundary the runtime cannot enforce.
    • The write ruling already drew this line for writes.
    • Pull is zero either way: the 8 example host handlers read no family table, and hotcrm has none.
  • B: widen the read refusal to the handler contexts.
    • This would make the rest of the seam's serve code dead and deletable: the projection, the evaluate refusals, the narrowing and probably the write-return serve.
    • It would also split host code's rules: writes allowed, reads refused. That holds unless the write ruling is reopened for host code too.

Tests

All at the final head f5d575d072: the merge of origin/main at 100f68b77f into this branch, as a merge commit, built whole (pnpm build --concurrency=2, 72 of 72 tasks, none cached).

  • Unit pins: src/stored-metadata-body-reads.test.ts, 26/26. The first round's 15 cases are unchanged. This round adds:
    • Five subject-record cases on the real QuickJS action face: a family-bound action, and an object-less action routed under each table, are refused with the envelope and never run. With no routed object, the declared family object stands in. Controls: an ordinary subject record is handed as before, and a family-routed call with no record runs.
    • Four MCP-door cases: a family-bound action is refused before any record load or dispatch, and an object-less action named under a family table does not resolve.
  • Public-door integration pin: src/stored-metadata-reader-contexts.pin.test.ts (REST /actions, the data door, /meta), on the built packages, 27/27. This round adds six cases:
    • the administrator's family-bound and object-less routes on both tables, refused 403 naming the metadata API;
    • the /meta-declared family-bound action, refused once bound;
    • the member stopped at the door with 404 RECORD_NOT_FOUND;
    • control: the host handler's subject record still served like the data door;
    • control: an ordinary subject record handed as before;
    • control: a family-routed call with no record runs.
  • Family pins together: with the write and boundary pins and the reader-seam unit file, 6 files, 121/121.
  • Full @objectstack/runtime suite: --project local 322 files, 4591 passed and 19 skipped; --project repo 3 files, 751 passed. The two runs were joined with && under one lock verdict, command-exit 0.
  • Typecheck: pnpm --filter @objectstack/runtime typecheck passes, check:test-typecheck OK.
  • Lint: full pnpm lint exits 0 with no findings.
  • Liveness evidence: pnpm --filter @objectstack/spec exec vitest run --project repo scripts/liveness/evidence.test.ts, 42/42.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack gives 71 families against merge base 100f68b77, all exit 0, with check:dual-build-cjs-loads after the full build. Reconciled with --ran: "71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN", every exit code recorded.

Acceptance notes

  • The changesets of this release now overlap. The pending reader-seam changesets (served read; evaluate refusals) and the binding-and-write changeset's "Unchanged: a body's reads" bullet describe the body context as served. This PR's changeset says it supersedes them for bodies. If they should read clean on their own, the binding-and-write changeset's bullet is the one line to amend before the release compiles. Carrier: the release seat.
  • A spec comment is incomplete. The header of packages/spec/src/kernel/stored-metadata-body-objects.ts lists what the runtime refuses for a body as binding and writing; it does not mention reading. That file is outside this lane's fence (packages/spec). Carrier: none.
  • The refusal is now the hook face's only guard. The deletion leaves nothing behind it, as the ruling intends; the ablation measured this. Both the unit pin and the integration pin go red if it is removed.
  • The base is merged. origin/main at 100f68b77f was merged into the branch as a merge commit (f5d575d072), with no rebase and no force-push. It merged cleanly. Main's change to sandbox/body-runner.ts (the job face's organization envelope) still builds its API through buildSandboxApi, so the job face keeps both body layers.
  • A family-bound action still installs and binds. Install-local, the /meta door and a lax defineStack all accept an action declared on a family table, and the body runner binds it. The refusal lands at run time, when a family row would be handed over. A binding-time refusal (the parallel of the hook-binding refusal) was not added: it could not see the object-less route, which needs no family declaration, and the run-time refusal covers both. Whether binding should also refuse is not this card's question. Carrier: none.

Generated by Claude Code

@github-actions github-actions Bot added the size/l label Oct 4, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 20 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/runtime/src/action-execution.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via getHistory (sdk, the bare tail of client method meta.getHistory, bound to GET /api/v1/meta/:type/:name/history), getItem (sdk, the bare tail of client method meta.getItem, bound to GET /api/v1/meta/:type/:name), meta.getItem (sdk, the route ledger binds it to GET /api/v1/meta/:type/:name))
  • content/docs/api/declarative-endpoints.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/api/error-catalog.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/api/wire-format.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/concepts/metadata-lifecycle.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/kernel/contracts/metadata-service.mdx (via getHistory (sdk, the bare tail of client method meta.getHistory, bound to GET /api/v1/meta/:type/:name/history), /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/permissions/capabilities.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/plugins/adding-a-metadata-type.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION), /api/v1/meta/:type/:name/history (route, a path literal in READ_PRESCRIPTION))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/protocol/kernel/metadata-service.mdx (via meta.saveItem (sdk, the route ledger binds it to PUT /api/v1/meta/:type/:name), saveItem (sdk, the bare tail of client method meta.saveItem, bound to PUT /api/v1/meta/:type/:name), /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/protocol/objectql/state-machine.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/protocol/objectui/index.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/ui/doc-pages.mdx (via getItem (sdk, the bare tail of client method meta.getItem, bound to GET /api/v1/meta/:type/:name))
  • content/docs/ui/forms.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))

⛔ 6 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/releases/v17/17-1.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/releases/v17/17-2.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/releases/v17/17-3.mdx (via deleteItem (sdk, the bare tail of client method meta.deleteItem, bound to DELETE /api/v1/meta/:type/:name), meta.deleteItem (sdk, the route ledger binds it to DELETE /api/v1/meta/:type/:name), meta.saveItem (sdk, the route ledger binds it to PUT /api/v1/meta/:type/:name), saveItem (sdk, the bare tail of client method meta.saveItem, bound to PUT /api/v1/meta/:type/:name), /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/releases/v17/17-5.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))
  • content/docs/releases/v17/17-6.mdx (via /api/v1/meta/:type/:name (route, a path literal in READ_PRESCRIPTION))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/runtime/src/action-execution.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 100f68b77fec265f0cbfd7d854f6f0d2d557f406 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c145b6f09c9bd19b4514eaf2bf6ba188c53bf492 — the merge of head f5d575d07290931aa3c648cf8932de5d7c83b4b5 into base 100f68b77fec265f0cbfd7d854f6f0d2d557f406, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c145b6f09c9bd19b4514eaf2bf6ba188c53bf492 && git checkout c145b6f09c9bd19b4514eaf2bf6ba188c53bf492
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 100f68b77fec265f0cbfd7d854f6f0d2d557f406 f5d575d07290931aa3c648cf8932de5d7c83b4b5 && git checkout -B drift-repro 100f68b77fec265f0cbfd7d854f6f0d2d557f406 && git merge --no-ff f5d575d07290931aa3c648cf8932de5d7c83b4b5

node scripts/docs-audit/affected-docs.mjs --json 100f68b77fec265f0cbfd7d854f6f0d2d557f406

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 100f68b77fec265f0cbfd7d854f6f0d2d557f406 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d5b890226c3ebbb70fbc01ab1980e59c44142b50
Local-runs: none

PR #21660 (card #21594, ruling 5974479930, letter B). Net diff against main from merge-base 15fe567c9c: 9 files, +640 / -186; the PR's file list and the 3-dot stat agree. Inputs: the card body and every comment, the PR body, file list and patches, the check-runs on the head, and git show of the precedent merges (#21513 abe8f289e8, #21539 2f837a5695, #21563 bd70706713) as background. Nothing built, run or re-run. Classes, doors and roles only.

Gates on the head. Every check-run is completed. The seven required contexts conclude success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Check Changeset, "Part-of PR must not also close its card" and "The card this PR closes must claim this branch" conclude success on their latest run, the one after the seat's Fixes edit. The latest Auto Label and Check PR Size runs are skipped (advisory). No path in the file list is a GOVERNED_SURFACES row.

① Derived judgments

Each accept-set and public-surface change the diff implies, judged against the head's source (the seam, sandbox/body-runner.ts, sandbox/quickjs-runner.ts, action-execution.ts, domains/actions.ts, app-artifact-handlers.ts):

  1. A sandboxed body's read of a family table is refused on every face, every verb, every derived context, before the query is read — RIGHT. buildSandboxApi is now the unchanged write layer over the new read layer over the source, and it is the api: of all three faces: the hook context, the action context and the job context (the job face is wired by scheduleAppArtifactJobs). The QuickJS bridge installs exactly find, findOne, count, aggregate as the read methods of ctx.api.object(...) and exactly the six write aliases as its write methods, nothing else; the read layer's set is the same four verbs, and the write layer refuses every function not in that set, so on a family table every verb a body can spell is refused. The refusing closure takes no arguments, so no filter, sort, grouping, search or projection is read; the ten-shape unit pin shows one answer per verb. The shared walk wraps object(), sudo(), withRunAs(), the transaction(fn) callback's context and beginTransaction().ctx; the VM's ctx.api.transaction sugar opens through beginTransaction on the body API, and the bridge resolves the repository at call time from the tx-scoped context or the body API, both walked. The related-title accessor (ctx.title(field)) reads through that same channel, so a lookup targeting a family table is refused too — derived from the bridge's code, not pinned by this diff.
  2. A body's evaluate shapes and default search move from the door's INVALID_FIELD / 400 to the boundary's PERMISSION_DENIED / 403 — RIGHT. A refusal that never reads the query is the stronger answer and no oracle; the integration pin re-pins all seven shapes on both roles.
  3. A hook body that reads the family now fails the write that fired it, for administrator and member, and nothing lands — RIGHT (the ruling's ③: loud, naming the metadata API).
  4. The deletion — RIGHT, and it removes nothing a live reader still needs. The only code dead for bodies was the serveStoredMetadataReadsThrough wrap in buildSandboxApi; it is gone. serveStoredMetadataReadsThrough keeps both call sites in buildActionApi, serveStoredMetadataRead keeps its call in buildActionEngineFacade, and the seam's projection, evaluate refusals, narrowing and write-return serve stay live for those host-handler contexts; packages/metadata-protocol is untouched. Consequence, measured by the dev's ablation and pinned: the hook face's ctx.api is the engine's raw context under one guard now, the read layer; with it ablated the stored form reaches a hook body. That is what the ruling's "not kept beside the refusal" buys, and 18 pins (10 unit, 8 integration) go red if the guard goes.
  5. [Decision] security(runtime): may an app-authored body touch the stored-metadata family's tables at all — a hook bound to them, or an elevated body writing them directly (#21454 items 3 and 4) #21520's write and hook-binding refusals are unchanged in behaviour — RIGHT. refuseBodyRepositoryWrites is byte-identical between main and the head; storedMetadataBodyHookBindingRefusal and storedMetadataBodyWriteRefusal are byte-identical; the module-private refusal() constructor gained a defaulted fourth parameter whose default is the previous constant, so both older callers' messages are unchanged; the binding consult in hookBodyRunnerFactory is unchanged. Comments only.
  6. Platform readers, the generic data door and the metadata API are unaffected — RIGHT. The refusal lives in the sandbox API layer only; the diff touches no engine, door or metadata-protocol code; the new controls pin the metadata API serving the item projected and the engine's own read still answering the stored form.
  7. The host-handler contexts (buildActionApi's ctx.api, buildActionEngineFacade's ctx.engine.find) are unchanged — RIGHT as a change (served projected and keyed, the door's evaluate refusals, both roles pinned). Whether they should have changed is ③ item 1.
  8. Public surface — RIGHT: no published change. package.json exports is . only; src/index.ts re-exports neither module; the two new exports (refuseStoredMetadataBodyReads, storedMetadataBodyReadRefusal) are module-level and unreachable from the entry; no export is deleted or renamed, so the pinned-sibling question does not arise.
  9. The envelope — RIGHT. No new error code: PERMISSION_DENIED / 403 with object and operation set, a new message and a read prescription naming GET /api/v1/meta/:type/:name and its /history route; no tracker number in the runtime string.
  10. The integration pin rewrite — RIGHT. The cases replaced pinned exactly the served branch the ruling removes; the handler ② / ③ cases and the engine-handle INVALID_FIELD case are kept; expectBodyReadRefused asserts status, code, the route in the message, and no family content or family row in any form on both wire shapes; the /meta-authored body's bind wait moved from "status under 300" to "not 404", the right predicate once the bound body's answer is a refusal.
  11. scripts/engine-double-contract.pinned.json, one row for the new double's findOne — RIGHT (tool-written; the gate that reads it runs inside the green Lint & Repo Gates).
  12. The doc-comment edits in the seam header, buildActionApi, buildSandboxApi and the boundary header — RIGHT: each now states the layering as the code has it.

Nothing judged WRONG.

② Semver level

  • Changeset .changeset/21594-body-family-read-refusal.md: @objectstack/runtime: minor, BREAKING, exactly one ADR-0087 marker (not-required (no-migration-prescription)), the route stated as the metadata API's read routes, the superseded entries of this release named, the host-handler contexts and the platform readers named unchanged. This is PR fix(runtime)!: refuse the stored-metadata family evaluate shapes and serve write returns at the reader-context seams #21539's and PR fix(runtime)!: an app-authored body may not bind a hook to, or write, the stored-metadata tables (#21520) #21563's shape, as the ruling sets it ("!, Clause-②: yes (narrowing), the ADR-0087 marker, minor"), and it matches what the diff publishes: one released package changes behaviour, nothing is unpublished, no export or authorable key moves. Check Changeset is green.
  • Clause-②: line: Clause-②: yes (narrowing) on the PR body and in the changeset body; the title carries !. (narrowing) is BREAKING and yes takes at least minor — both hold. Not skip-changeset: a released package's behaviour changes.
  • The "FROM → TO" line written as "The route:" is the shape the registration gate accepts beside not-required, and the shape both precedent changesets use — accepted.

③ Boundary flags

Escalated (to the maintainer, through the seat); not a verdict item:

  1. A2 — the host-handler contexts left served. I judge the seat's A disposition is NOT clearly within the ruling's literal scope; escalate. Governing text for the seat's reading: ruling 5974479930 — "an app-authored body may not READ … With [Decision] security(runtime): may an app-authored body touch the stored-metadata family's tables at all — a hook bound to them, or an elevated body writing them directly (#21454 items 3 and 4) #21520's A … for app-authored bodies, the family is reached through the metadata API only"; "app-authored body" is [Decision] security(runtime): may an app-authored body touch the stored-metadata family's tables at all — a hook bound to them, or an elevated body writing them directly (#21454 items 3 and 4) #21520's term, whose refusal is applied in buildSandboxApi only and whose own pin keeps a host handler's ctx.api outside; and the parameter reads "the now-dead … code for bodies is deleted". Governing text against it: the same ruling names the refusal point as "the reader-context seam (serveStoredMetadataReadsThrough)", which serves three author contexts (the card's measured section lists the handler's scoped API and engine handle beside the body's object API), and the PR refuses in a new layer beneath that seam while the seam goes on serving; the ruling's four-axis record takes B on 「删除读侧遮蔽/判定特例」 and 「删掉一套长期维护的接缝代码」, and its "Not taken: A" names the read-side special case "that every new query shape has to be judged against again" as the cost — under the seat's reading that special case stays, as a maintained one for host code (the dev's own option-A text says so); and the card's question is framed over 「应用代码」, which the eight example host handlers are. The dev's technical argument for A (host code holds the engine it registered on, so a refusal on its ctx.api would declare a boundary the runtime cannot enforce, Prime Directive 10) is sound and is the maintainer's to weigh. Either reading keeps this diff: the wider one adds a refusal at the seam and deletes its serve code in a follow-up; it reverses nothing here. If the answer is "widen", the PR's Fixes #21594 (the seat's edit) should become Part of, or the remainder takes a new card.
  2. A door-side family read a body can still receive, outside ctx.api — not closed by this diff; the seat routes or files it. An action body's ctx.record is pre-fetched by the shared /actions and run_action doors through the generic data door's get (loadActionSubjectRecord), so an action declared on a family object and invoked with a recordId would hand its body the door-served form of a family row. Served projected and keyed, so no stored credential and no stored hash leaks; but under B a body is served nothing of the family. No registration-time guard refuses an action bound to a family object ([Decision] security(runtime): may an app-authored body touch the stored-metadata family's tables at all — a hook bound to them, or an elevated body writing them directly (#21454 items 3 and 4) #21520's binding refusal covers hooks only). Not measured here whether such a binding is reachable through the app manifest or the /meta door. Outside the brief's posture item 1 (the API's verbs and contexts, which this diff closes); a reader's question for the seat, not this record's verdict.

Held by the seat as a landing precondition (noted, not measured here):

  1. The census's cloud leg is NOT MEASURED. examples and hotcrm measured 0 readers; objectstack-ai/cloud is unreachable from the seat's container and is with the maintainer. The PR is draft and stays so until that leg is answered; a real reader found there goes back to the maintainer before the refusal lands.

Dev flags, each answered:

  1. Part of changed to Fixes by the seat: tied to flag 1 above; answered there.
  2. Changeset "FROM → TO" written as "The route:": gate-constrained, precedent shape — accepted (②).
  3. scripts/engine-double-contract.pinned.json outside the dispatch's file surface: one tool-written row the gate requires — accepted.
  4. [finding] [security] An action/automation body's object API and an action handler's engine handle read the stored-metadata family outside its body projection and keyed serve (reach NOT MEASURED) #21454's integration pin edited in place: accepted (① item 10).
  5. [Decision] security(runtime): may an app-authored body touch the stored-metadata family's tables at all — a hook bound to them, or an elevated body writing them directly (#21454 items 3 and 4) #21520's code unchanged, comments only: verified byte-identical — accepted (① item 5).
  6. The full runtime suite ran on 9c87884191, not the final head: superseded by the head's green Test Core.
  7. No merge of main: since the merge-base main moved only on sibling .changeset/ files, none of this PR's paths — accepted; the queue rebuilds on main.
  8. Model-free commit trailers: every branch commit carries the Claude-Session: pair with no model identifier — accepted.

Out-of-scope findings, carriers as the dev and the seat named them:

  1. The release's three pending stored-metadata changesets now overlap for bodies; this changeset says it supersedes them. Carrier: the release seat, before the release compiles.
  2. The packages/spec/src/kernel/stored-metadata-body-objects.ts header omits "read": incomplete, not false; outside this lane. Carrier: the seat's pointer to the domain:spec seat at landing.
  3. The hook face has one guard now (① item 4): measured, pinned, by the ruling's design.

Implemented-by: claude/issue-21594-body-read-refusal
Reviewed-by: session_016GiHYRmLSNWTfbX9gVQkpz

VERDICT: PASS


Generated by Claude Code

claude added 2 commits October 4, 2026 10:19
…ts subject record (wip)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ith host and ordinary controls (wip)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
@github-actions github-actions Bot added size/xl and removed size/l labels Oct 4, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f5d575d07290931aa3c648cf8932de5d7c83b4b5
Local-runs: none

PR #21660 (card #21594; ruling B 5974479930, ruling A 5978653398), round 2 on this head. Net diff against main from merge-base 100f68b77f: 9 files, +935 / -183; the PR's file list and the 3-dot stat agree. Inputs: the card body and every comment, the PR body, file list and net diff, and the check-runs on the head. Nothing built, run or re-run. Classes, doors and roles only.

What moved since the PASS at d5b890226c. Three commits: the subject-record refusal, its pins, and a merge of origin/main at 100f68b77f as a merge commit. The merge commit's combined diff is empty (no hand-resolved hunk), and the net diff against the new base carries no hunk of main's own. Round 1's source files (the reader seam, action-execution.ts, the seam unit file, the engine-double ledger row) are unchanged since that head.

Gates on the head. Every check-run is completed. The required contexts conclude success: Lint & Repo Gates, TypeScript Type Check, Test Core (and its six shards), Dogfood Regression Gate (and its three shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard, Dogfood Verify CLI. Check Changeset, both "No other open PR" guards, "Part-of PR must not also close its card" and "The card this PR closes must claim this branch" conclude success on their latest run, the one after the seat's body edit. The latest Auto Label and Check PR Size runs are skipped (advisory, re-triggered by that edit); Console Pin Gate, Build Docs and the opt-in tarball smoke are skipped by path. No path in the file list is a GOVERNED_SURFACES row. origin/main has moved past the merge base on no PR path and on nothing under the runtime, objectql or spec-kernel trees; mergeable state is clean.

① Derived judgments

Round 1's twelve items were re-read on this head and stand: the body read layer beneath the unchanged write layer in buildSandboxApi, on every face and every derived context, refusing before the query is read; the evaluate and search shapes moving to the boundary's refusal; the hook body failing the write that fired it; the deletion of the body serve wrap and nothing else; platform readers, the data door and the metadata API unaffected; the envelope; the integration-pin rewrite; the ledger row; the comment edits. What this head adds, each judged against the head's source (the body runner, the boundary module, both action doors, the engine's action dispatch and its scoped repository, the bundle and install-local binders):

  1. The subject-record refusal sits at the right seam — RIGHT. actionBodyRunnerFactory's bound handler is the one choke point both binders reach (the bundle walk and the install-local binder register through it; the engine's default action runner for the metadata door's re-sync is the same factory), and it is the only point that knows the handler is a body: the doors dispatch body and host handlers alike. It is consulted before the sandbox context is built, so a refused row never reaches the bridge. A host handler registered with registerAction never passes through it.
  2. The subject is read right — RIGHT. Both doors assemble the context's params as the caller's bag followed by the door's recordId and objectName, so the routed object is the door's and a caller cannot override it. The handler-key rotation tries the routed object, then the object-less key, so an object-less body IS reachable under a family route and the routed object is the subject there. Absent a routed object, the declared object stands in: the engine's ScopedRepo.execute spreads the caller's bag into the context with no params key, so that fallback is the path host code's execute takes.
  3. "Handed" is read right — RIGHT. A record id, or a non-empty record. The door's subject load stamps an id onto an empty record only when a record id was given, and a denied load is already the shared 404 at both doors before dispatch (refuseDeniedSubjectLoad), so a body never meets a stamped-but-unread row. A family-routed call with neither hands the body nothing and runs; pinned.
  4. Every door the round-2 report measured is closed, or was already stopped — RIGHT. REST /actions as the administrator: an action declared on either table, an object-less action addressed under either table, and a family-bound action declared through the /meta door, each 403 at the wire with no family row in any form, pinned on the composed kernel. A member: stopped at the door's own subject load (404), unchanged. MCP run_action: refuses an action on any sys_ object before the subject load, and resolves an object-less action only under its own key; pinned at unit level on the door's own function. Install-local and boot: both bind through the same factory, so the same bound handler answers. The engine's execute path from host code: a family-declared action is refused through the declared-object fallback (unit pin); an object-less body handed a family row by host code through its own call bag is host code's hand-over of a row it already holds, which the runtime cannot refuse without refusing host code itself; outside the boundary by ruling A's reasoning, and not a door.
  5. Unchanged and pinned — RIGHT. A host handler addressed under a family table is still handed the row the data door serves; an ordinary object's subject record is handed to a body as before; a family-routed record-less call runs.
  6. Ruling A is applied as ruled — RIGHT. The host-handler contexts are untouched: buildActionApi's serve call sites and buildActionEngineFacade carry no code hunk (comment only), the seam's projection, evaluate refusals, default-search narrowing and write-return serve stay live for them, and the subject-record refusal lives inside the body factory alone. The PR's first line stays Fixes #21594, as the ruling's execution section sets it.
  7. The merge of main did not disturb the job face — RIGHT. Main's organization envelope for the job face is carried verbatim; buildJobSandboxContext still builds its API through buildSandboxApi, so the job face keeps both body layers; the combined diff of the merge is empty.
  8. [Decision] security(runtime): may an app-authored body touch the stored-metadata family's tables at all — a hook bound to them, or an elevated body writing them directly (#21454 items 3 and 4) #21520's write and hook-binding refusals are byte-unchanged — RIGHT. The hook-binding refusal, the write refusal, the write layer and its repository wrapper hash identical between main and the head; the binding consult in hookBodyRunnerFactory has no hunk; the module-private constructor's defaulted prescription keeps both older messages unchanged.
  9. Public surface — RIGHT, stated more precisely than round 1. actionBodyRunnerFactory and hookBodyRunnerFactory ARE package-entry exports, so the narrowing of the handlers they bind is a published behaviour change; it is what the BREAKING narrowing changeset declares. The three new module exports (the read refusal, the subject-record refusal, the read layer) are not on the entry; nothing is deleted or renamed, so the pinned-sibling question does not arise.
  10. The envelope — RIGHT. The boundary's own PERMISSION_DENIED / 403 with object set and operation record, the read prescription naming the metadata API's read routes, no new code, no tracker number in the runtime string.
  11. The pins — RIGHT. The unit cases on the real QuickJS action face assert refused-and-never-ran (the body's marker write is absent), with controls for the ordinary record and the record-less family route; the door pins assert status, code, the route in the message and no family row in any form on both wire shapes; the /meta-declared action's bind wait uses the right predicate (not 404) once the bound body's answer is a refusal.
  12. The changeset's subject-record bullet and the "unchanged" set naming a host handler's subject record — RIGHT.
  13. The boundary header's new bullet and the body runner's doc comment — RIGHT: each states the layering and the seam as the code has it.

Nothing judged WRONG.

② Semver level

  • Changeset .changeset/21594-body-family-read-refusal.md: @objectstack/runtime: minor, BREAKING, exactly one ADR-0087 marker (not-required (no-migration-prescription)), the route stated as the metadata API's read routes, the superseded entries of this release named, the unchanged set named (host handlers' three contexts, the binding and write refusals, platform readers, the data door and the metadata API, every other object). It matches what the diff publishes: one released package's behaviour narrows (the handlers two entry exports bind), nothing is unpublished, no export or authorable key moves. Check Changeset is green on the head. This is PR fix(runtime)!: refuse the stored-metadata family evaluate shapes and serve write returns at the reader-context seams #21539's shape, as ruling B sets it.
  • Clause-②: line: Clause-②: yes (narrowing) on the PR body and in the changeset body; the title carries !. (narrowing) is BREAKING and yes takes at least minor: both hold. Not skip-changeset.
  • The "FROM → TO" line written as "The route:" is the shape the registration gate accepts beside not-required, and both precedent changesets use it: accepted, as in round 1.

③ Boundary flags

The earlier review's flags, each closed:

  1. Flag 1 (A2, the host-handler contexts): ruled A by the maintainer (5978653398, 「同意」 on director batch 🔗 Broken links detected in documentation #276). Applied as ruled (① item 18). The ruling's stated cost, the seam's serve code kept for host handlers, is what the diff shows. Fixes #21594 stands with it.
  2. Flag 2 (the subject record): measured first, at the doors, found reachable on REST /actions as the administrator, and refused in this card (① items 13 to 17). Closed on every door the round-2 report measured.
  3. Flag 3 (the census's cloud leg): answered by triage's reading 5976321402 (zero app-authored readers in cloud, bodies and host handlers alike), cited by ruling A's record. A reading, not a ruling; with zero readers there is nothing to put to the maintainer, which is what ruling B's stop condition asks. The landing precondition is met.

Dev flags (round 2), each answered:

  1. The /meta door's object-less leg NOT MEASURED (a probe defect): accepted. The refusal is in the bound handler whichever door bound it, and the /meta-declared family-bound leg is pinned.
  2. Install-local measured at its route handler with a recording double, boot as an AppPlugin over a bundle: accepted. Both binders call the same factory, verified at both call sites.
  3. MCP run_action pinned at unit level, the composed kernel listing no standalone action: accepted. The door's refusal and its resolution live in the function the pin drives.
  4. One pin beyond the dispatch's list (the MCP-door cases): accepted.
  5. The PR body replaced by the seat (v3): the live body records ruling A, flag 2's measurement and the closing first line; accepted.
  6. origin/main moved after the merge, no second merge: accepted. No commit since the merge base touches a PR path, or any runtime, objectql or spec-kernel path; the queue rebuilds on main.
  7. Model-free commit trailers on the three new commits: verified.
  8. open_questions: none in the round-2 report.

Out-of-scope findings, carriers as named:

  1. A family-bound action still installs, binds and is dead at run time (it runs only record-less, handed nothing). A binding-time refusal would make the dead declaration loud at authoring, and is a new refusal on the binding doors and the spec surface that needs its own ruling. Not a leak: nothing is served. Carrier: none; it comes back with a measured pull.
  2. The release's overlapping stored-metadata changesets: the release seat, before the release compiles.
  3. The spec kernel header that omits "read": incomplete, not false; the seat's pointer to the domain:spec seat at landing (round 1).
  4. The docs-drift advisory lists hand-written pages by the route literal in the read prescription; the dev's grep found no page stating a body may read the family. Advisory; no edit owed here.

Implemented-by: claude/issue-21594-body-read-refusal
Reviewed-by: session_016GiHYRmLSNWTfbX9gVQkpz

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 4, 2026 11:07
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 4, 2026 11:07
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 316be32 Oct 4, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21594-body-read-refusal branch October 4, 2026 11:34
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 tests tooling

Projects

None yet

2 participants