Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
73011af
fix(objectql)!: a non-system lookup or user write names only a record…
claude Nov 2, 2026
836064c
test(objectql): a system write asks no read door and no probe
claude Nov 2, 2026
4517600
fix(objectql): an unresolvable lookup target fails open; forced test …
claude Nov 2, 2026
60e4002
chore(changeset): objectql lookup and user writes name only a readabl…
claude Nov 2, 2026
6177223
Merge the parked caller-read reference work (60e40025b) onto fresh main
claude Nov 5, 2026
61bba86
fix(objectql)!: the reference check runs before the record's validati…
claude Nov 5, 2026
c9cb46a
feat(plugin-security): a delegate reads the business units its admin …
claude Nov 5, 2026
129000b
fix(objectql): a system write keeps its validation rules before the r…
claude Nov 5, 2026
6602814
test(plugin-security): suites name users their callers read
claude Nov 5, 2026
eda7ee2
docs(qa): retire the write-without-reading-the-parent persona
claude Nov 5, 2026
2e53abd
test(plugin-audit): count the audit summary's reads, not the write's …
claude Nov 5, 2026
9af142f
docs: a write names only a record its caller can read; a delegate rea…
claude Nov 5, 2026
48a24f8
Merge origin/main into the #29029 branch before the PR
claude Nov 5, 2026
d2b3757
test(objectql): a header the write names answers the reference check …
claude Nov 5, 2026
c614b03
test(plugin-security): the rig-users testkit lives under __tests__, a…
claude Nov 5, 2026
b513250
Merge origin/main into claude/project-thread-m0wypr
claude Nov 5, 2026
93fce6c
Merge origin/main into claude/project-thread-m0wypr
claude Nov 5, 2026
75a7c44
test(plugin-security): the rule's peer carries an expired ban, so the…
claude Nov 5, 2026
0c2b985
docs(qa): the checklist item and the pins name the rule alone
claude Nov 5, 2026
ecaf3bc
Merge origin/main into claude/project-thread-m0wypr
claude Nov 5, 2026
3ec1f3e
test(plugin-security): the agent-ceiling grant suite names users its …
claude Nov 5, 2026
4ba9107
fix(verify): the parent-write-reach personas read the records a creat…
claude Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/29029-lookup-user-caller-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/objectql': patch
'@objectstack/plugin-security': patch
'@objectstack/verify': patch
---

fix(objectql)!: a non-system write may name, in a `lookup` or `user` field, only a record the caller can read (#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. The business-unit read widens: a delegated admin reads the business units inside the admin scope it holds, and no other.
Metadata-protocol: none

<!-- 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. -->

**BREAKING** accept-set narrowing at the write's reference check (`@objectstack/objectql`), served by every write door.

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.

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`).

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`).
16 changes: 10 additions & 6 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -316,12 +316,16 @@ object is rejected with `400 VALIDATION_FAILED`, and the specifics ride in
}
```

The check covers create, update and bulk update. Four cases are deliberately
*not* rejections: an empty value (`null` / `""` / `[]`) means "no link", a
`isSystem` write is exempt (seed replay and package install legitimately write
in an order that only resolves once the batch completes), a target that
cannot be checked at all — an unregistered object, an unreachable datasource —
fails **open** rather than inventing a rejection, and an update that carries back
The check covers create, update and bulk update. A write that is not a system
write may name, in a `lookup`, `master_detail` or `user` field, only a record
the caller can read: a record the caller cannot read is answered exactly as one
that exists nowhere, before the record's validation rules run, and so is one
whose read by the caller fails. Four cases are deliberately *not* rejections: an
empty value (`null` / `""` / `[]`) means "no link", a `isSystem` write is exempt
(seed replay and package install legitimately write in an order that only
resolves once the batch completes), a target that cannot be checked at all — an
unregistered object, or an unreachable datasource on a check that does not read
as the caller — fails **open** rather than inventing a rejection, and an update that carries back
a value (or a member of a multi-valued field) the row already stores is not
judged again, because the update does not add it; on a bulk update that holds
only when every matched row already stores it.
Expand Down
5 changes: 4 additions & 1 deletion content/docs/data-modeling/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,10 @@ The key's full contract is in the [Field reference](/docs/references/data/field)
`reference` is **enforced on write**. A create or update that sets a
`lookup` / `master_detail` to an id with no matching row in the target object is
rejected with `400 VALIDATION_FAILED` and a `fields[]` entry whose `code` is
`reference_not_found` (see the [error catalog](/docs/api/error-catalog)). The
`reference_not_found` (see the [error catalog](/docs/api/error-catalog)). A
write that is not a system write may name, in a `lookup`, `master_detail` or
`user` field, only a record its caller can read: one the caller cannot read is
rejected the same way, before the record's validation rules run. The
same check runs on bulk updates. An update judges only the references it adds: a
value the row already stores, carried back unchanged, is not judged again, so a
deleted user's id left in a `user` field does not block later edits of the record.
Expand Down
5 changes: 5 additions & 0 deletions content/docs/permissions/delegated-administration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ delegate holds. In the permission set editor (Studio's **All metadata** area), t
block lists the five, marks which ones the set already grants, and adds the
missing ones to the set in one click.

The scope itself grants read on the business units inside its subtree, and on
no unit outside it, so a delegate can pick the units it administers without a
`sys_business_unit` grant. A `sys_business_unit` read grant the delegate holds
reads as that grant says, as before.

## What the runtime gate enforces

Writes to the governed tables (`sys_user_position`, `sys_user_permission_set`,
Expand Down
3 changes: 2 additions & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ bugs — it is the protection or the side effect that silently does not happen.
| 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` |
| 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` |
| 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` |
| 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` |
| 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 | `assertReferencesResolve`, `referencesCheckedBeforeRules` |
| 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` |
| 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` |
| 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` |
Expand Down Expand Up @@ -540,6 +540,7 @@ and CI refuses one that differs from what the tree produces.
| `packages/objectql/src/engine-master-detail-related.ts#resolveOneHopRelated` | 1 | 29b |
| `packages/objectql/src/engine-referential-integrity.ts#assertReferencesResolve` | 1 | 23 |
| `packages/objectql/src/engine-referential-integrity.ts#masterDetailHeadersCallerReads` | 1 | 29c |
| `packages/objectql/src/engine-referential-integrity.ts#referencesCheckedBeforeRules` | 1 | 23 |
| `packages/objectql/src/engine-secret-internal-fields.ts#encryptSecretFields` | 1 | 23e |
| `packages/objectql/src/engine-secret-internal-fields.ts#stripSearchCompanionFromRead` | 1 | 27 |
| `packages/objectql/src/engine-validate.ts#validate` | 1 | 21b |
Expand Down
Loading
Loading