carlos / platform Public

spec: named-unit config delivery, revised after codex round 2

Reaper symlink check, three-state syncHost result with the marker written
only after both files succeed, flock around Sync, reload-owed marker on
reap, alias and load-state probe, EnvironmentFile reset line, supported
stamped shape enforced at the door and the root, Blocked set in the
notice. Round-2 findings committed unedited; §10 is the disposition.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 1729f812e6cbbfcb7b00559e494f3659f9e3e597 parent f62f1a0
2 files changed, +434 −162
  • docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md +336 −162
  • docs/superpowers/specs/CODEX-FINDINGS-UNIT-CONFIG-R2.md +98 −0
diff --git a/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md b/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md
index afcb1b130..bbb2b8e0d 100644
--- a/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md
+++ b/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md
@@ -2,11 +2,11 @@
**Date:** 2026-09-07
**Status:** approved in chat (Paul, 2026-09-07), revised after codex
-round 1 (§9), awaiting Paul's read of the file before the plan. Every
-choice below is marked **(decision)** and can be overturned cheaply; the
-two in §8 are Paul's **(ruling)**s of 2026-09-07 and are marked where
-they land. Drafted by Claude Fable from amadan discussion #114
-(carlos/platform), which follows #16 (delivered but not bounced) and
+rounds 1 and 2 (§9, §10), awaiting Paul's read of the file before the
+plan. Every choice below is marked **(decision)** and can be overturned
+cheaply; the two in §8 are Paul's **(ruling)**s of 2026-09-07 and are
+marked where they land. Drafted by Claude Fable from amadan discussion
+#114 (carlos/platform), which follows #16 (delivered but not bounced) and
issue #110 (the `Unit` column). Line numbers cite `origin/main` at
`8e16153`.
@@ -32,7 +32,7 @@ The reporter looked in `/etc/carlos/apps/` and found nothing rendered.
That is correct, and it is only half the picture. The app's default bundle
carries `exec-delivery: file` (`carlos env list -app woodstar-relay` prints
it), so the route matches `execAddrCandidate`
-(`internal/appconfig/syncexec.go:509`) plus the opt-in, and the agent's
+(`internal/appconfig/syncexec.go:512`) plus the opt-in, and the agent's
in-process sweep should be writing the merged bundle to
`/data/woodstar-relay.bdm.oncarlos.com.config.env`. The unit does not read
that file either. Nothing on the box reads any file carlos writes for this
@@ -71,20 +71,29 @@ unreachable from Sync today only because Sync skips every route whose
way to that unit (§3.2) rather than routing through the host-keyed
callback.
-**`Unit` and `Backing=unit` are exclusive.** `unitRail` (`cli.go:1524`)
-refuses a stamp on a unit-backed row and `backingRail` (`cli.go:1442`)
-refuses unit backing on a stamped row. `unitRail` deliberately does NOT
-refuse `kind: service` rows or `Platform` rows: a service route is
-"precisely a sibling process the platform only proxies, and naming the
-unit that owns it is a true statement", and a deployment's own console
-can be a platform-minted row. The column's doc comment
-(`internal/registry/registry.go:148-170`) says a stamped route is an
+**A root pass already knows how to be singular.** `restartPass`
+(`internal/host/restart.go:602`) takes an exclusive flock on its marks
+file for the whole pass (`lockRestartMarks`, `restart.go:300`,
+`flock_unix.go` with a non-unix stub for the cross-compile gate) and does
+nothing when it cannot. §3.2 gives `Sync` the same shape.
+
+**`Unit` and `Backing=unit` are exclusive, and nothing else about a stamp
+is checked.** `unitRail` (`cli.go:1524`) refuses a stamp on a unit-backed
+row and `backingRail` (`cli.go:1442`) refuses unit backing on a stamped
+row. `unitRail` deliberately does NOT refuse `kind: service` rows or
+`Platform` rows: a service route is "precisely a sibling process the
+platform only proxies, and naming the unit that owns it is a true
+statement", and a deployment's own console can be a platform-minted row.
+It also does not look at the upstream: `carlos add` (`cli.go:76-82`)
+accepts `--socket`, `--hibernate` and `--unit` on one row, a shape nothing
+provisions and nothing today refuses. The column's doc comment
+(`internal/registry/registry.go:148-170`) describes the intended shape, an
addr-backed route whose process no backing-dispatched restart could reach.
`Upsert` validates nothing (`registry.go:160-163`); the rails live at the
doors, and `ValidUnitName` (`registry.go:301-316`) is the door's rail:
`^[A-Za-z0-9@_][A-Za-z0-9@_.\-]{0,63}\.service$`. That rail is about a
typo, not about a root-written path; §3.2 adds the rail this spec needs at
-the point of the root write.
+the point of the root write, and §3.1 tightens the door.
**The two config halves share one set of predicates, in one package.**
`syncable` (`sync.go:284`) is `release.Adoptable`
@@ -103,17 +112,19 @@ decide about, and the CLI renders "could not check" as its own line
(`appconfig_cli.go:94-120`, copy at `:152-156`); a console predating the
field sends no `restart` key and the CLI prints nothing. The CLI carries
its own copy of the wire type (`apimode.go:2190-2207`) and converts it in
-`restartFromAPI` (`appconfig_cli.go:43`). #16's history is the reason for
-the three states: "nothing needs a restart" and "I could not tell"
-rendered identically is how that discussion got filed. §3.4 adds a state
-and keeps the discipline.
+`restartFromAPI` (`appconfig_cli.go:43`); the console converts the other
+way in `configRestartNotice` (`internal/console/apiconfig.go:537-541`).
+#16's history is the reason for the three states: "nothing needs a
+restart" and "I could not tell" rendered identically is how that
+discussion got filed. §3.4 adds two states and keeps the discipline.
**An opted-out route keeps its old file, by ruling.** `SyncExec` leaves
`<host>.config.env` exactly as it is when a route leaves scope
(`syncexec.go:420-440`, "RULING: do not auto-delete it"): removing it
would change what the app's own supervisor layers in on its next respawn,
at a moment carlos does not control. §3.3 honours that ruling for the
-ownership move too.
+ownership move too, and §3.2 makes the file irrelevant to a stamped unit
+without touching it.
**Systemd reads `EnvironmentFile` as pid 1.** The rendered file is
root-owned `0600` (`sync.go:425`), which is fine for a unit running as any
@@ -121,9 +132,10 @@ user, because systemd reads the file before it drops privileges.
`EnvironmentFile=` settings override `Environment=` lines, so a rendered
file wins over the hand-copied `Environment=WOODSTAR_APPLE_APP_ID=…` the
Woodstar units carry today. Drop-ins are applied in lexical order of file
-name across all search directories, and a later `EnvironmentFile=` wins
-over an earlier one for the same key; §3.2 chooses the file name with that
-in mind.
+name across all search directories; `EnvironmentFile=` assignments
+accumulate, a later file wins over an earlier one for the same key, and
+an empty assignment (`EnvironmentFile=`) resets the list. Drop-ins under
+an alias name apply to the aliased unit. §3.2 uses all four facts.
## 3. The change
@@ -151,7 +163,22 @@ Concretely, in `internal/appconfig`:
removes stamped routes from `execSyncable`, `execAddrCandidate`,
`ExecConfigApps`, the exec fingerprint and `AwaitingRestart`'s
delivered-to set at once. §3.4 keeps stamped routes in the notice's
- "could not tell" set through a separate test.
+ other sets through separate tests.
+
+**(decision)** The supported stamped shape is an addr upstream (`Addr`
+or `Addrs` set, `Socket` empty) that does not hibernate (codex R2-7).
+A socket upstream is the activator's to spawn, so a unit cannot own it;
+a hibernating route is woken by the activator and a config restart would
+start it behind the activator's back. The shape is enforced twice: at
+the root boundary, where a stamped route outside it is refused loudly
+every pass and reported through §3.4's `Blocked` set; and at the doors,
+where `unitRail` gains the same test and `carlos add` refuses `--unit`
+with `--socket` or `--hibernate`. `stamped()` itself stays as written
+above, so a mis-shaped row is seen, refused and reported rather than
+silently left to the exec half. The predicate and the shape rail are
+exported from `internal/appconfig` beside each other, for the reason §2
+gives: the door, the pass and the notice must agree on them to the
+character.
`Sync`'s skip message (`sync.go:165`) stays for the routes it still skips,
which after this are exec routes with no stamp. The comment at
@@ -170,12 +197,14 @@ behaviour and this spec does not widen the restart verb.
`<envDir>/<host>.env` through `syncHost` (`sync.go:331`) unchanged: the
same resolution, the same key-loss guard for a bound route whose app has
no default bundle, the same atomic write, the same `applyAgentVars` line.
-`syncHost` is split so that it returns whether it wrote, and the caller
-decides what follows; for `carlos-app@` routes what follows is exactly
-today's `restart(rt.Host)`.
+`syncHost` is split so that it returns one of three outcomes, `changed`,
+`unchanged` or `failed` (a refusal, a render error or a write error are
+all `failed`; codex R2-2), and the caller decides what follows; for
+`carlos-app@` routes what follows is exactly today's `restart(rt.Host)`
+on `changed`.
-For a stamped route the pass then makes sure the unit reads the file, at
-a fixed location:
+For a stamped route the pass then makes sure the unit reads the file, and
+only that file, at a fixed location:
```
/etc/systemd/system/<unit>.d/zz-carlos-env.conf
@@ -183,25 +212,40 @@ a fixed location:
# Managed by carlos: config delivery for the route naming this unit.
# Edit the route's config with `carlos env` / `carlos secrets`, not this file.
[Service]
+EnvironmentFile=
EnvironmentFile=-/etc/carlos/apps/<host>.env
```
+**(decision)** The empty assignment resets the unit's environment-file
+list before carlos's file is added (codex R2-8). Stamping hands the
+unit's `EnvironmentFile=` list to carlos, whole: a unit that read
+`/data/<host>.config.env` from the exec-delivery era, or any other file,
+reads only the rendered bundle after this. Without the reset, a key
+unset through the door would fall back to the frozen legacy file's old
+value on the next restart, and a member would have no way to see that.
+`Environment=` lines in the unit are untouched and keep their lower
+precedence, so an operator's inline values still apply where the bundle
+does not state the key. This is what "owns it fully" (ruling 1) means for
+the environment: the bundle is the unit's environment-file source, and
+anything else an operator wants in the environment goes into the bundle
+or into an `Environment=` line.
+
**(decision)** The location is `/etc/systemd/system/<unit>.d/` always,
never "beside the unit": systemd reads that directory's drop-ins for a
unit whether the unit file lives in `/etc/systemd/system` or
`/usr/lib/systemd/system` (codex R1-5). The file name starts with `zz-`
so that it sorts after `override.conf` and every conventionally named
-drop-in; a later `EnvironmentFile=` overrides an earlier one, so carlos's
-values win over an operator's earlier file for the same key without
-carlos editing anything of the operator's. An operator who adds a drop-in
-sorting after `zz-carlos-env.conf` that resets `EnvironmentFile=` has
-chosen to; the pass does not read back the unit's effective environment
-(§5).
+drop-in; with the reset above, an operator's earlier `EnvironmentFile=`
+lines are dropped rather than merely outranked. An operator who adds a
+drop-in sorting after `zz-carlos-env.conf` that sets `EnvironmentFile=`
+again has chosen to, and the verification step below reports it.
-**(decision)** The rail at the root write (codex R1-1, R1-2). Before the
-pass derives any path or writes any byte for a stamped route it checks,
-and refuses loudly every pass on failure:
+**(decision)** The rail at the root write (codex R1-1, R1-2, R2-5,
+R2-7). Before the pass derives any path or writes any byte for a stamped
+route it checks, and refuses loudly every pass on failure, reporting the
+refusal through §3.4's `Blocked` set:
+- the route has the supported shape of §3.1;
- `rt.Unit` passes `ValidUnitName`, contains no `@`, and the name before
`.service` does not end in `-`. A template name (`carlos-app@.service`)
has a drop-in directory systemd applies to every instance, and a prefix
@@ -210,12 +254,21 @@ and refuses loudly every pass on failure:
processes. An instance name (`x@y.service`) is refused with them for
simplicity: nothing stamps one today, and a template instance that
wants carlos config is `carlos-app@`;
+- the unit is loaded and the stamp is its canonical name: `systemctl
+ show -p Id,LoadState <unit>` answers `LoadState=loaded` and `Id=<unit>`.
+ A stamp that is an alias (`Id` differs) is refused with the canonical
+ name in the message, because drop-ins under an alias apply to the
+ aliased unit and two aliases would defeat the one-route-per-unit rule
+ below (codex R2-5). A unit systemd cannot load is refused rather than
+ given a drop-in directory for a service that does not exist; the route
+ converges on the pass after the unit is installed. The probe is one
+ `systemctl show` per stamped route per pass, the same cost as the
+ backing advice already pays (`adopt_advice.go`);
- `rt.Host` is a bare hostname: labels of `[A-Za-z0-9-]`, joined by `.`,
no label empty or starting or ending with `-`, at most 253 bytes. The
existing basename check (`sync.go:335`) stops a path separator; it does
not stop a newline, and a host is about to be written into a file
- systemd parses as directives. This check is the one that stops
- `relay\nUser=root` being a hostname;
+ systemd parses as directives;
- `/etc/systemd/system/<unit>.d` is created `0755` if absent and, present
or created, is a directory and not a symlink (`Lstat`). A symlinked
drop-in directory would let a root write land somewhere else.
@@ -226,41 +279,71 @@ addition to the basename check every route already gets.
**(decision)** Ownership of the drop-in is proven by content, not by
name (codex R1-3). The first line of the file is the fixed marker
comment above. The pass replaces or removes only a file whose first line
-is that marker; a file at that path without it is left alone and logged
-every pass ("not carlos's file; delivery to <host> is blocked until it is
-moved"). The marker is not decorative: it is the evidence for every
-destructive action the pass takes in that directory.
+is that marker; a file at that path without it is left alone and
+reported as blocking delivery to that host. The marker is the evidence
+for every destructive action the pass takes in that directory; the
+`Lstat` check is the evidence for where the action lands, and both are
+required on every path, delivery and reaping alike (codex R2-1).
**(decision)** One delivered route per unit per box (codex R1-7). If two
stamped routes on the box name one unit, the pass delivers to neither and
-says so every pass, naming both hosts, exactly as the key-loss guard
-refuses a route rather than guess. A unit has one process environment;
-two routes resolving different values for one key cannot both be
-delivered, and a rule that picks one silently is the failure this spec
-exists to end. Nothing provisions that shape today.
+reports both, exactly as the key-loss guard refuses a route rather than
+guess. A unit has one process environment; two routes resolving different
+values for one key cannot both be delivered, and a rule that picks one
+silently is the failure this spec exists to end. Nothing provisions that
+shape today. With the alias check above, unit identity is the canonical
+name, so two stamps that spell one unit differently are caught by the
+alias refusal before they reach this rule.
**(decision)** Writes are atomic (temp file, rename) and mode `0644`: the
drop-in carries a path, not a secret.
-**(decision)** Application is tracked separately from file content
-(codex R1-4). Beside the drop-in, `carlos-env.pending` (not `.conf`;
-systemd ignores it) marks that the unit owes a reload and a restart:
-
-1. When the env file or the drop-in changed, the pass writes the marker
- first, then the files.
-2. After the route loop, if any marker exists, the pass runs one
- `systemctl daemon-reload`. If it fails, every marker stays and every
- marked unit's restart is skipped this pass; the failure is logged per
- unit.
-3. For each marked unit the pass runs `systemctl restart <unit>`, not
+**(decision)** One config pass at a time (codex R2-3). `Sync` takes an
+exclusive flock on `<envDir>/.sync.lock` for its whole run, the shape
+`restartPass` already has, and returns without touching anything when it
+cannot; the hourly `carlos-adopt.service` and the triggered
+`carlos-config-sync.service` therefore never interleave inside a config
+transaction, and the next trigger or timer runs the pass that stepped
+aside. No unit file changes.
+
+**(decision)** Application is tracked separately from file content, and
+authorised only by successful preparation (codex R1-4, R2-2, R2-4).
+Beside the drop-in, `carlos-env.pending` (not `.conf`; systemd ignores
+it) marks that the unit owes a reload and, when it is still owned, a
+restart. Within one locked pass:
+
+1. For each stamped route that passes the rail and the one-per-unit
+ rule, the pass converges the env file (`syncHost`) and then the
+ drop-in. If either is `changed`, it writes the marker after both
+ writes succeeded. If either is `failed`, it writes no marker this
+ pass, logs the failure, and leaves any marker from an earlier pass in
+ place: a marker never describes unfinished work, because it is only
+ ever written after both files are on disk.
+2. Reaping (below) removes a marker-bearing drop-in for a unit no route
+ names and writes the marker in its place, so the reload it owes is
+ recorded even though the drop-in is gone.
+3. If any marker exists, the pass runs one `systemctl daemon-reload`. If
+ it fails, every marker stays, nothing is restarted, and the failure is
+ logged per marked unit.
+4. For each marked unit that still has a marker-bearing drop-in, whose
+ route passed the rail this pass and whose files both converged
+ (`changed` or `unchanged`, never `failed`), the pass verifies the
+ reload took: `systemctl show -p EnvironmentFiles <unit>` lists
+ `/etc/carlos/apps/<host>.env` and lists it last. A mismatch is
+ reported through `Blocked` with the unit's actual list, and the marker
+ stays. On a match the pass runs `systemctl restart <unit>`, not
`try-restart`: a marker means carlos changed this unit's config and
owes it a restart, and after a restart that failed the unit is
inactive, where `try-restart` is a no-op and would leave it down for
ever. On success the marker is removed; on failure it stays and the
failure is logged with systemd's words, exactly as
`systemctlTryRestartUnit` does.
-4. A pass that finds a marker with nothing changed still runs steps 2
- and 3. `carlos env sync` requests a root pass, so an operator who has
+5. A marked unit with no drop-in (reaped) is never restarted; its marker
+ is removed once the reload in step 3 succeeded. A marked unit whose
+ route was refused or failed this pass keeps its marker and is not
+ restarted; the marker is retried on the next pass.
+6. A pass that finds a marker with nothing changed still runs steps 3
+ to 5. `carlos env sync` requests a root pass, so an operator who has
fixed a bad value can retry without a box.
The restart is bound to the unit the pass prepared (codex R1-12): `Sync`
@@ -268,25 +351,26 @@ calls a `restartUnit(unit)` callback with `rt.Unit` as read at the start
of the pass, not the host-keyed `restart(host)`, which re-reads the row
and could cycle a unit re-stamped since. `rootRestartRoute` stays as it is
for `carlos-app@` routes and for the release pass. A re-stamp between two
-passes is handled by the fingerprint (§3.5) and the reaper below.
-
-Hibernation needs no special case: `provision` refuses the
-addr-with-hibernate shape (`internal/provision/provision.go:438`), and a
-stamped route is addr-backed by construction.
+passes is handled by the fingerprint (§3.5) and the reaper.
**(decision)** Reaping. After the route loop, the pass lists
-`/etc/systemd/system/*.service.d/zz-carlos-env.conf` and removes every
-one that carries the marker and whose unit is named by no route row on the
-box, together with its `carlos-env.pending`. The same rule as `reap` for
-env files (`sync.go:196`): keyed off the full route list, not the
-delivered subset, so a route mid-provisioning or unresolved keeps its
-drop-in; and an empty route list is never authority to delete anything
-(`sync.go:175-178`). A removal is followed by the batched daemon-reload.
-A file without the marker is never removed. The directory is never
-removed. A unit that was unstamped or whose route was removed is left
-with no carlos line in it on the next pass; it keeps running on whatever
-it loaded until something else cycles it, which is right, because an
-unstamp says carlos no longer owns that process.
+`/etc/systemd/system/*.service.d/`, applies the same `Lstat` check to
+each directory it will enter (a symlinked directory is skipped and
+logged, never followed; codex R2-1), and for each `zz-carlos-env.conf`
+that carries the marker and whose unit is named by no route row on the
+box, removes the file and writes `carlos-env.pending` in its place. The
+same rule as `reap` for env files (`sync.go:196`): keyed off the full
+route list, not the delivered subset, so a route mid-provisioning,
+unresolved or refused keeps its drop-in; and an empty route list is never
+authority to delete anything (`sync.go:175-178`). A file without the
+marker is never removed. The directory is never removed. A unit that was
+unstamped or whose route was removed is left with no carlos line in it
+after the next successful reload; it keeps running on whatever it loaded
+until something else cycles it, which is right, because an unstamp says
+carlos no longer owns that process. Its `/etc/carlos/apps/<host>.env`
+stays while the row exists, as every route's does today (`sync.go:196-
+223`); nothing reads it once the reload has taken, and it is reaped with
+the row.
### 3.3 The exec side lets go, and leaves the old file alone
@@ -294,18 +378,19 @@ unstamp says carlos no longer owns that process.
`<dataDir>/<host>.config.env` for a stamped route: after §3.1 the route is
out of its scope, and the existing ruling at `syncexec.go:428` governs the
file it may have written earlier. Removing it would change what a
-supervisor that reads it (the #16 relays are exactly that shape, and could
-be stamped later) layers in on its next respawn, at a moment carlos does
-not control, and a hand-built unit that names the file without the `-`
-prefix would fail to start on its absence. The file goes stale, holds the
-values it held, and is reaped when the route row goes, as today. §4 says
-so; the first draft of this spec removed it (codex R1-11 found the path
-could not even reach it) and the ruling is the better answer.
+supervisor that reads it layers in on its next respawn, at a moment
+carlos does not control, and a hand-built unit that names the file
+without the `-` prefix would fail to start on its absence. The file goes
+stale, holds the values it held, and is reaped when the route row goes,
+as today. §3.2's reset makes it irrelevant to the stamped unit's
+environment (codex R2-8); §4 says so. The first draft of this spec
+removed it (codex R1-11 found the path could not even reach it) and the
+ruling is the better answer.
The `exec-delivery: file` opt-in on `woodstar-relay`'s bundle becomes
inert for its stamped route and stays where it is.
-### 3.4 The set-time notice: a write that reached nothing
+### 3.4 The set-time notice: a write that reached nothing, or is blocked
**(decision)** `RestartPending` gains `Undelivered []string`: hosts of
routes the write reached that no pass delivers a file to on its account.
@@ -321,25 +406,39 @@ delivered to that route by any pass. It does not say no file exists; an
opted-out route may hold a file from before, by the ruling in §2. The
note's wording says "this write" rather than "no file".
+**(decision)** `RestartPending` also gains `Blocked []BlockedHost{Host,
+Reason string}` (codex R2-6): stamped routes of the app that the root
+pass will refuse for a reason knowable from the registry alone. The
+console applies the exported shape rail and name rail of §3.2 and the
+one-per-unit rule to the stamped routes it can see, checking uniqueness
+against the box's full route list and disclosing only the requesting
+account's hosts. The systemd-side checks (alias, load state, the
+`EnvironmentFiles` verification) are not knowable at the door and are
+not claimed; they appear in the root pass's log only, which is the same
+honesty the rest of the notice keeps. `Reason` is a short fixed string
+per rail, rendered by the CLI; the copy is copy-review's.
+
**(decision)** Stamped routes stay in the "could not tell" set (codex
R1-8). The loop also visits `stamped(rt)` routes of the app, runs the same
bundle loads, and puts one whose bound bundle is unreadable or missing in
-`Unresolved`; a stamped route whose bundles load is listed nowhere,
-because the root pass delivers and restarts it within seconds. The
-classification of who delivers is separate from whether resolution can be
-known to fail, and the second is knowable at the door.
+`Unresolved`. A stamped route that passes the door-side rails and whose
+bundles load is listed nowhere, because the root pass delivers and
+restarts it within seconds. `Blocked` is decided before bundle loading,
+so a blocked route is reported as blocked, not as healthy.
The same environment and account narrowing applies as for `Hosts`: a
named-bundle write reaches the routes bound to it, a default-bundle write
reaches every route of the app. The shadowing test does not apply to
-`Undelivered`.
+`Undelivered` or `Blocked`.
**(decision)** On the wire, both `apiConfigRestart` types gain
-`undelivered []string, omitempty`: the console's (`internal/console/
-apiconfig.go:495`) and the CLI's (`apimode.go:2196`), and
-`restartFromAPI` (`appconfig_cli.go:43`) copies it (codex R1-10).
-Additive: an older CLI ignores it; an older console sends no field and the
-newer CLI prints nothing for it. `Empty()` (`syncexec.go:655`) counts it.
+`undelivered []string, omitempty` and `blocked [{host, reason}],
+omitempty`: the console's (`internal/console/apiconfig.go:495`) and the
+CLI's (`apimode.go:2196`); `configRestartNotice` (`apiconfig.go:537-541`)
+and `restartFromAPI` (`appconfig_cli.go:43`) each copy both (codex
+R1-10, both halves). Additive: an older CLI ignores them; an older console
+sends neither and the newer CLI prints nothing for them. `Empty()`
+(`syncexec.go:655`) counts both.
**(decision)** `RestartHost.Unit`, `apiConfigRestartHost.Unit`, the
`restartCopyUnit` line and `TestAwaitingRestartCarriesUnit` are KEPT
@@ -348,12 +447,14 @@ newer CLI prints nothing for it. `Empty()` (`syncexec.go:655`) counts it.
does, and the remedy it names, `carlos restart`, is still true against
that console's box. The test moves from `AwaitingRestart` to the renderer.
-**(decision)** The CLI prints one line per undelivered host under the
-same `note:` prefix, then the existing scope caveat once. Copy direction,
-to be fixed by copy-review before the branch lands, not here:
+**(decision)** The CLI prints one line per undelivered host and one per
+blocked host under the same `note:` prefix, then the existing scope
+caveat once. Copy direction, to be fixed by copy-review before the branch
+lands, not here:
```
note: this write did not reach woodstar-relay.bdm.oncarlos.com. No pass delivers config to it. If a systemd unit runs its process, name it on the box (carlos route --unit); if the app's own supervisor reads a delivered file, opt the bundle in (carlos env exec-delivery --app woodstar-relay file).
+note: delivery to relay-b.bdm.oncarlos.com is blocked: two routes name relay.service. One unit takes one route's config.
(this machine's routes only)
```
@@ -386,16 +487,19 @@ as for `carlos-app@` tenants.
`docs/site/cli.md`: the `carlos route` section (the paragraph at line
908-911 that introduces `--backing` and `--unit`) says that naming the
unit also makes the root pass deliver the route's config to it, through a
-drop-in the pass manages under `/etc/systemd/system/<unit>.d/`, and
-restart it on change; the `carlos add --unit` sentence (line 884) says
-the same; the `carlos env` and `carlos secrets` sections gain one sentence
-on the "did not reach" note and its two remedies. `internal/docsite`
-checks headings and examples, not this prose, so the gate does not force
-these, but a change that alters what a flag does is not done until the
-page says so.
+drop-in the pass manages under `/etc/systemd/system/<unit>.d/` that
+becomes the unit's only `EnvironmentFile=` source, and restart it on
+change; and that the stamp needs an addr upstream and no hibernation. The
+`carlos add --unit` sentence (line 884) says the same. The `carlos env`
+and `carlos secrets` sections gain one sentence each on the "did not
+reach" and "blocked" notes and their remedies. `internal/docsite` checks
+headings and examples, not this prose, so the gate does not force these,
+but a change that alters what a flag does is not done until the page says
+so.
AGENTS.md's "Deploy reality" bullet on #110 (line 282) gains one clause:
-a stamped route also receives its config from the root pass.
+a stamped route also receives its config from the root pass, and the
+drop-in resets the unit's environment-file list.
## 4. Consequences to be explicit about
@@ -409,6 +513,15 @@ stays on the old file would be exactly the bug being fixed. A route whose
app has no manifest object at all (`sync.go:114`, `:153`) gets no file, no
drop-in, no marker and no restart.
+**Stamping resets the unit's environment-file list.** After the roll, a
+stamped unit reads exactly one `EnvironmentFile=`, the rendered bundle.
+A unit that read another file through its own `EnvironmentFile=` line
+stops reading it at that first restart. On today's fleet no stamped unit
+is known to carry such a line (Woodstar's carry `Environment=` only);
+an operator who has one moves the values into the bundle or into
+`Environment=`. This is the consequence of ruling 1 that a reader should
+check before the roll, and the drop-in's marker comment says it.
+
**A stamped console cycles itself on its own config change.** After the
roll, `carlos env set` on the console's app bounces `console.service`
within seconds, as it would any tenant. That is what stamping means, and
@@ -420,22 +533,29 @@ pass. The marker exists only after carlos changed that unit's config, and
the journal line names the unit and the reason; this is the price of a
failed config restart being recoverable without a box.
+**A blocked route stays blocked, loudly, until the operator acts.** A
+mis-shaped stamp, a template or alias name, a unit that will not load, a
+duplicate, an unmarked file at the drop-in path, or a later drop-in that
+takes the environment-file list back: each is refused every pass, logged
+on the box and, where the door can know it, reported at set time. None
+is repaired silently.
+
**The drop-in is a root write into a directory carlos does not own.** It
is additive to the unit, marker-owned, carries no secret, is removed when
the stamp goes, and is written only for a unit an operator named on the
route and only after the rail in §3.2. A hand-written file at the same
path without the marker blocks delivery and is reported, never replaced.
-**Two root passes can overlap.** `carlos-adopt.service` and
-`carlos-config-sync.service` are not serialised against each other today
-and both run `Sync`; the env-file writes already tolerate this (atomic
-rename, bytes-equal short circuit). The drop-in and marker writes are the
-same shape. Serialising root passes is a change to the existing units and
-is not part of this spec (codex R1-12, second half).
+**Root config passes are serialised.** The flock in `Sync` means a
+triggered config pass and the hourly adopt pass queue rather than
+interleave; a pass that cannot take the lock does nothing and says so,
+and the next trigger runs it. The release half of the adopt pass is not
+under this lock and is unchanged.
**The stale `/data/<host>.config.env` stays.** For a stamped route that
was opted into file delivery, the agent's old file is left as it is
-(§3.3). Nothing carlos owns reads it; it is reaped with the route row.
+(§3.3). The reset in §3.2 means the unit no longer reads it; it is reaped
+with the route row.
**Multi-box fleets.** The console reads its own box's registry for the
notice, as today (#145's blind spot); the caveat line stays. Delivery
@@ -446,9 +566,9 @@ registry.
- **Ask 3, delivered-generation readback.** Deferred **(ruling 2)**.
#11, #13 and #16 want the same absent field; it needs an app-side
- signal and deserves one decision across all four threads. The pass
- also does not read back a unit's effective environment after restart
- (codex R1-5's second half); that is the same absent readback.
+ signal and deserves one decision across all four threads. §3.2's
+ `EnvironmentFiles` verification checks what systemd loaded, not what
+ the process read; the latter is this readback.
- **A remote door for `--unit`.** Stamping is `carlos route --unit`
against the registry on the box (`cli.go:470-483`). AGENTS.md's rule
makes a box-only step a product gap, and this one remains; it is not
@@ -463,40 +583,56 @@ registry.
`restartApps` is `Adoptable`; a stamped service route is delivered to
and restarted by the config pass but not by the restart verb. Its own
thread if wanted.
-- **Serialising the two root passes** (§4).
+- **Serialising the release half of the adopt pass against the config
+ pass.** Only `Sync` is locked (§3.2); release staging and symlink
+ moves are as they were.
- **A post-delivery hook** for the un-owned addr shape (#16's second
ask).
## 6. Testing
`internal/appconfig/sync_test.go`, beside `TestSyncSkipsExecBackedRoutes`,
-with the same registry-and-tempdir harness. The drop-in root, the
-daemon-reload runner and the unit restarter are parameters of the
-function under test, so no test touches `/etc` or runs `systemctl`:
+with the same registry-and-tempdir harness. The drop-in root, the lock
+path, the `systemctl show` prober, the daemon-reload runner and the unit
+restarter are parameters of the function under test, so no test touches
+`/etc` or runs `systemctl`:
- a stamped `kind: instance` route and a stamped `kind: service` route
- each render `<host>.env`, write the drop-in with the expected bytes and
- marker, write the marker, reload once, restart the unit, clear the
- marker;
+ each render `<host>.env`, write the drop-in with the reset line, the
+ marker comment and the expected bytes, write the pending marker after
+ both files, reload once, verify `EnvironmentFiles`, restart the unit,
+ clear the marker;
- a second pass with nothing changed writes, reloads and restarts
nothing;
- an env-file change alone marks and restarts; a drop-in change alone
(a re-stamp to a new unit) marks and restarts the new unit and reaps
- the old unit's drop-in and marker;
-- a reload failure leaves the marker and skips the restart; the next
- pass with identical bytes reloads and restarts, then clears it;
+ the old unit's drop-in, leaving a marker there that the reload clears;
+- a `failed` env render (key-loss refusal) with a new drop-in writes no
+ marker and restarts nothing; an earlier marker on that unit survives
+ and is not acted on while the route is refused;
+- a reload failure leaves every marker and skips every restart; the next
+ pass with identical bytes reloads, verifies and restarts, then clears;
- a restart failure leaves the marker; the next pass restarts again;
-- two stamped routes naming one unit deliver to neither and log both;
-- a template name, a prefix name, an instance name, a host with a
- newline, and a symlinked drop-in directory are each refused before any
- write;
+- an `EnvironmentFiles` mismatch after reload reports the host as
+ blocked, keeps the marker, and does not restart;
+- two stamped routes naming one unit deliver to neither and report both;
+- a socket upstream, a hibernating route, a template name, a prefix
+ name, an instance name, an alias (`Id` differs), an unloaded unit, a
+ host with a newline, and a symlinked drop-in directory are each refused
+ before any write and reported;
- a file at the drop-in path without the marker is neither replaced nor
- removed, and delivery to that host is logged as blocked;
+ removed, and delivery to that host is reported as blocked;
- an app with no manifest object writes no file, no drop-in, no marker;
-- the key-loss guard still refuses a bound route with no default bundle
- when the file on disk holds keys the render drops;
-- a marker-bearing drop-in for a unit named by no route is removed with
- its marker and reload follows; an empty route list removes nothing;
+- a marker-bearing drop-in under a symlinked `*.service.d` is neither
+ read nor removed by the reaper;
+- a marker-bearing drop-in for a unit named by no route is removed and a
+ marker written; a reload failure keeps that marker; a later successful
+ reload clears it without restarting the unit; an empty route list
+ removes nothing;
+- a second `Sync` that cannot take the lock does nothing and says so; the
+ R2-3 interleaving (pass A restarts and pauses, pass B prepares and
+ fails reload, pass A clears) cannot occur under the lock, pinned by a
+ test that runs two passes against one lock path;
- `UnitConfigApps` and the unit fingerprint include stamped instance and
service routes; the fingerprint changes on re-stamp.
@@ -507,21 +643,31 @@ function under test, so no test touches `/etc` or runs `systemctl`:
`<host>.config.env`;
- `AwaitingRestart` lists an un-opted addr route in `Undelivered`, keeps
an unreadable bundle in `Unresolved`, puts a stamped route with a
- missing bound bundle in `Unresolved` and a healthy stamped route
+ missing bound bundle in `Unresolved`, a stamped socket route, a
+ template-named stamp and each of two routes sharing a unit in
+ `Blocked` with the right reason (uniqueness checked against a route of
+ another account, which is not disclosed), a healthy stamped route
nowhere, and never lists a stamped route in `Hosts` or `Undelivered`;
a named-bundle write lists only routes bound to it.
-`internal/console`, `apimode.go` and the CLI: `undelivered` round-trips
-through both wire types and `restartFromAPI`; a response with
-`hosts[].unit` from an older console still renders the unit remedy; a
-console that sends no field prints nothing.
+`cli_test.go`: `unitRail` refuses a socket or hibernating row; `carlos
+add` refuses `--unit` with `--socket` or `--hibernate`.
+
+`internal/console`, `apimode.go` and the CLI: `undelivered` and `blocked`
+round-trip through both wire types, `configRestartNotice` and
+`restartFromAPI`; a response with `hosts[].unit` from an older console
+still renders the unit remedy; a console that sends no field prints
+nothing.
`internal/host`: the comment change at `agent.go:2899` has no test; the
-restart arm's existing tests (`restart_test.go`) already cover a stamped
-route for the release pass.
+named-unit restart arm is already covered by
+`TestMakeRestartRouteRestartsTheUnitARouteNames` and
+`TestAdoptPassRestartsANamedUnitOnAdoption` in
+`internal/host/agent_test.go` (codex R2-9).
Everything runs under `go test ./... -count=1`; socket-binding tests in
-this repo need `TMPDIR=/tmp/g`.
+this repo need `TMPDIR=/tmp/g`. The flock helper has a non-unix stub
+already; the cross-compile gate needs nothing new.
## 7. Rollout
@@ -531,18 +677,25 @@ this repo need `TMPDIR=/tmp/g`.
fast-forward the GitHub mirror's `main` from forge `main`. AGENTS.md's
Build & test section still describes the earlier arrangement in which
GitHub was `origin`; the forge memory note is current.
-2. Roll the platform binary. The first root pass on each box writes the
+2. Before the roll, check each stamped unit on the fleet for an
+ `EnvironmentFile=` line of its own (§4, reset). `systemctl cat` is a
+ box command, so this check is one SSM read per box, once, before the
+ feature that ends the need for them; or skip it and read the first
+ pass's journal, which reports the reset in the drop-in and the
+ `EnvironmentFiles` verification per unit.
+3. Roll the platform binary. The first root pass on each box writes the
drop-ins and cycles each stamped, configured route once (§4).
-3. Verify on Woodstar with no box access: `carlos secrets set -app
+4. Verify on Woodstar with no box access: `carlos secrets set -app
woodstar-relay WOODSTAR_COMMUNITY_PROVISION_TOKEN=…` prints no note;
within seconds `GET /api/communities` on the live relay answers
`"creation_enabled": true`. That is the verification the platform can
offer today; ask 3 is what would make it a column.
-4. Reply on #114 with what shipped, the one-time restart, and that no
- box step is needed. The Woodstar setup script can drop its
- `Environment=WOODSTAR_APPLE_APP_ID` line at its next edit, since the
- bundle carries the var and the rendered file overrides it anyway; that
- edit is in the private repo and is not part of this change.
+5. Reply on #114 with what shipped, the one-time restart, the reset
+ semantics, and that no box step is needed. The Woodstar setup script
+ can drop its `Environment=WOODSTAR_APPLE_APP_ID` line at its next
+ edit, since the bundle carries the var and the rendered file overrides
+ it anyway; that edit is in the private repo and is not part of this
+ change.
## 8. Rulings (Paul, 2026-09-07)
@@ -550,11 +703,14 @@ this repo need `TMPDIR=/tmp/g`.
write the `EnvironmentFile=` drop-in, daemon-reload, restart on
change. Stamping the unit is the consent. No SSM step, including for
`console.service`, which cycles on its own config changes like any
- `carlos-app@` tenant. **(ruling 1)**, lands in §3.2 and §4.
+ `carlos-app@` tenant. **(ruling 1)**, lands in §3.2 and §4; the
+ environment-file reset in §3.2 is this spec's reading of "fully" and
+ is a **(decision)**, flagged for Paul's read.
2. **Ask 3 is deferred to its own thread**, to be decided once across
#11, #13, #16 and #114. **(ruling 2)**, lands in §5.
3. The design as presented in chat (§3.1 to §3.6, §4, §6) was approved
- without change; §9 records what the codex read changed afterwards.
+ without change; §9 and §10 record what the codex reads changed
+ afterwards.
## 9. Review round 1 (codex, 2026-09-07)
@@ -564,16 +720,34 @@ request changes. Disposition:
| # | Sev | Accepted | Where |
|---|-----|----------|-------|
| R1-1 | P1 | yes: template, prefix and instance names refused at the root write | §3.2 rail |
-| R1-2 | P1 | yes: host and unit re-validated at the write; newline-safe host rail; symlink check on the directory | §3.2 rail |
+| R1-2 | P1 | yes: host and unit re-validated at the write; newline-safe host rail; symlink check on the directory, on every path after round 2 | §3.2 rail |
| R1-3 | P1 | yes: marker is the ownership proof for replace and delete; unmarked file blocks and is reported | §3.2 |
-| R1-4 | P2 | yes: `carlos-env.pending` marker; `restart` not `try-restart` for a marked unit; retry every pass | §3.2, §4 |
-| R1-5 | P2 | partly: fixed `/etc/systemd/system/<unit>.d/` and `zz-` ordering accepted; reading back the effective environment declined as ask 3's readback | §3.2, §5 |
+| R1-4 | P2 | yes: `carlos-env.pending` marker; `restart` not `try-restart` for a marked unit; retry every pass; protocol completed in round 2 | §3.2, §4 |
+| R1-5 | P2 | yes after round 2: fixed `/etc/systemd/system/<unit>.d/`, `zz-` ordering, reset line, and `EnvironmentFiles` verification after reload; process-side readback still ask 3 | §3.2, §5 |
| R1-6 | P2 | yes: `stamped()` admits service and platform rows independently of `Adoptable` | §3.1 |
-| R1-7 | P2 | yes: one delivered route per unit; two refuse loudly | §3.2 |
+| R1-7 | P2 | yes: one delivered route per unit; aliases refused in round 2 so identity is the canonical name | §3.2 |
| R1-8 | P2 | yes: stamped routes stay in `Unresolved` when resolution is knowably failing | §3.4 |
| R1-9 | P2 | yes: `Undelivered` means "this write did not reach", remedies stated as what they are | §3.4 |
-| R1-10 | P2 | yes: both wire types and `restartFromAPI` updated; `unit` decoding and remedy kept for older consoles | §3.4 |
-| R1-11 | P2 | superseded: the file is no longer removed at all, per the existing ruling at `syncexec.go:428` | §3.3 |
-| R1-12 | P2 | partly: restart bound to the prepared unit; serialising the two root passes declined as out of scope and recorded | §3.2, §4, §5 |
-| R1-13 | P2 | declined: AGENTS.md's Build & test text predates the move of `origin` to the forge; step 1 now spells the actual sequence out | §7 |
+| R1-10 | P2 | yes: both wire types, `configRestartNotice` and `restartFromAPI` updated; `unit` decoding and remedy kept for older consoles | §3.4 |
+| R1-11 | P2 | superseded: the file is not removed, per the existing ruling at `syncexec.go:428`; the reset line makes it irrelevant | §3.3 |
+| R1-12 | P2 | yes after round 2: restart bound to the prepared unit; config passes serialised by a flock in `Sync` | §3.2, §4 |
+| R1-13 | P2 | declined: AGENTS.md's Build & test text predates the move of `origin` to the forge (codex confirmed in round 2 from the checkout's remote); step 1 spells the actual sequence out | §7 |
| R1-14 | P3 | yes: citations point at `unitRail`, `backingRail`, `provision.go:438`, and the right cli.md paragraph | §2, §3.2, §3.6 |
+
+## 10. Review round 2 (codex, 2026-09-07)
+
+Findings are in `CODEX-FINDINGS-UNIT-CONFIG-R2.md`, unedited, with
+codex's own disposition of round 1 at the top. Verdict was request
+changes. Disposition of the new findings:
+
+| # | Sev | Accepted | Where |
+|---|-----|----------|-------|
+| R2-1 | P1 | yes: the reaper applies the `Lstat` check to every `*.service.d` it enters and never follows a symlink | §3.2 reaping |
+| R2-2 | P2 | yes: `syncHost` returns changed/unchanged/failed; the marker is written only after both files succeeded; a marked unit is restarted only when its route passed the rail and both files converged this pass | §3.2 protocol |
+| R2-3 | P2 | yes: `Sync` takes an exclusive flock for the whole pass, the `restartPass` shape; the interleaving is pinned by a test | §3.2, §4, §6 |
+| R2-4 | P2 | yes: reaping replaces the drop-in with a marker; the marker is cleared only by a successful reload and never authorises a restart of an unowned unit; the surviving env file is recorded as today's reap policy | §3.2 |
+| R2-5 | P2 | yes: `systemctl show -p Id,LoadState` before any write; an alias or an unloaded unit is refused with the canonical name | §3.2 rail |
+| R2-6 | P2 | yes: `Blocked []{host, reason}` in the notice, from the exported rails and the one-per-unit rule over the full local list, disclosing only the caller's account; systemd-side refusals stay in the box log | §3.4 |
+| R2-7 | P2 | yes: supported shape is addr upstream, no socket, no hibernate; refused at the root boundary and reported, and refused at `unitRail` and `carlos add` | §3.1, §6 |
+| R2-8 | P2 | yes: the drop-in resets `EnvironmentFile=` before adding carlos's file, so a frozen legacy file cannot resurrect an unset key; flagged for Paul as the reading of "fully" | §3.2, §4, §8 |
+| R2-9 | P3 | yes: `agent_test.go` named; `execAddrCandidate` cited at its declaration | §1, §6 |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-UNIT-CONFIG-R2.md b/docs/superpowers/specs/CODEX-FINDINGS-UNIT-CONFIG-R2.md
new file mode 100644
index 000000000..83659fda1
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-UNIT-CONFIG-R2.md
@@ -0,0 +1,98 @@
+| Finding | Disposition | Evidence |
+|---|---|---|
+| R1-1 | Resolves it | §3.2 rejects templates, instance names and trailing-hyphen prefix names before writing. |
+| R1-2 | Resolves it partly | §3.2 validates unit and hostname and checks the delivery directory; the new reaper does not explicitly apply that containment check. |
+| R1-3 | Resolves it | §3.2 requires the ownership marker before replacing or deleting the drop-in and refuses unmarked collisions. |
+| R1-4 | Resolves it partly | The pending marker recovers ordinary reload/restart failures, but incomplete preparation, overlapping passes and deletion have uncovered recovery gaps below. |
+| R1-5 | Resolves it partly | The fixed location and `zz-` ordering address ordinary configurations. Deferring process readback holds; treating a check of configured file ordering as that same readback does not. |
+| R1-6 | Resolves it | §3.1’s `stamped()` independently admits ready instance and service routes, including platform rows, across delivery and polling. |
+| R1-7 | Resolves it partly | §3.2 refuses duplicate unit names, but distinct systemd aliases can bypass that check. |
+| R1-8 | Resolves it | §3.4 restores stamped routes with missing or unreadable bound bundles to `Unresolved`. |
+| R1-9 | Resolves it | §3.4 describes the current write accurately and states the supervisor prerequisite; §5 explicitly retains the remote-stamping product gap. |
+| R1-10 | Resolves it partly | Both wire types and legacy unit rendering are covered, but the console’s conversion at [apiconfig.go:538](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/internal/console/apiconfig.go:538) still needs to copy `Undelivered`. |
+| R1-11 | Resolves it | Superseding cleanup with preservation holds: [syncexec.go:428](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/internal/appconfig/syncexec.go:428) establishes the existing preservation rule. |
+| R1-12 | Resolves it partly | Restarting the prepared unit fixes the target mismatch. Declining serialization because individual writes are atomic does not hold for the new shared pending state. |
+| R1-13 | Resolves it | The declined reason holds for this checkout: its sole remote is `origin=https://amadan.net/carlos/platform`; the cited AGENTS topology is outdated. |
+| R1-14 | Resolves it | The revised references correctly identify `unitRail`, `backingRail`, the provisioning refusal and the route documentation paragraph. A separate test-location error remains below. |
+
+**R2-1 — P1 — §3.2: The reaper bypasses the new filesystem-containment check.**
+
+The `Lstat` requirement applies before delivery to a stamped route. Reaping instead scans directories for units with **no** route. If `retired.service.d` is a symlink to another directory containing a marker-bearing `zz-carlos-env.conf`, following the specified scan and removal deletes the target directory’s file. The marker proves the chosen content ownership convention; it does not prove location.
+
+Evidence: the [delivery rail](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:201) and [reaping algorithm](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:277) have different entry conditions.
+
+**Correction:** Apply containment and non-symlink checks to every reaping candidate before reading ownership evidence or removing either file. Test an orphaned symlinked directory with a marked file outside the permitted directory.
+
+**R2-2 — P2 — §3.2: A pending marker does not establish that preparation succeeded.**
+
+The marker is written before the files, but its presence alone authorizes reload and restart. If an env-file write fails, or a pass dies before completing both files, that marker describes unfinished preparation. On a subsequent pass, bundle resolution can fail while the marker remains; the specified marker loop still restarts and can clear it.
+
+The proposed `syncHost` result—whether it wrote—also cannot distinguish unchanged success from rendering failure or a key-loss refusal. Installing a new drop-in after either failure can restart against an absent or stale env file.
+
+Evidence: [the proposed return value](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:169), [marker steps 1–4](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:245), and existing refusal/error returns at [sync.go:376](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/internal/appconfig/sync.go:376), [sync.go:396](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/internal/appconfig/sync.go:396) and [sync.go:425](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/internal/appconfig/sync.go:425).
+
+**Correction:** Separate successful preparation from “bytes changed,” and distinguish preparation pending from application pending. Restart only after both files have been successfully prepared for a currently eligible, non-refused route. Test interruption between each write, failed rendering and a pending unit that subsequently becomes unresolved or duplicated.
+
+**R2-3 — P2 — §§3.2, 4: One pass can erase another pass’s pending restart.**
+
+A permitted interleaving is:
+
+1. Pass A prepares version 1 and successfully restarts the unit, then pauses before clearing the marker.
+2. Pass B writes the same marker, prepares version 2, and fails its daemon-reload.
+3. Pass A removes the marker.
+
+The process still runs version 1. Disk holds version 2. The next pass sees equal bytes and no pending work, so the change is permanently consumed.
+
+Evidence: [unconditional marker removal on success](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:255) and [explicitly permitted overlap](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:429). Atomic rename does not make this sequence atomic.
+
+**Correction:** Serialize the config transaction across processes, including preparation, reaping, reload, restart and acknowledgment; alternatively specify an equally complete concurrency protocol. A lock inside `Sync` need not change the service files. The repository already uses cross-process locking for restart state at [restart.go:602](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/internal/host/restart.go:602). Add the interleaving above as a regression test.
+
+**R2-4 — P2 — §3.2: Reaping consumes the evidence needed to retry a failed reload.**
+
+Reaping removes both the drop-in and its pending marker before daemon-reload. If reload fails or the pass stops before it runs, the next pass finds neither file and requests no reload. Systemd can retain the removed `EnvironmentFile=` setting, contrary to the stated post-unstamp behavior. An unstamped but still-present route also retains its root env file, allowing subsequent starts to read that stale file.
+
+Evidence: [reaping and its promised result](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:277), [env-file retention for live rows](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/internal/appconfig/sync.go:223), and systemd’s distinction between disk configuration and the manager’s loaded configuration. [systemctl documentation](https://raw.githubusercontent.com/systemd/systemd/main/man/systemctl.xml)
+
+**Correction:** Persist reload-only work before deletion and clear it only after successful reload. It must survive removal of the per-unit files and must never authorize restarting the now-unowned unit.
+
+**R2-5 — P2 — §3.2: Systemd aliases bypass the one-route-per-unit refusal.**
+
+Two routes can name `relay.service` and `relay-alias.service`, where the latter aliases the former. Both names pass the new rail and are unequal, but identify one service. Systemd considers drop-ins under aliases as well as the canonical name; the design can therefore accept conflicting configurations and restart the same process twice.
+
+Evidence: [the accepted name shape](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:205), [duplicate refusal](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:234), and [systemd’s alias and drop-in rules](https://raw.githubusercontent.com/systemd/systemd/main/man/systemd.unit.xml).
+
+**Correction:** Resolve names to systemd unit identity before checking uniqueness and preparing files, or explicitly refuse aliases. Bind ownership, pending work and restart deduplication to that identity. Test two accepted names for one service.
+
+**R2-6 — P2 — §§3.2, 3.4: New deterministic refusals still render as “all clear.”**
+
+Two stamped routes naming the same unit, with readable bundles, are refused by the root pass. Yet §3.4 lists neither anywhere because their bundles load. A stamped template or instance name rejected by the new root rail has the same outcome. These failures are already knowable from the registry available to the console; they require no delivered-generation readback.
+
+Evidence: [root refusals](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:201), [healthy-bundle silence](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:324), and [the renderer’s empty-result return](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/appconfig_cli.go:103).
+
+**Correction:** Share deterministic delivery validation with the notice calculation and expose refused stamped routes with an accurate reason. Check conflicts against the full local route list, while disclosing only the requesting account’s routes. Bundle resolution alone must not qualify a stamped route for silence.
+
+**R2-7 — P2 — §§3.1, 3.2: `stamped()` admits hibernating socket routes that the design assumes cannot exist.**
+
+A stamped route is not addr-backed by construction. `carlos add` accepts `--socket`, `--hibernate` and `--unit` together; `unitRail` checks only the backing conflict. Such a route passes the new `stamped()` predicate. Its config moves out of exec delivery, while the pending-marker path can start its named unit unconditionally despite the route being hibernating.
+
+Evidence: [the predicate](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:137), [the false construction claim](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:273), [CLI flags](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/cli.go:76), [route construction](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/cli.go:195), and [unitRail](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/cli.go:1524). The cited provisioning check applies to instance records, not every registry-writing door.
+
+**Correction:** Define and enforce the supported upstream/lifecycle combinations at the root boundary and relevant CLI doors. Either refuse this combination visibly or specify its delivery and wake semantics. Test the CLI-constructible shape.
+
+**R2-8 — P2 — §§3.2, 3.3: The retained legacy file can restore a secret after it is unset.**
+
+Consider a stamped unit already reading `/data/<host>.config.env`, containing `TOKEN=old`. The revision freezes that file and appends the new root env file through its drop-in. Setting `TOKEN=new` works because the later assignment wins. Unsetting `TOKEN` then removes it from the new file, leaving `TOKEN=old` effective again after restart.
+
+Evidence: [the additive EnvironmentFile directive](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:180), [explicit preservation of a supervisor-read legacy file](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:293), and [systemd’s environment-file merge semantics](https://raw.githubusercontent.com/systemd/systemd/main/man/systemd.exec.xml).
+
+**Correction:** Preserve the legacy file as ruled, but define how the managed unit stops receiving frozen Carlos values from it. Specify removal semantics during ownership transfer without indiscriminately dropping unrelated operator configuration. Test set, rotate and unset across that transfer.
+
+**R2-9 — P3 — §6: The claimed restart-arm test location is wrong.**
+
+The spec says `restart_test.go` covers the stamped restart arm for release adoption. That file exercises restart requests. The named-unit dispatch and release-adoption tests are in `agent_test.go`.
+
+Evidence: [the claim](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:519), [TestMakeRestartRouteRestartsTheUnitARouteNames](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/internal/host/agent_test.go:636), and [TestAdoptPassRestartsANamedUnitOnAdoption](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/internal/host/agent_test.go:730).
+
+**Correction:** Cite `internal/host/agent_test.go`. For an exact declaration reference, also change §1’s `execAddrCandidate` pinpoint from `syncexec.go:509` to [syncexec.go:512](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/internal/appconfig/syncexec.go:512).
+
+**Verdict: request changes.**
\ No newline at end of file