carlos / platform Public

spec: named-unit config delivery, revised after codex round 3; approved

Marker written as intent before mutation, reaper records the owed
reload before deleting, verification requires exactly carlos's file,
drift checked every pass, doors validate the resulting row, batched
probe. Paul read the file and ruled the EnvironmentFile reset stands.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev fcd941af73c30ef5ba2cfd5cda87ca7bf6fb28fe parent 1729f81
2 files changed, +149 −38
  • docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md +88 −38
  • docs/superpowers/specs/CODEX-FINDINGS-UNIT-CONFIG-R3.md +61 −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 bbb2b8e0d..1ec7a5b65 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
@@ -1,9 +1,9 @@
# Config delivery for routes that name their own unit
**Date:** 2026-09-07
-**Status:** approved in chat (Paul, 2026-09-07), revised after codex
-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
+**Status:** approved (Paul, 2026-09-07: design in chat, then the file with
+the reset standing), revised after codex rounds 1 to 3 (§9 to §11). The
+next step is the implementation 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
@@ -172,8 +172,13 @@ 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
+which validate the ROW THAT WOULD RESULT, not the row that exists (codex
+R3-5): `carlos route --unit <name>` is refused on a socket or hibernating
+row, `carlos route --unit -` (clearing) is always allowed whatever the
+old row's shape, `carlos route --hibernate` is refused on a stamped row
+(`runRouteHibernate`, `cli.go:1326`, gains the check beside its daemon
+check), 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
@@ -261,9 +266,11 @@ refusal through §3.4's `Blocked` set:
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`);
+ converges on the pass after the unit is installed. The probe is ONE
+ batched `systemctl show -p Id,LoadState,EnvironmentFiles <unit>...`
+ for every stamped unit on the box per pass, the shape
+ `adviseBackingMismatches` uses (`adopt_advice.go:46`); the same answer
+ serves the drift check below (codex R3-4, R3-6);
- `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
@@ -313,30 +320,37 @@ 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.
+ rule, the pass renders both files in memory and compares them with
+ disk. If either differs, it writes the marker FIRST, then the env
+ file (`syncHost`), then the drop-in. The marker records intent before
+ any mutation, so a pass that dies between the two writes, or after
+ them, leaves a marker for the next pass to act on (codex R3-1); a
+ marker-write failure aborts the route before anything is mutated. If
+ a write is `failed`, the marker stays, the failure is logged, and
+ nothing below restarts this unit this pass. The marker is intent, not
+ authorisation: step 4 authorises.
+2. Reaping (below) writes the marker FIRST for a unit no route names,
+ then removes its marker-bearing drop-in (codex R3-2), so the reload it
+ owes is recorded before the file 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
+ route passed the rail this pass and whose files both converged this
+ pass (`changed` or `unchanged`, never `failed`), the pass verifies
+ the reload took: `systemctl show -p EnvironmentFiles <unit>` lists
+ EXACTLY one file, `/etc/carlos/apps/<host>.env` (systemd renders the
+ `-` prefix as `(ignore_errors=yes)`, which the comparison
+ normalises). Presence or last position is not enough: a later drop-in
+ that resets the list and re-adds a legacy file before carlos's would
+ pass a weaker check and resurrect an unset key (codex R3-3). 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.
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
@@ -345,6 +359,15 @@ restart. Within one locked 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.
+7. Drift is checked every pass, marker or not (codex R3-4). The batched
+ probe of the rail already returns `EnvironmentFiles` for every
+ stamped unit; for an UNMARKED unit that answer is the effective list,
+ and if it is not exactly carlos's file the host is reported through
+ `Blocked` and logged, every pass, until the operator's later drop-in
+ is gone. Nothing is restarted for drift: the unit is running what the
+ operator configured, and carlos says so rather than fighting it. For
+ a marked unit the pre-reload answer is stale by construction, and
+ step 4's post-reload check is the one that counts.
The restart is bound to the unit the pass prepared (codex R1-12): `Sync`
calls a `restartUnit(unit)` callback with `rt.Unit` as read at the start
@@ -358,7 +381,8 @@ passes is handled by the fingerprint (§3.5) and the reaper.
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
+box, writes `carlos-env.pending` beside it and only then removes the
+drop-in (codex R3-2; a marker-write failure leaves the drop-in). 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
@@ -607,9 +631,15 @@ restarter are parameters of the function under test, so no test touches
- 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, 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 `failed` env render (key-loss refusal) leaves its marker and
+ restarts nothing; a pass killed after the env write and before the
+ drop-in write, and one killed after both, each leave a marker that the
+ next pass acts on with identical bytes; a marker-write failure mutates
+ nothing;
+- an unmarked unit whose probed `EnvironmentFiles` is not exactly
+ carlos's file is reported as blocked every pass and never restarted;
+ a list that ends with carlos's file but also holds another is a
+ mismatch;
- 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;
@@ -625,8 +655,9 @@ restarter are parameters of the function under test, so no test touches
- an app with no manifest object writes no file, no drop-in, no marker;
- 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
+- a marker-bearing drop-in for a unit named by no route gets a marker
+ written before it is removed; a reaper killed between the two leaves
+ the marker; 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
@@ -650,8 +681,10 @@ restarter are parameters of the function under test, so no test touches
nowhere, and never lists a stamped route in `Hosts` or `Undelivered`;
a named-bundle write lists only routes bound to it.
-`cli_test.go`: `unitRail` refuses a socket or hibernating row; `carlos
-add` refuses `--unit` with `--socket` or `--hibernate`.
+`cli_test.go`: `carlos route --unit <name>` is refused on a socket or
+hibernating row and `--unit -` is allowed on one; `carlos route
+--hibernate` is refused on a stamped 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
@@ -704,8 +737,8 @@ already; the cross-compile gate needs nothing new.
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; the
- environment-file reset in §3.2 is this spec's reading of "fully" and
- is a **(decision)**, flagged for Paul's read.
+ environment-file reset in §3.2 is this spec's reading of "fully";
+ Paul read it and it stands (§11).
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
@@ -751,3 +784,20 @@ changes. Disposition of the new findings:
| 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 |
+
+## 11. Review round 3 (codex, 2026-09-07)
+
+Findings are in `CODEX-FINDINGS-UNIT-CONFIG-R3.md`, unedited, with
+codex's own disposition of round 2 at the top. Verdict was request
+changes. Paul read the spec at this round and ruled that the reset in
+§3.2 stands (ruling 1's reading of "fully"; the flag in §8 is
+resolved). Disposition of the new findings:
+
+| # | Sev | Accepted | Where |
+|---|-----|----------|-------|
+| R3-1 | P2 | yes: the marker is written before any mutation as intent; step 4 alone authorises a restart, and only after both files converged this pass | §3.2 step 1 |
+| R3-2 | P2 | yes: the reaper writes the marker before removing the drop-in | §3.2 step 2, reaping |
+| R3-3 | P2 | yes: verification requires the effective list to be exactly carlos's file | §3.2 step 4 |
+| R3-4 | P2 | yes: drift is checked every pass from the batched probe for unmarked units, reported and never fought | §3.2 step 7 |
+| R3-5 | P2 | yes: the doors validate the resulting row; clearing a stamp always allowed; `--hibernate` refused on a stamped row | §3.1, §6 |
+| R3-6 | P3 | yes: one batched `systemctl show` per pass, the `adviseBackingMismatches` shape | §3.2 rail |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-UNIT-CONFIG-R3.md b/docs/superpowers/specs/CODEX-FINDINGS-UNIT-CONFIG-R3.md
new file mode 100644
index 000000000..c284abd96
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-UNIT-CONFIG-R3.md
@@ -0,0 +1,61 @@
+| Finding | Disposition | Evidence |
+|---|---|---|
+| R2-1 | Resolves it | [§3.2 reaping](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:356) requires `Lstat` on every candidate directory and skips symlinks. |
+| R2-2 | Resolves it partly | [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:315) distinguish failed preparation and gate restart correctly, but writing the marker afterward introduces R3-1. |
+| R2-3 | Resolves it | [The whole-pass flock](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:301) prevents concurrent config transactions from erasing each other’s pending work. |
+| R2-4 | Resolves it partly | [The reload-owed marker](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:322) survives reload failure once written, but deletion precedes its creation; see R3-2. |
+| R2-5 | Resolves it | [The identity probe](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:257) refuses aliases before writing. |
+| R2-6 | Resolves it | [The `Blocked` set](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:409) covers registry-known refusals, checks full-box uniqueness and limits disclosure to the caller’s hosts. |
+| R2-7 | Resolves it | [The root shape rail](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:168) refuses socket and hibernating stamps visibly before mutation or restart. The new door enforcement has a separate defect below. |
+| R2-8 | Resolves it | [The reset](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:219) removes the original legacy-file fallback. The decision remains awaiting the owner’s read; R3-3 concerns enforcement under that decision. |
+| R2-9 | Resolves it | [§6](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:660) names `agent_test.go`; §1 now cites `execAddrCandidate` at its declaration. |
+
+**R3-1 — P2 — §3.2: Writing the pending marker after preparation can permanently consume a change.**
+
+An established unit has its drop-in and no pending marker. A pass writes a changed env file, then dies before creating the marker. The next pass finds both files unchanged, creates no marker and never restarts. Marker-write failure produces the same outcome. The flock does not recover interrupted work.
+
+Evidence: [step 1’s ordering](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:315), [marker-dependent application](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:325), and the existing [equality short circuit](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/internal/appconfig/sync.go:421).
+
+**Correction:** Persist preparation intent before mutation, separately from authorization to restart. Retain the requirement that both files successfully converge in the current pass before application. Test interruption after each mutation and failure to write pending state.
+
+**R3-2 — P2 — §3.2: Reaping still has a window that loses the owed reload.**
+
+With no existing pending marker, the reaper removes the drop-in and then dies—or fails to write the marker. Neither file remains. Subsequent passes find nothing requiring reload, while systemd can retain the deleted setting.
+
+Evidence: [step 2](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:322) and [the 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:359) both specify removal before marker creation. Reload is what refreshes the manager’s loaded unit configuration. [Systemctl documentation](https://raw.githubusercontent.com/systemd/systemd/main/man/systemctl.xml)
+
+**Correction:** Successfully persist reload-only intent before removing the owned drop-in. Clear it only after successful reload, preserving the prohibition on restarting the unowned unit. Test interruption between deletion and reload.
+
+**R3-3 — P2 — §3.2: “Listed last” does not verify the reset contract.**
+
+A later `zzz.conf` can reset the list, then add the frozen legacy file followed by Carlos’s rendered file. The specified verification passes. If the bundle unsets `TOKEN`, the legacy file’s `TOKEN=old` becomes effective again.
+
+Evidence: [the contract requires only Carlos’s file](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:219), but [step 4 checks presence and last position](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:331). Later files override earlier assignments only for keys they contain. [Systemd environment semantics](https://raw.githubusercontent.com/systemd/systemd/main/man/systemd.exec.xml)
+
+**Correction:** Under the specified reset decision, require the complete effective list to contain exactly the intended Carlos source. Test a legacy source followed by the expected source.
+
+**R3-4 — P2 — §§3.2, 4: Successful delivery disables subsequent conflict detection.**
+
+After successful delivery clears the marker, an operator adds a later drop-in replacing `EnvironmentFile=` and reloads systemd. Carlos’s own files remain unchanged. Every subsequent unchanged pass—including an explicit sync—skips verification because the unit is unmarked. The conflict is never reported.
+
+Evidence: [step 4 visits marked units only](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:328), contradicting [§4’s promise to refuse the later-drop-in conflict every pass](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:536). Later drop-ins can replace earlier settings. [Systemd drop-in ordering](https://raw.githubusercontent.com/systemd/systemd/main/man/systemd.unit.xml)
+
+**Correction:** Check effective configuration independently of pending restart work. Test external configuration drift after a successful pass has cleared its marker.
+
+**R3-5 — P2 — §3.1: The door rail checks the existing state and misses the reverse mutation.**
+
+Adding the supported-shape check to `unitRail(rt)` prevents clearing an existing socket stamp: `runRouteUnit` checks the old row before applying the requested empty unit. Conversely, `carlos route --hibernate` can turn a supported stamped addr route with a proven non-daemon release into an unsupported row without calling `unitRail`. Root refusal contains delivery, but the claimed door enforcement fails.
+
+Evidence: [the specified door changes](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md:172), [validation before the unit update](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/cli.go:1486), [independent hibernation dispatch](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/cli.go:725), and [its daemon check and update](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/cli.go:1326).
+
+**Correction:** Validate the proposed resulting row at both mutation paths. Permit clearing a stamp regardless of the old stamped shape; refuse enabling hibernation while retaining a stamp.
+
+**R3-6 — P3 — §3.2: The probe-cost comparison is incorrect.**
+
+The spec says one `systemctl show` per stamped route costs what backing advice already pays. Backing advice uses one batched invocation for all candidates.
+
+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:264) and [the batched implementation](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/unit-route-config/internal/host/adopt_advice.go:46).
+
+**Correction:** Remove the comparison or state the actual invocation-count difference. The remaining file-and-line citations support their attached claims.
+
+**Verdict: request changes.**
\ No newline at end of file