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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…tead of a tracker number (stage 11) (objectstack-ai#21736)

Part of objectstack-ai#20749
Clause-②: no

Stage 11 of this card, and the second area of class (e): the test
strings shipped under `packages/spec/src`, as ruled in `5902360492` on
objectstack-ai#20513. This stage takes the whole `kernel/` directory. Its 102
test-title and test-message literals carried 106 tracker ids citing 69
records. Each id now either states what its record decided, in words
(form D), or is dropped where the title already says it. Text only: no
assertion, fixture value, test count or code comment changes.

## Census at the base (`3fa850cf00`, the claim's base)

Instrument: stage 10's `census10.cjs` (md5
`9d08602ab972b4b8643c90d64d40fa41`) and stage 9's `census.cjs` (md5
`6e42a45a926d375013c32d62f16a296e`), both byte-identical to the copies
stage 10 used. A literal counts as a test title when its folded message
is argument 0 of a `describe` / `it` / `test` call, `.each` / `.skip` /
`.only` chains included. Everything else is an "other" string.

Both instruments read **1696 messages / 1807 ids in 405 files at the
base**, which is stage 10's reading at its head exactly. `kernel/` reads
102 / 106, also stage 10's figure.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `data/` | 95 | 468 / 501 | 445 / 475 | 23 / 26 |
| `ui/` | 81 | 392 / 415 | 374 / 397 | 18 / 18 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| **`kernel/`** (this PR) | 36 | **102 / 106** | 92 / 96 | 10 / 10 |
| `shared/` | 21 | 85 / 95 | 73 / 81 | 12 / 14 |
| `contracts/` | 25 | 63 / 74 | 59 / 70 | 4 / 4 |
| `conversions/` | 9 | 34 / 34 | 34 / 34 | 0 |
| `security/` | 8 | 28 / 28 | 28 / 28 | 0 |
| `ai/` | 9 | 18 / 20 | 13 / 15 | 5 / 5 |
| `identity/` | 6 | 15 / 15 | 14 / 14 | 1 / 1 |
| `integration/` | 4 | 14 / 14 | 13 / 13 | 1 / 1 |
| `migrations/` | 2 | 9 / 12 | 9 / 12 | 0 |
| `marketplace/`, `meta-spelling/`, `studio/` | 5 | 7 / 7 | 7 / 7 | 0 |
| **total** | **405** | **1696 / 1807** | **1587 / 1692** | **109 /
115** |

- **Controls.** Lit, a title:
`kernel/capability-metadata-kind.test.ts:66` reads one message with
objectstack-ai#5961. Lit, a template-literal expect message:
`kernel/cli-command-contribution-retirement.test.ts:74` reads one
message. Dark: the `// ─── [objectstack-ai#17178] …` comment at
`kernel/execution-context.test.ts:205` reads 0 (the file's messages sit
at `:72`, `:167`, `:219`, `:233` and `:237`). Planted in a scratch copy:
an id added to a title reads 1 / 1, and an id in an added comment reads
0.
- **A wider pattern** (any `#` plus digits) reads 107 / 111 under
`kernel/` at the base. The five extra hits are hex colours (`#94A3B8`,
`#0f0`) in `functional-completeness.test.ts` and one `§3.10 objectstack-ai#3` section
reference in `manifest.zod.ts`, a non-test file. None is a tracker id.
At the head the wider pattern reads only those five, and the gate
pattern reads 0 / 0.
- **At the head:** 1594 messages / 1701 ids in 369 files. `kernel/`
reads 0 / 0. Nothing else moved.

## How the area was chosen

Stage 10's rule, applied before any card was read: rank whole
first-level directories by ids, and take the busiest one within about
10% of the ~100-id bound. The four busiest each exceed the bound alone:
`data/` (501), `ui/` (415), `api/` (201) and `system/` (165). The files
directly in `src/` (120) are 20% over. `kernel/` (106) is the busiest
whole directory within the bound, and its census reads exactly stage
10's 106, so the rule needed no second pass.

**Named for the next stages:** `data/` (about five stages, by
subdirectory or file group; `data/driver/` alone is 52), `ui/` (about
four), `api/` (two), `system/` (two), the files directly in `src/` (one,
120), `shared/` (one, 95), `contracts/` with `conversions/` (one, 108),
and `security/`, `ai/`, `identity/`, `integration/`, `migrations/`,
`marketplace/`, `meta-spelling/` and `studio/` together (one, 96).

## What each id became

Of the 106 ids, 32 now state a decision in words, in 31 literals. 74 are
dropped where the title already explains them; two of those (objectstack-ai#14478) sit
in literals that also gained words for another id. Every record was read
with its comments through REST. 58 answer 200. Ten answer 404, and their
decisions were read from what landed. One is in a repository not
attached to this session.

| record | ids | result |
|:--|--:|:--|
| objectstack-ai#12007, objectstack-ai#11825, objectstack-ai#12340, objectstack-ai#4914, objectstack-ai#15932, objectstack-ai#11846, objectstack-ai#16059 | 10 of 20 |
Every expect message that read "must have zero holders after #N" or
"must not be exported after #N" now reads "after its retirement": each
record retired the names it lists. The other 10 sit in `describe` / `it`
titles that already say what was retired, and were dropped. objectstack-ai#11846
answers 404; its retirement was read from the CHANGELOG entry for
landing `0c2334f`. |
| objectstack-ai#7280 | 1 | "the ADR-0069 gate posture is a declared field":
`authGate` is declared on `ExecutionContextSchema`, not spread behind an
`as any`. |
| objectstack-ai#17178 | 1 | "SEED_WRITE_EXECUTION_CONTEXT — one spelling of the seed
posture": one exported constant replaced the private copies. |
| cloud#687 | 1 | "(the founding case: a roll-up that reads 0 forever)".
The cloud repository is not attached to this session (403). The decision
was read from ADR-0078's "Surfaced by" line and the CHANGELOG paragraph
on the founding case: a bare `{ type: 'summary' }` field read 0 forever,
and the rule now flags it as an error. |
| objectstack-ai#14192 (404) | 4 | "(the silent-drop measurement, inverted)", where
the title said "the card's measurement". Dropped from 3 titles that
state the refusal. Decision read from landing `4d0d944`:
`ManifestSchema` goes strict and refuses unknown keys inside
`manifest:`. |
| objectstack-ai#10726 (404) | 2 | "removed for the `http.server` mount,
maintainer-ruled 2026-08-22", where the title said "Option B". Read from
landing `bc56e18` and PR objectstack-ai#12417's body: Option B removes
`contributes.routes` and points authors at the imperative `http.server`
mount. Dropped once. |
| objectstack-ai#4148 | 1 | "the object/field unknown-key warnings survive the
generalization", where the title said "the objectstack-ai#4148 behaviours". |
| objectstack-ai#4001 | 3 | "(the evidence base for the strict tiers)", where the
title said "objectstack-ai#4001 evidence phase". Dropped from 2 titles that state the
pinned rule. |
| objectstack-ai#4167, objectstack-ai#8687 | 3 | "top-level stack keys (named, then refused at
parse)": objectstack-ai#4167 made an undeclared top-level key say so instead of
vanishing, and objectstack-ai#8687 ruled Shape B, a strict top level. Dropped once
more for objectstack-ai#8687. |
| objectstack-ai#15624 | 2 | "cache.ttl → deleted (the unread outer block is
retired)". Dropped once. |
| objectstack-ai#14478 | 3 | Dropped. The `→ ttlMs` / `→ timeoutMs` renames and "carry
their unit" already state the rule: the unit lives in the key name. |
| objectstack-ai#15939 | 1 | "RuntimeConfig.resourceLimits.timeout → timeoutMs (its
unit was named in JSDoc only)", where the title said "ruling A". Ruling
A renames the keys whose unit was named only in JSDoc, per file. |
| objectstack-ai#5086 | 2 | "(PUT /meta refuses the inlet)": a code-only kind's create
is refused with 403 `NOT_CREATABLE` before anything persists. |
| objectstack-ai#7743 | 1 | "UNCHANGED, the field overlay refusal stays", where the
title said "objectstack-ai#7743's overlay refusal". |
| objectstack-ai#8154 | 1 | "(the consumer contract of the per-type redaction hook)".
|
| objectstack-ai#21120 | 1 | "stored metadata ROWS — the family-wide seam every exit
routes through". This says only what the card's public summary says: one
shared seam, which every surface routes through or refuses. |
| objectstack-ai#6245 | 1 | "the bound-but-unregistered fence, pinned": schemas are
bound for those kinds WITHOUT registering them. |
| objectstack-ai#11263 | 1 | "PLATFORM_PLUGIN_WIRED_RUNTIMES — runtimes wired by
plugins[], not by a token": a sibling roster was added, and no token was
minted. |
| objectstack-ai#3366 | 1 | "classifyRequiredCapability — preflight for an installable
provider in this edition". |
| objectstack-ai#17676 | 1 | "package-registry carve-out — its persistence is
always-on core, split from `marketplace`", where the title said "ruling
A′". |
| objectstack-ai#16365 | 1 | "accepts %s, which the regex refused before the SemVer
widening", where the title said "the pre-objectstack-ai#16365 regex". The `%s` values
do not change. |
| objectstack-ai#17227 | 1 | "dashboard.header.actions stays titled — the first
carrier given item-level names". |
| dropped only (live) | 45 | objectstack-ai#3308 (2), objectstack-ai#3433, objectstack-ai#3760, objectstack-ai#3786, objectstack-ai#4212,
objectstack-ai#4509, objectstack-ai#4587, objectstack-ai#4657, objectstack-ai#4741, objectstack-ai#4834, objectstack-ai#4939, objectstack-ai#5488, objectstack-ai#5961, objectstack-ai#6881, objectstack-ai#6931,
objectstack-ai#7893, objectstack-ai#8586, objectstack-ai#10039 (2), objectstack-ai#11169, objectstack-ai#12032, objectstack-ai#12428, objectstack-ai#13613, objectstack-ai#15678 (7),
objectstack-ai#16328, objectstack-ai#16334, objectstack-ai#16449, objectstack-ai#17232 (2), objectstack-ai#17445, objectstack-ai#17780, objectstack-ai#18124 (2), objectstack-ai#18791
(3), objectstack-ai#19630, objectstack-ai#20102: each title already states the pinned decision. |
| dropped only (404) | 8 | objectstack-ai#10194 (landing `2306a76`: each bound entry
is its stack collection's schema), objectstack-ai#10338 (`d2619fd`: `target` optional,
the gate holds the flow requirement), objectstack-ai#10724 (`be21955`: the nine dead
members tombstoned), objectstack-ai#11330 (`a9ee98992`: the trust-tier text tells the
truth), objectstack-ai#11332 (`dce5cd4`: the three dead containers retired), objectstack-ai#13135
(`9e0ba21`: the paper customization protocol retired), objectstack-ai#17147 (2,
`aaacf1d5c`: the granted set is registered and refuses nothing, said
truthfully). Each title already carries what landed. |

## Readers

- **Test-name filters:** none. A tracked-tree search for `-t` and
`--testNamePattern` finds only `packages/qa/dogfood/README.md:142` (`-t
"owner-scoped"`), which is unrelated.
- **Snapshots:** none. `kernel/` has no `__snapshots__`, and no `.snap`
file is tracked under `packages/spec`.
- **Titles by substring:** every old title, plus a window around each id
(256 needles), was searched across the tracked tree outside its own
file. No gate, doc or script matches one. At the head, 8 hits remain:
three sibling titles in other lanes' or stages' files
(`metadata-protocol/src/protocol.capability-write-door.test.ts:183`,
`spec/src/api/contract.test.ts:813`,
`spec/src/system/auth-config.test.ts:520`), three released
`packages/spec/CHANGELOG.md` entries, and one code comment in
`kernel/metadata-authoring-lint.ts:268`, which belongs to the comment
lane.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each string leaf that changed must sit in a test-call
title position, or on one of the 10 declared lines. Those are the expect
messages at `cli-command-contribution-retirement.test.ts:74` and `:86`,
`plugin-lifecycle-advanced-retirement.test.ts:103`, `:124` and `:141`,
`plugin-loading-retirement.test.ts:125`,
`plugin-security-scan-result-retirement.test.ts:123`,
`preview-mode-retirement.test.ts:193` and
`startup-orchestrator-retirement.test.ts:85` and `:106`. Each changed
leaf must carry a tracker id before and no `#` plus digits after.

- **Result:** 36 of 36 files SAME, 102 changed (92 title, 10 declared),
on all three legs.
- **Diff hunks:** exactly the 102 planned lines, with every file keeping
its line count.
- **Controls (10 of 10 as predicted, on scratch copies, each anchor hit
once):** identifier rename DIFF; numeric literal DIFF; comment edit
COMMENT DIFF; a non-title string with an id VIOLATION; a rewritten title
given a new id VIOLATION; a title that was id-free at base edited
VIOLATION; one title reverted to base SAME; a declared string keeping an
id VIOLATION; an undeclared expect message changed VIOLATION; a title
re-split into a `+` chain DIFF.

**Test counts:** the 36 files were run at the base (in a separate base
worktree) and at the head: 841 / 841 tests on both sides, with the same
count and status sequence per file in 36 of 36. 388 full test names
change, and each equals the base name with the planned replacements
applied.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files under
`files[]`. 0 of the 36 touched files are in it, and 0 `*.test.ts` at
all. The controls `src/kernel/manifest.zod.ts` and
`src/kernel/execution-context.zod.ts` are in it.
- In `dist/`, three new phrases and three old ones each read in 0 files.
The control `Plugin compatibility ranges (ADR-0025` reads in 20.

So this PR publishes nothing, and no changeset is added.

## Verification (at `e0ad8f50af`)

- `pnpm turbo run build` over all packages: 71 / 71 (at the first
commit), then `@objectstack/spec` rebuilt at `e0ad8f50af`.
- `@objectstack/spec`: `vitest run --project local`, 613 files and 18215
passed, 1 todo. `typecheck` exit 0, including `check:test-typecheck`,
whose program holds all 36 touched files.
- **Gates:** `dispatch-gates --commands` derived 79 families at
`e0ad8f50af`, and all 79 exit 0. `--ran` reconciles: 79 derived, 79 run,
0 NOT-MEASURED, 0 UNRUN.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 36
files, 0 errors and 0 warnings. The population comes from ESLint's own
config: 36 configured, 0 ignored. No `parserOptions.project` or
`projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 204 changed lines.

## Acceptance notes

- **Code comments still carry ids** in these 36 files and in the
`kernel/` sources, for example `execution-context.test.ts:205`,
`metadata-authoring-lint.ts:268` and `functional-completeness.ts:148`.
They are the comment lane's, untouched here.
- **A sibling title in another package** repeats `objectstack-ai#5961 — capability`
(`packages/metadata-protocol/src/protocol.capability-write-door.test.ts:183`).
It is that package's test-string stage, not this one.
- **The second commit** (`e0ad8f50af`) rewords one title from this PR's
first commit, "the one carrier already titled", which read as a
tautology, to "the first carrier given item-level names". Every proof
above was re-run at that head.
- **`origin/main` moved** three commits past the base before this PR
opened (objectstack-ai#21717, objectstack-ai#21715, objectstack-ai#21660). None touches `packages/spec`, so
nothing was merged.

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

---------

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

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants