carlos / platform Public

plan: named-unit config delivery, twelve tasks from the approved spec

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 5a4f100f55f42fc9c8c83d8c411dc929a5618933 parent fcd941a
1 file changed, +2957 −0
  • docs/superpowers/plans/2026-09-07-named-unit-config-delivery.md +2957 −0
diff --git a/docs/superpowers/plans/2026-09-07-named-unit-config-delivery.md b/docs/superpowers/plans/2026-09-07-named-unit-config-delivery.md
new file mode 100644
index 000000000..1d1d96459
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-07-named-unit-config-delivery.md
@@ -0,0 +1,2957 @@
+# Named-Unit Config Delivery Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A route stamped with a named systemd unit gets its config rendered, wired into the unit through a carlos-owned drop-in, and restarted by the root pass, and `env set` / `secrets set` say when a write reached nothing or is blocked.
+
+**Architecture:** `internal/appconfig` gains one predicate pair (`Stamped`, `unitDelivered`) and exported rails that the root pass, the console notice and the CLI doors all share. `Sync` grows a `UnitOps` seam (lock, probe, reload, restart, drop-in root) so the whole delivery protocol is unit-testable with fakes; `internal/host` supplies the real `systemctl` implementations. The notice gains `Undelivered` and `Blocked`, carried additively over both wire types.
+
+**Tech Stack:** Go, sqlite registry (`internal/registry`), systemd drop-ins, `go test ./... -count=1`.
+
+**Spec:** `docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md`
+
+## Global Constraints
+
+- Build and test gate: `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .` clean. Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; socket tests need `TMPDIR=/tmp/g` (mkdir it first).
+- Cross-compile gate: `internal/host` is linked into the CLI; anything Linux-only needs a build-tagged file with a stub (the existing `flock_unix.go` / `flock_windows.go` pair is the pattern). Nothing in this plan calls a Linux-only syscall directly; the flock helper is reused through a callback.
+- Every user-facing string added here (notice lines, refusal messages) goes through `copy-review` before the branch lands. Write them as the spec drafts them and flag them in the final commit message.
+- Wire shapes are additive: new JSON fields are `omitempty`; nothing existing is renamed or removed.
+- Drop-in location is fixed: `<DropInRoot>/<unit>.d/zz-carlos-env.conf`, marker file `<DropInRoot>/<unit>.d/carlos-env.pending`; production `DropInRoot` is `/etc/systemd/system`.
+- The drop-in's first line is exactly `# Managed by carlos: config delivery for the route naming this unit.` and that line is the ownership proof.
+- Commit after every task; commit messages end with `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`.
+- Never `git stash`; never push to `main`; the branch is `worktree-unit-route-config`.
+
+---
+
+### Task 1: Predicates and exported rails
+
+**Files:**
+- Create: `internal/appconfig/stamped.go`
+- Create: `internal/appconfig/stamped_test.go`
+- Modify: `internal/appconfig/sync.go:284-320` (`syncable` stays; `UnitConfigApps` uses `unitDelivered`)
+- Modify: `internal/appconfig/syncexec.go:478-482` (`execRouteBase` excludes stamped routes)
+- Modify: `internal/appconfig/routefingerprint.go:58-76` (unit row includes `Unit`)
+- Test: `internal/appconfig/routefingerprint_test.go`, `internal/appconfig/syncexec_test.go`
+
+**Interfaces:**
+- Produces: `func Stamped(rt registry.Route) bool`, `func unitDelivered(rt registry.Route) bool`, `func StampShape(rt registry.Route) error`, `func DeliverableUnitName(unit string) error`, `func BareHostname(host string) bool`. All later tasks use these names exactly.
+
+- [ ] **Step 1: Write the failing predicate tests**
+
+Create `internal/appconfig/stamped_test.go`:
+
+```go
+package appconfig
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func stampedRoute(host, unit string) registry.Route {
+ return registry.Route{Host: host, Kind: "instance", App: "relay", Account: "bab",
+ Status: "ready", Addr: "127.0.0.1:8451", Unit: unit}
+}
+
+func TestStampedAdmitsInstanceServiceAndPlatformRows(t *testing.T) {
+ inst := stampedRoute("relay.example.com", "relay.service")
+ svc := inst
+ svc.Kind = "service"
+ plat := inst
+ plat.Platform = true
+ for name, rt := range map[string]registry.Route{"instance": inst, "service": svc, "platform": plat} {
+ if !Stamped(rt) {
+ t.Errorf("%s: Stamped = false, want true", name)
+ }
+ if !unitDelivered(rt) {
+ t.Errorf("%s: unitDelivered = false, want true", name)
+ }
+ }
+}
+
+func TestStampedRejectsUnstampedNotReadyStaticAndAppless(t *testing.T) {
+ base := stampedRoute("relay.example.com", "relay.service")
+ cases := map[string]func(registry.Route) registry.Route{
+ "no unit": func(r registry.Route) registry.Route { r.Unit = ""; return r },
+ "pending": func(r registry.Route) registry.Route { r.Status = "pending"; return r },
+ "static": func(r registry.Route) registry.Route { r.Kind = "static"; return r },
+ "no app": func(r registry.Route) registry.Route { r.App = ""; return r },
+ }
+ for name, mut := range cases {
+ if Stamped(mut(base)) {
+ t.Errorf("%s: Stamped = true, want false", name)
+ }
+ }
+}
+
+func TestExecRouteBaseExcludesStampedRoutes(t *testing.T) {
+ rt := stampedRoute("relay.example.com", "relay.service")
+ if execRouteBase(rt) {
+ t.Fatal("execRouteBase admitted a stamped route; the root pass owns it")
+ }
+ if execAddrCandidate(rt) {
+ t.Fatal("execAddrCandidate admitted a stamped route")
+ }
+ rt.Unit = ""
+ if !execAddrCandidate(rt) {
+ t.Fatal("the same route without a stamp must still be an exec addr candidate")
+ }
+}
+
+func TestStampShape(t *testing.T) {
+ ok := stampedRoute("relay.example.com", "relay.service")
+ if err := StampShape(ok); err != nil {
+ t.Fatalf("StampShape(addr route) = %v, want nil", err)
+ }
+ addrs := ok
+ addrs.Addr, addrs.Addrs = "", "127.0.0.1:1,127.0.0.1:2"
+ if err := StampShape(addrs); err != nil {
+ t.Fatalf("StampShape(addrs route) = %v, want nil", err)
+ }
+ sock := ok
+ sock.Socket = "/run/x.sock"
+ if err := StampShape(sock); err == nil || !strings.Contains(err.Error(), "socket") {
+ t.Fatalf("StampShape(socket route) = %v, want a socket refusal", err)
+ }
+ hib := ok
+ hib.Hibernate = true
+ if err := StampShape(hib); err == nil || !strings.Contains(err.Error(), "hibernat") {
+ t.Fatalf("StampShape(hibernating route) = %v, want a hibernate refusal", err)
+ }
+ none := ok
+ none.Addr = ""
+ if err := StampShape(none); err == nil || !strings.Contains(err.Error(), "addr") {
+ t.Fatalf("StampShape(no upstream) = %v, want an addr refusal", err)
+ }
+}
+
+func TestDeliverableUnitName(t *testing.T) {
+ for _, good := range []string{"relay.service", "woodstar-relay.service", "console.service", "a_b.c.service"} {
+ if err := DeliverableUnitName(good); err != nil {
+ t.Errorf("%s: %v, want accepted", good, err)
+ }
+ }
+ for name, want := range map[string]string{
+ "carlos-app@.service": "template",
+ "carlos-app@x.service": "template",
+ "carlos-.service": "prefix",
+ "": "unit name",
+ "relay": "unit name",
+ "a/b.service": "unit name",
+ } {
+ err := DeliverableUnitName(name)
+ if err == nil || !strings.Contains(err.Error(), want) {
+ t.Errorf("%q: %v, want a refusal mentioning %q", name, err, want)
+ }
+ }
+}
+
+func TestBareHostname(t *testing.T) {
+ for _, good := range []string{"relay.example.com", "a", "woodstar-relay.bdm.oncarlos.com", "X1.y2"} {
+ if !BareHostname(good) {
+ t.Errorf("%q: want bare", good)
+ }
+ }
+ for _, bad := range []string{"", ".", "..", "a..b", "-a.b", "a-.b", "a\nUser=root", "a b", "a/b", strings.Repeat("a", 254)} {
+ if BareHostname(bad) {
+ t.Errorf("%q: want refused", bad)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run 'TestStamped|TestExecRouteBaseExcludes|TestStampShape|TestDeliverableUnitName|TestBareHostname' -count=1`
+Expected: FAIL with `undefined: Stamped` (compile error).
+
+- [ ] **Step 3: Create `internal/appconfig/stamped.go`**
+
+```go
+// The stamped shape: a route that names the systemd unit owning its
+// process (registry.Route.Unit, issue #110). Config delivery for it is the
+// ROOT pass's (spec 2026-09-07-named-unit-config-delivery-design.md §3.1):
+// the agent's exec sweep never touches it. The predicates and rails here
+// are shared by Sync (the pass), AwaitingRestart (the notice) and the CLI
+// doors (`carlos add`, `carlos route --unit/--hibernate`), so that the
+// three cannot disagree on which routes are stamped, or on why one is
+// refused.
+package appconfig
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Stamped reports whether rt names its own unit and is a route the root
+// pass may deliver config to. NOT Adoptable narrowed: `carlos route --unit`
+// admits service and platform rows (cli.go unitRail), so delivery admits
+// them too, or a supported stamp would belong to neither delivery half.
+// A row that is not ready is left alone as everywhere else.
+func Stamped(rt registry.Route) bool {
+ return rt.Unit != "" && rt.App != "" &&
+ (rt.Kind == "instance" || rt.Kind == "service") &&
+ (rt.Status == "" || rt.Status == "ready")
+}
+
+// unitDelivered is Sync's eligibility: the carlos-app@ shape (unit backing
+// on an adoptable row) or a stamped route. UnitConfigApps and the unit
+// fingerprint use the same test, for the reason each says beside itself.
+func unitDelivered(rt registry.Route) bool {
+ return (syncable(rt) && rt.Backing == registry.BackingUnit) || Stamped(rt)
+}
+
+// StampShape is the supported shape of a stamped route (spec §3.1): an
+// addr upstream, no socket, no hibernation. A socket route is the
+// activator's to spawn, so no unit can own it; a hibernating route is woken
+// by the activator, and a config restart would start it behind the
+// activator's back. The error text is the refusal reason the pass logs and
+// the notice reports.
+func StampShape(rt registry.Route) error {
+ switch {
+ case rt.Socket != "":
+ return fmt.Errorf("names unit %s but has a socket upstream: a socket route is the activator's to spawn, so no unit can own it", rt.Unit)
+ case rt.Addr == "" && rt.Addrs == "":
+ return fmt.Errorf("names unit %s but has no addr upstream", rt.Unit)
+ case rt.Hibernate:
+ return fmt.Errorf("names unit %s and hibernates: a config restart would start it behind the activator's back", rt.Unit)
+ }
+ return nil
+}
+
+// DeliverableUnitName is the rail at the root write (spec §3.2): a concrete
+// service name whose drop-in directory applies to that unit alone. A
+// template name (carlos-app@.service) has a directory systemd applies to
+// every instance; a prefix name (carlos-.service) has one systemd applies
+// to every unit sharing the prefix; an instance name (x@y.service) is
+// refused with them for simplicity, since nothing stamps one.
+func DeliverableUnitName(unit string) error {
+ if unit == "" || !registry.ValidUnitName(unit) {
+ return fmt.Errorf("%q is not a systemd service unit name", unit)
+ }
+ if strings.Contains(unit, "@") {
+ return fmt.Errorf("%s is a template or instance name; its drop-in directory would apply to every instance of the template", unit)
+ }
+ if strings.HasSuffix(strings.TrimSuffix(unit, ".service"), "-") {
+ return fmt.Errorf("%s is a prefix name; systemd applies its drop-in directory to every unit sharing the prefix", unit)
+ }
+ return nil
+}
+
+var hostLabelRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$`)
+
+// BareHostname: labels of [A-Za-z0-9-] joined by '.', no label empty or
+// edged with '-', at most 253 bytes. syncHost's basename check stops a path
+// separator; this stops a newline, which matters once a host is written
+// into a file systemd parses as directives (spec §3.2).
+func BareHostname(host string) bool {
+ if host == "" || len(host) > 253 {
+ return false
+ }
+ for _, l := range strings.Split(host, ".") {
+ if !hostLabelRe.MatchString(l) {
+ return false
+ }
+ }
+ return true
+}
+```
+
+- [ ] **Step 4: Exclude stamped routes from the exec side**
+
+In `internal/appconfig/syncexec.go`, change `execRouteBase` (line 478) to:
+
+```go
+func execRouteBase(rt registry.Route) bool {
+ return rt.Kind == "instance" && rt.App != "" && !rt.Platform &&
+ (rt.Status == "" || rt.Status == "ready") &&
+ rt.Backing != registry.BackingUnit &&
+ rt.Unit == ""
+}
+```
+
+Add to its doc comment, after "unit routes belong to the root adopt path":
+
+```go
+// ... A STAMPED route (rt.Unit != "") belongs to the root pass too, whatever
+// its backing: see stamped.go and spec §3.1. Excluding it here removes it
+// from execSyncable, execAddrCandidate, ExecConfigApps, the exec
+// fingerprint and AwaitingRestart's delivered-to set at once.
+```
+
+- [ ] **Step 5: Make `UnitConfigApps` and the unit fingerprint use `unitDelivered`**
+
+In `internal/appconfig/sync.go` `UnitConfigApps` (line 303), replace
+
+```go
+ if !syncable(rt) || rt.Backing != registry.BackingUnit {
+```
+
+with
+
+```go
+ if !unitDelivered(rt) {
+```
+
+In `internal/appconfig/routefingerprint.go` (lines 67-73), replace the row and the unit test with:
+
+```go
+ row := rt.Host + "|" + release.RouteAccount(rt) + "|" + rt.App + "|" + rt.Environment
+ if execSyncable(rt) || execAddrCandidate(rt) {
+ execRows = append(execRows, row)
+ }
+ if unitDelivered(rt) {
+ // Unit is part of the unit row (spec §3.5): a re-stamp to a
+ // different unit changes which drop-in the route belongs in
+ // without changing the env file's bytes, and this digest is
+ // what turns that registry change into a root pass request.
+ unitRows = append(unitRows, row+"|"+rt.Unit)
+ }
+```
+
+- [ ] **Step 6: Add fingerprint and app-list tests**
+
+Append to `internal/appconfig/routefingerprint_test.go`:
+
+```go
+func fpStampedRoute(host, unit string) registry.Route {
+ return registry.Route{Host: host, Kind: "instance", App: "woodstar-relay",
+ Account: "bdm", Status: "ready", Addr: "127.0.0.1:8451", Unit: unit}
+}
+
+func TestConfigRouteFingerprintsStampedRouteIsInTheUnitHalf(t *testing.T) {
+ stamped := []registry.Route{fpStampedRoute("relay.bdm.oncarlos.com", "woodstar-relay.service")}
+ none := []registry.Route{}
+ e, u := ConfigRouteFingerprints(stamped)
+ e0, u0 := ConfigRouteFingerprints(none)
+ if e != e0 {
+ t.Fatal("a stamped route must not be in the exec digest")
+ }
+ if u == u0 {
+ t.Fatal("a stamped route must be in the unit digest")
+ }
+ _, u2 := ConfigRouteFingerprints([]registry.Route{fpStampedRoute("relay.bdm.oncarlos.com", "other.service")})
+ if u == u2 {
+ t.Fatal("re-stamping to a different unit must move the unit digest")
+ }
+}
+
+func TestUnitConfigAppsIncludesStampedRoutes(t *testing.T) {
+ got := UnitConfigApps([]registry.Route{fpStampedRoute("relay.bdm.oncarlos.com", "woodstar-relay.service")})
+ if len(got) != 1 || got[0] != [2]string{"bdm", "woodstar-relay"} {
+ t.Fatalf("UnitConfigApps = %v, want [[bdm woodstar-relay]]", got)
+ }
+ if got := ExecConfigApps([]registry.Route{fpStampedRoute("relay.bdm.oncarlos.com", "woodstar-relay.service")}); len(got) != 0 {
+ t.Fatalf("ExecConfigApps = %v, want none for a stamped route", got)
+ }
+}
+```
+
+- [ ] **Step 7: Run the package tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -count=1`
+Expected: PASS. `TestAwaitingRestartCarriesUnit` now FAILS (a stamped route is no longer an exec addr candidate). That is expected and is fixed in Task 9; for this commit, change that test to assert the opposite and rename it:
+
+```go
+// TestAwaitingRestartLeavesStampedRoutesToTheRootPass: a stamped route is
+// the root pass's (spec §3.1); it is delivered and restarted there, so it
+// is never a host the notice tells someone to restart by hand.
+func TestAwaitingRestartLeavesStampedRoutesToTheRootPass(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ rt := addrRoute("console.example.com", "relay", "bab", "")
+ rt.Unit = "console.service"
+ got := AwaitingRestart(ctx, s, []registry.Route{rt}, "bab", "relay", "", nil)
+ if len(got.Hosts) != 0 {
+ t.Fatalf("Hosts = %+v, want none for a stamped route", got.Hosts)
+ }
+}
+```
+
+Re-run; expected: PASS across the package. Then `go vet ./internal/appconfig/ && gofmt -l internal/appconfig/`.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add internal/appconfig/stamped.go internal/appconfig/stamped_test.go internal/appconfig/sync.go internal/appconfig/syncexec.go internal/appconfig/routefingerprint.go internal/appconfig/routefingerprint_test.go internal/appconfig/awaitingrestart_test.go
+git commit -m "appconfig: a stamped route is the root pass's; shared shape and name rails (#114)
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: `syncHost` returns a three-state outcome
+
+**Files:**
+- Modify: `internal/appconfig/sync.go:331-441` (`syncHost`)
+- Test: `internal/appconfig/sync_test.go`
+
+**Interfaces:**
+- Produces: `type hostOutcome int` with `hostFailed`, `hostUnchanged`, `hostChanged`; `func prepareHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string) (path string, rendered []byte, cur []byte, existed bool, ok bool)`; `func commitHost(host, path string, rendered []byte) bool`; `func syncHost(...)` keeps its signature and is now `prepareHost` + `commitHost` + restart. Task 4 calls `prepareHost` and `commitHost` directly.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/appconfig/sync_test.go`:
+
+```go
+func TestPrepareAndCommitHostOutcomes(t *testing.T) {
+ envDir := t.TempDir()
+ priv, _, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ privKey, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ rt := registry.Route{Host: "keymail.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready"}
+ m := &Manifest{Vars: map[string]Var{"LOG_LEVEL": {Kind: "env", Value: "debug"}}}
+
+ path, rendered, cur, existed, ok := prepareHost(rt, m, false, privKey, envDir)
+ if !ok || existed || cur != nil || path != filepath.Join(envDir, "keymail.example.com.env") {
+ t.Fatalf("first prepare: ok=%v existed=%v cur=%q path=%q", ok, existed, cur, path)
+ }
+ if !commitHost(rt.Host, path, rendered) {
+ t.Fatal("commitHost failed")
+ }
+ _, rendered2, cur2, existed2, ok2 := prepareHost(rt, m, false, privKey, envDir)
+ if !ok2 || !existed2 || !bytes.Equal(cur2, rendered2) {
+ t.Fatalf("second prepare: ok=%v existed=%v equal=%v", ok2, existed2, bytes.Equal(cur2, rendered2))
+ }
+
+ // A non-bare host is a refusal: ok == false, nothing written.
+ bad := rt
+ bad.Host = "../x"
+ if _, _, _, _, ok := prepareHost(bad, m, false, privKey, envDir); ok {
+ t.Fatal("prepareHost accepted a non-bare hostname")
+ }
+}
+```
+
+Add `"bytes"` to the test file's imports.
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run TestPrepareAndCommitHostOutcomes -count=1`
+Expected: FAIL, `undefined: prepareHost`.
+
+- [ ] **Step 3: Split `syncHost`**
+
+Replace the body of `syncHost` in `internal/appconfig/sync.go` (keep its doc comment) with:
+
+```go
+func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) {
+ path, rendered, cur, existed, ok := prepareHost(rt, m, defaultMissing, priv, envDir)
+ if !ok {
+ return
+ }
+ if existed && bytes.Equal(cur, rendered) {
+ return // up to date — stay quiet on the periodic tick
+ }
+ if !commitHost(rt.Host, path, rendered) {
+ return
+ }
+ // No rt.Hibernate special case on purpose: try-restart is a no-op on an
+ // inactive unit, so a hibernated route's file converges without waking it.
+ if err := restart(rt.Host); err != nil {
+ fmt.Printf(" %s: config updated but restart failed: %v\n", rt.Host, err)
+ return
+ }
+ fmt.Printf(" %s: config updated, restarted\n", rt.Host)
+}
+
+// hostOutcome is what one route's env file did this pass. Three states,
+// not two (spec §3.2, codex R2-2): a refusal, a render error or a write
+// error are all hostFailed, and a caller that treats "nothing changed" and
+// "could not write" alike would restart a unit against a stale or absent
+// file.
+type hostOutcome int
+
+const (
+ hostFailed hostOutcome = iota
+ hostUnchanged
+ hostChanged
+)
+
+// prepareHost is the read-and-render half of syncHost: everything up to
+// the decision to write, with every refusal syncHost makes. ok == false
+// means a refusal or error was printed and nothing may be written for this
+// route this pass. Split from the write so the stamped path (unitsync.go)
+// can decide what else must happen before the env file lands.
+func prepareHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string) (path string, rendered, cur []byte, existed, ok bool) {
+ // The host is joined into a root-written path; a registry row carrying
+ // a separator must never place a file outside envDir. Same trust level
+ // as release's live/<host> symlinks, but this one is one line to pin.
+ if rt.Host == "" || rt.Host == "." || rt.Host == ".." || rt.Host != filepath.Base(rt.Host) {
+ fmt.Printf(" %q: not a bare hostname, refusing to derive an env file path from it\n", rt.Host)
+ return "", nil, nil, false, false
+ }
+ path = filepath.Join(envDir, rt.Host+".env")
+ cur, err := os.ReadFile(path)
+ existed = err == nil
+ if err != nil && !os.IsNotExist(err) {
+ fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err)
+ return "", nil, nil, false, false
+ }
+ if !existed {
+ cur = nil
+ }
+ // (the defaultMissing / lostKeys guard block moves here unchanged,
+ // returning ("", nil, nil, false, false) where it returned before)
+ if defaultMissing && existed && len(cur) > 0 {
+ lost, parsed := lostKeys(cur, m)
+ switch {
+ case !parsed:
+ fmt.Printf(" %s: environment %q is bound but %s has no default config bundle, so the merged view is that environment ALONE — "+
+ "refusing to replace %s, which this pass cannot parse as KEY=value and so cannot prove is safe to overwrite; "+
+ "import its config (`carlos env set --app %s KEY=value ...`) or remove the file\n",
+ rt.Host, rt.Environment, rt.App, path, rt.App)
+ return "", nil, nil, false, false
+ case len(lost) > 0:
+ fmt.Printf(" %s: environment %q is bound but %s has no default config bundle, so the merged view is that environment ALONE — "+
+ "refusing to drop %d var(s) already in %s (%s); import them (`carlos env set --app %s KEY=value ...`) "+
+ "and this route converges on the next pass, or remove the file if it is dead\n",
+ rt.Host, rt.Environment, rt.App, len(lost), path, strings.Join(lost, ", "), rt.App)
+ return "", nil, nil, false, false
+ }
+ }
+ rendered, err = RenderEnv(m, priv)
+ if err != nil {
+ fmt.Printf(" %s: render config: %v\n", rt.Host, err)
+ return "", nil, nil, false, false
+ }
+ rendered = applyAgentVars(rendered)
+ return path, rendered, cur, existed, true
+}
+
+// commitHost writes rendered to path atomically. False means the write
+// failed and was printed; the file on disk is whatever it was.
+func commitHost(host, path string, rendered []byte) bool {
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, rendered, 0o600); err != nil {
+ fmt.Printf(" %s: write %s: %v\n", host, path, err)
+ return false
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ os.Remove(tmp) // don't leave rendered secrets behind
+ fmt.Printf(" %s: rename %s: %v\n", host, path, err)
+ return false
+ }
+ return true
+}
+```
+
+Move the long comment blocks (the CRITICAL 1 guard explanation, the Spec §7.3 `applyAgentVars` note) with the code they describe; do not drop them.
+
+- [ ] **Step 4: Run the whole package**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -count=1 && go vet ./internal/appconfig/ && gofmt -l internal/appconfig/`
+Expected: PASS, no vet output, no unformatted files. Every existing Sync test still passes: behaviour is unchanged.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/appconfig/sync.go internal/appconfig/sync_test.go
+git commit -m "appconfig: split syncHost into prepare and commit with a three-state outcome
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: The drop-in file model
+
+**Files:**
+- Create: `internal/appconfig/dropin.go`
+- Create: `internal/appconfig/dropin_test.go`
+
+**Interfaces:**
+- Produces:
+ - `const DropInFile = "zz-carlos-env.conf"`, `const PendingFile = "carlos-env.pending"`, `const dropInMarker = "# Managed by carlos: config delivery for the route naming this unit."`
+ - `func dropInDir(root, unit string) string` → `<root>/<unit>.d`
+ - `func renderDropIn(envDir, host string) []byte`
+ - `func ensureDropInDir(root, unit string) error` (mkdir 0755; Lstat; refuse symlink or non-dir)
+ - `func readDropIn(root, unit string) (cur []byte, exists, marked bool, err error)`
+ - `func writeDropIn(root, unit string, rendered []byte) error` (atomic, 0644)
+ - `func writePending(root, unit string) error`, `func removePending(root, unit string) error`, `func hasPending(root, unit string) bool`
+ - `func listMarkedDropIns(root string) (units []string, skipped []string)` (walks `*.service.d`, Lstat each, returns units with a marked drop-in; symlinked dirs in `skipped`)
+ - `func listPending(root string) []string` (units with a marker, same Lstat rule)
+ - `func removeDropIn(root, unit string) error`
+- Task 4 and Task 6 consume all of these.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/appconfig/dropin_test.go`:
+
+```go
+package appconfig
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestRenderDropInHasMarkerResetAndFile(t *testing.T) {
+ got := string(renderDropIn("/etc/carlos/apps", "relay.example.com"))
+ want := dropInMarker + "\n" +
+ "# Edit the route's config with `carlos env` / `carlos secrets`, not this file.\n" +
+ "[Service]\n" +
+ "EnvironmentFile=\n" +
+ "EnvironmentFile=-/etc/carlos/apps/relay.example.com.env\n"
+ if got != want {
+ t.Fatalf("renderDropIn =\n%s\nwant\n%s", got, want)
+ }
+}
+
+func TestWriteReadAndMarkDropIn(t *testing.T) {
+ root := t.TempDir()
+ if err := ensureDropInDir(root, "relay.service"); err != nil {
+ t.Fatalf("ensureDropInDir: %v", err)
+ }
+ if _, exists, _, err := readDropIn(root, "relay.service"); err != nil || exists {
+ t.Fatalf("readDropIn before write: exists=%v err=%v", exists, err)
+ }
+ body := renderDropIn("/etc/carlos/apps", "relay.example.com")
+ if err := writeDropIn(root, "relay.service", body); err != nil {
+ t.Fatalf("writeDropIn: %v", err)
+ }
+ cur, exists, marked, err := readDropIn(root, "relay.service")
+ if err != nil || !exists || !marked || string(cur) != string(body) {
+ t.Fatalf("readDropIn after write: exists=%v marked=%v err=%v", exists, marked, err)
+ }
+ st, _ := os.Stat(filepath.Join(root, "relay.service.d", DropInFile))
+ if st.Mode().Perm() != 0o644 {
+ t.Fatalf("drop-in mode = %o, want 0644", st.Mode().Perm())
+ }
+ if hasPending(root, "relay.service") {
+ t.Fatal("pending marker present before writePending")
+ }
+ if err := writePending(root, "relay.service"); err != nil {
+ t.Fatalf("writePending: %v", err)
+ }
+ if !hasPending(root, "relay.service") {
+ t.Fatal("pending marker absent after writePending")
+ }
+ if got := listPending(root); len(got) != 1 || got[0] != "relay.service" {
+ t.Fatalf("listPending = %v", got)
+ }
+ if err := removePending(root, "relay.service"); err != nil || hasPending(root, "relay.service") {
+ t.Fatalf("removePending: err=%v still pending=%v", err, hasPending(root, "relay.service"))
+ }
+ if err := removePending(root, "relay.service"); err != nil {
+ t.Fatalf("removePending twice must be a no-op, got %v", err)
+ }
+}
+
+func TestReadDropInSeesAnUnmarkedFile(t *testing.T) {
+ root := t.TempDir()
+ dir := filepath.Join(root, "relay.service.d")
+ os.MkdirAll(dir, 0o755)
+ os.WriteFile(filepath.Join(dir, DropInFile), []byte("[Service]\nEnvironmentFile=/x\n"), 0o644)
+ _, exists, marked, err := readDropIn(root, "relay.service")
+ if err != nil || !exists || marked {
+ t.Fatalf("exists=%v marked=%v err=%v, want an existing unmarked file", exists, marked, err)
+ }
+ units, _ := listMarkedDropIns(root)
+ if len(units) != 0 {
+ t.Fatalf("listMarkedDropIns = %v, want none for an unmarked file", units)
+ }
+}
+
+func TestEnsureDropInDirRefusesSymlinkAndFile(t *testing.T) {
+ root := t.TempDir()
+ elsewhere := t.TempDir()
+ os.Symlink(elsewhere, filepath.Join(root, "linked.service.d"))
+ if err := ensureDropInDir(root, "linked.service"); err == nil || !strings.Contains(err.Error(), "symlink") {
+ t.Fatalf("ensureDropInDir(symlink) = %v, want a symlink refusal", err)
+ }
+ os.WriteFile(filepath.Join(root, "file.service.d"), []byte("x"), 0o644)
+ if err := ensureDropInDir(root, "file.service"); err == nil {
+ t.Fatal("ensureDropInDir(regular file) accepted")
+ }
+ if err := ensureDropInDir(root, "fresh.service"); err != nil {
+ t.Fatalf("ensureDropInDir(fresh) = %v", err)
+ }
+ st, _ := os.Lstat(filepath.Join(root, "fresh.service.d"))
+ if !st.IsDir() || st.Mode().Perm() != 0o755 {
+ t.Fatalf("fresh dir mode = %v", st.Mode())
+ }
+}
+
+func TestListMarkedDropInsSkipsSymlinkedDirs(t *testing.T) {
+ root := t.TempDir()
+ elsewhere := t.TempDir()
+ os.WriteFile(filepath.Join(elsewhere, DropInFile), renderDropIn("/e", "h"), 0o644)
+ os.Symlink(elsewhere, filepath.Join(root, "retired.service.d"))
+ os.MkdirAll(filepath.Join(root, "real.service.d"), 0o755)
+ os.WriteFile(filepath.Join(root, "real.service.d", DropInFile), renderDropIn("/e", "h"), 0o644)
+ units, skipped := listMarkedDropIns(root)
+ if len(units) != 1 || units[0] != "real.service" {
+ t.Fatalf("units = %v, want [real.service]", units)
+ }
+ if len(skipped) != 1 || skipped[0] != "retired.service" {
+ t.Fatalf("skipped = %v, want [retired.service]", skipped)
+ }
+ if _, err := os.Stat(filepath.Join(elsewhere, DropInFile)); err != nil {
+ t.Fatal("the file behind the symlink must be untouched")
+ }
+}
+
+func TestRemoveDropIn(t *testing.T) {
+ root := t.TempDir()
+ ensureDropInDir(root, "relay.service")
+ writeDropIn(root, "relay.service", renderDropIn("/e", "h"))
+ if err := removeDropIn(root, "relay.service"); err != nil {
+ t.Fatalf("removeDropIn: %v", err)
+ }
+ if _, exists, _, _ := readDropIn(root, "relay.service"); exists {
+ t.Fatal("drop-in still present")
+ }
+ if _, err := os.Stat(filepath.Join(root, "relay.service.d")); err != nil {
+ t.Fatal("the directory must never be removed")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run 'DropIn|Pending' -count=1`
+Expected: FAIL, `undefined: renderDropIn`.
+
+- [ ] **Step 3: Create `internal/appconfig/dropin.go`**
+
+```go
+// The drop-in a stamped route's unit reads its config through (spec §3.2):
+// <root>/<unit>.d/zz-carlos-env.conf, marker-owned, carrying a reset and
+// ONE EnvironmentFile line; and beside it carlos-env.pending, the record
+// that the unit owes a daemon-reload and (while it is still owned) a
+// restart. Everything here is a pure file operation with no systemd in it,
+// so the pass's protocol (unitsync.go) is testable with a temp dir.
+package appconfig
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+const (
+ // DropInFile sorts after override.conf and every conventionally named
+ // drop-in; with the reset line that ordering is belt and braces.
+ DropInFile = "zz-carlos-env.conf"
+ // PendingFile is not a .conf on purpose: systemd ignores it.
+ PendingFile = "carlos-env.pending"
+ // dropInMarker is the first line of every drop-in this package writes,
+ // and the ONLY evidence it accepts before replacing or removing one.
+ dropInMarker = "# Managed by carlos: config delivery for the route naming this unit."
+)
+
+func dropInDir(root, unit string) string { return filepath.Join(root, unit+".d") }
+
+// renderDropIn: the reset line hands the unit's EnvironmentFile list to
+// carlos whole (spec §3.2, codex R2-8); the second line is the rendered
+// bundle, with the '-' prefix so an absent file is not a start failure.
+func renderDropIn(envDir, host string) []byte {
+ return []byte(dropInMarker + "\n" +
+ "# Edit the route's config with `carlos env` / `carlos secrets`, not this file.\n" +
+ "[Service]\n" +
+ "EnvironmentFile=\n" +
+ "EnvironmentFile=-" + filepath.Join(envDir, host+".env") + "\n")
+}
+
+// ensureDropInDir creates <unit>.d if absent and refuses to use it if it
+// is not a plain directory: a symlinked drop-in directory would let a root
+// write land somewhere else (spec §3.2 rail; codex R1-2, R2-1).
+func ensureDropInDir(root, unit string) error {
+ dir := dropInDir(root, unit)
+ st, err := os.Lstat(dir)
+ switch {
+ case err == nil:
+ if st.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("%s is a symlink; refusing to write through it", dir)
+ }
+ if !st.IsDir() {
+ return fmt.Errorf("%s exists and is not a directory", dir)
+ }
+ return nil
+ case os.IsNotExist(err):
+ return os.Mkdir(dir, 0o755)
+ default:
+ return err
+ }
+}
+
+// dirIsPlain is ensureDropInDir's check without the mkdir, for readers.
+func dirIsPlain(dir string) bool {
+ st, err := os.Lstat(dir)
+ return err == nil && st.IsDir() && st.Mode()&os.ModeSymlink == 0
+}
+
+func readDropIn(root, unit string) (cur []byte, exists, marked bool, err error) {
+ cur, err = os.ReadFile(filepath.Join(dropInDir(root, unit), DropInFile))
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, false, false, nil
+ }
+ return nil, false, false, err
+ }
+ first, _, _ := bytes.Cut(cur, []byte("\n"))
+ return cur, true, string(first) == dropInMarker, nil
+}
+
+func writeDropIn(root, unit string, rendered []byte) error {
+ path := filepath.Join(dropInDir(root, unit), DropInFile)
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, rendered, 0o644); err != nil {
+ return err
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ os.Remove(tmp)
+ return err
+ }
+ return nil
+}
+
+func removeDropIn(root, unit string) error {
+ err := os.Remove(filepath.Join(dropInDir(root, unit), DropInFile))
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+}
+
+func pendingPath(root, unit string) string { return filepath.Join(dropInDir(root, unit), PendingFile) }
+
+func writePending(root, unit string) error {
+ return os.WriteFile(pendingPath(root, unit), []byte("reload and restart owed\n"), 0o644)
+}
+
+func removePending(root, unit string) error {
+ err := os.Remove(pendingPath(root, unit))
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+}
+
+func hasPending(root, unit string) bool {
+ _, err := os.Lstat(pendingPath(root, unit))
+ return err == nil
+}
+
+// unitDirs lists <unit> for every plain <unit>.d directory under root;
+// symlinked or non-directory entries are returned in skipped and never
+// entered (codex R2-1).
+func unitDirs(root string) (units, skipped []string) {
+ ents, err := os.ReadDir(root)
+ if err != nil {
+ return nil, nil
+ }
+ for _, e := range ents {
+ name := e.Name()
+ if !strings.HasSuffix(name, ".service.d") {
+ continue
+ }
+ unit := strings.TrimSuffix(name, ".d")
+ if !dirIsPlain(filepath.Join(root, name)) {
+ skipped = append(skipped, unit)
+ continue
+ }
+ units = append(units, unit)
+ }
+ sort.Strings(units)
+ sort.Strings(skipped)
+ return units, skipped
+}
+
+// listMarkedDropIns: units whose plain <unit>.d holds a marker-bearing
+// drop-in. An unmarked file at the path is not carlos's and is not listed.
+func listMarkedDropIns(root string) (units, skipped []string) {
+ dirs, skipped := unitDirs(root)
+ for _, u := range dirs {
+ if _, exists, marked, err := readDropIn(root, u); err == nil && exists && marked {
+ units = append(units, u)
+ }
+ }
+ return units, skipped
+}
+
+// listPending: units whose plain <unit>.d holds a pending marker.
+func listPending(root string) []string {
+ dirs, _ := unitDirs(root)
+ var out []string
+ for _, u := range dirs {
+ if hasPending(root, u) {
+ out = append(out, u)
+ }
+ }
+ return out
+}
+
+var errNotOurs = errors.New("not carlos's file")
+```
+
+(`errNotOurs` is used by Task 4.)
+
+- [ ] **Step 4: Run the tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run 'DropIn|Pending' -count=1 && go vet ./internal/appconfig/ && gofmt -l internal/appconfig/`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/appconfig/dropin.go internal/appconfig/dropin_test.go
+git commit -m "appconfig: the marker-owned drop-in and pending marker as pure file operations
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: `UnitOps`, the lock, the rail, and preparation of stamped routes
+
+**Files:**
+- Create: `internal/appconfig/unitsync.go`
+- Create: `internal/appconfig/unitsync_test.go`
+- Modify: `internal/appconfig/sync.go:42-180` (`Sync` becomes a wrapper around `SyncUnits`; the route loop uses `unitDelivered` and dispatches stamped routes)
+
+**Interfaces:**
+- Produces:
+ ```go
+ type UnitState struct { ID, LoadState string; EnvFiles []string }
+ type UnitOps struct {
+ DropInRoot string
+ Lock func() (unlock func(), err error)
+ Probe func(units []string) (map[string]UnitState, error)
+ Reload func() error
+ Restart func(unit string) error
+ }
+ func SyncUnits(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, ops *UnitOps) error
+ func Sync(ctx, reg, s, priv, envDir, restart) error // == SyncUnits(..., nil)
+ ```
+- Consumes: Task 1 rails, Task 2 `prepareHost`/`commitHost`, Task 3 file helpers.
+- Task 5 adds the reload/verify/restart half inside the same `SyncUnits`; this task ends with stamped routes prepared and marked, no reload or restart yet.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/appconfig/unitsync_test.go`:
+
+```go
+package appconfig
+
+import (
+ "context"
+ "crypto/ecdh"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// fakeUnits is the UnitOps seam with everything recorded: what was probed,
+// reloaded and restarted, and what the probe answers.
+type fakeUnits struct {
+ root string
+ states map[string]UnitState
+ probeErr error
+ reloadErr error
+ restartErr map[string]error
+ lockErr error
+ probed [][]string
+ reloads int
+ restarted []string
+}
+
+func newFakeUnits(t *testing.T) *fakeUnits {
+ t.Helper()
+ return &fakeUnits{root: t.TempDir(), states: map[string]UnitState{}, restartErr: map[string]error{}}
+}
+
+// loaded answers the probe for a healthy unit whose effective env list is
+// exactly carlos's file — the steady state after a successful pass.
+func (f *fakeUnits) loaded(unit, envDir, host string) {
+ f.states[unit] = UnitState{ID: unit, LoadState: "loaded", EnvFiles: []string{filepath.Join(envDir, host+".env")}}
+}
+
+func (f *fakeUnits) ops() *UnitOps {
+ return &UnitOps{
+ DropInRoot: f.root,
+ Lock: func() (func(), error) {
+ if f.lockErr != nil {
+ return nil, f.lockErr
+ }
+ return func() {}, nil
+ },
+ Probe: func(units []string) (map[string]UnitState, error) {
+ f.probed = append(f.probed, append([]string(nil), units...))
+ if f.probeErr != nil {
+ return nil, f.probeErr
+ }
+ out := map[string]UnitState{}
+ for _, u := range units {
+ if st, ok := f.states[u]; ok {
+ out[u] = st
+ } else {
+ out[u] = UnitState{ID: u, LoadState: "not-found"}
+ }
+ }
+ return out, nil
+ },
+ Reload: func() error { f.reloads++; return f.reloadErr },
+ Restart: func(unit string) error {
+ f.restarted = append(f.restarted, unit)
+ return f.restartErr[unit]
+ },
+ }
+}
+
+// unitKit: a store with one configured app "relay" (LOG_LEVEL=debug), a
+// registry, an env dir and the secrets key.
+func unitKit(t *testing.T) (ctx context.Context, s release.Store, reg *registry.Registry, envDir string, priv *ecdh.PrivateKey) {
+ t.Helper()
+ s = release.NewDirStore(t.TempDir())
+ ctx = context.Background()
+ reg = testRegistry(t)
+ envDir = t.TempDir()
+ p, _, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ priv, err = ParsePrivateKey(p, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"LOG_LEVEL": "debug"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ return
+}
+
+func upsertStamped(t *testing.T, reg *registry.Registry, host, unit string, mut func(*registry.Route)) {
+ t.Helper()
+ rt := registry.Route{Host: host, Kind: "instance", App: "relay", Account: "bab",
+ Status: "ready", Addr: "127.0.0.1:8451", Unit: unit}
+ if mut != nil {
+ mut(&rt)
+ }
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+}
+
+func noHostRestart(t *testing.T) func(string) error {
+ return func(h string) error { t.Fatalf("host-keyed restart called for %s; a stamped route restarts by unit", h); return nil }
+}
+
+func TestSyncUnitsPreparesAStampedRoute(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded"}
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+
+ if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ got, err := os.ReadFile(filepath.Join(envDir, "relay.example.com.env"))
+ if err != nil || string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" {
+ t.Fatalf("env file = %q, %v", got, err)
+ }
+ cur, exists, marked, _ := readDropIn(f.root, "relay.service")
+ if !exists || !marked || string(cur) != string(renderDropIn(envDir, "relay.example.com")) {
+ t.Fatalf("drop-in exists=%v marked=%v body=%q", exists, marked, cur)
+ }
+ if len(f.probed) < 1 || strings.Join(f.probed[0], ",") != "relay.service" {
+ t.Fatalf("probed = %v, want [[relay.service]] first", f.probed)
+ }
+}
+
+func TestSyncUnitsWithNilOpsSkipsStampedRoutes(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ out := captureStdout(t, func() {
+ if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), nil); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ })
+ if _, err := os.Stat(filepath.Join(envDir, "relay.example.com.env")); !os.IsNotExist(err) {
+ t.Fatal("a pass with no unit ops must not render for a stamped route")
+ }
+ if !strings.Contains(out, "relay.example.com") || !strings.Contains(out, "no unit ops") {
+ t.Fatalf("expected a skip line naming the host, got %q", out)
+ }
+}
+
+func TestSyncUnitsDoesNothingWithoutTheLock(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.lockErr = errors.New("held elsewhere")
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ if err := reg.Upsert(registry.Route{Host: "km.example.com", Kind: "instance", App: "relay", Account: "bab",
+ Status: "ready", Backing: registry.BackingUnit}); err != nil {
+ t.Fatal(err)
+ }
+ restarted := 0
+ if err := SyncUnits(ctx, reg, s, priv, envDir, func(string) error { restarted++; return nil }, f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ ents, _ := os.ReadDir(envDir)
+ if len(ents) != 0 || restarted != 0 || len(f.probed) != 0 {
+ t.Fatalf("a pass that cannot lock must touch nothing: files=%d restarts=%d probes=%d", len(ents), restarted, len(f.probed))
+ }
+}
+
+func TestSyncUnitsRefusesBadShapesNamesAndHosts(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ for _, u := range []string{"sock.service", "hib.service", "carlos-app@.service", "carlos-.service", "alias.service", "gone.service"} {
+ f.states[u] = UnitState{ID: u, LoadState: "loaded"}
+ }
+ f.states["alias.service"] = UnitState{ID: "real.service", LoadState: "loaded"}
+ f.states["gone.service"] = UnitState{ID: "gone.service", LoadState: "not-found"}
+ upsertStamped(t, reg, "sock.example.com", "sock.service", func(r *registry.Route) { r.Socket = "/run/x.sock"; r.Addr = "" })
+ upsertStamped(t, reg, "hib.example.com", "hib.service", func(r *registry.Route) { r.Hibernate = true })
+ upsertStamped(t, reg, "tmpl.example.com", "carlos-app@.service", nil)
+ upsertStamped(t, reg, "pref.example.com", "carlos-.service", nil)
+ upsertStamped(t, reg, "alias.example.com", "alias.service", nil)
+ upsertStamped(t, reg, "gone.example.com", "gone.service", nil)
+
+ out := captureStdout(t, func() {
+ if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ })
+ ents, _ := os.ReadDir(envDir)
+ if len(ents) != 0 {
+ t.Fatalf("refused routes must render nothing, got %v", ents)
+ }
+ if dirs, _ := os.ReadDir(f.root); len(dirs) != 0 {
+ t.Fatalf("refused routes must get no drop-in directory, got %v", dirs)
+ }
+ for _, want := range []string{"sock.example.com", "hib.example.com", "tmpl.example.com", "pref.example.com",
+ "alias.example.com", "real.service", "gone.example.com"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("refusal output lacks %q:\n%s", want, out)
+ }
+ }
+ // Only the routes that passed the shape and name rails reach the probe.
+ if len(f.probed) != 1 || strings.Join(f.probed[0], ",") != "alias.service,gone.service" {
+ t.Fatalf("probed = %v, want the two rail-passing units", f.probed)
+ }
+}
+
+func TestSyncUnitsRefusesTwoRoutesOnOneUnit(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded"}
+ upsertStamped(t, reg, "a.example.com", "relay.service", nil)
+ upsertStamped(t, reg, "b.example.com", "relay.service", nil)
+ out := captureStdout(t, func() {
+ if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ })
+ if ents, _ := os.ReadDir(envDir); len(ents) != 0 {
+ t.Fatalf("neither route may be delivered, got %v", ents)
+ }
+ if !strings.Contains(out, "a.example.com") || !strings.Contains(out, "b.example.com") {
+ t.Fatalf("both hosts must be named:\n%s", out)
+ }
+}
+
+func TestSyncUnitsRefusesAnUnmarkedDropIn(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded"}
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ os.MkdirAll(dropInDir(f.root, "relay.service"), 0o755)
+ foreign := []byte("[Service]\nEnvironmentFile=/x\n")
+ os.WriteFile(filepath.Join(dropInDir(f.root, "relay.service"), DropInFile), foreign, 0o644)
+ out := captureStdout(t, func() {
+ if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ })
+ cur, _, _, _ := readDropIn(f.root, "relay.service")
+ if string(cur) != string(foreign) {
+ t.Fatal("an unmarked file must never be replaced")
+ }
+ if hasPending(f.root, "relay.service") {
+ t.Fatal("no marker may be written for a blocked route")
+ }
+ if !strings.Contains(out, "not carlos's file") {
+ t.Fatalf("expected the blocked line, got %q", out)
+ }
+}
+
+func TestSyncUnitsWritesMarkerBeforeFiles(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded"}
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ // Make the env dir unwritable so the env write fails after the marker.
+ if os.Geteuid() == 0 {
+ t.Skip("root ignores directory modes")
+ }
+ os.Chmod(envDir, 0o500)
+ t.Cleanup(func() { os.Chmod(envDir, 0o700) })
+ out := captureStdout(t, func() {
+ if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ })
+ if !hasPending(f.root, "relay.service") {
+ t.Fatal("the marker records intent before mutation and must survive a failed write")
+ }
+ if len(f.restarted) != 0 {
+ t.Fatal("a failed preparation must not restart")
+ }
+ if !strings.Contains(out, "relay.example.com") {
+ t.Fatalf("expected a failure line, got %q", out)
+ }
+}
+
+func TestSyncUnitsCarlosAppRoutesUnchanged(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ if err := reg.Upsert(registry.Route{Host: "km.example.com", Kind: "instance", App: "relay", Account: "bab",
+ Status: "ready", Backing: registry.BackingUnit}); err != nil {
+ t.Fatal(err)
+ }
+ var restarted []string
+ if err := SyncUnits(ctx, reg, s, priv, envDir, func(h string) error { restarted = append(restarted, h); return nil }, f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ if len(restarted) != 1 || restarted[0] != "km.example.com" {
+ t.Fatalf("restarted = %v, want [km.example.com]", restarted)
+ }
+ if len(f.probed) != 0 || len(f.restarted) != 0 {
+ t.Fatal("a carlos-app@ route must not touch the unit ops")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run TestSyncUnits -count=1`
+Expected: FAIL, `undefined: SyncUnits`.
+
+- [ ] **Step 3: Create `internal/appconfig/unitsync.go`**
+
+```go
+// The stamped half of the root config pass (spec §3.2): for a route that
+// names its unit, render the env file, make the unit read it through a
+// marker-owned drop-in, and restart the unit through a pending marker that
+// survives any failure between the two. Everything that touches systemd
+// comes in through UnitOps, so the whole protocol runs in tests against a
+// temp dir and a fake.
+package appconfig
+
+import (
+ "fmt"
+ "path/filepath"
+ "sort"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// UnitState is one unit's answer to `systemctl show -p Id,LoadState,
+// EnvironmentFiles`. EnvFiles are paths only, in order, with systemd's
+// "(ignore_errors=yes)" annotation already stripped by the prober.
+type UnitState struct {
+ ID string
+ LoadState string
+ EnvFiles []string
+}
+
+// UnitOps is the root pass's seam onto systemd and the filesystem it may
+// not touch in tests. A nil *UnitOps means "this pass has no unit ops":
+// stamped routes are skipped with a log line, carlos-app@ routes are
+// delivered exactly as before.
+type UnitOps struct {
+ // DropInRoot is /etc/systemd/system in production.
+ DropInRoot string
+ // Lock serialises config passes across processes (spec §3.2, codex
+ // R2-3). An error means another pass holds it: do nothing this pass.
+ Lock func() (unlock func(), err error)
+ // Probe answers Id, LoadState and EnvironmentFiles for every unit in
+ // one systemctl invocation. A missing unit answers LoadState
+ // "not-found". An error means the probe itself failed and nothing
+ // about any unit is known this pass.
+ Probe func(units []string) (map[string]UnitState, error)
+ // Reload is `systemctl daemon-reload`.
+ Reload func() error
+ // Restart is `systemctl restart <unit>` — restart, not try-restart:
+ // a marked unit owes a restart even when it is inactive.
+ Restart func(unit string) error
+}
+
+// stampedPlan is what the pass decided about the stamped routes on this
+// box before touching anything: which unit each delivered host maps to,
+// and why each refused host was refused.
+type stampedPlan struct {
+ // unitOf maps a delivered host to its (canonical) unit.
+ unitOf map[string]string
+ // refused maps a host to its reason; printed per route in the loop.
+ refused map[string]string
+ // probe is the pre-mutation probe answer for every delivered unit.
+ probe map[string]UnitState
+}
+
+// planStamped applies the rail (spec §3.2) to every stamped route in
+// routes, in this order: shape, unit name, hostname, one-route-per-unit,
+// then the batched systemd probe (loaded, and the stamp is the canonical
+// name). Refusals are collected, never printed here: the route loop prints
+// them beside the route so a log reads in one place.
+func planStamped(routes []registry.Route, ops *UnitOps) stampedPlan {
+ p := stampedPlan{unitOf: map[string]string{}, refused: map[string]string{}, probe: map[string]UnitState{}}
+ byUnit := map[string][]string{}
+ for _, rt := range routes {
+ if !Stamped(rt) {
+ continue
+ }
+ if err := StampShape(rt); err != nil {
+ p.refused[rt.Host] = err.Error()
+ continue
+ }
+ if err := DeliverableUnitName(rt.Unit); err != nil {
+ p.refused[rt.Host] = err.Error()
+ continue
+ }
+ if !BareHostname(rt.Host) {
+ p.refused[rt.Host] = fmt.Sprintf("%q is not a bare hostname; refusing to write it into a systemd drop-in", rt.Host)
+ continue
+ }
+ byUnit[rt.Unit] = append(byUnit[rt.Unit], rt.Host)
+ }
+ var units []string
+ for unit, hosts := range byUnit {
+ if len(hosts) > 1 {
+ sort.Strings(hosts)
+ for _, h := range hosts {
+ p.refused[h] = fmt.Sprintf("two or more routes name %s (%v); one unit takes one route's config", unit, hosts)
+ }
+ continue
+ }
+ units = append(units, unit)
+ }
+ sort.Strings(units)
+ if len(units) == 0 {
+ return p
+ }
+ states, err := ops.Probe(units)
+ if err != nil {
+ for _, unit := range units {
+ p.refused[byUnit[unit][0]] = fmt.Sprintf("systemd probe failed (%v); nothing is known about %s this pass", err, unit)
+ }
+ return p
+ }
+ for _, unit := range units {
+ host := byUnit[unit][0]
+ st, ok := states[unit]
+ switch {
+ case !ok || st.LoadState != "loaded":
+ p.refused[host] = fmt.Sprintf("%s is not loaded by systemd (%s); install the unit and the route converges on the next pass", unit, st.LoadState)
+ case st.ID != unit:
+ p.refused[host] = fmt.Sprintf("%s is an alias of %s; stamp the canonical unit name", unit, st.ID)
+ default:
+ p.unitOf[host] = unit
+ p.probe[unit] = st
+ }
+ }
+ return p
+}
+
+// unitWork is what one delivered unit's preparation left behind, for the
+// reload/restart half to act on.
+type unitWork struct {
+ host string
+ // ok: both files converged this pass (changed or unchanged). False
+ // means a refusal or write failure was printed and this unit may be
+ // reloaded but must not be restarted this pass.
+ ok bool
+}
+
+// prepareStamped converges one stamped route's env file and drop-in
+// (spec §3.2 step 1): compare both first, write the pending marker before
+// any mutation, then the env file, then the drop-in. Returns ok as
+// unitWork.ok describes.
+func prepareStamped(rt registry.Route, unit string, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, ops *UnitOps) unitWork {
+ w := unitWork{host: rt.Host}
+ path, rendered, cur, existed, ok := prepareHost(rt, m, defaultMissing, priv, envDir)
+ if !ok {
+ return w
+ }
+ if err := ensureDropInDir(ops.DropInRoot, unit); err != nil {
+ fmt.Printf(" %s: drop-in directory for %s: %v\n", rt.Host, unit, err)
+ return w
+ }
+ curDrop, dropExists, marked, err := readDropIn(ops.DropInRoot, unit)
+ if err != nil {
+ fmt.Printf(" %s: read drop-in for %s: %v\n", rt.Host, unit, err)
+ return w
+ }
+ if dropExists && !marked {
+ fmt.Printf(" %s: %s holds a %s that is %v; delivery is blocked until it is moved\n",
+ rt.Host, dropInDir(ops.DropInRoot, unit), DropInFile, errNotOurs)
+ return w
+ }
+ wantDrop := renderDropIn(envDir, rt.Host)
+ envChanged := !(existed && bytes.Equal(cur, rendered))
+ dropChanged := !(dropExists && bytes.Equal(curDrop, wantDrop))
+ if !envChanged && !dropChanged {
+ w.ok = true
+ return w
+ }
+ if err := writePending(ops.DropInRoot, unit); err != nil {
+ fmt.Printf(" %s: record pending restart for %s: %v (nothing written)\n", rt.Host, unit, err)
+ return w
+ }
+ if envChanged && !commitHost(rt.Host, path, rendered) {
+ return w
+ }
+ if dropChanged {
+ if err := writeDropIn(ops.DropInRoot, unit, wantDrop); err != nil {
+ fmt.Printf(" %s: write drop-in for %s: %v\n", rt.Host, unit, err)
+ return w
+ }
+ fmt.Printf(" %s: drop-in written for %s\n", rt.Host, unit)
+ }
+ if envChanged {
+ fmt.Printf(" %s: config updated for %s\n", rt.Host, unit)
+ }
+ w.ok = true
+ return w
+}
+```
+
+Add `"bytes"`, `"crypto/ecdh"` to the imports.
+
+- [ ] **Step 4: Rework `Sync` into `SyncUnits`**
+
+In `internal/appconfig/sync.go`:
+
+1. Rename the existing `func Sync(...)` to `func SyncUnits(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, ops *UnitOps) error` and add above it:
+
+```go
+// Sync is SyncUnits with no unit ops: the carlos-app@ half only. Kept as
+// the name every existing test and the docs use; production (RunAdopt)
+// calls SyncUnits with the real ops.
+func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) error {
+ return SyncUnits(ctx, reg, s, priv, envDir, restart, nil)
+}
+```
+
+2. At the top of `SyncUnits`, after `routes, err := reg.List()`:
+
+```go
+ if ops != nil && ops.Lock != nil {
+ unlock, err := ops.Lock()
+ if err != nil {
+ fmt.Printf(" config sync: another pass holds the lock (%v); nothing touched this pass\n", err)
+ return nil
+ }
+ defer unlock()
+ }
+ var plan stampedPlan
+ if ops != nil {
+ plan = planStamped(routes, ops)
+ }
+ work := map[string]unitWork{} // by unit
+```
+
+3. In both route loops, replace `if !syncable(rt) {` with `if !unitDelivered(rt) {`.
+
+4. In the second loop, replace the block from `// Only unit-backed routes read /etc/carlos/apps` through `syncHost(rt, m, r.defaultMissing, priv, envDir, restart)` with:
+
+```go
+ if Stamped(rt) {
+ // A stamped route is delivered here, by unit, never through the
+ // host-keyed restart callback (spec §3.2, codex R1-12).
+ if ops == nil {
+ fmt.Printf(" %s: stamped %s — no unit ops on this pass, skipped\n", rt.Host, rt.Unit)
+ continue
+ }
+ if reason, bad := plan.refused[rt.Host]; bad {
+ fmt.Printf(" %s: delivery blocked: %s\n", rt.Host, reason)
+ continue
+ }
+ unit := plan.unitOf[rt.Host]
+ work[unit] = prepareStamped(rt, unit, m, r.defaultMissing, priv, envDir, ops)
+ continue
+ }
+ // Only unit-backed routes read /etc/carlos/apps — an exec-backed
+ // route's env comes from <DataDir>/<host>.env, which the activator
+ // owns (it also carries CARLOS_ADMIN_TOKEN, so it can't be blindly
+ // overwritten here). Materializing for one would write a file
+ // nothing reads and log a restart that restarted nothing; say so
+ // instead of succeeding silently. Spec §5 records this limitation.
+ // Delivery still happens for these routes — SyncExec, called from
+ // the agent's in-process configSyncLoop, not this pass.
+ if rt.Backing != registry.BackingUnit {
+ fmt.Printf(" %s: exec-backed — config delivered by the agent's in-process loop to /data/<host>.config.env, not this pass\n", rt.Host)
+ continue
+ }
+ syncHost(rt, m, r.defaultMissing, priv, envDir, restart)
+```
+
+Note the unresolved-resolution branch above this (`r.err != nil`) already prints and `continue`s for stamped routes too, and a stamped route whose app was never configured hits `if m == nil { continue }` before this block, so it gets no drop-in (spec §4).
+
+5. Keep `_ = work` at the end of the loop for now (Task 5 consumes it), so the build is clean.
+
+- [ ] **Step 5: Run the tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -count=1 && go vet ./internal/appconfig/ && gofmt -l internal/appconfig/`
+Expected: PASS for all, including every pre-existing `Sync` test.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/appconfig/unitsync.go internal/appconfig/unitsync_test.go internal/appconfig/sync.go
+git commit -m "appconfig: SyncUnits prepares stamped routes behind a lock and the root-write rail
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: Reload, verify, restart, and drift
+
+**Files:**
+- Modify: `internal/appconfig/unitsync.go` (add `applyStamped`)
+- Modify: `internal/appconfig/sync.go` (call `applyStamped` after the route loop, before env reaping)
+- Test: `internal/appconfig/unitsync_test.go`
+
+**Interfaces:**
+- Produces: `func applyStamped(envDir string, ops *UnitOps, plan stampedPlan, work map[string]unitWork)`; `func envFilesMatch(got []string, want string) bool`.
+- Consumes: Task 4's `stampedPlan`, `unitWork`, Task 3's marker helpers.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/appconfig/unitsync_test.go`:
+
+```go
+// syncOnce runs one pass and returns its output.
+func syncOnce(t *testing.T, ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, f *fakeUnits) string {
+ t.Helper()
+ return captureStdout(t, func() {
+ if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ })
+}
+
+func TestSyncUnitsReloadsVerifiesRestartsAndClears(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.loaded("relay.service", envDir, "relay.example.com")
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if f.reloads != 1 {
+ t.Fatalf("reloads = %d, want 1", f.reloads)
+ }
+ if len(f.restarted) != 1 || f.restarted[0] != "relay.service" {
+ t.Fatalf("restarted = %v, want [relay.service]", f.restarted)
+ }
+ if hasPending(f.root, "relay.service") {
+ t.Fatal("marker must be cleared after a successful restart")
+ }
+ // Post-reload verification probes the marked unit again.
+ if len(f.probed) != 2 {
+ t.Fatalf("probed = %v, want the rail probe and the post-reload probe", f.probed)
+ }
+
+ // Steady state: nothing happens.
+ f.reloads, f.restarted = 0, nil
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if f.reloads != 0 || len(f.restarted) != 0 {
+ t.Fatalf("second pass: reloads=%d restarted=%v, want none", f.reloads, f.restarted)
+ }
+
+ // A config change marks and restarts again.
+ if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"LOG_LEVEL": "info"}); err != nil {
+ t.Fatal(err)
+ }
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if f.reloads != 1 || len(f.restarted) != 1 {
+ t.Fatalf("after change: reloads=%d restarted=%v", f.reloads, f.restarted)
+ }
+}
+
+func TestSyncUnitsReloadFailureKeepsMarkerAndRetries(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.loaded("relay.service", envDir, "relay.example.com")
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ f.reloadErr = errors.New("busy")
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if len(f.restarted) != 0 || !hasPending(f.root, "relay.service") {
+ t.Fatalf("reload failure: restarted=%v pending=%v", f.restarted, hasPending(f.root, "relay.service"))
+ }
+ f.reloadErr = nil
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if len(f.restarted) != 1 || hasPending(f.root, "relay.service") {
+ t.Fatalf("retry with identical bytes: restarted=%v pending=%v", f.restarted, hasPending(f.root, "relay.service"))
+ }
+}
+
+func TestSyncUnitsRestartFailureKeepsMarkerAndRetries(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.loaded("relay.service", envDir, "relay.example.com")
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ f.restartErr["relay.service"] = errors.New("exit 1")
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if !hasPending(f.root, "relay.service") {
+ t.Fatal("restart failure must keep the marker")
+ }
+ delete(f.restartErr, "relay.service")
+ f.restarted = nil
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if len(f.restarted) != 1 || hasPending(f.root, "relay.service") {
+ t.Fatalf("retry: restarted=%v pending=%v", f.restarted, hasPending(f.root, "relay.service"))
+ }
+}
+
+func TestSyncUnitsVerificationMismatchBlocks(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ // Loaded, but the effective list has a legacy file first: a later
+ // drop-in re-added it. "Ends with carlos's file" is not enough.
+ f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded",
+ EnvFiles: []string{"/data/relay.example.com.config.env", filepath.Join(envDir, "relay.example.com.env")}}
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ out := syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if len(f.restarted) != 0 || !hasPending(f.root, "relay.service") {
+ t.Fatalf("mismatch: restarted=%v pending=%v", f.restarted, hasPending(f.root, "relay.service"))
+ }
+ if !strings.Contains(out, "blocked") || !strings.Contains(out, "/data/relay.example.com.config.env") {
+ t.Fatalf("expected a blocked line naming the actual list, got %q", out)
+ }
+}
+
+func TestSyncUnitsDriftIsReportedEveryPassAndNeverRestarted(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.loaded("relay.service", envDir, "relay.example.com")
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ syncOnce(t, ctx, reg, s, priv, envDir, f) // delivered, cleared
+ f.restarted, f.reloads = nil, 0
+ // An operator's later drop-in took the list back.
+ f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded", EnvFiles: []string{"/etc/other.env"}}
+ out := syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if len(f.restarted) != 0 || f.reloads != 0 {
+ t.Fatalf("drift must not restart or reload: restarted=%v reloads=%d", f.restarted, f.reloads)
+ }
+ if !strings.Contains(out, "blocked") || !strings.Contains(out, "/etc/other.env") {
+ t.Fatalf("expected a drift line naming the actual list, got %q", out)
+ }
+ if hasPending(f.root, "relay.service") {
+ t.Fatal("drift writes no marker")
+ }
+}
+
+func TestSyncUnitsRefusedRouteKeepsAnEarlierMarker(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.loaded("relay.service", envDir, "relay.example.com")
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ f.reloadErr = errors.New("busy")
+ syncOnce(t, ctx, reg, s, priv, envDir, f) // marker left behind
+ f.reloadErr = nil
+ // Now the route is refused (a second route names the unit).
+ upsertStamped(t, reg, "b.example.com", "relay.service", nil)
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if len(f.restarted) != 0 || !hasPending(f.root, "relay.service") {
+ t.Fatalf("refused route: restarted=%v pending=%v, want no restart and the marker kept", f.restarted, hasPending(f.root, "relay.service"))
+ }
+}
+
+func TestEnvFilesMatch(t *testing.T) {
+ want := "/etc/carlos/apps/h.env"
+ if !envFilesMatch([]string{want}, want) {
+ t.Fatal("exact single match must pass")
+ }
+ for _, bad := range [][]string{nil, {}, {"/x", want}, {want, "/x"}, {"/x"}} {
+ if envFilesMatch(bad, want) {
+ t.Fatalf("%v must not match", bad)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run 'TestSyncUnits|TestEnvFilesMatch' -count=1`
+Expected: FAIL (`undefined: envFilesMatch`, and the reload/restart tests fail on counts).
+
+- [ ] **Step 3: Add `applyStamped` to `internal/appconfig/unitsync.go`**
+
+```go
+// envFilesMatch: the unit's effective EnvironmentFile list must be EXACTLY
+// carlos's one file (spec §3.2 step 4, codex R3-3). 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 resurrect an unset key.
+func envFilesMatch(got []string, want string) bool {
+ return len(got) == 1 && got[0] == want
+}
+
+// applyStamped is spec §3.2 steps 3 to 7: one reload if anything is
+// pending, then per marked unit verify and restart, then drift for the
+// rest. Order matters: reload before any verification, verification
+// before any restart, and a unit that failed or was refused this pass is
+// never restarted however old its marker.
+func applyStamped(envDir string, ops *UnitOps, plan stampedPlan, work map[string]unitWork) {
+ pending := listPending(ops.DropInRoot)
+ if len(pending) > 0 {
+ if err := ops.Reload(); err != nil {
+ for _, unit := range pending {
+ fmt.Printf(" %s: daemon-reload failed (%v); reload and restart still owed\n", unit, err)
+ }
+ return
+ }
+ }
+ marked := map[string]bool{}
+ for _, unit := range pending {
+ marked[unit] = true
+ w, prepared := work[unit]
+ _, exists, isMarked, _ := readDropIn(ops.DropInRoot, unit)
+ switch {
+ case prepared && !w.ok:
+ // Prepared this pass and something failed (env write, drop-in
+ // write): the marker is intent that has not been met. Keep it
+ // whether or not a drop-in exists yet (codex R3-1).
+ fmt.Printf(" %s: restart still owed, but delivery to %s failed this pass; retrying next pass\n", unit, w.host)
+ continue
+ case !exists || !isMarked:
+ // Reaped (or never ours): the reload it owed has happened; it
+ // is not owned and is never restarted from here.
+ if err := removePending(ops.DropInRoot, unit); err != nil {
+ fmt.Printf(" %s: clear pending marker: %v\n", unit, err)
+ }
+ continue
+ case !prepared:
+ fmt.Printf(" %s: restart still owed, but its route was refused this pass; retrying next pass\n", unit)
+ continue
+ }
+ states, err := ops.Probe([]string{unit})
+ if err != nil {
+ fmt.Printf(" %s: post-reload probe failed (%v); restart still owed\n", unit, err)
+ continue
+ }
+ want := filepath.Join(envDir, w.host+".env")
+ if !envFilesMatch(states[unit].EnvFiles, want) {
+ fmt.Printf(" %s: delivery blocked: %s reads %v after reload, want exactly [%s]; a later drop-in owns the EnvironmentFile list\n",
+ w.host, unit, states[unit].EnvFiles, want)
+ continue
+ }
+ if err := ops.Restart(unit); err != nil {
+ fmt.Printf(" %s: config updated but restart of %s failed: %v; restart still owed\n", w.host, unit, err)
+ continue
+ }
+ if err := removePending(ops.DropInRoot, unit); err != nil {
+ fmt.Printf(" %s: clear pending marker for %s: %v\n", w.host, unit, err)
+ continue
+ }
+ fmt.Printf(" %s: config updated, %s restarted\n", w.host, unit)
+ }
+ // Drift (step 7): an unmarked, delivered unit whose pre-mutation probe
+ // shows anything but carlos's file is running what the operator
+ // configured. Say so every pass; never fight it.
+ units := make([]string, 0, len(work))
+ for unit := range work {
+ units = append(units, unit)
+ }
+ sort.Strings(units)
+ for _, unit := range units {
+ w := work[unit]
+ if marked[unit] || !w.ok {
+ continue
+ }
+ want := filepath.Join(envDir, w.host+".env")
+ if st, ok := plan.probe[unit]; ok && !envFilesMatch(st.EnvFiles, want) {
+ fmt.Printf(" %s: delivery blocked: %s reads %v, want exactly [%s]; a later drop-in owns the EnvironmentFile list\n",
+ w.host, unit, st.EnvFiles, want)
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Call it from `SyncUnits`**
+
+In `internal/appconfig/sync.go`, replace `_ = work` with, placed AFTER the route loop and BEFORE the `if len(routes) == 0 { return nil }` guard:
+
+```go
+ if ops != nil {
+ applyStamped(envDir, ops, plan, work)
+ }
+```
+
+(Task 6 inserts the drop-in reaper between the route loop and this call.)
+
+- [ ] **Step 5: Run the tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -count=1 && go vet ./internal/appconfig/ && gofmt -l internal/appconfig/`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/appconfig/unitsync.go internal/appconfig/unitsync_test.go internal/appconfig/sync.go
+git commit -m "appconfig: reload, verify exactly one env file, restart marked units, report drift
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: Reaping drop-ins with the reload owed recorded first
+
+**Files:**
+- Modify: `internal/appconfig/unitsync.go` (add `reapDropIns`)
+- Modify: `internal/appconfig/sync.go` (call it after the route loop, before `applyStamped`)
+- Test: `internal/appconfig/unitsync_test.go`
+
+**Interfaces:**
+- Produces: `func reapDropIns(routes []registry.Route, ops *UnitOps)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/appconfig/unitsync_test.go`:
+
+```go
+func TestSyncUnitsReapsDropInForAnUnstampedRouteWithMarkerFirst(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.loaded("relay.service", envDir, "relay.example.com")
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ // Unstamp: the row stays, the unit is no longer named by any route.
+ if err := reg.SetUnit("relay.example.com", ""); err != nil {
+ t.Fatal(err)
+ }
+ f.reloads, f.restarted = 0, nil
+ f.reloadErr = errors.New("busy")
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if _, exists, _, _ := readDropIn(f.root, "relay.service"); exists {
+ t.Fatal("drop-in must be removed for a unit no route names")
+ }
+ if !hasPending(f.root, "relay.service") {
+ t.Fatal("the reload owed must be recorded when the reload fails")
+ }
+ if len(f.restarted) != 0 {
+ t.Fatal("an unowned unit is never restarted")
+ }
+ f.reloadErr = nil
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if hasPending(f.root, "relay.service") || len(f.restarted) != 0 {
+ t.Fatalf("after a good reload: pending=%v restarted=%v", hasPending(f.root, "relay.service"), f.restarted)
+ }
+ if _, err := os.Stat(dropInDir(f.root, "relay.service")); err != nil {
+ t.Fatal("the directory is never removed")
+ }
+ // The env file stays while the row exists (today's reap policy).
+ if _, err := os.Stat(filepath.Join(envDir, "relay.example.com.env")); err != nil {
+ t.Fatal("env file must stay while the route row exists")
+ }
+}
+
+func TestSyncUnitsReaperNeverRemovesUnmarkedOrSymlinked(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ os.MkdirAll(dropInDir(f.root, "foreign.service"), 0o755)
+ foreign := []byte("[Service]\nEnvironmentFile=/x\n")
+ os.WriteFile(filepath.Join(dropInDir(f.root, "foreign.service"), DropInFile), foreign, 0o644)
+ elsewhere := t.TempDir()
+ os.WriteFile(filepath.Join(elsewhere, DropInFile), renderDropIn(envDir, "h"), 0o644)
+ os.Symlink(elsewhere, dropInDir(f.root, "linked.service"))
+ // One unrelated carlos-app@ route so the route list is not empty.
+ if err := reg.Upsert(registry.Route{Host: "km.example.com", Kind: "instance", App: "relay", Account: "bab",
+ Status: "ready", Backing: registry.BackingUnit}); err != nil {
+ t.Fatal(err)
+ }
+ if err := SyncUnits(ctx, reg, s, priv, envDir, func(string) error { return nil }, f.ops()); err != nil {
+ t.Fatal(err)
+ }
+ if cur, _, _, _ := readDropIn(f.root, "foreign.service"); string(cur) != string(foreign) {
+ t.Fatal("unmarked file touched")
+ }
+ if _, err := os.Stat(filepath.Join(elsewhere, DropInFile)); err != nil {
+ t.Fatal("file behind a symlinked directory removed")
+ }
+ if f.reloads != 0 {
+ t.Fatal("nothing carlos owned changed; no reload")
+ }
+}
+
+func TestSyncUnitsEmptyRegistryReapsNoDropIns(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ ensureDropInDir(f.root, "relay.service")
+ writeDropIn(f.root, "relay.service", renderDropIn(envDir, "relay.example.com"))
+ if err := SyncUnits(ctx, reg, s, priv, envDir, func(string) error { return nil }, f.ops()); err != nil {
+ t.Fatal(err)
+ }
+ if _, exists, _, _ := readDropIn(f.root, "relay.service"); !exists {
+ t.Fatal("an empty route list is never authority to delete")
+ }
+}
+
+func TestSyncUnitsReStampMovesTheDropIn(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.loaded("a.service", envDir, "relay.example.com")
+ f.loaded("b.service", envDir, "relay.example.com")
+ upsertStamped(t, reg, "relay.example.com", "a.service", nil)
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if err := reg.SetUnit("relay.example.com", "b.service"); err != nil {
+ t.Fatal(err)
+ }
+ f.restarted = nil
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if _, exists, _, _ := readDropIn(f.root, "a.service"); exists {
+ t.Fatal("old unit's drop-in must be reaped")
+ }
+ if _, exists, marked, _ := readDropIn(f.root, "b.service"); !exists || !marked {
+ t.Fatal("new unit's drop-in must be written")
+ }
+ if len(f.restarted) != 1 || f.restarted[0] != "b.service" {
+ t.Fatalf("restarted = %v, want [b.service] only", f.restarted)
+ }
+ if hasPending(f.root, "a.service") || hasPending(f.root, "b.service") {
+ t.Fatal("both markers must be cleared after one good reload and restart")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run 'TestSyncUnitsReap|TestSyncUnitsEmptyRegistry|TestSyncUnitsReStamp' -count=1`
+Expected: FAIL (drop-ins are not reaped yet).
+
+- [ ] **Step 3: Add `reapDropIns` to `internal/appconfig/unitsync.go`**
+
+```go
+// reapDropIns (spec §3.2 reaping, step 2): for every marker-bearing drop-in
+// whose unit is named by no route row on the box, record the reload it
+// owes FIRST, then remove the drop-in (codex R3-2). Keyed off the full
+// route list, never the delivered subset; an empty list is never authority
+// to delete anything (the same rule as reap for env files). Unmarked files
+// and symlinked directories are never touched.
+func reapDropIns(routes []registry.Route, ops *UnitOps) {
+ if len(routes) == 0 {
+ return
+ }
+ named := map[string]bool{}
+ for _, rt := range routes {
+ if rt.Unit != "" {
+ named[rt.Unit] = true
+ }
+ }
+ units, skipped := listMarkedDropIns(ops.DropInRoot)
+ for _, unit := range skipped {
+ fmt.Printf(" %s: %s is not a plain directory; not entered\n", unit, dropInDir(ops.DropInRoot, unit))
+ }
+ for _, unit := range units {
+ if named[unit] {
+ continue
+ }
+ if err := writePending(ops.DropInRoot, unit); err != nil {
+ fmt.Printf(" %s: record reload owed: %v (drop-in kept)\n", unit, err)
+ continue
+ }
+ if err := removeDropIn(ops.DropInRoot, unit); err != nil {
+ fmt.Printf(" %s: remove drop-in: %v\n", unit, err)
+ continue
+ }
+ fmt.Printf(" %s: no route names this unit, removed its drop-in; reload owed\n", unit)
+ }
+}
+```
+
+- [ ] **Step 4: Call it from `SyncUnits`**
+
+In `internal/appconfig/sync.go`, change the block added in Task 5 to:
+
+```go
+ if ops != nil {
+ reapDropIns(routes, ops)
+ applyStamped(envDir, ops, plan, work)
+ }
+```
+
+- [ ] **Step 5: Run the tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -count=1 && go vet ./internal/appconfig/ && gofmt -l internal/appconfig/`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/appconfig/unitsync.go internal/appconfig/unitsync_test.go internal/appconfig/sync.go
+git commit -m "appconfig: reap drop-ins for units no route names, recording the reload owed first
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 7: The real `UnitOps` in `internal/host` and the RunAdopt wiring
+
+**Files:**
+- Create: `internal/host/unitops.go`
+- Create: `internal/host/unitops_test.go`
+- Modify: `internal/host/agent.go:2895-2955` (RunAdopt: build ops, call `appconfig.SyncUnits`, rewrite the comment at 2899-2907)
+
+**Interfaces:**
+- Produces: `func productionUnitOps(envDir string) *appconfig.UnitOps`; `func parseSystemctlShow(out []byte, units []string) (map[string]appconfig.UnitState, error)`; `func lockConfigSync(path string) (func(), error)`.
+- Consumes: `appconfig.UnitOps`, `appconfig.UnitState`, `appconfig.SyncUnits`, `lockFileEx`/`unlockFile` (existing, per-OS).
+
+- [ ] **Step 1: Write the failing parser test**
+
+Create `internal/host/unitops_test.go`:
+
+```go
+package host
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestParseSystemctlShowBatched(t *testing.T) {
+ out := []byte("Id=relay.service\nLoadState=loaded\nEnvironmentFiles=/etc/carlos/apps/relay.example.com.env (ignore_errors=yes)\n" +
+ "\n" +
+ "Id=real.service\nLoadState=loaded\nEnvironmentFiles=/data/x.env (ignore_errors=no)\nEnvironmentFiles=/etc/carlos/apps/h.env (ignore_errors=yes)\n" +
+ "\n" +
+ "Id=gone.service\nLoadState=not-found\nEnvironmentFiles=\n")
+ got, err := parseSystemctlShow(out, []string{"relay.service", "alias.service", "gone.service"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if st := got["relay.service"]; st.ID != "relay.service" || st.LoadState != "loaded" ||
+ strings.Join(st.EnvFiles, ",") != "/etc/carlos/apps/relay.example.com.env" {
+ t.Fatalf("relay: %+v", st)
+ }
+ // The second block was asked for as alias.service and answered as
+ // real.service: keyed by the name asked, Id carries the canonical one.
+ if st := got["alias.service"]; st.ID != "real.service" || strings.Join(st.EnvFiles, ",") != "/data/x.env,/etc/carlos/apps/h.env" {
+ t.Fatalf("alias: %+v", st)
+ }
+ if st := got["gone.service"]; st.LoadState != "not-found" || len(st.EnvFiles) != 0 {
+ t.Fatalf("gone: %+v", st)
+ }
+}
+
+func TestParseSystemctlShowCountMismatchIsAnError(t *testing.T) {
+ if _, err := parseSystemctlShow([]byte("Id=a.service\nLoadState=loaded\n"), []string{"a.service", "b.service"}); err == nil {
+ t.Fatal("a short batch cannot be attributed positionally and must be an error")
+ }
+}
+
+func TestParseSystemctlShowSpaceSeparatedEnvFiles(t *testing.T) {
+ // Some systemd versions print the list on one line.
+ out := []byte("Id=a.service\nLoadState=loaded\nEnvironmentFiles=/x.env (ignore_errors=no) /y.env (ignore_errors=yes)\n")
+ got, err := parseSystemctlShow(out, []string{"a.service"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Join(got["a.service"].EnvFiles, ",") != "/x.env,/y.env" {
+ t.Fatalf("EnvFiles = %v", got["a.service"].EnvFiles)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host/ -run TestParseSystemctlShow -count=1`
+Expected: FAIL, `undefined: parseSystemctlShow`.
+
+- [ ] **Step 3: Create `internal/host/unitops.go`**
+
+```go
+// The root pass's real UnitOps (spec §3.2): systemctl for the probe, the
+// reload and the restart; a flock for the pass. Kept in one file, beside
+// the batched-probe shape adopt_advice.go already uses, so what the pass
+// runs on a box is readable in one place.
+package host
+
+import (
+ "bytes"
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+)
+
+// dropInRoot is where systemd looks for a unit's drop-ins whichever tree
+// the unit file itself lives in.
+const dropInRoot = "/etc/systemd/system"
+
+func productionUnitOps(envDir string) *appconfig.UnitOps {
+ return &appconfig.UnitOps{
+ DropInRoot: dropInRoot,
+ Lock: func() (func(), error) { return lockConfigSync(envDir + "/.sync") },
+ Probe: probeUnits,
+ Reload: daemonReload,
+ Restart: systemctlRestartUnit,
+ }
+}
+
+// lockConfigSync is lockRestartMarks's shape for the config pass: an
+// exclusive flock on <path>.lock held for the whole of one SyncUnits, so
+// carlos-adopt.service and carlos-config-sync.service queue rather than
+// interleave inside a config transaction (spec §3.2, codex R2-3).
+func lockConfigSync(path string) (unlock func(), err error) {
+ f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0o644)
+ if err != nil {
+ return nil, err
+ }
+ if err := lockFileEx(f); err != nil {
+ f.Close()
+ return nil, err
+ }
+ return func() { unlockFile(f); f.Close() }, nil
+}
+
+// probeUnits: ONE `systemctl show` for every unit, positional like
+// unitsAreActive. The three properties are the whole rail: LoadState says
+// the unit exists, Id says whether the stamp is its canonical name, and
+// EnvironmentFiles is the effective list the drift check and the
+// post-reload verification compare against.
+func probeUnits(units []string) (map[string]appconfig.UnitState, error) {
+ args := append([]string{"show", "-p", "Id,LoadState,EnvironmentFiles"}, units...)
+ out, err := exec.Command("systemctl", args...).Output()
+ if err != nil && len(out) == 0 {
+ return nil, fmt.Errorf("systemctl show: %w", err)
+ }
+ return parseSystemctlShow(out, units)
+}
+
+// parseSystemctlShow splits `systemctl show` output into per-unit blocks
+// (blank-line separated, in the order asked) and maps them onto units
+// positionally. A block count that does not match cannot be attributed and
+// is an error, never a guess (the same rule as parseIsActiveBatch).
+// EnvironmentFiles may repeat one path per line or list several on one
+// line; each entry is "<path> (ignore_errors=yes|no)" and only the path is
+// kept.
+func parseSystemctlShow(out []byte, units []string) (map[string]appconfig.UnitState, error) {
+ blocks := bytes.Split(bytes.TrimSpace(out), []byte("\n\n"))
+ if len(bytes.TrimSpace(out)) == 0 {
+ blocks = nil
+ }
+ if len(blocks) != len(units) {
+ return nil, fmt.Errorf("systemctl show answered %d unit(s) for %d asked", len(blocks), len(units))
+ }
+ res := make(map[string]appconfig.UnitState, len(units))
+ for i, unit := range units {
+ var st appconfig.UnitState
+ for _, line := range strings.Split(string(blocks[i]), "\n") {
+ k, v, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+ switch k {
+ case "Id":
+ st.ID = v
+ case "LoadState":
+ st.LoadState = v
+ case "EnvironmentFiles":
+ for _, f := range strings.Fields(v) {
+ if strings.HasPrefix(f, "(") {
+ continue
+ }
+ st.EnvFiles = append(st.EnvFiles, f)
+ }
+ }
+ }
+ res[unit] = st
+ }
+ return res, nil
+}
+
+func daemonReload() error {
+ out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("daemon-reload: %w: %s", err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+// systemctlRestartUnit is `systemctl restart`, NOT try-restart: a marked
+// unit owes a restart even when a previous one left it inactive (spec
+// §3.2 step 4). Logs and returns like systemctlTryRestartUnit.
+func systemctlRestartUnit(unit string) error {
+ out, err := exec.Command("systemctl", "restart", unit).CombinedOutput()
+ if err != nil {
+ log.Printf("adopt: restart %s: %v: %s", unit, err, strings.TrimSpace(string(out)))
+ return fmt.Errorf("restart %s: %w: %s", unit, err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+```
+
+- [ ] **Step 4: Wire RunAdopt**
+
+In `internal/host/agent.go`, replace the comment block at lines 2895-2907 (from `// One callback for both halves below` to `// ONE restart dispatch on this pass, not two that could drift.`) with:
+
+```go
+ // One callback for both halves below, built once here now that it needs
+ // the registry — it reads each route's `unit` column to decide what to
+ // cycle. See rootRestartRoute for why the named-unit arm has to exist on
+ // THIS pass.
+ //
+ // The release pass reaches that arm for stamped routes. Config sync
+ // does NOT: appconfig.SyncUnits restarts a stamped route's unit itself,
+ // bound to the unit it prepared rather than re-read from the row (spec
+ // 2026-09-07-named-unit-config-delivery-design.md §3.2), and uses this
+ // callback only for carlos-app@ routes. Both still dispatch through
+ // makeRestartRoute's arms for what they hand it.
+```
+
+Then change the `appconfig.Sync(...)` call (line 2950) to:
+
+```go
+ } else if err := appconfig.SyncUnits(ctx, reg, store, secretsPriv, appconfig.DefaultEnvDir, restart, productionUnitOps(appconfig.DefaultEnvDir)); err != nil {
+```
+
+- [ ] **Step 5: Build, vet, cross-compile, test**
+
+Run:
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go vet ./internal/host/ && GOOS=darwin go build ./... && GOOS=windows go build ./... && go test ./internal/host/ -run 'TestParseSystemctlShow|TestMakeRestartRoute|TestAdoptPass' -count=1 && gofmt -l internal/host/
+```
+Expected: all clean, tests PASS. (The `lockFileEx` pair already has a Windows stub; `unitops.go` uses nothing platform-specific.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/host/unitops.go internal/host/unitops_test.go internal/host/agent.go
+git commit -m "host: real unit ops for the config pass (batched show, daemon-reload, restart, flock)
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 8: The exec side leaves a stamped route's old file alone
+
+**Files:**
+- Test: `internal/appconfig/syncexec_test.go`
+
+**Interfaces:** none new. Task 1's `execRouteBase` change already excludes stamped routes; this task pins the file behaviour the spec promises (§3.3).
+
+- [ ] **Step 1: Write the test**
+
+Find an existing `SyncExec` test that opts an addr route into file delivery (grep `SetExecDelivery` in `syncexec_test.go`) and copy its setup shape. Append:
+
+```go
+func TestSyncExecLeavesAStampedRoutesOldFileAlone(t *testing.T) {
+ ctx, s, reg, dataDir, priv, _ := execKit(t)
+ privKey, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"TOKEN": "old"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
+ t.Fatal(err)
+ }
+ rt := registry.Route{Host: "relay.example.com", Kind: "instance", App: "relay", Account: "bab",
+ Status: "ready", Addr: "127.0.0.1:8451"}
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ if err := SyncExec(ctx, reg, s, privKey, dataDir, nil, ""); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(dataDir, "relay.example.com.config.env")
+ before, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("the opted-in addr route must have a file before the stamp: %v", err)
+ }
+ // Stamp it and change the bundle: the exec side must neither refresh
+ // nor remove the file (spec §3.3, the ruling at syncexec.go:428).
+ if err := reg.SetUnit("relay.example.com", "relay.service"); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"TOKEN": "new"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := SyncExec(ctx, reg, s, privKey, dataDir, nil, ""); err != nil {
+ t.Fatal(err)
+ }
+ after, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("file removed for a stamped route: %v", err)
+ }
+ if string(after) != string(before) {
+ t.Fatalf("file refreshed for a stamped route: %q -> %q", before, after)
+ }
+}
+```
+
+Check the exact `SyncExec` argument list against `syncexec.go:65` (`ctx, reg, s, priv, dataDir, bounce, appsDomain`) and the existing tests' way of passing a nil bounce; adjust the call to match what the neighbouring tests do.
+
+- [ ] **Step 2: Run it**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run TestSyncExecLeavesAStampedRoutesOldFileAlone -count=1`
+Expected: PASS already (Task 1 made it so). If it FAILS on the first `ReadFile`, the setup does not match how the package opts a route in; compare with the nearest existing opted-in test and fix the setup, not the assertion.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/appconfig/syncexec_test.go
+git commit -m "appconfig: pin that SyncExec leaves a stamped route's old file alone
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 9: `AwaitingRestart` gains `Undelivered` and `Blocked`, keeps stamped routes in `Unresolved`
+
+**Files:**
+- Modify: `internal/appconfig/syncexec.go:620-790` (`RestartPending`, `Empty`, `AwaitingRestart`)
+- Test: `internal/appconfig/awaitingrestart_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ type BlockedHost struct { Host, Reason string }
+ type RestartPending struct {
+ Hosts []RestartHost
+ Unresolved []string
+ Undelivered []string
+ Blocked []BlockedHost
+ }
+ func BlockedStamps(routes []registry.Route, acct, app string) []BlockedHost // exported; the console uses it through AwaitingRestart
+ ```
+- Consumes: Task 1 rails.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/appconfig/awaitingrestart_test.go`:
+
+```go
+func TestAwaitingRestartListsUnoptedAddrRouteAsUndelivered(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"K": "v"}); err != nil {
+ t.Fatal(err)
+ }
+ routes := []registry.Route{addrRoute("plain.example.com", "relay", "bab", "")}
+ got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"K"})
+ if len(got.Undelivered) != 1 || got.Undelivered[0] != "plain.example.com" {
+ t.Fatalf("Undelivered = %v, want [plain.example.com]", got.Undelivered)
+ }
+ if len(got.Hosts) != 0 || got.Empty() {
+ t.Fatalf("Hosts = %v, Empty = %v", got.Hosts, got.Empty())
+ }
+}
+
+func TestAwaitingRestartStampedRouteWithMissingBundleIsUnresolved(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"K": "v"}); err != nil {
+ t.Fatal(err)
+ }
+ rt := addrRoute("relay.example.com", "relay", "bab", "missing")
+ rt.Unit = "relay.service"
+ got := AwaitingRestart(ctx, s, []registry.Route{rt}, "bab", "relay", "", []string{"K"})
+ if len(got.Unresolved) != 1 || got.Unresolved[0] != "relay.example.com" {
+ t.Fatalf("Unresolved = %v, want the stamped route with a missing bound bundle", got.Unresolved)
+ }
+ if len(got.Hosts) != 0 || len(got.Undelivered) != 0 || len(got.Blocked) != 0 {
+ t.Fatalf("stamped route leaked into another set: %+v", got)
+ }
+}
+
+func TestAwaitingRestartHealthyStampedRouteIsListedNowhere(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"K": "v"}); err != nil {
+ t.Fatal(err)
+ }
+ rt := addrRoute("relay.example.com", "relay", "bab", "")
+ rt.Unit = "relay.service"
+ got := AwaitingRestart(ctx, s, []registry.Route{rt}, "bab", "relay", "", []string{"K"})
+ if !got.Empty() {
+ t.Fatalf("healthy stamped route must be silent, got %+v", got)
+ }
+}
+
+func TestAwaitingRestartBlockedStamps(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"K": "v"}); err != nil {
+ t.Fatal(err)
+ }
+ sock := addrRoute("sock.example.com", "relay", "bab", "")
+ sock.Unit, sock.Addr, sock.Socket = "sock.service", "", "/run/x.sock"
+ tmpl := addrRoute("tmpl.example.com", "relay", "bab", "")
+ tmpl.Unit = "carlos-app@.service"
+ a := addrRoute("a.example.com", "relay", "bab", "")
+ a.Unit = "shared.service"
+ // The duplicate is another ACCOUNT's route: it blocks a but is not disclosed.
+ other := addrRoute("other.example.com", "relay", "bae", "")
+ other.Unit = "shared.service"
+ got := AwaitingRestart(ctx, s, []registry.Route{sock, tmpl, a, other}, "bab", "relay", "", []string{"K"})
+ byHost := map[string]string{}
+ for _, b := range got.Blocked {
+ byHost[b.Host] = b.Reason
+ }
+ if len(byHost) != 3 {
+ t.Fatalf("Blocked = %+v, want sock, tmpl and a", got.Blocked)
+ }
+ if !strings.Contains(byHost["sock.example.com"], "socket") ||
+ !strings.Contains(byHost["tmpl.example.com"], "template") ||
+ !strings.Contains(byHost["a.example.com"], "shared.service") {
+ t.Fatalf("reasons = %v", byHost)
+ }
+ if _, leaked := byHost["other.example.com"]; leaked {
+ t.Fatal("another account's route must not be disclosed")
+ }
+ if strings.Contains(byHost["a.example.com"], "other.example.com") {
+ t.Fatal("the reason must not name another account's host")
+ }
+ if got.Empty() {
+ t.Fatal("Blocked must count as something to say")
+ }
+}
+```
+
+Add `"strings"` to the imports if missing.
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -run 'TestAwaitingRestart' -count=1`
+Expected: FAIL, `got.Undelivered undefined`.
+
+- [ ] **Step 3: Extend the types and the loop**
+
+In `internal/appconfig/syncexec.go`:
+
+Replace `RestartPending` and `Empty` (lines 647-655) with:
+
+```go
+// BlockedHost is a stamped route the root pass will refuse for a reason
+// knowable from the registry alone (spec §3.4, codex R2-6): the shape
+// rail, the name rail, the hostname rail or the one-route-per-unit rule.
+// The systemd-side refusals (alias, load state, the EnvironmentFiles
+// verification) are not knowable at the door and are not claimed here.
+type BlockedHost struct {
+ Host string
+ Reason string
+}
+
+// RestartPending is what a config write left behind on ONE box.
+//
+// Hosts: files that landed for processes carlos does not own, and
+// therefore does not restart. Unresolved: routes nothing can say
+// "delivered" or "not delivered" about. Undelivered: routes this write
+// reached that no pass delivers a file to (an un-opted addr route with no
+// stamp). Blocked: stamped routes the root pass will refuse. Each is its
+// own list because the honest sentence differs, and "could not tell" is
+// never rendered as any of the others.
+type RestartPending struct {
+ Hosts []RestartHost
+ Unresolved []string
+ Undelivered []string
+ Blocked []BlockedHost
+}
+
+// Empty reports whether there is nothing to say. The renderers' one test.
+func (r RestartPending) Empty() bool {
+ return len(r.Hosts) == 0 && len(r.Unresolved) == 0 && len(r.Undelivered) == 0 && len(r.Blocked) == 0
+}
+
+// BlockedStamps applies the door-side rails to the stamped routes of
+// (acct, app) in routes. Uniqueness is checked against the FULL list, so
+// a route of another account blocks this one, but only this account's
+// hosts are returned and no other account's host is named in a reason.
+func BlockedStamps(routes []registry.Route, acct, app string) []BlockedHost {
+ byUnit := map[string]int{}
+ for _, rt := range routes {
+ if Stamped(rt) && StampShape(rt) == nil && DeliverableUnitName(rt.Unit) == nil && BareHostname(rt.Host) {
+ byUnit[rt.Unit]++
+ }
+ }
+ var out []BlockedHost
+ for _, rt := range routes {
+ if !Stamped(rt) || rt.App != app || release.RouteAccount(rt) != acct {
+ continue
+ }
+ switch {
+ case StampShape(rt) != nil:
+ out = append(out, BlockedHost{rt.Host, StampShape(rt).Error()})
+ case DeliverableUnitName(rt.Unit) != nil:
+ out = append(out, BlockedHost{rt.Host, DeliverableUnitName(rt.Unit).Error()})
+ case !BareHostname(rt.Host):
+ out = append(out, BlockedHost{rt.Host, fmt.Sprintf("%q is not a bare hostname", rt.Host)})
+ case byUnit[rt.Unit] > 1:
+ out = append(out, BlockedHost{rt.Host, fmt.Sprintf("two or more routes on this box name %s; one unit takes one route's config", rt.Unit)})
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
+ return out
+}
+```
+
+In `AwaitingRestart`:
+
+1. Right after `var out RestartPending`, add:
+
+```go
+ out.Blocked = BlockedStamps(routes, acct, app)
+ blocked := map[string]bool{}
+ for _, b := range out.Blocked {
+ blocked[b.Host] = true
+ }
+```
+
+2. Change the loop's first filter from
+
+```go
+ if !execAddrCandidate(rt) || rt.App != app || release.RouteAccount(rt) != acct {
+ continue
+ }
+```
+
+to
+
+```go
+ stamped := Stamped(rt) && !blocked[rt.Host]
+ if (!execAddrCandidate(rt) && !stamped) || rt.App != app || release.RouteAccount(rt) != acct {
+ continue
+ }
+```
+
+3. After the `eb` bound-bundle checks and the `resolveExecDelivery` call (the two places that append to `out.Unresolved` and `continue`), and BEFORE the `if !v.On` test, add:
+
+```go
+ // A stamped route is delivered and restarted by the root pass within
+ // seconds of this write; it has nothing to be told about unless its
+ // bundle would not resolve, which the checks above have already
+ // reported (spec §3.4, codex R1-8).
+ if stamped {
+ continue
+ }
+```
+
+Note `resolveExecDelivery` is still called for a stamped route so that a default-bundle fetch error lands it in `Unresolved`; that is the point.
+
+4. Replace
+
+```go
+ if !v.On {
+ continue
+ }
+```
+
+with
+
+```go
+ // Not opted in — including the Unknown case, which decides "off" —
+ // means no pass delivers a file to this route: this write reached
+ // nothing here. Say so (spec §3.4, codex R1-9: "this write", not
+ // "no file" — an opted-out route may hold a file from before).
+ if !v.On {
+ out.Undelivered = append(out.Undelivered, rt.Host)
+ continue
+ }
+```
+
+5. Before `return out`, add `sort.Strings(out.Undelivered)`.
+
+- [ ] **Step 4: Run the tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/appconfig/ -count=1 && go vet ./internal/appconfig/ && gofmt -l internal/appconfig/`
+Expected: PASS. `TestAwaitingRestartSkipsUnoptedAndSocketRoutes` may now fail because an un-opted addr route is listed in `Undelivered`; read that test: if it asserts `Empty()` over a set that includes an un-opted ADDR route, change that assertion to expect the host in `Undelivered` and keep the socket/unit-backed expectations. If it only covers socket and unit-backed routes (as its name says), it passes unchanged.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/appconfig/syncexec.go internal/appconfig/awaitingrestart_test.go
+git commit -m "appconfig: the notice says when a write reached nothing or a stamp is blocked
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 10: Wire and CLI rendering
+
+**Files:**
+- Modify: `internal/console/apiconfig.go:495-543` (`apiConfigRestart`, `configRestartNotice`)
+- Modify: `apimode.go:2193-2207` (`apiConfigRestart`)
+- Modify: `appconfig_cli.go:43-53` (`restartFromAPI`), `:94-120` (`printRestartNotice`), `:149-157` (copy)
+- Test: `appconfig_restart_test.go`, `internal/console` (an existing apiconfig test file, grep `configRestartNotice` or `apiConfigRestart` under `internal/console/*_test.go`)
+
+**Interfaces:**
+- Both `apiConfigRestart` types gain `Undelivered []string \`json:"undelivered,omitempty"\`` and `Blocked []apiConfigBlockedHost \`json:"blocked,omitempty"\`` with `type apiConfigBlockedHost struct { Host string \`json:"host"\`; Reason string \`json:"reason"\` }`.
+
+- [ ] **Step 1: Write the failing CLI tests**
+
+Append to `appconfig_restart_test.go`:
+
+```go
+func TestRestartFromAPICarriesUndeliveredAndBlocked(t *testing.T) {
+ got := restartFromAPI(&apiConfigRestart{Known: true,
+ Undelivered: []string{"plain.example.com"},
+ Blocked: []apiConfigBlockedHost{{Host: "a.example.com", Reason: "two or more routes on this box name shared.service"}},
+ })
+ if !got.known || len(got.pending.Undelivered) != 1 || len(got.pending.Blocked) != 1 ||
+ got.pending.Blocked[0].Reason == "" {
+ t.Fatalf("restartFromAPI = %+v", got)
+ }
+}
+
+func TestPrintRestartNoticeUndeliveredAndBlocked(t *testing.T) {
+ var b bytes.Buffer
+ printRestartNotice(&b, "relay", &restartAnswer{known: true, pending: appconfig.RestartPending{
+ Undelivered: []string{"plain.example.com"},
+ Blocked: []appconfig.BlockedHost{{Host: "a.example.com", Reason: "two or more routes on this box name shared.service; one unit takes one route's config"}},
+ }})
+ out := b.String()
+ for _, want := range []string{"note:", "did not reach plain.example.com", "carlos route --unit", "exec-delivery --app relay file",
+ "blocked", "a.example.com", "shared.service", restartCopyScope} {
+ if !strings.Contains(out, want) {
+ t.Errorf("output lacks %q:\n%s", want, out)
+ }
+ }
+}
+
+func TestPrintRestartNoticeStillRendersAnOldConsolesUnitRemedy(t *testing.T) {
+ var b bytes.Buffer
+ printRestartNotice(&b, "relay", restartFromAPI(&apiConfigRestart{Known: true,
+ Hosts: []apiConfigRestartHost{{Host: "console.example.com", Unit: "console.service"}}}))
+ if !strings.Contains(b.String(), "carlos restart --app relay cycles console.service") {
+ t.Fatalf("old-console unit remedy lost:\n%s", b.String())
+ }
+}
+```
+
+Add `"strings"` and `"github.com/carlosframework/platform/internal/appconfig"` to that test file's imports if missing.
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestRestartFromAPI|TestPrintRestartNotice' -count=1`
+Expected: FAIL, `unknown field Undelivered`.
+
+- [ ] **Step 3: Extend both wire types**
+
+`apimode.go` (lines 2196-2207):
+
+```go
+type apiConfigRestart struct {
+ Known bool `json:"known"`
+ Hosts []apiConfigRestartHost `json:"hosts"`
+ Unresolved []string `json:"unresolved"`
+ // Undelivered and Blocked are additive (spec §3.4): an older console
+ // sends neither and this binary prints nothing for them.
+ Undelivered []string `json:"undelivered,omitempty"`
+ Blocked []apiConfigBlockedHost `json:"blocked,omitempty"`
+}
+
+// apiConfigBlockedHost is a stamped route the root pass will refuse, with
+// the door-side reason.
+type apiConfigBlockedHost struct {
+ Host string `json:"host"`
+ Reason string `json:"reason"`
+}
+```
+
+`internal/console/apiconfig.go` (lines 495-510): add the same two fields and the same `apiConfigBlockedHost` type to the console's `apiConfigRestart`, with the comment "Undelivered: routes this write reached that no pass delivers to. Blocked: stamped routes the root pass will refuse, with the reason. Both omitted when empty so an older CLI sees exactly what it saw."
+
+In `configRestartNotice` (line 538), replace
+
+```go
+ out := apiConfigRestart{Known: true, Unresolved: p.Unresolved}
+```
+
+with
+
+```go
+ out := apiConfigRestart{Known: true, Unresolved: p.Unresolved, Undelivered: p.Undelivered}
+ for _, b := range p.Blocked {
+ out.Blocked = append(out.Blocked, apiConfigBlockedHost{Host: b.Host, Reason: b.Reason})
+ }
+```
+
+- [ ] **Step 4: Convert and render in the CLI**
+
+`appconfig_cli.go` `restartFromAPI`: after `out.pending.Unresolved = r.Unresolved` add
+
+```go
+ out.pending.Undelivered = r.Undelivered
+ for _, b := range r.Blocked {
+ out.pending.Blocked = append(out.pending.Blocked, appconfig.BlockedHost{Host: b.Host, Reason: b.Reason})
+ }
+```
+
+`printRestartNotice`: after the `Unresolved` loop add
+
+```go
+ for _, h := range a.pending.Undelivered {
+ fmt.Fprintf(w, "%s %s\n", c.warn("note:"), fmt.Sprintf(restartCopyUndelivered, h, app))
+ }
+ for _, b := range a.pending.Blocked {
+ fmt.Fprintf(w, "%s %s\n", c.warn("note:"), fmt.Sprintf(restartCopyBlocked, b.Host, b.Reason))
+ }
+```
+
+Copy block: add
+
+```go
+ restartCopyUndelivered = "this write did not reach %s. 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 %s file)."
+ restartCopyBlocked = "delivery to %s is blocked: %s"
+```
+
+Leave `restartCopyUnit` and the `Unit` fields in place (spec §3.4: an older console still sends them).
+
+- [ ] **Step 5: Console-side test**
+
+Find the console test that exercises `configRestartNotice` or decodes the config door's `restart` field (`grep -rn 'restart' internal/console/apiconfig_test.go` or the nearest `*_test.go` naming `apiConfigRestart`). Add one case that seeds the test registry with an un-opted addr route and a stamped socket route for the app, performs the write the door exposes, and asserts the decoded JSON has `undelivered: ["<host>"]` and one `blocked` entry whose `reason` contains `socket`. Follow that file's existing setup verbatim (server construction, registry path, bearer token).
+
+- [ ] **Step 6: Run everything**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go test . ./internal/console/ ./internal/appconfig/ -count=1 && go vet ./... && gofmt -l .`
+Expected: PASS, clean. (If a console socket test fails with `bind: invalid argument`, re-run with `TMPDIR=/tmp/g` after `mkdir -p /tmp/g`.)
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add apimode.go appconfig_cli.go appconfig_restart_test.go internal/console/apiconfig.go internal/console/
+git commit -m "notice: undelivered and blocked over both wire types, rendered by the CLI (copy pending copy-review)
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 11: The doors validate the resulting row
+
+**Files:**
+- Modify: `cli.go:1486-1525` (`runRouteUnit`, `unitRail`), `cli.go:1317-1330` (`runRouteHibernate`), `cli.go:85-110` (`carlos add`)
+- Test: `cli_test.go:466-510`
+
+**Interfaces:**
+- `unitRail(rt registry.Route, unit string) error` (signature change: takes the proposed stamp; `""` always passes).
+- `hibernateRail(rt registry.Route) error` (new: refuses hibernation on a stamped row).
+
+- [ ] **Step 1: Write the failing tests**
+
+In `cli_test.go`, update `TestUnitRailRefusals` to the two-argument form (`unitRail(unitBacked, "console.service")`, and in the accept loop `unitRail(c.rt, "console.service")`), then append:
+
+```go
+func TestUnitRailValidatesTheResultingRow(t *testing.T) {
+ sock := registry.Route{Host: "s.example.com", Kind: "instance", App: "relay", Socket: "/run/s.sock"}
+ if err := unitRail(sock, "s.service"); err == nil || !strings.Contains(err.Error(), "socket") {
+ t.Fatalf("stamping a socket route = %v, want a socket refusal", err)
+ }
+ hib := registry.Route{Host: "h.example.com", Kind: "instance", App: "relay", Addr: "127.0.0.1:1", Hibernate: true}
+ if err := unitRail(hib, "h.service"); err == nil || !strings.Contains(err.Error(), "hibernat") {
+ t.Fatalf("stamping a hibernating route = %v, want a hibernate refusal", err)
+ }
+ // Clearing is always allowed, whatever the old row's shape.
+ sock.Unit = "s.service"
+ if err := unitRail(sock, ""); err != nil {
+ t.Fatalf("clearing a stamp on a socket route = %v, want accepted", err)
+ }
+}
+
+func TestHibernateRailRefusesAStampedRow(t *testing.T) {
+ stamped := registry.Route{Host: "c.example.com", Kind: "instance", App: "console", Addr: "127.0.0.1:8090", Unit: "console.service"}
+ if err := hibernateRail(stamped); err == nil || !strings.Contains(err.Error(), "console.service") {
+ t.Fatalf("hibernateRail(stamped) = %v, want a refusal naming the unit", err)
+ }
+ stamped.Unit = ""
+ if err := hibernateRail(stamped); err != nil {
+ t.Fatalf("hibernateRail(unstamped) = %v, want accepted", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestUnitRail|TestHibernateRail' -count=1`
+Expected: FAIL (compile: too many arguments / undefined `hibernateRail`).
+
+- [ ] **Step 3: Change the rails**
+
+In `cli.go`, replace `unitRail`:
+
+```go
+// unitRail is the row-shape half of runRouteUnit's refusals, split out as a
+// pure function for the same reason backingRail is. It validates the ROW
+// THAT WOULD RESULT (spec 2026-09-07-named-unit-config-delivery-design.md
+// §3.1, codex R3-5): clearing a stamp (unit == "") is always allowed, so
+// that no row can be stranded; stamping refuses a unit-backed row (two
+// answers to one question) and a row outside the supported stamped shape
+// (appconfig.StampShape: an addr upstream, no socket, no hibernation),
+// which the root pass would refuse every pass anyway.
+//
+// Deliberately NOT refused: Platform rows and service rows — see the
+// previous version's reasoning, which still holds.
+func unitRail(rt registry.Route, unit string) error {
+ if unit == "" {
+ return nil
+ }
+ if rt.Backing == registry.BackingUnit {
+ return fmt.Errorf("unit-backed route — its process is the carlos-app@%s unit the platform derives and already restarts, "+
+ "so a second unit name here would be two answers to one question (drop the backing first with `carlos route --backing \"\"` if this route really is owned by a bespoke service)", rt.Host)
+ }
+ proposed := rt
+ proposed.Unit = unit
+ if err := appconfig.StampShape(proposed); err != nil {
+ return fmt.Errorf("%v — the root pass delivers config only to an addr-backed, non-hibernating route", err)
+ }
+ return nil
+}
+
+// hibernateRail refuses hibernation on a stamped row: the root pass
+// restarts a stamped unit on config change, and a hibernating route is the
+// activator's to wake, so the two would fight over one process.
+func hibernateRail(rt registry.Route) error {
+ if rt.Unit != "" {
+ return fmt.Errorf("route names unit %s — a stamped route is restarted by the root config pass and cannot also hibernate; clear the stamp first (`carlos route --host %s --unit -`)", rt.Unit, rt.Host)
+ }
+ return nil
+}
+```
+
+Update `runRouteUnit`'s first line to `if err := unitRail(rt, unit); err != nil {`.
+
+In `runRouteHibernate`, inside `if on {` before the daemon check, add:
+
+```go
+ if err := hibernateRail(rt); err != nil {
+ log.Fatalf("route: %s: %v", rt.Host, err)
+ }
+```
+
+In `carlos add` (after the `--backing unit with --unit` refusal, before the socket default), add:
+
+```go
+ // The root pass delivers config to a stamped route only in one shape
+ // (appconfig.StampShape); this door must not mint a row it would refuse
+ // every pass. --socket is refused even though the default below would
+ // add one for a bare instance: a stamped route needs --addr.
+ if *unit != "" {
+ if *socket != "" || *addr == "" || *hibernate {
+ log.Fatalf("add %s: --unit %s needs --addr and neither --socket nor --hibernate: the root pass delivers config to a stamped route only when a unit owns an addr-backed, always-on process", host, *unit)
+ }
+ }
+```
+
+Add `"github.com/carlosframework/platform/internal/appconfig"` to `cli.go`'s imports if it is not already there (check: `grep -n 'internal/appconfig' cli.go`).
+
+- [ ] **Step 4: Run the CLI tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go test . -run 'Rail|TestRouteUnit|TestAdd' -count=1 && go vet . && gofmt -l .`
+Expected: PASS. `TestRouteUnitStampsAnExistingRoute` drives the door as a subprocess on an addr route and must still pass; if it stamps a route with no `--addr`, add `--addr 127.0.0.1:8090` to its fixture, because that route would now be refused by design.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cli.go cli_test.go
+git commit -m "cli: route --unit, --hibernate and add validate the row that would result
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 12: Documentation and the full gate
+
+**Files:**
+- Modify: `docs/site/cli.md:395-440` (`carlos env`, `carlos secrets`), `:872-895` (`carlos add`), `:897-915` (`carlos route`)
+- Modify: `AGENTS.md:282-290`
+
+- [ ] **Step 1: `docs/site/cli.md`**
+
+In the `### carlos route` section, after the sentence ending "so the command will not let you write both." (line 911), add:
+
+```markdown
+Naming a unit also hands its config to the root pass: on every config
+write the pass renders the route's bundle to `/etc/carlos/apps/<host>.env`,
+keeps a drop-in under `/etc/systemd/system/<unit>.d/` that makes that file
+the unit's only `EnvironmentFile=`, reloads systemd and restarts the unit.
+A stamp needs an addr upstream and no hibernation; `--unit` refuses a
+socket or hibernating row, `--hibernate` refuses a stamped one, and
+`--unit -` always clears.
+```
+
+In `### carlos add`, extend the `--unit console.service` sentence: "…which is what lets adoption cycle it and the root pass deliver its config; it needs `--addr` and neither `--socket` nor `--hibernate`."
+
+In `### carlos env` and `### carlos secrets`, after the paragraph that describes the existing restart note (grep `still running the old values` on the page; if the page does not mention the note, add this after the `set` example), add one sentence each:
+
+```markdown
+A write that reaches a route no pass delivers to says so ("this write did
+not reach <host>"), with the two ways out: name the unit that runs the
+process on the box, or opt the bundle into file delivery. A stamped route
+the root pass will refuse is reported as blocked with the reason.
+```
+
+- [ ] **Step 2: `AGENTS.md`**
+
+In the "Console restarts: manual until the route is unit-stamped" bullet (line 282), append after "Ordinary unit/exec tenants restart themselves either way.":
+
+```markdown
+ A stamped route also receives its config from the root pass (spec
+ `docs/superpowers/specs/2026-09-07-named-unit-config-delivery-design.md`):
+ the drop-in it writes RESETS the unit's `EnvironmentFile=` list to the
+ rendered bundle, so a hand-built unit's own `EnvironmentFile=` lines stop
+ applying once it is stamped; `Environment=` lines are untouched.
+```
+
+- [ ] **Step 3: Run the whole gate**
+
+Run:
+```
+mkdir -p /tmp/g && GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp/g go build ./... && go vet ./... && TMPDIR=/tmp/g go test ./... -count=1 && gofmt -l . && GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./...
+```
+Expected: every package PASS, no vet output, no unformatted files, three cross-compiles clean. `internal/docsite` checks the CLI page's headings and examples; none were added or removed, so it passes as before.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/site/cli.md AGENTS.md
+git commit -m "docs: a stamped route gets its config from the root pass; the notice's two new lines
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+```
+
+- [ ] **Step 5: Branch description and hand-off**
+
+The branch is complete when every task above is committed and the gate in Step 3 is green. Before it lands: run `copy-review` on the strings introduced in Tasks 1, 4, 5, 9, 10 and 11 (the refusal reasons, the `note:` lines, the door refusals) in an interactive session, then push to the forge and set the description with `amadan branch describe carlos/platform worktree-unit-route-config -body -`, wait for `amadan ci status carlos/platform -branch worktree-unit-route-config`, and land per the spec's §7.
+
+---
+
+## Self-review notes
+
+**Spec coverage.** §3.1 predicates and door shape: Tasks 1, 11. §3.2 rail, drop-in with reset, marker-first protocol, lock, batched probe, alias refusal, one-per-unit, verify exactly one file, restart not try-restart, drift, reaping with marker first: Tasks 3 to 7. §3.3: Task 8. §3.4 `Undelivered`, `Blocked`, stamped routes in `Unresolved`, both wire types, `restartFromAPI` and `configRestartNotice`, `Unit` remedy kept: Tasks 9, 10. §3.5 fingerprint: Task 1. §3.6 docs and AGENTS.md: Task 12. §6's test list maps onto the tests in Tasks 1 to 11; the R2-3 interleaving is covered by the lock test in Task 4 (a pass that cannot lock does nothing).
+
+**Names used across tasks.** `Stamped`, `unitDelivered`, `StampShape`, `DeliverableUnitName`, `BareHostname` (Task 1) are used in Tasks 4, 9, 11 as written. `prepareHost`/`commitHost` (Task 2) are used in Task 4. `renderDropIn`, `ensureDropInDir`, `readDropIn`, `writeDropIn`, `removeDropIn`, `writePending`, `removePending`, `hasPending`, `listPending`, `listMarkedDropIns`, `dropInDir`, `DropInFile`, `errNotOurs` (Task 3) are used in Tasks 4 to 6. `UnitOps`, `UnitState`, `SyncUnits`, `stampedPlan`, `unitWork`, `planStamped`, `prepareStamped` (Task 4), `applyStamped`, `envFilesMatch` (Task 5), `reapDropIns` (Task 6) match Task 7's `productionUnitOps`. `BlockedHost`, `RestartPending.Undelivered/Blocked`, `BlockedStamps` (Task 9) match Task 10's wire fields.
+
+**Known judgement calls for the implementer.** Task 8 and Task 10 Step 5 tell the implementer to copy an existing test's setup rather than reproduce it here, because the exact `SyncExec` bounce argument and the console test server construction are long and already in the neighbouring tests; the assertions are given in full.