Skip to content

fix(runtime): the resume door checks WHO is resuming — the run's starter, or the sys_automation_run read grant (#19987) - #20005

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19987-resume-caller-gate
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19987-resume-caller-gate

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19987

Clause-②: no

What this changes

POST /automation/:name/runs/:runId/resume now checks WHO is resuming. Before, the arm validated the body and called automationService.resume(runId, signal) without reading any identity. So an authenticated caller holding another user's run id (u2 on a run whose getRun().trigger.userId is u1) got 200 and resume ran. The resumed run then continued under the context STORED on the run (engine.ts const context = run.context): its data nodes ran as u1, with values u2 submitted. The screen read on the same pause already refused u2.

The write door now asks the screen read's question (maintainer ruling Option B on #7968, applied to the write by triage's direction 5817076635). Both doors call one predicate, isRunStarterOrRunStateReader (packages/runtime/src/domains/automation.ts):

Everyone else gets 403 PERMISSION_DENIED, the read twin's code and status, and resume() is never called, so the pause stays parked. refuseUnrelatedScreenRead and the new refuseUnrelatedResume are now two thin guard clauses over that one predicate. Each owns only its refusal sentence and WHERE it asks. The read twin's behaviour and message are byte-identical to before.

resumeAuthority today, as the ruling asks (read first, per triage's binding limit)

resumeAuthority is the closed enum 'any' | 'service' (packages/spec/src/automation/node-executor.zod.ts). It names no person. Measured on origin/main 6bf7f1c0:

Node type Declared resumeAuthority Where On this REST door
screen 'any' service-automation/src/builtin/screen-nodes.ts the route is the intended door; "any" names no specific caller ⇒ the new gate applies
wait 'any' service-automation/src/builtin/wait-node.ts same
map 'any' service-automation/src/builtin/map-node.ts same (the engine still walks to a service-gated item)
subflow 'any' service-automation/src/builtin/subflow-node.ts same (the engine still judges the child the signal lands on)
approval 'service' plugin-approvals/src/approval-node.ts refused by the ENGINE for every caller: the service marker is a symbol no JSON body can carry
approval_revise 'service' plugin-approvals/src/approval-revise-node.ts same
any undeclared type treated as 'service' (#5561) engine.ts resolveResumeAuthority same

An approver decides through ApprovalService, which resumes in process (approval-service.ts: this.automation!.resume!(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true })) and never enters handleAutomationRequest. So no caller this door admitted before is a caller the node names, and triage's limit 「不得拒绝节点自身 resumeAuthority 已经明确授权的人」 holds by measurement: the set of node-named callers on this door is empty. The node gate still answers, unchanged and word for word, behind the new gate (pinned: an admitted starter on an approval pause still gets the engine's own 403 message).

The two doors' rules, side by side

REST POST /automation/:name/runs/:runId/resume (this PR) MCP resume_run (mcp.ts resumeActionRun, unchanged)
Run's own starter admitted admitted
sys_automation_run read grant, not the starter admitted (operator override, as on the screen read) refused (starter-only by design, no operator override)
System context admitted n/a
Stranger 403 PERMISSION_DENIED 404 (one not-found for unknown, finished and foreign runs)
Node gate (resumeAuthority) still applies behind this gate still applies
Engine answer classification classifyResumeResult classifyResumeResult (same table)

Order of operations, decided by measurement

The gate runs AFTER the body checks and immediately BEFORE resume().

  • After the body checks. They read nothing about the run, so every 400 VALIDATION_FAILED is byte-identical for every caller. The gate's lookup is spent only on a request the engine would otherwise receive.
  • Before resume(). A refused resume consumes nothing.
  • Fail-closed on a run getRun cannot resolve. There is no 404-first step, and this is where the write departs from the read twin. The read twin answers 404 first because getSuspendedScreen is an authoritative "is there anything to disclose" check. The write has no such probe short of resume(), and resume() consumes the pause. getRun's null is not authoritative. The engine returns null for a run that is parked and resumable when the durable read degrades (engine.ts getRun: "a run that is parked and resumable reports as if it had never run"). It also returns null after the 1000-entry in-memory ring evicts the entry on a deployment with no suspended-run store (DEFAULT_MAX_EXECUTION_LOG_SIZE, the splice in recordLog), and a caller who can start runs can cause that eviction. Letting null through would open the gate in exactly those states.
  • What moves, and for whom. Only for a caller who is neither the run's starter, nor a grant holder, nor a system context: an unknown or finished run now answers them this 403 instead of the engine's 404. As a side effect, a stranger can no longer tell a paused run id from an unknown one here. For every caller the gate admits, every engine answer is unchanged, including the 404 for unknown and finished runs (both pinned). One cost falls on honest callers: a caller WITHOUT the grant who mistypes a run id now gets 403 rather than 404.

Tests

All readings are on HEAD b4cb8a4d, unless a line says otherwise.

  • Failing probe first (the card's named probe, committed as a test). automation-resume-caller-gate.test.ts was run against the unfixed arm (base 6bf7f1c0) and gave Tests 17 failed | 33 passed (50). The stranger u2 on u1's run got 200, with resume called.
  • New suite packages/runtime/src/domains/automation-resume-caller-gate.test.ts: 50 cases. Every refusal row asserts the envelope code AND status, and asserts that resume was NOT called:
    • stranger refused, with the exact message, and nothing about the caller's authorization topology in the refusal (The runtime dispatcher serialises a PermissionDeniedError's details to the client, so positions / permissionSets reach the browser on the /data transport #7450);
    • a run with no trigger.userId refused;
    • a run getRun cannot resolve refused;
    • a getRun throw refused, even for the starter;
    • a service with no getRun: refused unless the grant admits;
    • permission subsystem down: stranger refused, starter admitted;
    • starter admitted with NO grant: the over-block guard; the signal is forwarded field by field and explain is never called;
    • grant holder admitted: explain is asked for read on sys_automation_run with the caller's own context;
    • system context admitted without asking anything;
    • the node gate's own 403 still arrives word for word for an admitted starter;
    • unchanged answers: starter on a finished run gets the engine's 404; grant holder on an unknown id gets the engine's 404; a malformed body is refused VALIDATION_FAILED before the gate for every caller; anonymous gets 401 first; no security service, or a partial one, admits;
    • a parity table (3 callers × 5 security postures × 2 runs = 30 cases) that drives BOTH doors with the same inputs and requires the same verdict, and on refusal the same code, the same status and the same requirement sentence.
  • Rewritten pin. automation-screen-read-gate.test.ts had a case pinning the defect itself ("leaves resume's own authority checks alone": stranger not 403, resume called). It is replaced by two cases: the same stranger is refused at resume (same code and status, resume not called), and the triggering user is admitted with no grant. Comments in three sibling suites that said the resume door had no route-level gate are corrected. Their assertions are unchanged and still green.
  • Mutation leg (node scripts/ablation-replace.mjs, on committed a99d03a4, the gate's code identical at HEAD). It replaced if (unrelated) return unrelated; with an inert statement: anchor 1 → 0, blob 467c4019f123 → e8f9a4915391. With the gate removed, the two gate suites went Tests 15 failed | 57 passed (72): every stranger and unresolved row turned red, and every starter, grant and system row stayed green. The direction is red, as expected. Restore: blob == HEAD (467c4019f123) and git diff HEAD empty. The suite imports the arm by relative source path, so no dist/ is involved.
  • pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 (on a99d03a4; the later commit changes only a ledger note string and a docs table row): Test Files 276 passed (276), Tests 3893 passed | 1 skipped.
  • On HEAD b4cb8a4d, the nine suites that read the route ledger or drive the resume arm (route-ledger.conformance, http-dispatcher, discovery-schema-conformance, auth-forward-fault-sanitization and the five automation gate suites) were re-run: Test Files 9 passed (9), Tests 436 passed (436).
  • --project repo: Test Files 2 passed, Tests 69 passed.
  • pnpm --filter @objectstack/runtime typecheck: exit 0. Output: check:test-typecheck: OK — @objectstack/runtime's test layer compiles, and the new suite is not in the debt ledger.
  • pnpm --filter @objectstack/dogfood exec vitest run test/flow-durable-suspend.dogfood.test.ts against the rebuilt runtime dist/ (it carries isRunStarterOrRunStateReader): Tests 11 passed (11). That includes the real-engine resume after a real cold boot.
  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on HEAD derives 92 commands. All 92 were run with exit codes captured before any pipe, and all exited 0. --ran reconciliation: 92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN ("a DERIVED zero — all 92 recorded an exit code and none of them is 3"). Two runs needed a prerequisite first. check:dual-build-cjs-loads and check:type-check-debt refused with exit 3 (no dist/), and check:skill-examples refused the same way (no client-react .d.ts). A full turbo run build then ran (73 successful, 73 total), and all three were re-run green before the final union.
  • Roster gates whose rosters sit under a touched directory: check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:route-ledger-census, and also check:error-status-conformance. All exit 0.
  • pnpm lint (the full union, eslint . --no-inline-config): exit 0 in 112s.

Two gates were red on the first pass, and both reds were caused by this diff:

  • check:system-context-census: the isSystem read moved from refuseUnrelatedScreenRead into the shared predicate. The row in content/docs/permissions/system-context.mdx is re-anchored and now names the resume door.
  • check:doc-authoring: tracker ids in the route-ledger note string. They are removed from the string.

Docs

content/docs/automation/flows.mdx:

  • "Who may resume" now has the caller question first and the node question second. It states the three admitted callers, the refusal, the ordering and the MCP door's stricter rule.
  • The pause table's wait (signal) row no longer says "any caller".

Deviations, stated rather than chosen silently

  1. Refusal sentence. The code and status are the read twin's. The requirement clause is the read twin's, word for word: both messages are built from one constant, PAUSED_RUN_REQUIREMENT. The verb phrase is this door's own: "Resuming a paused run requires being the identity that triggered the run, or read access to 'sys_automation_run'." A resume refused with "Reading a paused run's screen …" would name an operation the caller did not attempt. That is the misdirection [finding] a refused POST /automation/:name/toggle is told "Authoring automation flows requires …" — the shared deny message names a verb the caller did not use #11666 took off the toggle door. If the literal read sentence is wanted instead, it is a one-line change: RESUME_DENY_MESSAGE = SCREEN_READ_DENY_MESSAGE.
  2. Unknown and finished runs keep today's answers for every caller the gate admits, not for every caller. The reasons are in "Order of operations" above.
  3. Two files beyond the claim's listed file surface. packages/runtime/src/route-ledger.ts: the resume row's note now describes the caller gate. content/docs/permissions/system-context.mdx: check:system-context-census refuses the tree until that row re-anchors.

Acceptance notes (observations, not filed)

  • Consequence of Option B, for the maintainer's eye. A caller that resumes runs it did NOT start now needs sys_automation_run read. Examples: an integration feeding a wait node's signal, or support tooling. The changeset says so. No in-repo caller does this: the REST resume callers on main are the route mount, the client SDK method and the tests.
  • WaitExecutorConfigSchema.webhookUrlPattern defaults to /api/v1/automation/resume/{executionId}/{nodeId}, a path no route serves, and it has zero runtime readers. It is dormant, not filed. Carrier: none.
  • The client SDK's automation.resume docblock lists the resume refusals but not this 403. The list is incomplete, not wrong. Not filed. Carrier: none.

NOT MEASURED locally (CI owns them)

  • Test Core shards, Temporal Conformance, the Dogfood Regression Gate beyond the one suite above, and Dogfood Verify CLI.
  • The workspace, examples and downstream type-check lanes: runtime's own typecheck ran. The public surface is unchanged (no export added or removed), so no importer owes a test.
  • The artifact-roster, wide-population and path-scheduled CI steps that dispatch-gates lists outside its derived total.

Generated by Claude Code

…s resuming

The resume arm validated the body and called resume() without reading any
identity, so an authenticated caller holding another user's run id continued
that user's paused run, which then ran under the starter's stored context.

The arm now asks the screen read's own question through one shared predicate,
isRunStarterOrRunStateReader: the run's trigger identity, OR the
sys_automation_run read grant as the operator override, OR a system context.
Refused callers get the read twin's 403 PERMISSION_DENIED with the same
requirement sentence under the door's own verb, and resume() is never called.
The gate sits after the body checks and immediately before the engine, and
fails closed on a run getRun cannot resolve.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
…used-run predicate

The system-context census row for the automation domain anchored
refuseUnrelatedScreenRead, whose isSystem read now lives in the predicate the
screen read and the resume door share. The route-ledger note drops the
tracker ids check:doc-authoring refuses in runtime strings.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 12 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx (via /:name/runs/:runId (route, a path literal in a comment on a changed line), /:name/runs/:runId/resume (route, a path literal in a comment on a changed line))
  • content/docs/api/client-sdk.mdx (via automation.resume (sdk, the route ledger binds it to POST /automation/:name/runs/:runId/resume, selected by route anchor /:name/runs/:runId/resume))
  • content/docs/automation/approvals.mdx (via /:name/runs/:runId (route, a path literal in a comment on a changed line), /:name/runs/:runId/resume (route, a path literal in a comment on a changed line))
  • content/docs/automation/flows.mdx (via getRun (sdk, the bare tail of client method automation.getRun, bound to GET /automation/:name/runs/:runId), /:name/runs/:runId (route, a path literal in a comment on a changed line), /:name/runs/:runId/resume (route, a path literal in a comment on a changed line))
  • content/docs/permissions/system-context.mdx (via handleAutomationRequest (symbol, a top-level function), isRunStarterOrRunStateReader (symbol, a top-level function))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via automation.resume (sdk, the route ledger binds it to POST /automation/:name/runs/:runId/resume, selected by route anchor /:name/runs/:runId/resume), getRun (sdk, the bare tail of client method automation.getRun, bound to GET /automation/:name/runs/:runId))
  • content/docs/releases/v17/17-1.mdx (via /:name/runs/:runId (route, a path literal in a comment on a changed line), /:name/runs/:runId/resume (route, a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-3.mdx (via /:name/runs/:runId (route, a path literal in a comment on a changed line), /:name/runs/:runId/resume (route, a path literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json cc4ee77587b121eb3a1fc6a42c12080615e9bdc7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c68c0b3907e30bbf69aa60981ae0934fa4679fc8 — the merge of head b4cb8a4d13f3ed63ce7b334df86fa2b816d65215 into base cc4ee77587b121eb3a1fc6a42c12080615e9bdc7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c68c0b3907e30bbf69aa60981ae0934fa4679fc8 && git checkout c68c0b3907e30bbf69aa60981ae0934fa4679fc8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cc4ee77587b121eb3a1fc6a42c12080615e9bdc7 b4cb8a4d13f3ed63ce7b334df86fa2b816d65215 && git checkout -B drift-repro cc4ee77587b121eb3a1fc6a42c12080615e9bdc7 && git merge --no-ff b4cb8a4d13f3ed63ce7b334df86fa2b816d65215

node scripts/docs-audit/affected-docs.mjs --json cc4ee77587b121eb3a1fc6a42c12080615e9bdc7

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs cc4ee77587b121eb3a1fc6a42c12080615e9bdc7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 24, 2026 17:56
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit b81da66 Sep 24, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19987-resume-caller-gate branch September 24, 2026 18:16
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Face review (post-hoc, released)

Served-tier: CONTRACT_REVIEW_TIER
Merge-sha: b81da66df773b5d237cad04d6b05a61e9544d553
Local-runs: none

Scope: the changeset (and named docs) of PR #20005, released; this review cannot block anything and exists to find false released prose. Security card: this record stays at doors, roles, codes and statuses; no request-body, header or field spelling and no reproduction steps.

Sentence verdicts

Changeset .changeset/19987-resume-caller-gate.md at the merge:

  1. "The resume route checked the body and then resumed. It never asked who the caller was. An authenticated user holding another user's run id could continue that user's paused run, and the run then went on under the identity stored on the run ... The screen read on the same pause ... already refused that caller." TRUE. The pre-change resume arm in packages/runtime/src/domains/automation.ts reached the engine straight after the body checks (the diff inserts the caller gate at :~2534-2536 immediately before the engine call at :~2537); the read twin already had refuseUnrelatedScreenRead (:~2716); the stored-context continuation is the card's measured mechanism (security: POST /automation/:name/runs/:runId/resume never checks WHO is resuming — an authenticated stranger with a run id continues another user's paused screen run, which then runs under the starter's stored context #19987 body, the engine's run context read) and is restated in the new docblock :~1225-1233.
  2. "The route now asks the screen read's own question, through the same function: the caller must be the user who started the run, OR hold read access to sys_automation_run (the operator override), OR be a system context. Anyone else gets 403 PERMISSION_DENIED, the screen read's code and status, and the run is not touched: nothing reaches the engine." TRUE. isRunStarterOrRunStateReader (:~1078-1101: system context, then the run's recorded starter, then mayReadRunState) is called by both refuseUnrelatedScreenRead (:~1147) and refuseUnrelatedResume (:~1195-1206, RUN_READ_DENY_STATUS / RUN_READ_DENY_CODE); gate runs before the engine call. Pinned in automation-resume-caller-gate.test.ts.
  3. "The message states the screen read's requirement word for word under this route's own verb." TRUE. PAUSED_RUN_REQUIREMENT shared; SCREEN_READ_DENY_MESSAGE and RESUME_DENY_MESSAGE differ only in the verb phrase (:~192-215).
  4. "The user who started a run resumes it exactly as before, with no grant needed, and still does while the permission subsystem is down." TRUE. The starter half is answered from the automation service before the permission subsystem is consulted; only the override half fails closed on an explain throw (docblock :~1065-1072, inherited from the read twin).
  5. "The suspended node's own gate (resumeAuthority) is unchanged and still answers behind this one. An approval pause is still refused on this route for every caller; approvers decide through the approvals API, which never uses this route." TRUE. Engine gate untouched; resumeAuthority is a closed enum of any / service naming no person (docblock :~1236-1250; the landing record on security: POST /automation/:name/runs/:runId/resume never checks WHO is resuming — an authenticated stranger with a run id continues another user's paused screen run, which then runs under the starter's stored context #19987 measured the enum); route-ledger note packages/runtime/src/route-ledger.ts:438.
  6. "Body checks come first, as before, so every 400 for a malformed body is unchanged for every caller." TRUE. Gate inserted after the last body check (:~2534).
  7. "For the starter, a sys_automation_run reader and a system context, every engine answer is unchanged, including the 404 for an unknown or finished run." TRUE. An admitted caller falls through to the unchanged engine call and classifyResumeResult.
  8. "A caller that resumes runs it did NOT start ... now needs read access to sys_automation_run. Without it, it gets the 403 above. Such a caller also gets that 403, not the old 404, for a run id that does not resolve. The check fails closed there because a run that is still paused can read as 'not found' while the run store is degraded." TRUE. The starter half admits nobody when the run cannot be resolved (:~1092-1096); the docblock states the degraded-store reason (:~1263-1278).

Docs content/docs/automation/flows.mdx at the merge:

  1. Table row for a signal wait: "the external producer calling the resume route — as the user who started the run, or with read access to sys_automation_run (see below); in process, resume(runId) on the automation service." TRUE (:767).
  2. "The route asks two questions, in this order. Both must pass." TRUE (caller gate :~2534, then the engine's node gate).
  3. The three admitted callers (starter, sys_automation_run reader, system context) and "Anyone else gets 403 PERMISSION_DENIED ... and nothing reaches the engine." TRUE (as changeset sentence 2).
  4. "This is the same rule, code and status as reading the run's screen ..., checked by the same function." TRUE (isRunStarterOrRunStateReader shared).
  5. "The check runs after the body checks, so a malformed body is still a 400 for every caller." TRUE.
  6. "If the run id does not resolve, only an admitted caller (a system context or a sys_automation_run reader) gets the engine's 404. Everyone else gets the 403 ..." TRUE, and precisely worded (a starter cannot be identified when the run does not resolve).
  7. "The MCP resume_run tool is stricter: only the user who started the run, with no operator override." TRUE. packages/runtime/src/domains/mcp.ts:890 refuses unless the caller is the run's recorded starter; no mayReadRunState branch there.
  8. "What the run is parked on. ... Every action descriptor carries a resumeAuthority" and the retained 'any' / 'service' bullets. TRUE, unchanged behaviour.

Docs content/docs/permissions/system-context.mdx:179 (row 58) at the merge: "Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume) and the two operator run-lifecycle writes all pass ... The screen read and the resume ask one predicate, so this is one bypass for both", anchors #mayReadRunState, #refuseUngrantedFlowWrite, #isRunStarterOrRunStateReader, #refuseUngrantedRunLifecycleWrite. TRUE. The predicate admits a system context at its first line (:~1084), and the anchors exist; the dev's check-system-context-census run passed on the PR head.

Still true on origin/main?

Yes. automation.ts:216 (RESUME_DENY_MESSAGE), :1165 (isRunStarterOrRunStateReader), :2964 (refuseUnrelatedResume before the engine call), :3155 (screen twin) all present; flows.mdx:782, 825-850 carry the reviewed section; the system-context row is present with identical text but renumbered from 58 to 56 (system-context.mdx:180) by a later table edit that removed rows (the reports retirement, 8d1f7ab), which changes no sentence. packages/runtime/CHANGELOG.md:1664 carries the reviewed text verbatim under b81da66.

Finding

NONE.

Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: CLEAN


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant