| index 830a71566..cd6e533e4 100644 |
| --- a/internal/appconfig/sync.go |
| +++ b/internal/appconfig/sync.go |
| @@ -67,6 +67,9 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec |
| // render is not an overlay, it is a wholesale replacement, so |
| // syncHost refuses to let it drop keys the file on disk already holds |
| // (see its doc comment; 2026-08-02 review, CRITICAL 1 and round 2). |
| + // The materialization loop sets the same flag for an UNBOUND route |
| + // whose only config is its instance overlay (senders design §6.2), |
| + // which is the same replacement reached by a different door. |
| defaultMissing bool |
| } |
| resolved := map[groupKey]resolution{} |
| @@ -166,14 +169,11 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec |
| fmt.Printf(" %s: config unresolved (environment %q), env file left as-is: %v\n", rt.Host, env, r.err) |
| continue |
| } |
| - m := r.m |
| - if m == nil { |
| - continue |
| - } |
| - // The instance overlay wins per key over the group's merge. An |
| - // unresolved overlay is NOT an absent one: this route keeps its file |
| - // and the pass says which route and why, exactly as it does for a |
| - // group whose bundle could not be read. |
| + // The instance overlay wins per key over the group's merge, and is |
| + // fetched BEFORE the m == nil skip below: an unbound route of an app |
| + // with no default bundle used to resolve to "never configured" and |
| + // stop here, but with an overlay it now resolves to something (senders |
| + // design §6.2), so the skip has to see the overlay first. |
| inst, _, err := overlayFor(k.acct, k.app, rt.Host) |
| if err != nil { |
| // Said ONCE per (account, app), not once per route. The listing |
| @@ -192,6 +192,18 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec |
| } |
| continue |
| } |
| + m := r.m |
| + if m == nil && inst == nil { |
| + continue // never configured at all: this file is not ours to touch |
| + } |
| + // An overlay with no group base behind it is a wholesale replacement, |
| + // not an overlay. it is the same state a bound route with no default |
| + // bundle is in, reached without a binding. It gets the same guard: |
| + // see syncHost. |
| + defaultMissing := r.defaultMissing |
| + if m == nil { |
| + defaultMissing = true |
| + } |
| m = applyInstanceOverlay(k.app, m, inst) |
| // Only unit-backed routes read /etc/carlos/apps — an exec-backed |
| // route's env comes from <DataDir>/<host>.env, which the activator |
| @@ -205,7 +217,7 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec |
| fmt.Printf(" %s: exec-backed — config delivered by the agent's in-process loop to /data/<host>.config.env, not this pass\n", rt.Host) |
| continue |
| } |
| - syncHost(rt, m, r.defaultMissing, priv, envDir, restart, ack) |
| + syncHost(rt, m, defaultMissing, priv, envDir, restart, ack) |
| } |
| // An empty route list is never authority to delete anything: |
| // registry.Open silently creates an empty database when handed a wrong |
| @@ -282,7 +294,10 @@ func reap(routes []registry.Route, envDir string) { |
| // 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 — |
| // except for the agent-owned names applyAgentVars adds to and removes from |
| -// the rendered file afterward, which is why they are skipped below. |
| +// the rendered file afterward, which is why they are skipped below. m is the |
| +// route's FULL merge, all three layers of it (default, environment, |
| +// instance), because the question is what the file would lose, not which |
| +// bundle a key came from. |
| // |
| // Blank lines and #-comments are ignored, matching how systemd's |
| // EnvironmentFile reads these files: they carry no configuration, so their |
| @@ -420,21 +435,29 @@ func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.Pr |
| // properly, at the cost of changing the rendered file format. |
| if defaultMissing && existed && len(cur) > 0 { |
| lost, parsed := lostKeys(cur, m) |
| + // Which overlay is standing alone. A bound route names its |
| + // environment; an unbound one is here because this instance has its |
| + // own config layer (senders design §6.2) and its app has no default |
| + // bundle, and naming an empty environment would be a riddle. |
| + alone := "this instance's own config layer" |
| + if rt.Environment != "" { |
| + alone = fmt.Sprintf("environment %q", rt.Environment) |
| + } |
| switch { |
| case !parsed: |
| // Unparseable content has unknown keys, so nothing here can prove |
| // the render drops none of them. Assume the worst — this guard's |
| // whole job is to be wrong in the safe direction. |
| - fmt.Printf(" %s: environment %q is bound but %s has no default config bundle, so the merged view is that environment ALONE — "+ |
| + fmt.Printf(" %s: %s has no default config bundle, so the merged view is %s ALONE — "+ |
| "refusing to replace %s, which this pass cannot parse as KEY=value and so cannot prove is safe to overwrite; "+ |
| "import its config (`carlos env set --app %s KEY=value ...`) or remove the file\n", |
| - rt.Host, rt.Environment, rt.App, path, rt.App) |
| + rt.Host, rt.App, alone, path, rt.App) |
| return |
| case len(lost) > 0: |
| - fmt.Printf(" %s: environment %q is bound but %s has no default config bundle, so the merged view is that environment ALONE — "+ |
| + fmt.Printf(" %s: %s has no default config bundle, so the merged view is %s ALONE — "+ |
| "refusing to drop %d var(s) already in %s (%s); import them (`carlos env set --app %s KEY=value ...`) "+ |
| "and this route converges on the next pass, or remove the file if it is dead\n", |
| - rt.Host, rt.Environment, rt.App, len(lost), path, strings.Join(lost, ", "), rt.App) |
| + rt.Host, rt.App, alone, len(lost), path, strings.Join(lost, ", "), rt.App) |
| return |
| } |
| } |