[Design Discussion] Cross-Flow Invocation via a Call Node #2639
Replies: 4 comments 4 replies
-
Example Usages
Sample Flow Definitions1. Authentication flow with a "Sign up" affordance that calls into the registration flow and returns to {
"name": "Basic Authentication with Inline Signup",
"handle": "auth-flow-with-signup",
"flowType": "AUTHENTICATION",
"nodes": [
{
"id": "start",
"type": "START",
"onSuccess": "prompt_credentials"
},
{
"id": "prompt_credentials",
"type": "PROMPT",
"meta": {
"components": [
{
"type": "BLOCK",
"id": "block_001",
"components": [
{
"id": "input_001",
"ref": "username",
"type": "TEXT_INPUT",
"required": true
},
{
"id": "input_002",
"ref": "password",
"type": "PASSWORD_INPUT",
"required": true
},
{
"type": "ACTION",
"id": "action_signin",
"variant": "PRIMARY",
"eventType": "SUBMIT"
}
]
},
{
"type": "ACTION",
"id": "action_signup",
"variant": "TEXT",
"eventType": "SUBMIT"
}
]
},
"prompts": [
{
"inputs": [
{ "ref": "input_001", "identifier": "username", "type": "TEXT_INPUT", "required": true },
{ "ref": "input_002", "identifier": "password", "type": "PASSWORD_INPUT", "required": true }
],
"action": {
"ref": "action_signin",
"nextNode": "basic_auth"
}
},
{
"action": {
"ref": "action_signup",
"nextNode": "call_registration"
}
}
]
},
{
"id": "basic_auth",
"type": "TASK_EXECUTION",
"executor": {
"name": "BasicAuthExecutor"
},
"onSuccess": "auth_assert",
"onIncomplete": "prompt_credentials"
},
{
"id": "call_registration",
"type": "CALL",
"flow": {
"ref": "default-registration-flow"
},
"onSuccess": "auth_assert"
},
{
"id": "auth_assert",
"type": "TASK_EXECUTION",
"executor": {
"name": "AuthAssertExecutor"
},
"onSuccess": "end"
},
{
"id": "end",
"type": "END"
}
]
}The registration flow referenced here no longer carries its own 2. Combined parent login flow that delegates to per-audience subflows {
"name": "Combined Login (Customer or Employee)",
"handle": "combined-login",
"flowType": "AUTHENTICATION",
"nodes": [
{
"id": "start",
"type": "START",
"onSuccess": "prompt_audience"
},
{
"id": "prompt_audience",
"type": "PROMPT",
"prompts": [
{
"action": {
"ref": "as_customer",
"nextNode": "call_customer_login"
}
},
{
"action": {
"ref": "as_employee",
"nextNode": "call_employee_login"
}
}
]
},
{
"id": "call_customer_login",
"type": "CALL",
"flow": {
"ref": "customer-login-flow"
},
"onSuccess": "end"
},
{
"id": "call_employee_login",
"type": "CALL",
"flow": {
"ref": "employee-login-flow"
},
"onSuccess": "end"
},
{
"id": "end",
"type": "END"
}
]
}Here both child flows handle their own |
Beta Was this translation helpful? Give feedback.
-
|
What will the be usecase for the requirement of splitting the Context for each flow? What is is the problem If we share the same context? From the technical perspective, If we consider the runtime graph will contain the sub flows, then I don't see a technical reason to keep different context |
Beta Was this translation helpful? Give feedback.
-
|
How is the error data propergation works from the referenced flow to caller node/flow? How this handles the error properly and pass only the necessary message to the user? |
Beta Was this translation helpful? Give feedback.
-
|
Tracking: CALL nodes as a path to securing direct flow initiation Linking some recent work that intersects with the phase-2 sub-flow direction raised here. In #3289 (refined in #3395) we restricted direct initiation of authentication flows on POST /flow/execute: applications with the authorization_code grant type must now start authentication through GET /oauth2/authorize, not via a direct flow-execute call. We deliberately scoped that guard to AUTHENTICATION only, because registration, recovery, and user onboarding flows currently have no other way to be initiated — they must be started directly through the flow API by passing the corresponding flowType. That leaves those three flow types as an open initiation surface on the flow API:
CALL nodes change this. Once a flow can transfer execution into another flow, registration and recovery no longer need to be independently initiable via flow/execute — they can be composed into the authentication journey (e.g. the "Sign up" affordance on the credentials prompt becomes a CALL into the registration flow, and the entry point remains the authentication flow that — for redirect apps — starts via /authorize). This is precisely what the sub-flow concept in the phase-2 question enables: "a flow that cannot be started directly via flow/execute and exists only to be referenced from another flow." So beyond solving same-flow-type composition, the sub-flow concept is also the cleanest lever for shrinking the directly-initiable flow-type surface: flow types that should only ever be reached as part of a larger journey could be marked non-initiable and reachable exclusively through a CALL node from a controlled entry flow. Worth keeping this security angle in mind when deciding between the two phase-2 directions — the dedicated sub-flow concept carries this benefit that "multiple flows of the same type per application" does not. Capturing this here so the direct-initiation hardening for registration/recovery/onboarding is tracked against the CALL-node design rather than pursued as a separate ad-hoc guard. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Related Feature Issue
#2025
Problem Summary
The flow engine today executes a single self-contained graph per
flow/executeinvocation. Real-world identity journeys, however, frequently need to cross flow boundaries — a user on a sign-in page who chooses to register, an SMS OTP login that converts into an SMS OTP signup when the subscriber is unknown, or a recovery flow that should land the user authenticated on completion. We currently somewhat work around this on the frontend (e.g. theRICH_TEXT"Sign up" link on the credentials prompt) which leaves the backend unaware of the transition and prevents shared state, auto-login, and reusable journey composition. We need a first-class way to express "continue execution in another flow and return here" inside a flow definition.High-Level Approach
Introduce a new node type —
CALL— that references another flow and, when reached, transfers execution into that flow. When the called flow completes, control returns to the caller and follows the call node's outgoing edge, which may point to any node in the caller graph (includingendor the caller'sauth_assertnode).Key characteristics:
flowblock with a mandatoryref(the referenced flow id/handle) field.STARTandENDare treated as transparent boundaries — control re-enters the caller after the callee'sEND.onSuccessis required (the node must connect somewhere on success),onFailureis optional (when absent, a failed call terminates the execution, matching how other nodes behave today).onIncompleteis not applicable to CALL nodes.AuthAssertExecutorto support auto-login (the caller can route the call node'sonSuccessinto its ownauth_assert). Embedding it inside the callee is still permitted — that is a flow-design choice. The same observation applies to any executor: when a callee runs an executor that the caller also runs, the second invocation simply sees what the first wrote into shared scope.Architecture Overview
Engine Execution at a Glance
The flow engine, today, walks one graph node-by-node against a single
EngineContext. With CALL nodes, the engine maintains a stack of frames; each frame holds the per-flow execution state (graph, current node, segment, runtime data, etc.), while shared journey-level state (user inputs, authenticated user, application, execution history) lives once on the context and crosses frames freely.The engine executes call node's
Execute()method just like any other node. Aligning with the existing pattern, the call/return mechanics live inside the CALL node implementation. The engine's only added responsibility is response post-processing: when it observes a "switch flow" response or a calleeEND, it manipulates the frame stack accordingly.In simple terms, on each step:
Execute()resolves the referenced flow (subject to the application-scoping check), validates the depth limit, and returns a response that the engine post-processes by pushing the current frame (recording the CALL node as its resume point) and switching the active frame to a new one rooted at the callee'sSTART.ENDnode — the engine recognises terminal status as it does today. If the frame stack is empty, the execution completes. Otherwise it pops the caller frame, looks up the recorded CALL node, and continues along that node'sonSuccessedge (oronFailureif the callee ended in failure and the caller's CALL node defined one; ifonFailureis absent on a failed callee, execution terminates as it would today for any failed node).Execute()before it asks the engine to push a frame.CALL Node Interface
The CALL node has its own typed interface alongside
PromptNodeandTaskExecutionNode. New fields are kept private and exposed via getters/setters:The
flowblock in JSON deserializes into a private struct on the node, accessed viaGetReferencedFlow().onIncompleteis not applicable to CALL nodes and is not part of the interface.Flow Definition Structure
Field naming below (
flow,ref) is illustrative and open for refinement during implementation.{ "id": "call_registration", "type": "CALL", "flow": { "ref": "default-registration-flow" }, "onSuccess": "auth_assert", "onFailure": "prompt_credentials" },Engine Context — Shared and Per-Frame
EngineContextis split into a shared scope and a stack of frames. New/modified fields are kept private and exposed through accessor methods (existing fields can migrate over time; for this change we apply the convention only to fields touched by the call-node work).Why each field sits where it does:
userInputs,authenticatedUser/authUser,assertion,application,executionHistory. These describe the user and the journey-as-a-whole; sharing them is the expectation.graph,flowType,currentNode,currentSegmentId,currentAction,forwardedData,additionalData. These describe what one specific flow is currently doing and must swap on call / restore on return.SetRuntimeData(key, value, shared bool)lets the executor choose. The choice belongs to the executor because it knows which of its outputs are flow-local mechanics and which are journey-level.RuntimeData(key)reads frame-local first, then falls back to shared.executionIdis unchanged across the chain; one execution spans the whole call tree. The persisted flow context (FlowContextDB) is extended to carry the frame stack so suspended/resumed executions reconstruct call state.Cycles and Depth
STARTthat leads to anEND. The graph builder validates reachability of anENDand the existence of every referenced flow at construction time. (Until a dedicated flow validator lands, this is the graph builder's responsibility.)MAX_CALL_DEPTH, a code-level constant. A request that would exceed it fails the call.Application Scoping (Phase 1)
Flows are executed against an application, and applications have a defined set of flow types attached (e.g. an authentication flow + a registration flow + a recovery flow). A CALL node may only invoke a flow that is attached to the same application — this is a runtime check (the flow being constructed does not know which applications will use it).
For phase 1, this design further restricts call targets to a different flow type from the caller. Same-type sub-flow calls (e.g. an authentication flow calling another authentication flow as a sub-routine) are deferred to phase 2, where we need to choose between two approaches:
flow/executeand exists only to be referenced from another flow.Security Considerations
Impacted Areas
AuthAssertExecutorinside registrationflow/execute)Alternatives Considered
Alternative 1 — Inline-merge referenced graphs at runtime into a single composite graph
Alternative 2 — Generalise
TASK_EXECUTIONwith a "SubFlowExecutor"Sub-Decision: Cross-frame data passing
An explicit input/output contract on the call node was considered for the data-passing aspect specifically. It would offer strict, statically validated data flow, but forces every flow designer to enumerate what crosses the boundary even though the common answers (user identity, already-collected inputs, history) are always the same. We chose the shared / per-frame split described in Architecture Overview instead.
Questions for Community Input
Beta Was this translation helpful? Give feedback.
All reactions