Repository navigation
fix(runtime): a no-organization job pull runs as the organization its writes are stamped with - #31886
Conversation
… writes are stamped with The engine answers its own system-write organization decision (ObjectQL#systemWriteOrganization, the assembly its system insert uses), and a pull of a job that declares no organization asks it once per run. Derived -> the pull context carries it, so the import match lookup scopes by the tenant column; every other decision keeps today's context. Claude-Session: https://claude.ai/code/session_01ADHkmSu9QNgRmHrQYpuG2N Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmSu9QNgRmHrQYpuG2N Co-authored-by: Claude <noreply@anthropic.com>
…rt's own decision Claude-Session: https://claude.ai/code/session_01ADHkmSu9QNgRmHrQYpuG2N Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmSu9QNgRmHrQYpuG2N Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmSu9QNgRmHrQYpuG2N Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check9 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 38 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 021b24cd2c262d2b85cb7abd649468e448e986d5 && git checkout 021b24cd2c262d2b85cb7abd649468e448e986d5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 551b68edf0c8c53d93ad23a1d0d9ced47164717a e31d876f9d77ed9c40064d430bc8ca25bf8730e0 && git checkout -B drift-repro 551b68edf0c8c53d93ad23a1d0d9ced47164717a && git merge --no-ff e31d876f9d77ed9c40064d430bc8ca25bf8730e0
node scripts/docs-audit/affected-docs.mjs --json 551b68edf0c8c53d93ad23a1d0d9ced47164717a |
|
domain:server#2 · os-steve · session_01ADHkmQKZzp4DLtqiCkZZRM Contract reviewServed-tier: Inputs: card #31823 (body, the seat's delta), PR #31886 body and file list, the dev's report (issuecomment-6736620105), and the net diff of the head against ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
domain:server#2 · os-steve · session_01ADHkmQKZzp4DLtqiCkZZRM 维护者速读
席位核对 — ACCEPT · domain:server#2 · Head-sha
|
Requested by Steve · project thread
Fixes #31823
Clause-②: yes (widening) —
@objectstack/objectqlgainsObjectQL#systemWriteOrganization(); the claim measurednobefore this public method existed (see Acceptance notes)Metadata-protocol: none
维护者速读
pull),每次运行前先问引擎"系统写入会落到哪个组织",单组织安装上就带着这个组织去拉取。引擎新增一个只读方法systemWriteOrganization(),答案和它自己做系统写入时的判定是同一套。single姿态下每行扫全表。1 万行同步从 41.6 秒降到 8.9 秒。body和handler形式不变。回滚即还原本 PR。packages/runtime/src/app-artifact-handlers.ts(resolveJobPullOrganization和 pull 的 run 闭包)、packages/objectql/src/engine-middleware-and-context.ts(systemWriteOrganization与插入路径共用的判定)、packages/objectql/src/engine.ts(新的公开方法)。pnpm --filter @objectstack/runtime exec vitest run --project local src/app-artifact-handlers.job-pull.test.ts,预期 33 个测试全绿,其中真实引擎用例断言匹配查询为organization_id = ? and external_id = ?。What changes
A
jobthat declares noorganizationand runs apullused to run the pull as{ isSystem: true }. Every write of that pull was still stamped with the install's organization (ADR-0131 D9, derived at write time onsinglewith exactly one organization), but the import's match lookup had no organization to scope by, so onsingle(no tenant read scope, ADR-0131 D11) it scanned the target table once per row. After #31812, the lookup scopes by the context'stenantIdwhen there is one; this PR gives this pull one.packages/objectql/src/engine-middleware-and-context.ts: the D9 decision half ofresolveSystemInsertOrganization(posture in force, the memoised organization probe, whethersys_organizationis registered, handed to core'sresolveSystemWriteOrganization) moves into its own facet body,MiddlewareAndContextFacet.systemWriteOrganization. The insert calls it with the posture it has already read, so the insert path is unchanged and there is one assembly.packages/objectql/src/engine.ts: new public facade methodObjectQL#systemWriteOrganization(), resolving to core'sSystemWriteOrganizationDecision, a one-line delegation (the facade gate holds). It writes nothing and refuses nothing; a probe outage propagates, as on the insert. The seat ruling asked to reuse the engine's assembly "if it is reachable from the runtime". It was private, so this method makes it reachable instead of re-assembling posture, probe and registry fact a second time in the runtime.packages/runtime/src/app-artifact-handlers.ts:resolveJobPullOrganization(module-private). For thepullform only, once per run, before the pull reads or writes: a declaredorganizationis used unchanged and the engine is not asked; otherwise the engine is asked, andderivedmakes the pull run asjobExecutionContext(DERIVED_ID).refuse(walled posture, zero or several organizations),no-organization-object, and an engine without the method keep today's{ isSystem: true }, so the write-time refusal stays the one that speaks. A probe that throws is logged once atwarnfor that run, naming the job, mapping and error, and the run continues with today's context.bodyandhandlerforms are unchanged (pinned). Whether they would gain anything: abodyjob'sctx.apiwrites are stamped the same way, but a body issues its own reads and has no shared match lookup that leans ontenantId, so the measured cost does not exist there. AhandlergetsexecutionContextas an envelope for its own code to use. Neither is named as a follow-up card; if a body or handler author needs the derived organization, that is a separate request.Tests
packages/runtime/src/app-artifact-handlers.job-pull.test.ts, two new top-leveldescribes in the existing harness file (the harness gained anengineoption that spreads methods onto its fakeql):derivedputs the organization on the pull context and is asked once per run (two runs, two asks, none at schedule); CONTROL a declared organization is used and the engine is not asked; CONTROLrefuse×3 (ambiguous, no organization, walled) andno-organization-objectkeep{ isSystem: true }and say nothing; CONTROL an engine without the method keeps today's context; a throwing probe gives one warn naming the job and the run completes; CONTROL abodyjob and ahandlerjob keep{ isSystem: true }and the engine is not asked.ObjectQL+SqlDriverbetter-sqlite3:memory:+ the real protocol + core'srunImport,single): one organization: the pull context is{ isSystem: true, tenantId: 'org_one' }, both match lookups arewhere organization_id = ? and external_id = ?with['org_one', key], rows are stored withorg_one, and a second run updates instead of creating; CONTROL a declared organization reads and writes the same way without asking; CONTROL two organizations: the context stays{ isSystem: true }, the lookup has noorganization_id, and both writes refuse withERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED, nothing stored. Theautomationservice in this case runs the import stepconnector-pull.tsruns (runImportover the protocol under the pull'scontext, same options) with the records in hand, since@objectstack/service-automationis not a runtime dependency.packages/objectql/src/system-write-organization.test.ts, newdescribeforObjectQL#systemWriteOrganization: one organization →derived, nothing written, and a following system insert is stamped with the same id on the same single probe (memo shared); several →refuseambiguous; injectedgroup/isolated→refusewalled with no probe; no provider andOS_TENANCY_POSTURE=isolated→ walled; lean composition →no-organization-object; a probe outage propagates.Acceptance notes
Clause-②: no. Measured on the diff,@objectstack/objectqlgains one public method, so the PR body and changeset sayyes (widening). Nothing an author or caller could send before is refused now. New public name for the seat:ObjectQL#systemWriteOrganization(inopen_questions).singlethe engine adds no tenant scope to reads (ADR-0131 D11), and an organization-carrying system write is stamped with that organization, the same one D9 derived before. So the watermark read is unchanged and the rows land where they did. Two visible differences: hooks and automations the pull's writes fire (runAutomations: true) now seetenantIdon their context, and a match no longer reaches a row whoseorganization_idis NULL (the import upsert: the per-row match lookup carries no organization predicate, so it skips the (organization_id, key) unique index and scans the table once per row #31812 rule: NULL is not an owner). On a certifiedsingleinstall every row carries the organization (ADR-0131 D1/D3).log once. Read as onewarnper failing run (each run resolves once), not once per job lifetime. A probe outage also fails the pull's writes at the engine, so the warn is not the only signal.Verification
Head
e31d876f: the fix (b24cadb3,5796b1e9,27deb45d), a merge oforigin/mainatb6fa08e3(2a308851), and one test-only commit forcheck:test-source-alias(e31d876f).Measurement on main and timing (G9 shape)
Scratch measure (not committed): the real engine,
single, one organization,Nrows seeded through the engine as organization-less system writes, then a no-organizationpulljob scheduled throughscheduleAppArtifactJobswhose import upserts the sameNkeys. Runtime source at2e9121ed(main, with #31812 merged at8fca8f70) vs this branch.2e9121ed{"isSystem":true}{"isSystem":true,"tenantId":"org_one"}select * from `po_item` where `external_id` = ? order by `id` asc limit ?select * from `po_item` where `organization_id` = ? and `external_id` = ? order by `id` asc limit ?SCAN po_item USING INDEX sqlite_autoindex_po_item_1SEARCH po_item USING INDEX uniq_po_item_organization_id_external_id (organization_id=? AND external_id=?)organization_idorg_oneorg_oneBoth sides:
created=0 updated=N errors=0. Shared-box wall clock under the verify lock.RED on main, ablation
packages/runtime/src/app-artifact-handlers.tsrestored from2e9121ed(tree only, thengit checkout HEAD --):Tests 3 failed | 30 passed (33). Failing: "derived: the pull context carries it, asked once per run", "a probe that throws is said once at warn…", and the real-engine "one organization…" (expected [ { isSystem: true } ] to deeply equal [ { isSystem: true, tenantId: 'org_one' } ]).scripts/ablation-replace.mjs: anchorreturn decision?.kind === 'derived' ? decision.organizationId : undefined;→return undefined;, anchor x1 → x0, blob0175ea79→6b12f08f.Tests 2 failed | 31 passed (33): "derived: the pull context carries it…" and the real-engine "one organization…". Restored: blob == HEAD,git diff HEADempty. The runtime suite importsapp-artifact-handlers.tsfrom source, so no rebuild or dist preflight leg.Builds, tests, typecheck, lint
pnpm --filter @objectstack/objectql build,typecheckfor@objectstack/objectqland@objectstack/runtime, thenpnpm --filter @objectstack/objectql test(Test Files 580 passed (580),Tests 11942 passed (11942)) andpnpm --filter @objectstack/runtime test(Test Files 566 passed (566),Tests 9171 passed | 19 skipped (9190)):VERDICT command-exit 0.origin/main(no incoming commit touchespackages/objectql,packages/runtime/src/app-artifact-handlers*or core's security files):turbo run build --concurrency=2 --filter='./packages/**'(Tasks: 81 successful, 81 total), both typechecks,system-write-organization.test.ts(Tests 46 passed (46)) and the two job suites (Tests 74 passed (74)):VERDICT command-exit 0. One31d876f: runtimetypecheckplus the two job suites again,Tests 74 passed (74),VERDICT command-exit 0.eslint --no-inline-configon the five touched source and test files, and onpackages/objectql/src+packages/runtime/srcwhole: exit 0 (neither package has its own lint script).node scripts/pm/dispatch-gates.mjs --commandsone31d876f: 72 derived families, every one exit 0;--ranreconciliation:72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN. The earlier rounds neededgit fetch --depth=1 origin 621a4876…forcheck-plugin-teardown-shape --self-testand a full packages build forcheck:dual-build-cjs-loads(both exit 3 until then), andcheck:test-source-aliasasked for a module-topimport '@objectstack/metadata'in the edited job-pull suite (added ine31d876f).!in the title, no(narrowing)arm).Generated by Claude Code