TopologyServer/HTTPGateway merge — design (v0.9.5, AAA prerequisite)¶
Status: design confirmed (2026-07-30 review), not yet implemented. This is the prerequisite
investigation for v0.9.5's "TopologyServer AuthN/AuthZ + access control design" backlog item
(docs/milestones.md). Per this repo's convention (design doc precedes implementation for
control-plane changes) and its explicit standing instruction on auth work ("hold off on auth,
don't knee-jerk it"), all four open questions in §6 have been resolved by explicit review (see
each decision below); implementation has not started.
1. The question that started this¶
While scoping the AAA design, TopologyServer was found to have zero authentication today —
acceptable so far because everything it serves is read-only. Investigating why it has no auth
led to a bigger question: why does TopologyServer exist as a separate HTTP-serving
implementation at all, when HTTPGateway already has a mature, ASGI-based, already-audited
AuthN story (API key, JWT, mTLS)? Confirmed by direct investigation, not assumed:
HTTPGateway actually predates TopologyServer by five days (2026-04-22 vs 2026-04-27) — this
wasn't "TopologyServer came first, HTTPGateway fixed it later." There's no design doc or commit
message anywhere explaining a deliberate decision to keep them separate. It just happened this
way.
Decision: merge them. TopologyServer becomes an ordinary agent behind HTTPGateway,
inheriting its auth stack instead of reinventing one. This document is the design + migration
plan for that merge.
2. Current state (traced through the actual code, not summarized from memory)¶
2.1 HTTPGateway's implementation has four layers with very different coupling¶
| Layer | File | Coupled to "route to an agent"? | Coupled to ASGI/uvicorn? |
|---|---|---|---|
| Transport | asgi.py (GatewayASGI) + uvicorn |
No | Yes — this is uvicorn's own glue |
| Request/response types + middleware chain | types.py, middleware.py |
No | No — pure dataclasses + async functions |
| Auth middlewares | auth.py (API key), jwt_auth.py, mtls.py |
jwt_auth.py: soft, getattr(gateway, "_jwt_verifier", None) — duck-typed, not isinstance. auth.py/mtls.py: no coupling at all. ratelimit.py: yes, calls request.gateway.call(...) |
No |
GatewayDispatcher |
dispatch.py |
Yes, fundamentally — its entire job is ask()/send() to an agent by name |
No |
The only genuinely ASGI/ agent-routing-coupled pieces are the transport layer and
GatewayDispatcher. Everything else — the part that actually matters for "inherit the auth
capabilities" — is already generic.
2.2 TopologyServer doesn't need GatewayDispatcher at all¶
TopologyServer(GenServer) → GenServer(AgentProcess) — it is already a real AgentProcess,
with .call()/.ask()/.send() for free (confirmed: ratelimit.py's request.gateway.call(...)
would work unmodified against it).
But Runtime wires it completely differently from every other agent — runtime.py (~L805, ~L855)
directly injects raw Python references, same-process, no bus, no serialization:
agent._root_supervisor = self._root_supervisor
agent._agents = self._agents_by_name
agent._metrics_collector = self._metrics if isinstance(self._metrics, MetricsCollector) else None
This is a deliberate, privileged exemption from this codebase's own "route by name, never by
object" rule (there's a comment saying exactly that, next to this exact injection). Its seven
endpoints (/health, /topology, /agents, /agents/{name}, /snapshot, /metrics,
/processes) are pure functions over that injected state — six of them are synchronous
same-process reads; /processes does one real bus round-trip per Worker
(self._bus.request(_agency.health_probe, ...)), which GenServer already supports natively.
None of this needs GatewayDispatcher's ask()-to-an-agent-by-name model. It needs to keep
being privileged-injected exactly as today, just reachable through HTTPGateway's transport +
middleware chain instead of a hand-rolled asyncio.start_server.
2.3 The one real structural incompatibility: /metrics isn't JSON¶
GatewayResponse.body is always JSON-encoded (asgi.py's _respond(): json.dumps(response.body)
unconditionally). TopologyServer's /metrics returns real Prometheus text-format
(text/plain; version=0.0.4; charset=utf-8), not JSON. Today's only precedent for a non-JSON
response (/docs, /openapi.json) bypasses the whole middleware/dispatch chain entirely via a
hardcoded special case in _handle_http() — which would defeat the point here (a hardcoded bypass
never gets auth middleware applied to it). This needs a real, governed extension, not a third
copy-pasted bypass — see D4 below.
2.4 The dependency-cost question is narrower than it first looked¶
civitas[http] (uvicorn + pydantic) is imported lazily inside HTTPGateway.on_start() — paid
only by whoever runs the Runtime process hosting the gateway. civitas/dashboard/client.py
(what civitas dashboard/topology show --live actually use) is a plain asyncio.open_connection
+ manual HTTP/1.1 parsing client — zero HTTPGateway/uvicorn dependency, and doesn't need to
change syntactically to keep working, since it just does GET /path over TCP and reads to EOF
on Connection: close, which uvicorn honors like any compliant HTTP/1.1 server. So the dependency
cost lands correctly on the side that's already about to need a real HTTP stack for auth anyway
(the server), not on every dashboard viewer.
3. Proposed design¶
D1 — TopologyServer's HTTP-serving half is deleted; its data half becomes a GenServer¶
New (or repurposed) TopologyAgent(GenServer) keeps the exact same privileged injection
contract (_root_supervisor/_agents/_metrics_collector, injected by Runtime exactly as
today — this does not change). It loses: _server, init()'s asyncio.start_server, on_stop()'s
socket cleanup, _handle_connection(), and _route_http()'s manual path parsing. It gains a
handle_call(payload, from_) that dispatches on a payload["__op__"] key ("topology",
"agents", "agent_detail", "snapshot", "metrics", "processes", "health"), each calling
the exact same _serialize_node/_build_agents_list/_build_agent_detail/_build_metrics/
_build_prometheus_metrics/_build_processes methods, unchanged. /processes's existing
await self._bus.request(...) keeps working — GenServer already has self._bus.
D2 — Introspection routes are auto-registered, not user-declared¶
The seven endpoints are fixed, canonical, not user-remappable — matching today's zero-config
expectation (civitas dashboard topology.yaml needs to keep working with no route configuration).
A new GatewayConfig.topology_agent: str | None = None field: when set to an agent name, the
gateway auto-registers those seven routes pointing at that agent, with sane per-route defaults
(D5). This is NOT the same as declaring them by hand in routes: — no boilerplate needed for the
common case. See D6d for the one confirmed refinement: a whole-surface path prefix is supported,
per-endpoint remapping is not.
D3a — Tree placement: a dedicated sub-supervisor (addendum, decided 2026-07-30)¶
The design didn't originally nail down WHERE the two objects (D3) land in the supervision tree.
Resolved by review: Option A — a topology_server YAML node builds a dedicated
Supervisor(name=f"{name}_supervisor", children=[TopologyAgent(name), HTTPGateway(f"{name}_gateway")]).
The two alternatives (splice as siblings into the parent; or hide the gateway entirely) were
rejected: siblings inherit the parent's restart strategy (a gateway crash could perturb the user's
own one_for_all subtree) and require changing _build_node's one-node-in/one-node-out contract;
hiding the gateway breaks the privileged-injection contract (the TopologyAgent MUST be a normal
supervised tree node to receive Runtime/ComponentSet wiring). The dedicated sub-supervisor
gives each half its own ONE_FOR_ONE restart, treats the pair as one structural unit (matching
"internally-owned gateway"), and leaves the user's sibling nodes untouched.
Observable consequence, accepted: /topology now shows a {name}_supervisor node with two
children ({name} the TopologyAgent, {name}_gateway the HTTPGateway) where a bare
topology_server node used to show as one agent node. Unavoidable once HTTP-serving is a separate
process from data-providing. The client side is unaffected — the dashboard/CLI still discover the
endpoint by host:port (unchanged), not by tree shape or node name.
Injection dispatch: all of Runtime's isinstance(agent, TopologyServer) checks (privileged
injection loop, auto-MetricsCollector provisioning, child-replaced callback, print_tree tag)
become isinstance(agent, _TopologyIntrospection) — the shared base of both TopologyServer (until
phase 6) and TopologyAgent — so the transition works for both, and keeps working unchanged after
phase 6 deletes TopologyServer.
D3 — YAML stays backward compatible: type: topology_server keeps existing, reinterpreted¶
The topology_server YAML node type is not removed. Runtime's topology loader keeps
building something from it, but instead of a raw TopologyServer, it now constructs two
objects under the hood: a plain TopologyAgent(GenServer) (privileged-injected as today) and an
internally-owned HTTPGateway configured with topology_agent=<that agent's name>. The existing
config: {host, port} block maps onto the internal gateway's host/port unchanged. A new
optional config: {auth: {...}} sub-block (reusing GatewayAuthConfig/http_gateway's existing
middleware: list shape) is the opt-in auth surface — omitted entirely, the behavior is
byte-for-byte what exists today (no auth, same default host/port).
This means zero YAML migration for the simple case. Every existing topology.yaml with a bare
type: topology_server node keeps working unchanged, including examples/dashboard_demo/'s and
the 4 files currently declaring it.
Optional refinement, not required for the base merge: config: {attach_to: <gateway-name>} to
mount the introspection routes onto an already-declared http_gateway node instead of
spinning up a dedicated one — for deployments that want introspection and agent-facing routes on
one port. Worth having, not blocking.
D4 — GatewayResponse gains a governed raw-body escape hatch, for /metrics only¶
GatewayResponse gains two new optional fields: raw_body: bytes | None = None,
content_type: str | None = None. _respond() checks raw_body is not None before the existing
json.dumps(response.body) path. This is only reachable for routes explicitly marked
(RouteEntry.raw_response: bool = False — auto-set True only for the auto-registered /metrics
route from D2) — not a general "any handler can return anything" hole. TopologyAgent.handle_call
returns a plain dict as GenServer requires ({"__raw_body__": ..., "__content_type__": ...});
_result_to_response() recognizes this sentinel only when entry.raw_response is set, and
converts it to GatewayResponse(raw_body=..., content_type=...).
D5 — Per-route auth, resolved here, not deferred again¶
This is where the actual AAA questions from the parent conversation get answered concretely:
/healthgets no auth middleware by default, even when the rest of the introspection routes do — matches the universal expectation that liveness checks stay reachable (k8s probes, LB health checks) — and is configurable per the samemiddleware:mechanism if an operator disagrees./topology,/agents,/agents/{name},/snapshot,/metrics,/processesall read the sameconfig.authmiddleware list — one shared read-tier, matching how they're all read-only today. No per-endpoint differentiation needed yet because there's no per-endpoint sensitivity differential yet — that changes once write routes exist (§5 migration plan, step 7).- This is also what unblocks the rest of v0.9.5's backlog (suspend/resume, kill, mailbox
ops): once write routes exist, they're just additional
RouteEntryitems with their own, stricter middleware list — the exact per-route AuthZ granularity that was previously described as "not yet designed" now falls directly out of the route table's existing shape, at zero extra mechanism cost.
D6 — TopologyServer (old class): removed — deliberate breaking change (decided)¶
TopologyServer is a public, importable name (from civitas import TopologyServer).
Decided: option (b) — TopologyServer is removed entirely, matching this project's history
of explicit, documented breaking changes when justified (e.g. v0.9.3's /metrics→/snapshot
rename). YAML users are unaffected (D3 preserves the type: topology_server node shape); only
direct-construction Python callers (TopologyServer(name, host, port) outside of YAML) need to
migrate to constructing an HTTPGateway + TopologyAgent themselves. This needs a CHANGELOG
"Breaking" callout and a migration note at release time.
D6b — config: {auth: {...}} reuses GatewayAuthConfig verbatim (decided)¶
A topology_server node's optional auth: block reuses GatewayAuthConfig exactly as
http_gateway nodes already do (mtls: {ca_cert, client_cert_mode, mtls_source,
trusted_proxy_cidrs}, plus a middleware: list for API-key/JWT) — one auth-config shape in the
whole YAML schema, not a second parallel one. Additional, genuinely topology-introspection-specific
fields can be added later if a real need shows up; none is known today.
D6c — attach_to (mount onto an existing gateway): deferred, tracked (decided)¶
The optional config: {attach_to: <gateway-name>} refinement (mount introspection routes onto an
already-declared http_gateway node, sharing one port/process) is a real, legitimate deployment
simplification but is explicitly deferred — not part of this migration (D1-D6). Tracked in
docs/milestones.md as a follow-on once the base merge (dedicated-internal-gateway-per-
topology_server) is built, tested, and released — not silently dropped.
D6d — Fixed routes, with an optional whole-surface prefix (decided, refines D2)¶
The seven introspection paths themselves stay fixed and non-remappable (confirmed: no
per-endpoint renaming, no removing/adding individual endpoints, no changing methods — rejected as
over-general for what is a small, canonical, always-the-same introspection surface). Refinement
from review: a topology_server node's config: block gains an optional prefix: str = ""
field, applied uniformly to all seven fixed paths — e.g. prefix: "/v1" turns them into
/v1/health, /v1/topology, /v1/agents, /v1/agents/{name}, /v1/snapshot, /v1/metrics,
/v1/processes. This is deliberately simple: one prefix for the whole surface, not per-endpoint
control — matches "simplify for now" from review; a richer remapping scheme can be revisited later
if a real need for it shows up. GatewayConfig gains a matching topology_prefix: str = "" field
that D2's auto-registration logic consults when building the seven RouteEntry path patterns.
D7 — Follow-on, not blocking this merge: dashboard/client.py needs to learn to send auth headers¶
Once a topology_server/http_gateway-merged node can require auth, civitas dashboard/
civitas top/topology show --live's polling client (civitas/dashboard/client.py) needs a way
to attach Authorization/X-API-Key headers to its requests (today it sends none). This is real,
necessary follow-on work for auth to be usable end-to-end, but is separable from this merge —
tracked here so it isn't lost, not scheduled as part of D1-D5.
4. What does NOT change¶
- The privileged injection contract (
_root_supervisor/_agents/_metrics_collector,Runtime-owned direct references, no bus hop) — this isTopologyAgent's entire reason to exist and stays exactly as designed today. civitas/cli/_topology_discovery.py'sfind_topology_server()— since the YAML node type and itsconfig: {host, port}shape are unchanged (D3), this file needs no functional change.- Multi-cluster (v0.9.4) — each cluster's
topology_servernode still maps to its own internally-owned gateway/port; thecivitas dashboard a.yaml b.yamlshape is unaffected. - The JSON response shapes of all six JSON endpoints —
TopologyAgent'shandle_callcalls the exact same, unchanged serializer methods.
5. Migration plan (phased, each phase independently testable)¶
GatewayResponseraw-body escape hatch (D4) — small, isolated, testable against a synthetic route before anything else changes. No behavior change for existing JSON routes.- Build
TopologyAgent(GenServer)wrapping today's serializer logic viahandle_call, verified for byte-for-byte JSON parity against the currentTopologyServer(same fixtures, same expected bodies) — a pure refactor, no new behavior yet. GatewayConfig.topology_agent+ auto-route-registration (D2/D6d) — teachHTTPGatewayto mount the seven fixed routes when configured, with the D5 per-route auth defaults and the optionaltopology_prefixapplied uniformly.Runtime's topology loader (D3) —type: topology_servernow constructs theTopologyAgent+ internally-ownedHTTPGatewaypair; verify every existing example topology (examples/dashboard_demo/, the 3 doc-referenced ones) still round-trips unchanged with zero YAML edits.- Real end-to-end verification: a real running
civitas runprocess,civitas dashboardattaching to it exactly as today (no client-side changes needed per D7's scoping), a real Prometheus scrape against the merged/metrics(proving D4's escape hatch works against a real scraper, not just a unit test), and — new — a real JWT-protected request against/topologyproving the inherited auth actually rejects/accepts correctly. - D6 execution — remove the old
TopologyServerclass entirely (deliberate breaking change, decided); delete the deadasyncio.start_server/_handle_connection/_route_httpcode, which becomes fully dead the moment step 4 lands. CHANGELOG "Breaking" callout + migration note for any direct-construction (non-YAML) caller. - Only after 1-6 are done and released: build the actual write actions (suspend/resume, the next AAA backlog item) as new routes on this now-merged, now-auditable surface.
6. Review outcome (2026-07-30) — all four questions resolved¶
- D6: (b) — deliberate breaking change.
TopologyServeris removed; YAML unaffected. - D6b:
config: {auth: {...}}reusesGatewayAuthConfigverbatim, with room to add topology-introspection-specific fields later if a genuine need appears. - D6c:
attach_to(mount onto an existinghttp_gateway) is deferred — tracked, not built as part of this migration. - D6d: fixed, auto-registered routes confirmed, refined with one addition: a whole-surface
prefix(e.g./v1) is supported; per-endpoint remapping is not ("simplify for now").
All open questions resolved — proceeding to implementation per the §5 migration plan.