Skip to content

S6: the launcher boots, the composition layer closes the listener seam, and the diagonal runs - #193

Merged
OpenCnid merged 13 commits into
masterfrom
d/s6-kata-launcher-boot-0658b9
Jul 25, 2026
Merged

S6: the launcher boots, the composition layer closes the listener seam, and the diagonal runs#193
OpenCnid merged 13 commits into
masterfrom
d/s6-kata-launcher-boot-0658b9

Conversation

@OpenCnid

Copy link
Copy Markdown
Owner

KataLauncher.boot raised after the G1 gate. It now claims a real microVM, and
session_host.open_workspace_session joins it to a host for the first time —
the diagonal that every earlier proof avoided by substituting a stand-in for
one half.

What was unbuilt, and is now

KataLauncher.boot. Mints a sandbox, ensures the pinned image in its own
containerd namespace, launches under the Kata runtime, refuses when containerd
reports success without creating a VM
, discovers the vsock socket from the
running VMM's argv, ships the package, and releases everything it allocated if
any step fails.

session_host.open_workspace_session. The composition layer — a context
manager over six forced steps: lease, host, open_session, boot, listeners,
scaffold. Not a new seam: the block already worked in five places (every probe,
the CLI selftest), each proving one half against a stand-in for the other. This
promotes it out of scripts/ and names it.

Three things measured rather than reasoned about

Each changed the code.

pgrep cannot carry the refusal. It excludes its own PID but not its
ancestors, so on a host with zero VMMs a pgrep -af cloud-hypervisor from any
process whose command line contains that string matches itself. comm is not
the fix
: TASK_COMM_LEN truncates at 15 characters, so a real VMM reads
cloud-hyperviso and the obvious equality test refuses every genuine boot while
looking correct. Identity is /proc/<pid>/exe.

Image stores are per-namespace. ctr -n trellis run fails with
image "…": not found until that namespace has pulled, so owning a namespace
means owning the pull — which is why the digest is now a config value with a
test asserting it tracks provision_kata_host.sh.

Readiness does not gate the refusal. By the time ctr run -d returns, the
VMM and its socket already exist (first poll at 15 ms found both). An absent VMM
is never booted, not not booted yet.

The eighth item, found and closed

Section 5.6 listed seven gaps. An eighth existed: nothing joined a launched
guest to the TrellisSandboxHost its own stubs dial.
The reading went to a
composed judge panel rather than being settled by the session that benefits from
it — the no-forwarder half promoted unanimously, the proposed replacement was
refused by all three seats on independent grounds, and the audit seat found the
evidence bundle had omitted a still-standing record passage. Closed by owner
ruling in favour of a composition layer.

Verification

Every check was watched failing against planted breakage before being trusted.

  • On the AX41, 7/7: a real VMM carrying the session's sandbox; the host end
    bound at <uds>_5001; an empty manifest reconstructing to a no-op; the
    workspace checked out for the life of the scope; a second session on the same
    live workspace refused
    ; the scope closing to zero VMMs, containers, VM
    directories and no lease; a crashed holder's lease auto-reclaimed against a
    real liveness check
    .
  • Negative control: VMM detection blinded against a real, successful
    ctr run — the refusal fired and left nothing behind.
  • First diagonal attempt returned 6/7, finding a defect no off-host test
    could see: step 5 was described in the module header and only prepared in the
    code, so a session composed without a backend came up with no host end at all.
    A step described in prose and merely prepared in code reads as done.

pytest 1042 → 1071 · vitest 1416/119 · check:repo-surface PASS ·
density-trellis contract PASS.

Records

BUILD_PLAN §5.6 carries the observed run and the closed eighth item;
THE_REPL_IN_TRELLIS.md is new and documents the system diagram, the identity
rules, and what running it changed; C12's density-chain row tracks the code.

Scope carried honestly

S6's [R] half now has its enforcing surface. The equivalence harness against
CONFORMANCE §6's twelve predicted-FALSE clauses is unbuilt, and the [A] half
— the metered real-model run — has not been proposed or spent.
Nine
density-chain classes remain stale from earlier commits on master and are
deliberately left for their own session.

OpenCnid added 13 commits July 24, 2026 19:44
`guest_main._default_harden` called `apply_tier0()` with no policy, so the
bare `Tier0Policy()` default applied: read-only roots of /usr, /lib, /lib64,
/bin, /sbin and /etc. Two things a real guest needs are outside that set.

`/proc` is the one S5 already paid for. `apply_tier0` installs the Landlock
ruleset and only afterwards reads /proc/self/status to fill in `seccomp_mode`
and `no_new_privs`, so without the grant a guest that hardened correctly
reports `Seccomp: -1` — evidence-gathering denied by the ruleset it was
verifying. S5 fixed it in the probe's own local policy and named it "not
optional" there; the default was left alone, so the next caller inherited the
gap.

The source root is the new half. The launcher unpacks `repl_sandbox` into a
directory under none of the default roots, and module-scope imports have
already run by the time Tier-0 is applied — it is the lazy ones that would
raise EACCES, deep in a later turn rather than at startup.

Neither was reachable before: `guest_main` has no non-test caller, which is
what `KataLauncher.boot` is about to change.

Each of the three checks was watched failing against the code as it stood —
both grants absent, and `_default_harden` calling `apply_tier0()` bare.
pytest 1042 -> 1045.
INTERFACES §3.1a corrected the design records in July: under the ratified
VMM's hybrid vsock the host accepts on an AF_UNIX socket, which carries no
peer CID, so identity is the host-assigned id bound to that sandbox's own
socket path. The records were followed; these two source comments were not.

`session.py`'s header said the CID "comes from the kernel at `accept()`" and
`capabilities.py` said guest CIDs are "kernel-assigned per microVM" — both
unqualified, both in the modules whose actual behaviour that correction
rewrote. Each now names what supplies the value under each transport and
points at §3.1a rather than restating it, following the same
one-place-to-read discipline the correction itself asks for.

Also ties the two copies of the guest-CID floor together. `MIN_GUEST_CID`
(derived) and `FIRST_GUEST_CID` (a literal) are one fact in two modules, and
each was checked only against its own literal — so a change to either would
leave two enforcing surfaces disagreeing about which CIDs are admissible with
both modules' tests still green. Asserting them against each other is the
point; a literal-vs-literal check passes while the copies drift, which is the
shape S6-entry found in the reserved names.

The new check was watched failing against a planted drift (FIRST_GUEST_CID
3 -> 4, reverted). pytest 1045 -> 1046.
`boot` ran the G1 gate and then raised. It now mints a sandbox, ensures the
pinned image, launches under the Kata runtime, refuses when containerd
reported success without a VM, discovers the vsock socket, ships the package,
and releases everything it allocated if any of that fails.

Three things were measured on the reference host rather than reasoned about,
and each changed the code.

`pgrep` excludes its own PID but not its ancestors, so on a host with zero
VMMs a `pgrep -af cloud-hypervisor` from any process whose command line
carries that string matches itself. A launcher whose job is refusing a boot
that produced no VM cannot use a check that invents one, so identity is
`/proc/<pid>/exe`. `comm` is not usable for it: TASK_COMM_LEN truncates at 15
characters, so a real VMM reads `cloud-hyperviso` and the obvious equality
test would refuse every genuine boot while looking correct.

Containerd image stores are per-namespace. `ctr -n trellis run` fails with
`image "...": not found` against a namespace that has not pulled, so owning a
namespace means owning the pull — which is why the digest is now a config
value with `test_config.py` asserting it tracks the provisioner's copy. The
namespace is not `default` because rule 19(a) asks a session to confirm a
destructive step over its whole reach, and Kata puts the namespace literally
in the leaked-cgroup path.

By the time `ctr run -d` returns, the VMM and its socket already exist — the
first poll 15 ms later found both. So an absent VMM is "never booted", not
"not booted yet", and the refusal needs no polling window.

`start_bridge` refuses instead of passing. The guest needs no in-guest
forwarder — there is no rlms in the guest, and the stubs dial AF_VSOCK
directly — but who owns a session's LM_PORT/DB_PORT listeners is not settled:
`kata_repl` assigns them to the LM handler and the broker, `KataLauncher`
takes no host, and no code outside tests and probes binds one. A silent no-op
would assert a bridge that does not exist, since setup() calls this step
"the bridge, before any untrusted worker process".

Observed on the AX41: G1 PASS at ratio 12.7; boot in 34.8s; VMM pid found;
socket discovered at /run/vc/vm/<sandbox>/clh.sock; the guest imported the
shipped package and reported its source root as /run/trellis. Negative
control — VMM detection blinded against a real, successful `ctr run` — the
refusal fired and left zero containers, tasks, VMMs, vm dirs and namespace
cgroups behind. pytest 1047 -> 1048, vitest 1416/119.
BUILD_PLAN §5.6 said `KataLauncher.boot` raises and the package has no guest
entry point. Both were false as of this branch and the second was false
before it — #190 built `guest_main.py`. §5.6 now carries the observed run,
the three corrections the build owes to running rather than reading, and the
gate ledger's S6 row says where it stopped.

The eighth item is recorded as open rather than resolved. `start_bridge`
refuses: the guest needs no in-guest forwarder, but who owns a session's
LM_PORT/DB_PORT listeners is unsettled, and no item 1-7 named it. That
reading went to a composed judge panel rather than being settled by the
session that benefits from it — the no-forwarder half promoted unanimously,
the proposed replacement was refused by all three seats on independent
grounds, and the audit seat found the filing had omitted CONFORMANCE §2.1's
still-standing "Option A ... is viable unchanged".

C12's density-chain row asserted the same two falsified claims in the
markdown and in the render's inline data. Both are densified in place rather
than appended, and the render's script block was re-checked for parse — a
hand-maintained artifact carrying executable data needs a syntax gate, not a
proofread, and this one shipped broken once before.

check:repo-surface PASS, density-trellis contract PASS, pytest 1048.
The previous commit updated C12's row in the class-overview table, which is
not the section the staleness check watches — the tier body at the bottom of
the file is. Three of its claims were false: that the launch path is absent,
that the lesson about portable enforcing surfaces had been paid twice, and
that `boot` still raises with no guest entry point behind it.

Densified in place rather than appended: T1 carries the third instance of the
surface-moves lesson, T2 carries the launcher that boots and the observation
that the host-to-guest composition exists only as a block hand-written five
times outside the package, and T5 replaces the "next real build" paragraph
with what the build found — the three measured corrections, and the refusal
it stopped at.
The lifetime question asked in the last session presupposed that the durable
thing and the executing thing are one object, which made a scope that ends
look like a session that is lost. They are two objects. A workspace survives
because its state is in the substrate; the microVM is compute borrowed for
one stretch of work.

The diagram places the REPL at the centre and draws that separation: many
swappable workspaces on durable storage, one loaded into a persistent
namespace, an ephemeral VM shell around it carrying the three ports, the
model holding addresses rather than bytes, and the artifact loop closing back
into the store through the judges and the user gate.

It also marks the open seam honestly: the host end of LM and DB has no owner.
An owner exchange established that a session's question about the sandbox
composition's lifetime rested on a false premise: it treated the REPL and the
microVM as one object, which makes a closing scope look like a lost session.
They are two objects, and the record now says so before a later session
presupposes it again.

THE_REPL_IN_TRELLIS.md documents the system diagram and the understanding
behind it: workspaces are many, swappable and durable (a repository is one,
which is how Trellis edits Trellis); one workspace per session; the namespace
holds handles rather than payloads; the microVM is torn down at session end
and nothing that matters is lost when it goes.

It answers the persistence question the owner put back to the session. A
workspace does not remember by snapshotting its namespace, on four grounds of
which the first is decisive: the namespace holds model-authored objects, and
deserialising them outside the sandbox is an arbitrary-code-execution
primitive running with the host's privileges. It remembers by reconstruction
over its own store plus a manifest -- which means the artifact loop is not a
feature on top of persistence, it IS the persistence mechanism, and a result
that was never filed was never meant to survive.

Concurrency is restated correctly after two wrong framings in as many
sessions. Instances run in parallel, one per database; workspaces are what
must not be shared, and checkout is the lock. That forces a correction on the
launcher's own commit message: the containerd namespace is a constant, so
parallel instances share it, and a cleanup sweep must key on the sandbox name
it minted rather than on the namespace.

Owed and not done here: an index row for the new record. docs/README.md has
~28 bytes of headroom against a checked budget, so it needs a densifying pass
rather than an append.
`_kill_shim` found its target with `pgrep -f 'containerd-shim-kata-v2.*<name>'`
and SIGKILLed whatever came back. During cleanup on the reference host an
operator ran that same pattern from a shell and killed the shell itself:
pgrep matches any process whose command line contains the pattern, and the
invoking command line did. That is the fourth time this trap has fired here
and the first time it destroyed something.

`vmm_pids_carrying` already avoided it by reading `/proc/<pid>/exe`. The fix
is to make that the only way this module identifies a process:
`_pids_by_executable` carries the walk, `vmm_pids_carrying` delegates, and
`_kill_shim` uses it. The `carrying` filter matches the sandbox name as a
whole path component rather than a substring, so `sess-1` cannot claim
`sess-10`'s shim.

Three checks over a fake `/proc`, each watched failing against the behaviour
they replace. The first draft of two of them passed the planted breakage --
their impostors were being caught by the name filter rather than by the
executable check, so they proved nothing about the fix. The impostors now
carry the sandbox name as a real path component, which leaves the executable
check as the only thing that can catch them; planting the pgrep behaviour
reddens exactly those two.

pytest 1048 -> 1051.
Owner-directed companion to trellis_repl_system.svg. It records what the
diagram claims and where each claim comes from, so a later session checks the
picture against the code rather than against prose.

The reason it exists is the error at its top: a session asked whether the
sandbox composition should be a context manager, framing it as unmissable
cleanup versus a session outliving its scope. That tension was manufactured
by treating the REPL and the microVM as one object. They are three lifetimes
-- a durable workspace, a per-session namespace, a disposable VM -- and a
scope ending releases the VM without losing anything, because what persists
is in the substrate. rlms already ends a non-persistent run with cleanup().

Also records, with measurements: that Cloud Hypervisor snapshot/restore is
real and cheap (0.2s, 174 MB on disk for a 2 GiB guest) but desynchronises
the Kata shim when driven behind containerd's back, so it is a scoping fact
for the workspace-persistence question rather than a ready mechanism; and
that this session's own containerd-namespace default is justified in
config.py by a property -- everything under this path is mine -- that is
false once two Trellis instances share a machine, which the owner has now
confirmed they will.
`session_host.open_workspace_session` is the seam the eighth item of
BUILD_PLAN 5.6 named and nothing owned. It is not new: the block already
worked in five places -- every probe and the CLI selftest each build a host,
open a session, bind the listeners and drive the backend -- but each proved
one half by substituting a stand-in for the other, and the diagonal never ran.
This promotes it out of `scripts/` and gives it a name.

A context manager, because the durable thing and the executing thing are
different objects. A workspace persists through the substrate; the microVM is
compute borrowed for one stretch of work. So a closing scope is a VM released,
never a session lost, and leaking a live VM stops being possible by
forgetting. rlms already models this -- its driver ends a non-persistent run
with `cleanup()`.

The manifest ships now and ships empty, so reconstruction is a no-op on day
one and no later session retrofits a mechanism around an absence. Its field
set is pinned as a test: it carries addresses and never a namespace, because
restoring model-authored objects outside the sandbox would be an
arbitrary-code-execution primitive with the host's privileges. A turn's
durable output is the filed artifact; the artifact loop is not a feature on
top of persistence, it is the mechanism.

Identity is two values, not one. A workspace id is stable -- physics is the
same workspace next Tuesday -- and a session id is unique per opening, dated,
with entropy, because one user opening two sessions in a day is ordinary and a
collision there shares a ledger. The user component is operational identity
only: it names a lease holder on this machine and never reaches the substrate,
where FEATURE_LIST 1.6 is closed on the ground that nothing names an owner.

Checkout auto-reclaims on an observation rather than a timeout. A lease names
the sandbox its holder booted, so liveness is answerable by asking whether a
real VMM still carries it -- the same exe-verified check written to refuse a
boot that produced no VM now proves a real one is gone. A timeout would be
wrong in both directions.

`start_bridge` no longer refuses unconditionally: it refuses when nobody
attached a bridge, which stays the honest default for a caller that boots a
guest and never composes a session.

Four properties watched failing against planted breakage before being trusted:
reclaiming a live holder, asking liveness about the claimant's sandbox instead
of the holder's, and leaking the lease on each of the two exit paths. pytest
1051 -> 1067, vitest 1416/119.
A live Kata guest was paused and snapshotted through Cloud Hypervisor's own
API socket. Every step reported success; afterwards the container could not
be re-entered and teardown could not reap the VMM.

The cause is that a Kata sandbox has two control planes reaching one virtual
machine and neither knows about the other. containerd's authority runs
through kata-agent, a process *inside* the guest; pausing the VM freezes that
process, the shim's RPC times out, and the channel is torn down. Resuming
brings the guest back and re-establishes nothing. The mistake errored at no
point -- reaching past containerd did not do something it forbade, it did
something it could not see.

That is the fifth instance of this program's recurring shape, and the first
stated as a rule about operations rather than about enforcing surfaces: an
operation is only as safe as the layer it is issued at.

The reconciliation is recorded because it looked like a fork and was not.
Workspace persistence appeared to want VM snapshots while the launch path is
containerd. The owner's ruling that the manifest ships empty settles it at
the application layer instead: the VM returns to being disposable, which is
what the context-manager lifetime already assumed, containerd stays the
launch path so nothing measured on hardware is invalidated, and snapshot
becomes a future warm-start optimisation rather than the foundation of
persistence.

Also records the four rulings the next build carries, and flags that the
workspace-identity composition is proposed rather than ratified -- Matt
raised it as a revision to the standing plan and deferred to Cnid, and that
agreement is not on the record yet.
Owner direction (Matt, 2026-07-25): a workspace identifier carries a timestamp
at the finest granularity the system offers plus a UUID, and the human-facing
label moves to metadata. That dissolved the ambiguity rather than managing it.
Deriving an identifier from a workspace's name and the current date forces a
choice between stable and unique -- a date breaks it as a lock key, no date
makes two workspaces called "physics" the same workspace. Minting once at
creation gives both. "physics" is now a display name: no lease, ledger,
manifest or audit line refers to it, so a rename is a one-field edit.

The user component stays operational identity only -- it names a lease holder
on this machine and never reaches the substrate, where FEATURE_LIST 1.6 is
closed on the ground that nothing names an owner. A stored Trellis user id is
a live proposal needing its own dated entry; this build forecloses neither
choice.

Then the diagonal ran on the AX41 for the first time: the real backend
composition against the real microVM boundary, rather than either half against
a stand-in. 7/7 on the second attempt -- a real VMM carrying the session's
sandbox, the host end bound at <uds>_5001, an empty manifest reconstructing to
a no-op, the workspace checked out for the life of the scope, a second session
on the same live workspace refused, the scope closing to zero VMMs, containers,
VM directories and no lease, and a crashed holder's lease auto-reclaimed
against a real liveness check.

The first attempt returned 6/7 and found what no off-host test could see. The
module header lists step 5 as "bind the listeners"; the code attached the
bridge and left starting it to KataREPL.setup(). Every off-host test passed,
because none of them opens a session without also driving a backend -- so a
session composed without one came up with no host end at all, and the guest's
first tool call would have met a closed connection. A step described in prose
and merely prepared in code reads as done; third instance of that shape here.

The repair makes SessionBridge.start idempotent by design rather than
defensively. The composition layer binds at step 5 because it owns the failure
-- a second listener can fail after the first is bound and a VM is running, and
only the party holding both can unwind them -- while KataREPL.setup is equally
right to assert the bridge is up before any untrusted worker. Both callers are
correct, so the second call confirms rather than rebinding a path already held.

pytest 1067 -> 1071, vitest 1416/119, repo-surface PASS, density-trellis PASS.
.claude/launch.json is scaffolded by the Claude Code preview tooling when the
browser pane is used, not authored. It appeared mid-session in one worktree,
had never been in git history, and was not ignored -- so a blanket `git add -A`
swept it into a commit, which would have made one session's incidental choice
of dev server the default every later session loads.

The repo already states this convention at the line above: committed harness
config is deliberate (.claude/settings.json, rules, skills, ceremonies) and
per-machine harness state is ignored (.claude/settings.local.json). This is the
second kind and had no rule yet.
@OpenCnid
OpenCnid merged commit 7d32d5b into master Jul 25, 2026
1 check passed
@OpenCnid
OpenCnid deleted the d/s6-kata-launcher-boot-0658b9 branch July 25, 2026 23:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant