Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
fbe331c
feat(formula): analyse the relationship hops a predicate names
claude Sep 22, 2026
2fd7ca5
feat(objectql): evaluate a validation predicate one hop through a lookup
claude Sep 22, 2026
d74371b
feat(objectql): resolve predicate relationships at every validation seam
claude Sep 22, 2026
bf12f83
feat(formula): refuse the traversal shapes that cannot be served as w…
claude Sep 22, 2026
2484484
feat(showcase): enforce the churned-account rule on the server, not j…
claude Sep 22, 2026
0065a40
fix(objectql): carry the declared query-options type on the related-r…
claude Sep 22, 2026
45db6b8
fix(objectql,formula): refuse in the engine, name the related object,…
claude Sep 22, 2026
2a3e97d
test(objectql): pin the engine half of relationship traversal
claude Sep 22, 2026
d4aa3ff
feat(objectql): read the related record under system authority for va…
claude Sep 22, 2026
cdaf954
fix(dogfood,qa): pick the persona-matrix control account by the rule,…
claude Sep 22, 2026
82db310
fix(objectql): keep the tracker id out of runtime prose and hold the …
claude Sep 22, 2026
995266c
chore(plugin-security): drop a stray blank line left by a reverted seam
claude Sep 22, 2026
0446ca9
fix(objectql): make the prescribed repair work, gate the dry run, and…
claude Sep 22, 2026
1201afd
chore(docs): re-derive the system-context census after the validation…
claude Sep 22, 2026
ac98ad3
fix(plugin-security,objectql): make the write-gate probe the write pa…
claude Sep 22, 2026
3fded10
test(objectql): follow the reworded unresolved-refusal sentence
claude Sep 22, 2026
e6036cb
feat(security): the write-gate probe judges the payload, not just the…
claude Sep 22, 2026
0e5c770
docs(security): state the write-gate probe's bound positively, not as…
claude Sep 22, 2026
47eb932
feat(security): add the ADR-0103 and ADR-0090 D12 pre-resolution arms…
claude Sep 22, 2026
af24584
test(security): pin both pre-resolution arms, in both directions and …
claude Sep 22, 2026
66c2de0
docs(security): name the arms at the two remaining sites that still s…
claude Sep 22, 2026
c12c0ec
docs(security): name the post-next() seam by its mechanism, not by a …
claude Sep 22, 2026
659d5a4
test(security): pin the D12 arm's row copy and its one DIRECTION case
claude Sep 23, 2026
fd85fd5
docs(security): state only what the admission suite pins, with no count
claude Sep 23, 2026
5472eeb
test(security): retitle the DIRECTION block without a count
claude Sep 23, 2026
4918cb1
fix(objectql): resolve no relationship on the referential FK clear
claude Sep 23, 2026
6f2c060
feat(security): canWriteObject asks the ADR-0123 D2 organization wall
claude Sep 23, 2026
1adad1e
docs(security,objectql): delete the sentences that bounded the channe…
claude Sep 23, 2026
c59a206
test(security): make the empty-resolution D2 case reach arm 4
claude Sep 23, 2026
2361bef
test(security): type the two option bags the FK-clear pin adds
claude Sep 23, 2026
6ff9842
docs(objectql,security): bound three comments to what the code does
claude Sep 23, 2026
7ef7ea9
fix(objectql): the unresolved refusal states only that the record was…
claude Sep 23, 2026
b6c74e6
test(security): pin the related read inside the caller's organization
claude Sep 23, 2026
b3efd1d
test(security): reach the pin's table past SqlDriver's protected knex
claude Sep 23, 2026
2ac3109
Merge remote-tracking branch 'origin/main' into claude/issue-18682-pr…
claude Sep 23, 2026
d8e47c9
test(security): give the D12 fixture's business units their organization
claude Sep 23, 2026
79f2e9a
docs(objectql): drop ", not the caller" from the related read's bound
claude Sep 23, 2026
cc48686
docs(objectql): delete the remaining claims that the caller does not …
claude Sep 23, 2026
f61d7d1
fix(objectql): judge a by-id update's traversing rule against the FK …
claude Sep 23, 2026
1c1fe26
fix(objectql): no related read for an org-less member under the group…
claude Sep 23, 2026
94bb92e
docs(objectql,plugin-security): delete three false sentences
claude Sep 23, 2026
ada09f0
docs(objectql): shorten the group-posture gate comment to one line
claude Sep 23, 2026
3b9c5f2
fix(objectql): no related read for an org-less user under any walled …
claude Sep 23, 2026
53f960f
Merge remote-tracking branch 'origin/main' into claude/issue-18682-pr…
claude Sep 24, 2026
1d6866b
fix(objectql): a related row outside every organization wall is answe…
claude Sep 24, 2026
1e487ef
docs(changeset): state the row bound on a related object no organizat…
claude Sep 24, 2026
11ddc2d
fix(objectql): bound a traversing rule's related read for every calle…
claude Sep 24, 2026
5b11234
docs(changeset): name every caller the row bound holds, and the org-l…
claude Sep 24, 2026
3e8b3c3
docs(permissions): census the not-system read that now bounds a trave…
claude Sep 24, 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
144 changes: 144 additions & 0 deletions .changeset/18682-predicate-relationship-traversal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
---
'@objectstack/formula': minor
'@objectstack/lint': minor
'@objectstack/objectql': minor
'@objectstack/plugin-security': minor
---

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)

Clause-②: yes (widening)

A validation predicate could only read the record it guards. A `lookup` /
`master_detail` field carries an **id**, so the natural cross-object rule —
"a partner account may not carry an opportunity over 10000" — faulted with
`runtime: No such key: type`, and because a broken validation is fail-closed it
rejected every write on the object. The capability mainstream platforms provide
as a matter of course could not be authored at all.

### What you can write now

```ts
validations: [{
name: 'partner_cap',
type: 'script',
message: 'Partner accounts are capped at 10000.',
condition: "record.account.type == 'partner' && record.amount > 10000",
}]
```

One hop, through any reference-typed field (`lookup`, `master_detail`, `user`,
`tree`). The engine reads the related row before evaluating and binds it in
place of the id, so `record.<reference field>.<field>` resolves.

### It is data pinned BEFORE evaluation, not a query from inside CEL

There is no `os.lookup(...)` / `os.exists` / `os.count` — those stay removed.
The engine statically analyses the predicate, learns exactly which reference
fields it reads through and which related fields it names, and loads those
**before** evaluation. Every registered function stays pure once `now` is
pinned, so `objectstack build` artifacts stay byte-stable.

The cost is bounded by construction: one hop, only the fields a rule actually
names, one batched read per reference field per write, and nothing at all when
no rule traverses.

### Read authority — system, bounded by the projection

The related row is read under **system authority**. A validation rule's output
is a pass/fail the *system* enforces, not data handed to the caller — which is
why RLS predicates are excluded from this capability altogether. Reading as the
acting user instead made the rule unauthorable for exactly the persona it exists
to constrain: a member with CRUD on the child and no read on the parent faulted
on every write.

What bounds the elevation is the **projection**: only the
columns the predicate names, intersected with the related object's declared
fields. A column the related object does not declare never enters the query, and
is refused as the authoring fault it is — distinct from a column that exists and
is empty, which evaluates as `null`.

A related object no organization wall scopes — no tenant column (`sys_user`
behind a `user` field), `tenancy.enabled: false`, or `external` — is bounded by
row as well: for any caller that is not system (a user, a public-form
submitter, a caller with no principal), only a row 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. Under a walled posture (`group` or `isolated`), such a
caller with no active organization gets no related read at all: a rule reading
through a stored reference refuses the write as not found.

⚠️ **The accepted cost, stated plainly.** A caller can *infer* a related value
they cannot see by observing which writes are refused. The value itself never
appears — the refusal names the field and the rule, never the value — and the
channel is deliberately no wider than "this rule refused this write".

### Two shapes are refused, with a prescription

Both fault at evaluation today, so neither removes anything that works:

| Shape | Why | Write instead |
| --- | --- | --- |
| `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 |
| `record.account.owner.email` | a second hop is not loaded | denormalise onto `account`'s object, or read it in a hook |

A field that is **not** reference-typed is untouched: `record.address.city` on
an object-valued field traverses today and keeps traversing.

### `@objectstack/plugin-security` gains `canWriteObject`

The WRITE admission — the sibling of the existing `canReadObject`, running the
middleware's own arms in the middleware's own order: system bypass; then, before
anything resolves, the ADR-0103 engine-owned write guard and the ADR-0090 D12
delegated-administration gate, each called as the middleware's own primitive;
then no resolved permission sets, unresolvable posture, the ADR-0066 D3
`requiredPermissions` capability AND-gate for both principals, the CRUD grant,
the ADR-0090 D10 delegator check, and — when the caller's payload is supplied —
the field-level security WRITE gate over it (`getFieldPermissions`, folded
through the D3 field-capability contract, intersected with the delegator's mask
under D10, then the forbidden-write detection); and last, the ADR-0123 D2
no-active-organization wall, the same verdict the middleware's step 3.7 throws
on. It exists for doors that must ask
"could this caller perform this write" without running the engine middleware —
the write preview is the first.

⭐ What it answers, POSITIVELY — by naming what it RUNS, never a category of the
write decision: the ADR-0103 engine-owned affordance gate, the ADR-0090 D12
delegated-admin gate, the fail-closed postures (#3545's unresolvable posture and
the D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both
principals, the `allowCreate`/`allowEdit` CRUD grant, the D10 delegator's
independent grant, the step 2.5 FLS write gate over the keys the payload
names, and the ADR-0123 D2 organization wall. It says nothing about any refusal
not in that list. `@objectstack/plugin-security`'s
`can-write-object-admission.test.ts` pins the method's answer equal to the
registered middleware's on its equivalence block's cases, and pins one D12
UPDATE case as a direction: the method `false`, the middleware `true`.

⛔ `true` never means the write will succeed, and ⛔ what follows is not an
enumeration of the distance to success: the middleware refuses both before and
after `next()` for reasons this method is never asked. Nearest to hand are the
remaining pre-resolution gates that run beside the two named above — the
package-managed and system-row write gates, which judge a row's PROVENANCE; the
curated-capability-name and audience-anchor binding refusals, which judge a
payload VALUE; and the ADR-0056 public-form grant, which no caller can present
to this method and which has no extracted primitive to call; the row-level and
post-image refusals — the `using` pre-image, the ADR-0055 controlled-by-parent
master edit, the RLS `check` post-image and the Layer 0 tenant post-image, none
of which this method can judge because it is asked about no ROW; the
payload-VALUE refusals the same caller passes by simply not sending the value —
the masked echo and the `owner_id` forge, which therefore widen the caller class
by nothing; the anti-filter-oracle guard on the caller's own predicate, which
this method is handed none of; the post-`next()` assertion that the insert
`check` seam really ran, which judges an executed write; and, outside the
middleware entirely, `readonlyWhen`, the static `readonly` strip and the
validation rules themselves.

### Scope

Object validation rules (`script` / `cross_field`) — and the system-authority
read is confined to that one seam. The field-level
`requiredWhen` / `readonlyWhen` / option `visibleWhen` predicates fail **open**
and are deliberately not covered here; RLS predicates are out too. Depth is one
hop. The cleanup UPDATE a `set_null` delete issues on a referencing record
resolves no relationship, so a rule there is evaluated as before this release —
against the bare id, where reading through it faults and refuses the cleanup,
and with it the delete.
16 changes: 9 additions & 7 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

This page is **the authority** for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at **110
because the flag is not one concept: it is a single boolean read at **112
distinct sites across 20 packages**, and knowing three of those behaviours gives
no hint that the other hundred-and-four exist. Every documented app-side bug
traced to `isSystem` had the same shape — the metadata was complete and correct,
Expand Down Expand Up @@ -101,6 +101,7 @@ that silently does not happen.
| 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` |
| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down Expand Up @@ -129,11 +130,12 @@ that silently does not happen.
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |

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

The largest single consumer — **17 of the 110 sites**.
The largest single consumer — **17 of the 112 sites**.

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

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

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