Directory router
Once cells can find and reach each other, something has to turn a name into a target. A browser arrives at a public URL; a message is addressed to an entity; a service needs to call another service. The directory routing layer answers all three, and it answers them with one governing rule: deny-closed. When the answer is empty, unmapped, ambiguous, or unproven, the router refuses, it never falls back to a plausible-looking wrong target.
That rule is the whole point. A router that guesses is worse than one that fails, because a wrong node, a wrong inbox, or an unauthenticated gateway is a cross-workspace leak waiting to happen. Every resolution path on this page is built to distinguish "no answer" from "one answer" and to treat everything in between (zero matches, many matches, corrupt matches) as a refusal.
Three resolvers, one discipline
The directory routing layer is not one function but three cooperating resolvers, each turning a different kind of name into a different kind of target:
| Resolver | Input | Output | Refusal signal |
|---|---|---|---|
| Front-door URL | A public front-door URL | A node's front door (its public-facing service group) | Returns nothing (HTTP 404) |
| Entity address | A system9:// address | An inbox target (workspace + inbox subject, local or remote) | Returns nothing; malformed input errors |
| Service scope | A (source, destination, service) scope | A ranked list of dialable endpoints | Returns an empty list |
The three share the deny-closed discipline but not their refusal convention, and the difference is worth stating once: the front-door and address resolvers signal refusal by returning nothing at all (a caller must treat that as "refuse," never as "use a default"); the service-scope resolver signals refusal by returning an empty list; and malformed input, a bad address string, an unknown selector key, raises rather than resolves. Same posture, three shapes.
Front-door URL → a node's front door
A node's front door is its public-facing service group, the reserved, admission-gated group that is the node's entry point from outside. Resolving a public URL to that front door is deliberately the strictest path in the system, because it is the one exposed to the open network.
The resolution is two reads and a normalization, with a refusal at every step:
- Normalize the URL: trim whitespace, drop exactly one trailing slash
(but never from a bare
scheme://root), lowercase the scheme and authority while preserving path case. The same normalizer is used when a URL is stored and when it is looked up, so the two always agree. - URL → node, by exact-host match on the normalized value against the registered gateway rows. Not a prefix match, exact. Uniqueness is enforced by a database constraint so one URL maps to one node per cell.
- Node → front door, by selecting the node's reserved public-facing service group.
Figure: the front-door URL resolve cascade. A URL is normalized, matched exact-host to a node, and mapped to that node's front door. Four branches (red) refuse deny-closed: an empty URL, an unmapped host, an ambiguous match spanning more than one node (a cross-cell collision) or corrupt zero-node rows, and a node that has no front-door group. Only the single-node, group-present path (green) resolves. Ceiling: refusal is surfaced over HTTP as 404 on every branch, and a URL-uniqueness collision on write is 409, the deny-closed contract is visible at the API edge, not just in the resolver.
Table equivalent
| Condition | Outcome |
|---|---|
| URL empty / blank after normalize | Refuse |
| Exact-host lookup finds 0 rows | Refuse (unmapped) |
| Matched rows span > 1 node | Refuse (ambiguous collision) |
| Matched rows span 0 nodes (corrupt) | Refuse |
| Matched node has no front-door group | Refuse |
| Exactly 1 node, front-door group present | Resolve to that node's front door |
Parity, done right. The front-door URL resolver exists on both the core and the edge, authored decision-for-decision, the same normalization, the same exact-host match, the same four deny-closed branches. It is not two implementations hoped to agree: a cross-language fixture is generated from the core resolver and checked against the edge, so a divergence fails a test rather than shipping. This is the parity precedent the platform copies, the contract stated once, both runtimes measured against it. On the edge the same refusal is served over an HTTP resolve endpoint that returns 404 on every refusal branch, matching the core.
Entity address → inbox target
The second resolver turns a system9:// address into a routable inbox. The
scheme has two forms:
- An entity address (
system9://{org}/{entity-type}/{entity-id}) names exactly one target. - A cell-selector address:
system9://{org}/cells?rack=…&provider=…&labels=k:vis declarative intent that resolves against live membership, so the same address names a different set of cells as the fabric scales in and out.
An entity address resolves down two branches depending on whether it names the local organization or a remote one:
- Local. Look up the entity; find its default workspace (falling back to an owned workspace, and for a secondary "lens" identity, to the base identity's home); build the inbox subject from that workspace. If the entity is unknown, or has no resolvable workspace, refuse.
- Remote. The router does not blindly forward across organizations. It
requires a trust edge from the local org to the target org whose relation is
peersorfederates; with no such edge it refuses. This is the deny-closed gate on cross-organization addressing.
Figure: entity-address resolution. A parsed address routes local (resolve
the entity's workspace and inbox subject) or remote (permitted only across a
peers/federates trust edge). Malformed input errors (amber) rather
than resolving; missing entity/workspace and a missing trust edge both refuse
deny-closed (red). Ceiling: remote resolution is deliberately a routability
check, not the dial, it confirms an address is reachable and carries a gateway
hint, while the actual endpoint selection happens at send time through the
service-scope resolver below. Address→inbox and scope→endpoint are two composable
halves, named as such, not one unified call.
Table equivalent
| Case | Gate | Outcome |
|---|---|---|
| Malformed address / bad selector key | Parse | Error (raises) |
| Local org, entity not found | Entity lookup | Refuse |
| Local org, no resolvable workspace | Workspace lookup | Refuse |
| Local org, workspace found | Workspace lookup | Inbox target (local) |
Remote org, no peers/federates edge | Trust-edge check | Refuse |
| Remote org, trust edge present | Trust-edge check | Routable target + gateway hint |
Service scope → dialable endpoint
The third resolver turns a (source scope, destination scope, service, protocol)
request into a ranked list of dialable endpoints, or an empty list if it
cannot find a compliant one. Its cascade is where the network policy lives:
- Network order. Prefer private, then VPN, then public.
- Policy gate, fail-closed. Authorize each candidate network. If the policy gate cannot even be consulted, the default is to deny all routing, a failed import of the gate does not open the door, it closes it. If no network is authorized, return empty.
- Intra-org vs cross-org. Determine whether source and destination share an organization; cross-org service routing is off unless explicitly allowed.
- Direct discovery first. Resolve the service by SRV/MX-style records walking the zone chain, preferring records that target the requested node.
- Gateway fallback. If direct discovery yields nothing, fall back to the destination node's gateways, or the org's authoritative nodes, or the org's gateways.
- mTLS enforcement. If policy required mutual TLS, exclude every gateway that does not itself require mutual client authentication, which can legitimately reduce the result to empty, meaning "no compliant route," not "no route."
- Rank and emit. Rank by health, TLS, mutual-TLS, round-trip time, priority, and cost tier; emit dial targets. No candidates → empty list.
Ceiling: this resolver refuses by returning an empty list, never by guessing a target; the load-bearing default is the fail-closed policy gate, if it cannot be consulted, all cross-cell routing is denied. mTLS enforcement is a filter, not a preference: a required-mTLS request drops non-compliant gateways entirely rather than ranking them lower.
Deny-closed, everywhere: the refusal ledger
Pulling the three resolvers together, every point at which the directory routing layer would rather refuse than guess:
| Where | Condition | Refusal |
|---|---|---|
| Front-door URL | Empty / unmapped / ambiguous / no front-door group | Returns nothing → HTTP 404 |
| Front-door URL (write) | Two URLs collide on one node uniqueness | HTTP 409 |
| Entity address | Malformed address / unknown selector key | Raises |
| Entity address (local) | Entity not found / no workspace | Returns nothing |
| Entity address (remote) | No peers/federates trust edge | Returns nothing |
| Service scope | Policy gate unavailable | Deny all (fail-closed default) |
| Service scope | No network authorized | Empty list |
| Service scope | No endpoint candidates | Empty list |
| Service scope | Required mTLS, no compliant gateway | Empty list (filtered out) |
Across the deployment matrix
| Corner | How directory routing behaves |
|---|---|
| Single-cell | The front door resolves to the local node's public-facing group; entity addresses resolve within the one org; service scopes resolve to local endpoints. Deny-closed still holds: an unmapped URL 404s on a single box too. |
| Multi-cell, cloud | Cross-org addressing gated on trust edges; service discovery walks the zone chain and ranks gateways across cells; mutual-TLS enforcement filters the candidates. |
| Air-gapped | No external DNS is required: exact-host front-door matching and zone-chain service records are all cell-local reads, so routing resolves with the link cut. Remote orgs simply present no reachable path. |
| Edge | The front-door resolver runs on the edge too, decision-for-decision with the core and pinned by the cross-language fixture, exposed over an HTTP resolve endpoint that 404s on every refusal. |
Two-runtime parity
- The front-door resolver is at parity, and proven so. The core and edge implementations are authored to make the same decision for the same input, and a fixture generated from the core resolver is checked on the edge, the divergence-fails-a-test discipline, not a hopeful reimplementation.
- The topology selector and resolve path is at parity. The declarative
cell-selector addressing (
system9://{org}/cells?…) resolves against a live membership view on both runtimes, likewise pinned by a cross-language fixture. - What is owed. Cross-cell enactment and the edge's egress-origin selection are recorded as owed work on the edge rather than narrated as live. See Core and edge for the ledger.
Related topics
- Finding, trusting, and reaching the cells these targets live on: Cell network fabric.
- The region → zone → rack → cell topology the service-scope resolver walks: Deployment topology.
- Cell peering and attested reachability, which decide whether a resolved endpoint is actually reachable: Joining the fabric.
- The parity discipline and owed-work ledger behind the two-runtime claims: Core and edge.