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
{{ message }}
Repository navigation
Commit 68c0a2b
Browse filesBrowse the repository at this point in the historyBrowse files
feat(lint): gate a hook body's ctx.api write to a readonly field (#13653)
A hook's `ctx.api` is a ScopedContext over the TRIGGERING operation's
execution context, so `ctx.api.object('x').update({ readonlyField })`
reaches the engine as an ordinary non-system caller and the update path
strips the key. The call returns success and the column stays null - a
failure only an end-to-end read-back detects. This completes the hook
side of the flow-side gate that shipped as `flow-update-readonly-field`.
The rule keys on the write CHANNEL, not on the field: a beforeInsert /
beforeUpdate body stamping `ctx.input.<field> = ...` writes a server
value that survives the strip (#5591) and is never flagged - `readonly`
plus a before-hook is a correct and widely used pairing. Also skipped,
each for a stated reason: `ctx.api.sudo()` chains (elevated, the
intended channel), insert/create (INSERT is engine-exempt, #3043/#3413),
dynamic object names, non-literal payloads, objects or fields this stack
does not declare, and `id` in an update payload (the row address, #8141).
- `hook-api-update-readonly-field` - error
- `hook-api-update-readonly-when-field` - warning (per-record state)
Wired through REFERENCE_INTEGRITY_RULES so it runs on `os validate`,
`os lint` and `os compile` at once. `buildReadonlyIndex` is now shared
from the flow rule rather than copied.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Add `validateReadonlyHookWrites` — an author-time gate on a hook body writing a `readonly` field through `ctx.api`.
6
+
7
+
A hook's `ctx.api` is a `ScopedContext` over the **triggering** operation's execution context, so `ctx.api.object('x').update({ someReadonlyField })` reaches the engine as an ordinary non-system caller and the update path strips the key. The call returns success, the step looks clean, and the column is simply always null — a failure only an end-to-end read-back detects. This completes the hook side of the flow-side gate that shipped as `flow-update-readonly-field`.
8
+
9
+
Two new rule ids, wired through `REFERENCE_INTEGRITY_RULES` so they run on `os validate`, `os lint` and `os compile`:
10
+
11
+
-`hook-api-update-readonly-field` — **error**. A literal `ctx.api.object('…').update()` / `.updateById()` writing a field the named object declares `readonly: true`.
12
+
-`hook-api-update-readonly-when-field` — **warning**. The same write against a `readonlyWhen` field, which strips per record state.
13
+
14
+
The rule keys on the write **channel**, not on the field, so the correct and widely used pairing is untouched: a `beforeInsert`/`beforeUpdate` body stamping `ctx.input.<field> = …` writes a server value that survives the strip and is **never** flagged. Also skipped, each for a stated reason: `ctx.api.sudo()` chains (elevated — the intended channel), `insert`/`create` (INSERT is engine-exempt), 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
+21-1Lines changed: 21 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -215,7 +215,7 @@ Because the checking is advisory and literal-only:
215
215
216
216
-**Treat `hook-body-write-unknown-field` as a build failure by convention.** It does not gate, but the rule is tuned for near-zero false positives — in practice a warning is a real typo.
217
217
-**Check by hand what the parser cannot see.** Computed keys, spreads, aliased input and dynamic object names are invisible to the rule; for an array or `"*"` hook, every field must exist on every target.
218
-
-**Prefer a flow `update_record` node when the write set is fixed — and for *this* check most of all.** A flow node's writes are structured config: they diff field-by-field, render in the Console designer, and a write to a `readonly:true` field is a **gating error** (`flow-update-readonly-field`) that hooks have no counterpart for. Since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271) the field-existence check gates there too — `flow-node-write-unknown-field` is an **error**, not the advisory warning a body gets, because a node's `fields` is a literal map next to a literal `objectName`: there is no parser in between that could have mis-extracted it, so a finding is a certainty rather than a best effort.
218
+
-**Prefer a flow `update_record` node when the write set is fixed — and for *this* check most of all.** A flow node's writes are structured config: they diff field-by-field, render in the Console designer, and since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271) the field-existence check gates there too — `flow-node-write-unknown-field` is an **error**, not the advisory warning a body gets, because a node's `fields` is a literal map next to a literal `objectName`: there is no parser in between that could have mis-extracted it, so a finding is a certainty rather than a best effort. (The *writability* check now has a hook-side counterpart — see [Writing a `readonly` field](#writing-a-readonly-field) below — but it covers only the `ctx.api` channel.)
219
219
-**Exercise the hook against a real object before shipping** — on SQL drivers the mistake surfaces on the first write; schemaless drivers won't tell you.
220
220
221
221
### Signature conventions
@@ -245,6 +245,26 @@ Per-invocation budgets default to **250ms** (hooks) / **5000ms** (actions) of **
245
245
246
246
A body may write *other* objects — e.g. `await ctx.api.object('parent').update({ ... })` from a child's `afterInsert`/`afterUpdate` (requires `api.write`). The target's own hooks fire too: the nested write runs in a **fresh sandbox VM** while the calling body is suspended, and this composes to any depth. This is the natural "when a child changes, roll the total up to the parent" automation — it does **not** need a denormalized, hand-maintained mirror field. Because each body's budget is **CPU time** (ADR-0102), the caller is **not** charged for the nested write's own run — so the stock 250ms default comfortably covers deep rollup chains, and you rarely need to raise `timeoutMs` (the spec still permits up to 30_000ms for a genuinely CPU-heavy body).
247
247
248
+
### Writing a `readonly` field
249
+
250
+
There is an asymmetry here that costs data if you learn it the hard way, so learn it here. A field declared `readonly: true` can still be **maintained by automation** — but only through two channels, and a nested `ctx.api` write is **not** one of them.
251
+
252
+
`readonly` governs the *caller* surface. On UPDATE the engine strips read-only keys from the payload, but only the ones the **caller supplied** and only when the value is still the caller's. So:
253
+
254
+
| How the body writes it | What happens |
255
+
|:---|:---|
256
+
|`ctx.input.<field> = …` in `beforeInsert`/`beforeUpdate`|**Lands.** The stamp is a *server* value, not a caller-supplied one, so the strip leaves it alone. This is the recommended shape. |
257
+
|`ctx.api.object('x').update({ <field> })`|**Silently dropped.**`ctx.api` is scoped to the *triggering* operation's context, so on any non-system trigger the payload is an ordinary caller payload and the key is stripped. The call still returns success. |
258
+
|`ctx.api.sudo().object('x').update({ <field> })`|**Lands.**`sudo()` elevates to a system context, which the strip skips — the hook-side analogue of a flow's `runAs: 'system'`. Use it deliberately: it also bypasses the acting user's row and field permissions for that write. |
259
+
|`ctx.api.object('x').insert({ <field> })`|**Lands.** INSERT is exempt — a create may legitimately seed read-only columns. |
260
+
261
+
The dropped case is the dangerous one: nothing fails, the step reports success, and the column is simply always null. Because both halves of that judgement are declared in your own stack, it is checked at author time and **gates the build**:
262
+
263
+
-`hook-api-update-readonly-field` — **error**. A body's literal `ctx.api.object('…').update()` / `.updateById()` writes a field the named object declares `readonly: true`.
264
+
-`hook-api-update-readonly-when-field` — **warning**. The same write against a `readonlyWhen` field, which strips per record *state*. Note that `readonlyWhen` also strips a `beforeUpdate`-derived value, so the own-hook stamp is **not** a workaround for it — `sudo()` is.
265
+
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
+
248
268
### Errors from `ctx.api`
249
269
250
270
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