carlos / platform Public

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
Paul Campbell 28f0434fde9e7d6b5ceb154fd0cdc85139a312a3 parent 8a33585
2 files changed, +205 −0
  • internal/appconfig/routefingerprint.go +90 −0
  • internal/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")
+ }
+}