fix(runtime)!: an app-authored body may not read the stored-metadata tables; it reaches them through the metadata API only (#21594) - #21660
Conversation
…tables (wip) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
… every door (wip) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ngine's own predicate (wip) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: PR #21660 (card #21594, ruling Gates on the head. Every check-run is ① Derived judgmentsEach accept-set and public-surface change the diff implies, judged against the head's source (the seam,
Nothing judged WRONG. ② Semver level
③ Boundary flagsEscalated (to the maintainer, through the seat); not a verdict item:
Held by the seat as a landing precondition (noted, not measured here):
Dev flags, each answered:
Out-of-scope findings, carriers as the dev and the seat named them:
Implemented-by: VERDICT: PASS Generated by Claude Code |
…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
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Contract reviewServed-tier: PR #21660 (card #21594; ruling B What moved since the PASS at Gates on the head. Every check-run is ① Derived judgmentsRound 1's twelve items were re-read on this head and stand: the body read layer beneath the unchanged write layer in
Nothing judged WRONG. ② Semver level
③ Boundary flagsThe earlier review's flags, each closed:
Dev flags (round 2), each answered:
Out-of-scope findings, carriers as named:
Implemented-by: VERDICT: PASS Generated by Claude Code |
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'sPERMISSION_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 (record5974479930).An action body is not handed a family row either. The
/actionsdoor 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 asctx.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 withregisterActionkeep the projected read. This PR leaves them exactly as they were: theirctx.api,ctx.engine.findand 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 inobjectstack-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. AddsstoredMetadataBodyReadRefusal(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.../historyfor 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(), atransaction(fn)callback andbeginTransaction(). 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.buildSandboxApiis the one place every body face gets its API. It is nowrefuseStoredMetadataBodyWrites(refuseStoredMetadataBodyReads(source)).stored-metadata-body-boundary.tsaddsstoredMetadataBodySubjectRecordRefusal(object, action). It uses the same envelope (PERMISSION_DENIED/ 403, operationrecord) and the same read prescription.sandbox/body-runner.tsconsults it first inactionBodyRunnerFactory'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.serveStoredMetadataReadsThroughno longer wraps a body's API inbuildSandboxApi. 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/runtimeminor, 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, whosefindOneroutes 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
examples/**at15fe567c9c:app-showcasechangelog (2) and one code comment inapp-showcase/src/system/connectors/index.ts.SystemObjectName.METADATA,STORED_METADATA_BODY_OBJECTSandisStoredMetadataBodyObjecthave 0 hits. A non-literal.object(...)argument has 0 hits..object(...)targets: fourshowcase_*objects.bind-position-sets.ts×2,seed-approval-demo.ts). Each is called only with non-family objects.body:key; 5 touchctx.api,ctx.engineorregisterAction.4054ec2680(a public shallow clone, read only, 924 tracked files):src/**orapps/**..object(...)reads: 7, in hook bodies. Each is bound to a local list ofcrm_*objects or to the hook's own object.crm_*or non-familysys_*objects.registerActionappears in a comment only.objectstack-ai/cloud): NOT MEASURED from this container (private, unreachable). Answered by triage's reading5976321402, as the ruling record5978653398cites: 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 asctx.record; the probe recorded classes (keys present, body column type, hash form), never values.defineStack/os validate. A strictdefineStack(the default) refuses an action whoseobjectNameis 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 validateanswersvalid: true, exit 0, with placement warnings only. An object-less body action is accepted in both modes. Measured withbin/run-dev.js validate --json.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:…andglobal:…, through the runtime's one binder. Handed a door-shaped context carrying a family row, each body received it. Measured in@objectstack/cloud-connectionagainst the built runtime./metaaction save door.PUT /meta/action/:namewithobjectNameset to a family table answers 200. Once bound,POST /actions/sys_metadata/:name/:idhanded its body the row.AppPluginover a JSON bundle, the compositionos start --artifactbuilds). Both actions bind./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./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.run_action. Not reached. The door refuses an action on anysys_*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.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:/actions, administrator:403 PERMISSION_DENIEDnaming the metadata API, for every family case (both tables, the family-bound action, the object-less route, the/meta-declared action).404at the door.PERMISSION_DENIED/ 403.A2. Which seam contexts carry app-authored code
buildSandboxApi)./actions, MCPrun_actionand an engineexecute; job bodies on the job scheduler.ctx.engine.findand ③ctx.apiof an action handler (buildActionEngineFacade,buildActionApi)./actions(domains/actions.ts) and MCPrun_action(action-execution.ts).packages/**, the onlyregisterActioncallers are the two body runners (the objectql metadata-service bind andapp-artifact-handlers.ts). Their handlers are sandboxed bodies, which never see ② and get ③ only as the source the body layers wrap.examples/app-todoregisters 8 host handlers from itsonEnable(ctx)throughctx.ql; hotcrm registers none.5978653398). Left served, unchanged.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:A3. The envelope
PERMISSION_DENIED/ 403, withobjectandoperationset. There is no new error code.find,findOne,count,aggregate). Search is a query shape on a read, and is refused with it.A4. The deletion and the ledgers
refuseStoredMetadataBodyReads,storedMetadataBodyReadRefusal); neither is on the package entry.packages/spec/liveness/**, every*.ledger.*file,scripts/engine-double-contract.pinned.json,content/docsanddocs. One hit: a liveness note inhook.jsonthat citesbuildSandboxApireadingctx.apifrom 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.git grepover hand-writtencontent/docsand publishedskills/found no page saying a body can read the family's tables. Zero hits, so nothing was touched.A5. Reverse verification (ablation)
scripts/ablation-replace.mjsin WRAP mode onpackages/runtime/src/stored-metadata-reader-seam.ts, from the committed state (9c87884191).if (BODY_FAMILY_READS.has(prop)) {, hit ×1 → ×0. Replaced byif (false && BODY_FAMILY_READS.has(prop)) {, ×0 → ×1.76eabe5afe1a→c56dca3ae3e6.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.76eabe5afe1a),git diff HEADempty,git status --porcelainempty.packages/runtime/src/sandbox/body-runner.tsat committed5e8fd37d78. Anchorif (subjectRefusal) throw subjectRefusal;, hit ×1 → ×0. Replaced byif (false && subjectRefusal) throw subjectRefusal;, ×0 → ×1.522b737bc800→3eb2d928aba3./meta-declared action).522b737bc800),git diff HEADempty,git status --porcelainempty.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 withregisterActionkeep 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.apiandctx.engine.find, the host code an app registers withregisterAction? Or do those stay outside, the same context set as the write refusal?ctx.qlinonEnable. A refusal on itsctx.apiwould declare a boundary the runtime cannot enforce.Tests
All at the final head
f5d575d072: the merge oforigin/mainat100f68b77finto this branch, as a merge commit, built whole (pnpm build --concurrency=2, 72 of 72 tasks, none cached).src/stored-metadata-body-reads.test.ts, 26/26. The first round's 15 cases are unchanged. This round adds: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:/meta-declared family-bound action, refused once bound;404 RECORD_NOT_FOUND;@objectstack/runtimesuite:--project local322 files, 4591 passed and 19 skipped;--project repo3 files, 751 passed. The two runs were joined with&&under one lock verdict, command-exit 0.pnpm --filter @objectstack/runtime typecheckpasses,check:test-typecheckOK.pnpm lintexits 0 with no findings.pnpm --filter @objectstack/spec exec vitest run --project repo scripts/liveness/evidence.test.ts, 42/42.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackgives 71 families against merge base100f68b77, all exit 0, withcheck:dual-build-cjs-loadsafter the full build. Reconciled with--ran: "71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN", every exit code recorded.Acceptance notes
packages/spec/src/kernel/stored-metadata-body-objects.tslists 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.origin/mainat100f68b77fwas merged into the branch as a merge commit (f5d575d072), with no rebase and no force-push. It merged cleanly. Main's change tosandbox/body-runner.ts(the job face's organization envelope) still builds its API throughbuildSandboxApi, so the job face keeps both body layers./metadoor and a laxdefineStackall 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