Capability Middleware Guide¶
Trussium provides ordered provider-neutral middleware around
CapabilityExecutionPipeline. Middleware can observe an immutable resolved
invocation, continue to the next layer exactly once, or intentionally return a
result or stream without invoking downstream work.
The contract is independent of HTTP, FastAPI, chat request models, providers, and transports. Existing applications configure no middleware by default and retain their prior execution behavior.
Public contract¶
One middleware implements both execution modes:
from collections.abc import AsyncIterator
from time import monotonic
from trussium.capabilities import (
CapabilityExecuteNext,
CapabilityInvocation,
CapabilityStreamNext,
)
class TimingMiddleware:
async def execute(
self,
invocation: CapabilityInvocation,
call_next: CapabilityExecuteNext,
) -> object:
started_at = monotonic()
try:
return await call_next()
finally:
record_duration(invocation.capability_name, monotonic() - started_at)
def stream(
self,
invocation: CapabilityInvocation,
call_next: CapabilityStreamNext,
) -> AsyncIterator[object]:
async def events() -> AsyncIterator[object]:
started_at = monotonic()
try:
async for event in call_next():
yield event
finally:
record_duration(invocation.capability_name, monotonic() - started_at)
return events()
CapabilityInvocation is frozen and exposes:
capability_name: the canonical validated registry identity.capability: the exact resolved provider-neutral implementation.model: the effective model field inherited from or applied to execution context.streaming:Falseforexecute()andTrueforstream().
Middleware should treat the resolved capability as an identity and should not mutate implementations. Capability-specific request values remain owned by the invocation callback; the generic middleware contract does not inspect or translate protocol payloads.
Compose middleware¶
Pass middleware in declaration order when creating a pipeline:
pipeline = CapabilityExecutionPipeline(
registry,
middleware=(audit_middleware, timing_middleware),
)
assert pipeline.middleware == (audit_middleware, timing_middleware)
The pipeline requires the same sealed CapabilityRegistry as before. It copies
the middleware sequence into a tuple so later caller mutation cannot change the
composition. Every entry must structurally implement execute() and stream().
Application composition accepts the same ordered sequence:
application = create_application(
capability_registry=registry,
capability_middleware=(audit_middleware, timing_middleware),
)
The isolated application-owned pipeline remains available at
application.state.capability_execution_pipeline. The existing registry and
chat_capability compatibility state remain unchanged.
Ordering and continuation¶
For middleware declared as (first, second), non-streaming execution enters
first, enters second, invokes the capability callback, exits second, and
then exits first. Streaming follows the same nesting for the complete
iterator lifecycle.
Each call_next object is valid once. A second call raises RuntimeError
before downstream work can run twice. Middleware may omit call_next and
return its own result or asynchronous iterator to short-circuit all remaining
layers.
Short-circuiting is an execution primitive, not a routing or policy system. Middleware remains responsible for returning a value compatible with the capability-specific caller.
Streaming ownership¶
Capability resolution remains eager, while middleware and the capability stream callback remain lazy until the returned iterator is consumed.
The pipeline tracks every asynchronous iterator created through the chain. It closes each layer at most once, from the innermost stream outward, after normal exhaustion, failure, cancellation, generator exit, or consumer early close. This includes downstream iterators created before a middleware raises or short-circuits.
Middleware can close the managed iterator returned by call_next() in its own
finally block; pipeline finalization remains idempotent. Consumers that stop
early must still close the iterator returned by pipeline.stream(), as the
runtime SSE transport already does.
Events retain their identities and order. The middleware layer does not buffer, copy, serialize, interpret, or normalize events.
Context, failures, and telemetry¶
The pipeline resolves the capability once, then binds the existing immutable
execution context around middleware entry, continuation, callback execution,
event iteration, and cleanup. Request, execution, provider, and effective model
fields remain available through get_execution_context() and are restored
afterward.
Middleware results and errors propagate unchanged. Native cancellation and generator exit retain their semantics. A cleanup failure does not replace an already active execution or stream failure; every known stream layer still receives a cleanup attempt.
Capability middleware adds no automatic log event, metric, or span. The existing chat decorators continue to emit exactly one capability and provider lifecycle. A configured middleware may explicitly add its own bounded behavior without changing the built-in telemetry schema.
Compatibility and boundaries¶
An empty middleware sequence is the default for direct pipeline and
create_application() callers. Registry lookup, metadata discovery,
GET /v1/capabilities, JSON and SSE response contracts, timeouts,
disconnects, graceful shutdown, request IDs, and provider adapters remain
unchanged.
Capability middleware does not define lifecycle hooks, health, routing, retries, fallback, caching, provider registration, plugin loading, authentication, authorization, tenant policy, settings, endpoints, Helm values, Kubernetes resources, CRDs, or operator behavior.
Application-owned lifecycle hooks use the separate Capability Lifecycle Management Guide. Informational availability uses the separate Capability Availability Reporting Guide without adding middleware behavior.