carlos / platform Public

Isolated exec spec: revision 4 after codex round 3

Round 3 findings committed unedited. All 7 accepted: the control
service authenticates the edge's MainPID instead of a token and the
edge refuses root peers; the exit record is advisory and lives in the
run directory (ExecStopPost=+ keeps the unit's tmpfs); UDP EPERM probe
for the IMDS deny; 150s seed deadline; nothing minted nested in this
release; no manual memory.oom.group.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ1ZvWj7naf73GjTX8MBv5
Paul Campbell pushed by paul@keymail.dev b6a35685488ba5f963e17074fd4ee2e1348fdc5b parent bcf3fcb
2 files changed, +701 −470
  • docs/superpowers/specs/2026-09-11-isolated-exec-design.md +610 −470
  • docs/superpowers/specs/CODEX-FINDINGS-ISOLATED-EXEC-R3.md +91 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-09-11-isolated-exec-design.md b/docs/superpowers/specs/2026-09-11-isolated-exec-design.md
index 62ee58737..dfb734168 100644
--- a/docs/superpowers/specs/2026-09-11-isolated-exec-design.md
+++ b/docs/superpowers/specs/2026-09-11-isolated-exec-design.md
@@ -1,8 +1,9 @@
# Isolated exec — every exec-backed instance in its own namespace
-**Date:** 2026-09-11 · **Status:** revision 3, after codex rounds 1 and 2
-(19 + 14 findings, all accepted; §13) · **Requested by:** discussion #126
-(Tito, with box evidence), approved in direction by Paul on 2026-09-11
+**Date:** 2026-09-11 · **Status:** revision 4, after codex rounds 1 to 3
+(19 + 14 + 7 findings, all accepted; §13) · **Requested by:** discussion
+#126 (Tito, with box evidence), approved in direction by Paul on
+2026-09-11
**Builds on:** discussion #126 and its companion #127; titogo's
`deploy/systemd/titogo@.service` (the namespace recipe, verified on
@@ -35,7 +36,10 @@ concurred with the control service and showed why it must authenticate
its caller (§5.4). Round 2 turned the migration from an agent-start side
effect into an operator-triggered fleet verb (§8), because the system
updater's own rollback runs the *old* binary and cannot reverse a layout
-it never knew.
+it never knew. Round 3 drew the transition boundary: while a box still
+runs unisolated tenants, nothing that runs as `carlos` is a trustworthy
+caller, so the root channel authenticates the agent *process*, not a
+secret the agent holds (§5.4).
The shape in one sentence: **the activator keeps owning every exec-backed
instance exactly as it does today, but the process it owns is launched as
@@ -120,7 +124,7 @@ above implies:
carries `IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128` for exactly
this reason; exec children carry nothing.
-### 1.3 The primitive under all of it: a symlink is a read
+### 1.3 The primitive under all of it: same uid, same paths
A namespace hides a sibling's files from the instance. It does not hide
them from the **agent**, which reads and writes the instance's directory
@@ -134,12 +138,16 @@ the instance as its own environment (`backend_exec.go:785-794, 966-971`;
sibling's database and the agent's replicator uploads the sibling's data
under the attacker's replica. Replace the listening socket with a symlink
to any other socket on the box and the edge dials it as this host
-(codex R1-1, R1-3). And any *file* the agent reads as instructions from
-the instance's directory is the same primitive without a symlink
-(codex R2-1). A design that bounds the instance but not the paths the
-agent follows on its behalf, or the records it trusts from the
-instance's own directory, has not bounded anything. §3.2 closes the
-first at the kernel; §3.3 states the rule for the second.
+(codex R1-1, R1-3). Any *file* the agent reads as instructions from the
+instance's directory is the same primitive without a symlink (R2-1).
+And for as long as a box still runs one unisolated tenant, every
+`carlos`-readable secret and every `carlos`-writable directory on the
+box is that tenant's too, so nothing that runs as `carlos` can be told
+apart from it by what it knows or where it writes (R3-1). A design that
+bounds the instance but not the paths the agent follows on its behalf,
+the records it trusts, or the callers it believes, has not bounded
+anything. §3.2 closes the symlinks at the kernel; §3.3 states the rule
+for records; §5.4 states the rule for callers.
### 1.4 What the unit backing already gets right, and why it is not the answer
@@ -178,15 +186,14 @@ scoped to the three instance templates and does not extend to
creates the flat files today. The registry's `DBPath` and `Socket`
columns point into the new layout once a host is migrated.
-2. **No symlink is ever followed under the instance trees.**
- `/data/instances` and `/run/carlos/instances` are bind mounts of
- themselves with `nosymfollow` (§3.2). The kernel refuses to traverse a
- symlink there for every process on the box, agent and edge included,
- with no check-then-use race. Files the instance must never touch (the
- log sockets, the exit records, the control channel, its token, the
- migration journal) live outside those two trees, where no tenant mount
- reaches. Nothing the agent does is ever directed by a file it read
- from an instance directory (§3.3).
+2. **No symlink is ever followed under the trees the agent binds or
+ opens for a tenant.** `/data/instances`, `/run/carlos/instances` and
+ `/run/carlos/logs` are bind mounts of themselves with `nosymfollow`
+ (§3.2). The kernel refuses to traverse a symlink there for every
+ process on the box, agent, edge and PID 1 included, with no
+ check-then-use race. Nothing the agent does is ever directed by a file
+ it read from an instance directory, and a migration journal is honoured
+ only when it says what the fleet's recorded intent already says (§3.3).
3. **A template unit per role.** The instance runs as
`carlos-instance@<host>.service`; its seed as
@@ -197,32 +204,33 @@ scoped to the three instance templates and does not extend to
with exactly the instance's own two directories bound back in,
`PrivatePIDs=yes`, `ProtectProc=invisible`, `RestrictNamespaces=yes`,
`IPAddressDeny=` for IMDS), and all three sit in one per-host slice
- that carries the host's memory and swap tier and its whole-family OOM
- rule. A sibling's database does not exist in this namespace; a
- sibling's socket does not exist; a sibling's pid does not exist.
+ that carries the host's memory and swap tier. A sibling's database does
+ not exist in this namespace; a sibling's socket does not exist; a
+ sibling's pid does not exist.
4. **The agent stays the owner.** Lease, restore, seed ordering,
replication, hibernation, the control socket, `carlos logs`, the
next-due tick, sidecar co-lifecycle and residence moves are unchanged
- in `internal/activator`, with one bounded change in the sweeper (§6.7).
- What changes is the `Proc` the exec backend hands back: it is a unit,
- not an `*exec.Cmd` (§6).
-
-5. **One root channel, authenticated.** The agent cannot start a system
- unit. A small root service, `carlos-instance-control`, socket-activated
- on a unix socket only uid `carlos` can open, starts and stops units
- named `carlos-instance*@<host>` and nothing else, and accepts a request
- only when it carries a token the agent reads from a path no tenant
- mount contains (§5). This is the synchronous form of the
- `carlos-stage.path` pattern.
-
-6. **Launch through a trampoline; exit through a record.** Each unit's
- `ExecStart` is `/opt/carlos/carlos instance-exec <role> <host>`, which
- runs inside the namespace, reads the launch description the agent
- wrote, and `execve`s the app binary with the exact argv and environment
- the agent composed (§6.2). Each unit's `ExecStopPost=+` runs outside
- the namespace as root and writes the invocation's exit record where
- the agent, and only the agent, reads it (§6.5).
+ in `internal/activator`, with two bounded changes in `activator.go`
+ (§6.3, §6.7). What changes is the `Proc` the exec backend hands back:
+ it is a unit, not an `*exec.Cmd` (§6).
+
+5. **One root channel, authenticating the process.** The agent cannot
+ start a system unit. A small root service, `carlos-instance-control`,
+ socket-activated on a unix socket in a root-owned directory, starts and
+ stops units named `carlos-instance*@<host>` and nothing else, and
+ accepts a request only from the edge unit's main process, as reported
+ by systemd, never from a child of it (§5). This is the synchronous
+ form of the `carlos-stage.path` pattern.
+
+6. **Launch through a trampoline; exit through the unit's own record
+ plus the kernel's word.** Each unit's `ExecStart` is
+ `/opt/carlos/carlos instance-exec <role> <host>`, which runs inside the
+ namespace, reads the launch description the agent wrote, and `execve`s
+ the app binary with the exact argv and environment the agent composed
+ (§6.2). Each unit's `ExecStopPost=` leaves an advisory exit record in
+ the instance's own run directory; termination itself is proven by
+ systemd's unit state and an empty cgroup, never by a file (§6.5).
7. **Resource walls become slices.** `carlos.slice` carries the
2026-08-28 outer wall; the edge unit and every instance sit under it;
@@ -231,20 +239,21 @@ scoped to the three instance templates and does not extend to
8. **One release, two layouts, one operator verb.** This release's
agent runs a *flat* host exactly as today (unisolated, `childProc`)
- and a *nested* host isolated. Nothing moves on upgrade, so the system
- updater's own rollback (which runs the previous binary) always lands
- on a layout that binary understands. `carlos system layout migrate`
- moves a fleet's hosts across under a journal the tenant cannot touch;
- `carlos system layout revert` moves them back; both are console
- verbs, reported per host in status (§8). The next release drops the
- flat path.
-
-9. **Addr routes are untouched, and now invisible.** A TCP-addr route's
- `<host>.config.env` stays flat in `/data`, because the app's own
- supervisor reads it there (titogo's `EnvironmentFile=-/data/%i.config.env`).
- Isolated instances can no longer see it. That closes the read #127
- leads with; #127's ask for root-owned delivery stays open as its own
- change (§11).
+ and a *nested* host isolated. Nothing moves on upgrade and no new row
+ is minted nested, so the system updater's own rollback (which runs the
+ previous binary) always lands on a layout that binary understands.
+ `carlos system layout migrate` records the fleet's intent; every box
+ converges its eligible hosts to it on every serial move; `revert`
+ records the opposite; status reports each host (§8). The next release
+ drops the flat spawn path for activator-managed hosts.
+
+9. **Addr routes and externally supervised routes are untouched, and now
+ invisible.** A TCP-addr route's `<host>.config.env` stays flat in
+ `/data`, because the app's own supervisor reads it there (titogo's
+ `EnvironmentFile=-/data/%i.config.env`); an unflagged socket route keeps
+ its flat paths because its supervisor owns them. Isolated instances can
+ no longer see either. That closes the read #127 leads with; #127's ask
+ for root-owned delivery stays open as its own change (§11).
---
@@ -264,20 +273,19 @@ scoped to the three instance templates and does not extend to
launch.seed.json agent-written before a seed
launch.sidecar.json agent-written before a sidecar start
<host>.db.corrupt a quarantined restore, as today
-/data/layout/ 0700 carlos:carlos; NOT under any tenant mount
+/data/layout/ 0700 carlos:carlos
<host>.json the migration journal for a host in flight (§8.2)
/run/carlos/instances/ bind mount of itself, nosymfollow; 2750 carlos:carlos-sockets
/run/carlos/instances/<host>/ 2750 carlos:carlos-sockets, created by the agent
app.sock the instance's listener (rt.Socket); 0660 after health, as today
app.sock.control the agent's control listener (rt.Socket + ".control"); 0600
-/run/carlos/logs/ 0700 carlos:carlos; NOT bound into any namespace
+ exit.<role>.<invocation>.json the unit's advisory exit record (§6.5); tenant-writable, and treated so
+/run/carlos/logs/ bind mount of itself, nosymfollow; 0700 carlos:carlos; NOT bound into any namespace
<host>.run.sock the agent's log listener for the instance (§6.4)
<host>.seed.sock ... for the seed
<host>.sidecar.sock ... for the sidecar
-/run/carlos/exits/ 0750 root:carlos; NOT bound into any namespace
- <host>.<role>.<invocation>.json root-written exit record (§6.5)
-/run/carlos/instance-control.sock the root channel (§5); not in any namespace
-/run/carlos/instance-control.token 0600 carlos:carlos, root-written; not in any namespace
+/run/carlos-control/ 0750 root:carlos; NOT under /run/carlos, NOT in any namespace
+ control.sock the root channel (§5); SocketUser=carlos, 0600
```
The socket tree keeps the #103 contract: `/run/carlos/instances` and
@@ -291,33 +299,35 @@ its run directory and can loosen it; that changes nothing outside,
because outside is where the mode is set and checked.
**One helper, one owner.** `internal/provision` gains
-`InstanceDir(dataDir, host)` beside `DefaultDBPath`, and
-`DefaultDBPath(dataDir, host)` and `DefaultSocket(host)` change to return
-the nested paths for exec routes:
+`InstanceDir(dataDir, host)` beside `DefaultDBPath`, and the nested
+defaults exist alongside the flat ones. In this release **no minting
+path uses the nested defaults** (§8.1, codex R3-6): `routeFor`
+(`reconcile.go:299-301`) and `carlos add` (`cli.go:118-136`) keep minting
+flat paths, and the layout pass is the only thing that ever writes a
+nested path into a row.
```go
func InstanceDir(dataDir, host string) string {
return filepath.Join(dataDir, "instances", host)
}
-func DefaultDBPath(dataDir, host string) string {
+func NestedDBPath(dataDir, host string) string {
return filepath.Join(InstanceDir(dataDir, host), host+".db")
}
func InstanceRunDir(host string) string {
return filepath.Join(SocketDir, "instances", host)
}
-func DefaultSocket(host string) string {
+func NestedSocket(host string) string {
return filepath.Join(InstanceRunDir(host), "app.sock")
}
func LogSocket(host, role string) string {
return filepath.Join(SocketDir, "logs", host+"."+role+".sock")
}
-func FlatDBPath(dataDir, host string) string { return filepath.Join(dataDir, host+".db") }
-func FlatSocket(host string) string { return filepath.Join(SocketDir, host+".sock") }
+// DefaultDBPath and DefaultSocket keep their flat meaning for this
+// release; the next release, which drops the flat spawn path, points
+// them at the nested helpers for activator-managed routes and keeps
+// them flat for externally supervised ones (§8.6).
```
-`FlatDBPath`/`FlatSocket` exist for this release only: they are how a row
-is classified (§8.1) and how the flat spawn path finds its files. Every
-DataDir-keyed builder in the tree moves onto these (the list is §9).
`ExecBackend.defaultDBPath` (`backend_exec.go:160-162`) is deleted in
favour of the provision helpers, so there is one definition, not two.
@@ -336,18 +346,11 @@ discussion asks for ("absent, not permission-denied") without any of
that, and leaves option B open as a later hardening on the same layout
(§11).
-**Custom `DBPath`.** `carlos add --db` may stamp any absolute path on an
-exec route (`cli.go:157-159` refuses only a relative one). Under this
-design an exec route's `DBPath` must be inside `InstanceDir`, because
-that is the only data path the namespace reveals. `carlos add` refuses a
-nested-layout exec route whose `--db` is elsewhere, naming the directory.
-Existing rows are handled by the migration (§8.3).
+### 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)
-### 3.2 Symlinks are never followed under the instance trees (codex R1-1, R1-2, R1-3)
-
-Two mount units, embedded and installed like every other unit
+Three mount units, embedded and installed like every other unit
(`internal/host/bootstrap.go:33-34`), and tmpfiles lines beside the
-existing `/run/carlos` one (`bootstrap.go:61`). Both mounts carry
+existing `/run/carlos` one (`bootstrap.go:61`). All three carry
`DefaultDependencies=no`: a mount unit's default ordering places it
before `local-fs.target`, and `systemd-tmpfiles-setup.service`, which
creates the directories, runs after that target, so the defaults would
@@ -373,35 +376,21 @@ Options=bind,nosymfollow,nosuid,nodev,noexec
WantedBy=multi-user.target
```
-```ini
-# run-carlos-instances.mount
-[Unit]
-Description=carlos instance sockets (bind, nosymfollow)
-DefaultDependencies=no
-After=systemd-tmpfiles-setup.service
-Before=carlos-edge.service carlos-instance-control.service
-Conflicts=umount.target
-Before=umount.target
-[Mount]
-What=/run/carlos/instances
-Where=/run/carlos/instances
-Type=none
-Options=bind,nosymfollow,nosuid,nodev,noexec
-[Install]
-WantedBy=multi-user.target
-```
+`run-carlos-instances.mount` and `run-carlos-logs.mount` are the same
+text for `/run/carlos/instances` and `/run/carlos/logs`, with
+`After=systemd-tmpfiles-setup.service` and no `RequiresMountsFor=`.
```
d /data/instances 0755 carlos carlos -
d /data/layout 0700 carlos carlos -
d /run/carlos/instances 2750 carlos carlos-sockets -
d /run/carlos/logs 0700 carlos carlos -
-d /run/carlos/exits 0750 root carlos -
+d /run/carlos-control 0750 root carlos -
```
`carlos-edge.service`, `carlos-instance-control.service` and the three
-templates gain `Requires=` and `After=` on both mounts, so an edge that
-starts without them does not start at all rather than starting
+templates gain `Requires=` and `After=` on the mounts they touch, so an
+edge that starts without them does not start at all rather than starting
unprotected. The plan's first box task is a **cold boot** with the full
unit set installed, then `systemctl list-dependencies --before
data-instances.mount` and a `systemd-analyze verify` over the embedded
@@ -412,12 +401,13 @@ set, pinned as a CI test that renders the units and runs
2.41) makes path resolution on that mount return `ELOOP` at any symlink,
for every process, in every namespace that sees the mount. It is a
per-mount flag, so it holds through the `BindPaths=` bind into the
-instance's namespace and through the agent's and the edge's ordinary
-lookups from outside. `readlink` still works; nothing the platform runs
-needs a symlink under these trees (SQLite, litestream, the app's listener
-and the agent's env files are all plain files). Hard links cannot escape
-either: a `link()` needs a source path the caller can resolve, and inside
-the namespace no sibling path resolves.
+instance's namespace, through the agent's and the edge's ordinary
+lookups from outside, and through PID 1's open of a `StandardOutput=file:`
+path. `readlink` still works; nothing the platform runs needs a symlink
+under these trees (SQLite, litestream, the app's listener and the
+agent's env files are all plain files). Hard links cannot escape either:
+a `link()` needs a source path the caller can resolve, and inside the
+namespace no sibling path resolves.
What this closes, race-free and for every reader at once:
@@ -429,7 +419,19 @@ What this closes, race-free and for every reader at once:
agent-readable path, and having the agent read it into the instance's
environment or replicate it into the instance's replica (R1-3);
- the instance planting a symlink at a predictable temp name so the
- agent's write truncates the target (R1-3).
+ agent's write truncates the target (R1-3);
+- a still-unisolated flat tenant, during the transition, planting a
+ symlink at a log socket path so that PID 1 opens a root-owned file as
+ some unit's stdout (R3-1). `/run/carlos/logs` is `carlos`-owned because
+ the agent must bind there, so a same-uid process can write it; the
+ mount flag is what makes that write inert.
+
+The flat tenants' own sockets under `/run/carlos` itself are not on such
+a mount (the directory holds flat sockets, the apps subtree and more, and
+this release does not change it). A flat tenant can therefore still point
+its socket at any socket on the box and have the edge dial it. §5.4 says
+why that no longer reaches the control service, and §6.1 adds the one
+check the edge makes on every unix dial.
Belt and braces in the agent, cheap and testable:
@@ -438,9 +440,9 @@ Belt and braces in the agent, cheap and testable:
`openat2(2)` with `RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS`
(Linux 5.6+), so a symlink is refused with a named error even on a
box where the mount option is missing; the same helper is what the
- agent uses to verify, at start, that both mounts carry `nosymfollow`
- (`/proc/self/mountinfo`), and it **refuses to wake any nested host**
- until they do (§8.5);
+ agent uses to verify, at start, that all three mounts carry
+ `nosymfollow` (`/proc/self/mountinfo`), and it **refuses to wake any
+ nested host** until they do (§8.5);
- every agent write into an instance directory creates its temp file
with `O_CREAT|O_EXCL` and a random suffix, then `renameat`s over the
target (rename replaces the directory entry and never follows the
@@ -448,26 +450,36 @@ Belt and braces in the agent, cheap and testable:
- launch files (§6.2) are written the same way, and `instance-exec`
reads them through the same helper.
-### 3.3 Nothing in an instance directory is an instruction (codex R2-1)
+### 3.3 Nothing in an instance directory is an instruction, and a journal is not one either (codex R2-1, R3-1)
The agent reads three kinds of thing from an instance directory: the
database (to replicate, restore and checkpoint), the two env files (to
-compose the instance's own environment), and nothing else. It never
-reads a path, a host name, a file list or a phase from there. In
-particular:
+compose the instance's own environment), and the advisory exit record
+(to decorate an event line, §6.5). It never reads a path, a host name, a
+file list or a phase from there. In particular:
- the migration journal lives in `/data/layout/<host>.json`, outside
every tenant mount, and every source and destination path in it is
*derived from the host name and the registry row* by the §3.1 helpers
- at plan time, never read back from a file (§8.2);
+ at plan time. On recovery the pass **recomputes** the plan from the row
+ and honours the journal only if its paths equal the recomputation and
+ its direction equals the fleet's recorded intent (`control/layout.json`,
+ a bucket object no tenant can write, §8.2). During the transition a
+ flat tenant can write `/data/layout` (same uid); a journal it forges can
+ therefore cause the agent to do exactly what the next layout pass would
+ do for that host anyway, and nothing else (R3-1);
- the launch file is agent-written and then read by the trampoline
inside the namespace as the instance itself; it directs nothing the
agent does (§6.2);
-- the exit record is root-written outside every tenant mount (§6.5).
+- the exit record is written inside the instance's run directory by the
+ unit's own `ExecStopPost=`, is therefore tenant-writable, and is treated
+ as the tenant's claim about its own exit status: it never decides
+ whether the process is gone (§6.5).
-A test plants a forged journal-shaped file in an instance directory and
-a forged launch file naming a sibling, and asserts the sibling is
-untouched by a full migrate, revert, wake and stop cycle.
+A test plants a forged journal (a sibling's paths under a host's name; a
+direction opposite to the fleet's intent), a forged launch file naming a
+sibling, and a forged exit record for a running unit, and asserts the
+sibling is untouched and the running unit is not treated as exited.
---
@@ -477,7 +489,7 @@ Unit files under `internal/host/units/`, embedded and installed exactly as
the existing nineteen are (`bootstrap.go:33-34`, `systemapply.go:339-398`):
`carlos-instance@.service`, `carlos-instance-seed@.service`,
`carlos-instance-sidecar@.service`, `carlos.slice`,
-`carlos-instances.slice`, the two mount units of §3.2, the probe of
+`carlos-instances.slice`, the three mount units of §3.2, the probe of
§8.5, and the two control units of §5.2. Nothing enables the templates;
an instance unit exists only while the agent has started it.
@@ -500,15 +512,19 @@ Description=carlos exec-backed instance %I
# PartOf= would propagate the RESTART and race the agent's own startup
# (codex R1-7).
BindsTo=carlos-edge.service
-Requires=data-instances.mount run-carlos-instances.mount
-After=carlos-edge.service data-instances.mount run-carlos-instances.mount
+Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+After=carlos-edge.service data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
[Service]
Type=simple
ExecStart=/opt/carlos/carlos instance-exec run %I
-# Runs as ROOT, outside the namespace ("+"), after the last process of
-# this invocation is gone: writes the exit record the agent reads (§6.5).
-ExecStopPost=+/opt/carlos/carlos instance-exit run %I
+# Runs inside the same namespace, as carlos, after this invocation's
+# processes have been signalled: leaves an ADVISORY exit record in the
+# instance's own run directory (§6.5). Not "+": the "+" prefix drops
+# User= and the Private*/Protect* settings but keeps the explicit
+# TemporaryFileSystem=/BindPaths= mounts (codex R3-2), so nothing run
+# from here can see the host's /run/carlos anyway.
+ExecStopPost=/opt/carlos/carlos instance-exit run %I
User=carlos
Group=carlos
# One slice per host holds the instance, its seed and its sidecar, and
@@ -523,8 +539,8 @@ Restart=no
KillMode=mixed
TimeoutStopSec=20
# The log stream: stdout and stderr connect to the agent's per-role
-# listener (§6.4). PID 1 opens this as root, outside the namespace, at a
-# path no tenant mount contains.
+# listener (§6.4). PID 1 opens this as root, outside the namespace, on
+# the nosymfollow logs mount, at a path no tenant mount contains.
StandardOutput=file:/run/carlos/logs/%I.run.sock
StandardError=inherit
@@ -557,11 +573,15 @@ RestrictSUIDSGID=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
-# --- per-service resources; the tier and the family OOM rule live on the
-# host slice (§4.3) ---
+# --- per-service resources; the tier lives on the host slice (§4.3) ---
TasksMax=256
# The kernel picks a tenant before it picks anything else in carlos.slice.
OOMScoreAdjust=500
+# One OOM kill takes every process of THIS service (systemd sets
+# memory.oom.group on the service cgroup for OOMPolicy=kill). The sidecar
+# is a sibling service with its own family rule; §4.3 says why the two are
+# not one family any more.
+OOMPolicy=kill
```
Lines that are load-bearing and why:
@@ -593,23 +613,19 @@ Lines that are load-bearing and why:
- **`IPAddressDeny=`** for IMDS. The nft rule in pinfra allows uid
`carlos` because the agent needs IMDS; this line is what stops an
instance, also uid `carlos`, from fetching the box's role credentials.
- It is a BPF filter, so a denied connect fails with `EPERM`, which is
- how the probe tells enforcement from an unreachable network (§8.5).
+ It is a cgroup BPF **packet** filter: a denied datagram send fails with
+ `EPERM`, a denied TCP `connect()` loses its SYN and times out. §8.5's
+ probe is written for that (R3-4).
- **`BindsTo=carlos-edge.service`** keeps today's "instances die with the
agent": when the edge stops, so do they, and nothing restarts them but
the agent's next wake. §6.7 adds the startup sweep for the case where
the coupling did not fire.
-- **`ExecStopPost=+`**: the `+` prefix runs the command with full
- privileges and *without* the unit's `User=` and file-system namespacing,
- so it can write under `/run/carlos/exits`, which no tenant mount
- contains. systemd runs it after the service's own processes have been
- signalled and reaped, with `$SERVICE_RESULT`, `$EXIT_CODE`,
- `$EXIT_STATUS` and `$INVOCATION_ID` in its environment. §6.5 says what
- the agent does with that and why it still checks the cgroup.
- **`StandardOutput=file:` to an `AF_UNIX` socket** is documented
systemd behaviour: a stream connection is made and used as stdout. It
is opened by PID 1 outside the namespace, so the path is the real one,
- and the path is in `/run/carlos/logs`, which no tenant mount contains.
+ the path is in `/run/carlos/logs`, which no tenant mount contains, and
+ that directory is on a `nosymfollow` mount, so a same-uid process
+ outside any namespace cannot turn it into a symlink either.
- **`/opt/carlos`** stays readable through `ProtectSystem=strict`; the
trampoline and the live symlink are all the instance needs from it.
@@ -623,16 +639,16 @@ Differences:
|---|---|---|
| `Type=` | `oneshot` | `simple` |
| `ExecStart=` | `/opt/carlos/carlos instance-exec seed %I` | `/opt/carlos/carlos instance-exec sidecar %I` |
-| `ExecStopPost=` | `+/opt/carlos/carlos instance-exit seed %I` | `+/opt/carlos/carlos instance-exit sidecar %I` |
+| `ExecStopPost=` | `/opt/carlos/carlos instance-exit seed %I` | `/opt/carlos/carlos instance-exit sidecar %I` |
| `StandardOutput=` | `file:/run/carlos/logs/%I.seed.sock` | `file:/run/carlos/logs/%I.sidecar.sock` |
-| timeouts | `TimeoutStartSec=120` (today's seed budget, `backend_exec.go:368-408`; a oneshot has no start timeout by default, R1-5), `TimeoutStopSec=20` | `TimeoutStopSec=20` |
+| 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` |
A Go test over the embedded FS asserts that the three files' namespace,
hardening and coupling lines are byte-identical (§10). Three files rather
than one because seed and instance never run together but sidecar and
instance do, and a template's instance is the host, not a role.
-### 4.3 Slices and the memory walls (codex R1-8, R1-10, R2-11)
+### 4.3 Slices and the memory walls (codex R1-8, R1-10, R2-11, R3-3)
```ini
# carlos.slice — the outer wall of the 2026-08-28 design, as a slice
@@ -676,15 +692,22 @@ Seed, instance and sidecar all charge that one cgroup, which is what
today's shared `carlos-exec@<host>` gives them
(`backend_exec.go:149-155, 373-375, 890-895`).
-**The family OOM rule** is `memory.oom.group=1` on the host slice's
-cgroup, written by the control service (root) directly to
-`/sys/fs/cgroup/carlos.slice/carlos-instances.slice/carlos-instances-<esc>.slice/memory.oom.group`
-after `set-property`, because systemd has no directive for it on a
-slice. With it, an OOM kill anywhere in the family takes the instance and
-its sidecar together, which is what `internal/cgroup` gives them today
-and what a per-service `OOMPolicy=kill` would not (R1-8, R2 disposition).
-systemd does not manage that attribute on slices, so the write does not
-fight it; the plan's box task confirms it survives a `daemon-reload`.
+**The family OOM rule changes, deliberately.** Today `memory.oom.group=1`
+on the shared `carlos-exec@<host>` cgroup means an OOM kill in the
+sidecar takes the instance with it. systemd writes `memory.oom.group`
+itself, for slices too, from the unit's cgroup context (default `0`,
+`cgroup_context_apply` in `src/core/cgroup.c`), so a value written
+behind its back is overwritten on the next property application, which
+`/start` for the sidecar performs on a slice whose instance is already
+running (codex R3-3). There is no supported way to keep it set on the
+slice. So the rule becomes: **each service is its own OOM family**
+(`OOMPolicy=kill`, which sets the attribute on the service's cgroup), and
+a sidecar killed by the OOM killer is a sidecar that died, which the
+sweeper already restarts with backoff (`activator.go:1165-1200`) while
+the instance keeps serving. An instance killed by the OOM killer still
+stands its entry down and co-stops its sidecar as today. The instance
+surviving its sidecar's OOM is the better of the two behaviours; the
+spec records it as a difference rather than claiming parity.
The cgroup path is
`/sys/fs/cgroup/carlos.slice/carlos-instances.slice/carlos-instances-<esc>.slice/`
@@ -710,7 +733,7 @@ waiting HTTP request.
[Unit]
Description=carlos instance control socket (agent → root)
[Socket]
-ListenStream=/run/carlos/instance-control.sock
+ListenStream=/run/carlos-control/control.sock
SocketUser=carlos
SocketGroup=carlos
SocketMode=0600
@@ -723,15 +746,14 @@ WantedBy=sockets.target
# carlos-instance-control.service
[Unit]
Description=carlos instance control (root: start/stop carlos-instance*@ units for the agent)
-Requires=carlos-instance-control.socket data-instances.mount run-carlos-instances.mount
-After=data-instances.mount run-carlos-instances.mount
+Requires=carlos-instance-control.socket data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+After=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
[Service]
ExecStart=/opt/carlos/carlos instance-control
# Root deliberately: the whole job is systemctl start/stop, and the
# caller is the unprivileged agent. Same rationale as carlos-stage.service.
ProtectSystem=strict
-ReadWritePaths=/run/carlos /sys/fs/cgroup/carlos.slice
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
@@ -743,30 +765,29 @@ MemoryDenyWriteExecute=yes
MemoryMax=64M
```
-The socket is owned by `carlos` with mode 0600 and the unit is socket-
-activated, so only the agent's uid can open it and it runs only while
-there is work. It is not in any instance's namespace: `/run/carlos`
-inside an instance is an empty tmpfs with the instance's own directory
-bound in. At start the service writes a fresh random token to
-`/run/carlos/instance-control.token` (`0600 carlos:carlos`, `O_EXCL`
-temp and rename) and runs the probe of §8.5.
+The socket lives in `/run/carlos-control`, `0750 root:carlos`: any
+`carlos` process can connect (search on the directory), none can create,
+rename or replace anything in it. The unit is socket-activated and runs
+only while there is work. It is not in any instance's namespace:
+`/run/carlos` inside an instance is an empty tmpfs with the instance's
+own directory bound in, and `/run/carlos-control` is not under it.
### 5.3 Protocol
HTTP/1.1 over the unix socket, the shape `edge.ListenControl` already
-uses for the per-instance control socket. Every request carries
-`Authorization: Bearer <token>`; every response to a request without the
-right token is 401 and nothing runs (§5.4 says why). Five verbs, no
-shell, no argument passes through to `systemctl` except a unit name the
-service built itself:
-
-| Verb | What it runs | Returns |
-|---|---|---|
-| `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`; write `memory.oom.group=1` on the slice cgroup; `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 |
-| `POST /seed?host=H&memory_max=N` | the same, on `carlos-instance-seed@<esc>.service` (oneshot: `start` blocks until exit or `TimeoutStartSec`) | 200 with `{invocation_id}`; the seed's result reaches the agent as an exit record (§6.5), not in this response |
-| `POST /stop?host=H&role=run\|seed\|sidecar` | `systemctl stop <unit>` (blocking; also cancels a pending start job) | 204 |
-| `POST /reset?host=H&role=…` | `systemctl reset-failed <unit>`, tolerating "not loaded" | 204 |
-| `GET /ready` | nothing | 204 once the probe verdict (§8.5) is `ready`; 503 with the verdict otherwise |
+uses for the per-instance control socket. Before reading a request line
+the service authenticates the **connection** (§5.4); a connection that
+fails is closed without a response. Five verbs, no shell, no argument
+passes through to `systemctl` except a unit name the service built
+itself:
+
+| Verb | What it runs | Returns | Agent deadline |
+|---|---|---|---|
+| `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 |
+| `POST /seed?host=H&memory_max=N` | the same, on `carlos-instance-seed@<esc>.service` (oneshot: `start` blocks until exit or `TimeoutStartSec`); then `systemctl show -p InvocationID,Result,ExecMainStatus,ExecMainCode` | 200 with `{invocation_id, result, status, code}` if the unit is still loaded, else 200 with `{invocation_id: ""}` and the agent reads the exit record (§6.5) | 150s: 120s seed budget plus `TimeoutStopSec` and slack (R3-5) |
+| `POST /stop?host=H&role=run\|seed\|sidecar` | `systemctl stop <unit>` (blocking; also cancels a pending start job) | 204 | 30s |
+| `POST /reset?host=H&role=…` | `systemctl reset-failed <unit>`, tolerating "not loaded" | 204 | 30s |
+| `GET /ready` | nothing | 204 once the probe verdict (§8.5) is `ready`; 503 with the verdict otherwise | 5s |
There is deliberately **no `reset-failed` inside `/start`**: `ResetFailedUnit`
refuses a unit that is not loaded, and a template instance that has never
@@ -774,11 +795,12 @@ started is not loaded, so the first wake of every host would fail (codex
R2-3). `systemctl start` on a `failed` unit already clears the failed
state; `/reset` exists for the sweeper's housekeeping only.
-Validation, in order: token; `host` must satisfy `provision.ValidHostShape`
-(`provision.go:262`); `role` must be one of the three literals; the
-memory value must parse as a `Limit` the agent already knows how to
-produce (`internal/cgroup`, `TierDefault`/`TierChildren`). Anything else
-is a 400 with no systemctl call. The unit name is
+Validation, in order: connection (§5.4); `host` must satisfy
+`provision.ValidHostShape` (`provision.go:262`); `role` must be one of
+the three literals; the memory value must parse as a `Limit` the agent
+already knows how to produce (`internal/cgroup`,
+`TierDefault`/`TierChildren`). Anything else is a 400 with no systemctl
+call. The unit name is
`"carlos-instance" + suffix + "@" + escape(host) + ".service"` and the
slice name `"carlos-instances-" + escape(host) + ".slice"`, built from
the validated pieces, never from the request string.
@@ -796,54 +818,67 @@ writes a runtime drop-in the following `start` honours
R1-1). The plan's first task exercises it on a dev box for a slice that
does not yet exist.
-Every call the agent makes to this service carries a deadline
-(`controlTimeout`, 30s; a blocking `start` that exceeds it is answered
-by a `stop` and reported as a failed wake), because §6.7 puts one such
-call on a path that must never stall the activator.
+A `/start` or `/stop` that exceeds its deadline is answered by the agent
+with a `/stop` and reported as a failed wake or an unproven stop
+(§6.3); the deadlines exist because §6.7 puts one such call on a path
+that must never stall the activator, and the seed's is long because a
+seed is allowed to be slow (R3-5).
-### 5.4 The choice put to codex, and the ruling
+### 5.4 Who may call it, and the ruling (codex R1-1, R3-1)
The other way to let uid `carlos` start these units is polkit: a rules
file granting `org.freedesktop.systemd1.manage-units` to `carlos` when
the unit name matches `carlos-instance*@*.service`. Codex R1-1 confirmed
-that polkit can express the unit and verb restrictions (systemd passes
-`unit` and `verb`, including `set-property` and `reset-failed`, under
-that one action; the draft's claim that properties need a separate
-action was wrong), and also that it cannot constrain property values,
-that a uid-only grant is reachable by every tenant through the system
-bus, and that a wildcard on names also authorises transient units.
-
-Codex's advisement, concurring with the draft's: **the control service,
-provided it authenticates the request and not the socket's uid.** The
-reason is the proxy attack in §1.3: the edge dials `rt.Socket` as uid
-`carlos` in its own namespace, so before §3.2 an instance could point its
-own socket path at the control socket and have the edge deliver an
-attacker-written `POST /stop?host=<sibling>` with a perfectly authorised
-peer uid. A peer-uid check cannot tell that request from the agent's
-own. `nosymfollow` removes the redirection; the token removes the class:
-a request is the agent's only if it carries the token, and the token is
-readable only from `/run/carlos/instance-control.token`, root-written at
-service start, `0600 carlos:carlos`, at a path no instance namespace
-contains and the edge never serves. The agent reads it once at start and
-on 401. A tenant that could learn it would already have to be reading
-arbitrary agent-readable files, which is the property §3.2 denies.
-
-Ruled, 2026-09-11: control service with bearer token; polkit is not
+that polkit can express the unit and verb restrictions, and also that it
+cannot constrain property values, that a uid-only grant is reachable by
+every tenant through the system bus, and that a wildcard on names also
+authorises transient units. Codex's advisement, concurring with the
+draft's: **the control service, provided it authenticates the caller and
+not the socket's uid.** Ruled, 2026-09-11: control service; polkit is not
installed and not relied on.
+Revision 2 authenticated the caller with a bearer token in a
+`carlos`-readable file. Round 3 showed why that is not a boundary during
+the transition: a flat tenant is uid `carlos` in the host namespace and
+reads that file as easily as the agent does (R3-1). A secret the agent
+holds cannot distinguish the agent from an unisolated child of it. What
+can is **systemd's own record of which process is the agent**:
+
+- On every accepted connection the service reads `SO_PEERCRED` and
+ requires `uid == carlos` **and** `pid == MainPID` of
+ `carlos-edge.service` as `systemctl show -p MainPID` reports it at that
+ moment (root, one call, cached for one second). A flat tenant is a
+ child of that pid, never the pid itself; a nested tenant cannot reach
+ the socket at all.
+- The edge is the agent, so a request the edge *proxies* for a tenant
+ (a flat tenant pointing its own socket at `control.sock`, §3.2) would
+ pass that check. It does not get that far: **the edge refuses any unix
+ dial whose peer is uid 0** (`SO_PEERCRED` after `connect`, before a
+ byte is sent; `edge.go:2000-2014`'s `unixProxy` and the activator's
+ health and control dials, §6.1). Instances are `carlos` or a
+ DynamicUser; the only root-owned listener the platform ever wants a
+ tenant's traffic near is none. A proxied request therefore never
+ reaches the service, and the service never sees a connection from
+ anything but the agent's own goroutines.
+
+No token. The file it lived in is gone, and with it the question of who
+can read it. The two checks are each one syscall and one cached
+`systemctl show`, both testable with a fake peer, and both hold on a box
+that still runs flat tenants, which is the whole transition.
+
---
## 6. The activator on units
-`internal/activator/activator.go` changes in exactly one place, the
-sidecar restart in `sweepOnce` (§6.7, codex R2-6). `doWake`
-(`activator.go:772`), `stop` (`:962-1047`), `Bounce` (`:946-956`) and the
-`Proc`/`Rep` seams (`:194-207`) keep their contracts. `Exited()` stays a
-non-blocking read of a flag (R1-4); everything that talks to systemd
-runs in the proc's own goroutine or in the sidecar-start goroutine. The
-rest of the change is inside `ExecBackend`, which keeps its current
-`childProc` path for flat hosts for this one release (§8.1) and adds the
-unit path for nested hosts.
+`internal/activator/activator.go` changes in exactly two places: the
+sidecar restart in `sweepOnce` (§6.7, codex R2-6) and the lease release
+in `stop` (§6.3, codex R2-5). `doWake` (`activator.go:772`), `Bounce`
+(`:946-956`) and the `Proc`/`Rep` seams (`:194-207`) keep their contracts.
+`Exited()` stays a non-blocking read of a flag (R1-4); everything that
+talks to systemd runs in the proc's own goroutine or in the
+sidecar-start goroutine. The rest of the change is inside `ExecBackend`,
+which keeps its current `childProc` path for flat hosts for this one
+release (§8.1) and adds the unit path for nested hosts.
### 6.1 Preparing a role, and `Start`
@@ -860,16 +895,16 @@ included (R1-5, R2-12):
as today. For `run` only: bind the control listener at
`rt.Socket + ".control"` (unchanged, `edge.ListenControl`) and clear
any stale `app.sock` (nothing is running: the unit is not active, or
- we would not be here).
+ we would not be here). Remove any stale `exit.<role>.*.json`.
3. Bind the role's log listener at `LogSocket(host, role)` (§6.4).
4. Write `launch.<role>.json` (§6.2).
`Start` is then: `prepare(rt, "run")`; `POST /start` with the route's
-tier; `waitHealthyCtx(ctx, rt.Socket, spawnHealthTimeout)` (unchanged);
-the `0660` normalisation of `app.sock` (unchanged); on failure
-`POST /stop`, wait for the proc's `done`, and clean up, exactly as the
-current failure path stops the child; return a `unitProc` carrying the
-invocation id.
+tier; `waitHealthyCtx(ctx, rt.Socket, spawnHealthTimeout)` (unchanged,
+with the peer-uid check of §5.4 on its dial); the `0660` normalisation of
+`app.sock` (unchanged); on failure `POST /stop`, wait for the proc's
+`done`, and clean up, exactly as the current failure path stops the
+child; return a `unitProc` carrying the invocation id.
### 6.2 The trampoline and `launch.<role>.json`
@@ -925,18 +960,18 @@ reads the file (§3.3).
Seed: `launch.seed.json` carries the argv from `rt.Seed`
(`backend_exec.go:332-338`) with the DB path substituted as today; `POST /seed`
-blocks up to `TimeoutStartSec`; the seed's exit status arrives as its
-exit record (§6.5); a non-zero status, a timeout or a cancelled `ctx`
-runs `POST /stop?role=seed`, waits for the seed proc's `done`, and only
-then runs the same cleanup as today (`backend_exec.go:438`), so the
-lease is never released (`activator.go:824-828`) while a seed can still
-write (R1-5).
+blocks up to its 150s deadline; the seed's exit status arrives in the
+response or in its exit record (§6.5); a non-zero status, a timeout or a
+cancelled `ctx` runs `POST /stop?role=seed`, waits for the seed proc's
+`done`, and only then runs the same cleanup as today
+(`backend_exec.go:438`), so the lease is never released
+(`activator.go:824-828`) while a seed can still write (R1-5).
Sidecar: `launch.sidecar.json` carries `[live/<host>, "sidecar", "run"]`
and the sidecar env from `<host>-sidecar.env` only, as today
(`backend_exec.go:885-911`); `POST /start?role=sidecar`.
-### 6.3 `unitProc`
+### 6.3 `unitProc`, and a stop that could not be proven (codex R2-5)
```go
type unitProc struct {
@@ -956,6 +991,18 @@ first, close the listener only after the process is gone) is preserved:
`Stop` posts the stop, waits for `done`, then closes `ctl` and `logs`.
`done` closes only on proven termination (§6.5), never on log EOF.
+If `ctx` expires before `done` closes, `Stop` returns `ErrStillRunning`
+without closing the listeners. Today `stop()` then calls `rep.Close`
+regardless (`activator.go:1014-1033`), which releases the lease over a
+process that may still be writing. **The second `activator.go` change**:
+on `ErrStillRunning`, `stop()` does not call `rep.Close`; it marks the
+entry's outcome uncertain (the field exists, `outcome.uncertain`), leaves
+the entry in `stopping`, and `sweepOnce` retries `proc.Stop` on its
+cadence until `done` closes, after which the normal `rep.Close` runs.
+The lease's TTL bounds the worst case, as it does today when an agent
+dies mid-stop; what changes is that the agent never *voluntarily*
+releases over an unproven termination.
+
### 6.4 Logs
Today `teeStd` (`backend_exec.go:123-137`) pipes the child's stdout and
@@ -968,13 +1015,16 @@ Each role's unit connects its stdout and stderr to that role's socket in
`/run/carlos/logs` (`StandardOutput=file:` on an `AF_UNIX` path; R1-2,
R1-6). The agent's `logListener` for a role accepts one connection per
start (the unit connects exactly once, at exec; a second connection
-during the same invocation is closed and logged, and can only come from
-PID 1 since no tenant mount contains the path), and feeds it to the same
-`applog.NewLineWriter` and the same `io.MultiWriter` to the agent's
+during the same invocation is closed and logged), and feeds it to the
+same `applog.NewLineWriter` and the same `io.MultiWriter` to the agent's
stdout. Same bytes, same cap, same sink, same `carlos logs`, and the
instance and its sidecar no longer contend for one connection. The
listener is bound in `prepare` before the start request, so PID 1's
-connect cannot race it.
+connect cannot race it. A flat tenant, during the transition, can
+connect to a log socket (same uid, host namespace) and write lines into
+another host's log stream; that is a nuisance the transition accepts and
+the next release ends, and it cannot make anything *read* wrong, because
+nothing reads the log stream as an instruction.
Grandchildren: a process the app forked that outlives it keeps the
socket open, as it keeps the pipe open today. `KillMode=mixed` plus
@@ -982,62 +1032,72 @@ socket open, as it keeps the pipe open today. `KillMode=mixed` plus
when systemd reaps it. EOF on the log connection ends **log collection
for that invocation**; it does not mean the process is gone (R1-4).
-### 6.5 Exit observation (codex R1-4, R2-4, R2-5)
+### 6.5 Exit observation (codex R1-4, R2-4, R2-5, R3-2, R3-7)
`childProc` learns of exit from `cmd.Wait()` (`backend_exec.go:722-736`),
which is process death. A unit proc needs the same certainty, attributed
to the right invocation, and it must not depend on systemd keeping an
inactive unit loaded: systemd garbage-collects an unreferenced inactive
unit and its `ExecMainStatus` with it, so a `show` after a quick clean
-exit can find nothing to show (R2-4). So the record is written by the
-unit itself, on the way out:
-
-**`carlos instance-exit <role> <host>`** runs as `ExecStopPost=+` (root,
-outside the namespace, §4.1). It writes
-`/run/carlos/exits/<host>.<role>.<INVOCATION_ID>.json` with `O_EXCL`
-(`0640 root:carlos`), containing `{invocation, result, code, status, at}`
-from `$INVOCATION_ID`, `$SERVICE_RESULT`, `$EXIT_CODE`, `$EXIT_STATUS`.
-It runs whether the service exited, was stopped, timed out or was
-OOM-killed, and it runs after the invocation's processes have been
-signalled and reaped by systemd's stop sequence. The directory is
-root-owned and outside every tenant mount, so nothing but this command
-writes there.
-
-Each `unitProc` runs one goroutine that watches that directory with
-inotify (`IN_CLOSE_WRITE`/`IN_MOVED_TO` on the file for its invocation)
-and, as fallback, polls every 2s. A record alone is not termination: a
-service enters `failed` after its final `SIGKILL` timeout **with processes
-still present** if one is unkillable (`src/core/service.c:4020-4022`),
-and the lease must not be released over a writer (R2-5). So the
-goroutine, on seeing the record (or on the fallback finding the unit
-`inactive`/`failed` with an empty or absent `InvocationID`, which is a
-unit already unloaded), then requires **cgroup emptiness**: the unit's
-cgroup directory under the host slice is absent, or its `cgroup.events`
-says `populated 0`. Only then does it emit the member-facing event line
-with today's wording (`instance exited: ...` with the status or signal
-from the record, or `instance exited (exit 0)`; `instance exited (status
-unknown: no exit record)` in the unloaded-without-record case), set
-`exited`, and close `done`. A populated cgroup after a record is logged
+exit can find nothing to show (R2-4). Revision 3 tried to write the
+record from `ExecStopPost=+` into a root-owned directory; the `+` prefix
+drops `User=` and the `Private*`/`Protect*` settings but keeps the
+explicit `TemporaryFileSystem=`/`BindPaths=` mounts (`apply_mount_namespace`
+in `src/core/exec-invoke.c`), so that command could not see the
+directory (R3-2). So the record is **advisory, and truth is elsewhere**:
+
+**`carlos instance-exit <role> <host>`** runs as `ExecStopPost=` inside
+the namespace as `carlos`, after the invocation's processes have been
+signalled and reaped by systemd's stop sequence, with `$INVOCATION_ID`,
+`$SERVICE_RESULT`, `$EXIT_CODE` and `$EXIT_STATUS` in its environment.
+It writes `/run/carlos/instances/<host>/exit.<role>.<INVOCATION_ID>.json`
+(`O_EXCL`) with those four values. The directory is the instance's own,
+so the instance could have written that file itself; the record is
+therefore the tenant's claim about its own exit and is used for one
+thing only: the wording of the member-facing event line. It never
+decides whether the process is gone, and a record for a running unit is
+ignored (§3.3). The agent, as `carlos`, removes it once consumed, and
+`prepare` removes stale ones (R3-7).
+
+Each `unitProc` runs one goroutine that establishes termination from
+two facts the tenant cannot forge:
+
+1. **systemd's unit state**: `systemctl show -p ActiveState,SubState,Result,InvocationID <unit>`
+ (read-only, unprivileged), polled every 2s and triggered early by
+ inotify on the run directory (the record's arrival) and on the unit's
+ `cgroup.events` file. Terminal is `inactive` or `failed` with the
+ proc's own `InvocationID`, **or** `inactive` with an empty
+ `InvocationID` (the unit was already unloaded; the proc's invocation
+ is gone).
+2. **cgroup emptiness**: the unit's cgroup directory under the host
+ slice is absent, or its `cgroup.events` says `populated 0`. A service
+ enters `failed` after its final `SIGKILL` timeout **with processes
+ still present** if one is unkillable (`src/core/service.c:4020-4022`),
+ and the lease must not be released over a writer (R2-5).
+
+Only when both hold does the goroutine emit the event line with today's
+wording (`instance exited: ...` with the status or signal from the
+record if one exists for this invocation, or `instance exited (exit 0)`,
+or `instance exited (status unknown: no exit record)`), set `exited`,
+and close `done`. A populated cgroup after a terminal state is logged
every 2s as `instance <host>: processes remain after SIGKILL; not
-releasing`, and `done` stays open; that is a box-level incident, not a
-lease release.
-
-A record whose invocation is not this proc's (someone restarted the
-unit outside the activator, which `Restart=no` does not forbid) is
-logged as `unit restarted outside the activator`, the proc's own
-invocation is treated as gone (terminal, status unknown), and the
+releasing`, and `done` stays open (§6.3); that is a box-level incident,
+not a lease release.
+
+An `InvocationID` that is neither the proc's own nor empty means someone
+restarted the unit outside the activator, which `Restart=no` does not
+forbid. The goroutine logs `unit restarted outside the activator`,
+treats its own invocation as gone (terminal, status unknown) once its
+own cgroup emptiness cannot be told apart from the newcomer's, and the
sweeper's stand-down then `POST /stop`s whatever is running; the agent
owns these units and any other invocation is an anomaly to end, not to
adopt.
-The agent removes a record once it has consumed it; on start it removes
-every record older than its own start, since no proc can be waiting on
-them. `sweepOnce`'s existing dead-child check (`activator.go:1105-1122`)
-sees a flag, never a blocking call.
-
-`Restart=no` in the template is what makes this sound: systemd never
-restarts the unit behind the activator's back; a failed unit stays
-`failed` until the agent's next `start`, which clears it.
+`sweepOnce`'s existing dead-child check (`activator.go:1105-1122`) sees a
+flag, never a blocking call. `Restart=no` in the template is what makes
+this sound: systemd never restarts the unit behind the activator's back;
+a failed unit stays `failed` until the agent's next `start`, which clears
+it.
### 6.6 cgroups, tiers and the sampler (codex R1-8, R1-9, R2-13)
@@ -1051,11 +1111,11 @@ directory.
Under units, systemd owns the cgroup. So:
- **Tier** travels in `POST /start` / `POST /seed` and lands on the host
- slice as the three properties of §4.3 plus the `memory.oom.group`
- write. The `Tier func(registry.Route) cgroup.Limit` seam and its box
- wiring (`stagedManifestOfLiveVersion`) are unchanged; only the consumer
- changes. The agent logs `instance: <host>: memory.max=…` on every start
- as it does today. Seed, instance and sidecar share the one cap.
+ slice as the three properties of §4.3. The `Tier func(registry.Route)
+ cgroup.Limit` seam and its box wiring (`stagedManifestOfLiveVersion`)
+ are unchanged; only the consumer changes. The agent logs
+ `instance: <host>: memory.max=…` on every start as it does today. Seed,
+ instance and sidecar share the one cap.
- **Per-host sampling** gains
`/sys/fs/cgroup/carlos.slice/carlos-instances.slice/` as a scan root
and reads each `carlos-instances-<esc>.slice/memory.current` (the
@@ -1079,7 +1139,7 @@ Under units, systemd owns the cgroup. So:
platform graph is continuous across the roll.
- **`Manager.Start`, `CleanupHost`, the clone3 path and the delegation
dance** stay in use for flat hosts during this release and are deleted
- with the flat path in the next (§8.6). **`Delegate=` on the edge
+ with the flat spawn path in the next (§8.6). **`Delegate=` on the edge
unit** goes with them.
### 6.7 Agent start and restart; the sidecar restart (codex R1-7, R2-6)
@@ -1095,8 +1155,8 @@ start, before anything else:
2. `POST /stop` for every unit that is not `inactive`, whatever its
role, and for every unit with a pending or running job (`stop` cancels
a queued start);
-3. wait for each to reach `inactive`/`failed` **and** for its cgroup to
- be empty (§6.5's rule), then remove stale exit records.
+3. wait for each to reach a terminal state **and** for its cgroup to be
+ empty (§6.5's rule), then remove stale exit records.
Every instance is therefore asleep, and no job is queued, when the
activator's state machine begins, which is the invariant the activator
@@ -1104,14 +1164,14 @@ holds today. Adopting a running unit instead (re-acquiring its lease,
re-attaching replication) is a later improvement with its own review; it
is not needed for correctness.
-**The one change in `activator.go`.** `sweepOnce` restarts a dead
+**The first `activator.go` change.** `sweepOnce` restarts a dead
sidecar by calling `StartSidecar` under `a.mu`
(`activator.go:1165-1200`), justified there by "an exec is quick". A
unit start is an HTTP call and a `systemctl start`, and a stall there
would hold every other host's wake and stop. So the sidecar restart
becomes: under `a.mu`, mark `e.sidecarStarting = true`, capture the
entry's wake generation, and schedule the backoff exactly as now; then
-in a goroutine, `StartSidecar` with `controlTimeout`; then re-take
+in a goroutine, `StartSidecar` with its 30s deadline; then re-take
`a.mu`, and if the entry is still awake with the same generation,
install the proc as today, otherwise `Stop` it. The lock still makes a
restart impossible to race with a stop, because the install step checks
@@ -1166,7 +1226,7 @@ is derived from the same rule.
## 8. Two layouts, one release, and the verb that moves between them
-### 8.1 Why nothing moves on upgrade (codex R2-9)
+### 8.1 Why nothing moves on upgrade, and nothing is minted nested (codex R2-9, R3-6)
`carlos system apply` is run by the **installed** binary: it stages the
candidate, restarts the edge, gates it, and on a failed gate restores the
@@ -1175,35 +1235,47 @@ previous files and restarts the edge again (`systemapply.go:360-372,
journal exists (`units/carlos-system-recover.service:17`); and a channel
rollback from the console (`system.go:466`) is an ordinary downgrade
install. In every one of those paths the code that runs after the
-downgrade is the *old* binary, which knows one layout. A migration that
-ran on the new agent's first start would therefore leave every failed
-first roll on a layout the recovering binary cannot serve. So:
+downgrade is the *old* binary, which knows one layout. So:
+
+**This release changes nothing about a host on upgrade, and mints
+nothing nested.** `routeFor` (`reconcile.go:299-301`, whose
+`DerivesPaths` predicate covers unflagged socket instances too,
+`provision.go:280`) and `carlos add` (`cli.go:118-136`) keep the flat
+defaults; a provisioning pass that runs during the candidate's gate
+creates a flat row the old binary serves (R3-6). Unflagged socket routes,
+whose supervisor owns their paths, keep flat paths in this release and
+every later one. The only writer of a nested path into a row is the
+layout pass, and it runs only on a recorded fleet intent.
-**This release changes nothing about a host on upgrade.** Its exec
-backend classifies each activator-managed route on every `Start`:
+The exec backend classifies each activator-managed route on every
+`Start`:
| Row | Classification | Spawn path |
|---|---|---|
-| `DBPath == FlatDBPath`, `Socket == FlatSocket` | `flat` | today's `childProc`, unchanged, unisolated |
-| `DBPath == DefaultDBPath` (nested), `Socket == DefaultSocket` (nested) | `nested` | `unitProc` (§6) |
+| `DBPath == DefaultDBPath` (flat), `Socket == DefaultSocket` (flat) | `flat` | today's `childProc`, unchanged, unisolated |
+| `DBPath == NestedDBPath`, `Socket == NestedSocket` | `nested` | `unitProc` (§6) |
| a journal exists in `/data/layout/<host>.json` | `fenced` | refuses to wake; reason from the journal |
-| anything else | `custom` | today's `childProc`; reported for the operator (§8.3) |
+| `DBPath` under `DataDir` and `Socket` under `SocketDir`, otherwise | `custom` | today's `childProc`; migrated from its actual paths by the pass (§8.3) |
+| anything else | `foreign` | today's `childProc`; reported for the operator (§8.3) |
A fleet rolled to this release behaves byte-for-byte as before until an
-operator runs the verb. A rollback before the verb is a rollback of
+operator records an intent. A rollback before that is a rollback of
nothing. The classification is published per route in the status
snapshot (§8.4) and is what every other consumer keys on.
-### 8.2 `carlos system layout migrate` and `revert` (codex R1-11, R1-12, R2-1, R2-7, R2-8)
+### 8.2 `carlos system layout migrate` and `revert` (codex R1-11, R1-12, R2-1, R2-7, R2-8, R2-9, R3-1)
The verb has the shape of `carlos restart` (#113): the console writes
-`control/layout.json` (`{id, requested_at, direction: nested|flat, scope}`,
-an append log like `restart-scope.json`), bumps the serial, and every box
-in scope acts on the serial move; boxes remember the last id they acted
-on. An old box never reads the object; an old console answers 404. Scope
-is the fleet, or `--box <label>` for one box, which is how a fleet
-migrates one box at a time with the console showing each box's rows
-turning `nested`.
+`control/layout.json` (`{id, requested_at, mode: nested|flat, scope}`,
+the newest entry of an append log like `restart-scope.json`), bumps the
+serial, and every box in scope converges to it. Scope is the fleet, or
+`--box <label>` for one box, which is how a fleet migrates one box at a
+time with the console showing each box's rows turning `nested`. **The
+recorded mode is the fleet's intent, and boxes converge to it on every
+serial move**, not once: a row that becomes eligible later (a route
+flagged for hibernation after the fleet went nested, a freshly
+provisioned flat row) is migrated on the next pass when it is asleep. An
+old box never reads the object; an old console answers 404.
On a box, the layout pass runs in the agent (uid `carlos`, no root
needed) and, per host in scope, in this order:
@@ -1214,17 +1286,23 @@ needed) and, per host in scope, in this order:
route with a socket is an externally supervised process (titogo's
router family is the live example); its files and rows are **not
touched**, it is not isolated by this change, and status reports it
- `external`. Only a `flat` row migrates; only a `nested` row reverts;
- `custom` and `fenced` rows are reported and skipped.
+ `external`. Under mode `nested`, `flat` and `custom` rows migrate;
+ under mode `flat`, `nested` rows revert; `fenced` rows recover first;
+ `foreign` rows are reported and skipped.
2. **Quiesce.** The pass asks the activator to stop the host
(`Bounce`-class stop with replication closed and the lease released,
- the same sequence `stop()` runs) and to refuse wakes for it until the
- pass is done. Nothing is writing.
-3. **Plan, in memory, from the row and the host name only.** Sources and
- destinations for `<host>.db`, `-wal`, `-shm`, the litestream shadow
- dir, `<host>.env`, `<host>-sidecar.env`, `<host>.config.env`,
- `<host>.db.corrupt`, computed by the §3.1 helpers. Nothing is read
- from the instance directory to build the plan (§3.3).
+ the same sequence `stop()` runs, including §6.3's refusal to release
+ over an unproven stop) and to refuse wakes for it until the pass is
+ done. Nothing is writing.
+3. **Plan, in memory, from the row and the host name only.** Sources are
+ the row's actual `DBPath` and `Socket` (for a `flat` row these are the
+ flat defaults; for a `custom` row whatever the operator stamped, as
+ long as they are under `DataDir` and `SocketDir`) and the siblings
+ derived from them (`-wal`, `-shm`, the litestream shadow dir,
+ `<host>.env`, `<host>-sidecar.env`, `<host>.config.env`,
+ `<host>.db.corrupt` in the source directory); destinations are the
+ §3.1 helpers. Nothing is read from the instance directory to build
+ the plan (§3.3).
4. **Ownership preflight** (R2-8). For every flat source name, compute
every route on the box that could own that name under the flat
rules: `<host>.config.env` is `host`'s sync-owned bundle **unless** a
@@ -1235,22 +1313,26 @@ needed) and, per host in scope, in this order:
on another route's file is a refusal, never an overwrite. A plan with
any ambiguous name is refused for that host with the two owners
named.
-5. **Destination check.** A destination that already exists is a
- refusal (`two databases (flat and nested); refusing to choose`),
- never a choice. Recorded in status; an operator resolves it on the
- box, which is the one box-local step this design keeps, for a state
- it never creates itself.
-6. **Journal.** Write `/data/layout/<host>.json` (`O_EXCL`), outside
- every tenant mount: `{host, direction, from: {db, socket}, to: {db,
- socket}, artifacts: [{name, present}], phase: "files"}`. Presence is
- recorded so recovery knows an absent optional artifact (a sidecar
- env, a quarantine) was absent, not lost (R2-7). From this moment the
- host is `fenced`: `Start`, `Seed`, `Restore`, the residence-move pass,
- `SyncExec`'s writer and the litestream generator all refuse it, with
- the journal's contents as the reason. The `MoveCutting` path in
- `movespass.go:285-305`, which would otherwise treat an absent DB as
- "nothing to checkpoint" and complete a flip, is the case the fence
- exists for (R1-12).
+5. **Destination check.** A destination `<host>.db` that already exists
+ is a refusal (`two databases; refusing to choose`), never a choice.
+ For the two env files, which an older binary running between a
+ migration and a revert can have minted fresh at the flat path
+ (`backend_exec.go:579`; R2-9 disposition), an existing destination is
+ moved aside as `<name>.aside-<timestamp>` in the destination
+ directory and the source takes its place: the source is the file the
+ instance ran with for longer, the aside copy is kept, and the event
+ line names both. `<host>.config.env` at the destination is simply
+ replaced: sync re-renders it on its next pass regardless.
+6. **Journal.** Write `/data/layout/<host>.json` (`O_EXCL`): `{host,
+ mode, from: {db, socket}, to: {db, socket}, artifacts: [{name,
+ present}], phase: "files"}`. Presence is recorded so recovery knows
+ an absent optional artifact (a sidecar env, a quarantine) was absent,
+ not lost (R2-7). From this moment the host is `fenced`: `Start`,
+ `Seed`, `Restore`, the residence-move pass, `SyncExec`'s writer and
+ the litestream generator all refuse it, with the journal's contents
+ as the reason. The `MoveCutting` path in `movespass.go:285-305`, which
+ would otherwise treat an absent DB as "nothing to checkpoint" and
+ complete a flip, is the case the fence exists for (R1-12).
7. **Files.** `os.Rename` each artifact recorded present. Same
filesystem, same owner, atomic per file, no root. Then journal
`phase: "row"`.
@@ -1262,8 +1344,13 @@ needed) and, per host in scope, in this order:
reverse); release the host to the activator. The edge picks the new
`Socket` up on its next registry poll (`edge.go:449-451`, ~2s).
-**Recovery is the journal, and it is exact** (R2-7). On agent start and
-at the top of every layout pass, for each journal:
+**Recovery is the journal, and it is exact, and it is checked** (R2-7,
+R3-1). On agent start and at the top of every layout pass, for each
+journal: recompute the plan from the row (step 3); if the journal's
+`from`/`to` differ from the recomputation, or its `mode` differs from
+the fleet's recorded intent, the journal is refused (`layout: <host>:
+journal does not match row or intent; not acting`), the host stays
+`fenced`, and status says so. Otherwise:
- `phase: files`: for each artifact recorded present: at destination →
done; at source → rename; at neither → error, fence stays, status
@@ -1276,47 +1363,51 @@ at the top of every layout pass, for each journal:
A second clean agent start finds `nested` rows and no journals and does
nothing (the classification table runs before any legacy-default test).
-A forged journal-shaped file inside an instance directory is not a
-journal: the pass reads `/data/layout` only.
-**Revert** is the same pass with `direction: flat`, the same journal,
-the same fence, the same preflight in reverse. It exists so that a fleet
+**Revert** is the same pass under mode `flat`, the same journal, the
+same fence, the same preflight in reverse. It exists so that a fleet
can be taken back to a layout the previous release serves **before**
that release is installed: the operator's rollback order is `layout
revert` (this release, verified `flat` in status) then `system rollback`.
A `system rollback` or a failed-gate rollback that happens while hosts
are `nested` lands the old binary on rows it honours (`rt.DBPath`,
-`rt.Socket`; `backend_exec.go:220-225, 557-562`) but on a run directory
-it never creates, so those hosts fail to bind their socket, loudly and
-losslessly, until the new binary is back or the revert has run from it.
-No data is ever in two places: the fence and the destination check see
-to that.
-
-### 8.3 `custom` rows
-
-A row whose `DBPath` or `Socket` is neither the flat nor the nested
-default was set by hand (`carlos add --db`; note `carlos route` has no
-`--db` flag, `cli.go:469-475`, so it was set at add time). The pass does
-not guess: the row is reported `custom` in status with both values, and
-the host keeps running on today's flat path. `carlos add` on the box,
-re-adding the route with the flat defaults, is the repair, and it is
-box-local because the row was made box-locally; a console-provisioned
-row (`reconcile.go:300-301`) is never `custom`.
+`rt.Socket`; `backend_exec.go:220-225, 557-562`). On the same boot the
+nested run directories still exist, so the old binary can bind the
+nested socket and serve the nested database while minting flat env
+files; after a reboot the run directories are gone and those hosts fail
+to bind, loudly and losslessly. Either way the database has one history,
+the nested one, and the revert's step 5 handles the stray env files when
+the new binary is back. The layout is never two databases: the fence
+and the destination check see to that.
+
+### 8.3 `custom` and `foreign` rows
+
+A row whose `DBPath` or `Socket` is neither default was set by hand
+(`carlos add --db`; `carlos route` has no `--db` flag, `cli.go:469-475`,
+so it was set at add time). If both paths are still under `DataDir` and
+`SocketDir` the row is `custom` and **the pass migrates it from the
+paths it actually names** (step 3), through the console verb like every
+other row (R1-18). Only a row naming a path outside those trees is
+`foreign`: it is reported with both values, keeps running on today's
+flat path, and `carlos add` on the box, re-adding the route with the
+defaults, is the repair, box-local because the row was made box-locally.
+A console-provisioned row (`reconcile.go:300-301`) is never either.
### 8.4 Status, through the API (codex R1-18)
`internal/status/snapshot.go` gains a per-route `layout` field with one
of `flat`, `nested`, `fenced` (with the journal's phase and reason),
-`custom` (with both values), `external`. `carlos status`, `carlos
-instances --layout` and the console's instance page render it; a box
-that predates the field shows `unknown (box predates layout reporting)`.
-It is the pre-roll inventory (every row `flat` or `custom` before the
-verb), the progress view during a migration, the member-visible proof
-that an instance is isolated (`nested`), and the reason a `fenced` host
-does not wake. `docs/site/cli.md` names the new verbs and flags (the
-docsite gate fails otherwise).
-
-### 8.5 Gate: no isolation, no nested tenants (codex R1-15, R2-14)
+`custom`, `foreign` (with both values), `external`, and a box-level
+`layout_mode` (`flat`, `nested`, or `none` before any intent). `carlos
+status`, `carlos instances --layout` and the console's instance page
+render them; a box that predates the field shows `unknown (box predates
+layout reporting)`. It is the pre-roll inventory, the progress view
+during a migration, the member-visible proof that an instance is
+isolated (`nested`), and the reason a `fenced` host does not wake.
+`docs/site/cli.md` names the new verbs and flags (the docsite gate fails
+otherwise).
+
+### 8.5 Gate: no isolation, no nested tenants (codex R1-15, R2-14, R3-4)
A box that cannot establish the namespace **does not start nested
tenants**. There is no fallback unit, no "proceed without
@@ -1333,37 +1424,49 @@ verdict line:
- `/data` and `/run/carlos` list nothing but the probe's own bound
directories;
- `/proc` lists only itself, and its own pid is 1;
-- `connect()` to `169.254.169.254:80` **and** to `[fd00:ec2::254]:80`
- fails with **`EPERM`** (the BPF filter's answer), while `connect()` to
- `127.0.0.1:1` fails with `ECONNREFUSED` (the network is live and the
- filter, not the network, said no). Any other errno on the metadata
- addresses, including a timeout, is `not enforced` (R2-14);
-- a symlink it creates in its directory is refused with `ELOOP`;
-- `/proc/self/mountinfo` shows `nosymfollow` on both bound directories.
+- a symlink it creates in its directory is refused with `ELOOP`, and
+ `/proc/self/mountinfo` shows `nosymfollow` on both bound directories;
+- **IMDS, for a packet filter** (R3-4). `IPAddressDeny=` is a cgroup BPF
+ egress filter; it drops packets, and a TCP `connect()` whose SYN is
+ dropped retries and times out rather than returning `EPERM`
+ (`tcp_connect`, `net/ipv4/tcp_output.c`). So the probe sends a UDP
+ datagram, where the filter's verdict comes straight back from
+ `sendmsg`: `sendto(169.254.169.254:80)` must fail with `EPERM`, and
+ `sendto([fd00:ec2::254]:80)` must fail with `EPERM` **or**, if the
+ host has no IPv6 route at all (the control service reads `ip -6 route
+ show default` and passes the answer in as the probe's environment),
+ with `ENETUNREACH`, which is then reported as `v6: no route, deny not
+ exercised` rather than `enforced`. As a control, `sendto(127.0.0.1:9)`
+ must succeed (the network stack is live and the filter, not the
+ stack, said no). A 2s TCP `connect()` to the IPv4 address must not
+ succeed. Any success on a metadata address, or any errno other than
+ those named, is `not enforced`.
The service records the verdict; `GET /ready` returns it; the agent
refuses every nested wake with the verdict's failing line until it is
204. The probe cannot pass while an instance would not be isolated,
because it runs under the same unit text (a test asserts the probe's
namespace block equals the templates'), and it fails only on a line
-that names the property that is missing.
+that names the property that is missing. It can fail while instances
+would be isolated only on a box with no IPv6 route whose operator has
+somehow made `ENETUNREACH` not the answer, which the verdict line names.
### 8.6 Rollout order
1. **Roll this release** to the dev fleet (`kass`, two boxes), boxes
before console, with `update-platform-binary.sh` enabling the new
units by name before it restarts the edge (§9). Every row stays
- `flat`; the probe verdict is `ready`; nothing else changes. Cold-boot
- one dev box and confirm both mounts, the control socket and the probe
- come up (R2-2).
+ `flat`; `layout_mode` is `none`; the probe verdict is `ready`; nothing
+ else changes. Cold-boot one dev box and confirm the three mounts, the
+ control socket and the probe come up (R2-2).
2. **`carlos system layout migrate --box <dev box>`**, watch the rows
turn `nested`, then the second box. The test that matters is from
inside: the plan's verification task provisions a throwaway instance
whose seed lists `/data` and `/run/carlos`, reads `/proc/1/status`,
- connects to both metadata addresses and to loopback, and plants and
- follows a symlink, and reads the results back through `carlos logs`.
- Run `layout revert` on one box and `migrate` again: both directions
- are exercised before the flagship sees either.
+ sends the three datagrams, and plants and follows a symlink, and
+ reads the results back through `carlos logs`. Run `layout revert` on
+ one box and `migrate` again: both directions are exercised before the
+ flagship sees either.
3. **Flagship**: roll the release region by region, console box last;
then `layout migrate` one box at a time. The AU box has failed every
gate since 2026-09-07 for an unrelated reason; do not read its failure
@@ -1372,14 +1475,16 @@ that names the property that is missing.
the flagship has run a day, then migrates. The seven flagged instances
move; the router family (unflagged) is reported `external` and left
alone. Reply on #126 with the build and the check list above.
-5. **The next release** removes the flat spawn path, `FlatDBPath`,
- `FlatSocket`, the `custom`/`flat` classifications, the spawn half of
+5. **The next release** removes the flat spawn path for activator-managed
+ hosts, points `DefaultDBPath`/`DefaultSocket` at the nested helpers
+ for flagged routes (and keeps them flat for unflagged ones, whose
+ supervisors own their paths), removes the spawn half of
`internal/cgroup` and `Delegate=` on the edge, and refuses to start on
- a box with a `flat` row.
+ a box whose `layout_mode` is not `nested`.
**The roll script and the planner.** `update-platform-binary.sh:52-70`
(pinfra) enables units by name; it gains `carlos-instance-control.socket`,
-both mount units and the probe, before it restarts the edge, and
+the three mount units and the probe, before it restarts the edge, and
`bootstrap.go`'s `systemctlPlan` (`:165-179`) starts `.socket` and
`.mount` units it enables, which it does not today (R1-14).
`ensureSystemUnits` (`systemunits.go:34-83`) checks the control socket
@@ -1394,21 +1499,27 @@ unaffected, which is the point of the two-layout release.
The rule (AGENTS.md): the consumer list is everything that parses the
shape, not everything that calls the API. From the sweep of 2026-09-11,
-extended by codex R1-9, R1-14, R1-17, R1-18, R2-8, R2-9, R2-10, R2-13:
+extended by codex R1-9, R1-14, R1-17, R1-18, R2-8, R2-9, R2-10, R2-13,
+R3-1, R3-6:
**Path builders that move onto the helpers**
-- `internal/provision/provision.go:1011-1012` — `DefaultSocket`, `DefaultDBPath` (the definitions), plus `InstanceDir`, `InstanceRunDir`, `LogSocket`, `FlatDBPath`, `FlatSocket`
-- `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); every nested read through `instancefs`; the classification of §8.1
+- `internal/provision/provision.go:280` (`DerivesPaths`, unchanged, but
+ named: it is why unflagged routes mint flat), `:1011-1012`
+ (`DefaultSocket`, `DefaultDBPath`, flat in this release), plus
+ `InstanceDir`, `InstanceRunDir`, `NestedDBPath`, `NestedSocket`,
+ `LogSocket`
+- `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); every nested read through `instancefs`; the classification of §8.1; the peer-uid check on the health and control dials (§5.4)
+- `internal/edge/edge.go:2000-2014` (`unixProxy`: the peer-uid check on every dial, §5.4)
- `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
- `internal/schedule/runner.go:461` and `internal/host/schedule_wiring.go:163` (admin token path, by classification)
- `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
-- `cli.go:135-136` (`carlos add` default), `:157-159` (relative-path refusal becomes "must be under InstanceDir" for nested rows), the new `carlos instances --layout` and `carlos system layout migrate|revert`; `docs/site/cli.md` names them
-- `internal/provision/reconcile.go:300-301` (`routeFor` mint) — new rows mint nested; `:461-464` and `:873-881` compare derived paths and log a `flat` row as drift for this one release (the message names the verb)
+- `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
+- `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)
**Readers of `rt.Socket` (no change; the value changes per host)**
-- `internal/edge/edge.go:536-538, 2000-2014` (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`
+- `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`
**The socket consumers group** (R2-10): `internal/host/bootstrap.go:41-62`
(`socketsGroup`, `tmpfilesConf` gains the lines of §3.2), the per-host
@@ -1424,16 +1535,16 @@ instance as an enrolled non-`carlos` uid
**Metrics and status**
- `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>`)
-- `internal/status/snapshot.go` gains the per-route `layout` field of §8.4; `carlos status` and the console instance page render it
+- `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
**The updater and the control plane**
-- `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, and a separate mark in `restart-marks.json`'s sibling `layout-marks.json`
+- `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`
- `internal/console`: the `POST /api/cli/system/layout` door (fleet-operator gate, fresh auth), `carlos system layout` in `system.go`
**Units and installers**
-- `internal/host/units/` gains: three templates, two slices, two 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=`
+- `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=`
- `internal/host/bootstrap.go:61` (tmpfiles lines), `:116-122` `enabledUnits` (control socket, mounts, probe), `:165-179` `systemctlPlan` (starts `.socket`/`.mount`)
- `internal/host/systemunits.go:34-83` (active check on the socket)
- `update-platform-binary.sh:52-70` (pinfra): enables the new units by name before restarting the edge
@@ -1451,63 +1562,76 @@ sentinel `.path` units name fixed files under `/data`, never a host.
Unit level, all runnable in CI (no systemd needed):
-- `internal/provision`: `InstanceDir`, the nested defaults, the flat
- helpers, `LogSocket`, the escape function against `systemd-escape`
- fixtures, `carlos add` refusal of a nested `--db` outside the directory.
+- `internal/provision`: `InstanceDir`, the nested helpers, the flat
+ defaults unchanged, `LogSocket`, the escape function against
+ `systemd-escape` fixtures.
- `instancefs`: refuses a symlink at every component, refuses `..`,
- `O_EXCL` temp plus rename, the mountinfo check.
-- `internal/activator`: classification of the four row shapes; `prepare`
- mints the token for seed and run, writes `launch.<role>.json` with the
- deduplicated env and the exact argv (the existing env-composition tests
- move their assertion to the effective environment of an executed stub);
- the control client sends the tier and the token with a deadline, and
- retries once on 401 after re-reading the token; `unitProc.Stop` orders
- stop → done → close listeners; `Exited()` only after an exit record (or
- an unloaded unit) **and** an empty cgroup, never on log EOF, with a
- populated cgroup holding `done` open; a foreign invocation's record;
- the failed-health path posts `stop` and waits; seed timeout and
- cancellation stop-and-wait before cleanup; the startup sweep stops
- activating units, cancels jobs and waits for empty cgroups; the sidecar
- restart reserves under the lock and installs or stops after, with a
- stalled start while another host wakes and stops. The control channel
- is a seam (`Control interface { Start; Seed; Stop; Reset; Ready }`)
- with a fake, as `Systemctl` is today; the exit observer takes a fake
- records directory and a fake cgroup tree.
+ `O_EXCL` temp plus rename, the mountinfo check for all three mounts.
+- `internal/edge` and `internal/activator` dials: a listener whose peer
+ is uid 0 is refused before a byte is sent; a `carlos` or DynamicUser
+ peer is accepted.
+- `instance-control`: a connection whose peer pid is not the edge's
+ `MainPID`, or whose uid is not `carlos`, is closed without a response;
+ every 400 (bad host, bad role, bad limit); the unit and slice names it
+ builds for a host with a dash; `set-property` (three properties) →
+ `start` ordering with no `reset-failed`; `/reset` tolerating "not
+ loaded"; `/seed` returning the show fields when loaded and an empty id
+ when not; `/ready` from the probe verdict; with a fake `systemctl`
+ runner and a fake peer.
+- `internal/activator`: classification of the five row shapes;
+ `prepare` mints the token for seed and run, writes `launch.<role>.json`
+ with the deduplicated env and the exact argv (the existing
+ env-composition tests move their assertion to the effective
+ environment of an executed stub); the control client's per-verb
+ deadlines (30s, 150s for seed); `unitProc.Stop` orders stop → done →
+ close listeners and returns `ErrStillRunning` on expiry without
+ closing; `stop()` skips `rep.Close` on `ErrStillRunning` and the
+ sweeper retries; `Exited()` only after a terminal unit state **and** an
+ empty cgroup, never on log EOF or on a record alone, with a populated
+ cgroup holding `done` open; a forged exit record for a running unit
+ ignored; a foreign invocation; the failed-health path posts `stop` and
+ waits; seed timeout and cancellation stop-and-wait before cleanup; the
+ startup sweep stops activating units, cancels jobs and waits for empty
+ cgroups; the sidecar restart reserves under the lock and installs or
+ stops after, with a stalled start while another host wakes and stops.
+ The control channel is a seam (`Control interface { Start; Seed; Stop;
+ Reset; Ready }`) with a fake; the exit observer takes a fake `show`, a
+ fake run directory and a fake cgroup tree.
- `instance-exec`: reads the file through `instancefs`, refuses schema
≠ 1, refuses an `argv[0]` that is not the live link, execs.
- `instance-exit`: writes the record with `O_EXCL` from the four
- variables; refuses to overwrite.
-- `instance-control`: 401 without the token; every 400 (bad host, bad
- role, bad limit); the unit and slice names it builds for a host with a
- dash; `set-property` (three properties) → `oom.group` write → `start`
- ordering with no `reset-failed`; `/reset` tolerating "not loaded";
- `/ready` from the probe verdict; with a fake `systemctl` runner and a
- fake cgroup tree.
+ variables into the run directory; refuses to overwrite.
- `instance-probe`: each verdict line, including `EPERM` versus
- `ECONNREFUSED` versus timeout on the metadata addresses, with a fake
- dialer.
+ `ENETUNREACH` (with and without a v6 route) versus other errnos on the
+ datagram sends, the loopback control, and the TCP connect bound, with
+ a fake socket layer.
- `logListener`: one connection per invocation, a second refused and
logged, line cap, EOF ends collection without marking exit.
- Layout pass: eligibility by the activator's predicate (flagged only;
- external, tunnel, unit-backed untouched); quiesce before plan; the
- ownership preflight for `x`/`x.config` in both directions; destination
- collision refusal; the journal's phases and every crash boundary
- (before files, mid-files, after files, after CAS, before journal
- removal) recovered exactly, with absent optional artifacts; a CAS
+ external, tunnel, unit-backed untouched); convergence on every serial
+ move; a later-flagged row migrating on the next pass; `custom` rows
+ migrated from their actual paths; `foreign` rows reported; quiesce
+ before plan; the ownership preflight for `x`/`x.config` in both
+ directions; DB destination collision refused, env destination moved
+ aside; the journal's phases and every crash boundary (before files,
+ mid-files, after files, after CAS, before journal removal) recovered
+ exactly, with absent optional artifacts; a journal whose paths or mode
+ do not match the recomputed plan and the fleet's intent refused; a CAS
conflict keeps the fence; a second clean start does nothing; every
fenced consumer (`Start`, `Seed`, `Restore`, moves pass, `SyncExec`,
litestream generator) refuses a fenced host; a partial rename followed
by a `MoveCutting` pass does not complete the flip; revert restores the
- flat layout byte for byte; a forged journal-shaped file and a forged
- launch file in an instance directory move nothing (§3.3).
+ flat layout byte for byte, aside files included; a forged journal, a
+ forged launch file and a forged exit record move nothing (§3.3).
- Units: a test over `unitsFS` asserts the three templates' and the
probe's namespace, hardening and coupling blocks are identical, that
`PrivatePIDs=yes`, `TemporaryFileSystem=/data:ro`,
`IPAddressDeny=169.254.169.254/32`, `Restart=no`,
`BindsTo=carlos-edge.service`, `Slice=carlos-instances-%i.slice`,
- `ExecStopPost=+` and the `%I` paths are present, that the seed carries
- `TimeoutStartSec=120`, that the control socket is `SocketMode=0600
- SocketUser=carlos`, that both mounts carry `nosymfollow` and
+ `OOMPolicy=kill`, an `ExecStopPost=` **without** `+`, and the `%I`
+ paths are present, that the seed carries `TimeoutStartSec=120`, that
+ the control socket is `SocketMode=0600 SocketUser=carlos` under
+ `/run/carlos-control`, that all three mounts carry `nosymfollow` and
`DefaultDependencies=no` with the ordering of §3.2, and that
`carlos-edge.service` carries `Slice=carlos.slice` and no `MemoryMax=`;
and, where `systemd-analyze` exists on the CI host, `systemd-analyze
@@ -1518,10 +1642,10 @@ Unit level, all runnable in CI (no systemd needed):
sampler roots publish per-host under `carlos-exec@<host>` and the edge
as the slice total, flat hosts still from the delegated subtree; the
#103 consumer dial through the nested run directory.
-- `internal/status`: the `layout` field for each of the five states and
- the pre-field box.
+- `internal/status`: the `layout` field for each of the six states, the
+ box mode, and the pre-field box.
- `internal/console` and `system.go`: the layout door's gate and
- refusal order, the bucket object, the scope forms.
+ refusal order, the bucket object, the scope forms, convergence marks.
Box level, in the plan's verification task and in the rollout:
@@ -1531,18 +1655,19 @@ Box level, in the plan's verification task and in the rollout:
`IPAddressDeny=`) turns it `not enforced` and blocks nested wakes.
- Migrate one host; wake it; `systemctl status carlos-instance@<esc>`
active in `carlos-instances-<esc>.slice` with the three memory
- properties and `memory.oom.group=1`; from inside via the throwaway
- instance's seed: `/data` and `/run/carlos` list one directory each,
- `/proc/1` is the app, both metadata addresses `EPERM`, loopback
- `ECONNREFUSED`, a planted symlink `ELOOP`; `carlos logs` shows all of
- it for both instance and sidecar at once; a quick-exiting seed's record
- arrives after the unit is unloaded; hibernate, unit gone, record
- consumed; `carlos restart` cycles it; `systemctl restart carlos-edge`
- stops it and does not start it; an enrolled `carlos-sockets` uid dials
- `app.sock` and cannot open the control socket; `carlos instances
- --layout` says `nested`; the console's memory graphs for the app and
- for the platform are continuous across the migration; revert, and the
- previous release serves the host again.
+ properties; from inside via the throwaway instance's seed: `/data` and
+ `/run/carlos` list one directory each, `/proc/1` is the app, the three
+ datagrams answer as §8.5 says, a planted symlink `ELOOP`; `carlos logs`
+ shows all of it for both instance and sidecar at once; a quick-exiting
+ seed's status arrives; hibernate, unit gone, record consumed; `carlos
+ restart` cycles it; `systemctl restart carlos-edge` stops it and does
+ not start it; an enrolled `carlos-sockets` uid dials `app.sock` and
+ cannot open the control socket; a flat tenant on the same box cannot
+ get a response from `control.sock` directly or through its own
+ redirected socket; `carlos instances --layout` says `nested`; the
+ console's memory graphs for the app and for the platform are
+ continuous across the migration; revert, and the previous release
+ serves the host again.
---
@@ -1554,8 +1679,8 @@ Box level, in the plan's verification task and in the rollout:
layout with a root chown in the control service. Not in this change.
- **Externally supervised exec routes** (unflagged instance routes with
a socket, §8.2 step 1): not isolated by this change; their supervisor
- owns their process. Whether the platform should offer them the same
- namespace is a discussion to open once this lands.
+ owns their process and their flat paths. Whether the platform should
+ offer them the same namespace is a discussion to open once this lands.
- **Unit-backed console instances.** Not needed for isolation, and a
hibernation rewrite. Whether `backing=unit` hibernation works on the
fleet today (`UnitBackend` runs `systemctl` unprivileged, §1.4) is its
@@ -1566,8 +1691,12 @@ Box level, in the plan's verification task and in the rollout:
invisible to isolated instances but not to the agent.
- **Adopting running units on agent start** instead of stopping them
(§6.7).
-- **The next release** (§8.6 step 5): drop the flat path and the spawn
- half of `internal/cgroup`.
+- **The next release** (§8.6 step 5): drop the flat spawn path and the
+ spawn half of `internal/cgroup`; with it, the transition-only
+ exposures §3.2 and §6.4 name (a flat tenant redirecting its own socket,
+ a flat tenant writing to a log socket) end.
+- **Whole-family OOM across instance and sidecar** (§4.3): not
+ reproduced; recorded as a deliberate difference.
- **Discussion #1 (memory pressure)**: no new numbers here (§4.3); the
slice shape makes a later change a unit-file edit, never a drop-in.
@@ -1590,12 +1719,14 @@ Box level, in the plan's verification task and in the rollout:
Within 3: trampoline over `EnvironmentFile=` (§6.2, one env composer);
three templates over one (§4.2, the instance is the host); the control
-service with a token over polkit (§5.4, ruled with codex);
-`nosymfollow` mounts over per-read symlink checks (§3.2, kernel-enforced
-and race-free where a check-then-open is neither); an exit record from
-`ExecStopPost=+` over polling unit state (§6.5, survives unit unload);
-and an operator verb over an agent-start migration (§8.1, because the
-updater's rollback runs the old binary).
+service authenticating the agent's pid over polkit and over a token
+(§5.4, ruled with codex across two rounds); `nosymfollow` mounts over
+per-read symlink checks (§3.2, kernel-enforced and race-free where a
+check-then-open is neither); termination from unit state plus cgroup
+emptiness, with the unit's own record as decoration, over any record as
+proof (§6.5); and an operator verb recording a fleet intent that boxes
+converge to over an agent-start migration (§8.1, because the updater's
+rollback runs the old binary).
---
@@ -1603,14 +1734,14 @@ updater's rollback runs the old binary).
- **Round 1** (codex, GPT-6, `CODEX-FINDINGS-ISOLATED-EXEC.md`, 19
findings, verdict request changes). All accepted:
- R1-1 control service with token, `nosymfollow` (§3.2, §5.4);
+ R1-1 control service, `nosymfollow` (§3.2, §5.4);
R1-2 log sockets outside tenant mounts, per role (§3.1, §6.4);
R1-3 `nosymfollow` plus `instancefs` and `O_EXCL` temps (§3.2, §7);
- R1-4 exit from a record plus cgroup emptiness, never from EOF (§6.5);
+ R1-4 exit from unit state plus cgroup emptiness, never from EOF (§6.5);
R1-5 seed timeout, stop verb, wait before cleanup (§4.2, §5.3, §6.2);
R1-6 per-role log listeners (§6.4);
R1-7 `BindsTo=` not `PartOf=`, sweep covers jobs (§4.1, §6.7);
- R1-8 per-host slice carries the tier and `memory.oom.group` for all three roles (§4.3, §6.6);
+ R1-8 per-host slice carries the tier for all three roles; whole-family OOM recorded as a difference after R3-3 (§4.3);
R1-9 cgroup path under `carlos.slice`, key compatibility, `metrics_probes.go` (§6.6, §9);
R1-10 one common wall in `carlos.slice`, no new numbers (§4.3);
R1-11 eligibility is the activator's predicate (§8.2);
@@ -1620,21 +1751,30 @@ updater's rollback runs the old binary).
R1-15 no `PrivatePIDs=` fallback; behavioural gate and probe (§8.5);
R1-16 env deduplication, effective-value test (§6.2);
R1-17 nested glob is discovery, not reporting (§9);
- R1-18 `layout` status through the API and CLI, `docs/site/cli.md`; repair is `carlos add`, not a `--db` flag `carlos route` does not have (§8.3, §8.4);
+ R1-18 `layout` status through the API and CLI; `custom` rows migrated through the verb; `docs/site/cli.md` (§8.3, §8.4);
R1-19 `UnitBackend` claim removed (§1.4, §11).
- **Round 2** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R2.md`, 14 new
findings, verdict request changes). All accepted:
- R2-1 journal outside tenant mounts, plan from row and host only, nothing in an instance directory is an instruction (§3.3, §8.2);
- R2-2 mounts with `DefaultDependencies=no` and explicit ordering, cold-boot verification, `systemd-analyze verify` in CI (§3.2);
+ R2-1 journal outside tenant mounts, plan from row and host only (§3.3, §8.2);
+ R2-2 mounts with `DefaultDependencies=no` and explicit ordering, cold-boot verification (§3.2);
R2-3 no `reset-failed` in `/start` (§5.3);
- R2-4 exit record from `ExecStopPost=+`, survives unit unload; foreign invocation handling (§6.5);
- R2-5 cgroup emptiness required on every path before `done` (§6.5);
- R2-6 sidecar restart reserved under the lock, started outside it with a deadline (§5.3, §6.7);
+ R2-4 termination not dependent on unit retention; `/seed` correlation (§5.3, §6.5);
+ R2-5 cgroup emptiness before `done`; `stop()` does not release over `ErrStillRunning` (§6.3, §6.5);
+ R2-6 sidecar restart reserved under the lock, started outside it with a deadline (§6.7);
R2-7 journal records artifact presence and phase; committed-row recognition; classification before legacy test (§8.1, §8.2);
R2-8 ownership preflight in both directions from `SyncExec`'s rule (§8.2);
- R2-9 two-layout release and operator verb; the updater's rollback lands on a layout the old binary serves (§8.1, §8.2, §8.6);
+ R2-9 two-layout release and operator verb; same-boot rollback behaviour stated; env aside rule (§8.1, §8.2);
R2-10 `carlos-sockets` traversal preserved on the run directories (§1.1, §3.1);
R2-11 `MemorySwapMax` on the host slice (§4.3);
R2-12 seed prepares the admin token and merged env (§6.1);
R2-13 edge discovered in `carlos.slice`, published as the slice total (§6.6);
- R2-14 probe distinguishes `EPERM` from unreachable, tests both metadata addresses (§8.5).
+ R2-14 probe distinguishes enforcement from unreachability (§8.5, reworked by R3-4).
+- **Round 3** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R3.md`, 7 new
+ findings, verdict request changes). All accepted:
+ R3-1 no token; the control service authenticates the edge's `MainPID`; the edge refuses root peers; `/run/carlos/logs` on a `nosymfollow` mount; journals checked against the recomputed plan and the fleet's intent (§3.2, §3.3, §5.4);
+ R3-2 exit record from `ExecStopPost=` without `+`, advisory only, in the run directory (§4.1, §6.5);
+ R3-3 no manual `memory.oom.group`; per-service families, difference recorded (§4.3);
+ R3-4 UDP `EPERM` probe with a loopback control and a no-route rule for v6 (§8.5);
+ R3-5 150s seed deadline (§5.3);
+ R3-6 nothing minted nested in this release; unflagged routes flat by contract (§3.1, §8.1, §8.6);
+ R3-7 the record lives where the agent can delete it (§6.5).
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-ISOLATED-EXEC-R3.md b/docs/superpowers/specs/CODEX-FINDINGS-ISOLATED-EXEC-R3.md
new file mode 100644
index 000000000..17b1c8200
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-ISOLATED-EXEC-R3.md
@@ -0,0 +1,91 @@
+| Prior finding | Disposition at `bcf3fcbb` |
+|---|---|
+| R2-1 | **Resolved for the reported forged-journal attack.** Authoritative journals move outside nested tenant mounts. The newly retained flat execution path creates a separate exposure, R3-1. |
+| R2-2 | **Resolved.** Explicit mount ordering removes the reported cycle; cold-boot verification is specified. |
+| R2-3 | **Resolved.** `/start` no longer resets an unloaded service. |
+| R2-4 | **Partially resolved.** Persistent records are specified, but `/seed` still obtains its invocation ID through `show` **after** the oneshot finishes and can unload ([§5.3:765](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:765)). Retain request-to-invocation correlation before completion. A foreign invocation’s exit record also cannot detect that invocation while it remains running. The new record writer has an additional defect, R3-2. |
+| R2-5 | **Partially resolved.** `done` requires emptiness, but `Stop` may return on context expiry ([§6.3:950](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:950)). The unchanged activator then calls `rep.Close`, releasing the lease despite unsuccessful termination ([activator.go:1015](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/internal/activator/activator.go:1015), [1033](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/internal/activator/activator.go:1033)). Preserve the lease until termination is proven. |
+| R2-6 | **Resolved at spec level.** Reservation, bounded work outside the mutex, generation checks and stop coordination are specified. |
+| R2-7 | **Resolved.** Presence recording, committed-row recognition and settled-nested classification cover the reported recovery states. |
+| R2-8 | **Resolved.** Ownership preflight covers both migration directions. |
+| R2-9 | **Partially resolved.** Explicit migration protects existing rows during the initial upgrade, but new-row creation contradicts that protection, R3-6. The claimed lossless refusal after downgrade is also false: a same-boot rollback retains nested run directories, so the old backend can serve nested DB/socket paths while minting a flat env file ([backend_exec.go:557](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/internal/activator/backend_exec.go:557), [579](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/internal/activator/backend_exec.go:579)). That creates a destination collision which the subsequent revert refuses. The downgrade protocol must prevent this mixed-layout execution. |
+| R2-10 | **Resolved.** The run-directory traversal and socket-group contract are preserved. |
+| R2-11 | **Resolved.** The shared host slice receives `MemorySwapMax`. |
+| R2-12 | **Resolved.** Seed and run both prepare the admin token before composing their environment. |
+| R2-13 | **Resolved.** Relocated discovery and preservation of the published aggregate are explicit. |
+| R2-14 | **Partially resolved.** Both address families are tested and unrelated failures cannot produce readiness, but the replacement assertion rejects effective filtering, R3-4. |
+| R1-4 | **Partially resolved.** EOF and mutex defects are addressed; the remaining completion defects are recorded under R2-4, R2-5 and R3-2. |
+| R1-5 | **Partially resolved.** Preparation, cancellation and cleanup ordering are specified. Completion remains affected by the findings above, and the new control deadline shortens the seed budget, R3-5. |
+| R1-8 | **Partially resolved.** The shared tier is restored, but systemd can overwrite the proposed family OOM setting, R3-3. |
+| R1-12 | **Resolved at spec level.** The journal, recovery protocol and named consumer fences address the reported partial-migration failure. |
+| R1-13 | **Partially resolved.** Reverse migration exists; the remaining downgrade defects are recorded under R2-9. |
+| R1-15 | **Partially resolved.** The unsafe fallback is removed, but the replacement gate cannot establish readiness as written, R3-4. |
+| R1-18 | **Partially resolved.** Remote reporting and compatibility with pre-field boxes are specified. Repair remains explicitly box-local in [§8.3:1301](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:1301); the requested API-backed repair operation remains absent. |
+
+**R3-1 — P1 — §§5.4, 8.1–8.3: Retain the authenticated control service, but protect it from the flat tenants this revision keeps running.**
+
+The two-layout release runs `flat` and `custom` hosts as ordinary agent children. Those processes can read `/run/carlos/instance-control.token`, connect to the control socket, and write `/run/carlos/logs`. The assertion that the token is unavailable to tenants therefore becomes false during the specified transition.
+
+This also exposes a new root-write route. A flat tenant can replace a role’s log pathname with a symlink to a root-owned file, then authenticate a start request. It can create the required nested directories and launch description for its own host. Systemd opens the specified stdout pathname before applying the tenant sandbox, handing the app a writable descriptor to the target. The `nosymfollow` mounts do not cover `/run/carlos/logs`.
+
+Evidence: [flat/custom execution:1187](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:1187), [token:746](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:746), [log directory:273](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:273), [systemd stdout setup](https://raw.githubusercontent.com/systemd/systemd/v257/src/core/exec-invoke.c).
+
+**Correction:** retain the bearer-authenticated service, but define a transition boundary that excludes every unisolated tenant from its credentials and root-opened paths. Do not activate this authority alongside unchanged same-uid flat children. Include `custom` and excluded same-uid processes in that boundary.
+
+**R3-2 — P1 — §§4.1, 6.5: `ExecStopPost=+` still receives the explicit tmpfs mounts and cannot write the exit record.**
+
+The `+` prefix disables privilege restrictions and several sandbox settings. It does **not** remove the explicit `TemporaryFileSystem=` and `BindPaths=` configuration in systemd 257. Its execution code passes both into namespace setup independently of `needs_sandboxing`.
+
+Consequently, `instance-exit` runs as root but still sees the empty `/run/carlos` tmpfs with only the instance directory bound back in. `/run/carlos/exits` is absent. The helper cannot write its record; its failure can leave the unit failed with an invocation ID, which also excludes the spec’s unloaded-unit fallback. `done` can remain open permanently.
+
+Evidence: [template:509](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:509), [record writer:995](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:995); systemd’s `apply_mount_namespace`, particularly lines 2993 and 3093–3097, in [exec-invoke.c](https://raw.githubusercontent.com/systemd/systemd/v257/src/core/exec-invoke.c).
+
+**Correction:** use a completion-record mechanism that actually operates in the host mount namespace. Keep its writable endpoint unavailable to the app; `+` alone does not establish that separation.
+
+**R3-3 — P2 — §§4.3, 6.6: systemd manages the slice’s `memory.oom.group` and can clear the manual setting.**
+
+The spec explicitly relies on systemd leaving this attribute alone. In v257, the common memory-controller application code writes `memory.oom.group` from the unit’s `CGroupContext`, including for slices. Its default is false.
+
+Reapplying memory properties therefore writes `0`. The specified sidecar-start sequence does exactly that on a slice whose main process is already running, before the broker writes `1` again. Other systemd reapplications have no corresponding repair step. The family OOM guarantee does not hold continuously.
+
+Evidence: [§4.3:679](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:679); `cgroup_context_apply`, lines 2030–2052, in [systemd cgroup.c](https://raw.githubusercontent.com/systemd/systemd/v257/src/core/cgroup.c), and [slice property application](https://raw.githubusercontent.com/systemd/systemd/v257/src/core/dbus-slice.c).
+
+**Correction:** give the family OOM setting a supported ownership and update mechanism that preserves it while tenants run, including across property changes and reloads. Remove the claim that systemd does not manage it.
+
+**R3-4 — P1 — §8.5: The readiness probe rejects correctly enforced IMDS filtering.**
+
+`IPAddressDeny=` attaches packet filters, not a connect-address rejection hook. For an ordinary TCP connection, dropping the outgoing SYN does not guarantee that `connect()` returns `EPERM`. Linux’s TCP connect path retains most transmission failures and retries the SYN; the caller can time out. An unavailable IPv6 route can fail before filtering is exercised.
+
+The probe classifies both outcomes as `not enforced`, so a correctly isolated box can never become ready and its nested tenants cannot wake.
+
+Evidence: [required errno:1336](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:1336), [systemd ingress/egress filter attachment](https://raw.githubusercontent.com/systemd/systemd/v257/src/core/bpf-firewall.c), and `tcp_connect`, lines 3862–3881, in [Linux 6.12 tcp_output.c](https://raw.githubusercontent.com/torvalds/linux/v6.12/net/ipv4/tcp_output.c).
+
+**Correction:** prove effective filtering using an observation appropriate to packet filtering, with controlled reachability and evidence for both address families. Preserve the refusal on unavailable evidence; do not substitute either “any connection failure” or “must return EPERM.”
+
+**R3-5 — P1 — §§5.3, 6.2: The universal 30-second control deadline makes valid seeds permanently unwakeable.**
+
+`/seed` blocks until its oneshot finishes, but every agent control request now has a 30-second deadline. A seed that normally completes in 45 seconds is cancelled and stopped on every wake, despite the explicitly retained 120-second seed budget. Its partial database is then removed and the next wake repeats the failure.
+
+Evidence: [blocking seed request:766](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:766), [universal deadline:799](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:799), [seed cleanup:927](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:927), [existing budget:466](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/internal/activator/backend_exec.go:466).
+
+**Correction:** give the blocking seed request a budget covering seed execution and termination, or return its invocation promptly and observe completion separately. Keep sidecar control calls bounded independently.
+
+**R3-6 — P1 — §§8.1, 8.2, 9: Unconditional nested defaults bypass the migration boundary.**
+
+Section 9 requires newly provisioned rows to use nested paths. A provisioning pass during the candidate’s gate can therefore create a nested host before any `layout migrate` command. Failed-gate rollback then encounters the incompatible layout that the two-layout release claims cannot exist yet.
+
+The same default change also reaches externally supervised routes. `routeFor` uses `DerivesPaths()`, whose predicate includes socket instances with `Hibernate=false`; `carlos add` likewise assigns defaults independently of hibernation. Those new routes receive nested paths although their unchanged supervisors still use the flat layout.
+
+Evidence: [new-row requirement:1407](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:1407), [compatibility promise:1182](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:1182), [routeFor:299](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/internal/provision/reconcile.go:299), [DerivesPaths:280](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/internal/provision/provision.go:280), [CLI defaults:118](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/cli.go:118).
+
+**Correction:** select new-row defaults using both activator eligibility and explicit box migration state. Before migration is enabled, retain flat defaults; externally supervised routes retain their existing path contract.
+
+**R3-7 — P2 — §§3.1, 6.5: The agent cannot delete consumed exit records.**
+
+`/run/carlos/exits` is `0750 root:carlos`, while the agent runs as uid `carlos`. Reading a record is permitted; unlinking it requires write permission on the directory, which the agent lacks. Both per-record acknowledgement and startup cleanup therefore fail, leaving records to accumulate in `/run`.
+
+Evidence: [directory ownership:277](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:277), [required cleanup:1033](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/isolated-exec-spec/docs/superpowers/specs/2026-09-11-isolated-exec-design.md:1033).
+
+**Correction:** assign acknowledgement and cleanup to the root broker through a bounded operation, preserving the protection against forged records.
+
+**Verdict: request changes.**
\ No newline at end of file