|
| 1 | +--- |
| 2 | +"@objectstack/objectql": minor |
| 3 | +--- |
| 4 | + |
| 5 | +fix(objectql): a TRUE `readonlyWhen` no longer strips hook-derived values — the conditional strip judges only API-boundary callers (#9107) |
| 6 | + |
| 7 | +`stripReadonlyWhenFields` runs AFTER the before-phase hooks and was keyed on |
| 8 | +`name in data` over the POST-hook payload, so a value a `beforeUpdate` hook |
| 9 | +computed was judged exactly like a key the caller forged. Unlike the static |
| 10 | +`readonly` strip immediately beside it, it carried no `isSystem` exemption |
| 11 | +either — so a field locked by a TRUE predicate had **no server-side write path |
| 12 | +at all**: a hook derived it and the strip deleted it; a cron or plugin wrote it |
| 13 | +with `{ context: { isSystem: true } }` and the strip deleted that too. |
| 14 | + |
| 15 | +Net effect before this change: the derived-field pattern (hook-computed column) |
| 16 | +and a conditional form lock could not coexist on one field. An author wanting |
| 17 | +"visible on the form but locked" **and** "recomputed by a hook" had no |
| 18 | +spec-compliant spelling, and the failure was silent behind an HTTP 200. |
| 19 | + |
| 20 | +Measured downstream (steedos-labs/os-project-titanwind-ehr#1446): |
| 21 | +`equipment.next_maintenance_date` is hook-derived (last maintenance date + cycle |
| 22 | +days) and declared with an always-true `readonlyWhen` to render |
| 23 | +visible-but-locked. After a maintenance sign-off the recompute never landed, and |
| 24 | +a scheduler keyed on that date regenerated the same maintenance plan on every |
| 25 | +scan — a user-visible duplicate-plans loop, diagnosed only by reading the |
| 26 | +engine's strip order in the dist bundle. |
| 27 | + |
| 28 | +The conditional strip now carries the exact key discipline #5591 gave the static |
| 29 | +one, on **both** branches (by-id and multi-row) off one engine-entry snapshot: a |
| 30 | +key is judged only while it is still an own property of the caller's payload as |
| 31 | +it arrived at engine entry AND still holds that caller's value by `Object.is`. A |
| 32 | +key a hook added, or overwrote, is a server value and survives. |
| 33 | + |
| 34 | +**The API-boundary lock is unchanged, and a caller cannot launder a write |
| 35 | +through the hook phase.** To reach the exempt side of either test a value must |
| 36 | +differ from what arrived at engine entry — which only server code can arrange. A |
| 37 | +client that echoes the locked key back is stripped exactly as before; if a hook |
| 38 | +overwrites that key, what persists is the **hook's** value, never the client's. |
| 39 | +`isSystem` is still deliberately NOT an exemption for `readonlyWhen`: a state |
| 40 | +lock that any system-context write could bypass would not be a state lock (the |
| 41 | +frozen paid-invoice-lines case depends on it). |
| 42 | + |
| 43 | +What moves for callers: |
| 44 | + |
| 45 | +- A `beforeUpdate` hook may now write a field locked by a TRUE `readonlyWhen`. |
| 46 | + This is the sanctioned channel for a conditionally-locked derived field. |
| 47 | +- `onFieldsDropped` no longer reports such a key under `readonly_when` — it is |
| 48 | + written, not dropped, so reporting it would make the observability seam lie. |
| 49 | +- `strictReadonlyWrites` no longer refuses a write whose only `readonlyWhen` |
| 50 | + "drop" was a hook's own value; a caller-supplied locked field is still refused. |
| 51 | +- The `ERR_READONLY_FIELD_REJECTED` refusal message's `readonlyWhen` remedy |
| 52 | + clause now reads "every **API-boundary** caller, isSystem included" and names |
| 53 | + the hook path. The error `code` is unchanged; a pin on the exact message text |
| 54 | + moves with it. |
| 55 | + |
| 56 | +If an app relied on the strip discarding a hook's own write to a locked field, |
| 57 | +that write now lands — remove the hook assignment, or narrow the predicate. |
0 commit comments