carlos / platform Public

plan: sidecar members — 16 tasks, TDD, gate-per-task

Paul Campbell pushed by paul@keymail.dev ae1e462c831af388f8c2ea32b7fae9afb7a7708c parent 1c6faf8
1 file changed, +2101 −0
  • docs/superpowers/plans/2026-08-17-sidecar-members.md +2101 −0
diff --git a/docs/superpowers/plans/2026-08-17-sidecar-members.md b/docs/superpowers/plans/2026-08-17-sidecar-members.md
new file mode 100644
index 000000000..77e0f31fd
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-17-sidecar-members.md
@@ -0,0 +1,2101 @@
+# Sidecar Members 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:** Make a sidecar a first-class platform member — provisioned through
+an API like a web app instance, unreachable from the internet by
+construction, hibernating with timer and command wakes, placeable on a box
+with no inbound path at all.
+
+**Architecture:** Mirror instance provisioning one directory over
+(`sidecars.json` manifest + `sidecars/<name>.json` create-only records +
+a reconcile pass + console verbs + an app-held token), key sidecars by a
+synthetic `<name>.<app>.<sqid>.sidecar.internal` host so every host-keyed
+subsystem works unchanged, and reuse `activator.Service` for the lifecycle
+while keeping sidecars entirely off its request path. A sidecar binds
+nothing; it reports readiness, next-due and idleness by dialing **out** to a
+new agent-side unix socket.
+
+**Tech Stack:** Go (stdlib only — no new dependencies), modernc sqlite
+(`internal/registry`), S3/dir-backed `release.Store`, litestream via
+`internal/lease`.
+
+**Spec:** `docs/superpowers/specs/2026-08-17-sidecar-members-design.md`
+
+## Global Constraints
+
+- **The gate, before every commit:** `go build ./...`, `go vet ./...`,
+ `go test ./... -count=1` all clean, and `gofmt -l` clean for files you
+ touched. Three pre-existing unformatted files elsewhere are left alone.
+- **Sandbox note:** use `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod
+ GOPROXY=off`. Go commands may need the sandbox disabled ("read-only file
+ system" from the toolchain is the tell).
+- **The gate is not Linux-only.** `internal/host` links into the CLI, which
+ people run on Macs. `GOOS=darwin go build ./...` must pass too.
+- **Wire-shape changes are additive.** Live pointers, manifests and registry
+ rows exist in production buckets and on production boxes; anything written
+ before your change must still parse. Never remove or repurpose a field.
+- **When a storage shape changes, the consumer list is everything that
+ PARSES the shape** — not everything that calls the API.
+- **No new dependencies.** Standard library only.
+- **Never touch a live box.** This branch is build-and-PR only. No deploys,
+ no `ssm`, no `aws` calls, no writes to any real bucket.
+- **Reserved suffix, exact string:** `.sidecar.internal`
+- **Synthetic host format, exact:** `<name>.<app>.<sqid>.sidecar.internal`
+- **Registry Kind value, exact:** `sidecar`
+- **Agent socket path, exact:** `/run/carlos/agent.sock`
+- **Env var names, exact:** `CARLOS_AGENT_SOCKET`, `CARLOS_SIDECAR_TOKEN`,
+ `CARLOS_SIDECAR_NAME`, `CARLOS_SIDECAR_HOST`, `CARLOS_INBOUND`
+- **Bucket keys, exact:** `apps/<sqid>/<app>/sidecars.json`,
+ `apps/<sqid>/<app>/sidecars/<name>.json`,
+ `apps/<sqid>/<app>/sidecar-wake/<name>.json`
+- **Defaults, exact:** idle `5m`, `max_run` `1h`, settle window `10s`,
+ hibernate `true`, placement `any`.
+- **Commit style:** one commit per task, imperative subject, body explaining
+ *why*. End every commit message with:
+ ```
+ Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
+ Claude-Session: https://claude.ai/code/session_014WvcQVWggXkE5BAkjRDgWF
+ ```
+
+---
+
+## File Structure
+
+**Created:**
+- `internal/provision/sidecar.go` — sidecar manifest + record wire shapes,
+ key helpers, validation, store operations. Sibling to `provision.go`,
+ same package: it is the same kind of object one directory over, and it
+ reuses `labelRe`, `ValidRegion`, `ValidPool` directly.
+- `internal/provision/sidecar_test.go`
+- `internal/provision/sidecar_reconcile.go` — the records→routes pass.
+- `internal/provision/sidecar_reconcile_test.go`
+- `internal/agentsock/agentsock.go` — the agent-side unix socket: listener,
+ per-wake token registry, the four verbs. Its own package because it is a
+ server with its own lifecycle, and because `internal/activator` must be
+ able to depend on it without a cycle.
+- `internal/agentsock/agentsock_test.go`
+- `internal/release/sidecarwake.go` — the wake generation object.
+- `internal/release/sidecarwake_test.go`
+- `internal/console/apisidecars.go` — the console CLI verbs.
+- `internal/console/apisidecars_test.go`
+- `sidecars_cli.go` (root package) — `carlos sidecars`.
+- `sidecars_cli_test.go`, `sidecars_e2e_test.go` (root package)
+- `infra/no-inbound-box.md` — the private-subnet runbook (Paul applies).
+
+**Modified:**
+- `internal/registry/registry.go` — `args` column (FOUR places), `SetArgs`,
+ `DueInstances` widening.
+- `internal/edge/edge.go` — structural sidecar refusal.
+- `internal/host/fleetbind.go` — never publish a sidecar binding.
+- `internal/host/restart.go` — cycle sidecars.
+- `internal/host/agent.go` — `CARLOS_INBOUND`, the sidecar reconcile leg,
+ the agent socket's lifecycle, the wake pass.
+- `internal/appconfig/syncexec.go` — deliver config to sidecars.
+- `internal/activator/activator.go` — `StartWorker` seam, `WakeWorker`,
+ socketless stop, `max_run`.
+- `internal/activator/backend_exec.go`, `backend_unit.go` — the seam's two
+ implementations.
+- `internal/provision/reconcile.go` — instance removal phase skips sidecars.
+- `internal/console/authcli.go` — route registration.
+- `internal/console/instances.go`, `overview.go` — Kind guards, sidecar words.
+- `internal/status/snapshot.go` — sidecars are not public Residents.
+- `cli.go` (root) — `carlos add` rails off `-kind sidecar` and the suffix.
+- `main.go` (root) — `sidecars` dispatch.
+- `apimode.go` (root) — the client methods.
+- `CLAUDE.md` — the co-spawn/native split.
+
+---
+
+## Task ordering and dependencies
+
+```
+1 registry ──┬── 3 edge/fleetbind ── 4 suffix rails
+ ├── 5 StartWorker ── 6 lifecycle ── 7 agentsock ── 8 wiring
+ ├── 11 config+restart
+ └── 15 console UI
+2 provision shapes ──┬── 9 reconciler ── 10 placement
+ ├── 12 console API ── 14 CLI
+ └── 13 wake object
+16 docs (last)
+```
+
+Tasks 1 and 2 are independent of each other and unblock everything else.
+
+---
+
+### Task 1: Registry — the `sidecar` kind, the `args` column, and the due-time widening
+
+**Files:**
+- Modify: `internal/registry/registry.go`
+- Test: `internal/registry/registry_test.go`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `Route.Args string` (JSON argv, `""` = none);
+ `Route.IdleSeconds int`, `Route.MaxRunSeconds int` (0 = use the platform
+ default); `(*Registry).SetArgs(host, args string) error`;
+ `(*Registry).SetWorkerLimits(host string, idle, maxRun int) error`;
+ `DueInstances` now returns `kind IN ('instance','sidecar')`.
+
+**Why the ceilings live on the row.** The sweeper decides on every tick
+whether a worker has outrun its ceiling, and it must do that from local
+state — a bucket read per wake would put the control plane in the hot path
+of a timer. So `idle_seconds`/`max_run_seconds` are materialized from the
+app's manifest by the reconcile pass exactly as `args` and `seed` are:
+truth stays in the bucket, the row is the box's local copy.
+
+**Why this is first and why it matters:** `DueInstances` is the sweeper's
+only source of timer wakes. Left filtering `kind = 'instance'`, a sidecar's
+`next_due` would be written, stored, and never once acted on — the schedule
+wake source would silently not exist, with no error anywhere.
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `internal/registry/registry_test.go`:
+
+```go
+func TestDueInstancesIncludesSidecars(t *testing.T) {
+ r := testRegistry(t) // use this file's existing helper for a temp registry
+ if err := r.Upsert(Route{
+ Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar",
+ DBPath: "/tmp/bot.db", Hibernate: true, Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert sidecar: %v", err)
+ }
+ if err := r.SetNextDue("bot.eleven.bdm.sidecar.internal", 100); err != nil {
+ t.Fatalf("SetNextDue: %v", err)
+ }
+
+ due, err := r.DueInstances(200)
+ if err != nil {
+ t.Fatalf("DueInstances: %v", err)
+ }
+ if len(due) != 1 || due[0].Host != "bot.eleven.bdm.sidecar.internal" {
+ t.Fatalf("DueInstances = %+v, want the due sidecar — a sidecar's timer wake is its ONLY schedule wake source", due)
+ }
+}
+
+func TestArgsRoundTrips(t *testing.T) {
+ r := testRegistry(t)
+ if err := r.Upsert(Route{Host: "bot.x.internal", Kind: "sidecar", Args: `["sidecar","run"]`}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ got, err := r.Get("bot.x.internal")
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if got.Args != `["sidecar","run"]` {
+ t.Fatalf("Args = %q, want the argv back", got.Args)
+ }
+
+ if err := r.SetArgs("bot.x.internal", `["worker","run"]`); err != nil {
+ t.Fatalf("SetArgs: %v", err)
+ }
+ got, _ = r.Get("bot.x.internal")
+ if got.Args != `["worker","run"]` {
+ t.Fatalf("Args after SetArgs = %q", got.Args)
+ }
+}
+
+func TestWorkerLimitsRoundTrip(t *testing.T) {
+ r := testRegistry(t)
+ if err := r.Upsert(Route{Host: "bot.x.internal", Kind: "sidecar", IdleSeconds: 300, MaxRunSeconds: 3600}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ got, _ := r.Get("bot.x.internal")
+ if got.IdleSeconds != 300 || got.MaxRunSeconds != 3600 {
+ t.Fatalf("limits = %d/%d, want 300/3600 — the sweeper reads these locally on every tick", got.IdleSeconds, got.MaxRunSeconds)
+ }
+ if err := r.SetWorkerLimits("bot.x.internal", 60, 120); err != nil {
+ t.Fatalf("SetWorkerLimits: %v", err)
+ }
+ got, _ = r.Get("bot.x.internal")
+ if got.IdleSeconds != 60 || got.MaxRunSeconds != 120 {
+ t.Fatalf("limits after set = %d/%d", got.IdleSeconds, got.MaxRunSeconds)
+ }
+}
+
+func TestRouteWrittenBeforeArgsStillParses(t *testing.T) {
+ // The additive rule: a row written by older code has no args value.
+ r := testRegistry(t)
+ if err := r.Upsert(Route{Host: "old.example.com", Kind: "instance"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ got, err := r.Get("old.example.com")
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if got.Args != "" || got.IdleSeconds != 0 || got.MaxRunSeconds != 0 {
+ t.Fatalf("row = %+v, want zero values for a pre-existing row", got)
+ }
+}
+```
+
+If `testRegistry` is not the helper's name in this file, use whatever the
+file already uses to open a registry in `t.TempDir()` — do not add a new
+helper.
+
+- [ ] **Step 2: Run the tests and watch them fail**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/registry/ -run 'DueInstancesIncludesSidecars|ArgsRoundTrips|BeforeArgs' -count=1
+```
+
+Expected: compile failure — `Route` has no field `Args`, `SetArgs` undefined.
+
+- [ ] **Step 3: Add the column in all FOUR places**
+
+`registry.go` warns at the `routeCols` comment that a column lives in four
+hand-maintained lists and that a miscount is a **silent column shift**, not
+an error. Make all four edits together:
+
+1. Struct field, beside `Seed` (`registry.go` ~line 127 area):
+ ```go
+ // Args is a sidecar's argv as a JSON array, materialized from the app's
+ // sidecars manifest on every reconcile pass (truth stays in the bucket).
+ // Empty for every other kind of route.
+ Args string
+ ```
+2. Migration — append to the `ALTER TABLE` list in `migrate()`:
+ ```go
+ `ALTER TABLE routes ADD COLUMN args TEXT NOT NULL DEFAULT ''`,
+ ```
+3. `routeCols` — append `args, idle_seconds, max_run_seconds` at the very
+ END of the list (after `created_at`). Position does not matter as long
+ as it is THE SAME position in all four lists; appending at the end is
+ the least error-prone choice.
+4. `scanRoute` — add `&t.Args, &t.IdleSeconds, &t.MaxRunSeconds` at the end
+ of the destination list, in that order.
+5. `Upsert` — add three `?` placeholders at the end of the placeholder row
+ and `t.Args, t.IdleSeconds, t.MaxRunSeconds` at the end of the argument
+ list, in that order.
+
+Then add the setter beside `SetSeed`:
+
+```go
+// SetArgs repoints a sidecar's argv — the narrow UPDATE the sidecar
+// reconcile pass uses when the app's manifest changed.
+func (r *Registry) SetArgs(host, args string) error {
+ _, err := r.sql.Exec(`UPDATE routes SET args = ? WHERE host = ?`, args, host)
+ return err
+}
+
+// SetWorkerLimits repoints a sidecar's idle window and run ceiling, both in
+// seconds, 0 meaning "use the platform default". Same narrow-UPDATE shape
+// and same reason as SetArgs: the manifest is the truth, this is the copy
+// the sweeper reads on every tick.
+func (r *Registry) SetWorkerLimits(host string, idle, maxRun int) error {
+ _, err := r.sql.Exec(
+ `UPDATE routes SET idle_seconds = ?, max_run_seconds = ? WHERE host = ?`,
+ idle, maxRun, host)
+ return err
+}
+```
+
+- [ ] **Step 4: Widen `DueInstances`**
+
+```go
+// DueInstances returns hibernatable instance AND sidecar routes whose
+// next_due has arrived (0 means no schedule and never matches). Sidecars
+// are included because the due-time sweep is their ONLY timer wake source:
+// nothing can dial a sidecar, so no request will ever wake one.
+func (r *Registry) DueInstances(now int64) ([]Route, error) {
+ rows, err := r.sql.Query(`SELECT `+routeCols+` FROM routes
+WHERE kind IN ('instance','sidecar') AND next_due > 0 AND next_due <= ? ORDER BY host`, now)
+```
+
+- [ ] **Step 5: Run the full registry suite**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/registry/ -count=1
+```
+
+Expected: PASS, including `TestUpsertRoundTripsAllColumns` — that test is
+the canary for a four-list miscount. If it fails with a value in the wrong
+field, you shifted a column: recount all four lists.
+
+- [ ] **Step 6: Full gate, then commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1
+gofmt -l internal/registry/
+git add internal/registry/
+git commit -m "registry: sidecar kind, args column, and due-time widening
+
+DueInstances filtered kind='instance', which would have stored every
+sidecar's next_due and acted on none of them — the schedule wake source
+would silently not have existed. A sidecar cannot be dialed, so the
+due-time sweep is its only timer wake.
+
+args carries a sidecar's argv to the box, materialized from the app's
+manifest each pass exactly as seed is; truth stays in the bucket.
+[trailers]"
+```
+
+---
+
+### Task 2: Provision — sidecar manifest and record shapes
+
+**Files:**
+- Create: `internal/provision/sidecar.go`
+- Test: `internal/provision/sidecar_test.go`
+
+**Interfaces:**
+- Consumes: `release.Store`, and this package's existing `labelRe`,
+ `ValidRegion`, `ValidPool`, `DefaultDBPath`.
+- Produces:
+ ```go
+ const SidecarSuffix = ".sidecar.internal"
+ type SidecarManifest struct{ Schema int; App, Pool, Channel, Placement string
+ Regions []string; Args []string; Hibernate *bool; IdleSeconds, MaxRunSeconds int }
+ type SidecarRecord struct{ Schema int; Name, Host, App, Account, Pool, Region,
+ Channel, Placement, Environment, CreatedAt, CreatedBy string }
+ func SidecarHost(name, app, sqid string) string
+ func ValidSidecarName(name string) bool
+ func ValidSidecarHost(host, app, sqid string) bool
+ func SidecarManifestKey(acct, app string) string
+ func SidecarsPrefix(acct, app string) string
+ func SidecarRecordKey(acct, app, name string) string
+ func ParseSidecarKey(key string, known map[string]bool) (acct, app, name string, ok bool)
+ func ValidateSidecarShape(rec SidecarRecord) error
+ func GetSidecarManifest(ctx, s, acct, app) (*SidecarManifest, error)
+ func GetSidecarManifestETag(ctx, s, acct, app) (*SidecarManifest, string, error)
+ func PutSidecarManifest(ctx, s, acct, app, m) error
+ func PutSidecarManifestIfMatch(ctx, s, acct, app, m, etag string) error
+ func CreateSidecarRecord(ctx, s, rec) error
+ func GetSidecarRecord(ctx, s, acct, app, name) (*SidecarRecord, error)
+ func ListSidecarRecords(ctx, s, acct, app string, max int) ([]SidecarRecord, bool, error)
+ func DeleteSidecarRecord(ctx, s, acct, app, name string) error
+ func (m SidecarManifest) IdleOrDefault() time.Duration // 5m
+ func (m SidecarManifest) MaxRunOrDefault() time.Duration // 1h
+ func (m SidecarManifest) HibernateOrDefault() bool // true
+ func (m SidecarManifest) PlacementOrDefault() string // "any"
+ ```
+
+**Durations are integer seconds on the wire** (`idle_seconds`,
+`max_run_seconds`), not Go duration strings — the existing records use plain
+JSON scalars and a string would invite `"5 minutes"`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/provision/sidecar_test.go`. Mirror the idiom in
+`provision_test.go` and `reconcile_test.go`: a real `release.DirStore` in
+`t.TempDir()`, no mocks.
+
+```go
+func TestSidecarHostAndValidation(t *testing.T) {
+ got := SidecarHost("bot-acme", "eleven", "bdm")
+ if want := "bot-acme.eleven.bdm.sidecar.internal"; got != want {
+ t.Fatalf("SidecarHost = %q, want %q", got, want)
+ }
+ if !ValidSidecarHost(got, "eleven", "bdm") {
+ t.Fatal("its own SidecarHost output must validate")
+ }
+ for _, bad := range []string{
+ "bot-acme.other.bdm.sidecar.internal", // wrong app
+ "bot-acme.eleven.bad.sidecar.internal", // wrong sqid
+ "bot-acme.eleven.bdm.example.com", // wrong suffix
+ "a.b.bot.eleven.bdm.sidecar.internal", // more than one label
+ } {
+ if ValidSidecarHost(bad, "eleven", "bdm") {
+ t.Fatalf("ValidSidecarHost(%q) = true, want false", bad)
+ }
+ }
+ for _, bad := range []string{"", "-lead", "trail-", "UPPER", "has.dot", "has/slash", "..", strings.Repeat("a", 64)} {
+ if ValidSidecarName(bad) {
+ t.Fatalf("ValidSidecarName(%q) = true — the name becomes a bucket key AND a filesystem path segment", bad)
+ }
+ }
+}
+
+func TestParseSidecarKey(t *testing.T) {
+ known := map[string]bool{"bdm": true}
+ acct, app, name, ok := ParseSidecarKey("apps/bdm/eleven/sidecars/bot-acme.json", known)
+ if !ok || acct != "bdm" || app != "eleven" || name != "bot-acme" {
+ t.Fatalf("ParseSidecarKey = %q/%q/%q ok=%v", acct, app, name, ok)
+ }
+ for _, bad := range []string{
+ "apps/bdm/eleven/sidecars.json", // the manifest, not a record
+ "apps/bdm/eleven/sidecars/nested/x.json", // nested
+ "apps/bdm/eleven/sidecars/.json", // empty name
+ "apps/unknown/eleven/sidecars/bot.json", // account not materialized
+ "apps/bdm/channels/sidecars/bot.json", // release-plane collision
+ "apps/bdm/eleven/instances/bot.json", // the other family
+ } {
+ if _, _, _, ok := ParseSidecarKey(bad, known); ok {
+ t.Fatalf("ParseSidecarKey(%q) parsed, want refusal", bad)
+ }
+ }
+}
+
+func TestCreateSidecarRecordIsCreateOnly(t *testing.T) {
+ ctx := context.Background()
+ s := dirStore(t) // the helper reconcile_test.go already uses
+ rec := SidecarRecord{
+ Schema: 1, Name: "bot-acme", Host: SidecarHost("bot-acme", "eleven", "bdm"),
+ App: "eleven", Account: "bdm", Channel: "stable",
+ }
+ if err := CreateSidecarRecord(ctx, s, rec); err != nil {
+ t.Fatalf("first create: %v", err)
+ }
+ err := CreateSidecarRecord(ctx, s, rec)
+ if !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("second create err = %v, want ErrCASConflict — a lost race must never silently overwrite", err)
+ }
+}
+
+func TestGetSidecarRecordRefusesRecordThatLiesAboutItsPath(t *testing.T) {
+ ctx := context.Background()
+ s := dirStore(t)
+ body := []byte(`{"schema":1,"name":"bot-acme","app":"OTHER","account":"bdm"}`)
+ if _, err := s.Put(ctx, SidecarRecordKey("bdm", "eleven", "bot-acme"), body, ""); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ if _, err := GetSidecarRecord(ctx, s, "bdm", "eleven", "bot-acme"); err == nil {
+ t.Fatal("a record disagreeing with its own key path must be refused, never guessed at")
+ }
+}
+
+func TestSidecarManifestDefaults(t *testing.T) {
+ var m SidecarManifest
+ if m.IdleOrDefault() != 5*time.Minute {
+ t.Fatalf("idle default = %v, want 5m", m.IdleOrDefault())
+ }
+ if m.MaxRunOrDefault() != time.Hour {
+ t.Fatalf("max_run default = %v, want 1h", m.MaxRunOrDefault())
+ }
+ if !m.HibernateOrDefault() {
+ t.Fatal("hibernate must default true")
+ }
+ if m.PlacementOrDefault() != "any" {
+ t.Fatalf("placement default = %q, want any", m.PlacementOrDefault())
+ }
+ no := false
+ m2 := SidecarManifest{Hibernate: &no}
+ if m2.HibernateOrDefault() {
+ t.Fatal("an explicit false must win — tri-state, like instances' Hibernate")
+ }
+}
+
+func TestSidecarManifestSchemaNewerIsRefused(t *testing.T) {
+ ctx := context.Background()
+ s := dirStore(t)
+ if _, err := s.Put(ctx, SidecarManifestKey("bdm", "eleven"), []byte(`{"schema":2}`), ""); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ if _, err := GetSidecarManifest(ctx, s, "bdm", "eleven"); err == nil {
+ t.Fatal("a schema newer than this binary understands must be refused, not silently truncated")
+ }
+}
+
+func TestSidecarManifestAbsenceIsNotExist(t *testing.T) {
+ _, err := GetSidecarManifest(context.Background(), dirStore(t), "bdm", "eleven")
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("absence err = %v, want os.ErrNotExist — absence means the feature does not exist for that app", err)
+ }
+}
+
+func TestValidateSidecarShape(t *testing.T) {
+ ok := SidecarRecord{Schema: 1, Name: "bot", Host: SidecarHost("bot", "eleven", "bdm"),
+ App: "eleven", Account: "bdm", Channel: "stable", Placement: "no-inbound"}
+ if err := ValidateSidecarShape(ok); err != nil {
+ t.Fatalf("valid record refused: %v", err)
+ }
+ bad := ok
+ bad.Placement = "sideways"
+ if err := ValidateSidecarShape(bad); err == nil {
+ t.Fatal("placement vocabulary must be closed")
+ }
+}
+```
+
+- [ ] **Step 2: Run and watch it fail**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/provision/ -run Sidecar -count=1
+```
+Expected: compile failure, everything undefined.
+
+- [ ] **Step 3: Implement `internal/provision/sidecar.go`**
+
+Write the file mirroring `provision.go` structurally. The store operations
+are **the same six functions with `Sidecar` in the name and the sidecar key
+helpers substituted** — copy the bodies and their doc comments' reasoning
+from these exact sites:
+
+- `GetSidecarManifest` / `GetSidecarManifestETag` ← `provision.go:508-529`
+ (schema>1 refusal included).
+- `PutSidecarManifest` ← `provision.go:535-557` (CAS upsert, one retry).
+- `PutSidecarManifestIfMatch` ← `provision.go:568-575` (no retry).
+- `CreateSidecarRecord` ← `provision.go:580-587` (`Put` with `ifMatch ""`).
+- `GetSidecarRecord` ← `provision.go:687-709`, **keeping the
+ identity-vs-path agreement check** (`rec.Account != acct || rec.App != app
+ || rec.Name != name` → error).
+- `ListSidecarRecords` ← `provision.go:726-760` (sorted, capped, `truncated`
+ reported on key count, malformed bodies skipped).
+- `DeleteSidecarRecord` ← `provision.go:641-643`.
+
+The new logic is only this:
+
+```go
+// SidecarSuffix is the reserved, never-resolvable suffix every sidecar host
+// carries. It is refused at every door where a name could become routable
+// (see cli.go's addRail, the route door, and domain attach) — reservation
+// is enforced, not documented.
+const SidecarSuffix = ".sidecar.internal"
+
+// SidecarHost builds a sidecar's synthetic host. It is the registry key, the
+// bucket key segment and the on-disk path segment, which is why every
+// component is validated before it is ever joined.
+func SidecarHost(name, app, sqid string) string {
+ return name + "." + app + "." + sqid + SidecarSuffix
+}
+
+// ValidSidecarName pins one DNS label exactly as ValidHost pins an
+// instance's: the name becomes a bucket key segment and the <host>.db /
+// cgroup path segments, so the label regex is what keeps a hostile name an
+// opaque, safe path component.
+func ValidSidecarName(name string) bool { return labelRe.MatchString(name) }
+
+func ValidSidecarHost(host, app, sqid string) bool {
+ rest, ok := strings.CutSuffix(host, SidecarSuffix)
+ if !ok {
+ return false
+ }
+ name, tail, ok := strings.Cut(rest, ".")
+ if !ok || tail != app+"."+sqid {
+ return false
+ }
+ return ValidSidecarName(name)
+}
+```
+
+Key helpers, beside the instance ones (`provision.go:155-159`):
+
+```go
+func SidecarManifestKey(acct, app string) string {
+ return release.AppPrefix(acct, app) + "sidecars.json"
+}
+func SidecarsPrefix(acct, app string) string {
+ return release.AppPrefix(acct, app) + "sidecars/"
+}
+func SidecarRecordKey(acct, app, name string) string {
+ return SidecarsPrefix(acct, app) + name + ".json"
+}
+```
+
+`ParseSidecarKey` is `ParseRecordKey` (`provision.go:171-196`) with
+`"instances/"` → `"sidecars/"`: keep the `known` account guard, the
+`channels`/`releases` collision refusal, and the exactly-one-non-empty-
+segment rule that makes `sidecars.json` and any nested path both refuse.
+
+Defaults:
+
+```go
+func (m SidecarManifest) IdleOrDefault() time.Duration {
+ if m.IdleSeconds > 0 {
+ return time.Duration(m.IdleSeconds) * time.Second
+ }
+ return 5 * time.Minute
+}
+func (m SidecarManifest) MaxRunOrDefault() time.Duration {
+ if m.MaxRunSeconds > 0 {
+ return time.Duration(m.MaxRunSeconds) * time.Second
+ }
+ return time.Hour
+}
+func (m SidecarManifest) HibernateOrDefault() bool {
+ if m.Hibernate != nil {
+ return *m.Hibernate
+ }
+ return true
+}
+func (m SidecarManifest) PlacementOrDefault() string {
+ if m.Placement == "" {
+ return PlacementAny
+ }
+ return m.Placement
+}
+```
+
+with `const ( PlacementAny = "any"; PlacementNoInbound = "no-inbound" )`.
+
+`ValidateSidecarShape` checks: schema ≤ 1; `ValidSidecarName(rec.Name)`;
+`rec.App`/`rec.Account` non-empty; placement in the closed vocabulary;
+`ValidRegion(rec.Region)` when non-empty; `ValidPool(rec.Pool)` when
+non-empty.
+
+- [ ] **Step 4: Run the tests**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/provision/ -count=1
+```
+Expected: PASS.
+
+- [ ] **Step 5: Full gate, then commit**
+
+```
+provision: sidecar manifest and record shapes
+
+Mirrors the instance record family one directory over, reusing its
+validation rails verbatim — the label regex, the identity-vs-path
+agreement check, create-only writes, and the absence-means-disabled
+convention. The name becomes a bucket key AND a filesystem path segment,
+which is why it is validated before it is ever joined into a host.
+```
+
+---
+
+### Task 3: The edge cannot bind a sidecar, and no box advertises one
+
+**Files:**
+- Modify: `internal/edge/edge.go` (the `Reload` dispatch switch, ~364-445)
+- Modify: `internal/host/fleetbind.go` (`publishResidentFleetBindings`, ~301-316)
+- Test: `internal/edge/edge_test.go`, `internal/host/fleetbind_test.go`
+
+**Interfaces:**
+- Consumes: Task 1's `Kind == "sidecar"`.
+- Produces: nothing other tasks call.
+
+**Why this is not paranoia.** A sidecar row is unroutable today only by
+*omission*: it has no Socket, Addr or Tunnel, so it falls to
+`default: continue` and never enters the routing map. But the dispatch
+switch has a `case isFleetPlaced && e.tunnels != nil` arm with no Kind
+check, and `publishResidentFleetBindings` iterates **every** route with no
+Kind filter. Eleven's sidecars are fleet-placed (`region: fleet/eleven`) —
+so the row that reaches that arm by shape is exactly the row this feature
+creates. Omission is not good enough; refuse explicitly at both ends.
+
+- [ ] **Step 1: Write the failing tests**
+
+In `internal/edge/edge_test.go` (follow the file's existing Reload/route
+fixtures):
+
+```go
+func TestSidecarRouteIsNeverServable(t *testing.T) {
+ // Adversarial on purpose: this row carries a socket, an addr, a tunnel
+ // AND a fleet placement. A sidecar must be refused on KIND, so that no
+ // present or future arm of the dispatch switch can ever bind one.
+ e := newTestEdge(t)
+ rt := registry.Route{
+ Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar",
+ Socket: "/tmp/whatever.sock", Addr: "127.0.0.1:9999",
+ Tunnel: "eleven/box1", Region: "fleet/eleven", FleetBox: "box1",
+ Status: "ready", App: "eleven",
+ }
+ if err := e.Reload([]registry.Route{rt}); err != nil {
+ t.Fatalf("Reload: %v", err)
+ }
+ if _, ok := e.current()["bot.eleven.bdm.sidecar.internal"]; ok {
+ t.Fatal("a sidecar row entered the routing map — it is reachable from the internet")
+ }
+ if e.Allowed("bot.eleven.bdm.sidecar.internal") == nil {
+ t.Fatal("a certificate must never be requested for a sidecar host")
+ }
+}
+```
+
+(Match `newTestEdge`/`current`/`Allowed` to whatever the file actually
+provides; if `Allowed` returns an error use `!= nil` as "refused".)
+
+In `internal/host/fleetbind_test.go`:
+
+```go
+func TestSidecarBindingIsNeverPublished(t *testing.T) {
+ routes := []registry.Route{
+ {Host: "app.example.com", Kind: "instance", Socket: "/run/carlos/a.sock"},
+ {Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar", Region: "fleet/eleven"},
+ }
+ got := residentBindingHosts(routes) // the pure helper you extract in step 3
+ if len(got) != 1 || got[0] != "app.example.com" {
+ t.Fatalf("published %v — a sidecar must never be advertised as served by a box", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run and watch both fail**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/edge/ ./internal/host/ -run 'Sidecar' -count=1
+```
+Expected: the edge test FAILS (the row is served via the tunnel/fleet arm);
+the fleetbind test fails to compile (`residentBindingHosts` undefined).
+
+- [ ] **Step 3: Refuse in the edge, before the switch**
+
+In `Reload`'s per-route loop, as the FIRST thing inside the loop body:
+
+```go
+// A sidecar is a member that serves nothing: it binds no socket, and
+// nothing may ever dial it. Refuse it on KIND here, before the dispatch
+// switch, rather than relying on it having no socket/addr/tunnel — a
+// fleet-placed sidecar (which is Eleven's shape) reaches the fleet arm
+// below on shape alone. This refusal is the structural guarantee that
+// survives a future arm, column or placement finding the row.
+if t.Kind == "sidecar" {
+ continue
+}
+```
+
+- [ ] **Step 4: Refuse in the fleet-binding publisher**
+
+Extract the host selection into a pure, testable helper and filter there:
+
+```go
+// residentBindingHosts is the set of hosts this box advertises as served.
+// Sidecars are excluded: nothing routes to one, so a binding claiming a box
+// serves it is a lie that the platform-side reconciler would then act on
+// (SetFleetBox -> the edge's fleet arm -> a live proxy).
+func residentBindingHosts(routes []registry.Route) []string {
+ var out []string
+ for _, rt := range routes {
+ if rt.Host == "" || rt.Kind == "sidecar" {
+ continue
+ }
+ out = append(out, rt.Host)
+ }
+ return out
+}
+```
+
+and call it from `publishResidentFleetBindings` in place of its current
+inline loop.
+
+- [ ] **Step 5: Run both tests, then the full gate, then commit**
+
+```
+edge, host: a sidecar row is refused on kind, at both ends of the fleet chain
+
+A sidecar was unroutable only by omission — no socket, addr or tunnel, so
+it fell to the dispatch switch's default. But the switch's fleet-placed
+arm checks no kind, and the binding publisher filtered no kind, and
+Eleven's sidecars are fleet-placed. That is a live path from a resident
+sidecar row to a working internet-facing proxy, so both ends now refuse
+explicitly and the test asserts it against a row that carries every
+routable column at once.
+```
+
+---
+
+### Task 4: The reserved suffix is enforced at every minting door
+
+**Files:**
+- Modify: `cli.go` (root, `runAdd` ~68-260 and `runRoute`'s rails ~1197-1373)
+- Modify: `internal/domains/reconcile.go` (alias minting)
+- Modify: `internal/console/domainattach.go` (attach door)
+- Test: `cli_test.go`, `internal/domains/reconcile_test.go`,
+ `internal/console/domainattach_test.go`
+
+**Interfaces:**
+- Consumes: `provision.SidecarSuffix`.
+- Produces: nothing.
+
+`carlos add` today accepts `-kind sidecar` with **no validation at all**
+(`cli.go:75,194-199`), and any host string reaches `registry.Upsert`. These
+rails are defence in depth behind Task 3's structural refusal.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestAddRefusesSidecarSuffixAndKind(t *testing.T) {
+ if err := addRail("bot.eleven.bdm.sidecar.internal", "instance"); err == nil {
+ t.Fatal("a host under the reserved suffix must be refused")
+ }
+ if err := addRail("app.example.com", "sidecar"); err == nil {
+ t.Fatal("carlos add must not mint a sidecar row — sidecars are provisioned, not added")
+ }
+ if err := addRail("app.example.com", "instance"); err != nil {
+ t.Fatalf("an ordinary add must still work: %v", err)
+ }
+}
+```
+
+Add matching cases asserting `domains` never mints an alias whose source or
+target carries the suffix, and that `domainattach` refuses to attach a
+custom domain to a host under it.
+
+- [ ] **Step 2: Run and watch it fail** (`addRail` undefined).
+
+- [ ] **Step 3: Implement the rail**
+
+In `cli.go`, beside `grantRail`/`backingRail`:
+
+```go
+// addRail refuses the two things `carlos add` must never mint: a host under
+// the reserved sidecar suffix, and a sidecar row itself. Sidecars are
+// provisioned through their own record family (carlos sidecars create), so
+// an operator minting one by hand would produce a row no reconcile pass
+// owns and no record explains.
+func addRail(host, kind string) error {
+ if strings.HasSuffix(host, provision.SidecarSuffix) {
+ return fmt.Errorf("%s is under the reserved suffix %s — sidecar hosts are minted by `carlos sidecars create`, never by hand", host, provision.SidecarSuffix)
+ }
+ if kind == "sidecar" {
+ return errors.New("carlos add does not mint sidecars — use `carlos sidecars create -app <app> <name>`")
+ }
+ return nil
+}
+```
+
+Call it at the top of `runAdd`, before any registry work. Apply the same
+`HasSuffix` refusal in the domains alias minting path and in the console's
+domain-attach handler (return the family's ordinary 400 there, not a panic).
+
+- [ ] **Step 4: Run the tests, full gate, commit.**
+
+---
+
+### Task 5: `StartWorker` — spawning a child that binds nothing
+
+**Files:**
+- Modify: `internal/activator/activator.go` (the `Backend` interface, ~134-167)
+- Modify: `internal/activator/backend_exec.go`
+- Modify: `internal/activator/backend_unit.go`
+- Test: `internal/activator/backend_exec_test.go` (or the existing backend test file)
+
+**Interfaces:**
+- Consumes: Task 1's `Route.Args`.
+- Produces:
+ ```go
+ // on Backend:
+ StartWorker(ctx context.Context, rt registry.Route, env map[string]string) (Proc, string, error)
+ ```
+ Returns the `Proc` and the spawn build version, exactly like `Start`.
+
+**Why a new method rather than a flag on `Start`:** `ExecBackend.Start` ends
+with an unconditional `waitHealthyCtx(ctx, rt.Socket, spawnHealthTimeout)`
+(`backend_exec.go:601`). Handed `Socket ""` it dials `unix://""` every 150ms
+for the full 30s and then fails the wake. A worker has no socket by
+definition, so it needs a spawn path with no probe — which is very nearly
+what `StartSidecar` (`backend_exec.go:746-773`) already is.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestStartWorkerSpawnsWithoutAnyHealthProbe(t *testing.T) {
+ dir := t.TempDir()
+ b := &ExecBackend{DataDir: dir, OptDir: dir, Cgroups: nil}
+ // A binary that runs until killed and never binds anything.
+ writeFakeLive(t, dir, "bot.x.internal", "#!/bin/sh\nsleep 30\n")
+
+ rt := registry.Route{Host: "bot.x.internal", Kind: "sidecar",
+ Args: `["sidecar","run"]`, DBPath: filepath.Join(dir, "bot.db")}
+
+ start := time.Now()
+ p, _, err := b.StartWorker(context.Background(), rt, map[string]string{"CARLOS_SIDECAR_NAME": "bot"})
+ if err != nil {
+ t.Fatalf("StartWorker: %v", err)
+ }
+ defer p.Stop(context.Background())
+ if d := time.Since(start); d > 5*time.Second {
+ t.Fatalf("StartWorker took %v — it waited for a health probe that can never answer", d)
+ }
+ if p.Exited() {
+ t.Fatal("worker exited immediately")
+ }
+}
+
+func TestStartWorkerRefusesEmptyArgs(t *testing.T) {
+ b := &ExecBackend{DataDir: t.TempDir()}
+ _, _, err := b.StartWorker(context.Background(), registry.Route{Host: "b.x.internal", Kind: "sidecar"}, nil)
+ if err == nil {
+ t.Fatal("a worker with no argv has nothing to run — refuse rather than exec the bare binary")
+ }
+}
+
+func TestUnitBackendRefusesWorkers(t *testing.T) {
+ var b UnitBackend
+ if _, _, err := b.StartWorker(context.Background(), registry.Route{Host: "x"}, nil); err == nil {
+ t.Fatal("unit-backed tenants have no agent-managed workers, exactly as they have no agent-managed sidecars")
+ }
+}
+```
+
+`writeFakeLive` — if the test file has no such helper, write one that
+creates the `live/<host>` symlink target as an executable shell script,
+matching however the existing exec-backend tests fake a binary.
+
+- [ ] **Step 2: Run and watch it fail.**
+
+- [ ] **Step 3: Add the method to the `Backend` interface**
+
+```go
+ // StartWorker spawns a child that binds NOTHING: no socket, no port.
+ // No health probe is possible or wanted — readiness is the caller's
+ // settle window, or the child's own call over the agent socket. env is
+ // merged over the route's own env layers and wins conflicts (it carries
+ // the per-wake sidecar token, which the tenant must never override).
+ StartWorker(ctx context.Context, rt registry.Route, env map[string]string) (Proc, string, error)
+```
+
+- [ ] **Step 4: Implement `ExecBackend.StartWorker`**
+
+Model it on `Start` (`backend_exec.go:476-636`) minus the probe and the
+socket-mode normalization, and on `StartSidecar` for the no-probe framing:
+
+```go
+func (b *ExecBackend) StartWorker(ctx context.Context, rt registry.Route, extra map[string]string) (Proc, string, error) {
+ var argv []string
+ if err := json.Unmarshal([]byte(rt.Args), &argv); err != nil || len(argv) == 0 {
+ return nil, "", fmt.Errorf("worker %s: no runnable argv in args %q", rt.Host, rt.Args)
+ }
+ if err := b.ensureLiveLink(rt.Host); err != nil {
+ return nil, "", err
+ }
+ spawnVer, _ := b.liveVersion(rt.Host) // same call Start uses; "" = unknown
+
+ fileEnv := b.instanceEnv(rt) // the same merged layers Start composes
+ for k, v := range extra {
+ fileEnv = withVar(fileEnv, k, v)
+ }
+
+ cmd, err := b.Cgroups.Start(rt.Host, func() *exec.Cmd {
+ c := exec.Command(b.liveLink(rt.Host), argv...)
+ c.Env = childEnv(fileEnv)
+ b.teeStd(c, rt)
+ return c
+ })
+ if err != nil {
+ return nil, "", err
+ }
+ p := &childProc{cmd: cmd, done: make(chan struct{})}
+ go func() {
+ p.err = cmd.Wait()
+ b.Cgroups.CleanupHost(rt.Host)
+ close(p.done)
+ }()
+ // Deliberately NO waitHealthy: a worker binds nothing, so there is
+ // nothing to probe. Readiness is the activator's settle window.
+ return p, spawnVer, nil
+}
+```
+
+Use the real names this file already has for the live-link, version and env
+helpers (`ensureLiveLink`, `liveLink`, `instanceEnv`, `childEnv`,
+`withVar`, `teeStd`); do not invent new ones. If `b.Cgroups` may be nil in
+tests, follow whatever nil-guard `Start` uses.
+
+`UnitBackend.StartWorker` and `SplitBackend.StartWorker` mirror
+`StartSidecar` (`backend_unit.go:186-188`, `:304-306`): refuse, and
+delegate by `b.pick(rt)` respectively.
+
+- [ ] **Step 5: Run the tests, full gate, commit.**
+
+---
+
+### Task 6: The standalone sidecar lifecycle inside the activator
+
+**Files:**
+- Modify: `internal/activator/activator.go`
+- Test: `internal/activator/activator_test.go`
+
+**Interfaces:**
+- Consumes: Task 5's `StartWorker`.
+- Produces:
+ ```go
+ func (a *Service) WakeWorker(rt registry.Route) error // idempotent; nil if already awake
+ func (a *Service) NoteWorkerContact(host string) // stamps lastActive
+ func (a *Service) SetWorkerNextDue(host string, at int64) error
+ func (a *Service) SleepWorker(host string) // /v1/idle
+ func (a *Service) WorkerToken(host string) string // "" unless awake
+ func (a *Service) NoteWorkerReady(host, version string)
+ ```
+ `WakeWorker` takes the route ALONE: the idle window and run ceiling ride
+ on the row (Task 1), so the sweeper never needs a bucket read to decide a
+ worker has outrun its ceiling.
+
+**Design constraint:** sidecars stay entirely off the request path.
+`beginRequest`, `BeginSidecarRequest` and `ServeHTTP` are not modified —
+`ServeHTTP` already gates on `rt.Kind == "instance" && rt.Hibernate`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestWorkerWakeUsesStartWorkerAndNoProbe(t *testing.T) {
+ a, b, _, _ := testActivator(t, 10*time.Second)
+ rt := testWorkerRoute("bot.x.internal")
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ if got := b.events(); !contains(got, "startWorker:bot.x.internal") {
+ t.Fatalf("events %v — a worker must spawn through StartWorker, never Start", got)
+ }
+ if b.startCount() != 0 {
+ t.Fatal("Start (the socket path, with its health probe) must never be called for a worker")
+ }
+}
+
+func TestWorkerSleepsWhenIdleAndRecordsNextDue(t *testing.T) {
+ a, b, clk, reg := testActivator(t, 10*time.Second)
+ rt := testWorkerRoute("bot.x.internal")
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ if err := a.SetWorkerNextDue("bot.x.internal", 4242); err != nil {
+ t.Fatalf("SetWorkerNextDue: %v", err)
+ }
+
+ clk.advance(11 * time.Second)
+ a.sweepOnce()
+ waitFor(t, "worker hibernation", func() bool { return a.entryCount() == 0 })
+
+ got, err := reg.Get("bot.x.internal")
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if got.NextDue != 4242 {
+ t.Fatalf("next_due = %d, want 4242 — a worker's next-due arrives over the agent socket while it is ALIVE, not from a probe of a process we are about to kill", got.NextDue)
+ }
+ if b.dueProbes() != 0 {
+ t.Fatal("a worker must never be probed for next-due: it has no socket to answer on")
+ }
+ if !b.lastRep().isClosed() {
+ t.Fatal("the lease must be released and the DB parked on a worker sleep, exactly as for an instance")
+ }
+}
+
+func TestWorkerContactDefersSleep(t *testing.T) {
+ a, _, clk, _ := testActivator(t, 10*time.Second)
+ if err := a.WakeWorker(testWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ clk.advance(9 * time.Second)
+ a.NoteWorkerContact("bot.x.internal")
+ clk.advance(9 * time.Second)
+ a.sweepOnce()
+ if a.entryCount() != 1 {
+ t.Fatal("agent-socket contact must defer the idle clock — it is a worker's only sign of life")
+ }
+}
+
+func TestWorkerMaxRunStopsALoopingWorker(t *testing.T) {
+ a, _, clk, _ := testActivator(t, time.Hour) // idle window never fires
+ rt := testWorkerRoute("bot.x.internal")
+ rt.MaxRunSeconds = 60
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ for i := 0; i < 10; i++ { // a worker that keeps talking but never idles
+ clk.advance(10 * time.Second)
+ a.NoteWorkerContact("bot.x.internal")
+ }
+ a.sweepOnce()
+ waitFor(t, "max_run stop", func() bool { return a.entryCount() == 0 })
+}
+
+func TestSleepWorkerIsImmediate(t *testing.T) {
+ a, _, _, _ := testActivator(t, time.Hour)
+ if err := a.WakeWorker(testWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ a.SleepWorker("bot.x.internal")
+ waitFor(t, "idle-requested stop", func() bool { return a.entryCount() == 0 })
+}
+
+func TestWakeWorkerIsIdempotent(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ rt := testWorkerRoute("bot.x.internal")
+ for i := 0; i < 3; i++ {
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("WakeWorker #%d: %v", i, err)
+ }
+ }
+ if n := b.workerStarts(); n != 1 {
+ t.Fatalf("workerStarts = %d, want 1 — a wake for an already-awake worker is a no-op, not a second child", n)
+ }
+}
+```
+
+Extend `fakeBackend` with `StartWorker` (recording `startWorker:<host>` in
+its `order` log and incrementing a `workerStarts` counter), and add
+`testWorkerRoute(host)` returning
+`registry.Route{Host: host, Kind: "sidecar", Args: `["sidecar","run"]`, DBPath: ..., Hibernate: true, Status: "ready"}`.
+Add the `dueProbes()` accessor if the fake does not already count `NextDue`
+calls.
+
+- [ ] **Step 2: Run and watch them fail.**
+
+- [ ] **Step 3: Implement**
+
+Add to `actEntry`:
+
+```go
+ worker bool // this entry is a sidecar: no socket, no requests
+ workerIdle time.Duration // 0 = fall back to cfg.Idle
+ workerMaxRun time.Duration // 0 = no ceiling
+ workerToken string // the per-wake agent-socket token
+ spawnedAt time.Time
+```
+
+populated from the route's `IdleSeconds`/`MaxRunSeconds` at wake time.
+
+```go
+// WakeWorker brings a sidecar up, or does nothing if it is already awake.
+// It is the request-less twin of beginRequest: a sidecar has no socket, so
+// no request can ever wake one — every wake arrives from the due-time
+// sweep, an operator command, or the agent's boot pass.
+func (a *Service) WakeWorker(rt registry.Route) error
+```
+
+Implementation:
+
+- Under `a.mu`: if an entry exists and is `actAwake` or `actWaking`, return
+ nil (idempotent). If `actStopping`, wait on `e.stopped` then retry.
+- Otherwise create the entry with `worker: true`, `workerMaxRun: maxRun`,
+ mint `workerToken` (32 bytes from `crypto/rand`, hex), launch
+ `go a.wake(e)` and **do not unlock before falling into the wait** — the
+ singleflight discipline PR #199 established; then wait on `e.ready`.
+- In `doWake`, branch step 4:
+ ```go
+ var (
+ proc Proc
+ spawnVer string
+ )
+ if e.worker {
+ proc, spawnVer, err = a.backend.StartWorker(ctx, rt, a.workerEnv(e))
+ } else {
+ proc, spawnVer, err = a.backend.Start(ctx, rt)
+ }
+ ```
+ and skip step 6 (sidecar co-spawn) entirely for a worker — a worker is
+ nobody's parent.
+- In `stop`, skip the next-due probe when `e.worker`: its next-due arrived
+ over the agent socket while it was alive. Everything after (stop the
+ proc, final checkpoint, lease release) is shared unchanged.
+- In `sweepOnce`, beside the existing idle check, add:
+ ```go
+ if e.worker && e.workerMaxRun > 0 && now.Sub(e.spawnedAt) >= e.workerMaxRun {
+ // A worker that never reports idle still has a ceiling: a wedged
+ // reconnect loop must not hold a lease and a box forever.
+ a.beginStopLocked(e)
+ continue
+ }
+ ```
+ and make the existing idle predicate use the entry's own window when set
+ (`e.workerIdle`), falling back to `a.cfg.Idle`. Otherwise it is unchanged:
+ `inflight` is always 0 for a worker, and `lastActive` is stamped by
+ agent-socket contact instead of by a released request.
+
+The four small public methods:
+
+```go
+func (a *Service) NoteWorkerContact(host string) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if e := a.entries[host]; e != nil && e.worker {
+ e.lastActive = a.now()
+ }
+}
+
+func (a *Service) SetWorkerNextDue(host string, at int64) error {
+ return a.due.SetNextDue(host, at)
+}
+
+func (a *Service) SleepWorker(host string) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if e := a.entries[host]; e != nil && e.worker && e.state == actAwake {
+ a.beginStopLocked(e)
+ }
+}
+
+// WorkerToken reports the live per-wake token for host, or "" — the agent
+// socket's authorization check. A token from a previous wake never matches.
+func (a *Service) WorkerToken(host string) string {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if e := a.entries[host]; e != nil && e.worker && e.state == actAwake {
+ return e.workerToken
+ }
+ return ""
+}
+```
+
+`a.workerEnv(e)` returns
+`{"CARLOS_AGENT_SOCKET": a.cfg.AgentSocket, "CARLOS_SIDECAR_TOKEN": e.workerToken, "CARLOS_SIDECAR_HOST": e.host, "CARLOS_SIDECAR_NAME": <first label of e.host>}`.
+Add `AgentSocket string` to `activator.Config`, defaulting to
+`/run/carlos/agent.sock`.
+
+- [ ] **Step 4: Run the activator suite in full**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/activator/ -count=1
+```
+Expected: PASS, **including every pre-existing instance test** — especially
+`TestActivatorFailedWakeIsSingleflight`, `TestActivatorCoStopOrdering` and
+`TestActivatorIdleSleep`. If any instance test changed behaviour, you put a
+worker branch on a shared path; move it.
+
+- [ ] **Step 5: Full gate, commit.**
+
+---
+
+### Task 7: The agent socket
+
+**Files:**
+- Create: `internal/agentsock/agentsock.go`, `internal/agentsock/agentsock_test.go`
+
+**Interfaces:**
+- Consumes: an interface it defines and Task 6 satisfies:
+ ```go
+ type Workers interface {
+ WorkerToken(host string) string
+ NoteWorkerContact(host string)
+ SetWorkerNextDue(host string, at int64) error
+ SleepWorker(host string)
+ NoteWorkerReady(host, version string)
+ }
+ ```
+- Produces:
+ ```go
+ func New(w Workers) *Server
+ func (s *Server) Serve(ctx context.Context, path string) error // listens, cleans up
+ func (s *Server) Handler() http.Handler // for tests
+ ```
+
+**The direction is the whole point:** the agent listens, children dial. The
+platform never dials a sidecar.
+
+Wire protocol — all `POST`, JSON in, `204` out, `Authorization: Bearer <token>`
+and `Carlos-Sidecar-Host: <host>`:
+
+| Path | Body | Effect |
+|---|---|---|
+| `/v1/ready` | `{"version":"<build>"}` | ends the settle window, records the running version |
+| `/v1/next-due` | `{"at": <unix seconds>}` | `SetWorkerNextDue` |
+| `/v1/idle` | — | `SleepWorker` |
+| `/v1/heartbeat` | — | `NoteWorkerContact` |
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+type fakeWorkers struct {
+ mu sync.Mutex
+ tokens map[string]string
+ contacts []string
+ due map[string]int64
+ slept []string
+ ready map[string]string
+}
+
+func (f *fakeWorkers) WorkerToken(host string) string { /* guarded read */ }
+// ... the rest recording into the slices/maps
+
+func TestAgentSockRejectsWrongToken(t *testing.T) {
+ f := &fakeWorkers{tokens: map[string]string{"bot.x.internal": "good"}}
+ h := New(f).Handler()
+
+ for _, tc := range []struct{ name, host, tok string }{
+ {"wrong token", "bot.x.internal", "bad"},
+ {"no token", "bot.x.internal", ""},
+ {"unknown host", "nope.x.internal", "good"},
+ {"token for another host", "other.x.internal", "good"},
+ } {
+ rec := post(h, "/v1/heartbeat", tc.host, tc.tok, "")
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("%s: code = %d, want 401", tc.name, rec.Code)
+ }
+ }
+ if len(f.contacts) != 0 {
+ t.Fatal("a refused call must have no effect at all")
+ }
+}
+
+func TestAgentSockTokenIsScopedToOneHost(t *testing.T) {
+ // The security property: possession of A's token says nothing about B.
+ f := &fakeWorkers{tokens: map[string]string{"a.x.internal": "tok-a", "b.x.internal": "tok-b"}}
+ h := New(f).Handler()
+ if rec := post(h, "/v1/idle", "b.x.internal", "tok-a", ""); rec.Code != http.StatusUnauthorized {
+ t.Fatalf("A's token spoke about B: code = %d", rec.Code)
+ }
+ if len(f.slept) != 0 {
+ t.Fatal("B must not have been slept by A's token")
+ }
+}
+
+func TestAgentSockVerbs(t *testing.T) {
+ f := &fakeWorkers{tokens: map[string]string{"bot.x.internal": "good"}}
+ h := New(f).Handler()
+
+ if rec := post(h, "/v1/ready", "bot.x.internal", "good", `{"version":"abc123"}`); rec.Code != http.StatusNoContent {
+ t.Fatalf("ready: %d", rec.Code)
+ }
+ if f.ready["bot.x.internal"] != "abc123" {
+ t.Fatal("ready must record the running version — it is a sidecar's only version signal")
+ }
+ if rec := post(h, "/v1/next-due", "bot.x.internal", "good", `{"at":1756}`); rec.Code != http.StatusNoContent {
+ t.Fatalf("next-due: %d", rec.Code)
+ }
+ if f.due["bot.x.internal"] != 1756 {
+ t.Fatalf("due = %d", f.due["bot.x.internal"])
+ }
+ if rec := post(h, "/v1/idle", "bot.x.internal", "good", ""); rec.Code != http.StatusNoContent {
+ t.Fatalf("idle: %d", rec.Code)
+ }
+ if len(f.slept) != 1 {
+ t.Fatal("idle must ask for a stop")
+ }
+}
+
+func TestAgentSockRefusesGarbage(t *testing.T) {
+ f := &fakeWorkers{tokens: map[string]string{"bot.x.internal": "good"}}
+ h := New(f).Handler()
+ for _, body := range []string{"not json", `{"at":"soon"}`, strings.Repeat("x", 1<<20)} {
+ rec := post(h, "/v1/next-due", "bot.x.internal", "good", body)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("body %.20q: code = %d, want 400", body, rec.Code)
+ }
+ }
+ if rec := post(h, "/v1/nope", "bot.x.internal", "good", ""); rec.Code != http.StatusNotFound {
+ t.Fatalf("unknown verb: %d", rec.Code)
+ }
+ req := httptest.NewRequest("GET", "/v1/idle", nil)
+ // GET on a POST-only verb
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusUnauthorized {
+ t.Fatalf("GET: %d", rec.Code)
+ }
+}
+
+func TestAgentSockServeCleansUpItsSocket(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "agent.sock")
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- New(&fakeWorkers{}).Serve(ctx, path) }()
+ waitFor(t, "socket to appear", func() bool { _, err := os.Stat(path); return err == nil })
+ cancel()
+ <-done
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatal("a stale socket file must not survive shutdown — the next start would fail to bind")
+ }
+}
+```
+
+Write `post(h http.Handler, path, host, tok, body string) *httptest.ResponseRecorder`
+as a small local helper.
+
+- [ ] **Step 2: Run and watch it fail.**
+
+- [ ] **Step 3: Implement**
+
+Package doc first, stating the direction:
+
+```go
+// Package agentsock is the agent's own listening socket — the ONE channel
+// between a sidecar and the platform, and it points inward: children dial
+// the agent, the agent never dials a child. That is what lets a sidecar
+// bind nothing, and what lets the identical artifact run off-platform
+// behind NAT with no protocol difference.
+//
+// Every exec child runs as the agent's user, so the socket's file mode is
+// not an identity boundary. Identity is the per-wake token the activator
+// mints and passes in the child's environment (CARLOS_SIDECAR_TOKEN),
+// scoped to one host for one wake: a token for A cannot speak about B, and
+// a token from a previous wake matches nothing.
+package agentsock
+```
+
+Implementation notes that the tests pin:
+
+- Compare tokens with `crypto/subtle.ConstantTimeCompare`, and refuse when
+ `WorkerToken(host)` returns `""` (asleep or unknown) — one 401 for every
+ failure, no distinguishing message.
+- Bound the body: `io.LimitReader(r.Body, 4096)`, the `NextDue` decode
+ precedent in `backend_exec.go:451`.
+- `Serve` should `os.Remove(path)` before binding (a stale socket from a
+ hard kill), `net.Listen("unix", path)`, `os.Chmod(path, 0o660)`, serve
+ with `http.Server`, and on `ctx.Done()` shut down and remove the file.
+- No timeouts longer than a few seconds; these are local calls.
+
+- [ ] **Step 4: Run the tests, full gate, commit.**
+
+---
+
+### Task 8: Wire the socket, the boot pass and the ceilings into the agent
+
+**Files:**
+- Modify: `internal/host/agent.go`
+- Modify: `internal/activator/activator.go` (implement `NoteWorkerReady`)
+- Test: `internal/host/agent_test.go`
+
+**Interfaces:**
+- Consumes: Tasks 6 and 7.
+- Produces: a running agent socket on every box that runs the activator, and
+ a boot pass that starts non-hibernating sidecars.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestWorkerBootPassStartsOnlyNonHibernatingSidecars(t *testing.T) {
+ routes := []registry.Route{
+ {Host: "a.x.sidecar.internal", Kind: "sidecar", Hibernate: false, Status: "ready"},
+ {Host: "b.x.sidecar.internal", Kind: "sidecar", Hibernate: true, Status: "ready"},
+ {Host: "c.example.com", Kind: "instance", Hibernate: false, Status: "ready"},
+ {Host: "d.x.sidecar.internal", Kind: "sidecar", Hibernate: false, Status: "draining"},
+ }
+ got := workersToStart(routes)
+ if len(got) != 1 || got[0].Host != "a.x.sidecar.internal" {
+ t.Fatalf("workersToStart = %v — only a ready, non-hibernating sidecar starts at boot; a hibernating one waits for its timer or a command", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run and watch it fail.**
+
+- [ ] **Step 3: Implement**
+
+```go
+// workersToStart is the boot pass's selection: a sidecar that does not
+// hibernate must be running whenever the agent is. A hibernating sidecar is
+// deliberately NOT started here — it waits for its due time, an operator
+// command, or (later) a delivery signal, which is the whole point of the
+// class.
+func workersToStart(routes []registry.Route) []registry.Route {
+ var out []registry.Route
+ for _, rt := range routes {
+ if rt.Kind == "sidecar" && !rt.Hibernate && (rt.Status == "" || rt.Status == "ready") {
+ out = append(out, rt)
+ }
+ }
+ return out
+}
+```
+
+Then, in the agent's startup block beside the other loops:
+
+- construct `agentsock.New(svc)` and `go srv.Serve(ctx, "/run/carlos/agent.sock")`
+ **only when the activator is enabled** (the same condition that
+ constructs the activator — with hibernation off there are no workers).
+- run the boot pass once after the first registry read, calling
+ `svc.WakeWorker(rt)` for each result.
+- The sweeper already handles the due-time wakes; make sure its
+ `DueInstances` results route to `WakeWorker` for `Kind == "sidecar"`
+ rather than the instance wake path.
+
+Add `NoteWorkerReady(host, version string)` to the activator: stamp
+`lastActive`, mark the settle window satisfied, and record the version via
+the registry's existing version setter so §9.2's honesty rule holds (a
+sidecar that never reports shows unknown, never "current").
+
+- [ ] **Step 4: Run the tests, full gate, commit.**
+
+---
+
+### Task 9: The sidecar reconciler — and the removal-authority collision
+
+**Files:**
+- Create: `internal/provision/sidecar_reconcile.go`, `sidecar_reconcile_test.go`
+- Modify: `internal/provision/reconcile.go` (removal phase, ~651-673)
+- Modify: `internal/host/agent.go` (call it through `reconcileSerialized`)
+
+**Interfaces:**
+- Consumes: Task 2's shapes, Task 1's `Args`.
+- Produces: `func ReconcileSidecars(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig) error`
+
+**The collision, stated plainly:** the instance pass's removal phase is
+scoped by `Provisioned`, region, pool and absence-from-records, with **no
+Kind check**. Sidecar rows are `Provisioned` and can never appear in the
+instance record set, so the existing pass would delete every sidecar on
+every tick — minting and destroying forever, with no error anywhere. Each
+pass's removal authority must be scoped to its own Kind.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestInstanceReconcileNeverRemovesSidecarRows(t *testing.T) {
+ f := setup(t) // reconcile_test.go's existing fixture
+ must(t, f.reg.Upsert(registry.Route{
+ Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar",
+ App: "eleven", Account: "bdm", Provisioned: true, Status: "ready",
+ }))
+ // No instance records at all: the pass's removal phase runs in full.
+ if err := Reconcile(context.Background(), f.store, f.reg, ReconcileConfig{DataDir: f.dir}); err != nil {
+ t.Fatalf("Reconcile: %v", err)
+ }
+ if _, err := f.reg.Get("bot.eleven.bdm.sidecar.internal"); err != nil {
+ t.Fatal("the instance pass deleted a sidecar row — each pass's removal authority is scoped to its OWN kind, or the two passes destroy each other's work forever")
+ }
+}
+
+func TestSidecarReconcileMints(t *testing.T) {
+ f := setup(t)
+ seedAccount(t, f.store, "bdm")
+ writeSidecarManifest(t, f.store, "bdm", "eleven", SidecarManifest{
+ Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}, Channel: "stable",
+ })
+ writeSidecarRecord(t, f.store, SidecarRecord{
+ Schema: 1, Name: "bot", Host: SidecarHost("bot", "eleven", "bdm"),
+ App: "eleven", Account: "bdm", Channel: "stable",
+ })
+
+ if err := ReconcileSidecars(context.Background(), f.store, f.reg, ReconcileConfig{DataDir: f.dir}); err != nil {
+ t.Fatalf("ReconcileSidecars: %v", err)
+ }
+ rt := mustGet(t, f.reg, SidecarHost("bot", "eleven", "bdm"))
+ if rt.Kind != "sidecar" {
+ t.Fatalf("Kind = %q", rt.Kind)
+ }
+ if rt.Socket != "" || rt.Addr != "" {
+ t.Fatalf("a sidecar row must carry NO socket and NO addr, got socket=%q addr=%q", rt.Socket, rt.Addr)
+ }
+ if rt.DBPath == "" || !rt.Provisioned || rt.Args != `["sidecar","run"]` {
+ t.Fatalf("route = %+v", rt)
+ }
+}
+
+func TestSidecarReconcileRemovesOnlyItsOwnKind(t *testing.T) {
+ f := setup(t)
+ seedAccount(t, f.store, "bdm")
+ must(t, f.reg.Upsert(registry.Route{Host: "gone.eleven.bdm.sidecar.internal",
+ Kind: "sidecar", App: "eleven", Account: "bdm", Provisioned: true}))
+ must(t, f.reg.Upsert(registry.Route{Host: "app.example.com",
+ Kind: "instance", App: "eleven", Account: "bdm", Provisioned: true}))
+
+ if err := ReconcileSidecars(context.Background(), f.store, f.reg, ReconcileConfig{DataDir: f.dir}); err != nil {
+ t.Fatalf("ReconcileSidecars: %v", err)
+ }
+ if _, err := f.reg.Get("gone.eleven.bdm.sidecar.internal"); err == nil {
+ t.Fatal("a provisioned sidecar with no record must be removed")
+ }
+ if _, err := f.reg.Get("app.example.com"); err != nil {
+ t.Fatal("the sidecar pass must never touch an instance row")
+ }
+}
+
+func TestSidecarReconcileSkipsRemovalWhenAReadFailed(t *testing.T) {
+ // The fail-safe: a transient read failure must never mass-retire a
+ // tenant's fleet. Minting still proceeds; minting is additive.
+ f := setup(t)
+ seedAccount(t, f.store, "bdm")
+ must(t, f.reg.Upsert(registry.Route{Host: "keep.eleven.bdm.sidecar.internal",
+ Kind: "sidecar", App: "eleven", Account: "bdm", Provisioned: true}))
+ f.store.failReadsFor(SidecarsPrefix("bdm", "eleven")) // fixture hook
+
+ _ = ReconcileSidecars(context.Background(), f.store, f.reg, ReconcileConfig{DataDir: f.dir})
+ if _, err := f.reg.Get("keep.eleven.bdm.sidecar.internal"); err != nil {
+ t.Fatal("a read failure skipped nothing — one bad list must not retire a fleet")
+ }
+}
+
+func TestSidecarReconcileRefusesHostWithAStagedCoSpawnFile(t *testing.T) {
+ f := setup(t)
+ seedAccount(t, f.store, "bdm")
+ host := SidecarHost("bot", "eleven", "bdm")
+ must(t, os.WriteFile(filepath.Join(f.dir, host+"-sidecar.env"), []byte("X=1\n"), 0o600))
+ writeSidecarManifest(t, f.store, "bdm", "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}})
+ writeSidecarRecord(t, f.store, SidecarRecord{Schema: 1, Name: "bot", Host: host, App: "eleven", Account: "bdm"})
+
+ _ = ReconcileSidecars(context.Background(), f.store, f.reg, ReconcileConfig{DataDir: f.dir})
+ if _, err := f.reg.Get(host); err == nil {
+ t.Fatal("a host with a staged legacy co-spawn file must not also get a native sidecar row — one host, one mechanism")
+ }
+}
+
+func TestSidecarReconcileConvergesArgsFromTheManifest(t *testing.T) {
+ f := setup(t)
+ seedAccount(t, f.store, "bdm")
+ writeSidecarManifest(t, f.store, "bdm", "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}})
+ writeSidecarRecord(t, f.store, SidecarRecord{Schema: 1, Name: "bot", Host: SidecarHost("bot", "eleven", "bdm"), App: "eleven", Account: "bdm"})
+ must(t, ReconcileSidecars(context.Background(), f.store, f.reg, ReconcileConfig{DataDir: f.dir}))
+
+ writeSidecarManifest(t, f.store, "bdm", "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"worker", "run"}})
+ must(t, ReconcileSidecars(context.Background(), f.store, f.reg, ReconcileConfig{DataDir: f.dir}))
+
+ rt := mustGet(t, f.reg, SidecarHost("bot", "eleven", "bdm"))
+ if rt.Args != `["worker","run"]` {
+ t.Fatalf("Args = %q — truth lives in the bucket and is re-stamped every pass, exactly as seed is", rt.Args)
+ }
+}
+
+func TestSidecarReconcileConvergesTheCeilings(t *testing.T) {
+ f := setup(t)
+ seedAccount(t, f.store, "bdm")
+ writeSidecarManifest(t, f.store, "bdm", "eleven", SidecarManifest{
+ Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}, IdleSeconds: 300, MaxRunSeconds: 3600})
+ writeSidecarRecord(t, f.store, SidecarRecord{Schema: 1, Name: "bot",
+ Host: SidecarHost("bot", "eleven", "bdm"), App: "eleven", Account: "bdm"})
+ must(t, ReconcileSidecars(context.Background(), f.store, f.reg, ReconcileConfig{DataDir: f.dir}))
+
+ writeSidecarManifest(t, f.store, "bdm", "eleven", SidecarManifest{
+ Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}, IdleSeconds: 60, MaxRunSeconds: 120})
+ must(t, ReconcileSidecars(context.Background(), f.store, f.reg, ReconcileConfig{DataDir: f.dir}))
+
+ rt := mustGet(t, f.reg, SidecarHost("bot", "eleven", "bdm"))
+ if rt.IdleSeconds != 60 || rt.MaxRunSeconds != 120 {
+ t.Fatalf("ceilings = %d/%d, want 60/120 — the sweeper reads the row, so the row must follow the manifest", rt.IdleSeconds, rt.MaxRunSeconds)
+ }
+}
+```
+
+- [ ] **Step 2: Run and watch them fail.**
+
+- [ ] **Step 3: Guard the instance pass's removal phase**
+
+In `reconcile.go`'s removal loop, in the same condition that already checks
+`rt.Provisioned` and scope:
+
+```go
+ // Removal authority is scoped to this pass's OWN kind. A sidecar row
+ // is Provisioned too, and can never appear in the instance record
+ // set — without this guard the two passes would delete each other's
+ // rows on every tick, forever, with no error anywhere.
+ if rt.Kind == "sidecar" {
+ continue
+ }
+```
+
+- [ ] **Step 4: Implement `ReconcileSidecars`**
+
+Mirror `Reconcile` (`reconcile.go:330-675`) exactly, with these
+substitutions and additions:
+
+- records come from `ParseSidecarKey` over `apps/`, manifests from
+ `GetSidecarManifest`.
+- scope is the same `rec.Region != cfg.Region || !poolServed(cfg.Pools, rec.Pool)` skip.
+- **placement gate**: skip a record whose `PlacementOrDefault()` is
+ `"no-inbound"` unless `cfg.Inbound == "none"` (Task 10 supplies the field).
+- mint:
+ ```go
+ func sidecarRouteFor(rec SidecarRecord, m SidecarManifest, dataDir string) registry.Route {
+ argv, _ := json.Marshal(m.Args)
+ return registry.Route{
+ Host: rec.Host, Slug: rec.Name, Kind: "sidecar",
+ App: rec.App, Account: rec.Account, Pool: rec.Pool,
+ Region: rec.Region, Channel: rec.Channel, Environment: rec.Environment,
+ DBPath: DefaultDBPath(dataDir, rec.Host),
+ Hibernate: m.HibernateOrDefault(),
+ Args: string(argv),
+ IdleSeconds: int(m.IdleOrDefault() / time.Second),
+ MaxRunSeconds: int(m.MaxRunOrDefault() / time.Second),
+ Provisioned: true,
+ Status: "ready",
+ // Socket and Addr are deliberately left empty: a sidecar binds
+ // nothing, and the edge refuses the kind outright.
+ }
+ }
+ ```
+- converge: re-stamp `Args` (narrow `SetArgs`), the ceilings (narrow
+ `SetWorkerLimits`), `Channel`, `Environment`, and `Hibernate` when they
+ differ.
+- removal: only `rt.Kind == "sidecar" && rt.Provisioned`, in scope, absent
+ from the record set, and not in `protect`; **skipped entirely when any
+ read failed**.
+- the co-spawn conflict: before minting, `os.Stat(filepath.Join(cfg.DataDir, rec.Host+"-sidecar.env"))`
+ — if it exists, log loudly and skip. One host, one mechanism.
+
+Finally, add the leg to `internal/host/agent.go` beside the instance one,
+**inside `reconcileSerialized`'s mutex** so it cannot race the instance pass
+(the comment at `agent.go:525-537` explains exactly why an overlapping pass
+is dangerous), and inside `fullPass` so a console serial bump converges it
+within ~2s.
+
+- [ ] **Step 5: Run the tests, full gate, commit.**
+
+---
+
+### Task 10: Placement — boxes that accept no-inbound work
+
+**Files:**
+- Modify: `internal/host/agent.go` (`provisionTickConfig`, ~1316-1334)
+- Modify: `internal/provision/reconcile.go` (mint gate)
+- Modify: `internal/provision/sidecar_reconcile.go` (placement gate)
+- Test: `internal/host/agent_test.go`, `internal/provision/reconcile_test.go`
+
+**Interfaces:**
+- Consumes: Task 9.
+- Produces: `ReconcileConfig.Inbound string` (`""`/`"any"` or `"none"`).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestProvisionTickConfigReadsInbound(t *testing.T) {
+ env := map[string]string{"CARLOS_REGION": "ie", "CARLOS_POOLS": "eleven", "CARLOS_INBOUND": "none"}
+ cfg := provisionTickConfig(func(k string) string { return env[k] })
+ if cfg.Inbound != "none" {
+ t.Fatalf("Inbound = %q", cfg.Inbound)
+ }
+ cfg = provisionTickConfig(func(string) string { return "" })
+ if cfg.Inbound != "" {
+ t.Fatal("an unset CARLOS_INBOUND must be an inert default — today's behaviour exactly")
+ }
+}
+
+func TestNoInboundBoxRefusesRoutedInstances(t *testing.T) {
+ f := setup(t)
+ seedAccount(t, f.store, "bdm")
+ writeManifest(t, f.store, "bdm", "tito", Manifest{Schema: 1, App: "tito", Domain: "go.tito.io"})
+ writeRecord(t, f.store, Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", Account: "bdm", Channel: "stable"})
+
+ err := Reconcile(context.Background(), f.store, f.reg, ReconcileConfig{DataDir: f.dir, Inbound: "none"})
+ if err != nil {
+ t.Fatalf("Reconcile: %v", err)
+ }
+ if _, err := f.reg.Get("acme.go.tito.io"); err == nil {
+ t.Fatal("a box that cannot serve must never be handed something to serve — a misfiled record is a log line, not a silent outage")
+ }
+}
+
+func TestNoInboundPlacementOnlyLandsOnMatchingBoxes(t *testing.T) {
+ f := setup(t)
+ seedAccount(t, f.store, "bdm")
+ writeSidecarManifest(t, f.store, "bdm", "eleven", SidecarManifest{
+ Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}, Placement: "no-inbound"})
+ writeSidecarRecord(t, f.store, SidecarRecord{Schema: 1, Name: "bot",
+ Host: SidecarHost("bot", "eleven", "bdm"), App: "eleven", Account: "bdm", Placement: "no-inbound"})
+
+ must(t, ReconcileSidecars(context.Background(), f.store, f.reg, ReconcileConfig{DataDir: f.dir})) // ordinary box
+ if _, err := f.reg.Get(SidecarHost("bot", "eleven", "bdm")); err == nil {
+ t.Fatal("a no-inbound workload must not land on a box that accepts inbound work")
+ }
+
+ must(t, ReconcileSidecars(context.Background(), f.store, f.reg, ReconcileConfig{DataDir: f.dir, Inbound: "none"}))
+ if _, err := f.reg.Get(SidecarHost("bot", "eleven", "bdm")); err != nil {
+ t.Fatal("it must land on a box that declares CARLOS_INBOUND=none")
+ }
+}
+```
+
+- [ ] **Step 2: Run and watch them fail.**
+- [ ] **Step 3: Implement** — add the field, read the env var, and add the
+ two gates with the comments the tests' failure messages quote.
+- [ ] **Step 4: Run the tests, full gate, commit.**
+
+---
+
+### Task 11: Config delivery and restart reach sidecars
+
+**Files:**
+- Modify: `internal/appconfig/syncexec.go` (`execRouteBase`, ~398-407)
+- Modify: `internal/host/restart.go` (~77, 192, 545)
+- Test: `internal/appconfig/syncexec_test.go`, `internal/host/restart_test.go`
+
+**`release.Adoptable` is deliberately NOT changed.** It is the most reused
+predicate in the repo — adopt membership, capability convergence, socket and
+backing advisories, status pages — and every one of those is correct to
+exclude a sidecar. Each consumer that genuinely wants sidecars opts in by
+name. Two deliberate edits instead of one blind one.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestSidecarGetsItsConfigEnvFile(t *testing.T) {
+ rt := registry.Route{Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar",
+ App: "eleven", Status: "ready"}
+ if !execRouteBase(rt) {
+ t.Fatal("a sidecar must receive config delivery — this is the direct replacement for the hand-staged <host>-sidecar.env, and the whole reason the file path existed")
+ }
+}
+
+func TestSidecarConfigStillExcludesPlatformAndUnitRoutes(t *testing.T) {
+ base := registry.Route{Host: "bot.x.sidecar.internal", Kind: "sidecar", App: "eleven", Status: "ready"}
+ plat := base
+ plat.Platform = true
+ if execRouteBase(plat) {
+ t.Fatal("a Platform alias is still excluded")
+ }
+ unit := base
+ unit.Backing = registry.BackingUnit
+ if execRouteBase(unit) {
+ t.Fatal("unit-backed routes are still excluded")
+ }
+}
+
+func TestRestartPassCyclesSidecars(t *testing.T) {
+ routes := []registry.Route{
+ {Host: "bot.x.sidecar.internal", Kind: "sidecar", App: "eleven", Account: "bdm", Status: "ready"},
+ }
+ if len(restartCandidates(routes)) != 1 {
+ t.Fatal("carlos restart -app X must cycle that app's sidecars too")
+ }
+}
+```
+
+- [ ] **Step 2: Run and watch them fail.**
+- [ ] **Step 3: Implement** — widen `execRouteBase`'s kind test to
+ `(rt.Kind == "instance" || rt.Kind == "sidecar")`, leaving every other
+ clause exactly as it is; and in `restart.go` accept sidecars alongside
+ `release.Adoptable` routes, cycling them via the activator's stop/wake
+ rather than the adopt path. Also confirm the reaper in
+ `syncexec.go:617` still treats `<host>-sidecar.env` as someone else's file
+ — the legacy path must stay untouched (Task 16 documents why).
+- [ ] **Step 4: Run the tests, full gate, commit.**
+
+---
+
+### Task 12: Console API — the sidecar verbs
+
+**Files:**
+- Create: `internal/console/apisidecars.go`, `apisidecars_test.go`
+- Modify: `internal/console/authcli.go` (`registerCLIRoutes`, ~273-286)
+
+**Interfaces:**
+- Consumes: Task 2's shapes, Task 13's wake object (build 13 first if you
+ prefer; the wake handler can land in either order).
+- Produces the routes:
+ ```
+ PUT /api/cli/apps/{account}/{app}/sidecars-manifest
+ GET /api/cli/apps/{account}/{app}/sidecars
+ POST /api/cli/apps/{account}/{app}/sidecars
+ GET /api/cli/apps/{account}/{app}/sidecars/{name}
+ DELETE /api/cli/apps/{account}/{app}/sidecars/{name}
+ POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake
+ ```
+
+**The app-held token works unchanged.** `CLIAppToken` binds a token to
+(account, app); these verbs authorize against the same principal, which is
+exactly the "apps can provision sidecar instances easily" requirement —
+Eleven's app process, holding no AWS credentials and no box access, creates
+a bot per enrolled member.
+
+Refusal order, copied from `apiinstances.go:474-478` and its tests:
+**principal gate → rate limit → releases configured → manifest present
+(409 naming `carlos sidecars enable`) → name shape (400) → region shape and
+allow-list (400) → channel (400) → shape validation (400) → create-only
+write (409 on a lost race) → audit + `bumpSerial` → 201.**
+
+- [ ] **Step 1: Write the failing tests**
+
+Mirror `apiinstances_test.go` exactly, reusing its fixture helpers
+(`rawReq`, `canonicalNotFound`, `assertNotFound`, `newConsoleFixture`):
+
+```go
+func TestAPISidecarRefusalMatrix(t *testing.T) {
+ // Every refusal before the principal is proven must be BYTE-IDENTICAL
+ // to http.NotFound's own output: a non-member must never learn that an
+ // account, an app, or a sidecar exists.
+ // ... one case per: no such account, no such app, not a member,
+ // app token bound to a different app, user token on an app verb.
+}
+
+func TestAPISidecarCreateRefusalOrder(t *testing.T) {
+ // Walk the order above with one token, asserting each stage's code and
+ // that the NEXT stage was not reached.
+}
+
+func TestAPISidecarAppTokenContainment(t *testing.T) {
+ // An app token creates/lists/deletes/wakes its OWN app's sidecars, and
+ // 401s on promote and on minting another token.
+}
+
+func TestAPISidecarCreateIsCreateOnly(t *testing.T) {
+ // Second POST for the same name -> 409, never a silent overwrite.
+}
+
+func TestAPISidecarDeleteIsIdempotent(t *testing.T) {
+ // DELETE of an absent name -> 200. The caller's goal is "not there".
+ // And it must work after the manifest is gone: deletion outlives policy.
+}
+
+func TestAPISidecarCreateWithoutManifestNamesTheEnableVerb(t *testing.T) {
+ // 409 whose body names `carlos sidecars enable` — membership is already
+ // proven at this point, so this is not a leak.
+}
+
+func TestAPISidecarWakeBumpsTheGeneration(t *testing.T) {
+ // Two wakes -> generation 1 then 2, and the serial moved both times.
+}
+```
+
+- [ ] **Step 2: Run and watch them fail.**
+- [ ] **Step 3: Implement** `apisidecars.go` following `apiinstances.go`'s
+ structure handler-for-handler, and register the routes. The enable handler
+ is gated by `apiAccountApp` **directly** (it is a human act), like
+ `handleAPIInstanceEnable`; the rest go through the shared gate.
+- [ ] **Step 4: Run the tests, full gate, commit.**
+
+---
+
+### Task 13: The wake generation object and the box-side pass
+
+**Files:**
+- Create: `internal/release/sidecarwake.go`, `sidecarwake_test.go`
+- Modify: `internal/host/restart.go` or a sibling in `internal/host`
+- Test: `internal/host/*_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ func SidecarWakeKey(acct, app, name string) string // apps/<acct>/<app>/sidecar-wake/<name>.json
+ type SidecarWake struct{ Schema, Generation int; At, By, Reason string }
+ ```
+
+Copy `restartgen.go` (`internal/release/restartgen.go`) wholesale: a small
+CAS-guarded generation object whose **etag moving** is the entire signal.
+The box compares etags per (account, app, name) against a cache and wakes
+what moved — the `restartGenPass` shape (`internal/host/restart.go:128`).
+
+Nothing is pushed at a box. That is what lets the box live in a private
+subnet.
+
+- [ ] **Step 1: Write the failing tests** — an etag move wakes exactly the
+ named sidecar; an unchanged etag wakes nothing; a first sighting on an
+ unseeded cache does **not** replay a wake (the no-replay-on-absence rule
+ the serial watcher already uses, `agent.go:1032-1042`); a read error for
+ one app does not suppress the others.
+- [ ] **Step 2: Run and watch them fail.**
+- [ ] **Step 3: Implement.**
+- [ ] **Step 4: Run the tests, full gate, commit.**
+
+---
+
+### Task 14: `carlos sidecars`
+
+**Files:**
+- Create: `sidecars_cli.go`, `sidecars_cli_test.go`, `sidecars_e2e_test.go`
+- Modify: `main.go` (dispatch), `apimode.go` (client methods)
+
+**Interfaces:**
+- Consumes: Task 12's routes.
+- Produces the verbs:
+ ```
+ carlos sidecars enable -app <app> -args '<argv>' [-placement no-inbound]
+ [-regions ie,fleet/eleven] [-pool p] [-channel c]
+ [-idle 5m] [-max-run 1h] [-no-hibernate]
+ carlos sidecars create -app <app> <name>
+ carlos sidecars ls -app <app> [-long]
+ carlos sidecars wake -app <app> <name>
+ carlos sidecars rm -app <app> <name>
+ carlos sidecars logs -app <app> <name>
+ ```
+
+Follow `instances_cli.go` precisely: subcommand words dispatch positionally
+before flag parsing; `enable` works in **both** API and direct/store mode
+(operator-only flags refused in signed-in mode); `create`/`ls`/`wake`/`rm`
+are **API-mode only** via `requireAPISession`, whose refusal must name
+sidecars' own advice, never another verb's.
+
+`ls` prints the short name by default and the synthetic host only under
+`-long`. It shows **running version against desired channel version**, and a
+sidecar that has never called `/v1/ready` shows `unknown`, never the desired
+version — a stamp that overclaims is worse than an absent one.
+
+`logs` delegates to the existing `carlos logs` path for the sidecar's host.
+
+- [ ] **Step 1: Write the failing tests** — unit tests for flag parsing and
+ the duration flags (`-idle 5m` → `IdleSeconds: 300`), plus an e2e test in
+ the `instances_e2e_test.go` mould driving a real `console.Server` over
+ `httptest` end to end: enable → create → ls → wake → rm.
+
+ **Every test that shells out to the carlos binary must build its env
+ through `carlosTestEnv` and set `cmd.Dir` to a scratch dir.** The binary
+ walks cwd ancestors for `.carlos/config`; the fallback is the operator's
+ real login and default console. A logged-in laptop running
+ `go test ./...` once minted 40+ real accounts on the production console.
+- [ ] **Step 2: Run and watch them fail.**
+- [ ] **Step 3: Implement.**
+- [ ] **Step 4: Run the tests, full gate, commit.**
+
+---
+
+### Task 15: The console tells the truth about sidecars
+
+**Files:**
+- Modify: `internal/console/instances.go` (~88-97), `internal/console/overview.go`
+ (~129-144, ~237-264, ~286-306), `internal/status/snapshot.go` (~406-427)
+- Test: the matching `_test.go` files
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestReportingHostIsNeverASidecar(t *testing.T) {
+ routes := []registry.Route{
+ {Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar", Status: "ready"},
+ {Host: "eleven.example.com", Kind: "instance", Status: "ready"},
+ }
+ if got := reportingHost(routes); got != "eleven.example.com" {
+ t.Fatalf("reportingHost = %q — polling an unroutable sidecar makes a HEALTHY app read 'not reporting'", got)
+ }
+}
+
+func TestSidecarIsNotAPublicResident(t *testing.T) {
+ counts := CollectBoxCounts([]registry.Route{{Host: "bot.x.sidecar.internal", Kind: "sidecar"}})
+ if counts.Resident != 0 {
+ t.Fatal("a sidecar must not be counted as a public resident app instance")
+ }
+ if counts.Sidecars != 1 {
+ t.Fatal("it belongs in its own bucket")
+ }
+}
+
+func TestSidecarHealthWordIsNotServing(t *testing.T) {
+ if w := healthWord("sidecar", "ready", false, false, false); w == "Serving" {
+ t.Fatal("a sidecar never serves a request — 'Serving' is false")
+ }
+}
+```
+
+- [ ] **Step 2: Run and watch them fail.**
+- [ ] **Step 3: Implement** — filter the reporting-host picker by Kind, give
+ `CollectBox` an explicit sidecar branch and a `Sidecars` counter (additive
+ field), count sidecars separately in the overview, and give `healthWord`
+ the sidecar vocabulary: `Running`, `Asleep`, `Waking`, `Crashed`,
+ `Never started`. Sidecars are **not** on the public status page: their
+ existence is account-visible, not public.
+- [ ] **Step 4: Run the tests, full gate, commit.**
+
+---
+
+### Task 16: Documentation, the runbook, and the legacy split
+
+**Files:**
+- Create: `infra/no-inbound-box.md`
+- Modify: `CLAUDE.md`
+- Modify: `internal/activator/activator.go` (package doc), `backend_exec.go`
+ (the `HasSidecar`/`StartSidecar` comments)
+
+- [ ] **Step 1: Write `infra/no-inbound-box.md`** — the private-subnet
+ runbook, in `infra/eleven-fleet.md`'s voice: preconditions, the tofu
+ shape (private subnet, no IGW route, egress via NAT, no inbound SG rules,
+ no SSM), the box env contract (`CARLOS_INBOUND=none` plus the fleet
+ contract), verification, and an abort path at every step. Mark clearly:
+ **written for Paul's applies; nothing here has been applied.**
+
+ Note the easier posture too: fleet boxes are *already* outbound-only dial,
+ so Eleven's first sidecar box needs no new network shape at all — the
+ private-subnet variant is the harder posture, available when wanted.
+
+- [ ] **Step 2: Record the legacy split** in the activator's package doc and
+ beside `HasSidecar`: the file-staged `<host>-sidecar.env` co-spawn is
+ **legacy but live** (Eleven runs on it today), the record path is native,
+ a host may not have both, and retiring the file path is a follow-up gated
+ on Eleven's own migration.
+
+- [ ] **Step 3: Add a CLAUDE.md bullet** under "Rules this repo has paid
+ for":
+
+ ```markdown
+ - **A sidecar is a member that nothing can dial.** `Kind == "sidecar"`
+ rows carry no socket and no addr, are refused by the edge on kind (not
+ merely unmatched by it), and are never advertised in a fleet binding —
+ a fleet-placed row reaches the edge's fleet arm on shape alone. Each
+ reconcile pass's removal authority is scoped to its OWN kind: the
+ instance pass and the sidecar pass would otherwise delete each other's
+ rows on every tick, forever, with no error anywhere.
+ ```
+
+- [ ] **Step 4: Full gate, commit.**
+
+---
+
+## Final verification before the PR
+
+- [ ] `go build ./...`, `go vet ./...`, `go test ./... -count=1` — all clean.
+- [ ] `GOOS=darwin go build ./...` — clean (`internal/host` links into the
+ CLI, which people run on Macs).
+- [ ] `gofmt -l` clean for every file this branch touched.
+- [ ] `git log --oneline main..HEAD` reads as one coherent story.
+- [ ] No live box was touched; no real bucket was written.
+- [ ] Push the branch and open the PR. **Do not merge it** — this is a new
+ subsystem touching the activator, the edge and the console's auth
+ surface, which is exactly the diff CLAUDE.md says waits for a person.