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 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
|
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 |
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).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
buildSandboxApionly, and its own pin keeps a host code handler'sctx.apioutside. 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/cloudcannot 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. 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)).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 (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/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.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. 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
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.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.The handler contexts (A2): dispositioned A by the seat
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
d5b890226cunless stated.src/stored-metadata-body-reads.test.ts, 15/15. They cover:ctx.apistill served;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.@objectstack/runtimesuite on9c87884191:--project local319 files, 4540 passed and 19 skipped;--project repo3 files, 751 passed. Since that run, the only changes are the new unit file's double and the ledger row.pnpm --filter @objectstack/runtime typecheckpasses,check:test-typecheckOK.pnpm lintexits 0 with no findings, ond5b890226c.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, all exit 0 ond5b890226c.check:dual-build-cjs-loadswas run after a fullpnpm build. Reconciled with--ran: "71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN", with 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/mainadvanced two commits during this work (759dbe9ed3: cli and service-analytics). Neither touchespackages/runtimeor its dependency closure, so no merge was taken.Generated by Claude Code