docs(engine): Record the resume-token decision (no-changelog) (#39089)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Danny Martini 2026-10-12 13:17:09 +00:00 committed by GitHub
parent c9cb3ee3ab
commit 26d93b601a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -0,0 +1,226 @@
# A derived token authorizes a resume request
Date: 2026-09-04
Status: Active
Decision Owner: Catalysts
Source: CAT-2928
## Context
A caller outside n8n ends a wait that accepts a resume request. The caller can be a webhook client,
a person who submits a form, or a person who approves a message. That caller uses a URL. The URL
travels through channels that the engine does not control. It stays available for the length of the
wait, which can be several months.
This record uses these terms:
- **Resume request:** a request that ends a wait. It carries a token.
- **Execution-bound resume URL:** a resume URL that names the execution and no step.
`$execution.resumeUrl`, `$execution.resumeFormUrl`, and `$resumeWebhookUrl` hold it. Any node can
read it, also before the step that waits exists. Its caller chooses the query and the body.
- **Step-bound resume URL:** a resume URL that names one step. The waiting node builds it with
`getSignedResumeUrl` while its step runs. The node fixes its query, for example `approved=true`.
- **Approval callback:** a step-bound resume request that Slack or Telegram sends to one fixed URL
when a person presses a button. The reference that names the step is in the request body.
- **Execution-bound wait:** a wait that accepts an execution-bound resume URL, for example the Wait
node in webhook or form mode.
- **Step-bound wait:** a wait that accepts a step-bound resume URL or an approval callback, for
example a send-and-wait node.
ADR-20260902-steps-declare-waits sends every resume request to a data-plane endpoint that checks it
against the waiting step. That endpoint accepts every request, so the request must authorize itself.
A resume request also runs the `webhook` method of the node that waits. That method needs the HTTP
request and the HTTP response, and only the control plane holds them.
The engine has two kinds of shared-secret token. Neither kind fits. `IDENTITY_TOKEN` goes from the
control plane to the data plane. `ACTION_TOKEN` goes from the data plane to the control plane. Their
issuer and audience values are opposite. This prevents the replay of one token at the endpoints of
the other. Both tokens live for 60 seconds. That length is correct for a call between two services.
It is too short for a URL in an email.
In engine v1, `$execution.resumeUrl` and `$execution.resumeFormUrl` carry a random `resumeToken`
from the data of the execution. Engine v1 compares it with a timing-safe equality check. The check
is optional: an execution without a stored token accepts any caller.
In engine v1, `getSignedResumeUrl` builds a URL whose path holds the execution id and the node id,
and whose query holds the parameters that the node sets. Engine v1 signs the path and the query
together with an HMAC secret, so a caller cannot change a parameter. Engine v1 derives that secret
from the encryption key of the instance, so the control plane can build a URL. The node id in the
path also selects the webhook of the waiting node. Therefore the URL from `$execution.resumeUrl`
cannot end the wait of a send-and-wait node.
The data plane can run as its own process. That process has no access to the database or the
encryption key of the control plane. Node code runs in the data plane, so every node builds its
resume URLs there. The control plane verifies one resume request in engine v1: the approval callback
of Slack and Telegram. It carries a signed reference in the request body to a fixed URL, and
Telegram limits that reference to 64 bytes.
## Decision
A **separate kind of capability token** authorizes a resume request. The engine derives the token
from a secret that only the data plane holds, and does not store the token. The token is the only
control between an unknown caller and a paused workflow. Therefore every resume request carries a
token, and a request without one is rejected. Engine v1 skips its check for executions from before
it stored tokens. Engine v2 has no such executions.
The bar this decision must meet is parity with engine v1: a resume request is as hard to forge here
as it is there. The decision meets that bar. It goes past v1 in two places. Only the runtime of the
data plane can build a token. A step-bound resume URL and an approval callback end one wait only.
1. **The token has its own spec.** That spec holds its own issuer and audience. Therefore a resume
token is not valid at the other endpoints of either plane, and the tokens of those endpoints are
not valid at the resume endpoint.
2. **The engine derives the token and does not persist it.** The engine calculates the token from
its claims and the resume secret. It does this each time it needs a token. This needs no column
and no migration. Any data-plane code that holds the claims can build the token. The
send-and-wait nodes need this when they compose the message that they send.
3. **The claims name what the builder knows.** An execution-bound resume URL names the execution.
Whichever node reads `$execution.resumeUrl` or `$execution.resumeFormUrl` evaluates it, usually
before the step that waits exists. The data plane therefore picks the waiting step of the
execution when the request arrives, as engine v1 does. A step-bound resume URL and an approval
callback name the step. The waiting node builds them while its step runs, so they end that wait
and no other.
4. **The token states its kind.** A token is an execution-bound resume URL, a step-bound resume URL,
or an approval callback, and the data plane applies the rule of that kind. A request resumes a
step only if the wait of the step is bound the same way as its token. An execution-bound wait
accepts an execution-bound resume URL. A step-bound wait accepts a step-bound resume URL and an
approval callback.
5. **The token binds what the node fixed.** A step-bound resume URL binds its path and its query, so
a caller cannot change, add, or remove a parameter. An approval callback binds its decision. An
execution-bound resume URL binds only the execution. Its caller chooses the query and the body,
because a webhook caller sends its data that way.
6. **The token does not expire.** The status of the step is the control. The token shows which
caller can make the request. The compare-and-set that every other transition uses decides if the
request still applies. `resumeStep` moves a step out of `waiting`, or it does nothing. A token
for a wait that is already resolved, timed out, or cancelled has no effect.
7. **The data plane owns the resume secret.** The resume secret is not the shared secret of the two
planes. Only the runtime of the data plane reads it, also where the control plane hosts that
runtime in its own process. The data plane mints every resume token and verifies every resume
request. No node code runs for a resume request before the data plane accepts its token. The
control plane passes the token to the data plane and does not read it.
8. **The control plane sends each resume request to the engine that runs the execution.** It picks
engine v1 or engine v2, and it does not read the token to do so.
This diagram shows how a resume request moves through the decisions above.
```mermaid
sequenceDiagram
participant Caller
participant CP as Control plane
participant DP as Data plane
Note over DP: A node builds a resume URL,<br/>and the data plane mints its token.
DP-->>Caller: Resume URL, sent by the node (for example in an email)
Note over DP: The step waits.
Caller->>CP: Resume request with the token
Note over CP: An approval callback comes to a fixed URL.<br/>The control plane reads only its id.
CP->>CP: Pick the engine by the shape of the id
CP->>DP: Pass on the token, with the identity token of the control plane
DP->>DP: Verify the token, its kind and binding.<br/>Check the tenant. Find the waiting step.
alt The data plane rejects the request
DP-->>CP: Rejected
CP-->>Caller: Error response
else The data plane accepts the request
DP-->>CP: The waiting step
CP->>CP: Run the webhook method of the node
CP->>DP: Resume the step with the output of the node
DP->>DP: Move the step out of waiting (compare-and-set)
DP-->>CP: Resumed
CP-->>Caller: Response
end
```
## Alternatives Considered
- **Add a scope to `ActionScope`.** We rejected it because almost none of the spec of the action
token would remain. The audience of that token is the control plane, but the data plane verifies a
resume request, and its 60-second lifetime would need an override. The shared enum would also tell
a reader that each token is still valid at one endpoint only, which would be false. This option
would reuse an existing token.
- **Mint the token at suspension and store it on the step row.** We rejected it because it would
need a column, and the shim would read the row again to build a message URL. No requirement asks
for the revocation path that it would give. This option could revoke one wait without a change to
the secret.
- **Set the expiry to the deadline of the wait.** We rejected it because a wait that only a resume
request ends has no deadline, so this option would need a second rule for that case. Two rules for
one question increase the risk of a gap in the check. This option would give the shortest window
for a wait that has a deadline.
- **Use one long lifetime, for example ninety days.** We rejected it because it would set a maximum
wait length that no other part of the engine sets. It would turn a secret-management problem into
a product limit. This option would limit the damage from a URL that leaks, and it would not depend
on the declaration.
- **Copy engine v1: store a random token per wait and compare it.** We rejected it for two reasons.
It would need the column that the derived token avoids. The optional check of v1 is also a failure
mode to avoid: with no stored token, v1 makes no check. This option is known, and it could revoke
one wait.
- **Name the execution in every token.** We rejected it because an approval from an earlier
iteration of a loop would end a later wait. This option would need one claim shape only.
- **Let the URL decide the kind, as engine v1 does.** We rejected it because the rule would then
depend on the path and on the configuration of the workflow, not on what the token proves. A
change to either would change the check. This option would need no kind in the token.
- **Bind the query of every resume URL.** We rejected it because a webhook caller sends its data in
the query, and such a request would fail. This option would need one binding rule only.
- **Derive the resume secret from the encryption key, as engine v1 does.** We rejected it for two
reasons. A separate data-plane process does not hold the encryption key of the control plane. The
control plane could also build a URL. This option would need no new configuration where both
planes run in one process.
- **Sign the token with the shared secret of the two planes.** We rejected it because the control
plane holds that secret, so the control plane could build a URL. This option would need no new
secret.
## Consequences
- A step-bound resume URL binds its query as the node built it, without the token parameter. The
data plane compares the same string when the request arrives. It does not rebuild the query from
parsed values, because two different queries can parse to the same values.
- The Slack and Telegram approval callback reaches a fixed URL, with its signed reference in the
request body. Engine v1 verifies that reference in the control plane. Here the data plane verifies
it. The control plane reads only the id in the reference, to pick the engine, and forwards the
body.
- The approval callback must fit in 64 bytes, the limit of Telegram. Therefore it carries its claims
in a compact signed form instead of the format of the resume token. Nothing is stored.
- The control plane picks the engine by the shape of the id in the request, as its other execution
routes do. A v1 id is numeric, and a v2 id is a UUID. Therefore an execution-bound resume URL
carries the execution id in its path, and a step-bound resume URL and an approval callback carry
the step id. An id of the wrong shape reaches an engine that rejects the token.
- The control plane cannot tell an execution-bound resume URL from a step-bound one. Both carry a
UUID in the same place, and only the token says which kind it is. Therefore the data plane offers
a single endpoint for all kinds of resume request. The endpoint uses the id in the request, and
the data plane reads the kind from the token.
- Every resume request also carries the identity token of the control plane that forwards it, like
every other call from the control plane to the data plane. The data plane resumes an execution
only if the execution belongs to the tenant of that identity token. The trust layer between the
planes specifies that check.
- The engine cannot revoke one resume URL. A URL stops working when the step leaves the `waiting`
status, or when the engine no longer accepts the secret that signed it.
- The token does not expire. Therefore a secret that signed a token must stay accepted while its
step waits, or that resume URL stops working.
- The token authenticates the request. It does not authorize the workflow. Who can resume a given
wait is a separate decision, if that rule becomes narrower than "the caller that holds the URL".
- An execution-bound resume URL covers every wait of the execution, not one wait. A request resumes
the wait that the execution is in when the request arrives. In a loop, the same URL resumes the
wait of every iteration, also a URL that a node sent in an earlier iteration. Engine v1 does the
same, so the bar is unchanged. An execution with two waits at the same time is ambiguous for an
execution-bound resume URL, and the resolve path answers 409. Engine v1 has the same single-wait
limit. A `webhookSuffix` is the natural way to tell two apart if that limit ever lifts.
- The verification of the token shows which execution or step the caller means. It does not show
that a step still waits. Therefore the resolve path reads the step row in all cases. The token
does not remove a database read. It decides if the request can continue.
- Two resume requests for the same step can both run the webhook of the node. The compare-and-set
lets only the first one resume the step. The other one gets a conflict (409).
- The token primitive must allow a token without an expiry. Therefore the lifetime is optional in
the token spec.
- A resume request can still arrive before the engine records the suspension
(ADR-20260902-steps-declare-waits). At this endpoint, that window appears as a valid token for a
step that does not yet hold the `waiting` status.
## Links
RFC: -
Documentation: Engine v2 — Detailed Design, §3.3 https://app.notion.com/p/n8n/34b5b6e0c94f81feba4bdb59a65d55dc; Engine v2: Trust Layer: CP ↔ DP — Design Overview https://app.notion.com/p/n8n/3725b6e0c94f8077a562cb2e2c821e9c
Related ADRs: ADR-20260902-steps-declare-waits