Skip to content

fix(cloud-connection): an install-local uninstall withdraws the package from the running kernel - #21581

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21576-uninstall-withdraws-registration
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21576-uninstall-withdraws-registration

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21576

Clause-②: no

What this changes

Files:

  • packages/cloud-connection/src/marketplace-install-local-plugin.ts: the uninstall path only. That is handleUninstall, the new withdrawFromRunningKernel, the corrected header block and the corrected log line.
  • packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts: a new unit pin, 6 cases.
  • packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts: order 3's two it.fails promoted to plain it, a hot-object case in orders 1 and 2, and a new order 4.
  • .changeset/21576-install-local-uninstall-withdraws-registration.md: @objectstack/cloud-connection patch, Clause-②: no.

A1: reproduction on 74281a8e4a

  • I ran order 3 with its two it.fails changed to plain it, on packages built in this worktree. It read Tests 2 failed | 11 passed (13). The 13 includes one temporary measurement case, used for A4 below. The two red cases are exactly the two set readings:
    • the other package's hot install re-projects the set;
    • the set survives the restart.
  • Then I restored the file: its blob equals HEAD's (72bd1cc16e), and git status --porcelain was empty.
  • After the fix, the same two cases are plain greens, and the grant-stays-revoked case stays green.

A2: how the door reaches the registry

  • deletePackage reaches the registry as this.engine.registry.uninstallPackage(packageId). this.engine is the ObjectQL engine the protocol is assembled over (assembleMetadataProtocol(ctx, this.ql, …) in packages/objectql/src/plugin.ts).
  • The same plugin registers that engine as the objectql service. The manifest service, which this door installs through, calls ql.registerApp on it.
  • So the door reads ctx.getService('objectql').registry and calls uninstallPackage(manifestId). The call is typed by the spec contract IObjectQLEngine.registry (EngineSchemaRegistryView.getPackage / uninstallPackage), with no as any.
  • It uses one verb, and there is no second unregistration mechanism.
  • The id is the manifest id, which is the key installPackage files the record under. The cleanups get the same id.

A3: order and failure

The order is: ledger removal, then the withdrawal, then the cleanups. The withdrawal is synchronous, and nothing is awaited between it and the ledger removal. It goes first for three reasons:

  1. deletePackage uses the same order: the registry withdrawal, then runUninstallCleanups.
  2. It closes a window. The cleanups await the store row by row. If the package were still registered while they ran, a concurrent hot install's metadata:reloaded could re-project the sets they had just removed.
  3. The one cleanup registered today, security.package-permissions, loses nothing. It selects by package id in the store and never reads the registry. Measured: orders 1 and 2 still report it as success: true, and the set and the grant are revoked.

Nothing is withdrawn when the uninstall did not happen. The unit pin covers each case:

  • a refused caller (401);
  • an id this door never installed (404), even one the running registry holds;
  • a failed ledger write (500).

When the withdrawal fails:

  • If the withdrawal throws (ADR-0029: another package extends an object this one owns), or the registry cannot be asked, the response carries one failed outcome named registry.uninstallPackage in cleanups. That is the way a failed cleanup is reported.
  • The cleanups still run, and the request still succeeds, because the ledger entry is already gone.
  • The operator log names the cause and the remedy. The thrown text stays in the log and never reaches the wire.

When the registry does not hold the package (for example, a cloud install whose hot-register failed), there is nothing to withdraw: no call and no outcome.

A4: what uninstallPackage withdraws, and what the object doors answer

From SchemaRegistry.uninstallPackage (packages/objectql/src/registry.ts), the verb withdraws:

  • the package's object contributions, including the overlay layer over an owned object;
  • its namespace;
  • every metadata item keyed to the package (unregisterItemsByPackage);
  • its boot disable seed;
  • the package record.

It does not touch tables or rows.

The object doors' answer moves, as intended. These are the package object's answers on the data route, read hot in the same process right after the DELETE:

order 1 order 2 order 3 order 4
before the fix (74281a8e4a, measured) 200, empty list 200, empty list 200, empty list (the order is new)
after the fix (pinned) 404 404 read, not asserted 404

After the fix, the hot answer carries the same error code the object answers after a restart. This is the consequence of "withdraws the package from the running registry". Orders 1, 2 and 4 pin it.

The reinstall control. I added order 4: hot install, then DELETE, then a hot reinstall of the same package in the same process. The object answers 200 again, and the set is projected again, exactly once, as the package's own. So the install path puts back everything the withdrawal removes.

One more value moves with the registry state. A same-process reinstall after an uninstall is now classified as a fresh install, so the install answer's upgradedFrom is null instead of previous-marketplace-version. A reinstall after a restart already answered null. No key moves, no value leaves the existing set, and nothing in this repository reads the field.

A5: the note

  • Before: "… The app remains loaded in the running kernel until the next restart (the kernel API does not support unregistering apps in-place)."
  • After: "Cached manifest removed, the package withdrawn from the running kernel, and the uninstall cleanups this runtime's plugins registered ran — each one's outcome is in cleanups."
  • When the withdrawal fails, the note says the package stays loaded until the next restart, and points at the registry.uninstallPackage entry.
  • The file header and the info log line are corrected the same way. Nothing parses the note.

A6: reverse verification

The mutation. scripts/ablation-replace.mjs replaced the anchor registry.uninstallPackage(manifestId); with a marker statement that logs ABLATED_21576_withdrawal. The anchor went x1 to x0 and the replacement x0 to x1. The blob changed from 7667b86205 to 83d263078b.

The build. I rebuilt @objectstack/cloud-connection. ablation-dist-preflight found the marker in dist/index.js and dist/index.cjs.

The readings:

The restore. ablation-replace restored the file: its blob equals HEAD's 7667b86205, and git diff HEAD is empty. After a rebuild, ablation-dist-preflight --absent found the marker absent from all 6 built files, with the whole tree clean.

Tests and gates, at 9f3e65608d

  • Integration file, on packages built in this worktree with no dist overlay (turbo build of @objectstack/cli^..., then @objectstack/cloud-connection rebuilt): Test Files 1 passed (1) / Tests 16 passed (16).
  • @objectstack/cloud-connection full suite: 34 passed (34) files, 420 passed (420) tests.
  • @objectstack/cloud-connection typecheck: both programs are green, and --listFiles shows the new test file in both.
  • @objectstack/cli: typecheck is green, including check:test-typecheck, and the tier partition pin passes 22/22. The only packages/cli change is the integration-tier file above, which ran in full. The rest of the unit tier is declared to CI.
  • dispatch-gates --commands: it derives 64 commands, and all 64 exit 0.
    • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET, because a whole-repo dist/ was missing. After pnpm build it exits 0.
    • The --ran reconciliation with an exit code per line reads "64 run, 0 NOT-MEASURED (a DERIVED zero)".
    • Two path-matched families take a value from the workflow and cannot run locally: check-issue-citations --census and check-shard-attestation. NOT MEASURED, left to CI.
  • pnpm lint (eslint . --no-inline-config), the full run with no narrowing: exit 0.

Acceptance notes

  • A collision edge, not handled. Suppose a ledger entry's id is also a config-defined app's id. The install door refuses to create that (MANIFEST_CONFLICT), but an older ledger can still overlay the app at rehydrate. The DELETE of that entry now withdraws the id from the running kernel until a restart registers the config app again. The [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 cleanups already removed that id's package-managed sets. Carrier: none.
  • State outside the registry is untouched by the withdrawal, as it was before: the package's tables and rows, the handlers bindArtifactHandlers bound, and the i18n bundles and seed datasets stashed at install. Once the objects are withdrawn, the bound handlers have no object route to fire through. This PR does not change any of it, and I did not measure it further.
  • An existing unit fake. The objectql.registry fake in marketplace-install-local-id-gate.test.ts carries only getAllPackages. So its DELETE case now reports a failed registry.uninstallPackage outcome. That case asserts the status, success and the ledger removal, and all three still hold. I left it as is.
  • A failed withdrawal cannot be retried through this door, because the ledger entry is already gone. The remedy is the restart, and the log line names it.

Generated by Claude Code

claude added 3 commits October 3, 2026 11:35
…ge from the running kernel

The install-local DELETE removed the ledger entry and ran the protocol's
uninstall cleanups, but left the package registered in the running kernel
until the next restart. Every reader of "registered packages" kept counting
it, and one of them re-created a ghost grant: another package's hot install
announces metadata:reloaded, the declared-permission seeding re-runs over
every registered package, and the uninstalled package's permission set came
back as a package-managed row that outlived the restart as an orphan.

The DELETE now withdraws the package through SchemaRegistry.uninstallPackage,
the one verb the protocol's own uninstall uses, on the objectql engine's
registry, right after the ledger removal and before the cleanups. A refused
withdrawal is reported on the response as a failed outcome in `cleanups`,
with the cause and remedy in the operator log. The response note no longer
says the kernel cannot unregister in place.

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com>
…withdrawal and its reinstall control

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com>
… its refusals; changeset

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m 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/cloud-connection, touching 5 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via MarketplaceInstallLocalPlugin (symbol, a top-level class))
What this run could not see
  • 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 — 3 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 cc645f2385b3410e0f00a4d5c6ae4b0fae14b69f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json cc645f2385b3410e0f00a4d5c6ae4b0fae14b69f

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 12:27
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 12:27
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 901e7cf Oct 3, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21576-uninstall-withdraws-registration branch October 3, 2026 12:47
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/m tests tooling

Projects

None yet

2 participants