Skip to content

Commit 1286eea

Browse files
fix(examples): showcase record write actions write as the caller instead of through an elevated script body (#25656)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu7ZZBnHrYcv5pDjzwJhi4K3)_ Fixes #25468 ## Summary Three showcase record actions — **Mark Done** (`showcase_mark_done`), **Reopen** (`showcase_reopen_task`) and **Submit for Sign-off** (`showcase_submit_signoff`) — set fields on their own record and nothing else, but they did it from a script body whose `ctx.api` runs with the system identity (`buildActionExecutionContext` stamps `isSystem: true`). So the write did not carry the caller's object, row or field permissions. Each one now **declares** the write (`operation: 'update'` + `patch`, spec #14092, server #15079), and the platform action route performs it **as the caller**. Object CRUD, RLS, FLS, hooks and validation all apply, exactly as for a `PATCH` of the same row. A caller who may edit the record uses the button as before; a caller who may not gets the same `403` the data plane gives, and nothing is written. **Ruled by Sales: B** (ruling record 6384686314 on #25468, Sales verbatim 「25468 B」). This departs from the triage grading 6369139790, which prescribed `requiredPermissions` (ADR-0066 D4); the departure is Sales's ruling. Under that route the three actions would have been usable by nobody on a fresh boot, admins included: admins hold no package capability, neither gate has an admin bypass, and no provisioned showcase user holds `showcase_contributor`. The declared write itself is the maintainer's 2026-09-01 ruling on #14092 (5494341350, 「同意」), with #14010's 5494343943: the write runs as the caller, never system-elevated. ## What changed - `examples/app-showcase/src/ui/actions/index.ts` - `showcase_mark_done`: `patch: { done: true, progress: 100 }`, `execution: 'perRecord'`; body removed. - `showcase_reopen_task`: `patch: { done: false }`, `execution: 'perRecord'`; body removed. The guard hook `showcase_guard_task_reopen` still refuses the reopening write with its sentence — now it refuses the caller's own update. - `showcase_submit_signoff`: `patch: { status: 'sent' }`; body removed. - Labels, icons, `successMessage`, `visible`, `locations` and `refreshAfter` are unchanged. `type` stays `'script'` (the schema default for a declared write). - The docblocks say which rule the showcase models: an action that only sets fields on its record declares the write; a script body that writes gates itself with `requiredPermissions` (ADR-0066 D4). - `examples/app-showcase/src/data/hooks/index.ts`, `src/ui/views/task.view.ts`: comments only. - `examples/app-showcase/test/actions.test.ts`: the executability sweep accepts a declared write; the #2169 handler regression now iterates the actions that still have a body; a new pin asserts that **no action body writes without a capability gate**, and that the declared writes are exactly these three. - `examples/app-showcase/test/task-reopen-hook-refusal.test.ts`: half 2 now pins the declared write (`operation: 'update'`, `patch: { done: false }`, no body). - `packages/qa/dogfood/test/showcase-record-write-actions.dogfood.test.ts` (new): over real HTTP on the stock showcase, each refusal is paired with the data plane's own answer for the same caller and row, and with an allow for a caller who may edit. ## Platform checklist (a `domain:devx` touch, declared) Two items named these actions' script bodies and are respelled, with `revision` bumped and a `history` row each: - `api-backend.packaged-action-disabled-dispatch` (rev 7): Mark Done is described as a declared write. While measuring it, the same boot answered the **enabled** control on a nonexistent record id `404 RECORD_NOT_FOUND`, which falsified the item's "the enabled control CANNOT 404". Every door already consumes the caller-scope load's verdict (`refuseDeniedSubjectLoad`), the script door included, so the text was stale before this change too. `knownGaps[1]` and `acceptance[2]` are respelled; `acceptance[2]` still scores from the disabled traces alone. - `records-forms.script-action-hook-refusal-toast` (rev 3): Reopen is described as a declared write. The two source rows for the nested action-VM hop leave, replaced by the route's catch and the new dogfood pin. The observable answer is unchanged: measured, the admin's Reopen still answers `400 VALIDATION_ERROR` carrying `TASK_REOPEN_REFUSAL`. The console dispatches an `operation: 'update'` action through the same registered script handler (objectui `ActionRunner.executeUpdateOperation` at the `.objectui-sha` pin `6fb31dc`), so the toast chain the item observes is the same. `pnpm check:platform-checklist` is green on `b829a362`. ## Tests All on the merged head `b829a362` (this branch plus `origin/main` `49653f91`), unless noted: - `pnpm --filter @objectstack/example-showcase test`: **39 files, 484 tests passed**; `tsc --noEmit` exit 0. - `packages/qa/dogfood/test/showcase-record-write-actions.dogfood.test.ts`: **7/7 passed** (run under `scripts/pm/os-verify-lock.sh`); dogfood `tsc --noEmit` exit 0. - **Ablation** (run on the pre-merge head and again on `b829a362`): with `examples/app-showcase/src/ui/actions/index.ts` restored from `origin/main` (bodies back), exactly the **3 refusal pins go red** (the action route no longer answers as the data plane does) and the 4 allow pins stay green. The file was then restored with `git checkout HEAD -- path`, and `git status --porcelain` was empty. - `eslint --no-inline-config` on the six changed source/test files: exit 0. ## Gates On the pre-merge head `3027e0a3` (the merge brings no change to this diff's files), `node scripts/pm/dispatch-gates.mjs --commands` derived 63 commands for this file surface; all 63 ran with exit 0, and `--ran` reconciles **63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN**. Two first answered exit 3 (a prerequisite, not a verdict) and were rerun after it was met: `check-plugin-teardown-shape.mjs --self-test` (its pinned fixture commit was missing from the shallow clone; fetched) and `check:dual-build-cjs-loads` (seven unbuilt dists; built). Dogfood battery: n/a (no `!` in the title, no narrowing arm). ## Declarations Clause-②: no Metadata-protocol: none - No changeset: `@objectstack/example-showcase` is `private: true` and publishes nothing (`skip-changeset`). - Branch `claude/project-thread-z0wf71` is the harness-pinned branch for this session and names no issue; the claim comment 6372266664 on #25468 is the identity record (services seat 1, `session_01SGzB5Np4fydLKpGjZm1dNr`). - Not `packages/**` code, not objectui. A hint that hides the button from a caller who may not edit is a console follow-up the triage explicitly kept out; today such a caller still sees the button and gets a `403` toast when they click it. ## Acceptance notes - `showcase_portfolio_snapshot` keeps its body. It reads (`capabilities: ['api.read']`), is `ai.exposed`, and declares no `requiredPermissions`, so its counts of accounts, projects and invoices are computed with the system identity for any signed-in caller, including one who cannot read invoices. It writes nothing, and it was outside this card's grading (write actions only). Noted for the seat to judge; carrier: none. - The record-load staleness in `api-backend.packaged-action-disabled-dispatch` described above predates this change; it is fixed here because this PR edits that item. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SGzB5Np4fydLKpGjZm1dNr --- _Generated by [Claude Code](https://claude.ai/code/session_01SGzB5Np4fydLKpGjZm1dNr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f123f75 commit 1286eea

8 files changed

Lines changed: 378 additions & 147 deletions

File tree

‎docs/qa/platform-checklist/areas/api-backend.json‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1607,7 +1607,7 @@
16071607
"title": "A disabled packaged action is refused 409 ACTION_DISABLED at BOTH dispatch doors — after the capability gate (a 403 is never an oracle for the switch), before the param contract and the record load, durable across restart",
16081608
"since": "v17",
16091609
"status": "active",
1610-
"revision": 6,
1610+
"revision": 7,
16111611
"priority": "P0",
16121612
"surface": "api",
16131613
"personas": [
@@ -1617,14 +1617,14 @@
16171617
"fixtures": {
16181618
"app": "showcase",
16191619
"requires": [
1620-
"showcase_mark_done on showcase_task (ui/actions/index.ts — script action, registered body, NO declared params, no requiredPermissions): the clean 409 probe",
1620+
"showcase_mark_done on showcase_task (ui/actions/index.ts — a declared record write: operation 'update' with patch { done: true, progress: 100 } and no body, which the action route performs as the caller; NO declared params, no requiredPermissions): the clean 409 probe",
16211621
"showcase_action_param_gallery on showcase_field_zoo (ui/actions/index.ts — script action with a required param p_text): the param-ordering discriminator — the ONLY stock action whose param contract can produce a contrasting 400, because enforceActionParams (action-execution.ts) returns null outright for an action with no declared params",
16221622
"showcase_zoo_perm_missing on showcase_field_zoo (predicate-matrix.action.ts — requiredPermissions: ['showcase.restricted_ops'], granted to nobody): the 403-oracle probe",
16231623
"showcase_portfolio_snapshot (ui/actions/index.ts — object-less/global, and the ONLY stock action with ai.exposed: true): the live MCP-door probe; its activation flip spells the object segment `global` (/actions/_activation/global/showcase_portfolio_snapshot)"
16241624
],
16251625
"knownGaps": [
16261626
"The MCP door cannot be driven live with showcase_mark_done: the bridge's AI-exposure gate (actionAiExposureError, fail-closed on ai.exposed !== true) sits AHEAD of the activation consult, so a non-exposed action answers the exposure refusal regardless of its switch. showcase_portfolio_snapshot is the only stock ai.exposed action. The MCP transport IS drivable live on a stock boot: the boot banner prints `MCP: http://localhost:<port>/api/v1/mcp`, and QA run #21056 drove POST /api/v1/mcp tools/call run_action against showcase_portfolio_snapshot (disabled → isError carrying code ACTION_DISABLED, status 409; re-enabled → ok). Score the MCP clause live. Where the endpoint is not mounted, record acceptance[4] blocked(environment), with the unit pin cited beside it: action-activation-dispatch.test.ts 'door 2' calls invokeBusinessAction in-process over a stubbed engine and metadata service, with no MCP transport and no HTTP, so it is evidence of that layer only, never the clause's pass (RUNNER rule 6, a pin scores only the layer it drives).",
1627-
"The record-load half of the ordering proof has NO contrasting refusal: the REST door's record load is best-effort (try/catch in domains/actions.ts — a missing record passes an empty record for new-record actions), so a nonexistent recordId never 404s on its own, enabled or disabled. The record leg is proven by code order plus the absence of any read/side effect, never by expecting a 404 from the enabled control."
1627+
"The record-load half of the ordering proof is scored from the DISABLED traces: code order plus the absence of any read/side effect. An enabled control on the same nonexistent recordId answers 404 RECORD_NOT_FOUND, because every door refuses a subject the caller-scope load did not deliver (refuseDeniedSubjectLoad, packages/runtime/src/action-execution.ts — the script/body door, the flow door and the declarative branch Mark Done now takes all call it; measured on a dogfood boot of #25468: enabled 404, disabled 409, same id). That 404 corroborates the order but is not the leg's evidence. The load itself is still caught (loadActionSubjectRecord), so a new-record or object-less action, which attempts no load, never 404s."
16281628
]
16291629
},
16301630
"steps": [
@@ -1639,7 +1639,7 @@
16391639
],
16401640
"acceptance": [
16411641
{
1642-
"clause": "the REST door refuses a disabled packaged action 409 ACTION_DISABLED and dispatches NOTHING: status 409, code ACTION_DISABLED (registered in the ADR-0112 ledger, error-code-ledger.zod.ts), message = the engine's own describeDisabledAction sentence naming the ledger — and no side effect lands (the mark_done body's done/progress write never ran)",
1642+
"clause": "the REST door refuses a disabled packaged action 409 ACTION_DISABLED and dispatches NOTHING: status 409, code ACTION_DISABLED (registered in the ADR-0112 ledger, error-code-ledger.zod.ts), message = the engine's own describeDisabledAction sentence naming the ledger — and no side effect lands (mark_done's declared done/progress write never ran)",
16431643
"oracle": "api",
16441644
"verify": "step-3 trace: 409 + code ACTION_DISABLED + the disabled-action sentence; follow-up reads show no task mutated",
16451645
"evidence": "the trace + the control re-reads"
@@ -1651,9 +1651,9 @@
16511651
"evidence": "both traces side by side"
16521652
},
16531653
{
1654-
"clause": "the consult also precedes the record load, proven by absence not by a contrasting refusal: the 409 arrives with a nonexistent recordId in play and no record read occurs — the enabled control CANNOT 404 on the bad id because the load is best-effort by design (domains/actions.ts catches and passes an empty record), so do not score this leg by expecting one",
1654+
"clause": "the consult also precedes the record load, proven by absence: the 409 arrives with a nonexistent recordId in play and no record read occurs. The enabled control on the same id answers 404 RECORD_NOT_FOUND (the caller-scope load's refusal, fixtures.knownGaps[1]), which corroborates the order but does not score this leg",
16551655
"oracle": "api",
1656-
"verify": "the step-3/4 disabled traces are 409 with the bogus recordId present; the run record notes the enabled-control caveat rather than a fabricated 404 expectation",
1656+
"verify": "the step-3/4 disabled traces are 409 with the bogus recordId present; an enabled-control 404, when captured, is recorded beside them as corroboration, never as the verdict",
16571657
"evidence": "traces + run-record note"
16581658
},
16591659
{
@@ -1748,6 +1748,12 @@
17481748
"date": "2026-10-20",
17491749
"change": "steps[0] respelled: 'boot showcase isolated on ' → 'boot the showcase on its own port and DB, on '. Round 12's out-of-scope finding (landing record 6287983829 on wave anchor #24579): the phrase 'showcase isolated' named the dogfood §0 own-port boot, and a runner read it as the `isolated` tenancy posture (OS_TENANCY_POSTURE=isolated). The new spelling names that boot by what it is, in RUNNER.md's own words for it (the trap table's counter: own port/DB, skill §0), and names no posture. Read at base e4d604efd3. No clause changed",
17501750
"ref": "#25003"
1751+
},
1752+
{
1753+
"revision": 7,
1754+
"date": "2026-10-23",
1755+
"change": "fixtures.requires[0] and acceptance[0] respelled for #25468: showcase_mark_done is no longer a script action with a registered body; it declares its write (operation 'update', patch { done: true, progress: 100 }), which the action route performs as the caller. Its type stays 'script', the schema default, and it still declares no params and no requiredPermissions, so it is still the clean 409 probe: the activation consult runs ahead of the declarative branch in packages/runtime/src/domains/actions.ts (read at base aa2dbab7b; measured on a dogfood boot of the change: disabled with a nonexistent recordId answers 409 ACTION_DISABLED). The same boot answered the ENABLED control on that id 404 RECORD_NOT_FOUND, which falsified fixtures.knownGaps[1] and acceptance[2]'s 'the enabled control CANNOT 404': every door now consumes the caller-scope load's verdict through refuseDeniedSubjectLoad (action-execution.ts), the script/body door included, so the text was already stale for a script body and stays false for the declared write. Both are respelled; acceptance[2] still scores from the disabled traces alone, and the enabled 404 is corroboration only. acceptance[2]'s clause text changed, its verdict basis did not",
1756+
"ref": "#25468"
17511757
}
17521758
]
17531759
},

‎docs/qa/platform-checklist/areas/records-forms.json‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5053,7 +5053,7 @@
50535053
"title": "A script action whose server-side hook refuses the write with a user-facing sentence answers a 4xx, the console shows exactly one error toast carrying that sentence, and the record is left unchanged",
50545054
"since": "v17.5",
50555055
"status": "active",
5056-
"revision": 2,
5056+
"revision": 3,
50575057
"priority": "P2",
50585058
"surface": "browser",
50595059
"personas": [
@@ -5062,7 +5062,7 @@
50625062
"fixtures": {
50635063
"app": "showcase",
50645064
"requires": [
5065-
"the refusal pair on showcase_task: the hook showcase_guard_task_reopen (examples/app-showcase/src/data/hooks/index.ts — beforeUpdate, condition previous.done == true && record.done != true, sandboxed body throws new Error(TASK_REOPEN_REFUSAL), onError:'abort') and the script action showcase_reopen_task (examples/app-showcase/src/ui/actions/index.ts — body writes { id, done: false } through ctx.api, execution perRecord, visible 'has(record.done) && record.done == true', locations list_item only — the Task Detail page is kind:'full' and renders no record_header bar)",
5065+
"the refusal pair on showcase_task: the hook showcase_guard_task_reopen (examples/app-showcase/src/data/hooks/index.ts — beforeUpdate, condition previous.done == true && record.done != true, sandboxed body throws new Error(TASK_REOPEN_REFUSAL), onError:'abort') and the action showcase_reopen_task (examples/app-showcase/src/ui/actions/index.ts — a declared record write: operation 'update' with patch { done: false } and no body, which the action route performs as the caller, execution perRecord, visible 'has(record.done) && record.done == true', locations list_item only — the Task Detail page is kind:'full' and renders no record_header bar)",
50665066
"the refusal sentence, verbatim: 'A finished task cannot be reopened. Create a follow-up task instead.' (TASK_REOPEN_REFUSAL in the hooks file — re-read it there before the run, never from this line)",
50675067
"a seeded FINISHED task (done: true) — 'Audit current IA' or 'App wireframes' (examples/app-showcase/src/data/seed/index.ts) — which is the only kind of record the action is offered on",
50685068
"a console built at this repo's .objectui-sha (pnpm objectui:build), served by the same boot the run drives",
@@ -5123,10 +5123,10 @@
51235123
],
51245124
"source": [
51255125
"examples/app-showcase/src/data/hooks/index.ts#GuardTaskReopenHook (the refusing beforeUpdate hook; the sentence is TASK_REOPEN_REFUSAL in the same file)",
5126-
"examples/app-showcase/src/ui/actions/index.ts#ReopenTaskAction (the script action whose ctx.api write the hook refuses)",
5126+
"examples/app-showcase/src/ui/actions/index.ts#ReopenTaskAction (the action whose declared write, performed by the action route as the caller, the hook refuses)",
51275127
"examples/app-showcase/test/task-reopen-hook-refusal.test.ts (pins the fixture: the hook refuses with the sentence and leaves the row unchanged on the real engine, and the action writes the refused shape)",
5128-
"packages/runtime/src/sandbox/quickjs-runner.ts#sandboxRefusalMessage (a nested sandboxed hook refusal keeps its business message across the action VM hop, so the action route answers 4xx — #17265 / PR #17679)",
5129-
"packages/runtime/src/sandbox/nested-hook-refusal-is-a-rejection.test.ts (the wire half: 400 VALIDATION_ERROR with the sentence, unit-level)",
5128+
"packages/runtime/src/domains/actions.ts (the declarative-update branch runs inside the route's one catch, which serves a data-plane refusal with its own status and code: the hook's refusal arrives as 400 VALIDATION_ERROR carrying the sentence, the answer /data gives the same write since #11588)",
5129+
"packages/qa/dogfood/test/showcase-record-write-actions.dogfood.test.ts (the wire half over real HTTP on the stock showcase: a caller who may edit the task gets a 4xx carrying the sentence and the task stays finished; a caller who may not edit it is refused 403 before the hook is reached)",
51305130
"objectui packages/app-shell/src/utils/__tests__/consoleServerAction.errorToast.test.ts (objectui#9252 — the console failure chain characterized end to end with the transport stubbed)",
51315131
"objectui packages/app-shell/src/chrome/ConsoleToaster.tsx (sonner Toaster: richColors, duration 4000, toast nodes carry data-sonner-toast and data-type)",
51325132
"objectui#9151 (the zero-toast symptom this item re-observes on a current build); #21596 (this item)"
@@ -5143,6 +5143,12 @@
51435143
"date": "2026-10-20",
51445144
"change": "steps[0] respelled: 'boot the showcase isolated (RUNNER canonical boot line: own port, own file DB, --seed-admin)' → 'boot the showcase with RUNNER's canonical checklist boot (own port, own file DB, --seed-admin)'. Round 12's out-of-scope finding (landing record 6287983829 on wave anchor #24579): the phrase 'showcase isolated' named the dogfood §0 own-port boot, and a runner read it as the `isolated` tenancy posture (OS_TENANCY_POSTURE=isolated). The new spelling names that boot by what it is, in RUNNER.md's own words for it (the trap table's counter: own port/DB, skill §0), and names no posture. Read at base e4d604efd3. No clause changed",
51455145
"ref": "#25003"
5146+
},
5147+
{
5148+
"revision": 3,
5149+
"date": "2026-10-23",
5150+
"change": "fixtures.requires[0] and source respelled for #25468: showcase_reopen_task no longer writes from a script body through ctx.api (an elevated write); it declares its write (operation 'update', patch { done: false }), which the action route performs as the caller, so the hook now refuses the caller's own update and the nested action-VM hop is gone from this path. Its type stays 'script', the schema default. The answer is unchanged: measured on a dogfood boot of the change, the seeded admin's Reopen on a finished task answers 400 VALIDATION_ERROR whose error.message is TASK_REOPEN_REFUSAL, and the console dispatches an operation 'update' action through the same registered script handler (objectui ActionRunner.executeUpdateOperation at the .objectui-sha pin 6fb31dc), so the toast chain knownGaps[0] cites is the one this item observes. The two source rows for the nested action-VM hop (quickjs-runner.ts#sandboxRefusalMessage and the unit pin nested-hook-refusal-is-a-rejection.test.ts) no longer describe this path and leave source; the route's catch in domains/actions.ts and the dogfood pin showcase-record-write-actions.dogfood.test.ts replace them. No clause changed",
5151+
"ref": "#25468"
51465152
}
51475153
]
51485154
},

‎examples/app-showcase/src/data/hooks/index.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,13 @@ export const TASK_REOPEN_REFUSAL =
133133
*
134134
* `throw new Error('<sentence>')` from a sandboxed body is the business-refusal
135135
* shape: the write is aborted (`onError: 'abort'`) and the sentence travels to
136-
* the caller as a 4xx — on `/data`, and through a script action's `ctx.api`
137-
* write on `/actions` too (`packages/runtime/src/sandbox/
138-
* nested-hook-refusal-is-a-rejection.test.ts`). `showcase_reopen_task`
139-
* (`src/ui/actions/index.ts`) is the script action that reaches it, and the
140-
* platform checklist item `records-forms.script-action-hook-refusal-toast`
141-
* drives that action in the console.
136+
* the caller as a 4xx — on `/data`, and on `/actions` too, whether the write is
137+
* an action's declared `operation: 'update'` or a script action's `ctx.api`
138+
* write (`packages/runtime/src/sandbox/nested-hook-refusal-is-a-rejection.test.ts`).
139+
* `showcase_reopen_task` (`src/ui/actions/index.ts`) is the action whose
140+
* declared write reaches it, and the platform checklist item
141+
* `records-forms.script-action-hook-refusal-toast` drives that action in the
142+
* console.
142143
*
143144
* The condition is the two-root transition form (same lesson as
144145
* {@link AuditTaskCompletionHook}): only the write that flips `done` from true

0 commit comments

Comments
 (0)