Skip to content

Commit 33681ea

Browse files
os-samclaude
andauthored
feat(hooks): Hook.runAs'system' | 'user' | 'inherit', default 'inherit' (#14915)
* feat(spec,objectql): Hook.runAs — system | user | inherit, default inherit WIP: schema + engine derivation. Tests, sweep and generated artifacts follow. A hook's ctx.api is a ScopedContext over the triggering write's context, so a column protected for a persona by field-level `editable: false` also refuses the hook that maintains it — the guard and the legitimate writer were the same door. `runAs` is the declared knob: - 'system' elevates the hook's ctx.api data operations (the security middleware short-circuits on isSystem before the field-level write check), carrying userId through — elevation is not anonymity, so updated_by still names the operator; - 'user' pins them to the triggering user, and REFUSES them when no trigger user resolves (HOOK_UNSCOPED_DATA_ACCESS, the hook-side twin of the flow engine's #3760 refusal); - 'inherit' (default) hands the engine-built api through by reference — the pre-runAs behaviour, so no existing hook changes. Scope is ctx.api data operations only: condition evaluation, the readonly strip on ctx.input, ctx.session and async semantics all keep reading the triggering operation's context. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF * test(objectql): pin Hook.runAs on the in-process surface Two layers: wrapDeclarativeHook over a real ScopedContext whose engine records the context every data operation carries, and a real ObjectQL dispatch read at the middleware seam plugin-security reads. Covers the three values, the absent key, the ADR-0112 refusal (with 'the engine was never called'), the restore after return and after a throw, the fire-and-forget detachment, the loud refusal of a non-member value, and the ruling's scope fence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF * test(runtime): pin Hook.runAs parity on the sandboxed (L2 body) surface The surface that had no elevation at all: sudo() is not marshalled into the VM, so the same source passed a native handler test and threw TypeError once the build lowered it into a body. No runtime source change is needed — the sandbox reads ctx.api from the engine context at call time, so the wrapper's swap covers it — which is exactly the composition fact a unit test cannot state. Includes a direct handler-vs-body parity assertion at the same seam. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF * test(dogfood): the #14010 symptom, end to end through the real security stack A computed column protected by field-level editable:false and maintained by a hook. Four legs on one boot: the persona's own PATCH is still refused 403; the persona can still write an ordinary column; a runAs:'system' hook BODY writes the protected column and the value lands, stamping updated_by with the operator; and an undeclared hook is still refused with the card's own error text, which is the zero-migration claim measured rather than asserted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF * docs,lint: teach runAs where the old prose said a hook cannot elevate The gating readonly-hook-writes rule now SKIPS a hook declaring runAs:'system' (the static strip skips a system context, so the write it exists to catch does not happen) and its hints name the knob. The hook-bodies table rows that called the absence a gap now point at it, and the canonical semantics live on the hooks page. The conditional-lock refusal is unchanged and still says why: elevation does not waive readonlyWhen, which is what the pre-existing pin predicted would outlive the sudo half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF * chore(spec): regenerate references + liveness counts; add the changeset Regenerated exactly the two artifacts check:generated proved stale (the api contract and error-code-ledger pages, both carrying the new HOOK_UNSCOPED_DATA_ACCESS registration) plus the liveness state counts for the hook.runAs row. HookSchema's own keys are not walked by authorable-surface/ — only HookContext is — so no surface or defaults shard moves, and check:authorable-surface is green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF * fix(tests,census): satisfy the derived gate family - the refusal message carries ADR-0049 only; tracker ids move to the source header (check:doc-authoring), and the dogfood fixture's authored strings lose theirs too; - the recording engine double routes update/delete through the shared dispatch predicates and is registered in the pinned ledger, so it can never be looser than ObjectQL (check:engine-double-contract); - the sandbox stub driver honours the caller's limit by presence (check:objectql-double-limit); - system-context.mdx anchors re-fixed for the one import line this change adds to engine.ts (15 anchors, pure line rot; check:system-context-census OK). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF * chore(i18n): regenerate the metadata-form bundles for the hook runAs field The new Execution-section select carries a label and helpText, so all four locale bundles gain the row; zh-CN / ja-JP / es-ES are hand-translated in the same pass (merge mode preserves them, and the refreshed source hashes are what makes check:i18n read them as translations rather than drift). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF * test(objectql): address the recorder's updates and await through try/catch Two defects the newly-registered dispatch predicate and the test typecheck found in my own fixtures, not in the change under test: three layer-1 calls issued an update with no address (a shape the real engine refuses, and now the double does too — the assert takes BOTH the payload and the options bag, since the address may ride either), and a HookHandler returns void | Promise<void>, so the refusal pins await inside try/catch rather than reaching for .catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF * chore: merge main and regenerate the two os-regen artifacts Discharges the merge commit's os-regen deferral. - liveness/state-counts.md: regenerated from the merged tree (pnpm --filter @objectstack/spec gen:liveness-counts). - system-context.mdx: the merge's only real conflict was one table row whose prose is byte-identical on both sides and differs only in anchor numbers. Resolved per the driver's own notice (take either side, then re-derive), then corrected to the MEASURED lines: the ledger anchors that row by needle, and on the merged tree the needle sits at 10008 — main's 10007 was stale by exactly the one import line this branch adds to engine.ts. field.zod.ts:1537 is main's and stays. check-system-context-census: OK, 140 anchors resolve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCHHMCjeFFjGBm7QQ37CWF * chore: regenerate generated artifacts after merging main Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 23c72be commit 33681ea

28 files changed

Lines changed: 1665 additions & 42 deletions

.changeset/hook-run-as-inherit.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/lint": patch
5+
---
6+
7+
feat(hooks): `runAs` on a hook — `'system' | 'user' | 'inherit'`, default `'inherit'`
8+
9+
A hook's `ctx.api` runs with the context of the write that fired it, so a column
10+
an app wants **computed and never hand-written** could not be expressed: author
11+
`editable: false` for the persona and the direct `PATCH` is refused — and so is
12+
the hook that maintains the column, by the same field-level check. The guard and
13+
the legitimate writer were the same door. The only elevation a hook had was the
14+
in-process `ctx.api.sudo()`, which is not marshalled into the sandbox (a
15+
`TypeError` once a build lowers the handler into a body) and which rides the L3
16+
bundle path that is being retired.
17+
18+
`HookSchema` now accepts `runAs`:
19+
20+
| value | the hook's `ctx.api` data operations run as |
21+
| --- | --- |
22+
| `'inherit'` (default) | the context of the triggering write — exactly the behaviour every hook has today |
23+
| `'system'` | elevated: a full-access, RLS-bypassing system principal |
24+
| `'user'` | the triggering user; a hook whose trigger resolved no user has its data operations **refused** (`HOOK_UNSCOPED_DATA_ACCESS`) rather than run unscoped |
25+
26+
`'system'` and `'user'` mean here exactly what they mean on `flow.runAs` — same
27+
word, same semantics. `'inherit'` is the hook-only third value, because only a
28+
hook has a context to inherit; a flow establishes its identity from nothing,
29+
which is why its default is `'user'` and this one's is `'inherit'`. Nothing on
30+
`FlowSchema` changes.
31+
32+
**Purely additive: no migration, no behaviour change for any existing hook.**
33+
The default reproduces today's behaviour by handing the engine-built `ctx.api`
34+
through unchanged, and an absent key parses to it.
35+
36+
Scope, deliberately narrow: `ctx.api` data operations only. `condition`
37+
evaluation, the `readonly` strip applied to the hook's own `ctx.input` payload,
38+
`ctx.session` and `async` semantics all keep reading the triggering operation's
39+
context, and declaring `runAs: 'system'` does not elevate the write that fired
40+
the hook.
41+
42+
Elevation is authorization, not anonymity: a `runAs: 'system'` write still
43+
carries the triggering user, so `created_by` / `updated_by` and the audit row
44+
still name the operator.
45+
46+
Honoured on both execution surfaces — the in-process `handler` and the
47+
sandboxed `body`.
48+
49+
Authoring notes:
50+
51+
- `sudo`, `elevate`, `elevated` and `isSystem` are refused with a prescription
52+
naming `runAs`, and `run_as` is answered as a rename.
53+
- `@objectstack/lint`'s gating `hook-api-update-readonly-field` rule now skips a
54+
hook that declares `runAs: 'system'` — the static `readonly` strip skips a
55+
system context, so the write it exists to catch does not happen — and its
56+
hints name the knob. The `readonlyWhen` warning is unchanged: a system context
57+
does not waive a conditional lock.

content/docs/automation/hook-bodies.mdx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,19 +259,39 @@ There is an asymmetry here that costs data if you learn it the hard way, so lear
259259
| How the body writes it | What happens |
260260
|:---|:---|
261261
| `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. |
262-
| `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. |
263-
| `ctx.api.sudo().object('x').update({ <field> })` | **`TypeError` — not available here.** `sudo()` is a member of the *in-process* `ScopedContext`; the VM's `ctx.api` carries `object()` and `transaction()` and nothing else, so a **body** cannot reach it. Worse than unavailable: the same source *works* when the handler runs in-process, so it passes a native `hook.handler(ctx)` test and throws only once the build lowers it into a body — aborting the triggering write under the default `onError: 'abort'`. `objectstack build` now refuses to lower such a handler and keeps it bundled instead. A hook has **no** declared elevation knob (no hook-side `runAs`); [#14010](https://github.com/objectstack-ai/objectstack/issues/14010) is where that gap is argued. |
262+
| `ctx.api.object('x').update({ <field> })` | **Silently dropped** — unless the hook declares `runAs: 'system'`. `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, and the call still returns success. Declaring [`runAs: 'system'`](/docs/automation/hooks#elevation--runas) gives that `ctx.api` a system context, which the strip skips, so the write lands. |
263+
| `ctx.api.sudo().object('x').update({ <field> })` | **`TypeError` — not available here.** `sudo()` is a member of the *in-process* `ScopedContext`; the VM's `ctx.api` carries `object()` and `transaction()` and nothing else, so a **body** cannot reach it. Worse than unavailable: the same source *works* when the handler runs in-process, so it passes a native `hook.handler(ctx)` test and throws only once the build lowers it into a body — aborting the triggering write under the default `onError: 'abort'`. `objectstack build` now refuses to lower such a handler and keeps it bundled instead. The knob to reach for is [`runAs: 'system'`](/docs/automation/hooks#elevation--runas) on the hook itself, which is declarative and works on **both** surfaces. |
264264
| `ctx.api.object('x').insert({ <field> })` | **Lands.** INSERT is exempt — a create may legitimately seed read-only columns. |
265265

266266
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**:
267267

268268
- `hook-api-update-readonly-field`**error**. A body's literal `ctx.api.object('…').update()` / `.updateById()` writes a field the named object declares `readonly: true`.
269-
- `hook-api-update-readonly-when-field` — **warning**. The same write against a `readonlyWhen` field, which strips per record *state*. The own-hook stamp **is** the workaround here, exactly as it is for static `readonly`: since [#9107](https://github.com/objectstack-ai/objectstack/issues/9107) the conditional strip judges the *caller's* entry payload, so a value a `beforeUpdate` hook **derives** is not caller-supplied and lands even on a locked record. (Deriving is the operative word — a hook that merely echoes the caller's own value back has written nothing the strip can tell from the caller's, and it still goes.) What does **not** help is elevation: `sudo()` a body cannot reach (see the row above), and — unlike the static strip — the conditional lock is **not** waived by a system context either, so there is no elevated caller for which a caller-supplied value survives. On this shape, confirm the write only targets records whose predicate is `false`, or derive the field in a `beforeUpdate` hook on the target object.
269+
- `hook-api-update-readonly-when-field`**warning**. The same write against a `readonlyWhen` field, which strips per record *state*. The own-hook stamp **is** the workaround here, exactly as it is for static `readonly`: since [#9107](https://github.com/objectstack-ai/objectstack/issues/9107) the conditional strip judges the *caller's* entry payload, so a value a `beforeUpdate` hook **derives** is not caller-supplied and lands even on a locked record. (Deriving is the operative word — a hook that merely echoes the caller's own value back has written nothing the strip can tell from the caller's, and it still goes.) What does **not** help is elevation: unlike the static strip, the conditional lock is **not** waived by a system context, so neither `runAs: 'system'` nor the `sudo()` a body cannot reach makes a caller-supplied value survive. On this shape, confirm the write only targets records whose predicate is `false`, or derive the field in a `beforeUpdate` hook on the target object.
270270

271271
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).
272272

273273
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.
274274

275+
### Elevating a body — `runAs`
276+
277+
The knob that makes "guarded **and** maintained" expressible is a hook-level declaration, not a body API: `runAs: 'system'` gives the hook's `ctx.api` a system context, which is what lets a column nobody may hand-write still be maintained by the automation that owns it. It is honoured identically for an in-process `handler` and a sandboxed `body` — which is the whole reason it exists rather than `ctx.api.sudo()`, a member only the in-process surface has.
278+
279+
```ts
280+
{
281+
name: 'stamp_account_grade',
282+
object: 'rating',
283+
events: ['afterInsert'],
284+
runAs: 'system', // this hook maintains a column nobody may hand-write
285+
body: {
286+
language: 'js',
287+
source: `await ctx.api.object('account').update({ id: ctx.input.account_id, current_grade: ctx.input.grade });`,
288+
capabilities: ['api.read', 'api.write'],
289+
},
290+
}
291+
```
292+
293+
It scopes `ctx.api` and nothing else — the `condition` gate, `ctx.session`, the strip applied to the hook's own `ctx.input`, and `async` behaviour all still read the triggering operation's context. Full semantics, including the `'user'` value and what it refuses, are on [Hooks → Elevation](/docs/automation/hooks#elevation--runas).
294+
275295
### Errors from `ctx.api`
276296

277297
A rejected `ctx.api` call gives your body the host error's `name` and `message`, plus two structured properties when the host supplied them:

content/docs/automation/hooks.mdx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,39 @@ Because `record` now means the record's *state*, `record.done == true` alone is
141141
on **every** update of an already-done row. If you wrote a condition under the old
142142
payload semantics expecting "the write that changed it", add the `previous` half.
143143

144+
## Elevation — `runAs`
145+
146+
A hook's `ctx.api` runs with the context of the write that **fired** it. That is usually what you want, and it is the one thing that makes a column both *guarded* and *maintained* impossible to express: author `editable: false` (or `readonly: true`) so nobody hand-writes the column, and the hook that computes it is refused by the very same check. The guard and the legitimate writer are the same door.
147+
148+
`runAs` is the declaration that separates them. It is the same key a [flow](/docs/automation/flows) declares, and `'system'` and `'user'` mean there exactly what they mean here, plus one value only a hook can have — a flow establishes its identity from nothing, so it has no context to inherit:
149+
150+
| `runAs` | The hook's `ctx.api` runs as |
151+
|:---|:---|
152+
| `'inherit'` *(default)* | the context of the triggering write — the behaviour every hook had before this key existed. |
153+
| `'system'` | elevated: a full-access, RLS-bypassing system principal. Row and field permissions do not apply, and the static `readonly` strip is skipped. |
154+
| `'user'` | the triggering **user**, so the hook can never exceed that user's grants. A hook fired by a write that carried no user has no identity to scope to, so its `ctx.api` data operations are **refused** (`HOOK_UNSCOPED_DATA_ACCESS`) rather than run unscoped. |
155+
156+
```ts
157+
{
158+
name: 'stamp_account_grade',
159+
object: 'rating',
160+
events: ['afterInsert'],
161+
runAs: 'system', // this hook maintains a column nobody may hand-write
162+
body: {
163+
language: 'js',
164+
source: `await ctx.api.object('account').update({ id: ctx.input.account_id, current_grade: ctx.input.grade });`,
165+
capabilities: ['api.read', 'api.write'],
166+
},
167+
}
168+
```
169+
170+
Four things worth knowing before you reach for it:
171+
172+
- **It scopes `ctx.api`, and nothing else.** The `condition` gate, `ctx.session`, the `readonly` strip applied to the hook's own `ctx.input` payload, and `async` behaviour all still read the triggering operation's context. Declaring `runAs: 'system'` does **not** elevate the write that fired the hook.
173+
- **Elevation is authorization, not anonymity.** The triggering user rides along, so an elevated write still stamps `created_by` / `updated_by` with the operator and still appears under their name in the audit log. You do not trade the audit trail for the write.
174+
- **Both surfaces honour it** — an in-process `handler` and a sandboxed `body` alike. This is the reason `runAs` exists rather than `ctx.api.sudo()`: `sudo()` is real only in-process, so the same source passed a unit test and threw in production.
175+
- **`'user'` is a narrowing, and it fails closed.** It cannot resolve to a grant: a hook that declares it and finds no trigger user refuses its data operations instead of running them with no principal at all.
176+
144177
## Before Hook
145178

146179
Mutate the incoming record before it is saved. The engine exposes the pending

0 commit comments

Comments
 (0)