Skip to content

fix(runtime): a no-organization job pull runs as the organization its writes are stamped with - #31886

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-wo4lmt
Nov 9, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-wo4lmt

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread
Fixes #31823

Clause-②: yes (widening) — @objectstack/objectql gains ObjectQL#systemWriteOrganization(); the claim measured no before this public method existed (see Acceptance notes)
Metadata-protocol: none

维护者速读

  • 改了什么:没有声明组织的定时同步任务(job 的 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 job that declares no organization and runs a pull used 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 on single with exactly one organization), but the import's match lookup had no organization to scope by, so on single (no tenant read scope, ADR-0131 D11) it scanned the target table once per row. After #31812, the lookup scopes by the context's tenantId when there is one; this PR gives this pull one.

  1. packages/objectql/src/engine-middleware-and-context.ts: the D9 decision half of resolveSystemInsertOrganization (posture in force, the memoised organization probe, whether sys_organization is registered, handed to core's resolveSystemWriteOrganization) 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.
  2. packages/objectql/src/engine.ts: new public facade method ObjectQL#systemWriteOrganization(), resolving to core's SystemWriteOrganizationDecision, 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.
  3. packages/runtime/src/app-artifact-handlers.ts: resolveJobPullOrganization (module-private). For the pull form only, once per run, before the pull reads or writes: a declared organization is used unchanged and the engine is not asked; otherwise the engine is asked, and derived makes the pull run as jobExecutionContext(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 at warn for that run, naming the job, mapping and error, and the run continues with today's context.

body and handler forms are unchanged (pinned). Whether they would gain anything: a body job's ctx.api writes are stamped the same way, but a body issues its own reads and has no shared match lookup that leans on tenantId, so the measured cost does not exist there. A handler gets executionContext as 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-level describes in the existing harness file (the harness gained an engine option that spreads methods onto its fake ql):
    • fake engine: derived puts 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; CONTROL refuse ×3 (ambiguous, no organization, walled) and no-organization-object keep { 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 a body job and a handler job keep { isSystem: true } and the engine is not asked.
    • real engine (ObjectQL + SqlDriver better-sqlite3 :memory: + the real protocol + core's runImport, single): one organization: the pull context is { isSystem: true, tenantId: 'org_one' }, both match lookups are where organization_id = ? and external_id = ? with ['org_one', key], rows are stored with org_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 no organization_id, and both writes refuse with ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED, nothing stored. The automation service in this case runs the import step connector-pull.ts runs (runImport over the protocol under the pull's context, same options) with the records in hand, since @objectstack/service-automation is not a runtime dependency.
  • packages/objectql/src/system-write-organization.test.ts, new describe for ObjectQL#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 → refuse ambiguous; injected group / isolated → refuse walled with no probe; no provider and OS_TENANCY_POSTURE=isolated → walled; lean composition → no-organization-object; a probe outage propagates.
  • No existing test was edited apart from the harness option above.

Acceptance notes

  • Clause-② differs from the claim. The claim says Clause-②: no. Measured on the diff, @objectstack/objectql gains one public method, so the PR body and changeset say yes (widening). Nothing an author or caller could send before is refused now. New public name for the seat: ObjectQL#systemWriteOrganization (in open_questions).
  • What a derived context changes beyond the lookup. On single the 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 see tenantId on their context, and a match no longer reaches a row whose organization_id is 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 certified single install every row carries the organization (ADR-0131 D1/D3).
  • log once. Read as one warn per 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 of origin/main at b6fa08e3 (2a308851), and one test-only commit for check:test-source-alias (e31d876f).

Measurement on main and timing (G9 shape)

Scratch measure (not committed): the real engine, single, one organization, N rows seeded through the engine as organization-less system writes, then a no-organization pull job scheduled through scheduleAppArtifactJobs whose import upserts the same N keys. Runtime source at 2e9121ed (main, with #31812 merged at 8fca8f70) vs this branch.

main 2e9121ed this branch
pull context {"isSystem":true} {"isSystem":true,"tenantId":"org_one"}
match lookup 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 ?
SQLite plan SCAN po_item USING INDEX sqlite_autoindex_po_item_1 SEARCH po_item USING INDEX uniq_po_item_organization_id_external_id (organization_id=? AND external_id=?)
stored organization_id all org_one all org_one
10,000 rows upserted 41,566 ms (241 rows/s) 8,930 ms (1,120 rows/s)
200 rows upserted 397 ms 338 ms

Both sides: created=0 updated=N errors=0. Shared-box wall clock under the verify lock.

RED on main, ablation
  • RED: the new tests against packages/runtime/src/app-artifact-handlers.ts restored from 2e9121ed (tree only, then git 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' } ]).
  • Ablation (skip the derivation) through scripts/ablation-replace.mjs: anchor return decision?.kind === 'derived' ? decision.organizationId : undefined; → return undefined;, anchor x1 → x0, blob 0175ea79 → 6b12f08f. Tests 2 failed | 31 passed (33): "derived: the pull context carries it…" and the real-engine "one organization…". Restored: blob == HEAD, git diff HEAD empty. The runtime suite imports app-artifact-handlers.ts from source, so no rebuild or dist preflight leg.
Builds, tests, typecheck, lint
  • Before the merge, under the lock: pnpm --filter @objectstack/objectql build, typecheck for @objectstack/objectql and @objectstack/runtime, then pnpm --filter @objectstack/objectql test (Test Files 580 passed (580), Tests 11942 passed (11942)) and pnpm --filter @objectstack/runtime test (Test Files 566 passed (566), Tests 9171 passed | 19 skipped (9190)): VERDICT command-exit 0.
  • After the merge of origin/main (no incoming commit touches packages/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. On e31d876f: runtime typecheck plus the two job suites again, Tests 74 passed (74), VERDICT command-exit 0.
  • eslint --no-inline-config on the five touched source and test files, and on packages/objectql/src + packages/runtime/src whole: exit 0 (neither package has its own lint script).
  • node scripts/pm/dispatch-gates.mjs --commands on e31d876f: 72 derived families, every one exit 0; --ran reconciliation: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN. The earlier rounds needed git fetch --depth=1 origin 621a4876… for check-plugin-teardown-shape --self-test and a full packages build for check:dual-build-cjs-loads (both exit 3 until then), and check:test-source-alias asked for a module-top import '@objectstack/metadata' in the edited job-pull suite (added in e31d876f).
  • Full dogfood: not run (no ! in the title, no (narrowing) arm).

Generated by Claude Code

… 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>
…rt's own decision

Claude-Session: https://claude.ai/code/session_01ADHkmSu9QNgRmHrQYpuG2N
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

9 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
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 80 pages)
  • the SDK route bridge reached 65 of 231 client-bound route-ledger rows — the other 166 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 166: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 107 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 — 38 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 551b68edf0c8c53d93ad23a1d0d9ced47164717a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 021b24cd2c262d2b85cb7abd649468e448e986d5 — the merge of head e31d876f9d77ed9c40064d430bc8ca25bf8730e0 into base 551b68edf0c8c53d93ad23a1d0d9ced47164717a, 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 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQKZzp4DLtqiCkZZRM

Contract review

Served-tier: TIER_DEFAULT
Head-sha: e31d876f9d77ed9c40064d430bc8ca25bf8730e0
Local-runs: none

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 main (6 files, +411/−16).

① Derived judgments

  1. @objectstack/objectql gains one public method, ObjectQL#systemWriteOrganization(): Promise<SystemWriteOrganizationDecision>. Its body is the decision half of the engine's own system insert (resolveSystemInsertOrganization), moved into one facet body both now call: same posture source, same memoised organization probe, same sys_organization registration test, same @objectstack/core predicate. Right: one assembly, not a second derivation. Not on IObjectQLEngine and not in packages/spec.
  2. The insert path is unchanged: derived still stamps, no-organization-object still carries none, refuse still throws SystemWriteOrganizationRequiredError with the same arguments. A probe outage still propagates. Right.
  3. The runtime job pull: a job with no organization asks the engine once per run, before the pull reads or writes, and runs as the organization only on derived. Every other decision, an engine without the method, and a throwing probe (one warn naming the job) keep { isSystem: true }. A declared organization, and the body and handler forms, are unchanged. Right: on derived the writes were already stamped with that organization, so only the match lookup's scope changes (it now probes the (organization_id, key) index), and the rows land where they did.
  4. No spec, schema, protocol, wire or stored-row change; Metadata-protocol: none is right.

② Semver level

Clause-②: yes (widening), naming the method, on the changeset and the PR body; patch on @objectstack/objectql and @objectstack/runtime. Right: a non-protocol widening ships patch (AGENTS.md Post-Task Checklist step 3), as #31812 (PR #31846) and #31852 (PR #31876) did. The claim's no predates the public method; the dev measured it again and the seat accepts yes.

③ Boundary flags

  • Name systemWriteOrganization: kept (dev's option A; new public names are the seat's call). It names the engine's ADR-0131 D9 decision, matching resolveSystemWriteOrganization and SystemWriteOrganizationDecision.
  • Probe failure: one warn per failing run, no latch (option A). An outage is per-run news, and the run's writes meet the same probe.
  • File surface widened to packages/objectql (engine.ts, engine-middleware-and-context.ts, a new test): accepted, the assembly was private. No other open PR touches these files or app-artifact-handlers.ts.

Implemented-by: claude/project-thread-wo4lmt
Reviewed-by: session_01ADHkmQKZzp4DLtqiCkZZRM

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQKZzp4DLtqiCkZZRM

维护者速读

  • 改了什么: 没声明组织的定时同步任务(job 的 pull)每次运行前先问引擎"系统写入会落到哪个组织",单组织安装上就带着这个组织去拉取。引擎新增只读方法 ObjectQL#systemWriteOrganization(),与它自己做系统写入时的判定是同一套代码。
  • 为什么改: 这类任务的写入本来就盖上了唯一的组织,但匹配查询带不上组织条件,single 姿态下每行扫全表。1 万行同步从 41.6 秒降到 8.9 秒。
  • 风险与回滚: 只有"单组织且任务未声明组织"这一种情况改变上下文,落库位置不变;其他情况与以前完全一样。回滚即还原本 PR。
  • 先看: packages/runtime/src/app-artifact-handlers.ts 的 resolveJobPullOrganization。
  • 需要你做的: 无,由席位落地。
  • 席位意见: ACCEPT。

席位核对 — ACCEPT · domain:server#2 · Head-sha e31d876f9d77ed9c40064d430bc8ca25bf8730e0 · 2026-11-09T09:57Z

  1. Shape: draft to main, Fixes #31823. The declaration is Clause-②: yes (widening), naming ObjectQL#systemWriteOrganization(), with Metadata-protocol: none. Patch changesets for @objectstack/objectql and @objectstack/runtime. 6 files, +411/−16.
  2. Contract review: TIER_DEFAULT, declaration face, PASS, on this head.
  3. Delta rulings, checked against the diff:
    • Measured on main: a no-organization job pull ran as { isSystem: true }, its match lookup was where external_id = ? (a SQLite SCAN), and every row was stamped with the one organization.
    • Pull form only: the body and handler forms and a declared organization are unchanged, pinned as CONTROLs.
    • One decision: the engine's D9 assembly is one facet body that the system insert and the new method both call; no "first organization" and no env default.
    • Only derived changes the context: each refuse, no-organization-object, an engine without the method, and a throwing probe keep today's context, each pinned.
  4. Dev deviations, accepted: the packages/objectql surface (the assembly was private), and Clause-②: yes (widening) over the claim's no. Open questions ruled A and A: the name systemWriteOrganization stays, and a failing probe warns once per run.
  5. Gates:
    • Dev: 3 pins red on main and green now; the ablation (skip the derivation) 2 red and restored; objectql 11942 and runtime 9171 tests pass; eslint 0; 72/72 derived gates, 0 UNRUN. The real-engine pin (ObjectQL over SqlDriver) reads the lookup carrying the organization column. 10k-row upsert 41.6s → 8.9s.
    • Check-runs on this head: 35 success, 15 skipped, 0 failed.
    • Governed surfaces: 0 of 6 paths on the register.
  6. Landing: pr_ready + automerge_enable.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 9, 2026 10:02
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 9, 2026
Merged via the queue into main with commit a15b544 Nov 9, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-wo4lmt branch November 9, 2026 10:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant