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
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