# Isolated exec — every exec-backed instance in its own namespace

**Date:** 2026-09-11 · **Status:** revision 17; codex verdict after
round 11 is **accept with corrections**, and the one correction is
applied here (54 findings over 11 rounds, all accepted; §13); a
whole-branch final review (plan 2) found six further items, fixed in
revision 13 (§13); plan 3's task 4 review settled the requested-stop
close, recorded in revision 14 (§6.5, §13 R13); the first roll's probe
found two defects, fixed in revision 15 (§4.1, §8.5, §13 R15); awaiting Paul's review · **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
systemd 257 and on carlos-tito-ie); `internal/edge/purge.go:53-107` and
`internal/activator/admintoken.go:6-14`, which already describe the gap;
the exec-child accounting design (2026-08-07, pinfra
`platform-design/superpowers/specs/`), the memory-pressure containment
design (2026-08-28, same place), the cross-user socket consumers group
(issue #103, `internal/host/bootstrap.go:41-62`), the system-update
design (2026-09-06) whose apply/rollback path the transition must
survive, and the root-relay pattern (`carlos-stage.path` →
`carlos adopt --stage-only`).

---

## 0. How this spec was arrived at

Tito filed #126 after verifying on carlos-tito-ie that an exec-backed
instance can read every other instance's database, admin token and
delivered config, including the relay route's platform credentials, and
including instances of other accounts' apps. The discussion offered two
shapes: make console-provisioned instances unit-backed, or isolate exec
children in place with a per-host directory plus a uid or namespace.

Three approaches were compared against the code on 2026-09-11 (§12).
Paul approved the third on the same day and asked that the one open
mechanism choice, how the unprivileged agent starts a root-managed unit,
be settled between Fable and codex during review. Codex's round 1
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. 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
a systemd template unit, still uid `carlos`, inside a mount namespace in
which nothing but that instance's own directory exists, on a mount where
no symlink is ever followed.** No uid change, no console change, no new
backing. One release carries both layouts; an operator verb moves each
fleet across; the same verb moves it back.

---

## 1. What exists, and what is wrong

### 1.1 The agent and its children

`carlos agent` runs as `User=carlos` under `carlos-edge.service`
(`internal/host/units/carlos-edge.service`), with
`CapabilityBoundingSet=CAP_NET_BIND_SERVICE`, `RestrictNamespaces=yes`,
`ReadWritePaths=/data /run/carlos` and `Delegate=cpu memory io pids`. It
cannot change a child's uid and cannot build a mount namespace for one.

Every exec-backed instance is a plain child of that process:
`internal/activator/backend_exec.go:690-692` builds
`exec.Command(bin, "--socket", rt.Socket, "--db", dbPath)` with
`cmd.Env = childEnv(childVars)`; no `cmd.Dir`, no `SysProcAttr.Credential`
(the only `SysProcAttr` fields set anywhere in the tree are the cgroup
manager's `Setpgid`, `Pdeathsig`, `UseCgroupFD` and `CgroupFD`,
`internal/cgroup/strict_linux.go:256`, `cgroup_linux.go:20-24`). Seed
(`backend_exec.go:377-410`) and sidecars (`:885-911`) are spawned the same
way.

Everything the instance owns sits flat in one directory, `DataDir`
(`/data`; `backend_exec.go:52-53`, `internal/host/agent.go:186-188`):

| File | Written by | Read by |
|---|---|---|
| `<host>.db` (+ `-wal`, `-shm`, `.activator-<host>.db-litestream/`) | the instance; restore, seed and replication in the agent | the instance, the agent's in-process replicator (`internal/lease/lease.go:257-299`) |
| `<host>.env` (admin token) | `EnsureAdminToken` (`admintoken.go:111-195`) | the agent (`backend_exec.go:350, 507, 579, 607, 790`; `internal/schedule/runner.go:461`) |
| `<host>-sidecar.env` | hand-staged | `backend_exec.go:865-877` |
| `<host>.config.env` | `internal/appconfig/syncexec.go:872-926` | `backend_exec.go:786` |

All of it is `carlos:carlos`, and every instance is uid `carlos`. Mode
0600 restricts nothing between siblings. `purge.go:53-95` says so in
prose, and names per-instance uids as "the real fix".

The sockets are shared on purpose with one more party: `/run/carlos` is
`2750 carlos:carlos-sockets` (`bootstrap.go:61`), and after the health
wait the activator normalises each instance socket to `0660`
(`backend_exec.go:744-750`), so a deployment's enrolled cross-user
consumer (titogo's router, uid `titogo`, issue #103) can dial every
instance socket on the box. That contract survives this change (§3.1).

### 1.2 What a sibling can reach today

Everything below is a read (or write) by any exec-backed instance of any
app on the box, as verified by Tito on 2026-09-10 and as the file table
above implies:

- every other exec instance's database, read and write;
- every other exec instance's admin token, which drives that host's
  schedule verbs (`admintoken.go:6-14`);
- every `<host>.config.env` in `/data`, including the ones for TCP-addr
  routes CARLOS never spawns (`carlos env exec-delivery file`), which for
  Tito carry every platform credential the relay holds (#127);
- every other exec instance's control socket, `rt.Socket + ".control"`
  (`backend_exec.go:594`; `purge.go:56-68`): a purge for another host, or
  a schedule tick with a token read from that host's env file;
- every other exec instance's **listening socket file** under
  `/run/carlos`: same uid, so a sibling can unlink `<other>.sock` and bind
  its own listener there, and the edge (`internal/edge/edge.go:536-538`)
  will dial it as that host. This is not in #126; it follows from the
  same uid and the same directory;
- `/data/registry.db`, `/data/litestream.env` (the box-wide replicator's
  bucket credentials, `carlos-litestream.service`) and, on a console box
  where `console.service` runs as `carlos` with `ReadWritePaths=/data`,
  `/data/console/console.db`. **Verify the console unit's user on the
  flagship before treating the last item as fact; the unit is
  hand-written and not in git.**
- the instance metadata service. `harden-host.sh:36-44` (pinfra) allows
  IMDS to uid 0 and uid `carlos` and drops everyone else, because the
  agent reads IMDS. Exec children are uid `carlos`. `carlos-app@.service`
  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: 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
from outside, as the same uid, by path. Any path the instance can write
and the agent later opens is therefore a request the instance makes on
the agent's behalf: replace `<host>.env` with a symlink to
`/data/litestream.env` and the next wake hands the bucket credentials to
the instance as its own environment (`backend_exec.go:785-794, 966-971`;
`EnsureAdminToken` only refuses the *chmod* through a symlink,
`admintoken.go:141-150`). Replace `<host>.db` with a symlink to a
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). 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

`carlos-app@.service` (`DynamicUser=yes`, `StateDirectory=carlos-app/%i`,
root-0600 config in `/etc/carlos/apps`) has the property #126 asks for: a
peer's state is absent, not permission-denied. But `UnitBackend`
(`internal/activator/backend_unit.go`) has none of the machinery the
exec backend gives a self-serve instance. Its `Acquire`, `Restore` and
`Seed` are inert (`:113-132`), replication belongs to the root
`carlos-litestream` unit because the agent cannot read a DynamicUser's
state directory (`:12-19`), `NextDue` returns nothing (`:174-179`), and
sidecars are refused (`:181-188`). `provision.Record` has no backing
field (`internal/provision/provision.go:36-135`) and the reconciler
refuses to adopt a row that carries one (`reconcile.go:426-436`). The
always-awake design already concluded that moving exec instances to
units "needs a separately reviewed root/unit/socket maintenance protocol
and data migration". Making console-provisioned instances unit-backed is
a rewrite of hibernation, not an isolation fix.

One more thing the sweep found: `UnitBackend.systemctl`
(`backend_unit.go:94-103`) runs `systemctl start` as the unprivileged
agent. Nothing in this tree or in pinfra grants uid `carlos` the
`org.freedesktop.systemd1.manage-units` action, and polkit is not
referenced anywhere. Whether `backing=unit` hibernation works on the
fleet today is a follow-up discussion (§11); this spec's channel is
scoped to the three instance templates and does not extend to
`carlos-app@` (codex R1-19).

---

## 2. The design in one page

1. **Per-instance directory.** An exec-backed instance's files move from
   `<DataDir>/<host>.*` to `<DataDir>/instances/<host>/` (§3). The agent
   creates the directory and everything in it, as `carlos`, exactly as it
   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 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
   `carlos-instance-seed@<host>.service`; its sidecar as
   `carlos-instance-sidecar@<host>.service` (§4). All three run as
   `User=carlos`, all three carry titogo's namespace
   (`TemporaryFileSystem=/data:ro` and `TemporaryFileSystem=/run/carlos:ro`
   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. 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 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;
   each host has its own slice inside that, carrying the tier (§4.3,
   §6.6).

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 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).

---

## 3. The instance directory

### 3.1 Layout

```
/data/.instances/                   the RAW directory: 0700 carlos:carlos; opened by the layout pass only (§3.2, §8.2)
/data/instances/                    nosymfollow bind mount OF /data/.instances (§3.2); the view everything else uses
/data/instances/<iid>/              0700 carlos:carlos, created by the agent
    <host>.db                        the database (+ -wal, -shm)
    .activator-<host>.db-litestream/ the in-process replicator's shadow
    <host>.env                       admin token (EnsureAdminToken, unchanged)
    <host>-sidecar.env               hand-staged sidecar env (unchanged)
    <host>.config.env                syncexec's rendered bundle (unchanged)
    launch.run.json                  agent-written before each instance start (§6.2)
    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
    <host>.json                      the migration journal for a host in flight (§8.2)
/run/carlos/instances/              nosymfollow bind mount OF /run/carlos/.instances; 2750 carlos:carlos-sockets
/run/carlos/instances/<iid>/        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
    exit.<role>.<invocation>.json    the unit's advisory exit record (§6.5); tenant-writable, and treated so
/run/carlos/logs/                   nosymfollow bind mount OF /run/carlos/.logs; 0700 carlos:carlos; NOT bound into any namespace
    <iid>.run.sock                   the agent's log listener for the instance (§6.4)
    <iid>.seed.sock                  ... for the seed
    <iid>.sidecar.sock               ... for the sidecar
/run/carlos-control/                0750 root:carlos; NOT under /run/carlos, NOT in any namespace
    control.sock                     the root channel (§5); SocketUser=carlos, 0600
```

**`<iid>` is the instance identifier, bounded to 40 bytes** (codex
R5-3). A host may be 253 bytes (`controlname.ValidHostShape`), and a
unix socket address holds 107 bytes of path, so a directory named after
the host cannot carry `app.sock.control` for every valid host.
`provision.InstanceID(host)` is the host itself when `len(host) <= 40`,
and otherwise `_` + the first 22 bytes of the host + `-` + the first 16
hex digits of `sha256(host)`, which is also 40 bytes. **The two domains
cannot meet** (codex R6-1): no valid host contains `_`
(`controlname` labels are `[a-z0-9-]`), so a hashed identifier is never
equal to any short host, and two hashed identifiers are equal only on a
64-bit hash collision between two long hosts sharing a 22-byte prefix.
It is the directory name under `/data/instances` and
`/run/carlos/instances`, the log socket prefix, and, escaped, the unit
instance and slice suffix (§4; `_` needs no escaping). File names
*inside* the directory keep the full host (a file name may be 255
bytes). The longest address this produces,
`/run/carlos/instances/<40>/app.sock.control`, is 79 bytes.

Two rows on one box whose hosts nonetheless collide in `InstanceID`
classify as **`conflict`**, which **refuses to wake** with both hosts
named and is never the unisolated fallback: a host that is already
`nested` must never be moved to the `childProc` path by the arrival of
a second row (R6-1). The layout pass skips a `conflict` host, status
reports it, and the console's create-instance door refuses a new host
whose identifier collides with an existing row on the target box. The
pass also preflights every socket address it is about to commit
against 107 bytes before it moves a file.

The socket tree keeps the #103 contract: `/run/carlos/instances` and
each host's run directory are `2750 carlos:carlos-sockets` (setgid, so
the socket an instance binds inherits the group, exactly as
`tmpfilesConf` arranges for `/run/carlos` today), the activator's
post-health `0660` normalisation is unchanged, and the control socket
stays `0600`. An enrolled consumer can traverse to and dial `app.sock`
and nothing else (codex R2-10). Inside the namespace the instance owns
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 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 InstanceID(host string) string {          // ≤ 40 bytes; see §3.1
    if len(host) <= 40 { return host }
    sum := sha256.Sum256([]byte(host))
    return "_" + host[:22] + "-" + hex.EncodeToString(sum[:8])   // '_' never occurs in a host
}
func InstanceDir(dataDir, host string) string {
    return filepath.Join(dataDir, "instances", InstanceID(host))
}
func NestedDBPath(dataDir, host string) string {
    return filepath.Join(InstanceDir(dataDir, host), host+".db")
}
func InstanceRunDir(host string) string {
    return filepath.Join(SocketDir, "instances", InstanceID(host))
}
func NestedSocket(host string) string {
    return filepath.Join(InstanceRunDir(host), "app.sock")
}
func LogSocket(host, role string) string {
    return filepath.Join(SocketDir, "logs", InstanceID(host)+"."+role+".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).
```

`ExecBackend.defaultDBPath` (`backend_exec.go:160-162`) is deleted in
favour of the provision helpers, so there is one definition, not two.

**File names inside the directory keep the host prefix.** It costs
nothing, it keeps every existing test fixture recognisable, and it means
a file found outside its directory is still attributable.

**Why nested and not per-instance uids.** titogo's unit comment says it
best: "A flat `/data/titogo/%i.db` cannot be isolated — the bind would
have to reveal all of `/data/titogo`, which is the regression itself."
Per-instance uids (the discussion's option 2, titogo's "Option B") would
additionally need a uid allocator, a chown as root on every migration and
every restore, and a group arrangement that keeps the agent able to open
the database for replication. The namespace gives the property the
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).

### 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)

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`). 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
form a cycle systemd breaks by dropping the mount (codex R2-2). The
ordering is therefore written out in full, including shutdown:

```ini
# data-instances.mount
[Unit]
Description=carlos instance data (bind, nosymfollow)
DefaultDependencies=no
RequiresMountsFor=/data
After=local-fs.target systemd-tmpfiles-setup.service
Before=carlos-edge.service carlos-instance-control.service carlos-litestream.service
Conflicts=umount.target
Before=umount.target
[Mount]
What=/data/.instances
Where=/data/instances
Type=none
Options=bind,nosymfollow,nosuid,nodev,noexec
[Install]
WantedBy=multi-user.target
```

**The source is a different directory from the mountpoint** (codex
R4-2). `rename(2)` refuses to cross a mount boundary with `EXDEV` even
when both sides are one filesystem, so a bind of `/data/instances` onto
itself would make the migration's rename from `/data/<host>.db` into it
impossible. Binding `/data/.instances` onto `/data/instances` keeps the
raw directory reachable on the `/data` mount: the layout pass renames
`/data/<host>.db` → `/data/.instances/<host>/<host>.db` on one mount,
atomically, and the file appears at `/data/instances/<host>/<host>.db`
through the protected view. The raw path is opened by **exactly one
caller**, the layout pass, and only as `openat2(RESOLVE_NO_SYMLINKS |
RESOLVE_BENEATH)` directory fds for `renameat`, so no lookup through it
ever follows a symlink either; a CI test greps the tree for the literal
`.instances` and fails on any other occurrence.

`run-carlos-instances.mount` and `run-carlos-logs.mount` are the same
shape for `/run/carlos/.instances` → `/run/carlos/instances` and
`/run/carlos/.logs` → `/run/carlos/logs`, with
`After=systemd-tmpfiles-setup.service` and no `RequiresMountsFor=`;
nothing is ever renamed into those, so their raw directories have no
caller at all.

```
d /data/.instances       0700 carlos carlos         -
d /data/instances        0700 carlos carlos         -
d /data/layout           0700 carlos carlos         -
d /run/carlos/.instances 2750 carlos carlos-sockets -
d /run/carlos/.logs      0700 carlos carlos         -
f /etc/carlos/probe-secret 0640 root carlos       - probe
d /run/carlos/instances 2750 carlos carlos-sockets -
d /run/carlos/logs      0700 carlos carlos         -
d /run/carlos-control   0750 root   carlos         -
```

`carlos-edge.service`, `carlos-instance-control.service` and the three
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
set, pinned as a CI test that renders the units and runs
`systemd-analyze verify` where the tool exists.

`nosymfollow` (Linux 5.10+, util-linux 2.36+; Debian 13 ships 6.12 and
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, 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:

- the instance replacing its own `app.sock` with a symlink to the root
  control socket, a sibling's socket, or a sibling's control socket, and
  having the edge dial it (R1-1);
- the instance replacing `<host>.env`, `<host>.config.env` or `<host>.db`
  with a symlink to a sibling's file, `/data/litestream.env` or any
  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);
- 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, by symlink or by editing the
registry row it can reach as `carlos`, and have the edge dial it. §5.4
says why that no longer reaches the control service, and §6.1 names the
two checks the edge and the agent make on every unix dial.

Belt and braces in the agent, cheap and testable:

- every agent open of a file under an instance directory goes through
  one helper, `instancefs.Open(dir, name, flags)`, built on
  `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 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
  destination);
- 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, 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 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. On recovery the pass **recomputes** both endpoints for
  the journal's own direction and honours the journal only if its paths
  equal the recomputation and the row stands at one of them (§8.2); a
  journal that passes is completed to its own end and the host is then
  converged to the fleet's recorded intent (`control/layout.json`, a
  bucket object no tenant can write) before it is released. 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 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 (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.

---

## 4. The units

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 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.

**Instance names are escaped instance identifiers.** The unit instance
is `systemd-escape(InstanceID(host))` (§3.1): for a valid identifier
(`[a-z0-9.-]`) that only turns `-` into `\x2d`. `%I` (unescaped) is used
wherever the identifier is a path component; `%i` (escaped) wherever it
is part of a unit name, which is what lets a per-host slice be spelled
in the template (R1-8). The agent and the control service share one Go
escape function (a copy of systemd's rules for this character set,
pinned by a test against `systemd-escape` output for the hosts in the
fixture set). The templates never need the full host: every path they
name is keyed by `<iid>`, and the trampoline learns the host from the
launch file (§6.2).

### 4.1 `carlos-instance@.service`

```ini
[Unit]
Description=carlos exec-backed instance %I
# Stop-only coupling: when the edge stops (including the stop half of a
# restart) this unit stops, and nothing starts it again but the agent.
# 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 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 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
# carries the tier (§4.3, §6.6). Slices referenced here are created on
# demand; no per-host unit file exists.
Slice=carlos-instances-%i.slice
# The activator owns recycling: a crash stands the entry down and the
# next request wakes it. systemd must not race that.
Restart=no
# SIGTERM to the app, SIGKILL to anything it left behind after
# TimeoutStopSec; today's actStopTimeout is 20s.
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, on
# the nosymfollow logs mount, at a path no tenant mount contains.
StandardOutput=file:/run/carlos/logs/%I.run.sock
StandardError=inherit

# --- the namespace (titogo@.service, 2026-07-31 design, on systemd 257) ---
ProtectSystem=strict
TemporaryFileSystem=/data:ro
TemporaryFileSystem=/run/carlos:ro
# Every other tree a carlos-uid unit writes on the box is hidden the same
# way (codex R9-2): the restore drills' cache under /var/cache, and
# /var/lib as a class. An instance needs nothing from either.
TemporaryFileSystem=/var/cache:ro
TemporaryFileSystem=/var/lib:ro
# Root-written credentials a carlos-GROUP process may read live here:
# /etc/carlos/relay-masters is root:carlos 0640 for carlos-relay.service,
# and an instance's Group=carlos would read it too (codex R10-1). An
# instance gets its configuration through its launch file and needs
# nothing from /etc/carlos.
InaccessiblePaths=/etc/carlos
BindPaths=/data/instances/%I /run/carlos/instances/%I
ReadWritePaths=/data/instances/%I /run/carlos/instances/%I
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
# Its own /dev/shm; PrivateDevices= would bind the host's (R15).
TemporaryFileSystem=/dev/shm:mode=1777,nosuid,nodev
# /sys/fs/cgroup read-only: the edge's delegated subtree is carlos-owned,
# and its cgroup.kill / cgroup.freeze would otherwise be one write away
# for a same-uid tenant (codex R9-1).
ProtectControlGroups=yes
ProtectProc=invisible
ProcSubset=pid
PrivatePIDs=yes
RestrictNamespaces=yes
NoNewPrivileges=yes
CapabilityBoundingSet=
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectClock=yes
ProtectHostname=yes
RemoveIPC=yes
LockPersonality=yes
RestrictRealtime=yes
# No RestrictSUIDSGID=: it refuses openat2 outright (R15).
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native

# --- 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:

- **`TemporaryFileSystem=/data:ro` + `BindPaths=/data/instances/%I`** is
  the isolation. The tmpfs is empty; the bind reveals one directory. A
  sibling's database is not permission-denied, it does not exist. The
  same for `/run/carlos`: a sibling's listening socket and control socket
  do not exist, which closes the socket-file impersonation in §1.2. The
  bound directory is inside the `nosymfollow` mount (§3.2), so a symlink
  the instance plants there is dead for every reader.
- **The bind sources must exist before the unit starts**, or systemd
  fails at namespace setup before any `ExecStartPre=` runs (titogo
  verified this; it is why `titogo-instance-dir@.service` exists). Here
  the agent is the supervisor and creates both directories before it
  asks for a start (§6.1), so no helper unit is needed.
- **`PrivatePIDs=yes` + `ProtectProc=invisible`.** Every instance is uid
  `carlos`, and `ProtectProc=invisible` does not hide same-uid peers
  (hidepid's test is `ptrace_may_access`, which same-uid passes). With its
  own pid namespace an instance's `/proc` lists only itself: no sibling
  pid to traverse into `/proc/<pid>/root/`, no sibling to signal, nothing
  for a same-uid ptrace to attach to. `PrivatePIDs=` needs systemd 257
  (Debian 13, which `infra/modules/region-host/variables.tf:134` pins)
  and is **ignored, not refused**, on a kernel that cannot provide it, so
  §8.5 gates on observed behaviour, not on the directive (R1-15).
- **`RestrictNamespaces=yes`**: without it an unprivileged user namespace
  could bind-mount over a fresh mountpoint and re-reveal what the tmpfs
  hid.
- **`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 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.
- **`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,
  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.
- **`ProtectControlGroups=yes`** (codex R9-1). `ProtectSystem=strict`
  leaves kernel API filesystems alone and `ProtectKernelTunables=` leaves
  `/sys/fs/cgroup` writable; the edge unit's `Delegate=` subtree,
  including the `supervise/` cgroup the agent moves itself into
  (`internal/cgroup/cgroup.go:172`), is created by `carlos` and its
  `cgroup.kill` and `cgroup.freeze` are owner-writable. A same-uid
  tenant with a writable cgroupfs could kill or freeze the agent with
  no pid and no socket. Read-only cgroupfs closes that; an instance has
  no business in cgroupfs at all.
- **`TemporaryFileSystem=/var/cache:ro` and `/var/lib:ro`** (codex
  R9-2). The isolation property is "no same-uid path on the box but its
  own", and two trees outside `/data` and `/run/carlos` are written by
  `carlos`-uid units: `carlos-hibernate-verify.service` and
  `carlos-restore-verify.service` set `CacheDirectory=carlos-verify` and
  restore every host's database into `/var/cache/carlos-verify` during a
  drill (`internal/ops/ops.go:274`, `internal/lease/check.go:79`), and
  `/var/lib` holds unit tenants' state. Both are hidden. §9 carries the
  audit rule that found them, so the next such unit is caught at review.
- **`InaccessiblePaths=/etc/carlos`** (codex R10-1). `ProtectSystem=strict`
  makes `/etc` read-only, not unreadable. `carlos-relay.service` runs as
  `carlos:carlos` and reads `/etc/carlos/relay-masters`
  (`internal/relay/masters.go:101`), which the relay design stages as
  `root:carlos 0640` (`2026-09-07-relay-stun-turn-design.md:325`): a
  file every `Group=carlos` process can read, and the secret every
  app's relay key is derived from (`internal/relay/server.go:665`).
  The instance's group is `carlos` for the socket contract (§3.1), so
  the directory is hidden outright. `host.env` and `apps/*.env` there
  are root-0600 and were already unreadable; hiding the directory is
  what makes the next `0640` file safe by default.
- **`/opt/carlos`** stays readable through `ProtectSystem=strict`; the
  trampoline and the live symlink are all the instance needs from it.

### 4.2 `carlos-instance-seed@.service` and `carlos-instance-sidecar@.service`

Identical `[Unit]` coupling, `[Service]` namespace, hardening and
resource blocks, and the same `Slice=carlos-instances-%i.slice`.
Differences:

| | seed | sidecar |
|---|---|---|
| `Type=` | `oneshot` with `RemainAfterExit=yes`: after `ExecStart` exits the unit stays `active (exited)` or `failed`, loaded, with its `InvocationID`, `Result` and `ExecMainStatus` retained until the control service stops it, so the seed's result can never be garbage-collected before it is read (codex R2-4, R5 disposition) | `simple` |
| `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` |
| `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, 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, R3-3)

```ini
# carlos.slice — the outer wall of the 2026-08-28 design, as a slice
[Unit]
Description=carlos (edge, activator and every exec-backed instance)
[Slice]
MemoryHigh=70%
MemoryMax=80%
```

```ini
# carlos-instances.slice — the parent of every per-host slice
[Unit]
Description=carlos exec-backed instances
[Slice]
# No cap of its own: the outer wall is carlos.slice, the inner walls are
# the per-host slices. This level exists so the sampler has one root to
# scan and so an operator can `systemctl status carlos-instances.slice`.
```

`carlos-edge.service` gains `Slice=carlos.slice` and **drops** its own
`MemoryHigh=70% MemoryMax=80%`: the wall moves up one level and now
bounds exactly what it bounded before, the agent plus every awake
instance, so borrowing between them and the OOM candidate set (a tenant
at `+500` is always picked before the agent) are unchanged. The
2026-08-28 reasoning, including the measured 1.2 GB leaf that was mostly
reclaimable page cache, carries over without new numbers.
`OOMPolicy=continue` stays on the edge unit.

The per-host slice `carlos-instances-<esc>.slice` is created on demand
by the first unit that names it. Before every start the control service
sets three properties on it from the route's tier (§5.3, §6.6):
`MemoryMax`, `MemoryHigh` (90% of max) and **`MemorySwapMax` equal to
`MemoryMax`**, the last because with zram on the box a cgroup at
`memory.max` otherwise spills into compressed swap and carries on, which
`internal/cgroup/cgroup.go:313-318` learned live on the flagship
(R2-11). The values are `512M`/`460M`/`512M` by default and
`1G`/`920M`/`1G` for an app whose manifest declares
`capabilities.children`, exactly what `internal/cgroup` writes today.
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 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/`
(R1-9).

---

## 5. The root channel: `carlos-instance-control`

### 5.1 Why a channel at all

The agent is uid `carlos`. `systemctl start` on a system unit needs
either root or a polkit grant; read-only `systemctl show` / `is-active` /
`list-units` do not. The existing root relays (`carlos-stage.path`,
`carlos-restart.path`) are asynchronous sentinel files, fine for a
minute-scale adoption pass and wrong for a wake that must answer a
waiting HTTP request.

### 5.2 Units

```ini
# carlos-instance-control.socket
[Unit]
Description=carlos instance control socket (agent → root)
[Socket]
ListenStream=/run/carlos-control/control.sock
SocketUser=carlos
SocketGroup=carlos
SocketMode=0600
Accept=no
[Install]
WantedBy=sockets.target
```

```ini
# 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 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
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
RestrictAddressFamilies=AF_UNIX
MemoryDenyWriteExecute=yes
MemoryMax=64M
```

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 hidden from every instance's namespace
by `InaccessiblePaths=` in the shared block (§4.2): a connect from
inside one is impossible, and the peer check refuses everything else.
`/run/carlos` inside an instance is an empty tmpfs with the instance's
own directory bound in.

### 5.3 Protocol

HTTP/1.1 over the unix socket, the shape `edge.ListenControl` already
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 `set-property`; `systemctl start` on `carlos-instance-seed@<esc>.service` (blocking: a oneshot's start job completes when `ExecStart` exits, or fails at `TimeoutStartSec`); if the InvocationID it left is the SAME one that was running before this start, an earlier request's stop never ran, so the stale unit is stopped and started again; the unit is now `active (exited)` or `failed` and **stays loaded** (`RemainAfterExit=yes`, §4.2); `systemctl show -p InvocationID,Result,ExecMainStatus,ExecMainCode` reads the retained result; `systemctl stop` (instant for an exited oneshot; clears it), on a context detached from the request so a disconnected client cannot leave it running; then wait for the cgroup to be empty (§6.5's rule, applied by the service as root) | 200 with `{invocation_id, result, status, code}`, always complete | 150s: 120s seed budget plus `TimeoutStopSec` and slack (R3-5) |
| `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 |
| `POST /sweep` | `systemctl list-units --all` and `list-jobs` over the three templates' instances; `systemctl stop` each that is not `inactive` or has a job; wait for terminal state and empty cgroup on each | 200 with the list of unit names swept, or 502 naming the unit that would not terminate | 60s |
| `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
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: 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(InstanceID(host)) + ".service"`
and the slice name `"carlos-instances-" + escape(InstanceID(host)) + ".slice"`,
built from the validated host by the same `InstanceID` and escape
functions the agent used to create the directories (§3.1, §4), never
from the request string (codex R5-3 disposition).

What this grants the agent: start, stop, reset and tier control of units
named `carlos-instance*@<host>` and their host slice. That is exactly
what it has today over its own children. It grants nothing else, and a
host that does not exist is a unit whose bind source does not exist,
which fails at namespace setup and starts nothing.

`systemctl set-property --runtime` on a slice or on an instance of an
installed template that has never been started loads the unit first and
writes a runtime drop-in the following `start` honours
(`SetUnitProperties` in `src/core/dbus-manager.c`; confirmed by codex
R1-1). The plan's first task exercises it on a dev box for a slice that
does not yet exist.

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 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, 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, because of two checks on
  **every unix dial the edge and the agent make** (`edge.go:2000-2014`'s
  `unixProxy`, the activator's health and control dials, §6.1):
  1. **the reserved endpoint is refused before the dial**: a path under
     `/run/carlos-control/` is never dialled as an upstream, so a
     registry row pointing at `control.sock` is refused before
     `connect()`. Nothing else is refused by path: a route whose
     operator stamped `--socket /run/titogo/example.sock` keeps working
     exactly as today (codex R5-1);
  2. **the listener answers to the path that was dialled**: after
     `connect()` and before a byte is sent, `getpeername()` on the
     connected socket returns the address the *listener* bound, which is
     its real path however it was reached; if that is not byte-equal to
     the path the edge dialled, the connection is closed. A symlink at
     `/run/carlos/<host>.sock` pointing anywhere, including a sibling's
     socket or the control socket, therefore yields a mismatch.
  Revision 4's rule, "refuse a peer whose uid is 0", is withdrawn: a
  listener created by PID 1 for `carlos-app@.socket` reports root
  credentials in `SO_PEERCRED` however the fd is later passed
  (codex R4-1), so that rule would have refused every unit-backed app.
  The two checks above do not look at credentials at all.

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. Each check is one syscall or one cached `systemctl show`,
each is testable with a real socket pair, and all of them hold on a box
that still runs flat tenants, which is the whole transition. They also
end, as a side effect, the flat-tenant socket impersonation §1.2 names,
which a symlink could otherwise still perform during this release.

---

## 6. The activator on units

`internal/activator/activator.go` changes in exactly three places: the
sidecar restart in `sweepOnce` (§6.7, codex R2-6), the lease release
in `stop` (§6.3, codex R2-5), and the route the wake runs on (§6.8,
codex R8-1). `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`

One helper, `prepare(rt, role)`, runs before **any** role starts, seed
included (R1-5, R2-12):

1. Ensure `InstanceDir` and `InstanceRunDir` exist (0700 and 2750 as in
   §3.1), and `/run/carlos/logs`.
2. `EnsureAdminToken(InstanceDir/<host>.env)` for `run` **and** `seed`
   (a seed is the first thing a fresh instance runs and must see the
   token the instance will, `backend_exec.go:346-352`); `instanceEnv`
   for both, reading the two files from `InstanceDir` through
   `instancefs`, with the seed's removal of the control-channel variables
   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). 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,
with the path and `getpeername` checks 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`

```json
{
  "schema": 1,
  "host": "<host>",
  "argv": ["/opt/carlos/live/<host>", "--socket", "/run/carlos/instances/<iid>/app.sock", "--db", "/data/instances/<iid>/<host>.db"],
  "env":  ["PATH=...", "CARLOS_ADMIN_TOKEN=...", "CARLOS_CONTROL_SOCKET=...", "..."]
}
```

`carlos instance-exec <role> <iid>` runs as `ExecStart`, inside the
namespace, as `carlos`. It reads `/data/instances/<iid>/launch.<role>.json`
through `instancefs`, refuses a schema it does not know, refuses an
`argv[0]` that is not directly under `/opt/carlos/live/` or that does
not equal `/opt/carlos/live/` plus the file's own `host` (defence in
depth: the file is agent-written; the prefix check is the one that
holds even against a rewritten file, since only the platform's live
links live there), and calls
`syscall.Exec(argv[0], argv, env)`. It never parses env syntax: the
agent's `childEnv` (`backend_exec.go:802-814`) composes the list exactly
as it does today, including the agent-owned removal of tenant-supplied
`CARLOS_CONTROL_SOCKET` / `CARLOS_PURGE` (`backend_exec.go:636-660`), and
the trampoline passes it through.

**One entry per key, last value wins** (R1-16). `os/exec` deduplicates
`cmd.Env` in favour of the last occurrence; `syscall.Exec` passes
duplicates through and a Go child keeps the first. `childEnv` therefore
gains the deduplication itself (a map keyed on the name, insertion order
preserved), and the test asserts the effective value *inside* an
executed stub, not the JSON array.

What the current `exec.Command` path does that the trampoline must
reproduce, and how: working directory (none is set today, and none is
set; the app starts in `/`), inherited fds (none beyond stdio today;
`syscall.Exec` inherits whatever the trampoline holds, which is stdio
only), `WaitDelay` (replaced by `TimeoutStopSec`, §6.4), `Pdeathsig`
(replaced by `BindsTo=` plus the startup sweep, §6.7),
`oom_score_adj` (`OOMScoreAdjust=`), the cgroup placement (`Slice=`).

Why not `EnvironmentFile=`: systemd's parser and the agent's
`parseEnvBytes` differ on quoting and continuation, and the agent-owned
variables would then have to be written into a file the tenant's
`<host>.env` overlay must not win over. One composer, one format, one
test.

The launch file is written fresh before every start, `O_EXCL` temp plus
rename (§3.2), and holds the same material the child could already read
from `/proc/self/environ`. It is inside the instance's own directory,
visible to that instance and the agent, nobody else. An instance can
rewrite its own launch file while it runs; the next start reads it again
through `instancefs`, refuses any `argv[0]` but its own live link, and
executes only what a tenant could execute anyway: its own binary with an
argv and env it chose. That is not a privilege, and the agent never
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 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`, and a stop that could not be proven (codex R2-5)

```go
type unitProc struct {
    host, role  string
    invocation  string                 // from the start response
    ctl         *edge.ControlListener  // run only; as childProc.ctl today
    logs        *logListener           // §6.4
    exited      atomic.Bool
    done        chan struct{}
}
func (p *unitProc) Stop(ctx context.Context) error   // POST /stop, then wait for done or ctx
func (p *unitProc) Exited() bool                     // p.exited.Load()
```

The control listener's close ordering (`backend_exec.go:992-1009`: signal
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
stderr through the agent: straight to the agent's own stdout (journald)
and, with `Logs` set, line-wise into `Logs.App` for `carlos logs`, capped
at `execLogLineMax`, with `WaitDelay` bounding a grandchild that holds
the pipe.

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 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. 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
`TimeoutStopSec=20` kills it on stop; on a crash the unit's cgroup empties
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, 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). 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 (§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` and
**`POST /stop`s the unit itself**, then keeps waiting: `done` closes
only when the unit is terminal and its cgroup is empty, whichever
invocation the processes belonged to (codex R2-5 disposition). The
agent owns these units and any other invocation is an anomaly to end,
not to adopt; the invocation id decides the event line's wording and
nothing about whether a writer is gone.

One exception, after the activator itself has asked for the stop
(`Stop` was called, or a failed health check stopped a fresh unit): a
unit that then reports an invocation that is not the proc's own, or
none at all while not terminal, has been replaced. systemd starts a new
invocation only once the previous stop job completed, which is its own
termination proof for the old one, so the goroutine emits `instance
exited (status unknown: replaced after stop)` and closes `done` without
the cgroup gate (the cgroup now belongs to the new invocation and would
never read empty). The goroutine does not stop the replacement: a stop
it did not request is not its anomaly to end, and a discarded proc
must never be able to stop a later, legitimate wake (plan 3, task 4
review).

**The seed's outcome comes from systemd, always.** The seed unit
carries `RemainAfterExit=yes` (§4.2), so it stays loaded, with its
`Result` and `ExecMainStatus`, until the control service reads them and
stops it (§5.3). `/seed` therefore always returns a complete result and
the seed's exit record is, like every other record, wording only.

`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)

`internal/cgroup` today: `Manager.Start` (`cgroup.go:242-285`) clones the
child into `carlos-exec@<host>` under the edge unit's delegated subtree,
writes `memory.max`/`memory.high`/`memory.swap.max` from the tier and
`memory.oom.group=1`, and the sampler reads the subtree as a scan root
(exec-child accounting design §4). `CleanupHost` (`:398-403`) removes the
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. 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
  slice's charge is the family's total, so no per-role summation is
  needed). It **publishes under the existing key**
  `activator.ExecName(host)` (`carlos-exec@<host>`), because that is the
  key `internal/console/metricsview.go:728-762` recognises and everything
  else is discarded at the app boundary there. Flat hosts, for this one
  release, are still sampled from the delegated subtree as today; the
  two roots never both hold a given host.
- **The edge's own reading.** Platform-unit discovery scans
  `/sys/fs/cgroup/system.slice` (`internal/host/metrics_probes.go:49`),
  where `carlos-edge.service` no longer lives once it moves into
  `carlos.slice`, and the accounting design defines the edge's reading
  as the hierarchical total including its children. So discovery adds
  `/sys/fs/cgroup/carlos.slice` as a root for the edge unit, and the
  value published under `carlos-edge.service` becomes **`carlos.slice`'s
  `memory.current`**: the agent plus every instance, which is what the
  key has always meant (R2-13). The edge unit's own leaf is published
  additionally under `carlos-edge.service/self` for the box view; the
  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 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)

Today an agent restart kills every child (`Pdeathsig`, the cgroup tree
dies with the unit). `BindsTo=carlos-edge.service` reproduces the stop
half of that for a `systemctl restart carlos-edge` and does not propagate
the start. For a crash, or a stop that did not propagate, the agent on
start, before anything else:

1. `POST /sweep` (§5.3). The control service, as root, enumerates every
   `carlos-instance@*`, `carlos-instance-seed@*` and
   `carlos-instance-sidecar@*` unit and every pending or running job on
   them, `systemctl stop`s each (which also cancels a queued start),
   waits for each to reach a terminal state **and** for its cgroup to be
   empty (§6.5's rule), and returns the list. The sweep is keyed on the
   unit names systemd reports, not on hosts, so it covers a unit whose
   identifier is hashed and whose registry row is gone: the agent could
   not name such a unit through `/stop`, which takes a host (codex
   R7-1).
2. The agent removes stale exit records under every run directory.

Every instance is therefore asleep, and no job is queued, when the
activator's state machine begins, which is the invariant the activator
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 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 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
the state under the same lock the stop flips it under, and a stop that
arrives during the start finds `sidecarStarting` and waits for the
install step to see it. One test drives a stalled sidecar start while
another host wakes and stops.

### 6.8 The wake runs on the committed row, not on a copy (codex R8-1)

The edge hands the activator the route it proxied for, and the
activator copies it into the entry (`activator.go:481`); `doWake` then
restores, seeds, spawns and replicates on that copy (`:806, :870`). The
copy can be older than the registry: for the interval between a layout
pass's row CAS and the edge's reload, the edge still holds the flat
paths, and a request in that interval would carry a flat route to a
host the registry already calls `nested`. On that copy the exec backend
would classify the host `flat`, restore a second database at the
vacated flat path, and run `childProc`, and `RetireExcept`
(`activator.go:1260`) would keep the entry because its host name did
not change.

**The third `activator.go` change**: at the top of `doWake`, before
`Acquire`, the activator **re-reads the row** (`reg.Get(host)`, one
indexed read) and uses that row for the whole wake, restore through
replication, storing it back into the entry. If the row's `DBPath` or
`Socket` differ from the copy the edge supplied, the wake logs
`route refreshed: <old> → <new>` and proceeds on the fresh one; if the
host has no row, or the row is `fenced` or `conflict`, the wake refuses
as §8.1 says. The exec backend's classification (§8.1) therefore always
sees the committed row. One test delivers a saved flat route for a
migrated host and asserts no flat artifact is created and no
`childProc` starts.

### 6.9 What does not change

`Acquire`, `Restore` (including the `.corrupt` quarantine and
`PurgeLocalDBArtifacts`, all keyed off `dbPath`), `lease.Replicate` (the
meta dir is derived from `dbPath`, `lease.go:286`, so it lands inside the
instance directory), `NextDue`, the schedule runner's token read, the
control socket's purge and schedule mounts, `Bounce`, the sweeper's
hibernation policy, residence moves (`movespass.go`, once it reads the
DB path from the row and honours the layout journal, §8.2 and §9),
`carlos restart` in all three scopes, `X-Carlos-Version`, the caps pass,
config-ack. All of these see a path change and nothing else.

---

## 7. Config delivery

`SyncExec` (`internal/appconfig/syncexec.go`) writes a nested exec
route's bundle to `InstanceDir/<host>.config.env` instead of
`<DataDir>/<host>.config.env`, through the `O_EXCL`-temp-and-rename
helper of §3.2 rather than `os.WriteFile` on a predictable `.tmp` name
(`syncexec.go:921-926`; R1-3), and a flat route's exactly as today. Its
ownership rule ("sync owns `*.config.env` outright, never touches
`<host>.env` or `<host>-sidecar.env`", `syncexec.go:1-31`) is unchanged
and, for a nested host, trivially true per directory. `reapConfigEnv`
(`:977-1024`) walks `<DataDir>/instances/*/` for nested routes as well as
the flat `DataDir` for flat and addr routes, and the bounce-on-change
path is unchanged (the agent bounces the entry; the next wake reads the
new file).

**Opted-in TCP-addr routes stay flat.** Their file is read by the app's
own supervisor at `/data/<host>.config.env` (titogo's
`EnvironmentFile=-/data/%i.config.env`), and moving it would break every
such drop-in. Isolated instances cannot see it any more, which is the
read #127 opens with. #127's request, root-rendered delivery into
`/etc/carlos/apps` for addr routes so that the file is not readable by
the agent either, is a separate, smaller change and stays open (§11).

`reapConfigEnv`'s host-`x.config` ambiguity guard and `SyncExec`'s
collision refusal (`syncexec.go:389-395`) stay for the flat directory,
where they still apply, and the migration's ownership preflight (§8.2)
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, 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
previous files and restarts the edge again (`systemapply.go:360-372,
738-748`); `carlos-system-recover.service` runs `carlos.prev` when a
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. 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.

The exec backend classifies each activator-managed route on every
`Start`:

| Row | Classification | Spawn path |
|---|---|---|
| `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 |
| another row on the box has the same `InstanceID` | `conflict` | refuses to wake; both hosts named (§3.1) |
| 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 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, R2-9, R3-1)

The verb has the shape of `carlos restart` (#113): the console writes
`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:

1. **Eligibility is the activator's own predicate.** `Kind == "instance"`,
   `Hibernate`, `Backing == ""`, `Tunnel == ""`, `Socket != ""`
   (`activator.go:1379-1384`, `edge.go:630-638`). An unflagged instance
   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`. Under mode `nested`, `flat` 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, 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, and only
   for files named after the host.** Each source follows its existing
   contract (codex R4-3): the database is the row's `DBPath`, and
   `-wal`, `-shm`, the litestream shadow dir and `.corrupt` are derived
   from it (`backend_exec.go:273, 1041-1057`, `lease.go:286`); the three
   env files are `DataDir`-relative regardless of `DBPath`
   (`backend_exec.go:785-794, 865-868`). The socket is the row's
   `Socket`. And every source must be **a file named `<host>` plus a
   platform suffix, directly under `DataDir` or `SocketDir`**: the row's
   `DBPath` must be `<DataDir>/<host>.db` and its `Socket`
   `<SocketDir>/<host>.sock`, which is what `flat` means (§8.1). A row
   naming any other path is `foreign` (§8.3) and the pass does not touch
   it, because during the transition the registry is writable by every
   flat tenant (same uid, §1.3) and a row is therefore not a trustworthy
   instruction to move an arbitrary file: a tenant that could point its
   `DBPath` at `/data/console/console.db` and have the pass move it
   into the tenant's directory would have the platform's own database.
   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
   route `<host>.config` exists, in which case it is that route's
   instance env and is not moved with `host` (this is `SyncExec`'s own
   collision rule, `syncexec.go:389-395`). Reverting into a flat
   directory applies the same table in reverse: a name that would land
   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 `<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"`.
8. **Row.** `registry.SetInstancePaths(host, from.db, from.socket,
   to.db, to.socket)`: one transaction, compare-and-swap on both columns,
   a new sibling of `SetDBPath` (`registry.go:1057-1075`). Then journal
   `phase: "done"`.
9. Remove the journal; log `layout: <host>: flat → nested` (or the
   reverse); **reload routing synchronously** (the agent owns the edge
   and calls its `Reload()`, `edge.go:449-451`, and the activator's
   `RetireExcept`/route refresh, rather than waiting for the 2s poll);
   only then release the host to the activator. A request that copied
   the old route before that reload is caught by §6.8 (codex R8-1).

**Recovery is the journal, and it is exact, and it is checked** (R2-7,
R3-1, R4 disposition). On agent start and at the top of every layout
pass, for each journal, the check is against **both endpoints**, because
the row is at `from` before step 8 and at `to` after it, and it is
derived from **the journal's own direction** (codex R5 disposition): for
`mode: nested`, `from` must be the host-named flat paths of step 3 and
`to` the nested paths the §3.1 helpers derive; for `mode: flat` the two
are swapped, artifact sources included. The row must currently equal
either `from` or `to`.
A journal that fails any of those is refused (`layout: <host>: journal
does not match row; not acting`), the host stays `fenced`, and status
says so. **A journal that passes is completed to its own endpoint even
if the fleet's intent has since changed**: a transaction runs to its
recorded end, and the host is then converged to the current intent in
the same pass, before it is released to the activator, so a forged or
stale journal can at most add one round trip a host never runs
unisolated through. Otherwise:

- `phase: files`: for each artifact recorded present: at destination →
  done; at source → rename; at neither → error, fence stays, status
  says which file. Artifacts recorded absent are skipped. Then continue
  from step 7's end.
- `phase: row`: if the row already equals `to` → the CAS committed
  before the crash; continue. If it equals `from` → CAS now. Anything
  else → error, fence stays, both values in status.
- `phase: done`: remove the journal.

A second clean agent start finds `nested` rows and no journals and does
nothing (the classification table runs before any legacy-default test).

**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`.
**That order is enforced, not advised** (codex R5-2). A downgrade
removes this release's unit files: `installUnits` runs `systemctl
disable --now` on every unit the candidate lacks (`systemapply.go:522`),
so the bind mounts go with the binary, `/data/instances/<iid>/` becomes
an empty mountpoint directory, and the old backend, honouring the nested
`rt.DBPath` (`backend_exec.go:220-225`), would find no database and
restore a second copy into the uncovered directory, which the next
mount would hide. So:

- **This release's `carlos system apply` refuses to install a candidate
  that predates the layout** (its version is below the release that
  carries the mount units) unless reversal is **complete**: **no row on
  the box, whatever its flags**, has a `DBPath` or `Socket` under
  `/data/instances` or `/run/carlos/instances`, no row is `fenced` or
  `conflict`, and no journal exists. The check is by path shape and not
  by activator eligibility, because a row can leave management while
  keeping nested paths: `SetHibernate(false)` (`registry.go:1565`),
  including when the reconciler applies a record's flag
  (`reconcile.go:828`), does not touch them (codex R7 disposition of
  R5-2). `layout_mode` is not consulted either, because it records
  intent, and a revert refused at quiescence or at the destination check
  leaves a nested row under a flat intent. The reason names the rows;
  the installer is this binary for every downgrade from here, because a
  downgrade is an install performed by the installed binary (§8.1).
- **Unflagging a nested row is refused.** `carlos route --hibernate=false`
  and the reconciler's flag application both refuse to clear `Hibernate`
  on a row whose paths are nested, with `layout revert first` as the
  reason (the reconciler logs it as it logs shape drift,
  `reconcile.go:873-881`), so a nested row is always activator-managed
  and the revert pass can always quiesce it. The classification table
  (§8.1) therefore never meets a nested row it cannot own.
- **The console's rollback door** (`system.go:466`) refuses a channel
  rollback to a pre-layout release while any box in the fleet reports
  any `nested`, `fenced` or `conflict` row, or no layout field at all,
  and names the boxes and rows.
- A failed-gate rollback of the upgrade *to* this release runs the old
  binary, but at that moment nothing is nested (§8.1). A failed-gate
  rollback of a later upgrade runs this binary, which knows the layout.
- **Recovery for a layout that was unmounted anyway** (an operator
  removing units by hand): at start the control service (root)
  inspects the underlying mountpoint directories in a private mount
  namespace (`unshare -m`, `umount` the bind there, list) and, if
  `/data/instances` or `/run/carlos/instances` holds anything under the
  mount, records the verdict `mountpoint held files while unmounted`
  and `GET /ready` refuses nested wakes until an operator has moved or
  removed them; the layout pass's destination check (step 5) then sees
  whatever was moved into the raw directory. Nothing is chosen
  silently; the databases are never two.

The database therefore has one history in every path that is allowed
to happen, and the two paths that are not allowed are refused with the
revert named as the fix.

### 8.3 `foreign` rows

A row whose `DBPath` or `Socket` is neither the flat nor the nested
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). The pass does not
move it, for the reason step 3 gives: while flat tenants share the
agent's uid, a row is not a trustworthy instruction to move an arbitrary
file, and the only files the pass will move are the ones named after
the host at the platform's own paths. A `foreign` row 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, and because a remote verb that
moved operator-named files would be the primitive this section refuses
to build (R1-18, accepted as the residual). A console-provisioned row
(`reconcile.go:300-301`) is never `foreign`.

### 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),
`conflict` (with the other host), `foreign` (with both values),
`external`, and a box-level
`layout_mode` (`flat`, `nested`, or `none` before any intent). `carlos
status --layout` (beside `--instances`, under the same operator gate;
R14) 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
`PrivatePIDs=`". Running the fleet with the same-uid `/proc/<pid>/root`
traversal open is the defect, not a degraded mode.

The gate is observed behaviour, not a version string. At start, and on
`daemon-reload`, the control service (root) runs
`carlos-instance-probe.service`, a oneshot with the exact namespace and
`IPAddressDeny=` block of §4.1 whose `ExecStart` is `carlos
instance-probe`. From inside it asserts, and reports each as a separate
verdict line:

- `/data` and `/run/carlos` list nothing but the probe's own bound
  directories, and `/var/cache` and `/var/lib` list nothing at all;
- `access(W_OK)` on the edge's own delegated `cgroup.procs`
  (`/sys/fs/cgroup/carlos.slice/carlos-edge.service/cgroup.procs`, chowned
  to carlos by `Delegate=` on `carlos-edge.service`) answers `EROFS`,
  never `EACCES` (the kernel checks DAC before the mount flag, so `EACCES`
  there means the file is not delegated, not a pass) or `ENOENT` (the edge
  unit is not loaded); and `/proc/self/mountinfo` shows `/sys/fs/cgroup`
  itself carrying `ro`, so the `EROFS` answer is the mount flag's doing
  and not some other cause;
- `/etc/carlos` cannot be opened (`EACCES` or `ENOENT`, never a
  listing), and the fixture `/etc/carlos/probe-secret` cannot be read.
  The fixture is **not** written by the control service, whose
  `ProtectSystem=strict` makes `/etc` read-only to it (codex R11-1); it
  is a tmpfiles line (§3.2, `f /etc/carlos/probe-secret 0640 root
  carlos - probe`) applied by bootstrap and by `update-platform-binary.sh`'s
  `systemd-tmpfiles --create`, before any probe runs. The control
  service, before it accepts a probe verdict, `stat`s the fixture and
  requires `root:carlos 0640`; a missing or differently-owned fixture is
  itself a `not ready` verdict line (`probe fixture absent`), never a
  vacuous pass;
- `/proc` lists only itself, and its own pid is 1;
- 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`;
  `sendto([fd00:ec2::254]:80)` must fail with `EPERM` (`v6: enforced`)
  or with `ENETUNREACH` (`v6: unverified, no route to the address`):
  the kernel's own answer for that exact destination, so no route query
  is needed and none is made (codex R4-4; the service's
  `RestrictAddressFamilies=AF_UNIX` stands). 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. `v6: unverified` does not block readiness on its own, because the
boxes have no IPv6 route to that address today and a box with no route
cannot reach it; it is **bounded and watched**: the probe re-runs every
five minutes and on `daemon-reload`, the verdict is part of the status
snapshot (§8.4), and the moment a v6 route appears and the answer is not
`EPERM` the verdict flips to `not enforced` and nested wakes stop
(codex R2-14 disposition). 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.

### 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`; `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`,
   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
   as this change's.
4. **carlos-tito-ie**: Tito's operator rolls it from Tito's console once
   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 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 whose `layout_mode` is not `nested`. Brought forward in plan 5
   (revision 16): on a box whose intent is `nested`, the reconciler and
   `carlos add` mint a fresh activator-managed row at the nested paths
   (`routeFor`'s `nested` argument), so a new tenant never runs flat
   there, not even for one pass. `DefaultDBPath`/`DefaultSocket` stay
   flat for every other caller until the next release.

**Nested by default (revision 17, Paul's decision 2026-10-06).** From
the release that carries plan 5 (v0.25.0), nested is the default, not an
opt-in. When `control/layout.json` is absent, the effective intent on a
box is `nested` **if and only if the box is ready**: the deployment's
default is nested (`CARLOS_LAYOUT_DEFAULT` in host.env is unset or
`nested`; `flat` is the per-deployment opt-out, for a self-hoster whose
boxes are not ready), the running build is at or above `LayoutFloor`
when it can be placed (a build that evaluates the rule carries the pass
by construction, so an unplaceable dev build passes and a mis-tagged
release below the floor does not), and the nested gate of §8.5 answers:
`instancefs.SelfCheck`, the three `nosymfollow` mounts, and the control
service's probe verdict. A box that is not ready behaves exactly as
before the default (flat, mints flat) and says why in its status
report (`layout.default_blocked`); a box that is nested by default says
so (`layout.default`), so `carlos status --layout` can tell a box that
was migrated on purpose from one that moved on its own.

A recorded intent always wins: an explicit `flat` is the opt-out, an
explicit `nested` behaves as today, and `--box` scoping is how an
operator keeps box-by-box control. The default never forces: the awake
grace, the holds and the quarantine of plan 4 apply as they do under a
recorded intent. The console's guards need no new rule: the downgrade
guard and the migrate guard judge what each box REPORTS (its rows and
its running release), so a box that is nested by default is treated
exactly like one under an explicit intent wherever it reports nested
rows. `carlos add` cannot reach the gate from an operator's shell, so
with no intent recorded it mints nested only when the box's registry
already holds an activator-managed row at the nested paths; a fresh
box's first hand-added row is minted flat and moved by the pass on the
next serial move.

Rollout, restated: the flagship cuts v0.25.0 with this in, rolls it, and
each box migrates on its own once its probe reports ready. An operator
who wants box-by-box control records `carlos system layout migrate
--box` (or `revert`) first; the recorded intent then governs.

Why a default and not a verb: the §1.2 list is what every flat tenant
can reach, and a fleet that stays flat because nobody ran a verb is a
fleet that stays exposed by omission. The gate is what makes the default
safe to have: a box that cannot isolate does nothing, loudly.

**The roll script and the planner.** `update-platform-binary.sh:52-70`
(pinfra) enables units by name; it gains `carlos-instance-control.socket`,
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
is *active*, not just enabled. An agent that finds the channel absent
refuses nested wakes with `instance control socket not listening`, which
is what a forgotten enable looks like from `carlos logs`; flat hosts are
unaffected, which is the point of the two-layout release.

---

## 9. Every consumer of the shape

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,
R3-1, R3-6:

**Path builders that move onto the helpers**

- `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); `:273, 1041-1057` (quarantine and artifact paths, the migration's source contract); every nested read through `instancefs`; the classification of §8.1; the path and `getpeername` checks on the health and control dials (§5.4)
- `internal/edge/edge.go:2000-2014` (`unixProxy`: the path and `getpeername` checks on every dial, §5.4)
- the layout pass's raw-directory renames (`/data/.instances`, §3.2): the one caller, pinned by a grep test
- `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: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` (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
run directory mode in `prepare`, and the #103 test that dials a migrated
instance as an enrolled non-`carlos` uid

**Litestream and replication**

- `internal/lease/lease.go:286` — meta dir derived from `dbPath`; follows
- `internal/ops/litestream.go:354-388` — the box-wide generator **discovers by glob and filters by registry claim**; it does not append claimed paths (R1-17). The glob gains `instances/*/*.db`, or nested non-hibernating databases are never replicated. Hibernating rows stay excluded at `:420-421` as today; a `fenced` host is excluded. Pinned in `ops_test.go` for both coverage and the `skipped` report
- `carlos-litestream.service` runs as `carlos` with `ReadWritePaths=/data`: reads nested 0700 directories fine; ordered after `data-instances.mount`

**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 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 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, 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
- `harden-host.sh` (pinfra) writes hardening drop-ins for `$TENANT_UNITS`; the instance templates carry their own and must not be listed there
- **The same-uid audit** (codex R9-1, R9-2): every unit in `internal/host/units/` that runs as `User=carlos` or delegates a `carlos`-owned tree, and every path it writes, is a path an isolated instance must not see. Today: `carlos-edge.service` (`/data`, `/run/carlos`, its delegated cgroup subtree), `carlos-litestream.service` (`/data`), `carlos-hibernate-verify.service` and `carlos-restore-verify.service` (`/var/cache/carlos-verify`). A test over `unitsFS` lists every `User=carlos` unit's `ReadWritePaths=`, `CacheDirectory=`, `StateDirectory=`, `RuntimeDirectory=` and `Delegate=` and asserts each resulting tree is covered by a `TemporaryFileSystem=`, `InaccessiblePaths=` or `ProtectControlGroups=` line in the templates, so a future unit that writes somewhere new fails the build until the templates hide it.
  **Reads count too** (codex R10-1): the same audit covers every file a `User=carlos` or `Group=carlos` unit *reads* that is not world-readable by design, which today is `/etc/carlos/relay-masters` (`root:carlos 0640`, `internal/relay/masters.go:101`) and the alternate path the relay design allows for it; the audit's second table lists each such file, its owner and mode, and the template line that hides it (`InaccessiblePaths=/etc/carlos`). A new credential input for a `carlos`-group reader is added to that table, or the test fails

**Tests that pin the flat layout** (§10): `internal/activator/activator_test.go` (20 fixture sites), `backend_exec_test.go` (4), `admintoken_test.go` (8, path-agnostic), `internal/appconfig/syncexec_test.go` (50), `internal/ops/ops_test.go` (52), `internal/provision/reconcile_test.go` (20), `internal/host/movespass_test.go` (1), `schedule_wiring_test.go` (1), `schedule_token_fence_test.go` (2), root `e2e_test.go` (24), `instances_e2e_test.go`, `config_e2e_test.go`, `deploy_e2e_test.go`. For this release they keep passing on the flat path; each gains a nested twin.

**Not consumers**: the console never reads box paths (`internal/console`
has no `DBPath`/`DataDir` reader); `readDiskFrac` is whole-volume; the
sentinel `.path` units name fixed files under `/data`, never a host.

---

## 10. Tests

Unit level, all runnable in CI (no systemd needed):

- `internal/provision`: `InstanceID` (identity below 41 bytes,
  truncation plus hash above, never longer than 40, stable), the nested
  helpers keyed by it, the flat defaults unchanged, `LogSocket`, the
  escape function against `systemd-escape` fixtures, and the socket
  address preflight: a host that fits today's flat paths but whose
  identifier-keyed paths would exceed 107 bytes cannot exist, pinned by
  computing the longest address for a 253-byte host; a short host and a
  hashed identifier can never be equal (the `_` prefix); two long hosts
  colliding in `InstanceID` classify as `conflict` and refuse to wake,
  including a host that was already `nested` when the second row
  arrived.
- `instancefs`: refuses a symlink at every component, refuses `..`,
  `O_EXCL` temp plus rename, the mountinfo check for all three mounts.
- `internal/edge` and `internal/activator` dials, with real socket
  pairs: a path under `/run/carlos-control/` is refused before
  `connect()`; a custom path elsewhere (`/run/titogo/example.sock`) is
  dialled as today; a symlink to another listener yields a `getpeername`
  mismatch and is closed before a byte is sent; a listener created by
  another process and passed over (the `carlos-app@.socket` shape) is
  accepted when its bound path is the one dialled.
- `internal/host/systemapply`: a pre-layout candidate is refused while
  `layout_mode` is `nested` or a journal exists, and installed when the
  box is `flat`; the control service's start-time inspection of an
  underlying mountpoint that holds files (with a fake namespace runner).
- `internal/console`: the rollback door refuses a pre-layout release
  while any box reports `nested`, `fenced` or no layout field, naming
  them.
- `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` reading a retained result from an `active (exited)`
  and from a `failed` oneshot, waiting for the empty cgroup, then
  stopping it; `/ready` from the probe verdict; with a fake `systemctl`
  runner and a fake peer.
- `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'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 into the run directory; refuses to overwrite.
- `instance-probe`: each verdict line, including `EPERM` versus
  `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); convergence on every serial
  move; a later-flagged row migrating on the next pass; `foreign` rows
  (a custom basename, a subdirectory, a path outside the trees, a row
  pointing at the console database) reported and never moved; quiesce
  before plan; the ownership preflight for `x`/`x.config` in both
  directions; DB destination collision refused, env destination moved
  aside; the rename performed through the raw `.instances` directory
  fds and never through the bind mount (a test with a real bind mount
  where `EXDEV` would otherwise fire, skipped where the CI host cannot
  mount); 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, with the row at either
  endpoint; a journal whose `to` is not the host's derived destination
  or whose `from` is not the host's flat paths refused; a journal
  completed to its own endpoint and the host then converged to a changed
  intent before release; 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, 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`,
  `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
  verify` over the rendered set.
- `internal/ops`: the nested glob, coverage of a nested non-hibernating
  row, the `skipped` report, the fence.
- `internal/host`: `systemctlPlan` starts sockets and mounts; the two
  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 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, convergence marks.

Box level, in the plan's verification task and in the rollout:

- Cold boot with the full unit set; `systemd-analyze verify`;
  `list-dependencies` shows the mounts before the edge.
- Probe verdict `ready`; a deliberately broken probe (a drop-in removing
  `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; 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.

---

## 11. Out of scope, and follow-ups

- **Per-instance uids (option B).** The nested layout is its
  prerequisite; the namespace closes every path the discussion names
  without it. If a later finding needs a uid boundary, it lands on this
  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 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
  own discussion; the control channel of §5 is deliberately not widened
  to `carlos-app@` (R1-19).
- **#127**: root-rendered config for opted-in addr routes into
  `/etc/carlos/apps`. Smaller and independent; this change makes the file
  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 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.

### 11.1 Hardening after the layout (#214 items 1-3, #178), and what it leaves

Added to the templates, the control service and the probe (each property
has a probe line, so `ready` reflects it):

- **CPU and IO.** The control service writes the tier's `CPUWeight=`,
  `CPUQuota=` and `IOWeight=` beside its memory caps on the host slice
  (`cgroup.SharesOf`: 100/100%/100, children tier 100/200%/100). Nothing
  new on the wire: the shares follow from the memory tier.
  *Residual:* `IOWeight=` is enforced only by io.cost or BFQ; the probe
  says `unverified` on a box with neither, which is the default on an
  EBS `none` scheduler. Enabling io.cost is a box change, not made here.
- **Disk.** No per-tenant quota: `/data` is a directory on the root ext4
  filesystem, and project quotas need `tune2fs -O project,quota` on an
  unmounted filesystem plus a `prjquota` mount; every writer is uid
  carlos, so reserved blocks cannot tell a tenant from the agent. The
  guard refuses rather than stops: below max(1 GiB, 10%) free, starts
  and seeds answer 507, and the control service logs a warning every
  30s while it lasts. Stopping the "biggest" tenant was tried and
  dropped in review: space held by unlinked-but-open files appears in
  no directory, so attribution can be gamed into stopping honest
  tenants, and a stop from the control service bypasses the activator's
  fence and holds. *Residual:* tenants already awake can keep writing
  past the floor until the disk is full. A dedicated `/data` volume
  with project quotas (or systemd's quota directives on top of them)
  is the real fix.
- **The version store.** Each instance sees an empty `/opt/carlos` with
  `/opt/carlos/carlos` and its own build put back: the control service
  writes `/run/carlos-control/exe/<iid>/{app -> /opt/carlos/live/<host>,
  live/<host> -> ../app}` before every start, and the unit binds them at
  `/opt/carlos/app` and `/opt/carlos/live`. `carlos-app@` gets the same
  view by binding `/opt/carlos/live/%i` over an empty `/opt/carlos`.
- **Egress.** `IPAddressDeny=` covers loopback, RFC 1918, CGNAT,
  link-local, ULA, NAT64, site-local, multicast and reserved ranges.
  An allow is per address, both ways and on every port, so no loopback
  resolver is ever opened. That includes resolved's 127.0.0.53/54
  stubs, because a tenant could bind the stub address and reach every
  wildcard listener through it. The namespace binds
  `/run/systemd/resolve/resolv.conf` (resolved's upstreams, when
  present) over `/etc/resolv.conf`. Before every probe, the control
  service re-opens the nameservers in that same file with
  `IPAddressAllow=` on `carlos-instances.slice`. Link-local resolvers
  are refused except 169.254.169.253: an allow carries no zone, so
  `fe80::1` would open that address on every link. A nameserver that
  is one of the box's own addresses is never opened either: the
  control service reads them from procfs (`/proc/net/fib_trie` LOCAL
  routes, `/proc/net/if_inet6`) as it writes the allow, closes every
  resolver if it cannot, and reports a refusal as a failing
  `resolver-local` line. The probe's `dns` and `resolver-ports` lines
  check from inside that each allowed resolver is reachable and does
  not answer on port 22. (The probe made the local-address check
  itself until v0.25.0, with `net.InterfaceAddrs`; that is netlink,
  which the unit's `RestrictAddressFamilies=` refuses, so no box was
  ever ready; #126.) *Residuals:* an allowed upstream is open on every port (it
  is a remote resolver, not the box), and a resolver change reaches
  running instances only at their next start. Loopback inside one instance is
  closed too, so a family that talks to itself over TCP loopback must
  use a unix socket in its own directory. Services the box exposes on a
  public interface address remain reachable, as from the internet.
- **IPC (#178).** `PrivateIPC=yes` and a private `/dev/mqueue`. Abstract
  unix sockets are scoped with Landlock (`LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET`,
  ABI 6, Linux 6.12) by the trampoline before it execs; a kernel without
  it fails the probe and the trampoline refuses to exec. *Residual:* a
  sibling can still bind an abstract name first, which is a refusal of
  service to the owner, never a connection to it. A per-unit network
  namespace would close that too, at the cost of a veth or proxy for
  egress; not done.

---

## 12. Approaches considered

1. **Console-provisioned instances become unit-backed** (#126 option 1).
   Rejected: `UnitBackend` has no restore, seed, replication, hibernate-
   from-replica, sidecar or move; `provision.Record` cannot express a
   backing and the reconciler refuses one. A rewrite of self-serve
   hibernation, and the always-awake design already said so.
2. **Per-instance uid from the agent** (#126 option 2 as written).
   Impossible without `CAP_SETUID` on the agent, which is root-equivalent
   for a compromised agent; and a uid alone does not hide a sibling's
   socket file or pid.
3. **Isolated exec: the agent's instance, systemd's namespace** (this
   spec). Keeps every activator invariant, needs one root channel, and
   migrates with a rename.

Within 3: trampoline over `EnvironmentFile=` (§6.2, one env composer);
three templates over one (§4.2, the instance is the host); the control
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).

---

## 13. Review log

- **Round 1** (codex, GPT-6, `CODEX-FINDINGS-ISOLATED-EXEC.md`, 19
  findings, verdict request changes). All accepted:
  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 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 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);
  R1-12 the journal fence and its consumers (§8.2);
  R1-13 explicit reverse migration; no "restore" claim (§8.2);
  R1-14 planner starts sockets and mounts; roll script; active check (§8.6);
  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`; the box-local repair of a `foreign` row accepted as the residual, with the reason (§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 (§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 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; 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 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`; every dial checks path and `getpeername` (the root-peer rule of revision 4 withdrawn by R4-1); `/run/carlos/logs` on a `nosymfollow` mount; journals checked against both endpoints (§3.2, §3.3, §5.4);
  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).
- **Round 4** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R4.md`, 4 new
  findings plus partials, verdict request changes). All accepted:
  R4-1 root-peer rule withdrawn; path allowlist before the dial and `getpeername` equality after it (§5.4, §6.1);
  R4-2 `/data/.instances` bound onto `/data/instances`; the pass renames on one mount through raw directory fds (§3.1, §3.2);
  R4-3 sources follow each file's existing contract; only host-named files at the platform's paths move; everything else is `foreign` (§8.2, §8.3);
  R4-4 no route query; the v6 answer comes from `sendto` itself; the probe re-runs and the verdict is watched (§8.5);
  R2-4 `/seed` correlates by `--no-block` then `InvocationID`; a seed record may decide a seed's outcome because it can deceive only its author (§5.3, §6.5);
  R2-5 a foreign invocation is stopped and `done` waits for the cgroup (§6.5);
  R2-7 journals validated against both endpoints and completed to their own end before convergence (§8.2);
  R2-14 `v6: unverified` bounded by the five-minute re-run and reported in status (§8.5);
  R1-18 the `foreign` residual accepted with its reason (§8.3).
- **Round 5** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R5.md`, 3 new
  findings plus 2 partials, verdict request changes). All accepted:
  R5-1 only the control endpoint is refused by path; custom sockets elsewhere dial as today (§5.4);
  R5-2 `system apply` and the console's rollback door refuse a pre-layout release while any box is nested; underlying mountpoints inspected at start (§8.2);
  R5-3 `InstanceID`, bounded to 40 bytes, keys every directory, socket and unit name; addresses preflighted against 107 bytes (§3.1, §4, §6.2);
  R2-4 the seed unit carries `RemainAfterExit=yes`, so its result is retained until read; the record is wording again (§4.2, §5.3, §6.5);
  R2-7 recovery endpoints derived from the journal's own direction; §3.3 reconciled with §8.2 (§3.3, §8.2).
- **Round 6** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R6.md`, 1 new
  finding plus 2 partials, verdict request changes). All accepted:
  R6-1 hashed identifiers carry a `_` prefix no host can have, 64-bit hash; a collision is `conflict`, which refuses to wake and is never the unisolated fallback; the create door refuses a colliding host (§3.1, §8.1);
  R5-2 the downgrade guards require completed reversal (no `nested`, `fenced` or `conflict` row, no journal), not intent (§8.2);
  R5-3 the control service builds unit and slice names from `InstanceID(host)` (§5.3).
- **Round 7** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R7.md`, 1 new
  finding plus 1 partial, verdict request changes). All accepted:
  R7-1 the startup sweep is `POST /sweep`, performed by the control service on the unit names systemd reports, so a hashed identifier without a row is still stopped (§5.3, §6.7);
  R5-2 the downgrade guard checks every row's path shape regardless of flags; unflagging a nested row is refused (§8.2);
  §10 aligned with the `conflict` rule.
- **Round 8** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R8.md`, 1 new
  finding, every prior item resolved, verdict request changes). Accepted:
  R8-1 the wake re-reads the row before `Acquire` and runs on it; the layout pass reloads routing before releasing the fence (§6.8, §8.2).
- **Round 9** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R9.md`, 2 new
  findings, every prior item resolved, verdict request changes). Both accepted:
  R9-1 `ProtectControlGroups=yes` in the templates and the probe; the probe writes `cgroup.kill` and expects `EROFS` (§4.1, §8.5);
  R9-2 `/var/cache` and `/var/lib` hidden; the same-uid audit rule and its test added to §9 (§4.1, §8.5, §9).
- **Round 10** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R10.md`, 1 new
  finding, every prior item resolved, verdict request changes). Accepted:
  R10-1 `InaccessiblePaths=/etc/carlos` in the templates and the probe; the audit extends to credential inputs a `carlos`-group process reads (§4.1, §8.5, §9).
- **Round 11** (codex, `CODEX-FINDINGS-ISOLATED-EXEC-R11.md`, 1 new
  finding, every prior item resolved, **verdict accept with
  corrections**). The correction is applied in this revision:
  R11-1 the probe fixture comes from tmpfiles at bootstrap, and the control service verifies its ownership and mode before accepting a verdict (§3.2, §8.5).
- R12 (final review, plan 2): cgroup-ro targets the edge's delegated cgroup.procs; DAC precedes the mount flag so a root-owned file answers EACCES.
- R13 (task 4 review, plan 3): after a stop the activator requested, a unit reporting a foreign or absent invocation proves the old invocation over without the cgroup gate (§6.5); the observer never stops an invocation it did not start once its own stop was requested.
- R14 (plan 4, 2026-10-04; recorded in revision 16): the per-host layout view is `carlos status --layout`, beside `--instances` and under the same operator gate, not `carlos instances --layout`: the instances verb lists the local registry, and the layout report lives in each box's private status snapshot, which only the console reads (§8.4).
- R17 (plan 5 follow-up, Paul's decision 2026-10-06; revision 17): nested is the default when no intent is recorded, gated on the box's readiness; `CARLOS_LAYOUT_DEFAULT=flat` is the deployment opt-out and a recorded `flat` intent the fleet opt-out (§8.6, "Nested by default").
- R15 (first roll, 2026-09-12; fixed 2026-09-25): the probe on dev's boxes answered `ready: false` twice, and the review of the fix found a third gap. (a) `RestrictSUIDSGID=yes` makes `openat2` fail with ENOSYS on systemd 257, because systemd cannot read the mode inside `open_how` and so blocks the whole call; every `instancefs` open in the namespace is an `openat2`. The line is dropped from all four templates. What it guarded is covered another way: with no capabilities an instance can only make a set-id file owned by `carlos`, which gains nothing for another `carlos` process; `NoNewPrivileges=yes` voids it inside the namespace; and no other uid can reach one (the tenant trees are `nosuid`, and `PrivateTmp=`'s `/tmp` and `/var/tmp` sit under a root 0700 directory, `/var/tmp` being on the root filesystem and not `nosuid`). (b) `control-unreachable` checked `Lstat`, but `InaccessiblePaths=` leaves a mode 000 root-owned node at the path. The check is now what a tenant could do, a listing of the directory and a dial of the socket, and passes only when both are refused for permission or absence; a refused connection or a timeout proves the path reachable and fails. (c) `PrivateDevices=` bind-mounts the host's `/dev/shm`, so every instance, all uid `carlos`, shared one; each unit now gets its own tmpfs there, and the probe's `shm-private` check plants a file in the host's `/dev/shm` (the control service's tmpfiles fixture) and fails if it is visible. POSIX message queues and abstract unix sockets are still box-wide; they are a follow-up (§11). (d) 2026-10-04, plan 4: `carlos-edge.service` carried the same line, and the agent runs there: its nested `instancefs` opens and every rename of the layout pass are `openat2` calls, so the first `layout migrate` would have met ENOSYS on its first host. The line is dropped from the edge unit too. The argument in (a) carries: the edge's one capability, `CAP_NET_BIND_SERVICE`, does not bear on set-id files (that takes `CAP_CHOWN`, `CAP_FSETID` or `CAP_SETUID`), so anything the agent could mark set-id is owned by `carlos`, and the agent and its flat children run under `NoNewPrivileges=`. An `openat` fallback in `instancefs` was considered and rejected: it would be a second resolution path, exercised in production only when the hardening had regressed, and it would route around the regression rather than report it. Instead the agent runs `instancefs.SelfCheck` in its own unit at start, and the nested-wake gate (§8.5) asks it first, so a blocked `openat2` refuses nested wakes and the layout pass with the directive named.
- R16 (plan 4 review, 2026-10-04): three gaps in the pass while flat tenants still share the agent's uid. (a) A tenant could make its own future directory under `/data/.instances` ahead of the pass and hard-link a sibling's database or `litestream.env` into it (its own uid's files, so `protected_hardlinks` allows it); `EnsureDirs` accepted the directory and the destination check looked only at the planned names. Now a migration refuses an instance directory holding any entry the plan does not name, refuses any file about to move that has more than one name (the shadow directory walked), and checks both again after the renames and before the row flips; a failure there keeps the journal at `files` and the host fenced. (b) Deferring an awake host (plan 4 amendment, "when it is asleep") was unbounded, so a tenant that never idled kept raw-path access to its nested siblings for good. The deferral is bounded: an hour of grace (`layout.DefaultAwakeGrace`), after which the pass stops the host as §8.2 step 2 always allowed and moves it; the serial leg re-runs on its own ten minutes after any deferral so the bound holds on a quiet fleet; the intent gains `force` for an operator who wants no grace; and `carlos status --layout` warns on every box with nested rows beside flat ones, because that box is not isolated yet. (c) Files moved aside at a destination (`<name>.aside-<unix>`) were kept inside the instance directory, readable by the tenant that gets the directory; they are kept under `/data/layout/aside` now, which no instance namespace contains. Still open: a flat tenant that is running while another host moves can plant between the check and the rename; the check is against what was planted before the pass arrived, and the closure is the bound in (b). (d) Re-check: (a)'s refusals were themselves escapable: a tenant could keep its own move refused with one syscall (`ln /data/B.db /data/.x`, or a file in its own future directory), and because the pass lifted its fence on a refusal the host woke flat again on its next request, for as long as it kept the refusal alive. The rule is now fail-closed on the way to nested: a flat host that has been stopped for its move and is then refused stays fenced and down (the pass keeps the fence, as it already did after a failed handover), status reports it `fenced` with the refusal (`held down after a refused move to nested`), every later pass retries from the kept fence and releases the host once it has moved, and a flat intent lifts the fence. A nested host refused its revert is not held: it is already isolated. The mixed-layout warning counts `foreign` and `external` rows beside `flat` ones, since all three run as uid carlos with no namespace; and the second-name refusal reports the inode and the `find <data> -xdev -samefile <path>` that lists the other names. (e) Landing follow-ups: a planted ENTRY in a host's future directory is no longer a refusal (one `touch` by a sibling would have held the host down until an operator acted, and the hold outlived the sibling's own migration); it is renamed into `/data/layout/aside/<host>/` with a random, no-replace name and the move goes on, logged and shown in status as a note. The hold stays for a second name (`nlink > 1`, no safe automatic fix) and for a symlink at the instance directory. The hold is persisted as `/data/layout/<host>.hold` and restored at agent start before anything wakes, by re-deriving it from the files: the marker is a hint to re-check, not the authority, because every file under `/data` is uid carlos's and a flat tenant can unlink it; a deleted marker therefore costs one grace after one restart, after which the next pass meets the same refusal and holds again, and a marker whose reason is gone is dropped rather than honoured. The serial leg re-runs on its own after a held result, as it does after a deferral, so a cleared reason is acted on within ten minutes on a quiet fleet.
