0007: Decouple Naming, Identity, and Routing
Status
Accepted
Refines the naming/SNI mechanism of ADR-0006: Implement mTLS in QuicEndpoint. The rete / mesh / cluster vocabulary this ADR uses is defined in ADR-0008: Distinguish Rete, Mesh, and Cluster.
Context
ADR-0006 settled mTLS and pinned the QUIC SNI to the .rete convenience hostname: the server was to read SNI, run dns::parse to recover a SpiffeId, and look up which published cert to present. identity.mdx defines that hostname as <service>.<rete>.rete (rete-scoped) or <service>.<node>.<rete>.rete (node-scoped), with <rete> standing in for the SPIFFE trust domain.
Implementing it surfaced three coupled problems that the convenience-name-as-SNI framing cannot resolve on its own:
-
The name→identity mapping is not injective, so the kind is lost.
dns::formatis total over the two dialable kinds (Service,Vertex), but a service and a vertex with the same name on the same node format to the same hostname — the shared.retenamespace ADR-0006 deliberately chose. A server that parses SNI therefore cannot recover whether the caller meant a service or a vertex, and so cannot resolve a vertex SVID at all. Harmless in C0 (services only); it breaks C1 mesh-transit, where flor dials vertices. -
Self-sufficient parse forces single-label trust domains. For
dns::parseto split<svc>.<node>.<rete>.retewithout external context,<rete>must be exactly one DNS label. That forbids dotted SPIFFE trust domains — the SPIFFE norm (cluster.local, and our own test domaindemo.flor) — undercutting the SPIFFE interop ADR-0005 sells, and it pushes operators to fake hierarchy with dashes, which does not scale to multi-rete or open federation. -
Treating SNI as the verbatim convenience name couples the wire to the human name. It fixes the on-wire encoding to the user-facing one, foreclosing an opaque or privacy-preserving routing label later, and conflates two concerns that change for different reasons.
We need a naming model that keeps the cryptographic identity authoritative, resolves human names with context, admits dotted trust domains, and frees the wire routing label to evolve — while staying trivial for single-rete C0 and open to multi-rete and an eventual post-IP namespace.
Decision
Separate three layers that the convenience-name-as-SNI design had collapsed into one string.
Three layers
- User-facing name — the
.reteconvenience hostname, which only services have (DNS-compatibility glue), carried byServiceHostname. Human/tool input; resolved to an identity. - Identity — the SPIFFE ID, and its validated connect-target view
Targetable. The canonical, durable name: it is what appears in cert SAN, ACLs, compiled artifacts, and the transport API. Authoritative. - Wire routing hint — SNI. Derived from the identity by the transport. The receiver recovers the identity by lookup, never by parsing it out of SNI; SNI itself is a routing label the transport may give richer structure than a bare identity (Istio encodes port and subset in it, for example) and route on. It is not the user-facing name — it is derived from the identity independently of it — and is free to evolve.
The identity layer is the stable core; the name and the routing hint are replaceable layers above and below it.
Resolution is contextual, not self-sufficient
Turning a name into an identity requires namespace context. In C0 the context is the caller's trust domain, and resolution is a pure-local string transform. Beyond C0, a network-aware Name Service (a separate layer, not part of the identity module) performs distributed lookup of the target's rete/trust-domain and identity. Both produce the same output; the local single-rete case is the degenerate one.
With context, the node-vs-rete shape is unambiguous even for dotted trust domains: strip the .rete suffix, strip the known trust-domain suffix, and the residual label count decides — 1 label ⇒ rete-scoped, 2 ⇒ node-scoped.
Targetable and ServiceHostname
Targetableis a validated identity newtype admitting exactly the kinds that can be a connection's target —ServiceandVertextoday,NodeandWorkloadreserved (Identity, ID-C0-15). It carries no hostname. The kind restriction is enforced where the value is born, not by a type zoo;publishbuilds one from each SVID it registers, and the Endpoint APIconnect(caller: &X509Svid, target: &Targetable)accepts any.ServiceHostnameis the.retecodec, for services alone.resolve(host, ctx_td) -> Result<ServiceHostname>strips.rete, strips the context trust domain, and lets the residual labels decide the scope; a non-matching trust-domain suffix is a foreign rete, unsupported in C0.render()is infallible concatenation,<service>[.<node>].<trust-domain>.rete. AServiceHostnameconverts into aTargetable, a service always being one.
The legacy path — SOCKS5 ingress — resolves a hostname to a ServiceHostname and connects to its Targetable; the native path — a FlorIO caller, or flor's own code dialing a vertex — builds a Targetable from an identity. Neither type checks DNS representability: that names of targetable kinds are single DNS labels is the validator's rule (Identity, ID-C0-12).
Dotted trust domains are allowed
No single-label constraint anywhere, and no short-name↔trust-domain map. Contextual resolution removes the need for either. render is pure concatenation and produces a valid DNS string for a dotted trust domain (tcp-echo.beta.flor.demo.rete); resolve strips the known trust domain, so the residual is unambiguous regardless of how many labels the trust domain has.
SNI is an internal routing hint, resolved by lookup
The transport derives SNI from the target alone, through sni_for(&Targetable), as a kind marker, a scope marker, the name, the node where there is one, the trust domain and .rete:
svc.rete.<service>.<td>.rete svc.rete.api.flor.demo.rete
svc.node.<service>.<node>.<td>.rete svc.node.ssh.alpha.flor.demo.rete
vtx.node.<vertex>.<node>.<td>.rete vtx.node.hop.alpha.flor.demo.reteA kind that becomes targetable takes a kind marker of its own. The markers come first and fix how many labels precede the trust domain, so two identities one endpoint publishes never share a label — even across trust domains, where a marker at the end would let a node label pass for the start of a dotted trust domain — and no name has to be reserved to keep it so. The server keys its published-cert registry by sni_for(...) and looks the incoming SNI up directly; it never parses an identity back out of SNI. Each registry entry carries the canonical SpiffeId (kind intact), so resolution is kind-preserving and needs no context on the server. SNI needs no inverse and no uniqueness beyond one endpoint: what it must do is let the server choose a certificate from the ClientHello alone, before it has seen the client's. The label reads back from a packet capture; because resolution is by lookup, it is free to become opaque or hashed so service names do not travel in clear, with no change visible to the identity layer. SNI is cleartext on the wire and visible to DPI; keeping sensitive routing data out of it, or adopting TLS Encrypted Client Hello (ECH), is a future option this decoupling preserves. SNI was never trusted (ADR-0006: SAN is authoritative); this formalizes it as a pure routing hint.
Exact SAN match remains the gate, and admits load balancing
The outbound verifier still gates on leaf SAN == expected target (ADR-0006). This is forward-compatible with load balancing without relaxation: a load-balanced (rete-scoped) service presents the rete-wide SAN; node-scope is an addressing/placement concern, never the client-facing SAN. An instance may hold more than one SVID — its node identity and the rete identity — which publish(Vec<X509Svid>) already supports. Both load-balancer shapes evolve from this with no verifier change:
- a TLS-terminating balancer presents the rete-wide SAN to the client and re-originates mTLS to node-scoped backends;
- a non-terminating coordinator steers the flow (e.g. QUIC connection-ID routing1) to a backend that completes the handshake directly with the client, presenting the rete-wide SAN; the coordinator holds no data-path SVID.
Rationale
Why decouple — Istio's precedent, with clearer names
Istio gives workloads SPIFFE identities — spiffe://<trust-domain>/ns/<namespace>/sa/<service-account>, default trust domain cluster.local — in the certificate's URI SAN, and authenticates peers by SAN match.2 Its SNI is a routing input, not identity: a mesh routes east-west through a gateway in AUTO_PASSTHROUGH mode that does not terminate TLS, forwarding by SNI,3 where that SNI is Istio's internal outbound_.<port>_.<subset>_.<hostname> form (e.g. outbound_.22_._.ssh-0.ssh-headless.infra.svc.cluster.local). Two lessons carry over — and they are exactly our three layers: identity lives in the SAN, and SNI is a routing label that may encode more than the identity (port, subset, hostname), distinct from the user-facing name.
On naming we are closer to Istio than it first looks — and cleaner. An Istio mesh is not cluster-bound: it spans multiple Kubernetes clusters, and a service is reached by the same app-facing name wherever its endpoints live (location transparency via cross-cluster endpoint discovery).4 A rete is the same — it is a trust domain, not a cluster (Florete has no cluster concept yet), free to span clusters — and a .rete name is likewise location-transparent. Neither Istio nor Florete puts a cluster identifier in the app-facing name, and both get the same location transparency.
Where we differ is in the clarity of the human-facing name, not in cluster-awareness:
- The trust domain is operator-named and visible. Istio's app-facing name carries a fixed
cluster.localsuffix — a leftover from its in-cluster origins, shared mesh-wide and rarely changed, so the name does not actually distinguish one trust domain from another. Florete lets the operator name the rete and surfaces it:<svc>.<rete>.retereads meaningfully and stays unambiguous across retes — the case that matters once retes federate. - No orchestration-isms. We drop the fixed
.svc.marker and the mandatory namespace infix of<svc>.<ns>.svc.cluster.local; those encode Kubernetes structure that is useless in a user-typed address. A.retename carries only what a human needs — service, optional node, rete. - The wire is decoupled from the name. What rides on the wire as SNI is
sni_for's business, resolved by lookup — free to carry richer or opaque routing later — while the human name and the SAN identity stay put.
So we borrow Istio's split (identity in the SAN, SNI as a routing hint) and its location transparency, and spend the freedom on a friendlier human surface: an explicit, operator-named trust domain and no orchestration-specific infixes.
Why contextual resolution over self-sufficient parse
The single-label constraint existed only to make parse context-free. It forbids dotted SPIFFE trust domains (interop loss), creates the node-vs-rete ambiguity for any multi-label suffix, and drives dash-mimicry. A short-name↔trust-domain map was considered and rejected: it works for small federations with known mappings but not for open/Internet-scale resolution. Contextual resolution degenerates to "ask your resolver," which scales the way DNS — and its eventual successor — does. The cost (names are no longer resolvable by pure string math) is the correct cost: a cross-rete name genuinely requires a directory.
Why Targetable rather than raw SpiffeId or a Service/Vertex type zoo
A raw SpiffeId target leaves "can this be a target?" as a runtime error at every call site and hides the from-anyone/to-a-target asymmetry. A precise Service/Vertex pair was considered, but nothing downstream dispatches on the distinction — the verifier and the registry both use the canonical SpiffeId — so it would be precision with no consumer. One validated Targetable, with the kind enforced where the value is born, makes illegal targets unrepresentable while keeping the SpiffeId as the single canonical value on the wire and in ACLs.
The type is named for what it guarantees — a principal that can be a target — while the binding names the role its value plays in one flow: connect(caller, target: &Targetable). A service that initiates is still targetable and is not the target of that flow, so a type named Target would be false wherever a value is held for anything else. Keeping the hostname codec off it, on ServiceHostname, is what lets native connections — which name targets by identity — share the one connect, and what unties the hostname from the wire label.
Why a marked SNI rather than the hostname
The hostname exists for what a person or a legacy application dials, and only services are dialled that way. Reusing it as SNI gave vertices a hostname nobody types and put a service and a vertex of one name on one node into one collision domain — a rule on names paid for by a projection with no consumer. A label of the transport's own, marked by kind and scope, keeps every identity an endpoint publishes distinct without any rule on names, and stays readable in a capture.
Why SNI by lookup, not parse
The receiver already holds the authoritative set of published identities, so resolving the on-wire label against that set (a) preserves the kind a vertex name would otherwise lose, (b) requires no context on the server, and (c) lets the wire encoding evolve independently of the human name. Parsing SNI back into an identity buys nothing and costs the kind.
Why exact SAN match survives load balancing
Relaxing the gate to a hierarchical match (service/<svc> accepting service/*/<svc>) would weaken it. Instead the load-balanced identity is a first-class rete-wide SVID that instances present, and node-scope stays addressing-only — keeping the tightest possible gate while admitting both balancer architectures. This also keeps placement (an orchestration concern) out of network identity.
Consequences
Benefits
- Dotted, SPIFFE-standard trust domains work from day one; interop is preserved.
- Single-rete C0 stays trivial (context = the local trust domain) yet the seam — a Name Service — is multi-rete- and Internet-ready with no rework of the identity layer.
- The wire routing hint can evolve (opaque/private SNI, alternative labels) without touching identities or user-facing names.
renderis infallible; illegalconnecttargets are unrepresentable viaTargetable; oneconnectserves the legacy and the native path.- Load balancing (both shapes) is reachable without changing the SAN gate or the
publish/registry model.
Trade-offs
- Name resolution is no longer a pure string function; it needs context (a trust domain now, a Name Service later). Cross-rete names cannot be resolved purely locally — by design.
- A service has two derived strings — its hostname and its wire label — which never coincide; nothing has to explain a coincidence between them.
- ADR-0006's SNI/
dns::parsemechanism is amended; thedns::format/dns::parsehelpers becomerender/resolveonServiceHostname, andconnecttakes aTargetable.
Evolution
- A Name Service replaces local
resolvefor federation; the context argument widens from&TrustDomainto a namespace/resolver abstraction. The call site (ingress) is the only consumer, so widening is cheap. - Multi-rete: cross-rete names carry the remote rete identifier; trust flows through shared or federated bundles, which ADR-0005's multi-authority trust set already supports.
- Post-IP / Internet-scale: the SPIFFE ID is the durable core;
.reteis DNS-compatibility glue that can thin out; resolution and the wire hint are the replaceable layers. We do not design that namespace now — we keep the seams that let it arrive without breaking identities.
Footnotes
-
QUIC-LB: Generating Routable QUIC Connection IDs — connection-ID routing lets a non-terminating coordinator steer flows without entering the mTLS session. ↩
-
Istio encodes workload identity as a SPIFFE URI —
spiffe://<trust-domain>/ns/<namespace>/sa/<service-account>, default trust domaincluster.local— in the certificate SAN, and authenticates peers by SAN match. See Istio: Security concepts and Teleport: How to secure microservices with SPIFFE and Istio. ↩ -
Istio DestinationRule —
AUTO_PASSTHROUGHTLS mode: the gateway routes by SNI without terminating TLS, preserving end-to-end mTLS — the mechanism multi-cluster east-west gateways use. ↩ -
Istio: Deployment Models — cross-cluster services are reached location-transparently via endpoint discovery, not by encoding a specific cluster identifier in the app-facing name. ↩