Skip to content

Commit 8a12067

Browse files
os-litantclaude
andauthored
feat(runtime): execute the declarative row-level operation: 'update' action — one data-plane update of the current record, as the caller (#15448)
* wip(runtime): declarative operation:'update' executor — implementation Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * feat(runtime): execute the declarative row-level operation:'update' action The platform action route (and the MCP run_action bridge, through the same shared executor) performs one data-plane update of the current record under the CALLER's own identity — never the isSystem-elevated script-body context. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * test(runtime): pin the declarative update double through assertEngineUpdateDispatch; re-anchor the system-context census page Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * chore(runtime): record the new pinned engine double in the contract ledger Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bc54b26 commit 8a12067

6 files changed

Lines changed: 1079 additions & 31 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): the platform action route executes the declarative row-level `operation: 'update'` action (#14092)
6+
7+
The spec half (#15077) made `operation: 'update'` + `patch` parse; nothing performed the
8+
write, so an authored update action reached the action route with no handler and collected
9+
the registry's loud not-registered answer. It now performs the write.
10+
11+
`POST /api/v1/actions/<object>/<action>/<recordId>` — and the MCP `run_action` bridge, through
12+
the same shared executor — performs exactly ONE data-plane update of the current record:
13+
14+
- **As the caller.** The write carries the caller's own `ExecutionContext`, never the
15+
`isSystem`-elevated context a `type: 'script'` BODY runs under. There is no author body here
16+
to trust, so the data plane's own gate is the only gate — the object's permissions, its hooks
17+
and its validations fire exactly as for a user edit, and their refusals reach the caller with
18+
their own `code` and `status`. This consumes the `runAs: 'user'` direction ruled on #14010; no
19+
`runAs` key is added.
20+
- **A caller who cannot read the row is refused before anything is written** (404
21+
`RECORD_NOT_FOUND`, the platform's one existence-non-disclosing envelope), by consuming the
22+
caller-scope load's verdict rather than re-deriving it from the stamped `record.id` — the
23+
#14143 class: a swallowed load must never become an implicit grant.
24+
- **The write is `{ ...patch, ...collectedParams }`** — static values under the dialog's, so a
25+
param of the same name wins. Nothing else from the action is merged, and the ADR-0104 D2 param
26+
contract still bounds what the wire can add.
27+
- **No current record ⇒ a located refusal**, never a silent no-op: no `recordId` on the route or
28+
in the body, an action addressed at the object-less key, or an empty write bag each answer 400
29+
naming the action and the fix.
30+
- **`undoable: true`** returns `undo: { type, objectName, recordId, undoData, redoData }` — the
31+
prior values of exactly the fields written, `null` for a field the row did not carry, so the
32+
existing Undo readers can restore. The three remaining `UndoableOperation` keys (`id`,
33+
`timestamp`, `description`) stay the client's.
34+
- `visible` is deliberately unread here: it is a per-record renderer predicate, and the
35+
authorization is the point above.
36+
37+
`operation` is read BEFORE `type` at every reader, so the HTTP door and the MCP bridge agree:
38+
`isHeadlessInvokableAction` now accepts a declarative update (it has neither `target` nor `body`
39+
by construction), `headlessActionTypeError` hands it no client-side-type prescription, and
40+
`summarizeAction` reports `operation` and `requiresRecord: true`.
41+
42+
Unchanged: a handler-less `type: 'script'` action WITHOUT `operation` still gets today's
43+
not-registered 404 — the script path is not widened.

content/docs/permissions/system-context.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ not on any flag.
6565

6666
`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
6767
cannot set it (`packages/rest/src/rest-server.ts:1548`, `:1577`), and neither
68-
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
68+
can an action body (`packages/runtime/src/domains/actions.ts:414`). It is
6969
written by internal callers only, as an option on the engine call:
7070

7171
```ts
@@ -160,7 +160,7 @@ The largest single consumer — **17 of the 106 sites**.
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161161
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4967`, `:6381`, `:6629`, `:7060`, `:7253` |
162162
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
163-
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
163+
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
165165
| 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` |
166166
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:241`, `:274` |
@@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
199199
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1580` (#3493 / #6640) |
200200
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280``281` |
201201
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` |
202-
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1548`, `:1577`; `domains/actions.ts:404` |
202+
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1548`, `:1577`; `domains/actions.ts:414` |
203203

204204
---
205205

0 commit comments

Comments
 (0)