Skip to content

Commit 8c90c0b

Browse files
committed
docs(ui): state the true reason a handler must not name a server-owned field (#15117)
The previous push removed `completed_date` from the handler snippet, which was right, and then explained it with a mechanism that is false. Re-measured: - A handler's `ctx.engine` runs ELEVATED — `buildActionExecutionContext` returns `{ ...base, isSystem: true }` (`packages/runtime/src/action-execution.ts`) — and the read-only strip is gated `if (!opCtx.context?.isSystem)` (`packages/objectql/src/engine.ts`). Nothing is stripped on that path, so "stripped from the payload before the record is validated" never happens. - The refusal does not exist either, for any caller. The hook's stamp is unconditional on the transition and its own docblock says that is precisely so a caller-supplied value is overwritten and survives the strip; a live test asserts it — "a caller that still sends `completed_date` is not punished for it — the hook value wins". The REJECTED row whose mechanism the page described is labelled "Measured before the fix". The true reason is close to the inverse, and is now what the page says: the write is not stripped, it LANDS, and on a write that is not a completion transition the hook does not stamp, so the handler's "now" silently replaces the real completion timestamp. I read the #7036 history at the three sites I cited and wrote it in the present tense, without reading the four sites the conclusion depended on. A citation that exists is not a citation that entails. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
1 parent cc5b792 commit 8c90c0b

1 file changed

Lines changed: 15 additions & 5 deletions

File tree

‎content/docs/ui/actions.mdx‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,21 @@ checked against. (The contract's own docblock asks for `ActionHandler`; that is
154154
function type, so it fits an arrow-function handler assigned to a `const`, while
155155
a function *declaration* like the one above annotates its parameter instead.)
156156

157-
Write only the fields the action owns. `completed_date` is **readonly** on
158-
`todo_task` — server-owned, stamped by the object's `beforeUpdate` hook on the
159-
transition — so a caller's write to it is stripped from the payload before the
160-
record is validated, and sending it here made this action refuse itself against
161-
that object's `completed_date_required` rule.
157+
Write only the fields the action owns — and read that literally, because a
158+
handler's `ctx.engine` runs **elevated** (`isSystem`). The read-only strip that
159+
protects a server-owned column from an ordinary caller is gated on *not* being
160+
a system write, so it does not run on a handler's write at all. **What a
161+
handler names, lands.** A server-owned field is therefore more dangerous here
162+
than in a form PUT, not less.
163+
164+
`completed_date` is the example. It is `readonly` on `todo_task` — the server
165+
owns it, and the object's `beforeUpdate` hook stamps it on the completion
166+
**transition**. Adding `completed_date: new Date().toISOString()` to the write
167+
above looks harmless, and on that transition it is: the hook overwrites it. But
168+
a completion write that is *not* a transition — re-completing a task that is
169+
already `completed` — is not stamped, so nothing overwrites the handler's value
170+
and nothing strips it either, and the action silently replaces the real
171+
completion timestamp with "now". That is why the snippet sends `status` alone.
162172

163173
<Callout type="warn">
164174
**`ctx.engine.find(object, filter)` takes a filter, not a query.** The second

0 commit comments

Comments
 (0)