carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Isolated exec — every exec-backed instance in its own namespace
2
3**Date:** 2026-09-11 · **Status:** revision 17; codex verdict after
4round 11 is **accept with corrections**, and the one correction is
5applied here (54 findings over 11 rounds, all accepted; §13); a
6whole-branch final review (plan 2) found six further items, fixed in
7revision 13 (§13); plan 3's task 4 review settled the requested-stop
8close, recorded in revision 14 (§6.5, §13 R13); the first roll's probe
9found two defects, fixed in revision 15 (§4.1, §8.5, §13 R15); awaiting Paul's review · **Requested by:** discussion
10#126 (Tito, with box evidence), approved in direction by Paul on
112026-09-11
12
13**Builds on:** discussion #126 and its companion #127; titogo's
14`deploy/systemd/titogo@.service` (the namespace recipe, verified on
15systemd 257 and on carlos-tito-ie); `internal/edge/purge.go:53-107` and
16`internal/activator/admintoken.go:6-14`, which already describe the gap;
17the exec-child accounting design (2026-08-07, pinfra
18`platform-design/superpowers/specs/`), the memory-pressure containment
19design (2026-08-28, same place), the cross-user socket consumers group
20(issue #103, `internal/host/bootstrap.go:41-62`), the system-update
21design (2026-09-06) whose apply/rollback path the transition must
22survive, and the root-relay pattern (`carlos-stage.path` →
23`carlos adopt --stage-only`).
24
25---
26
27## 0. How this spec was arrived at
28
29Tito filed #126 after verifying on carlos-tito-ie that an exec-backed
30instance can read every other instance's database, admin token and
31delivered config, including the relay route's platform credentials, and
32including instances of other accounts' apps. The discussion offered two
33shapes: make console-provisioned instances unit-backed, or isolate exec
34children in place with a per-host directory plus a uid or namespace.
35
36Three approaches were compared against the code on 2026-09-11 (§12).
37Paul approved the third on the same day and asked that the one open
38mechanism choice, how the unprivileged agent starts a root-managed unit,
39be settled between Fable and codex during review. Codex's round 1
40concurred with the control service and showed why it must authenticate
41its caller (§5.4). Round 2 turned the migration from an agent-start side
42effect into an operator-triggered fleet verb (§8), because the system
43updater's own rollback runs the *old* binary and cannot reverse a layout
44it never knew. Round 3 drew the transition boundary: while a box still
45runs unisolated tenants, nothing that runs as `carlos` is a trustworthy
46caller, so the root channel authenticates the agent *process*, not a
47secret the agent holds (§5.4).
48
49The shape in one sentence: **the activator keeps owning every exec-backed
50instance exactly as it does today, but the process it owns is launched as
51a systemd template unit, still uid `carlos`, inside a mount namespace in
52which nothing but that instance's own directory exists, on a mount where
53no symlink is ever followed.** No uid change, no console change, no new
54backing. One release carries both layouts; an operator verb moves each
55fleet across; the same verb moves it back.
56
57---
58
59## 1. What exists, and what is wrong
60
61### 1.1 The agent and its children
62
63`carlos agent` runs as `User=carlos` under `carlos-edge.service`
64(`internal/host/units/carlos-edge.service`), with
65`CapabilityBoundingSet=CAP_NET_BIND_SERVICE`, `RestrictNamespaces=yes`,
66`ReadWritePaths=/data /run/carlos` and `Delegate=cpu memory io pids`. It
67cannot change a child's uid and cannot build a mount namespace for one.
68
69Every exec-backed instance is a plain child of that process:
70`internal/activator/backend_exec.go:690-692` builds
71`exec.Command(bin, "--socket", rt.Socket, "--db", dbPath)` with
72`cmd.Env = childEnv(childVars)`; no `cmd.Dir`, no `SysProcAttr.Credential`
73(the only `SysProcAttr` fields set anywhere in the tree are the cgroup
74manager's `Setpgid`, `Pdeathsig`, `UseCgroupFD` and `CgroupFD`,
75`internal/cgroup/strict_linux.go:256`, `cgroup_linux.go:20-24`). Seed
76(`backend_exec.go:377-410`) and sidecars (`:885-911`) are spawned the same
77way.
78
79Everything the instance owns sits flat in one directory, `DataDir`
80(`/data`; `backend_exec.go:52-53`, `internal/host/agent.go:186-188`):
81
82| File | Written by | Read by |
83|---|---|---|
84| `<host>.db` (+ `-wal`, `-shm`, `.activator-<host>.db-litestream/`) | the instance; restore, seed and replication in the agent | the instance, the agent's in-process replicator (`internal/lease/lease.go:257-299`) |
85| `<host>.env` (admin token) | `EnsureAdminToken` (`admintoken.go:111-195`) | the agent (`backend_exec.go:350, 507, 579, 607, 790`; `internal/schedule/runner.go:461`) |
86| `<host>-sidecar.env` | hand-staged | `backend_exec.go:865-877` |
87| `<host>.config.env` | `internal/appconfig/syncexec.go:872-926` | `backend_exec.go:786` |
88
89All of it is `carlos:carlos`, and every instance is uid `carlos`. Mode
900600 restricts nothing between siblings. `purge.go:53-95` says so in
91prose, and names per-instance uids as "the real fix".
92
93The sockets are shared on purpose with one more party: `/run/carlos` is
94`2750 carlos:carlos-sockets` (`bootstrap.go:61`), and after the health
95wait the activator normalises each instance socket to `0660`
96(`backend_exec.go:744-750`), so a deployment's enrolled cross-user
97consumer (titogo's router, uid `titogo`, issue #103) can dial every
98instance socket on the box. That contract survives this change (§3.1).
99
100### 1.2 What a sibling can reach today
101
102Everything below is a read (or write) by any exec-backed instance of any
103app on the box, as verified by Tito on 2026-09-10 and as the file table
104above implies:
105
106- every other exec instance's database, read and write;
107- every other exec instance's admin token, which drives that host's
108 schedule verbs (`admintoken.go:6-14`);
109- every `<host>.config.env` in `/data`, including the ones for TCP-addr
110 routes CARLOS never spawns (`carlos env exec-delivery file`), which for
111 Tito carry every platform credential the relay holds (#127);
112- every other exec instance's control socket, `rt.Socket + ".control"`
113 (`backend_exec.go:594`; `purge.go:56-68`): a purge for another host, or
114 a schedule tick with a token read from that host's env file;
115- every other exec instance's **listening socket file** under
116 `/run/carlos`: same uid, so a sibling can unlink `<other>.sock` and bind
117 its own listener there, and the edge (`internal/edge/edge.go:536-538`)
118 will dial it as that host. This is not in #126; it follows from the
119 same uid and the same directory;
120- `/data/registry.db`, `/data/litestream.env` (the box-wide replicator's
121 bucket credentials, `carlos-litestream.service`) and, on a console box
122 where `console.service` runs as `carlos` with `ReadWritePaths=/data`,
123 `/data/console/console.db`. **Verify the console unit's user on the
124 flagship before treating the last item as fact; the unit is
125 hand-written and not in git.**
126- the instance metadata service. `harden-host.sh:36-44` (pinfra) allows
127 IMDS to uid 0 and uid `carlos` and drops everyone else, because the
128 agent reads IMDS. Exec children are uid `carlos`. `carlos-app@.service`
129 carries `IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128` for exactly
130 this reason; exec children carry nothing.
131
132### 1.3 The primitive under all of it: same uid, same paths
133
134A namespace hides a sibling's files from the instance. It does not hide
135them from the **agent**, which reads and writes the instance's directory
136from outside, as the same uid, by path. Any path the instance can write
137and the agent later opens is therefore a request the instance makes on
138the agent's behalf: replace `<host>.env` with a symlink to
139`/data/litestream.env` and the next wake hands the bucket credentials to
140the instance as its own environment (`backend_exec.go:785-794, 966-971`;
141`EnsureAdminToken` only refuses the *chmod* through a symlink,
142`admintoken.go:141-150`). Replace `<host>.db` with a symlink to a
143sibling's database and the agent's replicator uploads the sibling's data
144under the attacker's replica. Replace the listening socket with a symlink
145to any other socket on the box and the edge dials it as this host
146(codex R1-1, R1-3). Any *file* the agent reads as instructions from the
147instance's directory is the same primitive without a symlink (R2-1).
148And for as long as a box still runs one unisolated tenant, every
149`carlos`-readable secret and every `carlos`-writable directory on the
150box is that tenant's too, so nothing that runs as `carlos` can be told
151apart from it by what it knows or where it writes (R3-1). A design that
152bounds the instance but not the paths the agent follows on its behalf,
153the records it trusts, or the callers it believes, has not bounded
154anything. §3.2 closes the symlinks at the kernel; §3.3 states the rule
155for records; §5.4 states the rule for callers.
156
157### 1.4 What the unit backing already gets right, and why it is not the answer
158
159`carlos-app@.service` (`DynamicUser=yes`, `StateDirectory=carlos-app/%i`,
160root-0600 config in `/etc/carlos/apps`) has the property #126 asks for: a
161peer's state is absent, not permission-denied. But `UnitBackend`
162(`internal/activator/backend_unit.go`) has none of the machinery the
163exec backend gives a self-serve instance. Its `Acquire`, `Restore` and
164`Seed` are inert (`:113-132`), replication belongs to the root
165`carlos-litestream` unit because the agent cannot read a DynamicUser's
166state directory (`:12-19`), `NextDue` returns nothing (`:174-179`), and
167sidecars are refused (`:181-188`). `provision.Record` has no backing
168field (`internal/provision/provision.go:36-135`) and the reconciler
169refuses to adopt a row that carries one (`reconcile.go:426-436`). The
170always-awake design already concluded that moving exec instances to
171units "needs a separately reviewed root/unit/socket maintenance protocol
172and data migration". Making console-provisioned instances unit-backed is
173a rewrite of hibernation, not an isolation fix.
174
175One more thing the sweep found: `UnitBackend.systemctl`
176(`backend_unit.go:94-103`) runs `systemctl start` as the unprivileged
177agent. Nothing in this tree or in pinfra grants uid `carlos` the
178`org.freedesktop.systemd1.manage-units` action, and polkit is not
179referenced anywhere. Whether `backing=unit` hibernation works on the
180fleet today is a follow-up discussion (§11); this spec's channel is
181scoped to the three instance templates and does not extend to
182`carlos-app@` (codex R1-19).
183
184---
185
186## 2. The design in one page
187
1881. **Per-instance directory.** An exec-backed instance's files move from
189 `<DataDir>/<host>.*` to `<DataDir>/instances/<host>/` (§3). The agent
190 creates the directory and everything in it, as `carlos`, exactly as it
191 creates the flat files today. The registry's `DBPath` and `Socket`
192 columns point into the new layout once a host is migrated.
193
1942. **No symlink is ever followed under the trees the agent binds or
195 opens for a tenant.** `/data/instances`, `/run/carlos/instances` and
196 `/run/carlos/logs` are bind mounts of themselves with `nosymfollow`
197 (§3.2). The kernel refuses to traverse a symlink there for every
198 process on the box, agent, edge and PID 1 included, with no
199 check-then-use race. Nothing the agent does is ever directed by a file
200 it read from an instance directory, and a migration journal is honoured
201 only when it says what the fleet's recorded intent already says (§3.3).
202
2033. **A template unit per role.** The instance runs as
204 `carlos-instance@<host>.service`; its seed as
205 `carlos-instance-seed@<host>.service`; its sidecar as
206 `carlos-instance-sidecar@<host>.service` (§4). All three run as
207 `User=carlos`, all three carry titogo's namespace
208 (`TemporaryFileSystem=/data:ro` and `TemporaryFileSystem=/run/carlos:ro`
209 with exactly the instance's own two directories bound back in,
210 `PrivatePIDs=yes`, `ProtectProc=invisible`, `RestrictNamespaces=yes`,
211 `IPAddressDeny=` for IMDS), and all three sit in one per-host slice
212 that carries the host's memory and swap tier. A sibling's database does
213 not exist in this namespace; a sibling's socket does not exist; a
214 sibling's pid does not exist.
215
2164. **The agent stays the owner.** Lease, restore, seed ordering,
217 replication, hibernation, the control socket, `carlos logs`, the
218 next-due tick, sidecar co-lifecycle and residence moves are unchanged
219 in `internal/activator`, with two bounded changes in `activator.go`
220 (§6.3, §6.7). What changes is the `Proc` the exec backend hands back:
221 it is a unit, not an `*exec.Cmd` (§6).
222
2235. **One root channel, authenticating the process.** The agent cannot
224 start a system unit. A small root service, `carlos-instance-control`,
225 socket-activated on a unix socket in a root-owned directory, starts and
226 stops units named `carlos-instance*@<host>` and nothing else, and
227 accepts a request only from the edge unit's main process, as reported
228 by systemd, never from a child of it (§5). This is the synchronous
229 form of the `carlos-stage.path` pattern.
230
2316. **Launch through a trampoline; exit through the unit's own record
232 plus the kernel's word.** Each unit's `ExecStart` is
233 `/opt/carlos/carlos instance-exec <role> <host>`, which runs inside the
234 namespace, reads the launch description the agent wrote, and `execve`s
235 the app binary with the exact argv and environment the agent composed
236 (§6.2). Each unit's `ExecStopPost=` leaves an advisory exit record in
237 the instance's own run directory; termination itself is proven by
238 systemd's unit state and an empty cgroup, never by a file (§6.5).
239
2407. **Resource walls become slices.** `carlos.slice` carries the
241 2026-08-28 outer wall; the edge unit and every instance sit under it;
242 each host has its own slice inside that, carrying the tier (§4.3,
243 §6.6).
244
2458. **One release, two layouts, one operator verb.** This release's
246 agent runs a *flat* host exactly as today (unisolated, `childProc`)
247 and a *nested* host isolated. Nothing moves on upgrade and no new row
248 is minted nested, so the system updater's own rollback (which runs the
249 previous binary) always lands on a layout that binary understands.
250 `carlos system layout migrate` records the fleet's intent; every box
251 converges its eligible hosts to it on every serial move; `revert`
252 records the opposite; status reports each host (§8). The next release
253 drops the flat spawn path for activator-managed hosts.
254
2559. **Addr routes and externally supervised routes are untouched, and now
256 invisible.** A TCP-addr route's `<host>.config.env` stays flat in
257 `/data`, because the app's own supervisor reads it there (titogo's
258 `EnvironmentFile=-/data/%i.config.env`); an unflagged socket route keeps
259 its flat paths because its supervisor owns them. Isolated instances can
260 no longer see either. That closes the read #127 leads with; #127's ask
261 for root-owned delivery stays open as its own change (§11).
262
263---
264
265## 3. The instance directory
266
267### 3.1 Layout
268
269```
270/data/.instances/ the RAW directory: 0700 carlos:carlos; opened by the layout pass only (§3.2, §8.2)
271/data/instances/ nosymfollow bind mount OF /data/.instances (§3.2); the view everything else uses
272/data/instances/<iid>/ 0700 carlos:carlos, created by the agent
273 <host>.db the database (+ -wal, -shm)
274 .activator-<host>.db-litestream/ the in-process replicator's shadow
275 <host>.env admin token (EnsureAdminToken, unchanged)
276 <host>-sidecar.env hand-staged sidecar env (unchanged)
277 <host>.config.env syncexec's rendered bundle (unchanged)
278 launch.run.json agent-written before each instance start (§6.2)
279 launch.seed.json agent-written before a seed
280 launch.sidecar.json agent-written before a sidecar start
281 <host>.db.corrupt a quarantined restore, as today
282/data/layout/ 0700 carlos:carlos
283 <host>.json the migration journal for a host in flight (§8.2)
284/run/carlos/instances/ nosymfollow bind mount OF /run/carlos/.instances; 2750 carlos:carlos-sockets
285/run/carlos/instances/<iid>/ 2750 carlos:carlos-sockets, created by the agent
286 app.sock the instance's listener (rt.Socket); 0660 after health, as today
287 app.sock.control the agent's control listener (rt.Socket + ".control"); 0600
288 exit.<role>.<invocation>.json the unit's advisory exit record (§6.5); tenant-writable, and treated so
289/run/carlos/logs/ nosymfollow bind mount OF /run/carlos/.logs; 0700 carlos:carlos; NOT bound into any namespace
290 <iid>.run.sock the agent's log listener for the instance (§6.4)
291 <iid>.seed.sock ... for the seed
292 <iid>.sidecar.sock ... for the sidecar
293/run/carlos-control/ 0750 root:carlos; NOT under /run/carlos, NOT in any namespace
294 control.sock the root channel (§5); SocketUser=carlos, 0600
295```
296
297**`<iid>` is the instance identifier, bounded to 40 bytes** (codex
298R5-3). A host may be 253 bytes (`controlname.ValidHostShape`), and a
299unix socket address holds 107 bytes of path, so a directory named after
300the host cannot carry `app.sock.control` for every valid host.
301`provision.InstanceID(host)` is the host itself when `len(host) <= 40`,
302and otherwise `_` + the first 22 bytes of the host + `-` + the first 16
303hex digits of `sha256(host)`, which is also 40 bytes. **The two domains
304cannot meet** (codex R6-1): no valid host contains `_`
305(`controlname` labels are `[a-z0-9-]`), so a hashed identifier is never
306equal to any short host, and two hashed identifiers are equal only on a
30764-bit hash collision between two long hosts sharing a 22-byte prefix.
308It is the directory name under `/data/instances` and
309`/run/carlos/instances`, the log socket prefix, and, escaped, the unit
310instance and slice suffix (§4; `_` needs no escaping). File names
311*inside* the directory keep the full host (a file name may be 255
312bytes). The longest address this produces,
313`/run/carlos/instances/<40>/app.sock.control`, is 79 bytes.
314
315Two rows on one box whose hosts nonetheless collide in `InstanceID`
316classify as **`conflict`**, which **refuses to wake** with both hosts
317named and is never the unisolated fallback: a host that is already
318`nested` must never be moved to the `childProc` path by the arrival of
319a second row (R6-1). The layout pass skips a `conflict` host, status
320reports it, and the console's create-instance door refuses a new host
321whose identifier collides with an existing row on the target box. The
322pass also preflights every socket address it is about to commit
323against 107 bytes before it moves a file.
324
325The socket tree keeps the #103 contract: `/run/carlos/instances` and
326each host's run directory are `2750 carlos:carlos-sockets` (setgid, so
327the socket an instance binds inherits the group, exactly as
328`tmpfilesConf` arranges for `/run/carlos` today), the activator's
329post-health `0660` normalisation is unchanged, and the control socket
330stays `0600`. An enrolled consumer can traverse to and dial `app.sock`
331and nothing else (codex R2-10). Inside the namespace the instance owns
332its run directory and can loosen it; that changes nothing outside,
333because outside is where the mode is set and checked.
334
335**One helper, one owner.** `internal/provision` gains
336`InstanceDir(dataDir, host)` beside `DefaultDBPath`, and the nested
337defaults exist alongside the flat ones. In this release **no minting
338path uses the nested defaults** (§8.1, codex R3-6): `routeFor`
339(`reconcile.go:299-301`) and `carlos add` (`cli.go:118-136`) keep minting
340flat paths, and the layout pass is the only thing that ever writes a
341nested path into a row.
342
343```go
344func InstanceID(host string) string { // ≤ 40 bytes; see §3.1
345 if len(host) <= 40 { return host }
346 sum := sha256.Sum256([]byte(host))
347 return "_" + host[:22] + "-" + hex.EncodeToString(sum[:8]) // '_' never occurs in a host
348}
349func InstanceDir(dataDir, host string) string {
350 return filepath.Join(dataDir, "instances", InstanceID(host))
351}
352func NestedDBPath(dataDir, host string) string {
353 return filepath.Join(InstanceDir(dataDir, host), host+".db")
354}
355func InstanceRunDir(host string) string {
356 return filepath.Join(SocketDir, "instances", InstanceID(host))
357}
358func NestedSocket(host string) string {
359 return filepath.Join(InstanceRunDir(host), "app.sock")
360}
361func LogSocket(host, role string) string {
362 return filepath.Join(SocketDir, "logs", InstanceID(host)+"."+role+".sock")
363}
364// DefaultDBPath and DefaultSocket keep their flat meaning for this
365// release; the next release, which drops the flat spawn path, points
366// them at the nested helpers for activator-managed routes and keeps
367// them flat for externally supervised ones (§8.6).
368```
369
370`ExecBackend.defaultDBPath` (`backend_exec.go:160-162`) is deleted in
371favour of the provision helpers, so there is one definition, not two.
372
373**File names inside the directory keep the host prefix.** It costs
374nothing, it keeps every existing test fixture recognisable, and it means
375a file found outside its directory is still attributable.
376
377**Why nested and not per-instance uids.** titogo's unit comment says it
378best: "A flat `/data/titogo/%i.db` cannot be isolated — the bind would
379have to reveal all of `/data/titogo`, which is the regression itself."
380Per-instance uids (the discussion's option 2, titogo's "Option B") would
381additionally need a uid allocator, a chown as root on every migration and
382every restore, and a group arrangement that keeps the agent able to open
383the database for replication. The namespace gives the property the
384discussion asks for ("absent, not permission-denied") without any of
385that, and leaves option B open as a later hardening on the same layout
386(§11).
387
388### 3.2 Symlinks are never followed under the trees the platform binds or opens for a tenant (codex R1-1, R1-2, R1-3, R3-1)
389
390Three mount units, embedded and installed like every other unit
391(`internal/host/bootstrap.go:33-34`), and tmpfiles lines beside the
392existing `/run/carlos` one (`bootstrap.go:61`). All three carry
393`DefaultDependencies=no`: a mount unit's default ordering places it
394before `local-fs.target`, and `systemd-tmpfiles-setup.service`, which
395creates the directories, runs after that target, so the defaults would
396form a cycle systemd breaks by dropping the mount (codex R2-2). The
397ordering is therefore written out in full, including shutdown:
398
399```ini
400# data-instances.mount
401[Unit]
402Description=carlos instance data (bind, nosymfollow)
403DefaultDependencies=no
404RequiresMountsFor=/data
405After=local-fs.target systemd-tmpfiles-setup.service
406Before=carlos-edge.service carlos-instance-control.service carlos-litestream.service
407Conflicts=umount.target
408Before=umount.target
409[Mount]
410What=/data/.instances
411Where=/data/instances
412Type=none
413Options=bind,nosymfollow,nosuid,nodev,noexec
414[Install]
415WantedBy=multi-user.target
416```
417
418**The source is a different directory from the mountpoint** (codex
419R4-2). `rename(2)` refuses to cross a mount boundary with `EXDEV` even
420when both sides are one filesystem, so a bind of `/data/instances` onto
421itself would make the migration's rename from `/data/<host>.db` into it
422impossible. Binding `/data/.instances` onto `/data/instances` keeps the
423raw directory reachable on the `/data` mount: the layout pass renames
424`/data/<host>.db` → `/data/.instances/<host>/<host>.db` on one mount,
425atomically, and the file appears at `/data/instances/<host>/<host>.db`
426through the protected view. The raw path is opened by **exactly one
427caller**, the layout pass, and only as `openat2(RESOLVE_NO_SYMLINKS |
428RESOLVE_BENEATH)` directory fds for `renameat`, so no lookup through it
429ever follows a symlink either; a CI test greps the tree for the literal
430`.instances` and fails on any other occurrence.
431
432`run-carlos-instances.mount` and `run-carlos-logs.mount` are the same
433shape for `/run/carlos/.instances` → `/run/carlos/instances` and
434`/run/carlos/.logs` → `/run/carlos/logs`, with
435`After=systemd-tmpfiles-setup.service` and no `RequiresMountsFor=`;
436nothing is ever renamed into those, so their raw directories have no
437caller at all.
438
439```
440d /data/.instances 0700 carlos carlos -
441d /data/instances 0700 carlos carlos -
442d /data/layout 0700 carlos carlos -
443d /run/carlos/.instances 2750 carlos carlos-sockets -
444d /run/carlos/.logs 0700 carlos carlos -
445f /etc/carlos/probe-secret 0640 root carlos - probe
446d /run/carlos/instances 2750 carlos carlos-sockets -
447d /run/carlos/logs 0700 carlos carlos -
448d /run/carlos-control 0750 root carlos -
449```
450
451`carlos-edge.service`, `carlos-instance-control.service` and the three
452templates gain `Requires=` and `After=` on the mounts they touch, so an
453edge that starts without them does not start at all rather than starting
454unprotected. The plan's first box task is a **cold boot** with the full
455unit set installed, then `systemctl list-dependencies --before
456data-instances.mount` and a `systemd-analyze verify` over the embedded
457set, pinned as a CI test that renders the units and runs
458`systemd-analyze verify` where the tool exists.
459
460`nosymfollow` (Linux 5.10+, util-linux 2.36+; Debian 13 ships 6.12 and
4612.41) makes path resolution on that mount return `ELOOP` at any symlink,
462for every process, in every namespace that sees the mount. It is a
463per-mount flag, so it holds through the `BindPaths=` bind into the
464instance's namespace, through the agent's and the edge's ordinary
465lookups from outside, and through PID 1's open of a `StandardOutput=file:`
466path. `readlink` still works; nothing the platform runs needs a symlink
467under these trees (SQLite, litestream, the app's listener and the
468agent's env files are all plain files). Hard links cannot escape either:
469a `link()` needs a source path the caller can resolve, and inside the
470namespace no sibling path resolves.
471
472What this closes, race-free and for every reader at once:
473
474- the instance replacing its own `app.sock` with a symlink to the root
475 control socket, a sibling's socket, or a sibling's control socket, and
476 having the edge dial it (R1-1);
477- the instance replacing `<host>.env`, `<host>.config.env` or `<host>.db`
478 with a symlink to a sibling's file, `/data/litestream.env` or any
479 agent-readable path, and having the agent read it into the instance's
480 environment or replicate it into the instance's replica (R1-3);
481- the instance planting a symlink at a predictable temp name so the
482 agent's write truncates the target (R1-3);
483- a still-unisolated flat tenant, during the transition, planting a
484 symlink at a log socket path so that PID 1 opens a root-owned file as
485 some unit's stdout (R3-1). `/run/carlos/logs` is `carlos`-owned because
486 the agent must bind there, so a same-uid process can write it; the
487 mount flag is what makes that write inert.
488
489The flat tenants' own sockets under `/run/carlos` itself are not on such
490a mount (the directory holds flat sockets, the apps subtree and more, and
491this release does not change it). A flat tenant can therefore still point
492its socket at any socket on the box, by symlink or by editing the
493registry row it can reach as `carlos`, and have the edge dial it. §5.4
494says why that no longer reaches the control service, and §6.1 names the
495two checks the edge and the agent make on every unix dial.
496
497Belt and braces in the agent, cheap and testable:
498
499- every agent open of a file under an instance directory goes through
500 one helper, `instancefs.Open(dir, name, flags)`, built on
501 `openat2(2)` with `RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS`
502 (Linux 5.6+), so a symlink is refused with a named error even on a
503 box where the mount option is missing; the same helper is what the
504 agent uses to verify, at start, that all three mounts carry
505 `nosymfollow` (`/proc/self/mountinfo`), and it **refuses to wake any
506 nested host** until they do (§8.5);
507- every agent write into an instance directory creates its temp file
508 with `O_CREAT|O_EXCL` and a random suffix, then `renameat`s over the
509 target (rename replaces the directory entry and never follows the
510 destination);
511- launch files (§6.2) are written the same way, and `instance-exec`
512 reads them through the same helper.
513
514### 3.3 Nothing in an instance directory is an instruction, and a journal is not one either (codex R2-1, R3-1)
515
516The agent reads three kinds of thing from an instance directory: the
517database (to replicate, restore and checkpoint), the two env files (to
518compose the instance's own environment), and the advisory exit record
519(to decorate an event line, §6.5). It never reads a path, a host name, a
520file list or a phase from there. In particular:
521
522- the migration journal lives in `/data/layout/<host>.json`, outside
523 every tenant mount, and every source and destination path in it is
524 *derived from the host name and the registry row* by the §3.1 helpers
525 at plan time. On recovery the pass **recomputes** both endpoints for
526 the journal's own direction and honours the journal only if its paths
527 equal the recomputation and the row stands at one of them (§8.2); a
528 journal that passes is completed to its own end and the host is then
529 converged to the fleet's recorded intent (`control/layout.json`, a
530 bucket object no tenant can write) before it is released. During the
531 transition a flat tenant can write `/data/layout` (same uid); a journal
532 it forges can
533 therefore cause the agent to do exactly what the next layout pass would
534 do for that host anyway, and nothing else (R3-1);
535- the launch file is agent-written and then read by the trampoline
536 inside the namespace as the instance itself; it directs nothing the
537 agent does (§6.2);
538- the exit record is written inside the instance's run directory by the
539 unit's own `ExecStopPost=`, is therefore tenant-writable, and is treated
540 as the tenant's claim about its own exit status: it never decides
541 whether the process is gone (§6.5).
542
543A test plants a forged journal (a sibling's paths under a host's name; a
544direction opposite to the fleet's intent), a forged launch file naming a
545sibling, and a forged exit record for a running unit, and asserts the
546sibling is untouched and the running unit is not treated as exited.
547
548---
549
550## 4. The units
551
552Unit files under `internal/host/units/`, embedded and installed exactly as
553the existing nineteen are (`bootstrap.go:33-34`, `systemapply.go:339-398`):
554`carlos-instance@.service`, `carlos-instance-seed@.service`,
555`carlos-instance-sidecar@.service`, `carlos.slice`,
556`carlos-instances.slice`, the three mount units of §3.2, the probe of
557§8.5, and the two control units of §5.2. Nothing enables the templates;
558an instance unit exists only while the agent has started it.
559
560**Instance names are escaped instance identifiers.** The unit instance
561is `systemd-escape(InstanceID(host))` (§3.1): for a valid identifier
562(`[a-z0-9.-]`) that only turns `-` into `\x2d`. `%I` (unescaped) is used
563wherever the identifier is a path component; `%i` (escaped) wherever it
564is part of a unit name, which is what lets a per-host slice be spelled
565in the template (R1-8). The agent and the control service share one Go
566escape function (a copy of systemd's rules for this character set,
567pinned by a test against `systemd-escape` output for the hosts in the
568fixture set). The templates never need the full host: every path they
569name is keyed by `<iid>`, and the trampoline learns the host from the
570launch file (§6.2).
571
572### 4.1 `carlos-instance@.service`
573
574```ini
575[Unit]
576Description=carlos exec-backed instance %I
577# Stop-only coupling: when the edge stops (including the stop half of a
578# restart) this unit stops, and nothing starts it again but the agent.
579# PartOf= would propagate the RESTART and race the agent's own startup
580# (codex R1-7).
581BindsTo=carlos-edge.service
582Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
583After=carlos-edge.service data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
584
585[Service]
586Type=simple
587ExecStart=/opt/carlos/carlos instance-exec run %I
588# Runs inside the same namespace, as carlos, after this invocation's
589# processes have been signalled: leaves an ADVISORY exit record in the
590# instance's own run directory (§6.5). Not "+": the "+" prefix drops
591# User= and the Private*/Protect* settings but keeps the explicit
592# TemporaryFileSystem=/BindPaths= mounts (codex R3-2), so nothing run
593# from here can see the host's /run/carlos anyway.
594ExecStopPost=/opt/carlos/carlos instance-exit run %I
595User=carlos
596Group=carlos
597# One slice per host holds the instance, its seed and its sidecar, and
598# carries the tier (§4.3, §6.6). Slices referenced here are created on
599# demand; no per-host unit file exists.
600Slice=carlos-instances-%i.slice
601# The activator owns recycling: a crash stands the entry down and the
602# next request wakes it. systemd must not race that.
603Restart=no
604# SIGTERM to the app, SIGKILL to anything it left behind after
605# TimeoutStopSec; today's actStopTimeout is 20s.
606KillMode=mixed
607TimeoutStopSec=20
608# The log stream: stdout and stderr connect to the agent's per-role
609# listener (§6.4). PID 1 opens this as root, outside the namespace, on
610# the nosymfollow logs mount, at a path no tenant mount contains.
611StandardOutput=file:/run/carlos/logs/%I.run.sock
612StandardError=inherit
613
614# --- the namespace (titogo@.service, 2026-07-31 design, on systemd 257) ---
615ProtectSystem=strict
616TemporaryFileSystem=/data:ro
617TemporaryFileSystem=/run/carlos:ro
618# Every other tree a carlos-uid unit writes on the box is hidden the same
619# way (codex R9-2): the restore drills' cache under /var/cache, and
620# /var/lib as a class. An instance needs nothing from either.
621TemporaryFileSystem=/var/cache:ro
622TemporaryFileSystem=/var/lib:ro
623# Root-written credentials a carlos-GROUP process may read live here:
624# /etc/carlos/relay-masters is root:carlos 0640 for carlos-relay.service,
625# and an instance's Group=carlos would read it too (codex R10-1). An
626# instance gets its configuration through its launch file and needs
627# nothing from /etc/carlos.
628InaccessiblePaths=/etc/carlos
629BindPaths=/data/instances/%I /run/carlos/instances/%I
630ReadWritePaths=/data/instances/%I /run/carlos/instances/%I
631ProtectHome=yes
632PrivateTmp=yes
633PrivateDevices=yes
634# Its own /dev/shm; PrivateDevices= would bind the host's (R15).
635TemporaryFileSystem=/dev/shm:mode=1777,nosuid,nodev
636# /sys/fs/cgroup read-only: the edge's delegated subtree is carlos-owned,
637# and its cgroup.kill / cgroup.freeze would otherwise be one write away
638# for a same-uid tenant (codex R9-1).
639ProtectControlGroups=yes
640ProtectProc=invisible
641ProcSubset=pid
642PrivatePIDs=yes
643RestrictNamespaces=yes
644NoNewPrivileges=yes
645CapabilityBoundingSet=
646RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
647IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128
648ProtectKernelTunables=yes
649ProtectKernelModules=yes
650ProtectKernelLogs=yes
651ProtectClock=yes
652ProtectHostname=yes
653RemoveIPC=yes
654LockPersonality=yes
655RestrictRealtime=yes
656# No RestrictSUIDSGID=: it refuses openat2 outright (R15).
657MemoryDenyWriteExecute=yes
658SystemCallArchitectures=native
659
660# --- per-service resources; the tier lives on the host slice (§4.3) ---
661TasksMax=256
662# The kernel picks a tenant before it picks anything else in carlos.slice.
663OOMScoreAdjust=500
664# One OOM kill takes every process of THIS service (systemd sets
665# memory.oom.group on the service cgroup for OOMPolicy=kill). The sidecar
666# is a sibling service with its own family rule; §4.3 says why the two are
667# not one family any more.
668OOMPolicy=kill
669```
670
671Lines that are load-bearing and why:
672
673- **`TemporaryFileSystem=/data:ro` + `BindPaths=/data/instances/%I`** is
674 the isolation. The tmpfs is empty; the bind reveals one directory. A
675 sibling's database is not permission-denied, it does not exist. The
676 same for `/run/carlos`: a sibling's listening socket and control socket
677 do not exist, which closes the socket-file impersonation in §1.2. The
678 bound directory is inside the `nosymfollow` mount (§3.2), so a symlink
679 the instance plants there is dead for every reader.
680- **The bind sources must exist before the unit starts**, or systemd
681 fails at namespace setup before any `ExecStartPre=` runs (titogo
682 verified this; it is why `titogo-instance-dir@.service` exists). Here
683 the agent is the supervisor and creates both directories before it
684 asks for a start (§6.1), so no helper unit is needed.
685- **`PrivatePIDs=yes` + `ProtectProc=invisible`.** Every instance is uid
686 `carlos`, and `ProtectProc=invisible` does not hide same-uid peers
687 (hidepid's test is `ptrace_may_access`, which same-uid passes). With its
688 own pid namespace an instance's `/proc` lists only itself: no sibling
689 pid to traverse into `/proc/<pid>/root/`, no sibling to signal, nothing
690 for a same-uid ptrace to attach to. `PrivatePIDs=` needs systemd 257
691 (Debian 13, which `infra/modules/region-host/variables.tf:134` pins)
692 and is **ignored, not refused**, on a kernel that cannot provide it, so
693 §8.5 gates on observed behaviour, not on the directive (R1-15).
694- **`RestrictNamespaces=yes`**: without it an unprivileged user namespace
695 could bind-mount over a fresh mountpoint and re-reveal what the tmpfs
696 hid.
697- **`IPAddressDeny=`** for IMDS. The nft rule in pinfra allows uid
698 `carlos` because the agent needs IMDS; this line is what stops an
699 instance, also uid `carlos`, from fetching the box's role credentials.
700 It is a cgroup BPF **packet** filter: a denied datagram send fails with
701 `EPERM`, a denied TCP `connect()` loses its SYN and times out. §8.5's
702 probe is written for that (R3-4).
703- **`BindsTo=carlos-edge.service`** keeps today's "instances die with the
704 agent": when the edge stops, so do they, and nothing restarts them but
705 the agent's next wake. §6.7 adds the startup sweep for the case where
706 the coupling did not fire.
707- **`StandardOutput=file:` to an `AF_UNIX` socket** is documented
708 systemd behaviour: a stream connection is made and used as stdout. It
709 is opened by PID 1 outside the namespace, so the path is the real one,
710 the path is in `/run/carlos/logs`, which no tenant mount contains, and
711 that directory is on a `nosymfollow` mount, so a same-uid process
712 outside any namespace cannot turn it into a symlink either.
713- **`ProtectControlGroups=yes`** (codex R9-1). `ProtectSystem=strict`
714 leaves kernel API filesystems alone and `ProtectKernelTunables=` leaves
715 `/sys/fs/cgroup` writable; the edge unit's `Delegate=` subtree,
716 including the `supervise/` cgroup the agent moves itself into
717 (`internal/cgroup/cgroup.go:172`), is created by `carlos` and its
718 `cgroup.kill` and `cgroup.freeze` are owner-writable. A same-uid
719 tenant with a writable cgroupfs could kill or freeze the agent with
720 no pid and no socket. Read-only cgroupfs closes that; an instance has
721 no business in cgroupfs at all.
722- **`TemporaryFileSystem=/var/cache:ro` and `/var/lib:ro`** (codex
723 R9-2). The isolation property is "no same-uid path on the box but its
724 own", and two trees outside `/data` and `/run/carlos` are written by
725 `carlos`-uid units: `carlos-hibernate-verify.service` and
726 `carlos-restore-verify.service` set `CacheDirectory=carlos-verify` and
727 restore every host's database into `/var/cache/carlos-verify` during a
728 drill (`internal/ops/ops.go:274`, `internal/lease/check.go:79`), and
729 `/var/lib` holds unit tenants' state. Both are hidden. §9 carries the
730 audit rule that found them, so the next such unit is caught at review.
731- **`InaccessiblePaths=/etc/carlos`** (codex R10-1). `ProtectSystem=strict`
732 makes `/etc` read-only, not unreadable. `carlos-relay.service` runs as
733 `carlos:carlos` and reads `/etc/carlos/relay-masters`
734 (`internal/relay/masters.go:101`), which the relay design stages as
735 `root:carlos 0640` (`2026-09-07-relay-stun-turn-design.md:325`): a
736 file every `Group=carlos` process can read, and the secret every
737 app's relay key is derived from (`internal/relay/server.go:665`).
738 The instance's group is `carlos` for the socket contract (§3.1), so
739 the directory is hidden outright. `host.env` and `apps/*.env` there
740 are root-0600 and were already unreadable; hiding the directory is
741 what makes the next `0640` file safe by default.
742- **`/opt/carlos`** stays readable through `ProtectSystem=strict`; the
743 trampoline and the live symlink are all the instance needs from it.
744
745### 4.2 `carlos-instance-seed@.service` and `carlos-instance-sidecar@.service`
746
747Identical `[Unit]` coupling, `[Service]` namespace, hardening and
748resource blocks, and the same `Slice=carlos-instances-%i.slice`.
749Differences:
750
751| | seed | sidecar |
752|---|---|---|
753| `Type=` | `oneshot` with `RemainAfterExit=yes`: after `ExecStart` exits the unit stays `active (exited)` or `failed`, loaded, with its `InvocationID`, `Result` and `ExecMainStatus` retained until the control service stops it, so the seed's result can never be garbage-collected before it is read (codex R2-4, R5 disposition) | `simple` |
754| `ExecStart=` | `/opt/carlos/carlos instance-exec seed %I` | `/opt/carlos/carlos instance-exec sidecar %I` |
755| `ExecStopPost=` | `/opt/carlos/carlos instance-exit seed %I` | `/opt/carlos/carlos instance-exit sidecar %I` |
756| `StandardOutput=` | `file:/run/carlos/logs/%I.seed.sock` | `file:/run/carlos/logs/%I.sidecar.sock` |
757| timeouts | `TimeoutStartSec=120` (today's seed budget, `backend_exec.go:368-408, 466`; a oneshot has no start timeout by default, R1-5), `TimeoutStopSec=20` | `TimeoutStopSec=20` |
758
759A Go test over the embedded FS asserts that the three files' namespace,
760hardening and coupling lines are byte-identical (§10). Three files rather
761than one because seed and instance never run together but sidecar and
762instance do, and a template's instance is the host, not a role.
763
764### 4.3 Slices and the memory walls (codex R1-8, R1-10, R2-11, R3-3)
765
766```ini
767# carlos.slice — the outer wall of the 2026-08-28 design, as a slice
768[Unit]
769Description=carlos (edge, activator and every exec-backed instance)
770[Slice]
771MemoryHigh=70%
772MemoryMax=80%
773```
774
775```ini
776# carlos-instances.slice — the parent of every per-host slice
777[Unit]
778Description=carlos exec-backed instances
779[Slice]
780# No cap of its own: the outer wall is carlos.slice, the inner walls are
781# the per-host slices. This level exists so the sampler has one root to
782# scan and so an operator can `systemctl status carlos-instances.slice`.
783```
784
785`carlos-edge.service` gains `Slice=carlos.slice` and **drops** its own
786`MemoryHigh=70% MemoryMax=80%`: the wall moves up one level and now
787bounds exactly what it bounded before, the agent plus every awake
788instance, so borrowing between them and the OOM candidate set (a tenant
789at `+500` is always picked before the agent) are unchanged. The
7902026-08-28 reasoning, including the measured 1.2 GB leaf that was mostly
791reclaimable page cache, carries over without new numbers.
792`OOMPolicy=continue` stays on the edge unit.
793
794The per-host slice `carlos-instances-<esc>.slice` is created on demand
795by the first unit that names it. Before every start the control service
796sets three properties on it from the route's tier (§5.3, §6.6):
797`MemoryMax`, `MemoryHigh` (90% of max) and **`MemorySwapMax` equal to
798`MemoryMax`**, the last because with zram on the box a cgroup at
799`memory.max` otherwise spills into compressed swap and carries on, which
800`internal/cgroup/cgroup.go:313-318` learned live on the flagship
801(R2-11). The values are `512M`/`460M`/`512M` by default and
802`1G`/`920M`/`1G` for an app whose manifest declares
803`capabilities.children`, exactly what `internal/cgroup` writes today.
804Seed, instance and sidecar all charge that one cgroup, which is what
805today's shared `carlos-exec@<host>` gives them
806(`backend_exec.go:149-155, 373-375, 890-895`).
807
808**The family OOM rule changes, deliberately.** Today `memory.oom.group=1`
809on the shared `carlos-exec@<host>` cgroup means an OOM kill in the
810sidecar takes the instance with it. systemd writes `memory.oom.group`
811itself, for slices too, from the unit's cgroup context (default `0`,
812`cgroup_context_apply` in `src/core/cgroup.c`), so a value written
813behind its back is overwritten on the next property application, which
814`/start` for the sidecar performs on a slice whose instance is already
815running (codex R3-3). There is no supported way to keep it set on the
816slice. So the rule becomes: **each service is its own OOM family**
817(`OOMPolicy=kill`, which sets the attribute on the service's cgroup), and
818a sidecar killed by the OOM killer is a sidecar that died, which the
819sweeper already restarts with backoff (`activator.go:1165-1200`) while
820the instance keeps serving. An instance killed by the OOM killer still
821stands its entry down and co-stops its sidecar as today. The instance
822surviving its sidecar's OOM is the better of the two behaviours; the
823spec records it as a difference rather than claiming parity.
824
825The cgroup path is
826`/sys/fs/cgroup/carlos.slice/carlos-instances.slice/carlos-instances-<esc>.slice/`
827(R1-9).
828
829---
830
831## 5. The root channel: `carlos-instance-control`
832
833### 5.1 Why a channel at all
834
835The agent is uid `carlos`. `systemctl start` on a system unit needs
836either root or a polkit grant; read-only `systemctl show` / `is-active` /
837`list-units` do not. The existing root relays (`carlos-stage.path`,
838`carlos-restart.path`) are asynchronous sentinel files, fine for a
839minute-scale adoption pass and wrong for a wake that must answer a
840waiting HTTP request.
841
842### 5.2 Units
843
844```ini
845# carlos-instance-control.socket
846[Unit]
847Description=carlos instance control socket (agent → root)
848[Socket]
849ListenStream=/run/carlos-control/control.sock
850SocketUser=carlos
851SocketGroup=carlos
852SocketMode=0600
853Accept=no
854[Install]
855WantedBy=sockets.target
856```
857
858```ini
859# carlos-instance-control.service
860[Unit]
861Description=carlos instance control (root: start/stop carlos-instance*@ units for the agent)
862Requires=carlos-instance-control.socket data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
863After=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
864
865[Service]
866ExecStart=/opt/carlos/carlos instance-control
867# Root deliberately: the whole job is systemctl start/stop, and the
868# caller is the unprivileged agent. Same rationale as carlos-stage.service.
869ProtectSystem=strict
870ProtectHome=yes
871PrivateTmp=yes
872PrivateDevices=yes
873ProtectKernelTunables=yes
874ProtectKernelModules=yes
875ProtectKernelLogs=yes
876RestrictAddressFamilies=AF_UNIX
877MemoryDenyWriteExecute=yes
878MemoryMax=64M
879```
880
881The socket lives in `/run/carlos-control`, `0750 root:carlos`: any
882`carlos` process can connect (search on the directory), none can create,
883rename or replace anything in it. The unit is socket-activated and runs
884only while there is work. It is hidden from every instance's namespace
885by `InaccessiblePaths=` in the shared block (§4.2): a connect from
886inside one is impossible, and the peer check refuses everything else.
887`/run/carlos` inside an instance is an empty tmpfs with the instance's
888own directory bound in.
889
890### 5.3 Protocol
891
892HTTP/1.1 over the unix socket, the shape `edge.ListenControl` already
893uses for the per-instance control socket. Before reading a request line
894the service authenticates the **connection** (§5.4); a connection that
895fails is closed without a response. Five verbs, no shell, no argument
896passes through to `systemctl` except a unit name the service built
897itself:
898
899| Verb | What it runs | Returns | Agent deadline |
900|---|---|---|---|
901| `POST /start?host=H&role=run\|sidecar&memory_max=N` | `systemctl set-property --runtime carlos-instances-<esc>.slice MemoryMax=N MemoryHigh=0.9N MemorySwapMax=N`; `systemctl start <unit>` (blocking: the start job completes) | 200 with `{invocation_id}` from `systemctl show -p InvocationID <unit>`, read immediately after the start returns; or 502 with systemctl's stderr | 30s |
902| `POST /seed?host=H&memory_max=N` | the same `set-property`; `systemctl start` on `carlos-instance-seed@<esc>.service` (blocking: a oneshot's start job completes when `ExecStart` exits, or fails at `TimeoutStartSec`); if the InvocationID it left is the SAME one that was running before this start, an earlier request's stop never ran, so the stale unit is stopped and started again; the unit is now `active (exited)` or `failed` and **stays loaded** (`RemainAfterExit=yes`, §4.2); `systemctl show -p InvocationID,Result,ExecMainStatus,ExecMainCode` reads the retained result; `systemctl stop` (instant for an exited oneshot; clears it), on a context detached from the request so a disconnected client cannot leave it running; then wait for the cgroup to be empty (§6.5's rule, applied by the service as root) | 200 with `{invocation_id, result, status, code}`, always complete | 150s: 120s seed budget plus `TimeoutStopSec` and slack (R3-5) |
903| `POST /stop?host=H&role=run\|seed\|sidecar` | `systemctl stop <unit>` (blocking; also cancels a pending start job) | 204 | 30s |
904| `POST /reset?host=H&role=…` | `systemctl reset-failed <unit>`, tolerating "not loaded" | 204 | 30s |
905| `POST /sweep` | `systemctl list-units --all` and `list-jobs` over the three templates' instances; `systemctl stop` each that is not `inactive` or has a job; wait for terminal state and empty cgroup on each | 200 with the list of unit names swept, or 502 naming the unit that would not terminate | 60s |
906| `GET /ready` | nothing | 204 once the probe verdict (§8.5) is `ready`; 503 with the verdict otherwise | 5s |
907
908There is deliberately **no `reset-failed` inside `/start`**: `ResetFailedUnit`
909refuses a unit that is not loaded, and a template instance that has never
910started is not loaded, so the first wake of every host would fail (codex
911R2-3). `systemctl start` on a `failed` unit already clears the failed
912state; `/reset` exists for the sweeper's housekeeping only.
913
914Validation, in order: connection (§5.4); `host` must satisfy
915`provision.ValidHostShape` (`provision.go:262`); `role` must be one of
916the three literals; the memory value must parse as a `Limit` the agent
917already knows how to produce (`internal/cgroup`,
918`TierDefault`/`TierChildren`). Anything else is a 400 with no systemctl
919call. The unit name is
920`"carlos-instance" + suffix + "@" + escape(InstanceID(host)) + ".service"`
921and the slice name `"carlos-instances-" + escape(InstanceID(host)) + ".slice"`,
922built from the validated host by the same `InstanceID` and escape
923functions the agent used to create the directories (§3.1, §4), never
924from the request string (codex R5-3 disposition).
925
926What this grants the agent: start, stop, reset and tier control of units
927named `carlos-instance*@<host>` and their host slice. That is exactly
928what it has today over its own children. It grants nothing else, and a
929host that does not exist is a unit whose bind source does not exist,
930which fails at namespace setup and starts nothing.
931
932`systemctl set-property --runtime` on a slice or on an instance of an
933installed template that has never been started loads the unit first and
934writes a runtime drop-in the following `start` honours
935(`SetUnitProperties` in `src/core/dbus-manager.c`; confirmed by codex
936R1-1). The plan's first task exercises it on a dev box for a slice that
937does not yet exist.
938
939A `/start` or `/stop` that exceeds its deadline is answered by the agent
940with a `/stop` and reported as a failed wake or an unproven stop
941(§6.3); the deadlines exist because §6.7 puts one such call on a path
942that must never stall the activator, and the seed's is long because a
943seed is allowed to be slow (R3-5).
944
945### 5.4 Who may call it, and the ruling (codex R1-1, R3-1)
946
947The other way to let uid `carlos` start these units is polkit: a rules
948file granting `org.freedesktop.systemd1.manage-units` to `carlos` when
949the unit name matches `carlos-instance*@*.service`. Codex R1-1 confirmed
950that polkit can express the unit and verb restrictions, and also that it
951cannot constrain property values, that a uid-only grant is reachable by
952every tenant through the system bus, and that a wildcard on names also
953authorises transient units. Codex's advisement, concurring with the
954draft's: **the control service, provided it authenticates the caller and
955not the socket's uid.** Ruled, 2026-09-11: control service; polkit is not
956installed and not relied on.
957
958Revision 2 authenticated the caller with a bearer token in a
959`carlos`-readable file. Round 3 showed why that is not a boundary during
960the transition: a flat tenant is uid `carlos` in the host namespace and
961reads that file as easily as the agent does (R3-1). A secret the agent
962holds cannot distinguish the agent from an unisolated child of it. What
963can is **systemd's own record of which process is the agent**:
964
965- On every accepted connection the service reads `SO_PEERCRED` and
966 requires `uid == carlos` **and** `pid == MainPID` of
967 `carlos-edge.service` as `systemctl show -p MainPID` reports it at that
968 moment (root, one call, cached for one second). A flat tenant is a
969 child of that pid, never the pid itself; a nested tenant cannot reach
970 the socket at all.
971- The edge is the agent, so a request the edge *proxies* for a tenant
972 (a flat tenant pointing its own socket at `control.sock`, §3.2) would
973 pass that check. It does not get that far, because of two checks on
974 **every unix dial the edge and the agent make** (`edge.go:2000-2014`'s
975 `unixProxy`, the activator's health and control dials, §6.1):
976 1. **the reserved endpoint is refused before the dial**: a path under
977 `/run/carlos-control/` is never dialled as an upstream, so a
978 registry row pointing at `control.sock` is refused before
979 `connect()`. Nothing else is refused by path: a route whose
980 operator stamped `--socket /run/titogo/example.sock` keeps working
981 exactly as today (codex R5-1);
982 2. **the listener answers to the path that was dialled**: after
983 `connect()` and before a byte is sent, `getpeername()` on the
984 connected socket returns the address the *listener* bound, which is
985 its real path however it was reached; if that is not byte-equal to
986 the path the edge dialled, the connection is closed. A symlink at
987 `/run/carlos/<host>.sock` pointing anywhere, including a sibling's
988 socket or the control socket, therefore yields a mismatch.
989 Revision 4's rule, "refuse a peer whose uid is 0", is withdrawn: a
990 listener created by PID 1 for `carlos-app@.socket` reports root
991 credentials in `SO_PEERCRED` however the fd is later passed
992 (codex R4-1), so that rule would have refused every unit-backed app.
993 The two checks above do not look at credentials at all.
994
995A proxied request therefore never reaches the service, and the service
996never sees a connection from anything but the agent's own goroutines.
997No token: the file it lived in is gone, and with it the question of who
998can read it. Each check is one syscall or one cached `systemctl show`,
999each is testable with a real socket pair, and all of them hold on a box
1000that still runs flat tenants, which is the whole transition. They also
1001end, as a side effect, the flat-tenant socket impersonation §1.2 names,
1002which a symlink could otherwise still perform during this release.
1003
1004---
1005
1006## 6. The activator on units
1007
1008`internal/activator/activator.go` changes in exactly three places: the
1009sidecar restart in `sweepOnce` (§6.7, codex R2-6), the lease release
1010in `stop` (§6.3, codex R2-5), and the route the wake runs on (§6.8,
1011codex R8-1). `doWake` (`activator.go:772`), `Bounce` (`:946-956`) and
1012the `Proc`/`Rep` seams (`:194-207`) keep their contracts.
1013`Exited()` stays a non-blocking read of a flag (R1-4); everything that
1014talks to systemd runs in the proc's own goroutine or in the
1015sidecar-start goroutine. The rest of the change is inside `ExecBackend`,
1016which keeps its current `childProc` path for flat hosts for this one
1017release (§8.1) and adds the unit path for nested hosts.
1018
1019### 6.1 Preparing a role, and `Start`
1020
1021One helper, `prepare(rt, role)`, runs before **any** role starts, seed
1022included (R1-5, R2-12):
1023
10241. Ensure `InstanceDir` and `InstanceRunDir` exist (0700 and 2750 as in
1025 §3.1), and `/run/carlos/logs`.
10262. `EnsureAdminToken(InstanceDir/<host>.env)` for `run` **and** `seed`
1027 (a seed is the first thing a fresh instance runs and must see the
1028 token the instance will, `backend_exec.go:346-352`); `instanceEnv`
1029 for both, reading the two files from `InstanceDir` through
1030 `instancefs`, with the seed's removal of the control-channel variables
1031 as today. For `run` only: bind the control listener at
1032 `rt.Socket + ".control"` (unchanged, `edge.ListenControl`) and clear
1033 any stale `app.sock` (nothing is running: the unit is not active, or
1034 we would not be here). Remove any stale `exit.<role>.*.json`.
10353. Bind the role's log listener at `LogSocket(host, role)` (§6.4).
10364. Write `launch.<role>.json` (§6.2).
1037
1038`Start` is then: `prepare(rt, "run")`; `POST /start` with the route's
1039tier; `waitHealthyCtx(ctx, rt.Socket, spawnHealthTimeout)` (unchanged,
1040with the path and `getpeername` checks of §5.4 on its dial); the `0660` normalisation of
1041`app.sock` (unchanged); on failure `POST /stop`, wait for the proc's
1042`done`, and clean up, exactly as the current failure path stops the
1043child; return a `unitProc` carrying the invocation id.
1044
1045### 6.2 The trampoline and `launch.<role>.json`
1046
1047```json
1048{
1049 "schema": 1,
1050 "host": "<host>",
1051 "argv": ["/opt/carlos/live/<host>", "--socket", "/run/carlos/instances/<iid>/app.sock", "--db", "/data/instances/<iid>/<host>.db"],
1052 "env": ["PATH=...", "CARLOS_ADMIN_TOKEN=...", "CARLOS_CONTROL_SOCKET=...", "..."]
1053}
1054```
1055
1056`carlos instance-exec <role> <iid>` runs as `ExecStart`, inside the
1057namespace, as `carlos`. It reads `/data/instances/<iid>/launch.<role>.json`
1058through `instancefs`, refuses a schema it does not know, refuses an
1059`argv[0]` that is not directly under `/opt/carlos/live/` or that does
1060not equal `/opt/carlos/live/` plus the file's own `host` (defence in
1061depth: the file is agent-written; the prefix check is the one that
1062holds even against a rewritten file, since only the platform's live
1063links live there), and calls
1064`syscall.Exec(argv[0], argv, env)`. It never parses env syntax: the
1065agent's `childEnv` (`backend_exec.go:802-814`) composes the list exactly
1066as it does today, including the agent-owned removal of tenant-supplied
1067`CARLOS_CONTROL_SOCKET` / `CARLOS_PURGE` (`backend_exec.go:636-660`), and
1068the trampoline passes it through.
1069
1070**One entry per key, last value wins** (R1-16). `os/exec` deduplicates
1071`cmd.Env` in favour of the last occurrence; `syscall.Exec` passes
1072duplicates through and a Go child keeps the first. `childEnv` therefore
1073gains the deduplication itself (a map keyed on the name, insertion order
1074preserved), and the test asserts the effective value *inside* an
1075executed stub, not the JSON array.
1076
1077What the current `exec.Command` path does that the trampoline must
1078reproduce, and how: working directory (none is set today, and none is
1079set; the app starts in `/`), inherited fds (none beyond stdio today;
1080`syscall.Exec` inherits whatever the trampoline holds, which is stdio
1081only), `WaitDelay` (replaced by `TimeoutStopSec`, §6.4), `Pdeathsig`
1082(replaced by `BindsTo=` plus the startup sweep, §6.7),
1083`oom_score_adj` (`OOMScoreAdjust=`), the cgroup placement (`Slice=`).
1084
1085Why not `EnvironmentFile=`: systemd's parser and the agent's
1086`parseEnvBytes` differ on quoting and continuation, and the agent-owned
1087variables would then have to be written into a file the tenant's
1088`<host>.env` overlay must not win over. One composer, one format, one
1089test.
1090
1091The launch file is written fresh before every start, `O_EXCL` temp plus
1092rename (§3.2), and holds the same material the child could already read
1093from `/proc/self/environ`. It is inside the instance's own directory,
1094visible to that instance and the agent, nobody else. An instance can
1095rewrite its own launch file while it runs; the next start reads it again
1096through `instancefs`, refuses any `argv[0]` but its own live link, and
1097executes only what a tenant could execute anyway: its own binary with an
1098argv and env it chose. That is not a privilege, and the agent never
1099reads the file (§3.3).
1100
1101Seed: `launch.seed.json` carries the argv from `rt.Seed`
1102(`backend_exec.go:332-338`) with the DB path substituted as today; `POST /seed`
1103blocks up to its 150s deadline; the seed's exit status arrives in the
1104response or in its exit record (§6.5); a non-zero status, a timeout or a
1105cancelled `ctx` runs `POST /stop?role=seed`, waits for the seed proc's
1106`done`, and only then runs the same cleanup as today
1107(`backend_exec.go:438`), so the lease is never released
1108(`activator.go:824-828`) while a seed can still write (R1-5).
1109
1110Sidecar: `launch.sidecar.json` carries `[live/<host>, "sidecar", "run"]`
1111and the sidecar env from `<host>-sidecar.env` only, as today
1112(`backend_exec.go:885-911`); `POST /start?role=sidecar`.
1113
1114### 6.3 `unitProc`, and a stop that could not be proven (codex R2-5)
1115
1116```go
1117type unitProc struct {
1118 host, role string
1119 invocation string // from the start response
1120 ctl *edge.ControlListener // run only; as childProc.ctl today
1121 logs *logListener // §6.4
1122 exited atomic.Bool
1123 done chan struct{}
1124}
1125func (p *unitProc) Stop(ctx context.Context) error // POST /stop, then wait for done or ctx
1126func (p *unitProc) Exited() bool // p.exited.Load()
1127```
1128
1129The control listener's close ordering (`backend_exec.go:992-1009`: signal
1130first, close the listener only after the process is gone) is preserved:
1131`Stop` posts the stop, waits for `done`, then closes `ctl` and `logs`.
1132`done` closes only on proven termination (§6.5), never on log EOF.
1133
1134If `ctx` expires before `done` closes, `Stop` returns `ErrStillRunning`
1135without closing the listeners. Today `stop()` then calls `rep.Close`
1136regardless (`activator.go:1014-1033`), which releases the lease over a
1137process that may still be writing. **The second `activator.go` change**:
1138on `ErrStillRunning`, `stop()` does not call `rep.Close`; it marks the
1139entry's outcome uncertain (the field exists, `outcome.uncertain`), leaves
1140the entry in `stopping`, and `sweepOnce` retries `proc.Stop` on its
1141cadence until `done` closes, after which the normal `rep.Close` runs.
1142The lease's TTL bounds the worst case, as it does today when an agent
1143dies mid-stop; what changes is that the agent never *voluntarily*
1144releases over an unproven termination.
1145
1146### 6.4 Logs
1147
1148Today `teeStd` (`backend_exec.go:123-137`) pipes the child's stdout and
1149stderr through the agent: straight to the agent's own stdout (journald)
1150and, with `Logs` set, line-wise into `Logs.App` for `carlos logs`, capped
1151at `execLogLineMax`, with `WaitDelay` bounding a grandchild that holds
1152the pipe.
1153
1154Each role's unit connects its stdout and stderr to that role's socket in
1155`/run/carlos/logs` (`StandardOutput=file:` on an `AF_UNIX` path; R1-2,
1156R1-6). The agent's `logListener` for a role accepts one connection per
1157start (the unit connects exactly once, at exec; a second connection
1158during the same invocation is closed and logged), and feeds it to the
1159same `applog.NewLineWriter` and the same `io.MultiWriter` to the agent's
1160stdout. Same bytes, same cap, same sink, same `carlos logs`, and the
1161instance and its sidecar no longer contend for one connection. The
1162listener is bound in `prepare` before the start request, so PID 1's
1163connect cannot race it. A flat tenant, during the transition, can
1164connect to a log socket (same uid, host namespace) and write lines into
1165another host's log stream; that is a nuisance the transition accepts and
1166the next release ends, and it cannot make anything *read* wrong, because
1167nothing reads the log stream as an instruction.
1168
1169Grandchildren: a process the app forked that outlives it keeps the
1170socket open, as it keeps the pipe open today. `KillMode=mixed` plus
1171`TimeoutStopSec=20` kills it on stop; on a crash the unit's cgroup empties
1172when systemd reaps it. EOF on the log connection ends **log collection
1173for that invocation**; it does not mean the process is gone (R1-4).
1174
1175### 6.5 Exit observation (codex R1-4, R2-4, R2-5, R3-2, R3-7)
1176
1177`childProc` learns of exit from `cmd.Wait()` (`backend_exec.go:722-736`),
1178which is process death. A unit proc needs the same certainty, attributed
1179to the right invocation, and it must not depend on systemd keeping an
1180inactive unit loaded: systemd garbage-collects an unreferenced inactive
1181unit and its `ExecMainStatus` with it, so a `show` after a quick clean
1182exit can find nothing to show (R2-4). Revision 3 tried to write the
1183record from `ExecStopPost=+` into a root-owned directory; the `+` prefix
1184drops `User=` and the `Private*`/`Protect*` settings but keeps the
1185explicit `TemporaryFileSystem=`/`BindPaths=` mounts (`apply_mount_namespace`
1186in `src/core/exec-invoke.c`), so that command could not see the
1187directory (R3-2). So the record is **advisory, and truth is elsewhere**:
1188
1189**`carlos instance-exit <role> <host>`** runs as `ExecStopPost=` inside
1190the namespace as `carlos`, after the invocation's processes have been
1191signalled and reaped by systemd's stop sequence, with `$INVOCATION_ID`,
1192`$SERVICE_RESULT`, `$EXIT_CODE` and `$EXIT_STATUS` in its environment.
1193It writes `/run/carlos/instances/<host>/exit.<role>.<INVOCATION_ID>.json`
1194(`O_EXCL`) with those four values. The directory is the instance's own,
1195so the instance could have written that file itself; the record is
1196therefore the tenant's claim about its own exit and is used for one
1197thing only: the wording of the member-facing event line. It never
1198decides whether the process is gone, and a record for a running unit is
1199ignored (§3.3). The agent, as `carlos`, removes it once consumed, and
1200`prepare` removes stale ones (R3-7).
1201
1202Each `unitProc` runs one goroutine that establishes termination from
1203two facts the tenant cannot forge:
1204
12051. **systemd's unit state**: `systemctl show -p ActiveState,SubState,Result,InvocationID <unit>`
1206 (read-only, unprivileged), polled every 2s and triggered early by
1207 inotify on the run directory (the record's arrival) and on the unit's
1208 `cgroup.events` file. Terminal is `inactive` or `failed` with the
1209 proc's own `InvocationID`, **or** `inactive` with an empty
1210 `InvocationID` (the unit was already unloaded; the proc's invocation
1211 is gone).
12122. **cgroup emptiness**: the unit's cgroup directory under the host
1213 slice is absent, or its `cgroup.events` says `populated 0`. A service
1214 enters `failed` after its final `SIGKILL` timeout **with processes
1215 still present** if one is unkillable (`src/core/service.c:4020-4022`),
1216 and the lease must not be released over a writer (R2-5).
1217
1218Only when both hold does the goroutine emit the event line with today's
1219wording (`instance exited: ...` with the status or signal from the
1220record if one exists for this invocation, or `instance exited (exit 0)`,
1221or `instance exited (status unknown: no exit record)`), set `exited`,
1222and close `done`. A populated cgroup after a terminal state is logged
1223every 2s as `instance <host>: processes remain after SIGKILL; not
1224releasing`, and `done` stays open (§6.3); that is a box-level incident,
1225not a lease release.
1226
1227An `InvocationID` that is neither the proc's own nor empty means someone
1228restarted the unit outside the activator, which `Restart=no` does not
1229forbid. The goroutine logs `unit restarted outside the activator` and
1230**`POST /stop`s the unit itself**, then keeps waiting: `done` closes
1231only when the unit is terminal and its cgroup is empty, whichever
1232invocation the processes belonged to (codex R2-5 disposition). The
1233agent owns these units and any other invocation is an anomaly to end,
1234not to adopt; the invocation id decides the event line's wording and
1235nothing about whether a writer is gone.
1236
1237One exception, after the activator itself has asked for the stop
1238(`Stop` was called, or a failed health check stopped a fresh unit): a
1239unit that then reports an invocation that is not the proc's own, or
1240none at all while not terminal, has been replaced. systemd starts a new
1241invocation only once the previous stop job completed, which is its own
1242termination proof for the old one, so the goroutine emits `instance
1243exited (status unknown: replaced after stop)` and closes `done` without
1244the cgroup gate (the cgroup now belongs to the new invocation and would
1245never read empty). The goroutine does not stop the replacement: a stop
1246it did not request is not its anomaly to end, and a discarded proc
1247must never be able to stop a later, legitimate wake (plan 3, task 4
1248review).
1249
1250**The seed's outcome comes from systemd, always.** The seed unit
1251carries `RemainAfterExit=yes` (§4.2), so it stays loaded, with its
1252`Result` and `ExecMainStatus`, until the control service reads them and
1253stops it (§5.3). `/seed` therefore always returns a complete result and
1254the seed's exit record is, like every other record, wording only.
1255
1256`sweepOnce`'s existing dead-child check (`activator.go:1105-1122`) sees a
1257flag, never a blocking call. `Restart=no` in the template is what makes
1258this sound: systemd never restarts the unit behind the activator's back;
1259a failed unit stays `failed` until the agent's next `start`, which clears
1260it.
1261
1262### 6.6 cgroups, tiers and the sampler (codex R1-8, R1-9, R2-13)
1263
1264`internal/cgroup` today: `Manager.Start` (`cgroup.go:242-285`) clones the
1265child into `carlos-exec@<host>` under the edge unit's delegated subtree,
1266writes `memory.max`/`memory.high`/`memory.swap.max` from the tier and
1267`memory.oom.group=1`, and the sampler reads the subtree as a scan root
1268(exec-child accounting design §4). `CleanupHost` (`:398-403`) removes the
1269directory.
1270
1271Under units, systemd owns the cgroup. So:
1272
1273- **Tier** travels in `POST /start` / `POST /seed` and lands on the host
1274 slice as the three properties of §4.3. The `Tier func(registry.Route)
1275 cgroup.Limit` seam and its box wiring (`stagedManifestOfLiveVersion`)
1276 are unchanged; only the consumer changes. The agent logs
1277 `instance: <host>: memory.max=…` on every start as it does today. Seed,
1278 instance and sidecar share the one cap.
1279- **Per-host sampling** gains
1280 `/sys/fs/cgroup/carlos.slice/carlos-instances.slice/` as a scan root
1281 and reads each `carlos-instances-<esc>.slice/memory.current` (the
1282 slice's charge is the family's total, so no per-role summation is
1283 needed). It **publishes under the existing key**
1284 `activator.ExecName(host)` (`carlos-exec@<host>`), because that is the
1285 key `internal/console/metricsview.go:728-762` recognises and everything
1286 else is discarded at the app boundary there. Flat hosts, for this one
1287 release, are still sampled from the delegated subtree as today; the
1288 two roots never both hold a given host.
1289- **The edge's own reading.** Platform-unit discovery scans
1290 `/sys/fs/cgroup/system.slice` (`internal/host/metrics_probes.go:49`),
1291 where `carlos-edge.service` no longer lives once it moves into
1292 `carlos.slice`, and the accounting design defines the edge's reading
1293 as the hierarchical total including its children. So discovery adds
1294 `/sys/fs/cgroup/carlos.slice` as a root for the edge unit, and the
1295 value published under `carlos-edge.service` becomes **`carlos.slice`'s
1296 `memory.current`**: the agent plus every instance, which is what the
1297 key has always meant (R2-13). The edge unit's own leaf is published
1298 additionally under `carlos-edge.service/self` for the box view; the
1299 platform graph is continuous across the roll.
1300- **`Manager.Start`, `CleanupHost`, the clone3 path and the delegation
1301 dance** stay in use for flat hosts during this release and are deleted
1302 with the flat spawn path in the next (§8.6). **`Delegate=` on the edge
1303 unit** goes with them.
1304
1305### 6.7 Agent start and restart; the sidecar restart (codex R1-7, R2-6)
1306
1307Today an agent restart kills every child (`Pdeathsig`, the cgroup tree
1308dies with the unit). `BindsTo=carlos-edge.service` reproduces the stop
1309half of that for a `systemctl restart carlos-edge` and does not propagate
1310the start. For a crash, or a stop that did not propagate, the agent on
1311start, before anything else:
1312
13131. `POST /sweep` (§5.3). The control service, as root, enumerates every
1314 `carlos-instance@*`, `carlos-instance-seed@*` and
1315 `carlos-instance-sidecar@*` unit and every pending or running job on
1316 them, `systemctl stop`s each (which also cancels a queued start),
1317 waits for each to reach a terminal state **and** for its cgroup to be
1318 empty (§6.5's rule), and returns the list. The sweep is keyed on the
1319 unit names systemd reports, not on hosts, so it covers a unit whose
1320 identifier is hashed and whose registry row is gone: the agent could
1321 not name such a unit through `/stop`, which takes a host (codex
1322 R7-1).
13232. The agent removes stale exit records under every run directory.
1324
1325Every instance is therefore asleep, and no job is queued, when the
1326activator's state machine begins, which is the invariant the activator
1327holds today. Adopting a running unit instead (re-acquiring its lease,
1328re-attaching replication) is a later improvement with its own review; it
1329is not needed for correctness.
1330
1331**The first `activator.go` change.** `sweepOnce` restarts a dead
1332sidecar by calling `StartSidecar` under `a.mu`
1333(`activator.go:1165-1200`), justified there by "an exec is quick". A
1334unit start is an HTTP call and a `systemctl start`, and a stall there
1335would hold every other host's wake and stop. So the sidecar restart
1336becomes: under `a.mu`, mark `e.sidecarStarting = true`, capture the
1337entry's wake generation, and schedule the backoff exactly as now; then
1338in a goroutine, `StartSidecar` with its 30s deadline; then re-take
1339`a.mu`, and if the entry is still awake with the same generation,
1340install the proc as today, otherwise `Stop` it. The lock still makes a
1341restart impossible to race with a stop, because the install step checks
1342the state under the same lock the stop flips it under, and a stop that
1343arrives during the start finds `sidecarStarting` and waits for the
1344install step to see it. One test drives a stalled sidecar start while
1345another host wakes and stops.
1346
1347### 6.8 The wake runs on the committed row, not on a copy (codex R8-1)
1348
1349The edge hands the activator the route it proxied for, and the
1350activator copies it into the entry (`activator.go:481`); `doWake` then
1351restores, seeds, spawns and replicates on that copy (`:806, :870`). The
1352copy can be older than the registry: for the interval between a layout
1353pass's row CAS and the edge's reload, the edge still holds the flat
1354paths, and a request in that interval would carry a flat route to a
1355host the registry already calls `nested`. On that copy the exec backend
1356would classify the host `flat`, restore a second database at the
1357vacated flat path, and run `childProc`, and `RetireExcept`
1358(`activator.go:1260`) would keep the entry because its host name did
1359not change.
1360
1361**The third `activator.go` change**: at the top of `doWake`, before
1362`Acquire`, the activator **re-reads the row** (`reg.Get(host)`, one
1363indexed read) and uses that row for the whole wake, restore through
1364replication, storing it back into the entry. If the row's `DBPath` or
1365`Socket` differ from the copy the edge supplied, the wake logs
1366`route refreshed: <old> → <new>` and proceeds on the fresh one; if the
1367host has no row, or the row is `fenced` or `conflict`, the wake refuses
1368as §8.1 says. The exec backend's classification (§8.1) therefore always
1369sees the committed row. One test delivers a saved flat route for a
1370migrated host and asserts no flat artifact is created and no
1371`childProc` starts.
1372
1373### 6.9 What does not change
1374
1375`Acquire`, `Restore` (including the `.corrupt` quarantine and
1376`PurgeLocalDBArtifacts`, all keyed off `dbPath`), `lease.Replicate` (the
1377meta dir is derived from `dbPath`, `lease.go:286`, so it lands inside the
1378instance directory), `NextDue`, the schedule runner's token read, the
1379control socket's purge and schedule mounts, `Bounce`, the sweeper's
1380hibernation policy, residence moves (`movespass.go`, once it reads the
1381DB path from the row and honours the layout journal, §8.2 and §9),
1382`carlos restart` in all three scopes, `X-Carlos-Version`, the caps pass,
1383config-ack. All of these see a path change and nothing else.
1384
1385---
1386
1387## 7. Config delivery
1388
1389`SyncExec` (`internal/appconfig/syncexec.go`) writes a nested exec
1390route's bundle to `InstanceDir/<host>.config.env` instead of
1391`<DataDir>/<host>.config.env`, through the `O_EXCL`-temp-and-rename
1392helper of §3.2 rather than `os.WriteFile` on a predictable `.tmp` name
1393(`syncexec.go:921-926`; R1-3), and a flat route's exactly as today. Its
1394ownership rule ("sync owns `*.config.env` outright, never touches
1395`<host>.env` or `<host>-sidecar.env`", `syncexec.go:1-31`) is unchanged
1396and, for a nested host, trivially true per directory. `reapConfigEnv`
1397(`:977-1024`) walks `<DataDir>/instances/*/` for nested routes as well as
1398the flat `DataDir` for flat and addr routes, and the bounce-on-change
1399path is unchanged (the agent bounces the entry; the next wake reads the
1400new file).
1401
1402**Opted-in TCP-addr routes stay flat.** Their file is read by the app's
1403own supervisor at `/data/<host>.config.env` (titogo's
1404`EnvironmentFile=-/data/%i.config.env`), and moving it would break every
1405such drop-in. Isolated instances cannot see it any more, which is the
1406read #127 opens with. #127's request, root-rendered delivery into
1407`/etc/carlos/apps` for addr routes so that the file is not readable by
1408the agent either, is a separate, smaller change and stays open (§11).
1409
1410`reapConfigEnv`'s host-`x.config` ambiguity guard and `SyncExec`'s
1411collision refusal (`syncexec.go:389-395`) stay for the flat directory,
1412where they still apply, and the migration's ownership preflight (§8.2)
1413is derived from the same rule.
1414
1415---
1416
1417## 8. Two layouts, one release, and the verb that moves between them
1418
1419### 8.1 Why nothing moves on upgrade, and nothing is minted nested (codex R2-9, R3-6)
1420
1421`carlos system apply` is run by the **installed** binary: it stages the
1422candidate, restarts the edge, gates it, and on a failed gate restores the
1423previous files and restarts the edge again (`systemapply.go:360-372,
1424738-748`); `carlos-system-recover.service` runs `carlos.prev` when a
1425journal exists (`units/carlos-system-recover.service:17`); and a channel
1426rollback from the console (`system.go:466`) is an ordinary downgrade
1427install. In every one of those paths the code that runs after the
1428downgrade is the *old* binary, which knows one layout. So:
1429
1430**This release changes nothing about a host on upgrade, and mints
1431nothing nested.** `routeFor` (`reconcile.go:299-301`, whose
1432`DerivesPaths` predicate covers unflagged socket instances too,
1433`provision.go:280`) and `carlos add` (`cli.go:118-136`) keep the flat
1434defaults; a provisioning pass that runs during the candidate's gate
1435creates a flat row the old binary serves (R3-6). Unflagged socket routes,
1436whose supervisor owns their paths, keep flat paths in this release and
1437every later one. The only writer of a nested path into a row is the
1438layout pass, and it runs only on a recorded fleet intent.
1439
1440The exec backend classifies each activator-managed route on every
1441`Start`:
1442
1443| Row | Classification | Spawn path |
1444|---|---|---|
1445| `DBPath == DefaultDBPath` (flat), `Socket == DefaultSocket` (flat) | `flat` | today's `childProc`, unchanged, unisolated |
1446| `DBPath == NestedDBPath`, `Socket == NestedSocket` | `nested` | `unitProc` (§6) |
1447| a journal exists in `/data/layout/<host>.json` | `fenced` | refuses to wake; reason from the journal |
1448| another row on the box has the same `InstanceID` | `conflict` | refuses to wake; both hosts named (§3.1) |
1449| anything else | `foreign` | today's `childProc`; reported for the operator (§8.3) |
1450
1451A fleet rolled to this release behaves byte-for-byte as before until an
1452operator records an intent. A rollback before that is a rollback of
1453nothing. The classification is published per route in the status
1454snapshot (§8.4) and is what every other consumer keys on.
1455
1456### 8.2 `carlos system layout migrate` and `revert` (codex R1-11, R1-12, R2-1, R2-7, R2-8, R2-9, R3-1)
1457
1458The verb has the shape of `carlos restart` (#113): the console writes
1459`control/layout.json` (`{id, requested_at, mode: nested|flat, scope}`,
1460the newest entry of an append log like `restart-scope.json`), bumps the
1461serial, and every box in scope converges to it. Scope is the fleet, or
1462`--box <label>` for one box, which is how a fleet migrates one box at a
1463time with the console showing each box's rows turning `nested`. **The
1464recorded mode is the fleet's intent, and boxes converge to it on every
1465serial move**, not once: a row that becomes eligible later (a route
1466flagged for hibernation after the fleet went nested, a freshly
1467provisioned flat row) is migrated on the next pass when it is asleep. An
1468old box never reads the object; an old console answers 404.
1469
1470On a box, the layout pass runs in the agent (uid `carlos`, no root
1471needed) and, per host in scope, in this order:
1472
14731. **Eligibility is the activator's own predicate.** `Kind == "instance"`,
1474 `Hibernate`, `Backing == ""`, `Tunnel == ""`, `Socket != ""`
1475 (`activator.go:1379-1384`, `edge.go:630-638`). An unflagged instance
1476 route with a socket is an externally supervised process (titogo's
1477 router family is the live example); its files and rows are **not
1478 touched**, it is not isolated by this change, and status reports it
1479 `external`. Under mode `nested`, `flat` rows migrate; under mode
1480 `flat`, `nested` rows revert; `fenced` rows recover first; `foreign`
1481 rows are reported and skipped.
14822. **Quiesce.** The pass asks the activator to stop the host
1483 (`Bounce`-class stop with replication closed and the lease released,
1484 the same sequence `stop()` runs, including §6.3's refusal to release
1485 over an unproven stop) and to refuse wakes for it until the pass is
1486 done. Nothing is writing.
14873. **Plan, in memory, from the row and the host name only, and only
1488 for files named after the host.** Each source follows its existing
1489 contract (codex R4-3): the database is the row's `DBPath`, and
1490 `-wal`, `-shm`, the litestream shadow dir and `.corrupt` are derived
1491 from it (`backend_exec.go:273, 1041-1057`, `lease.go:286`); the three
1492 env files are `DataDir`-relative regardless of `DBPath`
1493 (`backend_exec.go:785-794, 865-868`). The socket is the row's
1494 `Socket`. And every source must be **a file named `<host>` plus a
1495 platform suffix, directly under `DataDir` or `SocketDir`**: the row's
1496 `DBPath` must be `<DataDir>/<host>.db` and its `Socket`
1497 `<SocketDir>/<host>.sock`, which is what `flat` means (§8.1). A row
1498 naming any other path is `foreign` (§8.3) and the pass does not touch
1499 it, because during the transition the registry is writable by every
1500 flat tenant (same uid, §1.3) and a row is therefore not a trustworthy
1501 instruction to move an arbitrary file: a tenant that could point its
1502 `DBPath` at `/data/console/console.db` and have the pass move it
1503 into the tenant's directory would have the platform's own database.
1504 Destinations are the §3.1 helpers. Nothing is read from the instance
1505 directory to build the plan (§3.3).
15064. **Ownership preflight** (R2-8). For every flat source name, compute
1507 every route on the box that could own that name under the flat
1508 rules: `<host>.config.env` is `host`'s sync-owned bundle **unless** a
1509 route `<host>.config` exists, in which case it is that route's
1510 instance env and is not moved with `host` (this is `SyncExec`'s own
1511 collision rule, `syncexec.go:389-395`). Reverting into a flat
1512 directory applies the same table in reverse: a name that would land
1513 on another route's file is a refusal, never an overwrite. A plan with
1514 any ambiguous name is refused for that host with the two owners
1515 named.
15165. **Destination check.** A destination `<host>.db` that already exists
1517 is a refusal (`two databases; refusing to choose`), never a choice.
1518 For the two env files, which an older binary running between a
1519 migration and a revert can have minted fresh at the flat path
1520 (`backend_exec.go:579`; R2-9 disposition), an existing destination is
1521 moved aside as `<name>.aside-<timestamp>` in the destination
1522 directory and the source takes its place: the source is the file the
1523 instance ran with for longer, the aside copy is kept, and the event
1524 line names both. `<host>.config.env` at the destination is simply
1525 replaced: sync re-renders it on its next pass regardless.
15266. **Journal.** Write `/data/layout/<host>.json` (`O_EXCL`): `{host,
1527 mode, from: {db, socket}, to: {db, socket}, artifacts: [{name,
1528 present}], phase: "files"}`. Presence is recorded so recovery knows
1529 an absent optional artifact (a sidecar env, a quarantine) was absent,
1530 not lost (R2-7). From this moment the host is `fenced`: `Start`,
1531 `Seed`, `Restore`, the residence-move pass, `SyncExec`'s writer and
1532 the litestream generator all refuse it, with the journal's contents
1533 as the reason. The `MoveCutting` path in `movespass.go:285-305`, which
1534 would otherwise treat an absent DB as "nothing to checkpoint" and
1535 complete a flip, is the case the fence exists for (R1-12).
15367. **Files.** `os.Rename` each artifact recorded present. Same
1537 filesystem, same owner, atomic per file, no root. Then journal
1538 `phase: "row"`.
15398. **Row.** `registry.SetInstancePaths(host, from.db, from.socket,
1540 to.db, to.socket)`: one transaction, compare-and-swap on both columns,
1541 a new sibling of `SetDBPath` (`registry.go:1057-1075`). Then journal
1542 `phase: "done"`.
15439. Remove the journal; log `layout: <host>: flat → nested` (or the
1544 reverse); **reload routing synchronously** (the agent owns the edge
1545 and calls its `Reload()`, `edge.go:449-451`, and the activator's
1546 `RetireExcept`/route refresh, rather than waiting for the 2s poll);
1547 only then release the host to the activator. A request that copied
1548 the old route before that reload is caught by §6.8 (codex R8-1).
1549
1550**Recovery is the journal, and it is exact, and it is checked** (R2-7,
1551R3-1, R4 disposition). On agent start and at the top of every layout
1552pass, for each journal, the check is against **both endpoints**, because
1553the row is at `from` before step 8 and at `to` after it, and it is
1554derived from **the journal's own direction** (codex R5 disposition): for
1555`mode: nested`, `from` must be the host-named flat paths of step 3 and
1556`to` the nested paths the §3.1 helpers derive; for `mode: flat` the two
1557are swapped, artifact sources included. The row must currently equal
1558either `from` or `to`.
1559A journal that fails any of those is refused (`layout: <host>: journal
1560does not match row; not acting`), the host stays `fenced`, and status
1561says so. **A journal that passes is completed to its own endpoint even
1562if the fleet's intent has since changed**: a transaction runs to its
1563recorded end, and the host is then converged to the current intent in
1564the same pass, before it is released to the activator, so a forged or
1565stale journal can at most add one round trip a host never runs
1566unisolated through. Otherwise:
1567
1568- `phase: files`: for each artifact recorded present: at destination →
1569 done; at source → rename; at neither → error, fence stays, status
1570 says which file. Artifacts recorded absent are skipped. Then continue
1571 from step 7's end.
1572- `phase: row`: if the row already equals `to` → the CAS committed
1573 before the crash; continue. If it equals `from` → CAS now. Anything
1574 else → error, fence stays, both values in status.
1575- `phase: done`: remove the journal.
1576
1577A second clean agent start finds `nested` rows and no journals and does
1578nothing (the classification table runs before any legacy-default test).
1579
1580**Revert** is the same pass under mode `flat`, the same journal, the
1581same fence, the same preflight in reverse. It exists so that a fleet
1582can be taken back to a layout the previous release serves **before**
1583that release is installed: the operator's rollback order is `layout
1584revert` (this release, verified `flat` in status) then `system rollback`.
1585**That order is enforced, not advised** (codex R5-2). A downgrade
1586removes this release's unit files: `installUnits` runs `systemctl
1587disable --now` on every unit the candidate lacks (`systemapply.go:522`),
1588so the bind mounts go with the binary, `/data/instances/<iid>/` becomes
1589an empty mountpoint directory, and the old backend, honouring the nested
1590`rt.DBPath` (`backend_exec.go:220-225`), would find no database and
1591restore a second copy into the uncovered directory, which the next
1592mount would hide. So:
1593
1594- **This release's `carlos system apply` refuses to install a candidate
1595 that predates the layout** (its version is below the release that
1596 carries the mount units) unless reversal is **complete**: **no row on
1597 the box, whatever its flags**, has a `DBPath` or `Socket` under
1598 `/data/instances` or `/run/carlos/instances`, no row is `fenced` or
1599 `conflict`, and no journal exists. The check is by path shape and not
1600 by activator eligibility, because a row can leave management while
1601 keeping nested paths: `SetHibernate(false)` (`registry.go:1565`),
1602 including when the reconciler applies a record's flag
1603 (`reconcile.go:828`), does not touch them (codex R7 disposition of
1604 R5-2). `layout_mode` is not consulted either, because it records
1605 intent, and a revert refused at quiescence or at the destination check
1606 leaves a nested row under a flat intent. The reason names the rows;
1607 the installer is this binary for every downgrade from here, because a
1608 downgrade is an install performed by the installed binary (§8.1).
1609- **Unflagging a nested row is refused.** `carlos route --hibernate=false`
1610 and the reconciler's flag application both refuse to clear `Hibernate`
1611 on a row whose paths are nested, with `layout revert first` as the
1612 reason (the reconciler logs it as it logs shape drift,
1613 `reconcile.go:873-881`), so a nested row is always activator-managed
1614 and the revert pass can always quiesce it. The classification table
1615 (§8.1) therefore never meets a nested row it cannot own.
1616- **The console's rollback door** (`system.go:466`) refuses a channel
1617 rollback to a pre-layout release while any box in the fleet reports
1618 any `nested`, `fenced` or `conflict` row, or no layout field at all,
1619 and names the boxes and rows.
1620- A failed-gate rollback of the upgrade *to* this release runs the old
1621 binary, but at that moment nothing is nested (§8.1). A failed-gate
1622 rollback of a later upgrade runs this binary, which knows the layout.
1623- **Recovery for a layout that was unmounted anyway** (an operator
1624 removing units by hand): at start the control service (root)
1625 inspects the underlying mountpoint directories in a private mount
1626 namespace (`unshare -m`, `umount` the bind there, list) and, if
1627 `/data/instances` or `/run/carlos/instances` holds anything under the
1628 mount, records the verdict `mountpoint held files while unmounted`
1629 and `GET /ready` refuses nested wakes until an operator has moved or
1630 removed them; the layout pass's destination check (step 5) then sees
1631 whatever was moved into the raw directory. Nothing is chosen
1632 silently; the databases are never two.
1633
1634The database therefore has one history in every path that is allowed
1635to happen, and the two paths that are not allowed are refused with the
1636revert named as the fix.
1637
1638### 8.3 `foreign` rows
1639
1640A row whose `DBPath` or `Socket` is neither the flat nor the nested
1641default was set by hand (`carlos add --db`; `carlos route` has no `--db`
1642flag, `cli.go:469-475`, so it was set at add time). The pass does not
1643move it, for the reason step 3 gives: while flat tenants share the
1644agent's uid, a row is not a trustworthy instruction to move an arbitrary
1645file, and the only files the pass will move are the ones named after
1646the host at the platform's own paths. A `foreign` row is reported with
1647both values, keeps running on today's flat path, and `carlos add` on
1648the box, re-adding the route with the defaults, is the repair: box-local
1649because the row was made box-locally, and because a remote verb that
1650moved operator-named files would be the primitive this section refuses
1651to build (R1-18, accepted as the residual). A console-provisioned row
1652(`reconcile.go:300-301`) is never `foreign`.
1653
1654### 8.4 Status, through the API (codex R1-18)
1655
1656`internal/status/snapshot.go` gains a per-route `layout` field with one
1657of `flat`, `nested`, `fenced` (with the journal's phase and reason),
1658`conflict` (with the other host), `foreign` (with both values),
1659`external`, and a box-level
1660`layout_mode` (`flat`, `nested`, or `none` before any intent). `carlos
1661status --layout` (beside `--instances`, under the same operator gate;
1662R14) and the console's instance page render them; a box that predates the field shows `unknown (box predates
1663layout reporting)`. It is the pre-roll inventory, the progress view
1664during a migration, the member-visible proof that an instance is
1665isolated (`nested`), and the reason a `fenced` host does not wake.
1666`docs/site/cli.md` names the new verbs and flags (the docsite gate fails
1667otherwise).
1668
1669### 8.5 Gate: no isolation, no nested tenants (codex R1-15, R2-14, R3-4)
1670
1671A box that cannot establish the namespace **does not start nested
1672tenants**. There is no fallback unit, no "proceed without
1673`PrivatePIDs=`". Running the fleet with the same-uid `/proc/<pid>/root`
1674traversal open is the defect, not a degraded mode.
1675
1676The gate is observed behaviour, not a version string. At start, and on
1677`daemon-reload`, the control service (root) runs
1678`carlos-instance-probe.service`, a oneshot with the exact namespace and
1679`IPAddressDeny=` block of §4.1 whose `ExecStart` is `carlos
1680instance-probe`. From inside it asserts, and reports each as a separate
1681verdict line:
1682
1683- `/data` and `/run/carlos` list nothing but the probe's own bound
1684 directories, and `/var/cache` and `/var/lib` list nothing at all;
1685- `access(W_OK)` on the edge's own delegated `cgroup.procs`
1686 (`/sys/fs/cgroup/carlos.slice/carlos-edge.service/cgroup.procs`, chowned
1687 to carlos by `Delegate=` on `carlos-edge.service`) answers `EROFS`,
1688 never `EACCES` (the kernel checks DAC before the mount flag, so `EACCES`
1689 there means the file is not delegated, not a pass) or `ENOENT` (the edge
1690 unit is not loaded); and `/proc/self/mountinfo` shows `/sys/fs/cgroup`
1691 itself carrying `ro`, so the `EROFS` answer is the mount flag's doing
1692 and not some other cause;
1693- `/etc/carlos` cannot be opened (`EACCES` or `ENOENT`, never a
1694 listing), and the fixture `/etc/carlos/probe-secret` cannot be read.
1695 The fixture is **not** written by the control service, whose
1696 `ProtectSystem=strict` makes `/etc` read-only to it (codex R11-1); it
1697 is a tmpfiles line (§3.2, `f /etc/carlos/probe-secret 0640 root
1698 carlos - probe`) applied by bootstrap and by `update-platform-binary.sh`'s
1699 `systemd-tmpfiles --create`, before any probe runs. The control
1700 service, before it accepts a probe verdict, `stat`s the fixture and
1701 requires `root:carlos 0640`; a missing or differently-owned fixture is
1702 itself a `not ready` verdict line (`probe fixture absent`), never a
1703 vacuous pass;
1704- `/proc` lists only itself, and its own pid is 1;
1705- a symlink it creates in its directory is refused with `ELOOP`, and
1706 `/proc/self/mountinfo` shows `nosymfollow` on both bound directories;
1707- **IMDS, for a packet filter** (R3-4). `IPAddressDeny=` is a cgroup BPF
1708 egress filter; it drops packets, and a TCP `connect()` whose SYN is
1709 dropped retries and times out rather than returning `EPERM`
1710 (`tcp_connect`, `net/ipv4/tcp_output.c`). So the probe sends a UDP
1711 datagram, where the filter's verdict comes straight back from
1712 `sendmsg`: `sendto(169.254.169.254:80)` must fail with `EPERM`;
1713 `sendto([fd00:ec2::254]:80)` must fail with `EPERM` (`v6: enforced`)
1714 or with `ENETUNREACH` (`v6: unverified, no route to the address`):
1715 the kernel's own answer for that exact destination, so no route query
1716 is needed and none is made (codex R4-4; the service's
1717 `RestrictAddressFamilies=AF_UNIX` stands). As a control,
1718 `sendto(127.0.0.1:9)` must succeed (the network stack is live and the
1719 filter, not the stack, said no). A 2s TCP `connect()` to the IPv4
1720 address must not succeed. Any success on a metadata address, or any
1721 errno other than those named, is `not enforced`.
1722
1723The service records the verdict; `GET /ready` returns it; the agent
1724refuses every nested wake with the verdict's failing line until it is
1725204. `v6: unverified` does not block readiness on its own, because the
1726boxes have no IPv6 route to that address today and a box with no route
1727cannot reach it; it is **bounded and watched**: the probe re-runs every
1728five minutes and on `daemon-reload`, the verdict is part of the status
1729snapshot (§8.4), and the moment a v6 route appears and the answer is not
1730`EPERM` the verdict flips to `not enforced` and nested wakes stop
1731(codex R2-14 disposition). The probe cannot pass while an instance
1732would not be isolated, because it runs under the same unit text (a test
1733asserts the probe's namespace block equals the templates'), and it
1734fails only on a line that names the property that is missing.
1735
1736### 8.6 Rollout order
1737
17381. **Roll this release** to the dev fleet (`kass`, two boxes), boxes
1739 before console, with `update-platform-binary.sh` enabling the new
1740 units by name before it restarts the edge (§9). Every row stays
1741 `flat`; `layout_mode` is `none`; the probe verdict is `ready`; nothing
1742 else changes. Cold-boot one dev box and confirm the three mounts, the
1743 control socket and the probe come up (R2-2).
17442. **`carlos system layout migrate --box <dev box>`**, watch the rows
1745 turn `nested`, then the second box. The test that matters is from
1746 inside: the plan's verification task provisions a throwaway instance
1747 whose seed lists `/data` and `/run/carlos`, reads `/proc/1/status`,
1748 sends the three datagrams, and plants and follows a symlink, and
1749 reads the results back through `carlos logs`. Run `layout revert` on
1750 one box and `migrate` again: both directions are exercised before the
1751 flagship sees either.
17523. **Flagship**: roll the release region by region, console box last;
1753 then `layout migrate` one box at a time. The AU box has failed every
1754 gate since 2026-09-07 for an unrelated reason; do not read its failure
1755 as this change's.
17564. **carlos-tito-ie**: Tito's operator rolls it from Tito's console once
1757 the flagship has run a day, then migrates. The seven flagged instances
1758 move; the router family (unflagged) is reported `external` and left
1759 alone. Reply on #126 with the build and the check list above.
17605. **The next release** removes the flat spawn path for activator-managed
1761 hosts, points `DefaultDBPath`/`DefaultSocket` at the nested helpers
1762 for flagged routes (and keeps them flat for unflagged ones, whose
1763 supervisors own their paths), removes the spawn half of
1764 `internal/cgroup` and `Delegate=` on the edge, and refuses to start on
1765 a box whose `layout_mode` is not `nested`. Brought forward in plan 5
1766 (revision 16): on a box whose intent is `nested`, the reconciler and
1767 `carlos add` mint a fresh activator-managed row at the nested paths
1768 (`routeFor`'s `nested` argument), so a new tenant never runs flat
1769 there, not even for one pass. `DefaultDBPath`/`DefaultSocket` stay
1770 flat for every other caller until the next release.
1771
1772**Nested by default (revision 17, Paul's decision 2026-10-06).** From
1773the release that carries plan 5 (v0.25.0), nested is the default, not an
1774opt-in. When `control/layout.json` is absent, the effective intent on a
1775box is `nested` **if and only if the box is ready**: the deployment's
1776default is nested (`CARLOS_LAYOUT_DEFAULT` in host.env is unset or
1777`nested`; `flat` is the per-deployment opt-out, for a self-hoster whose
1778boxes are not ready), the running build is at or above `LayoutFloor`
1779when it can be placed (a build that evaluates the rule carries the pass
1780by construction, so an unplaceable dev build passes and a mis-tagged
1781release below the floor does not), and the nested gate of §8.5 answers:
1782`instancefs.SelfCheck`, the three `nosymfollow` mounts, and the control
1783service's probe verdict. A box that is not ready behaves exactly as
1784before the default (flat, mints flat) and says why in its status
1785report (`layout.default_blocked`); a box that is nested by default says
1786so (`layout.default`), so `carlos status --layout` can tell a box that
1787was migrated on purpose from one that moved on its own.
1788
1789A recorded intent always wins: an explicit `flat` is the opt-out, an
1790explicit `nested` behaves as today, and `--box` scoping is how an
1791operator keeps box-by-box control. The default never forces: the awake
1792grace, the holds and the quarantine of plan 4 apply as they do under a
1793recorded intent. The console's guards need no new rule: the downgrade
1794guard and the migrate guard judge what each box REPORTS (its rows and
1795its running release), so a box that is nested by default is treated
1796exactly like one under an explicit intent wherever it reports nested
1797rows. `carlos add` cannot reach the gate from an operator's shell, so
1798with no intent recorded it mints nested only when the box's registry
1799already holds an activator-managed row at the nested paths; a fresh
1800box's first hand-added row is minted flat and moved by the pass on the
1801next serial move.
1802
1803Rollout, restated: the flagship cuts v0.25.0 with this in, rolls it, and
1804each box migrates on its own once its probe reports ready. An operator
1805who wants box-by-box control records `carlos system layout migrate
1806--box` (or `revert`) first; the recorded intent then governs.
1807
1808Why a default and not a verb: the §1.2 list is what every flat tenant
1809can reach, and a fleet that stays flat because nobody ran a verb is a
1810fleet that stays exposed by omission. The gate is what makes the default
1811safe to have: a box that cannot isolate does nothing, loudly.
1812
1813**The roll script and the planner.** `update-platform-binary.sh:52-70`
1814(pinfra) enables units by name; it gains `carlos-instance-control.socket`,
1815the three mount units and the probe, before it restarts the edge, and
1816`bootstrap.go`'s `systemctlPlan` (`:165-179`) starts `.socket` and
1817`.mount` units it enables, which it does not today (R1-14).
1818`ensureSystemUnits` (`systemunits.go:34-83`) checks the control socket
1819is *active*, not just enabled. An agent that finds the channel absent
1820refuses nested wakes with `instance control socket not listening`, which
1821is what a forgotten enable looks like from `carlos logs`; flat hosts are
1822unaffected, which is the point of the two-layout release.
1823
1824---
1825
1826## 9. Every consumer of the shape
1827
1828The rule (AGENTS.md): the consumer list is everything that parses the
1829shape, not everything that calls the API. From the sweep of 2026-09-11,
1830extended by codex R1-9, R1-14, R1-17, R1-18, R2-8, R2-9, R2-10, R2-13,
1831R3-1, R3-6:
1832
1833**Path builders that move onto the helpers**
1834
1835- `internal/provision/provision.go:280` (`DerivesPaths`, unchanged, but
1836 named: it is why unflagged routes mint flat), `:1011-1012`
1837 (`DefaultSocket`, `DefaultDBPath`, flat in this release), plus
1838 `InstanceDir`, `InstanceRunDir`, `NestedDBPath`, `NestedSocket`,
1839 `LogSocket`
1840- `internal/activator/backend_exec.go:160-162` `defaultDBPath` (deleted); `:350, 507, 579, 607, 786, 790` (`<host>.env`, `<host>.config.env`); `:865-868` (`<host>-sidecar.env`); `:744-750` (socket normalisation, unchanged); `:273, 1041-1057` (quarantine and artifact paths, the migration's source contract); every nested read through `instancefs`; the classification of §8.1; the path and `getpeername` checks on the health and control dials (§5.4)
1841- `internal/edge/edge.go:2000-2014` (`unixProxy`: the path and `getpeername` checks on every dial, §5.4)
1842- the layout pass's raw-directory renames (`/data/.instances`, §3.2): the one caller, pinned by a grep test
1843- `internal/appconfig/syncexec.go:389-395, 465, 872, 921-926` (nested routes into the directory; flat and addr routes flat; `O_EXCL` temp); `:977-1024` `reapConfigEnv` walks both
1844- `internal/schedule/runner.go:461` and `internal/host/schedule_wiring.go:163` (admin token path, by classification)
1845- `internal/host/movespass.go:285-305` — rebuilds `<DataDir>/<host>.db` from the record; reads `rt.DBPath` from the row instead, and refuses a `fenced` host
1846- `cli.go:118-136` (`carlos add` defaults, unchanged and named), `:157-159` (relative-path refusal), the new `carlos instances --layout` and `carlos system layout migrate|revert`; `docs/site/cli.md` names them
1847- `internal/provision/reconcile.go:299-301` (`routeFor` mint, unchanged and named); `:461-464` and `:873-881` compare derived paths and log a `nested` row as drift for this one release (the message names the layout mode)
1848
1849**Readers of `rt.Socket` (no change; the value changes per host)**
1850
1851- `internal/edge/edge.go:536-538` (dial, rebuilt on every `Reload`), `internal/activator/backend_exec.go:594, 738`, `internal/status/snapshot.go:487`, `internal/host/probes.go:273`, `internal/host/schedule_wiring.go:343-369`, `internal/schedule/runner.go:237-238`, `internal/appconfig/configack.go:168`, `internal/host/socket_advice.go:32-79` (its `/data/%s.config.env` string is for addr routes and stays), console display sites in `apiroutes.go`, `relayops.go`, `apirelay.go`
1852
1853**The socket consumers group** (R2-10): `internal/host/bootstrap.go:41-62`
1854(`socketsGroup`, `tmpfilesConf` gains the lines of §3.2), the per-host
1855run directory mode in `prepare`, and the #103 test that dials a migrated
1856instance as an enrolled non-`carlos` uid
1857
1858**Litestream and replication**
1859
1860- `internal/lease/lease.go:286` — meta dir derived from `dbPath`; follows
1861- `internal/ops/litestream.go:354-388` — the box-wide generator **discovers by glob and filters by registry claim**; it does not append claimed paths (R1-17). The glob gains `instances/*/*.db`, or nested non-hibernating databases are never replicated. Hibernating rows stay excluded at `:420-421` as today; a `fenced` host is excluded. Pinned in `ops_test.go` for both coverage and the `skipped` report
1862- `carlos-litestream.service` runs as `carlos` with `ReadWritePaths=/data`: reads nested 0700 directories fine; ordered after `data-instances.mount`
1863
1864**Metrics and status**
1865
1866- `internal/host/metrics_probes.go:49` (discovery: the two new roots), `internal/host/metrics_loop.go:659` (the edge's published value becomes the slice total), `internal/console/metricsview.go:728-762` (the key allowlist: unchanged because the sampler publishes under `carlos-exec@<host>`)
1867- `internal/status/snapshot.go` gains the per-route `layout` field and the box `layout_mode` of §8.4; `carlos status` and the console instance page render them
1868
1869**The updater and the control plane**
1870
1871- `internal/host/systemapply.go:360-372, 738-748`, `carlos-system-recover.service`, `system.go:466`: unchanged, by design (§8.1); the layout verbs are a separate console door and bucket object (`control/layout.json`), a separate box pass that runs on every serial move, and a separate mark in `restart-marks.json`'s sibling `layout-marks.json`
1872- `internal/console`: the `POST /api/cli/system/layout` door (fleet-operator gate, fresh auth), `carlos system layout` in `system.go`
1873
1874**Units and installers**
1875
1876- `internal/host/units/` gains: three templates, two slices, three mounts, the probe, the control socket and service (§4, §5, §8.5); `carlos-edge.service` gains `Slice=`, `Requires=`/`After=` on the mounts, and drops its memory lines; `carlos-litestream.service` gains the `After=`
1877- `internal/host/bootstrap.go:61` (tmpfiles lines), `:116-122` `enabledUnits` (control socket, mounts, probe), `:165-179` `systemctlPlan` (starts `.socket`/`.mount`)
1878- `internal/host/systemunits.go:34-83` (active check on the socket)
1879- `update-platform-binary.sh:52-70` (pinfra): enables the new units by name before restarting the edge
1880- `harden-host.sh` (pinfra) writes hardening drop-ins for `$TENANT_UNITS`; the instance templates carry their own and must not be listed there
1881- **The same-uid audit** (codex R9-1, R9-2): every unit in `internal/host/units/` that runs as `User=carlos` or delegates a `carlos`-owned tree, and every path it writes, is a path an isolated instance must not see. Today: `carlos-edge.service` (`/data`, `/run/carlos`, its delegated cgroup subtree), `carlos-litestream.service` (`/data`), `carlos-hibernate-verify.service` and `carlos-restore-verify.service` (`/var/cache/carlos-verify`). A test over `unitsFS` lists every `User=carlos` unit's `ReadWritePaths=`, `CacheDirectory=`, `StateDirectory=`, `RuntimeDirectory=` and `Delegate=` and asserts each resulting tree is covered by a `TemporaryFileSystem=`, `InaccessiblePaths=` or `ProtectControlGroups=` line in the templates, so a future unit that writes somewhere new fails the build until the templates hide it.
1882 **Reads count too** (codex R10-1): the same audit covers every file a `User=carlos` or `Group=carlos` unit *reads* that is not world-readable by design, which today is `/etc/carlos/relay-masters` (`root:carlos 0640`, `internal/relay/masters.go:101`) and the alternate path the relay design allows for it; the audit's second table lists each such file, its owner and mode, and the template line that hides it (`InaccessiblePaths=/etc/carlos`). A new credential input for a `carlos`-group reader is added to that table, or the test fails
1883
1884**Tests that pin the flat layout** (§10): `internal/activator/activator_test.go` (20 fixture sites), `backend_exec_test.go` (4), `admintoken_test.go` (8, path-agnostic), `internal/appconfig/syncexec_test.go` (50), `internal/ops/ops_test.go` (52), `internal/provision/reconcile_test.go` (20), `internal/host/movespass_test.go` (1), `schedule_wiring_test.go` (1), `schedule_token_fence_test.go` (2), root `e2e_test.go` (24), `instances_e2e_test.go`, `config_e2e_test.go`, `deploy_e2e_test.go`. For this release they keep passing on the flat path; each gains a nested twin.
1885
1886**Not consumers**: the console never reads box paths (`internal/console`
1887has no `DBPath`/`DataDir` reader); `readDiskFrac` is whole-volume; the
1888sentinel `.path` units name fixed files under `/data`, never a host.
1889
1890---
1891
1892## 10. Tests
1893
1894Unit level, all runnable in CI (no systemd needed):
1895
1896- `internal/provision`: `InstanceID` (identity below 41 bytes,
1897 truncation plus hash above, never longer than 40, stable), the nested
1898 helpers keyed by it, the flat defaults unchanged, `LogSocket`, the
1899 escape function against `systemd-escape` fixtures, and the socket
1900 address preflight: a host that fits today's flat paths but whose
1901 identifier-keyed paths would exceed 107 bytes cannot exist, pinned by
1902 computing the longest address for a 253-byte host; a short host and a
1903 hashed identifier can never be equal (the `_` prefix); two long hosts
1904 colliding in `InstanceID` classify as `conflict` and refuse to wake,
1905 including a host that was already `nested` when the second row
1906 arrived.
1907- `instancefs`: refuses a symlink at every component, refuses `..`,
1908 `O_EXCL` temp plus rename, the mountinfo check for all three mounts.
1909- `internal/edge` and `internal/activator` dials, with real socket
1910 pairs: a path under `/run/carlos-control/` is refused before
1911 `connect()`; a custom path elsewhere (`/run/titogo/example.sock`) is
1912 dialled as today; a symlink to another listener yields a `getpeername`
1913 mismatch and is closed before a byte is sent; a listener created by
1914 another process and passed over (the `carlos-app@.socket` shape) is
1915 accepted when its bound path is the one dialled.
1916- `internal/host/systemapply`: a pre-layout candidate is refused while
1917 `layout_mode` is `nested` or a journal exists, and installed when the
1918 box is `flat`; the control service's start-time inspection of an
1919 underlying mountpoint that holds files (with a fake namespace runner).
1920- `internal/console`: the rollback door refuses a pre-layout release
1921 while any box reports `nested`, `fenced` or no layout field, naming
1922 them.
1923- `instance-control`: a connection whose peer pid is not the edge's
1924 `MainPID`, or whose uid is not `carlos`, is closed without a response;
1925 every 400 (bad host, bad role, bad limit); the unit and slice names it
1926 builds for a host with a dash; `set-property` (three properties) →
1927 `start` ordering with no `reset-failed`; `/reset` tolerating "not
1928 loaded"; `/seed` reading a retained result from an `active (exited)`
1929 and from a `failed` oneshot, waiting for the empty cgroup, then
1930 stopping it; `/ready` from the probe verdict; with a fake `systemctl`
1931 runner and a fake peer.
1932- `internal/activator`: classification of the four row shapes;
1933 `prepare` mints the token for seed and run, writes `launch.<role>.json`
1934 with the deduplicated env and the exact argv (the existing
1935 env-composition tests move their assertion to the effective
1936 environment of an executed stub); the control client's per-verb
1937 deadlines (30s, 150s for seed); `unitProc.Stop` orders stop → done →
1938 close listeners and returns `ErrStillRunning` on expiry without
1939 closing; `stop()` skips `rep.Close` on `ErrStillRunning` and the
1940 sweeper retries; `Exited()` only after a terminal unit state **and** an
1941 empty cgroup, never on log EOF or on a record alone, with a populated
1942 cgroup holding `done` open; a forged exit record for a running unit
1943 ignored; a foreign invocation; the failed-health path posts `stop` and
1944 waits; seed timeout and cancellation stop-and-wait before cleanup; the
1945 startup sweep stops activating units, cancels jobs and waits for empty
1946 cgroups; the sidecar restart reserves under the lock and installs or
1947 stops after, with a stalled start while another host wakes and stops.
1948 The control channel is a seam (`Control interface { Start; Seed; Stop;
1949 Reset; Ready }`) with a fake; the exit observer takes a fake `show`, a
1950 fake run directory and a fake cgroup tree.
1951- `instance-exec`: reads the file through `instancefs`, refuses schema
1952 ≠ 1, refuses an `argv[0]` that is not the live link, execs.
1953- `instance-exit`: writes the record with `O_EXCL` from the four
1954 variables into the run directory; refuses to overwrite.
1955- `instance-probe`: each verdict line, including `EPERM` versus
1956 `ENETUNREACH` (with and without a v6 route) versus other errnos on the
1957 datagram sends, the loopback control, and the TCP connect bound, with
1958 a fake socket layer.
1959- `logListener`: one connection per invocation, a second refused and
1960 logged, line cap, EOF ends collection without marking exit.
1961- Layout pass: eligibility by the activator's predicate (flagged only;
1962 external, tunnel, unit-backed untouched); convergence on every serial
1963 move; a later-flagged row migrating on the next pass; `foreign` rows
1964 (a custom basename, a subdirectory, a path outside the trees, a row
1965 pointing at the console database) reported and never moved; quiesce
1966 before plan; the ownership preflight for `x`/`x.config` in both
1967 directions; DB destination collision refused, env destination moved
1968 aside; the rename performed through the raw `.instances` directory
1969 fds and never through the bind mount (a test with a real bind mount
1970 where `EXDEV` would otherwise fire, skipped where the CI host cannot
1971 mount); the journal's phases and every crash boundary (before files,
1972 mid-files, after files, after CAS, before journal removal) recovered
1973 exactly, with absent optional artifacts, with the row at either
1974 endpoint; a journal whose `to` is not the host's derived destination
1975 or whose `from` is not the host's flat paths refused; a journal
1976 completed to its own endpoint and the host then converged to a changed
1977 intent before release; a CAS conflict keeps the fence; a second clean
1978 start does nothing; every
1979 fenced consumer (`Start`, `Seed`, `Restore`, moves pass, `SyncExec`,
1980 litestream generator) refuses a fenced host; a partial rename followed
1981 by a `MoveCutting` pass does not complete the flip; revert restores the
1982 flat layout byte for byte, aside files included; a forged journal, a
1983 forged launch file and a forged exit record move nothing (§3.3).
1984- Units: a test over `unitsFS` asserts the three templates' and the
1985 probe's namespace, hardening and coupling blocks are identical, that
1986 `PrivatePIDs=yes`, `TemporaryFileSystem=/data:ro`,
1987 `IPAddressDeny=169.254.169.254/32`, `Restart=no`,
1988 `BindsTo=carlos-edge.service`, `Slice=carlos-instances-%i.slice`,
1989 `OOMPolicy=kill`, an `ExecStopPost=` **without** `+`, and the `%I`
1990 paths are present, that the seed carries `TimeoutStartSec=120`, that
1991 the control socket is `SocketMode=0600 SocketUser=carlos` under
1992 `/run/carlos-control`, that all three mounts carry `nosymfollow` and
1993 `DefaultDependencies=no` with the ordering of §3.2, and that
1994 `carlos-edge.service` carries `Slice=carlos.slice` and no `MemoryMax=`;
1995 and, where `systemd-analyze` exists on the CI host, `systemd-analyze
1996 verify` over the rendered set.
1997- `internal/ops`: the nested glob, coverage of a nested non-hibernating
1998 row, the `skipped` report, the fence.
1999- `internal/host`: `systemctlPlan` starts sockets and mounts; the two
2000 sampler roots publish per-host under `carlos-exec@<host>` and the edge
2001 as the slice total, flat hosts still from the delegated subtree; the
2002 #103 consumer dial through the nested run directory.
2003- `internal/status`: the `layout` field for each of the six states, the
2004 box mode, and the pre-field box.
2005- `internal/console` and `system.go`: the layout door's gate and
2006 refusal order, the bucket object, the scope forms, convergence marks.
2007
2008Box level, in the plan's verification task and in the rollout:
2009
2010- Cold boot with the full unit set; `systemd-analyze verify`;
2011 `list-dependencies` shows the mounts before the edge.
2012- Probe verdict `ready`; a deliberately broken probe (a drop-in removing
2013 `IPAddressDeny=`) turns it `not enforced` and blocks nested wakes.
2014- Migrate one host; wake it; `systemctl status carlos-instance@<esc>`
2015 active in `carlos-instances-<esc>.slice` with the three memory
2016 properties; from inside via the throwaway instance's seed: `/data` and
2017 `/run/carlos` list one directory each, `/proc/1` is the app, the three
2018 datagrams answer as §8.5 says, a planted symlink `ELOOP`; `carlos logs`
2019 shows all of it for both instance and sidecar at once; a quick-exiting
2020 seed's status arrives; hibernate, unit gone, record consumed; `carlos
2021 restart` cycles it; `systemctl restart carlos-edge` stops it and does
2022 not start it; an enrolled `carlos-sockets` uid dials `app.sock` and
2023 cannot open the control socket; a flat tenant on the same box cannot
2024 get a response from `control.sock` directly or through its own
2025 redirected socket; `carlos instances --layout` says `nested`; the
2026 console's memory graphs for the app and for the platform are
2027 continuous across the migration; revert, and the previous release
2028 serves the host again.
2029
2030---
2031
2032## 11. Out of scope, and follow-ups
2033
2034- **Per-instance uids (option B).** The nested layout is its
2035 prerequisite; the namespace closes every path the discussion names
2036 without it. If a later finding needs a uid boundary, it lands on this
2037 layout with a root chown in the control service. Not in this change.
2038- **Externally supervised exec routes** (unflagged instance routes with
2039 a socket, §8.2 step 1): not isolated by this change; their supervisor
2040 owns their process and their flat paths. Whether the platform should
2041 offer them the same namespace is a discussion to open once this lands.
2042- **Unit-backed console instances.** Not needed for isolation, and a
2043 hibernation rewrite. Whether `backing=unit` hibernation works on the
2044 fleet today (`UnitBackend` runs `systemctl` unprivileged, §1.4) is its
2045 own discussion; the control channel of §5 is deliberately not widened
2046 to `carlos-app@` (R1-19).
2047- **#127**: root-rendered config for opted-in addr routes into
2048 `/etc/carlos/apps`. Smaller and independent; this change makes the file
2049 invisible to isolated instances but not to the agent.
2050- **Adopting running units on agent start** instead of stopping them
2051 (§6.7).
2052- **The next release** (§8.6 step 5): drop the flat spawn path and the
2053 spawn half of `internal/cgroup`; with it, the transition-only
2054 exposures §3.2 and §6.4 name (a flat tenant redirecting its own socket,
2055 a flat tenant writing to a log socket) end.
2056- **Whole-family OOM across instance and sidecar** (§4.3): not
2057 reproduced; recorded as a deliberate difference.
2058- **Discussion #1 (memory pressure)**: no new numbers here (§4.3); the
2059 slice shape makes a later change a unit-file edit, never a drop-in.
2060
2061### 11.1 Hardening after the layout (#214 items 1-3, #178), and what it leaves
2062
2063Added to the templates, the control service and the probe (each property
2064has a probe line, so `ready` reflects it):
2065
2066- **CPU and IO.** The control service writes the tier's `CPUWeight=`,
2067 `CPUQuota=` and `IOWeight=` beside its memory caps on the host slice
2068 (`cgroup.SharesOf`: 100/100%/100, children tier 100/200%/100). Nothing
2069 new on the wire: the shares follow from the memory tier.
2070 *Residual:* `IOWeight=` is enforced only by io.cost or BFQ; the probe
2071 says `unverified` on a box with neither, which is the default on an
2072 EBS `none` scheduler. Enabling io.cost is a box change, not made here.
2073- **Disk.** No per-tenant quota: `/data` is a directory on the root ext4
2074 filesystem, and project quotas need `tune2fs -O project,quota` on an
2075 unmounted filesystem plus a `prjquota` mount; every writer is uid
2076 carlos, so reserved blocks cannot tell a tenant from the agent. The
2077 guard refuses rather than stops: below max(1 GiB, 10%) free, starts
2078 and seeds answer 507, and the control service logs a warning every
2079 30s while it lasts. Stopping the "biggest" tenant was tried and
2080 dropped in review: space held by unlinked-but-open files appears in
2081 no directory, so attribution can be gamed into stopping honest
2082 tenants, and a stop from the control service bypasses the activator's
2083 fence and holds. *Residual:* tenants already awake can keep writing
2084 past the floor until the disk is full. A dedicated `/data` volume
2085 with project quotas (or systemd's quota directives on top of them)
2086 is the real fix.
2087- **The version store.** Each instance sees an empty `/opt/carlos` with
2088 `/opt/carlos/carlos` and its own build put back: the control service
2089 writes `/run/carlos-control/exe/<iid>/{app -> /opt/carlos/live/<host>,
2090 live/<host> -> ../app}` before every start, and the unit binds them at
2091 `/opt/carlos/app` and `/opt/carlos/live`. `carlos-app@` gets the same
2092 view by binding `/opt/carlos/live/%i` over an empty `/opt/carlos`.
2093- **Egress.** `IPAddressDeny=` covers loopback, RFC 1918, CGNAT,
2094 link-local, ULA, NAT64, site-local, multicast and reserved ranges.
2095 An allow is per address, both ways and on every port, so no loopback
2096 resolver is ever opened. That includes resolved's 127.0.0.53/54
2097 stubs, because a tenant could bind the stub address and reach every
2098 wildcard listener through it. The namespace binds
2099 `/run/systemd/resolve/resolv.conf` (resolved's upstreams, when
2100 present) over `/etc/resolv.conf`. Before every probe, the control
2101 service re-opens the nameservers in that same file with
2102 `IPAddressAllow=` on `carlos-instances.slice`. Link-local resolvers
2103 are refused except 169.254.169.253: an allow carries no zone, so
2104 `fe80::1` would open that address on every link. A nameserver that
2105 is one of the box's own addresses is never opened either: the
2106 control service reads them from procfs (`/proc/net/fib_trie` LOCAL
2107 routes, `/proc/net/if_inet6`) as it writes the allow, closes every
2108 resolver if it cannot, and reports a refusal as a failing
2109 `resolver-local` line. The probe's `dns` and `resolver-ports` lines
2110 check from inside that each allowed resolver is reachable and does
2111 not answer on port 22. (The probe made the local-address check
2112 itself until v0.25.0, with `net.InterfaceAddrs`; that is netlink,
2113 which the unit's `RestrictAddressFamilies=` refuses, so no box was
2114 ever ready; #126.) *Residuals:* an allowed upstream is open on every port (it
2115 is a remote resolver, not the box), and a resolver change reaches
2116 running instances only at their next start. Loopback inside one instance is
2117 closed too, so a family that talks to itself over TCP loopback must
2118 use a unix socket in its own directory. Services the box exposes on a
2119 public interface address remain reachable, as from the internet.
2120- **IPC (#178).** `PrivateIPC=yes` and a private `/dev/mqueue`. Abstract
2121 unix sockets are scoped with Landlock (`LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET`,
2122 ABI 6, Linux 6.12) by the trampoline before it execs; a kernel without
2123 it fails the probe and the trampoline refuses to exec. *Residual:* a
2124 sibling can still bind an abstract name first, which is a refusal of
2125 service to the owner, never a connection to it. A per-unit network
2126 namespace would close that too, at the cost of a veth or proxy for
2127 egress; not done.
2128
2129---
2130
2131## 12. Approaches considered
2132
21331. **Console-provisioned instances become unit-backed** (#126 option 1).
2134 Rejected: `UnitBackend` has no restore, seed, replication, hibernate-
2135 from-replica, sidecar or move; `provision.Record` cannot express a
2136 backing and the reconciler refuses one. A rewrite of self-serve
2137 hibernation, and the always-awake design already said so.
21382. **Per-instance uid from the agent** (#126 option 2 as written).
2139 Impossible without `CAP_SETUID` on the agent, which is root-equivalent
2140 for a compromised agent; and a uid alone does not hide a sibling's
2141 socket file or pid.
21423. **Isolated exec: the agent's instance, systemd's namespace** (this
2143 spec). Keeps every activator invariant, needs one root channel, and
2144 migrates with a rename.
2145
2146Within 3: trampoline over `EnvironmentFile=` (§6.2, one env composer);
2147three templates over one (§4.2, the instance is the host); the control
2148service authenticating the agent's pid over polkit and over a token
2149(§5.4, ruled with codex across two rounds); `nosymfollow` mounts over
2150per-read symlink checks (§3.2, kernel-enforced and race-free where a
2151check-then-open is neither); termination from unit state plus cgroup
2152emptiness, with the unit's own record as decoration, over any record as
2153proof (§6.5); and an operator verb recording a fleet intent that boxes
2154converge to over an agent-start migration (§8.1, because the updater's
2155rollback runs the old binary).
2156
2157---
2158
2159## 13. Review log
2160
2161- **Round 1** (codex, GPT-6, `CODEX-FINDINGS-ISOLATED-EXEC.md`, 19
2162 findings, verdict request changes). All accepted:
2163 R1-1 control service, `nosymfollow` (§3.2, §5.4);
2164 R1-2 log sockets outside tenant mounts, per role (§3.1, §6.4);
2165 R1-3 `nosymfollow` plus `instancefs` and `O_EXCL` temps (§3.2, §7);
2166 R1-4 exit from unit state plus cgroup emptiness, never from EOF (§6.5);
2167 R1-5 seed timeout, stop verb, wait before cleanup (§4.2, §5.3, §6.2);
2168 R1-6 per-role log listeners (§6.4);
2169 R1-7 `BindsTo=` not `PartOf=`, sweep covers jobs (§4.1, §6.7);
2170 R1-8 per-host slice carries the tier for all three roles; whole-family OOM recorded as a difference after R3-3 (§4.3);
2171 R1-9 cgroup path under `carlos.slice`, key compatibility, `metrics_probes.go` (§6.6, §9);
2172 R1-10 one common wall in `carlos.slice`, no new numbers (§4.3);
2173 R1-11 eligibility is the activator's predicate (§8.2);
2174 R1-12 the journal fence and its consumers (§8.2);
2175 R1-13 explicit reverse migration; no "restore" claim (§8.2);
2176 R1-14 planner starts sockets and mounts; roll script; active check (§8.6);
2177 R1-15 no `PrivatePIDs=` fallback; behavioural gate and probe (§8.5);
2178 R1-16 env deduplication, effective-value test (§6.2);
2179 R1-17 nested glob is discovery, not reporting (§9);
2180 R1-18 `layout` status through the API and CLI; `docs/site/cli.md`; the box-local repair of a `foreign` row accepted as the residual, with the reason (§8.3, §8.4);
2181 R1-19 `UnitBackend` claim removed (§1.4, §11).
2182- **Round 2** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R2.md`, 14 new
2183 findings, verdict request changes). All accepted:
2184 R2-1 journal outside tenant mounts, plan from row and host only (§3.3, §8.2);
2185 R2-2 mounts with `DefaultDependencies=no` and explicit ordering, cold-boot verification (§3.2);
2186 R2-3 no `reset-failed` in `/start` (§5.3);
2187 R2-4 termination not dependent on unit retention; `/seed` correlation (§5.3, §6.5);
2188 R2-5 cgroup emptiness before `done`; `stop()` does not release over `ErrStillRunning` (§6.3, §6.5);
2189 R2-6 sidecar restart reserved under the lock, started outside it with a deadline (§6.7);
2190 R2-7 journal records artifact presence and phase; committed-row recognition; classification before legacy test (§8.1, §8.2);
2191 R2-8 ownership preflight in both directions from `SyncExec`'s rule (§8.2);
2192 R2-9 two-layout release and operator verb; same-boot rollback behaviour stated; env aside rule (§8.1, §8.2);
2193 R2-10 `carlos-sockets` traversal preserved on the run directories (§1.1, §3.1);
2194 R2-11 `MemorySwapMax` on the host slice (§4.3);
2195 R2-12 seed prepares the admin token and merged env (§6.1);
2196 R2-13 edge discovered in `carlos.slice`, published as the slice total (§6.6);
2197 R2-14 probe distinguishes enforcement from unreachability (§8.5, reworked by R3-4).
2198- **Round 3** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R3.md`, 7 new
2199 findings, verdict request changes). All accepted:
2200 R3-1 no token; the control service authenticates the edge's `MainPID`; every dial checks path and `getpeername` (the root-peer rule of revision 4 withdrawn by R4-1); `/run/carlos/logs` on a `nosymfollow` mount; journals checked against both endpoints (§3.2, §3.3, §5.4);
2201 R3-2 exit record from `ExecStopPost=` without `+`, advisory only, in the run directory (§4.1, §6.5);
2202 R3-3 no manual `memory.oom.group`; per-service families, difference recorded (§4.3);
2203 R3-4 UDP `EPERM` probe with a loopback control and a no-route rule for v6 (§8.5);
2204 R3-5 150s seed deadline (§5.3);
2205 R3-6 nothing minted nested in this release; unflagged routes flat by contract (§3.1, §8.1, §8.6);
2206 R3-7 the record lives where the agent can delete it (§6.5).
2207- **Round 4** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R4.md`, 4 new
2208 findings plus partials, verdict request changes). All accepted:
2209 R4-1 root-peer rule withdrawn; path allowlist before the dial and `getpeername` equality after it (§5.4, §6.1);
2210 R4-2 `/data/.instances` bound onto `/data/instances`; the pass renames on one mount through raw directory fds (§3.1, §3.2);
2211 R4-3 sources follow each file's existing contract; only host-named files at the platform's paths move; everything else is `foreign` (§8.2, §8.3);
2212 R4-4 no route query; the v6 answer comes from `sendto` itself; the probe re-runs and the verdict is watched (§8.5);
2213 R2-4 `/seed` correlates by `--no-block` then `InvocationID`; a seed record may decide a seed's outcome because it can deceive only its author (§5.3, §6.5);
2214 R2-5 a foreign invocation is stopped and `done` waits for the cgroup (§6.5);
2215 R2-7 journals validated against both endpoints and completed to their own end before convergence (§8.2);
2216 R2-14 `v6: unverified` bounded by the five-minute re-run and reported in status (§8.5);
2217 R1-18 the `foreign` residual accepted with its reason (§8.3).
2218- **Round 5** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R5.md`, 3 new
2219 findings plus 2 partials, verdict request changes). All accepted:
2220 R5-1 only the control endpoint is refused by path; custom sockets elsewhere dial as today (§5.4);
2221 R5-2 `system apply` and the console's rollback door refuse a pre-layout release while any box is nested; underlying mountpoints inspected at start (§8.2);
2222 R5-3 `InstanceID`, bounded to 40 bytes, keys every directory, socket and unit name; addresses preflighted against 107 bytes (§3.1, §4, §6.2);
2223 R2-4 the seed unit carries `RemainAfterExit=yes`, so its result is retained until read; the record is wording again (§4.2, §5.3, §6.5);
2224 R2-7 recovery endpoints derived from the journal's own direction; §3.3 reconciled with §8.2 (§3.3, §8.2).
2225- **Round 6** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R6.md`, 1 new
2226 finding plus 2 partials, verdict request changes). All accepted:
2227 R6-1 hashed identifiers carry a `_` prefix no host can have, 64-bit hash; a collision is `conflict`, which refuses to wake and is never the unisolated fallback; the create door refuses a colliding host (§3.1, §8.1);
2228 R5-2 the downgrade guards require completed reversal (no `nested`, `fenced` or `conflict` row, no journal), not intent (§8.2);
2229 R5-3 the control service builds unit and slice names from `InstanceID(host)` (§5.3).
2230- **Round 7** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R7.md`, 1 new
2231 finding plus 1 partial, verdict request changes). All accepted:
2232 R7-1 the startup sweep is `POST /sweep`, performed by the control service on the unit names systemd reports, so a hashed identifier without a row is still stopped (§5.3, §6.7);
2233 R5-2 the downgrade guard checks every row's path shape regardless of flags; unflagging a nested row is refused (§8.2);
2234 §10 aligned with the `conflict` rule.
2235- **Round 8** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R8.md`, 1 new
2236 finding, every prior item resolved, verdict request changes). Accepted:
2237 R8-1 the wake re-reads the row before `Acquire` and runs on it; the layout pass reloads routing before releasing the fence (§6.8, §8.2).
2238- **Round 9** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R9.md`, 2 new
2239 findings, every prior item resolved, verdict request changes). Both accepted:
2240 R9-1 `ProtectControlGroups=yes` in the templates and the probe; the probe writes `cgroup.kill` and expects `EROFS` (§4.1, §8.5);
2241 R9-2 `/var/cache` and `/var/lib` hidden; the same-uid audit rule and its test added to §9 (§4.1, §8.5, §9).
2242- **Round 10** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R10.md`, 1 new
2243 finding, every prior item resolved, verdict request changes). Accepted:
2244 R10-1 `InaccessiblePaths=/etc/carlos` in the templates and the probe; the audit extends to credential inputs a `carlos`-group process reads (§4.1, §8.5, §9).
2245- **Round 11** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R11.md`, 1 new
2246 finding, every prior item resolved, **verdict accept with
2247 corrections**). The correction is applied in this revision:
2248 R11-1 the probe fixture comes from tmpfiles at bootstrap, and the control service verifies its ownership and mode before accepting a verdict (§3.2, §8.5).
2249- R12 (final review, plan 2): cgroup-ro targets the edge's delegated cgroup.procs; DAC precedes the mount flag so a root-owned file answers EACCES.
2250- R13 (task 4 review, plan 3): after a stop the activator requested, a unit reporting a foreign or absent invocation proves the old invocation over without the cgroup gate (§6.5); the observer never stops an invocation it did not start once its own stop was requested.
2251- R14 (plan 4, 2026-10-04; recorded in revision 16): the per-host layout view is `carlos status --layout`, beside `--instances` and under the same operator gate, not `carlos instances --layout`: the instances verb lists the local registry, and the layout report lives in each box's private status snapshot, which only the console reads (§8.4).
2252- R17 (plan 5 follow-up, Paul's decision 2026-10-06; revision 17): nested is the default when no intent is recorded, gated on the box's readiness; `CARLOS_LAYOUT_DEFAULT=flat` is the deployment opt-out and a recorded `flat` intent the fleet opt-out (§8.6, "Nested by default").
2253- R15 (first roll, 2026-09-12; fixed 2026-09-25): the probe on dev's boxes answered `ready: false` twice, and the review of the fix found a third gap. (a) `RestrictSUIDSGID=yes` makes `openat2` fail with ENOSYS on systemd 257, because systemd cannot read the mode inside `open_how` and so blocks the whole call; every `instancefs` open in the namespace is an `openat2`. The line is dropped from all four templates. What it guarded is covered another way: with no capabilities an instance can only make a set-id file owned by `carlos`, which gains nothing for another `carlos` process; `NoNewPrivileges=yes` voids it inside the namespace; and no other uid can reach one (the tenant trees are `nosuid`, and `PrivateTmp=`'s `/tmp` and `/var/tmp` sit under a root 0700 directory, `/var/tmp` being on the root filesystem and not `nosuid`). (b) `control-unreachable` checked `Lstat`, but `InaccessiblePaths=` leaves a mode 000 root-owned node at the path. The check is now what a tenant could do, a listing of the directory and a dial of the socket, and passes only when both are refused for permission or absence; a refused connection or a timeout proves the path reachable and fails. (c) `PrivateDevices=` bind-mounts the host's `/dev/shm`, so every instance, all uid `carlos`, shared one; each unit now gets its own tmpfs there, and the probe's `shm-private` check plants a file in the host's `/dev/shm` (the control service's tmpfiles fixture) and fails if it is visible. POSIX message queues and abstract unix sockets are still box-wide; they are a follow-up (§11). (d) 2026-10-04, plan 4: `carlos-edge.service` carried the same line, and the agent runs there: its nested `instancefs` opens and every rename of the layout pass are `openat2` calls, so the first `layout migrate` would have met ENOSYS on its first host. The line is dropped from the edge unit too. The argument in (a) carries: the edge's one capability, `CAP_NET_BIND_SERVICE`, does not bear on set-id files (that takes `CAP_CHOWN`, `CAP_FSETID` or `CAP_SETUID`), so anything the agent could mark set-id is owned by `carlos`, and the agent and its flat children run under `NoNewPrivileges=`. An `openat` fallback in `instancefs` was considered and rejected: it would be a second resolution path, exercised in production only when the hardening had regressed, and it would route around the regression rather than report it. Instead the agent runs `instancefs.SelfCheck` in its own unit at start, and the nested-wake gate (§8.5) asks it first, so a blocked `openat2` refuses nested wakes and the layout pass with the directive named.
2254- R16 (plan 4 review, 2026-10-04): three gaps in the pass while flat tenants still share the agent's uid. (a) A tenant could make its own future directory under `/data/.instances` ahead of the pass and hard-link a sibling's database or `litestream.env` into it (its own uid's files, so `protected_hardlinks` allows it); `EnsureDirs` accepted the directory and the destination check looked only at the planned names. Now a migration refuses an instance directory holding any entry the plan does not name, refuses any file about to move that has more than one name (the shadow directory walked), and checks both again after the renames and before the row flips; a failure there keeps the journal at `files` and the host fenced. (b) Deferring an awake host (plan 4 amendment, "when it is asleep") was unbounded, so a tenant that never idled kept raw-path access to its nested siblings for good. The deferral is bounded: an hour of grace (`layout.DefaultAwakeGrace`), after which the pass stops the host as §8.2 step 2 always allowed and moves it; the serial leg re-runs on its own ten minutes after any deferral so the bound holds on a quiet fleet; the intent gains `force` for an operator who wants no grace; and `carlos status --layout` warns on every box with nested rows beside flat ones, because that box is not isolated yet. (c) Files moved aside at a destination (`<name>.aside-<unix>`) were kept inside the instance directory, readable by the tenant that gets the directory; they are kept under `/data/layout/aside` now, which no instance namespace contains. Still open: a flat tenant that is running while another host moves can plant between the check and the rename; the check is against what was planted before the pass arrived, and the closure is the bound in (b). (d) Re-check: (a)'s refusals were themselves escapable: a tenant could keep its own move refused with one syscall (`ln /data/B.db /data/.x`, or a file in its own future directory), and because the pass lifted its fence on a refusal the host woke flat again on its next request, for as long as it kept the refusal alive. The rule is now fail-closed on the way to nested: a flat host that has been stopped for its move and is then refused stays fenced and down (the pass keeps the fence, as it already did after a failed handover), status reports it `fenced` with the refusal (`held down after a refused move to nested`), every later pass retries from the kept fence and releases the host once it has moved, and a flat intent lifts the fence. A nested host refused its revert is not held: it is already isolated. The mixed-layout warning counts `foreign` and `external` rows beside `flat` ones, since all three run as uid carlos with no namespace; and the second-name refusal reports the inode and the `find <data> -xdev -samefile <path>` that lists the other names. (e) Landing follow-ups: a planted ENTRY in a host's future directory is no longer a refusal (one `touch` by a sibling would have held the host down until an operator acted, and the hold outlived the sibling's own migration); it is renamed into `/data/layout/aside/<host>/` with a random, no-replace name and the move goes on, logged and shown in status as a note. The hold stays for a second name (`nlink > 1`, no safe automatic fix) and for a symlink at the instance directory. The hold is persisted as `/data/layout/<host>.hold` and restored at agent start before anything wakes, by re-deriving it from the files: the marker is a hint to re-check, not the authority, because every file under `/data` is uid carlos's and a flat tenant can unlink it; a deleted marker therefore costs one grace after one restart, after which the next pass meets the same refusal and holds again, and a marker whose reason is gone is dropped rather than honoured. The serial leg re-runs on its own after a held result, as it does after a deferral, so a cleared reason is acted on within ten minutes on a quiet fleet.
2255