Skip to content

Commit 045b946

Browse files
fix(runtime,cloud-connection)!: install-local refuses a hook with no body and a job body that does not bind, and withholds such a hook on rehydrate (#21585) (#21615)
Fixes #21585 Clause-②: yes (narrowing) Triage's retriage answer `5970803032` (Q1: A), re-claimed in `5970905860`. This is #21489's ruling C applied to hooks and completed on rehydrate, with the off-spec job `body` folded in. ## What changes **The install-local door (`packages/cloud-connection`, `POST /api/v1/marketplace/install-local`, `os package install`)** refuses two more kinds of package it used to install with a 200. The refusal sits in the same step as the enabled-job refusal and gives the same response. - **A hook with no `body`.** A function-name `handler` names code that travels only in an artifact's runtime module, never in the JSON this door installs. So on this door such a hook could never bind to the package's own code. It used to install and then either never fire, or bind by name to a function the package does not ship. - Every hook is judged, since a hook has no on/off switch. - A hook carrying both `body` and `handler` installs, because its body wins, as in the binder. - **An enabled job whose `body` does not bind.** The door used to judge only that a job `body` was present. It now judges that the body binds, using the declaration's own parse of `JobSchema.body`. - An expression (L1) body, or one carrying `body.timeoutMs`, is refused instead of installing and never being scheduled. - **One answer:** `422 VALIDATION_ERROR`, the code the job refusal already used. - It names everything the door cannot run: each hook and its handler, each job and its handler, and each refused job body with the key the declaration refuses. - The remedies are a `body`, or `os start --artifact`. - Nothing is registered, persisted, bound or scheduled. - `os package install` renders it unchanged, with no special case: `Install failed (422 VALIDATION_ERROR): …`. - No ledger edit: `VALIDATION_ERROR` fits under the ledger's admission rule for a generic validation condition. The reasoning is in the constant's doc. **The ONE binder (`packages/runtime/src/app-artifact-handlers.ts`)** owns both judgements, so the door and the binder cannot disagree: - `collectHooksWithoutBody`: a hook whose `body` is not a body object. This mirrors the engine binder's own body-first test, so exactly the hooks whose `handler` the engine would resolve by name are named. - `bindAppArtifactHandlers` takes `withholdHooksWithoutBody`, which only install-local sets because it carries no runtime module. - Under it, a hook with no `body` is warned by name and **not bound**. - It fires only on the **rehydrate** of an entry an earlier build installed. The install route refuses such a hook first. - The result reports `withheldHooks`. - Install-local warns if the runtime it runs on predates the option. - `collectJobsWithoutBody` (the landed name, kept) also names an enabled job whose `body` does not bind: an expression body, or one carrying `body.timeoutMs`. Its TSDoc says so. It reads `judgeJobBody` (`sandbox/body-runner.ts`), a parse against `JobSchema.shape.body`. - The job body factory now binds by that same judgement, replacing its own parse with a separate `timeoutMs` check. - Its warn still says `invalid job.body shape — the job is NOT scheduled`, and now carries the declaration's sentence. - The export names `collectJobsWithoutBody` and `JobWithoutBody` are unchanged from `main`, because the shipped job liveness ledger anchors `job/enabled`'s evidence on that symbol. This round's first head renamed them, and CI's `@objectstack/spec` repo tier caught it. The landed names are restored, with no alias and no `packages/spec` edit. **Docs:** `content/docs/automation/jobs.mdx` now says the install door also refuses an enabled job whose `body` does not bind. No hand-written page states how a hook's `handler` behaves on `os package install`, so there was no hook sentence to correct. **Unchanged:** - `os start --artifact` and a `defineStack` config carry their runtime module: an app's own handler hooks bind to its own functions exactly as before. This is pinned at the public door and in the binder unit. - The metadata door is unchanged. - No `objectql`, `packages/spec` or error-code-ledger edit. - Owner-scoped function resolution on every door is the maintainer's decision #21604, which remains open and is not done here. - `scheduleAppArtifactJobs`'s scheduling identity is untouched. #21602 is held behind this PR. ## Measured before These readings were taken on `main` `6c5697dffb` through the public door, plus one unit reading on one engine, in os-dev report `5970542332`. - A package with a hook in the deprecated `handler` form and no `body` installed with exit 0. - When the name resolved nowhere, the hook never fired. - When another app in the same runtime had registered a function under that name, the hook bound to that app's code, hot and after a restart. - An expression job body and a `body.timeoutMs` job body each installed with exit 0 and were never scheduled. - Only a server warn said any of this. ## Pins | Pin (triage `5970803032`) | Where | |:---|:---| | The cross-app shape is refused at install with a non-zero exit | `packages/cli/test/package-install-local-hooks.integration.test.ts` (exit 1, `Install failed (422 VALIDATION_ERROR)`, names hook and remedy, nothing in the ledger); `cloud-connection` `marketplace-install-local-hooks.test.ts` (422, nothing registered, persisted or bound) | | A pre-existing entry rehydrates with the hook unbound and warned | the CLI integration pin, second boot over a ledger entry in an earlier build's layout: the record carries only the body hook's stamp, and the warn names the hook; `cloud-connection` rehydrate unit; `runtime` binder unit on a real `ObjectQL` engine (another app's function of the same name never runs) | | The body-hook control installs and fires | CLI integration (exit 0, stamp `body`); cloud-connection unit (bound under `app:MANIFEST_ID`) | | An `--artifact` app's own handler hooks still bind | CLI integration (the host artifact's own handler hook fires from its runtime module); runtime binder unit (no option: own function binds) | | Each off-spec job-body shape is refused; a valid body job installs and runs | CLI integration (expression body and `body.timeoutMs` each exit 1 naming the key, 0 rows; a valid body job exits 0 and writes rows); cloud-connection jobs unit; runtime collector unit, including that every job named is not scheduled and every enabled body job not named is | ## Reverse verification Each leg went through `scripts/ablation-replace.mjs` in WRAP mode, with its restore trap held by the tool. Each was rebuilt, then checked with `scripts/ablation-dist-preflight.mjs`, then measured. Each restore was proven by blob equals HEAD and an empty `git diff HEAD`. Each was rebuilt again and checked with the preflight in the opposite mode, and finished with the whole tree clean. All were taken from committed `e961c7f5d5`, before the main merge. | Leg | Anchor → mutation | Blob | dist preflight | Went red | Restore | |:---|:---|:---|:---|:---|:---| | (a) the hook refusal (door) | `if (unrunnable.jobs.length > 0 \|\| unrunnable.hooks.length > 0) {` → drops the hooks term | `83b6de87a2b1` → `64f0fb4337e1` | `--absent 'unrunnable.hooks.length > 0'`: absent from all 6 built files (it was in pristine dist, 1 hit in `index.js`) | cloud-connection hooks unit 2/6 (the two refusals); CLI integration 1/8 (the refusal; the CLI printed `Package installed`) | blob `83b6de87a2b1` == HEAD, diff empty; rebuilt: marker back in 2 built files, tree clean | | (b) the rehydrate withholding (binder) | `if (options.withholdHooksWithoutBody) {` → `… && String('ABLATED_21585_B') === '') {` | `8879aeae73fd` → `ee32244b3f9a` | marker present in `dist/index.js` and `index.cjs` | runtime binder unit 1/9; cloud-connection rehydrate unit 1/6; CLI integration 2/8: the rehydrated record read `hostbody`, so the cross-app binding is back, and no warn appeared | blob == HEAD, diff empty; rebuilt: marker absent from all 6, tree clean | | (c) the job-body bindability check (collector) | `if (judged.binds) continue;` → `if (judged.binds \|\| String('ABLATED_21585_C') !== '') continue;` | `8879aeae73fd` → `9e40620a0d48` | marker present in `dist/index.js` and `index.cjs` | runtime collector unit 3/26; cloud-connection jobs unit 2/11; CLI integration 2/8 (both off-spec shapes installed) | blob == HEAD, diff empty; rebuilt: marker absent from all 6, tree clean | The direction was as expected on every leg: red. ## Verification (at `c7144b68bc`, after merging `main` `83b3d32020`) - The test that went red in CI on `1b41b79d2d` is `pnpm --filter @objectstack/spec exec vitest run --project repo scripts/liveness/evidence.test.ts`: 42 passed. The whole `pnpm --filter @objectstack/spec test:repo`: 51 files, 879 passed. - `pnpm --filter '@objectstack/cli...' build` under `os-verify-lock`: VERDICT command-exit 0. - `@objectstack/runtime`: `typecheck` green, including `check:test-typecheck`. Full `test`: 318 files, 4495 passed, 19 skipped. - `@objectstack/cloud-connection`: `typecheck` green. Full `test`: 36 files, 437 passed. - `@objectstack/cli`, `--project unit`: 255 files, 3745 passed. - The install-local integration pins, on built packages: - `package-install-local-{hooks,jobs}`: 19 passed. - `package-install-local-{handlers,boot-steps,uninstall-cleanups}`: 40 passed. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 93 commands, all run, including the docs families the `jobs.mdx` edit brings in. `--ran` reports 93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN. - `check:skill-examples` and `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`, unrelated packages not yet built). Both exited 0 on rerun once a later gate in the run had built those packages. The record carries the reruns. - `main` moved to `5c9138b4b6` after this merge (`objectql` and `driver-sql`). The derivation did not call the tree stale, so the branch was not merged again. - Roster gates whose roster sits in a touched directory, all exit 0 on the first head: `check:error-code-casing`, `check:route-ledger-census`, `check:authz-resolver`, `check:filter-alias-parity`, `check-changeset-fixed`, `check:engine-double-contract`, `check:error-status-conformance`. - Full `pnpm lint` at `c7144b68bc`: exit 0. - The reverse-verification table above was taken on `e961c7f5d5`. The name restoration changes no logic, and the anchors it used are unchanged. ## Acceptance notes - A hook whose `body` IS an object the hook body runner refuses is warned and not bound on every door. The binder does not fall back to its `handler`. The door does not refuse that shape: the ruling folded in job-body bindability only. Observation; carrier: none. - The rehydrate pin's ledger entry is written in the layout the install route persists, because no earlier build exists in the tree to write it. - The contract review of record is owed before enqueue (claim `5970905860`). --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e367002 commit 045b946

11 files changed

Lines changed: 1276 additions & 96 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/runtime': minor
3+
'@objectstack/cloud-connection': minor
4+
---
5+
6+
fix(runtime,cloud-connection)!: install-local refuses a hook with no `body` and a job `body` that does not bind, and withholds such a hook on rehydrate (#21585)
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) no authorable key, spelling, export of a published release or stored shape moves: `HookSchema` and `JobSchema` are unchanged, so `objectstack migrate meta` has nothing to rewrite. What changes is which packages one install door accepts, and which hooks it binds on a rehydrate. The other categories are closed on facts: the packages publish (not `unpublished`); no ADR-0087 id covers a refused install or a withheld hook (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
11+
12+
**BREAKING**: `os package install` (the install-local door, `POST /api/v1/marketplace/install-local`) now refuses two more kinds of package it used to install with a 200:
13+
14+
- **A hook with no `body`.** A hook in the deprecated function-name `handler` form names code that travels only in an artifact's runtime module, never in the package JSON this door installs. Such a hook used to install and then either never fire or bind by name to a function the package does not ship. Every hook is judged, since a hook has no on/off switch. A hook that carries both a `body` and a `handler` installs as before: its `body` wins.
15+
- **An enabled job whose `body` does not bind.** The door used to judge only that a job `body` was present. It now judges that the body binds, by the declaration's own parse of `JobSchema.body`, the same parse the scheduler binds by. So a job whose `body` is an expression (L1) body, or carries `body.timeoutMs`, is refused instead of installed and never scheduled.
16+
17+
- **The refusal.** The install answers `422` with `VALIDATION_ERROR`, the answer the door already gives an enabled job with no `body`. One answer names everything the door cannot run: each hook and the function its `handler` names, each job and its handler, and each refused job `body` with the key the declaration refuses. Nothing is installed: nothing is registered, persisted, bound or scheduled. `os package install` exits non-zero and prints the code beside the status.
18+
- **Rehydrate.** A package installed by an earlier version keeps rehydrating after a restart. Its body hooks bind as before. A hook of it with no `body` is reported at `warn` by name and is **not bound**: this door carries no runtime module, so the hook's `handler` can never name the package's own code. Its job with no runnable `body` is reported and not run, as before.
19+
- **Runtime.** The binder exports the two judgements the door reads: `collectHooksWithoutBody`, and `collectJobsWithoutBody`, which also names a job whose `body` does not bind. `bindAppArtifactHandlers` takes `withholdHooksWithoutBody`, which a door that carries no runtime module sets, and reports the hooks it withheld as `withheldHooks`.
20+
- **Unchanged:** a boot that loads the artifact's runtime module (`os start --artifact`, a `defineStack` config) binds an app's handler hooks to its own functions exactly as before. Hooks authored through the metadata API are unchanged too. A package whose hooks carry a `body` and whose enabled jobs carry a valid `body` installs exactly as before.
21+
22+
The route for a refused package: give each hook a `body` (sandboxed JS, the form actions and jobs use), and correct each job `body` to the declared shape. That shape is a sandboxed JS body whose time limit is the job's own `timeoutMs`, and `os validate` reports the same refusal. Alternatively, boot the artifact with `os start --artifact`, which loads its runtime module. This ships as `minor`, under the launch-window convention for narrowings of an accept set.

‎content/docs/automation/jobs.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,9 @@ export const CloseStaleTasksJob = defineJob({
170170
job's `body` through the same binder. A `handler` is code: it travels only in the
171171
artifact's runtime module, so it runs only on a boot that loads that module (a
172172
config, or `os start --artifact`). `os package install` therefore refuses a
173-
package whose enabled job has no `body`, with `422 VALIDATION_ERROR` and the
174-
remedy: give the job a `body`, or boot it with `os start --artifact`.
173+
package whose enabled job has no `body`, or a `body` that does not bind (an
174+
expression body, or one carrying `body.timeoutMs`), with `422 VALIDATION_ERROR`
175+
and the remedy: give the job a valid `body`, or boot it with `os start --artifact`.
175176
Uninstalling a package stops its scheduled jobs at once, and a reinstall whose
176177
new version drops a job stops that job.
177178
</Callout>

0 commit comments

Comments
 (0)