Skip to content

Commit 366e4aa

Browse files
refactor(objectql): move the caller-envelope builders out of engine.ts into engine-execution-plumbing.ts (#25522 cut 9) (#26263)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XP77FHtiK2YPPuW6xf4GxC5)_ Part of #25522 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160 Cut 9 of the `packages/objectql/src/engine.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6421730179`. Four bodies of the "Execution plumbing" section move unedited into a new module, `packages/objectql/src/engine-execution-plumbing.ts`: `buildSession`, `buildProvenance`, `buildEvalUser` and `buildUser`, the builders of the caller envelopes a hook and a validation predicate read. No behaviour changes. #25522 stays open for the later cuts. Branch `claude/project-thread-3pdv8m` (the card thread's harness-pinned branch), cut from `origin/main` at `1eebbc08b`, which contains cut 8's merge (`43f1da0f6`, #26229). Nothing was edited before that merge landed. ## Why this area (the fresh seam table) Measured on `origin/main` `1eebbc08b` with cut 8's script, unchanged. `engine.ts` is 23,095 lines, 7,373 of them (31.9%) from the last 30 days (`git blame -s --since=2026-09-26T17:24Z`). `scripts/pm/git-history.mjs count` (window covered, exit 0): 127 commits in 30 days, 33 in 7. `class ObjectQL` holds 318 members, 255 methods and accessors plus the constructor. Columns as in cut 8: "inferred public" stays, "U" counts `this.FIELD.delete/insert/update(` sites, "private statics" stay. | range | section | first … last member | lines | 30-day lines | methods | inferred public | U | private statics | |---|---|---|---|---|---|---|---|---| | 4761-4986 | Instance state and constructor | `txStore` … `constructor` | 226 | 50 | 0 (0 public) | - | 0 | - | | 4987-5009 | Status and registry accessor | `getStatus` … `registry` | 23 | 0 | 2 (2 public) | getStatus | 0 | - | | 5010-5353 | Hook and function registry | `registerHook` … `triggerHooks` | 344 | 6 | 16 (16 public) | registerHook, triggerHooks | 1 | - | | 5354-5709 | Hook dispatch internals | `hasHooksFor` … `assertBulkPerRowHookBudget` | 356 | 61 | 7 (0 public) | - | 0 | - | | 5710-5870 | Action registry and activation | `registerAction` … `setActionActive` | 161 | 19 | 10 (10 public) | - | 1 | - | | 5871-6152 | Registered resolvers and write-gate probes | `_heldFileResolver` … `registerInsertRecomputeListener` | 282 | 116 | 6 (6 public) | - | 0 | - | | 6153-6906 | Readable-ids security and permission/formula resolution | `readableIdsSecurityReported` … `resolveDefaultPermissions` | 754 | 546 | 15 (2 public) | - | 0 | - | | 6907-7013 | Authz invalidation and middleware registration | `writeEpoch` … `hasObjectMiddleware` | 107 | 18 | 4 (4 public) | - | 0 | - | | 7014-7249 | Execution plumbing (middleware, session, provenance, users) | `executeWithMiddleware` … `buildUser` | 236 | 37 | 7 (0 public) | - | 0 | - | | 7250-7580 | Driver options and engine posture | `buildDriverOptions` … `resolveEnginePosture` | 331 | 209 | 6 (0 public) | - | 0 | - | | 7581-7790 | Organization probe and system-insert organization | `probeInstallOrganizations` … `transactionCoversDriverFor` | 210 | 45 | 4 (0 public) | - | 0 | - | | 7791-8156 | Field defaults | `resolveNowDefault` … `defaultFieldEntries` | 366 | 4 | 6 (0 public) | - | 0 | - | | 8157-8769 | Autonumber | `applyAutonumbers` … `seedAutonumber` | 613 | 80 | 6 (0 public) | - | 1 | - | | 8770-9434 | Package, app, plugin and driver registration | `registrationConversionWarned` … `registerDriver` | 665 | 170 | 6 (3 public) | registerApp, registerDriver | 0 | - | | 9435-9643 | Datasource registry | `supplyFileColumnsMovedResolver` … `listUnavailableDatasources` | 209 | 1 | 8 (7 public) | - | 3 | - | | 9644-9795 | Write guard and realtime publish | `assertWriteAllowed` … `publishBulkDataEvent` | 152 | 18 | 4 (1 public) | - | 0 | - | | 9796-9894 | Validation-message i18n | `setI18nService` … `objectDisplayLabel` | 99 | 14 | 4 (1 public) | - | 0 | - | | 9895-10987 | Referential integrity and master-detail resolution | `assertReferencesResolve` … `masterDeclaredFields` | 1093 | 429 | 12 (0 public) | - | 0 | - | | 10988-11103 | Dangling-reference and cascade inspection | `inspectDanglingReferences` … `inspectCascadeDelete` | 116 | 48 | 2 (2 public) | - | 0 | - | | 11104-11198 | Crypto and tenancy providers | `setCryptoProvider` … `setTenancyPostureProvider` | 95 | 20 | 4 (4 public) | - | 1 | - | | 11199-11618 | Secret and internal-field handling | `encryptSecretFields` … `resolveInternalField` | 420 | 61 | 9 (3 public) | - | 0 | - | | 11619-11687 | Schema lookup and filter judging | `getSchema` … `judgeFilter` | 69 | 48 | 3 (2 public) | - | 0 | - | | 11688-11953 | Datasource routing | `LIFECYCLE_DATASOURCE` … `setDatasourceMapping` | 266 | 20 | 8 (3 public) | setDatasourceMapping | 0 | - | | 11954-12121 | Lifecycle init and health | `init` … `resolvePrimaryDatasource` | 168 | 16 | 3 (3 public) | init | 0 | - | | 12122-12195 | Media and value-shape presence | `objectHasMediaField` … `valueShapeStrictFor` | 74 | 13 | 4 (0 public) | - | 0 | mediaFieldPresence, coveredValueFieldPresence | | 12196-12878 | Migration flags and attestations | `isFileReferencesMigrationVerified` … `wasDatastoreCreatedFromEmpty` | 683 | 81 | 17 (8 public) | - | 0 | - | | 12879-12901 | destroy | `destroy` … `destroy` | 23 | 0 | 1 (1 public) | destroy | 0 | - | | 12902-12920 | 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 | | 12921-13371 | Roll-up summaries | `autonumberCounters` … `recomputeSummaries` | 451 | 0 | 7 (1 public) | - | 0 | - | | 13372-13625 | Related-record expansion | `expandRelatedRecords` … `expandRelatedRecords` | 254 | 10 | 1 (0 public) | - | 0 | - | | 13626-14069 | File-reference hydration | `resolveFileReferences` … `readParentDerivedFiles` | 444 | 310 | 5 (0 public) | - | 0 | - | | 14070-14548 | Query lowering | `resolveWhereTokens` … `requireFindOnePredicate` | 479 | 272 | 9 (0 public) | - | 0 | - | | 14549-15105 | find / findOne | `find` … `staticReadonlyCreateStrip` | 557 | 142 | 4 (2 public) | - | 0 | - | | 15106-15763 | validate | `validate` … `validate` | 658 | 447 | 1 (1 public) | - | 0 | - | | 15764-17110 | insert / insertMany | `readUpdatePriorRow` … `insertMany` | 1347 | 439 | 3 (2 public) | - | 1 | - | | 17111-19044 | update | `update` … `update` | 1934 | 275 | 1 (1 public) | - | 0 | - | | 19045-20585 | Delete cascade and reference walking | `planCascadeAtomicity` … `organizationOwnedRowCounts` | 1541 | 733 | 22 (1 public) | - | 0 | storedReferenceIncludes, remainderAfterMemberRemoval, referenceCheckContext, outlivesOrganization, keepsOrganizationReference, organizationCountContext | | 20586-21155 | delete | `delete` … `delete` | 570 | 47 | 1 (1 public) | - | 0 | - | | 21156-21775 | count / aggregate | `count` … `aggregate` | 620 | 236 | 3 (2 public) | - | 0 | - | | 21776-22084 | execute and transactions | `execute` … `enforceTransactionOrigin` | 309 | 74 | 8 (2 public) | - | 0 | - | | 22085-22377 | Compatibility API | `registerObject` … `create` | 293 | 52 | 16 (16 public) | - | 0 | - | The per-method move screen for every section and the full pricing are in the project file `pm-orders/server/25522-cut9-seam-table.md`. A body stays if any of these holds: an inferred-return public method, a U site, a private-static read, an `objectql/src/engine.ts#NAME` pointer under `packages/spec/**` or `docs/adr/**`, an engine self-call, or a static. Bodies earlier cuts moved count as stubs. Pricing (net `engine.ts` lines, the seat's rule): a stub costs a comment, the host literal's open and close and the `.call`, plus two lines per field read and one per method read; a body that reads no member costs two (the comment and the call); the import adds about three. | area | 30-day lines | paying bodies | net | why not / why | |---|---|---|---|---| | **Execution plumbing** (this PR) | **37 of 236** | **4 of 7 bodies, 66 body lines** | **-55 measured** | the coldest section that pays: the four moved bodies carry **0** lines from the last 30 days; none reads an engine member, so there is no host and no getter at all; no `engine.ts#` pointer names them | | Autonumber | 80 of 613 | `adoptExplicitAutonumber` 19, `isIssuedAutonumberCollision` 5, `warnAutonumberReadersUnserved` 13, `seedAutonumber` 64 | about -75 | pays 20 lines more but holds over twice the recent lines; the next candidate | | count / aggregate | 236 of 620 | `count` 58, `rejectCredentialAggregation` 38 | about -71 | hot | | Migration flags and attestations | 81 of 683 | 8 bodies | about -91 | the section writes five fields, so its host needs setters (a seat ruling first, as cuts 7 and 8 said) | | Package registration, Referential integrity, File-reference hydration, Readable-ids, Delete cascade, Driver options, validate | 170 to 733 | 161 to 469 | -120 to -439 | the hottest sections; the card says take the coldest | | Query lowering, Hook dispatch internals, find, Roll-up summaries and the rest | - | - | -1 to -40 | pay little | Cut 8 priced this area at about -55 and the head measures exactly -55 (66 lines removed, 11 added). Areas the seat rejected priced about -5 and -15; it accepted -70 to -117. No other area pays better while being as cold: Autonumber is the only one between -55 and -91 below 100 recent lines, and its 80 recent lines include #26026's edit during cut 7. What stays in the section: `executeWithMiddleware` (ADR-0139 cites it as `engine.ts#executeWithMiddleware`), `buildReferentialFieldClear` (1 body line, the stub would cost 2) and `claimReferentialCascadeDelete` (5 body lines; it reads the `cascadeIssuedDeletes` field, so its stub would cost 6). ## The shape - The class keeps all four methods with name, modifiers (`private`), signature, docblock and position. Each body is now one comment and one `return NAME(execCtx);`, marked `[#25522 cut 9]`, the form earlier cuts use for a member-free body (`objectDisplayLabel`, `assertBulkPerRowHookBudget`, `resolveSecret`). - **No host object.** None of the four bodies reads `this` (AST screen, casts stripped): each is a function of the `ExecutionContext` it is handed. The card's rule is that shared state goes through one named host; here there is no shared state to hand over, so the module declares no host interface and imports nothing from the engine, not even as a type. - **Imports.** The module's imports are `HookContext` (`@objectstack/spec/data`) and `ExecutionContext` (`@objectstack/spec/kernel`), both as types, the bindings `engine.ts` uses. `engine.ts` keeps every import it had (both still have readers there) and gains the new module import with a two-line comment. - The module header names the four bodies, says what stays and why, and cites ADR-0068 and ADR-0139 by id only (no decision letter). ## Net lines (the seat's rule) `engine.ts` goes from 23,095 to 23,040 lines, **net -55** (11 added, 66 removed). The module is 122 lines: a 38-line header and imports, four one-line docblocks, and the 66 moved body lines with their signatures and closing braces. With the one re-keyed row of the census page, the diff is 201 changed lines over 3 files (+134 / -67), far under the 3,000-line human-merge threshold. ## The published `.d.ts` does not change (ruling `6348891160`) Base: `@objectstack/objectql` built with `engine.ts` restored from `1eebbc08b` and the new module removed (tree and index; restored with `git checkout HEAD --`, blob hashes compared, `git status` clean). Head: built at `2b013959b`. `buildSession(execCtx` occurs 1 time in the base `dist/index.mjs` (the inline method) and 3 times in the head's (the function, the method, the call), so each leg built the source it names. All six declaration files are **byte-identical**, and equal to the hashes cuts 7 and 8 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:** the four moved members are private, so the control drops `private` from the `buildUser` stub (anchor landed once, 1 hit after) and rebuilds: all six hashes and the chunk name changed (`util-Bxn7XMk8.d.ts` `18e7fa2d…`, `index.d.ts` `e5d86ee9…`), and the util chunk now prints `buildUser(execCtx?: ExecutionContext): HookContext['user'];`. Restored with `git checkout HEAD --` (blob hash compared, `git diff HEAD` empty, 0 hits), rebuilt, and all six files compared equal to the head build again. ## Proof of move-only A TypeScript-AST leaf-token compare (recursive `getChildren`, JSDoc skipped; `pm-orders/server/evidence/25522/astcmp.mjs`) of base `engine.ts` (`1eebbc08b`) against the head module: - For each of the four, the parameters, return type, type parameters and body tokens are **SAME**, `async` matches, and every body line equals the base line less two leading spaces, comments included: `buildSession` 205 tokens / 43 lines, `buildProvenance` 109 / 9, `buildEvalUser` 56 / 6, `buildUser` 72 / 8. - **Negative controls:** flipping `!flowRunId` to `flowRunId` in the moved `buildProvenance` reports DIFFER on its body tokens; editing only one comment in the moved `buildUser` fails the line rule. Both exit 1, and the module was restored from `HEAD` and its blob hash compared after each. - Class: 318 members at both ends, in the same order. 314 are byte-identical (full text, docblocks included), and the 4 stubs keep docblock, modifiers and signature byte-identical up to the body. - Top level: 231 of 232 base statements are byte-identical in head; the other is the class. Head adds the new module import with its comment. ## Readers of the moved lines - **The system-context census page (migrated in this PR).** `content/docs/permissions/system-context.mdx` row 63 anchored `packages/objectql/src/engine.ts#buildSession`, the read site that carries `isSystem` into the hook session. The read now lives in the module, so the row anchors `packages/objectql/src/engine-execution-plumbing.ts#buildSession`; its wording is unchanged. `check-system-context-census` at base: 151 read sites in 22 packages across 79 files, 144 of 144 symbols cited; at head: 151 sites across 80 files, 144 of 144 cited, OK. **Control:** with the base page restored over this head, the gate exits 1 with exactly two findings, `file-without-a-row` for the new module and `anchor-is-not-a-read-site` for the old anchor; restored from `HEAD`, blob hash compared, green again. - **ADR anchors.** No `scripts/adr-anchors/` entry describes a moved body: the engine entry's one paragraph is the transaction seam, and its ids stay cited in `engine.ts`. So no entry is added. `check-adr-anchors` at base `1eebbc08b` (clean detached worktree): 90 anchored files, **1,881** decision-letter citations; at head: 90 anchored files, **1,881** (resolving citations 59,224 → 59,226, the module header's two ADR ids). The moved bodies cite no decision letter; the docblocks that do (ADR-0090 D10 on `buildProvenance`) stay on the methods. - No `objectql/src/engine.ts#NAME` pointer under `packages/spec/**` or `docs/adr/**` names the four. Prose that names a method by file (`packages/spec/src/data/hook.zod.ts` on `buildSession()` in `packages/objectql/src/engine.ts`, `script-runner.ts` on `buildUser()`, the plugin and test comments) still resolves, because every method stays on the class in `engine.ts`; none of them reads source text. - No gate baseline keys a moved body (`durability-read-invention.baseline.json`, `query-options-erasure-baseline.json` and `federated-injected-column-readers.test.ts` name none of the four); `check-org-identifier`, which reads the session shape `buildSession` emits, is green. ## Acceptance notes - Serial check, re-read before `pr_create`: no open PR touches `packages/objectql/src/engine.ts`. #26261 edits three other `packages/objectql/src` files, #26138 a validation test, #25640 the package's `package.json` and changelog. #26258 (draft) edits row 45 of `system-context.mdx` (line 174), 39 lines above this PR's row 63 (line 213); the hunks do not overlap, and whichever lands second merges `main` per the card. `origin/main` moved 4 commits after the base, none on `packages/objectql/**`, `content/docs/permissions/**` or `scripts/adr-anchors/**`. - Next cut from this screen: Autonumber (about -75, four bodies, 80 recent lines). Migration flags still needs a setter ruling. ## Verification At `2b013959b`: - `pnpm --filter @objectstack/objectql typecheck` (`tsc --noEmit` + `check:test-typecheck`, 39 files / 233 errors / 64 pinned signatures held): VERDICT command-exit 0. `pnpm exec eslint --no-inline-config packages/objectql`: exit 0. - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`: 471 files, 8,774 tests passed (VERDICT command-exit 0). - Dependants: every changed member keeps its declaration and all six published `.d.ts` files are byte-identical, so no dependant can see a difference. CI's `TypeScript Type Check` covers them. - Gates at `2b013959b`: `dispatch-gates --commands` derived 86 families on the real diff. Every one was run, each exit code captured before any pipe, then reconciled with `--ran`: 86 accounted for, 84 run green, 2 **NOT MEASURED** (exit 3, PREREQUISITE NOT MET; CI runs both): `check:dual-build-cjs-loads` (needs every package's `dist/`) and `@objectstack/spec check:skill-examples` (needs the full build). Named readings: `check-system-context-census` OK (above), `check:org-identifier`, `check:logger-receiver-detach`, `check:durability-log-level`, `check:doc-anchors`, `check-affected-docs` exit 0; `check-engine-split-ratio --days 90` 97.6%. - Hand gates outside the derivation, all exit 0 at `2b013959b`: `check-adr-anchors` at base and head (above), `check-adr-symbol-anchors`, `check-tenant-audit-census`, `check-nul-bytes`, `check-platform-checklist --changed-from origin/main` and `check-commit-card-trailers --range origin/main..HEAD`. - No ablation, because no tested behaviour changed. `test:repo` was not run locally; CI's `Test Core` runs it. `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 docs edit changes only the anchor path in one row of the census table. --- _Generated by [Claude Code](https://claude.ai/code/session_01GYrEgnrA1u8hdMTsRxqJ4L)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent d613fdc commit 366e4aa

3 files changed

Lines changed: 134 additions & 67 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ a reader tracing where elevation travels needs them.
210210

211211
| # | Site | Package | What it does |
212212
|:--|:---|:---|:---|
213-
| 63 | `packages/objectql/src/engine.ts#buildSession` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
213+
| 63 | `packages/objectql/src/engine-execution-plumbing.ts#buildSession` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
214214
| 64 | `packages/objectql/src/engine.ts#isSystem` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
215215
| 65 | `packages/runtime/src/sandbox/body-runner.ts#executionContextFromHook` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |
216216

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25522 cut 9] The caller-envelope builders of `ObjectQL`'s execution
5+
* plumbing, moved out of `engine.ts` whole: `buildSession` (the
6+
* `HookContext.session` a hook reads, WHO is calling), `buildProvenance` (the
7+
* `HookContext.provenance` envelope, WHERE a write came from), `buildEvalUser`
8+
* (the ADR-0068 `current_user` validation-time predicates read) and
9+
* `buildUser` (the `HookContext.user` shortcut).
10+
*
11+
* A MOVE, not a redesign. Each exported function below is the body of the
12+
* `ObjectQL` method of the same name as it stood, re-indented one level and
13+
* otherwise unedited: the same copy list, the same flags carried only when
14+
* set, and the same `undefined` for a context with nothing to say. Each
15+
* method's docblock stays on the method in `engine.ts`, which is still the
16+
* contract.
17+
*
18+
* What stays in the class: `executeWithMiddleware`, because ADR-0139 cites it
19+
* by `engine.ts#executeWithMiddleware`; `buildReferentialFieldClear` and
20+
* `claimReferentialCascadeDelete`, whose bodies are no longer than the call
21+
* (and, for the second, the host literal) moving them would cost.
22+
*
23+
* ## How the moved bodies reach the class
24+
*
25+
* None of the four reads a member of the engine: each is a function of the
26+
* `ExecutionContext` it is handed. So there is no host object here — there
27+
* is no shared state to hand over — and each method keeps its name,
28+
* modifiers, signature, docblock and position and calls the function of the
29+
* same name directly, as the member-free bodies of earlier cuts do
30+
* (`engine-validation-messages.ts#objectDisplayLabel`).
31+
*
32+
* This module imports nothing from the engine, so there is no module cycle at
33+
* run time, and nothing re-exports it, so the published surface does not
34+
* move.
35+
*/
36+
37+
import type { HookContext } from '@objectstack/spec/data';
38+
import type { ExecutionContext } from '@objectstack/spec/kernel';
39+
40+
/** The body of `ObjectQL#buildSession`; the method's docblock there is its contract. */
41+
export function buildSession(execCtx?: ExecutionContext): HookContext['session'] {
42+
if (!execCtx) return undefined;
43+
const session = {
44+
userId: execCtx.userId,
45+
// `organizationId` is the blessed developer-facing name for the caller's
46+
// active org (matches the `organization_id` column, `current_user`
47+
// RLS shape, and seed rows). It comes from `execCtx.tenantId`, which the
48+
// kernel resolves from `session.activeOrganizationId`. The deprecated
49+
// `session.tenantId` alias (#3280) was removed here in v16 (#3290) — the
50+
// driver-layer `execCtx.tenantId` knob is a separate axis and stays.
51+
organizationId: execCtx.tenantId,
52+
positions: execCtx.positions,
53+
accessToken: execCtx.accessToken,
54+
// Propagate system-elevated flag so hooks can distinguish engine
55+
// self-writes (e.g. approval status mirror) from genuine user writes.
56+
...((execCtx as any).isSystem ? { isSystem: true } : {}),
57+
// Propagate the service-principal label (`ExecutionContext.actor`,
58+
// e.g. `svc:flow:<name>`) so a non-user write stays attributable in the
59+
// audit log — the writer's `userId ?? session.actor` fallback is dead
60+
// without this hop (the `ExecutionContext.actor` audit-attribution
61+
// contract, `@objectstack/spec` kernel/execution-context.zod.ts; #4366).
62+
...(typeof (execCtx as any).actor === 'string' && (execCtx as any).actor
63+
? { actor: (execCtx as any).actor }
64+
: {}),
65+
// Propagate the automation-suppression flag so the record-change trigger
66+
// can skip flow dispatch for seed/bulk writes (ADR: seed loads end-state
67+
// data, not user events). `skipAutomations` implies `skipTriggers` —
68+
// suppressing metadata hooks while still dispatching flows would leave
69+
// the "run automations & triggers" opt-out half-working (#2922).
70+
...((execCtx as any).skipTriggers || (execCtx as any).skipAutomations ? { skipTriggers: true } : {}),
71+
// Propagate the full automation opt-out so `triggerHooks` can skip
72+
// metadata-bound hooks (import with "run automations" unchecked, undo).
73+
...((execCtx as any).skipAutomations ? { skipAutomations: true } : {}),
74+
// Propagate the historical-import audit-preservation flag so the built-in
75+
// audit hook keeps a client-supplied updated_at/updated_by instead of
76+
// stamping now (#3493). Opt-in, server-set only.
77+
...((execCtx as any).preserveAudit ? { preserveAudit: true } : {}),
78+
} as HookContext['session'];
79+
// Nothing to say about the caller → say nothing. An object whose every
80+
// property is `undefined` conveys no more than no session at all, and
81+
// returning it would turn "no caller" into "an anonymous caller".
82+
return Object.values(session as Record<string, unknown>).some((v) => v !== undefined)
83+
? session
84+
: undefined;
85+
}
86+
87+
/** The body of `ObjectQL#buildProvenance`; the method's docblock there is its contract. */
88+
export function buildProvenance(execCtx?: ExecutionContext): HookContext['provenance'] {
89+
const flowRunId = (execCtx as any)?.flowRunId;
90+
const attributedUserId = (execCtx as any)?.attributedUserId;
91+
const performedByClientId = (execCtx as any)?.performedBy?.clientId;
92+
if (!flowRunId && !attributedUserId && !performedByClientId) return undefined;
93+
return {
94+
...(flowRunId ? { flowRunId: String(flowRunId) } : {}),
95+
...(attributedUserId ? { attributedUserId: String(attributedUserId) } : {}),
96+
...(performedByClientId ? { performedByClientId: String(performedByClientId) } : {}),
97+
};
98+
}
99+
100+
/** The body of `ObjectQL#buildEvalUser`; the method's docblock there is its contract. */
101+
export function buildEvalUser(
102+
execCtx?: ExecutionContext,
103+
): { id: string; positions: string[]; organizationId: string | null } | undefined {
104+
if (!execCtx || execCtx.userId == null) return undefined;
105+
return {
106+
id: String(execCtx.userId),
107+
positions: execCtx.positions ?? [],
108+
organizationId: execCtx.tenantId != null ? String(execCtx.tenantId) : null,
109+
};
110+
}
111+
112+
/** The body of `ObjectQL#buildUser`; the method's docblock there is its contract. */
113+
export function buildUser(execCtx?: ExecutionContext): HookContext['user'] {
114+
if (!execCtx || execCtx.userId == null) return undefined;
115+
return {
116+
id: String(execCtx.userId),
117+
...(execCtx.email ? { email: execCtx.email } : {}),
118+
// Always equals `session.organizationId` (both from `execCtx.tenantId`).
119+
// Undefined on unscoped (platform/community) calls.
120+
...(execCtx.tenantId != null ? { organizationId: String(execCtx.tenantId) } : {}),
121+
};
122+
}

‎packages/objectql/src/engine.ts‎

Lines changed: 11 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ import { enforceTransactionOrigin, execute, warnTransactionUnsupported } from '.
139139
// [#25522 cut 8] The compatibility-API bodies; the methods of the same names
140140
// below hand each body the members it reads (`CompatibilityApiHost`).
141141
import { getConfigs, introspectDatasource, registerObject, removePackage, syncObjectSchema, syncSchemas } from './engine-compatibility-api.js';
142+
// [#25522 cut 9] The hook-context caller-envelope builders; the methods of the
143+
// same names below call them, and none of the bodies reads a member.
144+
import { buildEvalUser, buildProvenance, buildSession, buildUser } from './engine-execution-plumbing.js';
142145
import { ExecutionContext, ExecutionContextSchema, type InstalledPackageSource } from '@objectstack/spec/kernel';
143146
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
144147
import type { FlowFunctionEffect } from '@objectstack/spec/automation';
@@ -7077,49 +7080,8 @@ export class ObjectQL implements IObjectQLEngine {
70777080
* session and stays gated.
70787081
*/
70797082
private buildSession(execCtx?: ExecutionContext): HookContext['session'] {
7080-
if (!execCtx) return undefined;
7081-
const session = {
7082-
userId: execCtx.userId,
7083-
// `organizationId` is the blessed developer-facing name for the caller's
7084-
// active org (matches the `organization_id` column, `current_user`
7085-
// RLS shape, and seed rows). It comes from `execCtx.tenantId`, which the
7086-
// kernel resolves from `session.activeOrganizationId`. The deprecated
7087-
// `session.tenantId` alias (#3280) was removed here in v16 (#3290) — the
7088-
// driver-layer `execCtx.tenantId` knob is a separate axis and stays.
7089-
organizationId: execCtx.tenantId,
7090-
positions: execCtx.positions,
7091-
accessToken: execCtx.accessToken,
7092-
// Propagate system-elevated flag so hooks can distinguish engine
7093-
// self-writes (e.g. approval status mirror) from genuine user writes.
7094-
...((execCtx as any).isSystem ? { isSystem: true } : {}),
7095-
// Propagate the service-principal label (`ExecutionContext.actor`,
7096-
// e.g. `svc:flow:<name>`) so a non-user write stays attributable in the
7097-
// audit log — the writer's `userId ?? session.actor` fallback is dead
7098-
// without this hop (the `ExecutionContext.actor` audit-attribution
7099-
// contract, `@objectstack/spec` kernel/execution-context.zod.ts; #4366).
7100-
...(typeof (execCtx as any).actor === 'string' && (execCtx as any).actor
7101-
? { actor: (execCtx as any).actor }
7102-
: {}),
7103-
// Propagate the automation-suppression flag so the record-change trigger
7104-
// can skip flow dispatch for seed/bulk writes (ADR: seed loads end-state
7105-
// data, not user events). `skipAutomations` implies `skipTriggers` —
7106-
// suppressing metadata hooks while still dispatching flows would leave
7107-
// the "run automations & triggers" opt-out half-working (#2922).
7108-
...((execCtx as any).skipTriggers || (execCtx as any).skipAutomations ? { skipTriggers: true } : {}),
7109-
// Propagate the full automation opt-out so `triggerHooks` can skip
7110-
// metadata-bound hooks (import with "run automations" unchecked, undo).
7111-
...((execCtx as any).skipAutomations ? { skipAutomations: true } : {}),
7112-
// Propagate the historical-import audit-preservation flag so the built-in
7113-
// audit hook keeps a client-supplied updated_at/updated_by instead of
7114-
// stamping now (#3493). Opt-in, server-set only.
7115-
...((execCtx as any).preserveAudit ? { preserveAudit: true } : {}),
7116-
} as HookContext['session'];
7117-
// Nothing to say about the caller → say nothing. An object whose every
7118-
// property is `undefined` conveys no more than no session at all, and
7119-
// returning it would turn "no caller" into "an anonymous caller".
7120-
return Object.values(session as Record<string, unknown>).some((v) => v !== undefined)
7121-
? session
7122-
: undefined;
7083+
// [#25522 cut 9] The body moved to `engine-execution-plumbing.ts`; it reads no member.
7084+
return buildSession(execCtx);
71237085
}
71247086

71257087
/**
@@ -7149,15 +7111,8 @@ export class ObjectQL implements IObjectQLEngine {
71497111
* populated (ADR-0049).
71507112
*/
71517113
private buildProvenance(execCtx?: ExecutionContext): HookContext['provenance'] {
7152-
const flowRunId = (execCtx as any)?.flowRunId;
7153-
const attributedUserId = (execCtx as any)?.attributedUserId;
7154-
const performedByClientId = (execCtx as any)?.performedBy?.clientId;
7155-
if (!flowRunId && !attributedUserId && !performedByClientId) return undefined;
7156-
return {
7157-
...(flowRunId ? { flowRunId: String(flowRunId) } : {}),
7158-
...(attributedUserId ? { attributedUserId: String(attributedUserId) } : {}),
7159-
...(performedByClientId ? { performedByClientId: String(performedByClientId) } : {}),
7160-
};
7114+
// [#25522 cut 9] The body moved to `engine-execution-plumbing.ts`; it reads no member.
7115+
return buildProvenance(execCtx);
71617116
}
71627117

71637118
/**
@@ -7217,12 +7172,8 @@ export class ObjectQL implements IObjectQLEngine {
72177172
private buildEvalUser(
72187173
execCtx?: ExecutionContext,
72197174
): { id: string; positions: string[]; organizationId: string | null } | undefined {
7220-
if (!execCtx || execCtx.userId == null) return undefined;
7221-
return {
7222-
id: String(execCtx.userId),
7223-
positions: execCtx.positions ?? [],
7224-
organizationId: execCtx.tenantId != null ? String(execCtx.tenantId) : null,
7225-
};
7175+
// [#25522 cut 9] The body moved to `engine-execution-plumbing.ts`; it reads no member.
7176+
return buildEvalUser(execCtx);
72267177
}
72277178

72287179
/**
@@ -7237,14 +7188,8 @@ export class ObjectQL implements IObjectQLEngine {
72377188
* `ctx.session.organizationId`, which is populated whenever a session is.
72387189
*/
72397190
private buildUser(execCtx?: ExecutionContext): HookContext['user'] {
7240-
if (!execCtx || execCtx.userId == null) return undefined;
7241-
return {
7242-
id: String(execCtx.userId),
7243-
...(execCtx.email ? { email: execCtx.email } : {}),
7244-
// Always equals `session.organizationId` (both from `execCtx.tenantId`).
7245-
// Undefined on unscoped (platform/community) calls.
7246-
...(execCtx.tenantId != null ? { organizationId: String(execCtx.tenantId) } : {}),
7247-
};
7191+
// [#25522 cut 9] The body moved to `engine-execution-plumbing.ts`; it reads no member.
7192+
return buildUser(execCtx);
72487193
}
72497194

72507195
/**

0 commit comments

Comments
 (0)