Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Vitrin OS

Vitrin OS is an agent-first display server: a small trusted core (vitrind) speaking a capability-native wire protocol, with every legacy Wayland or X11 application confined to its own per-app nested shim — so that humans and AI agents can operate the same GUIs concurrently, under granular, revocable, capability-scoped authorization.

The sentence the current stack cannot express

An agent is allowed to fill in one form, in one Firefox window, for the next five minutes. It cannot see the password manager open beside it. The moment you touch the mouse, you have control back. Hold Escape for a second and its authority is gone — mid-click, mid-keystroke, whatever it was doing.

Today’s agents drive desktops screenshot-by-screenshot: capture, pick pixel coordinates, click, capture again. That loop is slow and race-prone, and it runs with all-or-nothing authority — the isolation unit is a whole VM or desktop session, so one prompt-injected agent’s blast radius is everything on screen.

The underlying protocols cannot express the sentence above. X11 grants every client near-total authority over the session — that is its model, not a bug. Wayland achieved isolation by removing cross-client capabilities rather than mediating them, and its wl_seat singleton has no notion of N concurrent authenticated principals. AT-SPI2, the accessibility tree agents use to avoid pixels, is an unauthorized backdoor onto every application’s widgets.

Vitrin is built around the missing primitives instead: principals that authenticate at handshake, grants that carry verbs and constraints and revoke transitively, consent rendered by the core that owns the screen, and realms that make scoping structural rather than a policy setting.

Who this book is for

You are…Start at
Curious, want to see it workRun the demo in five minutes
Writing an agent against itYour first agent
Evaluating the security modelGrants, consent, and revocation
Wondering how apps are isolatedRealms and shims
Writing a client in another languageThe wire protocol
Building an alternate core or shimBuild your own client or shim

Read this before you trust anything here

Phase 1 is complete — every milestone closed on a named integration test that runs against the shipped binaries with no mock on any seam it claims. That is a real bar, and it is also a narrow one.

The sandbox is half-built. Since P2.6.2 an app in a realm runs in six namespaces with an identity uid/gid map, zero capabilities and a private mount table it cannot reshape — verified by the core from outside, and the spawn refused when it cannot be. Since P2.6.3 it also gets a Landlock ruleset with an enumerated read set, enforced before the shim’s execve, and a generated ABI matrix of what that ruleset requires of a kernel. P2.6.3 was accepted on 2026-08-19, and on corrected criteria rather than the ones it was written with, which is worth reading narrowly: that matrix probes nothing, so it is a table about the build rather than about kernels; the per-kernel one its criteria ask for exists separately, and is measuredwhich kernels this build starts on, five distribution kernels booted under QEMU with the shipped vitrind, three of them refused below the floor and two admitted — though every row there is a kernel reading taken in a bare initramfs and not a statement about the distribution that ships that kernel, so the number of distributions measured as such is still one, and five machines somebody might be refused on is not a sweep of the ABI ladder; and the ABI floor that replaced the degradation ladder narrowed the task rather than finishing it. But there is since P2.6.4 a seccomp deny-list rather than a syscall boundary: it closes the 13 rows vitrind --print-seccomp prints and leaves the rest of the kernel’s syscall surface unenumerated, so the realm is filesystem-confined and filtered against a named list and not syscall-confined, and it keeps the invoking user’s supplementary groups. Environment hygiene confines the well-behaved; it does not contain the hostile.

Do not deploy this against untrusted applications or untrusted agents. Where this is honest about its limits is the full list, and it is worth reading before the architecture convinces you of more than it should.

Other documents

Run the demo in five minutes

At the end of this you will have watched an agent connect over a real Unix socket, petition for a capability, capture a real application’s pixels, click into it, type into it, and prove the text landed — with the trusted core mediating every step.

Nothing here is mocked. cargo xtask demo fails loudly rather than substituting a stand-in.

What you need

Linux, and:

# Debian/Ubuntu
sudo apt-get install -y libxkbcommon-dev libpixman-1-dev weston xmlstarlet

# Arch
sudo pacman -S --needed libxkbcommon pixman weston meson

weston is there for weston-terminal, the real application the headless demo drives. The Rust toolchain pins itself — rust-toolchain.toml makes rustup install the right version on your first cargo command.

Build it

git clone https://github.com/vitrin-os/vitrin-os.git
cd vitrin-os

# The Rust side: vitrind, xtask, and the test fixtures.
cargo build --workspace

# The C side: the per-app wlroots shim. It lives outside the Cargo
# workspace by design and needs its own dependency step.
bash shim/ci/install-deps.sh
meson setup shim/build shim && meson compile -C shim/build

The shim is not optional. cargo xtask demo looks for it at shim/build/vitrin-shim (or wherever VITRIN_C_SHIM_BIN points) and stops with the exact meson command above if it is missing.

Run it

cargo xtask demo --headless

Expect output ending in xtask demo: PASS, plus paths to the run’s flight recorder (flight.jsonl) and its captured frames.

What just happened

cargo xtask demo --headless
   │
   ├─ boots vitrind --headless          the trusted core, software-rendered
   │    │
   │    ├─ fork/execs vitrin-shim       a real wlroots compositor, one per app,
   │    │     │                         with a scrubbed environment and its own
   │    │     │                         private runtime dir
   │    │     └─ fork/execs weston-terminal
   │    │           WAYLAND_DISPLAY points only at the shim's own socket, so
   │    │           the app's entire universe is that shim
   │    │
   │    └─ listens on a Unix socket for agent principals
   │
   └─ runs examples/agent-demo/run_demo.py
        connect → request_grant → await consent → settle → capture
        → click → type → capture → assert the typed text landed

The two captures are not compared naïvely. An earlier version of this gate asked only for 24 changed pixels between them, which weston-terminal’s own startup paint clears without any agent involvement — it passed whether or not the click and keystrokes reached anything. It now settles the app, watches it idle at least as long as it later polls, and demands a change shaped like a typed line: enough changed pixels and a densely inked run of them along one scanline.

That detail is in this getting-started page on purpose. It is the difference between a demo and a test.

Look at the evidence

The flight recorder journals every decision the core made:

# The path is printed at the end of the run.
jq -c 'select(.event | test("grant|consent|refus"))' /path/to/flight.jsonl

You will see the petition arrive, the consent decision resolve, and each actuation checked at the chokepoint — with the grant it was checked against.

The full integration suite

The demo is one test. To run every named milestone gate:

VITRIN_C_SHIM_BIN="$PWD/shim/build/vitrin-shim" bash tests/integration/run.sh

That drives the shipped vitrind binary against real applications — weston-terminal, a GTK entry probe, and Firefox ESR — over a real socket. tests/integration/README.md maps each test to the milestone it closes, and is explicit about which tests are component tests that close nothing.

Nested mode

Drop --headless and the core draws a real window on your own Wayland session, with Firefox ESR in the realm:

cargo xtask demo
# If your Firefox is not at firefox-esr:
VITRIN_DEMO_FIREFOX=/usr/bin/firefox cargo xtask demo

This needs a running compositor (GNOME, Hyprland, …) and a browser installed. It is never a CI dependency — nested mode has no headless equivalent by design.

Nested mode is also the only way to experience the two properties the headless run can only simulate: clicking Allow on a consent prompt the core drew itself, and physically holding Escape to watch a live grant die mid-actuation.

If it fails

SymptomCause
vitrin-shim not foundThe meson step above did not run, or VITRIN_C_SHIM_BIN points somewhere stale.
Hangs before any captureweston-terminal is not installed, so the realm has nothing to draw.
AuthFailed at connectThe demo identity is not in the principals.toml the core booted with.
Nested mode opens nothingNo Wayland session — check echo $WAYLAND_DISPLAY.
vitrind exits at startup naming landlock as the mechanism it could not getThis kernel has no usable Landlock, which since P2.6.3 is a startup requirement rather than an optimisation. Check three things, in order: uname -r ≥ 5.13, zgrep CONFIG_SECURITY_LANDLOCK /proc/config.gz, and cat /sys/kernel/security/lsm for landlock — a kernel can carry the code and leave it out of lsm=. vitrind --print-isolation prints all three as landlock.abi=N. A fourth condition is a build one, not a host misconfiguration: the number must be at or above build.landlock_min_abi from vitrind --print-floor (6 here), and a working Landlock below it is refused as below-floor(abi=N,required=M) with a newer kernel as the only remedy. --landlock=off starts realms with no ruleset at all and is the wrong answer to a kernel that could be configured. This is not the row below: that one is about user namespaces and its remedy does nothing here — no sysctl makes a kernel report a Landlock ABI. The word the refusal prints (landlock vs namespaces) is the diagnosis.
A realm’s log says WARNING: Glycin running without sandbox.Expected, and it is a published cost rather than a bug. A Landlock domain denies every mount, so glycin cannot build the bwrap sandbox it decodes images in; a realm makes that refusal arrive early and legibly by refusing nested user namespaces (/proc/sys/user/max_user_namespaces = 0 inside the realm), so bwrap fails at unshare(CLONE_NEWUSER) with a message glycin recognises and glycin takes the no-sandbox fallback it already ships. The decode then runs inside your realm with no second boundary around it. Read landlock-breaks-nested-image-sandboxes on the limits page before deciding whether that is acceptable for what you are opening.
A GTK app in a realm aborts on startup with Gtk:ERROR:…gtkiconhelper.c:495 and Loader process exited early with status '1'This was the shipped behaviour until 2026-08-15 and should no longer happen; if it does, your glycin does not recognise the refusal above. The abort is glycin concluding its bwrap sandbox is available, spawning a loader that then dies, and GTK treating the failed icon load as fatal. glycin classifies the availability probe by matching its stderr against a list of namespace-refusal strings; check the realm’s log for what bwrap actually printed, and see landlock-breaks-nested-image-sandboxes on the limits page for the full measurement. Check whether this path applies to you at all with ldd /usr/lib/libgdk_pixbuf-2.0.so.0 | grep glycin and command -v bwrap.
vitrind exits at startup naming an isolation mechanism it could not getYour host does not let an unprivileged user namespace carry its capabilities, so the default confinement cannot be built and the core refuses rather than running unconfined. Run vitrind --print-isolation to see the same probe on its own. On the one machine where this was measured — a GitHub ubuntu-latest runner, kernel 6.17.0-1020-azure, 2026-08-14 — the knob was kernel.apparmor_restrict_unprivileged_userns, set to 1. That is one runner on one date, and that machine’s /etc/os-release was never opened, so do not read it as “Ubuntu does this”; the refusal names the knobs your machine answered with. Packaging so this is arranged for you is #286, and the limits page states the requirement and the bound on that measurement. There is now a profile in the tree for the AppArmor case — packaging/apparmor/vitrind — and since 2026-08-15 it has been loaded and measured, though on one kernel (6.17.0-1022-azure, not the one this row names above) and one CI image: with it installed mount.in_userns moves from restricted-by-policy(errno=13) to available, the realm spawn from refused-as-expected to ok, the real-app confinement gate passes in the same run, and removing the profile makes the spawn fail again. Nobody has loaded it on an installed Ubuntu system, no second AppArmor-carrying distribution has been measured, and nothing here installs it for you, so treat it as a result from one runner rather than as a fix for your machine. The install steps are in that file’s own header and are deliberately not repeated here: the header states them as byte-for-byte what the apparmor-profile CI job runs, and a copy on this page is a third recipe nobody has tested the moment either one is edited. That header also says what the profile grants, what it costs, and why copying the binaries rather than symlinking them is load-bearing; the limits page carries the numbers that run reported and the boundary they do not clear. If the mechanism it names is landlock, this is the wrong row — see the row above, whose remedy is a kernel build and not a sysctl, and which no AppArmor profile touches.

Still stuck? Open an issue with the flight.jsonl attached; it is usually the single most useful artifact.

Next: Your first agent.

Your first agent

The SDK is pure Python — standard library only, no dependencies, by decision (D8). If you can run python3, you can drive a Vitrin core.

python -m pip install -e 'sdk/python[dev]'

Requires Python 3.11 or newer. The [dev] extra is only for the SDK’s own test suite; the client itself pulls in nothing.

The whole thing

import vitrin_os

conn = vitrin_os.connect(
    "/run/user/1000/vitrin-0/core.sock",
    identity="vitrin://local/agent/demo",
)

grant = conn.request_grant(
    realm="realm-0",
    verbs=("observe", "actuate.pointer", "actuate.text"),
    expiry_ms=300_000,          # five minutes, enforced by the core
)

grant.await_consent()           # blocks until a human decides

frame = grant.observe()
frame.to_png("before.png")

grant.pointer.click(640, 84)
grant.text.type("example.com\n")

grant.observe().to_png("after.png")

conn.close()

That is a complete agent. Everything below is what each line means and how it fails.

Connecting

conn = vitrin_os.connect(path, *, identity, credential_type="static-token",
                         credential="", timeout=None)

The handshake happens inside connect. It either returns a bound connection or raises — and the socket is closed on every failure path, so there is no half-open state to clean up.

from vitrin_os import AuthFailed, VersionUnsupported

try:
    conn = vitrin_os.connect(sock, identity="vitrin://local/agent/demo", timeout=30)
except AuthFailed:
    ...   # the core does not know this identity
except VersionUnsupported:
    ...   # this SDK speaks a protocol version the core will not serve

Version 1 identities are static tokens listed in the core’s principals.toml. The IDL is shaped for SPIFFE/OIDC credentials; that lands later.

Petitioning for a grant

grant = conn.request_grant(
    realm="realm-0",
    resource=None,              # None = the whole realm view
    verbs=("observe", "actuate.pointer", "actuate.text"),
    expiry_ms=300_000,
    max_event_rate=0,           # 0 = the core's default ceiling
    persistence=vitrin_os.Persistence.ONCE,
)

One request co-mints the grant, a consent observer, and the facets you asked for — grant.observe(), grant.pointer, grant.text. The facets are born inert. They exist as objects immediately, and they confer nothing until the grant resolves. There is no window in which you hold a usable handle to an unapproved capability.

Verbs can be strings, or the Verb flag enum:

from vitrin_os import Verb
verbs = Verb.OBSERVE | Verb.ACTUATE_POINTER

The enum has nine members, and the petition above asked for three of them: OBSERVE, ACTUATE_POINTER and ACTUATE_TEXT. Three more are served by this core on the same terms — LAYOUT_ARRANGE, LAYOUT_FOCUS and REALM_LAUNCH, exercised by grant.set_fullscreen(), grant.focus() and grant.launch().

The remaining three — OBSERVE_CURSOR, DESIGNATE_FILE and EGRESS — are defined and resolve unsupported: the first because per-principal cursor delivery does not exist yet, the second because no core-drawn file picker and no consent copy for it exist yet, the third because the out-of-core proxy an outbound connection would be made through does not exist. DESIGNATE_FILE and EGRESS both have a facet on the wire (vitrin_powerbox and vitrin_egress), which is worth knowing only so it is not mistaken for evidence either verb works: a facet is the request you would ask through, and there is nothing behind it to answer.

Whether a verb is served is a property of the deployment, not of the protocol — a deployment that will not host process creation refuses REALM_LAUNCH even though this one serves it — so read unsupported as “not here, not now” rather than “not in this protocol”.

The SDK carries every defined bit either way, for a precise reason: an out-of-range verb bit is a fatal invalid_argument that kills the connection, so an SDK that omitted one would turn a recoverable “not yet” into a dead socket.

grant.await_consent()

Blocks until the petition resolves. In nested mode that means a human looked at a prompt the core drew and clicked Allow. Your agent’s code is identical either way — which is the point. It cannot tell, and must not care, how the decision was reached.

Failure is typed:

from vitrin_os import (
    Busy, ConsentTimeout, GrantDenied, GrantUnsupported, LayoutHeld,
    RealmUnavailable,
)

try:
    grant.await_consent()
except GrantDenied:
    ...      # a human said no
except ConsentTimeout:
    ...      # nobody answered
except RealmUnavailable:
    ...      # no such realm, or it is not running
except GrantUnsupported:
    ...      # this deployment does not serve a verb you named
except Busy:
    ...      # another petition is already up
except LayoutHeld:
    ...      # somebody else holds layout.arrange for this output

There is one class per non-granted outcome, and the list is exhaustive on purpose: an outcome the SDK could not map would raise ServerContractViolation and close the connection, which is the opposite of what a recoverable answer is for.

After it returns, inspect what you actually got — the core may have attenuated your request:

print(grant.effective_verbs())        # may be narrower than you asked for
print(grant.effective_expiry_ms())    # may be shorter

Never assume the grant you hold is the grant you requested.

Observing

frame = grant.observe()
print(frame.width, frame.height, frame.format, frame.stride)
frame.to_png("shot.png")
raw = frame.raw                       # stride * height bytes, xrgb/argb8888

observe() is poll-model: you ask, you get the current frame. There is no subscription and no push.

One race worth knowing: await_consent() can return before the app inside the realm has painted anything. The honest reply then is NoSurface — and it is judged before the rate-limit bucket, so retrying costs you no budget:

from vitrin_os import NoSurface
import time

for _ in range(50):
    try:
        frame = grant.observe()
        break
    except NoSurface:
        time.sleep(0.1)

Actuating

grant.pointer.move(x, y)
grant.pointer.button(vitrin_os.BTN_LEFT, vitrin_os.ButtonState.PRESSED)
grant.pointer.scroll(vitrin_os.Axis.VERTICAL, 120)
grant.pointer.click(x, y)             # move + press + release

grant.text.type("héllo 世界\n")        # a trailing newline presses Enter

Text goes in as text, not as scancodes — the core synthesises the keymap needed to deliver it, so non-ASCII works without your agent knowing anything about layouts.

For a batch, suppress the per-call flush and bound it with one barrier:

grant.pointer.move(10, 10, flush=False)
grant.pointer.move(20, 20, flush=False)
grant.text.type("hello", flush=False)
conn.sync(grant)          # pass the grant so refusals raise here

conn.sync(grant) is a real barrier: it returns once every prior request has been processed and its events delivered. Pass the grant and any refusal in the batch surfaces as a typed exception at that point. Omit it and refusals stay queued on the grant until its next barrier — which is a good way to not notice that nothing you sent landed.

When actuation is refused

Every refusal is a distinct exception, because they mean genuinely different things:

from vitrin_os import (NotGranted, GrantExpired, Revoked, RateLimited,
                       Preempted, ConsentHeld, NoSurface, OperationFailed)
ExceptionMeaningRetry?
NotGrantedThis verb is not in your grant.No — fix the petition.
GrantExpiredexpiry_ms elapsed.No — petition again.
RevokedSomeone revoked it. Often the dead-man switch.No. A human meant this.
RateLimitedOver the event-rate ceiling.Yes, after retry_after_ms.
PreemptedA human touched the input device.Yes, but back off — a person is using the machine.
ConsentHeldA consent prompt is up; actuation is frozen.Yes, once it resolves.
NoSurfaceNothing to act on yet.Yes, cheaply.
OperationFailedThe core tried and could not.Maybe.

There is a ninth, AtCapacity, and it is missing from the list above on purpose: it is only ever produced by realm.launch, so an actuation can never see it. If you hold a launch grant, it means the session is already running as many realms as it will, and retrying is legal once one exits.

Revoked and Preempted are the two that matter for behaving well. Both mean a human intervened, and an agent that hammers through them is exactly the failure mode this project exists to make structurally impossible — so it will not work, but writing it that way is still bad manners.

try:
    grant.pointer.click(x, y)
except RateLimited as e:
    time.sleep(e.retry_after_ms / 1000)
except (Revoked, Preempted):
    return          # a human took over. stop.

Fatal versus recoverable

The exception hierarchy encodes the protocol’s central razor:

  • GrantRefused and GrantResolutionError are recoverable. Your request failed; the connection is fine.
  • FatalError means you violated the protocol. The core has closed the connection. InvalidObject, InvalidOpcode, InvalidArgument, Oversized, FdViolation, PreHandshake — all of these mean the bug is in your client, and no retry will help.

If you are catching FatalError and retrying, you have misread the model.

Where to go next

examples/agent-demo/run_demo.py is a real agent that does all of the above, including the parts this page glossed — settling the app before capture, locating a UI feature by pixels, and asserting the actuation landed. It is also the M1.5 gate, so it is kept honest by CI.

Next: Grants, consent, and revocation.

Grants, consent, and revocation

This is the security model. If you read one chapter, read this one.

No ambient authority

The rule the whole design turns on: a connection confers nothing. Being connected, being authenticated, even being a highly trusted principal — none of it lets you observe or touch anything. Authority exists only as grants, and a grant is checked on every single action.

Compare what the alternatives do:

Unit of authorityWho can revokeGranularity
X11The connectionNobody, reallyThe whole session
WaylandThe connectionNobodyYour own surfaces only
AT-SPI2Ambient — anyone on the busNobodyEvery widget of every app
VM-per-agentThe VMDestroy the VMOne whole desktop
VitrinThe grantAnyone, immediatelyVerb × resource × constraints

What a grant is

A row in the core’s grant table:

(principal × resource × verbs × constraints)
  • principal — who. Authenticated at handshake, never asserted by the requester afterwards.
  • resource — what. A realm, or a specific surface within it.
  • verbs — which actions. observe, actuate.pointer, actuate.text, the two layout.* verbs and realm.launch (added at wire version 2) today; three more are defined and refuse unsupportedobserve.cursor, designate.file (added at wire version 2, and unserved by every deployment until the core-drawn file picker and its consent copy exist), and egress (added at P2.7.2, whose facet has landed and whose mediating proxy has not). Defining a verb before serving it is deliberate: it makes asking for one a recoverable refusal instead of a fatal out-of-range bit. Which of the defined verbs a deployment actually serves is that deployment’s property, not the wire’s — a deployment that will not host process creation refuses realm.launch even though this one serves it.
  • constraints — under what limits: expiry, event-rate ceiling, focus conditions, persistence.

Three properties make it a capability rather than a permission bit:

Sender-constrained. The grant is bound to the connection that petitioned for it. Stealing the identifier gets you nothing; you would have to be that connection.

Attenuable. A grant can be narrowed — never widened — and handed on. An agent that needs a sub-agent to do one thing can pass a grant that permits exactly that thing and expires sooner.

Revocable, immediately and transitively. Revoking a grant kills everything attenuated from it, in the same operation. Not eventually. Not at the next check-in.

The lifecycle

   request_grant()
        │
        ▼
   ┌─────────┐  the facets exist here, and confer NOTHING
   │ pending │  ← consent prompt is up; actuation refuses ConsentHeld
   └─────────┘
        │
        ├──── denied / timeout / busy ──→ raises, no authority ever existed
        │
        ▼
   ┌──────────┐
   │ resolved │  ← effective_verbs() may be NARROWER than requested
   └──────────┘
        │
        ├──── expiry_ms elapses ────────→ GrantExpired
        ├──── human holds Escape ───────→ Revoked (and everything attenuated)
        ├──── human touches the mouse ──→ Preempted
        └──── over the rate ceiling ────→ RateLimited (retry_after_ms)

A grant resolves exactly once. There is no path back to pending, and no way to re-open a resolved grant into a wider one.

The prompt asking you to approve a petition is rendered by vitrind — the process that owns the screen and the input devices. That is the whole security argument, and it is worth being precise about why it works.

An application cannot draw a convincing fake, because:

  1. The core composites the prompt above every client surface. There is no z-order a client can request that goes higher.
  2. The core takes an exclusive input grab while it is up. Clicks land on the prompt, not on whatever is beneath it.
  3. Actuation on already-granted grants refuses ConsentHeld while a prompt is up — so an agent cannot act during the window in which a human is being asked about it.

This has its own mock-free gate, tests/integration/test_real_consent.py, and the gate is stricter than “a prompt appeared”. It proves the exported footprint really is a card raster at exactly the rectangle the core named — accent ring on all four edges, exact perimeter count, opaque body, buttons, antialiased text — and then that it carries zero of the app’s pixels. Separately, it proves the prompt does not leak into the capture path: the realm-view dump taken mid-prompt is byte-identical to a settled control, and the agent’s own observe() agrees with it.

Then it proves the freeze: a mid-prompt actuation on an already-granted grant, on a second connection of the same principal, refuses ConsentHeld specifically — and the journal shows that refusal falling strictly between the prompt’s shown and its resolution.

The trusted indicator, and what it does not prove

The core paints a band it owns, in a colour randomised per session. A client cannot match a colour it cannot observe.

test_real_trust_band.py proves the negative rigorously: a real app repaints its entire surface, band rows included, and the band’s rows still carry the app’s colour in both capture artifacts rather than the indicator’s — with a core-side witness reporting zero band changes across every composite it evaluated, held up by counterweights so a witness wired only into the reply path would fail. The harness never learns the indicator colour.

This is a proof that the band cannot be forged by a client. It is not a proof that a human notices when it is wrong. Those are different claims, and the second one needs user research this project has not done. The plan explicitly adjudicated unspoofability out of M1.4’s criteria for that reason — so do not cite the milestone as evidence for it.

Human override

Physical input preempts agent input by construction, not by a race. Input is origin-tagged at the core: the router knows which events came from a human device and which came from an agent’s actuation call, and the human wins because the code says so, not because it arrived first.

Preemption is per realm, and if you run several that matters: your hand on the keyboard suspends agents acting on the realm you are in, not agents working in the other fifteen. The dead-man switch below is deliberately the opposite — it is session-wide, because an off-switch scoped to whatever you happen to be looking at is not an off-switch. The narrowing of preempted is published as a limit (limits).

…and for the two layout verbs only, you can suspend it yourself. Tap Super and, for about a second, a client holding layout_focus or layout_arrange is not refused preempted — once. That is not you granting anything: the client could already do it, and what you withdrew was a courtesy the core was extending to your own typing. It exists because otherwise a shell running inside a realm can never switch realms — the Enter that sends the request is the physical input that forbids it. The core eats that key everywhere, no app ever sees it, and while the window is open a small marker sits just below the trusted band. A focus change with no marker up was not yours. The costs, including that either of two layout holders may take the press, are in limits.

The dead-man switch

Hold Escape for one second. Every live grant is revoked.

The agent’s very next call — observe() or any actuation — raises Revoked. Not “at its next poll”, not “within a few seconds”: test_real_deadman.py asserts both refuse on the immediately following check, that the real app’s target is left untouched (read via --capture-dump, which bypasses the now-revoked grant entirely), and that the flight recorder journals dead_man_triggered followed by grant_revoked.

Headless has no physical key to hold, so that gate uses a signal to stand in for the chord. The nested recipe for a genuinely held Escape is in shim/docs/firefox.md §9 — and it is worth doing once by hand, because watching an agent die mid-keystroke is the moment the model stops being abstract.

The chokepoint

Every one of these checks happens in one place. There is no fast path, no cache that skips the grant table, and no module that can act without going through it. That is what makes the trusted core auditable: the interesting question is only ever “what does the chokepoint do”, never “which of forty call sites forgot to check”.

The flight recorder journals each decision, so a run is reconstructible after the fact:

jq -c 'select(.event | test("grant|consent|revok|refus"))' flight.jsonl

Next: Realms and shims.

Realms and shims

Grants control what a principal may do. Realms control what an application can see. The two are independent, and the second one is structural.

The idea

A legacy application never talks to the trusted core. It talks to its own private Wayland compositor — a shim — which is itself an unprivileged client of the core.

        ┌──────────────────────────────────────────┐
        │  vitrind — the trusted core              │
        │  capability kernel · grant store         │
        │  compositor · input router · consent     │
        └──────────────────────────────────────────┘
             ▲                          ▲
   frames up │ input down     frames up │ input down
   (dmabuf/  │ (origin-       (dmabuf/  │ (origin-
    shm fd)  │  tagged)        shm fd)  │  tagged)
             ▼                          ▼
    ┌─────────────────┐        ┌─────────────────┐
    │ realm-0         │        │ realm-1         │
    │  ┌───────────┐  │        │  ┌───────────┐  │
    │  │ vitrin-   │  │        │  │ vitrin-   │  │
    │  │ shim      │  │        │  │ shim      │  │
    │  └───────────┘  │        │  └───────────┘  │
    │        ▲        │        │        ▲        │
    │  WAYLAND_DISPLAY│        │  WAYLAND_DISPLAY│
    │        │        │        │        │        │
    │  ┌───────────┐  │        │  ┌───────────┐  │
    │  │ Firefox   │  │        │  │ a terminal│  │
    │  └───────────┘  │        │  └───────────┘  │
    └─────────────────┘        └─────────────────┘

Firefox in realm-0 cannot enumerate the terminal in realm-1, cannot see its surfaces, and cannot receive its input — not because a policy forbids it, but because its entire Wayland universe is a compositor that contains only itself. There is nothing to enumerate. Scoping is structural.

This is the gamescope and Qubes precedent, applied per-application rather than per-session.

Why the shim is a separate process in C

The shim is a real wlroots compositor, written in C, built with Meson, deliberately outside the Cargo workspace. That looks like an odd choice in a Rust project until you see what it buys:

Legacy complexity is exiled from the TCB. Serving the full Wayland protocol surface — xdg-shell, subsurfaces, buffer management, all the quirks real toolkits rely on — is a large, messy job. None of it belongs in a process that also holds the grant table. The shim absorbs that complexity while being untrusted: the core assumes nothing about its behaviour.

It is disposable. One shim per application. It crashes, that app dies, and nothing else notices.

Patching it costs you nothing. The trademark policy makes this explicit: modifying the trusted core means renaming your build, but shim/ sits outside the TCB, so patching a shim or writing a whole new one does not change what the core enforces and does not cost you the name.

What confines a realm today

Be precise here, because the honest answer is short.

When the core launches a realm’s app it:

  • forks a per-app shim and hands it one end of a socketpair as its identity — no credential, no handshake; holding the descriptor is being that realm’s shim;
  • gives it a private 0700 runtime directory;
  • builds its environment from nothing — only names the operator allow-listed in realm.toml, plus a WAYLAND_DISPLAY pointing at that realm’s own socket. DISPLAY, the host WAYLAND_DISPLAY, WAYLAND_SOCKET, XAUTHORITY and the host XDG_RUNTIME_DIR cannot reach the app;
  • lets no unrelated descriptor cross the fork — not the agent listener, not the flight-recorder log, not other realms’ sockets, not capture memfds — via a close_range sweep between fork and execve;
  • resets signal dispositions, so the child does not inherit whatever the operator’s shell was ignoring.

Those last two are enforced by the fork itself rather than by every other module remembering to be careful. The full path is documented in crates/vitrin-core/src/spawn.rs.

That is the complete list. Read the next section before drawing conclusions from it.

One prerequisite, because it decides whether any of the above happens at all: the namespace set is built from an unprivileged CLONE_NEWUSER, so the host has to let such a namespace carry its capabilities. Where a host permits the unshare and then strips them, vitrind --isolation=default refuses to start rather than running a weaker session — see the limits page for the requirement, the one measurement behind it, and what is tracked to make the grant routine.

Since P2.6.3 there is a second such prerequisite, and it is a different condition with a different remedy: the kernel must actually have Landlock — ≥ 5.13, built with CONFIG_SECURITY_LANDLOCK=y, and with landlock in the active LSM list (/sys/kernel/security/lsm) — and, since 2026-08-15, an ABI at or above this build’s declared floor (build.landlock_min_abi from vitrind --print-floor, 6 here). The ruleset below is part of the confinement floor, so without all four the core refuses to start rather than confining a realm one mechanism less than its own journal claims. The fourth is the one a correctly configured kernel can still fail, and its remedy is a newer kernel rather than any knob. The refusal names the mechanism it could not get — namespaces for the paragraph above, landlock for this one — and that word is the diagnosis: the two remedies do not substitute for each other. vitrind --print-isolation answers both, without spawning anything.

What does not confine a realm

The sandbox is half-built. Decisions D9, D-020, D-036. The shim and its app run in six namespaces with an identity uid/gid map, zero capabilities, a private mount table and — since P2.6.3 — a Landlock ruleset enforced before the shim’s execve, whose read set is enumerated rather than granted at the realm root and whose write set reaches eight hierarchies (the four writable mounts in full, plus WRITE_FILE alone on /proc, /dev, /dev/pts and each render node — eight with one render node bound, one more for each additional one). What that ruleset requires of a kernel is published as a generated, CI-held table — the Landlock ABI matrix — and P2.6.3 was accepted on 2026-08-19 on corrected criteria rather than the ones it was written with, so read that narrowly: that table measures no kernel, the per-kernel one its criteria ask for exists on a page of its own — which kernels this build starts on, five distribution kernels booted under QEMU with the shipped vitrind, two admitted and three refused below-floor — but every row there is a kernel reading taken in a bare initramfs and not a statement about the distribution that ships that kernel, so the number of distributions measured as such is still one, and the ABI floor narrowed the task rather than closing it. Since P2.6.4 there is also a seccomp deny-list, installed immediately before the shim’s execve and inherited by every process the shim forks: it closes the 13 rows vitrind --print-seccomp prints, each naming the escape class it answers and the errno it returns, and leaves the rest of the kernel’s syscall surface unenumerated. So the realm is filesystem-confined and filtered against a named list and not syscall-confined. At --isolation=off none of it applies and the paragraph below holds in full; --landlock=off turns off the ruleset alone, and both say so in every journal entry. There is no --seccomp=off: a kernel that cannot accept a filter refuses the session instead of running one unfiltered.

An application that ignores WAYLAND_DISPLAY and connects directly to a path it already knows is not stopped by anything in this MVP.

And an app’s own sandbox no longer confines anything here. A Landlock domain denies every mount-topology change to a realm’s app and its descendants, unconditionally — mounting is not an access right, so no rule grants it and widening the ruleset cannot restore it. A nested sandbox therefore cannot be built inside a realm, and an app that decodes images in one (GTK → glycinbwrap) decodes them unsandboxed instead. A realm additionally refuses nested user namespaces outright, which takes no capability away — a namespace that cannot mount was already useless — and turns that into the conventional refusal such libraries already handle rather than an unexpected mount(2) failure. The measurement, and what it costs, are on the limits page.

Two further specifics worth naming rather than leaving to be discovered:

The session D-Bus is reachable at --isolation=off, and closed twice over at --isolation=default. The core advertises no DBUS_SESSION_BUS_ADDRESS and redirects XDG_RUNTIME_DIR either way, so a well-behaved client finds no bus — but advertisement is not reachability, and at off that is the whole of it: /run/user/<uid>/bus is still on the filesystem, still connectable by any process of that uid, and the abstract-socket namespace is still shared. Since P2.6.2 the default closes both halves, and neither closure is this project’s cleverness: the mount namespace removes /run/user/<uid>/bus as a path — the realm’s /run holds one entry, vitrin — and the network namespace removes the abstract-socket namespace the bus also listens on, because abstract sockets are scoped to a network namespace. So an operator who allow-lists DBUS_SESSION_BUS_ADDRESS in realm.toml at off turns an implicit hole into an audited one, and the same line at default names something that is not there. That closure is not the same claim as a measurement, though, and the distinction is worth its own sentence: it is derived from the mount table rather than measuredno test asserts the absence of /run/user, and tests/integration/test_real_confinement.py lists “that a realm cannot reach the session bus by other means” among the things it explicitly does not prove. The adversarial probe that would attempt org.a11y.Bus activation on every bus reachable from inside a realm has still not been written. Two residuals survive that, and both are narrower than what closed: binds names any absolute path outside / and /home, so an operator who binds the host’s runtime directory into a realm puts the bus socket back inside it under a key that says nothing about buses; and the designated-egress half of the network answer — reachability as a granted, host:port-scoped capability rather than as nothing at all — is still P13’s, unbuilt.

Same-uid separation is not attempted. The 0700 runtime directory bounds other users on the machine, not other processes of this user, and the app runs as the core’s uid in either isolation mode. What the realm’s XDG_RUNTIME_DIR names stopped being the same thing at P2.6.2, though. At --isolation=off it is $XDG_RUNTIME_DIR/vitrin-0/<realm>, one level below the directory holding the core’s own agent socket and the run’s flight-recorder log — it names the control plane as much as it hides it, and relocating the tree would not help, since a child of the core’s uid derives /run/user/<uid> from getuid() with or without a variable pointing at it. At --isolation=default the value is the fixed in-realm /run/vitrin, a bind of that same core-created directory, and .. resolves to the realm’s own /run, where there is no core.sock and no recorder log. The closure is the mount namespace’s rather than the path’s, and it is checked rather than argued: both are canaries every confined spawn probes through /proc/<shim>/root.

Environment hygiene confines the well-behaved. It does not contain the hostile. Real sandboxing arrives with the Phase-2 powerbox (E2.6/E2.7).

Configuring a realm

realm.toml names what a realm runs and what environment names may reach it:

[[realm]]
id = "realm-0"
command = "/usr/bin/firefox-esr"
args = ["--no-remote", "--new-window", "about:blank"]
env_allow = [
    "HOME", "LANG", "XDG_SESSION_TYPE",
    "MOZ_ENABLE_WAYLAND", "GDK_BACKEND",
    "DBUS_SESSION_BUS_ADDRESS",   # see above -- an audited hole at --isolation=off
]

env_allow is an allow-list of names, and values are copied from vitrind’s own environment. That is the only route by which a realm’s environment grows. examples/realm.toml carries the security rules inline.

A second realm is a second [[realm]] table, up to 16, and one of them must be realm-0 — the one realm name a client can know without being told, since there is still no way to enumerate realms on the wire. Each gets its own shim, its own private runtime tree and its own socket, exactly as the diagram above shows. Ids are otherwise free-form, with one refusal worth knowing: a realm’s lock file sits beside its directory as <id>.lock, so a realm named foo.lock would collide with realm foo, and startup refuses that naming both.

What a second realm does get is its own scene and its own capture: an observe grant returns the pixels of the realm it names, hidden or not, and a grant over a realm whose app has died refuses no_surface regardless of what its siblings are doing. What it does not get is its own output: the core composites one output from one realm’s scene, so with several realms running only the realm the output is bound to is visible — the first one to attach. Which realm that is is now somebody’s to choose: a client holding the layout.focus verb moves the output, and the human’s own keyboard and pointer move with it — one act, because showing a realm and typing into it must never come apart. Absent such a client the binding moves on the one event nobody chooses: the bound realm’s app exiting, after which the output follows to the first realm still serving, and to no realm at all once none is. Every other realm still renders, and pays for it. Read Known limits before configuring more than one.

--no-remote in that example is load-bearing, not hygiene: without it, a firefox --new-window on a machine already running Firefox hands the window to the existing instance over its remoting protocol — never touching the confined process at all, silently defeating the entire arrangement.

The buffer path

Frames move shim→core as file descriptors over SCM_RIGHTS. Two paths:

  • shm — universal, always available, one copy. CI runs entirely on it.
  • dmabuf — zero-copy on a real GPU. Version 0 imports exactly xrgb8888/argb8888 with the linear modifier implied; the allow-list is checked before any driver call. Failure produces an explicit buffer_done(import_failed) telling the shim to fall back to shm — never a silent black frame.

MVP success does not depend on zero-copy working, which is why CI can stay GPU-free.

Next: The wire protocol.

The wire protocol

You need this chapter if you are writing a client in a language the project does not ship an SDK for, or debugging one that exists.

The IDL is the source of truth. protocol/vitrin-v0.xml defines every interface, and where this book and an IDL <description> disagree, the IDL wins. docs/protocol/00-conventions.md is the normative conventions page this chapter summarises.

Shape

Wayland-influenced and deliberately so: object-oriented, per-connection object ids, requests and events over a Unix socket, file descriptors passed by SCM_RIGHTS. If you have written a Wayland client, this will feel familiar.

What is different is that authority is a first-class object. vitrin_grant is on the wire, with a lifecycle you can observe.

Framing

One message, one frame. All multi-byte integers little-endian. Every frame opens with an 8-byte header:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+---------------------------------------------------------------+
|                        object_id (u32)                        |
+-------------------------------+---------------+---------------+
|           size (u16)          |  opcode (u8)  | fd_count (u8) |
+-------------------------------+---------------+---------------+
|                       argument payload                        |
FieldNotes
object_idThe target. Per-connection; referencing an unknown id is fatal invalid_object.
sizeThe whole frame including this 8-byte header. Below 8, or a payload shorter than declared, is fatal oversized. The u16 ceiling of 65535 binds senders: do not construct a larger frame.
opcodeThe request or event opcode within the target’s interface, at the negotiated version.
fd_countHow many descriptors accompany this frame.

fd_count living in the header is a deliberate framing invariant, not an accident of the current signatures: a receiver can drop a frame it cannot interpret and still consume exactly the right number of descriptors, so an unknown message never desynchronises the fd stream. If the header’s fd_count disagrees with the target message’s signature, that is fatal.

At most one fd per message. Nothing in v0 needs two.

Argument types

TypeEncoding
intsigned 32-bit LE
uintunsigned 32-bit LE
objectu32 object id — may be 0 only where the IDL declares allow-null
new_idu32 id the sender allocates for the object being created
stringlength-prefixed, see the conventions page for padding
fdnot in the payload — carried out-of-band, counted by fd_count

Two connection classes, one wire format

ClassReaches the core byInterfaces
Agent principalConnecting to the core’s listening socket and authenticatingvitrin_handshake, vitrin_principal, vitrin_realm, vitrin_grant, vitrin_consent, vitrin_view, vitrin_actuator_*, vitrin_launcher, vitrin_layout_*, vitrin_egress
ShimInheriting a socketpair from the core across fork/execvitrin_shim_session, vitrin_shim_surface, vitrin_shim_seat

The classes are mutually unreachable. A message using the other class’s opcodes dies as fatal invalid_opcode, with no special-casing anywhere.

Note what a shim’s identity is: holding the inherited descriptor. There is no shim credential and no shim handshake — the socketpair is the authentication, because the core created both ends itself.

The interfaces

InterfacePurpose
vitrin_handshakeVersion + identity hello, resolving to a bound principal
vitrin_principalThe authenticated principal — root of the connection’s authority chain
vitrin_realmRealm address; an authority-free scope handle (realm-0 is the well-known realm and is always served; a deployment may configure more)
vitrin_grantCapability handle — the wire projection of one grant-table row
vitrin_consentConsent-prompt visibility for one petition (events only, no authority)
vitrin_viewObservation facet — poll-model frame capture
vitrin_actuator_pointerPointer actuation facet
vitrin_actuator_textText actuation facet
vitrin_shim_sessionShim connection bootstrap
vitrin_shim_surfaceShim-to-core buffer path
vitrin_shim_seatInput delivery to the shim (events only, origin-tagged)
vitrin_launcherRealm-launch facet (since wire version 2) — fork a new realm instance from an operator-written template, under a core-minted id; launch carries no arguments, so the command never crosses the wire
vitrin_layout_focusFocus facet (since wire version 2) — bind the output to the granted realm and send the human’s own input there, one act
vitrin_layout_arrangeArrangement facet (since wire version 2) — fill the output, or compose at the app’s own size; place, resize, raise and stacking are absent rather than refused
vitrin_powerboxDesignation facet (since wire version 2) — ask the human to pick one file or one directory subtree and have the descriptor delivered to the realm; no path crosses the wire in either direction. Vocabulary only so far: no deployment serves the verb, so vitrind mints the facet (issue #322) and refuses every ask not_granted until the picker lands
vitrin_egressEgress facet (since wire version 2) — one outbound connection to the single host:port the grant names, handed back as a socket fd. No deployment serves the egress verb: vitrind mints the facet and refuses every request_connect not_granted (issue #322), because the out-of-core mediating proxy does not exist

Each has a prose page under docs/protocol/.

The petition, in one request

A grant petition co-mints, in a single request: the grant, a consent observer, and the facets (view, pointer, text). The client allocates a new_id for each.

The facets are born inert. They are real objects from the moment the request is sent, and they confer nothing until the grant resolves. That shape is why there is no window in which a client holds a live handle to an unapproved capability — the alternative, minting facets after approval, would need a second round trip and a state machine on both sides.

client                                        core
  │  request_grant(new_id grant,                │
  │                new_id consent,              │
  │                new_id view, ptr, text)      │
  ├────────────────────────────────────────────>│
  │                                             │  petition registered,
  │              consent.state(shown)           │  prompt raised
  │<────────────────────────────────────────────┤
  │                                             │
  │              grant.resolved(outcome, verbs, │  human decides
  │                             expiry_ms, ...) │
  │<────────────────────────────────────────────┤
  │                                             │
  │  view.capture_frame()                       │
  ├────────────────────────────────────────────>│
  │              view.frame_ready(+ memfd)      │
  │<────────────────────────────────────────────┤

resolved carries the effective verbs and expiry, which may be narrower than requested. A client that assumes it got what it asked for is wrong.

The error razor

The single most important thing to get right.

Fatal — you violated the protocol. The core closes the connection. No retry helps, because the bug is in your client.

invalid_object · invalid_opcode · invalid_argument · oversized · fd_violation · pre_handshake · version_unsupported · auth_failed · internal · resource_exhausted

Recoverable — your request failed. The connection is healthy and you may try something else.

not_granted · expired · revoked · rate_limited · preempted · consent_held · no_surface · internal · capacity

Those are the IDL’s own spellings, which is what a client in another language has to match. The Python SDK’s exception names are deliberately not all identical to them — expired is GrantExpired, internal (7, recoverable) is OperationFailed, capacity is AtCapacity, and the fatal internal (8) is InternalError — so transcribe from the IDL, never from an SDK.

The line is: did the client send something incoherent, or did a coherent request get refused? Setting an out-of-range verb bit is incoherent — fatal. Asking for a verb the core does not serve is coherent — refused, unsupported. This is exactly why the Python SDK carries every defined verb bit, served or not, and every defined outcome and refusal code: one it did not know would turn a recoverable answer into a dead socket.

Ordering

Requests on one connection are processed in order, and events for one object arrive in order. sync is a barrier: it returns once all prior requests are processed and their events delivered — petition resolution excepted, since that waits on a human.

Versioning

Wayland-style. Interfaces grow by appending messages and appending enum values; existing opcodes never change meaning. The negotiated version comes out of the handshake, and each side serves only what that version defines.

Version 0 is frozen for Phase 1 — not forever. The wire integer is now 2, and it appends:

  • the realm_launch verb, and vitrin_principal’s attention event;
  • five structural mints on vitrin_grantget_launcher, get_layout_focus, get_layout_arrange, get_powerbox, get_egress;
  • the five interfaces they mint — vitrin_launcher (launch/launched), vitrin_layout_focus (focus), vitrin_layout_arrange (set_fullscreen), vitrin_powerbox (request_file, request_dir, designated, refused), vitrin_egress (request_connect, connected, connect_failed) — and the vitrin_layout_arrange.mode, vitrin_powerbox.mode, .kind and .refusal, and vitrin_egress.failure enums;
  • the designate_file verb, the file:/dir: resource prefixes, and vitrin_shim_session.designation — the powerbox vocabulary, refused unsupported by every deployment until the core-drawn picker and its consent copy exist. This core dispatches the messages as of issue #322 — get_powerbox mints, and both requests on the facet it mints refuse not_granted recoverably. Until then it had no arm for any of the three, so sending one was fatal invalid_opcode rather than a mint or a refusal;
  • the egress verb (128, at P2.7.2) and the net:HOST:PORT value its authority is named with. That half landed as a verb bit and a resource grammar, and no message at all; its facet followed separately. Every deployment still refuses the verb unsupported, because the out-of-core proxy a connection would be made through does not exist — a facet is a request to ask through, not a mechanism to answer with. get_egress and vitrin_egress.request_connect were unhandled on the same terms, and issue #322 closed both halves at once: the five requests this core did not dispatch were the two mints and the three facet requests behind them — not the two mints alone, which is what this list said while the two facets were landing on parallel branches, and the count is why one fix had to cover all five;
  • the capacity refusal code and the layout_held outcome;
  • on vitrin_shim_session, the cross-realm clipboard, the pointer constraints and the idle inhibit (request_selection, offer_selection, pointer_constraint_state, selection, pointer_constraint, idle_inhibit), and on vitrin_shim_seat, relative_motion and the four gesture events — plus the enums their arguments carry.

It changes nothing else: every version-1 signature is byte-identical at version 2. The complete, normative enumeration — every message, and every enum with a count to check the list against — is 00-conventions.md §7.3, and this list restates it. Phase 2 brings semantic trees and epoch/CAS action semantics. Expect to move.

Validating your understanding

The IDL is machine-checked and so is the code generated from it:

xmllint --noout --relaxng protocol/vitrin-v0.rng protocol/vitrin-v0.xml
cargo xtask codegen --check     # generated Rust + C header match the IDL

crates/vitrin-golden holds golden frame vectors, and the Python SDK’s tests/test_golden_vectors.py checks its encoder against the same bytes. Those vectors are the cheapest way to validate a new client’s codec.

Next: Build your own client or shim.

Build your own client or shim

Three things you might build, in increasing order of effort.

Licensing, up front, because it decides what you may do: the protocol, everything generated from it, the code generator, the conformance instruments and the SDKs are Apache-2.0, patent grant included. You never have to touch a copyleft file to write a client, an alternate compositor, or an integration. The MPL-2.0 copyleft binds one group only: people modifying the trusted core itself. NOTICE is the normative path→license map.

An SDK in another language

The codegen already emits Rust and a C header from the same IDL. A third language is a fork of that path, not a from-scratch effort.

Start here:

crates/vitrin-scanner/       the generator: IDL XML -> Rust + C header
crates/xtask/                its driver (cargo xtask codegen)
crates/vitrin-golden/        golden frame vectors
sdk/python/                  a complete client, stdlib-only, ~2k lines

crates/vitrin-scanner is Apache-2.0 precisely so that a third party writing a Go, TypeScript or C++ SDK forks it.

The order that works:

  1. Codec first, against the golden vectors. Encode and decode every message, and check your bytes against crates/vitrin-golden. Do not move on until they match exactly — every later bug looks like a codec bug anyway.
  2. Handshake. Version and identity hello, resolving to a bound principal. Get version_unsupported and auth_failed right; they are the first two errors you will actually hit.
  3. Petition and resolution. One request minting grant + consent + facets, then blocking on resolved. Read the effective verbs it returns rather than the ones you asked for.
  4. Observe. Receive a memfd over SCM_RIGHTS, honour stride (never assume width * 4 — v0 pins it, later versions need not), and map the format.
  5. Actuate. Pointer and text.

Three things to get right, because they are what a naïve port breaks:

  • Carry every defined verb, whether or not this core serves it (observe, actuate.pointer, actuate.text, observe.cursor, layout.arrange, layout.focus, designate.file, egress, realm.launch). An out-of-range verb bit is fatal invalid_argument and kills the connection. Omitting one turns a recoverable unsupported refusal into a dead socket for any user who petitions it. Which of them a deployment serves is that deployment’s business and can change under you — this core refuses observe.cursor, designate.file and egress, and serves the other six (egress because no mediating proxy exists, not because its facet is missing — vitrin_egress is on the wire, exactly as vitrin_powerbox is for designate.file) — so never bake the served set into a client. Transcribe the values from the IDL rather than assuming consecutive bits — realm.launch is 512, because 256 is allocated to a verb the IDL does not define yet and is still out of range; 64 was in that gap until designate.file landed on it, and 128 until egress did at P2.7.2 — so the set that sentence names is down to one. The Python SDK’s test_verb_parity.py pins this against the IDL; write the equivalent.
  • Model fatal versus recoverable in your type system. If your users can catch a fatal error and retry, your API is lying to them. The Python SDK makes them separate hierarchies for this reason.
  • Read fd_count from the header, always. It is what lets you skip a frame you do not understand without desynchronising the descriptor stream. A client that infers fd counts from opcodes will corrupt itself the first time it meets a message from a newer version.

An alternate shim

A shim is an unprivileged Wayland (or X11) compositor that forwards frames up to the core and replays origin-tagged input down into its app. The core assumes nothing about its behaviour, which is what makes writing a new one reasonable.

shim/ is the reference: wlroots, C, Meson, outside the Cargo workspace. shim/include/vitrin-protocol.h is generated from the IDL and is Apache-2.0 despite living under shim/ — writing a C client must never require touching copyleft code.

The contract:

  • Your identity is the socketpair you inherited. There is no handshake and no credential — holding the descriptor is being that realm’s shim.
  • Forward composited frames via vitrin_shim_surface, as shm or dmabuf. If you offer dmabuf, handle buffer_done(import_failed) by falling back to shm. Do not paint a black frame.
  • Receive input on vitrin_shim_seat — events only, origin-tagged — and replay it into your app through your own wl_seat.
  • Serve your app whatever protocol surface it needs. That is your problem, not the core’s, and it is the whole reason this is a separate process.

Text actuation avoids text-input-v3 deliberately (decision D7) and synthesises a keymap instead; shim/docs/ covers why, and it matters if your shim serves toolkits with their own IME assumptions.

A patched or replacement shim does not cost you the name. The trademark policy draws its line at the trusted core, and shim/ is outside the TCB by design.

An alternate core

The hard one, and the one with real obligations.

crates/vitrin-core and crates/vitrin-ipc are MPL-2.0. The copyleft is deliberate: the project’s claim is a small, auditable trusted core, and a modified capability kernel should not be shippable as a black box. MPL is per-file, so this does not reach applications running under it, and MPL §3.3’s Larger Work allowance keeps linking against MIT-licensed wlroots and Smithay clean.

If your change alters what the core enforces — the chokepoint, the grant lifecycle, the consent surface, the dead-man switch, input origin tagging — then TRADEMARK.md asks you to rename or ask first. The reasoning is Firefox/Iceweasel: the name is what tells someone which build actually enforces the security claims, and a rename is a remedy rather than a punishment. The default answer to asking is yes.

Conformance instruments, all Apache-2.0:

ToolWhat it checks
crates/vitrin-goldenPer-pixel + SSIM frame comparison
crates/vitrin-mock-shimA controllable synthetic shim peer — component tests only, never milestone evidence
fuzz/cargo-fuzz targets for protocol decode and vitrin-ipc framing, with a checked-in corpus
tests/integration/Drives the shipped binary against real apps over a real socket
shim/wlcs/Advisory WLCS conformance. GPL-3.0-only — never built by default, never linked into vitrin-shim

That last row is the one licensing trap in the tree: shim/wlcs/ compiles MPL-2.0 shim sources into a GPL-3.0-only module, which is lawful only because MPL keeps GPL-3.0 as a Secondary License. Never add MPL Exhibit B anywhere in the tree — it would switch that off and make the module undistributable.

Getting it reviewed

The project would rather have a second implementation than a perfect first one — a protocol with one implementation is a format, not a standard. Open an issue describing what you are building. Two specific asks:

  • Report IDL ambiguities as bugs. If you had to guess, the <description> is underspecified and that is a defect worth fixing while v0 is young.
  • Say plainly what you have not tested. The project’s own docs are written to that standard, and it is the most useful thing a second implementer can offer.

Getting out of a wedged session

vitrind --drm takes DRM master and the seat. When it is running, it is the display: nothing else can put a pixel on the panel, and the kernel has stopped handling the chord you would normally use to leave. So the ordinary question a display server never has to answer becomes the first one this one must — how do you get out when it stops responding?

This page is the answer, written for the one machine this project is developed on, because a generic recovery page is one you have to translate at the worst possible moment. It is a companion to docs/drm-bringup.md, which is the bring-up procedure; that page’s step 0 is where the escape route is proven before you start, and this page is what you read when you need it. Where the two overlap, step 0 is the source and this page links to it rather than restating it.

The honest frame, first. Every route below needs something to still be working: a keyboard, or a shell, or the ability to power-cycle and boot a USB stick. There is no route that works when all of those are gone. The project declined to run an SSH server on this machine (D-031, the first of two entries with that number), which is the one mitigation that would have been independent of the display, the seat and the local keyboard. That trade was made knowingly and its cost is exactly this paragraph.

Verified vs inferred

Same convention as the bring-up page, for the same reason — a recovery page that reads as tested when it is not is worse than one that admits it:

  • [verified] — read from this machine on 2026-08-10, read-only (no VT switched, no vitrind started, no destructive SysRq letter executed, no power state touched), or observed on hardware during the 2026-08-11 run recorded at the bottom of this page.
  • [inferred] — from the kernel’s own source or documentation, or from a configuration that is in place but whose behaviour was not exercised here.

Route 2 is the only route that has ever recovered a wedged session — on 2026-08-09, and again on 2026-08-13. Both times the command this page published was wrong for the wedge in front of it, in two different ways, so read the route itself before you rely on it: the pkill -f form was broken three ways (#260) and the kill -TERM that replaced it is inert against a stopped process (#277). What actually recovered the 2026-08-13 wedge was kill -CONT, and it recovered it in the next logged millisecond.

Route 1’s chord is confirmed to work from a healthy session (10 of 10 on 2026-08-11, L1 below; 5 more on 2026-08-09). That is a different claim from it gets you out of a wedge, and 2026-08-13 sharpened how it fails: against a SIGSTOPped session the chord is not refused and not lost — it is queued, and it completes the instant the session resumes. For 163.8 s the panel showed the previous VT while the kernel already considered the target VT active.

Route 3 is documented and unexecuted and route 4 has never been used. Treat both as careful predictions.

Which route, by symptom

Work down. Do not skip to a later route because an earlier one feels slow — the later ones cost you more, and the last one costs you the machine’s uptime.

What you are looking atRoute
Panel wrong or frozen, keyboard works1 — Ctrl-Alt-F<n>
Panel dark or wrong, you have a shell somewhere (another VT, or the Hyprland session on tty1)2 — a shell and a signal
vitrind will not die, or it died and the machine is still stuck; you have a shell with sudo3 — SysRq through /proc/sysrq-trigger
Nothing responds at all4 — power cycle, then the installer USB

A wedged session does not have to look dark, and on 2026-08-13 it did not. The operator pressed Ctrl-Alt-F3 to return to a SIGSTOPped session and the panel kept showing the previous VT’s last console content — tty2’s shell, sitting there apparently fine. The kernel had already made tty3 active (/sys/class/tty/tty0/active read tty3 for the whole wedge), but the stopped compositor never acknowledged the acquire and never set a mode, so the framebuffer simply retained what was last scanned out.

To a human that reads as “the VT switch did nothing”, which is the wrong diagnosis and points at the wrong route. Check /sys/class/tty/tty0/active before believing the screen: if it names the VT you asked for and you are not looking at it, the session on that VT is wedged, not the switch.

The switch was never refused, either. It sat pending for the whole 163.8 s and completed in the same millisecond the process resumed — the first line logged after SIGCONT was the seat activated this session; reclaiming the panel. Route 1’s chord had been accepted all along and was queued behind the wedge.

Route 1 — Ctrl-Alt-F<n>, which this core implements itself

Press Ctrl-Alt-F1 to get back to the Hyprland session on tty1, or Ctrl-Alt-F2F12 for another terminal.

This works only because the core implements it. Once a process holds DRM master and the VT is in graphics mode the kernel stops handling that chord, so a compositor either implements it or abolishes it — there is no third option, and the first bare-metal run of this backend proved it the hard way by not implementing it and trapping the maintainer on tty3 (D-031, the second entry with that number).

Four things worth knowing before you rely on it:

  • It works while the screen is locked and while a consent prompt is up. Deliberate: being trapped is worst in the state where you cannot dismiss what is in front of you. It is never a way past the lock — the lock is still up, and still wants your passphrase, when you come back.
  • Know your own VT number before you start. The startup banner logs it. A human who can leave and cannot come back is only half rescued.
  • If the switch fails you will see it on the panel, in a red band naming what happened — crate::notice::CoreNotice, added precisely because a log line is worth nothing to somebody who cannot leave the screen to read it. The flight recorder carries vt_switch_requested, vt_switch_refused and vt_switch_stalled. [verified: crates/vitrin-core/src/recorder.rs]
  • If that red band appears, this route is gone. Go to route 2.

Confirmed working; not confirmed as an escape. All 19 chords on record — 10 in L1 below, 9 in the bring-up page’s item 12 — were pressed against a healthy compositor, and every one behaved. The one deliberate wedge on record, L6, is exactly the case this route exists for, and the chord did not get the operator out of it: a SIGSTOPed compositor cannot call Session::change_vt, because the code that would call it is inside the stopped process. That is the boundary of what a compositor-implemented chord can do rather than a defect in it, and it is why route 2 is below this one on the page and still ahead of it in evidence.

Route 2 — a shell somewhere else, and a signal

This is the only route that has ever actually recovered a session. On 2026-08-09 the first bare-metal run wedged with no working VT chord, and what freed it was a terminal in the still-running Hyprland session on tty1.

Resolve the PID first, then signal that number. Never signal a pattern:

# 1. Find it. `-x` matches the process NAME, so nothing that merely mentions
#    vitrind in its own command line can match. `-a` prints each command line,
#    so you can see which session you are about to end before you end it.
pgrep -x -a vitrind

# 2. READ ITS STATE. This decides which signal, and getting it wrong looks
#    exactly like the signal not working.
ps -o pid,stat,args -p <PID>

# 3a. STAT contains `T` -- the process is STOPPED. `kill -TERM` is INERT here:
#     a stopped process cannot handle SIGTERM, so it queues as pending and
#     nothing observable happens. SIGCONT is the recovery, and it PRESERVES
#     the session -- the compositor resumes and carries on.
kill -CONT <PID>

# 3b. STAT is `S` or `R` -- running but unresponsive. This is the case route 2
#     was written for, and here TERM is right.
kill -TERM <PID>

Step 3a is not a footnote; it is #277. This page’s own L6 rung wedges the session with SIGSTOP, and until 2026-08-13 the only signal it published was TERM — which cannot recover that wedge. Verified twice on that date, once against a controlled process and once against the real vitrind:

14:16:46  SIGSTOP           -> state Tl+
14:16:49  SIGTERM, 3 s wait -> STILL ALIVE, state Tl+
14:16:52  SIGCONT           -> EXITED immediately; the pending TERM landed on resume

SIGKILL also works on a stopped process, immediately — but it discards the session, where SIGCONT gives it back.

Half of that is verified and half is not, and the difference matters here. Step 1 was run read-only against a live session on 2026-08-11 and returned exactly that session’s PID and nothing else [verified 2026-08-11]. Step 2 has never been used to recover a wedged session in this form — route 2’s one real recovery, on 2026-08-09, was typed as the broken command below. Run step 1 once while nothing is wrong, for the same reason bring-up step 0.1b makes you exercise the VT chord before you need it.

Do not “simplify” that back to pkill -TERM -f "vitrind --drm". That is what this page published until 2026-08-11, and it is broken three ways at once (#260):

  • Through a shell it is too greedy, and it aims at you. pkill -f matches whole command lines, so a shell that runs the command has the pattern in its own argv. pkill skips its own PID but not its parent — so -TERM ends the rescuer at the moment the rescue is being attempted.
  • On this machine it never matches the target at all. The ~/.local/bin/vitrind wrapper inserts --shim <path> between the binary and whatever you typed, so the literal string vitrind --drm does not appear anywhere in the real process’s command line — it is not a pattern that describes this process. One injected argument is enough; the wrapper’s other job is environment variables, which never reach argv at all, and --blank-idle is the operator’s own flag, which lands after --drm. Checked read-only against the running session: pgrep -f 'vitrind --drm' returned only the invoking shell, while pgrep -x -a vitrind returned the one real PID. [verified 2026-08-11]
  • Wrapped in a unit it is silently empty. systemd-run does not go through a shell, so the quotes are stripped and --drm arrives as a second argument. pkill takes one pattern, matches nothing, and the unit exits 1 having signalled nothing — while the operator believes the session was rescued. That is exactly what happened on 2026-08-11. [verified 2026-08-11]

A recovery command that fails silently is worse than one that fails loudly, because it is used precisely when nobody is reading the output.

A signal wrapped in a unit or a timer must not depend on shell quoting. There is no shell there to do the quoting. Resolve the PID before you arm it and give the unit a literal number:

# A standby rescue, armed before you wedge anything, with the PID already known.
systemd-run --on-active=120 --unit=l6-rescue /usr/bin/kill -CONT <PID>

The property this route depends on is that a vitrind session on tty3 does not disturb Hyprland on tty1 — so a terminal there, or an agent session running in one, still reaches the machine. Leave one open before you start anything. See bring-up step 0.1.

Escalate within the route rather than jumping out of it, on the same PID throughout:

PID=<the number you read above>   # one number, not a pattern
kill -INT "$PID"                  # ask for a clean shutdown first
sleep 2
kill -0 "$PID" 2>/dev/null && echo "still alive" || echo "gone"
kill -KILL "$PID"                 # only if -INT did nothing
pkill -x vitrin-shim              # shims are children of vitrind; check anyway

-INT before -KILL matters: a clean exit runs the realm shutdown ladder and drops DRM master in order. -KILL leaves the kernel to reclaim master, which usually works and occasionally leaves the panel in a bad mode — see the bring-up page’s recovery section R3 for that case.

wayvnc is not this route and must not be reached for. It runs through Hyprland as a wlr-screencopy client, so the moment vitrind takes master and Hyprland goes inactive it has nothing to capture and no compositor to talk to. It is named here so that you do not count on it. [verified 2026-08-09, recorded on the bring-up page]

Route 3 — SysRq through /proc/sysrq-trigger, sudo only

This route brings the machine down safely without the power button, when vitrind will not die, or when the ordinary shutdown path is itself stuck. It is not a way to un-wedge the display; it is a way to end the session without corrupting the filesystem.

Read the caveat before the commands. This path needs a reachable shell. It covers vitrind wedged the display” once a VT or the Hyprland-side shell is reachable. It does not cover “input is completely dead” — which is the only case the physical Alt+SysRq combo would have covered, and which is not available here (below). That trade is made knowingly. If you have no shell, this route does not exist for you; go to route 4.

The keyboard combo is not available here, and the mask is not being changed

/proc/sys/kernel/sysrq is 16 on this machine, set by /usr/lib/sysctl.d/50-default.conf:19. [verified 2026-08-10] 16 is 0x10enable sync command and nothing else. So from the physical keyboard Alt+SysRq+s works and every other letter does not: no r (unraw), no e/i (signal processes), no u (remount read-only), no b (reboot). The physical REISUB sequence is inert here by configuration.

That configuration stands, and raising it is not on this page. Handing REISUB to anyone at the physical keyboard of a machine whose entire premise is confining what runs on it is a trade this project declines. An earlier version of the bring-up page recommended raising the mask as an optional pre-step; that recommendation has been deleted rather than left standing.

Why the trigger file works anyway

The mask gates the keyboard path only. From the kernel’s own documentation, verbatim:

Note that the value of /proc/sys/kernel/sysrq influences only the invocation via a keyboard. Invocation of any operation via /proc/sysrq-trigger is always allowed (by a user with admin privileges).

Documentation/admin-guide/sysrq.rst, read 2026-08-10. [verified]

The mechanism behind that sentence, so it is not taken on trust: the file’s write handler calls __handle_sysrq(c, false), and that second argument is check_mask — the trigger path asks the kernel not to consult the bitmask. [verified against drivers/tty/sysrq.c::write_sysrq_trigger, mainline, read 2026-08-10]

/proc/sysrq-trigger is --w------- root root on this machine. [verified 2026-08-10] So the capability is the root user’s alone, which is the whole point: it is reachable by sudo and by nothing at the keyboard.

The sequence — and it is deliberately not REISUB

Do not write _reisub. The kernel’s own documentation offers echo _reisub > /proc/sysrq-trigger as its bulk-mode example, and on this machine it would be a hard reboot with two no-ops in front of it. Two independent findings, both checked against the kernel source on 2026-08-10:

  1. s and u do not do the work; they queue it. sysrq_handle_sync calls emergency_sync(), which is schedule_work(do_sync_work) and returns immediately; sysrq_handle_mountro calls emergency_remount(), which is schedule_work(do_emergency_remount) and returns immediately. [verified: fs/sync.c, fs/super.c] sysrq_handle_reboot calls emergency_restart(), which does not return at all.
  2. Bulk mode runs the whole string inside one write(), with no pause between letters — that is exactly what the leading _ buys. [verified: write_sysrq_trigger sets bulk = true on _ and loops the buffer]

Put together: _reisub queues a sync, queues a remount, and then reboots before either queued job can run. The kernel documentation says the same thing in its own words about the sync — “the sync hasn’t taken place until you see the “OK” and “Done” appear on the screen“ — and bulk mode is precisely the form that gives you no chance to see them.

And e and i are wrong for this machine anyway. e sends SIGTERM to every process except init and i sends SIGKILL. On this machine the escape route is a shell in the Hyprland session, and that session is the maintainer’s real work — so e destroys the thing you are recovering with, along with everything you have open. Their purpose in the keyboard sequence is to get processes out of the way when you have no shell; here you have one, and route 2’s signal to one resolved PID is the aimed version of the same idea.

So the correct procedure is three separate writes, waiting between them:

# In a second shell, so you can see the kernel's own completion messages.
# kernel.dmesg_restrict = 1 here, so this needs root. [verified 2026-08-10]
sudo dmesg -w
# STEP 1 — SAFE. Flush the page cache to disk.
printf 's' | sudo tee /proc/sysrq-trigger
#   WAIT for "Emergency Sync complete" in the dmesg -w window before continuing.
# STEP 2 — *** DESTRUCTIVE: every filesystem becomes read-only. ***
# Nothing on this machine can write to disk afterwards. Do not run this and
# then decide to keep working.
printf 'u' | sudo tee /proc/sysrq-trigger
#   WAIT for "Emergency Remount complete" before continuing.
# STEP 3 — *** DESTRUCTIVE: reboots the machine immediately, no unmount. ***
# Everything unsaved that steps 1 and 2 did not reach is gone.
printf 'b' | sudo tee /proc/sysrq-trigger

Step 2 does not strand step 3. The remount only touches superblocks with a backing block device — do_emergency_remount_callback tests sb->s_bdev before it does anything — and /proc has none, so the trigger file is still writable after every real filesystem has gone read-only, and sudo dmesg -w still reads. [verified: fs/super.c, read 2026-08-10] This is worth knowing in advance, because hesitating between steps 2 and 3 is exactly what a read-only filesystem invites.

One optional, non-destructive rung, useful only in a narrow case:

# SAFE. Console keyboard is dead after vitrind died: put it back in XLATE mode.
printf 'r' | sudo tee /proc/sysrq-trigger

r addresses the console keyboard mode only — the kernel documents it as “Turns off keyboard raw mode and sets it to XLATE”. It does not put the VT back into text mode, so a console left in graphics mode stays blank whatever r does. Only meaningful once vitrind is no longer running. [inferred: the letter’s documented behaviour is verified; that it helps in this specific case is not]

Two traps, one of them tested here

The redirect happens in the shell you are already in. echo s > /proc/sysrq-trigger fails as an unprivileged user, and sudo echo s > /proc/sysrq-trigger fails identically, because sudo applies to echo and not to the >. It must be | sudo tee (above) or sudo sh -c '...'.

Tested here, non-destructively: the unprivileged redirect returns permission denied and exits 1, writing nothing. [verified 2026-08-10]

You will probably not see the kernel’s messages on the console. kernel.printk is 1 4 1 4 on this machine [verified 2026-08-10], so the console log level is 1 — only KERN_EMERG reaches the screen, and the completion messages are below that. The kernel documentation’s advice to wait until you see them on the screen therefore does not work here as configured. sudo dmesg -w in a second shell is the observable, which is another reason this route needs a shell rather than a keyboard.

What was not executed, and why

The sudo write path has never been run on this machine. s is the one harmless letter and it is the only one permitted to be exercised, but sudo -n true fails here — a password is required, and the session that wrote this page could not supply one. [verified 2026-08-10] So step 1 above is documented and unexecuted.

Run step 1 yourself, once, while nothing is wrong. It is a sync; it costs a second and it is the only way to find out that tee typo before the day you need it. A recovery path you have never used is a plan. That is what bring-up step 0.1b already says about the VT chord, and it applies here unchanged.

Route 4 — the installer USB and a chroot

Real, and slower by orders of magnitude: physical access, a hard power cycle, boot the Arch USB, mount the root (and /boot, and unlock LUKS if the disk is encrypted), arch-chroot, undo whatever wedged it, exit, reboot. Minutes to tens of minutes, against seconds for a console command.

Have the USB physically in the room before you start. If it is in a drawer somewhere else, you do not have a fourth route; you have an errand. See bring-up step 0.3.

Note that a DRM mode does not survive a reboot — a panel left in a bad mode is annoying rather than persistent, and is not a reason to reach for the USB.

What none of this covers

  • Input completely dead. The one case the physical Alt+SysRq combo would have covered, and it is not available here (route 3). Every route above needs either a keyboard or a shell.

  • A kernel-side wedge in i915. If the GPU hangs, none of this applies: you get a hard freeze, and the only response is a power cycle and reading journalctl -b -1 -k -g i915 afterwards. That is a driver bug, not a vitrind bug.

  • A dark screen that is not a wedge. As of this release vitrind can turn the panel off on an idle timer, and a blanked session looks exactly like a dead one. Press a key before you conclude anything — any physical input wakes it. If the panel does not come back within a couple of seconds, treat it as a wedge and start at route 1. This is a new confusion that did not exist before the blank, and it is published as a limit rather than left to be discovered: Where this is honest about its limits.

    From a shell, you can now tell the two apart without looking at the panel. A blank logs the session went idle — it always did — but until this release that was the only line the whole cycle produced, so a wake that worked and a modeset that left the panel dark were both followed by silence. A wake now logs the panel is lit again, and a wake that never completed logs THE WAKE WAS NOT CONFIRMED at WARN — the case that is genuinely indistinguishable from a wedge at the panel, and the one that means route 1. The flight recorder carries the pair as screen_blanked and screen_woke, neither of which existed before; the wake entry’s outcome field is flip_landed (the panel came back), no_flip (it may not have) or seat_lost (the blank ended because you switched VT), and both entries carry locked, because an idle blank never raises a lock but can perfectly well go up behind one. [verified: crates/vitrin-core/src/session.rs, crates/vitrin-core/src/recorder.rs]

The settings this depends on, which this repository does not own

Suspend, lid and power-key policy is systemd-logind’s, not vitrind’s — that is a deliberate decision, not an omission, because reimplementing it would put session policy inside the trusted core. The consequence is that “suspend works” is not reproducible from a checkout of this repository alone. So the values are published here, read from the running logind on 2026-08-10 rather than transcribed from a config file:

PropertyValue on this machine
HandlePowerKeypoweroff[verified]
HandlePowerKeyLongPressignore[verified]
HandleSuspendKeysuspend[verified]
HandleHibernateKeyhibernate[verified]
HandleLidSwitchsuspend[verified]
HandleLidSwitchExternalPowerunset — falls through to HandleLidSwitch[verified]
HandleLidSwitchDockedignore[verified]
IdleAction / IdleActionSecignore / 1800 s[verified]
InhibitDelayMaxSec5 s[verified]
BlockInhibited(empty)[verified]
DelayInhibitedshutdown:sleep[verified]

Read with:

busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \
  org.freedesktop.login1.Manager \
  HandlePowerKey HandleSuspendKey HandleLidSwitch \
  HandleLidSwitchDocked HandleLidSwitchExternalPower IdleAction
systemd-inhibit --list

Four things that matter more than the table:

  • These are the machine’s, not the project’s. /etc/systemd/logind.conf contains a bare [Login] header and nothing else on this machine [verified 2026-08-10], so every value above is systemd’s compiled-in default (systemd 261) rather than a choice anyone made. They can change without any change to this repository, and a run recorded under different values is a run of a different system. Record them with your results.
  • Nothing currently blocks logind’s handling. BlockInhibited is empty, so no application has taken a handle-lid-switch, handle-power-key or handle-suspend-key lock. If one ever does, the lid stops suspending the machine and nothing in vitrind will tell you why — systemd-inhibit --list is where that shows up.
  • Six delay inhibitors are held (NetworkManager, rtkit, UPower, and two desktop applications) [verified 2026-08-10], bounded by InhibitDelayMaxSec = 5 s. So a suspend on this machine is delayed by up to five seconds and then proceeds. If a resume looks late, that is where the first five seconds went.
  • The lid is SW_LID on event0 [verified 2026-08-10, from /proc/bus/input/devices], and vitrind sees it and drops it at intake — switch events have no wire event, which crate::input::intake_physical’s own doc comment states. So closing the lid produces no vitrind behaviour at all; everything you observe is logind’s.

The hardware checklist

CI structurally cannot test any of this. A GitHub runner has no seat, no VT, no DRM device, no ACPI and no backlight. That is stated rather than dressed up as a criterion, and it is why this checklist exists.

Run it after a vitrind --drm session is up per the bring-up page, with the Hyprland-side shell of step 0.1 open the whole time. Rungs are numbered L1L7 so they do not collide with the bring-up page’s own 1–15. (L7 was added by the fix for #257, which the first run of L4 found; a rung added because a run found something is what this table is for.)

#DoExpectWorst credible failure
L110 VT switches away and back. Ctrl-Alt-F2, wait, Ctrl-Alt-F3 back. Ten times.Session survives every one; band the same colour each return; recorder shows paired pause/activateA dead session, or a black panel with vitrind alive (master not reacquired)
L25 suspend/resume cycles. systemctl suspend from the escape shell.Machine sleeps, wakes, panel comes back, apps still therePanel never returns; or apps frozen because no frame clock restarted
L35 lid close/open cycles.logind suspends on close, resumes on open, panel returnsLid does nothing (check systemd-inhibit --list); or resume leaves a black panel
L4Blank and unblank. Start with --blank-idle, leave the machine alone past the timeout, then press a key.Panel goes dark; any physical input brings it back; the log carries the session went idle and the panel is lit again, and the recorder a screen_blanked/screen_woke pair with outcome: flip_landedPanel dark and input swallowed — this is indistinguishable from a wedge, go to route 1. THE WAKE WAS NOT CONFIRMED in the log, or outcome: no_flip in the recorder, is that case naming itself
L5Confirm the blank did not lock. After L4’s wake, look at what is on screen, and at the screen_blanked entry the recorder wrote.The session as you left it — not a lock card — and locked: false on the entryA lock card, which would mean idle-blank and idle-lock got coupled. locked: true with no lock card on screen means the entry is wrong, which is its own defect
L6One deliberate wedge, recovered by a documented route. Choose the route before you wedge anything and write it down as you use it — see the warning below.Route 1 or 2 recovers itNeither does; record how far down the table you had to go
L7Leave and come back with the blank armed. With --blank-idle 60 (and, on a second pass, --lock-idle 60 as well), switch to another VT, stay there longer than the timeout, and switch back. Time how long the panel stays lit after the return.The panel stays lit for the full timeout measured from the return — 60 s, not 1.5 s — and the lock does not raiseThe panel blanks within a couple of seconds of coming back, or the session demands a passphrase for returning: the idle clock is being stamped with an instant from before the absence (#257)

Three rungs deserve their own warnings.

L2 and L3 are complete as of 2026-08-13, and the second run fixed more than the counts. 2026-08-11 managed 4 of 5 suspend/resume cycles and 2 of 5 lid cycles. 2026-08-13 added the fifth suspend and three more lid cycles that reached sleep, taking both to 5 of 5.

It also closed a hole the first run could not have seen. The 2026-08-11 session carried no --keymap, so nothing could be typed into the app — and an idle terminal produces no frames, which makes “the app is correctly idle” and “the app is frozen” the same artefact. The counts were met over a client that could not be proven alive. On 2026-08-13, with the keymap passed, the operator typed after each resume and the log carries the proof directly:

                    56 frames,  96 keys      before any suspend
  === suspend / resume ===
                     7 frames,  18 keys      typed after the systemctl suspend
  === lid close / open ===
                    13 frames,  24 keys      typed after the lid cycle

New frames after both resumes: the frame clock restarts, and the failure mode this rung names does not occur. Run these with a keymap — without one the rung passes on evidence it does not have.

Also worth keeping: on 2026-08-13 a lid close reopened within the same second never reached sleep at all. That is correct behaviour, not a miss, and it is the short-lid-close case one sample could never have established.

Do them with the escape shell open and with nothing unsaved.

L6’s answer was lost once, and is now recovered. On 2026-08-11 the wedge recovered in ~69 s and which route did it could not be reconstructed afterwards — not from the journal, not from either flight recorder, not from the process tree, not from two units’ exit codes. The page then guessed “fg typed blind”.

2026-08-13 settled it, and the guess was right in substance: fg sends SIGCONT, and SIGCONT is what recovered the second wedge. The route is now named, timed, and mechanised:

14:07:55.141  last log line -- session paused
              ... SIGSTOP, 163.8 s wedged ...
14:10:38.963  "the seat activated this session; reclaiming the panel"

Three things that run established which the first could not:

  1. kill -TERM does nothing to this wedge — verified against the real binary (#277). Route 2’s published command was the wrong signal for the page’s own rung.
  2. kill -CONT recovers it and keeps the session, in the next logged millisecond.
  3. Route 1’s chord was queued, not defeated. It completed the instant the process resumed, having sat pending for the whole wedge.

Decide the route before you wedge anything anyway, and write the time down as you use it. Reconstructing it four minutes later did not work the first time.

L4 can produce exactly the symptom this whole page is about. Unblanking is a full modeset, and a modeset that fails leaves a dark panel with the session running. If L4 does not come back, you are in route 1 — and that is a result to record, not a mishap.

The numbers this checklist owes

These were owed to issue #223 and were pasted into it on 2026-08-11. The second run, 2026-08-13, discharged the rest. Both runs are recorded below.

  • L1 — 10 of 10 (19 switches, 0 stalled; chord-to-pause median 240 ms). 2026-08-11.

  • L2 — 5 of 5. Four on 2026-08-11 (resume-to-panel 24–31 ms, one 2100 ms outlier), the fifth on 2026-08-13. Liveness proven on the second run only: the app took keystrokes and produced new frames after the resume. The first run had no keymap and therefore no way to tell an idle app from a frozen one.

  • L3 — 5 of 5. Two on 2026-08-11 (one usable), three on 2026-08-13, all three reaching sleep, with the same typed-after-resume liveness proof. A fourth close/open inside one second correctly never suspended.

  • L4 — blank at 61.2 s; wake confirmed. 2026-08-11 for the transition, 2026-08-12 for the log line and recorder pair (#258, #259), and observed a second time on 2026-08-13’s L7 runthe panel is lit again present, THE WAKE WAS NOT CONFIRMED absent, outcome: flip_landed. No wake has ever failed on this machine, so the WARN arm remains unexercised; that is the pass condition, not a gap.

  • L6 — SIGCONT, 163.8 s. The route is named at last, and the mechanism with it: kill -TERM is inert against this wedge (#277), route 1’s chord was queued rather than defeated, and the panel showed the previous VT rather than going dark. 2026-08-13.

  • L7 — 61.214 s, measured. 2026-08-13, --blank-idle 60. The seat returned at 14:22:21.655 and screen_blanked was journalled at 14:23:22.869, so the panel stayed lit for 61.214 s counted from the return against a 60 s timeout. The ~1.2 s over is service-loop granularity and matches L4’s independently measured 61.2 s.

    This replaces the by-eye pass of 2026-08-11, which ran at a 20 s timeout and could not distinguish “the full 20 s” from “17 s”. The figure this rung asked for exists now. Both instants come from vitrind itself — the seat-return line from the log, the blank from the recorder’s wall_ms — so no cross-process clock is involved. See the note on clocks in the 2026-08-13 record below before computing anything of this shape yourself.

Record the run

Date it and record the environment, the same shape docs/drm-bringup.md uses. The value of a manual runbook is entirely in whether anyone can tell when it was last actually executed. The next run copies the shape below, blanks the values and fills them in from its own eyes.

First run — 2026-08-11, L1–L6 on the target machine

Numbers below are read from the flight recorders, the tee’d logs and journalctl; the visual observations are the owner’s. Nothing here is inferred from source.

Executed:      2026-08-11, JST
By:            @tahaayan
Kernel:        7.1.6-arch1-1        Mesa 26.1.6
GPU:           i915, /dev/dri/card1, eDP-1 @ 2560x1600, scale 1
Binary:        target/release/vitrind --features drm-backend, built 2026-08-11

logind values, read at the time of the run (/etc/systemd/logind.conf carries
only the [Login] header, so these are the defaults in effect):
    HandleLidSwitch=suspend        HandlePowerKey=poweroff
    HandleSuspendKey=suspend       IdleAction=ignore
    InhibitDelayMaxSec=5s
    Delay inhibitors on sleep: NetworkManager, rtkit-daemon, upowerd

  L1. 10 VT switches ............. 10/10 survived; band colour stable? YES
  L2. suspend/resume ............. 4 of 5 cycles run; 4/4 panel returned
  L3. lid close/open ............. 2 of 5 cycles run; 1 suspended and behaved
                                   as L2, 1 never reached Sleep at all
  L4. Blank and unblank .......... blank after 61.2 s; unblank OK
  L5. Blank did not lock ......... PASS (session as left, no lock card)
  L6. Deliberate wedge ........... recovered in ~69 s, route INDETERMINATE
  L7. Return from another VT ..... RUN SEPARATELY, later on 2026-08-11, at
                                   --blank-idle 20 --lock-idle 20 (not 60).
                                   Panel stayed lit on the return -- it did NOT
                                   blank in ~1.5 s. Lock did NOT raise on the
                                   return; it raised only after the countdown
                                   ran again from the return. Both by eye:
                                   seconds NOT timed, absence NOT timed.
                                   Next run: panel stayed lit ___ s after the
                                   return (must be the full --blank-idle
                                   timeout); absence ___ s.

SysRq step 1 (`printf 's' | sudo tee /proc/sysrq-trigger`) executed? NO
    (still documented and unexecuted)

L1 — 10/10, and the chord is confirmed on hardware from a healthy session. 0 refused, 0 stalled over this run’s 10, out of 19 chords across the two runs. The other nine are on the bring-up page’s item 12, which records them as 5 switches honoured plus 4 vt_switch_refused already_here — the human chording the VT he was already on, i.e. the code declining a no-op rather than a switch failing. The two records are quoted side by side rather than merged into one refusal count, because only the operator’s recorder logs can say whether the 0 refused above was scoped to this run or to all 19, and nobody has gone back to them.

Zero stalls is a positive result rather than absent instrumentation: VtSwitchStalled is live code fired from a timer for the case where libseat_switch_session returns Ok and no PauseSession follows — the “chord appears to work and does not” shape that trapped the maintainer on the first bare-metal run. It never fired. Chord → seat pause latency, n=9: min 209 ms, median 240 ms, max 312 ms. Pause/activate pairing is exact: 9 pauses against 8 activates in the second run, the missing activate being the pause the session was left in.

What L1 does not establish is route 1. Every one of those 19 chords was pressed against a healthy compositor. The rung asks whether the chord works, not whether it gets you out — and L6 below, the only wedge on record, is the case where it does not.

L2 — 4 cycles, not 5. Kernel resume → vitrind reclaimed the panel:

resumelatency
13:03:4424 ms
13:04:3331 ms
13:06:262100 ms
13:07:0031 ms

Recorded as 4/5, not 5/5. The journal shows four systemctl suspend cycles where the rung asks for five. Every cycle that ran returned with a working panel and live apps. The 2100 ms outlier followed the shortest suspend of the set (5.6 s).

L3 — 2 cycles, not 5, and they disagreed. Recorded as 2/5. Both cycles behaved as L2 when they suspended, but only one of the two suspended at all:

  • Lid closed 13:07:12.96, opened 13:07:19.30 (6.3 s closed) — never reached Sleep. No suspend entry in the journal.
  • Lid closed 13:07:27.77 → suspend entry 13:07:28.08 → opened 13:07:38.57 → suspend exit 13:07:39.29 → panel reclaimed 30 ms later.

Whether a short lid close reliably does not suspend on this machine is not established by two samples and is not claimed here.

The summary line above is corrected, not transcribed. The #223 comment this record comes from writes L3’s one-line summary as 2/2 behaved as L2 while its own detail — the two bullets above, which are verbatim — says only one of the two suspended at all. The detail is what is right, so the line in the block reads 1 suspended and behaved as L2, 1 never reached Sleep at all. Against a rung asking for 5 cycles, L3 is 1 usable sample.

L4 / L5 — the blank and the lock behaved; the run still filed three defects against L4. Blank fired at 61.2 s against --blank-idle 60. The panel returned on ordinary physical input, twice, with the session unchanged and no lock card — so idle blank and idle lock are confirmed uncoupled on hardware, as D-033 intends, which is L5 and it passes. L4 is not a clean pass: the run found #257 on the return path, and #258 and #259 came out of the same session — the unblank logged nothing, so a successful wake and a modeset that left the panel dark were indistinguishable, and neither transition reached the flight recorder at all. All three now have fixes. #257’s has since been observed on hardware — see the L7 record below, run later the same day — but #258’s and #259’s have not: the enriched expectations in L4’s own row (the the panel is lit again line, the screen_blanked/screen_woke pair) describe output that did not exist when this run was made and was not looked for during the L7 run either.

L6 — recovered, route indeterminate. The wedge was produced by SIGSTOP on the compositor while it held DRM master and the libinput devices — a faithful “compositor hung”, reversible, and it does defeat Ctrl+Alt+F<n> exactly as this page predicts.

13:15:01.8   SIGSTOP -- wedge begins
13:16:10.8   alive again, processing a VT chord      ~69 s wedged
13:16:29     standby rescue fired into an already-running process (no-op)

The route that recovered it is not recoverable after the fact, and that is recorded rather than guessed. Ruled out by evidence: it was not Ctrl+C (the tee in the same foreground process group survived, and SIGINT does not resume a stopped process); it was not either standby timer (one ran 80 s before the wedge and exited 1 — that is the pkill -f defect in route 2 above — and the other fired 19 s after recovery). Something delivered SIGCONT from the tty3 session, most plausibly fg typed blind, but the operator did not recall four minutes later and no artefact records it. That indeterminacy is itself the finding, and it is why L6 now tells you to choose the route first.

Not done, and not quietly dropped:

  • The VKMS rung was not attempted by hand during this run. It is, however, attempted by CI on every pull request, and on 2026-08-13 that attempt was read rather than assumed. See the VKMS note below the third run’s record.
  • /proc/sysrq-trigger route 3 was not exercised. Still documented and unexecuted.
  • L2 and L3 are short of their stated counts, as recorded above.
  • L7 did not exist during this run — it was written from this run’s #257. It has since been executed, separately and later the same day; the result is the block below.
  • L4’s new log and recorder expectations were observed by nobody. They were added by the #258/#259 fixes after this run, so nothing in the block above reached them — and the L7 run that followed did not look at the log or the recorder either. A third run, on 2026-08-12, did: see L4 (second execution) below.

Filed from this run: #257 (returning to a paused session blanks the panel in ~1.5 s), #258 (the unblank is silent; success and failure look identical), #259 (blank/unblank leave no flight-recorder event) and #260 (this page’s published recovery command signalled the rescuer under a shell and nothing at all under systemd-run — corrected in route 2 above).

L7 — same day, separate run, --blank-idle 20 --lock-idle 20

Run after the fixes for #257–#259 landed on main, at a 20 s timeout rather than the 60 s the rung suggests. Both passes were done in one sitting.

Executed:      2026-08-11, JST, same machine and binary family as above
                              (rebuilt from main after #263 merged)
Flags:         --drm --blank-idle 20         (pass 1)
               --drm --blank-idle 20 --lock-idle 20   (pass 2)
               --lock-on-seat-change: not passed, so the default `never`

  Pass 1. Panel on return ........ STAYED LIT. It did not blank on the way back
                                   in, which is the ~1.5 s symptom #257 filed.
  Pass 2. Lock on return ......... DID NOT RAISE on the return. It raised only
                                   after the countdown ran again *from* the
                                   return, with the session sitting idle --
                                   which is what `--lock-on-seat-change never`
                                   is specified to do.

  Timed? ....................... NO. Both observations are by eye. The seconds
                                 the panel stayed lit were not measured, and
                                 the absence was not measured either.
  Log lines checked? ........... NO -- `the panel is lit again` (#258) was not
                                 looked for.
  Recorder checked? ............ NO -- the `screen_blanked`/`screen_woke` pair
                                 and `outcome: flip_landed` (#259) were not
                                 looked at.

What this settles and what it does not. It settles #257, which is a symptom question — the panel blanked ~1.5 s after a return, and it no longer does; the lock demanded a passphrase for coming back, and it no longer does. Both symptoms are gone on the machine that produced them, under the default seat policy. It settles nothing about #258 or #259: those are about what the wake says, and nobody read the log or the recorder during this run. It also produces no number — an eyeball pass at a 20 s timeout cannot distinguish “the full 20 s” from “17 s”, so the rung’s own question, how long did the panel stay lit, is still unanswered and the record block above says so.

L4 (second execution) — 2026-08-12, --blank-idle 60

The first run that read the log and the recorder rather than only the panel. #258 and #259 are settled by it, and nothing else is.

  Executed: 2026-08-12 14:00:57 JST (+0900), by the maintainer, on the same
            machine as every block above.
    Binary: vitrind rebuilt from `main` at 13:46 the same day.
            --blank-idle 60; --lock-idle NOT passed.

  Panel .......................... blanked on the idle timer, stayed dark, and
                                   came back on a keypress. Observed by eye.

  Log line (#258) ................ YES.
      the panel is lit again: physical input woke the session and the modeset
      was accepted. The wake itself restores no authority -- an idle blank
      never took any.
  THE WAKE WAS NOT CONFIRMED ..... 0 occurrences.

  Recorder (#259) ................ YES, the pair, from the same wake:
      {"kind":"screen_blanked","live_grants":0,"locked":false}
      {"kind":"screen_woke","dark_ms":5630,"outcome":"flip_landed",
       "live_grants":0,"locked":false}

  Timed? ......................... NO. `dark_ms` is how long the panel was dark
                                   before a key was pressed, not a latency: it
                                   measures the human, not the wake.

The earlier run recorded nothing because of the binary, not the code. The 13:42 attempt the same day used a vitrind built on 2026-08-11 at 12:34 — five hours older than the commit that added both the line and the pair — so it blanked and woke while carrying no code to write either down. A wake that logs nothing and a build that cannot log are indistinguishable in the artifact; only the binary’s mtime tells them apart. Check what you are running before reading a silence as a result.

What it does not settle. The WARN arm is unexercised: no wake failed, so THE WAKE WAS NOT CONFIRMED has still never been emitted on hardware, and its absence here is the pass condition rather than a gap. No figure was taken, so L7’s question is still unanswered.

A narrow point about L5, which passed on 2026-08-11 and is not reopened here. That row asks for two things from one run: no lock card on screen, and locked: false on the screen_blanked entry. The 2026-08-11 run checked the screen half with the lock armed and passed it; the recorder entry did not exist yet, so there was nothing to read. This run has the entry and it reads locked: false, but it did not arm the lock, so its clean screen is what an unarmed lock looks like rather than evidence about the boundary. Both halves hold, from different runs; the row as written has not been satisfied by a single one.

Adjudicated closed by the maintainer on 2026-08-12, and recorded as an adjudication rather than as an observation, because that is what it is. His reading is that the 2026-08-11 pass is the substantive one — the lock was armed, the blank fired, and no card came up — and that a second run to put both halves in one artifact is bookkeeping rather than evidence. That is a reasonable call and it is his to make; what it costs is stated here so nobody later reads L5 as something it was not. What a single arming pass would still buy, and only it: the case where the screen and the journal disagree — a locked: true entry under a screen with no card, which this row’s own failure column names as its own defect. Two runs cannot catch a disagreement between them by construction. Nobody believes that state is live; it simply has not been looked for.

Third run — 2026-08-13, L2/L3 completion, L6 and L7

  Executed: 2026-08-13, by the maintainer, on the same machine as every block
            above. Artefacts in ~/vitrin-runs/223-{cycles,l7}-*.{log,jsonl}.
    Binary: vitrind rebuilt 12:26 that day from a clean tree at 9b6239e,
            --features drm-backend. Shim rebuilt 12:28 against the VENDORED
            wlroots 0.19.3 -- a system upgrade had replaced 0.19 with 0.20 and
            the previously built shim could not start at all.

  L2 ....... 5 of 5. The fifth cycle, plus liveness (below).
  L3 ....... 5 of 5. Three more cycles reaching sleep, plus liveness. A fourth
             close/open inside one second correctly never suspended.
  LIVENESS . NEW, and the reason the counts now mean something. With --keymap
             passed, typing after each resume produced frames:
               before any suspend .... 56 frames, 96 keys
               after the suspend ......  7 frames, 18 keys
               after the lid cycle .... 13 frames, 24 keys
  L6 ....... SIGCONT, 163.8 s wedged. Route named for the first time.
  L7 ....... 61.214 s lit, measured from the seat's return, against
             --blank-idle 60. The figure this rung has owed since it was
             written.
  L4 ....... re-observed in passing on the L7 run: `the panel is lit again`
             present, `THE WAKE WAS NOT CONFIRMED` absent, flip_landed.
  L7 pass 2  ATTEMPTED. Caught no absence -- but see below: under the default
             seat policy the question is already answered by pass 1 plus the
             single shared idle clock, and 12a is the rung that is actually owed.

L7’s second pass is still owed, and saying so costs nothing. A --blank-idle 60 --lock-idle 60 session was run at 14:46:03. Its recorder carries zero seat activations: the first seat event of the run is the pause at 14:47:29, when the operator left at the end. There was no absence and no return inside it. What it recorded instead is the plain idle path, which is worth keeping:

  14:47:10.060  session_locked   cause: idle      both 60 s timers, same expiry
  14:47:10.084  screen_blanked   locked: true     24 ms after the lock
  14:47:19.793  screen_woke      flip_landed      woken while locked
  14:47:24.577  session_unlocked

So --lock-idle fires with the right cause, a wake works while locked, and unlock works.

And that is enough, under the default policy — a third run would add nothing. Pass 2 asks whether the lock raises on the return from an absence longer than the timeout. It cannot, and the reason is structural rather than observational: the lock and the blank read one clock, not two. last_activity was lifted out of LockScreen into backend::blank::SessionActivity behind an Rc<RefCell<..>> by WS-E.4.3 for exactly this reason — “two fields would be two clocks, they would drift, and the drift would be invisible” — and the seat’s return restamps that single field in set_seat_absent(false, now).

Three facts then compose to the answer:

  1. The restamp is measured. Pass 1’s 61.214 s is the blank firing 61.214 s after the seat returned, which is a measurement of the shared field being restamped on return.
  2. The lock reads that same field. This run shows it: the lock and the blank fired 24 ms apart off one expiry.
  3. Both runs were on_seat_change="never", under which the absence is not charged at all.

There is no second clock left that could have kept running, so the raise pass 2 looks for has nothing to fire from. The by-eye pass at a 20 s timeout on 2026-08-11 agrees, and so does the operator’s observation on 2026-08-13 that no lock card was present.

What is genuinely untested is a different rung on a different page. set_seat_absent has three branches and only Never has ever run on hardware. Under Idle the absence is charged, so a long absence should return locked — the opposite result, from the same call site. Under Immediate the raise happens on seat loss. Those are docs/drm-bringup.md step 12a (issue #246), written and never executed. If you want the lock-on-return question exercised for real, run 12a, not another L7.

Two method notes, both of which cost time before they were understood.

Never correlate the shim’s clock with vitrind’s. The shim’s 00:00:00.000 starts at shim launch, not core launch, and the tee’d log interleaves two writers with different buffering, so line order is not time order. A defect was briefly read into existence this way on the same day’s 13a run. Where one clock is needed, use the flight recorder: every entry carries both mono_us and wall_ms, and wall_ms correlates directly with the log’s tracing timestamps because both come from the same process. L7’s figure above was computed exactly that way.

A second vitrind refuses to start, and says so. An accidental second launch during this session exited with fatal: another vitrind already holds this runtime tree (its lock on /run/user/1000/vitrin-0/core.sock.lock…). It did not fight for DRM master. Worth knowing, because the failed attempt still writes its own near-empty log, and picking that file by timestamp will make a successful run look like it recorded nothing.

The VKMS rung: attempted every PR, and what it actually returns

.github/vkms/run-advisory.sh runs on every pull request, and the green check means nothing — the script exits 0 on a declared skip exactly as it does on a real probe, deliberately, so the rung can never start gating merges. The evidence is in the job log, not the checkmark. Read on 2026-08-13:

-- module state: loaded
-- no vkms card node appeared; skipping the GBM/EGL probe
-- probe summary: no-vkms-card-node

So the honest state is a third outcome, and not the one the rung’s own acceptance criterion anticipated. The module is not unavailable — it loads. But no /dev/dri/card* node appears behind it on the hosted runner, so nothing downstream runs: no connector enumeration, no mode set, no atomic commit, no page flip, and no GBM/EGL probe. The rung is attempted continuously and currently covers nothing.

That is worth stating plainly rather than leaving as “not attempted”, because the two are different claims and only one of them is true. What would change it is a host where the card node does appear — a local machine with udev and root, rather than a container. That has not been done here, and the reason is recorded rather than skipped: loading a new DRM device on a machine running a live compositor risks that compositor enumerating it and attaching an output to it. On the maintainer’s one laptop that is a live-session risk taken for a rung which, by its own header, can never prove the thing that matters — that the backend lights a real panel. docs/drm-bringup.md, executed by a human, remains the only evidence for that.

This page has been executed twice, on 2026-08-11 and 2026-08-13, and is now a pass on every rung it can reach. L1 through L7 have all been run and all have their numbers, with L2/L3 at full count and proven live, L6’s route named, and L7 timed. What remains unexecuted is named rather than implied: routes 3 and 4 are still careful predictions, the VKMS rung is attempted on every PR and currently covers nothing (the module loads, no card node appears), only the never seat policy has ever run on hardware – step 12a’s immediate and idle are written and unexecuted, and idle is the branch that would return lockedL5 is adjudicated closed rather than re-run, and the WARN arm of L4 has never fired because no wake has ever failed here.

Both runs’ headline findings were defects in this page’s own recovery commandpkill -f in 2026-08-11 (#260), kill -TERM against a stopped process in 2026-08-13 (#277). A recovery page that has been wrong twice about its own central instruction is a page to read sceptically. Correct it from your own eyes, and treat a failed observation as a result worth recording rather than a step to retry until it passes.

Session app matrix

Which applications this project has actually run, on one machine, at what bar, with the observable that was checked named in every cell.

This page is generated, and the generator can only emit a cell somebody executed. An application nobody ran does not appear as a row here no matter how confident anyone is that it would work; it appears in Requested, and not emitted instead. That is the whole design: the app set cannot widen by assertion, only by running something and landing its evidence.

The machine, the build, and the date

One machine was measured. Everything below is one laptop, one Intel iGPU, one internal panel, one kernel. None of it generalises, and reading this page as “Vitrin runs these applications” would be false.

  • Inventory read: 2026-08-10
  • Last recorded run in this corpus: 2026-08-11 — the operator-driven bare-metal session that ran alacritty and nautilus and produced #268. The last checklist run is still 2026-08-09, the second bare-metal DRM run (docs/drm-bringup.md)
  • Kernel: 7.1.6-arch1-1this is not the kernel the bare-metal evidence was taken on. Both DRM runs ran on 7.1.5-arch1-2 (docs/drm-bringup.md); the machine has since moved up one release
  • Mesa: 1:26.1.6-1
  • wlroots: wlroots0.19 0.19.3-1 — the built shim links libwlroots-0.19.so (readelf -dW shim/build/vitrin-shim). 0.17 and 0.20 are also installed on this machine and are not what is used
  • vitrind revision: 38978f6v0.1.0-56-g38978f6, the revision of the tree at the last regeneration. Not a self-reported vitrind --version: nothing was launched to produce this page. Individual runs predate it and name their own where one is recorded — the nested lock-screen run recorded core f9f2b8a, and the second DRM run followed the three fixes in cf0e7ff
  • Machine: one laptop. Intel iGPU on /dev/dri/card1 (i915) drives the only connected output, eDP-1, at 2560x1600 @ 240 Hz, scale 1. A second card (/dev/dri/card2, nvidia) has every connector disconnected and is not in the display path
  • Host compositor for nested runs: Hyprland 0.56.2-1, XDG_SESSION_TYPE=wayland

CI cannot produce this page’s contents. A GitHub runner has no DRM device, no seat and no GPU, so it cannot run vitrind against a panel and cannot run a GUI application at all. What CI can do, and does, is assert that the checked-in page is byte-identical to what the generator emits — which catches a hand edit, and catches nothing else. The measurements themselves come from a human executing the runbook at the bottom of this page, on the target machine.

How to read a cell

The bar is weak, and here is exactly how weak

Most rows below were scored at one bar: the application mapped a window and repainted. Nothing was typed into it, nothing was clicked, and nothing was checked for correct rendering.

An application can map a window and repaint and still be unusable, because a realm is missing things a desktop application assumes it has:

  • No cross-realm clipboard through the app’s own clipboard interface. The shim advertises wl_data_device_manager, but see shim/src/globals.c:217-224: “THIS GLOBAL STILL GRANTS NOTHING ACROSS THE REALM BOUNDARY” — a shim serves exactly one client on exactly one private socket, so both ends of any transfer through it are the same application. What exists across realms instead is a core-mediated channel driven by two physical human chords (WS-E.2.1, D-024), reachable by no client at any verb set.
  • No portals. No file chooser, no screen share, no opening a link.
  • No session bus. A realm has no session D-Bus of its own, so anything that expects one degrades or fails.
  • No IME. Nothing here serves text-input/input-method, so composing text in any non-Latin script does not work.
  • No XWayland child processes. There is no X server anywhere in this stack, so an application that forks an X11 helper loses that helper.

Where a row was proved by a named task instead — an integration gate, a runbook checklist, an issue’s acceptance criterion — the row names that task, and the task is what the row means. Those rows assert something specific and are much stronger than the weak bar.

The four evidence classes on this page

ClassWhat it provesWhere it appears
Named taskThe stated assertion held in a run of the shipped chainBar column reads named task: ...
Operator droveA human used the application and the named interactions worked. One person’s session, not a repeatable task — it proves the app was usable that once, by someone who was thereBar column reads operator drove: ...
Weak barThe application mapped a window and repainted, and nothing else was checkedBar column reads weak bar
LinkageAn ELF/strings measurement of a binary on the machine. Not a run.The three inventory tables near the bottom

Linkage is the weakest class here and it has demonstrated false positives in both directions on this very machine — see What this page does not measure. It is published because the question “what on this machine actually needs X11” has no better answer that does not require launching everything, and it is never mixed into the two execution tables.

Desktop applications executed against vitrind

Software a person would daily drive. Every row is a recorded execution against vitrind; there are no inferred rows.

AppVersionWhere it ranBarObservable checkedOutcomeRecorded, and where
Firefox ESR140.12.0esr, sha256 3323ee13…f433d92 (pinned by this repo)headlessnamed task: tests/integration/test_real_firefox.pyreal vitrind execs the real vitrin-shim, which execs this pinned Firefox rendering a local file:// page; the real Python SDK captures a frame through the real enforcement/capture path and asserts its dominant colour is the served #0000ff, and that the globals ledger contains nothing outside shim/docs/firefox-refused-globals.txt. No mock on any seammet the barevery PR in CI since 1ebeee2 (2026-07-22) — tests/integration/test_real_firefox.py, shim/docs/firefox.md §7 (the M1.2 milestone proof)
alacritty0.17.0-1 (installed at inventory)nested under Hyprlandnamed task: issue #203 acceptance criterion 1“a real toolkit terminal (alacritty) runs to completion under vitrind --nested” — i.e. a live nested run to completion, after the eager-set_mode abort that killed it beforehandmet the bar2026-08-06 (the fix landed in af98130 at 16:38:43 +0900) — issue #203, checked acceptance criterion; commit af98130
alacritty0.17.0-1 (installed at inventory)bare-metal DRM/KMSoperator drove: typed into, used as a terminal, and used to launch a second applicationthe first real desktop application ever run on the bare-metal backend — every earlier DRM row is solid-client, a test client from shim/tests. It mapped as realm-0’s app, took keyboard focus, and the operator typed at its prompt and read the output. It survived the session’s VT switches. --blank-idle blanked and unblanked over itmet the bar2026-08-11, during the L1–L7 rung session — /tmp/vitrind-drm-B.log from that session — realm configured … realm=realm-0 command=/usr/bin/alacritty, spawned app pid=…: /usr/bin/alacritty, keyboard focus taken by the app surface, app window mapped: "Alacritty" (Alacritty), and 399 seat-replay: … event=key origin=physical delivered=1 reason=ok lines. Reported by the operator; issue #268
Cursornot recorded at the time of the attempt (installed at inventory)bare-metal DRM/KMSweak barlaunched from the alacritty prompt in realm-0, the same way nautilus was launched a moment later. Nothing appeared on the paneldid not map — it did not open. The cause is not established, and nothing here guesses one2026-08-11, during the L1–L7 rung session — operator report. No log covers it — the retained /tmp/vitrind-drm-B.log segment ends before this attempt, and neither the application’s own stderr nor its exit status was kept
nautilus50.2.2-1 (installed at inventory)bare-metal DRM/KMSoperator drove: navigated with the mouse, then closed from its own title-bar close buttonlaunched from the alacritty prompt, so it was a second toplevel of realm-0 under the same shim rather than a realm of its own — the only way a second application has ever been started on this backend, since realms come from realm.toml and that file names one. It mapped over alacritty, took the keyboard from it, was navigated by mouse, and closed cleanly on its own close buttonmet the bar2026-08-11, during the L1–L7 rung session — operator report; the mechanism is read out in issue #268 against shim/src/xdg.c and shim/src/seat.c
kitty0.48.2-1 (installed at inventory)headlessweak barmapped a window and repainted. Nothing was typed into it, clicked, or checked for correct renderingmet the barundated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured”
Chromium151.0.7922.108-1 (installed at inventory)headlessweak barmapped a window and repainted. Nothing was typed into it, clicked, or checked for correct renderingmet the barundated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured”
Visual Studio Codevisual-studio-code-bin 1.131.0-1 (installed at inventory)headlessweak barmapped a window and repainted. Nothing was typed into it, clicked, or checked for correct renderingmet the barundated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured”
nautilus50.2.2-1 (installed at inventory)headlessweak barmapped a window and repainted. Nothing was typed into it, clicked, or checked for correct renderingmet the barundated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured”
gimp3.2.4-2 (installed at inventory)headlessweak barmapped a window and repainted. Nothing was typed into it, clicked, or checked for correct renderingmet the barundated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured”
inkscape1.4.4-4 (installed at inventory)headlessweak barmapped a window and repainted. Nothing was typed into it, clicked, or checked for correct renderingmet the barundated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured”
pavucontrol1:6.2-1 (installed at inventory)headlessweak barmapped a window and repainted. Nothing was typed into it, clicked, or checked for correct renderingmet the barundated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured”
xterm410-1 (installed at inventory)headlessweak barstarted in a realm and never mapped: it reported it could not open a display. There is no XWayland anywhere in this stack — not in the core, not in the shim’s advertised global set, not as a process the shim ever execsdid not map — needs an X server, and there is noneundated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured”
waybar0.15.0-2 (installed at inventory)headlessweak barconnected, bound six globals, and never mapped. The shim advertises no zwlr_layer_shell_v1, which is the interface a bar maps throughdid not map — there is no zwlr_layer_shell_v1, so it has nothing to map into. Not an X11 gap; owned by WS-E Stage 2, which owns layer-shellundated; recorded by 7863702 (2026-08-06) — the same row, plus D-021’s cost list in docs/plan/20-decision-log.md, which attaches the measurement: “measured: waybar connects, binds six globals and never maps”
rofi2.0.0-1 (installed at inventory)headlessweak barstarted in a realm and never mapped, in the same recorded row as waybardid not map — there is no zwlr_layer_shell_v1, so it has nothing to map into. Not an X11 gap; owned by WS-E Stage 2, which owns layer-shellundated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured”

What these rows over-claim if read quickly:

  • Firefox ESR — this is the pinned Mozilla ESR tarball, not a distro package — Arch ships no firefox-esr, and what is installed on the measured machine is firefox-developer-edition, which was never run against vitrind. The row is about 140.12.0esr and nothing else. The commit that first brought Firefox up in a realm, cae70f0 (2026-07-20), wired only firefox_bringup.sh into CI — under the mock core and declared SKIPPED with VITRIN_SKIP_FIREFOX_GATE=1 — so it is not this row’s provenance.
  • alacritty — #203 records only vitrind --nested; it does not name the host compositor, so the venue here is this machine’s host and not something the record states.
  • alacrittyNothing was measured. No latency, no frame count, no correctness check on what the terminal drew — the operator used it and reports that it worked. The log retained on disk covers a 60-second segment ending at a VT switch away, so it corroborates the launch, the focus and the keystroke delivery, and does not cover the later part of the session in which #268 was found.
  • Cursor — Plausible causes exist and none was checked, so none is recorded: an Electron app needs environment this realm’s env_allow may not carry, and the row above shows a second toplevel in a realm is a path with a known defect (#268). Both are guesses. What the corpus has is: it was launched, and nothing appeared. Re-running it with the realm’s stderr captured is what would turn this row into a cause.
  • nautilusClosing it broke the session’s keyboard, which is issue #268. toplevel_unmap clears keyboard focus to nobody, so alacritty — still mapped and still taking pointer events — could not be typed into again. Read this row as “nautilus itself worked” and not as “two windows in a realm work”: the second half of that is a filed bug. Minimize and maximize did nothing, which is not a defect — VITRIN_WM_CAPABILITIES withholds minimize deliberately and every window is single-maximized at the view size.
  • kitty — kitty was proved to crash before #203’s fix — the abort was reproduced with alacritty and with kitty. #203’s acceptance criterion names alacritty alone as re-run to completion afterwards, and no record anywhere shows kitty re-run post-fix. So this row is the weak bar and not #203’s named task.
  • Visual Studio Code — the recorded row reads “Electron (VS Code — so also Discord, Slack, Obsidian)”. Only VS Code was run; the other three are an inference from a shared runtime and are declined below rather than given rows.
  • xterm — the repository holds only the fragment Can't open display, never the as-emitted line. libXt’s format string is Can't open display: %s, so the real line carries a colon and the display name; those bytes were not captured and are not reconstructed here.
  • rofi — the six-globals count is recorded for waybar specifically, in D-021; the recorded row groups rofi with it but attaches no separate measurement. rofi also keeps an X11 path, so it appears in the Wayland-mode inventory table too.

Repository test clients executed against vitrind

Clients this repository wrote. Nobody daily drives them, and the strongest evidence in the tree is about them — which is exactly why they are in a separate table. The two checklist runs of the bare-metal backend both used solid-client; the desktop applications that have since run on it (2026-08-11) were driven by hand in one session and are recorded at the operator drove bar, which is not a repeatable task.

AppVersionWhere it ranBarObservable checkedOutcomeRecorded, and where
weston-terminalweston 15.0.1-3 on the measured machine — NOT the build the cited CI run usedheadlessnamed task: tests/integration/test_real_app.pyreal vitrind execs the real C shim which fork/execs weston-terminal; the application’s own frames flow shim → core and are byte-captured, and its identity is read from procfs. The #105 / M1.2 bottom-rung gate, no mock on any seammet the barevery PR in CI since e742f25 (2026-07-22) — tests/integration/test_real_app.py (APP_NAME = "weston-terminal")
gtk-entry-probeshim/tests/gtk_entry_probe.c at this revision (built from this repo)headlessnamed task: tests/integration/test_real_gtk.pya real GTK3 GtkEntry client renders a grey toplevel with a white text field headlessly, and its committed frame carries real non-uniform content through the real chain. The gate asserts render, explicitly not inputmet the barevery PR in CI since 1ebeee2 (2026-07-22) — tests/integration/test_real_gtk.py, shim/tests/gtk_entry_probe.c
clipboard-peershim/tests at this revision (built from this repo)headlessnamed task: tests/integration/test_real_clipboard.pytwo realms, two real clipboard-peers under two real C shims: the offer chord against an empty slot sends nothing, the promote chord still reaches no other realm, and only the second chord in the realm the output moved to produces offer_selection — after which the receiving application has the string byte for byte through a real wl_data_device transfermet the barevery PR in CI since 2f7c7cf (2026-08-08) — tests/integration/test_real_clipboard.py, described in tests/integration/README.md (WS-E.2.1, issue #213)
solid-clientshim/tests at this revision, debug build (built from this repo)bare-metal DRM/KMSnamed task: docs/drm-bringup.md observation checklist, first runfirst execution of the DRM/KMS backend by anyone: 2560x1600 @ 240 Hz set on card1; the client’s green square drew; the trusted band drew MIRRORED along the bottom edge; VT switch away and back was impossible, so the human could not leave; vitrind at 99.1% CPU over a 471 s runmet the bar2026-08-09, on kernel 7.1.5-arch1-2 — docs/drm-bringup.md, first-run record
solid-clientshim/tests at this revision, release build (built from this repo)bare-metal DRM/KMSnamed task: docs/drm-bringup.md observation checklist, second runafter the first run’s three fixes: trusted band at the TOP (the mirror is fixed); consent card drawn on the panel and clicked with a real mouse, petition granted, 13 captures served; held-Esc revocation against a LIVE grant (dead_man_triggered held_ms=1005 revoked=1); 5 chorded VT switches honoured; 32.9% CPU release against 99.1% debug; centre pixel read #55aa00ff for a realm configured 00aa55, which is exactly xrgb8888 little-endianmet the bar2026-08-09, after the three fixes in cf0e7ffdocs/drm-bringup.md, second-run record
input-echo-clientshim/tests at this revision (built from this repo)nested under Hyprlandnamed task: shim/docs/nested-lock-screen.md, eight-step by-eye checklistPASS on steps 1–7 with the client resolving keys through xkbcommon as a real toolkit does (a/b/ckeysym=0x61/0x62/0x63); a held Shift did not postpone the idle lock, and its release while locked was handledmet the bar2026-08-09, core f9f2b8ashim/docs/nested-lock-screen.md, executed record

What these rows over-claim if read quickly:

  • weston-terminal — a real third-party Wayland client with a mock-free gate — stronger evidence than any weak-bar row above — but nobody daily drives it, so it says nothing about a desktop. The cited gate runs on ubuntu-latest against apt’s weston (shim/ci/install-deps.sh), whose version this page does not record.
  • gtk-entry-probe — this is a GTK3 fixture, not nautilus, gimp or inkscape, and it must not be cited as evidence for those three. No GTK4 or Qt6 fixture exists.
  • clipboard-peer — the repository’s own precedent for how to record a substitution honestly: #213 names alacritty and Firefox, neither of which can be made in CI to put a known string on a clipboard without a human’s mouse, so a toolkit-free client stands in and the README says so. Not asserted: that a real chord on real hardware produces any of it.
  • solid-clientsolid-client commits wl_shm buffers and is not a desktop application. On the date of this run no desktop application had ever reached bare metal; two did on 2026-08-11, by hand, and their rows are above. The second run’s own open list notes the shim never emits dmabuf, so the zero-copy scanout path is dead code against every real application either way.
  • solid-client — still solid-client. Three items stayed open after this run: vitrind’s own log line renders the connector name empty, the shim never emits dmabuf, and refresh_view_cache composes for absent consumers.
  • input-echo-client — the run found a shipped defect — TextureKey::current enumerated every input to compose_human_visible except the lock, which is a locked session that looked unlocked. Two further warnings: the page states step 7 is weaker than it reads (input-echo-client is static, so every frame carried the identical digest), and the page contradicts itself by also carrying an empty “not yet executed” record block further down. It was executed.

Failures that are not X11 gaps

Issue #221 asks for this section by name, because a reader counting failures would otherwise fold every one of them into “no X11” and overstate what an X11 shim would buy.

  • waybar — there is no zwlr_layer_shell_v1, so it has nothing to map into. This is WS-E Stage 2, which owns layer-shell.
  • rofi — there is no zwlr_layer_shell_v1, so it has nothing to map into. This is WS-E Stage 2, which owns layer-shell.

Issue #203 is closed and fixed on main, and is described here in the past tense. It belongs in this section because it removed applications from the measurable set for a reason that had nothing to do with X11: on_new_deco answered an xdg-decoration request eagerly, and wlr_xdg_toplevel_decoration_v1_set_mode schedules a configure whose wlr_xdg_surface_schedule_configure asserts surface->initialized — which is correctly false at new_toplevel_decoration time, because xdg-decoration requires the decoration object to be created before the surface’s first commit. Every decoration-aware client therefore aborted the shim at startup. The fix defers the mode reply to the initial commit; shim/src/globals.c:55-77 carries the reasoning, and shim/tests/xdg_conformance_client.c’s FACT 4 is the regression test. Firefox never binds zxdg_decoration_manager_v1 at all, which is the only reason the entire acceptance suite missed it.

The machine’s X11-only software (linkage, not execution)

Measured on this machine by ELF linkage, not by running anything. These are the applications that would need Phase 3 E3.2 (per-app rootless X server with an embedded window manager) before they could run in a realm at all.

Two entries are here for completeness and are not an argument for E3.2: picom and openbox are an X11 compositor and an X11 window manager, so they are X11-only by definition rather than for want of a port, and running either inside a per-app rootless X server is not a thing anyone wants.

The interim, stated as what it is. Until E3.2 lands, the owner keeps a second session for the software in this table. So “I did not have to reboot into Hyprland” is false for this named set, and the claim must not be made without that carve-out. This is a workaround the owner accepts as a cost, not a mitigation the project offers: nothing in this stack confines that second session, it runs another compositor with full access to the same devices, and switching to it leaves the confined world entirely. See Where this is honest about its limits.

Binary or appVersionWayland modeWhat was measured
xterm410-1 (installed at inventory)none exists — there is no Wayland port of xtermreadelf -dW /usr/bin/xtermlibXaw.so.7, libXt.so.6, libX11.so.6, libXmu.so.6, libXext.so.6, libICE.so.6 and no Wayland library. strings -a matching any of libwayland-client, wl_compositor or wayland → 0 hits
feh3.12.2-1 (installed at inventory)none — raw Xlib plus imlib2, so there is no toolkit backend to switchreadelf -dW /usr/bin/fehlibX11.so.6, libXinerama.so.1, libImlib2.so.1; no Wayland library in the transitive closure. strings -a wayland matches → 0
nvidia-settings610.57.04-1 (installed at inventory)none — libXxf86vm is an X-server-only extension with no Wayland equivalentreadelf -dW → exactly libXxf86vm.so.1, libjansson.so.4, libX11.so.6, libXext.so.6, libm.so.6, libc.so.6. It does carry nine case-insensitive wayland strings, and they are a trap: every one is an NVIDIA probe symbol — wconn_get_wayland_display, wconn_get_wayland_output_info, libnvidia-wayland-client.so.610.57.04, Wayland Connector Library failed to connect. — for querying outputs under a Wayland session, not a GUI backend. A grep-only method misclassifies this one
dmenu5.4-1 (installed at inventory)none — suckless raw-Xlib launcherreadelf -dW /usr/bin/dmenulibX11.so.6, libXinerama.so.1, libXft.so.2, libfontconfig.so.1, libc.so.6 — no Wayland library, and strings -a finds no Wayland name to dlopen either. Note it would be unusable for two independent reasons: it is X11-only and it is a launcher, which is the layer-shell gap above
slock1.7-1 (installed at inventory)none, and none could exist — it locks by grabbing the X serverreadelf -dW /usr/bin/slocklibX11.so.6, libXrandr.so.2, libcrypt.so.2, libc.so.6; wayland strings → 0
polybar3.7.2-2 (installed at inventory)none — EWMH and ICCCM are X11 window-manager protocols with no Wayland analoguereadelf -dW /usr/bin/polybarlibxcb-ewmh.so.2, libxcb-icccm.so.4, libxcb-randr.so.0, libxcb-xkb.so.1, libxcb-cursor.so.0 and the rest of the xcb set; wayland strings → 0
lemonbarlemonbar-git v1.5.r2.g59b0d28-1 (installed at inventory)none — raw xcb barreadelf -dW /usr/bin/lemonbarlibxcb.so.1, libxcb-randr.so.0; wayland strings → 0
picompicom-git 2855_12.197.g6d676824_2026.06.02-1 (installed at inventory)none, by definition — it is an X11 compositing managerreadelf -dW /usr/bin/picomlibxcb-composite.so.0, libxcb-damage.so.0, libxcb-glx.so.0, libxcb-present.so.0, libxcb-xfixes.so.0, libX11-xcb.so.1; wayland strings → 0. Not an E3.2 requirement: E3.2 gives each X application its own rootless X server, which is not a thing to run a compositor inside
openbox3.6.1-14 (installed at inventory)none, by definition — it is an X11 window managerreadelf -dW /usr/bin/openboxlibX11.so.6, libXcursor.so.1, libXinerama.so.1, libXrandr.so.2, libXext.so.6, libSM.so.6, libICE.so.6; the 64-soname transitive closure contains no libwayland-* and wayland strings → 0. Not an E3.2 requirement, for the same reason as picom
xsel1.2.1-2 (installed at inventory)none — an Xlib selection clientreadelf -dW /usr/bin/xsellibX11.so.6, libc.so.6, and nothing else; the 6-soname closure contains no libwayland-*. Directly relevant to this workstream: the cross-realm clipboard (D-024) is the project’s answer to this class and xsel cannot participate in it. xdotool and xclip are not installed on this machine
OpenJDK desktop AWT/Swing (the system JVM)jdk-openjdk 26.0.2.u10-1 (installed at inventory)none — the system JVM ships no Wayland AWT backendfind /usr/lib/jvm -name 'libawt*.so' → exactly libawt.so, libawt_xawt.so, libawt_headless.so, and nothing else. libawt_xawt.so’s closure is libX11.so.6, libXext.so.6, libXi.so.6, libXrender.so.1, libXtst.so.6, libxcb.so.1 with no Wayland library. A capability statement about the runtime; no Swing application was launched

Software with a Wayland mode, so not an X11 dependency (linkage, not execution)

Measured on this machine by ELF linkage and strings, not by running anything. These are not X11 dependencies: each carries a Wayland path, selected by the switch in the third column. Counting any of them toward the X11 gap would overstate it.

Binary or appVersionWayland modeWhat was measured
Chromium151.0.7922.108-1 (installed at inventory)--ozone-platform=wayland, or --ozone-platform-hint=autolinkage alone would have called this X11-only and been wrong. The 89-soname DT_NEEDED closure carries libX11.so.6 and no libwayland-* at all, because Chromium statically links its own libwayland from third_party. What settles it is strings -a /usr/lib/chromium/chromium, which yields the flag ozone-platform and the Wayland wire-protocol interface names wl_compositor and xdg_wm_base. Corroborated by its weak-bar execution row above
Visual Studio Codevisual-studio-code-bin 1.131.0-1 (installed at inventory)--ozone-platform-hint=auto, already configured by the ownerthe strongest non-execution evidence on this page, because the owner has already configured it: ~/.config/code-flags.conf exists and contains, verbatim, --ozone-platform-hint=auto and --enable-wayland-ime under the comment “Native Wayland + text-input-v3 so fcitx5 works without GTK_IM_MODULE”. Linkage is weaker and must be read carefully: /usr/share/code/code links no libwayland-* directly; its 95-soname closure reaches all three only through libgtk-3.so.0, which is GTK’s Wayland and not Electron’s. The binary’s own 5 ozone/Wayland strings are what say the ozone machinery is compiled in
Electron runtime (and the Electron applications on it)electron43 43.3.0-1, plus ten other runtimes (installed at inventory)--ozone-platform=wayland / --ozone-platform-hint=auto/usr/lib/electron43/electron links no libwayland-* directly — its 103-soname closure reaches libwayland-client.so.0, libwayland-cursor.so.0 and libwayland-egl.so.1 only through libgtk-3.so.0. What says the ozone machinery is present is strings -a, which matches ozone-platform/wl_compositor/libwayland-client 5 times. /usr/lib/slack/slack measures identically (95-soname closure, no direct Wayland linkage). discord 1:1.0.152-1, obsidian 1.13.4-2 and element-desktop 1.12.23-1 are installed but their real binaries could not be resolved from their launchers without executing them, so they are asserted by runtime family and not measured individually — which is exactly why they are declined rather than given rows
alacritty0.17.0-1 (installed at inventory)native — prefers Wayland when WAYLAND_DISPLAY is set, falls back to X11the DT_NEEDED closure carries neither libX11 nor libwayland-client: everything is dlopened. strings -a /usr/bin/alacritty yields both libwayland-client.so.0 and libX11.so.6. Corroborated by the #203 run, which happened under vitrind --nested, a stack serving no X11 at all
kitty0.48.2-1 (installed at inventory)native, via dlopenlinkage says nothing either way — only 5 sonames in the closure and neither family among them, and /usr/bin/kitty is a small launcher so strings finds neither name. What places it here is that this repository ran it under vitrind, which serves no X11
nautilus, gimp, inkscape (GTK3/GTK4)50.2.2-1, 3.2.4-2, 1.4.4-4 (installed at inventory)GDK_BACKEND=wayland — GTK selects Wayland automatically when WAYLAND_DISPLAY is seteach carries both families, and each by a different route, which is the point. /usr/bin/nautilus links libwayland-client.so.0 directly (145-soname closure, which adds cursor and egl). /usr/bin/gimp-3.2 links none directly; its 115-soname closure reaches all three through GTK. /usr/bin/inkscape is a 19-soname launcher stub (7 direct NEEDED entries) whose GUI lives in /usr/lib/inkscape/libinkscape_base.so, a 147-soname closure carrying all three. GTK carries both backends in one library and picks at run time — which is exactly why shim/docs/firefox.md sets GDK_BACKEND=wayland explicitly, so a stray DISPLAY cannot silently drop the browser onto X11
blender17:5.2.0-4 (installed at inventory)native (GHOST_Wayland), via dlopena second case where linkage alone is wrong: the 266-soname closure carries libX11.so.6 and libX11-xcb.so.1 and no libwayland-* at all, yet strings -a /usr/bin/blender matches libwayland-client, wl_compositor or GHOST_Wayland 3 times — Blender dlopens its Wayland backend
scrcpy4.1-2, on sdl3 3.4.14-1 (installed at inventory)native via SDL3, which dlopens libwayland-client.so.0 and libdecor-0.so.0the clearest false positive in the scan. scrcpy’s direct NEEDED is libavformat, libavcodec, libavutil, libswresample, libSDL3.so.0, libavdevice, libusb-1.0 — so the X11 in its 163-soname closure arrives through libavdevice, which is ffmpeg’s x11grab capture input, not its GUI. Its GUI is SDL3, and libSDL3.so.0 has a 3-soname closure with no hard X11 and no hard Wayland while its strings carry libwayland-client.so.0, libdecor-0.so.0 and wl_compositor
openrgb1.0rc3-1 (installed at inventory)QT_QPA_PLATFORM=waylandreadelf -dW /usr/bin/openrgblibQt5Widgets.so.5, libQt5Gui.so.5 and nothing else relevant: Qt loads its platform plugin by name at run time, which is why the binary shows zero wayland strings. The plugin is installed (qt5-wayland 5.15.19+kde+r55-1; libqwayland-egl.so and libqwayland-generic.so are present)
rpi-imager2.0.9-1 (installed at inventory)QT_QPA_PLATFORM=waylandQt6 Quick application (libQt6Quick.so.6, libQt6Gui.so.6); qt6-wayland 6.11.1-1 is installed and libqwayland.so sits beside libqxcb.so in the Qt6 platform plugin directory
fcitx5-config-qtfcitx5-configtool 5.1.14-1 (installed at inventory)QT_QPA_PLATFORM=waylandQt6 (libQt6Widgets.so.6, libQt6Gui.so.6) with qt6-wayland installed and libqwayland.so present
Android Studio (JetBrains Runtime)android-studio 2026.1.3.7-1, JBR 25.0.2 (installed at inventory)a native Wayland AWT backend in the bundled runtimefind /opt/android-studio -name 'libawt_*.so'libawt_xawt.so, libawt_headless.so and libawt_wlawt.so, against a system JDK that ships no such file. A genuine split worth recording, because it contradicts the system-JDK row above. Not launched, and it was not verified that the backend is enabled by default
gamescope3.16.25-1 (installed at inventory)native — it is itself a Wayland compositor, and a Wayland client when nestedlinks both families directly: a 56-soname closure with libwayland-client.so.0 and libwayland-server.so.0 beside libX11.so.6, libICE.so.6 and libSM.so.6. Named here for two reasons: it is the one piece of the Steam stack on this machine that speaks Wayland natively, and it is the prior art docs/plan/03-phase-3-network-x11-fleet.md cites for E3.2’s per-app-Xwayland design
waybar, rofi0.15.0-2, 2.0.0-1 (installed at inventory)native — both link libwayland-client directlywaybar links libwayland-client.so.0 directly; its 108-soname closure adds cursor and egl, and its X11 entries are GTK’s. rofi links libwayland-client.so.0 and libwayland-cursor.so.0 directly while keeping its whole xcb path (libxcb-ewmh, libxcb-icccm, libxcb-randr, libxcb-cursor) in a 60-soname closure, so it classifies as both. Listed here so their failure above is never misfiled as an X11 gap — it is layer-shell. wofi is not installed, so its cell cannot be regenerated on this machine at all

Where linkage did not settle the question (linkage, not execution)

Linkage contradicted itself and only a run settles these. They are published as explicit unknowns rather than guessed into one of the tables above.

Games are recorded here as a measured dependency of this machine and as nothing else. E3.2’s exit criteria (docs/plan/03-phase-3-network-x11-fleet.md §E3.2) say nothing about games; games additionally need relative pointer, pointer constraints, gamepads and GPU features far beyond E3.2. This row is a measurement, not a commitment, and no schedule anywhere in this repository covers it.

Binary or appVersionWayland modeWhat was measured
steamwebhelper (the Steam client UI)a self-updating CEF build under ~/.local/share/Steam/ubuntu12_64, dated 2026-07-22; the Arch steam 1.0.0.87-1 package ships only a shell wrapper and does not describe what runs (vendor-bundled)unknown — the executable carries no Wayland path; the library that draws does. Only a run settles itthe executable’s own 36-soname readelf -dW closure carries libX11.so.6, libXi.so.6, libXrandr.so.2, libXcomposite.so.1 and no libwayland-*, and strings -a matches ozone-platform/wl_compositor/libwayland-client 0 times. That reads as settled and is not. Its direct NEEDED entries include libcef.so (219 444 168 bytes, 2026-07-09) and libSDL3.so.0, neither on ldconfig’s path, so the walk stops exactly where the answer is: strings -a on libcef.so matches — ozone-platform, ozone-platform-hint, wl_compositor, enable-wayland-ime, Failed to initialize Wayland platform — and SDL3 dlopens libwayland-client.so.0 (see its row). This is google-chrome’s case one library deeper. Steam has been run on this machine — ~/.local/share/Steam/steamapps holds 7 appmanifests, of which exactly one is a game and the rest are Proton and the Steam Linux Runtimes. No game binary was inspected and no game was run
google-chrome151.0.7922.71-1 (installed at inventory)unknown — presumably --ozone-platform=wayland, unverifiedthe contradiction is the finding. The 71-soname closure of /opt/google/chrome/chrome carries libX11.so.6, libXi.so.6, libXrandr.so.2 and no libwayland-* at all — yet the same binary matches ozone-platform/wl_compositor/libwayland-client 6 times, and Arch’s chromium measures identically (89 sonames, zero Wayland linkage) while demonstrably having a working --ozone-platform=wayland. Linkage cannot settle this one. Only a run can, and nothing was launched

Requested, and not emitted

These applications are on the list somebody wants covered, and the generator refused to give them a row because no execution against vitrind is recorded for them. They are named here rather than dropped silently, so the absence is legible.

  • wofi — named in the recorded failing row, but not installed on the measured machine (pacman -Q wofi reports it absent), so no package version can be recorded and the cell cannot be regenerated here even by a live runbook. See the waybar and rofi rows for the same failure.
  • Discord — the recorded Electron row reads “VS Code — so also Discord, Slack, Obsidian”. That parenthetical is an inference from a shared runtime, not a measurement: no record anywhere shows it executed against vitrind. See the Electron runtime row in the Wayland-mode table.
  • Slack — same inferred parenthetical as Discord; never executed against vitrind. See the Electron runtime row in the Wayland-mode table.
  • Obsidian — same inferred parenthetical as Discord; never executed against vitrind. See the Electron runtime row in the Wayland-mode table.
  • Steam (steamwebhelper) — the owner named Steam and games as a real dependency of this machine. Nothing in the Steam stack has ever been executed against vitrind, and no schedule in this repository covers games. See its row in the inconclusive table: the client’s windowing path came out unknown, which is a measurement and not a commitment.
  • google-chrome — the packaged Google build, distinct from Arch’s chromium. Never executed against vitrind, and its linkage contradicts itself. See the inconclusive table.

What this page does not measure

  • Most of the seed rows are undated. The weak-bar rows carry no log, no recorder dump and no screenshot; the only bound available is the commit that wrote the record down, which bounds when the row was written, not when the application was run. The Recorded, and where column says undated where that is the case rather than reusing a commit date as if it were a run date.
  • The verbatim xterm failure line was never captured. What this repository holds is the fragment Can't open display. The real format string in libXt is Can't open display: %s, so the emitted line has the shape <progname>: <error type>: Can't open display: <display> — but the bytes xterm actually wrote in that realm are gone, and they are not reconstructed here. Capturing them is a runbook step.
  • Desktop applications have run on bare metal exactly once, by hand. alacritty and nautilus on 2026-08-11, in one operator-driven session — not a checklist run, not repeatable by someone who was not there, and measured for nothing. The two runs the bring-up runbook records both used solid-client. That session also produced a defect (#268: closing a second window in a realm leaves the keyboard focused on nobody) and one application that did not open at all with no cause established. The named reason to expect further difference still stands, recorded by the second checklist run: the shim never emits dmabuf, so the zero-copy scanout path is dead code against every real application.
  • Every inventory row is linkage, not behaviour, and linkage has demonstrated false positives in both directions on this machine. Chromium, Blender, scrcpy, OpenRGB and rpi-imager all classify X11-only by DT_NEEDED and all five have Wayland paths (Chromium statically links its own libwayland; Blender and SDL3 dlopen theirs; Qt loads its platform plugin by name). Alacritty and kitty classify as neither, because they dlopen everything. Method, for reproducibility: readelf -d walked recursively with sonames resolved from ldconfig -p, plus strings -a. Never lddldd invokes the dynamic loader and can execute the binary under inspection.
  • That walk does not follow DT_RUNPATH, so a private library directory ends it. Measured, not theorised: /usr/bin/inkscape is a 19-soname launcher stub (7 direct NEEDED entries) carrying neither family, because its entire GUI lives in /usr/lib/inkscape/libinkscape_base.so — a path ldconfig -p does not know — whose own closure is 147 sonames and carries all three Wayland libraries. Any row here whose closure looks implausibly small is this case, and the fix is to point the walk at the real library.
  • The installed set is not the used set. A bulk scan of /usr/bin on 2026-08-10 classified 494 ELF binaries as X11-only, owned by 114 packages, 37 of them xorg-* and 58 explicitly installed. (Method, so the number is reproducible: every non-symlink ELF file in /usr/bin, transitive DT_NEEDED closure via readelf -d with sonames resolved from ldconfig -p, counted when the closure contains an X11 soname and no libwayland-*. The dlopen false-positive above applies to this count too, so it is an upper bound on the X11-only set, not an exact one.) Which of those the owner actually uses is not measured here: no shell history was read, no access times, no launcher history. The requirement list handed to E3.2 comes from the owner naming what he needs, never from ranking a /usr/bin.
  • xlsclients was empty at inventory time, and that means less than it looks. XWayland is running under the host compositor and the connection genuinely worked (xprop -root _NET_SUPPORTING_WM_CHECK returned a window id), yet xlsclients listed zero X11 clients. That is one instant on one day in a session a few hours old. It is not evidence that the owner never runs X11 software — the installed set proves he can — and a truthful version needs sampling over days, which nobody has done.
  • Steam games themselves are entirely unmeasured. What was measured is steamwebhelper, the client UI. No game binary was inspected: a Proton title’s windowing path runs through Wine inside a container, which no static scan here reaches.
  • Nothing was launched to produce this page. No vitrind, no realm, no nested compositor, no application under test. Every machine row is an inference from bytes on disk, and every execution row is a transcription of a run somebody else recorded earlier. Widening the matrix means executing the runbook below.

Runbook: regenerate this page on the target machine

CI has no DRM device, no seat and no GPU, and structurally cannot run this. It can only assert that the checked-in page matches a regeneration. Everything below is done by a human on the target machine.

0. Safety, non-negotiable

Run every step of section 2 in a nested host window. Never against the DRM/TTY backend from inside a live session: that takes DRM master and the seat, and kills the session you are sitting in. Bare-metal runs happen from a spare TTY, with the escape route rehearsed first — see docs/drm-bringup.md.

1. Re-take the read-only machine inventory

Nothing here launches anything. Record what these print; they are the header fields and the three inventory tables.

uname -srvmo                       # kernel -> header
pacman -Q mesa wlroots0.19         # mesa, wlroots -> header
git -C "$REPO" describe --tags --dirty   # vitrind revision -> header
pacman -Q <app>                    # one per row, for the Version column
xlsclients -l                      # X11 clients live in the current session

# Linkage, per candidate binary. NEVER use ldd: it invokes the dynamic
# loader and can execute the binary under inspection.
readelf -dW /usr/bin/<app> | grep NEEDED
strings -a /usr/bin/<app> | grep -iE 'libwayland-client|wl_compositor|ozone-platform'

A binary that shows neither family in NEEDED is not evidence of anything: alacritty, kitty, Blender, SDL3 and every Qt application load their backend at run time. Check strings before concluding, and if the two disagree, the row belongs in the inconclusive table, not in a guess.

2. Run an application in a realm, and record what you saw

Build first:

cargo build --workspace
meson compile -C shim/build

Write a one-realm file naming the application by absolute path:

cat > /tmp/matrix-realm.toml <<'EOF'
[[realm]]
id = "realm-0"
command = "/usr/bin/xterm"
args = []
env_allow = []
EOF

realm.toml refuses a relative command. Then run the core nested, capturing both streams — the failure you are measuring is usually on the application’s stderr, not in the core’s log:

./target/debug/vitrind --nested \
  --realm /tmp/matrix-realm.toml \
  --shim "$PWD/shim/build/vitrin-shim" \
  2>&1 | tee /tmp/matrix-xterm.log

Record, for the row:

  1. The observable you checked. Not “it worked”. Either the specific thing a named task asserts, or — if all you did was look at it — the weak bar, mapped a window and repainted, and nothing more.
  2. The verbatim failure line, copied out of the log, if it failed. A fragment is not a quote.
  3. The package version from step 1, and the date.
  4. The cause, if it failed: was an X server missing, or was it something else? A failure with a non-X11 cause goes in Cause::NotAnX11Gap with its owner named, or the page will overstate the X11 gap.

3. Land the measurement, and regenerate

Edit crates/xtask/src/session_matrix.rs — never this page. Add the application to REQUESTED if it is not there, then add an Execution (or a Linkage) carrying the evidence. The generator refuses a cell whose observable is a bare pass/works/ok, and refuses an Execution for an application that is not in REQUESTED.

cargo xtask session-matrix           # rewrite this page in place
git diff -- docs/book/src/session-app-matrix.md   # review what changed
cargo test -p xtask                  # the generator's own gates
cargo xtask session-matrix --check   # what CI runs; must print "no drift"

4. If you want a row and cannot get evidence for it

Add it to REQUESTED with the reason, and leave it declined. It will appear under Requested, and not emitted with your reason attached, which is the honest outcome and the one this page is built to make cheap.

The Landlock ABI matrix

PRD §20 says Landlock coverage is kernel-dependent. This page is the table that sentence is checkable against: what this build requires of a kernel’s Landlock, and what each rung of the ABI buys the ruleset on the way.

This build’s two numbers, both read out of the source that declares them rather than typed here:

  • floor — build.landlock_min_abi = 6. A kernel reporting a lower Landlock ABI is refused at startup. It is not confined at a weaker rung.
  • ceiling — build.landlock_max_rung = 9. A kernel reporting a higher ABI gets a rung-9 ruleset, journaled as isolation.landlock.clamped_by_build.

Both are printed by vitrind --print-floor.

The ladder below has 9 rung numbers naming 6 distinct enforced domains — that count is computed from the parsed ladder, not asserted, and the rungs that collapse into one domain are named in the domain table.

What this page is a fact about

This build, not your kernel. Nothing here probes anything. The generator runs on a laptop and on a CI runner and must emit the same bytes on both, so it reads the repository and never the machine — and the two machines this repository has actually run report different Landlock ABIs (the development box 9, the CI runner 7), which a probing generator could not have reconciled into one checked-in page.

The machine half is a command you run:

$ vitrind --print-isolation | grep landlock
$ vitrind --print-floor | grep landlock

The first prints what your kernel answers; the second prints the two build numbers above. The next table says what this build does with each possible answer. Which kernel releases produce which answer is not stated anywhere on this page, because it was not measured here — that mapping is a fact about mainline and about distributions, and this page probes neither. It is measured on a page of its own: which kernels this build starts on, from boot rows checked in under tests/kernel-matrix/rows/.

Read your own kernel against it

Every cell below is a property of this build’s own code — spawn::isolation’s Report::mechanism for the verdict and landlock::apply_with for the second refusal — with the floor at 6 and the ceiling at 9.

vitrind --print-isolation sayswhat this build does
landlock.abi=N with N at or above build.landlock_min_abi and at or below build.landlock_max_rungStarts. The helper asks for rung N and journals the rung it obtained, the rung it asked for, and the ABI the kernel reported.
landlock.abi=N with N above build.landlock_max_rungStarts, at the ceiling. The request is clamped down and the clamp is journaled as isolation.landlock.clamped_by_build.
landlock.abi=N with N at or above 1 and below build.landlock_min_abiRefuses to start at --isolation=default, reporting below-floor(abi=N,required=M). The remedy is a newer kernel, explicitly not a sysctl, an lsm= edit or a CONFIG_ change — those are already correct on such a machine.
landlock.abi=absent(errno=E)Refuses to start: the kernel does not implement the syscall. Check CONFIG_SECURITY_LANDLOCK and the kernel version.
landlock.abi=restricted-by-policy(errno=E)Refuses to start: the kernel has Landlock and something above it said no — most often landlock missing from the active lsm= list.
any of the above, with --landlock=off on the command lineStarts with no ruleset at all, journaling namespaces-only. It is not a remedy for a kernel that could be upgraded, and no confinement claim on this page applies to such a session.
any of the above, with --landlock=abi:N on the command linePins the request to rung N, including below the floor, because it is the instrument every per-rung measurement in this repository is taken with. A session pinned below the floor warns that no published confinement claim applies to its realms.

The ladder, one row per ABI rung

what it buys is the right or facility the rung adds. axis is which field of the request it moves, and it decides whether --landlock=abi:N can simulate a kernel without the rung: the cap sets handled_access_fs and scoped, so it can; it does not set the landlock_restrict_self flags word or handled_access_net, so for those rungs there is nothing for a cap to take away.

ABIwhat it buysaxiscapping simulates itthis build asks for ithandled_access_fsscopedvs. this build’s floorpublished claim
1the base access-mask bits — EXECUTE, WRITE_FILE, READ_FILE, READ_DIR, the REMOVE_* pair and the seven MAKE_* bitshandled_access_fsyes — --landlock=abi:N reproduces its absenceyes0x1fff0x0below the floor — a session refuses to start with below-floor(abi=1,required=6); reachable only through --landlock=abi:1, which warns that no published confinement claim appliesrefer-makes-the-cap-a-dial, abi-floor-refuses-below-the-number, sub-floor-rungs-hold-the-dial-not-the-floor
2LANDLOCK_ACCESS_FS_REFERhandled_access_fsyes — --landlock=abi:N reproduces its absenceyes0x3fff0x0below the floor — a session refuses to start with below-floor(abi=2,required=6); reachable only through --landlock=abi:2, which warns that no published confinement claim appliesrefer-makes-the-cap-a-dial, sub-floor-rungs-hold-the-dial-not-the-floor
3LANDLOCK_ACCESS_FS_TRUNCATEhandled_access_fsyes — --landlock=abi:N reproduces its absenceyes0x7fff0x0below the floor — a session refuses to start with below-floor(abi=3,required=6); reachable only through --landlock=abi:3, which warns that no published confinement claim appliestruncate-arrives-at-abi-3, sub-floor-rungs-hold-the-dial-not-the-floor
4handled_access_net — TCP bind/connect scoping by porthandled_access_netno — not an access-mask bitno — the realm’s own network namespace carries that claim structurally and far more completely, since it covers UDP and raw sockets too0x7fff0x0below the floor — a session refuses to start with below-floor(abi=4,required=6); reachable only through --landlock=abi:4, which warns that no published confinement claim appliesnet-scoping-is-carried-by-the-namespace, nine-rungs-are-six-domains, sub-floor-rungs-are-not-all-exercised
5LANDLOCK_ACCESS_FS_IOCTL_DEVhandled_access_fsyes — --landlock=abi:N reproduces its absenceyes0xffff0x0below the floor — a session refuses to start with below-floor(abi=5,required=6); reachable only through --landlock=abi:5, which warns that no published confinement claim appliesioctl-dev-does-not-close-the-render-node, sub-floor-rungs-are-not-all-exercised
6the scoped field — SCOPE_ABSTRACT_UNIX_SOCKET and SCOPE_SIGNALscopedyes — --landlock=abi:N reproduces its absenceyes0xffff0x3at or above the floor — a shipped session runs herescoped-is-defence-in-depth
7landlock_restrict_self log flags — LOG_SAME_EXEC_OFF, LOG_NEW_EXEC_ON, LOG_SUBDOMAINS_OFFlandlock_restrict_self flagsno — not an access-mask bitno — the log flags are observability, not confinement, and no published claim depends on them; the one that is reachable at all is reachable only through the VITRIN_LANDLOCK_AUDIT diagnostic in vitrind’s own environment0xffff0x3at or above the floor — a shipped session runs hererestrict-self-flags-are-not-mask-bits, nine-rungs-are-six-domains
8landlock_restrict_self TSYNC — apply the domain to every thread of the callerlandlock_restrict_self flagsno — not an access-mask bitno — the helper is single-threaded by design and enforces the domain on the one thread that then execves, so its shape already carries what TSYNC would buy0xffff0x3at or above the floor — a shipped session runs hererestrict-self-flags-are-not-mask-bits, nine-rungs-are-six-domains
9LANDLOCK_ACCESS_FS_IOCTL_DEV’s ladder successor RESOLVE_UNIXconnect(2) and addressed sendmsg(2) restricted to pathname UNIX socketshandled_access_fsyes — --landlock=abi:N reproduces its absenceyes0x1ffff0x3at or above the floor — a shipped session runs herethe-ladder-stops-at-the-build-ceiling
10not stated here — this build does not define ABI 10’s rights, and nothing in this repository has measured themnot known to this buildno — not an access-mask bitno — a build must not name a constant its own headers do not define; a kernel reporting ABI 10 or above is clamped down to this build’s ceiling and the clamp is journalednot requested by this buildnot requested by this buildabove this build’s ladder — clamped down to rung 9, journaled as clamped_by_buildthe-ladder-stops-at-the-build-ceiling

The handled_access_fs column is the cumulative mask this build asks a kernel at that rung for. It is parsed out of handled_access_fs in crates/vitrin-realm-init/src/landlock.rs and cross-checked against the measured table pinned in that crate’s the_rung_masks_pin_a_measured_table; the two readings disagreeing stops this page being emitted at all. The rights arrive in this order: rung 2 → REFER, rung 3 → TRUNCATE, rung 5 → IOCTL_DEV, rung 6 → the scoped field, rung 9 → RESOLVE_UNIX.

Which rungs are exercised is counted from this table, not asserted. A rung is counted here when a test in crates/vitrin-realm-init/src/main.rs enters a Landlock domain at it and asserts the kernel’s own answer — a syscall’s outcome inside the domain, or the kernel’s verdict on the request. Building a ruleset at a rung and never entering it does not count.

  • rung 1a_realm_can_write_where_it_was_granted_and_nowhere_else, rung_one_forbids_reparenting_that_the_rung_above_permits
  • rung 2rung_one_forbids_reparenting_that_the_rung_above_permits, the_truncate_rung_is_measured_and_its_absence_is_measured_with_it
  • rung 3the_truncate_rung_is_measured_and_its_absence_is_measured_with_it
  • rung 7the_audit_log_flag_is_off_unless_asked_for_and_the_kernel_takes_it

That is 4 of the 9 rungs this build can ask for. The one further row on this page (ABI 10) is above this build’s ceiling of 9 — a clamp, not a rung it requests — so it is not in that denominator. Below the floor the tally is the one docs/book/src/limits.md has to carry word for word:

below the floor of 6, rungs 1, 2 and 3 are exercised and rungs 4 and 5 are not.

Every cell on an unexercised row is derived from this build’s own source and measured against nothing — keeping the sub-floor tests that exist and adding none for the rest is decision D-044, not an oversight. Neither the name nor the rung is remembered. Each name above is resolved against BEHAVIOURAL_RUNGS in that same file, which declares the rungs that test enters a domain at; a name listed on a rung it does not enter refuses to render, a rung it does enter and this page omits refuses to render, and the tests cannot enter a domain without declaring it: the function that issues landlock_restrict_self demands a token only a recording ledger can mint, and the mint itself refuses a rung the test’s row does not declare — before the token exists, so that direction rests on no destructor. What the ledger still checks when it drops is the converse — a row declaring a rung the run never entered — and Drop is skippable in Rust by construction, which docs/book/src/limits.md publishes rather than argues away. The generator also refuses to emit when the limits page does not carry the tally above.

A test that asks the shipped helper for a rung enters a domain in another process, where no Rust type can reach it. That route is held separately: the core’s own confinement suite refuses a spawn reporting a rung this page publishes as entered by nothing, and tests/integration/ is scanned for a literal abi:N naming one. Both lists are computed from this corpus, so they move when it does rather than after it.

What each rung does not buy

The column this table exists for. A ladder printed without it reads as though every rung is pure gain, and the rows below say otherwise on the kernel’s own terms: rungs 4, 7 and 8 add nothing to the enforced domain of the rung beneath, counted from the parsed ladder rather than typed here. Rung 1’s row is sharper still — the absence of REFER makes its domain stricter, not weaker.

ABIwhat it buyswhat it does not buy
1the base access-mask bits — EXECUTE, WRITE_FILE, READ_FILE, READ_DIR, the REMOVE_* pair and the seven MAKE_* bitsREFER, and its absence makes a rung-1 domain stricter: it refuses rename(2) and link(2) across directories even inside the realm’s own writable storage. EXDEV at rung 1 and success at rung 2 are re-taken by a test on every run; rungs 3–9 succeeded in a hand run on 2026-08-14 that nothing since repeats.
2LANDLOCK_ACCESS_FS_REFERa tightening of any kind. Handling REFER is what permits cross-directory rename, which is how GTK and Firefox write files; a ladder read as “higher is tighter” has this rung backwards.
3LANDLOCK_ACCESS_FS_TRUNCATEprotection for a path outside every granted write hierarchy, which was never truncatable at any rung. What it adds is that a path the domain grants only READ_FILE on can no longer be emptied by truncate(2), creat(2) or O_TRUNC.
4handled_access_net — TCP bind/connect scoping by portanything this build asks for. handled_access_net stays zero, so the enforced domain at rung 4 is byte-identical to rung 3 — and because the cap moves handled_access_fs, --landlock=abi:3 cannot simulate a kernel without rung 4.
5LANDLOCK_ACCESS_FS_IOCTL_DEVclosure of the published render-node limit. The bit is all-or-nothing per hierarchy and the app needs the node’s ioctls, so the ruleset grants IOCTL_DEV on every bound render node and on /dev/pts. What the rung buys is denying ioctl on every other device node in the realm.
6the scoped field — SCOPE_ABSTRACT_UNIX_SOCKET and SCOPE_SIGNALa claim that rests on it. Both halves are already carried structurally by the realm’s namespaces — abstract sockets are per-netns, and the pid namespace already denies signalling outward — so this rung is defence in depth, and no published sentence would become false without it.
7landlock_restrict_self log flags — LOG_SAME_EXEC_OFF, LOG_NEW_EXEC_ON, LOG_SUBDOMAINS_OFFany access right — and because it is a flag rather than a mask bit, --landlock=abi:6 cannot simulate a kernel without it. There is nothing for the cap to remove from a request that never asked.
8landlock_restrict_self TSYNC — apply the domain to every thread of the callerany access right, and — as at rung 7 — nothing a mask cap can take away. --landlock=abi:7 and --landlock=abi:8 request the same domain.
9LANDLOCK_ACCESS_FS_IOCTL_DEV’s ladder successor RESOLVE_UNIXconnect(2) and addressed sendmsg(2) restricted to pathname UNIX socketsa rung above it that this build knows how to ask for. It travels with every writable hierarchy, because a socket the realm creates for itself — the shim’s wayland-0 among them — must stay connectable to it.
10not stated here — this build does not define ABI 10’s rights, and nothing in this repository has measured themanything, for this build. The clamp is asserted against a constructed ABI value rather than against a machine that reports one — nothing here has run on such a kernel.

The enforced domains

Two rungs enforce the same domain when this build’s request is byte-identical at both — handled_access_fs, scoped and the landlock_restrict_self flags word together. The grouping below is computed from the parsed ladder. applied_profile still spells every rung differently, so read that string as which rung was obtained, never as how much confinement.

Each statement is published verbatim on the limits page, so the two can be compared without anyone adjudicating a paraphrase.

domainrungshandled_access_fsscopedrestrict_self flagswhat this domain is
1 (T1)10x1fff0x00x0handled_access_fs=0x1fff, scoped=0x0: no REFER, so a realm capped at rung 1 cannot rename(2) across directories inside its own writable storage — the one rung that is stricter than the rung above it.
2 (T2)20x3fff0x00x0handled_access_fs=0x3fff, scoped=0x0: REFER arrives, and handling it is what permits cross-directory rename inside the realm’s own storage.
3 (T3)3, 40x7fff0x00x0handled_access_fs=0x7fff, scoped=0x0: TRUNCATE arrives at rung 3; rung 4 buys handled_access_net, which this build leaves zero, so rungs 3 and 4 are one domain.
4 (T4)50xffff0x00x0handled_access_fs=0xffff, scoped=0x0: IOCTL_DEV arrives, and it does not close the render-node limit — the app needs the node’s ioctls, so the ruleset grants them there.
5 (T5)6, 7, 80xffff0x30x0handled_access_fs=0xffff, scoped=0x3: rung 6 adds the scoped field; rungs 7 and 8 buy landlock_restrict_self flags rather than access-mask bits, so a mask cap cannot simulate their absence and rungs 6, 7 and 8 are one domain.
6 (T6)90x1ffff0x30x0handled_access_fs=0x1ffff, scoped=0x3: RESOLVE_UNIX arrives, and this is the highest rung this build requests — a kernel reporting a higher ABI is clamped here.

The flags column is zero at every rung because a shipped session passes zero. The one thing that moves it is VITRIN_LANDLOCK_AUDIT=1 in vitrind’s own environment, which sets rung 7’s LOG_NEW_EXEC_ON so the kernel keeps logging a realm’s denials past the shim’s execve. It changes what the kernel writes down, never what it permits, and it cannot be reached from realm.toml or a command line — so under it rungs 6 and 7 stop being one domain in the log flags only.

What the ruleset denies that the realm’s mount table does not

A realm is confined by a mount table and a Landlock domain, and most of what the domain refuses the mount table refuses too. Publishing the overlap as though the ruleset earned it would be the flattering direction, so this table is only the difference — and it is short.

the denialwhy the mount table does not already carry itwhat has been measuredpublished claim
execve(2) anywhere under /etc/etc is bound MS_RDONLY, MS_NOSUID, MS_NODEV and with no noexec, so the mount itself permits execution there. Everywhere else the two maps agree: the ruleset grants EXECUTE exactly where the mount table omits noexec.Nothing measures it. No test in this repository exercises this denial, which makes it the one row here that is prose rather than measurement. Said plainly rather than left implied.execute-under-etc-is-the-rulesets-own-denial

Every claim this table carries, and where it is published

A row with a right and no claim, or a claim with no row, stops the generator. Each needle below is checked against the surface it names on every run, so a published sentence cannot be deleted or reworded while this table still cites it.

claimwhat it sayspublished at
abi-floor-refuses-below-the-numberA kernel reporting a Landlock ABI below this build’s floor is refused at startup rather than confined at a weaker rung, and the number is printed as build.landlock_min_abi.docs/book/src/limits.md — “build.landlock_min_abi”; README.md — “build.landlock_min_abi”; SECURITY.md — “build.landlock_min_abi”
sub-floor-rungs-hold-the-dial-not-the-floorRungs below this build’s floor are unreachable in production – a kernel reporting one is REFUSED at startup rather than confined weakly – so a behavioural test taken at one of them holds the --landlock=abi:N DIAL honest and not the floor. This row is a rung such a test enters a domain at: it describes no state a stock session can reach, and those tests are the only evidence that this part of the table is not fiction (decision D-044, 2026-08-19).docs/book/src/limits.md — “hold the dial honest, not the floor”
sub-floor-rungs-are-not-all-exercisedThis rung is below the floor AND no behavioural test enters a Landlock domain at it, so every cell on this row is derived from this build’s own source and measured against nothing – the sub-floor half of the ladder is exercised in part, not throughout. D-044 (2026-08-19) kept the sub-floor tests that exist and deliberately added none, so this row’s status is a decision rather than an oversight.docs/book/src/limits.md — “exercised in part, not throughout”
refer-makes-the-cap-a-dialA domain denies cross-directory rename(2) unless its ruleset HANDLES REFER, so rung 1 is stricter about reparenting than rung 2 – the rung cap is a dial, not a one-way weakening.docs/book/src/limits.md — “The cap is a dial, not a one-way weakening”; README.md — “dial, not a one-way tightening”
truncate-arrives-at-abi-3Below ABI 3 there is no TRUNCATE right, so a payload that cannot write a file can still empty it – measured at rung 2 succeeding and rung 3 refusing.docs/book/src/limits.md — “Below ABI 3 there is no TRUNCATE right”
net-scoping-is-carried-by-the-namespaceABI 4 buys network scoping, which this build leaves zero because the realm’s own network namespace carries that claim and covers UDP and raw sockets too.docs/book/src/limits.md — “ABI 4 is network scoping”
ioctl-dev-does-not-close-the-render-nodeABI 5’s IOCTL_DEV is one all-or-nothing bit per hierarchy and the app needs the render node’s ioctls, so the ruleset grants them there and the published render-node limit survives the rung intact.docs/book/src/limits.md — “It does not close the render-node limit below.”; README.md — “the ruleset grants it there and this cost is unchanged”; SECURITY.md — “the app needs the node, so the ruleset grants it there”
scoped-is-defence-in-depthABI 6’s scoped field is defence in depth rather than the mechanism behind any published claim: the realm’s network namespace already isolates abstract UNIX sockets and its pid namespace already denies signalling outward.docs/book/src/limits.md — “ABI 6’s scoped field is defence in depth rather than the mechanism behind either published claim”
restrict-self-flags-are-not-mask-bitsABI 7 and ABI 8 buy landlock_restrict_self FLAGS rather than access-mask bits, so --landlock=abi:N cannot simulate their absence and those rungs are prose-backed rather than measurable here.docs/book/src/limits.md — “landlock_restrict_self flags”; README.md — “byte-identical at rungs 3 and 4 and again at rungs 6, 7 and 8”
nine-rungs-are-six-domainsRung numbers and enforced domains are not the same count: rungs that buy nothing this build requests collapse into their predecessor’s domain, while applied_profile still spells every rung differently.docs/book/src/limits.md — “Nine rung numbers name six different domains”
the-ladder-stops-at-the-build-ceilingThis build’s ladder stops at its ceiling and a newer kernel is clamped down to it, journaled per realm as isolation.landlock.clamped_by_build; nothing here has run on such a kernel.docs/book/src/limits.md — “This build’s ladder stops at rung 9”
execute-under-etc-is-the-rulesets-own-denial/etc is bound read-only with no noexec, so the mount permits execution there and only the Landlock ruleset refuses it – the one filesystem denial this layer contributes that the mount table does not already carry.docs/book/src/limits.md — “only this ruleset refuses it, and no test in this repository measures that denial yet”

What is NOT on this page

  • A per-kernel measurement. docs/plan/02-phase-2-semantic-epochs.md’s restated criteria for P2.6.3 ask for a table generated “from vitrind --print-isolation output on each kernel in the CI matrix, one row per ABI actually reported”. That is not what this is, and it is not a step towards it that was left half-taken: a table carrying the ABI of the machine that generated it cannot be byte-stable across two machines, so it cannot be the thing CI holds. The plan carries that as Correction 5.
  • Which kernels clear the floor — measured elsewhere, not here. This page still probes nothing. Since 2026-08-16 the per-kernel measurement exists as its own artefact: the kernel page, generated from boot logs checked in under tests/kernel-matrix/rows/. Read the two together and do not confuse them — this page says what the build requires, that one says what five kernels answered and which of them the floor of 6 admits. Two live machines are also on record: this repository’s development box at Landlock ABI 9 on 2026-08-15, and the runner its CI uses at ABI 7 on 2026-08-14. The runner’s number was read out of a CI job log that archives nothing, and it is corroborated but not replaced by the kernel page: booting the runner’s own 6.17.0-1020-azure in a bare initramfs answers ABI 7 too, which is a fact about that kernel and not about that runner.
  • Any statement that P2.6.3’s criteria were all met as written. The task (issue #187) was ACCEPTED on 2026-08-19, on its corrected criteria and on decision D-044 — not on the row the plan first wrote. That date is the plan’s acceptance record and the owner’s decision; the issue’s own closure timestamp is whatever GitHub writes when the merge lands, and this page asserts no value for it. What landed with this page is a generated ladder of what this build requires, held by CI. A per-kernel row set landed separately on 2026-08-16 — five kernels, on the kernel page — and it is a row per kernel, not the “one row per ABI actually reported” the criteria ask for, a clause no byte-stable checked-in page can satisfy (the plan carries that as Correction 5). Four things did not become true on acceptance: five kernels answered five ABIs, and four of the nine rungs are reported by none of them; every row on that page is a kernel reading taken in a bare initramfs rather than a distribution; the behavioural per-rung tests this page’s numbers rest on still live in vitrin-realm-init’s own suite, running on this repository’s development box and on the CI runner and on no third machine, with the values they pin recorded on one box on one date; and the sub-floor half of those tests is evidence about the --landlock=abi:N dial rather than about any state a stock session reaches.
  • The realm’s grant table. Which hierarchies get which rights is the limits page’s two-tier grant list, not a per-rung fact. The only grant-table row here is the one denial the mount table does not carry.

Runbook

To change what this page says, change the code or the published prose — never this file.

$ cargo xtask isolation-matrix          # regenerate in place, then review `git diff`
$ cargo xtask isolation-matrix --check  # what CI runs; reads only, writes nothing

The generator refuses to emit anything when:

  1. a pinned line of crates/vitrin-realm-init/ or crates/vitrin-core/ source is gone, so a cell here would describe code that no longer exists;
  2. LANDLOCK_MIN_ABI or LANDLOCK_BUILD_MAX_RUNG cannot be read, or the ladder in landlock.rs cannot be parsed — a shape the parser does not recognise is an error, never a rung silently dropped;
  3. the parsed ladder disagrees with the measured mask table pinned in the_rung_masks_pin_a_measured_table;
  4. a rung row names no published claim, or a published claim is named by no row;
  5. a claim’s needle is no longer on the surface it cites;
  6. a domain has no tier statement, or a tier statement is not on the limits page verbatim.

Adding a rung is therefore not an edit to this page: move the right in landlock.rs, publish what the rung is worth, add the row and its claim to crates/xtask/src/isolation_matrix.rs, and regenerate.

Which kernels this build starts on, measured

Each row below is a distribution kernel — 5 of them — booted under QEMU with the shipped vitrind in a minimal initramfs, and this page is their answers. Every cell below is a line that binary printed on that kernel — not a lookup table of when a Landlock ABI landed in mainline, and not a claim about the distributions those kernels come from.

The rows live in tests/kernel-matrix/rows/, one file per kernel, holding vitrind --print-isolation and vitrind --print-floor verbatim plus the startup line. This page is rendered from them; it measures nothing itself.

This build’s floor is Landlock ABI 6build.landlock_min_abi, printed by vitrind --print-floor. A kernel below it is refused at startup rather than confined at a weaker rung. What each rung of the ABI buys, and why the floor sits where it does, is the isolation matrix — a build-static page that probes no machine. Do not read the two as one document: that page says what this build requires, this one says what these 5 kernels answered.

The rows’ build half is held to this build, and that is the durable part. cargo xtask kernel-matrix --check reads every row’s own floor.mechanism= and applies.* lines and holds them to the sets declared in crates/vitrin-core/src/spawn/isolation.rs, so the day this build’s floor moves out from under these rows, this page goes RED and names the mechanism that moved. It cannot quietly go on describing an older binary, which is the failure it was built after: the floor grew by two mechanisms and every gate stayed green because the page and the rows were stale together. As rendered, that delta is empty — each row below was collected (2026-08-16) against a build whose startup floor was the same set this tree declares, namespaces, landlock, seccomp, no-new-privs.

Read the scope of that narrowly. This check is cheap and re-boots nothing, so it says the rows describe THIS build and says nothing whatever about whether these kernels still answer this way; only tests/kernel-matrix/collect.sh --check re-takes that half, and every row carries the date it was last taken on.

The measured table

kernel releaseshipped bylandlock.abins.allmount.in_usernstier--isolation=default
5.15.0-191-genericUbuntu 22.04 LTS1availableavailableintra-userrefusedbelow-floor(abi=1,required=6)
6.1.0-50-amd64Debian 12 (bookworm)2availableavailableintra-userrefusedbelow-floor(abi=2,required=6)
6.8.0-139-genericUbuntu 24.04 LTS (GA kernel)4availableavailableintra-userrefusedbelow-floor(abi=4,required=6)
6.12.101+deb13-amd64Debian 13 (trixie), current stable6availableavailableintra-userstarts
6.17.0-1020-azureUbuntu (azure kernel) — what this repository’s CI runners boot7availableavailableintra-userstarts

So of the five: 2 start and 3 are refused, and the boundary is exactly the floor.

Admitted:

  • 6.12.101+deb13-amd64 (Debian 13 (trixie), current stable) — landlock.abi=6, at or above the floor of 6.
  • 6.17.0-1020-azure (Ubuntu (azure kernel) — what this repository’s CI runners boot) — landlock.abi=7, at or above the floor of 6.

Refused:

  • 5.15.0-191-generic (Ubuntu 22.04 LTS) — landlock.abi=1, below the floor of 6. The remedy is a newer kernel, and specifically not a sysctl, an lsm= edit or a CONFIG_ change: this kernel’s Landlock is present, enabled and answering.
  • 6.1.0-50-amd64 (Debian 12 (bookworm)) — landlock.abi=2, below the floor of 6. The remedy is a newer kernel, and specifically not a sysctl, an lsm= edit or a CONFIG_ change: this kernel’s Landlock is present, enabled and answering.
  • 6.8.0-139-generic (Ubuntu 24.04 LTS (GA kernel)) — landlock.abi=4, below the floor of 6. The remedy is a newer kernel, and specifically not a sysctl, an lsm= edit or a CONFIG_ change: this kernel’s Landlock is present, enabled and answering.

All 5 rows report ns.all=available and mount.in_userns=available, so the Landlock floor is the only thing separating the two groups. 4 of this build’s 9 rungs are reported by none of these kernels — these are 5 machines somebody might be refused on, not a sweep of the ABI ladder, and no row here says anything about a kernel that is not in the table.

These are KERNEL rows. They are not distribution rows

Each boot loads a distribution’s unmodified vmlinuz and then runs a minimal initramfs of this repository’s own making: a static PID 1 (tests/kernel-matrix/init.c), /vitrind and its library closure, /proc /sys /dev /run mounted, uid 0 with a full capability set — and no distribution userspace at all. No AppArmor or SELinux policy is loaded, no /etc/subuid exists, no sysctl file from /etc/sysctl.d has been applied, no container runtime has adjusted anything.

That is a deliberate choice and it decides what the rows mean. A kernel is the same kernel wherever it boots, so landlock.abi and the namespace rows are properties of these bytes. The policy rows are not: they are properties of a running system, and this one is bare.

The cross-validation that settles it. The last row is the same kernel release the runners this repository’s CI uses report. Booted here it agrees with that runner on the kernel facts and disagrees with it on the policy facts. The left column is read out of the checked-in row; the right column is transcribed from a CI job log:

cellthis harness, bare initramfsthe CI runner, Ubuntu userspace
landlock.abi77
ns.allavailableavailable
policy.apparmor_restrict_unprivileged_userns01
mount.in_usernsavailablerestricted-by-policy(errno=13)
tierintra-usernone
provisioning.subuidabsent — an initramfs has no /etc/subuidwhatever the image ships

Same kernel, same binary, two different answers — and the ones that moved are exactly the policy cells. So this method can produce a kernel row and can never produce a distribution row. A distribution row has to come from that distribution, which for this repository means the runner’s own --print-isolation output, printed by the What confinement this runner actually grants step in .github/workflows/ci.yml. The runner column above is that step’s reading, transcribed from a job log GitHub expires; it is not an artefact in this tree, and the limits page publishes that bound.

This is also why tests/kernel-matrix/kernels.manifest has no policy-variant record. Booting one of these kernels with an AppArmor sysctl flipped on the command line would produce a row that looks like a distribution row while still being a kernel row with one knob moved, which is the confusion this whole section exists to prevent.

Ubuntu 24.04 needs the AppArmor profile and is refused anyway

This one is not obvious and it undercuts something this repository shipped, so it is stated plainly rather than left to be inferred from the table.

PR #290 added an AppArmor profile for vitrind (packaging/apparmor/vitrind), because Ubuntu 24.04 denies the capabilities vitrind needs inside a user namespace it has already granted — issue #286. That profile is measured working on one kernel on one CI imagethe limits page carries what the apparmor-profile job reported, and the bound it did not clear. Nothing below depends on which way that went.

What this page adds is that the profile cannot be sufficient on Ubuntu 24.04’s own GA kernel, whatever the job reports. That kernel is 6.8.0-139-generic, measured above at landlock.abi=4 — below this build’s floor of 6. So on a stock 24.04, even granting the profile everything it is meant to grant:

  1. the profile is aimed at the namespace refusal, and then
  2. the isolation preflight refuses the session at the next gate anyway, on the Landlock floor, with below-floor(abi=4,required=6).

The two refusals are independent and their remedies are disjoint — no AppArmor policy changes the number a kernel reports for its Landlock ABI. So on a stock 24.04 a working profile would change which refusal you get, not whether you get one; the remedy for the second is a newer kernel. Only a 24.04 running a newer HWE or cloud kernel — the 6.17.0-1020-azure row above is one, at ABI 7 — is a machine where the profile is the only thing standing between it and a session.

That is a real qualification on what PR #290 bought, and it belongs beside every description of the profile rather than only here. The limits page carries it in the host-must-have-landlock entry.

Why each kernel is in the set

  • 5.15.0-191-generic — Ubuntu 22.04 LTS (5.15-lts). The oldest kernel with Landlock at all that a reader might still be running. It is the bottom of the range: if this build refused nothing else, it would refuse this.
  • 6.1.0-50-amd64 — Debian 12 (bookworm) (6.1-lts). The previous Debian stable, and the row that shows the floor is not satisfied by “a recent LTS” — 6.1 is a long-term kernel and it is still four rungs short.
  • 6.8.0-139-generic — Ubuntu 24.04 LTS (GA kernel) (6.8-lts). The kernel PR #290’s AppArmor profile was written for, and the reason that profile cannot be sufficient on its own: the profile is aimed at the namespace refusal, and this kernel is refused at the next gate anyway, on the Landlock floor. The page’s “AppArmor profile” section is about this row.
  • 6.12.101+deb13-amd64 — Debian 13 (trixie), current stable (6.12-stable). The row the floor was lowered for (owner’s decision, 2026-08-16). Under the previous floor of 7 this kernel was refused; it reports ABI 6, and the domain this build enforces at rung 6 is identical to the one it enforces at rung 7.
  • 6.17.0-1020-azure — Ubuntu (azure kernel) — what this repository’s CI runners boot (ci-runner-kernel). The cross-validation row. It is the kernel release the CI runner reported ON THE COLLECTION DATE, so booting it here says whether this harness reproduces that machine — and, on the policy cells, whether it does not. The runner’s kernel moves: it reported 6.17.0-1020-azure on 2026-08-14 and 6.17.0-1022-azure by 2026-08-16. Both are ABI 7, so the rung this row cross-validates is unaffected, but do not read the row as naming whatever the runner boots today. GitHub bumps that image with no commit here, which is why this table is dated rather than presented as current.

Provenance, per row

A row is only worth as much as its ability to be re-taken. Each block below carries the bytes that were booted, where they came from, and the command that booted them. The sha256 is the identity and the URL is only where those bytes were found on the collection date: distribution pools prune superseded kernels, so a dead URL is expected eventually and the remedy is another mirror serving the same checksum — never a re-measurement against whatever the pool holds now.

5.15.0-191-generic — Ubuntu 22.04 LTS

rowtests/kernel-matrix/rows/ubuntu-22.04-5.15.row
collected2026-08-16 (UTC)
vitrind version0.1.0
--print-isolation schemavitrin-isolation 3
packagehttp://archive.ubuntu.com/ubuntu/pool/main/l/linux/linux-image-unsigned-5.15.0-191-generic_5.15.0-191.201_amd64.deb
package sha256240e407cd863d86dc2eefcf3a085b232849afae7a8c911461369e8c0a02e3f67
vmlinuz./boot/vmlinuz-5.15.0-191-generic
vmlinuz sha256e14e87b3c53124b655207647f695908cafc942cd28871c913ffa4aab712eba93
bootqemu-system-x86_64 -accel <accel> -m 512 -smp 1 -nographic -no-reboot -kernel <vmlinuz> -initrd <initramfs> -append "console=ttyS0 panic=1 quiet" (<accel> was tcg)
userspaceminimal initramfs from tests/kernel-matrix/init.c – static PID 1, /vitrind and its library closure, /proc /sys /dev /run mounted, NO distribution policy loaded, NO /etc/subuid, uid 0 with a full capability set. This is a KERNEL row and never a distribution row.

The startup line this kernel produced, verbatim:

ERROR vitrind: fatal: this build's isolation floor requires `landlock` and this machine reports `below-floor(abi=1,required=6)`. This kernel has Landlock and reports ABI 1; this build's floor is ABI 6 (owner's decisions of 2026-08-15 and 2026-08-16: declare a floor rather than publish a multi-rung ladder nothing measures, and set it at the lowest rung that gives up no enforcement). Nothing is misconfigured here and no sysctl, LSM list or boot parameter will change the number -- the remedy is a newer kernel. `uname -r` says 5.15.0-191-generic, and `vitrind --print-floor` prints the required number as `build.landlock_min_abi`. This build will not fall back to a lower rung: a realm confined by a weaker domain than the session's own journal names is the silent degradation D-020(6) exists to forbid. `--landlock=off` starts a session whose realms get NO ruleset at all -- it is the positive control this repository's confinement gates run against, not a way to run on an older kernel. Pass `--isolation=off` to start an UNCONFINED session anyway, or -- for a Landlock refusal specifically -- `--landlock=off` to start a session whose realms have namespaces and no ruleset. Both are weaker than what was asked for and both say so in every journal entry. `vitrind --print-isolation` shows every row behind this answer and `vitrind --print-floor` what this build requires.

6.1.0-50-amd64 — Debian 12 (bookworm)

rowtests/kernel-matrix/rows/debian-12-6.1.row
collected2026-08-16 (UTC)
vitrind version0.1.0
--print-isolation schemavitrin-isolation 3
packagehttps://deb.debian.org/debian/pool/main/l/linux/linux-image-6.1.0-50-amd64-unsigned_6.1.176-1_amd64.deb
package sha256439422e41d2dbb840b60b81e3bf5e5955bfa56b7a8c268aec83c9ff882d421e6
vmlinuz./boot/vmlinuz-6.1.0-50-amd64
vmlinuz sha256653421d9774c0de27502ca010d572323b52a5d7141d067b9b04214bd24baca3a
bootqemu-system-x86_64 -accel <accel> -m 512 -smp 1 -nographic -no-reboot -kernel <vmlinuz> -initrd <initramfs> -append "console=ttyS0 panic=1 quiet" (<accel> was tcg)
userspaceminimal initramfs from tests/kernel-matrix/init.c – static PID 1, /vitrind and its library closure, /proc /sys /dev /run mounted, NO distribution policy loaded, NO /etc/subuid, uid 0 with a full capability set. This is a KERNEL row and never a distribution row.

The startup line this kernel produced, verbatim:

ERROR vitrind: fatal: this build's isolation floor requires `landlock` and this machine reports `below-floor(abi=2,required=6)`. This kernel has Landlock and reports ABI 2; this build's floor is ABI 6 (owner's decisions of 2026-08-15 and 2026-08-16: declare a floor rather than publish a multi-rung ladder nothing measures, and set it at the lowest rung that gives up no enforcement). Nothing is misconfigured here and no sysctl, LSM list or boot parameter will change the number -- the remedy is a newer kernel. `uname -r` says 6.1.0-50-amd64, and `vitrind --print-floor` prints the required number as `build.landlock_min_abi`. This build will not fall back to a lower rung: a realm confined by a weaker domain than the session's own journal names is the silent degradation D-020(6) exists to forbid. `--landlock=off` starts a session whose realms get NO ruleset at all -- it is the positive control this repository's confinement gates run against, not a way to run on an older kernel. Pass `--isolation=off` to start an UNCONFINED session anyway, or -- for a Landlock refusal specifically -- `--landlock=off` to start a session whose realms have namespaces and no ruleset. Both are weaker than what was asked for and both say so in every journal entry. `vitrind --print-isolation` shows every row behind this answer and `vitrind --print-floor` what this build requires.

6.8.0-139-generic — Ubuntu 24.04 LTS (GA kernel)

rowtests/kernel-matrix/rows/ubuntu-24.04-6.8.row
collected2026-08-16 (UTC)
vitrind version0.1.0
--print-isolation schemavitrin-isolation 3
packagehttp://archive.ubuntu.com/ubuntu/pool/main/l/linux/linux-image-unsigned-6.8.0-139-generic_6.8.0-139.139_amd64.deb
package sha2566c5e6049c195ac7f8b4cbd2dd96dca4f8cfdd2c0627250c5688fe1ba9caf930f
vmlinuz./boot/vmlinuz-6.8.0-139-generic
vmlinuz sha256b500ae87509c77cde64aa9867804e901b410efa3eea41f62d3c766f8c0ee9ab6
bootqemu-system-x86_64 -accel <accel> -m 512 -smp 1 -nographic -no-reboot -kernel <vmlinuz> -initrd <initramfs> -append "console=ttyS0 panic=1 quiet" (<accel> was tcg)
userspaceminimal initramfs from tests/kernel-matrix/init.c – static PID 1, /vitrind and its library closure, /proc /sys /dev /run mounted, NO distribution policy loaded, NO /etc/subuid, uid 0 with a full capability set. This is a KERNEL row and never a distribution row.

The startup line this kernel produced, verbatim:

ERROR vitrind: fatal: this build's isolation floor requires `landlock` and this machine reports `below-floor(abi=4,required=6)`. This kernel has Landlock and reports ABI 4; this build's floor is ABI 6 (owner's decisions of 2026-08-15 and 2026-08-16: declare a floor rather than publish a multi-rung ladder nothing measures, and set it at the lowest rung that gives up no enforcement). Nothing is misconfigured here and no sysctl, LSM list or boot parameter will change the number -- the remedy is a newer kernel. `uname -r` says 6.8.0-139-generic, and `vitrind --print-floor` prints the required number as `build.landlock_min_abi`. This build will not fall back to a lower rung: a realm confined by a weaker domain than the session's own journal names is the silent degradation D-020(6) exists to forbid. `--landlock=off` starts a session whose realms get NO ruleset at all -- it is the positive control this repository's confinement gates run against, not a way to run on an older kernel. Pass `--isolation=off` to start an UNCONFINED session anyway, or -- for a Landlock refusal specifically -- `--landlock=off` to start a session whose realms have namespaces and no ruleset. Both are weaker than what was asked for and both say so in every journal entry. `vitrind --print-isolation` shows every row behind this answer and `vitrind --print-floor` what this build requires.

6.12.101+deb13-amd64 — Debian 13 (trixie), current stable

rowtests/kernel-matrix/rows/debian-13-6.12.row
collected2026-08-16 (UTC)
vitrind version0.1.0
--print-isolation schemavitrin-isolation 3
packagehttps://deb.debian.org/debian/pool/main/l/linux/linux-image-6.12.101+deb13-amd64-unsigned_6.12.101-1_amd64.deb
package sha25656ae4e2dbf5f07214ef5d07f515698f36c62fb63eee97831333f32b659afab66
vmlinuz./boot/vmlinuz-6.12.101+deb13-amd64
vmlinuz sha2568f6144580ef6e34459ea0c0819919c292dc605acbd6eac074d8bfef84505a055
bootqemu-system-x86_64 -accel <accel> -m 512 -smp 1 -nographic -no-reboot -kernel <vmlinuz> -initrd <initramfs> -append "console=ttyS0 panic=1 quiet" (<accel> was tcg)
userspaceminimal initramfs from tests/kernel-matrix/init.c – static PID 1, /vitrind and its library closure, /proc /sys /dev /run mounted, NO distribution policy loaded, NO /etc/subuid, uid 0 with a full capability set. This is a KERNEL row and never a distribution row.

The startup line this kernel produced, verbatim:

INFO vitrind: realms will be confined: each gets its own user, mount, PID, IPC, UTS and network namespace, an identity uid/gid map, zero capabilities, a Landlock ruleset enforced before the shim's execve, and a seccomp-bpf DENY-LIST installed immediately after it. The deny-list is a named-class claim and not a completeness one: `vitrind --print-seccomp` prints every row it closes, and the rest of the kernel's syscall surface is unenumerated -- a realm is filtered against a named list, NOT syscall-confined. No `applied_profile` is printed here on purpose: it names the rung a realm OBTAINED, and no realm exists yet -- the ladder's landing is per-spawn. Whatever it says, it is not a tier name either, because `intra-user` means namespaces PLUS Landlock PLUS seccomp and a profile string names only the Landlock rung isolation=default landlock=highest kernel=6.12.101+deb13-amd64

6.17.0-1020-azure — Ubuntu (azure kernel) — what this repository’s CI runners boot

rowtests/kernel-matrix/rows/ubuntu-azure-6.17.row
collected2026-08-16 (UTC)
vitrind version0.1.0
--print-isolation schemavitrin-isolation 3
packagehttp://archive.ubuntu.com/ubuntu/pool/main/l/linux-azure/linux-image-unsigned-6.17.0-1020-azure_6.17.0-1020.20_amd64.deb
package sha2560b6fc9fd94bf375fad1f83fe0672ab72152def473d1fd93c50b07b7452942faa
vmlinuz./boot/vmlinuz-6.17.0-1020-azure
vmlinuz sha25604d18f600df726d196e6dffd22338657f6702f25a4b3512b400b77640d148c59
bootqemu-system-x86_64 -accel <accel> -m 512 -smp 1 -nographic -no-reboot -kernel <vmlinuz> -initrd <initramfs> -append "console=ttyS0 panic=1 quiet" (<accel> was tcg)
userspaceminimal initramfs from tests/kernel-matrix/init.c – static PID 1, /vitrind and its library closure, /proc /sys /dev /run mounted, NO distribution policy loaded, NO /etc/subuid, uid 0 with a full capability set. This is a KERNEL row and never a distribution row.

The startup line this kernel produced, verbatim:

INFO vitrind: realms will be confined: each gets its own user, mount, PID, IPC, UTS and network namespace, an identity uid/gid map, zero capabilities, a Landlock ruleset enforced before the shim's execve, and a seccomp-bpf DENY-LIST installed immediately after it. The deny-list is a named-class claim and not a completeness one: `vitrind --print-seccomp` prints every row it closes, and the rest of the kernel's syscall surface is unenumerated -- a realm is filtered against a named list, NOT syscall-confined. No `applied_profile` is printed here on purpose: it names the rung a realm OBTAINED, and no realm exists yet -- the ladder's landing is per-spawn. Whatever it says, it is not a tier name either, because `intra-user` means namespaces PLUS Landlock PLUS seccomp and a profile string names only the Landlock rung isolation=default landlock=highest kernel=6.17.0-1020-azure

One cell is normalized, and it is named

policy.max_user_namespaces is derived from guest memory and tracks the size of the compressed initramfs, so it moves whenever vitrind’s binary changes size — which is most commits, including ones that touch nothing here. Publishing it raw would make every unrelated change look like a kernel behaving differently. The rows therefore replace that one value with a placeholder in the compared body and keep the raw reading in the row’s own header, where --check ignores it. It is the only cell that gets this treatment, and every other line of every row is compared byte-for-byte.

Runbook

No pull request boots a kernel. Collecting the rows needs QEMU, roughly 220 MiB of downloaded kernel packages and about fifteen seconds of emulation, and wiring that into per-PR CI would buy a check on something that changes when a distribution ships a kernel — not when this repository changes. It is a command a person runs, and the rows carry a collection date so a reader can see how stale the answer is.

# Re-measure every kernel and rewrite tests/kernel-matrix/rows/. Needs qemu.
$ cargo build --release --bin vitrind
$ tests/kernel-matrix/collect.sh

# Re-measure and DIFF against what is checked in; writes nothing. Red if a kernel
# now answers differently, or if a row is older than 180 days.
$ tests/kernel-matrix/collect.sh --check

# One kernel only. Says loudly that it is a partial run.
$ tests/kernel-matrix/collect.sh --only debian-13-6.12

# Re-render THIS PAGE from the checked-in rows. No qemu, no network.
$ cargo xtask kernel-matrix
$ cargo xtask kernel-matrix --check   # what CI runs on every pull request

The two --checks prove different things and neither substitutes for the other: cargo xtask kernel-matrix --check holds this page to the rows, and collect.sh --check holds the rows to the kernels. A green pull request means the first one passed. It says nothing about whether these kernels still answer this way.

A failed boot never produces a row. The collector requires its init’s sentinels, a zero exit from each probe, a schema-tagged and complete reading, exactly one startup verdict, and a kernel.release equal to the one the manifest pins — and it prints FAIL: and exits nonzero on any of them. There is no branch in it that emits an empty cell, a default, or an “unmeasured” that reads like an answer.

What is NOT on this page

  • A distribution matrix. See the section above: every row here ran with no distribution policy loaded. “Does vitrind run on Ubuntu 24.04” is not answered by the 6.8.0-139-generic row alone — that row answers “does this build’s floor admit Ubuntu 24.04’s GA kernel”, and the answer is no.
  • One row per ABI rung. Five kernels reported five distinct ABIs; four of this build’s nine rungs are reported by none of them. The per-rung behaviour table is the isolation matrix, which is generated from source and measures no machine.
  • A claim that these rows are current. Each carries a collection date. Nothing in a pull request re-boots them, which is a deliberate scope decision and not an oversight; collect.sh --check is how the claim gets re-taken, and it goes red on a row older than 180 days when somebody runs it.
  • Anything about non-x86-64, or about kernels not in the table. No row here says where between ABI 5 and ABI 6 a given mainline release sits. That mapping is a fact about mainline, and this page publishes measurements rather than lookups.

Where this is honest about its limits

Phase 1 is complete. That is a statement about a defined slice closing on named, mock-free gates — not a statement that this is ready for anything real. This page is the whole list, in one place, so you never have to discover an item on it yourself.

Do not deploy this yet

The sandbox is half-built, and which half you have depends on a flag. Decision D9, then D-020 and D-036. Since P2.6.2 a realm is spawned into six namespaces — user, mount, PID, IPC, UTS and network — with an identity uid/gid map, zero capabilities, and a private mount table the app cannot reshape. The core verifies all of that from outside, by reading the kernel’s answer about the child, and refuses the spawn when it cannot.

Since P2.6.3 the realm gets a Landlock ruleset, enforced immediately before the shim’s execve and inherited by every process the realm ever runs. The task’s second deliverable — a generated ladder table with a CI staleness gate — now exists too: the Landlock ABI matrix, emitted by cargo xtask isolation-matrix and held byte-for-byte by CI. It is not the per-kernel table the task’s restated criteria describe, and the difference is not a detail: it publishes what this build requires of a kernel’s Landlock, one row per ABI rung, and it probes nothing. The row-per-ABI-actually-reported half now exists separately, and is measured: which kernels this build starts on carries five distribution kernels booted under QEMU with the shipped vitrind, reporting ABI 1, 2, 4, 6 and 7 — three refused below the floor, two admitted. Read the two pages as what they each are: one says what this build requires of a kernel, the other says what five kernels answered.

What has not grown is the number of machines on which the suite itself has ever run, which is still exactly two. A kernel row is one binary printing its isolation report in a minimal initramfs; it is not a run of run.sh, and it is not a statement about the distribution that ships that kernel. The remaining gap is narrower than it was and is its own entry below.

The grant set is two tiers, and one sentence about “the write set” gets it wrong in the flattering direction — an earlier draft of this page said “its write set is exactly the four hierarchies the mount table already publishes”, and that was false. Read from crates/vitrin-realm-init/src/landlock.rs’s grants, which is the only authority:

  • Full write authority — create, delete, rename, truncate — on exactly the four hierarchies the mount table publishes as writable: /run/vitrin, /vitrin/home, /tmp and /dev/shm.
  • WRITE_FILE and nothing else on four more: /proc (real software writes its own /proc/self/*), /dev and /dev/pts (writing through a device node, never creating one — /dev is a read-only mount by the time the ruleset is built), and every bound render node. So the ruleset requests WRITE_FILE on eight hierarchies, not four — and eight is the count on a host with one render node, since render_nodes is a list and each entry is its own rule, so a two-GPU host is nine. None of those four carries TRUNCATE, any MAKE_*, any REMOVE_* or REFER — nothing there can be created, deleted, renamed or emptied — but “the write set is the four writable mounts” is still the wrong sentence, and the difference is exactly the kind a reader is entitled to have stated rather than to find in the source.
  • The read set is enumerated rather than granted at the realm root, and the execute half is narrower than the read half. Read+execute: /usr, the /bin-class compatibility names, the shim binary, the app’s own directory, every binds entry, and /tmp (whose mount is deliberately not noexec). Read only, no execute: /etc and /sys. Read+write, no execute: /proc and /dev. /dev/pts additionally carries IOCTL_DEV, and each bound render node carries read+write+IOCTL_DEV (see below). Nothing else is granted at all — a read of a path the mount table happened to leave reachable but nothing granted now fails EACCES rather than succeeding. Three of those rows are a file rather than a hierarchy — the shim binary, any binds entry naming a file, and every render node (a character device) — and they are granted without the directory-list right, because the kernel refuses a rule that names LANDLOCK_ACCESS_FS_READ_DIR on anything that is not a directory. Refuses the rule, not merely the right: measured EINVAL here (ABI 9, 2026-08-14 for the regular file, 2026-08-15 for the render node), and since every one of those rules is required and the helper fails closed, granting any of the three the wrong rights takes every realm on the box down at the shipped default — which is how both were found.
  • /etc is the one place the ruleset denies something the mount table does not. Everywhere else the two maps agree on execution: the ruleset grants EXECUTE exactly where the mount table omits noexec. /etc is bound MS_RDONLY|MS_NOSUID|MS_NODEV with no noexec, so the mount permits execve(2) there and only this ruleset refuses it, and no test in this repository measures that denial yet. It is the only row of the ABI matrix’s “what the ruleset denies that the mount table does not” table, and that page prints the same unmeasured status beside it — an unmeasured denial is prose, and is published as prose.

And “nothing else” is a small set — which is the honest half of that sentence, and it is published because the enumeration is the stronger-sounding claim. Re-collected on this repo’s box (Arch, kernel 7.1.9-arch1-2, Landlock ABI 9, 2026-08-23) with solid-client --probe over 40 distinct in-realm paths, in batches of eight, each batch run twice — shipped default and --landlock=off, byte-identical realm.toml asserted between the pair, and at least one path per batch reachable in both runs so a report from an app that could open nothing cannot satisfy a denial. With the app relocated out of the build tree, into a /tmp directory that the mount table and the ruleset both already carry, three paths were refused EACCES at the default and opened at --landlock=off:

denied at the default, reachable at --landlock=offwhat it is
/the realm root
/runthe parent of /run/vitrin
/vitrinthe parent of /vitrin/home and of the shim binary /vitrin/vitrin-shim

All three are directories the realm’s own mount table created on its root tmpfs purely to hold a bind target beneath it, and each holds nothing but the next component of that path. Every other probed path answered identically at both settings: /usr, /usr/bin, /usr/lib, /usr/lib64, /etc, /proc, /proc/self, /sys, /tmp, /dev and its nodes (/dev/null, /dev/zero, /dev/urandom, /dev/ptmx), /dev/shm, /dev/pts, /dev/dri, /vitrin/home, /vitrin/vitrin-shim, /run/vitrin, the /bin-class names (/bin, /sbin, /lib, /lib64), files inside them (/bin/sh, /usr/bin/env), and the app’s own directory and binary all opened in both; /dev/tty answered ENXIO in both, which is a realm with no controlling terminal and not a ruleset denial, and /dev/input answered ENOENT in both, which is the mount table and not the ruleset.

The /home chain this page used to publish is gone, and it was never a denial the design produced. The eight-row table carried here until 2026-08-23 was collected with the app running from a build tree, and five of its rows were that tree’s own ancestors — minted on the realm’s root tmpfs solely to hold the app-directory and shim-library bind targets beneath them. With the app relocated, /home and every component under it answer ENOENT at both settings: they do not exist inside the realm at all. The same batches were run again in the same session with the app left in the build tree (39 distinct paths there rather than 40, because two slots name the app’s own directory and collide with the build tree’s own), and that run still denies /, /run, /vitrin, /home, /home/<user> and every further ancestor of the app’s own directory — a count that is a function of how deep the checkout is and of nothing else, which is why the relocated run is the one published above and why the old table’s “eight” was a fact about a pathname.

That also settles a prediction this page carried and labelled as one. #283 renamed the shim’s bind target /vitrin/shim/vitrin/vitrin-shim and removed the shim-library bind by linking anything the shim vendors statically, and the page predicted four denials afterwards — /, /run, /vitrin and /home. The measurement says three: /home is not denied, it is absent. The prediction was wrong, in the direction that overstated the boundary, and it is deleted rather than left standing beside the run that replaced it.

Nothing in CI re-collects this and nothing will notice when it has aged again. It is a measurement session someone sits through — twenty core starts across the two app locations, two isolation settings each — not an edit, and what a reader is checking this page against is a run rather than a gate. It belongs to #187, the issue this ruleset and this table come from.

So what the enumeration buys over the realm-root grant #187 declined is, on this host, that the realm cannot list its own root and cannot list the handful of empty directories its mount table had to mint. It denies no path that carries data, because the mount table already puts host content nowhere but at a bind target and every bind target is granted. That is a narrower thing than “the read set is enumerated” sounds like, and it is the measured one. It is not nothing — / is exactly the directory a nested sandbox enumerates before binding, and test_real_confinement.py holds that denial as a gate — but a reader sizing residual risk should size it at a handful of empty directories, not at a filesystem.

Since P2.6.4 (#188) there is a seccomp filter, and it is a DENY-LIST — a named-class claim, never a completeness claim. vitrin-realm-init installs a classic-BPF program immediately before the shim’s execve, so the shim and every process it forks inherit it and cannot remove it. What it closes is the list vitrind --print-seccomp prints: 13 denied syscall rows today, each naming the PRD Doc 2 §15 escape class it answers and the errno it returns. What it leaves open is everything else, unenumerated — this build does not know Firefox’s syscall surface, and an allow-list built without a measured trace would fail closed against the project’s own acceptance app. So a realm is now syscall-filtered against a named list and is not “syscall-confined”: the residual surface is the kernel’s whole surface minus 13 denied syscall rows, and nobody here has counted what that leaves.

Read that beside the Landlock sentence above, because the two are the same shape and P2.9.4’s cross-check compares them: Landlock’s read set is enumerated and denies a handful of empty directories; seccomp’s deny set is enumerated and denies thirteen syscalls. Neither is a boundary around “everything an app might do”. Both are lists, and a list is exactly as large as it is.

Four things about that filter that a reader must not have to infer:

  • It answers two of §15’s eight actor rows, and part of a third. The two are Compromised shim — the only §15 row that names seccomp at all — and Malicious app in a shim, at its kernel-attack-surface half. The third is Reachable-service lateral escape, and it is answered at two services PRD Doc 2 §4.5’s own “there is simply nothing to reach” sentence does not cover: the operator’s kernel keyring, and AF_VSOCK. The remaining five rows — ransomware, hijacked agent, malicious agent client, malicious relying app, impersonating publisher — are answered by mechanisms that are not seccomp, and by nothing in this filter.
  • 11 of the 13 denied syscall rows are DEMONSTRATED on the kernel this was measured on; two are not. tests/integration/test_real_seccomp.py runs the same probe binary inside a realm and at --isolation=off, and a row whose syscall already fails outside a realm is reported not demonstrated rather than counted as confinement. bpf and userfaultfd land there on a box with kernel.unprivileged_bpf_disabled or vm.unprivileged_userfaultfd set — the denial is real, and on that machine it is not confinement this filter adds. Which rows are demonstrated is a property of the kernel, so it is measured per run and printed, never declared.
  • A realm cannot execute a foreign-ABI binary. Syscall numbers are per-ABI, so a process running under i386 or x32 on an x86-64 kernel meets a table whose numbers mean other syscalls. The filter kills it (SECCOMP_RET_KILL_PROCESS) rather than passing it unfiltered. A 32-bit app in a realm dies with SIGSYS on its first syscall.
  • The crash reporter is the acceptance app’s casualty, and the gate does not cover it. The ptrace row denies the pinned Firefox’s minidump writer. test_real_firefox.py sets MOZ_CRASHREPORTER_DISABLE=1, so that gate goes green without exercising the path this row breaks. The green tick is not evidence for that row, and this bullet exists so nobody reads it as one.

Timing, measured rather than asserted (R2.8). Three acceptance gates, 7 runs each, on Arch 7.1.8-arch1-3, 2026-08-16, against a control build identical except that it does not install the filter: test_real_app.py 2.139 s vs 2.154 s, test_real_gtk.py 1.722 s vs 1.717 s, test_real_firefox.py 2.943 s vs 2.947 s (medians). Every difference is smaller than the run-to-run spread of either arm, so the honest statement is no change measurable at this resolution — not “negligible”, which is a claim about magnitude this measurement cannot make. Note also what is not measured here: installing a seccomp filter enables the kernel’s speculative store bypass mitigation for the process unless SECCOMP_FILTER_FLAG_SPEC_ALLOW is passed, which this build does not pass. On hardware where that mitigation costs, the cost is real and no gate here would see it.

What that ruleset is, and what it is not, stated rather than left to be inferred from the word “Landlock”:

  • There is a declared ABI floor, and it is not a ladder (owner’s decision, 2026-08-15; the number lowered a rung on 2026-08-16). This build targets recent kernels: a kernel reporting a Landlock ABI below build.landlock_min_abi — printed by vitrind --print-floor, and 6 in this build — is refused at startup, with a refusal that names the number it found, the number it needed, and the fact that no sysctl, LSM list or boot parameter changes either. It does not fall back to a lower rung: a realm confined by a weaker domain than the session’s own journal names is the silent degradation D-020(6) exists to forbid. That is the fourth host requirement in the host-must-have-landlock entry below. Why 6, and why moving it down from 7 gave up no enforcement. The floor was 7 for one day. It is 6 because 6 is the lowest rung at which the domain this build actually enforces is unchanged: the enforced triple — handled_access_fs, scoped, and the landlock_restrict_self flags word — is identical at rungs 6, 7 and 8, because the only thing rungs 7 and 8 buy is flags (audit logging, TSYNC) and every shipped run passes flags = 0. Rung 5 differs (it is below where scoped arrives), which is why the floor cannot go lower without giving something up, and rung 9 differs too (it adds RESOLVE_UNIX) — so no page here says the domain is identical from 6 to 9. The floor decides admission, never which rung is applied: the rung a realm gets is still min(kernel ABI, build ceiling), so a machine that supplied rung 9 before supplies rung 9 now. All three facts are asserted, not narrated, by the_floor_costs_nothing_because_the_domain_is_flat_from_six_to_eight in crates/vitrin-realm-init/src/main.rs. Which kernel releases the floor excludes IS measured now, and it is measured on kernels rather than inferred from mainline changelogs — five distribution kernels were booted under QEMU with the shipped binary and their answers are checked in: Ubuntu 22.04’s 5.15.0-191-generic at ABI 1, Debian 12’s 6.1.0-50-amd64 at ABI 2 and Ubuntu 24.04’s GA 6.8.0-139-generic at ABI 4 are refused; Debian 13’s 6.12.101+deb13-amd64 at ABI 6 and the azure kernel this repository’s CI runners boot at ABI 7 start. See the kernel page for the rows, their provenance, and why they are kernel rows and not distribution rows. Two live machines are also on record and are a different kind of evidence: this repository’s development box (Arch, 7.1.8-arch1-3) answers landlock.abi=9, and the GitHub runner its CI uses answered landlock.abi=7 on 2026-08-14 — that second number lives only in a job log, read out of CI’s own diagnostic step, and no checked-in artefact carries that runner measurement; the host-must-have-landlock entry below states the bound. (The kernel page boots the same kernel release and also reads 7, which corroborates the number without making it a fact about that runner — the policy cells differ.) What a checked-in file does carry, since issue #288, is the claim: .github/workflows/ci.yml’s rust job sets VITRIN_REQUIRE_LANDLOCK_ABI: "7", which turns every Landlock rung measurement at or below 7 from a test that may skip into a test that must run. That does not re-take the measurement and must not be read as one — it asserts it, so a runner image that dropped below 7 would turn the job red instead of skipping five measurements quietly, which is what those five did before. Note the two numbers are now deliberately different: the build floor is 6 and the CI require-variable is 7, because the second is a statement about the runner’s kernel and not about what this build needs. The floor narrowed P2.6.3 rather than completing it, and what completed it was other work plus a decision — not this. PRD §20’s “coverage is kernel-dependent” caveat is answered for those five kernels and for no others: five kernels reported five ABIs and four of this build’s nine rungs are reported by none of them, so the per-rung table is still generated from source rather than measured on machines. The plan document carries that correction in as many words.

  • The rung matters, and the rung obtained is what is published. A Landlock ABI rung is which access rights the kernel will police at all. The helper asks for the highest rung this build knows that the kernel accepts, and journals the rung it got, the rung the session asked for, and the ABI the kernel reported. The one-rung-at-a-time descent still exists for a kernel that reports ABI N and then refuses rung N’s mask, and it bottoms out at the floor rather than walking to rung 1. applied_profile names the rung obtainednamespaces+landlock-abi9 on this repo’s own box — so a session that landed a rung below what it asked for cannot render like one that did not, and the core logs a WARN per spawn whenever the obtained rung is below the request or below the kernel’s own ABI, naming both numbers and the rights that moved between them. Below ABI 3 there is no TRUNCATE right, so a payload that cannot write a file can still destroy it — measured, not asserted: at rung 2 a truncate(2) on a read-granted file succeeds and the file goes to zero; at rung 3 the same call fails EACCES and the file survives. --landlock=abi:N pins a session to rung N so each rung’s absence can be measured on a modern kernel — for the rungs that move the mask, which is not all of them; see the rung-4/7/8 bullet below — and warns at startup. The cap may still be set below the floor, and that is deliberate: it is the instrument every per-rung measurement on this page is taken with, and a kernel that reported the same rung would be refused. A session pinned there warns in as many words that no confinement claim this build publishes applies to its realms. --landlock=off builds no ruleset at all and journals namespaces-only.

  • The cap is a dial, not a one-way weakening, and rung 1 is stricter about reparenting. --landlock=abi:N reproduces exactly what this build asks for on an ABI-N kernel, which includes reproducing that kernel’s strictness — but read that sentence with the exception two bullets down, because it is not the same as reproducing an ABI-N kernel. A Landlock domain denies reparenting — rename(2) and link(2) across directories — whenever its ruleset does not handle REFER, and no ruleset below ABI 2 can. So a realm capped at rung 1 cannot move a file between two directories inside its own writable storage, and every rung above it can. Measured on this repo’s box (kernel 7.1.8-arch1-3, Landlock ABI 9, 2026-08-14) with the realm’s whole writable set granted on one hierarchy: rung 1 answers EXDEV, rungs 2–9 succeed, and a same-directory rename succeeds at every rung. Two of those rungs are re-taken on every run and the rest are not, which matters because the next bullet says no test in this repository enters a domain at rung 4 or rung 5. rung_one_forbids_reparenting_that_the_rung_above_permits enters a Landlock domain at rung 1 and at rung 2 and at no other — that is declared in BEHAVIOURAL_RUNGS and asserted by the test itself against the rungs it actually entered — so rungs 3–9 above, and the same-directory result anywhere but rung 1, are a hand run on that one date: checked by a reader, and by nothing in CI. Practically, --landlock=abi:1 breaks every app that writes by rename-into-place (GTK, Firefox). Do not read the ladder as “higher is always tighter”; read it as “rung N is ABI N”.

  • Some of the rungs below the floor are exercised, on purpose: those tests hold the dial honest, not the floor. The rest are exercised by nothing, and this page says which is which rather than leaving it to be inferred. Three behavioural measurements in crates/vitrin-realm-init/src/main.rs enforce a domain at rung 1, 2 or 3 — rung_one_forbids_reparenting_that_the_rung_above_permits, the_truncate_rung_is_measured_and_its_absence_is_measured_with_it and a_realm_can_write_where_it_was_granted_and_nowhere_else. Every one of those rungs is below build.landlock_min_abi, so a kernel reporting one is refused at startup rather than confined weakly, and no shipped session has ever run at any of them. They are kept deliberately — decision D-044 (2026-08-19), taken as a dated decision precisely because this task’s previous narrowing was settled by attrition and reached these pages as a deferral nobody could date. The reasons are two, and neither is “deleting tests feels wrong”: they are the only evidence that any part of the ABI matrix’s lower half is not fiction, that page being derived from this build’s own source and observing no kernel answering anything; and the REFER result in the bullet above — rung 1 being stricter than rung 2 — cannot be read off the mask column at all, which is why two tests asserting the opposite invariant were replaced when it was measured. What they are not evidence about is said in the same breath: not the floor, not any confinement claim this build publishes, and not any state an operator running a stock build can reach. Nor are they evidence about a kernel: which kernels report which ABI is the kernel page and its checked-in boot rows, measured elsewhere and by other means.

    And the sub-floor rungs the three do not reach are exercised by nothing at all. Counted from the same list the generator checks:

    below the floor of 6, rungs 1, 2 and 3 are exercised and rungs 4 and 5 are not.

    So the sub-floor half of the ladder is exercised in part, not throughout. No test in this repository enters a Landlock domain at rung 4 or rung 5, so every cell on those two rows of the matrix is derived from this build’s own source and measured against nothing. (The 2026-08-14 hand run in the REFER bullet above did pass through those rungs once and left nothing behind that re-takes it; a measurement nobody can re-run is not coverage.) That is a decision rather than an oversight: D-044 was offered the option of adding the missing rungs and did not take it, rung 4 buying handled_access_net which this build leaves zero. The tally above is held, not remembered — cargo xtask isolation-matrix computes it from the corpus, prints it on the matrix page, and refuses to emit at all unless this page carries the same sentence. So is the rung each named test belongs to: the generator resolves every name against BEHAVIOURAL_RUNGS in crates/vitrin-realm-init/src/main.rs, which declares the rungs that test enters a domain at, so a test cannot be published on a rung it never enters and a rung it does enter cannot be left off.

    The absolute in bold above is a different kind of claim from the tally, and it is worth saying what holds it — because until 2026-08-23 nothing did. A tally is about tests that exist; “no test in this repository” is about tests nobody has written yet, and the three cross-checks in the paragraph above — declared name to a real fn, declared rung to a published row, row back to declared rung — are all comparisons against a declaration. A test that entered a domain and declared nothing appeared in none of them, so adding one at rung 4 would have left this sentence false on a fully green build. There are exactly two ways into a Landlock domain in this tree and each now has its own mechanism:

    • A test that issues the syscall itselfvitrin-realm-init’s forked measurement bodies, the only code in this workspace that calls landlock_restrict_self. Held by the type system: that function demands a token whose only mint in a test build is a ledger, and the token carries the ruleset, not a rung number beside it — so what the ledger records is the rung the kernel created that ruleset at, and the ruleset it recorded is the only descriptor the syscall can be given. The rung is compared against BEHAVIOURAL_RUNGS inside the mint, before the token exists, and the ledger refuses to open at all for a test name that table does not declare. Declaring is therefore not something a test can forget, and the production path (landlock::apply_with) has its own mint compiled out of test builds so it is not a way round.

      Two shapes of that token were found broken by compiling the counterexample rather than by reading the code, both on 2026-08-23:

      • It carried a rung number. A test could declare rung 1, build its ruleset at rung 4, enter a rung-4 domain and leave every mechanism here green. Closed by welding the two: entering(4) no longer type-checks, and a ruleset the ledger never saw cannot be named to the syscall at all.

      • The comparison lived only in the ledger’s destructor, and the token borrowed the ruleset without borrowing the ledger — so the ledger could be moved out from under a live token. This compiled, ran, entered a rung-4 domain and skipped the check, with every gate green:

        let entered = RungsEntered::for_test("a_realm_can_write_where_it_was_granted_and_nowhere_else");
        let ruleset = create_ruleset(4).expect("a rung-4 ruleset");
        let entering = entered.entering(&ruleset);
        std::mem::forget(entered);
        // ... enters a rung-4 domain; nothing records it
        

        Closed twice over, because one of the two fixes would have been a fix that still rested on a destructor. The mint now takes &'a self, so the ledger cannot be moved while a token minted from it is alive. Pasted from a probe compiled into the test module and then reverted, so the line numbers are the probe’s and not a line in the shipped tree:

        error[E0505]: cannot move out of `entered` because it is borrowed
            --> crates/vitrin-realm-init/src/main.rs:1995:26
             |
        1994 |         let entering = entered.entering(&ruleset);
             |                        ------- borrow of `entered` occurs here
        1995 |         std::mem::forget(entered);
             |                          ^^^^^^^ move out of `entered` occurs here
        1996 |         let _ = landlock::restrict_self(entering, 0);
             |                                         -------- borrow later used here
        

        And the comparison the sentence in bold depends on — this rung is one the row declares — moved out of the destructor into the mint, where no disposal of the ledger afterwards can skip it.

    • A test that asks the shipped helper for a rung, entering the domain in another process where no Rust type can reach it. The core’s own confinement suite is held at its single realm-spawn point, which refuses a spawn that reports one of these rungs; tests/integration/’s Python and shell files are held by a scan for abi:N naming one of them. Both lists are computed from the ladder corpus rather than typed, so a test added at rung 4 changes what the generator demands of this page instead of leaving it behind.

    What that does not cover, stated rather than implied. Drop is not guaranteed to run in Rust — mem::forget, ManuallyDrop, Box::leak, std::process::exit and a panic = "abort" profile each skip it — so nothing on this page publishes an absolute that rests on one. The half of the ledger’s comparison that is still in its destructor is the converse of the sentence in bold: a row declaring a rung the run never entered, which is a staleness check on the table and not a claim about which rungs are reachable. That half is skippable and is stated here as skippable. The scan reads literals in the suite’s files, so a rung composed at runtime from pieces would pass it. VITRIN_LANDLOCK=abi:4 set in the environment by whoever runs the suite is invisible to all of it — that is an operator pinning a session, which the bullets above describe as the instrument these measurements are taken with, and it is not a test in this repository. And the first bullet holds one function, not the kernel’s ABI: landlock_restrict_self is syscall 446, a test could issue it by hand through libc::syscall, and nothing checks that landlock::restrict_self remains its only caller — that restrict_self is the only such call today is a fact about the tree as it stands, not a mechanism. The first and the third would each make the sentence in bold silently false and neither is held; the second is an operator rather than a test, and is out of its scope by construction. The sentence in bold is about the files, and it is the files that hold it — as far as a literal can be read.

  • This build’s ladder stops at rung 9, and a newer kernel is clamped to it. ABI 10 exists in mainline and this build does not request it. A kernel reporting more than 9 gets a rung-9 ruleset, and that is journaled per realm as isolation.landlock.clamped_by_build; the constant it is measured against is printed by vitrind --print-floor as build.landlock_max_rung. Nothing here has been run on such a kernel — the clamp is asserted against a constructed ABI value, not against a machine that reports one.

  • Nine rung numbers name six different domains, and the profile string does not say so. Three rungs buy facilities this build never requests: ABI 4 is network scoping (handled_access_net, deliberately zero — the realm’s own network namespace carries that claim), and ABI 7 (audit-log control) and ABI 8 (TSYNC) are landlock_restrict_self flags, which this build passes as zero in every shipped run. (The one thing that moves the flags word is a diagnostic, VITRIN_LANDLOCK_AUDIT=1 in vitrind’s own environment, which sets ABI 7’s LOG_NEW_EXEC_ON so the kernel keeps logging a realm’s denials past the shim’s execve. It changes what the kernel writes down and nothing about what it permits, it cannot be reached from realm.toml or from a command line, and under it rungs 6 and 7 stop being byte-identical — in the log flags only.) Since none of the three moves anything the helper asks for, the enforced domain — handled_access_fs, scoped and the flags word together — is byte-identical at rungs 3 and 4, and byte-identical at rungs 6, 7 and 8. --landlock=abi:4 and --landlock=abi:7 are nevertheless accepted and journal namespaces+landlock-abi4 and namespaces+landlock-abi7: distinct strings for domains that are not distinct. Read a profile as which rung was requested and obtained, never as how much confinement, and read those five rung numbers as two rows of the ladder rather than five. Two consequences follow, and both cut against this page: capping the mask cannot simulate the absence of a facility the build never asked for, so rungs 4, 7 and 8 are prose-backed rather than measurable here; and the per-rung measurements quoted on this page are for the rungs that do move the mask (1, 2, 3, 5, 6, 9). Why neither flag is requested by a shipped session is in crates/vitrin-realm-init/src/landlock.rs: the helper is single-threaded, so its shape already carries what TSYNC would buy, and no published claim here depends on the log flags — they are pure observability, which is why the one of them that is reachable at all is reachable only as a diagnostic.

  • It does not close the render-node limit below. IOCTL_DEV (ABI 5) is one all-or-nothing bit per granted hierarchy, and an app that cannot ioctl its render node cannot render, so the ruleset grants it there. What the rung buys is denying ioctl on every other device node in the realm — the read-write render node, and everything the next bullet says about it, is unchanged.

  • One rung is requested and carries no claim of its own. ABI 6’s scoped field is defence in depth rather than the mechanism behind either published claim it touches: a realm’s abstract UNIX sockets are already isolated by its own network namespace, and its pid namespace already denies signalling outward. The ruleset asks for it anyway, because asking costs nothing and the namespaces could one day be relaxed; but no sentence on this page would become false if the kernel refused it, and the ABI matrix says so in that rung’s row rather than implying the rung is load-bearing.

  • A realm’s app can no longer mount anything, and that breaks nested sandboxes. A Landlock domain denies every mount-topology change to the process and its descendants, unconditionally — it is not an access right, so no rule grants it and widening the ruleset cannot restore it. Measured on this box (2026-08-15) with the granted rights held constant at everything on / and only the handled mask varied: with handled_access_fs = 0 the mount(NULL, "/", NULL, MS_REC|MS_SLAVE, NULL) returns 0; with EXECUTE alone handled it returns EPERM; with the full rung-9 mask handled and every one of those rights granted on / it still returns EPERM. So a realm’s app is confined by this system’s boundary and cannot build a second one inside it — the practical casualty is bubblewrap, which GTK’s glycin image loaders spawn to decode an SVG.

  • So the realm refuses nested user namespaces outright, and that is a hardening rather than a workaround. Since a domain forbids mount(2) unconditionally, a user namespace created inside a realm can build no mount and was already useless; vitrin-realm-init writes 0 to the realm’s own /proc/sys/user/max_user_namespaces (step K9b) so that the refusal arrives at unshare(CLONE_NEWUSER) instead of much later, at the first mount(2). Nothing an app could do becomes impossible. What changes is which error it receives: the conventional “this host does not allow unprivileged user namespaces” answer every sandbox library already has a branch for, rather than an opaque EPERM from deep inside its own setup. It also removes real attack surface, nested user-namespace creation being a recurring source of kernel CVEs and a realm having no legitimate use for one. Measured mock-free from inside a real realm by tests/integration/test_real_confinement.py (RealConfinementNestedUserns): the app’s forked unshare(CLONE_NEWUSER) fails ENOSPC at the shipped default and at --landlock=off — so the refusal is the realm’s ucount limit, not the ruleset — and succeeds at --isolation=off, which is the positive control that makes the two negatives mean anything.

  • Nested image sandboxes still do not work inside a realm; apps that want one now degrade instead of aborting. That distinction is the entry below, and it is a narrower claim than “fixed” in both directions.

  • The rung number is child-asserted; the denial is not. The namespace inodes, the realm’s root device and the canary set are read by the core from /proc. The Landlock rung cannot be: no /proc file names a process’s Landlock domain, so the number in the journal is one the helper reported and a substituted helper could report anything. What such a helper cannot forge is the realm’s behaviour, and that is measured — mock-free, from inside a real realm — by tests/integration/test_real_confinement.py, which opens a path the mount table leaves reachable and the ruleset does not grant (/vitrin, the directory holding the realm’s own shim and storage). Under the default it fails EACCES; under --landlock=off, same core, same mount table, same argv, it succeeds. Neither half is evidence without the other. What is still not measured that way is any particular rung’s rights inside a real realm — those are measured in vitrin-realm-init’s own suite, where a forked child can enforce a capped domain and try the syscall. Nor is the verb the same: that gate’s probe opens read-only (O_RDONLY), so what it measures mock-free is a read denial. P2.6.3’s criterion about a write to a path outside the granted set — with its positive control in the same run — is measured only by a_realm_can_write_where_it_was_granted_and_nowhere_else in vitrin-realm-init’s own suite, at rung 1, in a forked child. That is a component test and this page will not cite it as anything else. Where the write half is scheduled to be measured mock-free is #193 (P2.6.9, the ransomware gate), whose payload reports every write it attempted with the errno each got; nothing before that gate lands closes this, and giving test_real_confinement.py’s own probe a write verb was considered here and deliberately left to it rather than done in a review-fix branch.

  • There is a ladder table now, and it is a table about this build — not about kernels. P2.6.3 was accepted on 2026-08-19, on its corrected criteria and not on the ones its plan row first wrote, and this page will not round that up. The task’s own acceptance criteria (docs/plan/02-phase-2-semantic-epochs.md, P2.6.3) ask for two deliverables: the ruleset, which landed, and a per-ABI ladder table generated on each kernel in the CI matrix with CI going red when the checked-in copy is stale. What now exists is cargo xtask isolation-matrix, which emits the Landlock ABI matrix, and a --check step in .github/workflows/ci.yml that goes red when the checked-in page is stale. The per-kernel half has since been delivered by #281, and it is no longer correct to call it deferred: which kernels this build starts on is rendered from five checked-in boot rows under tests/kernel-matrix/rows/, each holding vitrind --print-isolation and --print-floor verbatim from a QEMU boot of that kernel, with cargo xtask kernel-matrix --check going red when the page and the rows disagree. It stayed a separate page rather than becoming a column on the ladder, for the reason that generator probes nothing: it parses the rung ladder out of the helper’s own source and the ABI floor out of the crate that declares it, because a page carrying the ABI of the machine that produced it could not be byte-identical on this repository’s two machines (development box: landlock.abi=9; CI runner: landlock.abi=7) and so could not be the thing CI holds. Which kernel releases clear the floor is now stated, and measured — 6.12 and 6.17 start; 5.15, 6.1 and 6.8 are refused below-floor. Three things about it are still true and still limits: every one of those rows is a kernel reading taken in a bare initramfs, so the number of distributions measured as such is still one; nobody other than the author has re-run the collector’s own failure levers, which needs QEMU on a second machine; and five kernels is five kernels, not a spectrum. What holds the build half of those rows is a gate rather than anybody’s memory: cargo xtask kernel-matrix --check reads each row’s own recorded floor.mechanism= and applies.* lines and holds them to the sets crates/vitrin-core/src/spawn/isolation.rs declares, so the page goes red the day the floor moves out from under them and names the mechanism that moved. That is worth stating because it has already failed once: P2.6.4 grew the floor by two mechanisms and every gate stayed green, because the check compared the page against the rows and both were stale together. Read its scope narrowly, though — it re-boots nothing, so a green pull request says the rows describe this build and says nothing whatever about whether these kernels still answer this way. Only tests/kernel-matrix/collect.sh --check re-takes that half; it needs QEMU, no pull request runs it, and every row carries the date it was last taken on. PRD §20’s “coverage is kernel-dependent” caveat is answered for those five and for no others. The per-rung behavioural statements quoted above (the TRUNCATE pair, the REFER pair) are held by vitrin-realm-init’s own tests, which run on this repository’s development box and on the CI runner — whose job declares VITRIN_REQUIRE_LANDLOCK_ABI=7, so a skip there is a panic and not a quiet pass — and on no third machine; the values they pin were recorded on one box on one date. Everything else about a rung is now generated and gated, which is a narrower promise than “measured”. What P2.6.3’s acceptance does and does not mean. It was accepted on the corrected criteria plus decision D-044 (the sub-floor rung tests, above), and two of the criteria written in the plan were wrong on the kernel’s own terms while a third — “one row per ABI actually reported, on each kernel in the CI matrix” — cannot be satisfied by any byte-stable checked-in page and was replaced rather than met; the plan document restates all three with the correction visible rather than deleting them. Four things did not become true on acceptance, and each is stated above in its own words: five kernels answered five ABIs and four of the nine rungs are reported by none of them; every one of those rows is a kernel reading in a bare initramfs, so the number of distributions measured is still one; the suite itself has still only ever run on two machines and nobody but the collector’s author has re-run its levers; and the per-rung behavioural statements are one box, on one date. Read an accepted task as an accepted task and this page for what is actually measured.

The six enforced domains, stated once so a generated table can be compared

crates/xtask/src/isolation_matrix.rs emits one row per distinct enforced domain and prints the statement below in that row, byte for byte. The generator refuses to emit the page at all when a statement here and the one it would print differ, so the two cannot drift and nobody has to decide whether a paraphrase still means the same thing. The domain count is derived from the parsed ladder rather than typed, which is why “nine rung numbers, six domains” above is a computed sentence rather than a remembered one.

  • T1 — rung 1. handled_access_fs=0x1fff, scoped=0x0: no REFER, so a realm capped at rung 1 cannot rename(2) across directories inside its own writable storage — the one rung that is stricter than the rung above it.
  • T2 — rung 2. handled_access_fs=0x3fff, scoped=0x0: REFER arrives, and handling it is what permits cross-directory rename inside the realm’s own storage.
  • T3 — rungs 3 and 4. handled_access_fs=0x7fff, scoped=0x0: TRUNCATE arrives at rung 3; rung 4 buys handled_access_net, which this build leaves zero, so rungs 3 and 4 are one domain.
  • T4 — rung 5. handled_access_fs=0xffff, scoped=0x0: IOCTL_DEV arrives, and it does not close the render-node limit — the app needs the node’s ioctls, so the ruleset grants them there.
  • T5 — rungs 6, 7 and 8. handled_access_fs=0xffff, scoped=0x3: rung 6 adds the scoped field; rungs 7 and 8 buy landlock_restrict_self flags rather than access-mask bits, so a mask cap cannot simulate their absence and rungs 6, 7 and 8 are one domain.
  • T6 — rung 9. handled_access_fs=0x1ffff, scoped=0x3: RESOLVE_UNIX arrives, and this is the highest rung this build requests — a kernel reporting a higher ABI is clamped here.

Every hexadecimal number above is the mask this build asks a kernel at that rung for, parsed out of crates/vitrin-realm-init/src/landlock.rs and cross-checked against the measured table pinned in that crate’s the_rung_masks_pin_a_measured_table. They are not statements that a kernel at that ABI enforces nothing else — they are statements about the request.

Inside a realm, a nested sandbox cannot be built, so an app that decodes images in one decodes them UNSANDBOXED. That is the whole of what this entry now claims, and the wording matters in both directions: nothing here says the nested sandbox works, and nothing here says an app dies for wanting one.

What this entry said until 2026-08-15, and why it no longer says it. It published that the shipped default took three of this repository’s own real-app gates red — test_real_actuation.py’s typing rung (M1.4’s actuation half, #108), test_real_gtk.py and test_real_firefox.py — with a two-column table of shipped-default failures against --landlock=off passes. That is no longer true, and a false published limit is as damaging as a missing one. The realm now refuses nested user namespaces (vitrin-realm-init’s K9b, the bullet above), so bwrap fails at unshare(CLONE_NEWUSER) rather than at its first mount(2), and glycin — which decides bwrap’s availability by matching its stderr against a fixed list of namespace-refusal strings — takes the graceful fallback it already ships. Re-measured on this repo’s box (Arch, kernel 7.1.8-arch1-3, Landlock ABI 9, 2026-08-15), each gate run at the shipped default with no flag changed and no app exempted:

gateshipped default, 2026-08-14shipped default, 2026-08-15
test_real_actuation.py — typing rung (M1.4, actuation half, #108)FAILpass
test_real_gtk.py (supporting — M1.2 render half)FAILpass
test_real_firefox.py (supporting — M1.2 render half)FAILpass

The right-hand column is not an assertion; it is what a whole bash tests/integration/run.sh on that box reported — Ran 118 tests, OK, 0 failures and 0 skips, ending on full suite: no skips, every named gate ran.

One qualification on that sentence, because a page about honesty may not cite a green suite as if it were a reproducible constant. When it was written the suite carried a flake in tests/integration/test_multi_realm.py — unrelated to Landlock, reproduced on main in a clean worktree — which took the whole run red often enough that “0 failures” was a run that happened rather than a state the suite returned to.

That flake was root-caused and fixed under #292, and the numbers are worth stating because they are the only thing that distinguishes a fix from a re-run. It was two independent races, both in the test’s observation and neither in the core: one test asserted on the runtime tree the instant the core’s socket appeared, which is a median 8.4 ms before the last of three realms is forked; the other pinned a death cause the core’s own module documentation calls nondeterministic in as many words. Measured on this box, that module run back-to-back as its own process: 19 red out of 60 before, 0 red out of 100 after. Each fix was reverted separately to confirm it was load-bearing — the first brought the failure back at 15/60, the second at 8/100 — and the second race’s member ran 100 more times green with its fix restored.

What that does not license is reading a green suite as a constant. Since the fix the whole suite has run 13 consecutive times green on this one box, each reporting Ran 118 tests, OK, 0 skips. Thirteen runs on one machine is thirteen runs on one machine: it is enough to say the known flake is gone — the pre-fix rate would have reddened roughly four of them — and it is not enough to say the suite has no others. The three-gate claim above does not rest on the whole-suite line either way: each gate was also run individually at the shipped default, and the per-gate lines below are what actually carry the column.

Each gate’s own line: test_real_gtk.py captured a 640×480 frame (196 distinct colour values in that run, 192 in a separate one the same day — the count is not a fixture and is quoted only to show a real frame arrived), test_real_actuation.py’s typing rung received héllo→世界 intact with 4324 pixels changed, and test_real_firefox.py painted #0000ff over 78% of a 1024×768 frame. The --landlock=off column that used to sit beside these has been deleted rather than carried: it was measured on 2026-08-14, it was never re-run, and with the left-hand column no longer failing it compared nothing.

The mechanism, read from a realm’s own log rather than inferred. Run bwrap itself as a realm’s app (command = "/usr/bin/bwrap", args = ["--unshare-all", "--die-with-parent", "--ro-bind", "/usr", "/usr", "--dev", "/dev", "--tmpfs", "/tmp", "--", "/usr/bin/true"]) and read <runtime>/vitrin-0/realm-0/realm.log. Both halves were measured on this box on 2026-08-15, one code change apart and nothing else:

before K9b:  bwrap: Failed to make / slave: Operation not permitted
after  K9b:  bwrap: Creating new namespace failed: nesting depth or
             /proc/sys/user/max_*_namespaces exceeded (ENOSPC)

Creating new namespace failed is on glycin’s known-string list (read out of strings /usr/lib/libglycin-2.so.0: Creating new namespace failed, No permissions to create a new namespace, bwrap: setting up uid map: Permission denied, …); Failed to make / slave is not. That single string is the whole difference between bwrap sandboxing available: true — followed by a loader that dies sandboxed, and a GTK 3.24 gtkiconhelper.c:495 g_error that turns a failed icon load into SIGABRT — and bwrap syscalls not available: STDERR contains known stringWARNING: Glycin running without sandbox.

What was NOT the cause, each measured and each worth keeping so nobody spends the day again. The enumerated read set is not missing a grant: a domain handling the whole rung-9 mask and granting every one of those rights on / — strictly more than the enumeration, and more than the realm-root grant #187 declined — failed the identical decode. gdk-pixbuf’s loader cache and the mime database are not corrupted or shadowed: inside a confined realm at the shipped default, sha256sum matches the host byte-for-byte for loaders.cache, mime.cache, image-missing.svg and the loader binary, and the loader binary executes — GTK’s “pixbuf loaders or the mime database could not be found” is a generic message and a red herring. MOZ_DISABLE_CONTENT_SANDBOX=1 does not help Firefox: it died at the same GTK abort while drawing its own chrome, before any content process was spawned.

What is still true, and is what this entry publishes. A realm’s app cannot build a sandbox inside the realm — mount(2) is denied to any process in a Landlock domain and no rule reaches it — so on a host whose image decoding is a nested-sandbox spawn, that decoding happens with no sandbox around it. glycin prints WARNING: Glycin running without sandbox. for a reason, and trading a nested sandbox away is a real loss, even though it is the loss every host without bwrap already takes. An image decoder is a large attack surface fed untrusted bytes; inside a realm it is contained by the realm’s own boundary and by nothing else.

Which hosts this bites is a property of the host’s image decoders, and one point on each side was measured. On this box gdk-pixbuf 2.44.7 carries a single in-process loader (io-wmf.so) with glycin 2.1.5 and bwrap both installed, so every other decode is a nested-sandbox spawn. In an ubuntu:24.04 container holding exactly what shim/ci/install-deps.sh installs, gdk-pixbuf 2.42.10 ships libpixbufloader-svg.so in process, carries no libglycin at all, and has no bwrap on PATH — so the nested-sandbox path is not reachable there. What was not measured is any gate on that image. Two decoder inventories were measured; no gate was run inside a container, and nothing here says these three gates pass on CI.

Nothing routes around this. No gate is skipped for it, no app is exempted from the ruleset, and the ruleset was not widened to the realm root — per the measurement above, that would not have worked either. The one thing that changed is when a nested sandbox is refused, and that change is applied to every realm at the shipped default rather than to the apps whose gates were red. VITRIN_LANDLOCK=off bash tests/integration/run.sh still exists as the no-ruleset control; that run announces itself as a control, is not the shipped default, and is evidence for no milestone.

Three things survive the namespaces, and each is published rather than left to be found:

  • The realm keeps your supplementary groups. video, render, input, docker — whatever the invoking user has. This is not an oversight: an unprivileged setgroups(0, NULL) and an unprivileged single-id gid_map require disjoint windows, so there is no moment at which the groups can be dropped. Measured, both orderings return EPERM. The mount table is therefore the only thing standing between a realm and any device those groups would open, and each spawn journals the count as supplementary_groups_retained.
  • The GPU render node is bound read-write. Never card* or controlD*, but the render node’s ioctl surface and the cross-realm GPU-memory side channels it carries are real and unaddressed. Read-only was tried and rejected: it disables the node rather than restricting it, which would silently break every accelerated app in every realm. P2.6.3’s Landlock rung 5 does not change this, for the same reason in a different mechanism: its IOCTL_DEV right is one bit per hierarchy, not a per-command filter, so the ruleset grants it on the node.
  • --isolation=off is exactly the old, unconfined path, and it exists so the confinement gates can run their positive controls. It has to be named on the command line; nothing selects it implicitly, and a session running that way says so on the panel.

Environment hygiene confines the well-behaved; it does not contain the hostile. Do not run untrusted applications, or untrusted agents, against this yet — a realm that can issue any syscall it likes is not a boundary you should stake anything on, whatever its filesystem view and whatever its Landlock ruleset denies.

And on some hosts you do not get that far: vitrind --isolation=default refuses to start unless the host lets an unprivileged user namespace actually carry its capabilities. Everything above is built out of an unprivileged CLONE_NEWUSER and a mount namespace inside it. A host can permit the unshare and still strip the capabilities that new namespace is supposed to confer — which the startup preflight now finds out by trying it, because creating the namespace and mounting inside it are two different answers on such a host. Where the probe’s mount(NULL, "/", NULL, MS_REC|MS_PRIVATE, NULL) fails, vitrind stops, before a realm is ever spawned. It does not quietly start a weaker session: silent degradation is the one outcome D-020(6) forbids, so a machine that cannot confine is told so up front, with the knobs the core actually read named in the refusal.

Stated as a requirement on the host rather than as a list of distributions that fail:

Creating an unprivileged user namespace must succeed, and a MS_REC|MS_PRIVATE remount of / inside it must succeed. vitrind --print-isolation answers both, for the machine in front of you, without spawning anything.

The evidence behind that sentence is one data point, and it is worth exactly one. On a GitHub ubuntu-latest runner — kernel 6.17.0-1020-azure, measured 2026-08-14 — kernel.apparmor_restrict_unprivileged_userns is 1, read from the runner’s own sysctl before CI changed anything, so it is the value that stock image ships. (Calling it the distribution’s default is one step further than this reaches — the runner’s /etc/os-release was never opened, and one image is not a distribution.) AppArmor permits the unshare and then confines the process to a profile denying the capabilities the new user namespace should have conferred, so the first mount answers EACCES. The matrix reads ns.all=available, mount.in_userns=restricted-by-policy(errno=13), tier=none, and no realm starts. That is one CI image, on one kernel, on one date — not a distribution survey. Nobody here has run one. Do not read this as “one distribution is broken and the rest are fine”; read it as “one mainstream default was measured and it was the unhappy answer”. Collecting the matrix across kernels is #281.

What is missing is packaging, not a fix to the refusal. The refusal is the behaviour this project wants, and its message already tells an operator where to look. What nothing published said, until this entry, was that a host may need to be granted something at all before the default isolation will run — so an operator met a stop rather than a prerequisite. Saying it, and shipping a profile that makes the grant, was #286, which is closed. Making it routine — having an installation of this project put the profile and the binaries where the profile expects them — is #293, and until that lands nothing here installs anything: a build outside /usr/lib/vitrin/ is not attached to the profile at all. --isolation=off is not that arrangement: it starts an unconfined session, and every confinement claim on this page stops applying to it.

There is an AppArmor profile in the tree, and as of 2026-08-15 it has been loaded and measured — on one kernel, on one CI image. packaging/apparmor/vitrind is the per-binary grant Ubuntu ships a mechanism for — the same shape the chrome, firefox and flatpak profiles in Ubuntu 24.04’s own apparmor package already use, chosen over telling operators to weaken a system-wide default. It was written on a machine with AppArmor compiled out (/sys/module/apparmor/parameters/enabled reads N), so for its first day here it had not been parsed, loaded, attached or observed to grant anything, and this page said exactly that. The apparmor-profile job now reports otherwise, and the numbers are below rather than a paraphrase of them.

Two different kinds of claim are in play here and this page keeps them apart, because an earlier draft did not. The profile’s behaviour is now measured — that is the table below. The profile’s form is cited: every structural choice in it is copied from a profile Ubuntu actually ships, and the file’s own header carries a provenance block naming the URL each one was fetched from and the date. An earlier draft named bubblewrap in that list from memory; the bwrap-userns-restrict profile is not in 24.04’s apparmor package at all, and the claim is gone rather than softened. If you are checking this page against reality, check the header’s URLs — that is what they are there for.

The instrument is the apparmor-profile job in .github/workflows/ci.yml. It runs on a ubuntu-latest runner it does not modify — the only job in that workflow that never touches kernel.apparmor_restrict_unprivileged_userns — and it fails rather than skips if that knob is not 1 when it starts, so it cannot quietly measure nothing. It re-reads the knob after setup and fails if it moved, because installing the apparmor package would load the distro’s own profiles and grant what this profile is meant to grant (parser_present=stock: the parser is already on the image, so no install happens). It installs the profile, loads it, spawns a real realm, runs the real-app confinement gate, then removes the profile and requires the spawn to fail again.

What it reported on kernel 6.17.0-1022-azure with apparmor_restrict_unprivileged_userns=1 and no sysctl touched:

baselinewith the profile
apparmor.labelunconfinedvitrind (unconfined)
mount.in_usernsrestricted-by-policy(errno=13)available
tiernoneper-uid
realm spawnrefused-as-expectedok

with realapp=pass over 8 executed confinement assertions, and the lever in the same run: lever_without=refused, lever_restored=ok. That lever is what distinguishes this profile working from Ubuntu’s own fallback unprivileged_userns profile, which carries audit deny capability, beside allow userns, and therefore fails with the identical EACCES=13 signature — a job that only asked “did it spawn?” could not tell a wrong profile from no profile.

Read the boundary as narrowly as it is written: one kernel, one image, one distribution. Nobody has loaded this profile on an installed Ubuntu system, and this repository has never measured a second AppArmor-carrying distribution.

One question decided whether the profile was worth anything, and the job was built around it. vitrind does not create the user namespace itself — it execves vitrin-realm-init, which does. If an AppArmor grant did not survive that exec, the profile would fix the core’s startup and not the realm’s spawn, which is worse than shipping nothing because the refusal moves somewhere less legible. The profile is written to make that question moot — one attachment glob over /usr/lib/vitrin/{vitrind,vitrin-realm-init}, so the exec is same-label and performs no transition at all, rather than betting on fallback semantics that PR_SET_NO_NEW_PRIVS restricts. A realm spawning under the profile is the measurement that answers it. The shim and the app deliberately get nothing further: vitrin-realm-init writes max_user_namespaces=0 inside the realm (K9b), so a nested user namespace is refused by design.

And the profile has a security cost, which is published here rather than buried in the file — but it is conditional, and an earlier draft of this page stated the condition backwards. A profile of this shape — flags=(unconfined) carrying a userns rule — is a name any local user may try to borrow: aa-exec -p vitrind -- <anything> asks to run an arbitrary program under a profile that grants a user namespace and restricts nothing else. Installing this file adds one entry to the set of names that can be asked for. It is the same cost Ubuntu already accepted for chrome, firefox and flatpak, which is company rather than a justification.

Whether the ask succeeds depends on a second knob, kernel.apparmor_restrict_unprivileged_unconfined, and it has now been measured: 0. Recorded by the apparmor profile CI job as RESULT unconfined_knob=0 on a stock ubuntu-latest (kernel 6.17.0-1022-azure, 2026-08-15), on the same machine and in the same run that apparmor_restrict_unprivileged_userns read 1.

So the cost is real and unmitigated. At 0, aa-exec -p vitrind borrows this profile’s name and the borrower is genuinely unconfined — any local user can obtain an unprivileged user namespace by naming a profile they do not own. That is the price of installing this file, and it does not depend on vitrin being installed or running.

This page asserted that knob twice before measuring it, wrongly in both directions — first 0 for the wrong reason, then 1 on the strength of the AppArmor project’s userns-restriction wiki page describing what upstream intends /usr/lib/sysctl.d/10-apparmor.conf to contain, which is not the same as reading what Ubuntu ships. The measurement happens to agree with the first guess. It was still a guess, and the second correction was confidently wrong, which is why the job now records this knob on every run rather than leaving it to prose.

Had it read 1, the unconfined-restriction page describes change_profile — what aa-exec -p performs — as stacking rather than transitioning, so the borrow would shed nothing. That is the branch this page does not get to claim, on this runner.

So: the cost is real where an operator has set that knob to 0, and is mitigated by the stacking behaviour where Ubuntu’s shipped 1 is in force. Neither half has been measured by this project — the correction above is a citation, not an experiment — which is why the apparmor-profile CI job records the knob’s value on its runner and refuses to report a verdict without it. vitrind --print-isolation reports the same knob as policy.apparmor_restrict_unprivileged_unconfined, so you can read your own machine’s answer, and its own AppArmor label as apparmor.label — the row that tells “no profile attached” apart from “a profile attached and granted nothing”, which are otherwise the same errno.

This is not the only host requirement, and the two are easy to confuse. The entry immediately below is a second one — the kernel must actually have Landlock — which stops the same command with the same shape of message and has a completely different remedy. Check which mechanism the refusal names before following anything here: this entry is the one that says namespaces.

And there is a second host requirement, added by P2.6.3 and just as capable of stopping a session before any realm exists: the kernel must actually have Landlock. Since #187 the Landlock ruleset is part of this build’s confinement floor, not an optimisation on top of it — so a kernel that answers the ABI query with ENOSYS no longer starts a weaker session, it refuses to start at all. That is the same D-020(6) trade as above, made deliberately: the alternative is a session whose realms are confined one mechanism less than its own journal claims.

Stated as a requirement on the host, in the order an operator should check it:

  1. Kernel ≥ 5.13, which is where Landlock arrived. uname -r.
  2. CONFIG_SECURITY_LANDLOCK=y in the running kernel’s config. zgrep CONFIG_SECURITY_LANDLOCK /proc/config.gz, or the matching file under /boot.
  3. landlock present in the active LSM list — the kernel can carry the code and still not enable it. cat /sys/kernel/security/lsm; if it is absent, add landlock to the lsm= boot parameter, keeping every name already there.
  4. The reported ABI must be at or above this build’s floor, which is build.landlock_min_abi from vitrind --print-floor6 in this build. This is a build requirement rather than a kernel-configuration one, and it is the only one of the four that a correctly configured, working Landlock can still fail. The kernel page lists five measured kernels and which side of this line each falls on.

vitrind --print-isolation answers (1)–(3) for the machine in front of you, as landlock.abi=N, without spawning anything; hold that number against --print-floor’s for (4).

Requirement (4) is an owner’s decision (2026-08-15, re-tuned 2026-08-16), and its remedy is different from the other three. Nothing is misconfigured on such a machine — Landlock is present, enabled and answering — so no sysctl, LSM list or boot parameter moves the number and the refusal says so rather than handing the operator the three checks above. The remedy is a newer kernel. The refusal carries both numbers, as below-floor(abi=N,required=M). The reasoning is in the ladder bullet above; the short form is that 6 is the lowest rung at which this build’s enforced domain is unchanged, so the floor sits at the point where refusing fewer machines costs no confinement.

Two things this entry does not say. It does not say which distributions ship (3) unset — nobody here has surveyed that, and #281 owns it alongside the namespace survey. (Which kernels fall below (4) is now measured, on five of them; see the kernel page.) And --landlock=off is not the remedy for a kernel that could be configured: it starts realms with no ruleset at all, so every sentence on this page about the enumerated read set, the write set and the rung ladder stops applying to that session. It exists for a machine that genuinely cannot have Landlock, and for the control runs this page’s own measurements are taken against.

These are two requirements, not one, and their remedies must not be crossed. Both stop the same command with the same shape of message, so the first thing to read is which mechanism the refusal names: namespaces is the entry above, landlock is this one. vitrind walks its confinement floor in order and refuses on the first mechanism whose probe failed, naming that one — so the word in the message is the diagnosis, not a heading.

the refusal nameswhat the host is missingwhat fixes itwhat does nothing
namespacesan unprivileged user namespace that carries its capabilitiesthe sysctl / policy the refusal quotes (kernel.apparmor_restrict_unprivileged_userns, user.max_user_namespaces, …)adding landlock to lsm=; rebuilding the kernel
landlockLandlock: too old a kernel, CONFIG_SECURITY_LANDLOCK=n, or landlock absent from lsm=the three checks above, two of which need a rebootany userns sysctl; apparmor_restrict_unprivileged_userns=0
landlock, as below-floor(abi=N,required=M)nothing — Landlock works; the kernel is older than this build’s declared ABI floora newer kernelall of the above, including the three checks: they are already satisfied

The two conditions are independent, and the one machine measured here shows it. The namespace refusal was measured on a kernel 6.17.0-1020-azure runner — four years past the 5.13 where Landlock arrived — so that machine failed the first requirement while being nowhere near failing the second. That same runner answered landlock.abi=7, which clears requirement (4) with a rung to spare.

That runner reading is still a transient observation, and it is worth being precise about what has and has not changed. It was printed by CI’s own What confinement this runner actually grants (diagnostic, never fails) step — --print-isolation on an unmodified runner — and read out of the job log for run 31776579437, integration job, 2026-08-14. No file in this repository records that runner’s own output. It is not archived as a CI artefact, is not asserted by any test, and GitHub expires job logs, so the distribution half of it — the policy rows, the tier, the mount.in_userns refusal — survives only as long as that log does. What #281 did close, on 2026-08-16, is the kernel half: the same kernel release (6.17.0-1020-azure) is now booted under QEMU with the shipped binary and its answer is a checked-in artefact reporting landlock.abi=7. That corroborates the ABI without turning it into a fact about the runner, because the same boot reads apparmor_restrict_unprivileged_userns=0 where the runner reads 1. See the kernel page, which states that distinction as the reason it is a kernel page and not a distribution page.

For the distribution people will ask about: Ubuntu 24.04’s own GA kernel is the 6.8 series, and this is now measured rather than inferred6.8.0-139-generic, booted with the shipped binary, reports landlock.abi=4, which is below requirement (4) and is refused with below-floor(abi=4,required=6). An earlier version of this page reached the same number by arithmetic over mainline release notes and labelled it as arithmetic; it is a row now. What that row still does not settle is requirements (2) and (3) on an arbitrary 24.04 install, or anything about that distribution’s userspace — see the next entry, and the kernel page’s section on why these are kernel rows. Note also that ubuntu-latest carries an Azure kernel nine releases newer than 24.04’s own, so the runner above says nothing about a stock 24.04 in either direction.

One interaction that is easy to miss, because it spans two refusals. PR #290 shipped an AppArmor profile aimed at the namespace requirement on Ubuntu 24.04 (issue #286). That profile is measured, on the Azure kernel the runner carries — see the entry above for what it reported. This paragraph holds either way, and that was deliberate when it was written: the point is what the kernel rows add regardless. 24.04’s GA kernel is the ABI-4 row above, so on a stock 24.04 the Landlock floor refuses the session at the next gate even if the profile grants everything it is meant to grant. The two remedies are disjoint — no AppArmor policy changes the number a kernel reports for its Landlock ABI — so a working profile there would change which refusal you get, not whether you get one, and the remedy for the second is a newer kernel. Only a 24.04 running a newer HWE or cloud kernel — 6.17.0-1020-azure is one — is a machine where the profile is the only thing standing between it and a session.

One note on this repository’s own CI, because it is easy to over-read. The integration job takes the printed remedy and modifies the runner before it runs a single confinement gate. Everything after that step is evidence about a machine that was granted what it needed, never about a default install — the measurement above is read from the diagnostic step before it, and the ordering in .github/workflows/ci.yml is deliberate for exactly that reason.

And when that sandboxing does arrive, it will not close this next gap, so the gap is published before the feature rather than after it. Host-level sidecars sit outside every realm and therefore outside every realm’s confinement — the VLM parser and the egress proxy are ordinary host processes with principal identities, deliberately not realm members, because the parser’s memory-unsafety must stay irrelevant to the TCB and the proxy must hold a listener inside a realm’s network namespace without being confined by it. The consequence, stated rather than left to be discovered:

The VLM sidecar has unmediated host network access. E2.7’s headline claim is therefore “a realm with no egress grant emits zero outbound packets” — a statement about the realm — and is not a statement that realm content cannot leave the machine.

It can leave, through a sidecar the realm’s network namespace says nothing about. Constraining the sidecars themselves is a decide-by-M3 item; it is not solved by attribution metadata and must not be described as if it were. See D-020(5) in docs/plan/20-decision-log.md.

On bare metal at --isolation=off, a realm’s app can plausibly open the real keyboard and read every key you type — including into other realms, and including a passphrase. This is the sandbox gap above, pointed at the one device the whole architecture is built to mediate. logind ACLs /dev/input/event* to the user owning the active seat session; an unconfined app runs as the core’s own uid with the core’s full filesystem view, so nothing stops it from opening those nodes directly. On this project’s own target machine the maintainer is additionally a member of the input group, which grants that access independently of any seat — so this is concrete rather than theoretical.

At --isolation=default this is closed, and by exactly one mechanism. The realm’s /dev is built from scratch and contains six nodes — null, zero, full, random, urandom, tty — plus render nodes. /dev/input is not among them, and the realm cannot mount, so it cannot put it there. Note what is not doing the work: the input group membership survives into the realm (see the supplementary-groups limit above), so the app still holds the credential that would open those nodes. It is the mount namespace alone that denies it the path. That is a single point of failure, stated as one.

What that bypasses is not a feature but the premise: vitrind’s input router, the origin tag that distinguishes a human from an agent, the per-realm routing, the consent grab that makes a prompt unspoofable, and the lock screen are all downstream of a device the app reached around. An app doing this is not observed by the journal, is not refused preempted, and does not appear in any capture.

This entry was published ahead of the code and has now been overtaken twice, in opposite directions. Both corrections are recorded rather than quietly edited, because the pair is the honest history of the hole.

First it got worse: the sentence “it is not reachable today — there is no DRM/KMS backend” was true when written and stopped being true when WS-E.3.2 landed the bare-metal backend, which has since run on real hardware many times. Under --nested the host compositor is still the only reader of those devices, so the exposure was always bare metal only.

Then it got better: P2.6.2’s mount namespace closes it at --isolation=default, as described above — and the same task gave spawn/isolation.rs its first real enforcement, so the module that used to probe this and enforce nothing now refuses a session below the floor. What remains open is --isolation=off, where every word of the original paragraph still holds, and the single-mechanism caveat: the credential survives, only the path is gone.

Testing gaps

The 24-hour fuzz soak has never been run (#156). fuzz/ ships two cargo-fuzz targets with a checked-in corpus that CI replays on every PR, plus a short per-PR burst. The 24-hour clean run the plan asks for is a documented manual procedure, not a scheduled job, and nobody has executed it end to end.

wlcs conformance is advisory and mostly red (#157). The 2026-07-25 run, against wlcs 1.6.1-1: total=180 passed=3 failed=145 skipped=32. The version is part of the number and not a footnote — the same shim scores 8/49 against wlcs 1.7.0 with no shim change in between.

That number needs its context, and the context is not an excuse. wlcs tests a general-purpose desktop compositor. The shim deliberately serves a narrow surface — no touch, no full xdg-shell policy, no decoration protocols — so most failures are “no such global” rather than misbehaviour, and the excluded touch tests are excluded for a structural absence rather than an expected failure. shim/wlcs/README.md separates the two categories honestly. But it is still the real number, it has not been re-measured since that date, and a partial run’s failed= count is a floor rather than a tally. It never gates a PR and is never built by default.

dmabuf zero-copy is proven by an env-gated test, not by CI. The path is implemented and wired on the nested backend. The zero-memcpy assertion needs a real GPU (EGL + a DRM render node) and runs only under VITRIN_GPU_TESTS=1 cargo test -p vitrin-core --features gpu-tests -- --ignored dmabuf. CI is GPU-free and exercises the shm path exclusively.

Four #[test] functions in this repository run in no CI job at all, and here they are by name. Issue #288 made this a checked number rather than a sentence: cargo xtask skip-scan parses every #[test] in the tree, works out which CI step compiles and selects it, and fails until each one that no step runs is listed in UNRUN_TESTS (crates/xtask/src/test_census.rs) with a reason and either a pointer to this page or the name of a test that executes it as a child process. The sentence you have just read is generated from that table and matched against this page, so adding a fifth gap is a red build until somebody writes both the fifth bullet and the word “Five” above it — the first version of this paragraph checked the names and left the number to prose, which is the exact shape of overclaim this page exists to refuse. Twenty-four tests were selected by no CI run when that check first ran, and the arithmetic is meant to be checkable: nineteen were wired into a job instead — which is what the check pushes toward — four are the bullets below, and the twenty-fourth is the two-process case described after them, which does execute. (Those historical numbers are prose; nothing re-derives them.)

  • dmabuf::gpu_tests::real_gpu_dmabuf_frames_are_zero_copy_end_to_end — needs a real GPU whose renderer imports XRGB8888+LINEAR dmabufs.
  • dmabuf::gpu_tests::real_gpu_probe_accepts_dmabuf_and_kills_memfd_lie — needs an EGL device and a DRM render node; a GitHub runner has neither.
  • dmabuf::gpu_tests::real_gpu_oversized_dmabuf_center_crops_the_full_view — the same GPU, plus the same per-driver import reality (plan risk R3).
  • screenshot::tests::measure_encode_cost_at_a_real_panel_size — not a hardware gap at all: it is a measurement, timing a 2560×1600 PNG encode and printing the number. There is no assertion in it for CI to fail, and a shared runner’s timing would not be a number anybody could act on. It is listed here so the count stays honest, not because a machine is missing.

What this list does not claim is that everything absent from it is well-tested. It measures whether a test runs, never whether it asserts anything — and it covers Rust #[test] functions only. The C shim’s Meson suites, the Python integration ladder and the SDK’s pytest suite each carry their own collection floors (tests/integration/run.sh, sdk/python/tests/conftest.py), and those are separate machinery with separate bounds.

One further test — spawn::isolation::tests::probe_under_ignored_sigchld — is selected by no CI run either, and is deliberately not on the list above, because it does execute: it is the child half of a two-process test, re-executed by name under an ignored SIGCHLD by a_launcher_that_ignores_sigchld_still_measures, which CI does run. The check holds that claim to the source, so a rename that broke the chain would be red rather than quiet.

The DRM/KMS backend will never have a green gate behind it, and that is the weakest evidence in this repository. Every other claim on this page closes on a named, mock-free test. This one cannot, and the reasons are structural rather than budgetary. Eight of them, named rather than summarised:

  • No DRM device in CI. A GitHub runner has no display controller. Nothing there can set a mode, commit a frame or receive a page flip.

  • No seat in CI. No logind session, no seatd, nothing for libseat to open a card through. The backend cannot even reach the point of failing usefully.

  • A compile check, and its own name in CI says COMPILE ONLY. This bullet has now been wrong in both directions and the page keeps both corrections, because a limits page that quietly acquires the right words teaches nothing about how it got the wrong ones. It first said, in the present tense, that a CI rung runs cargo clippy … --features drm-backend when neither the rung nor the feature existed. It was then corrected to “no such rung exists and no such feature exists — the backend itself is unwritten (#218)” — and #218 landed, so that correction is now the stale half. What is true today: .github/workflows/ci.yml carries a job named drm-compile-check (COMPILE ONLY - no display controller is touched), which installs the graphics dev stack, runs cargo clippy -p vitrin-core --all-targets --features drm-backend -- -D warnings, asserts that smithay’s soft-failing gbm probe actually ran, and runs the backend’s device-free unit tests. It proves the code type-checks against the smithay API and nothing whatsoever about behaviour. It sets no mode, commits no frame and delivers no key. A green tick in a repository whose readers are trained to trust green ticks is exactly how a compile check gets cited as a functional one, which is why the job’s own name shouts the qualifier and why this page quotes the name rather than paraphrasing it.

  • vkms-advisory does not close this, and must never be read as if it did. There is an advisory job that attempts sudo modprobe vkms and, when the module is available, reports what it found. What the job actually does is narrower than the device’s capabilities, and the distinction matters: it opens the node, reads mode-setting resources, and probes GBM/EGL/GLES up to locking a front buffer. It deliberately never calls drmSetMaster, never sets a mode and never flips a page — so it says nothing about mode setting, atomic commit or the page-flip loop, whatever a vkms device is capable of in principle. Whether it exercises the GBM + GLES scanout path at all is unmeasured — vkms exposes no render node, so the GLES half would need a software renderer and may not import into a vkms scanout buffer. The job measures and publishes that answer on each run; until it has run, this sentence is the honest state of it. It is advisory, it never gates a PR, and it is never to be named without the word advisory.

  • One machine, one GPU, one panel, one kernel. The evidence that this backend works is one person executing docs/drm-bringup.md on one laptop: a single Intel-driven eDP-1 at 2560x1600, scale 1, on one Arch kernel and one mesa version. It says nothing about any other GPU, panel, kernel or mesa. The PRD names “hardware matrix” as the first item of the support treadmill that consumed prior alternative display servers; this closes none of it and must not read as if it does.

    Say the second GPU precisely, because the loose word is the misleading one. That laptop has a second DRM device — /dev/dri/card2, nvidia, with nvidia_drm loaded and all four of its connectors disconnected, which is how docs/drm-bringup.md’s hazard H1 records it. What this project has exercised is one device node at a time, whichever one the seat’s primary GPU resolves to: the backend resolves its card through udev::primary_gpu(&seat_name) and opens exactly that one — and that selection has now been observed twice with two different answers, card1 on 2026-08-09 and /dev/dri/card2 on the step-13a run of 2026-08-13, from the same machine. An earlier version of this paragraph said “nothing here has ever opened card2”; that is retracted, and it was already false when this page last said it, which is the more useful half of the correction. What opening card2 established is narrow: that session lit a 2560x1600 output and carried six touchpad rungs through it, so the node the selection chose worked. What it did not establish is which device sat behind the node — the record names the node and not the driver, so whether the selection took the NVIDIA GPU or took the iGPU under a renumbered node is not recorded either way, and nothing here is evidence that this backend drives an NVIDIA card. Hard-code neither node. Nothing about a multi-GPU path changed either: there is no PRIME path, no multi-GPU renderer and no buffer import between devices anywhere in this repository, so no result from one node generalises to two. “Untested” would imply a multi-device path exists that nobody exercised; none exists. No issue tracks a hardware matrix, and none should: a matrix is a support treadmill the PRD names as the thing that consumed prior alternative display servers, not a defect.

  • The trusted band has an automated witness, and it covers one backend — not the one you would daily drive. backend/band_witness.rs measures the negative half of the band’s unspoofability property: that a confined app’s own rendering can never reach the band’s rows on the human-visible frame, in numbers a harness can hold without ever holding the session secret. It is wired into backend/headless.rs and into nothing else. Grep the DRM backend for band_witness and there are no hits, because a witness needs a framebuffer a test process can read and a bare-metal session’s is a scanout buffer behind DRM master. So the property the whole trust story rests on is machine-checked on the backend CI runs and asserted, not checked, on the backend a human looks at. Nothing was weakened to make that true and nothing restores it; the alternative would be a witness on a backend no runner can reach, which is not a check. #173 tracks the human half nobody has evidence for; the DRM half has no issue, because there is nothing a CI change could do about it.

  • The bring-up runbook has been executed twice in full, both on 2026-08-09, and it carries a dated record block for each. Neither was a clean pass: three defects came out of the first, one of which was that the page’s own first line of recovery did not exist. A third, partial execution followed on 2026-08-13: step 13a, the touchpad-class rung, which carries its own “Record block — EXECUTED 2026-08-13” and whose six sub-rungs came back five PASS and one defect — #275, a gesture interrupted by a VT switch that ends completed where it must say cancelled. Read that as one rung, not a third pass of the runbook: steps 12a, 16 and 17 are still marked NOT YET RUN on that page, and #220’s frame-cadence field was never captured in fps. A runbook nobody has executed is a plan, and the wlcs number above is this repository’s standing example of how a manual result ages once it is taken.

  • The session-lifecycle checklist has been executed twice, on 2026-08-11 and 2026-08-13 — plus a 2026-08-12 re-read of one rung — and neither full run was a clean pass. Blanking, suspend, lid handling, deliberate-wedge recovery and returning from another VT are rungs L1L7 in Getting out of a wedged session, where the dated records now live. What they establish, at the counts the rungs themselves ask for: L1 10 of 10 VT switches with a stable band colour; L2 5 of 5 suspend/resume cycles, four on 2026-08-11 and the fifth on 2026-08-13, each returning a working panel — with liveness proven on the second run only, because the first had no keymap passed and therefore no way to tell an idle app from a frozen one; L3 5 of 5 lid cycles, two on 2026-08-11 of which only one ever reached sleep, and three more on 2026-08-13 that all did, each with the same typed-after-resume liveness proof — plus a fourth close reopened inside one second that correctly never suspended at all, which is the short-lid-close case a single sample could never have established; L4 blank at 61.2 s with the panel returning on physical input, and L5 no lock card. L6’s answer was lost once and is now recovered: the 2026-08-11 wedge came back in ~69 s by a route that could not be reconstructed afterwards — not from the journal, not from either flight recorder, not from the process tree — and 2026-08-13 settled it, kill -CONT against a 163.8 s SIGSTOP wedge, recovering in the next logged millisecond, with route 1’s chord found to have been queued rather than defeated. L7 is now a measurement rather than an impression: 61.214 s lit, counted from the seat’s return against a 60 s timeout on 2026-08-13. The rungs filed four defects (#257–#260), one of them that the recovery page’s own published command was wrong — and a fifth, #268, came out of the same 2026-08-11 session, from driving alacritty and nautilus rather than from any rung, so a reader counting defects against that date should count five. The generated session app matrix is where that fifth one is recorded; understating a defect count is the direction this page holds to be the more corrosive one. The second run filed a sixth, #277, and it is #260’s class again: kill -TERM, the command route 2 published, is inert against a SIGSTOPed core, so the recovery page has now been wrong twice about its own central instruction — which is the reason to read it sceptically rather than a reason to leave the count at five. L4 is therefore not a clean pass: #257, #258 and #259 — the panel blanking ~1.5 s after a return from another VT, a silent unblank, and neither transition reaching the flight recorder — all came out of that session, and #257’s fix has since been observed on hardware — rung L7 was written from it and run later the same day, at a 20 s timeout: the panel stayed lit on the return and the lock did not raise, so both symptoms are gone on the machine that produced them. That pass was by eye and produced no figure; the figure exists now, from the 2026-08-13 L7 run at a 60 s timeout, and it is the 61.214 s above rather than anything the 20 s pass could distinguish from 17 s. #258 and #259 have since been observed on hardware too — a second L4 execution on 2026-08-12 read the log and the recorder rather than only the panel, and found the wake line and the screen_blanked/screen_woke pair carrying outcome: flip_landed, and 2026-08-13’s L7 run re-observed that same log line and recorder pair in passing. One caveat travels with it: the failed-wake WARN has still never been emitted on hardware, since no wake has failed there. Still unexecuted, and named rather than implied: the SysRq route (route 3) and route 4, both still careful predictions; L7’s second pass, which was attempted on 2026-08-13 and caught no absence to measure; and step 12a’s immediate and idle seat policies — only never has ever run on hardware, and idle is the branch that would return you locked. The advisory VKMS rung is no longer “never attempted” and is worse than that: CI attempts it on every pull request and it currently covers nothing — read on 2026-08-13, the module loads and no card node appears behind it, so no connector enumeration, no mode set and no GBM/EGL probe run at all. Two runs on one laptop are a report about that laptop and nothing more.

This is a recorded decision with a scheduled closure in the sense that page’s last section means: the closure is a dated human run, not a job. The alternative — a green check proving compilation, read as proving function — is strictly worse, and is precisely the honesty gap this page exists to prevent.

Model gaps

The trusted indicator is unforgeable within one VT, and not necessarily noticed. There is a rigorous gate proving a client cannot counterfeit the band. There is no evidence that a human notices when it is wrong — that needs user research nobody has done. The plan explicitly adjudicated unspoofability out of M1.4’s criteria for exactly this reason. Do not cite the milestone as evidence for the human half. The second qualifier is the VT: the band says nothing about any screen other than the one this core is driving, which is spelled out with the VT-switch entry below.

Several realms run; only one is visible. A realm.toml may now declare up to 16 realms, and each gets its own shim process, its own private runtime tree, its own Wayland socket and — since the output binding landed — its own scene, its own capture and its own seat state. What it does not get is its own output: the core composites one output from one realm’s scene, so with two realms running only the realm the output is bound to is on screen. Which realm that is is now somebody’s to choose: a client holding the layout.focus grant verb moves the output, and the human’s own keyboard and pointer move with it — one act, because showing a realm and typing into it must never come apart. An agent’s actuation does not follow the output at all — it follows the realm its own grant names, so an agent works in a realm nobody is looking at. Absent such a client the output binds to the first realm to attach, and moves on one event nobody chooses: the bound realm’s app exiting, after which the output follows to the first realm still serving, and to no realm at all once none is serving. Treat a multi-realm configuration as “several apps running, one of them on screen”.

And there is exactly one output, by contract — a second connected display is refused at startup rather than half-served. Those are the session’s two cardinalities and they do not move: up to 16 realms, one output. The singularity is in the contract rather than in the content, which is why sixteen live realms do not buy a second panel: Presenter::view_size is one size for the whole session and every realm’s shim is configured with it once before the first fork, RealmScenes::bound is a single Option<RealmId> that the human’s seat target and the agent cursor’s coordinate space both resolve through, and the status strip has one caption. Coming up on two panels anyway would light whichever connector enumerated first and leave a powered display dark with no message and no verb in the protocol that could ever move the output to it, so on --drm the backend refuses to start and names the connectors it found.

Two consequences, neither closed. A laptop plus an external monitor — the most ordinary desktop arrangement there is — does not work here, and the refusal tells you to unplug one. And the refusal is a startup one: this backend enumerates connectors once and installs no udev monitor, so a panel plugged in mid-session is neither lit nor complained about, and unplugging the only panel leaves the session compositing into a surface nobody sees. That gap is deliberately unowned rather than absorbed into the seat-pause handling, because a paused session still has its panel and is told when it gets it back, while an unplugged one has no event promising a return — holding a consent card and a lock for a screen that no longer exists is a different decision with a different failure mode, and nobody has taken it. The refusal came with WS-E.3.2 (#218); the hot-plug gap has no issue, and is a numbered item in that workstream’s runbook instead.

Layout is two requests, and the absences are deliberate. A holder can focus a realm and choose whether it fills the output or keeps its own size. There is no place, no resize, no raise and no stacking — not requests that refuse, but no requests at all, because a scene showing one unstacked realm cannot honour them and a verb that silently does less than its name is worse than one with no request. Do not plan a tiling shell against this yet.

A principal cannot draw, so nothing a client builds can be on screen. vitrin_view is capture-only and there is no principal-facing surface interface anywhere in the IDL — a grant can read a realm’s pixels and can put none back. So the switcher this project ships (examples/shell/run_shell.py) is a line-oriented program in a host terminal, and that is not a placeholder for a graphical one: no amount of client work reaches the output. The intended eventual shape is the shell running as a realm, drawing through its own shim like any other app while holding the layout verbs through the ordinary grant path — which needs no new protocol, and does need that realm to reach the core socket. Since 2026-08-24 that is decided and unbuilt rather than unanswered (#311, D-046), and this is what a shell realm may reach that an app realm may not:

  • Exactly one extra thing: a principal connection to the core. The core mints it and passes it down the spawn path as an inherited file descriptor — so nothing is mounted, no path is opened, no Landlock rule is added, and the realm’s mount table stays closed. A measured walk of a realm’s whole filesystem finds exactly one socket, the shim’s own, and that stays true after this. Everything else about a shell realm — its mount table, its Landlock domain, its namespaces, its retained supplementary groups — is the confinement every realm gets. An app realm is handed one socket, its own shim’s, and nothing else.
  • What the connection buys is fenced. It may petition, and it may hold layout_focus, layout_arrange and realm_launch over other realms — the last of which is authority to create realms, the widest authority here, and it is granted rather than assumed. observe, actuate_pointer and actuate_text are refused to it regardless of what a human approves.
  • A realm with no principal connection can hold no grant at all, because a grant row keys on a principal identity and a realm has none. That is the real size of the widening: one realm becomes both a confinement subject and an authority holder.
  • The authority to hand one over is an operator’s realm.toml declaration and a human’s consent — both, not either — at the while_running rung, because the durable rungs are structurally impossible in this build (they need a verified binary identity, which is Phase 3). So the consent is once per core start, and there is no connected-apps surface to forget it from later. D-046 further asks that the card render a disabled “remember me” with its reason rather than omit it. That is a reversal of what this core does today, not a description of it: a durable rung is currently absent from the card — “not greyed out, not hidden-but-implied” — because it is not representable at all, and two tests pin that. Nothing has changed the card.
  • None of this is built. It is a decision about work not yet done; no code in this tree passes such a descriptor, and the fence stated above is an intention until a chokepoint enforces it.

Until the shell realm exists, anything you would call a desktop shell — a bar, a launcher, an OSD, a window-switcher overlay — cannot exist on this display server, and the replacements are core-owned surfaces (the trusted band, the consent card, the attention marker, the lock screen and the status strip) that no client can add to.

No client status bar is possible, and the core’s --status strip is the whole of the replacement. zwlr_layer_shell_v1 is not in the shim’s global contract, and that was measured rather than assumed: waybar connects, binds six globals, and never maps a surface; rofi and wofi are the same class. So vitrind --status draws the strip itself, in reserved rows immediately below the trusted band, and it shows three facts: the focused realm’s name, the battery, and a clock. There is no tray, no notifications, no workspace switcher, and no click targets — it is not interactive at all, because a principal cannot receive physical input (above) and the core does not want another core-owned gesture for a status bar (it already owns eight; they are enumerated under principal-has-no-hotkey below). Three further limits belong with it:

  • The strip is unspoofable in pixels but is not self-authenticating. It always wins the composite, so a confined app cannot cover it — but an app can paint a convincing fake strip one row lower. The band above it is the anchor, and the rule is “trusted content is everything above the coloured line”. That is strictly weaker than the band’s own guarantee: the band proves itself, the strip only inherits position from it. This makes the indicator story three rules where there was one, and a human who cannot state the rule cannot apply it.
  • The clock is UTC unless you say otherwise, and there is no DST. The core carries no timezone database — a tzfile parser and a recurring read of /usr/share/zoneinfo is authority the TCB is not taking for a cosmetic field — so --status-utc-offset +09:00 states a fixed offset and the strip always labels the zone it is showing. A session running across a DST boundary shows an hour that is wrong until the operator changes the flag.
  • The strip is a recurring filesystem read inside the TCB. The battery comes from /sys/class/power_supply, re-read every 30 s, bounded to one fixed root, 16 directory entries and 16–32 bytes per attribute, with every failure — no battery, a desktop, a machine mid-suspend — collapsing to an empty slot rather than a guess. When Landlock over the core’s own process lands, this becomes a rule the core must grant itself, i.e. this widens that future sandbox. And --backlight widens it further, in the direction that matters: the brightness keys described below are a write to /sys/class/backlight/*/brightness, bounded the same way (one fixed root, names sorted before the 16-entry cap is applied, and the cap bounds the auto-pick — a device you name with --backlight-device is matched against the whole directory, or the flag would silently stop working on a class with seventeen entries in it — 24 bytes per read, every failure a no-op), which makes it the first rule in that future ruleset with a write bit in it. Recorded here rather than left for whoever writes the ruleset to discover. It is not a --status fact and does not need the strip; the two are listed together because they are the two sysfs class trees the core walks, and because the second is the one that turns a read-only future ruleset into a read-write one. They are not the only sysfs paths the trusted core touches. There are four, and the other two are single files read once rather than directories walked on a timer: /sys/class/tty/tty0/active, which the bare-metal backend reads to learn which VT it is on, and /sys/module/apparmor/parameters/enabled, which the spawn path reads to decide whether an AppArmor label means anything on this kernel — the same file this page already cites in the confinement section above. Both the read and the write are owed to #314, which owns the core’s own Landlock self-sandbox. It is unbuilt and no plan document schedules it. This bullet named #187 until 2026-08-23, and that was wrong rather than merely stale: #187 built the realm’s ruleset inside vitrin-realm-init, over a filesystem view that has already pivot_rooted away from /sys, and it never owned a rule about vitrind’s own process at any point in its life.

A principal cannot receive physical input either, so no client has a hotkey. There is no observe_input verb and none is designed. The core owns eight physical gestures — the dead-man chord, the attention chord, the two clipboard chords (Ctrl-Shift-Insert and Shift-Insert), the lock chord, the screenshot chord, and, on a --drm session started with --backlight and only then, the two brightness keys — and owns them precisely because the human’s off-switch, the human’s attention gesture, a cross-realm transfer, the act of locking a screen, a picture of one’s own screen and a panel you can actually read must not depend on a client being alive and correct. (On --drm the core also consumes the twelve Ctrl-Alt-F<N> chords, which is a different thing again: they hand the seat to another console rather than doing anything inside this session, so they are not on that list and are described under VT switching below.) The count was five before D-041 and that was already one short of its own list; it is written out in full here so the next entry has to change a number as well as add a clause. A convenience hotkey is not in that class and must not borrow that warrant, so “Super+Tab switches windows” is not a missing feature: it would mean the core reserving a chord on behalf of whichever client asked first, which is window-management policy the core deliberately does not have. What follows for a user is concrete: every layout change starts as a line you type into a terminal, and the terminal has to be somewhere you can reach.

The seat serves a pointer and a keyboard: there is no touch and no tablet. wl_touch is deliberately absent from the shim’s advertised seat capabilities — shim/src/globals.c says TOUCH IS NOT YET SERVED in those words — and a tablet or stylus has neither a shim global nor a wire event. The absence is deliberate rather than a smaller version of support: a class advertised with nothing behind it is worse than an absent one, because a toolkit that sees TOUCH stops installing its pointer fallbacks and you get an application that responds to nothing at all.

Both are deferrals with named reopening evidence, not refusals, and the difference is the whole reason they are stated this way. Touch reopens on a touchscreen appearing in the measured device set together with an application that needs it; tablet reopens on a pen or stylus in that set, its application half already being on record. The measured machine has neither device, and that is a measurement of one laptop rather than a property of the protocol — a wire protocol that intends to be permanent may not foreclose a device class because one machine lacks one.

What is served is relative motion, pointer gestures and pointer constraints (#222) — and they are landed in the tree and unproven on hardware. No run has yet delivered any of them to a connected application, because CI has no touchpad and no DRM device, so what stands behind them is unit and component tests rather than a mock-free gate.

A gesture that a consent card or the lock interrupts is ended the wrong way, and that is owed rather than argued for. The router ends an in-flight gesture cancelled on a realm switch and on a seat pause, for the stated reason that a begin with no end leaves the losing app accumulating a gesture forever. A consent card or the lock screen raising mid-gesture takes a different path on purpose — the gate withholds the gesture’s updates and keeps delivering its end, because the router only ever delivers an end for a begin it delivered — but what then arrives is the device’s own end, so an app that was previewing a pinch-zoom when a card came up is told the human completed what they in fact abandoned. Nothing wedges and nothing leaks; the app’s state is simply wrong in a way the human did not choose. Owned by #222.

And on the daily-driver backend a held key does not repeat at all. The shim sets wlr_keyboard_set_repeat_info to a rate and delay of zero, so no application in a realm ever runs its own repeat timer, and there is no repeat implementation anywhere in the core — grep crates/vitrin-core/src for one and there is nothing but comments about filtering a host’s autorepeat out. Nested, that is invisible: the host compositor repeats and the core forwards each repeated event individually, so a held key behaves. On --drm there is no host, libinput synthesizes no repeat, and holding a key therefore produces exactly one character. The refusal to turn the shim’s timer back on is a real decision and a good one — repeat is seat-wide, this seat carries an agent’s actuations beside the human’s, and the repeat machinery cannot see the per-event origin tag, so a client-side timer would repeat an agent’s held key — but the compensating core-side repeat that decision assumes was never written. Read this as an unimplemented half of D-028(5), not as a design: no run has confirmed it at a prompt, because CI cannot, and the one bare-metal session that drove a terminal (2026-08-11) did not test for it. It has no issue, because it was found by reading the tree during this sweep rather than by using the session.

If the shell dies, you keep the session and lose the ability to re-aim it. The switcher is a client (PRD §5.1, D-021(4)), so there is no core-side fallback — that is the price of the invariant, paid rather than argued away. Kill it and both realms keep running, their shims and apps being children of vitrind rather than of the shell, and the realm it last focused keeps receiving your keyboard and pointer, because the output binding is core state and nothing revokes it when the principal that set it disappears. What you cannot do is move it. Recovery is running the shell again, which re-petitions from zero and raises a fresh prompt per realm — and in a real session the terminal you would restart it from must already be the bound realm. If the shell died while the output was pointed at a realm with no terminal in it, nothing on screen can start it, and every remedy is outside Vitrin: an SSH session from another machine, a VT switch, or restarting vitrind. This is a genuine wedge; it is documented, asserted (tests/integration/test_shell.py), and not solved.

set_fullscreen is a no-op whenever the realm’s view is already the output’s usable size. The two modes differ only in whether the realm’s view size tracks the output’s, so while they are equal — which is the ordinary case for a realm spawned into an output that has not resized — switching between them changes nothing you can see. It is honest and it is surprising; the IDL says so in as many words. The size they are equal to is the output’s usable view, not the output: the core reserves the top rows of every frame for the trusted band, and for the status strip when --status is on, and an app is configured for what is left. This bullet said “the output and the realm are the same size” until that inset shipped, which named a condition that no longer occurs at all.

Captures do tell realms apart, and that is enforced rather than incidental. An agent’s capture is of the realm its grant names, never of whatever is on the output: the compositor keeps one composed frame per realm and the chokepoint resolves the frame from the grant’s realm id, on the same line it judges that realm’s liveness. A grant over a realm whose app has died refuses no_surface however busy its siblings are; a grant over a live but hidden realm returns that realm’s own pixels. There was a window — between the realm cap being raised and the output being bound — where this was not true and a capture could carry a live sibling’s pixels; it is closed.

Every realm renders, whether or not you are looking at it — and whether or not any agent is connected. A hidden realm keeps receiving frame callbacks paced by the output’s composites, and keeps having its view composed. That is not generosity: a Wayland client throttles on frame callbacks, so a realm that stopped being paced would stop repainting and its capture would go stale — which the protocol forbids outright (no_surface is documented as “never a stale frame”). The second half is the one this page used to leave out: the compositor also does not ask whether anything will read a composed view. With no agent connected, no --capture-dump and no --screenshot-dir, a realm that is painting still has its view composed and cached every round. Gating that on whether a grant happens to exist would make what a capture returns depend on when the grant appeared, and that trade was declined.

What the compositor does skip is a realm whose scene has not changed since the last composite: the cached view is then already byte-for-byte what recomposing would produce, so nothing about what any reader is served depends on it. That saves the idle case and the sibling case — one realm painting no longer costs its fifteen neighbours a composite each — and saves nothing at all for a single realm whose app is busy painting. The rest of the cost is real and is not traded away: on a laptop, up to sixteen apps compositing at the output’s rate with nobody watching fifteen of them, plus roughly 2 x width x height x 4 bytes of core-side pixels per realm (~590 MiB resident at sixteen realms on a 2560x1600 panel, measured).

The agent cursor is drawn only for the visible realm. The core paints a small crosshair where an agent is pointing, so a human can see that an agent is acting. It is painted into the output, which shows one realm — so an agent actuating inside a hidden realm draws no sprite, and the human loses that signal entirely for everything happening off-screen. This reintroduces, for hidden realms, exactly the defect the sprite was added to close, and per-realm input routing makes it more likely to bite rather than less: agents can now actually work in hidden realms, so there is more going on that nothing draws. The fix is a per-realm indicator in the trusted band and it is not built.

A human’s hand no longer stops agents in other realms — and that is a narrowing of a blanket safety behaviour. The core refuses an agent’s actuation preempted while physical human input owns the target, and “the target” used to be the whole session: touching the keyboard suspended every agent everywhere for half a second, whatever realm each was working in. It is now judged per realm. Typing in realm A suspends agents acting on realm A and leaves agents acting on realm B alone, which is what “several apps running concurrently” has to mean — but if you were relying on the old breadth as a crude session-wide “hands off while I work”, you no longer have it, and no wire event tells you so. Layout requests (focus, set_fullscreen) still yield to a hand anywhere the human is: those move what you are looking at rather than being delivered into a realm, so they are judged against the realm your input is following.

You cannot switch realms in the same half-second you typed — unless you tap Super first. Because layout requests yield to a hand, any physical input marks the realm you are in as yours for 500 ms, and a focus or set_fullscreen arriving inside that window is refused preempted. That is correct for the case the rule was written for — an agent must not move the output out from under someone mid-keystroke — but it lands hardest on the most ordinary human action there is: type focus editor into a shell and press Enter, and the Enter is itself the physical input that preempts the request the Enter just sent.

The core therefore owns a second key, Super (configurable to right-Super, and to nothing else). Tapping it opens a one-second, single-use window in which a layout request from a principal holding layout authority is not refused preempted, and it sends those principals a one-bit attention event so they know to send the request they had staged. The key is consumed: no app in any realm ever sees it, which is also why it cannot be used as a keystroke-timing oracle. It delegates nothing — everything the client does afterwards it could already do; what you withdrew was a courtesy the core was extending to your own typing.

What it costs you:

  • The core eats Super, everywhere. A nested compositor, a VM viewer, or a remote-desktop client running in a realm loses that key with no pass-through and no way to ask for one. The only remedy is --attention-chord rsuper, which is not really a remedy.
  • The window is session-wide. If two clients hold layout authority, either of them may consume the press — the core cannot know which one you meant, and choosing would be window-management policy it deliberately does not have. Your own switch then silently fails and the other one lands. The claim is journaled with the principal that took it, and the grant is revocable, and it is still a hole.
  • A client can ask you to press it. A shell printing “press Super to apply” is doing the right thing; a malicious one printing the same string and banking the timing is indistinguishable from it. What bounds this is that the press confers no authority the client did not already hold — but a human who learns “press Super when the screen tells me to” has learned a habit an attacker can invoke.
  • preempted on the layout verbs is now conditional on core state you cannot see. An agent reading its own journal can no longer reconstruct why one focus landed and an identical one did not.
  • Other principals lose a guarantee nobody tells them they lost. “A human typing means nobody moves the output” was true for 500 ms at a time and is now suspendable by a gesture no wire event announces to anyone but layout holders.

While the window is open the core draws a small marker just below the trusted band — never inside it, because the band has exactly one correct appearance and that is the whole of its value. A focus change that happened with no marker up was not yours.

Switching realms mid-gesture releases what you were holding, into the realm you left. A key or pointer button you are physically holding when the output binding moves is released to the app you are leaving, because your actual release will be delivered to the realm you moved to. The app cannot tell that release from a real one — it is told you let go when you did not. The alternative is worse (a modifier latched down forever, or a wedged pointer grab, in an app you can no longer see), and it is the same trade the core already makes when the nested window loses host keyboard focus; the difference is that it now happens on every switcher keypress rather than only on alt-tab. An agent’s held keys are not touched — its grant still reaches that realm, so it can release them itself.

Resizing the nested window does not resize the apps. vitrind --nested runs inside a host compositor’s window, and that window can be dragged to any size. The core announces the view size to a realm’s shim exactly once, when the shim session starts, and never re-announces it — so an app keeps its startup size and the composite centres it, 1:1, in whatever the window has become: background bars around it when the window grows, a centre crop when it shrinks. Nothing is lost and no capture is wrong (a capture is composed at the same view size), it simply looks wrong. This is not the per-realm resize WS-E.1.3 declined; it is the one output changing size with nothing told about it. --headless has a fixed virtual output and cannot reach this, which is also why no CI gate can see it — shim/docs/nested-multi-realm.md carries it as a manual step.

No realm enumeration on the wire — but do not read that as unguessable. A client cannot ask what realms exist; realm-0 is the one name it can know without being told, and vitrin_launcher.launched hands back the ids of realms it started itself. What that does not buy is secrecy of the others: instance ids are <template>.<n> with a small session-global counter, so a client that knows or guesses a template name can guess live instance ids cheaply, and petition admission answers differently for a realm that exists than for one that does not. So the id space is a naming scheme, not a capability — the grant is what confers authority, and knowing a name gets you a petition the human still has to approve. Treat any design that leans on an id being secret as broken. Multi-realm fleet mode — a 50-realm headless box — is Phase 3 and is a different thing again.

Runtime launch exists, and it is a real reduction in what the core guarantees. realm_launch is served: a principal holding it over a realm template can make the trusted core fork a process, repeatedly, for as long as its grant lives. Until this landed, the only thing that could make vitrind fork was startup reading a file the operator had hardened. That property is gone. What replaces it is weaker than “impossible” and is stated as such: a human’s approval on a card naming the template’s program, an expiry, revocation, the grant’s rate ceiling, a cap of 16 live realms (refused capacity), and a journal entry naming the principal and grant behind every spawn.

And little bounds what a launched app then does. A launch grant is authority to start a process confined exactly as much as the session is — at --isolation=off, an unconfined one with the core’s own uid and filesystem view; at --isolation=default, one that is path-confined and filtered against a named list of thirteen syscalls, which is not the same as syscall-confined. P2.6.2, P2.6.3 and P2.6.4 each narrowed this and none closed it: the launched process still gets the kernel’s whole syscall surface minus that list, the operator’s supplementary groups and a read-write render node, so the confinement limits above apply to it unchanged, one authority level up.

A launched realm cannot be closed, by anybody, ever. This is the sharper half of the point below and it is worth stating on its own: there is no wire request that ends a realm, and nothing in the core reclaims one. Revoking the launch grant does not close what it started; nor does closing the connection that asked; nor does the dead-man switch, which revokes every grant and leaves every process running. A realm ends when its own app exits, and not otherwise. So one approved realm_launch grant, exercised 15 times before the human revokes it, commits every remaining slot of the 16-realm cap for as long as those fifteen apps keep running, and revocation will not get one back.

State the cap’s arithmetic precisely, because the loose version overstates it. The cap counts live realms, not launches: Realm::occupies_capacity excludes the terminal state and capacity_used — not len — is what a launch is refused against, so when a realm’s app exits its slot returns and the session can launch again. Sixteen simultaneously live realms is the limit, not sixteen launches per session. Both halves have to be published together or each becomes a lie: no principal and no wire request can end a realm — revocation, disconnect and the dead-man switch all leave the process running (#234) — and a slot comes back only when the realm’s own app exits. So the human’s remedies for a realm they no longer want are the app’s own quit path, killing the process from a terminal, or restarting vitrind; the display server offers none. Revocation bounds future launches and nothing else; read it that way when deciding whether to approve one.

Launched realms accumulate for the life of a session. An exited realm keeps its row so unavailable keeps meaning not ever, so a session that launches continuously grows a table of dead names it never frees. It costs no process, no descriptor and no pixels — a name and a spawn config — and it is bounded only by the grant’s rate ceiling and expiry, not by a count. A long-lived session driven by an agent launching on a timer will grow that table without limit.

A human can now move text between two realms, and that is a channel with a stated bandwidth. Copy-paste between realms exists as of WS-E.2.1: pressing Ctrl-Shift-Insert asks the realm you are looking at for its selection and puts it in a single core-held slot; pressing Shift-Insert in another realm offers that slot to that realm’s app, which you then paste into with the app’s own paste key. Two gestures, one direction each, and no client can trigger, force or observe either — the core asks, and there is no message by which an app or a shim can announce a copy.

Read the rest as a bound rather than as an absence, because that is what it is:

  • text/plain;charset=utf-8 only. No images, no rich text, no file paths.
  • 60 KiB at a time (61 440 bytes), measured against real file sizes and against the wire’s own 64 KiB frame ceiling — a larger cap is not expressible without handing the trusted core a shim-controlled memory mapping.
  • The slot is cleared after two minutes, when the realm its contents came from dies, and whenever the dead-man switch fires. Nothing tells you it was cleared; a gesture that finds an empty slot simply does nothing.
  • Two colluding realms can therefore move ~60 KiB per human gesture pair. Qubes accepts the same bound. The honest statement is the bound, never “there is no channel”; the PRD’s threat-model row was edited rather than left standing.

The trusted core now stores bytes an application authored. Nothing else in it does — it holds client pixels it never interprets and typed values it validated itself. A password copied from a manager transits vitrind and rests in that slot until one of the three clearing rules fires. The cap, the one-type allow-list, the digest-only journaling (the flight recorder records a length and a BLAKE3 digest, never content) and the three clears bound it; none removes it. This was decided deliberately, with that cost stated, and it is the first time this project has made that trade.

Two more keys are taken from every app. Ctrl-Shift-Insert and Shift-Insert are consumed by the core in every realm, with no pass-through and no way to ask for one. Shift-Insert is the historical X11 primary-paste chord, so an app that binds it loses it. --clipboard-key moves both to another key, which is not a remedy so much as a different loss.

The lock screen does not lock out agents, and this is the single most surprising thing on this page. As of WS-E.2.2 there is a lock screen, and three things raise it: Ctrl-Alt-Delete, --lock-idle SECS of no physical input, and — only if you asked for it — a VT switch away under --lock-on-seat-change immediate, which is described with the other two seat policies further down this page and which a session that never names that flag can never produce. Whichever one raised it, it covers the output with a core-drawn card and takes every physical event away from every realm until you type your passphrase. What it does not do is touch a grant. An agent holding observe keeps capturing the realm across a lock, frame for frame, exactly as if you were sitting there; one holding actuate_pointer or actuate_text keeps acting.

That is a decision, not a gap somebody forgot to close. Observation is concurrent by design in the wire protocol (vitrin_view), so preempted and consent_held never refuse a capture, and a lock takes away your input, not an agent’s authority. Three alternatives were considered and rejected: a new refusal code (a v0 wire-semantic change, which belongs to the protocol track); blanking the realm view so agents receive black frames (a lie by omission — the agent is never told why it sees black); and routing the lock through the enforcement chokepoint as a synthetic human principal (which invents a wire principal the identity layer does not have).

The instrument for “stop everything” is unchanged and still works while locked: hold the dead-man chord, which revokes every grant in the session, denies every pending petition and clears the clipboard slot. The lock card says all of this on the card itself, in the same words, because a human who locks a screen and walks away should not learn it from a documentation page.

On bare metal, that same continued observation holds across a VT switch too — with one difference that is worse and is not softened here. The subject here is the agent’s access, not the paragraph immediately above: your dead-man chord is a physical gesture, and physical input is suspended for the whole time you are on another VT, so the emergency stop that still works while locked does not work while you are switched away. From another VT your only stop is a shell and a signal. An agent holding observe keeps being served its realm’s capture while you are on another VT — the capture is composed from the realm’s scene, which a VT switch does not touch, so the request keeps succeeding — and one holding actuate_pointer or actuate_text keeps acting on the app. Keeping both is right for the reason above: the grant is the authority, not your gaze. But the pixels stop changing. While the seat holds the devices no page flip lands, so no frame_done is issued, so every app that paces on it stops painting; the agent is served the same frame it had when you switched away, with no staleness signal and no refusal. That is not what happens across a lock, where the frame clock keeps running — so read the sentence above as true of a lock and this one as true of a VT switch. The net effect is the uncomfortable one: across a VT switch an agent can still act and cannot see the consequences, and neither can you. Giving realms a software frame cadence while the seat is away would fix the observation half; it is not scheduled, and the human’s half needs a mission-control shell (E3), which is also not in this workstream.

One more thing that gets quietly wider while you are away: preempted — the refusal that stops an agent acting where your hands are — is judged against recent physical input, and physical input is suspended for the whole switch. So the moment you leave is the moment agent actuation stops being refused preempted. That is correct (you really are absent) and it means agent authority is at its widest exactly when your view of it is at its narrowest.

In nested mode the lock screen locks a window, not a session. vitrind runs as a client of your real compositor, which is above it and owns the actual session: anyone can alt-tab away from the locked window, and the host’s own screen lock is still the thing protecting the machine. Treat the nested lock as what it is — a privacy cover over the realms vitrind is showing — and not as an authentication boundary for the seat.

vitrind never inhibits VT switching, and on bare metal it has to implement Ctrl-Alt-F<n> for it to work at all. On the nested backend the chord is the host compositor’s business and outside this project’s reach. On bare metal it is vitrind’s, and there is no third option: once a process holds the display, the kernel stops handling that chord, so a display server that does not implement it is one you cannot leave.

An earlier release of this page said the opposite — that the chord was left alone on purpose, because a display server that traps you on its own VT is one you cannot leave when it wedges. The reasoning was right and the effect was its own opposite. That code was run on a real panel for the first time on 2026-08-09 and the human could not leave: Ctrl-Alt-F1 and Ctrl-Alt-F2 did nothing, and the session ended only because it was killed from another shell. The words are being changed, not quietly swapped: the decision that was written to keep the escape hatch open is what welded it shut.

So, in this release:

  • Ctrl-Alt-F1Ctrl-Alt-F12 switch virtual terminal, exactly as they do under every other Linux compositor. vitrind never switches your VT for any other reason — not on a timer, not on an agent’s request, not to bring you back. Only your own hands can move it, and no principal on the wire can, whatever it holds.
  • They work while the screen is locked. That is deliberate, and it is argued rather than assumed: being trapped is worst in the state where you cannot dismiss what is in front of you. It is never a way past the lock — the lock stays up and still wants your passphrase when you come back. What someone standing at your locked laptop gains by pressing it is a login prompt on another terminal, which they could have reached before you started vitrind or by power-cycling the machine. It is strictly less than what they can already do: the dead-man chord revokes every grant in your session and fires through the lock on purpose.
  • Twelve keys are taken from every confined app on bare metal. The chords are consumed in every realm and never delivered. Same as every other Linux compositor; stated because this project states what it takes. f1f12 are also no longer available to --dead-man-chord, --lock-chord, --clipboard-key or --screenshot-chord under --drm, and a command line that asks for one is refused at startup rather than silently rearming your off-switch every time you leave the terminal.
  • Know your own VT number before you start. The startup banner logs it. A human who can leave and cannot come back is only half rescued.
  • If a switch fails, you will see it on the panel, in a red band that names what happened and what you can still do. A log line is worth nothing to somebody who cannot leave the screen to read it. If that band ever appears, the session is trapped: record it and treat it as serious.

The first of those five bullets is confirmed on hardware; the other four are not. No test in this project can take DRM master or a seat, so whether Ctrl-Alt-F2 really puts a tty on your panel is knowable only by running the runbooks on the one machine that has the hardware — and it has been: 5 chorded switches on 2026-08-09 and 10 of 10 on 2026-08-11, with the band the same colour on every return. Every one of those chords was pressed against a healthy compositor, which is a narrower claim than it sounds like: the one deliberate wedge on record defeated the chord, because a stopped compositor cannot run the code that switches the VT. So the chord is proven as a feature and unproven as an escape, and the recovery page is where that distinction is argued out. Nothing on hardware has yet exercised the chord under a lock, the twelve consumed keys, the startup banner’s VT number, or the red band. The four vt_switch_refused already_here events on 2026-08-09 are not a sighting of that band: that path returns before raise_trapped_notice, deliberately, because chording the VT you are already on is not a failure. The band that tells you the session is trapped has never been drawn on a real panel.

The trusted band covers this screen and this process, and nothing else. The coloured strip means one thing: everything above the line on this display was drawn by the vitrind you started, not by an app running inside it. It makes no claim about any other virtual terminal. While you are away, vitrind cannot see that screen, cannot draw on it, and cannot tell you afterwards what was on it.

What is checkable when you come back is the colour. It is minted once per vitrind process and never rotated — not on a VT switch, not on resume, not for any reason — so the same colour means the same core, and a different colour means the core you left is not the core you came back to. Treat everything on screen as untrusted until you know why it restarted. Photograph the band before you switch and compare side by side on return, rather than trusting your memory of an arbitrary colour: this page already says nobody has evidence a human reliably notices a wrong band, and a memory test is not a check.

Your screen now goes dark on its own — and a dark screen is not a locked session. With --blank-idle SECS on bare metal, vitrind turns the panel off after that long with no physical input from you. The session behind it stays unlocked. Any physical input brings it back, and what comes back is your session exactly as you left it — not a passphrase prompt.

Say the consequence rather than the feature: anyone who walks up to your dark laptop and touches a key is inside your session. That is worse than what most desktops do, where the screen blanking and the screen locking are the same timer. Here they are deliberately not coupled — locking is Ctrl-Alt-Delete, or --lock-idle SECS, and it is a separate thing you have to ask for. If you want a dark screen to mean a locked screen, set --lock-idle to a value you are comfortable with; nothing in the blank will do it for you, and the two timers do not know about each other beyond sharing the answer to “when did a human last touch this?”.

Two smaller things that come with it. Idle inhibition is served now, with three bounds worth knowing before you rely on it (issue #306, D-042). An app that says “don’t blank, I’m playing a film” over zwp_idle_inhibit_manager_v1 is relayed to the core, and the core holds the blank off — but only while your output is on that realm, so a video in a realm you are not looking at stops holding anything the moment you look away; it holds off the blank and never the lock, so a film longer than your --lock-idle still gets a lock screen over it, exactly as it would with no inhibit at all; and nobody has yet watched a video on real hardware and confirmed the panel stayed lit. The core-side guard, the shim relay and the cleanup on realm death are all tested, but a blank needs a display controller and CI has none — so the claim you can rely on today is “the ask reaches the core”, not “your film will not be interrupted”. And --blank-idle is refused on --nested: a vitrind running inside your real compositor’s window would be painting a black rectangle and calling it a dark screen, which asserts something about a display it does not own.

The volume keys still reach an app that cannot act on them. The brightness keys now work, on one backend, behind a flag, on the internal panel only. The keymap fallback learned the XF86 media and brightness rows, so none of these keys is dropped at intake — but for the media half, what changed is where they stop, not what they do: a delivered XF86AudioRaiseVolume lands on the focused realm’s shim seat, and a confined application cannot open a mixer, so the human presses volume and nothing happens. State it that way rather than reporting that the media keys were fixed. Volume actuation stays deferred, with named reopening evidence: a shell client holding a verb for it, which WS-E Stage 2 sketched and did not build, or an explicit owner decision. There is no one-file sysfs equivalent for a mixer and every route to one runs through a sound server — a bus or socket client inside the TCB, which is exactly the dependency this core refuses for logind. No issue tracks the volume half.

The brightness half closed, and it closed narrowly. On --drm only, and only when the session was started with --backlight, the core consumes XF86MonBrightnessUp/Down and writes /sys/class/backlight itself, one step of 5% of that device’s max_brightness per press — rounded up, and never smaller than one raw unit, so a ceiling of 10 moves by 1 rather than by nothing (D-041, issue #303). Five things about that are limits rather than features, and all five are permanent until somebody files work against them:

  • It does nothing for an external display. The write reaches the internal panel this machine exposes under /sys/class/backlight and nothing else, so the behaviour now varies by which screen you are looking at — which is a worse thing to learn than the uniform nothing it replaces.
  • It is off unless you ask, and it is off on nested and headless entirely, where the flag is a startup error rather than a silent no-op.
  • The two keys stop reaching your applications. That is a reversal of what the previous release shipped: a nested compositor, a VM viewer or a remote-desktop client inside a realm loses both keys, with no pass-through and no way to ask for one. The core takes them because an app that both cannot act on the key and can time the human’s presses is worse than an app that never sees it.
  • Whether it works at all is a property of your machine, not of this checkout. The write is reachable through a video-group membership or a logind/udev tag this project does not own. Every failure — no device, an unreadable value, a file this uid cannot open — is the key doing nothing, said once at startup and journalled on every press, and never a startup refusal.
  • The core now has a second way to change what your panel shows, and the blank state machine knows about one of them. Blanked-but-bright and unblanked-at-an-illegible-brightness are both reachable, and nothing makes the two paths agree. The mitigation is one-sided and stated as such: this core will never write a brightness below 5% of the device’s maximum — the percentage is rounded up, so that is a floor and not an approximation of one — because a black panel is indistinguishable from a blanked one — but that bounds the accidental case and not a buggy one.

No agent can touch any of it. There is no verb, no wire message and no request: the write happens on a physically-originated key press or not at all, so an agent has nothing to ask for and nothing to be refused. It also is not the blanking mechanism--blank-idle powers the panel down through the display controller, this only dims it, and the two paths do not know about each other.

A dark screen is not evidence that nothing is watching, either — and this is the same decision as the lock screen, not a second accident. An agent holding observe keeps capturing the realm while your panel is off, exactly as it does across a lock. Read the lock-screen entry above and read this as the same sentence: a lock, and now a blank, takes away your input and your view; neither touches an agent’s authority, because the grant is what confers it and your gaze never did. The instrument for “stop everything” is unchanged and still works in the dark: hold the dead-man chord. It fires through a blank for a structural reason rather than a lucky one — the switch watches an input tap no gate can suppress, so the very press that wakes your screen is also the first press of the hold.

But it is worse than that, and the honest version is uncomfortable: a blank stops every realm’s frame clock. With the display powered off there are no vertical blanks, so nothing tells the compositor a frame landed, so no application is given permission to draw the next one. Every app in the session stops painting for as long as the screen is dark. An agent holding observe therefore does not “keep seeing” — it is served the frame from just before the blank, over and over, indefinitely, with no signal that the picture is stale and no refusal to tell it something is wrong. This is the same effect this page already describes for a VT switch, where the project’s own notes call it “worse than a stall”; the difference is that a VT switch is something you do deliberately and a blank happens on a timer. So on a --blank-idle session, an agent can still act and cannot see the consequences, on a schedule, without anybody choosing it. The fix — giving realms a software frame cadence while the display is off — is named and is not scheduled. If you are running agents unattended, this is the entry to read twice, and the shortest honest advice is that a blank timeout and unattended agent work do not currently mix.

And vitrind still cannot see a panel that went dark for any other reason. It knows about the darkness it caused itself, and that is all: your monitor’s own power button, and the backlight controls your laptop exposes outside the display server, remain beyond it. And since D-041 the core is one of the things that can dim your panel without the blank knowing--backlight writes /sys/class/backlight from a path that has no idea whether a cover is up, so blanked-but-bright and unblanked-at-a-brightness-you-cannot-read are both reachable. The core will never write below 5% of the device’s maximum (rounded up, so the published number is the floor rather than a truncation of it), which bounds the accidental case and not a buggy one. So a consent card can still in principle be raised — and recorded as shown to you — while you are looking at a screen something else turned off. What vitrind does hold back is a prompt while its own blank is up, and a prompt while the seat is taken away from it, which is what happens when you switch to another VT and is the common case by a wide margin. An earlier release of this page said vitrind “never turns your screen off and has no way to tell that something else did”. The first half stopped being true with this release and the second half never covered the case it is now narrowed to; the sentence is corrected here rather than quietly replaced, because a limits page that acquires the right words without saying how it had the wrong ones is a page you cannot check.

The blanking behaviour above was confirmed on hardware across three dated sessions — 2026-08-11, 2026-08-12 and 2026-08-13 — and the confirmation is still narrower than the section. No test in this project can take DRM master, a seat, an ACPI event or a backlight, so all of it is knowable only by a human running the recovery runbook’s checklist on the one machine that has the hardware, and one human did. What that first run settled: the panel does go dark on the timeout (61.2 s against --blank-idle 60), it does come back on ordinary physical input, and the wake leaves the session as you left it with no lock card — idle blank and idle lock are uncoupled in fact and not only in design. What it did not settle, and what the third run did: suspend ran 4 of the 5 cycles the rung asks for and lid ran 2 of 5, of which only one suspended at all, so 2026-08-11 left a single usable lid sample and no basis at all for a claim about a short lid close; 2026-08-13 took both rungs to 5 of 5, added the typed-after-resume liveness the first run had no keymap to prove, and observed the short-lid-close case directly — a close reopened inside one second correctly never reached sleep. The first run also found the defects that are the honest headline here — returning to a paused session blanks the panel in ~1.5 s (#257), the unblank is silent so success and failure look identical (#258), and blank and unblank leave no flight-recorder event (#259). All three are since fixed, and all three fixes have since been observed on this same panel — #257 by the L7 run on 2026-08-11 and again, measured rather than eyeballed, by the 2026-08-13 L7 run that timed the panel at 61.214 s lit from the seat’s return against a 60 s timeout; #258 and #259 by a second L4 execution on 2026-08-12 that read the log and the recorder instead of only the screen. Treat this section as confirmed to that depth and no further: the frame-clock halt, the agent’s indefinitely stale frame and the prompt-suppression rules were not observed on a panel and remain claims about code.

That check runs in one direction only, and you should know which. A different colour on return is a sound alarm: the core you left is not the core you came back to. A matching colour is not proof that it is. Anyone who photographed your band — the very exposure the next paragraph describes — can reproduce it exactly, so photograph-and-compare catches a restarted or substituted core and does not catch a patient one. It is worth doing because the first case is the common one, not because it closes the second.

The cost of never rotating is real and is the price of the property. A colour observed once — a camera pointed at the panel, which is newly plausible when the panel is physically in the room — is observed for the whole session, and there is no rotation path to reach for. Rotation was refused because it would destroy what it appears to protect: a human who cannot tell a legitimate change from a forgery has no check left. What still holds is that a forged card gets no input grab, so a replica cannot mint a grant; the harm is deception, not authority. And the fix for a compromised colour is ending the session — which means leaving vitrind’s VT and killing the process. The dead-man chord is not that fix: it revokes every grant and denies every petition, which is the right instrument for “stop everything” and does nothing for the trust colour, because the process and its colour keep running. Note too that the chord is a physical gesture and physical input is suspended for the whole time you are on another VT, so from there your only stop is a shell and a signal.

By default a VT switch does not raise the lock screen, and a consent prompt raised while you are away is not recorded as shown. Switching away is not treated as walking away: it costs no passphrase, because making the escape hatch expensive to come back from would erode the reason it is open. The idle timer is also stopped while you are away, and the countdown restarts when you come back — so with --lock-idle a switch away does not lock the session either, however long it lasts. The cost of that is plain: a session you switched away from eight hours ago is unlocked when you switch back to it.

That is the default and it is unchanged, but it is no longer the only behaviour available: --lock-on-seat-change immediate|idle|never picks one, on --drm only, and never is what you get if you say nothing.

PolicyWhat leaving does
immediateThe lock goes up as you leave, so coming back always costs a passphrase (or an Enter, with no --lock-passphrase-file).
idleThe idle countdown keeps running across the absence, so a long switch-away comes back to a locked screen and a short one does not. Needs --lock-idle; with no countdown there is nothing to keep running.
neverThe default, described above. The countdown freezes for the absence and restarts when you return.

Two things no policy changes. A lock already up is untouched — a VT switch is never a way past a lock screen, under any of the three. And none of them suspends an agent: a locked screen does not stop observation or actuation (above), so immediate buys you a passphrase prompt and not a pause. What vitrind will not do is put a consent prompt on a screen it does not own: a petition that arrives while you are on another VT stays pending, is never journalled as shown, and times out on the ordinary sweep, which reaches the agent as a refusal. You will experience that as the system being obstructive. It is the fail-closed answer, and the flight recorder carries the reason (petition_resolved{timed_out} with no consent_transition{shown} before it).

Without --lock-passphrase-file the lock is a privacy screen, and it says so. Enter dismisses it, with no authentication of any kind. The passphrase path exists (Argon2id, one digest per session, one journal entry per attempt including the failures) and it is refused at startup with --headless, for a reason worth stating plainly: a headless core holds no keymap and has no keyboard. Letters and digits reach a nested core only because the host compositor interprets the layout; with no host and no device there is nothing to type with at all, so a passphrase would be unenterable and a session that came up that way would be locked out rather than locked.

That sentence used to say “the core holds no keymap”, full stop, and it was about to stop being true. WS-E.3.1 (D-028) puts an xkb keymap inside the core for the bare-metal backend — behind an off-by-default build feature, so a nested or headless vitrind links no libxkbcommon at all and this paragraph still describes it exactly. Two things follow that are worth knowing before that backend exists. The keymap is a pre-compiled file an operator points the core at, never a layout name: libxkbcommon’s name resolution searches ~/.config/xkb before the system path, and a realm’s app runs as the core’s own uid, so a name-resolved keymap would be an app-writable file the trusted core parses. And the core will link 383 KB of C it does not link today, which is a real increase in the trusted computing base, stated here rather than in a changelog.

Turkish, and every other layout whose letters are not Latin-1, is where this gets sharp. The lock passphrase reads a keysym as a codepoint, and libxkbcommon reports ğ ş ı İ as legacy keysyms whose number is not their codepoint — while ö ç ü are Latin-1 and are. The core normalises both into one convention so all of them type, but the failure it is avoiding is worth naming: some of your letters working and some of them silently vanishing looks like a typo, not a bug, and it would be discovered at a lock screen.

A fourth chord is now taken from every app, and it constrains the other three. Ctrl-Alt-Delete is consumed in every realm. It also means --dead-man-chord delete is refused at startup on an otherwise default command line: the dead-man switch detects in the router’s unconditional observe tap, so a lock chord sharing its key would arm your off-switch every single time you locked your screen. --lock-chord moves it, which — as with the clipboard — is a different loss rather than a remedy.

A vitrin screenshot shows the realm, not what you saw — and it cannot show a consent prompt. As of WS-E.2.4 there is a screenshot key: with --screenshot-dir PATH, Ctrl-PrintScreen writes one PNG of the focused realm’s view into that directory. No grant is involved at any point — a human photographing their own screen is not an agent capability — and the core mints the filename itself, so nothing a client controls reaches a path component.

What the file contains is the limit, and it is a deliberate one: the realm’s view only. No trusted band, no consent card, no trusted ring, no lock screen, no status strip, no agent cursor. So the single most useful thing a screenshot does — “send me a picture of that weird dialog” — is the thing this one cannot do. The correct answer today is a phone camera, and that is worse than every other desktop offers.

The reason is the trusted band, whose colour is this session’s secret. The confined realm runs as the core’s own uid, so any file the core writes is a file any app can read: a screenshot carrying the band would hand a forger the one thing that distinguishes a genuine consent prompt from a painted replica, permanently, on the first press of the key. Two softer designs were examined and both are worse. Cropping the band’s rows out does not close it — a genuine consent card is framed in the same colour, in the middle of the output, so a crop protects the secret only while no prompt is up, which is exactly when you want the screenshot. Replacing every pixel equal to the secret hands the app an oracle: an app paints a field of candidate colours, you take a screenshot, and it reads back which of its own pixels were recoloured — a ~22-bit secret falls in a handful of screenshots at 1080p.

Four more things belong with it:

  • The screenshots are readable by every app in every realm at --isolation=off. They are files written as your uid, and the file mode is 600, which keeps them from other users and does nothing whatsoever about an unconfined app running as you. This page creates no new hole — that is D9 — but this feature creates the files.

    At --isolation=default the screenshot directory is not in the realm’s mount table at all, so no app in any realm can name it. The narrowing is real, and so is its shape: it is a path denial, not a permission one. The mode is still 600 and the app still runs as your uid, so anything that ever puts that directory back inside a realm — a binds entry in realm.toml, a future designation — hands it over in full. Do not read the confinement as having changed what the files are.

  • A fifth chord is taken from every app. Ctrl-PrintScreen is consumed in every realm. It is a chord rather than a bare PrintScreen deliberately, and that is the one cost this feature pays back: bare PrintScreen is still delivered, so an app that binds it keeps it. --screenshot-chord moves the gesture, and — as with the clipboard and the lock — that is a different loss rather than a remedy. It may not share a key with any of the other four gestures; startup refuses it if it does.

  • A wrong --screenshot-dir puts pictures of your screen somewhere you did not intend, and nothing below the core enforces the choice. The directory is audited at startup (absolute, existing, a directory, not a symlink, not group- or world-writable) and then held open for the process’s life, so no later rename or planted symlink can redirect a write. Until E2.6/E2.7 confine the core, that audit is the whole of the enforcement.

  • The screenshot key does not work through a lock or a consent prompt — each of those consumes all physical input while it is up, so the chord never reaches the screenshot hook. The lock one is deliberate rather than incidental: a person standing at your locked machine must not be able to write the session behind it to a file.

  • Pressing it costs the compositor about 4 ms, and the encode no longer happens there at all. It used to be about 70 ms: 71.7 ms in a release build to encode one 2560x1600 frame into a 12.3 MB PNG, synchronously on the event-loop thread — roughly seventeen dropped frames at 240 Hz, on every press. Since issue #240 the encode runs on a worker thread that owns the screenshot directory, and what the press pays on the compositor thread is one copy of the frame out of the capture cache: 4.2 ms for the same 2560x1600 frame, measured in the same release build (73.9 ms for the encode itself, unchanged, on the same run). That is one frame at 240 Hz rather than seventeen. The remaining cost is the copy, and it scales with pixel count the same way; the queue is bounded at two presses behind the one being encoded, and a press past that is refused and journalled (encoder_busy) rather than queued, because each job is a whole frame of memory.

    Both numbers are CPU measurements on the development machine, in a release build — not a measurement of a session driving a real panel. What a screenshot does to a bare-metal session’s frame timing is knowable only from a run of docs/drm-bringup.md, which needs hardware CI does not have.

  • It DOES work during a dead-man hold, and that is deliberate. An earlier version of this page said otherwise and was simply wrong: DeadManHook::gate consumes only its own chord’s key and delivers every other, so Ctrl+Print reaches the screenshot hook mid-hold and a file is written. The behaviour is the intended one — the off-switch destroys authority, and a human photographing their own screen is not authority — but it means the dead-man chord is not a way to stop a screenshot you have already started, and a screenshot taken during a hold captures the session as it was before the revocation landed. Since the encode moved to a worker thread this is literal: a hold does not cancel an encode already accepted, and the end of the session waits for it — but only for five seconds. Past that the core stops waiting, the process exits over the top of the worker, and that last file may be truncated. The wait is bounded on purpose and in both halves (the outcome and the thread), because a screenshot directory on a mount that has stopped answering must not be able to make SIGTERM do nothing.

Identities are static tokens. Listed in principals.toml. The IDL is shaped for SPIFFE/OIDC credentials; the machinery is not here yet.

No semantic layer. Agents work on pixels. The AccessKit/AT-SPI2 bridge, versioned and diffable semantic trees, and epoch/CAS action semantics are all Phase 2 — which is to say the token-hungry screenshot loop this project criticises is still what an agent does against it today. The difference so far is authorization, not efficiency.

No portals, because a realm is advertised no session bus — and that absence is a missing service, not a confinement. There is no xdg-desktop-portal here: nothing in the core or the shim starts one, talks to one, or advertises one. The core injects no DBUS_SESSION_BUS_ADDRESS and points XDG_RUNTIME_DIR at the realm’s own private directory, so a well-behaved application looking for a session bus finds nothing. What that costs a desktop user is concrete and larger than it sounds: no portal file chooser (you get whatever dialog the toolkit draws itself, which cannot reach a file the application could not already open), no screen sharing, no notifications, and no “open this link in a browser” — a click that would hand a URL to another application does nothing.

Read the next sentence as the whole point of this entry. This is not a security property, and it must never be cited as one — in either mode. Where a realm is confined, the confinement is the kernel’s, not the missing portal: no version of “we serve no bus” is a boundary, and the paragraph below is about what is reachable, not about what is advertised. At --isolation=off there is no sandbox at all: /run/user/<uid>/bus is still on the filesystem and still connectable by any process of this uid, and the abstract-socket namespace is shared, so a determined application connects to the host session bus with no help from anybody. In practice an operator running Firefox allow-lists DBUS_SESSION_BUS_ADDRESS in realm.toml, which turns the implicit hole into an audited one — and hands that realm the host’s bus, with whatever services the host happens to be running on it, entirely outside anything this project mediates. What a toolkit then does with a host portal from inside a realm is unmeasured; nobody has run it. What has changed since this entry was written is the default, and it changed the reachability half only: P2.6.2’s mount namespace removes /run/user/<uid>/bus as a path — the realm’s /run holds one entry, vitrin — and its network namespace removes the abstract-socket namespace the bus also listens on, because abstract sockets are scoped to a network namespace, so at --isolation=default the same allow-list line names something that is not there. That is the half Phase-2 confinement (#160, E2.6/E2.7) named, delivered by the kernel; it makes the bus unreachable and it does not make the unserved portal a confinement. Read that closure as derived from the mount table rather than measured, because that is a different claim from “the kernel did it”: no test asserts the absence of /run/user, and tests/integration/test_real_confinement.py lists “that a realm cannot reach the session bus by other means” among the things it explicitly does not prove, saying in as many words that a full escape survey is not what it does. One residual is narrower than the closure and survives it: binds names any absolute path outside / and /home, so an operator who binds the host’s runtime directory into a realm puts the bus socket back inside it at --isolation=default, under a key that says nothing about buses. Serving portals properly — a core-mediated file chooser under a grant — is the Phase-2 powerbox’s job and is a different thing again from restoring the toolkit’s. Serving portals has no issue and appears in no plan document, so read this as an absence nobody has scheduled rather than as work in a queue.

No X11 shim. Wayland only. Per-app X11 with an embedded WM is Phase 3. There is no X server anywhere in this stack — not in the core, not among the globals a shim advertises, not as a process anything here ever starts — and a realm’s app is handed no DISPLAY at all, because DISPLAY and XAUTHORITY are refused outright by the environment the core builds for it. So xterm in a realm dies before it draws anything, and the failure this project recorded is Can't open display. That is the fragment the run wrote down; the full line xterm emitted was not captured, and this page does not reconstruct it.

For anyone thinking of this as a desktop, the consequence is not a footnote. On the one machine that has been measured, xterm, feh, xsel and nvidia-settings are X11-only, as are the X11 window manager, compositor, bar, launcher and screen locker installed on it. None of them can run here. The maintainer’s interim is a second session, on another virtual terminal, for X11-only software — so “I did not have to go back to my old compositor” is false for that set of programs. That is a workaround he accepts, not something this project offers or confines: the second session is another compositor with full access to the same devices, nothing here knows it exists, and switching to it leaves the confined world entirely. It is also one person’s arrangement on one machine, and it is not advice.

What has been run, with the observable each run actually checked, is the session app matrix. Read it before assuming anything else works; it is deliberately shorter than the list of things people expect a desktop to run.

The protocol will break. v0 is frozen for Phase 1, not forever.

No accessibility of any kind

This project builds an accessibility-derived semantic tree for agents and provides none at all for humans. Somebody was going to write that sentence about this project eventually; it is better here, in our own words, than as an external finding.

Concretely, and this is the whole list rather than a sample:

  • No screen reader. Nothing here speaks, and nothing here can be spoken to.

  • No magnifier. No zoom, no lens, no focus-follows-magnifier.

  • No on-screen keyboard. There is no input-method/text-input support at all — the same absence that stops you composing text in any non-Latin script — so there is nothing for one to type through even if one existed.

  • No sticky keys, no slow keys, no bounce keys, and no repeat tuning — on the daily-driver backend, no key repeat at all (see the entry below, which publishes that as its own limit rather than as an accessibility footnote). The input router forwards what the device reports.

  • No high-contrast signal and no reduced-motion signal. A confined app has no way to ask what the human needs and no way to be told.

  • No AT-SPI2 bus is advertised to a realm — and read that word exactly, in the register the portals entry above uses, because the stronger word is the one this project must not use about itself. There is no accessibility bridge, bus or client in the core, the shim, the wire protocol or the SDK; the core injects no DBUS_SESSION_BUS_ADDRESS and points XDG_RUNTIME_DIR at the realm’s private directory, so a well-behaved toolkit looking for org.a11y.Bus finds nothing, and the shim’s own acceptance runs disable the bridge a toolkit would otherwise start — GTK_A11Y=none and NO_AT_BRIDGE=1, for the stated reason “neither exists here”.

    That is advertisement, not reachability, and it is a missing service rather than a confinement. crates/vitrin-core/src/spawn.rs says “That is advertisement, not reachability” about the session bus in exactly those words; the missing-service framing is this page’s. And org.a11y.Bus is activated on that bus: at --isolation=off /run/user/<uid>/bus is still on the filesystem, still connectable by any process of this uid, and neither DBUS_SESSION_BUS_ADDRESS nor AT_SPI_BUS_ADDRESS is in RESERVED_ENV, so either can be allow-listed in realm.toml. In practice an operator running Firefox allow-lists DBUS_SESSION_BUS_ADDRESS — which hands that realm the host’s accessibility bridge along with everything else on that bus. At --isolation=default the mount and network namespaces P2.6.2 landed close the reachability half — the realm’s /run holds one entry, vitrin, and abstract sockets are scoped to a network namespace — so the same allow-list line names a bus that is not there; that is the half #160 (E2.6/E2.7) named, delivered by the kernel, and it makes the bus unreachable rather than making the unserved bridge a confinement — with the same residual the portals entry names, since an operator who binds the host’s runtime directory in with binds puts the socket back at --isolation=default too. That closure is derived from the mount table rather than measured: no test asserts the absence of /run/user, and the test that would prove it — P2.1.10’s adversarial probe, which attempts org.a11y.Bus activation on every reachable bus from inside a realm — does not exist yet. It is scheduled for what it settles in both modes: the route is open today at --isolation=off, and at --isolation=default nothing has yet measured the closure from inside a realm. Grep the core, the shim, the wire protocol and the SDK for AT-SPI and there are no hits, and cargo xtask limits-check holds that absence; the only mentions anywhere in this repository are prose about the backdoor this project exists to close, and the gate that holds this sentence.

The semantic tree does not make Orca work, and reading it as accessibility is the misreading this section exists to prevent. The AccessKit/AT-SPI2 bridge (#175, Phase 2) is derived from accessibility technology and serves a different consumer over a different transport under a different authority: it hands an agent a versioned tree over the Vitrin wire protocol, only where a human has approved a grant that names the realm. An assistive technology on this machine is a program running as the human, expecting a D-Bus bus name it can talk to without asking anybody’s permission, for a person who is not going to answer a consent card in order to read their own screen. Nothing in the agent path becomes the human path by being pointed at a different reader. If Phase 2 ships in full, Orca still does not work here.

This is an exclusion, not a deferral, and the distinction is deliberate. “Deferred” implies a schedule and there is none. PRD §5.3 places human accessibility inside the support treadmill that the horizon phase carries — “hardware matrix, HDR, color management, fractional scaling, human accessibility, IME for every user” — and that phase opens only on the M4 gate, whose thresholds (an independent implementer’s statement of intent, two regular non-author contributors, grant funding signed, a published benchmark) are unmet, every one of them. There is no issue tracking this, and that is on purpose: an issue would imply somebody intends to close it, and nobody has said so.

The reasons, stated so this does not read as indifference: there is no assistive-technology stack in this project and building one is not weeks of work; there is no session bus inside a realm for an existing stack to attach to (see the portals entry above), and the sandbox that would make that absence meaningful is itself unbuilt; and there is one maintainer. None of those is an argument that the exclusion is acceptable. A daily driver with no screen reader excludes people, and the honest thing to publish is the exclusion, not a promise. If that reads badly, it is supposed to.

Project gaps

One maintainer. Governance is a documented BDFL. Bus factor is tracked as a first-class project risk rather than waved away; the standing mitigations are spec-first artifacts, a design-doc-per-subsystem rule, and a review norm against cleverness in the TCB.

No OIN membership yet (#159). The project files no patents and relies on defensive publication plus the Apache-2.0 §3 and MPL-2.0 §2.1(b) grants, which are in force today. Joining the Open Invention Network is decided and not yet done. None of this is a freedom-to-operate opinion.

SPDX header coverage is not machine-checked (#155). There is no reuse lint-style CI gate, so a new file without a header will not be caught automatically.

What holds this page to the others, and what it does not

This page, the README, SECURITY.md and the project site state the same gaps in four different registers, and cargo xtask limits-check fails the build when they stop agreeing. How much of that is machine-held is worth writing down, because “there is a check” and “this page is checked” are different sentences, and only the second one is what a reader is really asking.

What it holds. Every claim in its table has to appear on each surface that carries it and still be true of the code — a page that overstates a gap fails as loudly as one that hides it, and both directions have caught real drift here. Every value with a single canonical definition — the Landlock ABI floor, the advisory wlcs counts, the wlcs release they were measured against, the kernel the AppArmor run was taken on, the size of the booted-kernel set — has to appear in every place each surface renders it, not merely somewhere on the page, so a surface cannot contradict itself the way this project’s own site once did. Constants duplicated between two files under a comment promising they mirror have to still mirror. The plan documents that enumerate this project’s limits have to enumerate the same set as this page. And the tables themselves are held to a written roll of ids, so losing coverage is a red build rather than a smaller number in a log nobody reads.

What it does not hold, listed rather than left to be inferred from a green build:

  • Claims about the world. Dates, hardware, “the runbook has been executed twice”, “the suite has only ever run on two machines”. No program can check those. They are published with their date and their one machine named, and a human repeating the run is the only check there is.
  • Whether the wlcs numbers are still true of the shim. The gate holds every surface to the same four counts and the same wlcs release; nothing re-runs wlcs, because the advisory job commits no artefact to compare against. That is #157.
  • A paragraph that states a held value in a register the table does not know. The check finds every occurrence of the registers it is given; it cannot find one nobody told it about. That gap closes by adding a row, and the same is true of any claim on this page with no row at all — most of this page is argued prose, and only the named subset is machine-held.
  • A published page nobody added to the table. Coverage is per page and per claim: a page the table does not name is unheld entirely, however many of these claims it repeats. docs/ARCHITECTURE.md and the Phase-2 plan document both restate the five-kernel figure today and neither is held; a page added tomorrow inherits the same gap on the day it ships.
  • Text a reader never sees. The check reads the file’s bytes, not the page a browser draws, so a block commented out or fenced still satisfies every anchor in it. The gate would report agreement across surfaces that had stopped publishing the claim at all — which is the understating direction, the one this page cares about most.
  • That this page and the issue tracker describe the same set. They do not, by policy and on purpose: many gaps here are permanent decisions with no issue, and the README promises exactly that. What runs on every pull request is the narrower, offline direction — every issue a held claim names must be cited on one of that claim’s own surfaces, so a reader who meets a gap can find what tracks it without leaving the page. The other direction needs the GitHub API and is a scheduled advisory report (.github/workflows/honesty-tracker.yml), never a gate, because a build that goes red when somebody else opens an issue is a build people learn to delete.

The tables, their rolls and the full argument for each of these bounds are in crates/xtask/src/limits.rs, and the gate prints what it compared on every run.

Why this page exists

From the project’s own security notes: a half-believed confinement claim is worse than an honest gap. Every item above is a recorded decision with a scheduled closure, not an oversight — see the decision log.

If you find something true that belongs on this page and is not here, that is a bug worth reporting, and it will be treated as one.