The attach door must not alias a custom domain onto a sidecar
Found by rerunning the sweep a second time, against the 405 commits main
moved between the first catch-up and this one. §14 was exhaustive on
2026-09-05; the point of redoing it is that such a list is a fact about the
tree ON A DATE, not about the tree.
readyRouteHost (internal/console/domainattach.go) picks the route a
newly-attached custom domain is aliased onto. Its doc says the selection is
domains.firstReadyUpstream's "minus the upstream copy", which is accurate
about the intent and wrong about the consequence. firstReadyUpstream ends
with a clause this one drops:
rt.Socket != "" || rt.Addr != "" || (rt.Kind == "static" && rt.Channel != "")
That clause does two jobs. It picks a row with something to COPY — the job
it was written for, and genuinely not needed here, since this function
copies nothing and returns only a host. It is also the question "is this row
dialable at all", and that second, unassigned job is the entire reason a
sidecar never appears in firstReadyUpstream's answer: a sidecar is exactly
the row with no socket and no addr.
Dropping the clause dropped the guard with it. Everything left passes a
sidecar — it is the app's, it is the account's, it is not Platform, and it
is ready, because ready is what makes it runnable. Selection runs in
reg.List's host order, so bot.<app>... sorts ahead of most hosts an app
would really serve from: the sidecar is the FIRST candidate, not a
straggler that wins a tie.
This is not a routing breach. ReconcileAliases already refuses a claim whose
Route carries the reserved suffix, so nothing is ever proxied. It is worse
in a quieter way: the attach returns 200, the claim is written naming a
sidecar, and the box refuses it on every pass from then on. A custom domain
that never serves, and nothing anywhere saying why.
The guard goes at the door, so the console agrees with the box rather than
writing claims for it to reject. Verified by neutralising it and watching
the test fail — the attach aliases echo.example.com onto
bot.story.bab.sidecar.internal.
Also here, in the design's new §15: the sweep's full result, including the
pattern this is the third instance of. The edges file (§14.1), path routing
(§14.2) and this door are one shape three times — a predicate that excluded
sidecars as a SIDE EFFECT of asking something else, then narrowed, copied or
re-derived by someone who correctly read its stated purpose and could not
see the unstated one. §15.2 says what to grep for next time, since
registry.Route and Kind are both the wrong things to look for. §15.4 records
a gap this merge opened rather than closed: StartSidecar gained isolated
exec's layout dispatch and StartWorker, the native path, did not.
Gate: go build, go vet, gofmt -l, the GOOS=darwin and GOOS=windows compiles
and go test ./... -count=1 all clean, less the two failures that reproduce
identically on a clean origin/main worktree (a console login the root
package's tests want, and one activator test whose own name pushes a unix
socket path past the 108-byte sun_path limit).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+175
−0
docs/superpowers/specs/2026-08-17-sidecar-members-design.md+118 −0internal/console/domainattach.go+13 −0internal/console/domainattach_test.go+44 −0
CI passed — 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 baf814f08..67d0901bb 100644 |
| --- a/docs/superpowers/specs/2026-08-17-sidecar-members-design.md |
| +++ b/docs/superpowers/specs/2026-08-17-sidecar-members-design.md |
| @@ -754,3 +754,121 @@ matches units to routes through `routeForUnit`, which matches a sidecar's |
| "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. |
| + |
| +## 15. Amended 2026-09-15 — the sweep, redone AGAIN, 405 commits later |
| + |
| +§14 was written against main at `66587fa8` on 2026-09-05 and was exhaustive |
| +then. Main moved 405 commits in the ten days that followed, and the branch |
| +went conflicted rather than merely stale a second time. The sweep was rerun |
| +from scratch, on the principle §14 had already arrived at: a consumer list |
| +is not a fact about the tree, it is a fact about the tree ON A DATE, and the |
| +only way to keep one true is to stop trusting it and look again. |
| + |
| +One new consumer reaches a sidecar. It is worth reading the shape of it, |
| +because it is the THIRD instance of one pattern and that pattern is now |
| +the thing to watch for rather than any of its three instances. |
| + |
| +### 15.1 The domain-attach door aliases onto a sidecar |
| + |
| +`internal/console/domainattach.go`'s `readyRouteHost` picks the route a |
| +newly-attached custom domain is aliased onto. Its own doc says the selection |
| +is `domains.firstReadyUpstream`'s "minus the upstream copy" — and that is |
| +accurate about the intent and wrong about the consequence. |
| + |
| +`firstReadyUpstream` ends with a clause this one drops: |
| + |
| + if rt.Socket != "" || rt.Addr != "" || (rt.Kind == "static" && rt.Channel != "") |
| + |
| +That clause is doing two jobs. It selects a row with something to copy — |
| +the job it was written for, and the job `readyRouteHost` genuinely does not |
| +need, since it copies nothing and returns only the host. It is ALSO the |
| +question "is this row dialable at all", and that job nobody assigned it is |
| +the entire reason a sidecar never appears in `firstReadyUpstream`'s answer: |
| +a sidecar is precisely the row with no socket and no addr. |
| + |
| +So dropping the clause dropped the guard with it, and every test that |
| +remains passes a sidecar. It belongs to the app. It belongs to the account. |
| +It is not `Platform`. It is `ready` — because ready is what makes it |
| +runnable. Selection is in `reg.List`'s host order, so `bot.<app>...` sorts |
| +ahead of most hosts an app would really serve from: the sidecar is not a |
| +straggler that wins a tie, it is the first candidate. |
| + |
| +The result is not a routing breach — `ReconcileAliases` refuses a claim |
| +whose `Route` carries the reserved suffix (the guard at aliases.go's alias |
| +branch, §14's sibling). It is worse in a quieter way: the member's attach |
| +returns 200, the claim is written naming a sidecar, and the box then refuses |
| +that claim on every pass, forever. A custom domain that never serves and |
| +nothing anywhere says why. |
| + |
| +Guard added at the door, so the console agrees with the box. Verified by |
| +neutralising it: the attach aliases `echo.example.com` onto |
| +`bot.story.bab.sidecar.internal`. |
| + |
| +### 15.2 The pattern, which is the actual finding |
| + |
| +Three sweeps have now found the same thing three times: |
| + |
| +- §14.1, the edges file: a writer that published "every served row, ALL |
| + kinds", true only while every kind was servable. |
| +- §14.2, path routing: a third table derived from the same claims, through |
| + a branch the reserved-suffix guard never saw. |
| +- §15.1, domain attach: a selection copied from a guarded one, minus the |
| + clause that was the guard. |
| + |
| +None of the three was written carelessly and none is a sidecar bug. Each is |
| +code written before an unroutable serving class existed, by someone with no |
| +reason to imagine one. The common shape is a predicate that EXCLUDED |
| +sidecars as a side effect of asking something else — "has an upstream to |
| +copy", "is a row this box serves" — being narrowed, copied or re-derived by |
| +someone who correctly identified the stated purpose and could not see the |
| +unstated one. |
| + |
| +The practical consequence for whoever sweeps next: grep for |
| +`registry.Route` is not enough, and neither is grepping for `Kind`. What |
| +finds these is asking, of every consumer, "what in this predicate happens to |
| +require a socket or an address?" — and then checking whether that clause is |
| +load-bearing somewhere it was never documented to be. |
| + |
| +### 15.3 Checked this pass and correct |
| + |
| +- `internal/gate` (new): the password curtain is applied at the edge, in |
| + front of a route serving requests. The edge refuses a sidecar on Kind |
| + before dispatch, so a `gate` hash on a sidecar row is inert. |
| +- `apexHolder` (`internal/console/apex.go`, new): reads `reg.Get` at the |
| + platform apex `<name>.<sqid>.<domain>`. A sidecar host is under |
| + `.sidecar.internal`, a different suffix entirely, so the lookup can never |
| + land on one. |
| +- `edgesfile.DeploymentRoutes`: reads the published edges files, which |
| + §14.1's writer-side guard has already emptied of sidecars. That guard |
| + covers this consumer too, and is the reason it needs none of its own. |
| +- `warnExternalSockets` (`internal/host/socket_advice.go`): explicit |
| + `Kind != "instance"`. |
| +- `versionsweep`: pins a sidecar's `Version`/`PrevVersion` against staged |
| + reaping. Correct and wanted — a sidecar runs the app's binary and the |
| + build it is running must not be swept out from under it. |
| +- `schedule`'s prune: marks a sidecar's host alive, so its schedule rows |
| + survive. Correct and load-bearing — a hibernating sidecar's schedule is |
| + its only way back up. |
| +- `ops/litestream.FleetDBs`: an always-on sidecar's database joins the |
| + box-wide replication set like any other always-on row, which is right; a |
| + hibernating one is excluded there because the activator replicates it |
| + per-wake. |
| +- None of the brand-new packages this pass (`analytics`, `clamd`, |
| + `maintenance*`, `objstore`, `privatefile`, `publicrelease`, `relay`, |
| + `telemetry`, `usage`) reads routes at all. |
| + |
| +### 15.4 A gap this merge opened, recorded not closed |
| + |
| +Isolated exec (§8.5/§8.6 of its own design) gave `StartSidecar` a layout |
| +dispatch: a nested box starts a sidecar as a unit through the control |
| +channel, a fenced one refuses, and only the default falls through to the |
| +child spawn. `StartWorker` — the NATIVE path, which is what a standalone |
| +`Kind == "sidecar"` row uses — has no such dispatch and always spawns a |
| +bare child. |
| + |
| +On a nested-layout box a native sidecar would therefore come up outside the |
| +isolation every other child gets. It is not wrong today, because no box |
| +runs both native sidecars and the nested layout, but it is exactly the kind |
| +of agreement that stops being true quietly. The fix is `StartWorker` |
| +learning the same dispatch, and it wants its own review rather than a |
| +merge's. |
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go| index 4e722fae0..a20b98937 100644 |
| --- a/internal/console/domainattach.go |
| +++ b/internal/console/domainattach.go |
| @@ -347,6 +347,19 @@ func (s *Server) readyRouteHost(sqid, app, attaching string) (alias, selfChannel |
| if rt.Platform || rt.Status != "ready" { |
| continue |
| } |
| + // Nor a sidecar, which is not a source to alias for a blunter |
| + // reason: nothing can dial one. This says explicitly what |
| + // firstReadyUpstream gets for free from the clause this function |
| + // drops — it requires an upstream (Socket/Addr, or a channel for a |
| + // static), and a sidecar is exactly the row that has neither. Every |
| + // other test here passes a sidecar: it is the app's, it is the |
| + // account's, it is not Platform, and it is ready, because ready is |
| + // what makes it runnable. Aliasing onto one would mint a claim the |
| + // box refuses for good (ReconcileAliases' own c.Route check), so the |
| + // member's domain would never serve and nothing would say why. |
| + if rt.IsSidecar() { |
| + continue |
| + } |
| if rt.Host == attaching { |
| selfChannel = rt.Channel |
| continue |
diff --git a/internal/console/domainattach_test.go b/internal/console/domainattach_test.go| index d2943b72a..6ea586e6d 100644 |
| --- a/internal/console/domainattach_test.go |
| +++ b/internal/console/domainattach_test.go |
| @@ -728,3 +728,47 @@ func TestDetachForeignClaimIs404(t *testing.T) { |
| t.Fatalf("refusal = %+v, want 404 for an unclaimed host", ref) |
| } |
| } |
| + |
| +// TestAttachNeverAliasesOntoASidecar is the reserved-suffix refusal above |
| +// taken from the other end. That one guards the host being ATTACHED; this |
| +// one guards the host it is attached TO. |
| +// |
| +// readyRouteHost picks the alias source, and its doc says the selection is |
| +// domains.firstReadyUpstream's "minus the upstream copy". The copy is not |
| +// all that went: firstReadyUpstream also REQUIRES an upstream |
| +// (Socket/Addr, or a channel for a static), and that requirement is the |
| +// whole of what keeps a sidecar out of it — a sidecar is precisely the row |
| +// with no socket and no addr. Dropping the clause dropped the guard with |
| +// it, and everything left in the filter passes a sidecar: it belongs to the |
| +// app, it belongs to the account, it is not Platform, and it is ready, |
| +// because ready is what makes it runnable. |
| +// |
| +// Host order decides which row wins, so a bot sorts ahead of most hosts an |
| +// app would actually serve from. The member gets a 200 and a claim naming a |
| +// sidecar as its Route; the box then refuses that claim forever |
| +// (ReconcileAliases' own c.Route check), so the domain never serves and |
| +// nothing ever says why. |
| +func TestAttachNeverAliasesOntoASidecar(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + // Host order is reg.List's, and "bot." sorts before "story." — the |
| + // sidecar is the FIRST candidate, not a straggler behind the real route. |
| + fx.srv.cfg.Registry = attachRegistry(t, |
| + registry.Route{Host: "bot.story." + fx.sqid + provision.SidecarSuffix, |
| + Kind: provision.SidecarKind, App: "story", Account: fx.sqid, Status: "ready"}, |
| + registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", |
| + Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| + ) |
| + |
| + res, ref := fx.srv.attachDomain(context.Background(), acct, "story", "echo.example.com", "", "", "paul@keymail.dev") |
| + if ref != nil { |
| + t.Fatalf("attach: refused %+v", ref) |
| + } |
| + if strings.HasSuffix(res.Claim.Route, provision.SidecarSuffix) { |
| + t.Fatalf("attach aliased onto %q — nothing routes to a sidecar", res.Claim.Route) |
| + } |
| + if want := "story." + fx.sqid + ".oncarlos.com"; res.Claim.Route != want { |
| + t.Fatalf("attach aliased onto %q, want the app's real route %q", res.Claim.Route, want) |
| + } |
| +} |