Close two consumers that reached a sidecar, found by re-running the sweep
The design's §9.4 sweep was exhaustive against main at d7077d1. This branch
then sat from 2026-08-18 while main moved 600 commits, and two of the
consumers built in that window reach a Kind=="sidecar" row. Neither was
written carelessly; both were written before an unroutable serving class
existed to reason about.
THE EDGES FILE. internal/host/edgesfile.go publishes control/edges/<box>.json
naming the hosts a box homes, and its own comment states the rule it was
built to: every SERVED row, ALL kinds, the serving-class split belongs to
the reader. That holds only while every kind is servable. A sidecar row is
Status "ready" — that is what makes it runnable — so it passed the only
filter and went out as role:"home", the assertion that this box SERVES that
host.
What makes it a hole rather than untidiness is that the guarantee this
design leans on cannot reach it. §6.1's edge refusal lives in Reload, which
builds THIS box's routing map from rows THIS box holds. A remote edge holds
no sidecar row and never sees a Kind. It misses on its own upstream lookup,
reads this file through the home map, and backhauls — dialing the home box
with the sidecar's own name as SNI. A live proxy into the one member the
platform guarantees nothing can dial. The same entry also satisfies
deploymentHost, which is what admits a name to the cert machinery, and
feeds DeploymentRoutes, which the alias pass treats as fleet-wide truth.
So the guard goes at the WRITER, exactly where §6.2a puts
publishResidentFleetBindings': a sidecar is never advertised as served, by
this box or any other. With it removed the test gets the row back as
{Role:home}.
PATH-BASED ROUTING. ReconcileAliases derived two tables when the
reserved-suffix guard was written; main added a third, from the same claims,
through a branch the guard never sees. Both ends refused now, and they are
not one finding twice. The host end is the alias guard's argument verbatim.
The target end has nothing else in front of it: the cross-tenant check
passes a sidecar because it belongs to the claim's OWN account — the
ordinary case, not an edge one — and the readiness check passes it because
a live sidecar row is ready. It is the one field in that pass which becomes
a proxied upstream. Removing the guard mints
bot -> bot.eleven.bdm.sidecar.internal.
Both guards were verified by neutralising them and watching the tests fail,
rather than by writing a test that passes either way.
Also here: the sweep's full result in the design (§14), including the two
gaps left open on purpose — narrowed restarts reach no sidecar, and a
trashed app's sidecar rows are not retired. Both leave a sidecar alone
rather than acting on it wrongly, and both want a decision rather than a
merge.
Gate: go build, go vet, gofmt -l, the GOOS=darwin compile and
go test ./... -count=1 all clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dCiSN3FerTijCwUkraDfi
4 files changed,
+171
−4
docs/superpowers/specs/2026-08-17-sidecar-members-design.md+95 −0internal/domains/aliases_paths_test.go+6 −1internal/host/edgesfile.go+26 −3internal/host/edgesfile_test.go+44 −0
CI cancelled — superseded by a newer push — run details
diff --git a/docs/superpowers/specs/2026-08-17-sidecar-members-design.md b/docs/superpowers/specs/2026-08-17-sidecar-members-design.md| index 75184b49e..baf814f08 100644 |
| --- a/docs/superpowers/specs/2026-08-17-sidecar-members-design.md |
| +++ b/docs/superpowers/specs/2026-08-17-sidecar-members-design.md |
| @@ -659,3 +659,98 @@ result, per the no-infra rule. |
| a non-goal. |
| - Multi-box placement/failover for a sidecar: one record, one lease, one |
| box at a time, exactly as instances work. |
| + |
| +## 14. Amended 2026-09-05 — the sweep, redone against 600 commits of main |
| + |
| +§9.4's consumer sweep was exhaustive when it was written, against main at |
| +`d7077d1`. This branch then sat unmerged from 2026-08-18 while main moved 600 |
| +commits, and the sweep was re-run on the merge. It is recorded here rather |
| +than folded into §9.4 because the *lesson* is the interesting part: the |
| +consumer list is not a fact about the tree, it is a fact about the tree ON A |
| +DATE. Two of the three new consumers were built by people who had no reason |
| +to know an unroutable serving class was coming. |
| + |
| +### 14.1 The edges file publishes every served row, and that is now wrong |
| + |
| +`internal/host/edgesfile.go` was added to main after this branch. Every box |
| +publishes `control/edges/<box>.json` naming the hosts it homes, and its own |
| +comment states the rule it was built to: *every SERVED row on this box, ALL |
| +kinds — the serving-class split belongs to the reader, not to this writer.* |
| + |
| +That rule was coherent when every kind was servable. A sidecar row is |
| +`Status: "ready"` — that is what makes it runnable — so it passed the only |
| +filter and was published as `role: "home"`, which is the assertion *this box |
| +serves that host*. Three consumers act on it: |
| + |
| +- **`edge.backhaul`** (`internal/edge/edge.go`, via `homeMap`). This is the |
| + sharp one, and it is sharp because the guarantee this design leans on |
| + cannot reach it. §6.1's edge refusal lives in `Reload`, which builds THIS |
| + box's routing map out of rows THIS box holds. A remote edge holds no |
| + sidecar row and never sees a `Kind` at all. It misses on its own upstream |
| + lookup, reads this file, and dials the home box with the sidecar's own name |
| + as SNI — a live proxy into the one member the platform guarantees nothing |
| + can dial. |
| +- **`deploymentHost`** = `Allowed(host) || homes.KnownHost(host)`, which is |
| + what admits a name to the cert machinery. A published sidecar becomes |
| + orderable deployment-wide. |
| +- **`edgesfile.DeploymentRoutes`**, which feeds the alias pass's |
| + "routes always win" rule fleet-wide. |
| + |
| +The guard therefore belongs to the WRITER, exactly as §6.2a puts |
| +`publishResidentFleetBindings`' there: *a sidecar is never advertised as |
| +served, by this box or any other.* Pinned by |
| +`TestEdgesFileNeverPublishesASidecar`, which fails with the guard removed — |
| +the row comes back `{Role: home}`. |
| + |
| +### 14.2 Path-based routing mints into a third table |
| + |
| +`ReconcileAliases` derived two tables when §3's reserved-suffix guard was |
| +written. Main added `route_paths`, from the same claims, through a branch the |
| +guard never sees. Both ends are now refused, and they are not the same |
| +finding twice: |
| + |
| +- the **host** end is the alias guard's argument verbatim: a path row keyed on |
| + a reserved-suffix name puts that name in the routing map, and edge.go's |
| + Kind check never sees it, because the row it reads is a path row. |
| +- the **target** end has nothing else standing in front of it. The |
| + cross-tenant check passes a sidecar, because a sidecar belongs to the |
| + claim's own account — that is the ordinary case, not an edge one. The |
| + readiness check passes it, because a live sidecar row is ready. So the |
| + target field is the one place in this pass where an account can route real |
| + traffic into its own undialable member, and only a check on the name closes |
| + it. Removing the guard mints `bot -> bot.eleven.bdm.sidecar.internal`. |
| + |
| +### 14.3 Two gaps recorded, deliberately not closed here |
| + |
| +Both are in the safe direction — a sidecar is left alone rather than acted on |
| +wrongly — and both are follow-ups rather than merge work. |
| + |
| +- **Narrowed restarts reach no sidecar.** `carlos restart --host` (main, |
| + after this branch) resolves hosts through `scopedHosts`, which gates on |
| + `release.Adoptable`, which is `Kind == "instance"` and stays that way for |
| + §9.3's reason. So a narrowed restart naming a sidecar cycles nothing. That |
| + is the right failure — a scoped bounce with no wake beside it would strand |
| + a worker, which is precisely what `cycleWorkers` exists to prevent — but |
| + reaching a sidecar by name wants a selector of its own. |
| +- **A trashed app's sidecar rows are not retired.** #340 gave the instance |
| + pass a second answer off the trash marker (`removable`, guarded by the |
| + production flag) and moved retirement onto every box. The sidecar pass keeps |
| + the mint-only predicate: retirement is not a question it can answer without |
| + inheriting an authority its removal phase was not designed around. A |
| + trashed app's sidecars therefore linger until their records go — the |
| + pre-#340 behaviour for everything. |
| + |
| +### 14.4 Checked and correct, for the record |
| + |
| +`internal/schedule` gates on `Kind != "instance"`. `internal/steering` selects |
| +on the `Steering` column, which no sidecar record can set. `movespass` is |
| +instance-only. `status/fleet.go` filters by kind. Metrics and the economics |
| +pass attribute a sidecar's cgroup like any other exec child, which is §9.6's |
| +stated intent, not an oversight. |
| + |
| +One vocabulary inconsistency is left standing: `carlos status --instances` |
| +matches units to routes through `routeForUnit`, which matches a sidecar's |
| +`carlos-exec@<host>` cgroup, so a running sidecar is listed there under |
| +"instances" rather than in the separate bucket §9.5 gives it. The door is |
| +authenticated and account-scoped, so nothing leaks; it is a naming fix, and |
| +changing the shape of a status API is not merge work. |
diff --git a/internal/domains/aliases_paths_test.go b/internal/domains/aliases_paths_test.go| index 4c8792904..8cc76afb1 100644 |
| --- a/internal/domains/aliases_paths_test.go |
| +++ b/internal/domains/aliases_paths_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "bytes" |
| "context" |
| "log" |
| + "os" |
| "sort" |
| "strconv" |
| "strings" |
| @@ -324,7 +325,11 @@ func TestReconcilePathsRefusesASidecarAsATarget(t *testing.T) { |
| } |
| var logged bytes.Buffer |
| log.SetOutput(&logged) |
| - t.Cleanup(func() { log.SetOutput(nil) }) |
| + // os.Stderr, not nil: the log package is process-global, and this file's |
| + // other tests read it. Restoring nil leaves every later test in the |
| + // package writing into a discarded logger — which is how this landed as |
| + // a failure in a test that has nothing to do with sidecars. |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| putClaim(t, s, Claim{ |
| Host: "eleven.example.com", Account: "bdm", App: "eleven", |
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| index e6b89b944..c1e669b1a 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -101,9 +101,11 @@ type edgesFile struct { |
| } |
| // edgesFilePass builds this box's edges file from the routes currently in |
| -// reg (every SERVED row on this box, ALL kinds — the serving-class split |
| -// belongs to the reader, Task 3, not to this writer) and publishes it at |
| -// edgesFileKey(box) when it changed. |
| +// reg (every SERVED row on this box, every SERVING kind — the split between |
| +// those belongs to the reader, Task 3, not to this writer; the split between |
| +// serving and NOT, which no reader of this file can make, belongs here — see |
| +// the sidecar guard in the loop) and publishes it at edgesFileKey(box) when |
| +// it changed. |
| // |
| // The change gate is the marshaled body WITH Generation cleared: Generation |
| // is a fresh RFC3339 stamp every pass, so comparing the full body would |
| @@ -156,6 +158,27 @@ func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, |
| if r.Status != "ready" { |
| continue |
| } |
| + // A sidecar is NEVER advertised as served — by this box or any |
| + // other. This is publishResidentFleetBindings' guard on the file |
| + // that superseded it, and it is load-bearing rather than tidy, |
| + // because the edge's own Kind refusal cannot reach this path: that |
| + // refusal lives in Reload, which builds THIS box's routing map from |
| + // rows it holds. A remote edge never sees the row at all. It sees |
| + // this file, misses on its own upstream lookup, and takes the |
| + // backhaul arm (edge.go's e.backhaul → homeMap) — dialing the home |
| + // box with the sidecar's name as SNI, which is a live proxy to the |
| + // one member the platform guarantees nothing can dial. The same |
| + // entry also passes deploymentHost, so the name becomes orderable |
| + // by the cert machinery deployment-wide. |
| + // |
| + // So the writer's "ALL kinds, the serving-class split belongs to |
| + // the reader" rule above holds only while every kind is servable. |
| + // It stops holding at the first row that is unroutable BY DESIGN, |
| + // and a reader that cannot tell (a remote edge, reading JSON) is |
| + // not a reader that can make the split. |
| + if r.IsSidecar() { |
| + continue |
| + } |
| // Keys are registry row Host values verbatim, INCLUDING *.parent |
| // catch-all wildcard rows (Task 3's reconcile pass derives these |
| // from a claim's catch-all row, same as edge.go's upstream map) — |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| index d43ea1c99..719ff7db8 100644 |
| --- a/internal/host/edgesfile_test.go |
| +++ b/internal/host/edgesfile_test.go |
| @@ -205,6 +205,50 @@ func TestEdgesFileExcludesNonReadyRows(t *testing.T) { |
| } |
| } |
| +// A sidecar row is READY — that is what makes it runnable — so the readiness |
| +// filter above lets it straight through, and this file is published to every |
| +// box in the deployment. That matters more than it looks: a REMOTE edge has |
| +// no sidecar row and never sees a Kind, so edge.Reload's refusal cannot |
| +// protect it. It misses on its own upstream lookup, reads this file through |
| +// the home map, and backhauls — dialing the home box with the sidecar's own |
| +// name as SNI. The same entry also satisfies deploymentHost, which is what |
| +// admits a name to the cert machinery. |
| +// |
| +// So the guarantee has to be made by the WRITER, exactly as |
| +// publishResidentFleetBindings makes it: a sidecar is never advertised as |
| +// served, by this box or any other. |
| +func TestEdgesFileNeverPublishesASidecar(t *testing.T) { |
| + s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + seedRoute(t, reg, "ready.example.com") |
| + // Minted the way the sidecar reconcile pass mints one: ready, and |
| + // carrying neither socket nor addr, because nothing dials it. |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar", |
| + App: "eleven", Account: "bdm", Status: "ready", Provisioned: true, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + var last []byte |
| + if err := edgesFilePass(context.Background(), s, reg, edgesFile{Box: "annie"}, "1.2.3.4", &last); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, _, err := s.Get(context.Background(), "control/edges/annie.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var f edgesFile |
| + if err := json.Unmarshal(got, &f); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := f.Hosts["ready.example.com"]; !ok { |
| + t.Fatalf("hosts = %+v, want the ordinary route still published", f.Hosts) |
| + } |
| + if _, ok := f.Hosts["bot.eleven.bdm.sidecar.internal"]; ok { |
| + t.Fatalf("hosts = %+v, want the sidecar ABSENT — publishing it makes every remote edge a backhaul door onto a member nothing may dial", f.Hosts) |
| + } |
| +} |
| + |
| // FINDING 3 (review, 2026-08-22): a catch-all claim's registry row is a real |
| // "*.parent" host (Task 3's reconcile pass derives it the same way |
| // edge.go's upstream map does). This writer performs no wildcard expansion |