Skip to content

fix(runtime)!: an app-authored body may not bind a hook to, or write, the stored-metadata tables (#21520) - #21563

Merged
objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/issue-21520-family-body-boundary
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/issue-21520-family-body-boundary

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 carrying PERMISSION_DENIED / 403 and a prescription that names the metadata API:

  1. Binding. A hook with a sandboxed body whose object names a family table is refused at registration.
  2. Writing. A sandboxed body's write of a family table through ctx.api is refused before the write runs. This also closes the write verb's own predicate path, which triage 5965718076 carried 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 at fd5a1cd597.

  • A call counts as a family write when its callee is a write verb (insert, create, update, updateById, upsert, delete, deleteById, updateMany, deleteMany, and the bulk spellings) and its object argument resolves to a family name.
  • An object argument resolves when it is a literal, a same-file binding, or an exported constant (OVERLAY_TABLE, METADATA_HISTORY_OBJECT). The receiver X.object(name) counts the same way.
  • Write calls with a non-literal object argument, in files that name the family, are listed separately so a dynamic writer is not hidden.

Readings.

  • 13 resolved family writes: metadata-protocol (sys-metadata-repository.ts ×5, protocol.ts ×2, migrations/recorded-by-sentinel.ts ×1), service-datasource (datasource-admin-plugin.ts ×4) and plugin-security (permission-set-overlay-discard.ts ×1). Every one is platform module code calling the engine directly (this.engine / engine / ql).
  • 112 unresolved write calls across 18 family-naming files. All are module code on an engine or driver receiver.
  • buildSandboxApi is the only constructor of a body's API. It is reached only from buildSandboxContext (hook bodies) and buildActionSandboxContext (action bodies). No platform writer goes through it.
  • Zero shipped hook or action bodies name a family table in packages/** or examples/** (non-test). The only object: 'sys_metadata' hits are the platform's own list views in metadata-core, matching the ruling's census.

A1's assumption measured false: the existing seam is not body-only. serveStoredMetadataReadsThrough is applied at buildSandboxApi (bodies). It is also applied at buildActionApi, which is the ctx.api of a host code action handler as well as of an action body. So a throw in serveRepository's shared write branch would also refuse deployer host code. The ruling says the seam refuses bodies only, and triage 5964836549 put deployer host code outside the family. So the write refusal is a separate, body-only layer in the same seam file:

  • refuseStoredMetadataBodyWrites layers over the read seam.
  • It shares one derived-context walk (deriveThroughSeam) with the read seam, so there is no second walk.
  • It is applied only at 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 a body becomes a handler, at registration:

  • the boot artifact and an installed artifact, install and rehydrate, through bindAppArtifactHandlers (its explicit runner);
  • runtime-authored hooks, through ObjectQLPlugin's metadata-service bind (boot sync and resync), which use the engine's default body runner installed by AppPlugin. That runner is the same factory.

bindAppArtifactHandlers alone 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 at error, or rethrows under strict. 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, at info.

Platform hooks are code handlers, never bodies, so they never reach this factory. Pinned: a code hook on sys_metadata still 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's ErrorCode union (the standard catalog).

So this is not PENDING LEDGER CODE, and nothing under packages/spec is edited.

Reach first (A5), measured as classes before the fix

The pins below were run against the pre-fix body-runner.ts (BASE fd5a1cd597, byte-restored to HEAD afterwards, git diff HEAD empty). Every observation is a neutral marker token on a free-text column. No stored content is read.

  • Binding (unit tier, real ObjectQL + QuickJS): a body hook targeting each family table bound and ran on that table's write event, through all three doors (artifact binder, runtime-authored default runner, wildcard). Result: 6 red, 2 controls green.
  • Composed kernel:
    • The metadata door's own save ran an explicit family body hook, the wildcard body hook and a runtime-authored family body hook (all three markers present on the saved row).
    • An elevated action body's insert into sys_metadata answered 200 for the administrator and for a member.
    • Result: 4 red, 3 controls green.

Pins

  • stored-metadata-body-boundary.test.ts, 8 cases, binding, on a real engine:
    • refused at registration for each family table, string and list forms, with code, status and the metadata-API prescription;
    • refused and recorded through the artifact binder and through the runtime-authored default runner;
    • thrown under strict binding;
    • a wildcard binds and never runs on a family table;
    • controls: an ordinary hook binds, and a code hook on a family table binds.
  • stored-metadata-body-writes.test.ts, 10 cases, writing, on a counting double:
    • every write verb on each family table is refused before it reaches the store;
    • a predicate write answers identically whatever its predicate names, and runs nothing;
    • reads pass to the read seam;
    • controls: ordinary tables write;
    • sudo, withRunAs, transaction and beginTransaction refuse the same way;
    • the layer is idempotent and transparent to the read seam's marker;
    • the read seam alone (a host handler's ctx.api) keeps its writes;
    • through the real QuickJS sandbox, an action body and a hook body on an ordinary table are both refused.
  • stored-metadata-body-boundary.pin.test.ts, 7 cases, composed kernel, boot paid in beforeAll:
    • the metadata door's save runs no body bound to a family table, and still fires the platform code hook;
    • controls: ordinary-table hooks fire, the wildcard among them;
    • a runtime-authored family hook is not bound, while a runtime-authored ordinary hook binds;
    • an action body's family writes (an insert, a predicate update, a history insert) answer 403 PERMISSION_DENIED and land nothing, for administrator and member;
    • control: the same body's ordinary write lands.

Reverse verification (ablation), fix committed first, at 0d8af06c80

Each 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 HEAD empty). The pins resolve body-runner.ts by relative path within the package (src), so no dist leg applies.

  • A1, registration throw disabled: 5 red (the 5 refusal and record pins), 20 green. The composed "no body ran" pin stayed green because the dispatch-side check still stops the body: defence in depth, observed as expected.
  • A2, dispatch-side check disabled: 2 red (the wildcard unit pin, and the composed save pin with the wildcard marker present).
  • B, the body write layer removed from buildSandboxApi: 4 red (both sandbox unit pins, and both composed write pins).

Tests and gates, at 9a95e459d1 (after merging origin/main ce532184d1, 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-typecheck is OK with the ledger unchanged, and all three new test files are in the tsconfig.test.json program (--listFilesOnly).
  • dispatch-gates --commands --repo objectstack-ai/objectstack with no paths derived 62 families. All 62 were run, each exit 0, and --ran reconciled 62 derived, 62 run, 0 not-measured. check:dual-build-cjs-loads first answered PREREQUISITE NOT MET; after a full turbo run build it measured 106 entries across 66 packages.
  • Lint, a proven narrowing (pnpm lint itself is CI's run):
    • population, from eslint's own config: 6 of the 7 changed paths are linted (the changeset .md has no matching configuration);
    • count, from --format json: 6 files, 0 errors, 0 warnings;
    • invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.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

Changeset

@objectstack/runtime minor (the launch-window convention for accept-set narrowings), with Clause-②: yes (narrowing) and the ADR-0087 disposition not-required (no-migration-prescription). No stored metadata shape, authorable key or export moves.


Generated by Claude Code

claude added 14 commits October 3, 2026 03:39
…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
…ispatch predicates; record pinned coverage

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>
…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>
@github-actions github-actions Bot added the size/l label Oct 3, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 27 documentable anchor(s).

33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5dbcee8a6d4f3a3feac3ab0a64e5555a1f45cf7c.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 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 5dbcee8a6d4f3a3feac3ab0a64e5555a1f45cf7c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0010704fcdcf091956df8a241f6921f7512a4bed — the merge of head 9a95e459d1439e6ae25823f3a2c19175642e6562 into base 5dbcee8a6d4f3a3feac3ab0a64e5555a1f45cf7c, 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 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

⚠️ 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 5dbcee8a6d4f3a3feac3ab0a64e5555a1f45cf7c → 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: 9a95e459d1439e6ae25823f3a2c19175642e6562
Local-runs: none

Read on GitHub 2026-10-03T08:41Z, rendered by an isolated subagent of the domain:cli seat. Inputs: card #21520 (body, the ruling 5965059068, the pointer, the claim, the os-dev report and the seat's ACCEPT), PR #21563 (body, file list, and the net diff against main read as git objects from the merge-base, no checkout) and the check-runs on the head. Classes, doors and roles only.

Shape. Draft, base main, head repo is the base repo. 7 files, six under packages/runtime plus one changeset, +981 / -18; no governed path. Merge-base with main is ce532184d1, which carries #21539's landed seam, so the diff against main is exactly these 7 files and the PR is not stacked (the earlier in-flight merge of #21539's open head resolved to the landed squash's bytes). Check-runs on the head: every one of the seven required contexts is completed / success (Lint & Repo Gates, TypeScript Type Check, Test Core on all six shards and the rollup, Dogfood Regression Gate, Build Core, Temporal Conformance, Governed Surface Queue Guard); the remaining rows are skipped advisories. The card's newest Claim: names this branch; the single-writer-path and card-claims-branch checks are green.

① Derived judgments

Each accept-set or surface change the diff implies, judged against ruling A (5965059068, maintainer 「同意」) and triage 5965718076:

  1. Binding refusal, at hookBodyRunnerFactory's per-hook resolver: right. A hook carrying a sandboxed body whose target names either family table, as a string or inside a list, throws the boundary's refusal before the body is parsed. Read in hook-binder.ts: the resolver runs inside the binder's per-hook try, so the throw is recorded against that hook, logged at error, and the loop continues to the next hook; under strict it is rethrown. Both doors reach this one resolver: bindAppArtifactHandlers (the boot artifact and install-local, the only caller that constructs an explicit runner) and the engine's default runner installed by AppPlugin, which the ObjectQL plugin's metadata-service bind of runtime-authored hooks uses with no runner of its own. A repo-wide sweep at the head finds no third construction site. Attaching at the artifact binder alone would have missed the runtime-authored door; the dev's deviation 3 is the correct reading.
  2. Wildcard body hook binds and is never run on a family event: right. A '*' target names no family table, so refusing it would refuse every wildcard body hook with no ruling behind it. The bound handler returns before any sandbox context is built when the engine's dispatched object is a family table (the engine's hook context carries object as a string and matches hooks on it), so the body never receives the row as input and has no write-back channel. The author is told once at bind, at info. The ablation leg that disabled this check went red on the composed save pin, so it is the half that holds whenever a global registration admits the family.
  3. Write refusal as a body-only layer applied at buildSandboxApi: right, and the open question is answered A. buildSandboxApi is the one constructor of a body's ctx.api, reached from the hook face and the action face, and the fallback facades it synthesises sit inside the wrapped object. Attaching the throw in serveRepository's shared write branch (the pointer 5965604037 and dispatch item A4) would also refuse a host-code action handler's ctx.api, which buildActionApi serves through the read seam. The ruling says the seam refuses bodies only, and triage 5964836549 placed deployer host code outside the family, so the seat's pointer was imprecise and the ruling text governs. The layer shares one derived-context walk with the read seam (object, sudo, withRunAs, the transaction callback's context, beginTransaction's context), is idempotent and transparent to the read seam's marker, so an action body's already-served API is wrapped exactly once.
  4. Fail-closed verb set: right. For a family table only the four served reads (find, findOne, aggregate, count) pass through; every other function, the nine write aliases, execute, and any verb added later, is refused before it is called. So the changeset's claim that a body's reads are unchanged is true, and the write-verb predicate oracle triage carried onto this card closes with it: a refused write runs nothing and its answer depends on the verb only, pinned across three predicates.
  5. Elevation does not lift it: right. sudo, withRunAs and both transaction contexts are refused with the same envelope, and the composed action body, which runs elevated for the member as for the administrator, lands nothing for either role.
  6. Code and envelope: right. PERMISSION_DENIED / 403 is a member of the standard catalog, 403 maps to it in the status map, and the ledger's admission rule sends a generic permission condition to the standard member, so the ruling's "ledgered code" is met with no row added and nothing under packages/spec edited. The prescription names the metadata API door on both refusals.
  7. Public surface: right, and declared. No packages/runtime root export is added or removed; the new module and the seam's new function are internal. What changes is the behaviour of the root-exported hookBodyRunnerFactory (it now throws for a family target) and of every body's ctx.api; both are what the changeset's BREAKING text says.
  8. Census (premise 2): right by construction, corroborated by the dev's symbol walk. The refusal attaches only to a sandboxed body's API, which no platform module calls through; platform writers call the engine or the driver directly. The composed pin confirms the platform's own code hook on a family table still binds and fires on the metadata door's save, and the door's save itself lands.
  9. The ruling's three pins are present. The refusal fires on each family table (unit, both doors, string and list forms; composed, through the door's save and the action route). An ordinary table binds and writes as before (unit and composed controls, the wildcard among them). The metadata door's own save still fires the platform's internal hooks (composed, counted).
  10. Reverse verification transfers to this head. The two edited source files at the ablation commit 0d8af06c80 are byte-identical to the head's (blob ids compared), so the three red legs the dev reports describe the code under review. Not re-run here.
  11. Bind-failure logging at error is the binder's standing posture, unchanged. A refused family hook is a visible functional refusal, which the degradation rule would place at warn, but every bind failure already logs at error there, and the body-runner's error on any body that throws (the dev's "noted, not filed") is likewise pre-existing. Neither is this PR's to move.

② Semver level

@objectstack/runtime minor, declared Clause-②: yes (narrowing) line-initial in both the changeset and the PR body, matches what the diff publishes. The change is an accept-set narrowing on a released package (what a sandboxed body may be bound to and may write), so patch would understate it and skip-changeset does not apply; minor is the repo's launch-window convention for accept-set narrowings, as the twenty-odd precedent changesets at the head spell it. The body carries one BREAKING paragraph that names, as classes, what is refused, what is unchanged and the route (the metadata API), and exactly one ADR-0087 disposition marker, not-required (no-migration-prescription), with its facts stated: no authorable key, export, spelling or stored shape moves, so the migrate command has nothing to rewrite, and the other categories are closed by name. Check Changeset and Lint & Repo Gates (which carries the ADR-0087 registration gate) are green on the head. No packages/spec artifact is touched, so no regeneration was owed.

③ Boundary flags

Dev deviations (os-dev report 5967051141), each answered:

  1. Attach point: answered A, ① item 3. The seat's ACCEPT (5967073500) reached the same answer; this record concurs independently from the diff and the ruling text.
  2. Stacking: resolved, see Shape; the unsupported form existed only in flight and is not what lands.
  3. Binding point at the factory, not the artifact binder: right, ① item 1.
  4. Wildcard not refused, never run on a family event: right, ① item 2.
  5. No composed probe of a predicate over the family's content columns: accepted; the card's no-recipe constraint holds, and the unit pin's predicate-independence assertion is the right instrument.
  6. PERMISSION_DENIED as the ledgered code: right, ① item 6.
  7. Ablation commit predates the two main merges: verified byte-identical, ① item 10.
  8. Attribution: the branch's own commits carry the model-free trailer pair and the PR body ends with the session-URL footer. Clean.

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 error-level log on an expected body refusal is correctly noted, not filed.

Escalated to the seat, neither a defect in this diff:

Implemented-by: claude/issue-21520-family-body-boundary
Reviewed-by: session_016GiHYRmLSNWTfbX9gVQkpz

VERDICT: PASS


Generated by Claude Code

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