carlos / platform Public

merge: external-socket advisory — a stray process serving an exec route's socket is now loud (#28)

* spec: external-socket advisory (#17) + exec-delivery deferred minors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* plan: external-socket advisory + deferred minors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* agent: external-socket advisory — warn when a stray process serves an exec route's socket

An exec-shaped route (kind=instance, backing≠unit, socket upstream) can
have its socket served by a process the platform never spawned — dev's
hand-written helloworld.service. Spawn-time config delivery then never
runs for the route, and with hibernate=1 the squatter fights the wake
path. #14's advisory only probes carlos-app@<host>.service, so the shape
was invisible.

warnExternalSockets is the pure predicate (in scope: appconfig's
execSyncable shape; addr-upstream routes from #18 are externally managed
by design and never warn), socketServing the real unix dial, and
socketAdvisoryLoop the hourly agent loop — advisory only, log lines, no
route mutation, like adopt_advice.go. It lives in the agent because the
only honest owner test is the activator's in-memory entry table, which
RunAdopt cannot see: activator.Service.Owns reports a live entry in ANY
state, so a child still binding its listener or draining SIGTERM never
reads as a stray.

Implements platform issue #17.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* host: batch backing-mismatch systemctl probe into one invocation

warnBackingMismatch now takes unitsAreActive(hosts []string) map[string]bool
instead of a per-host unitActive callback, so a single systemctl is-active
call carries every candidate host (PR #16 deferred minor). Zero candidates
means zero probe calls. Behavior and warned-hosts result are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* appconfig: guard .config namespace collision + fetch-failure cross-group test

Host x's sync-owned x.config.env is byte-identical in name to host
x.config's hand-staged instance env (<host>.env). Guard reapConfigEnv
against deleting a live route's instance env under that name, and guard
SyncExec's single syncExecHost call site (shared by the socket and
issue #18 addr shapes) against writing into it. Also adds the M4
cross-group fetch-failure coverage gap: one group's manifest fetch
failing must not affect another group's convergence in the same pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* appconfig: fix vacuous refusal assertion, strengthen reap test, hoist TrimSuffix

Review found TestSyncExecRefusesConfigNamespaceCollision's
strings.Contains(out, "x.config") passes even when the guard never
fires, since route x.config's own normal convergence line also
contains that substring. Assert on the guard's own distinctive text
instead. Also strengthens the reap test the same way, and hoists the
three-times-evaluated TrimSuffix in reapConfigEnv to a local.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* host: final-review fix wave — batch parse guard, ready-status coverage, armed log

adopt_advice.go: unitsAreActive's positional systemctl parse had no length
check, so a short batch misattributed active to the wrong host and pointed
an operator at the wrong host's decrypted config.env. Extracted
parseIsActiveBatch (renamed from the brief's parseIsActive, which collides
with probes.go's unrelated single-unit helper) with a strict length guard —
mismatched line counts return an empty map rather than a guess — and widened
the active set to accept "reloading" alongside "active", restoring parity
with the old --quiet exit-0 behavior.

socket_advice_test.go: added a warn-row with Status: "ready", the value
production routes actually carry, since every prior warn case relied on the
zero value.

socket_advice.go: added socketAdvisoryLoop's missing "armed" startup line,
matching configSyncLoop's sibling phrasing in agent.go.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b8e930b54a26a9f8c7b6ce800bdaf3c4c60c5a51 parent 9834815
11 files changed, +1089 −10
  • docs/superpowers/plans/2026-08-03-external-socket-advisory.md +413 −0
  • docs/superpowers/specs/2026-08-03-external-socket-advisory-design.md +137 −0
  • internal/activator/activator.go +13 −0
  • internal/activator/activator_test.go +45 −0
  • internal/appconfig/syncexec.go +30 −0
  • internal/appconfig/syncexec_test.go +178 −0
  • internal/host/adopt_advice.go +50 −9
  • internal/host/adopt_advice_test.go +62 −1
  • internal/host/agent.go +1 −0
  • internal/host/socket_advice.go +83 −0
  • internal/host/socket_advice_test.go +77 −0
diff --git a/docs/superpowers/plans/2026-08-03-external-socket-advisory.md b/docs/superpowers/plans/2026-08-03-external-socket-advisory.md
new file mode 100644
index 000000000..7a7cb53f1
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-03-external-socket-advisory.md
@@ -0,0 +1,413 @@
+# External-Socket Advisory (#17) + Deferred Minors 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:** Warn operators when an exec-shaped route's socket is served by a process the activator does not manage (issue #17), and land the remaining PR #16 deferred minors (systemctl batching, `.config` namespace-collision guards, fetch-failure sweep-isolation test).
+
+**Architecture:** A new hourly advisory loop in the agent process (`internal/host/socket_advice.go`) with a pure injectable predicate mirroring `adopt_advice.go`'s shape, backed by a new `activator.Service.Owns` method (entry present in any state) and a unix-dial probe. Minors are local reworks inside `internal/host/adopt_advice.go` and `internal/appconfig/syncexec.go`.
+
+**Tech Stack:** Go stdlib only (`net`, `os/exec`, `math/rand/v2`).
+
+**Spec:** `docs/superpowers/specs/2026-08-03-external-socket-advisory-design.md` — read it first.
+
+## Global Constraints
+
+- Advisory only: log lines, never a route mutation (adopt_advice.go's package doc states the rule).
+- Addr-upstream exec routes (issue #18, `Socket == ""`, `Addr != ""`) are externally managed BY DESIGN — they must never trip the new advisory.
+- `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l` (files you touch) all clean. Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; go commands may need the sandbox disabled ("read-only file system" is the tell).
+- Do NOT commit — the orchestrator commits after review (parallel agents share this worktree).
+- Match surrounding comment density and idiom; comments state constraints, not narration.
+
+---
+
+### Task 1: `Service.Owns` + external-socket advisory loop (model: Opus)
+
+**Files:**
+- Modify: `internal/activator/activator.go` (add `Owns` near `Bounce`, ~line 696)
+- Create: `internal/host/socket_advice.go`
+- Modify: `internal/host/agent.go` (one wiring line in `RunAgent`, beside `go configSyncLoop(reg, svc, dd)` at ~line 150)
+- Test: `internal/activator/activator_test.go`, new `internal/host/socket_advice_test.go`
+
+**Interfaces:**
+- Consumes: `registry.Route` fields `Kind`, `App`, `Platform`, `Status`, `Backing`, `Socket`, `Host`, `Hibernate`; `registry.BackingUnit`; `activator.Service.entries` under `a.mu`.
+- Produces: `func (a *Service) Owns(host string) bool`; `func warnExternalSockets(routes []registry.Route, serving func(socket string) bool, owns func(host string) bool) []registry.Route`; `func socketServing(socket string) bool`; `func socketAdvisoryLoop(reg *registry.Registry, svc *activator.Service)`.
+
+- [ ] **Step 1: Failing tests for the pure predicate + probe** — create `internal/host/socket_advice_test.go`:
+
+```go
+package host
+
+import (
+ "net"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestWarnExternalSocketsShapeAndConditions(t *testing.T) {
+ alwaysServing := func(string) bool { return true }
+ neverOwns := func(string) bool { return false }
+ sock := func(h string) string { return "/run/carlos/" + h + ".sock" }
+ cases := []struct {
+ name string
+ rt registry.Route
+ serving, owns bool
+ warn bool
+ }{
+ {"hibernate=0 live socket warns", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x")}, true, false, true},
+ {"hibernate=0 dead socket quiet", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x")}, false, false, false},
+ {"hibernate=1 unowned live socket warns", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x"), Hibernate: true}, true, false, true},
+ {"hibernate=1 owned live socket quiet", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x"), Hibernate: true}, true, true, false},
+ {"addr route (issue #18) never warns", registry.Route{Kind: "instance", App: "a", Host: "x", Addr: "127.0.0.1:9000"}, true, false, false},
+ {"unit backing belongs to #14 advisory", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x"), Backing: registry.BackingUnit}, true, false, false},
+ {"platform route out of scope", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x"), Platform: true}, true, false, false},
+ {"appless route out of scope", registry.Route{Kind: "instance", Host: "x", Socket: sock("x")}, true, false, false},
+ {"non-ready status out of scope", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x"), Status: "draining"}, true, false, false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ serving := func(string) bool { return c.serving }
+ owns := func(string) bool { return c.owns }
+ got := warnExternalSockets([]registry.Route{c.rt}, serving, owns)
+ if (len(got) == 1) != c.warn {
+ t.Errorf("warn = %v, want %v", len(got) == 1, c.warn)
+ }
+ })
+ }
+ _ = alwaysServing
+ _ = neverOwns
+}
+
+// The probe distinguishes a live LISTENER from a stale socket FILE and a
+// missing path — only the first is "served".
+func TestSocketServingProbe(t *testing.T) {
+ dir := t.TempDir()
+ live := filepath.Join(dir, "live.sock")
+ l, err := net.Listen("unix", live)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer l.Close()
+ go func() {
+ for {
+ c, err := l.Accept()
+ if err != nil {
+ return
+ }
+ c.Close()
+ }
+ }()
+ if !socketServing(live) {
+ t.Error("live listener not reported as serving")
+ }
+ stale := filepath.Join(dir, "stale.sock")
+ l2, err := net.Listen("unix", stale)
+ if err != nil {
+ t.Fatal(err)
+ }
+ l2.(*net.UnixListener).SetUnlinkOnClose(false)
+ l2.Close()
+ if socketServing(stale) {
+ t.Error("stale socket file reported as serving")
+ }
+ if socketServing(filepath.Join(dir, "absent.sock")) {
+ t.Error("missing path reported as serving")
+ }
+}
+```
+
+Check `registry.Route` field names against `internal/registry/registry.go` (~line 60-80) before assuming; adjust the literals if a field differs (e.g. `Addr`).
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/host/ -run 'ExternalSockets|SocketServing' -count=1` — expect compile failure (undefined `warnExternalSockets`).
+
+- [ ] **Step 3: Implement `internal/host/socket_advice.go`:**
+
+```go
+// External-socket advisory (issue #17): an exec-shaped route whose socket
+// is served by a process the activator does not manage. Advisory only,
+// like adopt_advice.go — log lines for the operator, never a mutation.
+// Lives in the AGENT, not the root adopt pass, because the only honest
+// answer to "did the activator spawn the thing serving this socket" is
+// the activator's in-memory entry table, which RunAdopt cannot see.
+package host
+
+import (
+ "log"
+ "math/rand/v2"
+ "net"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// warnExternalSockets returns the in-scope routes whose socket is being
+// SERVED by something the activator does not manage. In scope is the
+// spawnable-exec shape, mirroring appconfig's execSyncable: instance,
+// has an app, non-platform, ready, non-unit backing, socket upstream.
+// Addr-upstream routes (issue #18) are externally managed BY DESIGN and
+// never warn; unit-backed routes belong to the #14 advisory. The dial
+// runs before the owns check so a spawn racing the sweep reads as owned,
+// not stray.
+func warnExternalSockets(routes []registry.Route, serving func(socket string) bool, owns func(host string) bool) []registry.Route {
+ var warned []registry.Route
+ for _, rt := range routes {
+ if rt.Kind != "instance" || rt.App == "" || rt.Platform ||
+ (rt.Status != "" && rt.Status != "ready") ||
+ rt.Backing == registry.BackingUnit || rt.Socket == "" {
+ continue
+ }
+ if !serving(rt.Socket) {
+ continue
+ }
+ if rt.Hibernate && owns(rt.Host) {
+ continue // the activator's own child, in whatever state
+ }
+ warned = append(warned, rt)
+ }
+ return warned
+}
+
+// socketServing: the real probe. A live listener answers the dial; a
+// stale socket file nobody listens on refuses it — only the former is
+// "served". Same-box unix accept, so the timeout is generous.
+func socketServing(socket string) bool {
+ c, err := net.DialTimeout("unix", socket, 500*time.Millisecond)
+ if err != nil {
+ return false
+ }
+ c.Close()
+ return true
+}
+
+// socketAdvisoryLoop runs the advisory hourly, first pass ~1min after
+// boot, like its sibling loops. svc may be nil (activator unarmed):
+// owns is then constantly false and every live socket on an exec route
+// warns — correctly, nothing on this box manages them. Needs no env
+// arming: it only reads the registry and dials sockets.
+func socketAdvisoryLoop(reg *registry.Registry, svc *activator.Service) {
+ owns := func(host string) bool { return svc != nil && svc.Owns(host) }
+ delay := time.Minute
+ for {
+ time.Sleep(delay)
+ delay = time.Hour + rand.N(10*time.Minute)
+ routes, err := reg.List()
+ if err != nil {
+ log.Printf("agent: socket advisory: list routes: %v", err)
+ continue
+ }
+ for _, rt := range warnExternalSockets(routes, socketServing, owns) {
+ if rt.Hibernate {
+ log.Printf("agent: route %s is activator-managed (hibernate=1) but its socket %s is served by a process the activator did not spawn — a stray supervisor is squatting the socket and will fight the wake path; find and retire it (see platform issue #17)", rt.Host, rt.Socket)
+ } else {
+ log.Printf("agent: route %s is exec-shaped with hibernate=0 and its socket %s is live — an external process serves it, the activator never engages, and spawn-time config delivery (/data/%s.config.env) never reaches it; retire the external supervisor and set hibernate=1 once the app speaks the activation contract (see platform issue #17)", rt.Host, rt.Socket, rt.Host)
+ }
+ }
+ }
+}
+```
+
+Verify `reg.List()` is the list method `configSyncLoop`'s callee `appconfig.SyncExec` uses (`internal/appconfig/syncexec.go:58` calls `reg.List()`); if the registry method is named differently, follow the code.
+
+- [ ] **Step 4: Add `Owns` to `internal/activator/activator.go`** (place directly above `Bounce`, ~line 696):
+
+```go
+// Owns reports whether host has a live entry in ANY state — waking,
+// awake, or stopping. This is the external-socket advisory's predicate
+// (issue #17): while an entry exists, whatever is serving the host's
+// socket is this activator's own child — possibly still binding its
+// listener, or draining SIGTERM — so any-state presence, not awake-only,
+// is what keeps the advisory from calling the activator's own children
+// strays.
+func (a *Service) Owns(host string) bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.entries[host] != nil
+}
+```
+
+- [ ] **Step 5: Test `Owns` in `internal/activator/activator_test.go`** — reuse the package's existing fake-backend harness (see `TestActivatorDueTimeWakeCycle` at ~line 1225 and the `entryAwake` helper at ~line 1215). Assertions: before any request `Owns("h") == false`; after a request wakes the instance (however the neighboring tests do it) `Owns` is true while `entryAwake` is true; after the harness's stop/retire path completes, `Owns` returns false again; `Owns("never-seen")` is false throughout. Follow the naming and structure of the surrounding tests.
+
+- [ ] **Step 6: Wire the loop in `RunAgent`** (`internal/host/agent.go`, with the sibling `go` lines at ~146-150):
+
+```go
+ go socketAdvisoryLoop(reg, svc)
+```
+
+- [ ] **Step 7: Run** — `go build ./... && go vet ./... && go test ./internal/host/ ./internal/activator/ -count=1` — expect PASS; then `gofmt -l` on touched files — expect empty. Do not commit.
+
+---
+
+### Task 2: Batch the backing-mismatch systemctl probe (model: Sonnet)
+
+**Files:**
+- Modify: `internal/host/adopt_advice.go`
+- Test: `internal/host/adopt_advice_test.go`
+
+**Interfaces:**
+- Produces: `func warnBackingMismatch(routes []registry.Route, unitsActive func(hosts []string) map[string]bool) []string` (signature change); `func unitsAreActive(hosts []string) map[string]bool` replacing `unitIsActive`.
+
+- [ ] **Step 1: Rework the tests** — read `internal/host/adopt_advice_test.go`, adapt every existing `warnBackingMismatch` call to the new batch seam, and ADD a test pinning single-invocation batching:
+
+```go
+// One probe call carries every candidate — the PR #16 deferred minor this
+// rework exists for. A per-host seam would be called len(candidates) times.
+func TestWarnBackingMismatchProbesAllCandidatesInOneCall(t *testing.T) {
+ routes := []registry.Route{
+ {Kind: "instance", Host: "a.example"},
+ {Kind: "instance", Host: "b.example"},
+ {Kind: "instance", Host: "unit.example", Backing: registry.BackingUnit},
+ {Kind: "proxy", Host: "not-instance.example"},
+ }
+ var calls [][]string
+ warnBackingMismatch(routes, func(hosts []string) map[string]bool {
+ calls = append(calls, hosts)
+ return map[string]bool{"b.example": true}
+ })
+ if len(calls) != 1 {
+ t.Fatalf("probe called %d times, want 1", len(calls))
+ }
+ want := []string{"a.example", "b.example"}
+ if !reflect.DeepEqual(calls[0], want) {
+ t.Errorf("candidates = %v, want %v", calls[0], want)
+ }
+}
+
+// Zero candidates must mean zero probe calls — no systemctl exec at all.
+func TestWarnBackingMismatchSkipsProbeWithNoCandidates(t *testing.T) {
+ routes := []registry.Route{{Kind: "instance", Host: "u", Backing: registry.BackingUnit}}
+ warnBackingMismatch(routes, func(hosts []string) map[string]bool {
+ t.Fatal("probe called with no candidates")
+ return nil
+ })
+}
+```
+
+(Adjust route literals to whatever shapes the existing tests use — keep their conventions; check the actual field names in the existing file.)
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/host/ -run BackingMismatch -count=1` — expect compile failure (signature mismatch).
+
+- [ ] **Step 3: Implement** — in `internal/host/adopt_advice.go` replace the per-host seam:
+
+```go
+// warnBackingMismatch reports hosts whose route claims exec backing
+// ("") while a carlos-app@<host> unit is actively running — the state
+// where release adoption still works but config sync silently skips the
+// route. unitsActive answers for ALL candidates in one probe (one
+// systemctl invocation, not one per route). Returns the warned hosts;
+// the caller logs them.
+func warnBackingMismatch(routes []registry.Route, unitsActive func(hosts []string) map[string]bool) []string {
+ var candidates []string
+ for _, rt := range routes {
+ if rt.Kind != "instance" || rt.Platform || rt.Backing == registry.BackingUnit {
+ continue
+ }
+ candidates = append(candidates, rt.Host)
+ }
+ if len(candidates) == 0 {
+ return nil
+ }
+ active := unitsActive(candidates)
+ var warned []string
+ for _, h := range candidates {
+ if active[h] {
+ warned = append(warned, h)
+ }
+ }
+ return warned
+}
+
+// unitsAreActive: the real batched systemd probe. `systemctl is-active`
+// prints one state line per named unit, positionally, and exits non-zero
+// when ANY unit is not active — the exit code is meaningless for a batch,
+// so only stdout is read.
+func unitsAreActive(hosts []string) map[string]bool {
+ args := []string{"is-active"}
+ for _, h := range hosts {
+ args = append(args, "carlos-app@"+h+".service")
+ }
+ out, _ := exec.Command("systemctl", args...).Output()
+ lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n")
+ active := make(map[string]bool, len(hosts))
+ for i, h := range hosts {
+ if i < len(lines) && strings.TrimSpace(lines[i]) == "active" {
+ active[h] = true
+ }
+ }
+ return active
+}
+```
+
+Update `adviseBackingMismatches` to pass `unitsAreActive`. Add `"strings"` to imports; drop `unitIsActive`.
+
+- [ ] **Step 4: Run** — `go build ./... && go vet ./... && go test ./internal/host/ -count=1` then `gofmt -l internal/host/adopt_advice.go internal/host/adopt_advice_test.go` — expect PASS / empty. Do not commit.
+
+---
+
+### Task 3: `.config` namespace-collision guards + fetch-failure sweep-isolation test (model: Sonnet)
+
+**Files:**
+- Modify: `internal/appconfig/syncexec.go`
+- Test: `internal/appconfig/syncexec_test.go`
+
+**Interfaces:**
+- Consumes: `configEnvSuffix` (".config.env"), `reapConfigEnv`, `SyncExec`'s per-route convergence loop, the test file's existing store/registry fakes.
+- Produces: no exported surface change.
+
+**The hazard (from the PR #16 deferred list):** host `x`'s sync-owned file `x.config.env` is byte-identical in NAME to host `x.config`'s hand-staged instance-env file (`<host>.env`). Unguarded: (a) `reapConfigEnv` deletes live route `x.config`'s instance env whenever no route `x` exists; (b) `SyncExec` converging route `x` overwrites or removes route `x.config`'s instance env.
+
+- [ ] **Step 1: Write failing tests** in `internal/appconfig/syncexec_test.go`, following the file's existing fixture conventions (read a neighboring test such as `TestSyncExecReapsOnlyItsOwnNamespace` at ~line 495 first and reuse its helpers):
+
+ - `TestReapSparesLiveRoutesInstanceEnvWithConfigSuffix`: dataDir contains `x.config.env`; routes contain a live route with `Host: "x.config"` and NO route `x`. After the sweep the file must still exist.
+ - `TestSyncExecRefusesConfigNamespaceCollision`: routes `x` (socket exec shape, manifest present in the store) and `x.config` both live; pre-stage `x.config.env` with sentinel content (it is `x.config`'s instance env). After `SyncExec`: the file's bytes are unchanged (route `x` was refused, loudly), and route `x.config`'s own config file `x.config.config.env` converged normally.
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/appconfig/ -run 'ConfigSuffix|NamespaceCollision' -count=1` — expect FAIL (file deleted / bytes clobbered).
+
+- [ ] **Step 3: Implement both guards** in `internal/appconfig/syncexec.go`:
+
+ (a) in `reapConfigEnv`, after the existing `if live[host] { continue }`:
+
+```go
+ if live[strings.TrimSuffix(e.Name(), ".env")] {
+ // The name is ambiguous: <x>.config.env is host x's sync-owned
+ // config file AND host x.config's hand-staged instance env
+ // (<host>.env). With no route x but a live route x.config, the
+ // bytes on disk may be the latter — another owner's file, never
+ // deleted here.
+ fmt.Printf(" %s: not reaped — live route %q claims this name as its instance env\n", e.Name(), strings.TrimSuffix(e.Name(), ".env"))
+ continue
+ }
+```
+
+ (b) in `SyncExec`, build a host set once (`hostSet := make(map[string]bool)` over `routes`) and, in the per-route convergence path(s), refuse before ANY `syncExecHost` call (both the socket and the addr shape reach `syncExecHost`; guard every path exactly once per route):
+
+```go
+ if hostSet[rt.Host+".config"] {
+ fmt.Printf(" %s: not converging — route %q exists on this box and %s%s is its instance-env file (<host>.env); refusing to write into another owner's namespace\n", rt.Host, rt.Host+".config", rt.Host, configEnvSuffix)
+ continue
+ }
+```
+
+ Read `SyncExec`'s body first — the convergence loop and the addr-shape handling changed with issue #18; place the guard so every route that would reach `syncExecHost` passes through it once.
+
+- [ ] **Step 4: Run to verify pass** — same command as Step 2, expect PASS.
+
+- [ ] **Step 5: Fetch-failure sweep-isolation coverage (M4)** — first CHECK whether any existing test already has one group's store fetch erroring while ANOTHER app's group converges in the same `SyncExec` pass (`grep -n "errors.New\|fetchErr\|failStore" internal/appconfig/syncexec_test.go` and read the hits — `TestSyncExecOptedInAddrRouteFetchFailureLogsAndLeavesFileAsIs` covers only the failing route's leave-alone half). If the cross-group half is genuinely uncovered, add:
+
+ - `TestSyncExecFetchFailureLeavesOtherGroupsConverging`: two socket-exec routes, apps `appa` and `appb`; the store fake errors on `appa`'s manifest key and serves `appb`'s. Pre-stage `hosta`'s config file with stale content. After one `SyncExec` pass: `hosta`'s file bytes unchanged (error ≠ absence — not removed), `hostb`'s file converged, and the pass returned without aborting the sweep.
+
+ If it IS already covered, state where and skip the new test.
+
+- [ ] **Step 6: Run** — `go build ./... && go vet ./... && go test ./internal/appconfig/ -count=1` then `gofmt -l internal/appconfig/syncexec.go internal/appconfig/syncexec_test.go` — expect PASS / empty. Do not commit.
+
+---
+
+## Orchestrator-only closing steps (not for subagents)
+
+- [ ] Opus review of the full diff (spec conformance + the repo's paid-for rules), fixes applied
+- [ ] `go build ./... && go vet ./... && go test ./... -count=1` full sweep, `gofmt -l` clean
+- [ ] Commit per task, push `external-socket-advisory`, open draft PR referencing #17 and the PR #16 deferred list
+- [ ] File platform issue: adoptLoop/configSyncLoop boot retry/backoff (PR #16 deferred item)
+- [ ] File carlosframework/rastrillo issue: `--db` / activation contract, referencing platform #17
+- [ ] Delete leftover ops/ artifacts from prod buckets (staged 079b085 binaries, fix/revert scripts)
diff --git a/docs/superpowers/specs/2026-08-03-external-socket-advisory-design.md b/docs/superpowers/specs/2026-08-03-external-socket-advisory-design.md
new file mode 100644
index 000000000..fb54dbcc8
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-03-external-socket-advisory-design.md
@@ -0,0 +1,137 @@
+# External-socket advisory (issue #17) + exec-delivery deferred minors — design
+
+Date: 2026-08-03. Follows from issue #17 (dev live-proof finding) and the
+deferred-minors comment on PR #16. Autonomous-session design: approaches were
+weighed inline; the user directive was "go ahead with #17 and any other loose
+ends, dispatched to appropriate capability agents."
+
+## Problem
+
+An exec-shaped route (`kind=instance`, `backing≠unit`, socket upstream) can
+have its socket served by a process the platform does not manage — dev's
+`helloworld.service` incident. Consequences: spawn-time config delivery
+(`/data/<host>.config.env` merge) never runs for the route; kills get
+resurrected by the stray supervisor and read as a misbehaving app; and with
+`hibernate=1` a squatter would fight the activator's wake path. The existing
+`adviseBackingMismatches` (#14) checks only `carlos-app@<host>.service`, so
+this shape is invisible to it.
+
+## Why the detector lives in the agent, not the adopt pass
+
+`adviseBackingMismatches` runs in `RunAdopt` — a separate root process that
+cannot see the activator's in-memory entry table, which is the only honest
+answer to "did the activator spawn the thing serving this socket". The agent
+process (`RunAgent`) holds both the registry and the `*activator.Service`, and
+already runs sibling hourly loops. The detector is therefore a new agent loop.
+
+## Design
+
+### Predicate (pure, injectable — mirrors `warnBackingMismatch`'s shape)
+
+New file `internal/host/socket_advice.go`:
+
+```go
+// warnExternalSockets returns routes whose socket is being SERVED by a
+// process the activator does not manage.
+func warnExternalSockets(routes []registry.Route,
+ serving func(socket string) bool,
+ owns func(host string) bool) []registry.Route
+```
+
+Route shape in scope: mirrors `appconfig.execSyncable` — `Kind=="instance"`,
+`App != ""`, `!Platform`, `Status` empty or `"ready"`, `Backing !=
+registry.BackingUnit`, `Socket != ""`. Addr-upstream routes (issue #18) are
+externally managed **by design** and must never warn. Unit-backed routes
+belong to the #14 advisory.
+
+Warn condition per in-scope route: `serving(rt.Socket) && !(rt.Hibernate &&
+owns(rt.Host))`.
+
+- `hibernate=0` + live socket → externally managed, always: the edge's
+ activator gate (`internal/edge/edge.go`, `hib := …`) only engages the
+ activator for hibernate-flagged instances, so nothing the platform manages
+ can be serving that socket.
+- `hibernate=1` + live socket + no activator entry → a stray supervisor is
+ squatting a socket the activator believes it owns.
+- `svc == nil` (activator unarmed) → `owns` is constantly false; live sockets
+ on exec routes still warn, correctly.
+
+### `Service.Owns`
+
+New exported method on `activator.Service`: entry present for host in **any**
+state (waking/awake/stopping), under `a.mu`. Any-state, not awake-only, is
+what makes the advisory false-positive-safe: a child that is still binding its
+listener (waking) or draining SIGTERM (stopping) is activator-owned the whole
+time its entry exists, and the entry is forgotten only after the child is
+stopped. The residual race (probe lands in the instant between entry
+forgetting and listener close) is tolerated: the advisory is hourly and log-only.
+
+### Probe
+
+`net.DialTimeout("unix", socket, 500*time.Millisecond)`; on success close and
+report true. Connection refused / no such file → false (a stale socket *file*
+nobody listens on is not "served"). The agent can reach `/run/carlos` (it is
+in the unit's ReadWritePaths) and the sockets are its own children's when
+healthy.
+
+### Loop and log lines
+
+`socketAdvisoryLoop(reg *registry.Registry, svc *activator.Service)` started
+from `RunAgent` beside the other loops: first pass ~1 minute after boot, then
+hourly plus jitter (same cadence family as `configSyncLoop`). Needs no env
+arming — it reads the registry and dials sockets, nothing more.
+
+Two log variants (the caller formats on `rt.Hibernate`):
+
+- hibernate=0: socket live, activator structurally never engages — name the
+ consequence (spawn-time config delivery never runs; `Bounce` on config
+ change is a no-op) and the remediation (retire the external supervisor and
+ set `hibernate=1` once the app speaks the activation contract — see
+ platform issue #17; rastrillo v1 apps do not yet).
+- hibernate=1: socket live but no activator entry — a stray process is
+ squatting the socket and will fight the wake path; find and retire it.
+
+Dev's helloworld route (deliberately external until rastrillo can hibernate)
+WILL warn hourly. That is correct and accepted — same operator-facing posture
+as #14's advisory, which fired until the row was stamped.
+
+## Deferred minors from PR #16 (this branch)
+
+- **M1 — batch `systemctl is-active`**: `warnBackingMismatch` probes one unit
+ per candidate route. Rework the injectable seam to a batch probe:
+ `unitsActive(hosts []string) map[string]bool` implemented by ONE
+ `systemctl is-active` invocation naming every `carlos-app@<host>.service`
+ (output is one state line per unit, positional; exit code is non-zero when
+ any unit is inactive, so the implementation reads stdout, not the exit
+ code). Zero candidates → no exec at all. Behavior otherwise unchanged.
+- **M3 — `.config` host-collision guard**: host `x`'s sync-owned file
+ `x.config.env` is byte-identical in name to host `x.config`'s hand-staged
+ instance-env file `<host>.env`. Two protective guards, both loud:
+ (a) `reapConfigEnv` skips deleting `<name>.config.env` when
+ `live[<name>.config.env minus ".env">]` — i.e. the file is a live route's
+ instance env, not an orphaned config file; (b) `SyncExec` refuses (prints,
+ skips) converging a route `x` when a route named `x.config` exists on the
+ box, in either direction of existing file state — never write into another
+ owner's namespace. Tests pin both.
+- **M4 — fetch-failure sweep-isolation test**: verify (add only if truly
+ missing) a test where the store fails for one app's group mid-sweep while a
+ second app's group still converges in the same pass — the die-free-per-group
+ contract. Existing `TestSyncExecOptedInAddrRouteFetchFailureLogsAndLeavesFileAsIs`
+ covers leave-alone for the failing route; the cross-group half is the gap.
+
+## Out of scope (recorded elsewhere)
+
+- rastrillo `--db` support (or an activator no-db spawn shape) — framework
+ repo issue, filed against carlosframework/rastrillo referencing #17.
+- Boot retry/backoff for `adoptLoop`/`configSyncLoop` registry-open failure —
+ platform issue, per the PR #16 comment ("shared ticket if it ever bites").
+- `CARLOS_CONTROL_SOCKET` comment minor — already superseded on main by the
+ purge-channel work's fuller spoofing rationale in `backend_exec.go`.
+
+## Testing
+
+Pure-function table tests for `warnExternalSockets` (fake `serving`/`owns`);
+one real-unix-socket test pinning the dial probe against a live listener, a
+refused socket file, and a missing path; `Service.Owns` covered via the
+activator package's existing test harness; M1/M3/M4 as above. `go build`,
+`go vet`, `gofmt -l`, `go test ./... -count=1` all clean before commit.
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index 6c98be5ea..96aa4753e 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -686,6 +686,19 @@ func (a *Service) beginStopLocked(e *actEntry) {
go a.stop(e)
}
+// Owns reports whether host has a live entry in ANY state — waking,
+// awake, or stopping. This is the external-socket advisory's predicate
+// (issue #17): while an entry exists, whatever is serving the host's
+// socket is this activator's own child — possibly still binding its
+// listener, or draining SIGTERM — so any-state presence, not awake-only,
+// is what keeps the advisory from calling the activator's own children
+// strays.
+func (a *Service) Owns(host string) bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.entries[host] != nil
+}
+
// Bounce initiates a graceful stop of host's RUNNING instance so that
// its next wake — the next request, or its schedule — respawns it with
// a freshly composed environment. This is config delivery's restart
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go
index ddf337c06..eb9ad4e20 100644
--- a/internal/activator/activator_test.go
+++ b/internal/activator/activator_test.go
@@ -1208,6 +1208,51 @@ func TestBounceStopsAwakeInstanceOnly(t *testing.T) {
}
}
+// Owns is the external-socket advisory's predicate (issue #17): a live entry
+// in ANY state means whatever serves the host's socket is this activator's own
+// child, so a host draining its SIGTERM must still read as owned — otherwise
+// the advisory calls the activator's own children strays.
+func TestOwnsCoversEntryLifetimeAnyState(t *testing.T) {
+ a, b, clk, _ := testActivator(t, 10*time.Second)
+ host := "own.example.com"
+ if a.Owns(host) {
+ t.Fatal("Owns(host) = true before any request, want false")
+ }
+
+ release, err := a.beginRequest(context.Background(), testRoute(host))
+ if err != nil {
+ t.Fatalf("beginRequest: %v", err)
+ }
+ release()
+ if !a.entryAwake(host) || !a.Owns(host) {
+ t.Fatalf("awake: entryAwake=%v Owns=%v, want both true", a.entryAwake(host), a.Owns(host))
+ }
+
+ // Hold the stop open so the entry sits in stopping.
+ gate := make(chan struct{})
+ p := b.lastProc()
+ p.mu.Lock()
+ p.stopGate = gate
+ p.mu.Unlock()
+ clk.advance(time.Minute)
+ a.sweepOnce()
+ if a.entryAwake(host) {
+ t.Fatal("entry still awake after the idle sweep, want stopping")
+ }
+ if !a.Owns(host) {
+ t.Error("Owns(stopping host) = false, want true")
+ }
+ close(gate)
+
+ waitFor(t, "hibernation", func() bool { return a.entryCount() == 0 })
+ if a.Owns(host) {
+ t.Error("Owns(host) = true after the entry was retired, want false")
+ }
+ if a.Owns("never-woken.example.com") {
+ t.Error("Owns(unknown host) = true, want false")
+ }
+}
+
// --- due-time index --------------------------------------------------------------
// entryAwake reports whether host has a live entry in the awake state — how the
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go
index fe81811db..d6ea31337 100644
--- a/internal/appconfig/syncexec.go
+++ b/internal/appconfig/syncexec.go
@@ -60,6 +60,15 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
if err != nil {
return err
}
+ // hostSet names every route on this box, in or out of scope, so the
+ // namespace-collision guard below sees a route x.config even though
+ // x.config itself may never reach syncExecHost. Built once: the guard
+ // runs once per in-scope route in the materialization loop, and every
+ // route's Host is fixed for the pass.
+ hostSet := make(map[string]bool, len(routes))
+ for _, rt := range routes {
+ hostSet[rt.Host] = true
+ }
type groupKey struct{ acct, app, env string }
// The same three states Sync resolves to, with this path's own file
// semantics for the middle one: a manifest to converge to; "never
@@ -293,6 +302,18 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
if r.m == nil && skipRemovals {
continue
}
+ // Namespace-collision guard, checked before EITHER shape can reach
+ // syncExecHost below (the single call site both shapes share): if a
+ // route "<rt.Host>.config" also lives on this box, its hand-staged
+ // instance env (<host>.env for THAT route) is named
+ // "<rt.Host>.config.env" — byte-identical to what converging rt
+ // would write here. Refuse loudly rather than clobber another
+ // owner's file; see reapConfigEnv's twin guard for the
+ // deletion-side half of the same hazard.
+ if hostSet[rt.Host+".config"] {
+ fmt.Printf(" %s: not converging — route %q exists on this box and %s%s is its instance-env file (<host>.env); refusing to write into another owner's namespace\n", rt.Host, rt.Host+".config", rt.Host, configEnvSuffix)
+ continue
+ }
// The bucket key whose absence would drive a removal, named in the
// removal log so cause and effect land on one line. Always the
// DEFAULT bundle's key: removal is reachable only from the absent
@@ -570,6 +591,15 @@ func reapConfigEnv(routes []registry.Route, dataDir string) {
if live[host] {
continue
}
+ if asInstanceEnvHost := strings.TrimSuffix(e.Name(), ".env"); live[asInstanceEnvHost] {
+ // The name is ambiguous: <x>.config.env is host x's sync-owned
+ // config file AND host x.config's hand-staged instance env
+ // (<host>.env). With no route x but a live route x.config, the
+ // bytes on disk may be the latter — another owner's file, never
+ // deleted here.
+ fmt.Printf(" %s: not reaped — live route %q claims this name as its instance env\n", e.Name(), asInstanceEnvHost)
+ continue
+ }
if err := os.Remove(filepath.Join(dataDir, e.Name())); err != nil {
fmt.Printf(" %s: remove orphaned config file: %v\n", host, err)
continue
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go
index a5f12f875..11f5b632d 100644
--- a/internal/appconfig/syncexec_test.go
+++ b/internal/appconfig/syncexec_test.go
@@ -1330,3 +1330,181 @@ func TestSyncExecConvergesToEmptyFileWhenAllVarsUnset(t *testing.T) {
t.Fatalf("bounced = %v, want exactly one bounce for the change to empty", bounced)
}
}
+
+// TestReapSparesLiveRoutesInstanceEnvWithConfigSuffix covers the PR #16
+// deferred namespace-collision hazard: host "x.config"'s hand-staged
+// instance env is named "x.config.env" (<host>.env for host "x.config"),
+// which is byte-identical to what host "x"'s SYNC-OWNED file would be
+// named. With no route "x" at all, the naive reap trims configEnvSuffix off
+// "x.config.env", sees host "x", finds it not live, and deletes what is
+// actually route "x.config"'s own instance env — another owner's file.
+func TestReapSparesLiveRoutesInstanceEnvWithConfigSuffix(t *testing.T) {
+ ctx, s, reg, dd, priv, _ := execKit(t)
+ pk, _ := ParsePrivateKey(priv, "test")
+ path := filepath.Join(dd, "x.config.env")
+ sentinel := []byte("SENTINEL=owned-by-x-config\n")
+ if err := os.WriteFile(path, sentinel, 0o600); err != nil {
+ t.Fatalf("stage: %v", err)
+ }
+ // A live route named "x.config" and, critically, no route "x" at all.
+ if err := reg.Upsert(registry.Route{
+ Host: "x.config", Kind: "instance", App: "eleven", Account: "bab", Status: "ready",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ out := captureStdout(t, func() {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ })
+ // Pin the guard's own text, not just the file's survival — a passing
+ // file-survival check alone wouldn't distinguish "the guard fired" from
+ // "reap never ran at all".
+ if !strings.Contains(out, "not reaped") || !strings.Contains(out, "claims this name as its instance env") {
+ t.Fatalf("stdout = %q, want the guard's own refusal text", out)
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("x.config's instance env was reaped: %v", err)
+ }
+ if !bytes.Equal(got, sentinel) {
+ t.Fatalf("x.config's instance env bytes changed: got %q, want %q", got, sentinel)
+ }
+}
+
+// TestSyncExecRefusesConfigNamespaceCollision covers the other half of the
+// same hazard: route "x" IS live (an ordinary socket-exec route) alongside
+// route "x.config". Converging route x would write/rename into
+// "x.config.env" — route x.config's hand-staged instance env, not route x's
+// own namespace. Route x must be refused, loudly, and route x.config's OWN
+// config file ("x.config.config.env") must still converge normally: the
+// guard protects one file without collaterally blocking the unrelated
+// route that happens to produce the colliding name.
+func TestSyncExecRefusesConfigNamespaceCollision(t *testing.T) {
+ ctx, s, reg, dd, priv, _ := execKit(t)
+ pk, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := SetEnv(ctx, s, "bab", "eleven", map[string]string{"X": "1"}); err != nil {
+ t.Fatalf("SetEnv (x's app): %v", err)
+ }
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"Y": "2"}); err != nil {
+ t.Fatalf("SetEnv (x.config's app): %v", err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "x", Kind: "instance", App: "eleven", Account: "bab", Status: "ready",
+ Socket: "/run/carlos/x.sock",
+ }); err != nil {
+ t.Fatalf("Upsert x: %v", err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "x.config", Kind: "instance", App: "titogo", Account: "bab", Status: "ready",
+ Socket: "/run/carlos/xc.sock",
+ }); err != nil {
+ t.Fatalf("Upsert x.config: %v", err)
+ }
+ // x.config's hand-staged instance env, pre-staged with sentinel bytes:
+ // its name ("x.config.env") is exactly what route x's own sync-owned
+ // config file would be named.
+ collidingPath := filepath.Join(dd, "x.config.env")
+ sentinel := []byte("SENTINEL=owned-by-x-config\n")
+ if err := os.WriteFile(collidingPath, sentinel, 0o600); err != nil {
+ t.Fatalf("stage: %v", err)
+ }
+ var out string
+ var bounced []string
+ bounce := func(h string) error { bounced = append(bounced, h); return nil }
+ out = captureStdout(t, func() {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ })
+ got, err := os.ReadFile(collidingPath)
+ if err != nil {
+ t.Fatalf("read colliding file: %v", err)
+ }
+ if !bytes.Equal(got, sentinel) {
+ t.Fatalf("x.config's instance env was overwritten by route x's convergence: got %q, want %q", got, sentinel)
+ }
+ if slices.Contains(bounced, "x") {
+ t.Fatalf("bounced = %v, want route x never bounced (it was refused, not converged)", bounced)
+ }
+ // "x.config" alone is a vacuous assertion here — route x.config's own
+ // convergence prints " x.config: config updated" regardless of whether
+ // the guard fired at all. Pin the guard's OWN distinctive text instead.
+ if !strings.Contains(out, "not converging") || !strings.Contains(out, "refusing to write into another owner's namespace") {
+ t.Fatalf("stdout = %q, want the guard's own refusal text", out)
+ }
+ // Route x.config's OWN config file is unrelated to the guard and must
+ // converge exactly as it would with no collision in play.
+ xConfigOwn, err := os.ReadFile(filepath.Join(dd, "x.config.config.env"))
+ if err != nil {
+ t.Fatalf("x.config's own config file did not converge: %v", err)
+ }
+ if string(xConfigOwn) != "Y=2\n" {
+ t.Fatalf("x.config's own config file = %q, want Y=2", xConfigOwn)
+ }
+}
+
+// TestSyncExecFetchFailureLeavesOtherGroupsConverging is the M4
+// cross-group half TestSyncExecOptedInAddrRouteFetchFailureLogsAndLeavesFileAsIs
+// doesn't cover (that test has exactly one group in the pass): one app's
+// manifest fetch fails while a DIFFERENT app's group, in the SAME pass,
+// converges normally. A fetch failure must stay scoped to its own group —
+// it must neither remove/leave-stale the failing group's file (error is not
+// absence) NOR abort the sweep for every other group on the box.
+func TestSyncExecFetchFailureLeavesOtherGroupsConverging(t *testing.T) {
+ ctx, s, reg, dd, priv, _ := execKit(t)
+ pk, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := SetEnv(ctx, s, "bab", "appa", map[string]string{"A": "1"}); err != nil {
+ t.Fatalf("SetEnv appa: %v", err)
+ }
+ if err := SetEnv(ctx, s, "bab", "appb", map[string]string{"B": "2"}); err != nil {
+ t.Fatalf("SetEnv appb: %v", err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "hosta", Kind: "instance", App: "appa", Account: "bab", Status: "ready",
+ Socket: "/run/carlos/a.sock",
+ }); err != nil {
+ t.Fatalf("Upsert hosta: %v", err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "hostb", Kind: "instance", App: "appb", Account: "bab", Status: "ready",
+ Socket: "/run/carlos/b.sock",
+ }); err != nil {
+ t.Fatalf("Upsert hostb: %v", err)
+ }
+ stalePath := filepath.Join(dd, "hosta.config.env")
+ stale := []byte("STALE=1\n")
+ if err := os.WriteFile(stalePath, stale, 0o600); err != nil {
+ t.Fatalf("stage: %v", err)
+ }
+ blipErr := errors.New("s3: transient 503 SlowDown")
+ failing := &failingKeyStore{Store: s, failKey: Key("bab", "appa"), err: blipErr}
+ var bounced []string
+ bounce := func(h string) error { bounced = append(bounced, h); return nil }
+ if err := SyncExec(ctx, reg, failing, pk, dd, bounce); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ got, err := os.ReadFile(stalePath)
+ if err != nil {
+ t.Fatalf("read hosta config: %v", err)
+ }
+ if !bytes.Equal(got, stale) {
+ t.Fatalf("hosta's config changed despite its own group's fetch failure: got %q, want %q", got, stale)
+ }
+ gotB, err := os.ReadFile(filepath.Join(dd, "hostb.config.env"))
+ if err != nil {
+ t.Fatalf("hostb's config didn't converge in the same pass: %v", err)
+ }
+ if string(gotB) != "B=2\n" {
+ t.Fatalf("hostb config = %q, want B=2", gotB)
+ }
+ if !slices.Contains(bounced, "hostb") || slices.Contains(bounced, "hosta") {
+ t.Fatalf("bounced = %v, want exactly [hostb] — appa's route left untouched, appb's route converged", bounced)
+ }
+}
diff --git a/internal/host/adopt_advice.go b/internal/host/adopt_advice.go
index 4e0a3691a..5357713c9 100644
--- a/internal/host/adopt_advice.go
+++ b/internal/host/adopt_advice.go
@@ -7,6 +7,7 @@ package host
import (
"log"
"os/exec"
+ "strings"
"github.com/carlosframework/platform/internal/registry"
)
@@ -14,30 +15,70 @@ import (
// warnBackingMismatch reports hosts whose route claims exec backing
// ("") while a carlos-app@<host> unit is actively running — the state
// where release adoption still works but config sync silently skips the
-// route. Returns the warned hosts; the caller logs them.
-func warnBackingMismatch(routes []registry.Route, unitActive func(host string) bool) []string {
- var warned []string
+// route. unitsActive answers for ALL candidates in one probe (one
+// systemctl invocation, not one per route). Returns the warned hosts;
+// the caller logs them.
+func warnBackingMismatch(routes []registry.Route, unitsActive func(hosts []string) map[string]bool) []string {
+ var candidates []string
for _, rt := range routes {
if rt.Kind != "instance" || rt.Platform || rt.Backing == registry.BackingUnit {
continue
}
- if unitActive(rt.Host) {
- warned = append(warned, rt.Host)
+ candidates = append(candidates, rt.Host)
+ }
+ if len(candidates) == 0 {
+ return nil
+ }
+ active := unitsActive(candidates)
+ var warned []string
+ for _, h := range candidates {
+ if active[h] {
+ warned = append(warned, h)
}
}
return warned
}
-// unitIsActive: the real systemd probe for warnBackingMismatch.
-func unitIsActive(host string) bool {
- return exec.Command("systemctl", "is-active", "--quiet", "carlos-app@"+host+".service").Run() == nil
+// unitsAreActive: the real batched systemd probe. `systemctl is-active`
+// prints one state line per named unit, positionally, and exits non-zero
+// when ANY unit is not active — the exit code is meaningless for a batch,
+// so only stdout is read.
+func unitsAreActive(hosts []string) map[string]bool {
+ args := []string{"is-active"}
+ for _, h := range hosts {
+ args = append(args, "carlos-app@"+h+".service")
+ }
+ out, _ := exec.Command("systemctl", args...).Output()
+ return parseIsActiveBatch(out, hosts)
+}
+
+// parseIsActiveBatch maps `systemctl is-active` stdout onto hosts
+// positionally (named distinctly from probes.go's single-unit parseIsActive,
+// whose tri-state *bool shape this is not). A mismatched line count cannot
+// be attributed positionally — a short batch would misattribute "active" to
+// the wrong host, and the resulting warning tells an operator to delete the
+// wrong host's decrypted secrets file. No warnings beats wrong warnings, so
+// a length mismatch returns an empty map.
+func parseIsActiveBatch(out []byte, hosts []string) map[string]bool {
+ lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n")
+ if len(lines) != len(hosts) {
+ return map[string]bool{}
+ }
+ active := make(map[string]bool, len(hosts))
+ for i, h := range hosts {
+ switch strings.TrimSpace(lines[i]) {
+ case "active", "reloading":
+ active[h] = true
+ }
+ }
+ return active
}
// adviseBackingMismatches runs the advisory against live systemd and
// logs each finding — called from RunAdopt, root-side, where systemctl
// is reachable and the hourly log is read.
func adviseBackingMismatches(routes []registry.Route) {
- for _, h := range warnBackingMismatch(routes, unitIsActive) {
+ for _, h := range warnBackingMismatch(routes, unitsAreActive) {
log.Printf("adopt: route %s claims exec backing but carlos-app@%s is ACTIVE — stamp backing='unit' (see platform issue #14), then remove the stray /data/%s.config.env the exec loop materialized — the unit reads /etc/carlos/apps instead", h, h, h)
}
}
diff --git a/internal/host/adopt_advice_test.go b/internal/host/adopt_advice_test.go
index 8a11c8ef4..9caadd2da 100644
--- a/internal/host/adopt_advice_test.go
+++ b/internal/host/adopt_advice_test.go
@@ -1,6 +1,7 @@
package host
import (
+ "reflect"
"testing"
"github.com/carlosframework/platform/internal/registry"
@@ -18,8 +19,68 @@ func TestWarnBackingMismatch(t *testing.T) {
{Host: "alias.example.com", Kind: "instance", App: "kass", Platform: true}, // aliases never warn
}
active := map[string]bool{"mismatch.example.com": true, "honest-unit.example.com": true}
- got := warnBackingMismatch(routes, func(h string) bool { return active[h] })
+ got := warnBackingMismatch(routes, func(hosts []string) map[string]bool { return active })
if len(got) != 1 || got[0] != "mismatch.example.com" {
t.Fatalf("warned = %v, want exactly [mismatch.example.com]", got)
}
}
+
+// One probe call carries every candidate — the PR #16 deferred minor this
+// rework exists for. A per-host seam would be called len(candidates) times.
+func TestWarnBackingMismatchProbesAllCandidatesInOneCall(t *testing.T) {
+ routes := []registry.Route{
+ {Kind: "instance", Host: "a.example"},
+ {Kind: "instance", Host: "b.example"},
+ {Kind: "instance", Host: "unit.example", Backing: registry.BackingUnit},
+ {Kind: "proxy", Host: "not-instance.example"},
+ }
+ var calls [][]string
+ warnBackingMismatch(routes, func(hosts []string) map[string]bool {
+ calls = append(calls, hosts)
+ return map[string]bool{"b.example": true}
+ })
+ if len(calls) != 1 {
+ t.Fatalf("probe called %d times, want 1", len(calls))
+ }
+ want := []string{"a.example", "b.example"}
+ if !reflect.DeepEqual(calls[0], want) {
+ t.Errorf("candidates = %v, want %v", calls[0], want)
+ }
+}
+
+// Zero candidates must mean zero probe calls — no systemctl exec at all.
+func TestWarnBackingMismatchSkipsProbeWithNoCandidates(t *testing.T) {
+ routes := []registry.Route{{Kind: "instance", Host: "u", Backing: registry.BackingUnit}}
+ warnBackingMismatch(routes, func(hosts []string) map[string]bool {
+ t.Fatal("probe called with no candidates")
+ return nil
+ })
+}
+
+// parseIsActiveBatch maps systemctl is-active output onto hosts positionally.
+// A line-count mismatch cannot be attributed positionally — attributing it
+// anyway would tell an operator to delete the wrong host's decrypted
+// secrets file, so a mismatch must produce an empty map, not a guess.
+func TestParseIsActiveBatch(t *testing.T) {
+ hosts := []string{"a", "b", "c"}
+ cases := []struct {
+ name string
+ out string
+ want map[string]bool
+ }{
+ {"exact match", "active\ninactive\nactive\n", map[string]bool{"a": true, "c": true}},
+ {"short output", "active\ninactive\n", map[string]bool{}},
+ {"extra output", "active\ninactive\nactive\nactive\n", map[string]bool{}},
+ {"empty output", "", map[string]bool{}},
+ {"reloading counts as active", "reloading\ninactive\nactive\n", map[string]bool{"a": true, "c": true}},
+ {"failed does not count", "failed\ninactive\nactive\n", map[string]bool{"c": true}},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got := parseIsActiveBatch([]byte(c.out), hosts)
+ if !reflect.DeepEqual(got, c.want) {
+ t.Errorf("parseIsActiveBatch(%q) = %v, want %v", c.out, got, c.want)
+ }
+ })
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 794744dc4..d73c6c32f 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -149,6 +149,7 @@ func RunAgent(args []string) {
go domainsLoop(reg, peekAcmeCache(args))
go metricsLoop()
go configSyncLoop(reg, svc, dd)
+ go socketAdvisoryLoop(reg, svc)
// The same serving loop as `carlos edge`, with the activator injected.
// Caching applies here too: cached hits still avoid the activator, which
diff --git a/internal/host/socket_advice.go b/internal/host/socket_advice.go
new file mode 100644
index 000000000..183097eb1
--- /dev/null
+++ b/internal/host/socket_advice.go
@@ -0,0 +1,83 @@
+// External-socket advisory (issue #17): an exec-shaped route whose socket
+// is served by a process the activator does not manage. Advisory only,
+// like adopt_advice.go — log lines for the operator, never a mutation.
+// Lives in the AGENT, not the root adopt pass, because the only honest
+// answer to "did the activator spawn the thing serving this socket" is
+// the activator's in-memory entry table, which RunAdopt cannot see.
+package host
+
+import (
+ "log"
+ "math/rand/v2"
+ "net"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// warnExternalSockets returns the in-scope routes whose socket is being
+// SERVED by something the activator does not manage. In scope is the
+// spawnable-exec shape, mirroring appconfig's execSyncable: instance,
+// has an app, non-platform, ready, non-unit backing, socket upstream.
+// Addr-upstream routes (issue #18) are externally managed BY DESIGN and
+// never warn; unit-backed routes belong to the #14 advisory. The dial
+// runs before the owns check so a spawn racing the sweep reads as owned,
+// not stray.
+func warnExternalSockets(routes []registry.Route, serving func(socket string) bool, owns func(host string) bool) []registry.Route {
+ var warned []registry.Route
+ for _, rt := range routes {
+ if rt.Kind != "instance" || rt.App == "" || rt.Platform ||
+ (rt.Status != "" && rt.Status != "ready") ||
+ rt.Backing == registry.BackingUnit || rt.Socket == "" {
+ continue
+ }
+ if !serving(rt.Socket) {
+ continue
+ }
+ if rt.Hibernate && owns(rt.Host) {
+ continue // the activator's own child, in whatever state
+ }
+ warned = append(warned, rt)
+ }
+ return warned
+}
+
+// socketServing: the real probe. A live listener answers the dial; a
+// stale socket file nobody listens on refuses it — only the former is
+// "served". Same-box unix accept, so the timeout is generous.
+func socketServing(socket string) bool {
+ c, err := net.DialTimeout("unix", socket, 500*time.Millisecond)
+ if err != nil {
+ return false
+ }
+ c.Close()
+ return true
+}
+
+// socketAdvisoryLoop runs the advisory hourly, first pass ~1min after
+// boot, like its sibling loops. svc may be nil (activator unarmed):
+// owns is then constantly false and every live socket on an exec route
+// warns — correctly, nothing on this box manages them. Needs no env
+// arming: it only reads the registry and dials sockets.
+func socketAdvisoryLoop(reg *registry.Registry, svc *activator.Service) {
+ owns := func(host string) bool { return svc != nil && svc.Owns(host) }
+ log.Printf("agent: socket advisory armed (hourly + jitter)")
+ delay := time.Minute
+ for {
+ time.Sleep(delay)
+ delay = time.Hour + rand.N(10*time.Minute)
+ routes, err := reg.List()
+ if err != nil {
+ log.Printf("agent: socket advisory: list routes: %v", err)
+ continue
+ }
+ for _, rt := range warnExternalSockets(routes, socketServing, owns) {
+ if rt.Hibernate {
+ log.Printf("agent: route %s is activator-managed (hibernate=1) but its socket %s is served by a process the activator did not spawn — a stray supervisor is squatting the socket and will fight the wake path; find and retire it (see platform issue #17)", rt.Host, rt.Socket)
+ } else {
+ log.Printf("agent: route %s is exec-shaped with hibernate=0 and its socket %s is live — an external process serves it, the activator never engages, and spawn-time config delivery (/data/%s.config.env) never reaches it; retire the external supervisor and set hibernate=1 once the app speaks the activation contract (see platform issue #17)", rt.Host, rt.Socket, rt.Host)
+ }
+ }
+ }
+}
diff --git a/internal/host/socket_advice_test.go b/internal/host/socket_advice_test.go
new file mode 100644
index 000000000..990ede62e
--- /dev/null
+++ b/internal/host/socket_advice_test.go
@@ -0,0 +1,77 @@
+package host
+
+import (
+ "net"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestWarnExternalSocketsShapeAndConditions(t *testing.T) {
+ sock := func(h string) string { return "/run/carlos/" + h + ".sock" }
+ cases := []struct {
+ name string
+ rt registry.Route
+ serving, owns bool
+ warn bool
+ }{
+ {"hibernate=0 live socket warns", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x")}, true, false, true},
+ {"hibernate=0 dead socket quiet", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x")}, false, false, false},
+ {"hibernate=1 unowned live socket warns", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x"), Hibernate: true}, true, false, true},
+ {"hibernate=1 owned live socket quiet", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x"), Hibernate: true}, true, true, false},
+ {"hibernate=0 live socket ready status warns", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x"), Status: "ready"}, true, false, true},
+ {"addr route (issue #18) never warns", registry.Route{Kind: "instance", App: "a", Host: "x", Addr: "127.0.0.1:9000"}, true, false, false},
+ {"unit backing belongs to #14 advisory", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x"), Backing: registry.BackingUnit}, true, false, false},
+ {"platform route out of scope", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x"), Platform: true}, true, false, false},
+ {"appless route out of scope", registry.Route{Kind: "instance", Host: "x", Socket: sock("x")}, true, false, false},
+ {"non-ready status out of scope", registry.Route{Kind: "instance", App: "a", Host: "x", Socket: sock("x"), Status: "draining"}, true, false, false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ serving := func(string) bool { return c.serving }
+ owns := func(string) bool { return c.owns }
+ got := warnExternalSockets([]registry.Route{c.rt}, serving, owns)
+ if (len(got) == 1) != c.warn {
+ t.Errorf("warn = %v, want %v", len(got) == 1, c.warn)
+ }
+ })
+ }
+}
+
+// The probe distinguishes a live LISTENER from a stale socket FILE and a
+// missing path — only the first is "served".
+func TestSocketServingProbe(t *testing.T) {
+ dir := t.TempDir()
+ live := filepath.Join(dir, "live.sock")
+ l, err := net.Listen("unix", live)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer l.Close()
+ go func() {
+ for {
+ c, err := l.Accept()
+ if err != nil {
+ return
+ }
+ c.Close()
+ }
+ }()
+ if !socketServing(live) {
+ t.Error("live listener not reported as serving")
+ }
+ stale := filepath.Join(dir, "stale.sock")
+ l2, err := net.Listen("unix", stale)
+ if err != nil {
+ t.Fatal(err)
+ }
+ l2.(*net.UnixListener).SetUnlinkOnClose(false)
+ l2.Close()
+ if socketServing(stale) {
+ t.Error("stale socket file reported as serving")
+ }
+ if socketServing(filepath.Join(dir, "absent.sock")) {
+ t.Error("missing path reported as serving")
+ }
+}