carlos / platform Public

appconfig: unit tenants learn the purge channel exists — the agent owns the var in both directions

Spec §7 item 3 deferred delivering the advisory CARLOS_PURGE=header var to
unit tenants; the exec backend already set/stripped it (withoutVars in
internal/activator/backend_exec.go). This lands the appconfig sync side:
applyAgentVars (agentvars.go) strips tenant-supplied agent-owned vars
(CARLOS_PURGE, CARLOS_CONTROL_SOCKET) from every rendered env file and
appends the agent's own CARLOS_PURGE=header, deterministically and
idempotently so syncHost's bytes.Equal convergence check still holds.

lostKeys now excludes agent-owned names from its "would this overwrite drop
a key" check — otherwise the var this sync itself appends would read as a
lost key on every subsequent pass and permanently refuse every
defaultMissing route that had ever converged. Regression pinned red before
the fix (TestLostKeysIgnoresAgentOwnedVars), plus a syncHost-level
convergence test proving a second identical pass neither rewrites nor
re-restarts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 257986dc0295333dfbcfd6a770f7b5dd29f7304b parent 542f97a
5 files changed, +182 −28
  • docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md +6 −5
  • internal/appconfig/agentvars.go +51 −0
  • internal/appconfig/agentvars_test.go +18 −0
  • internal/appconfig/sync.go +13 −0
  • internal/appconfig/sync_test.go +94 −23
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..f91f9e1f5 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,11 +683,12 @@ 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.
+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.
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
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..b99ea96c9 100644
--- a/internal/appconfig/sync.go
+++ b/internal/appconfig/sync.go
@@ -259,6 +259,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 +362,13 @@ 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.
+ 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..45a205673 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)
}
@@ -164,7 +164,9 @@ func TestSyncLeavesHandStagedFileAloneWhenNoManifestExists(t *testing.T) {
func TestSyncConvergesToEmptyAfterEveryVarIsUnset(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)
}
}
}