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