carlos / platform Public

spec: sidecar members — a serving class that nothing can dial

Paul Campbell pushed by paul@keymail.dev 1c6faf8922cf01873fcd6fa6bd6197f7063b0149 parent d7077d1
1 file changed, +625 −0
  • docs/superpowers/specs/2026-08-17-sidecar-members-design.md +625 −0
diff --git a/docs/superpowers/specs/2026-08-17-sidecar-members-design.md b/docs/superpowers/specs/2026-08-17-sidecar-members-design.md
new file mode 100644
index 000000000..0064c4889
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-17-sidecar-members-design.md
@@ -0,0 +1,625 @@
+# Sidecar members: a serving class that nothing can dial
+
+**Date:** 2026-08-17 · **Status:** approved design (Paul, via brainstorming
+session, 2026-08-17) · **Requested by:** Paul, for Eleven.
+
+**Closes:** the gap between the platform spec's `sidecar` serving class
+(`2026-08-01-carlos-platform-design.md` §5.4, §4 manifest) and what the
+platform can actually be asked to run. The runtime half has existed since
+Plan A; the declaration half has never existed.
+
+**Extends:** instance provisioning (`2026-08-04-instance-provisioning-design.md`
+— the record/manifest/reconciler/API pattern this mirrors), exec config
+delivery (`2026-08-02-exec-config-delivery-design.md` §"Per-sidecar config …
+deferred", which this closes), customer fleets
+(`2026-08-05-customer-fleets-design.md` — outbound-only boxes),
+`carlos restart` (#113 — the pull-based generation-object idiom the wake
+verb copies).
+
+**Deliberately does not depend on** the unmerged trust-classes and
+private-instances specs (PR #183). Those name the *vocabulary* this class
+will eventually be placed by (an A-class sidecar holding a member key is
+exactly their subject); nothing here waits on them.
+
+## 1. Why
+
+A sidecar today is a file on a box. `HasSidecar` is `os.Stat` of
+`/data/<host>-sidecar.env` (`internal/activator/backend_exec.go:732`), and
+nothing in the platform writes that file — config delivery explicitly
+disowns it (`internal/appconfig/syncexec.go:617`). So the only way to run
+one is to get root on a box and place a file, which is precisely the
+product gap CLAUDE.md names: *every `carlos` command is designed for a user
+with no access to the underlying infra.*
+
+The runtime that exists is also the wrong shape for what Eleven needs. It
+is a **co-spawn**: one sidecar bound to one routed instance, alive exactly
+while that instance is awake (`internal/activator/activator.go:17-24`). A
+bot that must run on a box with no routed app on it — eventually a box in a
+private subnet with no inbound path from the internet at all — cannot be
+expressed that way, because there is no parent instance to hang it from.
+
+## 2. Rulings (Paul, 2026-08-17)
+
+1. **Standalone members.** A sidecar gets its own record, its own registry
+ row, its own identity and its own lifecycle. It can be the only thing on
+ a box. The existing co-spawn path keeps working, untouched, for Eleven's
+ current shape; provisioned sidecars never use it (§10).
+2. **Hibernating**, with schedule and operator-command wakes. Delivery-wake
+ (the edge signalling that ciphertext is queued for a sidecar's identity)
+ is designed here but **not built** — it needs edge work this does not
+ include (§7.5).
+3. **Synthetic internal host** as the identity: `<name>.<app>.<sqid>.sidecar.internal`,
+ never resolvable, refused by the edge, so every host-keyed subsystem
+ works unchanged (§3).
+4. **Placement axis in the platform now; infra spec'd, not applied.** Boxes
+ declare that they accept no-inbound work; the reconciler honours it; the
+ tofu for a real private-subnet box is a runbook for Paul's applies (§8).
+
+## 3. Identity: a host that cannot be a host
+
+A sidecar is keyed by a **synthetic host**:
+
+ <name>.<app>.<sqid>.sidecar.internal
+ bot-acme.eleven.bdm.sidecar.internal
+
+`name` is one DNS label, validated by the same regex instance hosts use
+(`provision.labelRe`) and for the same stated reason: the name becomes a
+bucket key segment and a filesystem path segment (`<host>.db`, the cgroup
+directory, the log sink file), and the label regex is what keeps a hostile
+name an opaque, safe path component.
+
+**Why a synthetic host and not a new identity axis.** The registry, config
+delivery, cgroups, the S3 lease, the DB path, the log sink, metrics
+attribution, `carlos logs`, `carlos restart` and the release ladder are all
+keyed by host. A second identity axis would mean touching every consumer
+that assumes host is the key — the wire-shape rule this repo has paid for
+three times. A synthetic host costs one reserved suffix and buys every one
+of those subsystems working on day one.
+
+**`.sidecar.internal` is reserved, and reservation is enforced, not
+documented.** The suffix is refused at every door where a name becomes
+routable:
+
+- the edge never binds a `Kind == "sidecar"` row at all (§6.1), which is
+ the structural guarantee; the suffix refusals below are defence in depth
+ against a *different* row type wearing the name.
+- `carlos add` / the route door / domain attach refuse a host ending in
+ `.sidecar.internal`.
+- the domains reconciler refuses to mint an alias for one.
+- a custom domain may not be attached to a sidecar.
+
+The CLI speaks the short name (`carlos sidecars ls` prints `bot-acme`); the
+synthetic host is the internal key, shown only by `-long`.
+
+## 4. Bucket shapes (all additive)
+
+Mirrors instance provisioning exactly, one directory over.
+
+### 4.1 `apps/<sqid>/<app>/sidecars.json` — the app's opt-in and policy
+
+```json
+{
+ "schema": 1,
+ "app": "eleven",
+ "pool": "eleven",
+ "regions": ["fleet/eleven"],
+ "channel": "stable",
+ "args": ["sidecar", "run"],
+ "placement": "no-inbound",
+ "hibernate": true,
+ "idle": "5m",
+ "max_run": "1h"
+}
+```
+
+- **Absence means the feature does not exist for that app** — the
+ litestream/instances convention. The API refuses creates with a message
+ naming `carlos sidecars enable`, only after membership is proven.
+- `args` (required): the argv the platform execs after the release binary.
+ App-level, not per-record — like `seed`, it is a property of the app's
+ current release contract. `["sidecar", "run"]` is the shape the existing
+ co-spawn hard-codes; making it declared is what lets an app ship more
+ than one kind of worker later.
+
+ **One binary, many sidecars.** Args being app-level is what makes
+ Eleven's shape — a bot per enrolled member — work: the same release runs
+ every sidecar, and each child learns *which* one it is from its
+ environment, not its argv:
+
+ CARLOS_SIDECAR_NAME=bot-acme
+ CARLOS_SIDECAR_HOST=bot-acme.eleven.bdm.sidecar.internal
+
+ Per-sidecar configuration beyond that is the config plane's job, not the
+ manifest's: a record may carry `environment`, and route-scoped config
+ delivery (merged, `#206`) then gives that sidecar its own variables and
+ sealed secrets. There is deliberately no per-record `args`.
+- `placement`: `"any"` (default) or `"no-inbound"` (§8).
+- `hibernate` (default true), `idle` (default 5m), `max_run` (default 1h) —
+ the lifecycle knobs (§7).
+- `regions`, `pool`, `channel`: as instances. `regions: ["fleet/eleven"]`
+ is how Eleven's sidecars land only on Eleven's own boxes.
+
+Written by the operator or the app owner — `carlos sidecars enable`,
+API and direct/store mode both, the `carlos instances enable` pattern.
+
+### 4.2 `apps/<sqid>/<app>/sidecars/<name>.json` — the record
+
+```json
+{
+ "schema": 1,
+ "name": "bot-acme",
+ "host": "bot-acme.eleven.bdm.sidecar.internal",
+ "app": "eleven",
+ "account": "bdm",
+ "pool": "eleven",
+ "region": "fleet/eleven",
+ "channel": "stable",
+ "placement": "no-inbound",
+ "created_at": "2026-08-17T23:00:00Z",
+ "created_by": "app:bdm/eleven"
+}
+```
+
+Create-only (`If-None-Match: *`) — a lost race is a 409, never a silent
+overwrite. The body must agree with its own key path (name/app/account vs
+the key's segments) or the reconciler skips it loudly. No tombstone:
+deletion deletes the object; the parked DB and WAL stream under the
+regional bucket are deliberately untouched, the same data-safety contract
+`registry.Delete` and `carlos remove` hold.
+
+`ParseRecordKey`'s discipline is inherited whole: exactly one non-empty
+segment after `sidecars/`, so `sidecars.json` (the manifest) and any nested
+path both refuse to parse as a record.
+
+### 4.3 `apps/<sqid>/<app>/sidecar-wake/<name>.json` — the wake generation
+
+The operator/app wake source (§7.4), copied from `restart-gen.json`
+(`internal/release/restartgen.go`): a small CAS-guarded generation object
+whose **etag moving** is the whole signal. A sibling prefix rather than a
+key under `sidecars/` so that §4.2's one-segment rule stays exactly as
+strict as it reads.
+
+```json
+{"schema": 1, "generation": 3, "at": "2026-08-17T23:05:00Z",
+ "by": "member:bdm", "reason": "operator wake"}
+```
+
+## 5. Registry: `Kind = "sidecar"`
+
+A sidecar row is a route row that is not a route: `Kind "sidecar"`,
+**`Socket ""`, `Addr ""`** — the two columns that make a row servable are
+empty by construction, and §6.1 makes that structural rather than
+incidental.
+
+Set: `App`, `Account`, `Pool`, `Region`, `Channel`, `Slug` (the short
+name), `DBPath` (`<DataDir>/<host>.db`), `Hibernate`, `Provisioned true`,
+`NextDue`, and one additive column:
+
+- **`args`** (new, additive TEXT, JSON argv) — materialized from the
+ manifest on every reconcile pass, exactly as `seed` is. Truth stays in
+ the bucket; the column is the box's local copy.
+
+`Provisioned` carries removal authority as it does for instances: the flag,
+inside the agent's own region/pool scope, with the record gone, is what
+authorizes the one sanctioned destructive move.
+
+**Additive-only, per the wire-shape rule:** every route written before this
+change parses unchanged (`Kind` defaults to `"instance"` at
+`internal/registry/registry.go:704`; an absent `args` is the empty argv).
+
+**`DueInstances` must widen — this is load-bearing.**
+`internal/registry/registry.go:1341-1343` reads
+
+```sql
+SELECT ... FROM routes WHERE kind = 'instance' AND next_due > 0 AND next_due <= ?
+```
+
+The sweeper's timer wake is driven entirely by that query. Left alone, 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.
+It widens to `kind IN ('instance','sidecar')`, and the test asserts a due
+sidecar row comes back.
+
+This is the shape of bug the wire-shape rule exists to catch: the consumer
+that breaks is a `WHERE` clause, not a caller.
+
+## 6. What makes a sidecar unreachable
+
+Three independent mechanisms, in order of how much they are relied on:
+
+### 6.1 The edge cannot bind it (structural)
+
+The edge's route→servable path refuses `Kind == "sidecar"` at the point a
+host is resolved to a route, before any upstream selection. Not "has no
+socket so nothing happens" — an explicit refusal with a test that asserts a
+sidecar row is never servable even if a `Socket` value is somehow present.
+The test is the point: it is the assertion that survives someone later
+adding a socket column to a sidecar row for an unrelated reason.
+
+Likewise the sidecar row is excluded from: upstream selection
+(`firstReadyUpstream`), alias minting (`candidateApps`), the apex/canary
+domain reconciler, custom domain attach, and the status page's public
+surface (a sidecar's existence is not public information).
+
+### 6.2 It has no listener (contract)
+
+A sidecar binds nothing. The platform never dials it — there is no health
+probe, no admin socket, no next-due probe. Everything the platform learns
+from a sidecar, the sidecar tells it by dialing **out** (§7.1). This is the
+property that lets the identical artifact run off-platform behind NAT,
+which the platform spec §5.4 names as the whole point of the class.
+
+### 6.2a The fleet-binding chain must be closed — this one is not theoretical
+
+A Kind-blind chain exists today from a resident route to a servable edge
+upstream, and **Eleven's sidecars sit exactly in it**, because they are
+fleet-placed (`region: "fleet/eleven"`):
+
+1. `publishResidentFleetBindings` (`internal/host/fleetbind.go:301-316`)
+ iterates **every** route with no Kind filter and publishes a fleet
+ binding claiming this box serves that host.
+2. `ReconcileFleetBindings` (`fleetbind.go:210-255`) reads it back, checks
+ `registry.FleetPlacement(rt.Region)` — again no Kind filter — and stamps
+ `SetFleetBox`.
+3. The edge's dispatch switch has a `case isFleetPlaced && e.tunnels != nil`
+ arm (`internal/edge/edge.go:379-383`) with **no Kind check**, which
+ builds a live tunnel proxy.
+
+A sidecar row is unroutable today only because it has no Socket, no Addr
+and no Tunnel — it falls to `default: continue` (`edge.go:445`) and is
+never inserted into the routing map. That is omission, not refusal. Put a
+fleet placement on it, which is precisely what Eleven's records carry, and
+step 3's arm is reached on shape alone.
+
+So the guard is added at **both** ends, and both are tested:
+
+- `publishResidentFleetBindings` skips `Kind == "sidecar"` — a sidecar is
+ never advertised as served by any box.
+- the edge refuses `Kind == "sidecar"` **before** the dispatch switch, so
+ no present or future arm of it can bind one.
+
+The edge-side refusal is the one that must hold on its own: it is the
+assertion that survives someone later adding a column, an arm, or a
+placement that reaches a sidecar row by shape.
+
+### 6.3 The box need not be reachable at all (placement)
+
+§8. A box that accepts only sidecar work needs no certs, no inbound ports,
+no tunnel, and can sit in a private subnet with egress-only networking.
+
+## 7. Lifecycle
+
+A sidecar is an activator-managed child, like a hibernating instance, with
+the probe-shaped parts replaced by an outbound protocol.
+
+### 7.1 The agent socket — the only channel, and it points outward
+
+The agent listens on a unix socket; children dial it. **Nothing ever dials
+a sidecar.**
+
+ /run/carlos/agent.sock 0660 carlos:carlos
+
+Exec children all run as the agent's user, so the socket's file mode is not
+an identity boundary. Identity is a **per-wake token** the agent mints and
+passes in the child's environment — the `CARLOS_ADMIN_TOKEN` idiom
+inverted:
+
+ CARLOS_AGENT_SOCKET=/run/carlos/agent.sock
+ CARLOS_SIDECAR_TOKEN=<per-wake, per-sidecar, never persisted>
+
+The token is scoped to one sidecar for one wake: it authorizes calls about
+that host only, and it dies when the child is stopped. A stale token from a
+previous wake is refused.
+
+Verbs (all `POST`, all JSON, all tiny):
+
+| Verb | Meaning |
+|---|---|
+| `/v1/ready` | "I am up." Ends the settle window; carries the running version (§9.2). |
+| `/v1/next-due` `{"at": <unix>}` | "Wake me at T." Recorded in `routes.next_due`. |
+| `/v1/idle` | "I have nothing to do." The agent stops me. |
+| `/v1/heartbeat` | Optional liveness for a long-running sidecar. |
+
+An app that implements none of them still works: it runs until `max_run`
+or until the record is deleted. `idle` is what makes hibernation actually
+cheap, and `next-due` is what makes a schedule survive sleep.
+
+### 7.2 Wake, run, sleep
+
+**Wake** (any source): claim the per-host S3 lease → restore the DB from
+the bucket (`fresh` computed as today) → seed if fresh and a seed is
+declared → mint the wake token → spawn `live/<host> <args...>` in the
+host's cgroup, output teed to the log sink → **settle window** instead of a
+health probe: the child is considered up when it either calls `/v1/ready`
+or survives `sidecarSettle` (10s) without exiting. A child that exits
+non-zero inside the settle window is a failed wake, released and backed off
+like a failed instance start.
+
+**The seam this needs.** `doWake`'s step 4 calls `Backend.Start`, and
+`ExecBackend.Start` ends with an **unconditional**
+`waitHealthyCtx(ctx, rt.Socket, spawnHealthTimeout)`
+(`internal/activator/backend_exec.go:601`) — there is no branch for a route
+with no socket. Handed `Socket ""` it would dial `unix://""` every 150ms for
+the full 30s and then fail the wake. So the `Backend` seam gains one method
+rather than the existing one growing a mode flag:
+
+```go
+// StartWorker spawns a child that binds nothing. No health probe is
+// possible or wanted: readiness is the settle window, or the child's own
+// call to /v1/ready over the agent socket.
+StartWorker(ctx context.Context, rt registry.Route, tok string) (Proc, string, error)
+```
+
+`ExecBackend.StartWorker` is `Start`'s body with the health wait and the
+socket-mode normalization removed and `args` in place of
+`--socket/--db` — which is very nearly what `StartSidecar` already is
+(`backend_exec.go:746-773`), the one spawn path in the package that
+deliberately has no health wait. `UnitBackend.StartWorker` refuses, exactly
+as it refuses `StartSidecar` today.
+
+**Run**: supervised. A crash restarts with the existing exponential
+backoff (2s → 1m, reset after a healthy minute) — the constants the
+co-spawn path already uses, now applied to a standalone member.
+
+**Sleep**, triggered by `/v1/idle`, by `max_run`, or by the idle timer
+(no `/v1/heartbeat` and no agent-socket call within `idle`):
+SIGTERM (5s grace, then SIGKILL) → record `next_due` from whatever the
+sidecar last declared → park the DB to the bucket → release the lease.
+The ordering is the instance stop path's, minus the next-due probe: a
+sidecar's next-due arrived over the socket while it was alive, which is
+strictly better than asking a process we are about to kill.
+
+**Always-on** is `hibernate: false`: the record exists ⇒ the process runs,
+same supervision, no idle clock, no lease churn.
+
+### 7.3 Where this lives: inside the activator, off its request path
+
+A sidecar's lifecycle is ~80% of `doWake`/`stop` already: acquire the lease,
+restore, seed, spawn, replicate; then stop, park, release. Reimplementing
+that in a sibling package would duplicate exactly the code where bugs are
+expensive — the error paths that must release a lease. So sidecars reuse
+`activator.Service` and its `actEntry`, with the request path left alone:
+
+- **Nothing routes to a sidecar, so `beginRequest`, `BeginSidecarRequest`
+ and `ServeHTTP` never see one.** The edge refuses the row first (§6.1),
+ and `ServeHTTP` already gates on `rt.Kind == "instance" && rt.Hibernate`
+ (`activator.go:1128`), so it is unreachable there twice over. A new
+ `WakeWorker(host)` entry point creates the entry and runs the same
+ `wake`/`doWake` machinery the sweeper's request-less due-time wake uses.
+- **The idle clock is reused verbatim, with a different input.** For a
+ sidecar `inflight` is always 0 and `lastActive` is stamped by **agent
+ socket contact** instead of by a released HTTP request. The existing
+ predicate — `inflight == 0 && now-lastActive >= idle`
+ (`activator.go:927`) — then means exactly the right thing with no new
+ branch, and `/v1/idle` is a stop requested immediately rather than a
+ timestamp nudged backwards.
+- **`stop` skips only the next-due probe** (`activator.go:820-845`): a
+ sidecar's next-due arrived over the socket while it was alive, which is
+ strictly better than asking a process we are about to kill. Everything
+ after it — stop, final checkpoint, lease release — is shared unchanged.
+- **`max_run`** is one more sweep predicate beside the idle one.
+
+The co-spawn fields on `actEntry` (`sidecar`, `sidecarWanted`, …) are
+untouched: a standalone sidecar is the entry's own `proc`, not a passenger
+on someone else's.
+
+### 7.4 Wake sources built here
+
+1. **Timer** — the sweeper's existing due-time wake, unchanged: a sleeping
+ host whose `next_due` has arrived is woken with no client request. The
+ machinery already exists for instances; a sidecar is simply a host that
+ only ever wakes this way.
+2. **Operator/app command** — `carlos sidecars wake`, pull-based: the
+ console bumps the wake generation (§4.3) and the serial; every box
+ serving that app notices within seconds (the `carlos restart` pattern
+ verbatim) and wakes the named sidecar. Nothing is pushed at a box —
+ which is what lets the box live in a private subnet.
+3. **Boot** — a non-hibernating sidecar starts with the agent.
+
+### 7.5 Delivery wake — designed, not built
+
+The spec's third wake source is the edge observing (metadata only) that
+ciphertext is queued for a sidecar's identity and signalling the host
+agent. It is out of scope here and needs: an identity→sidecar mapping the
+edge can consult without learning content, a signalling path to a box that
+may have no inbound port (so: the same pull-based generation object as §7.4,
+written by the edge instead of the console), and a rate discipline so a
+chatty correspondent cannot bill a member for continuous wakes. Recorded so
+the shape is not invented differently later.
+
+## 8. Placement: boxes that accept no-inbound work
+
+Two halves that must agree.
+
+**The box declares.** A new env var, read where `CARLOS_REGION`/
+`CARLOS_POOLS` are read (`internal/host/agent.go:1317`):
+
+ CARLOS_INBOUND=none # this box serves sidecar work only
+
+Unset/`any` is today's behaviour exactly — an inert default, the
+apps-domain-style gate this repo already uses. On a `none` box:
+
+- the provisioning reconciler **refuses to mint routed instance rows**,
+ loudly. A box that cannot serve must never be handed something to serve;
+ this is the guard that turns a misfiled record into a log line instead of
+ a silent outage.
+- the edge and cert machinery are not required to be running at all.
+
+**The record asks.** `placement: "no-inbound"` on the manifest/record makes
+the sidecar reconciler mint **only** on boxes declaring `CARLOS_INBOUND=none`.
+`placement: "any"` (default) places by pool/region alone.
+
+The two are independent on purpose: `none` is a property of the box's
+network, `no-inbound` is a requirement of the workload. A member who wants
+"this bot must only ever run somewhere with no inbound path" gets a
+mechanical guarantee rather than an operator's promise.
+
+**Infra is written, not applied** (Paul's ruling): a runbook for a
+private-subnet box — no IGW route, egress via NAT, no inbound SG rules, no
+SSM — lands beside `infra/eleven-fleet.md` for Paul's own applies. Note
+that fleet boxes are *already* outbound-only dial (`infra/eleven-fleet.md`),
+so Eleven's first sidecar box needs no new network shape at all: the
+private-subnet variant is the harder posture, available when wanted.
+
+## 9. The rest of the platform
+
+### 9.1 Config delivery
+
+Sidecars get their env files the way instances do — `execRouteBase`
+(`internal/appconfig/syncexec.go:404`) widens to include `Kind == "sidecar"`.
+This is the direct replacement for the hand-staged `<host>-sidecar.env`:
+`carlos env set` reaches a sidecar, secrets are sealed and delivered the
+same way, and the 2s serial-driven convergence applies unchanged.
+
+### 9.2 Version visibility without a header
+
+`carlos deploy` verifies a roll by reading `X-Carlos-Version` off the URL.
+A sidecar has no URL. The substitute is the `/v1/ready` call: the sidecar
+reports the version it is running, the agent records it on the row, and
+`carlos sidecars ls` shows *running* version against *desired* channel
+version. A sidecar that never calls `/v1/ready` shows its version as
+unknown rather than as current — the honesty rule this repo has paid for:
+a stamp that overclaims is worse than an absent one.
+
+### 9.3 Logs, metrics, restart
+
+**Correct for free**, because they key on host or on the cgroup name rather
+than on Kind: the log sink names its file by host; `internal/cgroup`'s
+`carlos-exec@<host>` convention means `listExecCgroups`
+(`internal/host/metrics_probes.go:240-261`) samples a sidecar's CPU and
+memory the moment it spawns into that cgroup, with no code change; and
+`ops.FleetDBs`'s `claimed` map (`internal/ops/litestream.go:371-379`) is
+Kind-agnostic, so a sidecar's `DBPath` is replicated like any other.
+
+**Needs widening**: `carlos restart -app X` is gated on
+`release.Adoptable` (`internal/release/adopt.go:518-521`, via
+`internal/host/restart.go:77,192,545`), which requires `Kind == "instance"`.
+Sidecars are cycled by the restart pass too, so `restart.go` widens.
+
+**`release.Adoptable` itself does not change.** It is the most reused
+predicate in the repo — adopt membership, capability convergence, socket
+and backing advisories, status pages all inherit it, and every one of those
+is *correct* to exclude a sidecar. Widening it centrally would silently
+enrol sidecars in capability drop-ins and adopt bookkeeping that make no
+sense for them. Each consumer that genuinely wants sidecars opts in by
+name: the config-delivery predicate (§9.1) and the restart pass. That is
+two deliberate edits instead of one blind one.
+
+### 9.4 Consumers that need a guard, and why
+
+The exhaustive list, from a sweep of every `.Kind` branch and every route
+iteration in the tree. Each is a test in this branch.
+
+| Site | Today with a sidecar row | Change |
+|---|---|---|
+| `edge.go:364-445` dispatch | unroutable by omission (`default: continue`) | explicit Kind refusal before the switch (§6.1) |
+| `fleetbind.go:301-316` publish | advertises a sidecar as served | skip `Kind == "sidecar"` (§6.2a) |
+| `provision/reconcile.go:651-673` removal | **deletes every sidecar row, every pass** | see below |
+| `registry.go:1341` `DueInstances` | timer wakes never fire | `kind IN ('instance','sidecar')` (§5) |
+| `appconfig/syncexec.go:404` | no env file ever written | include sidecars (§9.1) |
+| `host/restart.go` (via `Adoptable`) | never cycled | include sidecars (§9.3) |
+| `console/instances.go:88-97` | can pick a sidecar as an app's *reporting host*, making a healthy app read "not reporting" | filter by Kind |
+| `status/snapshot.go:406-427` `CollectBox` | falls to `default:` → counted as a public **Resident** instance | explicit branch; sidecars are not public |
+| `console/overview.go:286-306` `healthWord` | renders "Serving", which is false | a sidecar's own words (§9.5) |
+| `console/overview.go:129-144` | `InstanceTotal` counts sidecars | count separately |
+| `cli.go:75,194-199` `carlos add -kind` | **no validation at all** — accepts `-kind sidecar` today | rail it off (§3) |
+
+**The removal-phase collision is the sharpest of these.** The instance
+reconciler's removal phase (`reconcile.go:651-673`) is scoped by
+`Provisioned`, region, pool and absence-from-records — with **no Kind
+check**. Sidecar rows are `Provisioned` (§5) and can never appear in the
+instance record set, so the existing pass would delete every sidecar on
+every tick: a silent, self-healing-looking loop where sidecars are minted
+and destroyed forever, with no error anywhere.
+
+The fix is that each pass's removal authority is scoped to **its own Kind**:
+the instance pass skips `Kind == "sidecar"`, the sidecar pass touches only
+sidecar rows. Both halves are asserted directly, because this is the
+failure mode with no symptom other than work quietly disappearing.
+
+### 9.5 What a sidecar's status reads
+
+A sidecar never serves a request, so the vocabulary the console uses for
+routes is wrong for it. It gets its own words — `Running`, `Asleep`,
+`Waking`, `Crashed`, `Never started` — and is counted in its own bucket
+rather than folded into an instance count. Sidecars are **not** on the
+public status page: their existence is account-visible, not public.
+
+### 9.6 Object storage, ledgers, economics
+
+A sidecar is an ordinary tenant for the object-store primitive and accrues
+usage like any exec child. No new meters.
+
+## 10. The existing co-spawn stays
+
+`HasSidecar`/`StartSidecar` on the file-staged path are **not removed**.
+Eleven runs on that shape today, and this branch does not touch a live box.
+The relationship is stated in code and docs: the file path is legacy, the
+record path is native, and a host may not have both (the reconciler refuses
+to mint a sidecar row for a host that already has a staged
+`<host>-sidecar.env`, naming the conflict). Retiring the file path is a
+follow-up issue, gated on Eleven's own migration.
+
+## 11. Console API and CLI
+
+Verbs under `registerCLIRoutes`, bearer, JSON, mirroring the instances
+family including its refusal ordering (principal gate → rate limit →
+manifest present → name shape → region shape/allow-list → channel → the
+create-only write) and its byte-identical-404 discipline:
+
+ GET /api/cli/apps/{account}/{app}/sidecars
+ GET /api/cli/apps/{account}/{app}/sidecars/{name}
+ POST /api/cli/apps/{account}/{app}/sidecars
+ DELETE /api/cli/apps/{account}/{app}/sidecars/{name}
+ POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake
+
+The **app-held token** kind that authorizes an app's own instance verbs
+extends to its own sidecar verbs — this is 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.
+
+CLI, the `instances` family's shape:
+
+ carlos sidecars enable -app eleven -args 'sidecar run' [-placement no-inbound]
+ carlos sidecars create -app eleven bot-acme
+ carlos sidecars ls -app eleven
+ carlos sidecars wake -app eleven bot-acme
+ carlos sidecars rm -app eleven bot-acme
+ carlos sidecars logs -app eleven bot-acme
+
+Every one of them completes its job with no box access and verifies its own
+result, per the no-infra rule.
+
+## 12. Testing
+
+- **Unreachability is a test, not a claim**: a `Kind == "sidecar"` row is
+ never servable by the edge, never selected as an upstream, never aliased,
+ never attachable to a domain, never on the status page — asserted
+ directly, including the adversarial case of a sidecar row carrying a
+ socket.
+- **Reserved suffix**: every door that mints or accepts a host refuses
+ `.sidecar.internal`.
+- **Lifecycle**: wake → ready → next-due → idle → sleep → timer wake, on
+ the activator's existing fake clock/fake backend harness; crash-backoff;
+ settle-window failure; `max_run`; lease release on every exit path.
+- **Agent socket**: token scoping (a token for host A cannot speak about
+ host B), stale-token refusal after a stop, malformed input, and the
+ socket's absence being survivable.
+- **Reconciler**: mint/converge/remove with the fail-safe (a read failure
+ skips the removal phase), placement matching, and the co-spawn conflict
+ refusal.
+- **Additive parse**: a route row and a bucket record written before this
+ change still parse.
+- The gate: `go build ./...`, `go vet ./...`, `go test ./... -count=1`,
+ `gofmt -l` clean, plus the `GOOS=darwin` compile.
+
+## 13. Explicitly out of scope
+
+- Delivery-wake (§7.5).
+- Retiring the file-staged co-spawn (§10).
+- Applying any infra (§8) or touching any live box.
+- Sidecars for unit-backed tenants — `UnitBackend` refuses agent-managed
+ sidecars today and continues to; the capabilities spec already names this
+ a non-goal.
+- Multi-box placement/failover for a sidecar: one record, one lease, one
+ box at a time, exactly as instances work.