provision: close Backing's siblings and pass-ownership gaps in shapeMatches
Review fix (2026-08-10, Task 4 re-review). Amendment 2 named only Backing as the box-scoped column a record can never state; Backing's own doc comment names Tunnel and Caps as its siblings, and both were live gaps: - Tunnel outranks the Socket a record derives (internal/edge picks the tunnel proxy before ever looking at Socket), so a tunnel-backed route could match every compared column and get adopted with its traffic still going down a channel the record can't see or reclaim. - Caps is the operator capability-grant column; reg.Delete's purge of it is already treated as human-gated/last-resort elsewhere in this file, and adoption would have handed that purge to an unattended record deletion. Also closes two Important findings: Platform/Canary rows (domains.Reconcile's own removal-authority flags, exploitable via mintedRoute's empty Slug/Pool/Region/DBPath) are now excluded from adoption too, and the refusal log — the one line an operator backfilling the fleet actually sees on a refusal — now names the first column that actually differed instead of three columns that are identical on every non-obvious refusal. shapeMatches is now shapeMismatch(...) == "", so the match predicate and the diagnostic message can never drift apart. Added refusal tests for Tunnel, Caps, Platform, Canary, and a DBPath-only mismatch (the latter closing a gap where deleting the DBPath comparison would have left the suite green).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+236
−22
internal/provision/reconcile.go+77 −22internal/provision/reconcile_test.go+159 −0
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go| index 66e93c12b..3c564fd16 100644 |
| --- a/internal/provision/reconcile.go |
| +++ b/internal/provision/reconcile.go |
| @@ -79,30 +79,86 @@ func routeFor(rec Record, dataDir, seed string) registry.Route { |
| // wrong channel is the whole reason to adopt one. Requiring them to agree |
| // would make adoption impossible in exactly the case it exists for. |
| // |
| -// Backing is deliberately required to be empty on the existing row. A |
| -// record has no field for Backing — it is a box-scoped operator column, |
| -// alongside capability grants and tunnels (registry.Route.Backing's own |
| -// doc comment) — so a record can never describe a carlos-app@<host> unit |
| -// tenant at all. A row this pass cannot state the Backing of is a row this |
| -// pass must not claim to describe, no matter how well everything else |
| -// lines up. |
| +// Backing, Tunnel and Caps are deliberately required to be empty (unset) on |
| +// the existing row — registry.Route's own doc comments name these three as |
| +// the box-scoped operator columns, and a record has a field for none of |
| +// them, so a record can never fully describe a row that carries one: |
| +// - Backing "unit" names a carlos-app@<host> systemd tenant; adopting |
| +// across it would claim a process shape the record says nothing about. |
| +// - Tunnel is routing-decisive and OUTRANKS the Socket/Addr a record can |
| +// state: internal/edge picks the tunnel proxy before it ever looks at |
| +// Socket, so a tunnel-backed route can agree with a record on every |
| +// other compared column while every byte it actually serves goes down |
| +// a channel the record cannot see or reclaim. |
| +// - Caps is the operator-granted, box-local capability grant column |
| +// (port:25, host-role, ...). registry.Delete's purge of a route's |
| +// grants is already treated as a human-gated, last-resort action |
| +// elsewhere in this file (the shape-drift log below); adoption must not |
| +// hand that purge to an unattended bucket-record deletion. |
| +// |
| +// A row this pass cannot state the shape of is a row this pass must not |
| +// claim to describe, no matter how well everything else lines up. |
| +// |
| +// Platform and Canary are likewise required false: those flags mark a row |
| +// domains.Reconcile minted and re-mints/removes on its own authority |
| +// (mintedRoute leaves Slug/Pool/Region/DBPath at their zero values, so a |
| +// platform apex on an unconstrained-region box can otherwise agree with a |
| +// record on every other compared column). Adoption must not hand this |
| +// pass's removal authority to a row another pass already owns and |
| +// periodically rebuilds — nor let it write Channel/Environment onto a row |
| +// the CLI itself refuses (platformCanaryOwnedRow, cli.go) for the same |
| +// reason. |
| // |
| // Everything else that identifies the route is compared, including the |
| // derived Socket and DBPath — two rows agreeing on kind and app but not on |
| // where the database lives are not the same route, and adopting across that |
| // difference would point a running process at a second copy of its own data. |
| func shapeMatches(existing, want registry.Route) bool { |
| - return existing.Backing == "" && |
| - existing.App == want.App && |
| - existing.Account == want.Account && |
| - existing.Kind == want.Kind && |
| - existing.Addr == want.Addr && |
| - existing.Unit == want.Unit && |
| - existing.Slug == want.Slug && |
| - existing.Pool == want.Pool && |
| - existing.Region == want.Region && |
| - existing.Socket == want.Socket && |
| - existing.DBPath == want.DBPath |
| + return shapeMismatch(existing, want) == "" |
| +} |
| + |
| +// shapeMismatch names the first column on which existing disagrees with |
| +// want — including the box-scoped and pass-ownership guards shapeMatches |
| +// documents — or "" when every compared column agrees. It backs both |
| +// shapeMatches and the adoption refusal log: printing only a couple of |
| +// columns there (the original message did app/kind/addr) leaves every |
| +// refusal over any OTHER column looking identical and undiagnosable to the |
| +// operator the refusal message exists for. |
| +func shapeMismatch(existing, want registry.Route) string { |
| + switch { |
| + case existing.Backing != "": |
| + return fmt.Sprintf("backing %q (box-scoped, not expressible in a record)", existing.Backing) |
| + case existing.Tunnel != "": |
| + return fmt.Sprintf("tunnel %q (box-scoped, not expressible in a record)", existing.Tunnel) |
| + case existing.Caps != "": |
| + return fmt.Sprintf("caps %q (box-scoped, not expressible in a record)", existing.Caps) |
| + case existing.Platform: |
| + return "platform (owned by the domains reconcile pass)" |
| + case existing.Canary: |
| + return "canary (owned by the domains reconcile pass)" |
| + case existing.App != want.App: |
| + return fmt.Sprintf("app %q/%q", existing.App, want.App) |
| + case existing.Account != want.Account: |
| + return fmt.Sprintf("account %q/%q", existing.Account, want.Account) |
| + case existing.Kind != want.Kind: |
| + return fmt.Sprintf("kind %q/%q", existing.Kind, want.Kind) |
| + case existing.Addr != want.Addr: |
| + return fmt.Sprintf("addr %q/%q", existing.Addr, want.Addr) |
| + case existing.Unit != want.Unit: |
| + return fmt.Sprintf("unit %q/%q", existing.Unit, want.Unit) |
| + case existing.Slug != want.Slug: |
| + return fmt.Sprintf("slug %q/%q", existing.Slug, want.Slug) |
| + case existing.Pool != want.Pool: |
| + return fmt.Sprintf("pool %q/%q", existing.Pool, want.Pool) |
| + case existing.Region != want.Region: |
| + return fmt.Sprintf("region %q/%q", existing.Region, want.Region) |
| + case existing.Socket != want.Socket: |
| + return fmt.Sprintf("socket %q/%q", existing.Socket, want.Socket) |
| + case existing.DBPath != want.DBPath: |
| + return fmt.Sprintf("db %q/%q", existing.DBPath, want.DBPath) |
| + default: |
| + return "" |
| + } |
| } |
| // convergeOwned applies the record's Channel and Environment onto a route it |
| @@ -382,10 +438,9 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // over the row, so the match test is the only thing standing |
| // between a backfill and a retired production route. |
| want := routeFor(rec, cfg.DataDir, seed) |
| - if !shapeMatches(existing, want) { |
| - log.Printf("provision: %s exists but is not the route this record describes "+ |
| - "(app %q/%q kind %q/%q addr %q/%q) — leaving it alone", |
| - rec.Host, existing.App, want.App, existing.Kind, want.Kind, existing.Addr, want.Addr) |
| + if reason := shapeMismatch(existing, want); reason != "" { |
| + log.Printf("provision: %s exists but is not the route this record describes (%s) — leaving it alone", |
| + rec.Host, reason) |
| continue |
| } |
| if err := reg.SetProvisioned(rec.Host, true); err != nil { |
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go| index ef6f30117..9af1a31c7 100644 |
| --- a/internal/provision/reconcile_test.go |
| +++ b/internal/provision/reconcile_test.go |
| @@ -1039,6 +1039,165 @@ func TestReconcileRefusesToAdoptBackedRoute(t *testing.T) { |
| } |
| } |
| +// Tunnel is routing-decisive and OUTRANKS the Socket/Addr a record can |
| +// state: internal/edge picks the tunnel proxy before it ever looks at |
| +// Socket (edge.go's route-selection switch), so a tunnel-backed route can |
| +// otherwise agree with a record on every other compared column while every |
| +// byte it actually serves goes down a channel the record cannot see or |
| +// reclaim (2026-08-10 review, Critical). |
| +func TestReconcileRefusesToAdoptTunnelBoundRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + Tunnel: "pi-fleet", |
| + } |
| + reg := regWith(t, handAdded) |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("a tunnel-bound route was touched: before %+v after %+v", before, after) |
| + } |
| + if after.Provisioned { |
| + t.Error("a tunnel-bound route was adopted — a record cannot state or reclaim its Tunnel binding") |
| + } |
| +} |
| + |
| +// Caps is the operator-granted, box-local capability-grant column, and |
| +// registry.Delete's purge of a route's grants is already a human-gated, |
| +// last-resort action elsewhere in this file (the shape-drift log). Adoption |
| +// must not hand that purge to an unattended bucket-record deletion |
| +// (2026-08-10 review, Critical). |
| +func TestReconcileRefusesToAdoptCapsBearingRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + Caps: "port:25", |
| + } |
| + reg := regWith(t, handAdded) |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("a caps-bearing route was touched: before %+v after %+v", before, after) |
| + } |
| + if after.Provisioned { |
| + t.Error("a caps-bearing route was adopted — its grants would now be one record deletion from purged") |
| + } |
| +} |
| + |
| +// domains.mintedRoute builds platform routes with empty Slug/Pool/Region/ |
| +// DBPath, copying Kind/Socket/Addr from the source route — so a record |
| +// whose Slug/Pool/Region are also empty (an unconstrained-region/pool box) |
| +// can otherwise agree with a platform-minted row on every other compared |
| +// column. Adoption must not hand this pass's removal authority to a row |
| +// domains.Reconcile already owns and periodically re-Upserts/removes on its |
| +// own authority (2026-08-10 review, Important 1). |
| +func TestReconcileRefusesToAdoptPlatformRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := Record{Schema: 1, Host: "acme.go.tito.io", App: "tito", Account: sqid, |
| + Channel: "stable", CreatedAt: time.Now()} |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Status: "ready", Platform: true, |
| + } |
| + reg := regWith(t, handAdded) |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + cfg := ReconcileConfig{DataDir: "/data"} // unconstrained region, default pool only |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("a platform-owned route was touched: before %+v after %+v", before, after) |
| + } |
| + if after.Provisioned { |
| + t.Error("a platform-owned route was adopted — removal authority granted to a second pass") |
| + } |
| +} |
| + |
| +// Same guard, the other flag domains.Reconcile also uses for its own |
| +// removal authority (2026-08-10 review, Important 1). |
| +func TestReconcileRefusesToAdoptCanaryRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := Record{Schema: 1, Host: "acme.go.tito.io", App: "tito", Account: sqid, |
| + Channel: "stable", CreatedAt: time.Now()} |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Status: "ready", Canary: true, |
| + } |
| + reg := regWith(t, handAdded) |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + cfg := ReconcileConfig{DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("a canary-owned route was touched: before %+v after %+v", before, after) |
| + } |
| + if after.Provisioned { |
| + t.Error("a canary-owned route was adopted — removal authority granted to a second pass") |
| + } |
| +} |
| + |
| +// Nothing currently locked the derived Socket/DBPath comparison specifically |
| +// — the only prior mismatch test differed on App, so a regression that |
| +// dropped the DBPath comparison from shapeMismatch would leave the suite |
| +// green (2026-08-10 review, test gap called out alongside the Important |
| +// findings). |
| +func TestReconcileRefusesToAdoptOnDBPathMismatch(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/other.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("a DBPath-mismatched route was touched: before %+v after %+v", before, after) |
| + } |
| + if after.Provisioned { |
| + t.Error("a route was adopted despite a DBPath mismatch") |
| + } |
| +} |
| + |
| // An adopted route is removable by the pass that adopted it, on the same |
| // terms as one it minted: the record disappearing is the bucket's own |
| // decision to retire the instance. |