Repository navigation
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 |
A second pass over every cited sha, PR number, key name and behavioural claim on the 17.7.0 page, against each changeset. Among the corrections: element:text variant is an advisory component-props finding, not a parse refusal; the missing-table exit 1 of the one-shot previews was superseded in the same release by empty work and exit 0; the cloud AI runtime never read agent.lifecycle; #21464 has nine stages, not eleven; and a job body's write of the stored-metadata tables is not covered by #21563. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
…n every door, and install-local refuses an enabled job with no body (objectstack-ai#21489) (objectstack-ai#21584) Fixes objectstack-ai#21489 Clause-②: yes (narrowing) This executes ruling E + C (record `5964305303`, maintainer 「jobs同意」), as the card's runtime half and C, with the scope the erratum `5968972501` restates: a job `body` is authored as data and `os build` never mints one (objectstack-ai#21540 ruled C, record `5968961157`). Nothing here adds a build or lowering route. - **E, runtime half.** A job's sandboxed `body` (`JobSchema.body`, the hook body shape) is scheduled on every door that brings an artifact in: the boot (a config, or `os start --artifact`), and install-local on install and on every rehydrate. With both `body` and `handler` declared, the `body` wins. - **C.** install-local refuses a package whose enabled job has no `body`. The answer is `422` with `VALIDATION_ERROR`, it names each job and its handler, and it gives the remedy: give the job a `body`, or boot it with `os start --artifact`. Nothing is registered, persisted or scheduled. - **CLI.** `os package install` prints a refusal's code beside its status, for every refusal alike. - A job still on `handler` keeps working on a config or `--artifact` boot (the control pin). - **Patch round 1 (REWORK `5969239835`).** A package's jobs stop with it. Uninstalling a package cancels its scheduled jobs, on install-local's `DELETE` and on the protocol's package uninstall alike, and a reinstall cancels the jobs its new version drops. - **Amendment `5969471197` (seat-directed).** The texts this landing makes false ride this PR: `JobSchema.body`'s describe, `defineJob`'s TSDoc example, the regenerated `content/docs/references/system/job.mdx`, and the callout and example comment in `content/docs/automation/jobs.mdx`. ## What changes **The one binder's job half** (`packages/runtime/src/app-artifact-handlers.ts`): - `scheduleAppArtifactJobs(ctx, bundle, { appId, ql, source })` is the one place a declared job becomes a scheduled one. It holds the loop that used to sit inline in `AppPlugin.start`: the deployment switch (objectstack-ai#17396), the job-service probe, the `enabled` skip, `toBoundaryJobSchedule`, the `retryPolicy` / `timeoutMs` threading, and the failure posture (error level plus `jobScheduleFailuresTotal`). - Per job, a `body` is bound through `jobBodyRunnerFactory`. Otherwise the `handler` resolves against `functions`, with the in-process `JobHandlerContext` as before. A body that cannot be bound (an L1 expression, or a `body.timeoutMs`) schedules nothing, and never the handler beside it. - Callers: `AppPlugin` on `kernel:ready`, and install-local's `bindArtifactHandlers`, which runs on the install route and on the rehydrate. - It is a second entry point of the same module rather than a block inside `bindAppArtifactHandlers` for one reason, timing. The boot binds hooks and actions in `start()` but schedules jobs once the kernel is ready, while install-local's doors are already past that point. One implementation, two moments, no per-door copy. - `collectJobsWithoutBody(bundle)` names the enabled jobs with no `body`. It is the judgement C refuses on, read from the jobs the binder schedules. **A package's jobs stop with it** (`app-artifact-handlers.ts`, patch round 1): - The job half keeps a record of which job names each app scheduled, per job service instance (one per kernel). The last app to schedule a name owns it, so cancelling one app's jobs never stops a job another app scheduled under that name. - **Re-scheduling replaces.** Every job an app scheduled before and does not schedule now is cancelled through `IJobService.cancel`, the verb every adapter implements: the cron adapter stops its timer, and the DB adapter also marks the `sys_job` row inactive. That covers a job the new version drops, disables or can no longer run. A version with no jobs cancels them all. A cancel that throws is logged at `error` and the name stays on the record for the next attempt. - **Uninstall.** The first time a package's jobs are scheduled on a kernel, the job half registers ONE uninstall cleanup, `runtime.package-jobs`, through the protocol's existing `registerUninstallCleanup` (objectstack-ai#21490). It cancels the package's recorded jobs. The protocol's `deletePackage` and install-local's `DELETE` both run every registered cleanup with the package id, so both stop the jobs with no per-door copy, and the outcome rides the response's `cleanups`. A job it could not cancel is an outcome (`success: false`, naming the job), never a throw. No `metadata-protocol` file is edited. - The DELETE path keeps PR objectstack-ai#21581's behaviour: the registry withdrawal runs first, then the cleanups. This PR does not edit that path. **The sandbox job origin** (`sandbox/script-runner.ts`, `sandbox/quickjs-runner.ts`, `sandbox/body-runner.ts`): - `ScriptOrigin.kind` gains `'job'`. - `QuickJSScriptRunner` gains `jobTimeoutMs`, default 5000 ms of CPU, like an action body. `resolveTimeout` now picks a default per kind instead of hook-or-else. There is no env override: a job's own `timeoutMs` (uncapped) is the declared place to raise it. - A job body runs in the `(ctx)` wrapper hooks use; a job has no input. - `jobBodyRunnerFactory` passes the job's `timeoutMs` as `opts.timeoutMs`, the one limit `JobSchema.timeoutMs` states. - `jobBodyRunnerFactory` reads the body's return as a `JobRunOutcome`, in the declared shape only. - `jobBodyRunnerFactory` serves `ctx.api` through `buildSandboxApi`, like every body's, under `{ isSystem: true }`. A job has no caller: an action body with no caller gets the same envelope, and a `handler` job's raw `ql` amounts to it. The stored-metadata write refusal still applies. **install-local** (`packages/cloud-connection/src/marketplace-install-local-plugin.ts`): the refusal sits as step 1c, beside the id gate. That is ahead of the conflict check, the posture gate, the hot-register and the ledger write. Rehydrate is not gated, for the id gate's reason. An entry an older build installed still rehydrates; its handler-only job is reported at `warn` and not run. **CLI** (`packages/cli/src/commands/package/install.ts`): the generic refusal branch prints `Install failed (STATUS CODE): MESSAGE`. It was `Install failed (STATUS): MESSAGE`, which dropped the code. **Spec ledger** (`packages/spec/liveness/job.json`, `state-counts/job.md` regenerated): see the deviations below. The `job.body` children `language`, `source`, `capabilities` and `memoryMb` flip `planned` to `live`. `authorWarn` / `authorHint` are dropped, as the row's own carrier note prescribed for this card's commit. `body.timeoutMs` stays `planned` (refused). The five rows that cited `app-plugin.ts#start` for the moved loop are repointed to `scheduleAppArtifactJobs`. ## Measurements **A4, reach at the public door, before the fix** (base `bd70706713`). The composed pin `packages/cli/test/package-install-local-jobs.integration.test.ts` ran unchanged against the base build: 4 red, 2 green. - The body-job package installed with exit 0, and its job wrote 0 rows hot and 0 after a restart. - The handler-only package installed: exit 0, `Package installed into the running kernel`. It was never scheduled. - Control, `os start --artifact` of one artifact carrying both forms plus its runtime module: the handler job ran (rows written) and the body job wrote 0 rows. **After the fix:** 6 of 6 green, at `f99d6dcd39` and again at head `c866c5ac9d`. **A1, the pointer's facts, re-measured at `bd70706713`:** 1. `AppPlugin#start` resolved `fnMap[job.handler]` only (`app-plugin.ts:1178`). A body-only job was skipped at warn, and with both keys present `body` was ignored. Now the body binds, and wins (pins below). 2. `ScriptOrigin.kind` was `hook | action` (`script-runner.ts:118`; `body-runner.ts:120` and `:228`), and `resolveTimeout` defaulted everything non-hook to the action budget. Now `'job'` has its own default. 3. `job.timeoutMs` reaches the runner as `opts.timeoutMs`. Pinned: a spinning body with `timeoutMs: 40` rejects with `job 'spin_job' exceeded CPU budget of 40ms`, and the adapter receives `{ timeoutMs: 40 }`. 4. What a job body receives, measured with `Object.keys(ctx)` inside the VM: `api`, `log` and `crypto`, each behind its capability token. `input`, `previous`, `user` and `session` are present and `null`; the shared `installCtx` installs them for every body. There is no `jobId` and no trigger `data`, even when a manual trigger passes data. `ctx` is not widened. **A2, the one binder:** see above. **Patch round 1, measured at the public door before the cancellation** (the extended pin at `37c472764f`, whose code was `c866c5ac9d`): 2 red, 9 green. - After an install-local `DELETE` (200), the uninstalled package's body job kept writing: 38 to 42 rows in 4 s. - After a reinstall whose new version dropped one of two jobs, the dropped job kept writing: 17 to 21 rows in 4 s. - After a restart, neither ran, because neither is rehydrated. Another installed package's job ran throughout. **After the cancellation:** 11 of 11 green. After PR objectstack-ai#21581 landed, an uninstalled package's own object stops answering, so the pin's packages write into an object the host artifact owns; a run that should have stopped still shows there. **A3, codes.** `VALIDATION_ERROR` / 422 is an existing member of the `ErrorCode` union (the standard catalog), so this is **not** `PENDING LEDGER CODE` and nothing under the error-code ledger is edited. - The condition is generic: the install payload fails this door's acceptance rule, which is that every enabled job carries a `body`. - The ledger's admission rule sends a generic validation condition to the standard member rather than to a registered synonym (`error-code-ledger.zod.ts`, "Registering a new code"). This follows PR objectstack-ai#21563's `PERMISSION_DENIED` reasoning. - `PLUGIN_MANIFEST_INVALID` was rejected because it would be untrue: the manifest is valid, since `os validate` passes it and `os start --artifact` runs it. - 422 rather than this door's 400/502 split: a catalog package that declares a handler job is no upstream fault. 422 derives `VALIDATION_ERROR` (`standardErrorCodeForHttpStatus`), so code and status agree. - If the contract review prefers a dedicated code, it is a one-constant change here (`JOB_WITHOUT_BODY_REFUSAL_CODE`) plus a spec-lane ledger row. **A5, CLI rendering.** Before: `Install failed (422): MESSAGE`, with the code dropped. That was a rendering gap, so the generic branch now names the code for every refusal. There is no case per code, and an envelope with no code prints the status alone. Pinned by unit and integration tests. **A6, the sibling (hooks).** Measured once at the public door. A package with a hook in the deprecated `handler` form and no function installs with exit 0 (`Package installed into the running kernel`), and the hook never fires: an inserted row keeps `legacy: null`, while a body-hook control on the same object stamped `bodied: yes`. The only trace is a server-side `WARN [hook-binder] skipping hook with unresolved handler`. That is silent at the door. Reported for the seat to file; not fixed here. ## Pins - `packages/runtime/src/app-artifact-handlers.jobs.test.ts` (23, real QuickJS) covers: - a body job is scheduled, and a run writes through `ctx.api` as `{ isSystem: true }`; - with both keys the body wins, and the handler is never called; - an L1 body and a `body.timeoutMs` are not scheduled, and never the handler beside them; - `timeoutMs` reaches the adapter and bounds the run; - with no `timeoutMs`, the runner's JOB default applies (not the hook's or the action's); - the `JobRunOutcome` shape; - the `ctx` surface has no `jobId` or `data`; - the handler control, the handler-not-found warn naming the body remedy, the disabled skip, the deployment switch, and `collectJobsWithoutBody`; - the boot door: `AppPlugin` schedules a body-only job on `kernel:ready`; - patch round: a reinstall that drops a job cancels it and keeps the other; a disabled job and a version with no jobs cancel; another app's jobs are never cancelled, even one that took over a name; a cancel that throws is said at `error`; - patch round: `runtime.package-jobs` is registered once per protocol, cancels every job of the uninstalled package and none of another's, is a no-op for a package that scheduled nothing, and reports an uncancellable job as `success: false`. - `packages/cloud-connection/src/marketplace-install-local-jobs.test.ts` (8, real runtime `dist`): - install schedules and runs the body; - rehydrate schedules it; - the refusal answers 422 `VALIDATION_ERROR`, names the job, the handler and both remedies, and leaves nothing registered, persisted or scheduled; - the plural message; - a disabled handler-only job installs; - a package without jobs installs with the same response keys; - patch round: `DELETE` cancels the uninstalled package's job through the cleanup, with `runtime.package-jobs` on the response's `cleanups`, and a control package's job stays scheduled; - patch round: a reinstall that drops a job cancels it. - `packages/cli/test/package-install-refusal-rendering.test.ts` (3, unit). - `packages/cli/test/package-install-local-jobs.integration.test.ts` (11, integration): install, restart, refusal, the control on both forms, and, in the patch round: after the `DELETE`, no further row hot and none after a restart; after a dropping reinstall, the same for the dropped job while the kept job runs on; and another package's job running throughout. ## Reverse verification (ablation), fix committed first Every leg ran through `scripts/ablation-replace.mjs` in wrap mode. In each, the anchor went from 1 to 0 on disk and the blob changed. The marker was proven in `dist/` by `ablation-dist-preflight.mjs` (present on the mutate leg, `--absent` plus a clean tree after the rebuild on the restore leg). The restore was proven by blob equal to HEAD and an empty `git diff HEAD`. - **Leg A, body scheduling disabled** (`if (job.body) {` in `scheduleAppArtifactJobs`, runtime rebuilt): - runtime unit: 8 red / 7 green (the handler, `collectJobsWithoutBody` and runner-default cases stayed green); - cloud-connection: 2 red (install, rehydrate) / 4 green; - CLI integration: 3 red (hot, restart, control body) / 3 green. - **Leg B, the refusal disabled** (the `withoutBody.length` guard of step 1c in install-local, cloud-connection rebuilt): - runtime: 15 green; - cloud-connection: 2 red (both refusals) / 4 green; - CLI integration: 1 red (the refusal) / 5 green. - **Leg C, the cancellation disabled** (`await svc.cancel(name);` in `retireAppJobs`, runtime rebuilt), at `bb25c4992e`: - runtime: 6 red (every replace and uninstall-cleanup case) / 17 green; - cloud-connection: 2 red (`DELETE`, reinstall) / 6 green; - CLI integration: 2 red (uninstall hot 37 to 41 rows, dropped job 16 to 20) / 9 green. - **Leg D, the cleanup registration disabled** (`ensureJobUninstallCleanup(ctx, jobService);`, runtime rebuilt): only the uninstall pins went red. Runtime 4 red / 19 green, cloud-connection 1 red / 7 green, CLI integration 1 red (uninstall hot) / 10 green. The reinstall pins stayed green, so the two mechanisms are pinned apart. - **Leg C repeated on the final head `650ff1e486`** (after PR objectstack-ai#21581's withdrawal landed, as `ABLATION_21489_E`): runtime 6 red, cloud-connection 2 red, CLI integration 2 red (uninstall hot 38 to 42 rows, dropped job 16 to 20). The withdrawal alone does not stop the job. ## Tests and gates Final head `650ff1e486`, which merges `origin/main` after PR objectstack-ai#21581 landed (no conflict): - `@objectstack/runtime` `pnpm test`: 317 files, 4467 passed, 19 skipped. - `@objectstack/cloud-connection` `pnpm test`: 35 files, 428 passed. - `@objectstack/cli` `--project unit`: 254 files, 3721 passed. - `@objectstack/cli` `--project integration`, the four install-local pins (jobs, handlers, boot-steps, uninstall-cleanups): 4 files, 51 passed. The rest of the integration tier is declared to CI. - `@objectstack/spec` `pnpm test`: 606 files, 17952 passed. - `typecheck` green for runtime, cloud-connection and cli. - `check:generated` after the describe edit: only `check:docs` was stale. `--fix` regenerated `content/docs/references/system/job.mdx` alone, and only the describe sentence moved. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 118 families (the docs and spec families joined with the amendment), all run at `650ff1e486`, every one exit 0. The `--ran` reconciliation reads 118 run, 0 NOT-MEASURED, with exit codes recorded. - `pnpm lint` (full repo): exit 0 at `650ff1e486`. ## Deviations and file surface - **`packages/spec/liveness/job.json` and `state-counts/job.md` were edited, although the dispatch keeps this lane out of `packages/spec`.** Moving the job loop out of `AppPlugin.start` turned `check:liveness`, a required gate, red: `job/retryPolicy` and `job/enabled` cited `app-plugin.ts`, which no longer names them. The gate's prescription is to repoint. The ledger's own `job.body` carrier note designates this card's commit for the `planned` to `live` flip. Left `planned`, the published `authorHint` makes `os validate` print a false warning. Measured with a config declaring a body job, `os validate` printed `job 'vj_tick_body': sets body.source but this job property is planned ... (not read YET)` before this edit, and prints no such warning after it. No Zod schema, no error-code ledger and no generated docs were touched. The spec package rides the changeset as `minor`, because `liveness/` is in its `files[]`. - `docs/qa/platform-checklist/areas/integration-system.json`: one source anchor repointed (`app-plugin.ts#handler` to `app-artifact-handlers.ts#scheduleAppArtifactJobs`). `check:platform-checklist` went red on the move. - `packages/runtime/src/sandbox/quickjs-runner.ts` (the per-kind default and the wrapper) and `packages/runtime/src/index.ts` (exports) were outside the expected list. - **Seat-directed, amendment `5969471197`:** `packages/spec/src/system/job.zod.ts` (the `JobSchema.body` describe sentence and the `defineJob` TSDoc example comment, text only, no shape change), the regenerated `content/docs/references/system/job.mdx`, and `content/docs/automation/jobs.mdx` (the callout and the example comment, plus one sentence on uninstall and reinstall). No wording names a build or lowering route. ## Acceptance notes - **Pointer fact 5**, reported only: `allowRuntimeCreate: false` for `job` is justified by `handler` alone. A runtime-authored job with a `body` would now be runnable in principle, but no door schedules a runtime-authored job; the binder schedules artifact jobs. - `body-runner.ts`'s job factory is not exported from `@objectstack/runtime`'s root, unlike the hook and action factories; it has no consumer outside the binder. - Code-read, unmeasured: `os package install` renders every 404 as "install-local endpoint not found", including a catalog 404 (`CLOUD_FETCH_FAILED`, a package missing from the catalog). - Code-read, unmeasured: a handler-form hook falls back to `engine.resolveFunction`, so it could bind to a same-named function another app registered. The silent drop of a handler-form hook is filed as objectstack-ai#21585. - `packages/qa/dogfood/test/expression-conformance.ledger.ts` prose still names `runtime/app-plugin.ts start` as the `toBoundaryJobSchedule` call site. - objectstack-ai#21540 is not addressed here (ruled C; see the erratum above). --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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