carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Sidecar members: a serving class that nothing can dial
2
3**Date:** 2026-08-17 · **Status:** approved design (Paul, via brainstorming
4session, 2026-08-17) · **Requested by:** Paul, for Eleven.
5
6**Closes:** the gap between the platform spec's `sidecar` serving class
7(`2026-08-01-carlos-platform-design.md` §5.4, §4 manifest) and what the
8platform can actually be asked to run. The runtime half has existed since
9Plan A; the declaration half has never existed.
10
11**Extends:** instance provisioning (`2026-08-04-instance-provisioning-design.md`
12— the record/manifest/reconciler/API pattern this mirrors), exec config
13delivery (`2026-08-02-exec-config-delivery-design.md` §"Per-sidecar config …
14deferred", which this closes), customer fleets
15(`2026-08-05-customer-fleets-design.md` — outbound-only boxes),
16`carlos restart` (#113 — the pull-based generation-object idiom the wake
17verb copies).
18
19**Deliberately does not depend on** the unmerged trust-classes and
20private-instances specs (PR #183). Those name the *vocabulary* this class
21will eventually be placed by (an A-class sidecar holding a member key is
22exactly their subject); nothing here waits on them.
23
24## 1. Why
25
26A sidecar today is a file on a box. `HasSidecar` is `os.Stat` of
27`/data/<host>-sidecar.env` (`internal/activator/backend_exec.go:732`), and
28nothing in the platform writes that file — config delivery explicitly
29disowns it (`internal/appconfig/syncexec.go:617`). So the only way to run
30one is to get root on a box and place a file, which is precisely the
31product gap CLAUDE.md names: *every `carlos` command is designed for a user
32with no access to the underlying infra.*
33
34The runtime that exists is also the wrong shape for what Eleven needs. It
35is a **co-spawn**: one sidecar bound to one routed instance, alive exactly
36while that instance is awake (`internal/activator/activator.go:17-24`). A
37bot that must run on a box with no routed app on it — eventually a box in a
38private subnet with no inbound path from the internet at all — cannot be
39expressed that way, because there is no parent instance to hang it from.
40
41## 2. Rulings (Paul, 2026-08-17)
42
431. **Standalone members.** A sidecar gets its own record, its own registry
44 row, its own identity and its own lifecycle. It can be the only thing on
45 a box. The existing co-spawn path keeps working, untouched, for Eleven's
46 current shape; provisioned sidecars never use it (§10).
472. **Hibernating**, with schedule and operator-command wakes. Delivery-wake
48 (the edge signalling that ciphertext is queued for a sidecar's identity)
49 is designed here but **not built** — it needs edge work this does not
50 include (§7.5).
513. **Synthetic internal host** as the identity: `<name>.<app>.<sqid>.sidecar.internal`,
52 never resolvable, refused by the edge, so every host-keyed subsystem
53 works unchanged (§3).
544. **Placement axis in the platform now; infra spec'd, not applied.** Boxes
55 declare that they accept no-inbound work; the reconciler honours it; the
56 tofu for a real private-subnet box is a runbook for Paul's applies (§8).
57
58## 3. Identity: a host that cannot be a host
59
60A sidecar is keyed by a **synthetic host**:
61
62 <name>.<app>.<sqid>.sidecar.internal
63 bot-acme.eleven.bdm.sidecar.internal
64
65`name` is one DNS label, validated by the same regex instance hosts use
66(`provision.labelRe`) and for the same stated reason: the name becomes a
67bucket key segment and a filesystem path segment (`<host>.db`, the cgroup
68directory, the log sink file), and the label regex is what keeps a hostile
69name an opaque, safe path component.
70
71**Why a synthetic host and not a new identity axis.** The registry, config
72delivery, cgroups, the S3 lease, the DB path, the log sink, metrics
73attribution, `carlos logs`, `carlos restart` and the release ladder are all
74keyed by host. A second identity axis would mean touching every consumer
75that assumes host is the key — the wire-shape rule this repo has paid for
76three times. A synthetic host costs one reserved suffix and buys every one
77of those subsystems working on day one.
78
79**`.sidecar.internal` is reserved, and reservation is enforced, not
80documented.** The suffix is refused at every door where a name becomes
81routable:
82
83- the edge never binds a `Kind == "sidecar"` row at all (§6.1), which is
84 the structural guarantee; the suffix refusals below are defence in depth
85 against a *different* row type wearing the name.
86- `carlos add` / the route door / domain attach refuse a host ending in
87 `.sidecar.internal`.
88- the domains reconciler refuses to mint an alias for one.
89- a custom domain may not be attached to a sidecar.
90
91The CLI speaks the short name (`carlos sidecars ls` prints `bot-acme`); the
92synthetic host is the internal key, shown only by `-long`.
93
94## 4. Bucket shapes (all additive)
95
96Mirrors instance provisioning exactly, one directory over.
97
98### 4.1 `apps/<sqid>/<app>/sidecars.json` — the app's opt-in and policy
99
100```json
101{
102 "schema": 1,
103 "app": "eleven",
104 "pool": "eleven",
105 "regions": ["fleet/eleven"],
106 "channel": "stable",
107 "args": ["sidecar", "run"],
108 "placement": "no-inbound",
109 "hibernate": true,
110 "idle": "5m",
111 "max_run": "1h"
112}
113```
114
115- **Absence means the feature does not exist for that app** — the
116 litestream/instances convention. The API refuses creates with a message
117 naming `carlos sidecars enable`, only after membership is proven.
118- `args` (required): the argv the platform execs after the release binary.
119 App-level, not per-record — like `seed`, it is a property of the app's
120 current release contract. `["sidecar", "run"]` is the shape the existing
121 co-spawn hard-codes; making it declared is what lets an app ship more
122 than one kind of worker later.
123
124 **One binary, many sidecars.** Args being app-level is what makes
125 Eleven's shape — a bot per enrolled member — work: the same release runs
126 every sidecar, and each child learns *which* one it is from its
127 environment, not its argv:
128
129 CARLOS_SIDECAR_NAME=bot-acme
130 CARLOS_SIDECAR_HOST=bot-acme.eleven.bdm.sidecar.internal
131
132 Per-sidecar configuration beyond that is the config plane's job, not the
133 manifest's: a record may carry `environment`, and route-scoped config
134 delivery (merged, `#206`) then gives that sidecar its own variables and
135 sealed secrets. There is deliberately no per-record `args`.
136- `placement`: `"any"` (default) or `"no-inbound"` (§8).
137- `hibernate` (default true), `idle` (default 5m), `max_run` (default 1h) —
138 the lifecycle knobs (§7).
139- `regions`, `pool`, `channel`: as instances. `regions: ["fleet/eleven"]`
140 is how Eleven's sidecars land only on Eleven's own boxes.
141
142Written by the operator or the app owner — `carlos sidecars enable`,
143API and direct/store mode both, the `carlos instances enable` pattern.
144
145### 4.2 `apps/<sqid>/<app>/sidecars/<name>.json` — the record
146
147```json
148{
149 "schema": 1,
150 "name": "bot-acme",
151 "host": "bot-acme.eleven.bdm.sidecar.internal",
152 "app": "eleven",
153 "account": "bdm",
154 "pool": "eleven",
155 "region": "fleet/eleven",
156 "channel": "stable",
157 "placement": "no-inbound",
158 "created_at": "2026-08-17T23:00:00Z",
159 "created_by": "app:bdm/eleven"
160}
161```
162
163Create-only (`If-None-Match: *`) — a lost race is a 409, never a silent
164overwrite. The body must agree with its own key path (name/app/account vs
165the key's segments) or the reconciler skips it loudly. No tombstone:
166deletion deletes the object; the parked DB and WAL stream under the
167regional bucket are deliberately untouched, the same data-safety contract
168`registry.Delete` and `carlos remove` hold.
169
170`ParseRecordKey`'s discipline is inherited whole: exactly one non-empty
171segment after `sidecars/`, so `sidecars.json` (the manifest) and any nested
172path both refuse to parse as a record.
173
174### 4.3 `apps/<sqid>/<app>/sidecar-wake/<name>.json` — the wake generation
175
176The operator/app wake source (§7.4), copied from `restart-gen.json`
177(`internal/release/restartgen.go`): a small CAS-guarded generation object
178whose **etag moving** is the whole signal. A sibling prefix rather than a
179key under `sidecars/` so that §4.2's one-segment rule stays exactly as
180strict as it reads.
181
182```json
183{"schema": 1, "generation": 3, "at": "2026-08-17T23:05:00Z",
184 "by": "member:bdm", "reason": "operator wake"}
185```
186
187## 5. Registry: `Kind = "sidecar"`
188
189A sidecar row is a route row that is not a route: `Kind "sidecar"`,
190**`Socket ""`, `Addr ""`** — the two columns that make a row servable are
191empty by construction, and §6.1 makes that structural rather than
192incidental.
193
194Set: `App`, `Account`, `Pool`, `Region`, `Channel`, `Slug` (the short
195name), `DBPath` (`<DataDir>/<host>.db`), `Hibernate`, `Provisioned true`,
196`NextDue`, and one additive column:
197
198- **`args`** (new, additive TEXT, JSON argv) — materialized from the
199 manifest on every reconcile pass, exactly as `seed` is. Truth stays in
200 the bucket; the column is the box's local copy.
201
202`Provisioned` carries removal authority as it does for instances: the flag,
203inside the agent's own region/pool scope, with the record gone, is what
204authorizes the one sanctioned destructive move.
205
206**Additive-only, per the wire-shape rule:** every route written before this
207change parses unchanged (`Kind` defaults to `"instance"` at
208`internal/registry/registry.go:704`; an absent `args` is the empty argv).
209
210**`DueInstances` must widen — this is load-bearing.**
211`internal/registry/registry.go:1341-1343` reads
212
213```sql
214SELECT ... FROM routes WHERE kind = 'instance' AND next_due > 0 AND next_due <= ?
215```
216
217The sweeper's timer wake is driven entirely by that query. Left alone, a
218sidecar's `next_due` would be written, stored, and **never once acted on** —
219the schedule wake source would silently not exist, with no error anywhere.
220It widens to `kind IN ('instance','sidecar')`, and the test asserts a due
221sidecar row comes back.
222
223This is the shape of bug the wire-shape rule exists to catch: the consumer
224that breaks is a `WHERE` clause, not a caller.
225
226## 6. What makes a sidecar unreachable
227
228Three independent mechanisms, in order of how much they are relied on:
229
230### 6.1 The edge cannot bind it (structural)
231
232The edge's route→servable path refuses `Kind == "sidecar"` at the point a
233host is resolved to a route, before any upstream selection. Not "has no
234socket so nothing happens" — an explicit refusal with a test that asserts a
235sidecar row is never servable even if a `Socket` value is somehow present.
236The test is the point: it is the assertion that survives someone later
237adding a socket column to a sidecar row for an unrelated reason.
238
239Likewise the sidecar row is excluded from: upstream selection
240(`firstReadyUpstream`), alias minting (`candidateApps`), the apex/canary
241domain reconciler, custom domain attach, and the status page's public
242surface (a sidecar's existence is not public information).
243
244### 6.2 It has no listener (contract)
245
246A sidecar binds nothing. The platform never dials it — there is no health
247probe, no admin socket, no next-due probe. Everything the platform learns
248from a sidecar, the sidecar tells it by dialing **out** (§7.1). This is the
249property that lets the identical artifact run off-platform behind NAT,
250which the platform spec §5.4 names as the whole point of the class.
251
252### 6.2a The fleet-binding chain must be closed — this one is not theoretical
253
254A Kind-blind chain exists today from a resident route to a servable edge
255upstream, and **Eleven's sidecars sit exactly in it**, because they are
256fleet-placed (`region: "fleet/eleven"`):
257
2581. `publishResidentFleetBindings` (`internal/host/fleetbind.go:301-316`)
259 iterates **every** route with no Kind filter and publishes a fleet
260 binding claiming this box serves that host.
2612. `ReconcileFleetBindings` (`fleetbind.go:210-255`) reads it back, checks
262 `registry.FleetPlacement(rt.Region)` — again no Kind filter — and stamps
263 `SetFleetBox`.
2643. The edge's dispatch switch has a `case isFleetPlaced && e.tunnels != nil`
265 arm (`internal/edge/edge.go:379-383`) with **no Kind check**, which
266 builds a live tunnel proxy.
267
268A sidecar row is unroutable today only because it has no Socket, no Addr
269and no Tunnel — it falls to `default: continue` (`edge.go:445`) and is
270never inserted into the routing map. That is omission, not refusal. Put a
271fleet placement on it, which is precisely what Eleven's records carry, and
272step 3's arm is reached on shape alone.
273
274So the guard is added at **both** ends, and both are tested:
275
276- `publishResidentFleetBindings` skips `Kind == "sidecar"` — a sidecar is
277 never advertised as served by any box.
278- the edge refuses `Kind == "sidecar"` **before** the dispatch switch, so
279 no present or future arm of it can bind one.
280
281The edge-side refusal is the one that must hold on its own: it is the
282assertion that survives someone later adding a column, an arm, or a
283placement that reaches a sidecar row by shape.
284
285### 6.3 The box need not be reachable at all (placement)
286
287§8. A box that accepts only sidecar work needs no certs, no inbound ports,
288no tunnel, and can sit in a private subnet with egress-only networking.
289
290## 7. Lifecycle
291
292A sidecar is an activator-managed child, like a hibernating instance, with
293the probe-shaped parts replaced by an outbound protocol.
294
295### 7.1 The agent socket — the only channel, and it points outward
296
297The agent listens on a unix socket; children dial it. **Nothing ever dials
298a sidecar.**
299
300 /run/carlos/agent.sock 0660 carlos:carlos
301
302Exec children all run as the agent's user, so the socket's file mode is not
303an identity boundary. Identity is a **per-wake token** the agent mints and
304passes in the child's environment — the `CARLOS_ADMIN_TOKEN` idiom
305inverted:
306
307 CARLOS_AGENT_SOCKET=/run/carlos/agent.sock
308 CARLOS_SIDECAR_TOKEN=<per-wake, per-sidecar, never persisted>
309
310The token is scoped to one sidecar for one wake: it authorizes calls about
311that host only, and it dies when the child is stopped. A stale token from a
312previous wake is refused.
313
314Verbs (all `POST`, all JSON, all tiny):
315
316| Verb | Meaning |
317|---|---|
318| `/v1/ready` | "I am up." Ends the settle window; carries the running version (§9.2). |
319| `/v1/next-due` `{"at": <unix>}` | "Wake me at T." Recorded in `routes.next_due`. |
320| `/v1/idle` | "I have nothing to do." The agent stops me. |
321| `/v1/heartbeat` | Optional liveness for a long-running sidecar. |
322
323An app that implements none of them still works: it runs until `max_run`
324or until the record is deleted. `idle` is what makes hibernation actually
325cheap, and `next-due` is what makes a schedule survive sleep.
326
327### 7.2 Wake, run, sleep
328
329**Wake** (any source): claim the per-host S3 lease → restore the DB from
330the bucket (`fresh` computed as today) → seed if fresh and a seed is
331declared → mint the wake token → spawn `live/<host> <args...>` in the
332host's cgroup, output teed to the log sink → **settle window** instead of a
333health probe: the child is considered up when it either calls `/v1/ready`
334or survives `sidecarSettle` (10s) without exiting. A child that exits
335non-zero inside the settle window is a failed wake, released and backed off
336like a failed instance start.
337
338**The seam this needs.** `doWake`'s step 4 calls `Backend.Start`, and
339`ExecBackend.Start` ends with an **unconditional**
340`waitHealthyCtx(ctx, rt.Socket, spawnHealthTimeout)`
341(`internal/activator/backend_exec.go:601`) — there is no branch for a route
342with no socket. Handed `Socket ""` it would dial `unix://""` every 150ms for
343the full 30s and then fail the wake. So the `Backend` seam gains one method
344rather than the existing one growing a mode flag:
345
346```go
347// StartWorker spawns a child that binds nothing. No health probe is
348// possible or wanted: readiness is the settle window, or the child's own
349// call to /v1/ready over the agent socket.
350StartWorker(ctx context.Context, rt registry.Route, tok string) (Proc, string, error)
351```
352
353`ExecBackend.StartWorker` is `Start`'s body with the health wait and the
354socket-mode normalization removed and `args` in place of
355`--socket/--db` — which is very nearly what `StartSidecar` already is
356(`backend_exec.go:746-773`), the one spawn path in the package that
357deliberately has no health wait. `UnitBackend.StartWorker` refuses, exactly
358as it refuses `StartSidecar` today.
359
360**Run**: supervised. A crash restarts with the existing exponential
361backoff (2s → 1m, reset after a healthy minute) — the constants the
362co-spawn path already uses, now applied to a standalone member.
363
364A hibernating worker's exit is read by **exit status**, the only thing that
365separates the two meanings it can carry (final review, 2026-08-17): a clean
366exit is "my work is done" and parks; a non-zero exit is a crash and restarts
367with that same backoff, exactly as an always-on worker's does. Without the
368distinction, a bot that fell over before setting a next-due slept forever —
369nothing can dial one.
370
371A **fired** due time is consumed by the wake it causes, never by the sleep
372that follows (final review, 2026-08-17): a worker's stop writes no
373`next_due` at all, so otherwise the passed value is re-served on every sweep
374and the sidecar wakes, idles, parks and re-wakes forever. The consumption is
375conditional on the row still holding the value that fired, so a next-due the
376child posted during its own wake survives — and a worker that sleeps BEFORE
377its timer fires keeps its schedule untouched.
378
379**Sleep**, triggered by `/v1/idle`, by `max_run`, or by the idle timer
380(no `/v1/heartbeat` and no agent-socket call within `idle`):
381SIGTERM (5s grace, then SIGKILL) → record `next_due` from whatever the
382sidecar last declared → park the DB to the bucket → release the lease.
383The ordering is the instance stop path's, minus the next-due probe: a
384sidecar's next-due arrived over the socket while it was alive, which is
385strictly better than asking a process we are about to kill.
386
387**Always-on** is `hibernate: false`: the record exists ⇒ the process runs,
388same supervision, no idle clock, no lease churn. Two consequences of that
389rule, both from the final review (2026-08-17): the sweep starts an always-on
390worker that has no live entry — otherwise one created while the agent was
391running never ran at all, since the boot pass runs once, at arming — and
392`max_run` applies to it too (it is the wedged-reconnect-loop guard, and a
393long-lived worker is exactly that shape), but is spent there as a RECYCLE of
394its child on the same entry, keeping the lease, because stopping an
395always-on worker leaves nothing to start it again.
396
397### 7.3 Where this lives: inside the activator, off its request path
398
399A sidecar's lifecycle is ~80% of `doWake`/`stop` already: acquire the lease,
400restore, seed, spawn, replicate; then stop, park, release. Reimplementing
401that in a sibling package would duplicate exactly the code where bugs are
402expensive — the error paths that must release a lease. So sidecars reuse
403`activator.Service` and its `actEntry`, with the request path left alone:
404
405- **Nothing routes to a sidecar, so `beginRequest`, `BeginSidecarRequest`
406 and `ServeHTTP` never see one.** The edge refuses the row first (§6.1),
407 and `ServeHTTP` already gates on `rt.Kind == "instance" && rt.Hibernate`
408 (`activator.go:1128`), so it is unreachable there twice over. A new
409 `WakeWorker(host)` entry point creates the entry and runs the same
410 `wake`/`doWake` machinery the sweeper's request-less due-time wake uses.
411- **The idle clock is reused verbatim, with a different input.** For a
412 sidecar `inflight` is always 0 and `lastActive` is stamped by **agent
413 socket contact** instead of by a released HTTP request. The existing
414 predicate — `inflight == 0 && now-lastActive >= idle`
415 (`activator.go:927`) — then means exactly the right thing with no new
416 branch, and `/v1/idle` is a stop requested immediately rather than a
417 timestamp nudged backwards.
418- **`stop` skips only the next-due probe** (`activator.go:820-845`): a
419 sidecar's next-due arrived over the socket while it was alive, which is
420 strictly better than asking a process we are about to kill. Everything
421 after it — stop, final checkpoint, lease release — is shared unchanged.
422- **`max_run`** is one more sweep predicate beside the idle one.
423
424The co-spawn fields on `actEntry` (`sidecar`, `sidecarWanted`, …) are
425untouched: a standalone sidecar is the entry's own `proc`, not a passenger
426on someone else's.
427
428### 7.4 Wake sources built here
429
4301. **Timer** — the sweeper's existing due-time wake, unchanged: a sleeping
431 host whose `next_due` has arrived is woken with no client request. The
432 machinery already exists for instances; a sidecar is simply a host that
433 only ever wakes this way.
4342. **Operator/app command** — `carlos sidecars wake`, pull-based: the
435 console bumps the wake generation (§4.3) and the serial; every box
436 serving that app notices within seconds (the `carlos restart` pattern
437 verbatim) and wakes the named sidecar. Nothing is pushed at a box —
438 which is what lets the box live in a private subnet.
439
440 The box's etag memory is seeded once, at arming, and that seeding is
441 what keeps an agent restart from replaying every wake in history. It is
442 scoped to arming precisely (final review, 2026-08-17): a sidecar route
443 seen for the first time AFTER that baseline has just been minted, and a
444 wake object already standing at it is a wake somebody asked for and the
445 CLI already reported as accepted — the create-then-wake flow, which is
446 the feature's primary one, since an operator wake is a hibernating
447 sidecar's only first-run mechanism. A spurious extra wake is idempotent
448 and cheap; a lost one is a bot that never runs.
4493. **Boot** — a non-hibernating sidecar starts with the agent.
450
451### 7.5 Delivery wake — designed, not built
452
453The spec's third wake source is the edge observing (metadata only) that
454ciphertext is queued for a sidecar's identity and signalling the host
455agent. It is out of scope here and needs: an identity→sidecar mapping the
456edge can consult without learning content, a signalling path to a box that
457may have no inbound port (so: the same pull-based generation object as §7.4,
458written by the edge instead of the console), and a rate discipline so a
459chatty correspondent cannot bill a member for continuous wakes. Recorded so
460the shape is not invented differently later.
461
462## 8. Placement: boxes that accept no-inbound work
463
464Two halves that must agree.
465
466**The box declares.** A new env var, read where `CARLOS_REGION`/
467`CARLOS_POOLS` are read (`internal/host/agent.go:1317`):
468
469 CARLOS_INBOUND=none # this box serves sidecar work only
470
471Unset/`any` is today's behaviour exactly — an inert default, the
472apps-domain-style gate this repo already uses. On a `none` box:
473
474- the provisioning reconciler **refuses to mint routed instance rows**,
475 loudly. A box that cannot serve must never be handed something to serve;
476 this is the guard that turns a misfiled record into a log line instead of
477 a silent outage.
478- the edge and cert machinery are not required to be running at all.
479
480**The record asks.** `placement: "no-inbound"` on the manifest/record makes
481the sidecar reconciler mint **only** on boxes declaring `CARLOS_INBOUND=none`.
482`placement: "any"` (default) places by pool/region alone. Where the two
483disagree, the **most restrictive wins** (final review, 2026-08-17): a record
484may narrow an app that asks for nothing, but a record stating `"any"` under a
485`no-inbound` manifest does not widen it — that is the direction the console
486door already refuses, and a direct bucket write must not reach around it.
487
488The two are independent on purpose: `none` is a property of the box's
489network, `no-inbound` is a requirement of the workload. A member who wants
490"this bot must only ever run somewhere with no inbound path" gets a
491mechanical guarantee rather than an operator's promise.
492
493**Infra is written, not applied** (Paul's ruling): a runbook for a
494private-subnet box — no IGW route, egress via NAT, no inbound SG rules, no
495SSM — lands beside `infra/eleven-fleet.md` for Paul's own applies. Note
496that fleet boxes are *already* outbound-only dial (`infra/eleven-fleet.md`),
497so Eleven's first sidecar box needs no new network shape at all: the
498private-subnet variant is the harder posture, available when wanted.
499
500## 9. The rest of the platform
501
502### 9.1 Config delivery
503
504Sidecars get their env files the way instances do — `execRouteBase`
505(`internal/appconfig/syncexec.go:404`) widens to include `Kind == "sidecar"`.
506This is the direct replacement for the hand-staged `<host>-sidecar.env`:
507`carlos env set` reaches a sidecar, secrets are sealed and delivered the
508same way, and the 2s serial-driven convergence applies unchanged.
509
510### 9.2 Version visibility without a header
511
512`carlos deploy` verifies a roll by reading `X-Carlos-Version` off the URL.
513A sidecar has no URL. The substitute is the `/v1/ready` call: the sidecar
514reports the version it is running, the agent records it on the row, and
515`carlos sidecars ls` shows *running* version against *desired* channel
516version. A sidecar that never calls `/v1/ready` shows its version as
517unknown rather than as current — the honesty rule this repo has paid for:
518a stamp that overclaims is worse than an absent one.
519
520### 9.3 Logs, metrics, restart
521
522**Correct for free**, because they key on host or on the cgroup name rather
523than on Kind: the log sink names its file by host; `internal/cgroup`'s
524`carlos-exec@<host>` convention means `listExecCgroups`
525(`internal/host/metrics_probes.go:240-261`) samples a sidecar's CPU and
526memory the moment it spawns into that cgroup, with no code change; and
527`ops.FleetDBs`'s `claimed` map (`internal/ops/litestream.go:371-379`) is
528Kind-agnostic, so a sidecar's `DBPath` is replicated like any other.
529
530**Needs widening**: `carlos restart -app X` is gated on
531`release.Adoptable` (`internal/release/adopt.go:518-521`, via
532`internal/host/restart.go:77,192,545`), which requires `Kind == "instance"`.
533Sidecars are cycled by the restart pass too, so `restart.go` widens.
534
535**`release.Adoptable` itself does not change.** It is the most reused
536predicate in the repo — adopt membership, capability convergence, socket
537and backing advisories, status pages all inherit it, and every one of those
538is *correct* to exclude a sidecar. Widening it centrally would silently
539enrol sidecars in capability drop-ins and adopt bookkeeping that make no
540sense for them. Each consumer that genuinely wants sidecars opts in by
541name: the config-delivery predicate (§9.1) and the restart pass. That is
542two deliberate edits instead of one blind one.
543
544### 9.4 Consumers that need a guard, and why
545
546The exhaustive list, from a sweep of every `.Kind` branch and every route
547iteration in the tree. Each is a test in this branch.
548
549| Site | Today with a sidecar row | Change |
550|---|---|---|
551| `edge.go:364-445` dispatch | unroutable by omission (`default: continue`) | explicit Kind refusal before the switch (§6.1) |
552| `fleetbind.go:301-316` publish | advertises a sidecar as served | skip `Kind == "sidecar"` (§6.2a) |
553| `provision/reconcile.go:651-673` removal | **deletes every sidecar row, every pass** | see below |
554| `registry.go:1341` `DueInstances` | timer wakes never fire | `kind IN ('instance','sidecar')` (§5) |
555| `appconfig/syncexec.go:404` | no env file ever written | include sidecars (§9.1) |
556| `host/restart.go` (via `Adoptable`) | never cycled | include sidecars (§9.3) |
557| `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 |
558| `status/snapshot.go:406-427` `CollectBox` | falls to `default:` → counted as a public **Resident** instance | explicit branch; sidecars are not public |
559| `console/overview.go:286-306` `healthWord` | renders "Serving", which is false | a sidecar's own words (§9.5) |
560| `console/overview.go:129-144` | `InstanceTotal` counts sidecars | count separately |
561| `cli.go:75,194-199` `carlos add -kind` | **no validation at all** — accepts `-kind sidecar` today | rail it off (§3) |
562
563**The removal-phase collision is the sharpest of these.** The instance
564reconciler's removal phase (`reconcile.go:651-673`) is scoped by
565`Provisioned`, region, pool and absence-from-records — with **no Kind
566check**. Sidecar rows are `Provisioned` (§5) and can never appear in the
567instance record set, so the existing pass would delete every sidecar on
568every tick: a silent, self-healing-looking loop where sidecars are minted
569and destroyed forever, with no error anywhere.
570
571The fix is that each pass's removal authority is scoped to **its own Kind**:
572the instance pass skips `Kind == "sidecar"`, the sidecar pass touches only
573sidecar rows. Both halves are asserted directly, because this is the
574failure mode with no symptom other than work quietly disappearing.
575
576### 9.5 What a sidecar's status reads
577
578A sidecar never serves a request, so the vocabulary the console uses for
579routes is wrong for it. It gets its own words — `Running`, `Asleep`,
580`Waking`, `Crashed`, `Never started` — and is counted in its own bucket
581rather than folded into an instance count. Sidecars are **not** on the
582public status page: their existence is account-visible, not public.
583
584### 9.6 Object storage, ledgers, economics
585
586A sidecar is an ordinary tenant for the object-store primitive and accrues
587usage like any exec child. No new meters.
588
589## 10. The existing co-spawn stays
590
591`HasSidecar`/`StartSidecar` on the file-staged path are **not removed**.
592Eleven runs on that shape today, and this branch does not touch a live box.
593The relationship is stated in code and docs: the file path is legacy, the
594record path is native, and a host may not have both (the reconciler refuses
595to mint a sidecar row for a host that already has a staged
596`<host>-sidecar.env`, naming the conflict). Retiring the file path is a
597follow-up issue, gated on Eleven's own migration.
598
599## 11. Console API and CLI
600
601Verbs under `registerCLIRoutes`, bearer, JSON, mirroring the instances
602family including its refusal ordering (principal gate → rate limit →
603manifest present → name shape → region shape/allow-list → channel → the
604create-only write) and its byte-identical-404 discipline:
605
606 GET /api/cli/apps/{account}/{app}/sidecars
607 GET /api/cli/apps/{account}/{app}/sidecars/{name}
608 POST /api/cli/apps/{account}/{app}/sidecars
609 DELETE /api/cli/apps/{account}/{app}/sidecars/{name}
610 POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake
611
612The **app-held token** kind that authorizes an app's own instance verbs
613extends to its own sidecar verbs — this is the "apps can provision sidecar
614instances easily" requirement: Eleven's app process, holding no AWS
615credentials and no box access, creates a bot per enrolled member.
616
617CLI, the `instances` family's shape:
618
619 carlos sidecars enable -app eleven -args 'sidecar run' [-placement no-inbound]
620 carlos sidecars create -app eleven bot-acme
621 carlos sidecars ls -app eleven
622 carlos sidecars wake -app eleven bot-acme
623 carlos sidecars rm -app eleven bot-acme
624 carlos sidecars logs -app eleven bot-acme
625
626Every one of them completes its job with no box access and verifies its own
627result, per the no-infra rule.
628
629## 12. Testing
630
631- **Unreachability is a test, not a claim**: a `Kind == "sidecar"` row is
632 never servable by the edge, never selected as an upstream, never aliased,
633 never attachable to a domain, never on the status page — asserted
634 directly, including the adversarial case of a sidecar row carrying a
635 socket.
636- **Reserved suffix**: every door that mints or accepts a host refuses
637 `.sidecar.internal`.
638- **Lifecycle**: wake → ready → next-due → idle → sleep → timer wake, on
639 the activator's existing fake clock/fake backend harness; crash-backoff;
640 settle-window failure; `max_run`; lease release on every exit path.
641- **Agent socket**: token scoping (a token for host A cannot speak about
642 host B), stale-token refusal after a stop, malformed input, and the
643 socket's absence being survivable.
644- **Reconciler**: mint/converge/remove with the fail-safe (a read failure
645 skips the removal phase), placement matching, and the co-spawn conflict
646 refusal.
647- **Additive parse**: a route row and a bucket record written before this
648 change still parse.
649- The gate: `go build ./...`, `go vet ./...`, `go test ./... -count=1`,
650 `gofmt -l` clean, plus the `GOOS=darwin` compile.
651
652## 13. Explicitly out of scope
653
654- Delivery-wake (§7.5).
655- Retiring the file-staged co-spawn (§10).
656- Applying any infra (§8) or touching any live box.
657- Sidecars for unit-backed tenants — `UnitBackend` refuses agent-managed
658 sidecars today and continues to; the capabilities spec already names this
659 a non-goal.
660- Multi-box placement/failover for a sidecar: one record, one lease, one
661 box at a time, exactly as instances work.
662
663## 14. Amended 2026-09-05 — the sweep, redone against 600 commits of main
664
665§9.4's consumer sweep was exhaustive when it was written, against main at
666`d7077d1`. This branch then sat unmerged from 2026-08-18 while main moved 600
667commits, and the sweep was re-run on the merge. It is recorded here rather
668than folded into §9.4 because the *lesson* is the interesting part: the
669consumer list is not a fact about the tree, it is a fact about the tree ON A
670DATE. Two of the three new consumers were built by people who had no reason
671to know an unroutable serving class was coming.
672
673### 14.1 The edges file publishes every served row, and that is now wrong
674
675`internal/host/edgesfile.go` was added to main after this branch. Every box
676publishes `control/edges/<box>.json` naming the hosts it homes, and its own
677comment states the rule it was built to: *every SERVED row on this box, ALL
678kinds — the serving-class split belongs to the reader, not to this writer.*
679
680That rule was coherent when every kind was servable. A sidecar row is
681`Status: "ready"` — that is what makes it runnable — so it passed the only
682filter and was published as `role: "home"`, which is the assertion *this box
683serves that host*. Three consumers act on it:
684
685- **`edge.backhaul`** (`internal/edge/edge.go`, via `homeMap`). This is the
686 sharp one, and it is sharp because the guarantee this design leans on
687 cannot reach it. §6.1's edge refusal lives in `Reload`, which builds THIS
688 box's routing map out of rows THIS box holds. A remote edge holds no
689 sidecar row and never sees a `Kind` at all. It misses on its own upstream
690 lookup, reads this file, and dials the home box with the sidecar's own name
691 as SNI — a live proxy into the one member the platform guarantees nothing
692 can dial.
693- **`deploymentHost`** = `Allowed(host) || homes.KnownHost(host)`, which is
694 what admits a name to the cert machinery. A published sidecar becomes
695 orderable deployment-wide.
696- **`edgesfile.DeploymentRoutes`**, which feeds the alias pass's
697 "routes always win" rule fleet-wide.
698
699The guard therefore belongs to the WRITER, exactly as §6.2a puts
700`publishResidentFleetBindings`' there: *a sidecar is never advertised as
701served, by this box or any other.* Pinned by
702`TestEdgesFileNeverPublishesASidecar`, which fails with the guard removed —
703the row comes back `{Role: home}`.
704
705### 14.2 Path-based routing mints into a third table
706
707`ReconcileAliases` derived two tables when §3's reserved-suffix guard was
708written. Main added `route_paths`, from the same claims, through a branch the
709guard never sees. Both ends are now refused, and they are not the same
710finding twice:
711
712- the **host** end is the alias guard's argument verbatim: a path row keyed on
713 a reserved-suffix name puts that name in the routing map, and edge.go's
714 Kind check never sees it, because the row it reads is a path row.
715- the **target** end has nothing else standing in front of it. The
716 cross-tenant check passes a sidecar, because a sidecar belongs to the
717 claim's own account — that is the ordinary case, not an edge one. The
718 readiness check passes it, because a live sidecar row is ready. So the
719 target field is the one place in this pass where an account can route real
720 traffic into its own undialable member, and only a check on the name closes
721 it. Removing the guard mints `bot -> bot.eleven.bdm.sidecar.internal`.
722
723### 14.3 Two gaps recorded, deliberately not closed here
724
725Both are in the safe direction — a sidecar is left alone rather than acted on
726wrongly — and both are follow-ups rather than merge work.
727
728- **Narrowed restarts reach no sidecar.** `carlos restart --host` (main,
729 after this branch) resolves hosts through `scopedHosts`, which gates on
730 `release.Adoptable`, which is `Kind == "instance"` and stays that way for
731 §9.3's reason. So a narrowed restart naming a sidecar cycles nothing. That
732 is the right failure — a scoped bounce with no wake beside it would strand
733 a worker, which is precisely what `cycleWorkers` exists to prevent — but
734 reaching a sidecar by name wants a selector of its own.
735- **A trashed app's sidecar rows are not retired.** #340 gave the instance
736 pass a second answer off the trash marker (`removable`, guarded by the
737 production flag) and moved retirement onto every box. The sidecar pass keeps
738 the mint-only predicate: retirement is not a question it can answer without
739 inheriting an authority its removal phase was not designed around. A
740 trashed app's sidecars therefore linger until their records go — the
741 pre-#340 behaviour for everything.
742
743### 14.4 Checked and correct, for the record
744
745`internal/schedule` gates on `Kind != "instance"`. `internal/steering` selects
746on the `Steering` column, which no sidecar record can set. `movespass` is
747instance-only. `status/fleet.go` filters by kind. Metrics and the economics
748pass attribute a sidecar's cgroup like any other exec child, which is §9.6's
749stated intent, not an oversight.
750
751One vocabulary inconsistency is left standing: `carlos status --instances`
752matches units to routes through `routeForUnit`, which matches a sidecar's
753`carlos-exec@<host>` cgroup, so a running sidecar is listed there under
754"instances" rather than in the separate bucket §9.5 gives it. The door is
755authenticated and account-scoped, so nothing leaks; it is a naming fix, and
756changing the shape of a status API is not merge work.
757
758## 15. Amended 2026-09-15 — the sweep, redone AGAIN, 405 commits later
759
760§14 was written against main at `66587fa8` on 2026-09-05 and was exhaustive
761then. Main moved 405 commits in the ten days that followed, and the branch
762went conflicted rather than merely stale a second time. The sweep was rerun
763from scratch, on the principle §14 had already arrived at: a consumer list
764is not a fact about the tree, it is a fact about the tree ON A DATE, and the
765only way to keep one true is to stop trusting it and look again.
766
767One new consumer reaches a sidecar. It is worth reading the shape of it,
768because it is the THIRD instance of one pattern and that pattern is now
769the thing to watch for rather than any of its three instances.
770
771### 15.1 The domain-attach door aliases onto a sidecar
772
773`internal/console/domainattach.go`'s `readyRouteHost` picks the route a
774newly-attached custom domain is aliased onto. Its own doc says the selection
775is `domains.firstReadyUpstream`'s "minus the upstream copy" — and that is
776accurate about the intent and wrong about the consequence.
777
778`firstReadyUpstream` ends with a clause this one drops:
779
780 if rt.Socket != "" || rt.Addr != "" || (rt.Kind == "static" && rt.Channel != "")
781
782That clause is doing two jobs. It selects a row with something to copy —
783the job it was written for, and the job `readyRouteHost` genuinely does not
784need, since it copies nothing and returns only the host. It is ALSO the
785question "is this row dialable at all", and that job nobody assigned it is
786the entire reason a sidecar never appears in `firstReadyUpstream`'s answer:
787a sidecar is precisely the row with no socket and no addr.
788
789So dropping the clause dropped the guard with it, and every test that
790remains passes a sidecar. It belongs to the app. It belongs to the account.
791It is not `Platform`. It is `ready` — because ready is what makes it
792runnable. Selection is in `reg.List`'s host order, so `bot.<app>...` sorts
793ahead of most hosts an app would really serve from: the sidecar is not a
794straggler that wins a tie, it is the first candidate.
795
796The result is not a routing breach — `ReconcileAliases` refuses a claim
797whose `Route` carries the reserved suffix (the guard at aliases.go's alias
798branch, §14's sibling). It is worse in a quieter way: the member's attach
799returns 200, the claim is written naming a sidecar, and the box then refuses
800that claim on every pass, forever. A custom domain that never serves and
801nothing anywhere says why.
802
803Guard added at the door, so the console agrees with the box. Verified by
804neutralising it: the attach aliases `echo.example.com` onto
805`bot.story.bab.sidecar.internal`.
806
807### 15.2 The pattern, which is the actual finding
808
809Three sweeps have now found the same thing three times:
810
811- §14.1, the edges file: a writer that published "every served row, ALL
812 kinds", true only while every kind was servable.
813- §14.2, path routing: a third table derived from the same claims, through
814 a branch the reserved-suffix guard never saw.
815- §15.1, domain attach: a selection copied from a guarded one, minus the
816 clause that was the guard.
817
818None of the three was written carelessly and none is a sidecar bug. Each is
819code written before an unroutable serving class existed, by someone with no
820reason to imagine one. The common shape is a predicate that EXCLUDED
821sidecars as a side effect of asking something else — "has an upstream to
822copy", "is a row this box serves" — being narrowed, copied or re-derived by
823someone who correctly identified the stated purpose and could not see the
824unstated one.
825
826The practical consequence for whoever sweeps next: grep for
827`registry.Route` is not enough, and neither is grepping for `Kind`. What
828finds these is asking, of every consumer, "what in this predicate happens to
829require a socket or an address?" — and then checking whether that clause is
830load-bearing somewhere it was never documented to be.
831
832### 15.3 Checked this pass and correct
833
834- `internal/gate` (new): the password curtain is applied at the edge, in
835 front of a route serving requests. The edge refuses a sidecar on Kind
836 before dispatch, so a `gate` hash on a sidecar row is inert.
837- `apexHolder` (`internal/console/apex.go`, new): reads `reg.Get` at the
838 platform apex `<name>.<sqid>.<domain>`. A sidecar host is under
839 `.sidecar.internal`, a different suffix entirely, so the lookup can never
840 land on one.
841- `edgesfile.DeploymentRoutes`: reads the published edges files, which
842 §14.1's writer-side guard has already emptied of sidecars. That guard
843 covers this consumer too, and is the reason it needs none of its own.
844- `warnExternalSockets` (`internal/host/socket_advice.go`): explicit
845 `Kind != "instance"`.
846- `versionsweep`: pins a sidecar's `Version`/`PrevVersion` against staged
847 reaping. Correct and wanted — a sidecar runs the app's binary and the
848 build it is running must not be swept out from under it.
849- `schedule`'s prune: marks a sidecar's host alive, so its schedule rows
850 survive. Correct and load-bearing — a hibernating sidecar's schedule is
851 its only way back up.
852- `ops/litestream.FleetDBs`: an always-on sidecar's database joins the
853 box-wide replication set like any other always-on row, which is right; a
854 hibernating one is excluded there because the activator replicates it
855 per-wake.
856- None of the brand-new packages this pass (`analytics`, `clamd`,
857 `maintenance*`, `objstore`, `privatefile`, `publicrelease`, `relay`,
858 `telemetry`, `usage`) reads routes at all.
859
860### 15.4 A gap this merge opened, recorded not closed
861
862Isolated exec (§8.5/§8.6 of its own design) gave `StartSidecar` a layout
863dispatch: a nested box starts a sidecar as a unit through the control
864channel, a fenced one refuses, and only the default falls through to the
865child spawn. `StartWorker` — the NATIVE path, which is what a standalone
866`Kind == "sidecar"` row uses — has no such dispatch and always spawns a
867bare child.
868
869On a nested-layout box a native sidecar would therefore come up outside the
870isolation every other child gets. It is not wrong today, because no box
871runs both native sidecars and the nested layout, but it is exactly the kind
872of agreement that stops being true quietly. The fix is `StartWorker`
873learning the same dispatch, and it wants its own review rather than a
874merge's.
875