[Design Discussion] Pre/ Post Execution Hooks for Flow Validations #2375
Replies: 6 comments
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. |
|
I think we should consider taking the interceptors to register from configuration. So the embedder application can consider to configure only required interceptors - Similar concept we are planning with executor registry. |
|
Small clarification regarding If the interceptor is PRE/POST_REQUEST what does it mean for a executor to be able to skip it ? AFAIU this should not be allowed |
Implementation Update: Changes from Proposed DesignWhile the core design from this discussion is preserved, several refinements were made during implementation to better align with existing engine patterns and address practical concerns. Below is a summary of the key changes. Interface Restructuring: Interceptor vs. InterceptorUnitThe proposal defined a single
This separation allows the same interceptor to operate at multiple lifecycle points without duplication. For example, Category Simplified to BooleanThe proposed Three-State Response ModelThe proposed
The response also carries structured fields beyond a simple failure reason: type InterceptorResponse struct {
Status InterceptorStatus
Error *serviceerror.ServiceError
EngineOutputs map[string]string
ChallengeToken string
FieldErrors []FieldError
}
SharedData for Cross-Request StateThe proposed Registry Scope ChangeThe proposal's registry supported mode-based lookups ( type InterceptorRegistryInterface interface {
RegisterInterceptor(name string, interceptor InterceptorInterface)
GetInterceptor(name string) (InterceptorInterface, error)
IsRegistered(name string) bool
}Mode-based filtering and scope resolution are handled at runtime by the interceptor runner during Configuration-Driven RegistrationThe proposal hardcoded all interceptor registrations in configuration:
flow:
interceptors: [] # empty = register all built-in interceptorsWhen the list is empty, all known built-in interceptors are registered. When populated, only the listed interceptors are initialized. This gives deployers control over which interceptors are active without code changes. Default Interceptors as Pre-Built UnitsDefault interceptors are defined as pre-built Challenge Token: Dual-Mode InterceptorThe proposal listed
Context AdjustmentsThe
Scope: Only ChallengeTokenInterceptor ImplementedThis phase focuses on the core interceptor framework and the Database SchemaA new |
|
Interceptors can have predefined, non-configurable inputs, but these inputs must arrive through the flow data. That is, the inputs should already be present in the flow context, collected by prompt nodes or other upstream graph participants. Interceptors process whatever data is available to them; they do not request missing data from the flow execution. An interceptor will not prompt the user to supply it. Instead, the prompt node that an interceptor is applied to is responsible for ensuring the interceptor's inputs are collected. For example, if a captcha interceptor requires a captcha_token, then the prompt node it is attached to should declare captcha_token as a required input. The prompt node verifies the input is present before execution proceeds, so by the time the captcha interceptor runs, the token is guaranteed to be available. |
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.
All reactions