catalogue: compose a route's name from a label and its node's domain, and resolve it in-mesh

A public route used to carry its whole hostname as a literal in the module
manifest, so running the same catalogue against a different domain meant
overriding that literal on every routed module, per node. The mesh was, in
effect, holding a map of names to services: the one thing it should never hold,
because the subdomain is the operator's choice and the domain is the node's.

Compose instead. A route contribution carries a `label` (the subdomain); a node
carries its `public_domain` as node-level configuration; the mesh joins
`<label>.<public-domain>` and grants exactly that, interpreting neither half.
Held as a node property beside the node's other node-level facts (endpoint,
site, overlay address), not in a module's settings — the ADR calls it
node-level, and the settings table is keyed per module.

Additive, so an unmigrated catalogue keeps working: a contribution that still
carries a full `name` and no `label` passes through unchanged, and the catalogue
can migrate module by module. A labelled contribution on a node with no public
domain composes nothing, reading downstream as a route that named no host.

And propagate: each granted route name is published into internal resolution
mesh-wide, mapped to the node that serves it, alongside the `<node>.internal`
names every container already gets. So a container — and an internal ACME
validator, which cannot complete a challenge for a name it cannot reach —
resolves a routed name to the proxy that serves it. Name-agnostic throughout:
the mesh propagates whatever names it was told to serve and knows nothing about
what they mean.

novox/hq 02-DECISIONS/0056

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-09 23:27:35 +02:00
parent c147a26138
commit 232862315c
8 changed files with 448 additions and 5 deletions
+35 -2
View File
@@ -22,7 +22,7 @@ import (
func nodeCommand(ctx context.Context, args []string) error {
if len(args) == 0 {
return errors.New("node add <name>, node list, or node show <name>")
return errors.New("node add <name>, node list, node show <name>, or node public-domain <name> [domain]")
}
open, err := openStores(ctx)
if err != nil {
@@ -65,8 +65,30 @@ func nodeCommand(ctx context.Context, args []string) error {
}
return nil
case "public-domain":
// The domain this node composes its routed names under (novox/hq ADR 0056). Given a domain,
// it is set; given nothing, it is cleared — a node that stops facing the outside composes no
// names. Lab-versus-production is this one setting and nothing else (see the ADR).
if len(args) < 2 || len(args) > 3 {
return errors.New(
"node public-domain <name> [domain] — a domain sets it, nothing clears it")
}
domain := ""
if len(args) == 3 {
domain = args[2]
}
if err := inv.SetPublicDomain(ctx, args[1], domain); err != nil {
return err
}
if domain == "" {
fmt.Printf("%s has no public domain, so it composes no routed names\n", args[1])
} else {
fmt.Printf("%s composes its routed names under %s\n", args[1], domain)
}
return nil
default:
return fmt.Errorf("node has no %q; it has add and list", args[0])
return fmt.Errorf("node has no %q; it has add, list, show and public-domain", args[0])
}
}
@@ -261,6 +283,17 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
fmt.Printf("%s\n", node.Name)
fmt.Printf(" last heard from %s\n", heardFrom(node))
// The domain its routed names are composed under, when it has one (novox/hq ADR 0056). Shown
// only when set: a machine that serves nothing to the outside has no domain, and saying so of
// every internal node would be noise.
domain, err := inv.PublicDomainOf(ctx, name)
if err != nil {
return err
}
if domain != "" {
fmt.Printf(" public domain %s\n", domain)
}
held, err := inv.Profile(ctx, name)
if err != nil {
return err