| 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 | |