Skip to content

fix(automation,approvals): gate the generic run-resume route on the suspended node (#3801) - #3822

Merged
os-zhuang merged 2 commits into
mainfrom
claude/run-resume-auth-gate-w67lvk
Jul 28, 2026
Merged

fix(automation,approvals): gate the generic run-resume route on the suspended node (#3801)#3822
os-zhuang merged 2 commits into
mainfrom
claude/run-resume-auth-gate-w67lvk

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #3801.

The hole

POST /api/v1/automation/:name/runs/:runId/resume forwards a caller-supplied { inputs, output, branchLabel } straight into AutomationEngine.resume, and resumeInternal validated machine state only — the concurrent-resume latch, the run exists, the flow exists, the suspended node still exists. Nothing asked who.

Approval nodes suspend and resume through exactly that mechanism, so a resume carrying branchLabel: 'approve' walked the approve edge with no approver check, no sys_approval_action row and no status mirror — the sys_approval_request row and the run then disagreed permanently. The only thing standing between the route and the approvals rules was convention, spelled out in a showcase comment; a comment in an example is not an access control.

Why not "remove the route"

It is load-bearing for screen flows — the UI flow-runner posts { inputs } there to advance a paused screen node. So the gate discriminates by what the run is parked on, not by the route.

The gate

  • ActionDescriptor.resumeAuthority ('any' | 'service', default 'any') — a pausing node declares who may continue it. approval declares 'service'; screen / wait keep the default and behave exactly as before.
  • The suspension carries its own node type. SuspendedRun.nodeType / sys_automation_run.node_type, captured at suspend time rather than re-derived from the live flow, so a flow republished mid-pause cannot re-type the node out from under the gate. Rows written before this fall back to the flow definition. A deprecated ADR-0018 alias resolves through aliasOf to its canonical type, so renaming is not an escape hatch either.
  • The marker is a symbol. RESUME_AUTHORITY_SERVICE (@objectstack/spec/contracts) is stamped on the ResumeSignal by the owning service. The transport builds its signal out of a JSON body and no JSON body can produce a symbol-keyed property, so the route cannot forge it — the gate never has to trust a caller's claim about itself. ApprovalService stamps it in one place (serviceResume), on the tail of a decision it has already authorized and recorded, so a future outcome path cannot quietly ship a resume the gate then rejects at runtime.
  • The gate follows the subflow chain. A parent parked on a subflow node delegates the signal to its suspended child, so the gate resolves the effective suspension first and judges the node the signal actually lands on. Resuming the parent is not a way around it.
  • Refusal is an authorization answer. resume returns { success: false, code: 'forbidden' }; the route answers 403 rather than a 200 carrying success: false (which reads as "your resume ran and the flow failed"). Nothing is consumed — the request stays pending and the run stays parked, so the real decision still lands.

Engine-internal continuations (subflow delegation and up-bubble, map re-entry, the wait-timer wake) go through the private resumeInternal and are not re-gated — they continue work an already-authorized call started.

Consumer impact

  • FROM: client.automation.resume(flow, runId, { branchLabel: 'approve' }) to finish an approval → TO: client.approvals.approve(requestId, …) / .reject / .recall. The old call now answers 403 and changes nothing.
  • Registering your own pausing node whose continuation belongs to a service? Declare resumeAuthority: 'service' on its descriptor and stamp RESUME_AUTHORITY_SERVICE from that service.

Changeset carries the same FROM → TO mapping.

Known adjacent gap (deliberately out of scope)

ADR-0044's revise window parks the run on an ordinary author-placed wait node (signal flavor), which a type-keyed gate cannot distinguish from any other wait — a raw resume there still forces a resubmit without a sys_approval_action row. Recorded in the ADR-0019 addendum; closing it needs a per-suspension owner claim rather than a node-type one, so it is filed separately rather than widening this PR.

Tests

New packages/services/service-automation/src/resume-authority-gate.test.ts (11): refusal leaves the run parked and downstream unrun; the marker lets the owner through; ungated (screen/wait) and descriptor-less pauses unchanged; the tag is recorded; the gate survives a restart via the durable store and falls back for pre-tag rows; a deprecated alias of a gated type is still gated; machine-state errors (unknown run) are still machine-state errors; subflow — resuming the parent over a gated child is refused with neither run consumed, while an ungated child still delegates.

Plus: end-to-end in plugin-approvals (real engine + real ApprovalService) proving a raw resume leaves the request pending with only the submit action recorded and the later real decision still landing; the 403 mapping (and the non-403 for an ordinary failed resume) in http-dispatcher.test.ts; resumeAuthority default/parse in the spec.

Verified: spec (6739), service-automation (381), plugin-approvals (279), runtime (663) all green; eslint clean; check:docs / check:api-surface / check:spec-changes / check:skill-refs / check:skill-docs / check:upgrade-guide / check:skill-examples / downstream-contract typecheck all in sync (reference docs + api-surface snapshot regenerated, not hand-edited).


Generated by Claude Code

…uspended node (#3801)

`POST /api/v1/automation/:name/runs/:runId/resume` forwarded a caller-supplied
`{ inputs, output, branchLabel }` straight into `AutomationEngine.resume`, and
`resumeInternal` validated machine state only — the concurrent-resume latch, the
run exists, the flow exists, the suspended node still exists. Nothing asked who.

Approval nodes suspend and resume through exactly that mechanism, so a resume
carrying `branchLabel: 'approve'` walked the approve edge with no approver
check, no `sys_approval_action` row and no status mirror — leaving the request
row and the run permanently disagreeing. The only thing between the route and
the approvals rules was convention, spelled out in a showcase comment.

Removing the route is not the fix: it is load-bearing for screen flows. So the
gate keys on what the run is parked on:

- `ActionDescriptor.resumeAuthority` ('any' | 'service', default 'any') — a
  pausing node declares who may continue it; `approval` declares 'service'.
- A suspension records the node type that produced it (`SuspendedRun.nodeType`
  / `sys_automation_run.node_type`), captured at suspend time so a flow
  republished mid-pause cannot re-type the node out from under the gate; older
  rows fall back to the flow definition. A deprecated ADR-0018 alias resolves
  to its canonical type, so renaming is not an escape hatch.
- The engine refuses a 'service' suspension unless the signal carries
  `RESUME_AUTHORITY_SERVICE` — a symbol, so a JSON body can never mint it.
  `ApprovalService` stamps it in one place, on the tail of a decision it has
  already authorized and recorded.
- The gate follows a subflow pause down to the child the signal would actually
  reach, so resuming the parent is no way around it.
- Refusal returns `{ success: false, code: 'forbidden' }` and the route answers
  403. Nothing is consumed: the request stays pending and the run stays parked,
  so the real decision still lands.

Engine-internal continuations (subflow delegation/up-bubble, `map` re-entry,
wait-timer wake) go through the private `resumeInternal` and are not re-gated.
`screen` and `wait` pauses are unchanged.

Known adjacent gap, deliberately out of scope: ADR-0044's revise window parks
the run on an ordinary author-placed `wait` node, which a type-keyed gate
cannot tell from any other wait. Recorded in the ADR addendum and filed
separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gvN32u1EiuvY9uQEMJiMR
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 28, 2026 7:36am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/client, @objectstack/plugin-approvals, @objectstack/runtime, packages/services, @objectstack/spec.

116 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via packages/client, @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/client, packages/runtime, @objectstack/spec)
  • content/docs/api/data-flow.mdx (via @objectstack/client)
  • content/docs/api/environment-routing.mdx (via @objectstack/client, @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/client, @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/approvals.mdx (via @objectstack/plugin-approvals, packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime, packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via packages/services, @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/runtime, packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/client, @objectstack/runtime, @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/client)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/client, packages/services, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/client, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/client, @objectstack/plugin-approvals, @objectstack/runtime, packages/services, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/services, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/client)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/client, @objectstack/plugin-approvals, @objectstack/runtime, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/client, @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…t advisory (#3801)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gvN32u1EiuvY9uQEMJiMR
@os-zhuang
os-zhuang marked this pull request as ready for review July 28, 2026 08:20
@os-zhuang
os-zhuang merged commit 57a3bb3 into main Jul 28, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/run-resume-auth-gate-w67lvk branch July 28, 2026 08:21
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

Development

Successfully merging this pull request may close these issues.

automation: the generic run-resume route needs an authorization gate keyed on the suspended node

2 participants