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