Architectural decision record (ADR) 012: prepared application templates and connection-local runtimes
Status
Proposed.
Target document: docs/adr-012-prepared-application-and-connection-runtime.md.
Date
Proposed 2026-08-08.
Context and Problem Statement
WireframeApp currently represents three different lifecycle phases:
- a mutable builder that collects routes, middleware, lifecycle callbacks, protocol hooks, serializer, codec configuration, message assembly, and application data;
- an immutable application template read by every connection;
- a value constructed afresh by
AppFactory inside each connection task.
Those roles have incompatible ownership requirements.
The builder must own mutable registration collections. The immutable template should build middleware chains once and be shared by independent connection tasks. The connection runtime should own stream state, lifecycle state, codec state, message assembly, fragmentation state, and other values that exist for exactly one connection.
The current hybrid creates several observable problems:
- route chains may be built lazily from a fresh app for each connection;
- a route table is stored as
OnceCell<Arc<HashMap<...>>> and cloned into connection handling even though the app remains alive;
- the generic server and examples use different application-sharing topologies;
- documentation variously describes an app per worker and an app per connection;
- application-owned callbacks and protocol objects acquire nested
Arc layers because the shared template itself is not explicit;
- factory failures happen after a connection has already been accepted and are logged rather than reported as startup failure;
- lifecycle teardown and protocol-hook correctness must be threaded through a type that also carries builder-only state.
Traceability
This ADR governs the application half of Epic #635 and implements ADR 011's shared-root rule.
Primary code surfaces:
src/app/builder/core.rs;
src/app/builder/routing.rs;
src/app/builder/lifecycle.rs;
src/app/builder/protocol.rs;
src/app/inbound_handler.rs;
src/server/mod.rs;
src/server/runtime.rs;
src/server/runtime/accept.rs;
src/server/connection_spawner.rs;
examples/support/runtime_bootstrap.rs.
Related issues and decisions:
Decision Drivers
- Prepare immutable routing and middleware state once, before serving traffic.
- Make readiness mean that application preparation has succeeded.
- Give each connection explicit ownership of connection-local state.
- Preserve one clear shared ownership root for independent connection tasks.
- Surface application construction errors deterministically.
- Avoid arbitrary per-worker state duplication on a multithreaded executor where accept-loop tasks are not thread-affine.
- Keep the migration reviewable and avoid coupling it to ADR 010 or the zero-copy public API migration.
Options Considered
Option A: retain a fresh WireframeApp per connection
Continue invoking AppFactory::build in the connection task and optimize individual fields in place.
This preserves current runtime behaviour but repeats application construction, leaves startup readiness underspecified, and keeps the builder/template/runtime roles entangled.
Option B: prepare one application per accept-loop worker
Invoke the factory once per configured worker and share that prepared app among connections accepted by the worker.
This reduces per-connection construction, but accept-loop tasks are not pinned to executor threads. Per-worker application state therefore partitions state by an implementation detail rather than by a meaningful ownership boundary, and it duplicates route/middleware preparation workers times.
Option C: prepare one application template per server and create one runtime per connection (preferred)
Invoke the application factory once during server startup, consume the resulting builder into an immutable prepared template, and share one Arc<PreparedApp> among independent connection tasks. Create connection-local runtime state after accept.
This aligns preparation, readiness, sharing, and connection ownership.
Option D: expose both per-server and per-connection application strategies immediately
Add explicit strategy types or constructors and preserve both models as first-class APIs.
This offers flexibility but doubles the lifecycle surface before the project has evidence that per-connection application construction is needed. Per-connection resources can already be created through the connection setup hook.
Decision Outcome
Adopt Option C.
1. Separate the lifecycle phases
Wireframe will distinguish the following concepts, whether initially public or crate-private:
WireframeApp builder
│
│ prepare().await
▼
PreparedApp
│ Arc clone at independent connection-task boundary
▼
ConnectionRuntime
WireframeApp
WireframeApp remains the fluent registration/builder surface. It owns mutable route and middleware registrations and is not used directly to process a stream after preparation.
PreparedApp
PreparedApp is immutable after construction. It owns by value or Box:
- the prepared route table and completed middleware chains;
- serializer and codec templates/configuration;
- application data;
- lifecycle callback definitions;
- protocol and message-assembler implementations;
- immutable fragmentation, memory-budget, timeout, and push configuration.
The server owns one Arc<PreparedApp> and clones it once per independent connection task. Values that cannot escape independently should not add another Arc layer merely because the prepared root is shared.
ConnectionRuntime
ConnectionRuntime owns values whose lifetime is exactly one connection:
- accepted/rewound stream and framed codec state;
- connection setup state
C;
ConnectionContext and protocol-hook invocation state;
- inbound frame pipeline, deserialization failure count, fragment reassembly, and message assembly;
- outbound connection actor state, connection-local
Fragmenter, queues, and cancellation observations;
- peer metadata and teardown guard.
Connection-local resources are created through setup/runtime construction, not by rebuilding the entire application.
2. Prepare before readiness
run_with_shutdown will:
- evaluate
AppFactory once;
- prepare routes and middleware chains;
- construct the shared
PreparedApp root;
- spawn accept loops;
- send the readiness signal.
Application build or preparation errors become ServerError variants and fail startup. No connection is accepted by a server whose application template failed to prepare.
3. Treat AppFactory as a startup factory
The existing WireframeServer::new(factory) surface may remain during migration, but the factory is evaluated once per server run rather than once per connection.
The implementation issue must assess semver impact and choose one of:
- document the new evaluation semantics as a bug/performance correction if no supported contract promised per-connection invocation;
- introduce a direct
WireframeServer::from_app(app) or from_prepared_app constructor and deprecate ambiguous factory semantics;
- retain an explicitly named per-connection factory API only if a concrete use case cannot be expressed through connection setup state.
The final public API disposition must be recorded before this ADR is accepted.
4. Build route chains exactly once
Middleware transformation may remain asynchronous, so prepare() may be async. The resulting route map is owned directly by PreparedApp; no inner Arc<HashMap<...>> or per-connection OnceCell clone is required.
5. Make lifecycle cleanup structural
Once setup succeeds, teardown must run exactly once on every terminal path, including decode, protocol, transport, cancellation, and panic-recovery paths where execution can safely continue. The connection runtime should use an explicit guard/finalization path rather than a clean-path-only tail call. This coordinates with #549.
6. Apply protocol hooks consistently
The prepared protocol definition and connection-local hook context must cover normal app responses as well as actor-driven push/streaming output. This coordinates with #547 while preserving ADR 010's packet-oriented hook decision.
7. Unify examples with the production runtime
Examples should stop maintaining a separate Arc<WireframeApp> bootstrap topology. They should exercise the same preparation and server runtime used by library consumers, unless an example deliberately demonstrates a lower-level API.
Consequences
Positive
- Route and middleware preparation no longer repeats per connection.
- Readiness acquires a precise meaning.
- One
Arc<PreparedApp> reflects one real shared ownership boundary.
- Connection-local invariants become visible in a dedicated type.
- Startup failures become deterministic and observable.
- Lifecycle and protocol-hook fixes gain one structural home.
- The generic server and examples share one topology.
Negative
- Existing factory side effects or per-connection assumptions may change behaviour.
prepare() introduces an explicit asynchronous startup phase.
ServerError grows application-build/preparation variants.
- More internal types and transition code increase short-term refactor size.
- Tests that called
WireframeApp::handle_connection_result directly may need a preparation helper or lower-level harness.
Rejected Shortcuts
- Keeping
WireframeApp as all three phases and only changing OnceCell<Arc<_>> to OnceCell<_>.
- Building one template per worker without thread affinity or a worker-local-state requirement.
- Making the whole builder
Clone and cloning it into every connection.
- Moving connection-local mutable state into the shared prepared root behind locks.
Migration Plan
Phase 1: introduce internal preparation types
Add PreparedApp and ConnectionRuntime internally, with compatibility wrappers for current entry points.
Phase 2: move route and middleware preparation
Consume handler and middleware registrations during preparation and remove per-connection route initialization.
Phase 3: switch server startup
Evaluate and prepare the app before spawning accept loops and readiness notification. Thread one prepared root into connection tasks.
Phase 4: collapse nested ownership
Replace application-owned Arc callback/assembler layers with direct or boxed ownership where they cannot escape independently. Consolidate protocol ownership under its dedicated implementation issue.
Phase 5: settle public API and documentation
Document factory evaluation semantics, update examples, add migration notes if required, and expose only the preparation/runtime APIs that downstream users genuinely need.
Verification
- A counter-based test proves the app factory and middleware transforms run once per server run, not once per connection.
- Multiple simultaneous connections share the same prepared route table without rebuilding it.
- Readiness is not signalled before preparation completes.
- Factory/preparation failure prevents acceptance and returns a typed server error.
- Setup/teardown runs exactly once across clean close and error paths.
- Protocol
before_send applies to ordinary responses and actor-driven outputs according to ADR 010.
- Existing handler ordering, codec, fragmentation, message assembly, memory-budget, and shutdown tests continue to pass.
- Connection-startup benchmarks compare the old and prepared paths.
Outstanding Decisions Before Acceptance
- Whether
PreparedApp and ConnectionRuntime remain internal or gain public advanced APIs.
- The exact public migration for
WireframeServer::new(factory).
- Whether preparation consumes the builder irreversibly or supports a separately cloneable reusable prepared template.
- How testkit helpers expose preparation without making tests depend on private internals.
References
Architectural decision record (ADR) 012: prepared application templates and connection-local runtimes
Status
Proposed.
Target document:
docs/adr-012-prepared-application-and-connection-runtime.md.Date
Proposed 2026-08-08.
Context and Problem Statement
WireframeAppcurrently represents three different lifecycle phases:AppFactoryinside each connection task.Those roles have incompatible ownership requirements.
The builder must own mutable registration collections. The immutable template should build middleware chains once and be shared by independent connection tasks. The connection runtime should own stream state, lifecycle state, codec state, message assembly, fragmentation state, and other values that exist for exactly one connection.
The current hybrid creates several observable problems:
OnceCell<Arc<HashMap<...>>>and cloned into connection handling even though the app remains alive;Arclayers because the shared template itself is not explicit;Traceability
This ADR governs the application half of Epic #635 and implements ADR 011's shared-root rule.
Primary code surfaces:
src/app/builder/core.rs;src/app/builder/routing.rs;src/app/builder/lifecycle.rs;src/app/builder/protocol.rs;src/app/inbound_handler.rs;src/server/mod.rs;src/server/runtime.rs;src/server/runtime/accept.rs;src/server/connection_spawner.rs;examples/support/runtime_bootstrap.rs.Related issues and decisions:
Decision Drivers
Options Considered
Option A: retain a fresh
WireframeAppper connectionContinue invoking
AppFactory::buildin the connection task and optimize individual fields in place.This preserves current runtime behaviour but repeats application construction, leaves startup readiness underspecified, and keeps the builder/template/runtime roles entangled.
Option B: prepare one application per accept-loop worker
Invoke the factory once per configured worker and share that prepared app among connections accepted by the worker.
This reduces per-connection construction, but accept-loop tasks are not pinned to executor threads. Per-worker application state therefore partitions state by an implementation detail rather than by a meaningful ownership boundary, and it duplicates route/middleware preparation
workerstimes.Option C: prepare one application template per server and create one runtime per connection (preferred)
Invoke the application factory once during server startup, consume the resulting builder into an immutable prepared template, and share one
Arc<PreparedApp>among independent connection tasks. Create connection-local runtime state after accept.This aligns preparation, readiness, sharing, and connection ownership.
Option D: expose both per-server and per-connection application strategies immediately
Add explicit strategy types or constructors and preserve both models as first-class APIs.
This offers flexibility but doubles the lifecycle surface before the project has evidence that per-connection application construction is needed. Per-connection resources can already be created through the connection setup hook.
Decision Outcome
Adopt Option C.
1. Separate the lifecycle phases
Wireframe will distinguish the following concepts, whether initially public or crate-private:
WireframeAppWireframeAppremains the fluent registration/builder surface. It owns mutable route and middleware registrations and is not used directly to process a stream after preparation.PreparedAppPreparedAppis immutable after construction. It owns by value orBox:The server owns one
Arc<PreparedApp>and clones it once per independent connection task. Values that cannot escape independently should not add anotherArclayer merely because the prepared root is shared.ConnectionRuntimeConnectionRuntimeowns values whose lifetime is exactly one connection:C;ConnectionContextand protocol-hook invocation state;Fragmenter, queues, and cancellation observations;Connection-local resources are created through setup/runtime construction, not by rebuilding the entire application.
2. Prepare before readiness
run_with_shutdownwill:AppFactoryonce;PreparedApproot;Application build or preparation errors become
ServerErrorvariants and fail startup. No connection is accepted by a server whose application template failed to prepare.3. Treat
AppFactoryas a startup factoryThe existing
WireframeServer::new(factory)surface may remain during migration, but the factory is evaluated once per server run rather than once per connection.The implementation issue must assess semver impact and choose one of:
WireframeServer::from_app(app)orfrom_prepared_appconstructor and deprecate ambiguous factory semantics;The final public API disposition must be recorded before this ADR is accepted.
4. Build route chains exactly once
Middleware transformation may remain asynchronous, so
prepare()may be async. The resulting route map is owned directly byPreparedApp; no innerArc<HashMap<...>>or per-connectionOnceCellclone is required.5. Make lifecycle cleanup structural
Once setup succeeds, teardown must run exactly once on every terminal path, including decode, protocol, transport, cancellation, and panic-recovery paths where execution can safely continue. The connection runtime should use an explicit guard/finalization path rather than a clean-path-only tail call. This coordinates with #549.
6. Apply protocol hooks consistently
The prepared protocol definition and connection-local hook context must cover normal app responses as well as actor-driven push/streaming output. This coordinates with #547 while preserving ADR 010's packet-oriented hook decision.
7. Unify examples with the production runtime
Examples should stop maintaining a separate
Arc<WireframeApp>bootstrap topology. They should exercise the same preparation and server runtime used by library consumers, unless an example deliberately demonstrates a lower-level API.Consequences
Positive
Arc<PreparedApp>reflects one real shared ownership boundary.Negative
prepare()introduces an explicit asynchronous startup phase.ServerErrorgrows application-build/preparation variants.WireframeApp::handle_connection_resultdirectly may need a preparation helper or lower-level harness.Rejected Shortcuts
WireframeAppas all three phases and only changingOnceCell<Arc<_>>toOnceCell<_>.Cloneand cloning it into every connection.Migration Plan
Phase 1: introduce internal preparation types
Add
PreparedAppandConnectionRuntimeinternally, with compatibility wrappers for current entry points.Phase 2: move route and middleware preparation
Consume handler and middleware registrations during preparation and remove per-connection route initialization.
Phase 3: switch server startup
Evaluate and prepare the app before spawning accept loops and readiness notification. Thread one prepared root into connection tasks.
Phase 4: collapse nested ownership
Replace application-owned
Arccallback/assembler layers with direct or boxed ownership where they cannot escape independently. Consolidate protocol ownership under its dedicated implementation issue.Phase 5: settle public API and documentation
Document factory evaluation semantics, update examples, add migration notes if required, and expose only the preparation/runtime APIs that downstream users genuinely need.
Verification
before_sendapplies to ordinary responses and actor-driven outputs according to ADR 010.Outstanding Decisions Before Acceptance
PreparedAppandConnectionRuntimeremain internal or gain public advanced APIs.WireframeServer::new(factory).References
docs/adr-010-transport-frame-boundary-for-zero-copy.md