// 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. // // The INSTANCE CONFIG LAYER is deliberately not here either, and for the // same reason with a sharper edge: apps///instances-config/ // .json is a bucket object, so seeing a change to one would cost a // listing per app per tick at best and a read per route at worst. It does // not need to be seen here — writing or deleting an overlay bumps the app's // generation object (ApplyInstance, DeleteInstanceConfig), which this loop // already watches, and that is the layer's whole signal. // // 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)) }