edge: wildcard parents — always emit the class mark, catch-all-blind wake, spec amendment (#300 round 2)
An absent platform key now means a pre-mark publisher, never a box homing nothing platform-class. The wake predicate applies Hosts()'s own filter so a marked catch-all coming or going wakes nobody. Tests for a name losing its mark (the rollback case), catch-all churn, the no-home-map edge and non-ready rows; spec records the wire growth and the home-boxes-first roll order.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+63
−12
docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md+10 −1internal/edge/homemap.go+12 −5internal/edge/wildcard_parents_test.go+23 −0internal/host/edgesfile.go+5 −3internal/host/edgesfile_test.go+13 −3
diff --git a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md| index 530fcf771..8bf68d77c 100644 |
| --- a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md |
| +++ b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md |
| @@ -396,7 +396,16 @@ timestamp writes nothing. Edge writes do NOT bump `control/serial` — |
| the serial stays console-written per the writer map, matching Part 2's |
| certs ruling; edges mirror the `control/edges/` prefix on their own |
| 30-second poll, the same cadence the certs mirror uses. Every edge |
| -folds these files into a deployment-wide host→home map. The file |
| +folds these files into a deployment-wide host→home map. **Amendment |
| +(2026-08-26, #300):** each host object also carries `platform: bool` |
| +(additive, always written) — the publisher's class mark for |
| +platform-minted rows (registry Platform or Provisioned). Every edge |
| +derives its wildcard-parent set from the DEPLOYMENT's marked names |
| +(its own routes ∪ the home map's marked hosts), not the box's own |
| +routes, so a region edge serves the account wildcard of an app it only |
| +backhauls to. Fail closed: a file without the key (pre-mark publisher) |
| +contributes no parents until its box republishes, so home boxes roll |
| +before region edges. The file |
| doubles as the DR runbook's explicit statement of what a dead box was |
| serving. **Keys are registry row hosts verbatim, including `*.parent` |
| catch-all rows** — this writer performs no wildcard expansion, so a |
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go| index abebf535c..d8931fcf2 100644 |
| --- a/internal/edge/homemap.go |
| +++ b/internal/edge/homemap.go |
| @@ -321,22 +321,29 @@ func (m *homeMap) refreshHosts(ctx context.Context) error { |
| func samePlatformSet(a, b map[string]homeEntry) bool { |
| na, nb := 0, 0 |
| for h, e := range a { |
| - if !e.platform { |
| + if !parentInput(h, e) { |
| continue |
| } |
| na++ |
| - if eb, ok := b[h]; !ok || !eb.platform { |
| + if eb, ok := b[h]; !ok || !parentInput(h, eb) { |
| return false |
| } |
| } |
| - for _, e := range b { |
| - if e.platform { |
| + for h, e := range b { |
| + if parentInput(h, e) { |
| nb++ |
| } |
| } |
| return na == nb |
| } |
| +// parentInput is the one filter Hosts and samePlatformSet share: a marked, |
| +// exact (non catch-all) name. A catch-all coming or going cannot move a |
| +// parent, so it must not wake the manager either. |
| +func parentInput(host string, e homeEntry) bool { |
| + return e.platform && !strings.HasPrefix(host, "*.") |
| +} |
| + |
| // Hosts is every exact PLATFORM-class name the deployment's OTHER boxes |
| // home. Sorted, nil-safe. The wildcard manager unions these with this box's |
| // own routes so its parent set is the deployment's, not the box's (#300): |
| @@ -360,7 +367,7 @@ func (m *homeMap) Hosts() []string { |
| snap := m.snapshot() |
| out := make([]string, 0, len(snap.hosts)) |
| for h, e := range snap.hosts { |
| - if !e.platform || strings.HasPrefix(h, "*.") { |
| + if !parentInput(h, e) { |
| continue |
| } |
| out = append(out, h) |
diff --git a/internal/edge/wildcard_parents_test.go b/internal/edge/wildcard_parents_test.go| index 76bf9e4cb..a97052123 100644 |
| --- a/internal/edge/wildcard_parents_test.go |
| +++ b/internal/edge/wildcard_parents_test.go |
| @@ -67,6 +67,25 @@ func TestHomeMapHostsAndWakeOnChange(t *testing.T) { |
| t.Fatalf("Hosts = %v; want %v", got, want) |
| } |
| + // A name LOSING its mark (a home box rolled back below the mark) is a |
| + // change: the parent must leave, and the manager must hear about it. |
| + putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"4.4.4.4","hosts":{"new.bae.apps.test":{"role":"home"}}}`) |
| + refreshHomeMap(t, m) |
| + waitWoke(t, &woke, 4) |
| + if got, want := m.Hosts(), []string{"other.bad.apps.test"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("Hosts after unmark = %v; want %v", got, want) |
| + } |
| + // A marked catch-all coming and going moves nothing and wakes nobody. |
| + putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"4.4.4.4","hosts":{"new.bae.apps.test":{"role":"home"},"*.claimed.apps.test":{"role":"home","platform":true}}}`) |
| + refreshHomeMap(t, m) |
| + time.Sleep(20 * time.Millisecond) |
| + if woke.Load() != 4 { |
| + t.Fatalf("a catch-all woke the manager (%d)", woke.Load()) |
| + } |
| + if got, want := m.Hosts(), []string{"other.bad.apps.test"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("Hosts after catch-all = %v; want %v", got, want) |
| + } |
| + |
| var nilMap *homeMap |
| if nilMap.Hosts() != nil { |
| t.Fatal("nil map must report no hosts") |
| @@ -118,7 +137,11 @@ func TestDeploymentHostsUnionsTheHomeMap(t *testing.T) { |
| if want := []string{"cribado.bad.apps.test", "own.acct.apps.test"}; !reflect.DeepEqual(got, want) { |
| t.Fatalf("deploymentHosts = %v; want %v", got, want) |
| } |
| + // No deployment store, no home map: a single-box deployment is its own. |
| lone := testIssuerEdge("own.acct.apps.test") |
| + if lone.homes.Load() != nil { |
| + t.Fatal("test edge unexpectedly has a home map") |
| + } |
| if got := lone.deploymentHosts(); !reflect.DeepEqual(got, lone.currentHosts()) { |
| t.Fatalf("no home map: deploymentHosts %v != currentHosts %v", got, lone.currentHosts()) |
| } |
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| index 09991129b..e40f4bea8 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -38,9 +38,11 @@ type edgeHost struct { |
| Role string `json:"role"` |
| // Platform marks a row the platform minted (registry Platform or |
| // Provisioned) — the class edge.currentHosts admits to wildcard-parent |
| - // derivation. Additive (omitempty): a reader seeing no flag treats the |
| - // host as unclassed and derives no parent from it (#300). |
| - Platform bool `json:"platform,omitempty"` |
| + // derivation. Additive; always emitted (never omitempty), so a file with |
| + // no "platform" key at all is definitely a pre-mark publisher, not a box |
| + // homing nothing platform-class. A reader seeing no flag treats the host |
| + // as unclassed and derives no parent from it (#300). |
| + Platform bool `json:"platform"` |
| } |
| // edgesFile is the wire shape at edgesFileKey(box), schema v1 (spec Part 3): |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| index 4ec99aef9..79966559a 100644 |
| --- a/internal/host/edgesfile_test.go |
| +++ b/internal/host/edgesfile_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "encoding/json" |
| "path/filepath" |
| + "strings" |
| "testing" |
| "time" |
| @@ -263,7 +264,7 @@ func TestEdgesFileRawWireBodyMatchesSchema(t *testing.T) { |
| } |
| want := `{"v":1,"box":"annie-welding","eip":"99.81.104.219","generation":"` + |
| - probe.Generation + `","hosts":{"app.example.com":{"role":"home"}}}` |
| + probe.Generation + `","hosts":{"app.example.com":{"role":"home","platform":false}}}` |
| if string(got) != want { |
| t.Fatalf("raw wire body =\n%s\nwant\n%s", got, want) |
| } |
| @@ -305,7 +306,7 @@ func TestEdgesFileRawWireBodyRegionField(t *testing.T) { |
| } |
| gen := genOf(t, got) |
| want := `{"v":1,"box":"annie-welding","eip":"99.81.104.219","generation":"` + |
| - gen + `","hosts":{"app.example.com":{"role":"home"}}}` |
| + gen + `","hosts":{"app.example.com":{"role":"home","platform":false}}}` |
| if string(got) != want { |
| t.Fatalf("raw wire body (region unset) =\n%s\nwant\n%s", got, want) |
| } |
| @@ -324,7 +325,7 @@ func TestEdgesFileRawWireBodyRegionField(t *testing.T) { |
| } |
| gen := genOf(t, got) |
| want := `{"v":1,"box":"annie-welding","eip":"99.81.104.219","region":"eu-west-1","generation":"` + |
| - gen + `","hosts":{"app.example.com":{"role":"home"}}}` |
| + gen + `","hosts":{"app.example.com":{"role":"home","platform":false}}}` |
| if string(got) != want { |
| t.Fatalf("raw wire body (region set) =\n%s\nwant\n%s", got, want) |
| } |
| @@ -575,6 +576,7 @@ func TestEdgesFileMarksPlatformRows(t *testing.T) { |
| {Host: "app.acct.apps.test", Kind: "service", Addr: "10.0.0.1:3000", Status: "ready", Platform: true}, |
| {Host: "prov.acct.apps.test", Kind: "service", Addr: "10.0.0.1:3001", Status: "ready", Provisioned: true}, |
| {Host: "stray.acct.apps.test", Kind: "service", Addr: "10.0.0.1:3002", Status: "ready"}, |
| + {Host: "pending.acct.apps.test", Kind: "service", Addr: "10.0.0.1:3003", Status: "pending", Platform: true}, |
| } { |
| if err := reg.Upsert(r); err != nil { |
| t.Fatal(err) |
| @@ -601,4 +603,12 @@ func TestEdgesFileMarksPlatformRows(t *testing.T) { |
| t.Errorf("%s platform = %v; want %v", host, got, want) |
| } |
| } |
| + if _, ok := f.Hosts["pending.acct.apps.test"]; ok { |
| + t.Error("a non-ready row is published regardless of class") |
| + } |
| + // The mark is always written, so a keyless host object can only mean a |
| + // pre-mark publisher. |
| + if !strings.Contains(string(body), `"stray.acct.apps.test":{"role":"home","platform":false}`) { |
| + t.Errorf("mark not emitted for an unclassed row: %s", body) |
| + } |
| } |