Skip to content

Commit eb96480

Browse files
fix(objectql)!: a non-system write may name, in a lookup or user field, only a record the caller can read (#31031)
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc Fixes #29029 Clause-②: yes (narrowing) — the write's reference check narrows: a non-system `lookup` or `user` value naming a record the caller cannot read is refused, and an update's forgiveness of a value the row already stores compares only stored values the caller may read. The business-unit read widens: a delegated admin reads the business units inside the admin scope it holds, and no other. Metadata-protocol: none Skills-docs: none — no file under `skills/**` teaches a reference to a record its writer cannot read, a delegate's business-unit read, or an update's carried-back reference (a grep of `skills/` for `reference_not_found`, `already stores` and `not judged again` finds only the permission-set catalog refusal, unchanged); `content/docs` states the rule on four pages (below). ## 维护者速读 - 改了什么:非系统写在 `lookup` / `user` 字段里只能引用调用方自己读得到的记录,读不到的一律答 `reference_not_found`(与 `master_detail` 同一条规则),且先于记录校验规则;委派管理员可读其 adminScope 覆盖的业务单元。本 PR 在 #30747 之上再加一条:更新时「行里已存的值不再判」这条宽免,只比对调用方读得到的已存值,调用方可改不可读的列不宽免任何值。 - 为什么改:裁决 6663849489(A 重申 + 委派读);宽免一条由安全席位要求与本规则同时落地。 - 风险与回滚:BREAKING 收窄。依赖「引用读不到的记录」的非系统写会被拒;可改不可读的引用列上,更新带回原值也按新值判。读得到该列的调用方照旧宽免,系统写(种子、安装、启动)不变。回滚即撤本 PR。 - 先看:`packages/objectql/src/engine-referential-integrity.ts` 的 `storedReferencesCallerReads` 与 `assertReferencesResolve` 末段;`packages/objectql/src/engine.ts`(三条写路径的顺序);`packages/plugins/plugin-security/src/delegated-admin-gate.ts` 的 `delegatedUnitReadScope`。 - 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run src/reference-forgiveness-readable-column.test.ts`,预期 26/26 绿。 - 需要你做的:无,席位落地。 > [!WARNING] > **BREAKING** — accept-set narrowing at the write's reference check (`@objectstack/objectql`), served by every write door. After the upgrade, a write that is not a system write is refused `400 VALIDATION_FAILED` with the field's localized `reference_not_found` when a `lookup` or `user` value it supplies names a record the caller cannot read; and an update's forgiveness of a value the row already stores compares only stored values the caller may read. **Supersedes #30747** (closed with a pointer to this PR). A continuation by the security seat (security#1, session_01L9qibvynRbCtMndW8EHNGc): #30747's head `0e54448624` on a new branch, `main` merged three times, and the forgiveness rule below added at the same time, as #30747 itself continued #30672. #30747 carries a contract review PASS at `0e54448624` and the maintainer's per-PR BREAKING word; the seat retires #30747 with a pointer here. ## What changes **The rule.** A write that is not a system write may name, in a `lookup`, `user` or `master_detail` field, only a record its caller's own read door returns. An unreadable reference target always answers `reference_not_found`, before the record's validation rules. A record the caller cannot read answers exactly as a record that exists nowhere, and so does one whose read by the caller fails. System writes (`isSystem`) stay exempt, keep the order they had and give the answer they gave; a flow run's elevated write keeps its existence check. A reference to an object the registry does not hold is not judged. `tree` keeps the existence check alone. A `lookup` or `user` value naming a user the caller cannot read answers `reference_not_found` and its ban is not asked; a user the caller can read is judged for its ban as before (#30205). **The forgiveness (new on this PR).** An update that carries back a `master_detail`, `lookup` or `user` value the row already stores is still not judged again (#29062), by id and in bulk ("every matched row stores it") alike, but the update's reference forgiveness compares only stored values the caller may read: the stored rows as the caller's own read door serves them, each column kept only where it is served exactly as stored. A column the caller may edit but not read (a field permission `readable: false, editable: true`) holds no stored value for it, so it forgives nothing: a value named there is judged like any other, and the answer is the same whatever the column holds. A caller who can read the column keeps the forgiveness. A system write compares the rows as stored, as before. A failed read of the stored row forgives nothing. **The delegate read.** An admin scope over a business-unit subtree (ADR-0090 D12) grants read on the business units inside that subtree, and on no unit outside it. It applies to a read of `sys_business_unit` that the caller's own permission sets do not grant, on the caller's own behalf, bounded to the union of the subtrees the caller's held scopes cover. A caller with a `sys_business_unit` grant reads as that grant says, as before. - `packages/objectql/src/engine-referential-integrity.ts` — `lookup` and `user` join `master_detail` on the caller's read door; `referencesCheckedBeforeRules`, the one predicate every write door and the validate preview read for the order; **new:** `storedReferencesCallerReads`, the stored rows the forgiveness compares against, read once per refusing write through the caller's read door and kept per column by `servedAsStored` (the comparison the `update`-mode validate preview already makes for its stored row). - `packages/objectql/src/engine.ts`, `engine-validate.ts` — insert, update by id, bulk update and the validate preview check references before the record's validation rules unless the write is a system write; the reference check's host gains `find`. - `packages/objectql/src/engine-references-and-master-detail.ts`, `engine-master-detail-related.ts` — the caller-readable reference helpers (`callerReadableReferenceExists`, and `callerReadableReferenceRow` for the user reference whose ban is also asked), and the #18682 docblock now states the rule. - `packages/plugins/plugin-security/src/delegated-admin-gate.ts`, `security-plugin-authz-middleware.ts` — `scopedBusinessUnitIds` and `delegatedUnitReadScope`; the middleware admits the read and narrows it to `id IN` the scoped units. - `packages/verify/src/parent-write-reach.ts` — the parent-write-reach verifier's personas read every record of each object a probed detail's required reference names outside its master chain, so the create probe names records its writer can read. - `docs/qa/platform-checklist/areas/records-forms.json` — `records-forms.predicate-relationship-traversal` takes revision 7, written to the rule. - `content/docs/api/error-catalog.mdx`, `content/docs/data-modeling/fields.mdx`, `content/docs/permissions/delegated-administration.mdx` — each states the rule where a sentence would otherwise have gone false; the first two now also say the forgiveness compares only stored values the caller may read. - `content/docs/permissions/system-context.mdx` — row 23 says where a system write's reference check runs and that a system write compares an update's stored rows as stored; its Symbol cell and the generated site register name `referencesCheckedBeforeRules` and `storedReferencesCallerReads`. - `.changeset/29029-lookup-user-caller-read.md` — `@objectstack/objectql`, `@objectstack/plugin-security` and `@objectstack/verify`, patch, BREAKING; amended with the forgiveness paragraph and the widened `Clause-②:` line. ## Tests - **New:** `packages/plugins/plugin-security/src/reference-forgiveness-readable-column.test.ts` (26) — the real engine and the real security plugin. For `lookup`, `user` and `master_detail`, by id and in bulk over two rows: in a column the caller may edit but not read, a value the rows store and one they do not answer byte-identically (status, code, field finding and sentence, with the sent id written out) and the rows are untouched; controls — the same grant edits another column of the same rows; a caller who can read the column has the stored value carried back land and a new unreadable value refused; a system write keeps its exemption. - **New block:** `packages/objectql/src/engine-unreadable-reference-target.test.ts` (now 113) — a top-level describe on the same rig: a middleware withholds the reference columns from the caller's reads of the row; `lookup` and `user`, by id and in bulk, the stored value answers as another unreadable value; a failed read of the stored row forgives nothing; controls — a column the caller reads keeps the forgiveness; a flow run's system write compares the rows as stored. - `packages/objectql/src/engine-unreadable-reference-target.test.ts` (#30747's 99): `lookup` and `user` × insert / batch insert / update by id / bulk update — an unreadable target is refused as one that exists nowhere; a refused read of the target object answers the same; a multi-valued field with one unreadable member; a system write; a stored value carried back; the read door before the elevated probe. On all four doors and for `lookup`, `user` and `master_detail`, an unreadable reference answers `reference_not_found` before the record's validation rules while a readable one reaches them; the validate preview agrees; the order controls for system and flow-run writes; a read of the target that fails is refused; a reference to an unregistered object is admitted. - `packages/plugins/plugin-security/src/delegated-admin-own-name-set.posture.test.ts` (33), three postures: a delegate reads exactly the units its scope covers (`find`, `findOne`, `count`); assigning a unit in scope is admitted; a unit outside the scope it cannot read is refused `reference_not_found`; controls — a writer with no scope, and an organization admin, unchanged. - `packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts`: the controls' callers read the related account; a new block drives the editor who cannot, and both doors answer the lookup's `reference_not_found`. - Forced edits, rule-preserving (from #30747): six plugin-security suites provision `sys_user` with a row per user they name (`__tests__/rig-users.testkit.ts`); `agent-ceiling-grant-refusal` provisions the three users its grants name; `predicate-related-read-tenant-scope` counts the rule's related reads alone; four plugin-audit suites leave the write's id-only reference-check read out of their read counters. - `packages/objectql/src/engine-banned-user-reference.test.ts` (11), `packages/verify/src/parent-write-reach.test.ts`, `packages/qa/dogfood/test/predicate-relationship-traversal.dogfood.test.ts`: as on #30747. <details><summary>Ablations of the forgiveness rule (this PR): through scripts/ablation-replace.mjs, the comparison handed the rows as stored instead of as the caller reads them; restored blob equal to HEAD, git diff HEAD empty</summary> The objectql pins import `./engine.js` from source and plugin-security's vitest aliases `@objectstack/objectql` to source, so the mutation is read from `src/`. Anchor `: await storedReferencesCallerReads.call(…)` x1 → x0, replacement `: storedAsStored;` x0 → x1, blob `6fc9b11c36b0` → `9bf670a3ec08`; restore blob `6fc9b11c36b0` == HEAD blob, `git diff HEAD` empty, both legs. 1. `reference-forgiveness-readable-column.test.ts`: 6 failed | 20 passed (26) — exactly the identical-answer legs, `lookup` / `user` / `master_detail` × by id / bulk; every control green, the readable-column forgiveness among them. 2. `engine-unreadable-reference-target.test.ts`: 6 failed | 107 passed (113) — the hidden-column legs (`lookup`, `user` × by id / bulk) and the failed-read legs (`lookup`, `user`); the readable-column forgiveness and the flow-run controls green. #30747's seven ablations stand as recorded there (on #30672 at `75a7c44d96`). </details> ## Deviations - Main merged three times on this branch (`06cccd32e1` → `1aeeafd9d3`, `af1d59b692`, `a11ef71e93`): no conflict in any. The third auto-merged `docs/qa/platform-checklist/areas/records-forms.json` (this PR's item revision 7 beside main's position-name renames in three other items, disjoint hunks). - `content/docs/permissions/system-context.mdx` gains `storedReferencesCallerReads` in row 23 because the system-context census counts the forgiveness's one `isSystem` read; the register is regenerated by `check-system-context-census.mjs --write`. - The rest as on #30747 (#30546 overlap and the two Test Core fixes on #30672). ## Acceptance notes - `packages/services/service-automation/src/runas-system-reference-check.integration.test.ts` still names `masterDetailHeaderExists` in a comment, outside this card's surface. - The delegate read covers a caller's own read; a read on another's behalf (`delegatorSets`) is not widened. Explain and `canReadObject` do not reflect the delegate read. - The forgiveness's read of the stored rows runs only once a reference has been refused, one `find` for the write's rows, under the caller's context. ## Verification Final head `2ae4d980c2`: #30747's head `0e54448624` with `origin/main` merged three times and the forgiveness rule added. The third merge (main `a11ef71e93`) was followed by `pnpm install --frozen-lockfile`, a whole-workspace build (`turbo run build`, 119 of 119 tasks) and the suites below; the last commit after it (`2ae4d980c2`) edits one verify test file, and the verify suite, the typecheck, the dogfood battery and the full gate set ran on it. Delta against `main`: 40 files, +1739 / -248 (1987 changed lines; #30747 was 1563). <details><summary>Suites and typecheck</summary> - `@objectstack/objectql` (at `dc1dbb4db7`, same objectql source as the head): Test Files 560 passed (560), Tests 11314 passed (11314). - `@objectstack/plugin-security` (at `dc1dbb4db7`, same plugin-security source as the head): Test Files 247 passed (247), Tests 5731 passed, 45 skipped (5776). - `@objectstack/verify` at `2ae4d980c2`: Test Files 48 passed (48), Tests 408 passed (408). - Typecheck at `2ae4d980c2`, under the verify lock: `turbo run typecheck` over the four packages the diff touches (objectql, plugin-security, verify, plugin-audit) and their 37 direct consumers with a typecheck script (downstream: every workspace package declaring one of the four as a dependency), 112 of 112 tasks successful. The forgiveness adds nothing to any emitted declaration: neither `storedReferencesCallerReads` nor the reference-check host type appears in `packages/objectql/dist/*.d.ts`. </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` at `2ae4d980c2` (40 changed paths): 98 families, each run with stdin from `/dev/null` and its exit code captured before any pipe; 97 exited 0 on the pass, and `pnpm check:type-check-debt` exited 3 there only because its closure build queued behind this branch's own locked dogfood run; re-run alone under the lock, exit 0. `--ran` with every exit code recorded: 98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero). Two families were red on an earlier head and are fixed on this one: `check:system-context-census` (the forgiveness's one system-write read needed an owning row: row 23's Symbol cell, register regenerated) and `check:test-source-alias` (the third merge brought in its source-reach half, which judges the changed `packages/verify/src/parent-write-reach.test.ts`: the two lazy service loads are now paid at module top, as it prescribes). `node scripts/pm/check-governed-merges.mjs --branch origin/claude/sec-hardening-r12`: 0 of 40 paths governed; 1987 changed lines, under 3000. </details> Full dogfood battery on `2ae4d980c2` under the verify lock: `VERDICT command-exit 0`, Test Files 428 passed, 3 skipped (431), Tests 3560 passed, 41 skipped (3601). `main` moved 23 commits while these ran; a local merge-tree is clean, `servedAsStored` is unchanged there and none of them touches `engine-referential-integrity.ts`. The seat's owed merge of `main` before queueing covers it. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e396095 commit eb96480

40 files changed

Lines changed: 1739 additions & 248 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/plugin-security': patch
4+
'@objectstack/verify': patch
5+
---
6+
7+
fix(objectql)!: a non-system write may name, in a `lookup` or `user` field, only a record the caller can read (#29029)
8+
9+
Clause-②: yes (narrowing) — the write's reference check narrows: a non-system `lookup` or `user` value naming a record the caller cannot read is refused, and an update's forgiveness of a value the row already stores compares only stored values the caller may read. The business-unit read widens: a delegated admin reads the business units inside the admin scope it holds, and no other.
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes. The refusal is the engine's own localized reference_not_found, so there is nothing for a migration to rewrite and no tombstone. -->
13+
14+
**BREAKING** accept-set narrowing at the write's reference check (`@objectstack/objectql`), served by every write door.
15+
16+
After the upgrade, an insert or update that is not a system write is refused with `400 VALIDATION_FAILED` and the field's localized `reference_not_found` when a `lookup` or `user` value it supplies names a record the caller cannot read, before the record's validation rules run.
17+
18+
An update that carries back a `lookup`, `master_detail` or `user` value the row already stores is still not judged again, but that comparison reads only stored values the caller may read, by id and in bulk alike. In a column the caller may edit but not read (a field permission `{ readable: false, editable: true }`), every value is judged as new: one naming a record the caller cannot read is refused with `reference_not_found` even when the row already stores it. A caller who can read the column keeps the forgiveness, and a system write is unchanged.
19+
20+
A delegated admin whose admin scope covers a business-unit subtree can read the business units in that subtree, and no business unit outside it (`@objectstack/plugin-security`).
21+
22+
The parent-write-reach verifier's personas read every record of each object a probed detail's required reference names outside its master chain, so the create probe names records its writer reads (`@objectstack/verify`).

‎content/docs/api/error-catalog.mdx‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -316,15 +316,21 @@ object is rejected with `400 VALIDATION_FAILED`, and the specifics ride in
316316
}
317317
```
318318

319-
The check covers create, update and bulk update. Four cases are deliberately
320-
*not* rejections: an empty value (`null` / `""` / `[]`) means "no link", a
321-
`isSystem` write is exempt (seed replay and package install legitimately write
322-
in an order that only resolves once the batch completes), a target that
323-
cannot be checked at all — an unregistered object, an unreachable datasource —
324-
fails **open** rather than inventing a rejection, and an update that carries back
319+
The check covers create, update and bulk update. A write that is not a system
320+
write may name, in a `lookup`, `master_detail` or `user` field, only a record
321+
the caller can read: a record the caller cannot read is answered exactly as one
322+
that exists nowhere, before the record's validation rules run, and so is one
323+
whose read by the caller fails. Four cases are deliberately *not* rejections: an
324+
empty value (`null` / `""` / `[]`) means "no link", a `isSystem` write is exempt
325+
(seed replay and package install legitimately write in an order that only
326+
resolves once the batch completes), a target that cannot be checked at all — an
327+
unregistered object, or an unreachable datasource on a check that does not read
328+
as the caller — fails **open** rather than inventing a rejection, and an update that carries back
325329
a value (or a member of a multi-valued field) the row already stores is not
326330
judged again, because the update does not add it; on a bulk update that holds
327-
only when every matched row already stores it.
331+
only when every matched row already stores it. That comparison reads only stored
332+
values the caller may read: a column the caller may edit but not read holds no
333+
stored value for it, so a value written there is judged like any other.
328334
</Callout>
329335

330336
### `DUPLICATE_VALUE`

‎content/docs/data-modeling/fields.mdx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,10 +283,15 @@ The key's full contract is in the [Field reference](/docs/references/data/field)
283283
`reference` is **enforced on write**. A create or update that sets a
284284
`lookup` / `master_detail` to an id with no matching row in the target object is
285285
rejected with `400 VALIDATION_FAILED` and a `fields[]` entry whose `code` is
286-
`reference_not_found` (see the [error catalog](/docs/api/error-catalog)). The
286+
`reference_not_found` (see the [error catalog](/docs/api/error-catalog)). A
287+
write that is not a system write may name, in a `lookup`, `master_detail` or
288+
`user` field, only a record its caller can read: one the caller cannot read is
289+
rejected the same way, before the record's validation rules run. The
287290
same check runs on bulk updates. An update judges only the references it adds: a
288291
value the row already stores, carried back unchanged, is not judged again, so a
289292
deleted user's id left in a `user` field does not block later edits of the record.
293+
That comparison reads only stored values the caller may read: in a column the
294+
caller may edit but not read, every value is judged as new.
290295
291296
Clearing a relationship is not a dangling reference: `null`, `""` and `[]` all mean
292297
"no link". Which of them `deleteBehavior: 'set_null'` writes when the referenced

‎content/docs/permissions/delegated-administration.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ delegate holds. In the permission set editor (Studio's **All metadata** area), t
5656
block lists the five, marks which ones the set already grants, and adds the
5757
missing ones to the set in one click.
5858

59+
The scope itself grants read on the business units inside its subtree, and on
60+
no unit outside it, so a delegate can pick the units it administers without a
61+
`sys_business_unit` grant. A `sys_business_unit` read grant the delegate holds
62+
reads as that grant says, as before.
63+
5964
## What the runtime gate enforces
6065

6166
Writes to the governed tables (`sys_user_position`, `sys_user_permission_set`,

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ bugs — it is the protection or the side effect that silently does not happen.
134134
| 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 | `insert` |
135135
| 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 | `validate` |
136136
| 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 | `insert`, `packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS` |
137-
| 23 | **Referential-integrity check skipped** — except a `runAs: 'system'` flow's record writes, which keep it | objectql | Get: writes proceed against unreachable/unresolvable targets — a seed replay, an import, a restore or a cascade may store a reference to a row that does not exist yet. Lose: an `isSystem` caller can write a **dangling reference**. A flow run's elevated context (`resolveRunDataContext`) carries an internal mark that keeps the check, so a flow's `create_record` / `update_record` naming a row that does not exist is refused `400 VALIDATION_FAILED` / `reference_not_found`, as on the user path; its authorization elevation is unchanged. A write the flow's write sets off (a hook's `ctx.api`, its `sudo()`, a cascade leg of a flow's delete) spreads the run's context and is checked as the flow's own, as on the user path. The mark is a symbol no flow definition or request can spell, and `ExecutionContextSchema.parse` drops it | `assertReferencesResolve` |
137+
| 23 | **Referential-integrity check skipped** — except a `runAs: 'system'` flow's record writes, which keep it | objectql | Get: writes proceed against unreachable/unresolvable targets — a seed replay, an import, a restore or a cascade may store a reference to a row that does not exist yet. Lose: an `isSystem` caller can write a **dangling reference**. A flow run's elevated context (`resolveRunDataContext`) carries an internal mark that keeps the check, so a flow's `create_record` / `update_record` naming a row that does not exist is refused `400 VALIDATION_FAILED` / `reference_not_found`, as on the user path; its authorization elevation is unchanged. A write the flow's write sets off (a hook's `ctx.api`, its `sudo()`, a cascade leg of a flow's delete) spreads the run's context and is checked as the flow's own, as on the user path. The mark is a symbol no flow definition or request can spell, and `ExecutionContextSchema.parse` drops it. Where a system write runs the check (a flow run's), it runs after the record's validation rules, as before; every other write runs it before them. An update's check does not judge again a value every row it lands on already stores; a system write compares those rows as stored, every other write only as the caller may read them, so a column the caller cannot read forgives nothing | `assertReferencesResolve`, `referencesCheckedBeforeRules`, `storedReferencesCallerReads` |
138138
| 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 | `assertPositionNamesCatalogRow` |
139139
| 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`, `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onUpdate` |
140140
| 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`, `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onUpdate` |
@@ -541,6 +541,8 @@ and CI refuses one that differs from what the tree produces.
541541
| `packages/objectql/src/engine-master-detail-related.ts#resolveOneHopRelated` | 1 | 29b |
542542
| `packages/objectql/src/engine-referential-integrity.ts#assertReferencesResolve` | 1 | 23 |
543543
| `packages/objectql/src/engine-referential-integrity.ts#masterDetailHeadersCallerReads` | 1 | 29c |
544+
| `packages/objectql/src/engine-referential-integrity.ts#referencesCheckedBeforeRules` | 1 | 23 |
545+
| `packages/objectql/src/engine-referential-integrity.ts#storedReferencesCallerReads` | 1 | 23 |
544546
| `packages/objectql/src/engine-secret-internal-fields.ts#encryptSecretFields` | 1 | 23e |
545547
| `packages/objectql/src/engine-secret-internal-fields.ts#stripSearchCompanionFromRead` | 1 | 27 |
546548
| `packages/objectql/src/engine-validate.ts#validate` | 1 | 21b |

0 commit comments

Comments
 (0)