[Design Discussion] Pre/ Post Execution Hooks for Flow Validations #2375
Replies: 1 comment
-
Implementation DetailsPackage StructureA new Interceptor InterfaceAn interceptor follows a contract similar to executors — with an // InterceptorInterface defines the interface for flow interceptors.
type InterceptorInterface interface {
// Execute runs the interceptor logic and returns a result.
Execute(ctx *InterceptorContext) (*InterceptorResult, error)
// Other common methods. Can decide which are required at the implementation time.
GetName() string
GetMode() InterceptorMode
GetCategory() InterceptorCategory
GetPriority() int
}The interceptor context carries the execution state and the HTTP request context: // InterceptorContext holds the context available to an interceptor during execution.
type InterceptorContext struct {
// Context carries the HTTP request context (includes request metadata,
// permissions, trace IDs, etc.)
Context context.Context
// Flow-level fields
ExecutionID string
FlowType common.FlowType
AppID string
UserInputs map[string]string
RuntimeData map[string]string
Application appmodel.Application
// Node-level fields (nil for PRE_REQUEST/POST_REQUEST interceptors)
CurrentNodeID string
NodeType common.NodeType
// Interceptor-specific properties (from flow definition)
Properties map[string]interface{}
// Authenticated user state
AuthenticatedUser authncm.AuthenticatedUser
ExecutionHistory map[string]*common.NodeExecutionRecord
}The result is pass/fail with optional context enrichment: // InterceptorResult holds the outcome of an interceptor execution.
type InterceptorResult struct {
// Status indicates whether the interceptor passed or failed.
Status InterceptorStatus // PASS or FAIL
// FailureReason provides a human-readable reason when Status is FAIL.
FailureReason string
// RuntimeData allows interceptors to enrich the execution context
// (e.g., set metadata for downstream nodes).
RuntimeData map[string]string
}Key constraints:
Registration and MetadataInterceptors register with an // InterceptorRegistryInterface defines registry operations for interceptors.
type InterceptorRegistryInterface interface {
RegisterInterceptor(interceptor InterceptorInterface)
GetInterceptors(mode InterceptorMode) []InterceptorInterface
GetInterceptor(name string) (InterceptorInterface, error)
IsRegistered(name string) bool
}The registry groups interceptors by mode and sorts them by priority within each group. At initialization time, all interceptors register themselves with their metadata: // Initialize registers available interceptors and returns the interceptor registry.
func Initialize(
flowFactory core.FlowFactoryInterface,
// ... service dependencies
) InterceptorRegistryInterface {
reg := newInterceptorRegistry()
// Default interceptors (always enforced, high priority)
reg.RegisterInterceptor(newChallengeTokenInterceptor(...))
reg.RegisterInterceptor(newSensitiveDataInterceptor(...))
// Configurable interceptors (engaged via flow definition)
reg.RegisterInterceptor(newPermissionInterceptor(flowFactory))
reg.RegisterInterceptor(newUniquenessInterceptor(flowFactory, userSchemaService, userProvider))
reg.RegisterInterceptor(newCaptchaInterceptor(...))
reg.RegisterInterceptor(newRateLimiterInterceptor(...))
return reg
}Each interceptor declares its metadata through the interface methods:
The engine uses this metadata at runtime:
Interceptor Engagement ModelPer-Request Interceptors (mode:
Per-Node Interceptors (mode:
Node Scoping for Per-Node InterceptorsConfigurable interceptors specify their scope in the flow definition:
Nodes can opt out of globally-scoped interceptors via their existing {
"id": "some_internal_node",
"type": "TASK_EXECUTION",
"properties": {
"skipInterceptors": ["InputValidator"]
},
"executor": {
"name": "SomeExecutor"
},
"onSuccess": "end"
}Engine IntegrationThe interceptor execution is woven into the existing engine loop: Interceptor failure at any point halts execution and returns an appropriate error response to the client. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Related Feature Issue
N/A
Problem Summary
Thunder flow engine currently relies on executors as the primary workers within a flow graph. Each executor is attached to a task execution node, and the flow definition (graph) explicitly wires nodes together. This works well for the core flow logic, but introduces friction for cross-cutting concerns — operations that need to run across many (or all) nodes/requests but don't belong in the per-node execution path. Some examples are;
Following current pattern, implementing these as executors would require duplicating nodes throughout every flow definition, which is impractical. Some concerns (e.g., challenge token validation, permission validation, attribute uniqueness) are already handled through hardcoded checks in the engine or via special-purpose executors (
PermissionValidator,AttributeUniquenessValidator), but there is no unified model for them.We need a first-class abstraction that lets these cross-cutting concerns be declared once and engaged automatically at the right lifecycle points, without polluting every flow graph.
High-Level Approach
Introduce an interceptor abstraction — lightweight, focused processing units that execute at well-defined lifecycle points during flow execution. Interceptors are distinct from executors in that:
Lifecycle Points
Interceptors engage at specific points in the engine's execution lifecycle:
Interceptor Categories
Default interceptors are invisible in the flow definition and enforced in engine code. Configurable interceptors are explicitly declared and can be scoped to all nodes or selected nodes.
Architecture Overview
Interface
Interceptors follow the same
Execute(ctx) → resultpattern as executors, but with a narrower contract:InterceptorContextcarrying the HTTP request context, flow state, current node info, and interceptor-specificpropertiesRuntimeDataRegistration and Metadata
Interceptors register with an
InterceptorRegistryat startup, mirroring the executor registry pattern. Each interceptor self-describes its metadata at registration:Name"CaptchaInterceptor","RateLimiter"ModesPRE_REQUEST,PRE_NODE,POST_NODE,POST_REQUESTCategoryDEFAULT,CONFIGURABLEPriority100(default),200(configurable)Default interceptors always run. Configurable interceptors only run when declared in the flow definition.
Scoping
Configurable interceptors declare their scope in the flow definition:
"scope": "ALL"— runs for every node"scope": "SELECTED"with"applyTo": [...]— runs only for listed nodesNodes can opt out of globally-scoped interceptors via
"skipInterceptors"in their existingproperties.{ "id": "some_internal_node", "type": "TASK_EXECUTION", "properties": { "skipInterceptors": ["InputValidator"] }, "executor": { "name": "SomeExecutor" }, "onSuccess": "end" }Engine Integration
The engine invokes interceptors at each lifecycle point within the existing execution loop. Default interceptors always run; configurable ones run only when declared in the flow definition. Failure at any point halts execution and returns error to the client.
Configurable interceptors are declared as a top-level
interceptorsarray alongsidenodes. They usepropertiesfor configuration (same pattern as nodes). Nodes opt out of globally-scoped interceptors via their existingpropertiesfield.{ "name": "Default Basic Authentication Flow", "handle": "default-basic-flow", "flowType": "AUTHENTICATION", "interceptors": [ { "name": "CaptchaInterceptor", "mode": "PRE_NODE", "scope": "SELECTED", "applyTo": ["prompt_credentials"], "properties": { "provider": "recaptcha", "siteKey": "..." } }, { "name": "RateLimiter", "mode": "PRE_REQUEST", "scope": "ALL", "properties": { "maxAttempts": 5, "windowSeconds": 300 } } ], "nodes": [ { "id": "start", "type": "START", "onSuccess": "prompt_credentials" }, { "id": "prompt_credentials", "type": "PROMPT", "meta": { "..." : "..." }, "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_001", "nextNode": "basic_auth" } } ] }, { "id": "basic_auth", "type": "TASK_EXECUTION", "executor": { "name": "BasicAuthExecutor" }, "onSuccess": "authorization_check", "onIncomplete": "prompt_credentials" }, { "id": "authorization_check", "type": "TASK_EXECUTION", "executor": { "name": "AuthorizationExecutor" }, "onSuccess": "auth_assert" }, { "id": "auth_assert", "type": "TASK_EXECUTION", "properties": { "skipInterceptors": ["RateLimiter"] }, "executor": { "name": "AuthAssertExecutor" }, "onSuccess": "end" }, { "id": "end", "type": "END" } ] }Key points in this example:
CaptchaInterceptorrunsPRE_NODE, scoped only toprompt_credentials— it validates the CAPTCHA before the prompt node processes user inputRateLimiterrunsPRE_REQUESTfor all requests — butauth_assertopts out viaskipInterceptorsin its propertiesExisting Patterns That Become Interceptors
Several existing mechanisms would be unified under this model:
validateChallengeToken()in engineChallengeTokenInterceptorPRE_REQUESTclearSensitiveInputs()in engineSensitiveDataInterceptorPOST_NODEPermissionValidatorexecutorPermissionInterceptorPRE_NODEAttributeUniquenessValidatorexecutorUniquenessInterceptorPRE_NODESecurity Considerations
skipInterceptors.RuntimeDataenrichment is permitted.Impacted Areas
flowexec/engine.go) — Interceptor invocation points in the execution loopflow/core/) —InterceptorInterface,InterceptorContext,InterceptorResultdefinitionsflow/interceptor/) — NEW package with registry, init, and interceptor implementationsflow/mgt/model.go) —InterceptorDefinitionstruct andinterceptorsfield onFlowDefinitionflow/mgt/graph_builder.go) — Parsing interceptor definitions from flow definitionsflow/executor/) — Migration ofPermissionValidatorandAttributeUniquenessValidatortoflow/interceptor/Alternatives Considered
Alternative 1: Keep validators as executors with special handling
Alternative 2: Middleware-style pipeline (wrap the entire engine)
Alternative 3: Implicit executor chains (engine auto-inserts hidden nodes)
Questions for Community Input
Naming — Pick the term that best communicates the intent for this abstraction:
Mode vs Hook — For the lifecycle point field on the interceptor definition, should we use
"mode"(consistent with executor definitions) or"hook"(more semantically explicit about lifecycle engagement)?UX of flow builder UI, mainly how are we going to represent validators in the flow configuration UI.
Beta Was this translation helpful? Give feedback.
All reactions