Application composition

September 4, 2026 · View on GitHub

bootstrap.create_app(role) is the FastAPI composition root. api.py hosts the ordered route table, attaches concern routers at compatibility-sensitive registration points, and calls the factory once at EOF so the deployed treg.api:app import path remains the default all role.

The factory owns concrete assembly: the three core pure-ASGI middleware registrations, the optional V2 path normalizer, five exception handlers, static mounts, optional MCP mounts and lifespans, GET-to-HEAD widening, the OpenAPI wrapper that hides implied HEAD operations, shared HTTP client creation, startup work, shutdown drains, and the Ads conversion worker. Registration order is compatibility behavior. The four stage-0 snapshots stay byte-identical for role="all" unless that composition intentionally changes.

For every role, the factory wires the Catalog observation port to one process-local CachedEndpointObservationReader backed by short background_session_maker reads — the cache never awaits the source on the request path (a miss returns empty and schedules a refresh), so those callrecord aggregates are off-request work and belong off the API's pool. all and dataplane lifespans inject that exact instance into both mounted MCP catalog surfaces; the HTTP catalog routes and the observed-stats prose pages (use-case and workflow) on all and control read the instance from app state. This keeps one cache and one refresh Task per process even when HTTP and MCP search concurrently. The refresh Task starts lazily on a miss rather than appearing in the role's always-running background-task manifest. The lifespan still owns it: shutdown first unbinds it from MCP, then calls aclose(), which refuses new refreshes and cancels the shared Task before database and HTTP resources disappear.

bootstrap_handlers.py owns the app-wide pool-saturation and HTTP-exception adapters. call_surface.split_call_path classifies both /call/ and /catalog/call/ so those adapters share the same call-id, audit and idempotency-release contract while retaining call versus catalog_call ingress attribution. The composition root supplies the call-specific _stamp_call_exit callback from routers/call.py before registration; the callback owns call ids, refusal classification, audit fallback, exceptional call telemetry, and idempotency-label release. After caller identity exists, the pool adapter reports failure_kind=db_pool in the tool_called funnel. A timeout during identity resolution instead emits call_intake_failed, with no team or target attribution, so it does not change the admitted-call population. Both also send the infrastructure exception to analytics.capture_fault before returning the typed 503; normal HTTP refusals remain responses, not server faults.

bootstrap_http.py owns the app-wide middleware implementations. The middleware stack is _BodyDecodeMiddleware -> _SecurityHeadersMiddleware -> _LegacyHostRedirectMiddleware -> routes/mounts. All three are pure ASGI. The security wrapper adds headers at http.response.start with case-insensitive setdefault semantics, and the redirect wrapper either sends the same 301/302 response as before or calls its child directly. Keeping BaseHTTPMiddleware.call_next() out of this stack matters for streaming and disconnects: an MCP client may close while its stateless transport terminates without sending a response, which is a normal end to an already-dead connection rather than a server 500.

Pure ASGI does not make a genuine missing-response defect silent. Uvicorn's RequestResponseCycle.run_asgi checks an app that returns while the connection is still live, logs ASGI callable returned without starting response., and sends a 500. It skips that error only when the protocol has already marked the client disconnected, when no response can be delivered. Response completion also remains responsible for Starlette background tasks: the /call relay's StreamingResponse runs BackgroundTask(upstream_resp.aclose) after its body, and an assertion test pins that the shared httpx connection is released exactly once. Removing the two AnyIO memory-stream hops changes streaming backpressure and scheduling but not interruption semantics, which the callmatrix stream-failure case pins.

Role manifests

Every created app exposes app.state.role_manifest with explicit routes, background_tasks, and startup_checks lists. tests/test_app_roles.py pins all three lists for every role, while the call architecture test separately pins the dataplane/control startup split and background-task ownership.

RoleHTTP routes and mountsBackground tasksStartup checks
allThe complete surface, including /run, static files, /mcp, and the flagged /mcp/v2Ads conversion worker when enabledRead-only DB verify, HTTP client, enabled MCP lifespans
dataplane/call/{rest:path}, /catalog/call/{rest:path}, MCP mounts, and their resource metadata; no /run, static files, docs, or OpenAPINoneRead-only DB verify, HTTP client, enabled MCP lifespans
controlEverything except the calling surfaces; includes OAuth issuance, /run, and static filesAds conversion worker when enabledRead-only DB verify, HTTP client

No role lifespan writes schema, performs a data backfill, or provisions the local single user. The explicit python -m treg upgrade release phase owns content-driven backfills; the default python -m treg serve path adds single-user provisioning before Uvicorn starts. Raw ASGI operators must run the upgrade command separately on every release. verify_db() only checks revision compatibility and the Fernet-key guard; the exact startup manifests are pinned to a read-only allowlist.

Every role lifespan calls analytics.install_fault_handler after the read-only DB verification. With no PostHog key it is a no-op; otherwise ERROR+ root logs and Uvicorn ASGI exception records feed the bounded analytics queue until the lifespan removes the handler in its outer finally. This is logging-only observability: it adds no middleware, background task, or database access.

MCP is calling traffic (the refactor plan's role table assigns mcp.py to the dataplane), so a future dataplane deployment serves agents on both entry points. OAuth token issuance - consent pages and the /oauth/* endpoints - stays on control; the MCP surface only validates tokens, which is a read. domain.identity.session is therefore a both-role primitive: control signs browser and identity tokens, while both roles share its signing-key validation through domain.identity.mcp_oauth.

_CONTROL_ROUTE_KEYS and _DATAPLANE_ROUTE_KEYS assign every api.router route to exactly one owner. App creation fails on an unclassified, stale, duplicate, or multiply-owned key, so adding a route cannot silently expand the dataplane. Role separation is preparatory in stage 1; only the all role is deployed.

TREG_CLAUDE_CONNECTOR_ENABLED=true adds /mcp/v2 and starts its lifespan. When the flag is false or missing, only the team /mcp mount starts. The nested V2 mount is registered first so the parent /mcp mount cannot consume it.

When V2 is enabled, NormalizeDirectoryMCPPath rewrites the exact /mcp/v2 path to /mcp/v2/ before route matching. Claude can remove the final slash from a custom-connector URL. Both spellings must stay on the V2 transport and OAuth audience.

Route cloning

Each factory call must produce an independent app whose dependency overrides belong to that app. _include_routes therefore shallow-clones every APIRoute, points its dependency override provider at the new FastAPI instance, and rebuilds its request handler. This also avoids the internal _IncludedRouter wrapper added by the current FastAPI include_router() implementation, which would otherwise change route inspection and the committed surface snapshot.

Public routes added since: /{INDEXNOW_KEY}.txt (indexnow_key, routers/web.py) — the IndexNow key file; listed in the ownership table beside /sitemap.xml. See interface/seo.md § IndexNow.