Skip to content

Commit 378c940

Browse files
refactor(objectql): move the ReadableIds composition and permission resolution bodies out of engine.ts (#25522 cut 15) (#26645)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XW9mBRdGopgsLvLN2tj9V1s)_ Part of #25522 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160 ## 维护者速读 - **改了什么**:把 `engine.ts` 里「ReadableIds 安全与权限/公式解析」一段的 8 个方法体(ReadableIds 读取范围链的组合、启动时 controlled_by_parent 主表的检查、单个父对象读取范围的询问、一次操作共享的权限解析及其三个使用方、公式故障的一次性告警)原样搬进新文件 `engine-readable-ids-permission-formula.ts`,类上的方法签名与注释不变,只把方法体换成转交;只被这些方法体用到的常量 `READABLE_IDS_MAX_CHAIN_DEPTH` 随之搬走,与留下的代码共用的 2 个工具函数和 4 个类型原地加 `export`、由新文件导入(不复制)。`engine.ts` 22,092 → 21,947 行。 - **为什么改**:#25522 按缝拆分 `engine.ts` 的第 15 刀(18.2.0 发布后重启)。缝表重测后,`validate`(约 −439)仍受 `.d.ts` 重排的裁决所限;第二名「包、应用、插件与驱动注册」(约 −233)与在途的 #26337 重叠(同一个 `METADATA_ARRAY_KEYS` 及其门禁),所以取不重叠的第三名。 - **风险与回滚**:纯搬移,无行为变化;公开 `.d.ts` 六个文件逐字节不变(含正控);新文件与 `engine.ts` 互相导入(运行期循环),已用一次性探针在新旧两版构建产物上逐行对照(78 行完全一致)。回滚 = revert 本 PR。 - **先看**:`packages/objectql/src/engine-readable-ids-permission-formula.ts`(搬入的方法体与 `ReadableIdsPermissionFormulaHost`)、`packages/objectql/src/engine.ts` 里 `composeReadableIdsChain` 的转交桩(泛型、递归经实例方法回入)与原地 `export`、`packages/core/src/security/row-serving-door-exposure.pin.test.ts`(一行改键)。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`,应见全部测试文件通过。 - **需要你做的**:无,席位落地。 Cut 15 of the `packages/objectql/src/engine.ts` split (#24586 ruling A, recorded in `6270097840`), the first after the release pause (`6434935278`; restart record `6439182543`, `@objectstack/spec@18.2.0`). The claim is `6439254992`. Eight bodies of the "Readable-ids security and permission/formula resolution" section move unedited into a new module, `packages/objectql/src/engine-readable-ids-permission-formula.ts`: `composeReadableIdsChain` (the chain of composed `ReadableIds` read scopes), `findUnservableControlledByParentMasters` (the boot screen over `controlled_by_parent` details), `readableIdsScopeOf` (the scope one leaf is composed with), `resolveOptionPermissions`, `permissionResolution`, `resolveFormulaPermissions` and `resolveDefaultPermissions` (an operation's one effective-permission resolution and its three consumers), and `reportFormulaFault`. No behaviour changes. #25522 stays open for the later cuts. Branch `claude/project-thread-firh6t` (the card thread's harness-pinned branch), cut from `origin/main` at `f9867020c`, which contains cut 14's merge (`00874b9e09`, #26489) and the 18.2.0 release. ## What changes ### Why this area Re-measured on `f9867020c` with cut 11's measure script and cut 14's price step (`price3.mjs`), both unchanged: a helper a paying body shares with a staying body is exported in place and blocks nothing; one read only by moving bodies moves with them. Both earlier screens still apply (query-options erasure sites, 8 at base, none in this section; module-helper closure). `engine.ts` is 22,092 lines, 7,009 of them from the last 30 days (`git blame --since=2026-09-27T10:15Z`, clone deepened to 2026-09-22 first). `scripts/pm/git-history.mjs count`: 130 commits in 30 days, 35 in 7. `class ObjectQL` holds 318 members, 255 methods and accessors. Since cut 14, one commit touched the file (#26551, +7 lines in "Action registry and activation"); the release added no new section. <details><summary>Seam table on <code>f9867020c</code> (41 sections of <code>class ObjectQL</code>; U = untyped receivers)</summary> | range | section | first … last member | lines | 30-day lines | methods | inferred public | U | private statics | |---|---|---|---|---|---|---|---|---| | 4659-4884 | Instance state and constructor | `txStore` … `constructor` | 226 | 50 | 0 (0 public) | - | 0 | - | | 4885-4907 | Status and registry accessor | `getStatus` … `registry` | 23 | 0 | 2 (2 public) | getStatus | 0 | - | | 4908-5251 | Hook and function registry | `registerHook` … `triggerHooks` | 344 | 6 | 16 (16 public) | registerHook, triggerHooks | 1 | - | | 5252-5607 | Hook dispatch internals | `hasHooksFor` … `assertBulkPerRowHookBudget` | 356 | 61 | 7 (0 public) | - | 0 | - | | 5608-5770 | Action registry and activation | `registerAction` … `setActionActive` | 163 | 24 | 10 (10 public) | - | 1 | - | | 5771-6052 | Registered resolvers and write-gate probes | `_heldFileResolver` … `registerInsertRecomputeListener` | 282 | 116 | 6 (6 public) | - | 0 | - | | 6053-6806 | Readable-ids security and permission/formula resolution | `readableIdsSecurityReported` … `resolveDefaultPermissions` | 754 | 546 | 15 (2 public) | - | 0 | - | | 6807-6913 | Authz invalidation and middleware registration | `writeEpoch` … `hasObjectMiddleware` | 107 | 18 | 4 (4 public) | - | 0 | - | | 6914-7091 | Execution plumbing (middleware, session, provenance, users) | `executeWithMiddleware` … `buildUser` | 178 | 45 | 7 (0 public) | - | 0 | - | | 7092-7300 | Driver options and engine posture | `buildDriverOptions` … `resolveEnginePosture` | 209 | 150 | 6 (0 public) | - | 0 | - | | 7301-7510 | Organization probe and system-insert organization | `probeInstallOrganizations` … `transactionCoversDriverFor` | 210 | 45 | 4 (0 public) | - | 0 | - | | 7511-7876 | Field defaults | `resolveNowDefault` … `defaultFieldEntries` | 366 | 4 | 6 (0 public) | - | 0 | - | | 7877-8489 | Autonumber | `applyAutonumbers` … `seedAutonumber` | 613 | 80 | 6 (0 public) | - | 1 | - | | 8490-9154 | Package, app, plugin and driver registration | `registrationConversionWarned` … `registerDriver` | 665 | 170 | 6 (3 public) | registerApp, registerDriver | 0 | - | | 9155-9363 | Datasource registry | `supplyFileColumnsMovedResolver` … `listUnavailableDatasources` | 209 | 1 | 8 (7 public) | - | 3 | - | | 9364-9515 | Write guard and realtime publish | `assertWriteAllowed` … `publishBulkDataEvent` | 152 | 18 | 4 (1 public) | - | 0 | - | | 9516-9614 | Validation-message i18n | `setI18nService` … `objectDisplayLabel` | 99 | 14 | 4 (1 public) | - | 0 | - | | 9615-10323 | Referential integrity and master-detail resolution | `assertReferencesResolve` … `masterDeclaredFields` | 709 | 308 | 12 (0 public) | - | 0 | - | | 10324-10439 | Dangling-reference and cascade inspection | `inspectDanglingReferences` … `inspectCascadeDelete` | 116 | 48 | 2 (2 public) | - | 0 | - | | 10440-10534 | Crypto and tenancy providers | `setCryptoProvider` … `setTenancyPostureProvider` | 95 | 20 | 4 (4 public) | - | 1 | - | | 10535-10954 | Secret and internal-field handling | `encryptSecretFields` … `resolveInternalField` | 420 | 61 | 9 (3 public) | - | 0 | - | | 10955-11023 | Schema lookup and filter judging | `getSchema` … `judgeFilter` | 69 | 48 | 3 (2 public) | - | 0 | - | | 11024-11289 | Datasource routing | `LIFECYCLE_DATASOURCE` … `setDatasourceMapping` | 266 | 20 | 8 (3 public) | setDatasourceMapping | 0 | - | | 11290-11457 | Lifecycle init and health | `init` … `resolvePrimaryDatasource` | 168 | 16 | 3 (3 public) | init | 0 | - | | 11458-11531 | Media and value-shape presence | `objectHasMediaField` … `valueShapeStrictFor` | 74 | 13 | 4 (0 public) | - | 0 | mediaFieldPresence, coveredValueFieldPresence | | 11532-12121 | Migration flags and attestations | `isFileReferencesMigrationVerified` … `wasDatastoreCreatedFromEmpty` | 590 | 96 | 17 (8 public) | - | 0 | - | | 12122-12144 | destroy | `destroy` … `destroy` | 23 | 0 | 1 (1 public) | destroy | 0 | - | | 12145-12163 | 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 | | 12164-12614 | Roll-up summaries | `autonumberCounters` … `recomputeSummaries` | 451 | 0 | 7 (1 public) | - | 0 | - | | 12615-12868 | Related-record expansion | `expandRelatedRecords` … `expandRelatedRecords` | 254 | 10 | 1 (0 public) | - | 0 | - | | 12869-13066 | File-reference hydration | `resolveFileReferences` … `readParentDerivedFiles` | 198 | 183 | 5 (0 public) | - | 0 | - | | 13067-13545 | Query lowering | `resolveWhereTokens` … `requireFindOnePredicate` | 479 | 272 | 9 (0 public) | - | 0 | - | | 13546-14102 | find / findOne | `find` … `staticReadonlyCreateStrip` | 557 | 142 | 4 (2 public) | - | 0 | - | | 14103-14760 | validate | `validate` … `validate` | 658 | 447 | 1 (1 public) | - | 0 | - | | 14761-16107 | insert / insertMany | `readUpdatePriorRow` … `insertMany` | 1347 | 439 | 3 (2 public) | - | 1 | - | | 16108-18041 | update | `update` … `update` | 1934 | 275 | 1 (1 public) | - | 0 | - | | 18042-19582 | Delete cascade and reference walking | `planCascadeAtomicity` … `organizationOwnedRowCounts` | 1541 | 733 | 22 (1 public) | - | 0 | storedReferenceIncludes, remainderAfterMemberRemoval, referenceCheckContext, outlivesOrganization, keepsOrganizationReference, organizationCountContext | | 19583-20152 | delete | `delete` … `delete` | 570 | 47 | 1 (1 public) | - | 0 | - | | 20153-20772 | count / aggregate | `count` … `aggregate` | 620 | 192 | 3 (2 public) | - | 0 | - | | 20773-21081 | execute and transactions | `execute` … `enforceTransactionOrigin` | 309 | 74 | 8 (2 public) | - | 0 | - | | 21082-21374 | Compatibility API | `registerObject` … `create` | 293 | 52 | 16 (16 public) | - | 0 | - | The per-member screen (movable bodies, what stays and why) and the full price output are in the project files, `pm-orders/server/25522-cut15-seam-table.md`. </details> | price (net lines) | section | taken? | |---|---|---| | -439 | validate | no: the `.d.ts` reorder ruling (exporting `insertStaticReadonlySubject`), unchanged since cut 14 | | -233 | Package, app, plugin and driver registration | no: overlaps open #26337, which edits `METADATA_ARRAY_KEYS` (read only by `registerMetadataCollections`, so it would move) and its gate row in `scripts/check-stack-collection-maps.mjs` | | -156 priced, **-145 landed** | Readable-ids security and permission/formula resolution | **yes**, without `resolveFormulaRelated` (below) | | -124 | Delete cascade and reference walking | pays less | | -71 or less | count / aggregate, Query lowering, the rest | pays less | **Why `resolveFormulaRelated` stays.** Its return type `FormulaRelated` is a non-exported type that top-level helpers that stay also use (`formulaHopScope`, `applyFormulaPlan`, `hydrateWriteFormulas` and `StoredExpressionServing`), so moving the body means exporting it in place. Measured by exporting each shared declaration on its own on the base and building (`dts-export-variant-cut15.sh`): `writeFailureLogMeta`, `isExpressionDefault`, `PermissionResolution`, `FormulaPlanEntry`, `ReadableIdsSecurity` and `ReadableIdsChain` each leave all six declaration files byte-identical; `FormulaRelated`, which names `RelatedRecordBinding` from `validation/rule-validator`, makes the declaration bundler pull the validator declarations in earlier: the same declarations in another order, with the chunk renamed (`util-DOIDsRyc` to `util-BWosSWLp`). That is the same mechanism that keeps `validate` out, and the claim's byte-identical bar forbids it, so `resolveFormulaRelated` stays (39 body lines, about -30 of the priced -156). A first full move that included it is how this was found: its build reordered the chunk, and the variant measurement named the one export responsible. ### The shape - The class keeps all eight methods with name, modifiers, signature, docblock and position. Seven bodies become one host literal of exactly the `ReadableIdsPermissionFormulaHost` members they read, plus a `.call`, marked `[#25522 cut 15]`, as cuts 8 and 10 to 14 did. Fields go as getters over a `readX` closure (`_registry`, `logger`, `_effectiveObjectPermissionsResolver`, `formulaFaultReported`); methods go as late-bound closures (`refuseUncomposableReadableIdsClasses`, `readableIdsScopeOf`, `composeReadableIdsChain`, `uncomposableReadableIdsParent`, `buildEvalUser`, `defaultFieldEntries`), so a spy or double set on the engine is still the one a call reaches. No moved body assigns a field, so there is no setter (cut 13's setter ruling has nothing to apply to). - `composeReadableIdsChain` is generic and recursive: its stub calls the function instantiated with its type parameter `N` and `.call`s it on the host, the spelling #25467's generic cuts used, and the host's `composeReadableIdsChain` closure re-enters through the method, so the recursion still passes the instance (the probe counts both levels). - `resolveOptionPermissions` reads no member of the class, so its method calls the function directly (`return resolveOptionPermissions(schema, payloads, resolution);`). - **Helpers and types, under the cut-14 seat ruling.** `READABLE_IDS_MAX_CHAIN_DEPTH` is read only by `composeReadableIdsChain`, so it moved with it, docblock included and byte for byte. `writeFailureLogMeta` (read too by `reportFindFailure`, `insert`, `update`, `delete`) and `isExpressionDefault` (`applyFieldDefaults`) stay where they were, byte for byte, and gain `export` in place; so do the types the moved bodies share with staying code: `PermissionResolution`, `FormulaPlanEntry`, `ReadableIdsSecurity` and `ReadableIdsChain` (each `export` alone was measured `.d.ts`-neutral, above). `UnservableControlledByParentMaster` was already exported. The module imports the two functions as values and the five types as types from `./engine.js`; it is a new module, so before that it imported nothing from `./engine.js`. `index.ts` and `core.ts` name their exports singly, so nothing new is published (below). - **The run-time cycle (measured).** That value import makes `engine.ts` and the module import each other. The module runs nothing at evaluation (declarations only), and `engine.ts` imports it right after `./engine-master-detail-related.js`, after every module the moved bodies import, so the evaluation order of every other module is unchanged. Measured on the built bundles (`index.mjs`, `core.mjs`, `index.js`, `core.js`): with the `// src/engine.ts` region markers set aside, the `// src/...` region order at head equals the base's with one line added, the new module, between `engine-master-detail-related` and `in-memory-aggregation`. One `engine.ts` region disappears: the one that held only the external `@objectstack/spec/data` import of `ReadableIds`, `RLS_DENY_FILTER`, `markFilterSubtreeProvenance` and `isControlledByParentMasterField`, whose readers all moved; that import now sits in the module's region (an external package already loaded by earlier regions; ESM imports are hoisted). The bundles hold both files in one module, so the published build has no cycle. The late-binding probe answers the same on base and head (Tests). - **Imports.** The module imports `ReadableIds`, `RLS_DENY_FILTER`, `isControlledByParentMasterField`, `markFilterSubtreeProvenance`, `FilterCondition` (type), `ExecutionContext` (type), `toEvalPermissions`, `EvalError` and `EvalPermissions` (types), `collectComposableReadableIds`, `composeReadableIdsLeaves`, `describeReadableIdsLeaf`, `readableIdsRefusalError`, `ComposableReadableIdsSite` (type), `optionVisibilityReadsPermissions` and `readsPermissionPredicate`. `engine.ts` drops `composeReadableIdsLeaves`, the whole `ReadableIds` / `RLS_DENY_FILTER` / `markFilterSubtreeProvenance` / `isControlledByParentMasterField` import line, and `toEvalPermissions`, whose last readers moved. It keeps `optionVisibilityReadsPermissions` and `readsPermissionPredicate`: no code in `engine.ts` reads them any more, but the docblocks that stay on `resolveOptionPermissions` and `resolveFormulaPermissions` `{@link}` them, the same use `DatasourceUnavailableError` already has there at base (TypeScript counts a `{@link}` as a use under `noUnusedLocals`), and that import line stays byte-identical. - The module header names what moved, what stays and why, and how the helpers were handled. ### Net lines (the seat's rule) `engine.ts` goes from 22,092 to 21,947 lines, **net -145** (59 added, 204 removed). The module is 362 lines. With the one path reader below, the diff against `origin/main` is 628 changed lines over 3 files (+423 / -205). ### Readers of the moved lines - `packages/core/src/security/row-serving-door-exposure.pin.test.ts` lists every non-test file that calls a security service's `canReadObject(`. `engine.ts`'s only call was in `readableIdsScopeOf`, so its row moves to the new module, with its `not-a-door` classification and reason unchanged (the reason already described exactly this ask: "the ReadableIds composition (ADR-0139 D2) …"). Without the re-key the census would read `engine.ts` as stale and the module as unclassified. - No other census, ADR anchor, docs row or test key names a moved member or helper by `engine.ts` path: `objectql/src/engine.ts#NAME` pointers under `packages/spec/**`, `docs/**`, `content/**` and `scripts/**` name none of them; `content/docs/permissions/system-context.mdx` and its `NON_READ_ANCHORS` have no row for them (no moved body reads `isSystem`); `federated-injected-column-readers.test.ts` keys none of them (its `resolveFormulaRelated` row stays with that body in `engine.ts`); no moved body calls `referenceTargetOf(` (core's `second-object-read-exposure.pin.test.ts` is unchanged); no moved body holds a `query-options/no-any-erasure` site (8 at base and head), a tenant-audit untyped receiver or a private static read. Mentions by bare name (plugin-security's `READABLE_IDS_MAX_CHAIN_DEPTH` comments, `plugin.ts`'s `ObjectQL#findUnservableControlledByParentMasters`) name the constant and the method, which still exist under those names. - **No ADR anchor paragraph describes a moved body** (the `engine.ts` entry is the transaction-seam paragraph), so the module needs no anchor entry of its own. - **Read-seam census (the cut-21 trap of #25467).** `check-durability-degradation-log-level --list` at base and head is identical once line numbers are masked: 48 durability seams and 82 read seams, the same entries. No moved body is named in a read-seam entry. - **Decision-letter count:** `check-adr-anchors` at base `f9867020c` (clean detached worktree): 91 anchored files, **1,881** decision-letter citations on the anchored surface, 59,819 resolving citations across 8,353 files. At head: 91 anchored files, **1,880**, 59,820 across 8,354. The one letter that leaves the anchored surface is `[ADR-0139 D6, as amended — the nested master chain]` in `READABLE_IDS_MAX_CHAIN_DEPTH`'s docblock, which moved with the constant into the module, which no anchor entry names; the citation still resolves (it is in the 59,820), it is just no longer counted on the anchored surface. The +1 resolving citation and +1 file are the new module (its header names ADR-0139 once). The same one-letter move was disclosed on cuts 8 and 12 of #25467; anchoring the module to keep the count would mean writing a new invariant paragraph, which is not a move. ## Why #25522 splits `engine.ts` one seam at a time so that a change to one area reads, reviews and conflicts as that area alone. This cut takes the section that pays most under this cut's rulings and that no open PR touches (table above). Nothing about what the engine does changes. ## Tests No test is added or changed apart from the re-keyed row above: the move is proved by identity, not by new behaviour. <details><summary>Public <code>.d.ts</code> byte-identical, with a positive control</summary> Built `@objectstack/objectql` at the base (`f9867020c`, `engine.ts` blob `9c44e9da88b5`) and at head (`engine.ts` blob `19a84bece0b0`), each from a clean tree (`dts-cut15.sh`), and hashed every declaration file. All six are byte-identical: | file | sha256 (base = head) | |---|---| | `core.d.mts` | `e2913d082b71f3342f9013f7e75afad720488366f922beef28bbeee67a8cc81b` | | `core.d.ts` | `79919dabaf5a0bf7a81be2c09eee1535b789699f8e6363064e40fa8007f26c14` | | `index.d.mts` | `e6368a2e3c25d19f03cfbc471fbdd763daf9ca19de8cf5ce22f4493c8eca5f28` | | `index.d.ts` | `74cb25f41cf0aa8d94e495967ed228dcaaeeba8ba8fa8c54fab10eb01ddc1b51` | | `util-DOIDsRyc.d.mts` | `791b203b5e36d557cd0d59f15c317b9d85ef644e5ed730b5f3a6aa801fd37adf` | | `util-DOIDsRyc.d.ts` | `791b203b5e36d557cd0d59f15c317b9d85ef644e5ed730b5f3a6aa801fd37adf` | The builds are the moved code, not a stale cache: `composeReadableIdsChain.call(host` occurs 0 times in the base `dist/index.mjs` and once in the head's. **Positive control.** `scripts/ablation-replace.mjs` dropped `private` from ` private async readableIdsScopeOf(` on the head (anchor count 1 to 0; `engine.ts` blob `19a84bece0b0` to `c8dcf0a369f7`) and rebuilt: every declaration hash changed (`core.d.mts` `7f089f7719d3…`, `core.d.ts` `ef7645cf34d2…`, `index.d.mts` `6927c24b7c73…`, `index.d.ts` `3d457f36059b…`) and the chunk was renamed `util-BFa4erHR.d.ts`, which now publishes `readableIdsScopeOf(object: string, operation: string, parent: string, svc: ReadableIdsSecurity, …)` (line 5646). So the comparison sees a one-word change to a moved member's signature. The restore leg put the blob back to `19a84bece0b0` (`git diff HEAD` empty), and the head was rebuilt before every later step. </details> <details><summary>AST compare (move-only)</summary> `astcmp.mjs` (TypeScript compiler API, comments and whitespace ignored, tokens compared) on base `engine.ts` against head `engine.ts` plus the module: - Moved bodies, all eight **SAME** (parameters, return type, type parameters, `async`, body tokens, and every body line equal after one level of indent): `composeReadableIdsChain` 380 tokens / 46 lines, `findUnservableControlledByParentMasters` 201 / 12, `readableIdsScopeOf` 253 / 29, `resolveOptionPermissions` 149 / 19, `permissionResolution` 80 / 11, `resolveFormulaPermissions` 178 / 25, `reportFormulaFault` 101 / 14, `resolveDefaultPermissions` 223 / 21. - `class ObjectQL`: 318 of 318 members kept, in the same order; 310 byte-identical, and the 8 stubs keep docblock, modifiers and signature identical. - Top level: 217 of 228 base statements byte-identical. The other 11 (`exportcmp.mjs`): the 3 import edits above; 6 declarations that gained `export` and are otherwise byte-identical with their leading docblocks (`writeFailureLogMeta`, `isExpressionDefault`, `PermissionResolution`, `FormulaPlanEntry`, `ReadableIdsSecurity`, `ReadableIdsChain`); `READABLE_IDS_MAX_CHAIN_DEPTH`, byte-identical in the module (1,137 characters with its docblock); and the class. The head adds one statement, the module import. </details> <details><summary>Late-binding probe (base and head <code>dist</code>)</summary> `zz-probe-cut15.mjs` (a one-off, not committed) loads the built `ObjectQL` and drives all eight methods with doubles set on the instance for every member the hosts read (`_registry`, `logger`, `_effectiveObjectPermissionsResolver`, `formulaFaultReported`, `refuseUncomposableReadableIdsClasses`, `readableIdsScopeOf`, `composeReadableIdsChain`, `uncomposableReadableIdsParent`, `buildEvalUser`, `defaultFieldEntries`, plus `hasObjectMiddleware` and `resolveDatasourceBinding` under them), then prints results, the call order on the doubles and the log lines. Cases: the `ReadableIds` chain through `composeReadableIdsAtBoundary` (a nested two-level chain, which re-enters `composeReadableIdsChain` through the instance; a cycle, refused; a denied parent; no restriction; `getReadFilter` throwing; the admission throwing; the `explain` fallback; the deny sentinel; a non-filter answer; a `readableIdsScopeOf` double replaced after construction); `findUnservableControlledByParentMasters` over both D5 classes; `permissionResolution` with no user, with a user (asked once for two `get()`s) and with no resolver; `resolveOptionPermissions`, `resolveFormulaPermissions` and `resolveDefaultPermissions` with no resolution, a resolution and a throwing one; `reportFormulaFault` twice on one pair (one line), and with a `formulaFaultReported` double set after construction. The 78 output lines are identical on base and head (the engine's own construction log lines, which carry timestamps, set aside), which is the check the seat's ruling asks for on the run-time cycle. The module-order measurement on the bundles is under "The run-time cycle" above. </details> <details><summary>Suites</summary> Under `scripts/pm/os-verify-lock.sh` on head `4d72947a5` (after merging `main`): - Closure build of `@objectstack/objectql`, `@objectstack/rest`, `@objectstack/runtime`, `@objectstack/client`, `@objectstack/client-react` and `@objectstack/core` with their dependencies: exit 0. - `typecheck` of `@objectstack/objectql` and `@objectstack/core`: exit 0 each. - `@objectstack/objectql` vitest `--project local`: 472 files, 8,795 tests passed, exit 0. - Every `packages/*/vitest.repo-tests.json` project: core 6 files / 83 tests (it holds the re-keyed census), objectql 1 / 5, rest 5 / 280 passed and 18 skipped, runtime 3 / 947, spec 80 / 1,293, types 1 / 11. Exit 0 each. - The `.d.ts` pair and the late-binding probe were run on both bases: `f9867020c` before the merge and `ce9bc104e` after it, with the same six hashes and the same 78 probe lines both times (`engine.ts` is the same blob at both bases). </details> ## Verification Head `4d72947a56925b533ac8ad37659bcf47c615ea75`. The gate list is `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff at that head (56 commands, merge base `ce9bc104e`, no STALE warning), each run with its exit code captured before any pipe, then reconciled with `--ran`. - **Derived gates: 56 of 56 exit 0.** `--ran` reconciliation: `56 derived famil(ies) accounted for — 56 run, 0 NOT-MEASURED`. `check:dual-build-cjs-loads` refused on its first pass (exit 3, a package's `dist/` not yet built) and was re-run once the build closure was complete: 112 entry points across 69 packages load, exit 0. `check-engine-split-ratio` and `check-plugin-teardown-shape` ran after `git fetch --shallow-since=2026-07-22 origin main` and `git fetch origin 621a487`. - **Hand gates, exit 0 each:** `node scripts/check-adr-anchors.mjs` (counts above), `node scripts/check-tenant-audit-census.mjs`, `node scripts/check-adr-symbol-anchors.mjs`, `node scripts/check-platform-checklist.mjs --changed-from origin/main`, `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD`, `node scripts/check-durability-degradation-log-level.mjs --list`, and eslint as `pnpm lint` runs it (`--no-inline-config`) on `packages/objectql` and `packages/core`. ## Acceptance notes - **Serial order and restart.** Nothing was edited before the claim `6439254992` named this branch. The branch is cut from `f9867020c`, which holds cut 14's merge (`00874b9e09`) and the 18.2.0 release; `main` (`ce9bc104e`) was merged in once before opening (clean; no merged commit touches `engine.ts`, which is the same blob at both bases). - **Overlap.** Open PRs touching `packages/objectql/**` or the re-keyed core pin, listed at start and again just before opening this: #26337 (`engine.ts` and other objectql files), #26599 (`validation/rule-validator.test.ts` only) and #26517 (the release PR: `CHANGELOG.md`, `package.json`). #26337 edits `engine.ts` in the imports, `OperationContext`, `METADATA_ARRAY_KEYS`, after `SECURITY_SERVICE_SOURCE_SLOT`, and in `readUpdatePriorRow` / `insert` / `update`; none of those is moved here, and it does not touch the core pin. `git merge-tree` of this head with #26337's current head (`cbd27d9a6c`) exits 0, no conflict. Whichever lands second merges `main`. - **`resolveFormulaRelated` stays, measured.** Moving it needs `FormulaRelated` exported, and that export alone reorders the published declaration chunk (Why this area). It is the same question the restart carries for `validate` (may a move-only cut reorder declarations inside a published `.d.ts` chunk when the declared surface is unchanged?), so a ruling on that question would free both: `validate` (-439) and `resolveFormulaRelated` (about -30). - **Package, app, plugin and driver registration is next once #26337 lands** (about -233). It was not taken here only because #26337 edits `METADATA_ARRAY_KEYS` and its `check-stack-collection-maps.mjs` row; that row's `file:` and the constant would move with `registerMetadataCollections`. - **Receivers.** The host literals carry `_registry`, `logger`, `_effectiveObjectPermissionsResolver` and `formulaFaultReported` as getters, so each body reads whatever the instance holds when it reads it: the probe replaces `formulaFaultReported` and `readableIdsScopeOf` on the instance after construction and sees the moved bodies use the replacements. No body assigns a field, so there is no setter. - **Left as is.** Two docblocks that stay in `engine.ts` (`composeReadableIdsAtBoundary`'s and `composeReadableIdsChain`'s) still say `{@link READABLE_IDS_MAX_CHAIN_DEPTH}`, which now lives in the module, and `optionVisibilityReadsPermissions` / `readsPermissionPredicate` stay imported in `engine.ts` for the `{@link}`s on `resolveOptionPermissions` and `resolveFormulaPermissions` (above). Editing a staying docblock would break its byte identity, so these are noted here rather than touched, as cut 14 noted `staticReadonlyStripTakes`. - **Decision-letter count 1,881 → 1,880**, explained under "Readers of the moved lines": one `[ADR-0139 D6 …]` citation moved with its constant off the anchored surface; nothing was rewritten or dropped. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind (`ReadableIdsPermissionFormulaHost`, `engine-readable-ids-permission-formula.ts`). - [x] 3. No formatter ran; `pnpm lint` passes on the touched packages. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. No publish-visible change, so `skip-changeset`; both declaration lines are filled in above. - [x] 6. `packages/spec` not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English, apart from the maintainer quick-read block the project asks for. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. `skip-changeset`: move-only inside `@objectstack/objectql`, with the published declarations byte-identical and no behaviour change, so there is nothing for a release note to say. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5nM7fbhbE84nNy6VkE Co-authored-by: Claude <noreply@anthropic.com>
1 parent 86ae47e commit 378c940

3 files changed

Lines changed: 423 additions & 205 deletions

File tree

‎packages/core/src/security/row-serving-door-exposure.pin.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ const CALLERS: Record<string, Classification> = {
101101
kind: 'not-a-door',
102102
reason: 'the provider\'s service registration: it binds the admission into the `security` service the plugin registers; it serves no rows',
103103
},
104-
'packages/objectql/src/engine.ts': {
104+
// [#25522 cut 15] The ReadableIds composition's admission ask, moved out of engine.ts.
105+
'packages/objectql/src/engine-readable-ids-permission-formula.ts': {
105106
kind: 'not-a-door',
106107
reason:
107108
'the ReadableIds composition (ADR-0139 D2): it asks the admission of a PARENT object to compose the scope of a `$in` leaf '
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25522 cut 15] The `ReadableIds` composition, permission and formula
5+
* resolution bodies of `ObjectQL`, moved out of `engine.ts`:
6+
* `composeReadableIdsChain` (the chain of composed read scopes),
7+
* `findUnservableControlledByParentMasters` (the boot screen over
8+
* `controlled_by_parent` details), `readableIdsScopeOf` (the scope one leaf is
9+
* composed with), `resolveOptionPermissions`, `permissionResolution`,
10+
* `resolveFormulaPermissions` and `resolveDefaultPermissions` (an operation's
11+
* one effective-permission resolution and its three consumers) and
12+
* `reportFormulaFault` (a formula fault, said once per field).
13+
*
14+
* A MOVE, not a redesign. Each exported function below is the body of the
15+
* `ObjectQL` method of the same name as it stood, re-indented one level and
16+
* otherwise unedited. Each method's docblock stays on the method in
17+
* `engine.ts`, which is still the contract.
18+
*
19+
* What stays in `engine.ts`: `composeReadableIdsAtBoundary` and
20+
* `refuseUncomposableReadableIdsClasses` (ADR-0139 names them by path),
21+
* `resolveReadableIdsSecurity` (it hands `this` on as a value),
22+
* `resolveFormulaRelated` (its return type, `FormulaRelated`, is shared with
23+
* module helpers that stay, and exporting it reorders the published declaration
24+
* chunk), and the short ones that do not pay for a host
25+
* (`uncomposableReadableIdsParent`, `serveFormulaField`, `formulaFaultSink`).
26+
*
27+
* ## How the moved bodies reach the class
28+
*
29+
* The shape #23050 set for `rest-server.ts`, applied per method as
30+
* `engine-driver-options.ts` applies it. The class keeps all eight methods with
31+
* name, modifiers, signature, docblock and position. Seven bodies become one
32+
* literal of exactly the {@link ReadableIdsPermissionFormulaHost} members that
33+
* method reads, plus a `.call`. The fields a body reads (`_registry`, `logger`,
34+
* `_effectiveObjectPermissionsResolver`, `formulaFaultReported`) go as GETTERS,
35+
* so each read happens when the body makes it, as it did; no moved body assigns
36+
* a field. The methods they call go as late-bound closures over the instance,
37+
* so a double or spy set on the engine is still the one a call reaches, and
38+
* `composeReadableIdsChain`'s recursion re-enters through the method.
39+
* `resolveOptionPermissions` reads no member of the class, so its method calls
40+
* the function directly.
41+
*
42+
* ## Module helpers
43+
*
44+
* `READABLE_IDS_MAX_CHAIN_DEPTH` is read by `composeReadableIdsChain` alone, so
45+
* it moved with it. Two helpers are read by these bodies AND by bodies that
46+
* stay: `writeFailureLogMeta` (the write doors' failure logs) and
47+
* `isExpressionDefault` (`applyFieldDefaults`). One home each: they stay
48+
* declared in `engine.ts`, exported in place, and are imported here, never
49+
* copied. The types these bodies share with bodies that stay
50+
* (`PermissionResolution`, `FormulaPlanEntry`, `ReadableIdsSecurity`,
51+
* `ReadableIdsChain`) are exported in place the same way and imported as types.
52+
* Nothing re-exports any of them from the package entry, so the published
53+
* surface does not move.
54+
*
55+
* That import is a run-time module cycle (`engine.ts` imports this module, this
56+
* module imports `engine.ts`). Nothing here runs at module evaluation, only
57+
* declarations, and `engine.ts` imports this module after every module this one
58+
* imports, so the engine's evaluation order is what it was. The published build
59+
* bundles both files into one, where the cycle does not exist.
60+
*/
61+
62+
import { ReadableIds, RLS_DENY_FILTER, isControlledByParentMasterField, markFilterSubtreeProvenance } from '@objectstack/spec/data';
63+
import type { FilterCondition } from '@objectstack/spec/data';
64+
import type { ExecutionContext } from '@objectstack/spec/kernel';
65+
import { toEvalPermissions, type EvalError, type EvalPermissions } from '@objectstack/formula';
66+
import { collectComposableReadableIds, composeReadableIdsLeaves, describeReadableIdsLeaf, readableIdsRefusalError } from './filter-comparand-shape.js';
67+
import type { ComposableReadableIdsSite } from './filter-comparand-shape.js';
68+
import { optionVisibilityReadsPermissions, readsPermissionPredicate } from './validation/rule-validator.js';
69+
import { isExpressionDefault, writeFailureLogMeta } from './engine.js';
70+
import type { FormulaPlanEntry, PermissionResolution, ReadableIdsChain, ReadableIdsSecurity, UnservableControlledByParentMaster } from './engine.js';
71+
import type { ObjectQL } from './engine.js';
72+
73+
/**
74+
* The `ObjectQL` members the functions below read — every one of them, and no
75+
* other; each function's `this` is the `Pick` of the members IT reads. Each is
76+
* typed AS the class member (an indexed access on the class), so a change to a
77+
* member's type on the class is a change here too, and naming a member the
78+
* class does not have is a compile error. See the module header for which
79+
* members are getters, and which one also has a setter.
80+
*/
81+
export interface ReadableIdsPermissionFormulaHost {
82+
readonly _registry: ObjectQL['_registry'];
83+
readonly logger: ObjectQL['logger'];
84+
readonly _effectiveObjectPermissionsResolver: ObjectQL['_effectiveObjectPermissionsResolver'];
85+
readonly formulaFaultReported: ObjectQL['formulaFaultReported'];
86+
refuseUncomposableReadableIdsClasses: ObjectQL['refuseUncomposableReadableIdsClasses'];
87+
readableIdsScopeOf: ObjectQL['readableIdsScopeOf'];
88+
composeReadableIdsChain: ObjectQL['composeReadableIdsChain'];
89+
uncomposableReadableIdsParent: ObjectQL['uncomposableReadableIdsParent'];
90+
buildEvalUser: ObjectQL['buildEvalUser'];
91+
defaultFieldEntries: ObjectQL['defaultFieldEntries'];
92+
}
93+
94+
/**
95+
* [ADR-0139 D6, as amended — the nested master chain] How deep a chain of
96+
* composed scopes the engine composes: the scope answered for a leaf in the
97+
* read's own `where` is one scope deep, the scope answered for a leaf inside
98+
* it is two, and so on down a master-detail chain whose every master is
99+
* itself `controlled_by_parent`. A chain deeper than this is refused by name.
100+
*
101+
* A COST ceiling, not a semantic rule, and the same cut as the
102+
* `controlled_by_parent` derivation's (`CBP_MAX_CHAIN_DEPTH`,
103+
* `packages/plugins/plugin-security`): a detail with seven derived masters
104+
* above it composes, an eighth derived master is refused there and here.
105+
* Every authored chain in this repository is one hop, the deepest any consumer
106+
* has asked for is two, and 8 is four times that. Termination needs no bound —
107+
* the chain refuses a parent already on its branch — so what this caps is the
108+
* statement the driver compiles, one nested subquery per level. At the bound
109+
* the read is REFUSED, loud (D5's envelope), ⛔ never composed partway and
110+
* never answered "no restriction".
111+
*/
112+
const READABLE_IDS_MAX_CHAIN_DEPTH = 8;
113+
114+
/** The body of `ObjectQL#composeReadableIdsChain`; the method's docblock there is its contract. */
115+
export async function composeReadableIdsChain<N>(
116+
this: Pick<ReadableIdsPermissionFormulaHost, 'refuseUncomposableReadableIdsClasses' | 'readableIdsScopeOf' | 'composeReadableIdsChain'>,
117+
chain: ReadableIdsChain,
118+
node: N,
119+
sites: readonly ComposableReadableIdsSite[],
120+
ancestors: readonly string[],
121+
): Promise<N> {
122+
const { object, operation } = chain;
123+
const parents = this.refuseUncomposableReadableIdsClasses(object, operation, sites);
124+
for (const parent of parents) {
125+
if (chain.answers.has(parent)) continue;
126+
const site = sites.find((s) => s.leaf.object === parent)!;
127+
const branch = [object, ...ancestors, parent];
128+
if (ancestors.includes(parent)) {
129+
throw readableIdsRefusalError(
130+
object,
131+
`${operation}('${object}'): ${describeReadableIdsLeaf(site.at, site.leaf)}, inside the read scope of ` +
132+
`'${ancestors[ancestors.length - 1]}', and the engine composes no such leaf: the chain of read scopes ` +
133+
`${branch.join(' -> ')} returns to '${parent}', a cycle. The query was NOT run and no driver was asked. ` +
134+
`A parent's read scope cannot be derived from its own readable ids; fix the read scope the security ` +
135+
`service answers for '${ancestors[ancestors.length - 1]}'.`,
136+
);
137+
}
138+
if (ancestors.length >= READABLE_IDS_MAX_CHAIN_DEPTH) {
139+
throw readableIdsRefusalError(
140+
object,
141+
`${operation}('${object}'): ${describeReadableIdsLeaf(site.at, site.leaf)}, and the engine composes no ` +
142+
`such leaf: the chain of read scopes ${branch.join(' -> ')} is ${ancestors.length + 1} scopes deep, and the ` +
143+
`engine composes a chain at most ${READABLE_IDS_MAX_CHAIN_DEPTH} scopes deep. The query was NOT run and ` +
144+
`no driver was asked. A master-detail chain this deep is not one the engine composes; read '${parent}' as ` +
145+
'the caller first and match its ids with a list, or shorten the chain.',
146+
);
147+
}
148+
const answer = chain.security.kind === 'usable'
149+
? await this.readableIdsScopeOf(object, operation, parent, chain.security.svc, chain.context)
150+
: null;
151+
if (answer === null || answer.scope === undefined) {
152+
chain.answers.set(parent, answer);
153+
continue;
154+
}
155+
// The scope is the `where` of a read of `parent`: judged as one, and
156+
// the leaves it carries are composed before this parent's leaf is.
157+
const nested = collectComposableReadableIds(object, operation, answer.scope, 'where', `${site.at}.scope`);
158+
chain.answers.set(parent, nested.length === 0
159+
? answer
160+
: { scope: await this.composeReadableIdsChain(chain, answer.scope, nested, [...ancestors, parent]) });
161+
}
162+
// A parent with no answer (`null`, or never asked: not registered) is
163+
// `$in: []`.
164+
return composeReadableIdsLeaves(node, (leaf) => {
165+
const answer = chain.answers.get(leaf.object);
166+
return answer ? ReadableIds.compose(leaf, answer.scope) : [];
167+
});
168+
}
169+
170+
/** The body of `ObjectQL#findUnservableControlledByParentMasters`; the method's docblock there is its contract. */
171+
export function findUnservableControlledByParentMasters(
172+
this: Pick<ReadableIdsPermissionFormulaHost, '_registry' | 'uncomposableReadableIdsParent'>,
173+
): UnservableControlledByParentMaster[] {
174+
const found: UnservableControlledByParentMaster[] = [];
175+
for (const object of this._registry.getAllObjects() as Array<{ name?: string; sharingModel?: unknown; fields?: Record<string, unknown> }>) {
176+
if (object?.sharingModel !== 'controlled_by_parent' || typeof object.name !== 'string') continue;
177+
const masters = Object.entries(object.fields ?? {}).filter(([, f]) => isControlledByParentMasterField(f));
178+
if (masters.length !== 1) continue;
179+
const [field, def] = masters[0];
180+
const master = (def as { reference?: unknown }).reference;
181+
if (typeof master !== 'string' || !this._registry.getObject(master)) continue;
182+
const uncomposable = this.uncomposableReadableIdsParent(object.name, master);
183+
if (uncomposable) found.push({ detail: object.name, field, master, ...uncomposable });
184+
}
185+
return found;
186+
}
187+
188+
/** The body of `ObjectQL#readableIdsScopeOf`; the method's docblock there is its contract. */
189+
export async function readableIdsScopeOf(
190+
this: Pick<ReadableIdsPermissionFormulaHost, 'logger'>,
191+
object: string,
192+
operation: string,
193+
parent: string,
194+
svc: ReadableIdsSecurity,
195+
context: ExecutionContext | undefined,
196+
): Promise<{ scope: FilterCondition | undefined } | null> {
197+
const deny = (why: string, e?: unknown): null => {
198+
this.logger.warn(
199+
`[ObjectQL] ${operation}('${object}'): the ReadableIds leaf over '${parent}' is answered $in: [] ` +
200+
`(fail-closed) — ${why}`,
201+
e === undefined ? undefined : writeFailureLogMeta(e, { object, parent }),
202+
);
203+
return null;
204+
};
205+
let admitted: boolean;
206+
try {
207+
admitted = typeof svc.canReadObject === 'function'
208+
? (await svc.canReadObject(parent, context)) === true
209+
: (await svc.explain!({ object: parent, operation: 'read' }, context))?.allowed === true;
210+
} catch (e) {
211+
return deny('the object-level read admission threw', e);
212+
}
213+
if (!admitted) return null;
214+
let scope: unknown;
215+
try {
216+
scope = await svc.getReadFilter(parent, context);
217+
} catch (e) {
218+
return deny('getReadFilter() threw', e);
219+
}
220+
if (scope === undefined) return { scope: undefined };
221+
if (scope === RLS_DENY_FILTER) return null;
222+
if (scope === null || typeof scope !== 'object' || Array.isArray(scope)) {
223+
return deny(`getReadFilter() answered ${scope === null ? 'null' : Array.isArray(scope) ? 'an array' : typeof scope}, not a filter`);
224+
}
225+
return { scope: markFilterSubtreeProvenance(scope as FilterCondition, 'policy') };
226+
}
227+
228+
/** The body of `ObjectQL#resolveOptionPermissions`; the method's docblock there is its contract. */
229+
export async function resolveOptionPermissions(
230+
schema: unknown,
231+
payloads: ReadonlyArray<Record<string, unknown> | null | undefined>,
232+
resolution: PermissionResolution | undefined,
233+
): Promise<(payload: Record<string, unknown> | null | undefined) => EvalPermissions | undefined> {
234+
const none = (): undefined => undefined;
235+
// [#20082] The write's ONE resolution ({@link permissionResolution}) —
236+
// `undefined` on exactly the two conditions this method used to test
237+
// itself: no resolver registered, or no acting user.
238+
if (!resolution) return none;
239+
const fields = (schema as { fields?: Parameters<typeof optionVisibilityReadsPermissions>[0] } | null | undefined)?.fields;
240+
const needs = (payload: Record<string, unknown> | null | undefined): boolean =>
241+
optionVisibilityReadsPermissions(fields, payload);
242+
if (!payloads.some(needs)) return none;
243+
let permissions: EvalPermissions;
244+
try {
245+
permissions = await resolution.get();
246+
} catch (err) {
247+
return (payload) => {
248+
if (needs(payload)) throw err;
249+
return undefined;
250+
};
251+
}
252+
return (payload) => (needs(payload) ? permissions : undefined);
253+
}
254+
255+
/** The body of `ObjectQL#permissionResolution`; the method's docblock there is its contract. */
256+
export function permissionResolution(
257+
this: Pick<ReadableIdsPermissionFormulaHost, '_effectiveObjectPermissionsResolver' | 'buildEvalUser'>,
258+
context: ExecutionContext | undefined,
259+
): PermissionResolution | undefined {
260+
const resolver = this._effectiveObjectPermissionsResolver;
261+
if (!resolver || !this.buildEvalUser(context)) return undefined;
262+
let pending: Promise<EvalPermissions> | undefined;
263+
return {
264+
// `Promise.resolve().then(...)` so a resolver that throws SYNCHRONOUSLY is
265+
// memoised as a rejection too, rather than escaping `get()` un-memoised
266+
// and being asked again by the next consumer.
267+
get: () => (pending ??= Promise.resolve()
268+
.then(() => resolver(context))
269+
.then((answer) => toEvalPermissions(answer))),
270+
};
271+
}
272+
273+
/** The body of `ObjectQL#resolveFormulaPermissions`; the method's docblock there is its contract. */
274+
export async function resolveFormulaPermissions(
275+
this: Pick<ReadableIdsPermissionFormulaHost, 'logger'>,
276+
object: string,
277+
plan: readonly FormulaPlanEntry[],
278+
records: readonly unknown[],
279+
context: ExecutionContext | undefined,
280+
resolution: PermissionResolution | undefined,
281+
): Promise<EvalPermissions | undefined> {
282+
if (!context?.userId) return undefined;
283+
if (!records.some((r) => r != null && typeof r === 'object')) return undefined;
284+
const fields = plan.filter((entry) => readsPermissionPredicate(entry.expression)).map((entry) => entry.name);
285+
if (fields.length === 0) return undefined;
286+
if (!resolution) {
287+
this.logger.warn(
288+
`formula field(s) ${fields.join(', ')} on '${object}' call current_user.can(), and no `
289+
+ 'effective-permission source is registered, so they read null: compose a security '
290+
+ 'service that registers one (registerEffectiveObjectPermissionsResolver) for can() to '
291+
+ 'be answered',
292+
{ object, fields, reason: 'no-permission-source' },
293+
);
294+
return undefined;
295+
}
296+
try {
297+
return await resolution.get();
298+
} catch (error) {
299+
this.logger.warn(
300+
`formula field(s) ${fields.join(', ')} on '${object}' call current_user.can(), and the `
301+
+ 'effective-permission resolution failed, so they read null on this read (failed closed: '
302+
+ 'neither a grant nor a denial was assumed)',
303+
{ object, fields, reason: 'permission-resolution-failed', error },
304+
);
305+
return undefined;
306+
}
307+
}
308+
309+
/** The body of `ObjectQL#reportFormulaFault`; the method's docblock there is its contract. */
310+
export function reportFormulaFault(
311+
this: Pick<ReadableIdsPermissionFormulaHost, 'formulaFaultReported' | 'logger'>,
312+
object: string,
313+
field: string,
314+
error: EvalError,
315+
): void {
316+
const key = JSON.stringify([object, field]);
317+
if (this.formulaFaultReported.has(key)) return;
318+
this.formulaFaultReported.add(key);
319+
// The evaluator's message carries the source and a caret on the lines after
320+
// its first; the first line goes in the sentence, the whole of it in the meta.
321+
const headline = error.message.split('\n', 1)[0];
322+
this.logger.warn(
323+
`formula field '${field}' on '${object}' could not be evaluated, so it reads null wherever this `
324+
+ `fault holds (${error.kind}: ${headline}). If the expression itself is at fault — an unknown `
325+
+ 'function, a missing field — `os validate`, or a re-save of the object, refuses it with a located '
326+
+ 'message; if it faults on some records\' values, guard the operands it reads. Reported once per '
327+
+ 'object and field per engine instance.',
328+
{ object, field, kind: error.kind, error: error.message },
329+
);
330+
}
331+
332+
/** The body of `ObjectQL#resolveDefaultPermissions`; the method's docblock there is its contract. */
333+
export async function resolveDefaultPermissions(
334+
this: Pick<ReadableIdsPermissionFormulaHost, 'defaultFieldEntries'>,
335+
object: string,
336+
rows: ReadonlyArray<unknown>,
337+
context: ExecutionContext | undefined,
338+
resolution: PermissionResolution | undefined,
339+
onlyFields?: readonly string[],
340+
): Promise<(row: unknown) => EvalPermissions | undefined> {
341+
const none = (): undefined => undefined;
342+
if (!resolution || !context?.userId) return none;
343+
const gated = (this.defaultFieldEntries(object) ?? [])
344+
.filter((f) => !onlyFields || onlyFields.includes(f.name))
345+
.filter((f) => isExpressionDefault(f.defaultValue) && readsPermissionPredicate(f.defaultValue))
346+
.map((f) => f.name);
347+
if (gated.length === 0) return none;
348+
const needs = (row: unknown): boolean =>
349+
row != null && typeof row === 'object'
350+
&& gated.some((name) => (row as Record<string, unknown>)[name] == null);
351+
if (!rows.some(needs)) return none;
352+
let permissions: EvalPermissions;
353+
try {
354+
permissions = await resolution.get();
355+
} catch (err) {
356+
return (row) => {
357+
if (needs(row)) throw err;
358+
return undefined;
359+
};
360+
}
361+
return (row) => (needs(row) ? permissions : undefined);
362+
}

0 commit comments

Comments
 (0)