carlos / platform Public

appconfig, activator, spec: name the first-pass restart and the drift risks review flagged

Comment/spec-only follow-up to 257986d, no behavior change:

- syncHost's applyAgentVars comment and spec §7 item 3 now say out loud
  that the first sync pass after this ships restarts every active
  unit-backed route once (every existing <host>.env gains the
  CARLOS_PURGE=header line, so bytes.Equal fails fleet-wide on that one
  pass) — weighed and accepted deliberately, the same trade
  syncexec.go's execAddrConfigHeader declined for a lesser payload.
- backend_exec.go's withoutVars now points at internal/appconfig's
  agentOwned (agentvars.go) as the second agent-owned-vars list that
  must not drift from this one.
- syncexec.go's render site documents why applyAgentVars is correctly
  NOT applied there: the socket-exec activator's CARLOS_PURGE handling
  is cache-conditional, so baking the var into <host>.config.env
  unconditionally would make it dishonest on disk on a cacheless box.
- Spec §7 item 3's rollout paragraph rewritten from future to past/
  present tense to match its "Landed 2026-08-03" header.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 69a52719a8bb8da588c123163dee89469a209da8 parent 257986d
4 files changed, +47 −5
  • docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md +18 −4
  • internal/activator/backend_exec.go +5 −0
  • internal/appconfig/sync.go +14 −1
  • internal/appconfig/syncexec.go +10 −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 f91f9e1f5..38bb527df 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
@@ -690,14 +690,28 @@ also forwarded; the fix in both cases is upgrading the edge, not the app.
**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
- 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
+ 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 shipped changed the byte content of every active
+ unit tenant's file — `syncHost`'s `bytes.Equal` convergence check saw a
+ diff for each one and restarted it once (hibernated routes were
+ unaffected; `try-restart` is a no-op on an inactive unit). That one-time
+ fleet-wide restart was 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 declined this exact trade for the socket-exec shape — a bare
+ comment header wasn'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
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/sync.go b/internal/appconfig/sync.go
index b99ea96c9..b1f8b4c5a 100644
--- a/internal/appconfig/sync.go
+++ b/internal/appconfig/sync.go
@@ -367,7 +367,20 @@ func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.Pr
// 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.
+ // 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/syncexec.go b/internal/appconfig/syncexec.go
index fe81811db..44e7ed722 100644
--- a/internal/appconfig/syncexec.go
+++ b/internal/appconfig/syncexec.go
@@ -501,6 +501,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)