Skip to content

Commit bdfad1f

Browse files
refactor(objectql): move the write-path reference check and the caller's master-detail header read out of engine.ts into engine-referential-integrity.ts (#25522 cut 10) (#26308)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XN7mA8KDABioDDHv3PkYJVj)_ Part of #25522 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160 Cut 10 of the `packages/objectql/src/engine.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6423785652`. Two bodies of the "Referential integrity and master-detail resolution" section move unedited into a new module, `packages/objectql/src/engine-referential-integrity.ts`: `assertReferencesResolve` (the write path's #4441 reference check) and `masterDetailHeadersCallerReads` (#22519, which master-detail headers the caller's read door returns). The module helper `isEmptyReferenceValue`, which only the first calls, moves with them. No behaviour changes. #25522 stays open for the later cuts. Branch `claude/project-thread-oed4lq` (the card thread's harness-pinned branch), cut from `origin/main` at `366e4aa69`, which is cut 9's merge (#26263). Nothing was edited before that merge landed. ## Why this area, and why not Autonumber Measured on `366e4aa69` with cut 9's script plus two screens it lacked. `engine.ts` is 23,040 lines, 7,384 of them from the last 30 days (`git blame -s --since=2026-09-26T19:30Z`). `scripts/pm/git-history.mjs count`: 128 commits in 30 days, 34 in 7. `class ObjectQL` holds 318 members, 255 methods and accessors. | range | section | first … last member | lines | 30-day lines | methods | inferred public | U | private statics | |---|---|---|---|---|---|---|---|---| | 4764-4989 | Instance state and constructor | `txStore` … `constructor` | 226 | 50 | 0 (0 public) | - | 0 | - | | 4990-5012 | Status and registry accessor | `getStatus` … `registry` | 23 | 0 | 2 (2 public) | getStatus | 0 | - | | 5013-5356 | Hook and function registry | `registerHook` … `triggerHooks` | 344 | 6 | 16 (16 public) | registerHook, triggerHooks | 1 | - | | 5357-5712 | Hook dispatch internals | `hasHooksFor` … `assertBulkPerRowHookBudget` | 356 | 61 | 7 (0 public) | - | 0 | - | | 5713-5873 | Action registry and activation | `registerAction` … `setActionActive` | 161 | 19 | 10 (10 public) | - | 1 | - | | 5874-6155 | Registered resolvers and write-gate probes | `_heldFileResolver` … `registerInsertRecomputeListener` | 282 | 116 | 6 (6 public) | - | 0 | - | | 6156-6909 | Readable-ids security and permission/formula resolution | `readableIdsSecurityReported` … `resolveDefaultPermissions` | 754 | 546 | 15 (2 public) | - | 0 | - | | 6910-7016 | Authz invalidation and middleware registration | `writeEpoch` … `hasObjectMiddleware` | 107 | 18 | 4 (4 public) | - | 0 | - | | 7017-7194 | Execution plumbing (middleware, session, provenance, users) | `executeWithMiddleware` … `buildUser` | 178 | 45 | 7 (0 public) | - | 0 | - | | 7195-7525 | Driver options and engine posture | `buildDriverOptions` … `resolveEnginePosture` | 331 | 209 | 6 (0 public) | - | 0 | - | | 7526-7735 | Organization probe and system-insert organization | `probeInstallOrganizations` … `transactionCoversDriverFor` | 210 | 45 | 4 (0 public) | - | 0 | - | | 7736-8101 | Field defaults | `resolveNowDefault` … `defaultFieldEntries` | 366 | 4 | 6 (0 public) | - | 0 | - | | 8102-8714 | Autonumber | `applyAutonumbers` … `seedAutonumber` | 613 | 80 | 6 (0 public) | - | 1 | - | | 8715-9379 | Package, app, plugin and driver registration | `registrationConversionWarned` … `registerDriver` | 665 | 170 | 6 (3 public) | registerApp, registerDriver | 0 | - | | 9380-9588 | Datasource registry | `supplyFileColumnsMovedResolver` … `listUnavailableDatasources` | 209 | 1 | 8 (7 public) | - | 3 | - | | 9589-9740 | Write guard and realtime publish | `assertWriteAllowed` … `publishBulkDataEvent` | 152 | 18 | 4 (1 public) | - | 0 | - | | 9741-9839 | Validation-message i18n | `setI18nService` … `objectDisplayLabel` | 99 | 14 | 4 (1 public) | - | 0 | - | | 9840-10932 | Referential integrity and master-detail resolution | `assertReferencesResolve` … `masterDeclaredFields` | 1093 | 429 | 12 (0 public) | - | 0 | - | | 10933-11048 | Dangling-reference and cascade inspection | `inspectDanglingReferences` … `inspectCascadeDelete` | 116 | 48 | 2 (2 public) | - | 0 | - | | 11049-11143 | Crypto and tenancy providers | `setCryptoProvider` … `setTenancyPostureProvider` | 95 | 20 | 4 (4 public) | - | 1 | - | | 11144-11563 | Secret and internal-field handling | `encryptSecretFields` … `resolveInternalField` | 420 | 61 | 9 (3 public) | - | 0 | - | | 11564-11632 | Schema lookup and filter judging | `getSchema` … `judgeFilter` | 69 | 48 | 3 (2 public) | - | 0 | - | | 11633-11898 | Datasource routing | `LIFECYCLE_DATASOURCE` … `setDatasourceMapping` | 266 | 20 | 8 (3 public) | setDatasourceMapping | 0 | - | | 11899-12066 | Lifecycle init and health | `init` … `resolvePrimaryDatasource` | 168 | 16 | 3 (3 public) | init | 0 | - | | 12067-12140 | Media and value-shape presence | `objectHasMediaField` … `valueShapeStrictFor` | 74 | 13 | 4 (0 public) | - | 0 | mediaFieldPresence, coveredValueFieldPresence | | 12141-12823 | Migration flags and attestations | `isFileReferencesMigrationVerified` … `wasDatastoreCreatedFromEmpty` | 683 | 81 | 17 (8 public) | - | 0 | - | | 12824-12846 | destroy | `destroy` … `destroy` | 23 | 0 | 1 (1 public) | destroy | 0 | - | | 12847-12865 | 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 | | 12866-13316 | Roll-up summaries | `autonumberCounters` … `recomputeSummaries` | 451 | 0 | 7 (1 public) | - | 0 | - | | 13317-13570 | Related-record expansion | `expandRelatedRecords` … `expandRelatedRecords` | 254 | 10 | 1 (0 public) | - | 0 | - | | 13571-14014 | File-reference hydration | `resolveFileReferences` … `readParentDerivedFiles` | 444 | 310 | 5 (0 public) | - | 0 | - | | 14015-14493 | Query lowering | `resolveWhereTokens` … `requireFindOnePredicate` | 479 | 272 | 9 (0 public) | - | 0 | - | | 14494-15050 | find / findOne | `find` … `staticReadonlyCreateStrip` | 557 | 142 | 4 (2 public) | - | 0 | - | | 15051-15708 | validate | `validate` … `validate` | 658 | 447 | 1 (1 public) | - | 0 | - | | 15709-17055 | insert / insertMany | `readUpdatePriorRow` … `insertMany` | 1347 | 439 | 3 (2 public) | - | 1 | - | | 17056-18989 | update | `update` … `update` | 1934 | 275 | 1 (1 public) | - | 0 | - | | 18990-20530 | Delete cascade and reference walking | `planCascadeAtomicity` … `organizationOwnedRowCounts` | 1541 | 733 | 22 (1 public) | - | 0 | storedReferenceIncludes, remainderAfterMemberRemoval, referenceCheckContext, outlivesOrganization, keepsOrganizationReference, organizationCountContext | | 20531-21100 | delete | `delete` … `delete` | 570 | 47 | 1 (1 public) | - | 0 | - | | 21101-21720 | count / aggregate | `count` … `aggregate` | 620 | 236 | 3 (2 public) | - | 0 | - | | 21721-22029 | execute and transactions | `execute` … `enforceTransactionOrigin` | 309 | 74 | 8 (2 public) | - | 0 | - | | 22030-22322 | Compatibility API | `registerObject` … `create` | 293 | 52 | 16 (16 public) | - | 0 | - | The full per-method screen and both price lists are in the project file `pm-orders/server/25522-cut10-seam-table.md`. The stay rules are cut 9's (inferred-return public, a U site, a private-static read, an `objectql/src/engine.ts#NAME` pointer under `packages/spec/**` or `docs/adr/**`, an engine self-call, a static), plus two new ones: - **Erasure.** A body holding a `query-options/no-any-erasure` site stays. `scripts/query-options-erasure-baseline.json` is shrink-only in its key set ("adding a file here is not a sanctioned move"), so a new module cannot carry one. Linting `engine.ts` under an ungrandfathered file name finds the baseline's 8 sites, in `seedAutonumber`, `referenceExists`, `resolveMasterDetailParent`, `resolveMasterDetailParents`, `inspectDanglingReferences`, `find`, `findOne` and `probeReferencingRows`. - **Module-helper closure.** A body calling a non-exported top-level helper of `engine.ts` moves only when every user of that helper (and of that helper's own helpers) moves too. Otherwise the module would value-import `engine.ts`, a cycle at run time. | area | paying bodies under both screens | 30-day lines in them | net | verdict | |---|---|---|---|---| | **Referential integrity** (this PR) | `assertReferencesResolve` 79, `masterDetailHeadersCallerReads` 23, helper `isEmptyReferenceValue` | **29 of 102** body lines | **-98 measured** | the coldest area that pays | | Autonumber (cut 9's proposal, about -75) | `isIssuedAutonumberCollision` 5, `warnAutonumberReadersUnserved` 13 | 15 of 18 | about -5 | does not pay. `seedAutonumber` holds an erasure site; `adoptExplicitAutonumber` shares `readStoredAutonumberCounter` with it, and spec prose (`packages/spec/src/data/autonumber-format.ts`) names that helper in `engine.ts`, which this claim may not edit | | File-reference hydration | 5 bodies, 288 lines, helpers local | 176 of 288 | about -243 | pays most, but 61% of its lines are from the last 30 days | | Driver options and engine posture | 6 bodies with `accessibleOrganizationsOf` | about 84 of 146 | about -120 | hot | | Migration flags and attestations | 8 bodies | 50 of 154 | about -91 | the section writes fields, so its host needs setters (a seat ruling first) | | Readable-ids, Package registration, count, Delete cascade, Query lowering and the rest | - | - | -87 to 0 | hot, or blocked by a shared helper, or pays little | In Referential integrity the rest stays: `referenceExists`, `resolveMasterDetailParent` and `resolveMasterDetailParents` read the private static `referenceCheckContext` and hold erasure sites; `settleMasterDetailLanding` calls `staticReadonlyStripTakes`, whose `staticReadonlyStoredView` `update` also uses; `resolvePreviewParents` (`masterIdOf`, `servedAsStored`) and `resolvePredicateRelated` / `resolveOneHopRelated` (`undeclaredRelatedFields`) share helpers with bodies that stay; `masterDetailHeaderExists`, `materializeParentHeader` and `masterDeclaredFields` are no longer than their stubs would be. ## The shape - The class keeps both methods with name, modifiers (`private async`), signature, docblock and position. Each body is now one host literal of exactly the `ReferentialIntegrityHost` members it reads, plus a `.call`, marked `[#25522 cut 10]`, as cut 8's stubs are: `assertReferencesResolve` gets `masterDetailHeaderExists` and `referenceExists` as late-bound closures; `masterDetailHeadersCallerReads` gets `find` as a closure and `logger` as a getter. - **One named host.** `ReferentialIntegrityHost` types each member as the class member (`ObjectQL['find']` 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.** `isEmptyReferenceValue` moves with its docblock byte for byte (508 bytes, 12 lines) and is not exported; its only user was `assertReferencesResolve`. One copy, no re-derivation. - **Imports.** The module imports `referenceTargetOf` and `EngineQueryOptions` (`@objectstack/spec/data`), `ExecutionContext` (type), and `ValidationError` and `buildFieldError` (`./validation/record-validator.js`), the bindings `engine.ts` used. `engine.ts` drops `buildFieldError` from its import (its last reader moved; `tsc` reported TS6133) and gains the new module import with a three-line comment. - The module header names what moved, what stays and why. It cites no ADR. ## Net lines (the seat's rule) `engine.ts` goes from 23,040 to 22,942 lines, **net -98** (18 added, 116 removed). The module is 200 lines. With the two re-keyed census rows and the one re-keyed readers row, the diff is 340 changed lines over 4 files (+221 / -119). ## The published `.d.ts` does not change (ruling `6348891160`) Base: `@objectstack/objectql` built with `engine.ts` restored from `366e4aa69` (blob `c9773e3a` checked) and the new module removed; restored with `git checkout HEAD --`, blob hashes compared, `git diff HEAD` empty. Head: built at `b1f3252c8`. `masterDetailHeadersCallerReads(master` occurs 2 times in the base `dist/index.mjs` and 3 times in the head's, so each leg built the source it names. All six declaration files are **byte-identical**, and equal to the hashes cuts 7 to 9 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:** dropping `private` from the `masterDetailHeadersCallerReads` stub (anchor landed once, 0 private hits after) and rebuilding changed all six hashes and the chunk name (`util-Cz_Kcdws.d.ts` `e762bbf9…`, `index.d.ts` `5f1e19b4…`), and the chunk now prints `masterDetailHeadersCallerReads(master: string, ids: ReadonlyArray of unknown, context: ExecutionContext | undefined)`. Restored with `git checkout HEAD --` (blob hash compared, `git diff HEAD` empty, the private spelling back to 1 hit), rebuilt, and all six files compared equal to the head build again. ## Proof of move-only A TypeScript-AST leaf-token compare (`pm-orders/server/evidence/25522/astcmp.mjs`, unchanged from cut 9) of base `engine.ts` (`366e4aa69`) against the head module: - Both bodies are **SAME** in parameters, return type, type parameters, body tokens and `async`, and every body line equals the base line less two leading spaces, comments included: `assertReferencesResolve` 285 tokens / 79 lines, `masterDetailHeadersCallerReads` 239 / 23. - `isEmptyReferenceValue`: the base text from its docblock to its closing brace is a substring of the module, and `engine.ts` no longer declares it. - Class: 318 members at both ends, in the same order. 316 are byte-identical, and the 2 stubs keep docblock, modifiers and signature byte-identical up to the body. - Top level: 229 of 233 base statements are byte-identical in head. The other four: the class; the `record-validator.js` import, less `buildFieldError`; `isEmptyReferenceValue`, moved; and `masterIdOf`, whose own text and docblock are identical (`getText` equal) but whose leading trivia now starts with the `implements IObjectQLEngine` comment that used to sit above `isEmptyReferenceValue`'s docblock. That comment describes the class, so it stays in `engine.ts` unedited. Head adds the new module import with its comment. ## Readers of the moved lines - **The system-context census page (migrated in this PR).** Both bodies read `isSystem`, so rows 23 (`assertReferencesResolve`) and 29c (`masterDetailHeadersCallerReads`) of `content/docs/permissions/system-context.mdx` now anchor `packages/objectql/src/engine-referential-integrity.ts#NAME`; their wording is unchanged. `check-system-context-census` at base: 153 read sites across 81 files, 145 of 145 symbols cited, OK; at head: 153 across 82 files, 145 of 145, OK. **Control:** with the base page over this head the gate exits 1 with exactly three findings: `file-without-a-row` for the new module and `anchor-is-not-a-read-site` for each old anchor. Restored from `HEAD`, blob hash compared, green again. - **The federated injected-column readers table (migrated in this PR).** `federated-injected-column-readers.test.ts` keys rows by `FILE#FUNCTION :: SEAM`, and `assertReferencesResolve` calls `referenceTargetOf`, so its row is re-keyed `engine-referential-integrity.ts#assertReferencesResolve :: referenceTargetOf()`; disposition and reason unchanged. Head: 5 of 5 pass. **Control:** the base file over this head fails 1 of 5 and names the new key; restored, blob hash compared. - **ADR anchors.** No `scripts/adr-anchors/` entry describes a moved body (the engine entry's one paragraph is the transaction seam). The moved bodies cite no decision letter, and the docblocks that cite ADR-0131 D1 and ADR-0055 stay on the methods. `check-adr-anchors` at base `366e4aa69` (clean detached worktree): 90 anchored files, **1,880** decision-letter citations, 59,261 resolving citations; at head: 90, **1,880**, 59,261. - No `objectql/src/engine.ts#NAME` pointer under `packages/spec/**` or `docs/adr/**` names the two or the helper. `docs/qa/platform-checklist/areas/records-forms.json` cites `packages/objectql/src/engine.ts#assertReferencesResolve` twice as evidence; the method is still declared there with the docblock the citation quotes, and `check-platform-checklist --changed-from origin/main` judges 13 anchors in the change set, 0 lost. Prose that names the methods (`service-messaging` and `service-settings` `actor-reference.ts`, `plugin-security` `position-catalog-refusal.ts`, test comments) still resolves, because both methods stay on the class. - No gate baseline keys a moved body (`durability-read-invention.baseline.json` keys only `referenceExists`, which stays; `query-options-erasure-baseline.json` keeps `engine.ts` at 8, since `masterDetailHeadersCallerReads` spells `as EngineQueryOptions`). ## Acceptance notes - Serial check, re-read before `pr_create`: no open PR touches `packages/objectql/src/engine.ts`, `system-context.mdx` or `scripts/adr-anchors/`. #26138 edits `validation/rule-validator.test.ts` only. `origin/main` moved 10 commits after the base, none on `packages/objectql/**`, `content/docs/permissions/**`, `scripts/adr-anchors/**` or the readers test, so the branch was not merged again. - **Cut 9's proposal does not pay.** Autonumber, priced about -75 without the erasure and helper screens, pays about -5 under them (table above). The seam script now in the project files carries both screens. - Next cut from this screen: File-reference hydration (about -243, all five bodies and their three helpers local, 61% recent lines), or Driver options (about -120, `driverWriteTarget` moves with `engineLayer0Verdict` so `accessibleOrganizationsOf` stays module-local). Migration flags still needs a setter ruling. ## Verification At `b1f3252c8`: - `pnpm --filter @objectstack/objectql typecheck` (`tsc --noEmit` + `check:test-typecheck`): 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`: 472 files, 8,789 tests passed (VERDICT command-exit 0). - Dependants: both changed 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. - Gates: `dispatch-gates --commands` derived 90 families on the real diff. Every one was run, each exit code captured before any pipe, then reconciled with `--ran`: 90 accounted for, 88 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). Three first-run exit codes were prerequisites, re-run green after meeting them: `check-engine-split-ratio --days 90` (shallow clone; deepened to 2026-07-21, 97.6%), `check-plugin-teardown-shape --self-test` and `check:lean-entry-closure` (needed the objectql `dist/`). Named readings: `check-system-context-census` OK, `check:query-options-erasure`, `check:durability-log-level`, `check:org-identifier`, `check:logger-receiver-detach`, `check:doc-anchors`, `check-affected-docs` exit 0. - Hand gates outside the derivation, all exit 0 at `b1f3252c8`: `check-adr-anchors` at base and head (above), `check-adr-symbol-anchors`, `check-tenant-audit-census`, `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 two rows of the census table. --- _Generated by [Claude Code](https://claude.ai/code/session_01KiAsjPtReu1tmXvCEd8f7R)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 77dc706 commit bdfad1f

5 files changed

Lines changed: 229 additions & 120 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ that silently does not happen.
128128
| 21 | **`readonly` strip bypassed — INSERT** | objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-`readonly` strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. `isSystem` is the **only** exemption on this path: `preserveAudit` is deliberately not read on create, so a non-system historical import is still stripped. A second read in the same symbol gates the pass that withholds those caller values from `beforeInsert` before the strip, the same exemption, so a system caller's hook still sees what it sent | `packages/objectql/src/engine.ts#insert` |
129129
| 21b | **`readonly` strip bypassed — the write PREVIEW (`ObjectQL.validate`)** | objectql | Same as 19–21, in the preview: it runs the write's caller-write strips under the write's own gate, so a system preview reports no `readonly` drop and judges the value a system write would store. The `computed` strip of a `formula` value is NOT gated here or on either write verb — no driver has a column for it, so it runs for every caller | `packages/objectql/src/engine.ts#validate` |
130130
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing. Two exceptions, both judged in every context: a `formula` value, which the `computed` strip takes from every caller, and a value for a stored field that declares `expression` when it differs from the value the engine computes in its place (`stored_expression`), so strict still refuses both | `packages/objectql/src/engine.ts#insert`, `packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS` |
131-
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `packages/objectql/src/engine.ts#assertReferencesResolve` |
131+
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `packages/objectql/src/engine-referential-integrity.ts#assertReferencesResolve` |
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` |
@@ -140,7 +140,7 @@ that silently does not happen.
140140
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `packages/objectql/src/engine.ts#dependentCountIsDisclosable` |
141141
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `packages/objectql/src/engine.ts#recordReferenceCheckElevation` |
142142
| 29b | A traversing validation rule's related read skips the **caller's bounds** | objectql | Get: no row bound from the caller's own read on a related object no organization wall scopes, no read door on a header the rule reads through a `master_detail` field, and a related read even with no organization under a walled posture (a `tenantId` on the context still scopes it). Every caller that is not system is held to all three: a header its read door does not return is unavailable to the rule exactly as a header that exists nowhere is, whatever it holds, and a header it can read is judged on its stored values. A plain `lookup` to an object an organization wall scopes asks no read door, system caller or not | `packages/objectql/src/engine.ts#resolveOneHopRelated` |
143-
| 29c | A detail write's master-detail header read asks **no read door** | objectql | Get: the write binds the header a parent-scoped `requiredWhen` / `readonlyWhen` reads as `parent` from the header's stored row whoever can read it, as before. Every caller that is not system meets its own read door first: a header that read does not return answers as a header that exists nowhere — an unbound `parent`, and `reference_not_found` on the reference check (which a system caller's write skips altogether) | `packages/objectql/src/engine.ts#masterDetailHeadersCallerReads` |
143+
| 29c | A detail write's master-detail header read asks **no read door** | objectql | Get: the write binds the header a parent-scoped `requiredWhen` / `readonlyWhen` reads as `parent` from the header's stored row whoever can read it, as before. Every caller that is not system meets its own read door first: a header that read does not return answers as a header that exists nowhere — an unbound `parent`, and `reference_not_found` on the reference check (which a system caller's write skips altogether) | `packages/objectql/src/engine-referential-integrity.ts#masterDetailHeadersCallerReads` |
144144
| 30 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts#registerAuthzMiddleware` |
145145
| 30b | **The import doors admit a control opt-out** — `runAutomations: false` and `treatAsHistorical: true` on `POST /data/:object/import` and `/import/jobs` | rest | Get: an import whose rows are written with `skipAutomations` (no metadata-bound hook, no record-change trigger: an app's approvals and record freezes among them) or with `skipStateMachine` and `preserveAudit`. Every other caller, every signed-in user and administrator included, is refused `403 PERMISSION_DENIED` before the body is parsed, so nothing is written and no job is created. Inbound HTTP never carries the flag, so this is an in-process caller's door. Note: the flag still suppresses none of those controls by itself (see below); it only admits the opt-outs that do | `packages/rest/src/import-prepare.ts#importControlOptOutRefusal` |
146146
| 30c | **The import undo keeps the control skips** — `POST /data/import/jobs/:jobId/undo` | rest | Get: an undo whose deletes and restores are written with `skipAutomations` and `skipStateMachine`, so no metadata-bound hook, record-change trigger or `state_machine` rule refuses a reversal. Every other caller, every signed-in user and administrator included, undoes under those controls: a reversal one of them refuses is reported per row in the undo answer's `failures` and kept in the job's undo log, and the job is stamped reverted only once every row is. A historical job's `preserveAudit` belongs to the job, not the caller, and rides every undo. Inbound HTTP never carries the flag, so this is an in-process caller's door | `packages/rest/src/data-action-routes.ts#importUndoWriteContext` |

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,6 @@ const CALLERS: Record<string, Classification> = {
138138
read: "the expansion sub-read (`ObjectQL.expandRelatedRecords`), for the data door's `$expand`",
139139
decidedIn: 'packages/metadata-protocol/src/protocol-query-validation.ts',
140140
also:
141-
'the write-path reference-existence probe (`referenceExists`: whether a stored id resolves, no field) and ' +
142141
'the validation-predicate reads (`resolvePredicateRelated`: a pass/fail verdict, the value never served). ' +
143142
"The engine's privileged callers (a flow's `config.expand`, a hook's read) never pass the door, by design.",
144143
},
@@ -210,6 +209,14 @@ const CALLERS: Record<string, Classification> = {
210209
kind: 'not-a-served-read',
211210
reason: "resolves a seed's references at install, under the system identity; serves nothing to a caller",
212211
},
212+
// [#25522 cut 10] The write-path reference check, moved out of engine.ts.
213+
'packages/objectql/src/engine-referential-integrity.ts': {
214+
kind: 'not-a-served-read',
215+
reason:
216+
'the write-path reference-existence check (`assertReferencesResolve`): it asks whether a caller-supplied id ' +
217+
"resolves on the target (the engine's `referenceExists` probe) and answers `reference_not_found` or nothing, " +
218+
'never a field of the target',
219+
},
213220
'packages/objectql/src/integrity/dangling-reference-audit.ts': {
214221
kind: 'not-a-served-read',
215222
reason: 'an integrity audit: it probes whether a stored id resolves and reports ids, never a field of the target',

0 commit comments

Comments
 (0)