|
| 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. |
0 commit comments