Skip to content

fix(runtime,cloud-connection)!: a job's sandboxed body is scheduled on every door, and install-local refuses an enabled job with no body (#21489) - #21584

Merged
objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-21489-job-bodies
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-21489-job-bodies

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #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 (#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 ([Decision] 平台自带的四个定时示例流一个都声明不了组织 —— 而新规则要求它们必须声明 #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 ([finding] An install-local uninstall (DELETE /api/v1/marketplace/install-local/:id) leaves the package's permission sets in sys_permission_set: the "no ghost grants" uninstall cleanup never runs on that door #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 fix(cloud-connection): an install-local uninstall withdraws the package from the running kernel #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 #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 fix(runtime)!: an app-authored body may not bind a hook to, or write, the stored-metadata tables (#21520) #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 fix(cloud-connection): an install-local uninstall withdraws the package from the running kernel #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 #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


Generated by Claude Code

@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 4 package(s): @objectstack/cli, @objectstack/cloud-connection, @objectstack/runtime, @objectstack/spec, touching 38 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/runtime/src/index.ts, packages/spec/liveness/job.json, packages/spec/liveness/state-counts/job.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

18 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 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d.

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

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/runtime/src/index.ts, packages/spec/liveness/job.json, packages/spec/liveness/state-counts/job.md) — pages documenting those are invisible to this run
  • 8 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 — 149 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 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cf86783dfea69686e166f9035db86da5fabcfa00 — the merge of head 650ff1e486a5b956106904631bb49f0b805693bd into base 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d, 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 cf86783dfea69686e166f9035db86da5fabcfa00 && git checkout cf86783dfea69686e166f9035db86da5fabcfa00
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d 650ff1e486a5b956106904631bb49f0b805693bd && git checkout -B drift-repro 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d && git merge --no-ff 650ff1e486a5b956106904631bb49f0b805693bd

node scripts/docs-audit/affected-docs.mjs --json 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d

⚠️ 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 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…job, and a control package (measures the pre-fix reach)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
… drops them

The binder's job half records which jobs each app scheduled and cancels the
ones a new version no longer schedules; the runtime.package-jobs uninstall
cleanup, registered on the protocol's registry when a package's jobs are first
scheduled, cancels an uninstalled package's jobs on every uninstall door.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…n after the uninstall withdrawal still shows

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 650ff1e486a5b956106904631bb49f0b805693bd
Local-runs: none

Contract review seat, an isolated subagent of the domain:cli seat's session, read on GitHub 2026-10-03T14:35Z. Inputs: card #21489 (body and all eleven comments, the two cross-lane declarations it points to on #18549 and #20163), PR #21584 (body, 19-file list, net diff against main at the head), and the check-runs on the head. Nothing was built, run or re-run; object reads only (git show / git grep at the head sha).

Gate verdicts on the head (latest run per name, all 35 completed): 31 success, 4 skipped (Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke). The seven required contexts are all success: Lint and Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Check Changeset (re-run after the last body edit) and Spec property liveness are success. No run was unfinished when this record was rendered.

Governed surfaces: none in the file list (no docs/adr/, .claude/, skills/, AGENTS.md, CLAUDE.md, docs/NORTH-STAR.md). Head repo equals base repo. The review is owed on Clause-②: yes and the packages/spec paths, not on a tier.

① Derived judgments

Each accept-set or public-surface change the diff implies, judged against ruling E + C (5964305303), the erratum (5968972501), the REWORK (5969239835) and its amendment (5969471197).

The install-local door (POST /api/v1/marketplace/install-local, packages/cloud-connection)

  1. Accept set narrows: a package whose ENABLED job has no body is refused, 422 VALIDATION_ERROR, at step 1c — after the id gate, ahead of the conflict check, the posture gate, the hot-register and the ledger write. RIGHT. This is C as the erratum restates it. Nothing is registered, persisted or scheduled on a refusal (pinned: cloud-connection answers 422 VALIDATION_ERROR ... and changes nothing; CLI integration nothing of it is installed). The judgement is the binder's own collectJobsWithoutBody, so the door and the binder read the same jobs (collectBundleJobs) and the same enabled rule.
  2. The code: the existing standard-catalog member VALIDATION_ERROR, not a new ledger row. RIGHT. The ledger's admission rule (error-code-ledger.zod.ts, "Registering a new code") sends a generic validation condition to the standard catalog instead of a registered synonym, and since [finding] @objectstack/rest registers four generic synonyms the standard catalog already covers (CONFLICT, NOT_FOUND, FORBIDDEN, INTERNAL) — contract call, not a cleanup #8211 refuses a synonym mechanically; the ledger's own text records that 422 derives VALIDATION_ERROR. The ruling's "ledgered code" is met by a member of the published ErrorCode union; this is not a PENDING LEDGER CODE. PLUGIN_MANIFEST_INVALID was rightly rejected (the manifest is valid). 422 over the door's 400/502 split is right: the package is well-formed, this door cannot process it, and that holds for both the inline and the catalog branch.
  3. The remedy text names a body or os start --artifact, and no build or lowering route. RIGHT (erratum; [Decision] os build cannot lower a job's handler into the new JobSchema.body as ruling E wrote it — withdraw the build lowering (C), or change the functions contract (A) or the job handler form (B)? #21540 ruled C). Every refused job is named with its handler, so one pass fixes all (pinned, plural case).
  4. Rehydrate is not gated: an entry an older build installed still rehydrates, its handler-only job warned and not run. RIGHT, on the id gate's precedent (gating rehydrate would strand an entry that can no longer be healed or removed). A disabled handler-only job installs (pinned). A package without jobs installs with the same response keys (pinned).
  5. Install and rehydrate now schedule the package's job bodies through scheduleAppArtifactJobs from the existing bindArtifactHandlers. RIGHT: E's runtime half, one binder, no per-door copy. A runtime lacking the export schedules nothing and says so at warn (absence is loud, no second scheduling path).
  6. The DELETE path is not edited; the package's jobs stop through the protocol's uninstall-cleanup registry. RIGHT (REWORK step 1, honoured).

The binder's job half (packages/runtime/src/app-artifact-handlers.ts)
7. scheduleAppArtifactJobs is the one place a declared job becomes a scheduled one; AppPlugin calls it on kernel:ready, replacing its inline loop byte-for-behaviour (deployment switch #17396, job-service probe, enabled skip, toBoundaryJobSchedule, retryPolicy / timeoutMs threading, error level plus jobScheduleFailuresTotal). RIGHT. A second exported entry point rather than a block in bindAppArtifactHandlers is right for the timing reason given (hooks and actions bind in start(), jobs once the kernel is ready).
8. A body wins over a handler beside it; a body that cannot be bound (an L1 expression, a body.timeoutMs) schedules nothing and never the handler. RIGHT: Prime Directive 12, no lenient fallback to code the author replaced (pinned twice).
9. A handler job still runs its functions entry on a config or --artifact boot, with the in-process JobHandlerContext. RIGHT (the control, pinned in runtime and at the public door).
10. Re-scheduling replaces: every job the app scheduled before and does not schedule now is cancelled through IJobService.cancel (dropped, disabled, unrunnable, or all of them for a version with no jobs; also when the deployment switch is off). RIGHT (REWORK step 2, measured real first: 17 to 21 rows, then green). cancel(name) is on the published IJobService contract. A cancel that throws is error and the name stays on the record. No job service: nothing to cancel, withheld returned.
11. Uninstall: ONE cleanup, runtime.package-jobs, registered once per protocol through the existing registerUninstallCleanup the first time a package's jobs are scheduled; it cancels the package's recorded jobs and reports an uncancellable one as success: false, never a throw. RIGHT (REWORK step 1, preferred route; no metadata-protocol edit; pinned apart from replace by legs C and D). Both doors run every registered cleanup with the package id, so the protocol's deletePackage is covered too.
12. Ownership record keyed by job service instance; the last app to schedule a name owns it, and cancelling one app's jobs never stops a job another app scheduled under that name. RIGHT as far as the record goes; the identity residue it follows is escalated under ③.

The sandbox (sandbox/script-runner.ts, quickjs-runner.ts, body-runner.ts)
13. ScriptOrigin.kind gains 'job' (public type widening on @objectstack/runtime); QuickJSScriptRunnerOptions.jobTimeoutMs, default 5000 ms CPU; resolveTimeout picks one default per kind. RIGHT: a job falling into the action branch would have read the action's env override as its own. No env override for the job default is right: JobSchema.timeoutMs (uncapped) is the declared knob, and it reaches the runner as opts.timeoutMs, the one limit (pinned: timeoutMs: 40 bounds the run and reaches the adapter).
14. A job body takes the (ctx) wrapper; ctx is api / log / crypto behind capability tokens, no jobId, no trigger data. RIGHT: the surface JobSchema.body declares, not widened (claim fence held; measured with Object.keys(ctx) and pinned).
15. jobBodyRunnerFactory parses with ScriptBodySchema, refuses body.timeoutMs at bind as well, reads the return as JobRunOutcome in the declared shape only, rethrows a throw. RIGHT. Not exported from the package root (no consumer outside the binder): acceptable, narrower surface.
16. Security posture: a package-authored job body's ctx.api runs under the system envelope, bounded by its declared capability tokens and the stored-metadata write refusal every body's api carries; a job has no caller, and this matches what a hook body's engine api falls back to and what a handler job's raw ql amounts to. RIGHT and consistent with the recorded posture (#3914). The install door itself stays behind its existing manage_metadata admission, and the uninstall cleanup runs only from an admitted uninstall. Named at the class level only.

Public surface of @objectstack/runtime (src/index.ts)
17. New root exports scheduleAppArtifactJobs, collectJobsWithoutBody, types AppArtifactJobScheduling, AppArtifactJobSchedulingOptions, JobWithoutBody. RIGHT, additive. collectBundleJobs and PACKAGE_JOBS_UNINSTALL_CLEANUP are module exports only, not on the root: acceptable.

The CLI (packages/cli/src/commands/package/install.ts)
18. The generic refusal branch prints Install failed (STATUS CODE): MESSAGE; no case per code; an envelope without a code prints the status alone. RIGHT: the code is the machine-readable half an installer branches on, and the rendering gap predated this card (pinned, 3 unit cases plus the integration refusal).

packages/spec paths, judged as the published surface they are
19. src/system/job.zod.ts: the JobSchema.body describe sentence and the defineJob TSDoc example comment, text only. RIGHT and accurate to the landed behaviour: every door schedules a body; a handler runs only on a boot that loads the runtime module; the door refuses an enabled job with no body. No shape change, no build or lowering route in the wording (#21540 C). Seat-directed by the amendment, declared to domain:spec (5969245751, amended), the spec seat closing its shift on the maintainer's instruction (5969273857), no objection on the card. The regenerated content/docs/references/system/job.mdx moves exactly that sentence (check:docs inside the green TypeScript Type Check job).
20. liveness/job.json: five rows (name, schedule, handler, retryPolicy, enabled) repointed from app-plugin.ts#start to app-artifact-handlers.ts#scheduleAppArtifactJobs; body.language / source / capabilities / memoryMb flip planned to live with authorWarn / authorHint dropped in the same edit; body.timeoutMs stays planned (refused at parse and at bind); enabled gains the collectJobsWithoutBody leg. RIGHT. The base row's own carrier note designated this card's commit for exactly that flip and that drop, and check:liveness requires the repoint once the loop moves. liveness/ ships in the spec package's files[], so this is a published change: os validate no longer prints the planned-body warning (measured both ways by the dev). state-counts/job.md 15/5 to 19/1 matches. Spec property liveness is success.

Docs and checklist
21. content/docs/automation/jobs.mdx: the warn callout is replaced by the landed behaviour (422 VALIDATION_ERROR, the two remedies, uninstall and reinstall stopping jobs); the example comment reads "deprecated and optional, body wins". RIGHT; the domain:devx seat named this PR as the carrier (5969373369).
22. docs/qa/platform-checklist/areas/integration-system.json: one source anchor repointed. RIGHT; devx raised no objection.

The ruling's four pins are all present at the head: a body job scheduled on install-local hot and after a restart (CLI integration, cloud-connection install and rehydrate); a handler-only enabled job refused; a package with no jobs installs unchanged; the boot path still schedules a handler job (control). The patch-round pins (uninstall hot and after restart, dropped-job reinstall, another package's job running throughout) are present too, with the ablation legs A to D reported blob- and dist-proven, and leg C repeated on the final head after PR #21581's withdrawal.

Nothing under ① is judged WRONG.

② Semver level

Clause-②: yes (narrowing)

  • The arm is right. The install-local door's accept set narrows (a package it used to accept with 200 is now refused), which is the BREAKING arm; the job-body scheduling is the widening half and rides under it. The PR body and the changeset carry the same line, line-initial.
  • The changeset .changeset/21489-job-bodies-install-local.md grades @objectstack/runtime, @objectstack/cloud-connection, @objectstack/cli and @objectstack/spec as minor, with a **BREAKING** banner, the FROM-to-TO route for a refused package (give each enabled job a body, or boot with os start --artifact), and the ADR-0087 marker not-required (no-migration-prescription). RIGHT.
    • minor for a breaking narrowing is the launch-window convention scripts/check-changeset-no-major.mjs enforces (breaking ships as minor until GA, the BREAKING banner is the carrier); the changeset says so in its own words. Check Changeset is success.
    • The level axis holds: yes takes at least minor on a package whose published source moves; runtime (new exports, a new origin kind, new behaviour) and cloud-connection (the narrowing) both qualify. cli publishes an output-line change; spec publishes a describe text and a shipped ledger flip (liveness/ is in files[]). All four are published packages at 17.6.0; the fixed group takes the highest bump anyway.
    • The ADR-0087 disposition is right on the facts: no authorable key, spelling, export or stored shape moves (JobSchema.body landed in spec(system): JobSchema gains a sandboxed L2 body and deprecates handler; a body job has one time limit #21538), so objectstack migrate meta has nothing to rewrite; the route text is an authoring remedy, not a key-rewrite prescription. registered / already-registered / unpublished / runtime-interface-only / type-surface-only are each closed in the marker's prose.
  • skip-changeset does not apply and was not used. No packages/*/CHANGELOG.md or content/docs/releases/ edit. check:api-surface has nothing to record (no spec export moves); the authorable surface is unchanged.

③ Boundary flags

Every dev flag and open_questions entry, answered or escalated.

Round 1 deviations (5969208762):

  • packages/spec/liveness/job.json plus regenerated state-counts/job.md outside the dispatch's lane: ANSWERED, dispositioned by the seat and declared to domain:spec (5969245751); judged right under ① item 20.
  • docs/qa anchor repoint: ANSWERED, declared to domain:devx, no objection (5969373369).
  • quickjs-runner.ts and index.ts outside the expected list; the second entry point scheduleAppArtifactJobs; no env override for the job default: ANSWERED, accepted by the seat; each judged right under ① items 7 and 13.
  • origin/main merged (three times across the rounds), no rebase, no force-push: ANSWERED, ordinary.
  • The harness trailer reminder: ANSWERED by the AGENTS.md exemption (commits carry the model-free pair; the PR footer is the session-URL form).

Round 1 open question (four published texts made false): ANSWERED. First routed A by the REWORK, then reversed by the amendment so the texts ride this PR; all four are in the diff (① items 19 and 21) with no build route in any wording.

Round 2 deviations (5970071293): the container restart's re-runs (everything re-run on the final head, commits already remote); the pin's packages writing into a host-owned object after PR #21581's withdrawal (the right instrument: a failed write would prove nothing about the job stopping); the third main merge. ANSWERED, accepted; judged right. open_questions: [].

Out-of-scope findings, each dispositioned:

  • The deprecated handler-form hook that install-local drops silently: filed as [finding] os package install accepts a package whose hook uses only the deprecated function-name handler (no body), answers "installed", and the hook never fires: install-local drops it with a server-side warn only #21585 per the ruling's direction. CLOSED here.
  • An uninstalled package's job kept running: fixed in the patch round, pinned and ablated. CLOSED.
  • ESCALATED (unfiled trap, for the domain:cli seat to file): a job is identified by its name alone on IJobService, and install-local now schedules jobs from many packages, so a later-installed package declaring a job name another package already scheduled silently replaces that job's handler and takes the name over; the ownership record follows it, so the first package's job is gone without a line at its door. Before this PR only the boot config scheduled package jobs, so the cross-package reach is new. It matches the recorded IJobService contract and the ruling asked for no namespacing, so it is not a defect of this PR; it is a reproducible metadata-authoring trap under Prime Directive 10 and should carry its own card (the fix candidates are a per-package namespace on the scheduled name, or a refusal at the door when a name is already owned by another package).
  • ESCALATED (residual silence on the same door, outside C's ruled scope): the door judges "has a body", not "can bind the body". A package whose job body is off-spec (an L1 expression, or a body.timeoutMs), which os validate already refuses, passes step 1c, and the binder then warns server-side and schedules nothing: installed 200, never run. The door runs no whole-manifest schema parse ahead of 1c today, which is pre-existing door behaviour. For the seat to decide whether the door should also judge bindability (the binder's own jobBodyRunnerFactory already says why) or run the package through its schema first.
  • Pointer fact 5 (allowRuntimeCreate: false for job is justified by handler alone in metadata-plugin.zod.ts and the lifecycle docs page): ESCALATED to the domain:spec and domain:devx lanes as a rationale-text refresh. The decision stays right (no door schedules a runtime-authored job), only its stated reason is now incomplete.
  • The CLI's catch-all 404 rendering, and a handler-form hook's engine.resolveFunction fallback that could bind to another app's same-named function: NOTED, pre-existing, the latter belongs with [finding] os package install accepts a package whose hook uses only the deprecated function-name handler (no body), answers "installed", and the hook never fires: install-local drops it with a server-side warn only #21585's family.
  • packages/qa/dogfood/test/expression-conformance.ledger.ts prose naming the old call site: NOTED, prose drift with no gate, for the qa lane when next touched.

Cross-lane state read: no objection from domain:spec or domain:devx on the card before this record; the amendment's "an objection goes on #21489 before PR #21584 enqueues" clause is satisfied as of this reading.

Implemented-by: claude/issue-21489-job-bodies
Reviewed-by: session_016GiHYRmLSNWTfbX9gVQkpz

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 14:37
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 14:37
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 6c5697d Oct 3, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21489-job-bodies branch October 3, 2026 15:05
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 protocol:system size/xl tests tooling

Projects

None yet

2 participants