ADR-0044: Pipeline Pattern for Request Processing¶
- Status: Accepted
- Date: 2026-04-18
- Supersedes: —
- Superseded by: —
Context¶
The /invoke processing path currently lives in a single function in
runtime.py. Authentication, rate limiting, input validation, Cedar
policy evaluation, handler dispatch, provenance recording, cost
tracking, and observability emission are all handled inline. This flat
structure has several drawbacks:
- Testing is expensive. Exercising a single concern (e.g., policy evaluation) requires setting up the entire invoke context, because the concern is interleaved with everything else.
- Extension is fragile. Users who want to inject custom behaviour (audit logging, request transformation, tenant isolation) have no supported hook point. They resort to monkey-patching or wrapping the entire invoke function.
- Reordering is a code change. Moving rate limiting before auth (or vice versa) means editing deeply nested control flow rather than swapping two items in a list.
- Observability is tangled. Each concern emits its own events at ad-hoc points; there is no uniform lifecycle model for stages.
The Chain of Responsibility (Pipeline) pattern addresses all four: each concern becomes an isolated, testable stage that receives a shared mutable context and either processes it or aborts the pipeline.
Options considered¶
-
Middleware stack (ASGI/WSGI style). Each middleware wraps the next, forming a nested call chain. Familiar but makes abort semantics awkward (must propagate exceptions through wrapper layers) and ordering is implicit in nesting depth.
-
Event-driven hooks. Emit events at fixed points; listeners subscribe. Flexible but non-deterministic ordering, hard to abort early, and listeners cannot easily communicate via shared state.
-
Linear pipeline of stages (this ADR). Stages execute in list order against a shared
PipelineContext. Any stage can abort. Users insert/remove/reorder stages by name. Deterministic, testable, composable.
Decision¶
Adopt option 3: a composable Pipeline of Stage objects that process
a PipelineContext in order.
Design¶
PipelineContextis a mutable dataclass carrying the capability id, arguments, principal, trace id, result, error, and an openmetadatadict for inter-stage communication.Stageis a protocol with aname: strattribute and anexecute(ctx) -> Nonemethod.Pipelineholds an ordered list of stages and providesadd,insert_before,insert_after, andremovefor composability.Pipeline.execute(ctx)runs stages sequentially. If any stage setsctx.aborted = True, subsequent stages are skipped.- A
default_pipeline()factory returns the standard Trails request pipeline with all built-in stages in canonical order.
Built-in stages (canonical order)¶
- AuthStage — validate Bearer token
- RateLimitStage — per-IP rate limiting
- ValidationStage — validate arguments against
@shape - PolicyStage — evaluate Cedar policy
- InvokeStage — call the capability handler
- ProvStage — record PROV-O provenance
- CostStage — track cost
- ObservabilityStage — emit observability events
Extension point¶
Users insert custom stages at any position:
pipe = default_pipeline()
pipe.insert_after("auth", AuditLogStage())
pipe.insert_before("invoke", TenantIsolationStage())
Consequences¶
- Each concern is independently testable with a minimal
PipelineContextfixture — no full runtime setup required. - Custom stages can be added, removed, or reordered without modifying framework internals.
- The pipeline module is standalone (
trails.pipeline); wiring it intoruntime.pyandhttp_adapter.pyis a separate, non-breaking step. - Stage ordering is explicit and visible in
default_pipeline(). - Slight runtime overhead from iterating a list of stages vs. a monolithic function; negligible for the I/O-bound workloads Trails targets.