You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
lint: warn when an action body writes a readonlyWhen field through ctx.api (#13844)
* wip(lint): action-surface readonly rule + wiring
* feat(lint): warn when an action body writes a readonlyWhen field through ctx.api
Adds validateReadonlyActionWrites, the action-surface member of the readonly
write family, wired through REFERENCE_INTEGRITY_RULES.
An action body's ctx.api is createContext({ ...callerEnvelope, isSystem: true }),
so the engine's static readonly strip - which runs only under
!opCtx.context?.isSystem - is skipped and a readonly:true write LANDS there. The
conditional strip takes no isSystem exemption, so a readonlyWhen field written
through ctx.api is still dropped on records whose predicate is TRUE. Only that
second shape is reported, as a warning.
ctx.record is excluded from the match set: an action's ctx.record is a dead
snapshot the runtime never writes back, so no strip is ever consulted on it and
a readonly verdict there would be false on every occurrence.
Reuses buildReadonlyIndex from the flow rule and collectActionBodies from the
action rule rather than growing a second walk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
* docs(automation): name the action surface in the readonly write-gate enumeration
The 'Writing a readonly field' section's table is hook-scoped and its closing
sentence enumerated the surfaces carrying the gate (hook, flow). Landing
action-api-update-readonly-when-field would have left that enumeration one short,
and left the hook-scoped table readable as covering actions on a page titled
'Hook & Action Bodies'.
States the measured difference: an action body runs elevated, so the static strip
does not apply and a readonly write LANDS there, while the conditional lock is
not waived by elevation and does carry across.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
---------
Co-authored-by: Claude <noreply@anthropic.com>
Add `validateReadonlyActionWrites` — an author-time warning on an action body writing a `readonlyWhen` field through `ctx.api`.
6
+
7
+
The action surface is the third write surface in the readonly family, after `flow-update-readonly-field` and `hook-api-update-readonly-field`, and it is the one where the family's answer differs. An action body's `ctx.api` is `createContext({ ...callerEnvelope, isSystem: true })` — elevated by design, so RLS/FLS-bypassing trusted execution is the documented posture — and the engine's **static** readonly strip runs only for non-system callers. Measured against a real engine over a memory driver:
So exactly one shape is a silent no-op on this surface, and that is what the new rule reports:
16
+
17
+
-`action-api-update-readonly-when-field` — **warning**. A literal `ctx.api.object('…').update()` / `.updateById()` in an action body writing a field the named object declares `readonlyWhen`. The conditional strip takes no `isSystem` exemption, so elevation is not a workaround and the hint does not offer one: confirm the call only targets records whose predicate is FALSE, or derive the field in a `beforeUpdate` hook on the target object (a hook-written value is not caller-supplied and does land).
18
+
19
+
A static-`readonly` counterpart is deliberately **not** shipped: an elevated action write lands on such a field, so the finding would state a falsehood and, at the hook rule's `error` grade, would gate a build over working code.
20
+
21
+
Wired through `REFERENCE_INTEGRITY_RULES`, so it runs on `os validate`, `os lint` and `os compile` at once. It reuses the existing machinery rather than adding any: `buildReadonlyIndex` from the flow rule for the field metadata, and `collectActionBodies` from the action rule for the body walk (both registration sites, with the merged-action de-duplication that walk owns).
22
+
23
+
`ctx.record` is excluded from the match set, and that exclusion is the rule's load-bearing decision: an action's `ctx.record` is a dead snapshot the runtime never writes back, so no readonly strip is ever consulted on it and a readonly verdict there would be false on every occurrence. `action-record-write-discarded` already owns that shape and states its real reason. Also skipped, each for a stated reason: `insert` / `create` (INSERT is exempt from both strips), `ctx.input` writes (an action's `ctx.input` is its params bag), dynamic object names, non-literal payloads, objects this stack does not declare, fields the object does not declare, and `id` in an `update` payload (the row address, not a field write).
Copy file name to clipboardExpand all lines: content/docs/automation/hook-bodies.mdx
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -265,6 +265,8 @@ The dropped case is the dangerous one: nothing fails, the step reports success,
265
265
266
266
Only literal object names and literal payload keys are seen; a `sudo()` chain, a dynamic object name, an object this stack does not declare, and `insert`/`create` are all skipped, so the rule has no opinion on them. The flow surface has carried the same gate as `flow-update-readonly-field` since [#3425](https://github.com/objectstack-ai/objectstack/issues/3425).
267
267
268
+
The table above is about a **hook** body. An **action** body is the one surface where the answer changes, so read this before you move a body from one to the other: an action body runs **elevated** — its `ctx.api` is built over the caller's envelope with `isSystem` set, which is the same trusted posture that lets an action bypass row and field permissions — and the static strip applies only to non-system callers. So `ctx.api.object('x').update({ someReadonlyField })`**lands** in an action, and there is no finding for it. Elevation does not waive the *conditional* lock, though, so that half does carry across: `action-api-update-readonly-when-field` — a **warning** — on an action body's literal `ctx.api` update to a `readonlyWhen` field ([#13770](https://github.com/objectstack-ai/objectstack/issues/13770)). Net effect when you move a body: a `readonly` write changes behaviour, a `readonlyWhen` write does not.
269
+
268
270
### Errors from `ctx.api`
269
271
270
272
A rejected `ctx.api` call gives your body the host error's `name` and `message`, plus two structured properties when the host supplied them:
0 commit comments