Skip to content

Commit 83e2fee

Browse files
fix(runtime,cloud-connection)!: install-local refuses an enabled job whose pull does not bind (#21672) (#21683)
Fixes #21672 Clause-②: yes (narrowing) Built to triage `5975994778` (direction) and `5976336256` (unlocked once PR #21668 landed as `909229e976`), dispatched under claim `5976423110`. This is PR #21615's shape: one pull clause in the existing unrunnable judgement, read from the same `judgeJobPull` the binder schedules by. There is no second judge. ## What changes **The install-local door (`packages/cloud-connection`, `POST /api/v1/marketplace/install-local`, `os package install`)** now refuses a package whose enabled job declares a `pull` that does not bind. It used to install it with a 200, and the binder then warned and never scheduled the job. - "Does not bind" is exactly `judgeJobPull`'s answer: the `pull` names a mapping the package does not declare, or a mapping with no `connectorSource`, or the job declares `body` or `handler` beside the `pull`. - **One answer:** `422 VALIDATION_ERROR`, the code and status the door already gives a job `body` that does not bind. No new error code. - `describeUnrunnable` gains a pull clause beside the body clauses. It names each such job with the refusal `judgeJobPull` gives (`pull.mapping: …`), and the remedy: declare the mapping with a `connectorSource`, or correct the `pull`. `os validate` refuses the same `pull`. - A pull job is never described as a job with no `body`: that would send the author to write a `body` beside the `pull`, the shape the declaration refuses. - Nothing is registered, persisted or scheduled. `os package install` exits 1 and prints `Install failed (422 VALIDATION_ERROR)`. - A **disabled** pull job does not block its install, as a disabled body job does not. - A pull job naming a declared mapping with a `connectorSource` installs and is scheduled, as before. **The ONE binder (`packages/runtime/src/app-artifact-handlers.ts`).** - `collectJobsWithoutBody` (the landed name, kept: `packages/spec/liveness/job.json` anchors on it) now judges a job that declares `pull` by calling `judgeJobPull(job, bundle)`, the function the binder calls before it schedules a pull job. A pull that binds is not named. A pull that does not bind is named with its refusal. - `JobWithoutBody` gains one optional field, **`pullRefusal`**: the refusal `judgeJobPull` gives. A job carrying it carries no `bodyRefusal`, since a `pull` is judged before any `body` beside it, as in the binder. - The binder itself is unchanged. Its TSDoc now says install-local refuses the shape up front, so on that door the binder's pull warn fires only on a rehydrate. **Docs:** `content/docs/automation/jobs.mdx` now says the install door refuses an enabled job whose `pull` does not bind. That replaces "A `pull` job is data too, and is not refused". It also says what happens on rehydrate. **Unchanged:** `packages/spec`, `service-automation` and `objectql` are untouched. So are `JobSchema`, `MappingSchema`, the binder's scheduling, the boot door and the error-code ledger. ## A pending release note this change makes false, corrected here (confirmation requested) `.changeset/20281-job-pull-organization.md` (PR #21668, not yet released) says: > `collectJobsWithoutBody` no longer names a `pull` job, so `os package install` does not refuse one. This PR makes that false. It now reads: > `collectJobsWithoutBody` does not name a `pull` job that binds, so `os package install` installs one. Nothing else in that note changed. `check-empty-changeset` names this case its DELIBERATE CORRECTION class, so **`Check Changeset` stays red on this PR by design**. That context is not required. Its own text asks for the correction to be confirmed in writing on the PR, and ⛔ never `skip-changeset`. Restoring the note from the base would ship the false sentence in the same release as this PR's own changeset. ## Measured before (A1), at the public door, on `origin/main` `eed2dee481` Measured with the new integration file below against unmodified runtime and cloud-connection `dist/`, as part of the CLI's dependency closure built at `eed2dee481`: - A pull job naming an undeclared mapping (`orders_pul`) installed with exit 0: `Package installed into the running kernel`. - The server said, at `WARN`: `[MarketplaceInstallLocal] job pull does not bind — the job is NOT scheduled: pull.mapping: this artifact declares no mapping 'orders_pul' — …`, with `{"appId":"com.example.pullmissing","job":"pull_missing_orders"}` on the line. - A pull job whose mapping has no `connectorSource` installed the same way, and the warn said `pull.mapping: mapping 'orders_pull' declares no connectorSource, so there is nothing to pull — …`. - Both packages were in the install-local ledger, and neither job was ever scheduled (no `sys_job` row). - The file's three refusal pins went red and its five controls went green. ## One judge (A2) - The collector calls `judgeJobPull(job, bundle)`. That is the same function, with the same arguments, that `scheduleAppArtifactJobs` calls before it schedules a pull job. The door only formats the `pullRefusal` it is handed, and does not re-judge or paraphrase the question. - Pinned in the runtime unit: on one bundle, every pull job the collector names is one the binder does not schedule. Every enabled pull job it does not name, the binder schedules. Each named job's `pullRefusal` is the exact tail of the warn the binder logs when it withholds that job. - `collectJobsWithoutBody` and `JobWithoutBody` are not renamed. `packages/spec/liveness/job.json` anchors `job/enabled` on the collector and the `pull` row on `judgeJobPull`. Both rows are unchanged, and `scripts/liveness/evidence.test.ts` passes (42). ## Rehydrate (A4): it already held, so it is pinned, not coded On `origin/main` the binder's existing skip already withheld a non-binding pull job of a persisted entry and warned with the job's name in the line's meta. The rehydrate pins in the integration file were green before the fix and stay green after it. No rehydrate code was added. ## Pins | Pin | Where | |:---|:---| | An undeclared mapping is refused at the public door | `packages/cli/test/package-install-local-jobs-pull.integration.test.ts`: exit 1, `Install failed (422 VALIDATION_ERROR)`, names the job, `pull.mapping: …` and `os validate`; not in the ledger, no `sys_job` row. `cloud-connection` `marketplace-install-local-jobs.test.ts`: 422, nothing registered, persisted or scheduled (not even a valid body job beside it), and the no-`body` clause is not used. | | A mapping with no `connectorSource` is refused the same way | CLI integration (exit 1, names the job and the reason); cloud-connection unit | | One answer names every kind | cloud-connection unit: a handler-only job and an unbindable pull job in one 422 | | Control: a declared mapping installs and is scheduled | CLI integration: exit 0, its `sys_job` row, and a `sys_job_run` row per run. Each run reaches the automation service's pull door, which records `failed` because the package declares no `connectors[]` entry. That is the run's verdict, not the install's. cloud-connection unit: 200, scheduled, and a run calls `pullConnectorSource` with the mapping. | | A disabled unbindable pull job installs | CLI integration (exit 0, no `sys_job` row); cloud-connection unit | | Rehydrate of an entry an earlier build persisted withholds the job | CLI integration, second boot over a ledger entry: the bindable pull job of the entry is scheduled and runs, the unbindable one has no `sys_job` or `sys_job_run` row, and a `WARN` line names it with `pull.mapping: …`. cloud-connection rehydrate unit: the same, with the warn's `job` meta. | | One judge | runtime `app-artifact-handlers.job-pull.test.ts` (the two pins above) | The runtime pin that asserted the old behaviour, `collectJobsWithoutBody never names a pull job`, is replaced by the two collector pins above. ## Reverse verification (A5) One leg went through `scripts/ablation-replace.mjs` in WRAP mode, with its restore trap held by the tool. It was rebuilt, checked with `scripts/ablation-dist-preflight.mjs`, then measured. The leg was taken on committed `413869dfe1`. The door's acceptance condition has no pull-specific term: it refuses on `unrunnable.jobs.length`. So the door's pull clause, as a judgement, is the collector's pull leg, and that is what was ablated. Ablating `describeUnrunnable`'s sentence alone would leave the 422 standing and change only prose. | Leg | Anchor → mutation | Blob | dist preflight | Went red | Stayed green | Restore | |:---|:---|:---|:---|:---|:---|:---| | The collector's pull leg (the door's pull judgement) | `if (judged.binds) continue;` + newline + `pullRefusal = judged.refusal;` → the same with `\|\| String('ABLATED_21672_PULL') !== ''` added to the condition, so every pull job is skipped, as on `main` | `d207bdb16564` → `f4e6ffa8924b` | marker present in `dist/index.js` and `index.cjs` | runtime collector unit 2/21. cloud-connection jobs unit 3/17: both refusals and the one-answer pin. CLI integration 3/8: both refusals, CLI printed `Package installed`, and the ledger pin. | the declared-mapping control, the disabled pull job, and the rehydrate pins (all three layers) | the tool: blob `d207bdb16564` == HEAD, `git diff HEAD` empty. Rebuilt, then `--absent`: marker absent from all 6 built files, tree clean. | The direction was red, as expected. The tool refused a first attempt before running anything: that replacement still contained the anchor, so the anchor count could not drop. It restored the file and nothing was measured. ## Verification (at `413869dfe1`) All runs are at `413869dfe1`, the final commit, with build and test runs under `os-verify-lock`. `origin/main` has since moved one commit, to `7d0781482d`. That commit touches only `.claude/skills/pm-dispatch/references/execution-duties.md`, so this branch was not merged again. - `@objectstack/runtime`: `typecheck` green, including `check:test-typecheck`. Full suite (`vitest run --project local`): 320 files, 4555 passed, 19 skipped. - `@objectstack/cloud-connection`: `typecheck` green, including `tsconfig.test.json`. Full suite: 36 files, 443 passed. - `@objectstack/cli`: `typecheck` green. Its test-layer program compiles the new integration file, counted with `--listFilesOnly` (1 hit). `--project unit`: 257 files, 3771 passed. - The install-local integration pins, on built `runtime` and `cloud-connection` `dist/` (the pull clause present in both door bundles, the ablation marker absent): `package-install-local-{jobs-pull,jobs,jobs-shared-name,hooks,handlers,boot-steps,uninstall-cleanups}`, 7 files, 76 passed. - `pnpm --filter @objectstack/spec exec vitest run --project repo scripts/liveness/evidence.test.ts`: 42 passed. Every touched symbol was grepped across `packages/spec/liveness/**` and `*.ledger.*`: only the two unchanged anchors hit. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; 9 paths vs merge base `eed2dee48`): 93 commands. 92 exit 0, and 1 exits 1 by design: `check-empty-changeset --base origin/main`, the pending release note corrected above. `--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`), because packages outside this diff's closure were unbuilt. Both exited 0 on rerun once those packages were built. The record carries the reruns. - Full `pnpm lint` (`eslint . --no-inline-config`): exit 0, no findings. ## Acceptance notes - `content/docs/references/system/job.mdx` is generated from `JobSchema.body`'s describe in `packages/spec`. It says `os package install` "refuses an enabled job with no `body` (a `pull` job excepted: it is data too)". That stays literally true, because the exception is from the no-`body` refusal. It is not edited, since `packages/spec` is out of this card's surface. The next PR that touches `packages/spec/src/system/job.zod.ts` could add that an unbindable `pull` is refused too. Not filed. - Version skew: a newer `@objectstack/runtime` behind an older `@objectstack/cloud-connection` would describe an unbindable pull job with the no-`body` clause. That is the wrong remedy, though still a 422. The two packages are in one `fixed` release group in `.changeset/config.json`, and the door already tells an operator to upgrade them together. Not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 38bef8c commit 83e2fee

9 files changed

Lines changed: 777 additions & 54 deletions

‎.changeset/20281-job-pull-organization.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Clause-②: yes (widening)
1212
- **`JobSchema.organization`**. The organization a job runs as. It applies to the body's `ctx.api`, to the handler's new `executionContext`, and to the pull's reads and writes. The value shape is the scheduled flow's: a non-empty `sys_organization.id`. A near-miss spelling (`organizationId`, `orgId`, `tenantId`, …) is refused at parse and pointed at the key.
1313
- **`defineStack`, and so `os validate`**, refuses a job whose `pull` names a mapping the stack does not declare, or a mapping with no `connectorSource`. The refusal is the existing `STACK_CROSS_REFERENCE_INVALID` envelope.
1414
- **`IAutomationService.pullConnectorSource`** (`@objectstack/spec/contracts`, with `ConnectorSourcePullRequest`, `ConnectorSourcePullResult` and `ConnectorSourcePullSummary`). The connector sync executor is now on the `automation` service. `@objectstack/service-automation`'s engine serves it from the executor `AutomationServicePlugin` attaches at init (`AutomationEngine.setConnectorPullSource`). A bare engine refuses with `SERVICE_UNAVAILABLE` (503).
15-
- **The job binder** (`@objectstack/runtime`, `scheduleAppArtifactJobs`) schedules a `pull` job on every door: the boot, and `os package install` on install and rehydrate. Each run calls `pullConnectorSource` through the service registry. A refused pull fails the run, and `retryPolicy` applies. A pull whose rows the import runner refused records the run `degraded`, with the counts. A pull naming a mapping the artifact does not carry is not scheduled, and neither is one whose mapping has no `connectorSource`, nor one on a kernel whose `automation` service cannot pull. Each case is logged at `warn` with the reason. `collectJobsWithoutBody` no longer names a `pull` job, so `os package install` does not refuse one. The result gains `pulls` and `missingOrganization`.
15+
- **The job binder** (`@objectstack/runtime`, `scheduleAppArtifactJobs`) schedules a `pull` job on every door: the boot, and `os package install` on install and rehydrate. Each run calls `pullConnectorSource` through the service registry. A refused pull fails the run, and `retryPolicy` applies. A pull whose rows the import runner refused records the run `degraded`, with the counts. A pull naming a mapping the artifact does not carry is not scheduled, and neither is one whose mapping has no `connectorSource`, nor one on a kernel whose `automation` service cannot pull. Each case is logged at `warn` with the reason. `collectJobsWithoutBody` does not name a `pull` job that binds, so `os package install` installs one. The result gains `pulls` and `missingOrganization`.
1616
- **The organization, judged at bind** by the posture rule scheduled flows use (`resolveScheduledWorkPolicy`). Every run carries `{ isSystem: true, tenantId: <organization> }`, or `{ isSystem: true }` for a job that declares none. Under `single` the key is not required. Under `group` it is optional; an undeclared job is scheduled and named once at `warn`, because a tenant-scoped row it writes is refused. Under `isolated`, with package-authored scheduled work switched on, it is **required**. **Action on such a deployment:** declare `organization` on each packaged job, or the job is not scheduled; the error log names the job. Until now such a job was scheduled, and every tenant-scoped write it made was refused at the write. An unrecognized `OS_TENANCY_POSTURE` withholds every job (`scheduled-work-policy-unreadable`) instead of guessing whether a declaration is required.
1717
- **Texts this makes true.** The `mapping.connectorSource` description, the `connector.syncConfig` tombstone prescription and the `connector-sync-keys-retired` upgrade entry said "nothing schedules a pull yet". They now name the `job` `pull` that drives it.
1818

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/runtime': minor
3+
'@objectstack/cloud-connection': minor
4+
---
5+
6+
fix(runtime,cloud-connection)!: install-local refuses an enabled job whose `pull` does not bind, as it refuses a job `body` that does not bind (#21672)
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: `JobSchema` and `MappingSchema` are unchanged, so `objectstack migrate meta` has nothing to rewrite. What changes is which packages one install door accepts. The other categories are closed on facts: the packages publish (not `unpublished`); no ADR-0087 id covers a refused install (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 a package whose enabled job declares a `pull` that does not bind. It used to install such a package with a 200, and the job was never scheduled; only a server warn said so.
13+
14+
- **What does not bind.** The `pull` names a mapping the package does not declare, or a mapping with no `connectorSource`, or the job declares `body` or `handler` beside its `pull`. The door judges this with the scheduler's own judgement, so the door and the scheduler cannot disagree. `defineStack` and `os validate` already refuse the same `pull`, so only a hand-edited package reaches the door with one.
15+
- **The refusal.** The install answers `422` with `VALIDATION_ERROR`, the answer the door already gives an enabled job whose `body` does not bind. One answer names everything the door cannot run, and gives each such job the reason its `pull` does not bind, prefixed with the key it names (`pull.mapping: …`). Nothing is installed: nothing is registered, persisted or scheduled. `os package install` exits non-zero and prints the code beside the status.
16+
- **Unchanged.** A pull job naming a declared mapping with a `connectorSource` installs and is scheduled as before. A disabled pull job does not block its install. A package installed by an earlier version still rehydrates after a restart, and its pull job that does not bind is not scheduled, with a warn naming the job and the reason, as before.
17+
- **Runtime.** `collectJobsWithoutBody` now names an enabled job whose `pull` does not bind, and `JobWithoutBody` gains an optional `pullRefusal`: the reason the scheduler gives when it does not schedule the job. Such a job carries no `bodyRefusal`.
18+
19+
The route for a refused package: declare the mapping the job's `pull` names in the package, with a `connectorSource` naming the `rest` or `openapi` connector it reads from, or correct the `pull` as the refusal says. `os validate` refuses the same `pull`. This ships as `minor`, under the launch-window convention for narrowings of an accept set.

‎content/docs/automation/jobs.mdx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ export const CloseStaleTasksJob = defineJob({
176176
package whose enabled job has no `body`, or a `body` that does not bind (an
177177
expression body, or one carrying `body.timeoutMs`), with `422 VALIDATION_ERROR`
178178
and the remedy: give the job a valid `body`, or boot it with `os start --artifact`.
179-
A [`pull`](#pulling-a-mapping) job is data too, and is not refused.
179+
A [`pull`](#pulling-a-mapping) job is data too: it installs when its `pull`
180+
binds, and is refused with the same `422` when it does not.
180181
Uninstalling a package stops its scheduled jobs at once, and a reinstall whose
181182
new version drops a job stops that job.
182183
</Callout>
@@ -249,7 +250,10 @@ export const OrdersPullJob = defineJob({
249250
- **Checked when you build.** `defineStack` — and so `os validate` — refuses a
250251
`pull` that names a mapping the stack does not declare, or one with no
251252
`connectorSource`. The binder checks the same reference against the artifact
252-
before it schedules the job, on every door.
253+
before it schedules the job, on every door, and `os package install` refuses a
254+
package whose enabled job's `pull` fails that check, with `422 VALIDATION_ERROR`
255+
naming the job and the reason. A package an earlier version installed still
256+
loads after a restart; such a job of it is not scheduled, and a warning names it.
253257
- **Each run is one pull.** It calls the connector's read action once, reads one
254258
response, and writes the records through the import runner with the mapping's
255259
`mode` and `upsertKey` — see

0 commit comments

Comments
 (0)