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>
4 files changed,
+47
−5
docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md+18 −4internal/activator/backend_exec.go+5 −0internal/appconfig/sync.go+14 −1internal/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) |