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

Merged via the queue into main with commit bd70706 Oct 3, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21520-family-body-boundary branch October 3, 2026 09:03
hotlong pushed a commit that referenced this pull request Oct 6, 2026
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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
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