# Security track execution plan: paths, protocol namespace, SMEP/SMAP

The design is settled in
[communication.md](os-development/communication.md),
[protocol-namespace.md](os-development/protocol-namespace.md),
[file-system-hierarchy.md](file-system-development/file-system-hierarchy.md),
and [smep-smap.md](os-development/smep-smap.md). This file is the build order
— one phase at a time, each phase green before the next starts. Delete or
archive this file when the last milestone lands.

**Context a fresh session should read first:** the four design docs above,
then this plan's *Settled decisions* section — those decisions came out of a
full-code grounding pass (2026-07-31) and must not be re-derived or reopened.

**Definition of green, every phase:** `zig build` clean, `zig build test`
clean, `python3 test/qemu_test.py` passes (existing scenarios plus the
phase's new ones — record the suite count in the checkbox), and the relevant
design doc's status/known-gap lines updated in the same commit. Commit per
green phase, style `area: lower-case declarative summary`, **no co-author
trailers**. On a suite failure, read
`zig-out/qemu-test/<case>-failed-serial.log` before changing anything.

**Workflow:** work in a dedicated git worktree on feature branches cut from
`main` (one branch per milestone group as marked below); when a group's
phases are all green, merge to `main` and push. The loop marks a phase `[x]`
in the same commit that lands it.

**Numbering note:** milestones use the design docs' own names (PM, H1–H3,
HS, P1–P4) — the M-number sequence is left alone (M19–M22 are reserved by
the logging/USB-lifecycle track).

## Status

**Live state — updated on `main` after every phase, so this file read from a
plain `main` checkout always tells the truth about where the work is.**

| | |
|---|---|
| Working on | **H3** — SMAP (the last phase) |
| Branch carrying it | `feat/security-group-4` (pushed to origin) |
| On `main` | everything through P4c — groups 1, 2 and 3 merged |
| Awaiting merge | H2, HS — land with the group 4 merge |
| Suite | 113 cases, all passing |
| Last updated | 2026-08-01 |

A checkbox below means the phase met its definition of green and was
committed — on the branch named above, which reaches `main` at the next
group boundary.

- [x] **Phase 0** — baseline: suite green on `main` (106/106, 2026-07-31; `zig build` + `zig build test` clean at 9a32380), plan committed
- [x] **PM** — path-migration flag-day (`/etc`→`/system/configuration`, `/var/log`→`/system/logs`, `/mnt/usb`→`/volumes/usb`; vfs carve-out for the two writable `/system` subtrees, FAT's `/var` mount split in two; suite 106/106)
- [x] **H1** — the `user-memory` module; nine stragglers converted; leaf U/S+W checks (plus physmap-coverage confirmation, so an `mmio_map`'d buffer cannot fault ring 0 — this also closes the same hazard on the IPC path; `fs_resolve`'s out-capacity bound made overflow-safe; suite 107/107)
- [x] **merge** group 1 → main, push (f3bc23c, 2026-07-31)
- [x] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel` (mechanics only, nothing converted; suite unchanged at 107)
- [x] **P2** — registry in init; `/protocol` reserved; ServiceId flag-day (11 binds, 17 lookups; `protocol.csv` grants, chain-attested identity, dead-owner rebind; the kernel's endpoint-death sweep generalized off the retired registry; suite 108/108). Three adversarial review rounds closed six defects a green suite had missed: a forged power event could shut the machine down; the ping path leaked a capability per call, first in init and then in the shared harness; supervisor attestation by name was defeated by a laundering deputy; and the kernel let any handle-holder bind signals, timers, exits and IRQs to an endpoint it did not own.
- [x] **P3** — open grants: `protocol.csv` enforcement, denial test. `onOpen`
  consults the manifest with the same chain-attested identity a bind uses, and a
  refused caller gets the *same* answer as one naming a contract nobody bound —
  `-ENOENT`, no capability, the same reply bytes, no log line, and both questions
  asked on every open so there is nothing to time. Twenty-seven `open` rows cover
  the whole live client set. One wrinkle the plan had not foreseen: the driver
  tree is three deep (device manager → PS/2 bus → keyboard/mouse) and attestation
  is one hop, so a legitimate grandchild read exactly like a laundering deputy;
  the manifest gained a third permission, `supervise`, which names an authorized
  supervising task per contract and is deliberately **open-only**, leaving P2's
  bind attestation and every refusal it makes untouched (suite 109/109)
- [x] **merge** group 2 → main, push
- [x] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input; display's one overloaded request split per-operation and its field abuse ended, scanout's bogus 64-byte maximum deleted, directory EOF re-spelled as a nameless entry, input moved onto the service harness; new `protocol-conformance` case asks every reachable provider for `describe` and requires `-ENOSYS` for an undefined verb; suite 110/110)
- [x] **P4b** — misfit protocols rebased (device-manager, power, usb-transfer; every leading operation byte folded into the header, and with it the `device_id`/`device_token` that followed it — `Header.target` now carries the device in all three. device-manager's own `enumerate`/`subscribe` became the reserved verbs and its three `{status, reserved}` reply structs the envelope's `Status`; `ChildAdded` is one struct under two numbers, a call and an event, landing exactly on the 64-byte push floor. power's kinds became one declared event each, the input protocol's shape, so init reads *what happened* from the header; usb-transfer's control data stage moved to the packet tail in both directions, which made `Status.len` the transferred length and `actual_length` redundant. The two silent-breakage sites — init's byte-offset power parse and acpi's `message[0]` dispatch — are gone, the shutdown badge gate unchanged; three more rows in the conformance table. Suite 110/110)
- [x] **P4c** — harness subscriber lift + badge-scoped per-client integers (the
  subscriber table, the reserved subscribe/unsubscribe verbs, the fan-out and the
  dead-subscriber sweep are `service.Subscribers` now; input, acpi and
  device-manager deleted three hand-rolled variants and their three different
  ideas of when a subscriber goes away, standardizing on published exit
  notifications — acpi had no sweep at all and input polled the process list on
  every subscribe. The three guessable-id namespaces are scoped to the opening
  badge: FAT node ids on every verb that names one, xHCI device tokens on open,
  control, bulk and interrupt_subscribe, display layers on configure, fill, blit,
  damage and destroy — each refusing a wrong owner with the *same* answer as an id
  nobody holds. New `badge-scope` case, two processes of one fixture, every
  refusal paired with a control; suite 111/111)
- [x] **merge** group 3 → main, push
- [x] **H2** — SMEP on every core (shared CPUID helper; CR4 bit 20 set in the per-CPU bring-up both the BSP and every AP run, asserted per core by the smp case; ring-0-executes-user-pages audit clean incl. the pre-paging window on the loader's tables; fail-open with a posture line; `-cpu max` added to the harness since QEMU's default model has neither bit; new `fault-smep` case; suite 112/112)
- [x] **HS** — SYSRET canonical-RIP guard (the syscall exit sign-extends the
  return RIP from bit 47 and returns through `iretq` when that changes it —
  four register ALU ops and a never-taken branch on the hot path, no load; the
  fallback un-pops the rip slot so `iretq` consumes the frame entry already
  built, reloads R11 from the rflags slot, and keeps the `swapgs` in the same
  place relative to the ring change. 4-level is not an assumption but a fact:
  nothing sets CR4.LA57, and the comment says what a 5-level port must change.
  Ring 3 **can** reach the hazard — `syscall` as the last two bytes of the last
  canonical page returns to `user_half_end` — so the new `sysret-canonical`
  case is a real ring-3 probe doing exactly that, and dies of a ring-3 #GP at
  `0x0000800000000000` while the kernel runs on. Its teeth are the refusal
  counter, not the outcome: measured with the guard's branch removed, TCG does
  not model Intel's ring-0 #GP and every outcome check still passed. Suite
  113/113)
- [ ] **H3** — SMAP + boot-patched `clac`; `-cpu max` in the harness; negative tests
- [ ] **merge** group 4 → main, push

---

## Settled decisions (grounding pass, 2026-07-31 — do not reopen)

These resolve every open wrinkle the code inventory surfaced. Where one
amends a design doc, the amendment lands in the same commit as the phase
that implements it.

1. **Every packet — request, reply, and event — begins with the envelope
   `Header`, exactly as the design says; the header is FOLDED, never
   stacked.** It absorbs each protocol's existing operation/id fields
   rather than sitting on top of them, so the two apparent 64-byte-limit
   offenders fit: `ChildAdded` re-lays to 60 bytes (its packed operation
   byte and `device_id` become `Header.operation`/`.target`);
   `InterruptReport` puts `device_token` in `Header.target` and trims
   inline data 48 → 40 bytes (largest real report today is 8). A
   headerless-events variant was considered and REJECTED (2026-07-31): it
   re-invents per-protocol mini-headers and breaks uniform tooling. No
   design-doc amendment; `Define`'s event check stays ≤ 64 *including*
   the header.
2. **Bind/open authorization is chain-attested identity: the
   kernel-stamped binary name PLUS the supervision chain**, both read from
   the kernel's process records (`ProcessDescriptor` carries `name` and
   `supervisor`; init walks the chain with `process_enumerate` — no new
   protocol). A grant row names the binary *and* the supervisor expected
   in its chain, so a malicious process re-spawning a granted binary
   (ungated `spawn`, hostile argv — the confused deputy) is refused: its
   chain roots at the attacker, not at init or device-manager. Name alone
   is NOT sufficient — that was considered and rejected (2026-07-31).
   Pure delegation (device-manager forwarding driver binds as
   capabilities — "option B") is deliberately deferred to P5, whose
   spawner-wired namespaces subsume it. Amends protocol-namespace.md's
   "Authorization" bullet in P2.
3. **Grants live in a new manifest, `/system/configuration/protocol.csv`**
   (rows: `binary-path, supervisor, bind|open, protocol-name`, where
   `supervisor` is the binary expected in the caller's supervision chain —
   `init` for init's own children, `kernel` for harness-spawned fixtures),
   not in extra init.csv columns — today every post-path init.csv field is
   argv, and overloading that is ambiguous. init parses both files.
   *(P2 spelling: the supervisor column carries the binary exactly as the
   kernel stamped it, so init's own children say `/system/services/init` and
   the drivers say `/system/services/device-manager`; `kernel` stays a bare
   word because a kernel task has no binary. A trailing `*` on any field
   matches a subtree, which is how decision 4's `/test/` rule is expressed.)*
   *(Clarification, 2026-08-01: the supervisor column names **the authorized
   supervising task, matched by identity** — the binary is how the row spells
   it, but init checks the task id. `kernel` is satisfied only by supervisor
   id 0 (which only the kernel confers — user `system_spawn` always stamps the
   caller); init's own path only by this init's task id; any other path only by
   a task init spawned itself or one the kernel spawned. Matching the supervisor
   by *name* alone is defeated by a laundering deputy — an attacker runs its own
   instance of `/system/services/init`, has that spawn `/system/services/input`,
   and both stamped names satisfy the row while the chain is entirely the
   attacker's. Walking to the root of the chain does not fix it either, since
   the laundered chain still roots at the real PID 1.)*
   *(P3 amendment: a third permission, `supervise`, joins `bind|open`. One-hop
   attestation cannot express the one three-deep chain in the tree — the device
   manager starts the PS/2 bus, and the bus starts the keyboard and mouse
   drivers — and nothing structural tells that chain apart from the laundering
   deputy, since both are a granted binary spawned by a granted binary. Only
   policy can: a `supervise` row names the authorized supervising task the way
   every other row names a claimant (binary, its own supervisor, the contract it
   concerns), and an `open` row may then name that task in its supervisor
   column. The delegate is itself attested the ordinary strict way, so the chain
   still anchors in init or the kernel one hop above it and the recursion stops
   there. It is **open-only** on purpose — a delegate may vouch for what its
   children *reach*, never for what they *claim* — so the bind path is
   byte-for-byte P2's and the laundering-deputy refusal is untouched.)*
4. **Test fixtures bind under `/protocol/test/...`**, granted to any
   binary whose path starts `/test/` — the subtree-scoping rule from the
   design doc, dogfooded. `shared_memory_test` (the borrowed-ServiceId
   hack) becomes `/protocol/test/shared-memory`; process-test's child gets
   `/protocol/test/process`.
5. **Rebind after provider death:** a `bind` hitting an existing binding
   succeeds only if the current owner process is dead (init checks
   liveness); otherwise `-EBUSY`. Init also unbinds in `restartChild`
   before respawning its own children. This preserves collision-refusal
   while making restart work for providers init does not supervise.
6. **Cross-thread service access** (the display mouse-listener's
   per-thread self-lookup, `display.zig:512`): threads resolve and open
   `/protocol/<name>` like any client — once, at thread startup. No
   special mechanism.
7. **The envelope module is `library/protocol/envelope/envelope.zig`**
   (module name `envelope`) — the one protocol-package module not ending
   in `-protocol`, because it is not a protocol. Wired as a new
   `addModule` row in `library/protocol/build.zig` with its host tests in
   that package's test step.
8. **The QEMU harness gains `-cpu max`** (in `qemu_args`,
   `test/qemu_test.py:66-83`) so TCG exposes SMEP/SMAP — without it the
   enabled paths never execute in CI. Landed in H2 so the flag soaks
   before H3 depends on it.
9. **Scenario fixtures that need the registry are init-driven.** Kernel
   test cases that today spawn providers directly (shared-memory,
   process-test) either spawn init first or move to init.csv-driven
   scenario boots — resolved per-case in P2 with the suite as the
   arbiter.
   *(P2 resolution: init gained a `registry` argv role — it mounts
   `/protocol`, reads the grants, and starts no services — and each affected
   case calls `spawnRegistry(rd)` before its own providers. Every case keeps
   its own spawn set, so no scenario had to be re-shaped.)*
10. **The capsule-staleness caveat is documented, not fixed.** On-volume
    edits to `/system/configuration/*.csv` do not reach the initrd copy
    the loader boots (capsule shadows tree). Same drift exists today with
    `/etc`; PM adds the note to file-system-hierarchy.md and moves on.

---

## PM — path-migration flag-day

One commit, everything moves together. The authoritative site inventory is
the grounding pass; the checklist order:

1. Move repo `etc/` → `configuration/` sources; fix the three CSVs'
   self-referencing headers (`etc/init.csv:1,12`, `etc/devices.csv:1`,
   `etc/init-diagnose.csv:1`).
2. `build.zig:309-311`: bundled entries `etc/...` →
   `system/configuration/...` (this alone re-shapes the image, manifest,
   and capsule — `tools/make-fat-image.py` and the EFI loader need
   nothing; the tree-walk fallback even starts picking the CSVs up, a
   bonus fix).
3. `system/kernel/vfs.zig` `mountBackend` (`:332-340`): allow exactly
   `/system/configuration` and `/system/logs` as backend prefixes beneath
   the initrd `/system` mount; keep refusing everything else under
   `/system` and `/test`.
4. `system/services/fat/fat.zig`: `mount_point` → `/volumes/usb` (`:25`);
   replace the `/var` mount (`:155`) with two `mountRewritten` calls for
   `/system/configuration` and `/system/logs`; update the mount log lines
   (the harness matches them).
5. `system/services/init/init.zig:76` and
   `system/services/device-manager/device-manager.zig:48`: open the new
   CSV paths; update the message strings (`init.zig:77,92`,
   `device-manager.zig:49,61-63,454`).
6. `system/services/logger/logger.zig:44`: `base = "/system/logs"`
   (buffers derive from `base.len` comptime — nothing else changes).
7. `system/kernel/tests.zig:2808-2810`: exclude `/system/configuration/`
   from the spawn-everything sweep (the CSVs are not programs).
8. Tests: `fat-test.zig` and `vfs-test.zig` `/mnt/usb` literals →
   `/volumes/usb`; harness regexes `test/qemu_test.py:175,211,632,717`.
9. Comment sweep (init, device-manager, logger, fat, engine, vfs, abi,
   file-system, csv, device, protocol/device-manager, drivers, acpi,
   build.zig — full list in the grounding inventory); delete vestigial
   repo `var/`.

**Test:** no new case — the existing 106 are the test, since fat/logger/
init/device-manager scenarios all assert the new paths through their
regexes. Suite stays 106.

## H1 — user-memory copy discipline

New kernel module `system/kernel/user-memory.zig`:

- `copyFromUser` moves from ipc-synchronous.zig (which re-exports or
  imports it); new `copyToUser(user_as, user_va, source) bool` — the
  mechanical mirror (kernel-source `copyAcross` already does this for IPC
  replies at `ipc-synchronous.zig:431,460`).
- The page walk gains leaf U/S and writable checks: `paging.translateIn`
  (`architecture/x86_64/paging.zig:513-525`) tests only `present` today —
  add a flags-accumulating variant (2 MiB leaves included); reads require
  U/S, writes require U/S+W. Closes the TODO at
  `ipc-synchronous.zig:20-22`.
- Convert the nine stragglers (table in smep-smap.md). Read direction is
  local to `process.zig`; the write direction restructures callees with
  kernel bounce buffers: `scheduler.enumerate` (`scheduler.zig:1209`),
  `devices_broker.enumerate` (`devices-broker.zig:136`), `log.readAt`
  (`log.zig:209`), and the `fs_node` flows through
  `vfs.nodeRead/nodeStatus/nodeReaddir` (`vfs.zig:257/269/289`).

**Test:** kernel unit coverage in `system/kernel/tests.zig` for
`copyToUser` bounds/permission refusals; one new QEMU case `user-memory` —
a fixture passes an unmapped-but-in-range buffer to `klog_read`,
`process_enumerate`, and `fs_resolve` and asserts `-EFAULT` returns with
the system still alive (today each would oops the kernel). Suite 107.

## P1 — envelope, vfs additions, Channel

- `library/protocol/envelope/envelope.zig`: `Header` {operation:u32, pad,
  target:u64}, `Status`, reserved verbs (describe=0, enumerate=1,
  subscribe=2, unsubscribe=3, protocol verbs from 16), `packet_maximum`
  = 256 / `post_maximum` = 64 (the floor constants protocols compile
  against — nothing exports them today), and comptime
  `Define(.{name, version, operations, events})` generating request/reply
  types, encode/decode, a provider dispatch table (automatic `describe`,
  `-ENOSYS` for unknown verbs), and compile-time size checks: 
  request/reply ≤ 256, each `.events` entry ≤ 64 *including* its Header
  (decision 1). Host unit tests in the protocol package's test step.
- `library/protocol/vfs/vfs-protocol.zig`: `NodeKind.protocol = 7`; the
  open-reply-may-carry-capability convention documented in the module.
  Rewrite the value-pinning unit test (`:108-117`) to pin the *new*
  stable values.
- `library/kernel/file-system.zig` + a new `Channel` type in
  `library/kernel` (or `library/client`): `open("/protocol/<name>")` →
  resolve, vfs open, receive the reply capability → a `Channel` wrapping
  the handle with `call`/typed helpers. Nothing uses it yet — P2 converts
  the world.
- Docs: vfs-protocol.md's NodeKind table gains value 7 (no
  protocol-namespace.md amendment — decision 1 conforms to it as written).

**Test:** host unit tests only (envelope round-trips, size-check compile
errors via `error` tests, Channel plumbing against a mock). Suite stays
107.

## P2 — the registry; ServiceId flag-day

The single biggest phase; one branch, may be several commits, green at the
end of each.

- **init as registry backend** (`system/services/init/init.zig`): a second
  endpoint (the supervision endpoint's reply-empty loop is unsuitable for
  a vfs backend); serve vfs `open`/`readdir` over `/protocol` plus the
  `bind` operation (name payload + capability). Mount `/protocol` before
  spawning children. Parse `/system/configuration/protocol.csv`
  (decision 3). Authorization by chain-attested identity (decision 2):
  badge → kernel process records → binary name **and** supervision chain
  (walk `supervisor` links) checked against the grant row's expected
  supervisor. Unbind on child death in `restartChild`; dead-owner rebind
  rule (decision 5).
  Provenance: readdir/diagnostics show name → pid → binary path.
- **Kernel:** reserve `/protocol` — `mountBackend` refuses mounts at or
  under it once bound, `installMount`'s remount-replace path refuses it,
  and `fs_unmount` refuses it (`vfs.zig:164-181,332-351`,
  `process.zig:1879-1889`). First mount wins (init is PID 1).
- **Harness:** `library/kernel/service.zig` `Callbacks.service:
  ?abi.ServiceId` becomes a protocol name; the register call (`:49-51`)
  becomes bind-with-retry via the registry.
- **Flag-day conversion** — all 11 registration sites and 17 lookup sites
  from the grounding inventory: providers (input:123, ps2-bus:223,
  device-manager:569, acpi:193, usb-xhci-bus:676, usb-storage:205,
  fat:307, display:699, virtio-gpu:550, shared-memory-server:43,
  process-test:130 → `/protocol/test/...` per decision 4); clients
  (input-client:53, display-client:28, driver.zig:173, usb.zig:139,
  block.zig:72+87, ps2-bus keyboard:35 + mouse:34, virtio-gpu:478,
  display:314+512 (decision 6), acpi:212, init:218+245 — init
  short-circuits its own registry, shared-memory-client:22,
  process-test:85, device-list:22, crash-test:32). Retry loops keep their
  cadence, wrapping resolve+open instead of lookup.
- **Delete:** `abi.zig:36-37` (syscall ids — leave holes),
  `abi.zig:287-303` (enum), `process.zig:223-224,314-343`,
  `ipc-synchronous.zig:41-43,646-664` and the registry sweep in
  `:121-140`; the wrappers `library/kernel/ipc.zig:33-35,47-50`; comment
  sweep (irq.zig:50, tests.zig:3744, vdso.md's syscall table, the docs
  list in the inventory).
- Kernel-spawned test scenarios made init-driven where they need the
  registry (decision 9).

**Test:** new QEMU case `protocol-registry`: a fixture asserts (a) bind of
an ungranted name → `-EPERM`, (b) bind collision with a live owner →
`-EBUSY`, (c) provider kill → re-resolve reaches the restarted instance.
Every existing scenario doubles as conversion proof. Suite 108.

## P3 — open grants (restriction stage one)

- `protocol.csv` `open` rows enforced in the registry's `open` handler,
  same name-based identity as bind. Default rows grant what today's
  clients need (from the P2 conversion table); a deliberate hole for the
  test fixture.
- Docs: protocol-namespace.md stage-one section gets its "landed" line.

**Test:** new QEMU case `protocol-denied`: a fixture granted
`/protocol/test/shared-memory` but not `/protocol/display` asserts open of
the first succeeds and the second fails identically to not-found. Suite
109.

*Landed. Four things the plan did not foresee, recorded because P4 and P5
inherit them:*

- *`supervise` — decision 3's amendment. The PS/2 keyboard and mouse drivers
  are started by the PS/2 bus driver, which the device manager started: the
  tree's one three-deep chain, and one hop deeper than attestation reaches.
  Nothing structural separates it from the laundering deputy, so the manifest
  says which delegate is authorized, per contract. Open-only, so P2's bind
  attestation is unchanged.*
- *Indistinguishability is a claim about work, not only about bytes. `onOpen`
  refreshes the process table, identifies the caller, scans the grants and
  scans the bindings on **every** open and forms one verdict at the end; and
  it logs nothing on any branch, because `klog_read` is ungated (a line
  written on one branch is a line the refused caller can read) and a serial
  line is milliseconds it could time. The operator's diagnosis is the pair the
  namespace publishes anyway: `readdir /protocol` for what is bound, the
  manifest for who may reach it.*
- *The fixture is `protocol-denied-test`, and its scenario boots the **input
  service** so the forbidden name is genuinely bound — the fixture reads the
  namespace listing to prove it before asking for it. Without a live provider
  the case would be comparing two boot races and asserting nothing.*
- *Two channels stay open by design, named rather than papered over: `readdir`
  over `/protocol` lists every bound name to anyone (deliberate — the tree is
  diagnosable), and `/system/configuration/protocol.csv` is world-readable on
  the `/system` mount. Stage one hides neither the set of contracts nor the
  policy; what it removes is the **oracle in the reply**, which is what stage
  two's parked and faked opens depend on.*

## P4a — clean protocols onto Define

vfs, block, display, scanout, input — the modules whose shapes map
directly (grounding inventory §1,3,4,6,8):

- vfs: `node` → `target`; `Reply.node` (open's result) moves to reply
  payload — `library/kernel/file-system.zig` decoders change; readdir
  stays a protocol verb.
- block: pure renumber; `attach`'s DMA cap rides the call as today.
- display: the overloaded 40-byte `Request` becomes per-operation structs
  (attach_scanout's field abuse dies); `layer` → `target`; blit payload
  grows to 224 bytes.
- scanout: renumber; drop its bogus `message_maximum=64` (sync floor is
  256); fix virtio-gpu's hard-coded `service.run(256, …)` to the
  generated constant.
- input: subscribe merges into reserved subscribe; publish renumbers;
  the event re-lays onto the Header folded (operation = event kind,
  target = 0; 16 + 28-byte payload = 44 ≤ 64); **input moves onto the
  service harness** (it is the last hand-rolled loop, no ping/terminate
  compliance today).

**Test:** new QEMU case `protocol-conformance`: a fixture opens every
registered protocol and asserts `describe` answers (name, version) and an
unknown verb returns `-ENOSYS`. Existing input/display/fat scenarios prove
the rebase. Suite 110.

## P4b — misfit protocols onto Define

device-manager, power, usb-transfer (inventory §2,5,7 — the u8-operation
re-layouts and raw-offset readers):

- device-manager: u8 operations → Header; its enumerate=4/subscribe=5
  merge into the reserved verbs; `ChildAdded` splits its dual role —
  request struct and event, both Header-first (folded to 60 B ≤ 64);
  `ChildRemoved`'s (parent, bus_address) addressing stays payload.
- power: u8 operations → Header; subscribe merges; **init's raw
  byte-offset event parsing (`init.zig:171-173`) and acpi's
  `message[0]` dispatch (`acpi.zig:435-467`) are rewritten against the
  generated types** — the two silent-breakage sites, called out so the
  loop treats them as first-class conversions, not collateral.
- usb-transfer: `device_token` → `target` (already layout-identical);
  `InterruptReport` re-lays onto the Header (`device_token` → `target`,
  inline data trimmed 48 → 40 — largest real report is 8); control/bulk
  budgets re-verified by `Define` (Status absorbs `actual_length`).

**Test:** existing scenarios are the proof (device hot-add, power button,
USB storage/HID all exercise these wires); the conformance case now covers
three more providers. Suite 110.

*Landed. Three judgment calls the plan left open, recorded because a reader of
the wire formats will want them:*

- *`ChildAdded` is 48 bytes, not the 44 the "60 B" estimate assumed: three `u64`s
  give the struct eight-byte alignment, so 41 bytes of content round up whatever
  order the fields sit in. The packet is therefore **exactly** 64 — on the push
  floor, not under it — which `Define` accepts and the module pins in a test. The
  fields are ordered small-tail-last deliberately, so the slack the rounding pays
  for is where the small ones live.*
- *power's events are declared **per kind** (`power_button`, `lid`, `ac`,
  `battery`, `notify`), not one `event` with the kind in the payload. That is the
  shape P4a gave input — "the class is the header's operation, so a subscriber
  reads the kind from the packet rather than from a tag inside the payload" — and
  it is what makes `Header.operation` carry information here at all. It also kept
  every call site's spelling: `Protocol.Event` is re-exported as the protocol's
  own `Event`, with the members it always had.*
- *the conformance case still checks two providers, and the three new rows report
  as unbound. All three P4b contracts arrive with the device manager — it is the
  first, it spawns the discovery service that binds the second, and the xHCI
  driver that binds the third — so booting one means booting the driver tree, and
  the fixture takes **one snapshot** of `/protocol`: a scenario whose bound set
  depends on how far that tree got would make the case's own summary line a boot
  race. The rows still earn their place — a future scenario that binds one gets it
  checked with no edit here, and `/test/*` already holds the `open` grant for
  `device-manager`.*

## P4c — harness subscriber lift + badge scoping

- `library/kernel/service.zig` grows the subscriber table, exit-
  notification sweep, and fan-out loop declared via `Define(.events)`;
  input (:33-116), acpi (:67-68,393-406), and device-manager (:155-166)
  delete their hand-rolled variants. One sweep idiom: exit notifications
  (fat's pattern), replacing input's process-list polling and acpi's
  none-at-all.
- Badge-scoped per-client integers (the guessable-id holes): fat node ids
  gain owner checks on every operation (`fat.zig:72-76`), xhci device
  tokens validate sender and sweep on exit (`usb-xhci-bus.zig:66-88,479`),
  display layers gain an owner field.

**Test:** extend the fat scenario: a second fixture guesses the first's
node id and asserts refusal; kernel-side unit test for the harness sweep.
Suite 111.

*Landed. Four things the plan had not foreseen:*

- *One sweep idiom means one more kernel subscriber per provider, and the kernel's
  published-exit table held **eight**. A normal boot now fields six (fat, input,
  power, device-manager, display, and one per xHCI controller), so the table grew
  to sixteen. It is not a table anyone notices until a service silently loses its
  sweep, which is exactly the failure the old ceiling was two subscriptions away
  from.*
- *The device manager hears each of its drivers die **twice** now — it is both the
  supervisor its spawn named and, through the harness, a subscriber to published
  exits — and the notify ring delivers the two badges separately. Untreated, one
  death counted as two: the restart backoff doubled and the crash-loop cap fired
  at half the deaths it names. `onDriverExit` therefore retires the dead process
  id before it decides anything, and the second notification finds nothing to act
  on. (The `driver-restart` and `pci-scan` drills are what would have caught it.)*
- *Refusal-equals-absence has a corollary for the verbs that **release**: FAT's
  `close` used to answer 0 for an unknown node, so scoping it had to change that
  too — a foreign node and a free one both answer `-ENOENT`, or the pair would
  have been an oracle for which ids are live. The same applies to the harness's
  `unsubscribe`.*
- *Ownership is per **task**, not per process, because the badge is: the kernel
  stamps the sending thread's id, which is already the granularity of the exit
  sweep that releases the state (a worker thread's death releases the handles that
  worker opened). Nothing in the tree shares an id across its own threads today;
  a per-process notion would need the kernel to stamp the leader, and belongs with
  P5's spawner-wired namespaces if it is ever wanted.*

## H2 — SMEP

- Generalize the cpuid helper (`apic.zig:351-365`, private, subleaf-0) to
  a shared probe; gate on `cpuid(0).eax >= 7`.
- Set CR4 bit 20 in `per-cpu.zig:initSystemCall` (or a sibling called
  from both `cpu.zig:148` and `smp.zig:181` — the one path both BSP and
  every AP already execute). Log enabled/absent (fail-open, IOMMU style).
- Harness: add `-cpu max` to `qemu_args` (decision 8).

**Test:** new QEMU case `fault-smep` mirroring the `fault-*` injector
pattern (`tests.zig:3906-3938`): ring-0 call through a pointer into a
user-mapped page; expect `page fault (vector 14)` + `error code : 0x11` +
kernel-half IP, machine reports the exception (deliberate-exception cases
put the text in `expect`, per `qemu_test.py:189`). Suite 112.

## HS — SYSRET canonical-RIP guard

- `isr.s` syscall exit (`:256`): validate RCX canonicality before
  `sysretq`; non-canonical → `iretq` fallback (or kill), per the hazard
  note at `isr.s:192-194`.

**Test:** kernel unit case driving a thread whose return RIP is forged
non-canonical via the syscall path if constructible cheaply; otherwise the
review-level proof plus the existing fault cases regression. Suite 112.
*(Landed: it was constructible, and from ring 3 rather than by forgery — the
`sysret-canonical` case, suite 113.)*

## H3 — SMAP

- `clac` patch site at `isr_common` (`isr.s:367`, before the CPL test —
  ring-0 nesting inherits AC too): assemble a 3-byte NOP, patch to `clac`
  at boot through the physmap (the `process.zig:1990-1995` /
  `smp.zig:79-111` precedent), BSP-only before AP bring-up.
- Set CR4 bit 21 in the same per-CPU init as SMEP.
- Coding standards: kernel code touches user memory only through
  `user-memory`; no `stac` anywhere, ever.

**Test:** new QEMU case `fault-smap`: ring-0 deliberate read of a mapped
user page; expect vector 14 + `error code : 0x1` + kernel IP. And the
whole suite becomes the tripwire — any missed straggler now fails loudly.
Suite 114 (HS added one).

---

**Explicitly out of scope** (own tracks, after this plan): P5 restriction
stage two (spawn's initial capability, namespace views, parked replies,
dedicated killable channels — needs a design session on the spawn
contract), file-path namespacing, trusted UI (display track), pipes/FIFOs
(Python track), `/applications` and its storage, `fs_mount`/`spawn`/
`klog_read` gating beyond the `/protocol` reserved prefix, KPTI, IPC
priority inheritance.
