Skip to content

Commit 1f05ea4

Browse files
os-justinclaude
andauthored
feat(formula,objectql): read one hop through a lookup in a validation predicate (#19728)
Fixes #18682 Clause-②: yes ⚠️ **Rewritten short by the dispatching seat (2026-09-23T13:10Z).** Its history, with each correction, is in this PR's comments. Three earlier review records were served below the contract-review tier and do not count (notice `5790480639`); the next at-tier record judges the whole PR. ## What changes A `script` / `cross_field` validation rule can read one hop through a lookup: `record.account.type` on an opportunity resolves the related account's field. Before this change the expression faulted, and because a broken validation fails closed, every write on the object was refused. - The engine finds the lookup fields a rule traverses and the related fields it names, loads those related rows before evaluation, and binds them on a copy of the record for that rule only. On an update, the related row is the one named by the foreign key the write stores, read after read-only fields are stripped. The foreign key the driver receives is unchanged. - The related read runs under **system** authority, limited to the fields the expression names (intersected with the related object's declared fields) plus `id`. When the caller has an active organization, the read carries it: under `isolated`, a reference to another organization's row reads 0 rows (measured on `driver-sql` and `driver-sqlite-wasm`). Two bounds hold every caller that is not system (a user, a public-form submitter, or a caller with no principal); they never apply to a system caller. One consequence for system callers is stated rather than left implicit: a system context that carries a `userId` but no active organization now gets the system read under a walled posture, where it read nothing before round 17. Under a walled posture (`group` or `isolated`), such a caller with no active organization gets no related read, and every stored reference stays unresolved. A related object that no organization wall scopes (no tenant column, as `sys_user` behind a `user` field; `tenancy.enabled: false`; or `external`) is read only for the rows the caller's own read of that object returns; a reference to any other row refuses the write as not readable, whatever that row holds, and its columns are never read. A public-form submitter's grant admits a read of the form's own object only, so its own read of any other such object is refused; a caller with no principal reads through the security middleware's fall-open. The organization scope, the org-less no-read (user, public-form and principal-less callers) and the `sys_user` own-read bound (user and public-form callers) are pinned in `packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts`, the user case against the shipped `member_default` wall; the principal-less own read and the `tenancy.enabled: false` / `external` arms are covered by reading, not by a pin. - An unresolved relation (no id, no row found, or a failed read) leaves the field unbound, and the write is refused. The refusal states that the related record was not found, with the same text whether the id names a row in another organization or no row at all. On a related object no organization wall scopes, a row outside the caller's own read, stored or not, is refused as not readable (`could not read 'sys_user'`), with one text; so is a related read that is itself refused. - Two shapes are refused when the rule is authored: reading a lookup both through the relationship and as a plain value, and reading more than one hop deep. ## The write preview (`ObjectQL.validate()`) The preview resolves related rows only for a caller that `@objectstack/plugin-security`'s `canWriteObject` admits; the checks it runs are listed in its docblock, and include the organization wall (ADR-0123 D2). `can-write-object-admission.test.ts` pins the method against the registered middleware on its equivalence block's cases, one D12 delegate UPDATE as a direction case, and the D12 arm receiving copies. `write-preview-field-gate-parity.test.ts` pins the preview against the write path for the field-level gate. ⛔ The preview does not promise that the write would succeed. ## Cascade delete The foreign-key clear a cascade delete issues does **not** resolve relations, which is `main`'s behaviour. Known limit: a traversing rule on a child that clears its reference to the deleted parent refuses that delete, and the refusal's prescription points at the child object rather than the related one. ## Not in this PR - The field-level `requiredWhen` / `readonlyWhen` / option `visibleWhen` predicates and RLS predicates (the UI seams are #19727). Depth is one hop. - #19853 — the master-detail `parent` seam resolves a by-id update's parent before read-only strips. It is the same order this PR fixes on its own seam, predates this PR, and is filed separately. ## Acceptance `docs/qa/platform-checklist/areas/records-forms.json` gains `records-forms.predicate-relationship-traversal`. On the showcase app, `showcase_invoice` refuses an invoice against a churned account on the server. ## Changeset `minor` on `@objectstack/formula`, `@objectstack/lint`, `@objectstack/objectql` and `@objectstack/plugin-security`. ## 维护者速读 - 校验规则现在可以读关联记录上一层的字段(例如在商机上判断所属客户的类型)。以前这样写会出错,导致该对象的**所有**保存被拒。 - 关联记录以系统身份读取,只读表达式里点名的字段。调用者有当前组织时,读取带上该组织(`isolated` 下读不到别的组织的记录);在有组织墙的部署里,没有当前组织的非系统调用者(包括公开表单的匿名提交者)完全不做关联读取。两条都有测试钉住。 - 关联对象不分组织时(例如 `user` 字段指向的 `sys_user`),只读调用者自己读得到的那一行;读不到(比如别的组织的用户)就按「不可读」响亮拒绝,不管那一行存的是什么,也不读它的字段。这两条约束对所有非系统调用者都成立:登录用户、公开表单的匿名提交者、没有身份的内部调用;系统调用不受这两条约束(带 `userId` 但没有当前组织的系统调用,在有组织墙的部署里现在会做系统读取,之前不读)。 - 级联删除清空外键时不读关联记录,与 `main` 一致;已知局限:这时带关联读取的规则会拒绝那次删除,提示语指向的对象不对。 - 更新时按「去掉只读字段后真正要写入的外键」去读关联记录,堵住了先读后剥只读字段的绕过。 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cc4ee77 commit 1f05ea4

22 files changed

Lines changed: 4813 additions & 69 deletions
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
---
2+
'@objectstack/formula': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/objectql': minor
5+
'@objectstack/plugin-security': minor
6+
---
7+
8+
A validation rule can read one hop through a lookup — `record.account.type` on an opportunity resolves the owning account's field instead of faulting (#18682)
9+
10+
Clause-②: yes (widening)
11+
12+
A validation predicate could only read the record it guards. A `lookup` /
13+
`master_detail` field carries an **id**, so the natural cross-object rule —
14+
"a partner account may not carry an opportunity over 10000" — faulted with
15+
`runtime: No such key: type`, and because a broken validation is fail-closed it
16+
rejected every write on the object. The capability mainstream platforms provide
17+
as a matter of course could not be authored at all.
18+
19+
### What you can write now
20+
21+
```ts
22+
validations: [{
23+
name: 'partner_cap',
24+
type: 'script',
25+
message: 'Partner accounts are capped at 10000.',
26+
condition: "record.account.type == 'partner' && record.amount > 10000",
27+
}]
28+
```
29+
30+
One hop, through any reference-typed field (`lookup`, `master_detail`, `user`,
31+
`tree`). The engine reads the related row before evaluating and binds it in
32+
place of the id, so `record.<reference field>.<field>` resolves.
33+
34+
### It is data pinned BEFORE evaluation, not a query from inside CEL
35+
36+
There is no `os.lookup(...)` / `os.exists` / `os.count` — those stay removed.
37+
The engine statically analyses the predicate, learns exactly which reference
38+
fields it reads through and which related fields it names, and loads those
39+
**before** evaluation. Every registered function stays pure once `now` is
40+
pinned, so `objectstack build` artifacts stay byte-stable.
41+
42+
The cost is bounded by construction: one hop, only the fields a rule actually
43+
names, one batched read per reference field per write, and nothing at all when
44+
no rule traverses.
45+
46+
### Read authority — system, bounded by the projection
47+
48+
The related row is read under **system authority**. A validation rule's output
49+
is a pass/fail the *system* enforces, not data handed to the caller — which is
50+
why RLS predicates are excluded from this capability altogether. Reading as the
51+
acting user instead made the rule unauthorable for exactly the persona it exists
52+
to constrain: a member with CRUD on the child and no read on the parent faulted
53+
on every write.
54+
55+
What bounds the elevation is the **projection**: only the
56+
columns the predicate names, intersected with the related object's declared
57+
fields. A column the related object does not declare never enters the query, and
58+
is refused as the authoring fault it is — distinct from a column that exists and
59+
is empty, which evaluates as `null`.
60+
61+
A related object no organization wall scopes — no tenant column (`sys_user`
62+
behind a `user` field), `tenancy.enabled: false`, or `external` — is bounded by
63+
row as well: for any caller that is not system (a user, a public-form
64+
submitter, a caller with no principal), only a row the caller's own read of that
65+
object returns. A reference to any other row refuses the write as not readable,
66+
whatever that row holds. Under a walled posture (`group` or `isolated`), such a
67+
caller with no active organization gets no related read at all: a rule reading
68+
through a stored reference refuses the write as not found.
69+
70+
⚠️ **The accepted cost, stated plainly.** A caller can *infer* a related value
71+
they cannot see by observing which writes are refused. The value itself never
72+
appears — the refusal names the field and the rule, never the value — and the
73+
channel is deliberately no wider than "this rule refused this write".
74+
75+
### Two shapes are refused, with a prescription
76+
77+
Both fault at evaluation today, so neither removes anything that works:
78+
79+
| Shape | Why | Write instead |
80+
| --- | --- | --- |
81+
| `record.account.type == 'x' && record.account == 'acc_1'` | reading through the relationship resolves `record.account` to the related RECORD, so the id comparison would stop matching — silently | `record.account.id == 'acc_1'` for the value comparison |
82+
| `record.account.owner.email` | a second hop is not loaded | denormalise onto `account`'s object, or read it in a hook |
83+
84+
A field that is **not** reference-typed is untouched: `record.address.city` on
85+
an object-valued field traverses today and keeps traversing.
86+
87+
### `@objectstack/plugin-security` gains `canWriteObject`
88+
89+
The WRITE admission — the sibling of the existing `canReadObject`, running the
90+
middleware's own arms in the middleware's own order: system bypass; then, before
91+
anything resolves, the ADR-0103 engine-owned write guard and the ADR-0090 D12
92+
delegated-administration gate, each called as the middleware's own primitive;
93+
then no resolved permission sets, unresolvable posture, the ADR-0066 D3
94+
`requiredPermissions` capability AND-gate for both principals, the CRUD grant,
95+
the ADR-0090 D10 delegator check, and — when the caller's payload is supplied —
96+
the field-level security WRITE gate over it (`getFieldPermissions`, folded
97+
through the D3 field-capability contract, intersected with the delegator's mask
98+
under D10, then the forbidden-write detection); and last, the ADR-0123 D2
99+
no-active-organization wall, the same verdict the middleware's step 3.7 throws
100+
on. It exists for doors that must ask
101+
"could this caller perform this write" without running the engine middleware —
102+
the write preview is the first.
103+
104+
⭐ What it answers, POSITIVELY — by naming what it RUNS, never a category of the
105+
write decision: the ADR-0103 engine-owned affordance gate, the ADR-0090 D12
106+
delegated-admin gate, the fail-closed postures (#3545's unresolvable posture and
107+
the D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both
108+
principals, the `allowCreate`/`allowEdit` CRUD grant, the D10 delegator's
109+
independent grant, the step 2.5 FLS write gate over the keys the payload
110+
names, and the ADR-0123 D2 organization wall. It says nothing about any refusal
111+
not in that list. `@objectstack/plugin-security`'s
112+
`can-write-object-admission.test.ts` pins the method's answer equal to the
113+
registered middleware's on its equivalence block's cases, and pins one D12
114+
UPDATE case as a direction: the method `false`, the middleware `true`.
115+
116+
⛔ `true` never means the write will succeed, and ⛔ what follows is not an
117+
enumeration of the distance to success: the middleware refuses both before and
118+
after `next()` for reasons this method is never asked. Nearest to hand are the
119+
remaining pre-resolution gates that run beside the two named above — the
120+
package-managed and system-row write gates, which judge a row's PROVENANCE; the
121+
curated-capability-name and audience-anchor binding refusals, which judge a
122+
payload VALUE; and the ADR-0056 public-form grant, which no caller can present
123+
to this method and which has no extracted primitive to call; the row-level and
124+
post-image refusals — the `using` pre-image, the ADR-0055 controlled-by-parent
125+
master edit, the RLS `check` post-image and the Layer 0 tenant post-image, none
126+
of which this method can judge because it is asked about no ROW; the
127+
payload-VALUE refusals the same caller passes by simply not sending the value —
128+
the masked echo and the `owner_id` forge, which therefore widen the caller class
129+
by nothing; the anti-filter-oracle guard on the caller's own predicate, which
130+
this method is handed none of; the post-`next()` assertion that the insert
131+
`check` seam really ran, which judges an executed write; and, outside the
132+
middleware entirely, `readonlyWhen`, the static `readonly` strip and the
133+
validation rules themselves.
134+
135+
### Scope
136+
137+
Object validation rules (`script` / `cross_field`) — and the system-authority
138+
read is confined to that one seam. The field-level
139+
`requiredWhen` / `readonlyWhen` / option `visibleWhen` predicates fail **open**
140+
and are deliberately not covered here; RLS predicates are out too. Depth is one
141+
hop. The cleanup UPDATE a `set_null` delete issues on a referencing record
142+
resolves no relationship, so a rule there is evaluated as before this release —
143+
against the bare id, where reading through it faults and refuses the cleanup,
144+
and with it the delete.

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
99
service self-write, a migration.
1010

1111
This page is **the authority** for what that flag actually does. It exists
12-
because the flag is not one concept: it is a single boolean read at **110
12+
because the flag is not one concept: it is a single boolean read at **112
1313
distinct sites across 20 packages**, and knowing three of those behaviours gives
1414
no hint that the other hundred-and-four exist. Every documented app-side bug
1515
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -101,6 +101,7 @@ that silently does not happen.
101101
| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#computeReadableFields` |
102102
| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` |
103103
| 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` |
104+
| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` |
104105
| 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
105106
| 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` |
106107
| 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` |
@@ -129,11 +130,12 @@ that silently does not happen.
129130
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `packages/objectql/src/engine.ts#stripSearchCompanionFromRead` |
130131
| 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` |
131132
| 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` |
133+
| 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, 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 both | `packages/objectql/src/engine.ts#resolvePredicateRelated` |
132134
| 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.ts#start` |
133135

134136
### 3. Sharing (`plugin-sharing`)
135137

136-
The largest single consumer — **17 of the 110 sites**.
138+
The largest single consumer — **17 of the 112 sites**.
137139

138140
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
139141
|:--|:---|:---|:---|
@@ -279,7 +281,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
279281
independent decisions, and a seed loader plausibly wants the first two but not
280282
the third. The concept is nevertheless **staying as one boolean**:
281283

282-
- **Shipped semantics.** `isSystem` is a published contract with 110 read sites
284+
- **Shipped semantics.** `isSystem` is a published contract with 112 read sites
283285
in 20 packages. Splitting it is a breaking contract change across all of them.
284286
(The ruling was taken when the census read 80 sites in 18 packages; the count
285287
has grown, which strengthens rather than weakens the argument.)
@@ -353,16 +355,16 @@ still holds equal to the census on every pull request:
353355
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
354356
| — parsed as a declaration | 23 | ✅ |
355357
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
356-
| — parsed as a property **read** | 116 | ✅ |
358+
| — parsed as a property **read** | 118 | ✅ |
357359
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
358360
| — the remainder: text inside comments and string literals | 358 | — |
359361
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
360-
| Of those reads: reads of `ExecutionContext.isSystem` | **110** | ✅ |
361-
| — behaviour-bearing (rows 1–63 above) | 106 | ✅ |
362+
| Of those reads: reads of `ExecutionContext.isSystem` | **112** | ✅ |
363+
| — behaviour-bearing (rows 1–63 above) | 108 | ✅ |
362364
| — carry the flag onward only (rows 64–67 above) | 4 | ✅ |
363365
| Packages containing at least one elevation read | **20** | ✅ |
364366
| Files containing at least one elevation read | 45 | ✅ |
365-
| — the distinct symbols those reads live in — what this page anchors | 91 | ✅ |
367+
| — the distinct symbols those reads live in — what this page anchors | 93 | ✅ |
366368
| — of those files, the ones holding more than one read in one symbol | 9 | ✅ |
367369

368370
The six rows marked — are a **dated decomposition, not a live claim**: they were

0 commit comments

Comments
 (0)