carlos / platform Public

merge: follow-up fixes — the bake clock leaves /opt, 1xx stops killing the cache, purges ride a host index, unit tenants learn the channel (#33)

Follow-up fixes: bake clock to /data (#13), 1xx latch, per-host purge index, §7.3 delivery
Paul Campbell cc933026e48470f310eac271baf9de6e799e2468 parent b8e930bparent 584c6a3
17 files changed, +683 −68
  • docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md +39 −10
  • internal/activator/backend_exec.go +5 −0
  • internal/appconfig/agentvars.go +51 −0
  • internal/appconfig/agentvars_test.go +18 −0
  • internal/appconfig/sync.go +29 −1
  • internal/appconfig/sync_test.go +95 −24
  • internal/appconfig/syncexec.go +10 −0
  • internal/edge/cache.go +26 −12
  • internal/edge/cache_test.go +29 −0
  • internal/edge/edge.go +8 −0
  • internal/edge/edge_test.go +52 −0
  • internal/edge/purge_header.go +11 −11
  • internal/host/agent.go +6 −6
  • internal/release/adopt.go +29 −1
  • internal/release/adopt_test.go +67 −0
  • internal/release/bake.go +96 −3
  • internal/release/bake_test.go +112 −0
diff --git a/docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md b/docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md
index df7a07293..fe27c7868 100644
--- a/docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md
+++ b/docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md
@@ -683,20 +683,35 @@ also forwarded; the fix in both cases is upgrading the edge, not the app.
that restart, the box's edge does not read the header and apps that
emit it are simply not purging — which is exactly today's behaviour, so
the intermediate state is safe.
-3. **`CARLOS_PURGE=header`** — **DEFERRED, and exec-only as shipped.** The
- var is currently set by `internal/activator`'s exec backend for
- exec-backed instances only. Delivery to **unit** tenants via the
- `internal/appconfig` sync into `/etc/carlos/apps/<host>.env` on the root
- adopt path is *not* built yet; it is tracked as follow-on work.
-
- This costs the feature nothing, which is the point of the var being
- advisory: **the channel works whether or not the app ever sees it**, so a
- unit tenant that emits `Carlos-Purge` today is honoured today, variable
- or no variable. When the sync does land it takes effect on that unit's
+3. **`CARLOS_PURGE=header`** — **Landed 2026-08-03** (was deferred at ship):
+ the appconfig sync appends the var to every unit tenant's env file — see
+ `internal/appconfig/agentvars.go`. The var was already set by
+ `internal/activator`'s exec backend for exec-backed instances; delivery to
+ **unit** tenants via the `internal/appconfig` sync into
+ `/etc/carlos/apps/<host>.env` on the root adopt path has now been built.
+
+ Deferring this cost the feature nothing while it was deferred, which is
+ the point of the var being advisory: **the channel works whether or not
+ the app ever sees it**, so a unit tenant that emits `Carlos-Purge` was
+ honoured before this landed, variable or no variable, just as it is now.
+ Now that the sync appends it, delivery takes effect on each unit tenant's
next restart — hibernating tenants pick it up on wake, per the standard
rule — and because the var is advisory there is no window in which it
means something untrue.
+ Landing it is not free of side effects, though: because every existing
+ `/etc/carlos/apps/<host>.env` on the fleet gains this line, the *first*
+ sync pass after this ships will change the byte content of every active
+ unit tenant's file — `syncHost`'s `bytes.Equal` convergence check will see
+ a diff for each one and will restart each once (hibernated routes are
+ unaffected; `try-restart` is a no-op on an inactive unit). That one-time
+ fleet-wide restart is weighed and accepted deliberately, not an
+ overlooked side effect: the var cannot take effect without a restart.
+ Contrast `internal/appconfig/syncexec.go`'s `execAddrConfigHeader`,
+ which declines this exact trade for the socket-exec shape — a bare
+ comment header isn't worth bouncing every instance on the fleet. Here
+ the restart is not a cost of the payload, it *is* the payload landing.
+
Where the var *is* set, it is **agent-owned in both directions**: set
when there is a cache, and **removed** from the child's environment when
there is not. The removal half is load-bearing, not decoration. The env
@@ -873,6 +888,20 @@ privileges.**
ships (it is cheap and strictly better isolation) and candidate (a)'s
per-route group machinery has to be built alongside it. **This is
Paul's call and it is the only thing blocking a plan.**
+
+ **Resolved 2026-08-03: request-coupled purge ships alone.** TTL-bounded
+ staleness (≤ `DefaultCacheMaxTTL`, 300s) for scheduled sales open/close
+ transitions is accepted: the parent spec's `SalesEndAt` default change
+ already covers the common case, and a scheduled transition being up to
+ five minutes stale on a cached public page is within the same tolerance
+ the TTL already imposes on every uncached-to-cached transition; the
+ effective bound per page is the app's own max-age when smaller (titogo's
+ public pages set max-age=120, so its transitions bound at two minutes).
+ Candidate (a)'s per-route group machinery is the documented escalation if
+ production contradicts this. Decision taken in-session under delegated
+ authority (Paul: "keep going until all those are done"), recorded here
+ for veto.
+
2. **Header name.** `Carlos-Purge` versus reusing the de-facto
`Surrogate-Key` vocabulary, which some app frameworks already emit and
which would make an app portable between CARLOS and a Fastly-style CDN.
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index ace80a2e8..16ade10d1 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -370,6 +370,11 @@ func withVar(env map[string]string, key, value string) map[string]string {
// it, "the agent decides whether this var is set" is only true when the agent
// sets it; a tenant supplying the same name in its own env file wins by
// default. Same copy discipline as withVar: the caller's map is never mutated.
+//
+// This package keeps its own agent-owned list inline (the two names above,
+// at the b.Cache != nil branch) rather than sharing one with
+// internal/appconfig's agentOwned (agentvars.go), which the unit-tenant sync
+// path uses for the same purpose — the two lists must not drift apart.
func withoutVars(env map[string]string, keys ...string) map[string]string {
out := make(map[string]string, len(env))
for k, v := range env {
diff --git a/internal/appconfig/agentvars.go b/internal/appconfig/agentvars.go
new file mode 100644
index 000000000..ea6d32883
--- /dev/null
+++ b/internal/appconfig/agentvars.go
@@ -0,0 +1,51 @@
+package appconfig
+
+import "strings"
+
+// Agent-owned vars (parent spec §7.3): CARLOS_PURGE is set by the platform
+// when the box has a cache and REMOVED when it does not — the removal half
+// is load-bearing, since the env layers are tenant-supplied and a tenant
+// advertising a purge channel that does not exist would have apps skip
+// their own invalidation and serve stale content indefinitely.
+// CARLOS_CONTROL_SOCKET is stripped for the stronger reason: a
+// tenant-chosen socket path is a name the agent never vouched for. This
+// mirrors the exec backend's withoutVars (internal/activator/backend_exec.go);
+// the two lists must not drift.
+//
+// The sync appends CARLOS_PURGE=header UNCONDITIONALLY because cache
+// construction is unconditional in carlos agent (internal/host/agent.go,
+// edge.NewCache at startup) — every box this sync runs on serves through
+// that cache. The day the cache grows an off switch, this must learn to
+// read it.
+func agentOwned(name string) bool {
+ return name == "CARLOS_PURGE" || name == "CARLOS_CONTROL_SOCKET"
+}
+
+// applyAgentVars rewrites one rendered env file: tenant-supplied
+// agent-owned vars are dropped and the agent's own CARLOS_PURGE=header is
+// appended last (last assignment wins under systemd EnvironmentFile, so
+// even a hand-staged earlier line could not override it — but we strip
+// those anyway). Deterministic and idempotent, so syncHost's bytes.Equal
+// convergence check still short-circuits the steady state.
+//
+// RenderEnv (materialize.go) emits exactly one `KEY=value\n` line per var,
+// keys sorted, and nothing else — no comments, no blank lines — so dropping
+// blank lines here is a no-op against its actual output; it's kept because a
+// transformation over line-oriented KEY=value content should not depend on
+// its input never containing one.
+func applyAgentVars(rendered []byte) []byte {
+ var out strings.Builder
+ for _, line := range strings.Split(string(rendered), "\n") {
+ s := strings.TrimSpace(line)
+ if s == "" {
+ continue
+ }
+ if name, _, ok := strings.Cut(s, "="); ok && agentOwned(strings.TrimSpace(name)) {
+ continue
+ }
+ out.WriteString(line)
+ out.WriteByte('\n')
+ }
+ out.WriteString("CARLOS_PURGE=header\n")
+ return []byte(out.String())
+}
diff --git a/internal/appconfig/agentvars_test.go b/internal/appconfig/agentvars_test.go
new file mode 100644
index 000000000..cb2fc9104
--- /dev/null
+++ b/internal/appconfig/agentvars_test.go
@@ -0,0 +1,18 @@
+package appconfig
+
+import "testing"
+
+func TestApplyAgentVars(t *testing.T) {
+ in := []byte("A=1\nCARLOS_PURGE=socket\nB=2\nCARLOS_CONTROL_SOCKET=/tmp/x\n")
+ got := string(applyAgentVars(in))
+ want := "A=1\nB=2\nCARLOS_PURGE=header\n"
+ if got != want {
+ t.Fatalf("got %q want %q", got, want)
+ }
+ if again := string(applyAgentVars([]byte(got))); again != want {
+ t.Fatalf("not idempotent: %q", again)
+ }
+ if got := string(applyAgentVars(nil)); got != "CARLOS_PURGE=header\n" {
+ t.Fatalf("empty render: got %q", got)
+ }
+}
diff --git a/internal/appconfig/sync.go b/internal/appconfig/sync.go
index ce177e02a..8a471e7e0 100644
--- a/internal/appconfig/sync.go
+++ b/internal/appconfig/sync.go
@@ -240,7 +240,9 @@ func reap(routes []registry.Route, envDir string) {
// render is what the caller is deciding whether to WRITE, and this decision
// has to be made before rendering (a refused route must never reach the
// render-diff-write-restart path at all). RenderEnv emits exactly one
-// KEY=value line per var, so the two key sets are the same by construction.
+// KEY=value line per var, so the two key sets are the same by construction —
+// except for the agent-owned names applyAgentVars adds to and removes from
+// the rendered file afterward, which is why they are skipped below.
//
// Blank lines and #-comments are ignored, matching how systemd's
// EnvironmentFile reads these files: they carry no configuration, so their
@@ -259,6 +261,12 @@ func lostKeys(cur []byte, m *Manifest) (lost []string, parsed bool) {
if !ok || name == "" {
return nil, false
}
+ if agentOwned(name) {
+ // The render is post-processed by applyAgentVars, which appends
+ // CARLOS_PURGE=header to every file this sync writes: counting
+ // it as lost would refuse every previously-converged route.
+ continue
+ }
if _, kept := m.Vars[name]; !kept {
lost = append(lost, name)
}
@@ -356,6 +364,26 @@ func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.Pr
fmt.Printf(" %s: render config: %v\n", rt.Host, err)
return
}
+ // Spec §7.3: deliver CARLOS_PURGE=header to unit tenants the same way
+ // the exec backend already does for exec-backed ones (withoutVars /
+ // CARLOS_PURGE in internal/activator/backend_exec.go's Start) — strip
+ // any tenant-supplied agent-owned vars and append the agent's own.
+ // Deterministic and idempotent (agentvars.go), so the bytes.Equal
+ // check below still short-circuits the steady state ON EVERY PASS
+ // AFTER THE FIRST.
+ //
+ // The first pass after this ships is NOT a no-op, though: every
+ // existing <host>.env on the fleet gains this line, so bytes.Equal
+ // fails for every active unit tenant and this pass's restart(rt.Host)
+ // fires for each of them once — hibernated routes are unaffected
+ // (try-restart is a no-op on an inactive unit). That was weighed and
+ // accepted, not overlooked: the var can't take effect without a
+ // restart, unlike syncexec.go's execAddrConfigHeader, which declined
+ // exactly this trade for the socket-exec shape (a comment header
+ // alone isn't worth bouncing every instance on the fleet). Here the
+ // var itself is the payload, so the one-time fleet-wide restart is the
+ // feature landing, not a side effect of it.
+ rendered = applyAgentVars(rendered)
if existed && bytes.Equal(cur, rendered) {
return // up to date — stay quiet on the periodic tick
}
diff --git a/internal/appconfig/sync_test.go b/internal/appconfig/sync_test.go
index 1fedbc201..c331df0b5 100644
--- a/internal/appconfig/sync_test.go
+++ b/internal/appconfig/sync_test.go
@@ -64,7 +64,7 @@ func TestSyncWritesFileAndRestartsOnChange(t *testing.T) {
if err != nil {
t.Fatalf("read rendered env: %v", err)
}
- want := "LOG_LEVEL=debug\nSES_SECRET=shh\n"
+ want := "LOG_LEVEL=debug\nSES_SECRET=shh\nCARLOS_PURGE=header\n"
if string(got) != want {
t.Fatalf("rendered env = %q, want %q", got, want)
}
@@ -161,10 +161,12 @@ func TestSyncLeavesHandStagedFileAloneWhenNoManifestExists(t *testing.T) {
}
}
-func TestSyncConvergesToEmptyAfterEveryVarIsUnset(t *testing.T) {
+func TestSyncConvergesToAgentOwnedOnlyAfterEveryVarIsUnset(t *testing.T) {
// The mirror of the case above: the manifest object DOES exist, it just
// has no vars left. That is a real, operator-intended state and must
- // converge — the file is emptied and the unit restarted.
+ // converge — every tenant var is dropped and the unit restarted. The
+ // file is not fully emptied: CARLOS_PURGE=header is agent-owned, not a
+ // tenant var, and applyAgentVars appends it unconditionally.
s := release.NewDirStore(t.TempDir())
ctx := context.Background()
reg := testRegistry(t)
@@ -192,8 +194,8 @@ func TestSyncConvergesToEmptyAfterEveryVarIsUnset(t *testing.T) {
t.Fatalf("Sync: %v", err)
}
path := filepath.Join(envDir, "keymail.example.com.env")
- if got, err := os.ReadFile(path); err != nil || string(got) != "LOG_LEVEL=debug\n" {
- t.Fatalf("after set: env = %q, err = %v; want LOG_LEVEL=debug", got, err)
+ if got, err := os.ReadFile(path); err != nil || string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" {
+ t.Fatalf("after set: env = %q, err = %v; want LOG_LEVEL=debug\\nCARLOS_PURGE=header", got, err)
}
if _, err := Unset(ctx, s, "bab", "keymail", []string{"LOG_LEVEL"}); err != nil {
@@ -207,8 +209,10 @@ func TestSyncConvergesToEmptyAfterEveryVarIsUnset(t *testing.T) {
if err != nil {
t.Fatalf("read env after unset: %v", err)
}
- if len(got) != 0 {
- t.Fatalf("after full unset: env = %q, want empty", got)
+ // Convergence to empty is now "just the agent-owned line" — the agent
+ // still owns CARLOS_PURGE regardless of what the tenant's manifest holds.
+ if string(got) != "CARLOS_PURGE=header\n" {
+ t.Fatalf("after full unset: env = %q, want just CARLOS_PURGE=header", got)
}
if len(restarted) != 1 || restarted[0] != "keymail.example.com" {
t.Fatalf("restarted = %v, want exactly [keymail.example.com]", restarted)
@@ -489,9 +493,9 @@ func TestSyncRendersEachRoutesBoundEnvironment(t *testing.T) {
}
want := map[string]string{
- "plain.example.com": "LOG_LEVEL=info\nSHARED=yes\n",
- "demo.example.com": "DEMO_ONLY=1\nLOG_LEVEL=debug\nSHARED=yes\n",
- "staging.example.com": "LOG_LEVEL=trace\nSHARED=yes\nSTAGING_ONLY=1\n",
+ "plain.example.com": "LOG_LEVEL=info\nSHARED=yes\nCARLOS_PURGE=header\n",
+ "demo.example.com": "DEMO_ONLY=1\nLOG_LEVEL=debug\nSHARED=yes\nCARLOS_PURGE=header\n",
+ "staging.example.com": "LOG_LEVEL=trace\nSHARED=yes\nSTAGING_ONLY=1\nCARLOS_PURGE=header\n",
}
for host, body := range want {
got, err := os.ReadFile(filepath.Join(envDir, host+".env"))
@@ -612,8 +616,8 @@ func TestSyncLeavesEnvFileUntouchedWhenBoundEnvironmentIsMissing(t *testing.T) {
if err != nil {
t.Fatalf("sibling route not materialized: %v", err)
}
- if string(sibling) != "LOG_LEVEL=info\n" {
- t.Fatalf("sibling env = %q, want LOG_LEVEL=info", sibling)
+ if string(sibling) != "LOG_LEVEL=info\nCARLOS_PURGE=header\n" {
+ t.Fatalf("sibling env = %q, want LOG_LEVEL=info + CARLOS_PURGE=header", sibling)
}
if len(restarted) != 1 || restarted[0] != "ok.example.com" {
t.Fatalf("restarted = %v, want exactly [ok.example.com]", restarted)
@@ -733,8 +737,8 @@ func TestSyncRefusesToReplaceStagedFileWhenAppHasNoDefaultBundle(t *testing.T) {
if err != nil {
t.Fatalf("fresh route not materialized: %v", err)
}
- if string(fresh) != "LOG_LEVEL=debug\n" {
- t.Fatalf("fresh route env = %q, want LOG_LEVEL=debug", fresh)
+ if string(fresh) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" {
+ t.Fatalf("fresh route env = %q, want LOG_LEVEL=debug + CARLOS_PURGE=header", fresh)
}
if len(restarted) != 1 || restarted[0] != "fresh.example.com" {
t.Fatalf("restarted = %v, want exactly [fresh.example.com]", restarted)
@@ -778,8 +782,8 @@ func TestSyncEnvironmentOnlyAppKeepsConvergingAfterItsOwnFirstWrite(t *testing.T
t.Fatalf("Sync (pass 1): %v", err)
}
path := filepath.Join(envDir, "fresh.example.com.env")
- if got, err := os.ReadFile(path); err != nil || string(got) != "LOG_LEVEL=debug\n" {
- t.Fatalf("pass 1: env = %q, err = %v; want LOG_LEVEL=debug", got, err)
+ if got, err := os.ReadFile(path); err != nil || string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" {
+ t.Fatalf("pass 1: env = %q, err = %v; want LOG_LEVEL=debug + CARLOS_PURGE=header", got, err)
}
// The operator edits the bundle: one value changed, one key added.
@@ -797,7 +801,7 @@ func TestSyncEnvironmentOnlyAppKeepsConvergingAfterItsOwnFirstWrite(t *testing.T
if err != nil {
t.Fatalf("pass 2: read env: %v", err)
}
- if string(got) != "LOG_LEVEL=trace\nNEW=1\n" {
+ if string(got) != "LOG_LEVEL=trace\nNEW=1\nCARLOS_PURGE=header\n" {
t.Fatalf("pass 2: env = %q, want the edited bundle to converge", got)
}
if len(restarted) != 1 || restarted[0] != "fresh.example.com" {
@@ -897,7 +901,7 @@ func TestSyncEnvOnlyKeyRemovalIsRefusedUntilADefaultBundleExists(t *testing.T) {
if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
t.Fatalf("Sync (pass 2): %v", err)
}
- if got, _ := os.ReadFile(path); string(got) != "GONE=2\nKEEP=1\n" {
+ if got, _ := os.ReadFile(path); string(got) != "GONE=2\nKEEP=1\nCARLOS_PURGE=header\n" {
t.Fatalf("env = %q, want the removal refused while the app has no default bundle", got)
}
// The moment a default bundle exists the app is bucket-managed and the
@@ -909,7 +913,7 @@ func TestSyncEnvOnlyKeyRemovalIsRefusedUntilADefaultBundleExists(t *testing.T) {
if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
t.Fatalf("Sync (pass 3): %v", err)
}
- if got, _ := os.ReadFile(path); string(got) != "KEEP=1\n" {
+ if got, _ := os.ReadFile(path); string(got) != "KEEP=1\nCARLOS_PURGE=header\n" {
t.Fatalf("env = %q, want the removal to land once a default bundle exists", got)
}
}
@@ -965,7 +969,7 @@ func TestSyncMergedRenderIsAuthoritativeOnceADefaultBundleExists(t *testing.T) {
if err != nil {
t.Fatalf("read env: %v", err)
}
- if string(got) != "LOG_LEVEL=debug\n" {
+ if string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" {
t.Fatalf("env = %q, want the merged render to win once a default bundle exists", got)
}
if len(restarted) != 1 || restarted[0] != "prod.example.com" {
@@ -1015,12 +1019,79 @@ func TestSyncEnvironmentOnlyAppWithNoDefaultBundle(t *testing.T) {
if err != nil {
t.Fatalf("%s: read rendered env: %v", host, err)
}
- if string(got) != "ONLY=here\n" {
- t.Fatalf("%s: rendered env = %q, want ONLY=here", host, got)
+ if string(got) != "ONLY=here\nCARLOS_PURGE=header\n" {
+ t.Fatalf("%s: rendered env = %q, want ONLY=here + CARLOS_PURGE=header", host, got)
}
}
}
+func TestLostKeysIgnoresAgentOwnedVars(t *testing.T) {
+ // The sync itself appends CARLOS_PURGE=header to every file it writes
+ // (applyAgentVars). If lostKeys counts it, every defaultMissing route
+ // the sync previously converged reads as "about to lose a key" and is
+ // refused forever — the false-refusal loop.
+ cur := []byte("A=1\nCARLOS_PURGE=header\n")
+ m := &Manifest{Schema: 1, App: "keymail", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}
+ lost, parsed := lostKeys(cur, m)
+ if !parsed || len(lost) != 0 {
+ t.Fatalf("lost=%v parsed=%v, want none/true", lost, parsed)
+ }
+}
+
+// TestSyncHostConvergesAgentOwnedVarWithoutRewrite pins the convergence
+// property applyAgentVars must not break: syncHost's bytes.Equal
+// short-circuit (sync.go) has to still hold once every render carries an
+// appended CARLOS_PURGE=header line. The first pass writes the file
+// (containing the var) and restarts; an identical second pass must leave the
+// file's mtime and content alone and must NOT call restart again — proof
+// that applyAgentVars is deterministic and idempotent, not just that its
+// output happens to look right once.
+func TestSyncHostConvergesAgentOwnedVarWithoutRewrite(t *testing.T) {
+ envDir := t.TempDir()
+ m := &Manifest{Schema: 1, App: "keymail", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}
+ rt := registry.Route{Host: "steady.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready", Backing: registry.BackingUnit}
+
+ var restarted int
+ restart := func(string) error { restarted++; return nil }
+
+ syncHost(rt, m, false, nil, envDir, restart)
+ path := filepath.Join(envDir, "steady.example.com.env")
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read env after first sync: %v", err)
+ }
+ if string(got) != "A=1\nCARLOS_PURGE=header\n" {
+ t.Fatalf("env = %q, want A=1 + CARLOS_PURGE=header", got)
+ }
+ if restarted != 1 {
+ t.Fatalf("restarted = %d after first sync, want 1", restarted)
+ }
+ fi, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("stat after first sync: %v", err)
+ }
+
+ // Second, identical pass.
+ syncHost(rt, m, false, nil, envDir, restart)
+ if restarted != 1 {
+ t.Fatalf("restarted = %d after second identical sync, want still 1 (no re-restart)", restarted)
+ }
+ now, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("stat after second sync: %v", err)
+ }
+ if !now.ModTime().Equal(fi.ModTime()) {
+ t.Fatalf("env file was rewritten on an unchanged pass (mtime moved)")
+ }
+ got2, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read env after second sync: %v", err)
+ }
+ if string(got2) != string(got) {
+ t.Fatalf("second pass content = %q, want unchanged (%q)", got2, got)
+ }
+}
+
func TestSyncMaterializesEveryRouteOfAMultiRouteApp(t *testing.T) {
// One app, two simultaneous routes (e.g. a stable route and a canary
// route are two different Hosts) — the shared config must land in
@@ -1056,8 +1127,8 @@ func TestSyncMaterializesEveryRouteOfAMultiRouteApp(t *testing.T) {
if err != nil {
t.Fatalf("%s: read rendered env: %v", host, err)
}
- if string(got) != "LOG_LEVEL=debug\n" {
- t.Fatalf("%s: rendered env = %q, want LOG_LEVEL=debug", host, got)
+ if string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" {
+ t.Fatalf("%s: rendered env = %q, want LOG_LEVEL=debug + CARLOS_PURGE=header", host, got)
}
}
}
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go
index d6ea31337..7366b8424 100644
--- a/internal/appconfig/syncexec.go
+++ b/internal/appconfig/syncexec.go
@@ -522,6 +522,16 @@ func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.Private
// the instance's own <host>.env, the one file here that IS
// hand-staged, is a separate file layered on top at spawn and never
// read or written by this sweep.
+ // Deliberately NOT run through applyAgentVars (sync.go, spec §7.3):
+ // this is <host>.config.env, one layer under the socket-exec
+ // instance's own <host>.env, and the activator applies withVar/
+ // withoutVars to CARLOS_PURGE at spawn time (backend_exec.go's
+ // Start) — cache-CONDITIONAL, set only when b.Cache != nil. Baking
+ // CARLOS_PURGE=header into this file unconditionally would make the
+ // var dishonest on disk on a cacheless box, the exact failure mode
+ // agent-ownership exists to prevent. The asymmetry with sync.go's
+ // unit path (which does apply it, unconditionally, because cache
+ // construction is unconditional there) is correct; do not "fix" it.
rendered, err := RenderEnv(m, priv)
if err != nil {
fmt.Printf(" %s: render config: %v\n", rt.Host, err)
diff --git a/internal/edge/cache.go b/internal/edge/cache.go
index 630a183c8..d1c08cb16 100644
--- a/internal/edge/cache.go
+++ b/internal/edge/cache.go
@@ -63,6 +63,7 @@ type Cache struct {
entries map[cacheFullKey]*list.Element
byURL map[cacheURLKey]map[cacheFullKey]*list.Element // urlKey -> its Vary variants
byPath map[cachePathKey]map[cacheFullKey]*list.Element // pathKey -> every entry under that path
+ byHost map[string]map[cacheFullKey]*list.Element // host -> every entry for that host
}
type cacheURLKey struct {
@@ -136,6 +137,7 @@ func NewCache(maxEntries, maxBodyBytes int) *Cache {
entries: make(map[cacheFullKey]*list.Element),
byURL: make(map[cacheURLKey]map[cacheFullKey]*list.Element),
byPath: make(map[cachePathKey]map[cacheFullKey]*list.Element),
+ byHost: make(map[string]map[cacheFullKey]*list.Element),
}
}
@@ -335,6 +337,10 @@ func (c *Cache) Put(key CacheKey, vary string, resp *CachedResponse) {
c.byPath[pk] = make(map[cacheFullKey]*list.Element)
}
c.byPath[pk][fk] = el
+ if c.byHost[uk.Host] == nil {
+ c.byHost[uk.Host] = make(map[cacheFullKey]*list.Element)
+ }
+ c.byHost[uk.Host][fk] = el
c.capGroupLocked(c.byURL[uk], maxVaryVariantsPerURL, el)
c.capGroupLocked(c.byPath[pk], maxEntriesPerPath, el)
@@ -389,6 +395,12 @@ func (c *Cache) removeLocked(el *list.Element) {
delete(c.byPath, pk)
}
}
+ if group := c.byHost[uk.Host]; group != nil {
+ delete(group, e.key)
+ if len(group) == 0 {
+ delete(c.byHost, uk.Host)
+ }
+ }
}
// PurgeURL drops every cached variant (every VaryValue) of (host, path, rawQuery)
@@ -438,23 +450,25 @@ func (c *Cache) PurgePath(host, path string) int {
}
// PurgeHost drops every cached entry for host — all paths, all query strings,
-// all Vary variants — and returns how many it removed. This is what a deploy
-// calls: a fresh process for a host means new code and potentially new
-// content, so everything cached from the old one is suspect. A whole-cache
-// scan is fine here; it happens once per spawn, not per request.
+// all Vary variants — and returns how many it removed. A deploy calls this: a
+// fresh process for a host means new code and potentially new content, so
+// everything cached from the old one is suspect.
+//
+// It is no longer only a deploy-time cost, though, and so no longer a
+// whole-cache scan: a Carlos-Purge header that names more targets than the
+// 64-target/8KiB caps allow degrades to PurgeHost (see purge_header.go),
+// which puts it on the request path. It rides the byHost index instead — the
+// same discipline byURL and byPath already keep in Put/removeLocked — so a
+// misbehaving or hostile app declaring an oversized purge cannot turn every
+// response into a scan of the entire cache.
func (c *Cache) PurgeHost(host string) int {
c.mu.Lock()
defer c.mu.Unlock()
- var doomed []*list.Element
- for key, el := range c.entries {
- if key.Host == host {
- doomed = append(doomed, el)
- }
- }
- for _, el := range doomed {
+ els := elementsOf(c.byHost[host])
+ for _, el := range els {
c.removeLocked(el)
}
- return len(doomed)
+ return len(els)
}
// elementsOf snapshots a group's elements so the caller can remove them while
diff --git a/internal/edge/cache_test.go b/internal/edge/cache_test.go
index eb9eb087b..28b321531 100644
--- a/internal/edge/cache_test.go
+++ b/internal/edge/cache_test.go
@@ -468,6 +468,35 @@ func TestCachePurgeHostRemovesEveryEntryForThatHost(t *testing.T) {
}
}
+// PurgeHost must ride a per-host index rather than scan every entry: a
+// capped Carlos-Purge header degrades to PurgeHost on the request path (see
+// purge_header.go), so a whole-cache scan there is request-reachable, not
+// just a once-per-deploy cost.
+func TestPurgeHostUsesPerHostIndex(t *testing.T) {
+ c := NewCache(100, 1<<20)
+ put := func(host, path, q string) {
+ c.Put(CacheKey{Host: host, Method: "GET", Path: path, RawQuery: q}, "",
+ &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("x"), StoredAt: time.Now(), TTL: time.Minute})
+ }
+ put("a.example", "/1", "")
+ put("a.example", "/1", "v=2")
+ put("a.example", "/2", "")
+ put("b.example", "/1", "")
+ if n := c.PurgeHost("a.example"); n != 3 {
+ t.Fatalf("purged %d, want 3", n)
+ }
+ if _, ok := c.get(CacheKey{Host: "b.example", Method: "GET", Path: "/1"}); !ok {
+ t.Fatal("b.example must survive a.example's purge")
+ }
+ if len(c.byHost) != 1 {
+ t.Fatalf("byHost holds %d hosts after purge, want 1 (empty groups must be deleted)", len(c.byHost))
+ }
+ c.PurgeHost("b.example")
+ if len(c.byHost) != 0 || len(c.entries) != 0 {
+ t.Fatal("indexes must be empty after purging everything")
+ }
+}
+
// An attacker pumping distinct query strings at one path must not be able to
// evict the rest of the fleet's cache: the per-path cap bounds how many
// entries a single path can occupy, independent of the global LRU.
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 916d8ce09..8c5372eb7 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -627,6 +627,14 @@ type recordingResponseWriter struct {
}
func (r *recordingResponseWriter) WriteHeader(status int) {
+ // 1xx responses (103 Early Hints, forwarded by httputil.ReverseProxy)
+ // precede the final status; latching one would record it as THE status
+ // and maybeStore's 2xx check would then refuse the real response that
+ // follows. Pass them through and keep waiting for the final one.
+ if status < 200 {
+ r.ResponseWriter.WriteHeader(status)
+ return
+ }
if !r.wroteHdr {
r.status = status
r.wroteHdr = true
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go
index 651fd1df7..59008f1ef 100644
--- a/internal/edge/edge_test.go
+++ b/internal/edge/edge_test.go
@@ -13,6 +13,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "slices"
"strings"
"sync/atomic"
"syscall"
@@ -1412,3 +1413,54 @@ func TestCacheBeforeWakeFullCycle(t *testing.T) {
t.Fatalf("expected the post-purge request to miss and wake once more, wakes = %d", wakes)
}
}
+
+type mockResponseWriterFor1xx struct {
+ header http.Header
+ codes []int
+}
+
+func (m *mockResponseWriterFor1xx) Header() http.Header {
+ return m.header
+}
+
+func (m *mockResponseWriterFor1xx) WriteHeader(status int) {
+ // Stand-in for a real ResponseWriter. httptest.ResponseRecorder rejects
+ // writes after a 1xx status (RFC 9110 forbids bodies on 1xx), but a real
+ // ResponseWriter accepts the sequence 1xx then 2xx (103 Early Hints
+ // followed by the final response status) and forwards both. This mock
+ // accepts and records both.
+ m.codes = append(m.codes, status)
+}
+
+func (m *mockResponseWriterFor1xx) Write(p []byte) (int, error) {
+ return len(p), nil
+}
+
+func TestRecordingWriterIgnoresInformational(t *testing.T) {
+ mock := &mockResponseWriterFor1xx{header: make(http.Header)}
+
+ rec := &recordingResponseWriter{
+ ResponseWriter: mock,
+ request: httptest.NewRequest("GET", "http://app.example/x", nil),
+ cacheable: true,
+ maxBody: 1 << 20,
+ }
+ rec.WriteHeader(http.StatusEarlyHints)
+ rec.Header().Set("Cache-Control", "max-age=60")
+ rec.WriteHeader(http.StatusOK)
+ if _, err := rec.Write([]byte("final body")); err != nil {
+ t.Fatal(err)
+ }
+ if rec.status != http.StatusOK {
+ t.Fatalf("recorded status %d, want %d — informational must not latch", rec.status, http.StatusOK)
+ }
+ if !slices.Equal(mock.codes, []int{http.StatusEarlyHints, http.StatusOK}) {
+ t.Fatalf("underlying writer saw %v, want [103 200] — the 103 must be forwarded, not swallowed", mock.codes)
+ }
+ e := &Edge{cache: NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)}
+ key := CacheKey{Host: "app.example", Method: "GET", Path: "/x"}
+ e.maybeStore(key, rec)
+ if _, ok := e.cache.get(key); !ok {
+ t.Fatal("response written after a 103 was not cached")
+ }
+}
diff --git a/internal/edge/purge_header.go b/internal/edge/purge_header.go
index 0112f6697..c9afa81a6 100644
--- a/internal/edge/purge_header.go
+++ b/internal/edge/purge_header.go
@@ -140,17 +140,17 @@ const PurgeHostHeader = "Carlos-Purge-Host"
// path: without them a single response could name a million targets and drive
// a million locked cache operations before the client gets its bytes.
//
-// Degrading is NOT the cheap path — it is the SIMPLE one, and it is more
-// expensive than honouring the declaration. Honouring 64 targets is 128 map
-// lookups (each target hits the GET and HEAD index groups); PurgeHost takes
-// the same global lock and scans EVERY entry in the cache, which at
-// DefaultCacheMaxEntries (4096) is an order of magnitude more work — roughly
-// 32x at a full cache, and worse the fuller the cache is. So an app gains
-// nothing by exceeding the bound except a bigger blast radius on its own
-// cache; the bound is a cliff in behaviour (targeted -> whole host), not an
-// invitation. What the caps genuinely buy is that the per-response cost has a
-// CEILING that does not scale with the header's length — one host scan
-// instead of unbounded lookups.
+// Degrading is NOT the cheap path — it is the SIMPLE one. Honouring 64
+// targets is a bounded operation: up to 128 map lookups (each target hits
+// the GET and HEAD index groups) plus parsing each target string. PurgeHost
+// is bounded too — it rides the byHost index (see cache.go), a single map
+// lookup for the whole host's entry set, not a scan of the cache. Neither
+// path's cost is the point of the caps; what they genuinely buy is that the
+// per-response cost has a CEILING that does not scale with how much the
+// header declares, whichever path is taken. So an app gains nothing by
+// exceeding the bound except a bigger blast radius on its own cache — the
+// coarser whole-host purge, not a more precise one; the bound is a cliff in
+// behaviour (targeted -> whole host), not an invitation.
//
// 64 targets is well above what a real write invalidates (a page, its
// paginated index, a couple of locale variants). 8 KiB bounds the joined
diff --git a/internal/host/agent.go b/internal/host/agent.go
index d73c6c32f..49d4d016d 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -215,7 +215,7 @@ func adoptLoop(reg *registry.Registry) {
// hourly callers both nagging would double the log line every hour;
// worse, if this loop's cadence ever changes, a nag wired here changes
// frequency with it silently. One caller, one nag.
- if err := release.Pass(ctx, reg, store, pub, optDir, restartRoute); err != nil {
+ if err := release.Pass(ctx, reg, store, pub, optDir, restartRoute, release.BakeDir(dataDir())); err != nil {
log.Printf("agent: adopt pass: %v", err)
}
}
@@ -299,7 +299,7 @@ func canaryAdoptLoop(reg *registry.Registry) {
// still would nag on every single tick — a journal-flooding poll storm
// out of what is supposed to be an hourly, easy-to-skim log line. The
// nag belongs to RunAdopt (carlos-adopt.service) alone.
- if err := release.PassFilter(ctx, reg, store, pub, optDir, restartRoute, isCanaryRoute); err != nil {
+ if err := release.PassFilter(ctx, reg, store, pub, optDir, restartRoute, isCanaryRoute, release.BakeDir(dataDir())); err != nil {
log.Printf("agent: canary adopt pass: %v", err)
}
}
@@ -777,10 +777,10 @@ func RunAdopt(args []string) {
// canary-TTL nag (spec §5) belongs to the hourly root pass
// (carlos-adopt.service, this verb's ExecStart), the same split PR #9's
// config sync above uses. adoptLoop and canaryAdoptLoop in this same
- // package call release.Pass/PassFilter with no options at all, so an
- // expired canary nags once an hour from here, never every 2-3 seconds
- // from the in-process fast loop.
- if err := release.Pass(ctx, reg, store, pub, release.OptDir(), restartRoute, release.NagExpiredCanaries(os.Stdout)); err != nil {
+ // package call release.Pass/PassFilter without the nag option (they pass
+ // BakeDir only), so an expired canary nags once an hour from here, never
+ // every 2-3 seconds from the in-process fast loop.
+ if err := release.Pass(ctx, reg, store, pub, release.OptDir(), restartRoute, release.NagExpiredCanaries(os.Stdout), release.BakeDir(dataDir())); err != nil {
log.Fatalf("adopt: %v", err)
}
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index 895ecf69d..c654bb1da 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -33,6 +33,9 @@ type passConfig struct {
// default — every call site that does not pass NagExpiredCanaries)
// means the nag never fires at all, not even to a discarded writer.
nag io.Writer
+ // bakeDir, when non-empty, is the directory holding the bake clock
+ // (BakeDir option); empty keeps the legacy /opt-dir location.
+ bakeDir string
}
// NagExpiredCanaries opts a Pass/PassFilter call into spec §5's nag: one
@@ -58,6 +61,23 @@ func NagExpiredCanaries(w io.Writer) PassOption {
return func(c *passConfig) { c.nag = w }
}
+// BakeDir moves this pass's bake clock into dir — in production the data
+// dir (CARLOS_DATA_DIR, default /data), the one place both the sandboxed
+// edge unit (ReadWritePaths=/data) and the root adopt one-shot can write.
+// Reads migrate: a box whose clock still lives at the legacy
+// /opt/carlos/channel-first-seen.json keeps every observation (issue #13).
+// Unset, the clock stays at its legacy path — existing tests and callers
+// are unaffected.
+//
+// The migration is durable after the FIRST pass this option is set on a box
+// still on the legacy path: PassFilter forces a Save even when no new
+// channel head was observed, so the new path is fully populated (and the
+// legacy file provably safe to delete) one pass later, not whenever the
+// fleet next happens to see a new head.
+func BakeDir(dir string) PassOption {
+ return func(c *passConfig) { c.bakeDir = dir }
+}
+
// Pass is one full adoption sweep: group instance routes by (App, Channel),
// resolve each channel pointer once, verify signatures and artifact sha256s,
// download/stage missing versions, and repoint live/<host> symlinks.
@@ -185,7 +205,15 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
// ever ticks for pointers that verified above.
now := time.Now()
bake := LoadFirstSeen(FirstSeenPath(optDir))
- changed := false
+ if cfg.bakeDir != "" {
+ bake = LoadFirstSeenMigrating(FirstSeenPath(cfg.bakeDir), FirstSeenPath(optDir))
+ }
+ // bake.migrated forces this pass's Save even if nothing new is observed
+ // below: otherwise a changed-gated Save could leave the new path absent
+ // for days on a fleet that happens to see no new channel head, and the
+ // legacy file would look safe to delete long before it actually is (see
+ // LoadFirstSeenMigrating and BakeDir's doc comments, 2026-08-03 review).
+ changed := bake.migrated
for k, ptr := range pointers {
if ptr != nil && bake.Observe(k.acct, k.app, k.channel, ptr.Version, now) {
changed = true
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go
index ab541f310..f7ffb98e1 100644
--- a/internal/release/adopt_test.go
+++ b/internal/release/adopt_test.go
@@ -76,6 +76,73 @@ func TestPassRepointsAwakeAndHibernating(t *testing.T) {
}
}
+// TestPassBakeDirMovesTheClock proves the BakeDir option's whole point
+// (issue #13): the bake clock lands under the given dir, not optDir's
+// legacy /opt-dir location.
+func TestPassBakeDirMovesTheClock(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ bakeDir := t.TempDir()
+ reg := testRegistry(t)
+
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ addRoute(t, reg, "awake.example.com", "myapp", "canary/x", false)
+
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(bakeDir)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(bakeDir, "channel-first-seen.json")); err != nil {
+ t.Fatalf("BakeDir option did not land the clock in bakeDir: %v", err)
+ }
+ if _, err := os.Stat(FirstSeenPath(opt)); !os.IsNotExist(err) {
+ t.Fatalf("bake clock also written to the legacy opt dir despite BakeDir (stat err %v)", err)
+ }
+}
+
+// TestPassMigratesBakeClockDurably proves the migration off the legacy path
+// completes in ONE pass even when nothing new was observed (2026-08-03
+// review, IMPORTANT): Save is changed-gated, so with the head already
+// recorded in the legacy clock, Observe reports no change this pass and the
+// ordinary "tick and save" path would never write the new location — a box
+// could run for days with the new path absent, and deleting the legacy file
+// in that window would silently reset the channel's bake window. The legacy
+// clock here already has this exact head, so the ONLY way the new path ends
+// up populated is the migration forcing its own first Save.
+func TestPassMigratesBakeClockDurably(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ bakeDir := t.TempDir()
+ reg := testRegistry(t)
+
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ addRoute(t, reg, "awake.example.com", "myapp", "canary/x", false)
+
+ legacy := LoadFirstSeen(FirstSeenPath(opt))
+ legacy.Observe("bab", "myapp", "canary/x", "v1", time.Now())
+ if err := legacy.Save(); err != nil {
+ t.Fatalf("seed legacy clock: %v", err)
+ }
+
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(bakeDir)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+
+ st := LoadFirstSeen(filepath.Join(bakeDir, "channel-first-seen.json"))
+ if _, ok := st.EligibleAt("bab", "myapp", "canary/x", "v1", DefaultHolds); !ok {
+ t.Fatal("migrated content did not land in the new path after one pass with no new head observed")
+ }
+}
+
func TestPassPoisonedBuildSkipsRemainingHosts(t *testing.T) {
s := NewDirStore(t.TempDir())
ctx := context.Background()
diff --git a/internal/release/bake.go b/internal/release/bake.go
index 9def2aae6..ed87fbc43 100644
--- a/internal/release/bake.go
+++ b/internal/release/bake.go
@@ -55,7 +55,14 @@ func HoldFor(holds map[string]time.Duration, channel string) time.Duration {
return 72 * time.Hour
}
-// FirstSeenPath is where a box persists its bake clock, under its opt dir.
+// FirstSeenPath is the LEGACY bake clock location, under the box's opt dir.
+// It is read-only for every production caller now: BakeDir moved the
+// writable clock to the data dir (issue #13 — the sandboxed edge unit
+// cannot write under optDir, and must not be able to), and
+// LoadFirstSeenMigrating reaches back to this path only to carry an
+// existing box's observations across that move. Nothing in production
+// writes here anymore; a box still on the legacy path only reads it once,
+// on the pass that migrates it.
func FirstSeenPath(optDir string) string {
return filepath.Join(optDir, "channel-first-seen.json")
}
@@ -78,6 +85,13 @@ type FirstSeen struct {
Channels map[string]map[string]time.Time `json:"channels"`
path string // where Save persists; set by LoadFirstSeen
+
+ // migrated marks a clock LoadFirstSeenMigrating built from its
+ // legacy-fallback branch (the new path didn't exist yet). PassFilter
+ // checks this to force an immediate Save even when Observe reports no
+ // change this pass — see LoadFirstSeenMigrating's doc comment for why
+ // a changed-gated Save alone isn't enough to make the migration durable.
+ migrated bool
}
// channelClockKey identifies one channel head's clock. The ACCOUNT is part of
@@ -129,6 +143,34 @@ func LoadFirstSeen(path string) *FirstSeen {
return st
}
+// LoadFirstSeenMigrating reads the bake clock from path, reaching back to
+// legacyPath for its CONTENT when path does not exist yet — the one-time
+// migration off /opt/carlos (issue #13: the sandboxed edge cannot write the
+// version store, and must not be able to; the clock belongs in the data
+// dir). Save always writes path. The legacy file is never written or
+// deleted: an older binary still running against it keeps a working clock,
+// and this binary simply stops advancing it.
+//
+// The returned clock is marked migrated, which its caller (PassFilter) uses
+// to force that first Save even when nothing new was observed this pass —
+// Save is otherwise changed-gated (bake.go/adopt.go), and a box whose fleet
+// happens to see no new channel head could otherwise run for days with path
+// still absent, still re-reading legacyPath every pass. That window matters
+// because it invites deleting the legacy file as soon as this binary ships,
+// which — before the migrated Save lands — would silently wipe every stable
+// channel's observations and freeze the fleet on a fresh 72h window. Forcing
+// the first Save makes migration complete (path fully populated,
+// legacyPath now provably disposable) after exactly one pass.
+func LoadFirstSeenMigrating(path, legacyPath string) *FirstSeen {
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ st := LoadFirstSeen(legacyPath)
+ st.path = path
+ st.migrated = true
+ return st
+ }
+ return LoadFirstSeen(path)
+}
+
// Observe records a channel head this box hasn't seen at that head before.
// Returns whether anything changed (the caller persists via Save). Never
// call it on a pointer that failed verification — the clock only ever ticks
@@ -166,13 +208,64 @@ func (st *FirstSeen) Observe(acct, app, channel, version string, now time.Time)
return changed
}
-// Save persists the clock back to the path it was loaded from.
+// Save persists the clock back to the path it was loaded from, via a
+// temp-file-in-the-same-dir + rename — the same co-written-file pattern
+// internal/appconfig/sync.go and syncexec.go already use for /data files
+// more than one uid writes (2026-08-03 review, issue #13): carlos-edge.service
+// runs as `carlos`, the root adopt one-shot runs as root, and BakeDir makes
+// both write this same path. An in-place O_TRUNC write needs write
+// permission on the EXISTING file, so whichever uid creates it first (0644,
+// owned by that uid) locks the other one out — EACCES with nothing to
+// repair the ownership (bootstrap only chowns /data once, at bootstrap).
+// rename(2), by contrast, only requires write permission on the containing
+// directory, which both uids have on /data, so either side can always
+// replace the file — and a crash mid-write leaves the old content intact
+// rather than a torn, half-written file.
+//
+// The temp name is minted fresh per attempt (os.CreateTemp's "channel-
+// first-seen-*.json"), never a fixed "<path>.tmp": two uids can each crash
+// between write and rename, and a fixed name would leave cross-uid residue —
+// a root-owned 0644 leftover that the carlos-uid loop then fails to
+// O_TRUNC-rewrite forever (EACCES, no repair path short of root's next
+// pass), aborting its whole Pass every tick until then. A unique name per
+// attempt means a crash never leaves a temp file another uid's next Save
+// must contend with. CreateTemp also mints its file 0600, which this
+// explicitly widens to 0644 with Chmod BEFORE the rename: a 0600 root:root
+// clock is unreadable by the carlos-uid loop, and LoadFirstSeen turns that
+// read failure into a silently EMPTY clock — not an error, just every
+// channel head baking from "now" again, freezing stable adoption for a
+// fresh 72h. Any failure path removes the temp file rather than leaving it
+// behind for the next Save to trip over.
func (st *FirstSeen) Save() error {
raw, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
- return os.WriteFile(st.path, append(raw, '\n'), 0o644)
+ raw = append(raw, '\n')
+ f, err := os.CreateTemp(filepath.Dir(st.path), "channel-first-seen-*.json")
+ if err != nil {
+ return err
+ }
+ tmp := f.Name()
+ _, writeErr := f.Write(raw)
+ closeErr := f.Close()
+ if writeErr != nil {
+ os.Remove(tmp)
+ return writeErr
+ }
+ if closeErr != nil {
+ os.Remove(tmp)
+ return closeErr
+ }
+ if err := os.Chmod(tmp, 0o644); err != nil {
+ os.Remove(tmp)
+ return err
+ }
+ if err := os.Rename(tmp, st.path); err != nil {
+ os.Remove(tmp) // don't leave a stray temp file behind
+ return err
+ }
+ return nil
}
// EligibleAt is the moment a version first seen at a channel head may be
diff --git a/internal/release/bake_test.go b/internal/release/bake_test.go
index 1217e5734..90077ebf6 100644
--- a/internal/release/bake_test.go
+++ b/internal/release/bake_test.go
@@ -1,6 +1,7 @@
package release
import (
+ "bytes"
"context"
"encoding/json"
"os"
@@ -244,3 +245,114 @@ func TestBakeClockReadsPreAccountRecordsAsOps(t *testing.T) {
t.Fatal("a pre-accounts record satisfied a different account's window")
}
}
+
+func TestLoadFirstSeenMigrating(t *testing.T) {
+ now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC)
+
+ t.Run("prefers the new path when it exists", func(t *testing.T) {
+ dir := t.TempDir()
+ newPath := filepath.Join(dir, "new.json")
+ oldPath := filepath.Join(dir, "old.json")
+ stNew := LoadFirstSeen(newPath)
+ stNew.Observe("acct", "app", "stable", "v-new", now)
+ if err := stNew.Save(); err != nil {
+ t.Fatal(err)
+ }
+ stOld := LoadFirstSeen(oldPath)
+ stOld.Observe("acct", "app", "stable", "v-old", now)
+ if err := stOld.Save(); err != nil {
+ t.Fatal(err)
+ }
+ st := LoadFirstSeenMigrating(newPath, oldPath)
+ if _, ok := st.EligibleAt("acct", "app", "stable", "v-new", nil); !ok {
+ t.Fatal("new path's observation missing")
+ }
+ if _, ok := st.EligibleAt("acct", "app", "stable", "v-old", nil); ok {
+ t.Fatal("legacy content leaked in despite the new path existing")
+ }
+ })
+
+ t.Run("falls back to legacy content, saves to the new path, leaves legacy untouched", func(t *testing.T) {
+ dir := t.TempDir()
+ newPath := filepath.Join(dir, "new.json")
+ oldPath := filepath.Join(dir, "old.json")
+ stOld := LoadFirstSeen(oldPath)
+ stOld.Observe("acct", "app", "stable", "v1", now)
+ if err := stOld.Save(); err != nil {
+ t.Fatal(err)
+ }
+ legacyRaw, err := os.ReadFile(oldPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ st := LoadFirstSeenMigrating(newPath, oldPath)
+ if _, ok := st.EligibleAt("acct", "app", "stable", "v1", nil); !ok {
+ t.Fatal("legacy observation not carried over")
+ }
+ if err := st.Save(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(newPath); err != nil {
+ t.Fatalf("Save did not write the new path: %v", err)
+ }
+ afterRaw, err := os.ReadFile(oldPath)
+ if err != nil || !bytes.Equal(legacyRaw, afterRaw) {
+ t.Fatalf("legacy file must be byte-identical after migration (err %v)", err)
+ }
+ })
+
+ t.Run("both missing is a fresh clock at the new path", func(t *testing.T) {
+ dir := t.TempDir()
+ newPath := filepath.Join(dir, "new.json")
+ st := LoadFirstSeenMigrating(newPath, filepath.Join(dir, "absent.json"))
+ st.Observe("acct", "app", "stable", "v1", now)
+ if err := st.Save(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(newPath); err != nil {
+ t.Fatalf("Save must create the new path: %v", err)
+ }
+ })
+}
+
+// TestFirstSeenSaveReplacesFileItCannotWriteInPlace stands in for the real
+// production race (2026-08-03 review, CRITICAL): carlos-edge.service writes
+// this path as user `carlos`, the root adopt one-shot writes it as root —
+// whichever creates the file first locks the other uid out, because an
+// in-place O_TRUNC write needs write permission on the FILE, and a 0644 file
+// owned by the other uid has none for anyone else. A file this process
+// cannot open for writing (0o444, no write bit for anyone, including its own
+// owner) reproduces the same "cannot write in place" shape without needing a
+// second uid: rename(2) only requires write permission on the containing
+// directory, never on the file being replaced, so a Save implemented as
+// temp-file+rename must still succeed here where a truncating write cannot.
+func TestFirstSeenSaveReplacesFileItCannotWriteInPlace(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "clock.json")
+ if err := os.WriteFile(path, []byte("{}"), 0o444); err != nil {
+ t.Fatal(err)
+ }
+ st := LoadFirstSeen(path)
+ st.Observe("acct", "app", "stable", "v1", time.Now())
+ if err := st.Save(); err != nil {
+ t.Fatalf("Save must replace via rename, not an in-place write: %v", err)
+ }
+ // The temp name is minted fresh per attempt (CreateTemp's
+ // "channel-first-seen-*.json"), not a fixed "<path>.tmp" — glob for the
+ // pattern rather than stat-ing one fixed name.
+ if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "channel-first-seen-*.json")); err != nil {
+ t.Fatalf("glob for stray temp files: %v", err)
+ } else if len(matches) != 0 {
+ t.Fatalf("Save left a temp file behind: %v", matches)
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var reloaded FirstSeen
+ if err := json.Unmarshal(raw, &reloaded); err != nil {
+ t.Fatalf("Save produced invalid/partial content: %v", err)
+ }
+ if _, ok := reloaded.Channels[channelClockKey("acct", "app", "stable")]["v1"]; !ok {
+ t.Fatal("observation missing after Save")
+ }
+}