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

Draft
objectstack-fleet[bot] wants to merge 6 commits into
mainfrom
claude/issue-21594-body-read-refusal
Draft

objectstack-fleet[bot] wants to merge 6 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).

The handler-context leg (A2) is resolved by the seat as A, inside the ruling (ACCEPT on #21594). The ruling's term "app-authored body" is #21520's term, and it is tied to #21520's scope ("With #21520's A … for app-authored bodies"). #21520's refusal is applied in buildSandboxApi only, and its own pin keeps a host code handler's ctx.api outside. So the host-handler contexts stay served, and this PR closes the card. If the maintainer wants host code covered too, that is a new ruling over host code's whole family access, not this card.

Landing is held on the census's cloud leg. objectstack-ai/cloud cannot be reached from this container, and the ruling sends any reader found there back to the maintainer before the refusal lands.

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)).
  • 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 (the open leg). 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.
  • 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. The repository is private and this container cannot reach it. The PM routes this leg to the maintainer.

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: this is the fork. 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.

The handler contexts (A2): dispositioned A by the seat

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 d5b890226c unless stated.

  • Unit pins: src/stored-metadata-body-reads.test.ts, 15/15. They cover:
    • every read verb on both tables, refused with the envelope and the read route;
    • one answer whatever the query names;
    • an ordinary table read as before;
    • every derived context;
    • the composed body API (a read gets the read refusal, a write keeps the write refusal);
    • a host handler's ctx.api still served;
    • the real QuickJS sandbox on the action face (plain, uncaught, inside a transaction), the hook face and the job face.
  • Public-door integration pin: src/stored-metadata-reader-contexts.pin.test.ts (REST /actions, the data door, /meta), on the built dependency closure (pnpm --filter '@objectstack/runtime^...' build), 21/21.
  • Family pins together: with the write and boundary pins and the reader-seam unit file, 6 files, 104/104.
  • Full @objectstack/runtime suite on 9c87884191: --project local 319 files, 4540 passed and 19 skipped; --project repo 3 files, 751 passed. Since that run, the only changes are the new unit file's double and the ledger row.
  • Typecheck: pnpm --filter @objectstack/runtime typecheck passes, check:test-typecheck OK.
  • Lint: full pnpm lint exits 0 with no findings, on d5b890226c.
  • 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, all exit 0 on d5b890226c. check:dual-build-cjs-loads was run after a full pnpm build. Reconciled with --ran: "71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN", with 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 moved. origin/main advanced two commits during this work (759dbe9ed3: cli and service-analytics). Neither touches packages/runtime or its dependency closure, so no merge was taken.

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 17 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 eea82af67779f504bd5d53fccda3151066cdeaf6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from eb6c638f7b955ae05b463efbf73e1972aff776be — the merge of head d5b890226c3ebbb70fbc01ab1980e59c44142b50 into base eea82af67779f504bd5d53fccda3151066cdeaf6, 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 eb6c638f7b955ae05b463efbf73e1972aff776be && git checkout eb6c638f7b955ae05b463efbf73e1972aff776be
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eea82af67779f504bd5d53fccda3151066cdeaf6 d5b890226c3ebbb70fbc01ab1980e59c44142b50 && git checkout -B drift-repro eea82af67779f504bd5d53fccda3151066cdeaf6 && git merge --no-ff d5b890226c3ebbb70fbc01ab1980e59c44142b50

node scripts/docs-audit/affected-docs.mjs --json eea82af67779f504bd5d53fccda3151066cdeaf6

⚠️ 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 eea82af67779f504bd5d53fccda3151066cdeaf6 → 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

This branch has not been deployed

No deployments
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/l tests tooling

Projects

None yet

2 participants