Repository navigation
feat(spec,runtime,service-automation): a job pulls a mapping by declaration (pull: { mapping }) and runs as its declared organization (#20281 stage 3) - #21668
Conversation
…ation and runs as its declared organization (#20281 stage 3) Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…s move; ledger rows for both Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…s two isSystem type declarations Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…ores Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ec3e628438815d62029252e58adddd5261edebef && git checkout ec3e628438815d62029252e58adddd5261edebef
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fea67065a3dfd972a40f95225077cd2e21443d58 36da2bfc8882a5fbbb9d5277f7e37d8a18587f8b && git checkout -B drift-repro fea67065a3dfd972a40f95225077cd2e21443d58 && git merge --no-ff 36da2bfc8882a5fbbb9d5277f7e37d8a18587f8b
node scripts/docs-audit/affected-docs.mjs --json fea67065a3dfd972a40f95225077cd2e21443d58
|
…whose pull does not bind (objectstack-ai#21672) (objectstack-ai#21683) Fixes objectstack-ai#21672 Clause-②: yes (narrowing) Built to triage `5975994778` (direction) and `5976336256` (unlocked once PR objectstack-ai#21668 landed as `909229e976`), dispatched under claim `5976423110`. This is PR objectstack-ai#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 objectstack-ai#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>
…ete_record node whose static objectName is a stored-metadata table, with the runtime's prescription (objectstack-ai#21654) (objectstack-ai#21687) Fixes objectstack-ai#21654 Clause-②: yes (narrowing) The save-time half of objectstack-ai#21624, route A as ruled (triage `5972908953`, the `domain:services` seat's answer `5974847898`). objectstack-ai#21624 remains open until both halves have landed; its seat owns that card. The run-time half is PR objectstack-ai#21649 (`f40bb3217f`). ## What changes - **The refusal.** `flowNodeConfigRefusals` (`packages/spec/src/automation/flow-node-config-refusals.ts`), the one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share, gains a third arm beside the executor-contract arm and the decision arm. A `create_record`, `update_record` or `delete_record` node whose `config.objectName` is a **static string** naming a stored-metadata table, judged by `isStoredMetadataBodyObject` by exact name, is refused at `nodes.N.config.objectName` (any depth, ADR-0031 regions included). The message names the node type and the table, uses the run-time refusal's verbs (create a record in / update / delete from) and ends on the run-time prescription. - **A new closed-set code.** `write-node-stored-metadata-target` joins `FLOW_SLOT_REFUSAL_CODES` with `params: { nodeType, objectName }` (`flow-node-expression-paths.ts`: the code table the judge's return type requires). - **Out of reach on purpose.** A dynamic `objectName` (a `{token}` template, an expression envelope) is not judged at save: the run judges the name it hands the data engine. A `get_record` node is not judged by this arm: a read is not a write. - **One prescription sentence.** The hook refusal's private `STORED_METADATA_BODY_PRESCRIPTION` moved, byte for byte, to the import-free leaf `kernel/stored-metadata-body-objects.ts` as an export. `data/hook.zod.ts` and the new flow arm both import it, and `kernel/metadata-type-redaction.ts` re-exports it beside the family set, so `@objectstack/spec/kernel` publishes it (`api-surface/kernel.json`, `export-origins/kernel.json` regenerated). In `hook.zod.ts` only the import line, the constant and the three comment lines describing it changed. The `handler` doc region that objectstack-ai#21604 holds is untouched. - **The ADR-0087 kit.** The D3 semantic entry `entries/semantic/18.flow-write-node-stored-metadata-target-refused.ts` (its prescription names the metadata protocol), its step-18 rationale fragment at order 74, the next free order on `main` at `7d0781482d` (71 to 73 are taken), and the regenerated `registry.ts` regions. No tombstone (no key is removed) and no D2 conversion (a refused node carries no intent a rewrite could keep). One BREAKING `@objectstack/spec` changeset with the `registered` disposition and the `Clause-②` line. ## Wording, set against the run-time refusal - Run time (`service-automation`, `storedMetadataWriteRefusal`): "create_record: refusing to create a record in 'sys_metadata': it holds stored metadata, and a flow may not write it directly, so the write was not run." Then the prescription. - Save time (this PR): "This `create_record` node's `objectName` is 'sys_metadata', so it would create a record in a table that holds stored metadata, and a flow may not write it directly: every run that reaches the node refuses it before anything is written, and re-running changes nothing." Then the prescription. - One residual difference, in the prescription itself: the run-time node spells "Elevation (`runAs: 'system'`)", while the shared constant (the hook refusal's, now exported) spells "Elevation (`runAs`, a system context)". Both say elevation does not change the outcome. Importing the exported sentence in `service-automation` makes them identical; that is a named follow-up, not done here. ## Census, before any edit At `417443eb27`: **0** write nodes aimed at either family table outside tests, across `packages/**`, `examples/**`, `skills/**`, `content/docs/**` and `docs/**` (229 write-node declarations). The only hits are the run-time half's own pins, `write-nodes-stored-metadata-family-refusal.integration.test.ts` (a static target at lines 270 and 271, and a parameterized one through `configFor`). Those pins are a test of the run-time refusal, not a writer. This PR re-expresses them (next section). The positive control fired: the same windowed search finds those pins and this PR's new tests. ## The run-time pins, re-expressed (claim revision `5977090032`, open question 1 answered A) The run-time half's pins (`packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts`, from PR objectstack-ai#21649) registered static family-target flows through `registerFlow` in order to run them. `registerFlow` parses first, so the save-time refusal turned 12 of their 17 cases red. The PM revised the claim to add this one file, test-only (claim revision `5977090032`; cross-lane declaration `5977094605` on objectstack-ai#21118). No other `service-automation` file changes, and the engine, `crud-nodes.ts` and the runtime are untouched. **The edit.** One new harness step, `registerForRun(def)`, replaces the two direct `registerFlow` calls (`runWatched` and `codeAsAFlowReadsIt`). - A definition with no static family target registers exactly as before. That covers the variable-target cases and the ordinary-object controls. - A definition carrying one is first judged at save. `registerFlow` must throw, with exactly one `custom` issue per family write node at its path: `nodes.1.config.objectName`, or `nodes.1.config.try.nodes.0.config.objectName` for the `try_catch` region flow. Each issue's message must name the metadata protocol. `getFlow(name)` must answer `null` (nothing was registered), and the target table's snapshot must be unchanged. - It then reaches the run-time guard with a definition the parse never judged. The same definition is registered aimed at a stand-in object (`pin_stand_in_target`, which does not exist). The family table is then put back on the parsed definition `registerFlow` returned. `getFlow(name)` must answer the family table at every original path before the run starts. - Every existing run-time assertion is unchanged, byte for byte: the run fails, nothing downstream runs, no engine write reaches the family, the table is unchanged, the flow reads `PERMISSION_DENIED`, a fault edge does not route, and all of it under both identities and both compositions. No assertion was removed or loosened. **The engine behaviour the route relies on, and why it is stable.** Two facts, neither touched by this PR, which changes no `service-automation` source file: - `AutomationEngine.registerFlow` stores the parsed definition it returns, by reference: `this.flows.set(name, parsed)`, then `return parsed`. - `execute` runs `this.flows.get(name)` as stored and never re-parses it. The engine documents `this.flows` as holding only `FlowSchema.parse` output. Both are read back rather than assumed. The `getFlow(name)` assertion above must see the family table at every retargeted path. If the engine ever copied, froze or re-parsed the stored definition, the retarget would fail loudly: a copy or a re-parse leaves the stand-in in place, so the read-back goes red and the run fails with not-found instead of the family refusal; a frozen definition throws on the assignment itself. The route cannot pass silently. **Evidence** (at `a9d2d5d453`): - The pins file: `Tests 17 passed (17)`. - The full `service-automation` suite: `Test Files 170 passed (170)`, `Tests 2098 passed (2098)`, 0 failed. `pnpm --filter @objectstack/service-automation typecheck` exits 0, and `check:test-typecheck` is OK. eslint on the file gives 0 errors and 0 warnings. - **Run-time guard ablation** (`crud-nodes.ts`, predicted 12 red / 5 green). `scripts/ablation-replace.mjs` pointed `storedMetadataWriteRefusal`'s family check at a name nothing matches. The anchor went 1 to 0 and the blob `b9bb559a0c` to `ffe005789b`. No build was needed: the pins reach it through relative `src` imports. Observed `Tests 12 failed | 5 passed (17)`. Every first failure is a run-time assertion ("the run must fail: expected true to be false"), and 0 are save-time assertions. So the save step passed, and the run then wrote, which the pins catch. Restore: blob after restore `b9bb559a0c` equals HEAD's, and `git diff HEAD` is empty; the script's own trap re-confirmed it with 0 diff lines. The first attempt used a one-line anchor that hits twice in the file (once in the `get_record` read refusal). The tool refused it (`ANCHOR AMBIGUOUS`, exit 3) and wrote nothing. The rerun used a longer anchor unique to the write refusal. - **Save-time arm ablation** (spec, predicted 12 red / 5 green). The arm's push became a `globalThis` marker assignment. The anchor went 1 to 0 and the blob `82a173479e` to `ee1614818e`. `@objectstack/spec` was rebuilt, and `ablation-dist-preflight.mjs` proved the marker reached `packages/spec/dist`, because `service-automation` resolves `@objectstack/spec` through `dist`. Observed `Tests 12 failed | 5 passed (17)`. Every failure is the new save-time assertion ("registerFlow must refuse a static family target at save: expected undefined to be defined"). Restore: blob `82a173479e` equals HEAD's and `git diff HEAD` is empty. The rebuilt `dist` was proven free of the marker (`--absent`, exit 0), and the rerun gave `17 passed`. ## Doors, tested and probed (all at `f7d1216a8f` unless noted) - `FlowSchema.parse`: refused at `nodes.1.config.objectName` for each write node and each family table, and at `nodes.1.config.try.nodes.0.config.objectName` inside a region. The message is the judge's own and ends on the leaf's prescription. - `defineStack`: `STACK_SCHEMA_INVALID` / 422 at `flows.1.nodes.1.config.objectName`. `ObjectStackDefinitionSchema` (the stack parse `objectstack validate` runs) refuses at `flows.0.nodes.1.config.objectName`. The registered `flow` type schema (the metadata save door's) and an artifact's parse refuse too. - `objectstack validate`, the real CLI on a temporary fixture (deleted afterwards): with a `create_record` node on `sys_metadata` it gave exit 1, `"code": "STACK_SCHEMA_INVALID"`, and an error naming `flows.0.nodes.1.config.objectName` and the prescription. The same stack aimed at an ordinary object gave exit 0, `"valid": true`. - `registerFlow`: refuses, because it parses first, and a committed pin now says so. The re-expressed run-time pins assert the throw, its path and the unchanged table for every static case (above). The throw comes from `FlowSchema.parse` inside `canonicalizeStoredFlow` (`engine.ts:4346`), which `registerFlow` (`engine.ts:4368`) calls. - Lit controls: each write node on an ordinary object passes. A dynamic `objectName` (`{record.target}`, `{target}`, and the envelope `{ dialect: 'cel', source: ... }`) passes at save, and the judge returns nothing for it. `get_record` on either family table passes. The refused set equals the predicate's by exact name (`SYS_METADATA`, `sys_metadata_draft`, ` sys_metadata` with a leading space, `sys_meta`, `metadata` all pass). ## Ablation of the spec pins (one-shot, not kept) At `f7d1216a8f`, with the implementation committed, `scripts/ablation-replace.mjs` replaced the arm's push line with a no-op plus a marker. On disk the anchor went 1 to 0, the marker 0 to 1, and the blob `82a173479e` to `a832964b45`. No dist rebuild was needed: the tests reach the judge through relative `src` imports. Predicted 17 red / 29 green over the two pin files; observed `Tests 17 failed | 29 passed (46)`. Restore: the tool reported blob after restore equal to the HEAD blob (`82a173479e`) and `git diff HEAD` empty. The script's own EXIT/INT/TERM trap, using `git checkout HEAD --` on the absolute path plus a hash compare, re-confirmed it with 0 diff lines. The rerun gave 46 passed. An earlier run at `8f5adb6ad6` (before the stack-parse door test existed) read 16 / 29, as predicted. ## Verification Spec-side readings at `f7d1216a8f`. `packages/spec` has not changed since; round 2 touched only the `service-automation` test file. - `@objectstack/spec`, full local project: `Test Files 611 passed (611)`, `Tests 18141 passed | 1 todo`. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `check:test-typecheck` is OK, and `tsc -p tsconfig.test.json --listFiles` lists both edited test files. - `check:generated`: all 15 artifacts up to date, against a dist the run built. - `@objectstack/lint` (the judge's other caller, `validateStackExpressions`): `Test Files 119 passed`, `Tests 5627 passed`. - Lint, a proven narrowing (`pnpm lint` itself belongs to CI). eslint's config lints 9 of the 12 round-1 paths: 0 errors and 0 warnings from `--format json --no-inline-config`. It ignores the 3 that are `.md` / `.json`. The config sets no `parserOptions.project` and enables no typed rules, so this diff cannot move an untouched file's verdict. Readings at `a9d2d5d453` (the head): - `@objectstack/service-automation`: `Test Files 170 passed (170)`, `Tests 2098 passed (2098)`. Typecheck exits 0. - eslint on the round-2 file: 0 errors and 0 warnings. The control-byte scan over the 13 changed paths found none. - `dispatch-gates --commands` (no paths), derived fresh at this head: 94 families, the round-1 92 plus `check-tenant-audit-census` and its self-test. All 94 were run fresh on this head, each exited 0, and `--ran` with exit codes recorded reads 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN. `origin/main` was merged twice through `scripts/pm/os-regen-merge.sh` (no rebase): once for objectstack-ai#21668 / objectstack-ai#21673, which landed `registry.ts` changes, and once for `7d0781482d`. Each merge regenerated and re-checked the artefacts afterwards. The sibling entries are all present (each id counted the same on `origin/main` and here), and this branch's `registry.ts` delta against `main` is +61 / -0. ## Acceptance notes - The comment above the `flowNodeConfigRefusals` walk in `packages/spec/src/automation/flow.zod.ts` still says "Two arms" and lists two. With this PR there are three. The file is outside the claim's surface, so it is noted, not edited. The judge's own docblock in `flow-node-config-refusals.ts` states all three arms. - `packages/lint/src/validate-expressions.ts` describes its call into the judge as "a key its contract requires, left out, and a `decision` branch list it cannot read". That list is now incomplete, not false: the call also emits the new refusal, as `error`. - Follow-up, not done here: `service-automation`'s `storedMetadataWriteRefusal` and the runtime body boundary's private `PRESCRIPTION` can import `STORED_METADATA_BODY_PRESCRIPTION` from `@objectstack/spec/kernel`, which makes the sentence one. Body refreshed 2026-10-04T06:42Z (round 2: the run-time pins re-expressed). The docs-drift advisory on this PR was read: it is advisory, and a spot-check of the hand-written pages naming `sys_metadata` with flows found none that this change falsifies. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20281
Clause-②: yes (widening)
This is stage ③ of ruling A (
5904845660) on this card, "thejobdriving it". It is built to the maintainer's ruling5974483403(Q1-B + Q2-O1). Stage ① (#20903, spec) and stage ② (#21084, the executor) are already onmain, so this PR finishes the plan the ruling set.What changed
Q1-B: a job pulls a mapping by declaration
spec:
JobSchema.pull: { mapping }(system/job.zod.ts). This is a third run form. It is closed, andmappingmust be a snake_case name. Writing it besidebodyorhandleris refused at parse, at pathpull. The at-least-one rule now coversbody/handler/pull.body+handlerstays legal, and the body still wins. The exclusion is not in the closed projection list, so it is recorded as a dropped refinement site indropped-refinements.baseline.json:system/Jobroot plus the fourmanifest.jobs.elementechoes, total 660 → 665.contract:
IAutomationService.pullConnectorSource?(request), withConnectorSourcePullRequest,ConnectorSourcePullResultandConnectorSourcePullSummary(contracts/automation-service.ts). The method is optional, likegetConnectorDescriptors.service-automation. The registered
automationservice is the ENGINE, so the engine gainspullConnectorSource. It serves the executor thatAutomationServicePlugin.init()attaches withsetConnectorPullSource, before the engine is registered. The plugin keeps its materialized-connector map; the engine is handed the call. A bare engine refuses withSERVICE_UNAVAILABLE(503).runtime: the one binder (
app-artifact-handlers.ts,scheduleAppArtifactJobs). Apulljob is judged byjudgeJobPull: no code beside it,pullparses, and the artifact declares the mapping with aconnectorSource. Each run then callspullConnectorSourcethrough the service registry, resolved again on every run. The outcome is mapped once (pullRunOutcomeOf):failedandretryPolicyapplies;{ outcome: 'degraded', reason }with the counts;completed.A pull that does not bind is not scheduled and is logged at
warn. So is a pull on a kernel with no pull door.collectJobsWithoutBodynever names a pull job. The result gainspullsandmissingOrganization.defineStack→os validate.validateCrossReferencesgainscollectJobPullMappingErrors, which refuses apull.mappingthe stack'smappingsdo not declare, or one whose mapping has noconnectorSource. It runs ahead of the no-object early return and uses the existingSTACK_CROSS_REFERENCE_INVALIDenvelope.os validatereaches it because every config isdefineStack-built (refuseUnbuiltStack). This is a rule inside an existing validator; no gate is added.Q2-O1: a job runs as its declared organization
JobSchema.organizationreusesScheduleOrganizationSchema, the scheduled flow's value shape, by reference. A near-miss spelling (organizationId,orgId,tenantId, …) is refused at parse and pointed at the key through the closed shape's aliases.resolveScheduledWorkPolicy(@objectstack/types), the resolver the scheduled flows bind by:requiresActingOrganization(isolated, switch on): a job declaring none is NOT scheduled and is logged aterror(missingOrganization);runOwnership: 'per-record'(group): undeclared jobs are scheduled and named once atwarn;scheduled-work-policy-unreadable), and only when the switch is on; with it off the posture is never read.jobExecutionContext(org), which is{ isSystem: true, tenantId: ORG }, or{ isSystem: true }for a job that declares none:ctx.apienvelope (body-runner.ts,buildJobSandboxContext);context;JobHandlerContext.executionContext. This member is additive, andqlstays the raw engine: a handler writes as the organization by passing it ascontext.Texts this change made false, now corrected
mapping.zod.ts(TSDoc and theconnectorSourcedescribe),connector.zod.tsSYNC_CONFIG_RETIRED, the D3 entry18.connector-sync-keys-retired.ts(and the regeneratedmigrations/registry.ts),SYNC_ARCHITECTURE.md(five places),connector-pull.ts(header and thecontextdoc),plugin.ts(thepullConnectorSourcedoc),service-automation/src/index.ts, the comment inlint/src/authoring-rules.ts, themapping.jsonledger note, and the two test pins that asserted "nothing schedules … yet".content/docs/automation/hook-bodies.mdxis untouched: its plannedctx.connector(...)line belongs to Q1-A, which was not taken.content/docs/releases/v17/17-6.mdxis release-owned and accurate for 17.6.0, so it is untouched.Mechanism assumptions, measured
The posture rule has one reusable implementation: CONFIRMED, with a boundary. The predicate is
resolveScheduledWorkPolicy()(packages/types/src/env.ts), and it is reused, not copied. The value shapeScheduleOrganizationSchemais reused too. The refusal sentencedescribeMissingScheduleOrganizationis flow-shaped (it names the start node'sconfig), so the job has its own sentence beside the binder (describeMissingJobOrganization). That is a separate sentence, not a second rule.pullConnectorSourcewas reachable only as a plugin method: CONFIRMED. The contract method lands on the engine, the service the kernel registers. The binder reaches it only throughctx.getService('automation'). A bootedLiteKernel'sautomationservice reaches the plugin executor (connector-pull-service-door.test.ts). The integration test's second pull now goes through the service, end to end over a realrestconnector and SQLite.The binder file and install-local: PARTLY DISPROVED. Unchanged,
collectJobsWithoutBodywould have named every pull job as "has nobody", soos package installwould have REFUSED every pull job, with the wrong prescription. After this change, pull jobs are never named. Measured through the real install-local door with a probe that is not committed (runtimedist/built ata3e9317f77; nothing in the binder changed after that):{ isSystem: true, tenantId: 'org_a' };pull.mapping: this artifact declares no mapping ….The door does not refuse that second case. A door refusal needs a clause in
cloud-connection'sdescribeUnrunnable, which is outside this claim's file surface. See the report's open question.The liveness row: CONFIRMED.
liveness/job.jsongainspull(drilled, withmappingliveplus its producer) andorganization(liveplus its producer).gen:liveness-countsmovesjobto 21 live / 23 classified.Two places where the dispatch text and the tree disagree
body/handler". The job applies at-least-one:body+handleris legal and the body wins. What was built follows the ruling text:pullis exclusive with both, and the old pair is unchanged. Narrowingbody+handlerwould have been a breaking change.os validate)". The posture and the scheduled-work switch are environment facts, not knowable at authoring.schedule-organization.zod.tssays the scheduled flows' rule lives at bind and forbids an authoring-time lint for it, and the ruling says to use the rule scheduled flows use. So the posture check is built at bind, andos validatechecks the mapping name only.Behaviour change to read
On an
isolateddeployment that has switched package-authored scheduled work ON, a packaged job declaring noorganizationwas scheduled before this change, and its tenant-scoped writes were refused at the write. It is now not scheduled, logged aterror. This is the ruling's "required underisolated". The switch is OFF by default in every posture. The changeset states the action needed.Tests (HEAD
36da2bfc88)New suites:
packages/spec/src/system/job-pull-organization.test.tshas 18 cases: the run form, the exclusion in both pairs (the old pair stays legal), closed shape, mapping-name shape, themappingnear-miss,organizationagreeing withScheduleOrganizationSchemaon every value, near-miss refusals, and thedefineStackenvelope (STACK_CROSS_REFERENCE_INVALID/ 422) with its control.packages/runtime/src/app-artifact-handlers.job-pull.test.tshas 20 cases. It covers the pull schedule and run,completed/degraded/ rejected outcomes, the non-binding refusals, the sibling-package mapping, a missing pull door, per-run service resolution, and the door judgement. On the organization side it covers the envelope on all three forms (the body runs in the real QuickJS sandbox), isolated / group / single, and the unreadable posture with the switch on and off.packages/services/service-automation/src/connector-pull-service-door.test.tshas 3 cases: a bare engine refuses 503, the attached executor's pass-through, and a booted kernel'sautomationservice reaching the plugin.Pins moved with the change:
connector-sync-retirement.test.ts,mapping-connector-source.test.ts, the result-shape pin inapp-artifact-handlers.jobs.test.ts, and the context-keys pin inapp-plugin.job-data-reach.test.ts(addsexecutionContext).connector-pull.integration.test.ts's second pull now goes through theautomationservice.Runs, each through
scripts/pm/os-verify-lock.shwithVERDICT command-exit 0:pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2: 610 files, 18075 passed, 1 todo, at36da2bfc88.pnpm --filter @objectstack/spec typecheck: green at36da2bfc88, test layer included.pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2: 319 files, 4550 passed, 19 skipped, at1b0a4b3d0f.pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2: 169 files, 2081 passed, at1b0a4b3d0f.typecheck: green, test layers included.1b0a4b3d0ftouches onlysystem/job.zod.ts's alias table and one spec test, both re-run.cloud-connection'smarketplace-install-local-jobs.test.ts, against the new runtimedist/: 11 passed.Ablations and reverse verification (one-off; no permanent files)
Every mutation went through
node scripts/ablation-replace.mjs(WRAP mode) on committed code. The anchor hit was proven on disk, and the restore was proven as blob == HEAD with an emptygit diff HEAD. Each subject is imported fromsrc/(relative imports, nodist/in the path).stack.zod.ts: drop thecollectJobPullMappingErrorscalljob.zod.ts: exclusivity predicate always truerequiresActingOrganizationbranch unreachablebuildJobSandboxContext: never carriestenantIdpullRunOutcomeOf: never degradedcollectJobsWithoutBody: drop the pull skipCross-package type reverse verification: a temporary
packages/runtime/srcprobe typed{ mapping, bogusKey }asConnectorSourcePullRequest, andtsc --noEmit -p packages/runtime/tsconfig.jsonanswered TS2353 on that line. Its other line, which readsIAutomationService['pullConnectorSource'], compiled. So the runtime typecheck read the rebuilt spec.d.ts. The probe was deleted.Local verification
All at HEAD
36da2bfc88, after the last commit:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackprinted 119 commands. All 119 exited 0, each exit code captured before any pipe.--ranreconciliation: "119 derived, 119 run, 0 NOT-MEASURED, 0 UNRUN" (exit 0). The union includespnpm --filter @objectstack/spec run check:generated, which checks all 15 generated artifacts.a3e9317f77and found one real drift.check-system-context-censusreported[declared-count]23 against 25: the two new{ isSystem: true; tenantId?: string }type declarations.pnpm gen:system-context-censuscorrectedcontent/docs/permissions/system-context.mdx, and the gate is now green.check:skill-examplesandcheck:dual-build-cjs-loadsexited 3 there (unbuilt prerequisites, so nothing was measured). Both are green in the final union.check:generated --fixonly where they were proven stale:api-surface/contracts.json,export-origins/contracts.json,authorable-surface/system.json,liveness/state-counts/job.md, the strictness-ledgersystem.mdcount,migrations/registry.ts, and the three reference pages.dropped-refinements.baseline.jsonwas edited by hand from the build's printed corrections.pnpm lintis CI's run. This is the proven narrowing:eslint.config.mjs:**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}minusNEVER_LINTED.eslint --no-inline-config --format jsonover the 23 changed lintable files reports 23 files, 0 errors, 0 warnings.parserOptions.project, no typed rules), and this diff edits neither the config nor a file it reads. So no verdict on an untouched file can move.os validateon a config whose job pullsorders_pulexits 1 withcode: STACK_CROSS_REFERENCE_INVALID. With the name corrected, the load passesdefineStack. That control's exit 1 comes only from unrelated docs-tree rules (docs/namespace-required,docs/metadata-embed-ref), with no cross-reference error.packages/cliintegration tier (package-install-local-jobs.integration.test.ts). This diff touches no CLI file and no spawn entry.Acceptance notes
cloud-connectionclause (UnrunnableCode.jobsgains a pull refusal,describeUnrunnablea sentence). Carrier: PM decision, in the report's open questions.handlerjob writes as its organization only by passingexecutionContext.qlstays the raw engine, so existing handlers are unchanged byte for byte. The handler form is deprecated; body and pull carry the envelope by construction.os validateresolvespull.mappingagainst the stack's own top-levelmappings, like every mapping reference invalidateCrossReferences. The binder resolves against the artifact's resolved collections (ADR-0130 D4,packages[]included), so the binder's scope contains the validator's.Generated by Claude Code