Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): gate the paused-run screen read to the run's trigger identity (#7968) - #8226
Conversation
…entity (#7968) `GET /automation/:name/runs/:runId/screen` served the paused run's ScreenSpec to any authenticated caller who knew a run id. A screen node's `defaults` and per-field `defaultValue` are interpolated against the live flow variables at suspend time, so a flow prefilling from its triggering record persists those values into the spec this route hands back — measured on a real screen flow over a `crm_lead` record: company in the title, email in the description, and email, phone and salary band as field defaults, answered 200 to a stranger explicitly refused the `sys_automation_run` read grant. Maintainer ruling 2026-08-12 (Option B): the route now requires the run's own trigger identity (`ExecutionLogEntry.trigger.userId`) OR read access to `sys_automation_run` as an operator override. The object grant ALONE is deliberately not the gate — it would refuse the screen to the very person the flow paused for, which is why #7900 audited this route out of its convergence. So the grant is the override half, and the over-block direction is pinned as hard as the under-block one. The `sys_automation_run` question is now one predicate (`mayReadRunState`) shared by both gates rather than a second copy of the resolution / feature-detection / fail-closed logic. Unchanged: the 404 for a run with no pending screen (the gate runs after the lookup, so every not-found path is byte-identical for every caller), the 501, the 401 anonymous floor, `resume`'s own authority checks, and which runs exist. Option A — the per-run `resumeAuthority` read gate — stays the recorded direction and is out of scope here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
… not on getRun being unused (#7968) `should get the pending screen via GET /:name/runs/:runId/screen` claimed that the screen path is not swallowed by the `/:name/runs/:runId` branch below it, and asserted it as "getRun was never called". The #7968 gate reads the run to resolve its trigger identity, so that proxy no longer tracks the claim. Asserted on the answer instead: the caller gets the screen envelope (`{ runId, screen }`) and not the `ExecutionLogEntry` the run-detail branch serves verbatim — the mock's entry is `{ id, status }`, so the two are distinguishable by shape. The routing claim is now pinned more directly than before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
hotlong
commented
Aug 12, 2026
PM review — |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7968
Implements the binding maintainer ruling of 2026-08-12 (comment 5266069853, Option B):
GET /automation/:name/runs/:runId/screenis gated to the run's own trigger identity (ExecutionLogEntry.trigger.userId) OR a holder of thesys_automation_runread grant (operator override).The disclosure, reproduced before it was gated
A real screen flow was run through the real engine (
registerScreenNodes, ascreennode whose field defaults interpolate{record.email}/{record.phone}/{record.salary_band}, triggered on acrm_leadrecord). The persistedScreenSpecthatgetSuspendedScreenreturns:{ "nodeId": "collect", "title": "Confirm Acme Health", "description": "Contact for ceo@acme-health.example", "fields": [ { "name": "email", "defaultValue": "ceo@acme-health.example" }, { "name": "phone", "defaultValue": "+1-555-0100" }, { "name": "salary", "defaultValue": "L7 / 285000 USD" } ] }Fed through the real dispatcher, a caller with valid auth, no relationship to the run, and a security service explicitly refusing them the
sys_automation_runread grant received it under200 { success: true }— the whole spec, every value. After the gate, the identical request answers:{ "success": false, "error": { "code": "PERMISSION_DENIED", "message": "Reading a paused run's screen requires being the identity that triggered the run, or read access to 'sys_automation_run'.", "httpStatus": 403 } }That observed payload is the fixture in
automation-screen-read-gate.test.ts, so the pins run against the measured disclosure rather than a plausible stand-in.Why the obvious gate is the wrong one
Requiring the
sys_automation_rungrant alone — the #7900 mechanism the sibling run-state reads converged on — would refuse the screen to the very person the flow paused for. That is why #7900's audit left this route out of its convergence, and it is why the ruling needed a second mechanism rather than an application of the first. The grant is therefore the override half, never the whole question, and the over-block direction is pinned as hard as the under-block one.mayReadRunStateis now one shared predicate asking the grant question for both gates, so the two doors cannot drift into two policies that merely agree today.Acceptance, and how each case is asserted
code === 'PERMISSION_DENIED'andstatus === 403(full ADR-0112 envelope, neverstatus >= 400— this route's refusal and its not-found answer are one status apart), plus each of the four record-derived strings asserted absent from the body, plusdata.screenundefinedfieldslength 3, every record-derived value present — not merely a 200sys_automation_runread ⇒ screenexplainverified to askreadonsys_automation_runwith the caller's own context and no targetuserIdAlso pinned: a run whose trigger carries no
userIdrefuses rather than matching on absence (two undefineds); agetRunthrow is unresolved identity, not a match; the end user is still served while the permission subsystem is down (only the override half fails closed) while the same outage still refuses the stranger.Not-found behaviour is untouched, deliberately
The gate runs after
getSuspendedScreen, so a run with no pending screen still answers404 No pending screen for runfor every caller, and the permission question is not even asked. Gating first would mean failing closed on an unresolvable run and converting today's 404 into a 403 for everyone, honest typos included. The consequence is stated on the gate rather than hidden: a stranger can still tell a paused run id (403) from an unknown one (404) — an existence oracle over run ids, strictly narrower than the record values it replaces, and closing it means answering 404 to the refused caller, which is a different design and not what was ruled. Both rows are pinned so a future change to that distinction is deliberate.Reverse verification
Gate reverted (
automation.tsback toorigin/main, tests unchanged) — predicted before running, matched exactly: 9 red, 12 green. Reds are the denial pins and the "gate was consulted" pins, e.g.The 12 that stayed green are the ones that pin behaviour the gate deliberately leaves unchanged, and they should: the three admit cases (an ungated route serves everyone, so an over-block pin cannot see a missing gate — only a wrong one), every 404/501 row, the four non-denials, the 401 anonymous floor, and the resume-scope case.
Probe — the rejected mechanism, twice. Gating on the
sys_automation_rungrant alone: every denial pin stays green while the over-block guards red withexpected 403 to be 200on "serves it whole to the identity the flow paused for, with NO grant at all". Gating on an object-read grant over the flow's own record object (trigger.object) reds the same guards plusexpected 'crm_lead' to be 'sys_automation_run'. Both wrong gates are caught only by the over-block direction — which is exactly what makes this ruling non-obvious. Restored after each probe;git diff HEADempty.Scope
⛔ Option A — a per-run authority read gate derived from the suspension's own
resumeAuthority/ assignee state (#3801 / #5561) — stays the recorded coherent end state and ADR-0019-class design work. Not built here. B does not preclude it: both refuse the same stranger and admit the same end user.Unchanged: which runs exist;
resume's own authority checks; the/runslist route (#8054); the POST/PUT registration paths (#8055 / #8123).One pre-existing assertion moved rather than broke:
http-dispatcher.test.ts's routing claim ("screen must not be swallowed by the getRun route below it") asserted itself as "getRun was never called". The gate reads the run to resolve its trigger identity, so that proxy stopped tracking the claim; it is now asserted on the answer — the caller gets the{ runId, screen }envelope and not theExecutionLogEntryshape — which pins the routing claim more directly than before. The #7900 audit table'sscreenrow is retired with a note pointing at its new file, as that table's own rule requires.Verification
pnpm --filter @objectstack/runtime test— 148 files, 2293 tests, all pass (21 new)pnpm --filter @objectstack/runtime typecheck— cleanpnpm check:type-check-debt— OK, "none above its recorded number";@objectstack/runtimemeasured exactly at its recorded 227, not raisedpnpm check:route-envelope— green including--self-test, no ratchet changenode scripts/check-nul-bytes.mjs— OK; targeted control-byte self-scan cleanGenerated by Claude Code