node public-domain: asking what it is no longer takes it away

`node public-domain <name>` cleared the domain. It reads like a question — it is exactly
what anybody types to find out what the answer is — and it silently took every routed
name the node had. There is no output that makes up for that: by the time it prints, the
fact is gone, and the mesh cannot tell a person what a domain used to be.

The bare form reports now. Clearing is still a real thing to want — a machine that stops
facing the outside composes no names, and lab-versus-production is this one setting
(novox/hq ADR 0056) — so it keeps a way to be said, by name: `--clear`. A domain and
`--clear` together are refused rather than one of them silently winning.

The other `node` subcommands were checked. `add`, `list` and `show` write nothing they
were not asked to, so there is nothing to make consistent with.

`overlay place <node>` with no flags has the same shape — it clears the endpoint, the
site and the hub flag — and is deliberately left alone here. It is a verb rather than a
question and every caller passes flags, so the fix is a different judgement and belongs
in its own change.

The usage text gains the three forms, and `module forget`'s new flag, neither of which
it named before.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-10 21:11:48 +02:00
parent d032fe6e8d
commit acbb8cf6da
3 changed files with 177 additions and 22 deletions
+5 -1
View File
@@ -123,6 +123,9 @@ func usage() {
node add <name> create a node record node add <name> create a node record
node list the nodes this mesh knows about node list the nodes this mesh knows about
node show <name> what one machine reported it can do, and why node show <name> what one machine reported it can do, and why
node public-domain <name> the domain it composes its routed names under
node public-domain <name> <d> ...set it to d
node public-domain <name> --clear ...it faces the outside no longer
token issue --node <name> a one-time right to join, for an existing record token issue --node <name> a one-time right to join, for an existing record
token issue --new <name> create the record and issue for it token issue --new <name> create the record and issue for it
identity show this control plane's signing key identity show this control plane's signing key
@@ -134,7 +137,8 @@ func usage() {
module add <file> register a module from its manifest module add <file> register a module from its manifest
module list what modules this mesh knows about module list what modules this mesh knows about
module moved <name> <commit> the source has a newer commit than the mesh built module moved <name> <commit> the source has a newer commit than the mesh built
module forget <name> remove one, unless a node is running it module forget <name> remove one, unless a node runs it or the mesh holds things for it
module forget <name> --and-what-it-holds ...and discard its settings, secrets and ports too
module issue <name> --node <m> a broker account for a module, scoped to its emits and consumes module issue <name> --node <m> a broker account for a module, scoped to its emits and consumes
status [--json] what is wrong, what is quiet, and what is out of date status [--json] what is wrong, what is quiet, and what is out of date
board [--listen ADDR] the same three questions, as a page that holds nothing board [--listen ADDR] the same three questions, as a page that holds nothing
+73 -21
View File
@@ -22,7 +22,7 @@ import (
func nodeCommand(ctx context.Context, args []string) error { func nodeCommand(ctx context.Context, args []string) error {
if len(args) == 0 { if len(args) == 0 {
return errors.New("node add <name>, node list, node show <name>, or node public-domain <name> [domain]") return errors.New("node add <name>, node list, node show <name>, or " + publicDomainUsage)
} }
open, err := openStores(ctx) open, err := openStores(ctx)
if err != nil { if err != nil {
@@ -66,32 +66,84 @@ func nodeCommand(ctx context.Context, args []string) error {
return nil return nil
case "public-domain": case "public-domain":
// The domain this node composes its routed names under (novox/hq ADR 0056). Given a domain, // The domain this node composes its routed names under (novox/hq ADR 0056).
// 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). // **The form with no argument reports; clearing is asked for by name.** It used to clear —
if len(args) < 2 || len(args) > 3 { // so `node public-domain anchor`, which reads like a question and is what anybody types to
return errors.New( // find out what the answer is, silently took every routed name the node had. A read-shaped
"node public-domain <name> [domain] — a domain sets it, nothing clears it") // invocation must never be a destructive write: there is no output that makes up for it,
} // because the damage is already done by the time it prints.
domain := "" return publicDomain(ctx, inv, args[1:])
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: default:
return fmt.Errorf("node has no %q; it has add, list, show and public-domain", args[0]) return fmt.Errorf("node has no %q; it has add, list, show and public-domain", args[0])
} }
} }
// publicDomainUsage is the one description of the three forms, so a refusal and the help agree.
const publicDomainUsage = "node public-domain <name> — what it is now; " +
"<name> <domain> to set it; <name> --clear to take it away"
// publicDomain reads, sets or clears the domain a node composes its routed names under.
//
// Three forms, and the destructive one is the only one that has to be asked for. Clearing is a
// real thing to want — a machine that stops facing the outside composes no names, and
// lab-versus-production is this one setting (novox/hq ADR 0056) — so it keeps a way to say it.
// What it does not keep is being the thing that happens when nothing was said at all.
func publicDomain(ctx context.Context, inv *inventory.Inventory, args []string) error {
set := flag.NewFlagSet("node public-domain", flag.ContinueOnError)
clear := set.Bool("clear", false, "take the domain away; it composes no routed names after")
positionals, err := parseAround(set, args)
if err != nil {
return err
}
if len(positionals) == 0 || len(positionals) > 2 {
return errors.New(publicDomainUsage)
}
node := positionals[0]
switch {
case *clear && len(positionals) == 2:
// Both, which cannot be meant. Refused rather than one of them silently winning.
return fmt.Errorf("give %s a domain or --clear, not both: %q and --clear say opposite "+
"things and the mesh will not choose between them", node, positionals[1])
case *clear:
if err := inv.SetPublicDomain(ctx, node, ""); err != nil {
return err
}
fmt.Printf("%s has no public domain, so it composes no routed names\n", node)
fmt.Printf(" run `push %s` to take them off it\n", node)
return nil
case len(positionals) == 2:
if err := inv.SetPublicDomain(ctx, node, positionals[1]); err != nil {
return err
}
fmt.Printf("%s composes its routed names under %s\n", node, positionals[1])
fmt.Printf(" run `push %s` to send it\n", node)
return nil
default:
// Asked, so answered. NodeByName first, so a name the mesh has never heard of is a refusal
// rather than "it has no public domain", which is true of that name and says nothing.
if _, err := inv.NodeByName(ctx, node); err != nil {
return err
}
domain, err := inv.PublicDomainOf(ctx, node)
if err != nil {
return err
}
if domain == "" {
fmt.Printf("%s has no public domain, so it composes no routed names\n", node)
fmt.Printf(" `node public-domain %s <domain>` gives it one\n", node)
return nil
}
fmt.Printf("%s composes its routed names under %s\n", node, domain)
return nil
}
}
func tokenCommand(ctx context.Context, args []string) error { func tokenCommand(ctx context.Context, args []string) error {
if len(args) == 0 || args[0] != "issue" { if len(args) == 0 || args[0] != "issue" {
return errors.New("token issue --node <name>, or token issue --new <name>") return errors.New("token issue --node <name>, or token issue --new <name>")
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"strings"
"testing"
)
// **A read-shaped invocation is never a destructive write.**
//
// `node public-domain anchor` used to clear the domain. It reads like a question — it is what
// anybody types to find out what the answer is — and it silently took every routed name the node
// had. There is no output that makes up for that: by the time it prints, the fact is gone.
func TestAskingForANodesPublicDomainDoesNotTakeItAway(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
if err := open.inventory.SetPublicDomain(ctx, "anchor", "example.test"); err != nil {
t.Fatal(err)
}
if err := publicDomain(ctx, open.inventory, []string{"anchor"}); err != nil {
t.Fatal(err)
}
domain, err := open.inventory.PublicDomainOf(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
if domain != "example.test" {
t.Fatalf("asking what the domain is took it away: %q", domain)
}
}
// Clearing is a real thing to want — a machine that stops facing the outside composes no names —
// so it keeps a way to be said. What it stops being is what happens when nothing was said.
func TestClearingANodesPublicDomainIsAskedForByName(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
if err := open.inventory.SetPublicDomain(ctx, "anchor", "example.test"); err != nil {
t.Fatal(err)
}
if err := publicDomain(ctx, open.inventory, []string{"anchor", "--clear"}); err != nil {
t.Fatal(err)
}
domain, err := open.inventory.PublicDomainOf(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
if domain != "" {
t.Fatalf("--clear did not clear it: %q", domain)
}
}
// A domain sets it, as it always did.
func TestGivingANodeAPublicDomainSetsIt(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
if err := publicDomain(ctx, open.inventory, []string{"anchor", "example.test"}); err != nil {
t.Fatal(err)
}
domain, err := open.inventory.PublicDomainOf(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
if domain != "example.test" {
t.Fatalf("got %q", domain)
}
}
// A domain and --clear say opposite things. Refused rather than one of them silently winning.
func TestADomainAndClearTogetherIsRefused(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
if err := open.inventory.SetPublicDomain(ctx, "anchor", "example.test"); err != nil {
t.Fatal(err)
}
err := publicDomain(ctx, open.inventory, []string{"anchor", "other.test", "--clear"})
if err == nil {
t.Fatal("a domain and --clear together were accepted")
}
if !strings.Contains(err.Error(), "not both") {
t.Fatalf("the refusal does not say why: %v", err)
}
// And neither half happened.
domain, err := open.inventory.PublicDomainOf(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
if domain != "example.test" {
t.Fatalf("a refused command changed something: %q", domain)
}
}
// Asking about a name the mesh has never heard of is a refusal, not "it has no public domain" —
// which is true of that name and says nothing.
func TestAskingAboutAMachineTheMeshHasNeverHeardOfIsRefused(t *testing.T) {
open := aMesh(t)
if err := publicDomain(t.Context(), open.inventory, []string{"nowhere"}); err == nil {
t.Fatal("a name the mesh does not know was answered as if it were a machine")
}
}