Skip to content

Commit 2e7127b

Browse files
refactor(objectql): move the driver options and engine posture bodies out of engine.ts into engine-driver-options.ts (#25522 cut 12) (#26371)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XXDgykqb95p8z3DLbM1Udhf)_ Part of #25522 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160 ## 维护者速读 - **改了什么**:把 `engine.ts` 里「驱动选项与引擎租户姿态」整段 6 个方法和它们独用的一个辅助函数原样搬进新文件 `engine-driver-options.ts`,类上方法签名与注释不变,只把方法体换成转交。`engine.ts` 22,651 → 22,517 行。 - **为什么改**:#25522 按缝拆分 `engine.ts` 的第 12 刀;本段在刀前重测的缝表里净减行最多,且不与在途 PR 重叠。 - **风险与回滚**:纯搬移,无行为变化;公开 `.d.ts` 六个文件逐字节不变。回滚 = revert 本 PR。 - **先看**:`packages/objectql/src/engine-driver-options.ts`(搬入的方法体)、`packages/objectql/src/engine.ts`(6 个转交桩)、`scripts/adr-anchors/` 两个 JSON(ADR 锚段落按方法拆分)。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`,应见 472 个测试文件全部通过。 - **需要你做的**:无,席位落地。 Cut 12 of the `packages/objectql/src/engine.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6429419365`. The whole "Driver options and engine posture" section moves unedited into a new module, `packages/objectql/src/engine-driver-options.ts`: `buildDriverOptions` (the `DriverOptions` every driver call receives), `driverReadScope` (the Layer 0 verdict projected onto a read's driver scope), `engineLayer0Verdict` (that verdict over the engine's own facts), `wallExemptedOperation` (did the wall's exemption lift an operation's scope), `driverWriteTarget` (an insert's write target) and `resolveEnginePosture` (the tenancy posture in force). The one module helper only they use, `accessibleOrganizationsOf`, moves with them. No behaviour changes. #25522 stays open for the later cuts. Branch `claude/project-thread-isr622` (the card thread's harness-pinned branch), cut from `origin/main` at `222755ebb`, which contains cut 11's merge (`fc70ab36ba`, #26351). Nothing was edited before that merge landed. `origin/main` (`6390fe52c`) was merged in once before this PR opened; none of the commits it brought touch `packages/objectql/**`, `packages/core/src/security/**`, `content/docs/permissions/**`, `scripts/adr-anchors/**` or `scripts/check-system-context-census.mjs`. ## What changes ### Why this area Re-measured on `222755ebb` with cut 11's scripts, unchanged (both screens: query-options erasure sites and module-helper closure). `engine.ts` is 22,651 lines, 7,202 of them from the last 30 days (`git blame --since=2026-09-26T23:28Z`). `scripts/pm/git-history.mjs count`: 130 commits in 30 days, 36 in 7. `class ObjectQL` holds 318 members, 255 methods and accessors. `engine.ts` at `222755ebb` is byte-identical to cut 11's head, where the table was first pre-measured while #26351 was in the queue. <details><summary>Seam table at 222755e (41 sections)</summary> | range | section | first … last member | lines | 30-day lines | methods | inferred public | U | private statics | |---|---|---|---|---|---|---|---|---| | 4710-4935 | Instance state and constructor | `txStore` … `constructor` | 226 | 50 | 0 (0 public) | - | 0 | - | | 4936-4958 | Status and registry accessor | `getStatus` … `registry` | 23 | 0 | 2 (2 public) | getStatus | 0 | - | | 4959-5302 | Hook and function registry | `registerHook` … `triggerHooks` | 344 | 6 | 16 (16 public) | registerHook, triggerHooks | 1 | - | | 5303-5658 | Hook dispatch internals | `hasHooksFor` … `assertBulkPerRowHookBudget` | 356 | 61 | 7 (0 public) | - | 0 | - | | 5659-5819 | Action registry and activation | `registerAction` … `setActionActive` | 161 | 19 | 10 (10 public) | - | 1 | - | | 5820-6101 | Registered resolvers and write-gate probes | `_heldFileResolver` … `registerInsertRecomputeListener` | 282 | 116 | 6 (6 public) | - | 0 | - | | 6102-6855 | Readable-ids security and permission/formula resolution | `readableIdsSecurityReported` … `resolveDefaultPermissions` | 754 | 546 | 15 (2 public) | - | 0 | - | | 6856-6962 | Authz invalidation and middleware registration | `writeEpoch` … `hasObjectMiddleware` | 107 | 18 | 4 (4 public) | - | 0 | - | | 6963-7140 | Execution plumbing (middleware, session, provenance, users) | `executeWithMiddleware` … `buildUser` | 178 | 45 | 7 (0 public) | - | 0 | - | | **7141-7471** | **Driver options and engine posture** (this PR) | `buildDriverOptions` … `resolveEnginePosture` | 331 | 209 | 6 (0 public) | - | 0 | - | | 7472-7681 | Organization probe and system-insert organization | `probeInstallOrganizations` … `transactionCoversDriverFor` | 210 | 45 | 4 (0 public) | - | 0 | - | | 7682-8047 | Field defaults | `resolveNowDefault` … `defaultFieldEntries` | 366 | 4 | 6 (0 public) | - | 0 | - | | 8048-8660 | Autonumber | `applyAutonumbers` … `seedAutonumber` | 613 | 80 | 6 (0 public) | - | 1 | - | | 8661-9325 | Package, app, plugin and driver registration | `registrationConversionWarned` … `registerDriver` | 665 | 170 | 6 (3 public) | registerApp, registerDriver | 0 | - | | 9326-9534 | Datasource registry | `supplyFileColumnsMovedResolver` … `listUnavailableDatasources` | 209 | 1 | 8 (7 public) | - | 3 | - | | 9535-9686 | Write guard and realtime publish | `assertWriteAllowed` … `publishBulkDataEvent` | 152 | 18 | 4 (1 public) | - | 0 | - | | 9687-9785 | Validation-message i18n | `setI18nService` … `objectDisplayLabel` | 99 | 14 | 4 (1 public) | - | 0 | - | | 9786-10789 | Referential integrity and master-detail resolution | `assertReferencesResolve` … `masterDeclaredFields` | 1004 | 415 | 12 (0 public) | - | 0 | - | | 10790-10905 | Dangling-reference and cascade inspection | `inspectDanglingReferences` … `inspectCascadeDelete` | 116 | 48 | 2 (2 public) | - | 0 | - | | 10906-11000 | Crypto and tenancy providers | `setCryptoProvider` … `setTenancyPostureProvider` | 95 | 20 | 4 (4 public) | - | 1 | - | | 11001-11420 | Secret and internal-field handling | `encryptSecretFields` … `resolveInternalField` | 420 | 61 | 9 (3 public) | - | 0 | - | | 11421-11489 | Schema lookup and filter judging | `getSchema` … `judgeFilter` | 69 | 48 | 3 (2 public) | - | 0 | - | | 11490-11755 | Datasource routing | `LIFECYCLE_DATASOURCE` … `setDatasourceMapping` | 266 | 20 | 8 (3 public) | setDatasourceMapping | 0 | - | | 11756-11923 | Lifecycle init and health | `init` … `resolvePrimaryDatasource` | 168 | 16 | 3 (3 public) | init | 0 | - | | 11924-11997 | Media and value-shape presence | `objectHasMediaField` … `valueShapeStrictFor` | 74 | 13 | 4 (0 public) | - | 0 | mediaFieldPresence, coveredValueFieldPresence | | 11998-12680 | Migration flags and attestations | `isFileReferencesMigrationVerified` … `wasDatastoreCreatedFromEmpty` | 683 | 81 | 17 (8 public) | - | 0 | - | | 12681-12703 | destroy | `destroy` … `destroy` | 23 | 0 | 1 (1 public) | destroy | 0 | - | | 12704-12722 | Expand/cascade limits (statics) | `MAX_EXPAND_DEPTH` … `MAX_BULK_PER_ROW_HOOK_ROWS` | 19 | 0 | 0 (0 public) | - | 0 | MAX_EXPAND_DEPTH, MAX_CASCADE_DEPTH | | 12723-13173 | Roll-up summaries | `autonumberCounters` … `recomputeSummaries` | 451 | 0 | 7 (1 public) | - | 0 | - | | 13174-13427 | Related-record expansion | `expandRelatedRecords` … `expandRelatedRecords` | 254 | 10 | 1 (0 public) | - | 0 | - | | 13428-13625 | File-reference hydration (stubs, cut 11) | `resolveFileReferences` … `readParentDerivedFiles` | 198 | 183 | 5 (0 public) | - | 0 | - | | 13626-14104 | Query lowering | `resolveWhereTokens` … `requireFindOnePredicate` | 479 | 272 | 9 (0 public) | - | 0 | - | | 14105-14661 | find / findOne | `find` … `staticReadonlyCreateStrip` | 557 | 142 | 4 (2 public) | - | 0 | - | | 14662-15319 | validate | `validate` … `validate` | 658 | 447 | 1 (1 public) | - | 0 | - | | 15320-16666 | insert / insertMany | `readUpdatePriorRow` … `insertMany` | 1347 | 439 | 3 (2 public) | - | 1 | - | | 16667-18600 | update | `update` … `update` | 1934 | 275 | 1 (1 public) | - | 0 | - | | 18601-20141 | Delete cascade and reference walking | `planCascadeAtomicity` … `organizationOwnedRowCounts` | 1541 | 733 | 22 (1 public) | - | 0 | 6 statics | | 20142-20711 | delete | `delete` … `delete` | 570 | 47 | 1 (1 public) | - | 0 | - | | 20712-21331 | count / aggregate | `count` … `aggregate` | 620 | 236 | 3 (2 public) | - | 0 | - | | 21332-21640 | execute and transactions | `execute` … `enforceTransactionOrigin` | 309 | 74 | 8 (2 public) | - | 0 | - | | 21641-21933 | Compatibility API | `registerObject` … `create` | 293 | 52 | 16 (16 public) | - | 0 | - | </details> The full per-method screen and price list are in the project file `pm-orders/server/25522-cut12-seam-table.md`; the move script is `pm-orders/server/evidence/25522/move-cut12.mjs` and the anchor split `anchor-cut12.py`. | area | paying bodies under both screens | 30-day lines in them | net | verdict | |---|---|---|---|---| | **Driver options and engine posture** (this PR) | all 6 bodies (161 lines) and the helper `accessibleOrganizationsOf`, whose two users (`engineLayer0Verdict`, `driverWriteTarget`) both move | 107 of 161 | priced -110 for the four bodies the script calls free, -122 for all six by the rule; **-134 measured** (the helper and four import bindings go too) | pays most; nothing in the section stays behind | | Migration flags and attestations | 8 bodies | 50 of 154 | about -91 | its host needs setters (a seat ruling first); not taken, per the order | | Readable-ids security and permission/formula resolution | 6 bodies | 78 of 127 | about -87 | three bodies blocked by shared module helpers; hot | | Package registration, count, Delete cascade and the rest | - | - | -30 to 0 | blocked by a shared helper, or pays little | The script lists `engineLayer0Verdict` as blocked by its helper `accessibleOrganizationsOf`, whose other user is `driverWriteTarget`. `driverWriteTarget` alone does not pay (a 4-line body behind a 5-line stub, +1), but moving it with `engineLayer0Verdict` lets the helper go too, so the pair pays about -19 together with it. That is the order's reading ("`engineLayer0Verdict` only together with `driverWriteTarget`"), and it empties the section. ### The shape - The class keeps all six methods with name, modifiers (`private`), signature, docblock and position. Each body is now one host literal of exactly the `DriverOptionsHost` members it reads, plus a `.call`, marked `[#25522 cut 12]`, as cuts 8, 10 and 11 did. Fields go as getters over a `readX` closure (`txStore`, `_registry`, `tenancyPostureProvider`); methods go as late-bound closures (`isClosedUnitHandle`, `transactionCoversDriverFor`, `driverWriteTarget`, `driverReadScope`, `wallExemptedOperation`, `engineLayer0Verdict`, `resolveEnginePosture`), so a spy or double set on the engine is still the one a call reaches. - **One named host.** `DriverOptionsHost` types each member as the class member (`ObjectQL['txStore']` and so on); each function's `this` is the `Pick` of what it reads. The module imports the engine only as a type. - **The helper.** `accessibleOrganizationsOf` moves with its docblock byte for byte and is not exported; both its users moved. One copy, no re-derivation. - **Imports.** The module imports `isTenancyDisabled` (`@objectstack/spec/data`), `ExecutionContext` (type), `computeTenantLayer0Verdict` (`@objectstack/core`, with its two-line import comment), `resolveTenantFieldName` (`./tenancy/system-write-organization.js`), `resolveTenancyPosture` (`@objectstack/types`), `normalizeTenancyPosture`, `TenantLayer0VerdictSchema` and the types `TenancyPosture` / `TenantLayer0Verdict` (`@objectstack/spec/security`) and `isFederatedObject` (`./federated-object.js`). `engine.ts` drops `isTenancyDisabled`, `computeTenantLayer0Verdict` (with its comment), `resolveTenancyPosture` (the whole import line), `normalizeTenancyPosture` and `TenantLayer0VerdictSchema`, whose last code readers moved (three of them are still named in docblocks that stay, which no linter counts as a use), and gains the module import with a three-line comment. `isFederatedObject`, `resolveTenantFieldName` and the two types keep readers in `engine.ts` and stay imported there. - The module header names what moved and why nothing stays, and names ADR-0119 and ADR-0067 (the anchor ids, below) without a decision letter. ### Net lines (the seat's rule) `engine.ts` goes from 22,651 to 22,517 lines, **net -134** (44 added, 178 removed). The module is 304 lines. With the path readers below, the diff against `origin/main` is 544 changed lines over 7 files (+361 / -183). ### Readers of the moved lines (migrated in this PR) - **ADR anchor.** `scripts/adr-anchors/packages__objectql__src__engine.ts.json` describes the transaction seam in one paragraph whose first half is `buildDriverOptions`'s mechanism. The paragraph is divided, one home per half: - **New entry** `packages__objectql__src__engine-driver-options.ts.json`, `adrs: ["ADR-0067", "ADR-0119"]` (ADR-0119's same-origin amendment, recorded on the 2026-08-06 ruling with ADR-0067's): the opening sentence ("The transaction seam is same-origin by construction (#5351/#5696, 2026-08-06 ruling).") plus the `buildDriverOptions` sentence verbatim, plus the last clause of the paragraph's closing sentence, "Never restore the unconditional handle lift." - **The `engine.ts` entry keeps** its three ids, the same opening sentence, the `enforceTransactionOrigin` sentences verbatim, and the closing sentence's first clause, "Never add a companion transaction on the second driver (no two-phase commit in IDataDriver)." - So the only words that change are the split of the closing sentence at its "and" (the second clause now starts a sentence) and the opening sentence, which both halves carry. `engine-execute-transactions.ts`'s entry (cut 7) still carries the whole paragraph; this PR does not edit it. - **Decision-letter count:** `check-adr-anchors` at base `222755ebb` (clean detached worktree): 90 anchored files, **1,877** decision-letter citations, 59,281 resolving citations. At head `1b8e2b8b1`: 91, **1,877**, 59,285; at the merged head `17781355c`: 91, **1,877**, 59,316 (main's own citations). The letter-citing body comments that moved (`ADR-0131 D8`, `D9`, `D13`, the `ADR-0131 D8` import comment and the helper's `ADR-0105 D2`) left `engine.ts` but now count in the newly anchored module, so the count is equal. - **The system-context census page.** `buildDriverOptions` reads `execCtx?.isSystem` (row 24, "Tenant-audit warning silenced"). The row's anchor in `content/docs/permissions/system-context.mdx` and its `NON_READ_ANCHORS` entry in `scripts/check-system-context-census.mjs` (`collapsesOntoRead`, file key only) now name `engine-driver-options.ts#buildDriverOptions`. **Control:** with the move and neither re-key, the census fails with `[ledger-stale-collapse]` and `[anchor-is-not-a-read-site]` on `engine.ts#buildDriverOptions`; with both re-keys (and the module tracked), it passes: 153 read sites across 83 files (82 at base), 145 of 145 symbols cited. - **The federated injected-column readers table.** `federated-injected-column-readers.test.ts` keys rows `FILE#FUNCTION :: SEAM`; the two `buildDriverOptions` rows (`isFederatedObject()`, `resolveTenantFieldName()`) are re-keyed to `engine-driver-options.ts#buildDriverOptions`, dispositions unchanged. - No `objectql/src/engine.ts#NAME` pointer under `packages/spec/**`, `docs/adr/**`, `docs/qa/**` or `content/**` names a moved member other than row 24. Prose that names `buildDriverOptions` or `resolveEnginePosture` (ADRs 0034, 0119 and 0131, driver and plugin comments, tests, CHANGELOGs) still resolves, because the methods stay on the class. No core census keys a moved body (`row-serving-door-exposure` keys `.canReadObject(` callers and `second-object-read-exposure` keys `referenceTargetOf(` callers; the moved bodies make neither call). No moved body holds a `query-options/no-any-erasure` site (`engine.ts` stays at 8 in `query-options-erasure-baseline.json`) or a tenant-audit untyped receiver. ## Tests <details><summary>Published .d.ts identical (ruling 6348891160), with a positive control</summary> Base: `@objectstack/objectql` built in a clean detached worktree at `222755ebb` (its own `pnpm install` and `^...` closure build; `engine.ts` blob `d68a6760` checked, no module file). Head: built at `1b8e2b8b1`. `buildDriverOptions.call(host` occurs 0 times in the base `dist/index.mjs` and once in the head's. All six declaration files are **byte-identical**, and equal to the hashes cuts 7 to 11 recorded: | file | sha256, base = head | |---|---| | `core.d.mts` | `ef178bf0725eccc2f358f082ce13dc5122a6d5a53c19ef630017256f67de03b4` | | `core.d.ts` | `1d20303a455a909e4b55fd0802fb0b0bcb59ec51784daf27d5ef4c0659410953` | | `index.d.mts` | `ccf900240aa137abe72f82b8abfc872d42c5645467400134c580285f2a3b3090` | | `index.d.ts` | `0454d5e70b156d8effbf27b8915587c7cbee241dcd7d698013c0a4c369a02aa5` | | `util-CVzOGxAn.d.mts` | `85ef1cbec2b016d5f7afabaa21689c86c6dffc573076fdfdeb8ea0f1a1b1ad53` | | `util-CVzOGxAn.d.ts` | `85ef1cbec2b016d5f7afabaa21689c86c6dffc573076fdfdeb8ea0f1a1b1ad53` | **Positive control:** `scripts/ablation-replace.mjs` dropped `private` from the `driverWriteTarget` stub (anchor hit once, x1 to x0; replacement x0 to x1) and rebuilt: all six hashes and the chunk name changed (`util-QbuYzUnN.d.ts` `6a0616b618e4…`, `index.d.ts` `363d8d91d88e…`), and the chunk now prints `driverWriteTarget(execCtx: ExecutionContext | undefined): {` as a public member. The tool restored the file (blob `a17c9e9b93e6` equal to HEAD, `git diff HEAD` empty); after a rebuild, all six files compared equal to the head build again. </details> <details><summary>Proof of move-only (TypeScript-AST leaf-token compare)</summary> `pm-orders/server/evidence/25522/astcmp.mjs` (unchanged from cuts 9 to 11), base `engine.ts` (`222755ebb`) against the head module: - All six bodies are **SAME** in parameters, return type, type parameters, body tokens and `async`: `buildDriverOptions` 379 tokens / 111 lines, `driverReadScope` 139 / 16, `engineLayer0Verdict` 45 / 15, `wallExemptedOperation` 86 / 8, `driverWriteTarget` 61 / 4, `resolveEnginePosture` 38 / 7. - Class: 318 members at both ends, in the same order. 312 are byte-identical, and the 6 stubs keep docblock, modifiers and signature byte-identical up to the body. - Top level: 224 of 230 base statements are byte-identical in head. The other six: the class; the helper, moved; the `@objectstack/spec/data` import less `isTenancyDisabled`; the `@objectstack/core` import less `computeTenantLayer0Verdict` and its comment; the `@objectstack/types` import, dropped; the `@objectstack/spec/security` import less `normalizeTenancyPosture` and `TenantLayer0VerdictSchema`. Head adds the module import. - The whole move was dry-run first on cut 11's head while #26351 was in the queue; the files committed here are byte-identical to that dry run (`git hash-object`, 7 of 7). </details> <details><summary>Suites</summary> At `1b8e2b8b1` (the cut; the merge of `origin/main` after it changes no file under `packages/objectql/**` or `packages/core/**`): - `pnpm --filter @objectstack/objectql typecheck` (`tsc --noEmit` + `check:test-typecheck`): VERDICT command-exit 0. `pnpm exec eslint --no-inline-config packages/objectql scripts/check-system-context-census.mjs`: exit 0. - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`: **472 files, 8,789 tests passed** (VERDICT command-exit 0). The suites that drive these bodies (`engine-driver-read-scope`, `engine-external-tenant-scope`, `engine-reference-tenant-scope`, `engine-timezone-presence-gate`, `engine-transaction-same-origin`, `engine-data-events`, `federated-injected-column-readers`) are among them. - `vitest run --project repo` in every package that has one, after the `^...` closure builds: `core` 6/6 files (83 tests), `objectql` 1/1 (5), `rest` 5/5 (280 passed, 18 skipped), `runtime` 3/3 (947), `spec` 79/79 (1,279), `types` 1/1 (11), all VERDICT command-exit 0. - Dependants: all six members keep their declarations and all six published `.d.ts` files are byte-identical, so no dependant can see a difference. CI's `TypeScript Type Check` covers them. </details> ## Acceptance notes - **Serial check**, re-read before `pr_create` (17 open PRs at 00:38Z): two touch `packages/objectql/**`. #26337 (ADR-0142 phase 2, head `9ada3a920`) edits `engine.ts` in nine hunks (an import near line 245, three declarations near lines 3916, 4221 and 4751, and the `insert` / `update` bodies from base line 15629 on) and adds rows to `federated-injected-column-readers.test.ts` near line 355. None is in the moved section (base lines 7141 to 7471), on the import lines or helper this cut edits, or on the two re-keyed rows (line 188). `git merge-tree --write-tree` of this head with its head merges cleanly (exit 0). Under the card's rule 4 whichever lands second merges `main`. #26138 edits `validation/rule-validator.test.ts` only. No open PR touches `system-context.mdx`, `check-system-context-census.mjs` or `scripts/adr-anchors/`. - **Receiver note, not a change in what runs:** `resolveEnginePosture` calls `this.tenancyPostureProvider?.()`. In the class the call's receiver was the engine; in the module it is the host, whose getter returns the same function. The one production registration (`plugin-security`'s `setTenancyPostureProvider(() => this.tenancyPosture)`) and every test registration pass arrow functions, so nothing reads the receiver. `this.txStore.getStore()` and `this._registry.getObject(...)` keep their own receivers (the store and the registry). - **A stale sentence elsewhere, not edited:** `engine-realtime-publish.ts`'s header says `accessibleOrganizationsOf` stays in `engine.ts`; after this cut it lives in `engine-driver-options.ts`. The file is outside this claim's surface, so it is left for the seat to route. - **Next cut from this screen:** Migration flags and attestations (about -91) still needs a setter ruling; Readable-ids (about -87) is blocked by three shared module helpers; the rest pay -30 or less. A ruling on Migration flags' setters is the next lever. ## Verification At `17781355c` (final head, the merge of `origin/main` `6390fe52c` into `1b8e2b8b1`): - **Gates:** `dispatch-gates --commands` derived 111 families on the real diff (the same list at `1b8e2b8b1` and after the merge). Every one was run, each exit code captured before any pipe, then reconciled with `--ran`: 111 accounted for, 110 run green, 1 **NOT MEASURED** (exit 3, PREREQUISITE NOT MET; CI runs it): `check:dual-build-cjs-loads` (needs every package's `dist/`). Three first answered a prerequisite and were re-run green after the prerequisite was met: `check-engine-split-ratio --days 90` and `check-plugin-teardown-shape --self-test` (shallow clone, re-run after `git fetch --unshallow`), and `check:skill-examples` (re-run after building `@objectstack/client-react` and `@objectstack/client`: 264 prose examples type-check). Named readings: `check-system-context-census` OK, `check:adr-anchors` OK, `check:query-options-erasure`, `check:durability-log-level`, `check:engine-double-contract`, `check:lean-entry-closure`, `check:type-check-debt`, `check:logger-receiver-detach`, `check:org-identifier`, `check:nul-bytes`, `check-affected-docs`, all exit 0. - **Hand gates outside the derivation**, all exit 0: `check-adr-anchors` at base and head (above), `check-adr-symbol-anchors` (2,664 anchors resolve), `check-tenant-audit-census` (244 write sites certified), `check-platform-checklist --changed-from origin/main` (13 anchors judged, 0 lost) and `check-commit-card-trailers --range origin/main..HEAD`. - No ablation, because no tested behaviour changed. The whole `Test Core` farm is CI's. `skip-changeset`: move-only. The published `.d.ts` files are byte-identical, `liveness/` and every other published path are untouched, and the module's runtime is the same code under a different file name. The other edits are a docs page, a test key, a repo script and two anchor files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_014SEAruKUVJHjiYsA6bZyLy --- _Generated by [Claude Code](https://claude.ai/code/session_014SEAruKUVJHjiYsA6bZyLy)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 61c0798 commit 2e7127b

7 files changed

Lines changed: 361 additions & 183 deletions

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ that silently does not happen.
132132
| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no position in the security catalog is not refused | plugin-security | Get: a system writer can store an assignment naming no position — the seed loader writes `stack.data` from `AppPlugin.start()` under a system context, as invitation acceptance and the platform's own bootstraps do. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `position`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no position of that name: the environment registry (ADR-0131 D3), one per deployment, which takes no organization, so a name only a `sys_position` row carries — any organization's — is refused like one nobody carries. It is the same stand-down as the engine's referential-integrity check for a lookup column | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` |
133133
| 23c | **Grant-name catalog check skipped** — a `sys_user_permission_set` write whose `permission_set` names no permission set in the security catalog is not refused | plugin-security | Get: a system writer can store a grant naming a set not yet registered — seed replay and boot provisioning may write the grant before the definition it names is registered. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `permission_set`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no set by that name; a grant that names no set at all is refused `required` for every caller. The grant names its set by name alone, and nothing derives the name from `permission_set_id` (ADR-0131 D4, D10) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onInsert`, `#onUpdate` |
134134
| 23d | **Grant-holder membership check skipped** — a `sys_user_position` or `sys_user_permission_set` write naming a user with no `sys_member` row in the row's organization is not refused | plugin-security | Get: a system writer can store such a grant — invitation acceptance writes the membership and its placement together, and seed replay, the organization-admin reconcile and the platform bootstraps write grants as the system. Lose: such a row takes effect once its user is admitted to the organization. A non-system insert, or a non-system update that moves `user_id` or `organization_id`, is refused `400 VALIDATION_FAILED` / `reference_not_found` at `user_id` instead, for every caller, a platform administrator included; the membership is read as `{ isSystem: true, tenantId }` with `tenantId` the row's own organization, never another one. A row stored with no organization is not judged. Under `single`, 18.0 stamps a new grant with the Default Organization, so such a row is one an earlier release wrote | `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert`, `#onUpdate` |
135-
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `packages/objectql/src/engine.ts#buildDriverOptions` |
135+
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `packages/objectql/src/engine-driver-options.ts#buildDriverOptions` |
136136
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite`, `#assertEngineOwnedWriteAllowed` |
137137
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` |
138138
| 26b | API key restore guard does not refuse | plugin-auth | Get: a write setting `revoked: false` on a `sys_api_key` row an administrator's forced password reset revoked is admitted without the platform-administrator check a user-context restore must pass; the reason is still cleared. Lose: nothing a caller reaches: no system writer restores a key, and the mint, the last-used stamp and the forced reset itself never set `revoked: false` | `packages/plugins/plugin-auth/src/api-key-restore-guard.ts#isUserContextWrite` |
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25522 cut 12] The driver-options and engine-posture bodies of `ObjectQL`,
5+
* moved out of `engine.ts` whole: `buildDriverOptions` (the `DriverOptions`
6+
* every driver call receives: the transaction handle, the tenant scope, the
7+
* timezone and the tenant-audit fill-ins), `driverReadScope` (the Layer 0
8+
* verdict projected onto a read's driver scope), `engineLayer0Verdict` (that
9+
* verdict over the engine's own facts), `wallExemptedOperation` (did the
10+
* wall's exemption lift this operation's scope), `driverWriteTarget` (the
11+
* write target of an insert or upsert) and `resolveEnginePosture` (the
12+
* tenancy posture in force), with the one module helper only they use:
13+
* `accessibleOrganizationsOf`.
14+
*
15+
* A MOVE, not a redesign. Each exported function below is the body of the
16+
* `ObjectQL` method of the same name as it stood, re-indented one level and
17+
* otherwise unedited. Each method's docblock stays on the method in
18+
* `engine.ts`, which is still the contract. The helper moves with its
19+
* docblock, byte for byte, and is not exported.
20+
*
21+
* Nothing stays behind in this section: every member of it moved.
22+
*
23+
* ## The transaction seam's same-origin half (ADR-0119, ADR-0067)
24+
*
25+
* `buildDriverOptions` threads the open transaction handle only to the driver
26+
* that owns it (`transactionCoversDriverFor`, which stays in the class). The
27+
* ADR anchor entry for this file carries that half of the paragraph the
28+
* `engine.ts` entry carried; the `enforceTransactionOrigin` half stays there.
29+
*
30+
* ## How the moved bodies reach the class
31+
*
32+
* The shape #23050 set for `rest-server.ts`, applied per method as
33+
* `engine-file-reference-hydration.ts` applies it. The class keeps all six
34+
* methods with name, modifiers, signature, docblock and position, and each
35+
* body is one literal of exactly the {@link DriverOptionsHost} members that
36+
* method reads, plus a `.call`. The fields a body reads (`txStore`,
37+
* `_registry`, `tenancyPostureProvider`) go as GETTERS, so each read happens
38+
* when the body makes it, as it did. The methods they call
39+
* (`isClosedUnitHandle`, `transactionCoversDriverFor`, `driverWriteTarget`,
40+
* `driverReadScope`, `wallExemptedOperation`, `engineLayer0Verdict`,
41+
* `resolveEnginePosture`) go as late-bound closures over the instance, so a
42+
* double or spy set on the engine is still the one a call reaches.
43+
*
44+
* This module imports the engine only as a TYPE, so there is no module cycle
45+
* at run time, and nothing re-exports it, so the published surface does not
46+
* move.
47+
*/
48+
49+
import { isTenancyDisabled } from '@objectstack/spec/data';
50+
import type { ExecutionContext } from '@objectstack/spec/kernel';
51+
import {
52+
// [ADR-0131 D8] The ONE tenant read predicate, shared with plugin-security's
53+
// Layer 0 — see {@link buildDriverOptions}.
54+
computeTenantLayer0Verdict,
55+
} from '@objectstack/core';
56+
import { resolveTenantFieldName } from './tenancy/system-write-organization.js';
57+
import { resolveTenancyPosture } from '@objectstack/types';
58+
import {
59+
normalizeTenancyPosture,
60+
TenantLayer0VerdictSchema,
61+
type TenancyPosture,
62+
type TenantLayer0Verdict,
63+
} from '@objectstack/spec/security';
64+
import { isFederatedObject } from './federated-object.js';
65+
import type { ObjectQL } from './engine.js';
66+
67+
/**
68+
* The `ObjectQL` members the functions below read — every one of them, and no
69+
* other; each function's `this` is the `Pick` of the members IT reads. Each is
70+
* typed AS the class member (an indexed access on the class), so a change to a
71+
* member's type on the class is a change here too, and naming a member the
72+
* class does not have is a compile error. See the module header for which
73+
* members are getters.
74+
*/
75+
export interface DriverOptionsHost {
76+
readonly txStore: ObjectQL['txStore'];
77+
readonly _registry: ObjectQL['_registry'];
78+
readonly tenancyPostureProvider: ObjectQL['tenancyPostureProvider'];
79+
isClosedUnitHandle: ObjectQL['isClosedUnitHandle'];
80+
transactionCoversDriverFor: ObjectQL['transactionCoversDriverFor'];
81+
driverWriteTarget: ObjectQL['driverWriteTarget'];
82+
driverReadScope: ObjectQL['driverReadScope'];
83+
wallExemptedOperation: ObjectQL['wallExemptedOperation'];
84+
engineLayer0Verdict: ObjectQL['engineLayer0Verdict'];
85+
resolveEnginePosture: ObjectQL['resolveEnginePosture'];
86+
}
87+
88+
/**
89+
* [ADR-0105 D2] The context's organization access set
90+
* (`ExecutionContext.accessible_org_ids`), stringified — `undefined` when
91+
* absent or empty, which the verdict reads as "no membership" under `group`.
92+
*/
93+
function accessibleOrganizationsOf(execCtx: ExecutionContext | undefined): string[] | undefined {
94+
const set = (execCtx as { accessible_org_ids?: unknown } | undefined)?.accessible_org_ids;
95+
return Array.isArray(set) && set.length > 0 ? set.map(String) : undefined;
96+
}
97+
98+
/** The body of `ObjectQL#buildDriverOptions`; the method's docblock there is its contract. */
99+
export function buildDriverOptions(
100+
this: Pick<DriverOptionsHost, 'isClosedUnitHandle' | 'txStore' | 'transactionCoversDriverFor' | '_registry' | 'driverWriteTarget' | 'driverReadScope' | 'wallExemptedOperation'>,
101+
object: string,
102+
execCtx?: ExecutionContext,
103+
base?: any,
104+
scope: { writeTarget?: boolean; recordedVerdict?: unknown } = {},
105+
): any {
106+
// The open transaction may arrive explicitly via the context, or ambiently
107+
// via txStore when an internal query runs during a transactional write
108+
// (ADR-0034). Explicit wins; ambient is the safety net. [#22855] A handle
109+
// whose unit has closed is no handle: work the unit started and did not
110+
// await runs unenrolled (`runOpenedUnit`).
111+
const explicit = execCtx?.transaction;
112+
const tx = explicit !== undefined && !this.isClosedUnitHandle(explicit)
113+
? explicit
114+
: this.txStore.getStore()?.transaction;
115+
// [#5351] SAME-ORIGIN GATE. A transaction handle is a property of ONE
116+
// driver's connection; handing it to a different driver does not put that
117+
// driver's statement inside the transaction, it executes the statement on
118+
// the WRONG CONNECTION — measured on knex/SQLite as `no such table` against
119+
// a database that never held the object. The write path's
120+
// `enforceTransactionOrigin` has already refused a business write by the
121+
// time we get here (and let a system ledger through by decision), so this
122+
// is the structural half: whatever survives to here, the handle only ever
123+
// reaches the driver that owns it. It covers READS too, which have no gate
124+
// of their own and were riding the same wrong connection.
125+
const hasTx = tx !== undefined && this.transactionCoversDriverFor(object, tx);
126+
const objectSchema = this._registry.getObject(object) as any;
127+
// `isFederatedObject` is the same predicate every schema-sync seam routes a
128+
// federated object by — one spelling of "this schema is the remote's",
129+
// not a second reading of it.
130+
const isFederated = isFederatedObject(objectSchema);
131+
const tenantObject = !isTenancyDisabled(objectSchema) && !isFederated;
132+
const tenantScope = tenantObject
133+
? scope.writeTarget
134+
? this.driverWriteTarget(execCtx)
135+
: this.driverReadScope(execCtx, scope.recordedVerdict)
136+
: undefined;
137+
const hasTenant = tenantScope !== undefined;
138+
const hasTz = execCtx?.timezone !== undefined;
139+
const isSystem = execCtx?.isSystem === true;
140+
// [ADR-0131 D9/D13] The tenant-audit's scope is the object's SCHEMA: a
141+
// tenant column (the registered schema's, as `resolveTenantFieldName`
142+
// reads it) on an object that is not federated. That is the same reading
143+
// the system-insert rule takes, and there is no second, per-table one —
144+
// the #13491 ledger that used to narrow this by name retired with D13. An
145+
// object is outside the audit only by declaring itself outside the
146+
// machinery (`tenancy.enabled: false`, `systemFields.tenant: false`, no
147+
// tenant field, `external`), which `scripts/platform-object-tenancy-census.json`
148+
// enumerates for the platform namespaces.
149+
const isTenantAuditInScope =
150+
resolveTenantFieldName(objectSchema) !== null &&
151+
!isFederated;
152+
// [ADR-0131 D8 · 8c-1] The wall's exemption, declared to the driver. An
153+
// exempt platform admin's predicate write reaches the driver with no
154+
// `tenantId` because the wall lifted the scope on purpose, and the
155+
// driver's `[tenant-audit]` line tells such a write to declare
156+
// `bypassTenantAudit: true` — an option no REST caller can set. The
157+
// exemption is decided once, by `computeTenantLayer0Verdict`, and arrives
158+
// here as the recorded verdict; this reads it, it does not re-decide it
159+
// (see {@link wallExemptedOperation}).
160+
const wallExempt =
161+
tenantObject && isTenantAuditInScope && !scope.writeTarget &&
162+
this.wallExemptedOperation(execCtx, scope.recordedVerdict);
163+
const preserveAudit = (execCtx as any)?.preserveAudit === true;
164+
if (!hasTx && !hasTenant && !isSystem && !hasTz && !preserveAudit && !wallExempt) return base;
165+
const opts: any = base && typeof base === 'object' ? { ...base } : {};
166+
if (hasTx && opts.transaction === undefined) {
167+
opts.transaction = tx;
168+
}
169+
if (hasTenant && opts.tenantId === undefined) {
170+
opts.tenantId = tenantScope.tenantId;
171+
}
172+
if (hasTenant && opts.tenantIds === undefined && tenantScope.tenantIds !== undefined) {
173+
opts.tenantIds = tenantScope.tenantIds;
174+
}
175+
if (hasTz && opts.timezone === undefined) {
176+
// Thread the business timezone so date-dependent driver generation
177+
// (autonumber `{YYYYMMDD}` tokens) resolves the calendar day correctly.
178+
opts.timezone = execCtx!.timezone;
179+
}
180+
if (isSystem && opts.bypassTenantAudit === undefined && !isTenantAuditInScope) {
181+
// [#13491 → ADR-0131 D13] The isSystem mute, cut by the object's
182+
// schema alone (2026-08-31 ruling, execution point 1: the OBJECT decides,
183+
// never the caller's flag).
184+
//
185+
// It used to mute EVERY elevated write, whatever the object was. The
186+
// census cited in commit e49d98896's message measured what that cost: 135 of 175 write call sites
187+
// (77%) were silenced here — at the control's LARGEST gate, sitting
188+
// ahead of the condition the control is about — and the control has
189+
// never produced a finding, while all five known instances of the defect
190+
// were found by a person reading call sites.
191+
//
192+
// What still mutes: an object with no tenant field at all (nothing to be
193+
// unscoped from — the driver would exit at `resolveTenantField` anyway)
194+
// and a federated one. What does not: any object with a tenant column,
195+
// platform namespace or not — a name prefix, or a hand-kept list of
196+
// names, decides nothing here. A caller that means it still passes
197+
// `bypassTenantAudit: true` explicitly and is honoured above — this
198+
// branch only fills in a value nobody supplied.
199+
//
200+
// ⚠️ Direction, per execution point 3: what this ADDS is a driver WARN
201+
// line. It changes nothing about what any write touches.
202+
opts.bypassTenantAudit = true;
203+
}
204+
if (wallExempt && opts.bypassTenantAudit === undefined) {
205+
// The same fill-in-only rule as the isSystem mute above: a caller's own
206+
// value wins. Diagnostics only — the driver's scope is already the
207+
// verdict's (no `tenantId`), and this option silences a warning and
208+
// touches nothing else; on a read it is inert.
209+
opts.bypassTenantAudit = true;
210+
}
211+
if (preserveAudit && opts.preserveAudit === undefined) {
212+
// Historical import (#3493): let the driver keep a supplied `updated_at`
213+
// instead of force-stamping now on the update path.
214+
opts.preserveAudit = true;
215+
}
216+
return opts;
217+
}
218+
219+
/** The body of `ObjectQL#driverReadScope`; the method's docblock there is its contract. */
220+
export function driverReadScope(
221+
this: Pick<DriverOptionsHost, 'engineLayer0Verdict'>,
222+
execCtx: ExecutionContext | undefined,
223+
recordedVerdict: unknown,
224+
): { tenantId: string; tenantIds?: string[] } | undefined {
225+
const activeOrganization =
226+
execCtx?.tenantId !== undefined && execCtx.tenantId !== null && String(execCtx.tenantId) !== ''
227+
? String(execCtx.tenantId)
228+
: undefined;
229+
const recorded = recordedVerdict === undefined ? undefined : TenantLayer0VerdictSchema.safeParse(recordedVerdict);
230+
const verdict = recorded?.success ? recorded.data : this.engineLayer0Verdict(execCtx, activeOrganization);
231+
switch (verdict.kind) {
232+
case 'none':
233+
return undefined;
234+
case 'organization':
235+
return { tenantId: verdict.organizationId };
236+
case 'organizations':
237+
return { tenantId: activeOrganization ?? verdict.organizationIds[0], tenantIds: [...verdict.organizationIds] };
238+
case 'deny':
239+
return activeOrganization !== undefined ? { tenantId: activeOrganization } : undefined;
240+
}
241+
}
242+
243+
/** The body of `ObjectQL#engineLayer0Verdict`; the method's docblock there is its contract. */
244+
export function engineLayer0Verdict(
245+
this: Pick<DriverOptionsHost, 'resolveEnginePosture'>,
246+
execCtx: ExecutionContext | undefined,
247+
activeOrganization: string | undefined,
248+
): TenantLayer0Verdict {
249+
return computeTenantLayer0Verdict({
250+
tenancyPosture: this.resolveEnginePosture(),
251+
organizationId: activeOrganization,
252+
accessibleOrgIds: accessibleOrganizationsOf(execCtx),
253+
// Left to the driver, which resolves the tenant column from the
254+
// table it actually built (`resolveTenantField`): an object with no
255+
// such column is unscoped there whatever this says. `undefined` is
256+
// the verdict's "assume a tenant object", as Layer 0 reads an
257+
// unresolved field set.
258+
objectHasOrgIdField: undefined,
259+
tenancyDisabled: false,
260+
// The engine holds no permission sets: no exemption is ITS to grant.
261+
posturePermitsCrossTenant: false,
262+
isPlatformAdmin: false,
263+
});
264+
}
265+
266+
/** The body of `ObjectQL#wallExemptedOperation`; the method's docblock there is its contract. */
267+
export function wallExemptedOperation(
268+
this: Pick<DriverOptionsHost, 'engineLayer0Verdict'>,
269+
execCtx: ExecutionContext | undefined,
270+
recordedVerdict: unknown,
271+
): boolean {
272+
if (recordedVerdict === undefined) return false;
273+
const recorded = TenantLayer0VerdictSchema.safeParse(recordedVerdict);
274+
if (!recorded.success || recorded.data.kind !== 'none') return false;
275+
const activeOrganization =
276+
execCtx?.tenantId !== undefined && execCtx.tenantId !== null && String(execCtx.tenantId) !== ''
277+
? String(execCtx.tenantId)
278+
: undefined;
279+
return this.engineLayer0Verdict(execCtx, activeOrganization).kind !== 'none';
280+
}
281+
282+
/** The body of `ObjectQL#driverWriteTarget`; the method's docblock there is its contract. */
283+
export function driverWriteTarget(
284+
this: Pick<DriverOptionsHost, 'resolveEnginePosture'>,
285+
execCtx: ExecutionContext | undefined,
286+
): { tenantId: string; tenantIds?: string[] } | undefined {
287+
if (execCtx?.tenantId === undefined) return undefined;
288+
const tenantIds =
289+
this.resolveEnginePosture() === 'group' ? accessibleOrganizationsOf(execCtx) : undefined;
290+
return { tenantId: execCtx.tenantId as string, ...(tenantIds ? { tenantIds: [...tenantIds] } : {}) };
291+
}
292+
293+
/** The body of `ObjectQL#resolveEnginePosture`; the method's docblock there is its contract. */
294+
export function resolveEnginePosture(
295+
this: Pick<DriverOptionsHost, 'tenancyPostureProvider'>,
296+
): TenancyPosture {
297+
const injected = normalizeTenancyPosture(this.tenancyPostureProvider?.());
298+
if (injected) return injected;
299+
try {
300+
return resolveTenancyPosture();
301+
} catch {
302+
return 'isolated';
303+
}
304+
}

0 commit comments

Comments
 (0)