Skip to content

feat(spec,runtime,service-automation): a job pulls a mapping by declaration (pull: { mapping }) and runs as its declared organization (#20281 stage 3) - #21668

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20281-stage3-job-pull
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20281-stage3-job-pull

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20281

Clause-②: yes (widening)

This is stage ③ of ruling A (5904845660) on this card, "the job driving it". It is built to the maintainer's ruling 5974483403 (Q1-B + Q2-O1). Stage ① (#20903, spec) and stage ② (#21084, the executor) are already on main, 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, and mapping must be a snake_case name. Writing it beside body or handler is refused at parse, at path pull. The at-least-one rule now covers body / handler / pull. body + handler stays legal, and the body still wins. The exclusion is not in the closed projection list, so it is recorded as a dropped refinement site in dropped-refinements.baseline.json: system/Job root plus the four manifest.jobs.element echoes, total 660 → 665.

  • contract: IAutomationService.pullConnectorSource?(request), with ConnectorSourcePullRequest, ConnectorSourcePullResult and ConnectorSourcePullSummary (contracts/automation-service.ts). The method is optional, like getConnectorDescriptors.

  • service-automation. The registered automation service is the ENGINE, so the engine gains pullConnectorSource. It serves the executor that AutomationServicePlugin.init() attaches with setConnectorPullSource, before the engine is registered. The plugin keeps its materialized-connector map; the engine is handed the call. A bare engine refuses with SERVICE_UNAVAILABLE (503).

  • runtime: the one binder (app-artifact-handlers.ts, scheduleAppArtifactJobs). A pull job is judged by judgeJobPull: no code beside it, pull parses, and the artifact declares the mapping with a connectorSource. Each run then calls pullConnectorSource through the service registry, resolved again on every run. The outcome is mapped once (pullRunOutcomeOf):

    • a refused pull rejects, so the run is failed and retryPolicy applies;
    • refused rows give { outcome: 'degraded', reason } with the counts;
    • otherwise the run is 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. collectJobsWithoutBody never names a pull job. The result gains pulls and missingOrganization.

  • defineStack → os validate. validateCrossReferences gains collectJobPullMappingErrors, which refuses a pull.mapping the stack's mappings do not declare, or one whose mapping has no connectorSource. It runs ahead of the no-object early return and uses the existing STACK_CROSS_REFERENCE_INVALID envelope. os validate reaches it because every config is defineStack-built (refuseUnbuiltStack). This is a rule inside an existing validator; no gate is added.

Q2-O1: a job runs as its declared organization

  • spec: JobSchema.organization reuses ScheduleOrganizationSchema, 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.
  • The binder judges it at bind with resolveScheduledWorkPolicy (@objectstack/types), the resolver the scheduled flows bind by:
    • requiresActingOrganization (isolated, switch on): a job declaring none is NOT scheduled and is logged at error (missingOrganization);
    • runOwnership: 'per-record' (group): undeclared jobs are scheduled and named once at warn;
    • single: nothing is said.
    • An unrecognized posture fails closed (scheduled-work-policy-unreadable), and only when the switch is on; with it off the posture is never read.
  • Every form runs as jobExecutionContext(org), which is { isSystem: true, tenantId: ORG }, or { isSystem: true } for a job that declares none:
    • the body's ctx.api envelope (body-runner.ts, buildJobSandboxContext);
    • the pull's context;
    • the handler's new JobHandlerContext.executionContext. This member is additive, and ql stays the raw engine: a handler writes as the organization by passing it as context.

Texts this change made false, now corrected

mapping.zod.ts (TSDoc and the connectorSource describe), connector.zod.ts SYNC_CONFIG_RETIRED, the D3 entry 18.connector-sync-keys-retired.ts (and the regenerated migrations/registry.ts), SYNC_ARCHITECTURE.md (five places), connector-pull.ts (header and the context doc), plugin.ts (the pullConnectorSource doc), service-automation/src/index.ts, the comment in lint/src/authoring-rules.ts, the mapping.json ledger note, and the two test pins that asserted "nothing schedules … yet". content/docs/automation/hook-bodies.mdx is untouched: its planned ctx.connector(...) line belongs to Q1-A, which was not taken. content/docs/releases/v17/17-6.mdx is release-owned and accurate for 17.6.0, so it is untouched.

Mechanism assumptions, measured

  1. 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 shape ScheduleOrganizationSchema is reused too. The refusal sentence describeMissingScheduleOrganization is flow-shaped (it names the start node's config), so the job has its own sentence beside the binder (describeMissingJobOrganization). That is a separate sentence, not a second rule.

  2. pullConnectorSource was reachable only as a plugin method: CONFIRMED. The contract method lands on the engine, the service the kernel registers. The binder reaches it only through ctx.getService('automation'). A booted LiteKernel's automation service 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 real rest connector and SQLite.

  3. The binder file and install-local: PARTLY DISPROVED. Unchanged, collectJobsWithoutBody would have named every pull job as "has no body", so os package install would 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 (runtime dist/ built at a3e9317f77; nothing in the binder changed after that):

    • a package with a pull job installs 200, is scheduled, and its run pulls under { isSystem: true, tenantId: 'org_a' };
    • a pull job whose mapping the package lacks installs 200 and is NOT scheduled; the binder warns pull.mapping: this artifact declares no mapping ….

    The door does not refuse that second case. A door refusal needs a clause in cloud-connection's describeUnrunnable, which is outside this claim's file surface. See the report's open question.

  4. The liveness row: CONFIRMED. liveness/job.json gains pull (drilled, with mapping live plus its producer) and organization (live plus its producer). gen:liveness-counts moves job to 21 live / 23 classified.

Two places where the dispatch text and the tree disagree

  • The dispatch called this "the same exactly-one rule the job already applies to body / handler". The job applies at-least-one: body + handler is legal and the body wins. What was built follows the ruling text: pull is exclusive with both, and the old pair is unchanged. Narrowing body + handler would have been a breaking change.
  • The dispatch said the posture check is "a rule inside existing validators (parse / os validate)". The posture and the scheduled-work switch are environment facts, not knowable at authoring. schedule-organization.zod.ts says 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, and os validate checks the mapping name only.

Behaviour change to read

On an isolated deployment that has switched package-authored scheduled work ON, a packaged job declaring no organization was scheduled before this change, and its tenant-scoped writes were refused at the write. It is now not scheduled, logged at error. This is the ruling's "required under isolated". 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.ts has 18 cases: the run form, the exclusion in both pairs (the old pair stays legal), closed shape, mapping-name shape, the mapping near-miss, organization agreeing with ScheduleOrganizationSchema on every value, near-miss refusals, and the defineStack envelope (STACK_CROSS_REFERENCE_INVALID / 422) with its control.
  • packages/runtime/src/app-artifact-handlers.job-pull.test.ts has 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.ts has 3 cases: a bare engine refuses 503, the attached executor's pass-through, and a booted kernel's automation service reaching the plugin.

Pins moved with the change: connector-sync-retirement.test.ts, mapping-connector-source.test.ts, the result-shape pin in app-artifact-handlers.jobs.test.ts, and the context-keys pin in app-plugin.job-data-reach.test.ts (adds executionContext). connector-pull.integration.test.ts's second pull now goes through the automation service.

Runs, each through scripts/pm/os-verify-lock.sh with VERDICT command-exit 0:

  • pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2: 610 files, 18075 passed, 1 todo, at 36da2bfc88.
  • pnpm --filter @objectstack/spec typecheck: green at 36da2bfc88, test layer included.
  • pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2: 319 files, 4550 passed, 19 skipped, at 1b0a4b3d0f.
  • pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2: 169 files, 2081 passed, at 1b0a4b3d0f.
  • runtime and service-automation typecheck: green, test layers included.
  • The one commit after 1b0a4b3d0f touches only system/job.zod.ts's alias table and one spec test, both re-run.
  • cloud-connection's marketplace-install-local-jobs.test.ts, against the new runtime dist/: 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 empty git diff HEAD. Each subject is imported from src/ (relative imports, no dist/ in the path).

# Mutation Expected Observed
A stack.zod.ts: drop the collectJobPullMappingErrors call the 4 cross-ref refusals red, control green 4 failed / 14 passed
B job.zod.ts: exclusivity predicate always true pull+body and pull+handler red 2 failed / 16 passed
C binder: requiresActingOrganization branch unreachable both isolated tests red 2 failed / 18 passed
D buildJobSandboxContext: never carries tenantId the body-organization test red 1 failed / 19 passed
E pullRunOutcomeOf: never degraded the degraded test red 1 failed / 19 passed
F collectJobsWithoutBody: drop the pull skip the door-judgement test red 1 failed / 19 passed

Cross-package type reverse verification: a temporary packages/runtime/src probe typed { mapping, bogusKey } as ConnectorSourcePullRequest, and tsc --noEmit -p packages/runtime/tsconfig.json answered TS2353 on that line. Its other line, which reads IAutomationService['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:

  • Derived gate union. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack printed 119 commands. All 119 exited 0, each exit code captured before any pipe. --ran reconciliation: "119 derived, 119 run, 0 NOT-MEASURED, 0 UNRUN" (exit 0). The union includes pnpm --filter @objectstack/spec run check:generated, which checks all 15 generated artifacts.
  • The first union ran at a3e9317f77 and found one real drift. check-system-context-census reported [declared-count] 23 against 25: the two new { isSystem: true; tenantId?: string } type declarations. pnpm gen:system-context-census corrected content/docs/permissions/system-context.mdx, and the gate is now green. check:skill-examples and check:dual-build-cjs-loads exited 3 there (unbuilt prerequisites, so nothing was measured). Both are green in the final union.
  • Generated spec artifacts, regenerated by check:generated --fix only where they were proven stale: api-surface/contracts.json, export-origins/contracts.json, authorable-surface/system.json, liveness/state-counts/job.md, the strictness-ledger system.md count, migrations/registry.ts, and the three reference pages. dropped-refinements.baseline.json was edited by hand from the build's printed corrections.
  • Lint. The repo-wide pnpm lint is CI's run. This is the proven narrowing:
    1. Population, from eslint.config.mjs: **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} minus NEVER_LINTED.
    2. Count: eslint --no-inline-config --format json over the 23 changed lintable files reports 23 files, 0 errors, 0 warnings.
    3. Invariance: the config enables no type-aware linting (its own statement: no 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.
  • Door readings, measured with throwaway probes that were then deleted:
    • os validate on a config whose job pulls orders_pul exits 1 with code: STACK_CROSS_REFERENCE_INVALID. With the name corrected, the load passes defineStack. That control's exit 1 comes only from unrelated docs-tree rules (docs/namespace-required, docs/metadata-embed-ref), with no cross-reference error.
    • The install-local door readings are in assumption 3 above.
  • Not run locally, declared for CI: the packages/cli integration tier (package-install-local-jobs.integration.test.ts). This diff touches no CLI file and no spawn entry.

Acceptance notes

  • The install-local door does not refuse a pull job that does not bind (assumption 3). It installs 200 and the binder warns. A refusal is a cloud-connection clause (UnrunnableCode.jobs gains a pull refusal, describeUnrunnable a sentence). Carrier: PM decision, in the report's open questions.
  • A handler job writes as its organization only by passing executionContext. ql stays 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 validate resolves pull.mapping against the stack's own top-level mappings, like every mapping reference in validateCrossReferences. 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

claude added 6 commits October 4, 2026 00:18
…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>
…s two isSystem type declarations

Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/lint, @objectstack/runtime, @objectstack/service-automation, @objectstack/spec, touching 40 documentable anchor(s). ⚠️ 9 changed file(s) yielded no anchor (packages/services/service-automation/src/index.ts, packages/spec/api-surface/contracts.json, packages/spec/authorable-surface/system.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json fea67065a3dfd972a40f95225077cd2e21443d58.

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

What this run could not see
  • 9 changed file(s) yielded no anchor (packages/services/service-automation/src/index.ts, packages/spec/api-surface/contracts.json, packages/spec/authorable-surface/system.json, …) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: environmentId (36 routes)
  • 15 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 143 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 fea67065a3dfd972a40f95225077cd2e21443d58 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ec3e628438815d62029252e58adddd5261edebef — the merge of head 36da2bfc8882a5fbbb9d5277f7e37d8a18587f8b into base fea67065a3dfd972a40f95225077cd2e21443d58, 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 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

⚠️ 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 fea67065a3dfd972a40f95225077cd2e21443d58 → 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 4, 2026 02:38
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 4, 2026 02:38
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 909229e Oct 4, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20281-stage3-job-pull branch October 4, 2026 03:16
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec(integration): build the connector sync executor that syncConfig and fieldMappings declare (14 keys), once and on the mainstream shape

2 participants