feat(orchestrations): give durable runs an identity and fail soat calls loudly - #879
Merged
Merged
Conversation
…ls loudly A `soat` tool node reaches the REST API over a loopback HTTP call, which carries an `Authorization` header only when the run is driven inline (`wait: true`). Every durable path — a queued start, a scheduler wake, a redrive after a crash, an `awaiting_input` resume — drove with no header at all, so the self-call came back 401. And because `executeSoatTool` returned the response body whatever its status, that 401 became the node's artifact and the run continued as though the action had happened. A task-dispatched run is always durable, so an orchestration could never move its own task. Two changes, in that order: 1. A non-2xx self-call now throws `HttpToolError`, mapped at the call boundary to `TOOL_HTTP_ERROR` exactly as an `http` tool's target rejecting a call is. The upstream status survives in `meta.tool_status_code`, which `isRetriableError` already reads to keep a terminal 4xx from being retried. `HttpToolError` moves to its own module so both throw sites can import it without a cycle. 2. A run persists the principal that started it (`principal_kind` / `principal_id`), and each background drive re-mints a short-lived run-as token from it, mirroring `triggerToken.ts`. The token asserts identity only — authorization is still evaluated per call against current policies, so revoking access reaches a run already in flight. Keeping a run from out-reaching the credential that started it drove the rest of the design: - An API-key-started run carries a `key` claim resolved into that key's policies as a boundary; a revoked key stops the run acting rather than falling back to its owner's access. - Trigger- and OAuth-started runs record no principal at all. Their boundary lives in the token, not the principal, so re-minting would drop it. They keep executing inline with the original token. - Run tokens are marked with `orn`. Without a marker the middleware cannot tell one from an OAuth token and builds a consent boundary from an absent `scope` claim, which allows nothing. Task automation threads the identity along the chain: the creator or the principal that fired a transition, and for an automation-fired hop the principal of the run that routed it there — so a chain of states keeps acting as whoever set it going instead of decaying to no principal at the second state. Tests drive the real loopback (binding the worker's port, as `mcp.test.ts` does): a background run's `soat` node returns real data, a read-only key cannot escalate through a run it started, and a workflow-dispatched orchestration transitions its own task end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UTVN32qiBQxeS5o4PM8M4D
Deploy Outputs
|
Member
Author
|
CI is green. Noting the one red job in the run history so it isn't mistaken for a real signal: Tutorials Tests failed on the first attempt — Before re-running I checked it wasn't mine rather than assuming:
That leaves it as the residual nondeterminism in this step that Generated by Claude Code |
This was referenced Aug 8, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A
soattool node reaches the REST API over a loopback HTTP call, which carries anAuthorizationheader only when the run is driven inline (wait: true). Every durable path — a queued start, a scheduler wake, a redrive after a crash, anawaiting_inputresume — drove with no header at all, so the self-call came back 401.Worse,
executeSoatToolreturned the response body whatever its status, so that 401 became the node's artifact and the run succeeded, as though the action had happened. A task-dispatched run is always durable (tasksDispatch.ts, deliberate per #855), so an orchestration dispatched by a workflow state could never move its own task — silently.What changed
1. A non-2xx self-call fails the tool call. It throws
HttpToolError, mapped at the call boundary toTOOL_HTTP_ERROR, exactly as anhttptool's target rejecting a call already is. The upstream status survives inmeta.tool_status_code— whichisRetriableErroralready reads, so a terminal 4xx isn't retried.HttpToolErrormoves into its own module so both throw sites can import it without an import cycle.2. A run carries an identity. It persists the principal that started it (
principal_kind/principal_id— internal columns, not exposed in the API), and each background drive re-mints a short-lived run-as token from it, mirroring the existingtriggerToken.ts. The token asserts identity only: authorization is still evaluated per call against current policies, so revoking access reaches a run already in flight — which a token minted once at run start and stored would not.Keeping a run from out-reaching the credential that started it drove the rest:
keyclaim, resolved into that key's policies as a boundary. A revoked key stops the run acting rather than falling back to its owner's broader access.ornclaim. Without a marker the middleware can't tell one from an OAuth access token and builds a consent boundary from an absentscopeclaim — a policy that allows nothing. (Found by a test, not by reading.)3. The identity survives a chain of states. Task automation threads the principal through: the task's creator, the principal that fired a transition, and for an automation-fired hop the principal of the run that routed it there. Without that last step a chain works once and then decays to no principal at the second state.
Testing
Tests drive the real loopback, binding the worker's own port the way
mcp.test.tsdoes — without a listener the status under test never happens.502 TOOL_HTTP_ERRORwithtool_status_code: 404instead of returning the error bodysoatnode returns real, project-scoped datalib/orchestrationRunToken.test.tscovers the branches no entry point reaches: a deleted user, a revoked key, and trigger/OAuth/plain tokens that must not be mistaken for run tokensFull server suite: 4916/4917 passing. The one failure (
files.test.ts,upload_urlreturning an absolute URL) reproduces on a cleanmainin this environment and is unrelated.pnpm typecheck,pnpm eslint --fix,pnpm docs-lint, and the@soat/postgresdbsuite (includingschemaDrift) all pass.Not run: smoke tests and tutorials — both need Docker, which isn't available in this environment. No smoke steps were added: this fixes existing behavior rather than adding a user-facing flow, and I'd rather not add assertions I couldn't execute.
Notes for review
principal_kind: user), while still bounded by the key's policies. SettingapiKeyPublicIdon a JWT-derived auth context would name the key precisely but risks confusing code that assumes the scoped-key identity fields travel together.orchestrations.md; a run-depth or transition budget would be the fix if it ever bites.task_transitionnode — the docs now recommend atoolnode bound to asoattool forcreate-task/transition-task, and spell out that the edge should stay fire-and-forget (a graph waiting on a task inverts the two lifetimes).Open questions, self-resolved
🤖 Generated with Claude Code
https://claude.ai/code/session_01UTVN32qiBQxeS5o4PM8M4D
Generated by Claude Code