fix(runtime)!: an app-authored body may not bind a hook to, or write, the stored-metadata tables (#21520) - #21563
Conversation
…metadata reader seam Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…narrowing, execute reach Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…erve (#21454) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…used helper) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
… seam Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ispatch predicates; record pinned coverage Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…arrowing (#21454) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…tadata table A hook body is refused at registration when its target names a table of the stored-metadata family, at hookBodyRunnerFactory: the one point every body hook passes through to become a handler, whichever door bound it (the boot artifact and an installed artifact through bindAppArtifactHandlers, and runtime-authored hooks through the engine's default runner). The refusal carries PERMISSION_DENIED / 403 and names the metadata API. A wildcard body hook still binds, and its body is never run for a family table's event. Platform hooks are code, not bodies, and are untouched. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
…tack the body write refusal on it The write refusal attaches to the stored-metadata reader-context seam, which the evaluate-refusals change holds; this branch stays a draft until that change lands on main. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
…ble at the seam 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>
…owing 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>
…evaluate refusals The seam conflict resolves to this branch's side: its seam blob before this branch's own edits equals the landed squash's byte for byte, so the merge keeps exactly the body write-refusal layer on top of what landed. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 0010704fcdcf091956df8a241f6921f7512a4bed && git checkout 0010704fcdcf091956df8a241f6921f7512a4bed
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5dbcee8a6d4f3a3feac3ab0a64e5555a1f45cf7c 9a95e459d1439e6ae25823f3a2c19175642e6562 && git checkout -B drift-repro 5dbcee8a6d4f3a3feac3ab0a64e5555a1f45cf7c && git merge --no-ff 9a95e459d1439e6ae25823f3a2c19175642e6562
node scripts/docs-audit/affected-docs.mjs --json 5dbcee8a6d4f3a3feac3ab0a64e5555a1f45cf7c
|
Contract reviewServed-tier: Read on GitHub 2026-10-03T08:41Z, rendered by an isolated subagent of the Shape. Draft, base ① Derived judgmentsEach accept-set or surface change the diff implies, judged against ruling A (
② Semver level
③ Boundary flagsDev deviations (os-dev report
Open questions: one, answered A above. Out-of-scope findings: the metadata save door accepting a hook the runtime then refuses at bind is filed as #21565 by the seat; the Escalated to the seat, neither a defect in this diff:
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #21520
Clause-②: yes (narrowing)
Executes ruling A (record
5965059068, maintainer 「同意」): an app-authored body may not touch the stored-metadata family's tables (sys_metadata,sys_metadata_history). For an app-authored body the metadata protocol is their only writer. Two refusals, each carryingPERMISSION_DENIED/ 403 and a prescription that names the metadata API:bodywhoseobjectnames a family table is refused at registration.ctx.apiis refused before the write runs. This also closes the write verb's own predicate path, which triage5965718076carried onto this card: a refused write runs nothing, and its answer does not depend on what it names.Platform code is outside the refusals: the metadata protocol and its writers, the platform's code hooks, and host code a deployer registers.
Census of platform writers (A1), by symbol walk
Method. A TypeScript AST walk (the compiler API) over every non-test source file under
packages/*/src: 2707 files atfd5a1cd597.insert,create,update,updateById,upsert,delete,deleteById,updateMany,deleteMany, and the bulk spellings) and its object argument resolves to a family name.OVERLAY_TABLE,METADATA_HISTORY_OBJECT). The receiverX.object(name)counts the same way.Readings.
metadata-protocol(sys-metadata-repository.ts×5,protocol.ts×2,migrations/recorded-by-sentinel.ts×1),service-datasource(datasource-admin-plugin.ts×4) andplugin-security(permission-set-overlay-discard.ts×1). Every one is platform module code calling the engine directly (this.engine/engine/ql).buildSandboxApiis the only constructor of a body's API. It is reached only frombuildSandboxContext(hook bodies) andbuildActionSandboxContext(action bodies). No platform writer goes through it.packages/**orexamples/**(non-test). The onlyobject: 'sys_metadata'hits are the platform's own list views inmetadata-core, matching the ruling's census.A1's assumption measured false: the existing seam is not body-only.
serveStoredMetadataReadsThroughis applied atbuildSandboxApi(bodies). It is also applied atbuildActionApi, which is thectx.apiof a host code action handler as well as of an action body. So a throw inserveRepository's shared write branch would also refuse deployer host code. The ruling says the seam refuses bodies only, and triage5964836549put deployer host code outside the family. So the write refusal is a separate, body-only layer in the same seam file:refuseStoredMetadataBodyWriteslayers over the read seam.deriveThroughSeam) with the read seam, so there is no second walk.buildSandboxApi.serveRepository's write branch keeps serving write returns for host handlers. Its comment now says why the refusal is not attached there.The binding point (A2): one place every door shares
Every door a body hook binds through reaches
hookBodyRunnerFactory's per-hook resolver. That resolver is where abodybecomes a handler, at registration:bindAppArtifactHandlers(its explicit runner);AppPlugin. That runner is the same factory.bindAppArtifactHandlersalone would have missed the runtime-authored door. The refusal is a throw from the resolver, so the binder records it against the hook and logs it aterror, or rethrows understrict. The hook is never registered.Wildcard. A
'*'body hook names no family table, so it binds, but it admits the family's tables. Its body is therefore not run for a family table's event: a dispatch-side check in the bound handler. The bind says so once, atinfo.Platform hooks are code handlers, never bodies, so they never reach this factory. Pinned: a code hook on
sys_metadatastill binds and fires, and the metadata door's save still fires a platform code hook.Codes (A3): an existing code fits, no new ledger row
Both refusals carry
PERMISSION_DENIED/ 403, a member of the ledger'sErrorCodeunion (the standard catalog).@objectstack/restregisters four generic synonyms the standard catalog already covers (CONFLICT,NOT_FOUND,FORBIDDEN,INTERNAL) — contract call, not a cleanup #8211, mechanical) sends a generic permission condition to the standard member rather than to a registered synonym.So this is not
PENDING LEDGER CODE, and nothing underpackages/specis edited.Reach first (A5), measured as classes before the fix
The pins below were run against the pre-fix
body-runner.ts(BASEfd5a1cd597, byte-restored to HEAD afterwards,git diff HEADempty). Every observation is a neutral marker token on a free-text column. No stored content is read.sys_metadataanswered200for the administrator and for a member.Pins
stored-metadata-body-boundary.test.ts, 8 cases, binding, on a real engine:stored-metadata-body-writes.test.ts, 10 cases, writing, on a counting double:sudo,withRunAs,transactionandbeginTransactionrefuse the same way;ctx.api) keeps its writes;stored-metadata-body-boundary.pin.test.ts, 7 cases, composed kernel, boot paid inbeforeAll:403 PERMISSION_DENIEDand land nothing, for administrator and member;Reverse verification (ablation), fix committed first, at
0d8af06c80Each leg ran through
scripts/ablation-replace.mjs: the anchor hit 1 → 0 on disk, the blob changed, and the restore was proven (blob == HEAD,git diff HEADempty). The pins resolvebody-runner.tsby relative path within the package (src), so no dist leg applies.buildSandboxApi: 4 red (both sandbox unit pins, and both composed write pins).Tests and gates, at
9a95e459d1(after mergingorigin/maince532184d1, which carries #21539's landed seam)@objectstack/runtime,--project local: Test Files 316 passed, Tests 4444 passed, 19 skipped.--project repo: 3 files, 751 passed.pnpm --filter @objectstack/runtime typecheck: exit 0.check:test-typecheckis OK with the ledger unchanged, and all three new test files are in thetsconfig.test.jsonprogram (--listFilesOnly).dispatch-gates --commands --repo objectstack-ai/objectstackwith no paths derived 62 families. All 62 were run, each exit 0, and--ranreconciled 62 derived, 62 run, 0 not-measured.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET; after a fullturbo run buildit measured 106 entries across 66 packages.pnpm lintitself is CI's run):.mdhas no matching configuration);--format json: 6 files, 0 errors, 0 warnings;eslint.config.mjsenables no type-aware linting (noparserOptions.project), and its only import rule is per-file (no-restricted-imports), so this diff cannot move the verdict on an untouched file.check:nul-bytes: OK. Control-byte self-scan of the 7 changed files: grep exit 1 (none).Acceptance notes
objectis a family table answers200at the metadata door (recorded by the composed pin). The bind then refuses it and records the refusal aterror, but nothing refuses it at save. This is reported to the seat as an authoring-trap finding; it is not fixed here (the save door and the spec are outside this card's surface).error([BodyRunner] sandboxed action threw). That is the runner's existing posture for any body that throws, and it is unchanged here.origin/mainwas merged on top. The seam file's fix(runtime)!: refuse the stored-metadata family evaluate shapes and serve write returns at the reader-context seams #21539 bytes are identical to the landed squash, so this PR's diff againstmainis exactly the 7 files listed by the gate derivation.Changeset
@objectstack/runtimeminor (the launch-window convention for accept-set narrowings), withClause-②: yes (narrowing)and the ADR-0087 dispositionnot-required (no-migration-prescription). No stored metadata shape, authorable key or export moves.Generated by Claude Code