appconfig: fingerprint the registry-side facts that decide a route's config (#196)
ConfigRouteFingerprints digests (host, account, app, environment) over each delivery half's in-scope routes. The agent's fast loop watches bucket etags only, so a route's environment binding — a registry column — moved nothing it could see.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gyhxeoXGBW4WCFRnMqBue
2 files changed,
+205
−0
internal/appconfig/routefingerprint.go+90 −0internal/appconfig/routefingerprint_test.go+115 −0
diff --git a/internal/appconfig/routefingerprint.go b/internal/appconfig/routefingerprint.go| new file mode 100644 |
| index 000000000..10cd89e27 |
| --- /dev/null |
| +++ b/internal/appconfig/routefingerprint.go |
| @@ -0,0 +1,90 @@ |
| +// Route-shape fingerprints: the registry-side half of "has this box's |
| +// config delivery got anything to do". |
| +// |
| +// The agent's fast loop (host package's configGenLoop) decides whether to |
| +// converge by reading each config-carrying app's generation object out of |
| +// the bucket. That catches every BUCKET-side change — a var set, a secret |
| +// rotated, an exec_delivery flag flipped — and nothing at all on the |
| +// REGISTRY side. But a route's Environment is a registry column, and it |
| +// decides which bundle the route's config comes from, so binding a route |
| +// to an environment changes what that route should be served while moving |
| +// no etag anywhere. Delivery then waits out configSyncLoop's hourly floor: |
| +// issue #196, reproduced on carlos-tito-ie 2026-08-15 as ten minutes of a |
| +// live route serving the wrong merge, whose only symptom was the app |
| +// reporting a credential missing — which reads as a BAD credential rather |
| +// than an undelivered one. The nudge that fixed it was re-writing three |
| +// identical secret values, purely to bump the generation object. |
| +// |
| +// These digests give that loop something to watch. They live here, beside |
| +// ExecConfigApps and UnitConfigApps and composed from the SAME eligibility |
| +// predicates those two use, for the reason both of those give for living |
| +// where they do: a fingerprint that disagreed with the sweep about which |
| +// routes matter would either burn passes forever or miss changes silently. |
| +package appconfig |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "sort" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ConfigRouteFingerprints returns one digest per delivery half over the |
| +// registry-side facts that decide what config a route gets: which routes |
| +// are in scope at all, and — for each — the (account, app, environment) |
| +// triple its manifest resolves from. |
| +// |
| +// Set MEMBERSHIP is what carries scope changes: a route that gains |
| +// Status: "failed", loses its app stamp, or is deleted outright simply |
| +// stops contributing a row, which moves the digest exactly as a rebinding |
| +// does. That is deliberate — those transitions all change what should be |
| +// on disk (a file to remove, a file to stop updating), and the pass a |
| +// movement triggers resolves each case properly rather than acting on the |
| +// digest itself. |
| +// |
| +// The OPT-IN is deliberately NOT here, for the reason ExecConfigApps gives |
| +// for omitting it too: reading it costs a bucket fetch per app per tick, |
| +// which is exactly what the fast loop exists to avoid. It does not need to |
| +// be — exec_delivery is a bundle write, and every bundle write bumps the |
| +// app's generation object (SetExecDeliveryAt), which the loop already |
| +// watches. |
| +// |
| +// Two digests, not one, because the two halves are delivered by different |
| +// processes: the exec half by the agent itself (SyncExec), the unit half |
| +// by a root pass the agent can only REQUEST. A shared digest would ask for |
| +// a root config run every time an exec route was rebound. |
| +func ConfigRouteFingerprints(routes []registry.Route) (exec, unit string) { |
| + var execRows, unitRows []string |
| + for _, rt := range routes { |
| + // Exactly the inputs the two sweeps resolve a route's manifest |
| + // from: SyncExec's groupKey and Sync's are both |
| + // (RouteAccount, App, Environment), and Host is what names the |
| + // file. Nothing else on the row can change which bytes belong in |
| + // it, so nothing else belongs in the digest — a Version stamp |
| + // here would fire a pass on every adopt. |
| + row := rt.Host + "|" + release.RouteAccount(rt) + "|" + rt.App + "|" + rt.Environment |
| + if execSyncable(rt) || execAddrCandidate(rt) { |
| + execRows = append(execRows, row) |
| + } |
| + if syncable(rt) && rt.Backing == registry.BackingUnit { |
| + unitRows = append(unitRows, row) |
| + } |
| + } |
| + return digestRows(execRows), digestRows(unitRows) |
| +} |
| + |
| +// digestRows sorts and hashes. Sorted because registry order is not stable |
| +// across passes and an order-sensitive digest would fire constantly; |
| +// NUL-separated because none of the four fields can contain one, so no two |
| +// distinct row sets can concatenate to the same bytes. |
| +func digestRows(rows []string) string { |
| + sort.Strings(rows) |
| + h := sha256.New() |
| + for _, r := range rows { |
| + h.Write([]byte(r)) |
| + h.Write([]byte{0}) |
| + } |
| + return hex.EncodeToString(h.Sum(nil)) |
| +} |
diff --git a/internal/appconfig/routefingerprint_test.go b/internal/appconfig/routefingerprint_test.go| new file mode 100644 |
| index 000000000..da060b2c3 |
| --- /dev/null |
| +++ b/internal/appconfig/routefingerprint_test.go |
| @@ -0,0 +1,115 @@ |
| +package appconfig |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// fpExecRoute is the TCP-addr exec shape (issue #18): carlos proxies to it |
| +// but neither spawns nor owns the process. |
| +func fpExecRoute(host, env string) registry.Route { |
| + return registry.Route{Host: host, Kind: "instance", App: "titogo", |
| + Account: "bab", Status: "ready", Addr: "127.0.0.1:9001", Environment: env} |
| +} |
| + |
| +// fpUnitRoute is the unit-backed shape the ROOT pass delivers config for. |
| +func fpUnitRoute(host, env string) registry.Route { |
| + return registry.Route{Host: host, Kind: "instance", App: "keymail", |
| + Account: "bae", Status: "ready", Addr: "127.0.0.1:9100", |
| + Backing: registry.BackingUnit, Environment: env} |
| +} |
| + |
| +func TestConfigRouteFingerprintsMoveOnRebinding(t *testing.T) { |
| + before := []registry.Route{fpExecRoute("connect.go.tito.io", "")} |
| + after := []registry.Route{fpExecRoute("connect.go.tito.io", "relay")} |
| + be, bu := ConfigRouteFingerprints(before) |
| + ae, au := ConfigRouteFingerprints(after) |
| + if be == ae { |
| + t.Fatal("binding an exec route to an environment must move the exec digest — this is the hour issue #196 cost") |
| + } |
| + if bu != au { |
| + t.Fatal("an exec route's rebinding must not move the UNIT digest — that would ask for a root config pass for nothing") |
| + } |
| +} |
| + |
| +func TestConfigRouteFingerprintsUnitHalfIsIndependent(t *testing.T) { |
| + before := []registry.Route{fpUnitRoute("keymail.dev", "")} |
| + after := []registry.Route{fpUnitRoute("keymail.dev", "staging")} |
| + be, bu := ConfigRouteFingerprints(before) |
| + ae, au := ConfigRouteFingerprints(after) |
| + if bu == au { |
| + t.Fatal("binding a unit-backed route to an environment must move the unit digest — the root pass resolves per environment too") |
| + } |
| + if be != ae { |
| + t.Fatal("a unit route's rebinding must not move the exec digest") |
| + } |
| +} |
| + |
| +func TestConfigRouteFingerprintsIgnoreOrderAndIrrelevantColumns(t *testing.T) { |
| + a := fpExecRoute("a.example", "") |
| + b := fpExecRoute("b.example", "relay") |
| + e1, _ := ConfigRouteFingerprints([]registry.Route{a, b}) |
| + e2, _ := ConfigRouteFingerprints([]registry.Route{b, a}) |
| + if e1 != e2 { |
| + t.Fatal("registry order must not move the digest — it is not stable across passes") |
| + } |
| + // A column that cannot change which bytes belong in a route's config |
| + // file must not move the digest, or every adopt would fire a pass. |
| + c := fpExecRoute("a.example", "") |
| + c.Version = "deadbeef" |
| + c.PrevVersion = "cafebabe" |
| + c.LastError = "whatever" |
| + c.Hibernate = true |
| + e3, _ := ConfigRouteFingerprints([]registry.Route{c, b}) |
| + if e1 != e3 { |
| + t.Fatalf("an irrelevant column moved the digest (%s -> %s)", e1, e3) |
| + } |
| +} |
| + |
| +func TestConfigRouteFingerprintsMoveWhenARouteLeavesScope(t *testing.T) { |
| + both := []registry.Route{fpExecRoute("a.example", ""), fpExecRoute("b.example", "")} |
| + one := []registry.Route{fpExecRoute("a.example", "")} |
| + e1, _ := ConfigRouteFingerprints(both) |
| + e2, _ := ConfigRouteFingerprints(one) |
| + if e1 == e2 { |
| + t.Fatal("a route leaving the registry must move the digest — its file needs reaping") |
| + } |
| + // Leaving scope by STATUS, not by deletion, counts exactly the same: |
| + // the eligibility predicate is what set membership is drawn from. |
| + failed := []registry.Route{fpExecRoute("a.example", ""), fpExecRoute("b.example", "")} |
| + failed[1].Status = "failed" |
| + e3, _ := ConfigRouteFingerprints(failed) |
| + if e1 == e3 { |
| + t.Fatal("a route falling out of the eligibility predicate must move the digest") |
| + } |
| +} |
| + |
| +func TestConfigRouteFingerprintsIgnoreOutOfScopeRoutes(t *testing.T) { |
| + base := []registry.Route{fpExecRoute("a.example", "")} |
| + withOthers := append([]registry.Route{}, base...) |
| + withOthers = append(withOthers, |
| + // A static route: no config is delivered to it by either half. |
| + registry.Route{Host: "site.example", Kind: "static", App: "site", Account: "bab"}, |
| + // A platform-minted row: excluded by execRouteBase and by |
| + // release.Adoptable alike. |
| + registry.Route{Host: "edge.example", Kind: "instance", App: "titogo", Account: "bab", |
| + Status: "ready", Addr: "127.0.0.1:9500", Platform: true}, |
| + ) |
| + e1, u1 := ConfigRouteFingerprints(base) |
| + e2, u2 := ConfigRouteFingerprints(withOthers) |
| + if e1 != e2 || u1 != u2 { |
| + t.Fatal("a route neither half can deliver config to must not move either digest") |
| + } |
| +} |
| + |
| +func TestConfigRouteFingerprintsEmptySetIsStable(t *testing.T) { |
| + e1, u1 := ConfigRouteFingerprints(nil) |
| + e2, u2 := ConfigRouteFingerprints([]registry.Route{}) |
| + if e1 != e2 || u1 != u2 { |
| + t.Fatal("nil and empty must digest identically — a box with no routes must not look like it changed every tick") |
| + } |
| + if e1 == "" || u1 == "" { |
| + t.Fatal("the empty set must have a real digest, not the empty string: the fast loop tells 'never computed' from 'computed, and empty' by its own bool, not by this value") |
| + } |
| +} |