# M21 execution plan: ACPI events + system power The operational plan for the event side of the acpi service and orderly shutdown — the capstone [m19-m20-plan.md](m19-m20-plan.md) previewed. Same rules as its predecessors: one phase at a time, each green before the next; this file is the build order and the checklist. **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 one), and the relevant design doc updated. Commit per green phase (no co-author trailers). Failing cases preserve their serial logs (`-failed-serial.log`). **Workflow:** dedicated worktree; branch `feat/power-events` off `main`; auto-merge to main when the branch is green; keep the branch; push everything. ## Settled decisions (2026-07-13, approved) 1. **S5 is executed by the acpi service from ring 3.** No new syscall: the broad port grant (M20 decision 5) already made this physically possible — the service holds the PM1 control ports in its io grant and derives `_S5` from its own namespace (`aml.sleepState`). Formalizing it adds no authority. The kernel keeps `power.zig` for its own test paths and panic-time use. 2. **The power surface is domain-named** (decision 7 of the last plan): a `power-protocol` module + `ServiceId.power = 5`, registered by the acpi service — on ARM, a PSCI/mailbox service registers the same id and subscribers never know the difference. Messages: `subscribe` (endpoint as the call's capability, the input/manager pattern), `shutdown` (accepted only from PID 1 — init), and events published as buffered messages: `power_button`, `lid`, `ac`, `battery`, generic `notify` with a code. 3. **The service learns event ports from its own FADT copy**: the kernel adds the FADT as one more memory resource on the acpi-tables node; the service tells it apart from the AML blobs by signature ("FACP" header — the blob resources are header-stripped bytecode and start with no signature). The kernel's own FADT parse is untouched. 4. **The acpi service converts to the harness** (`runtime.service.run`): protocol messages (subscribe/shutdown), the SCI notification, and the existing report flow fold into one loop — the shape it was always meant to have. 5. **GPE/Notify correctness is proven by host unit tests** (synthetic AML with a Notify inside a method body; aml.zig joins the `zig build test` loop). The QEMU scenario proves the power button — a *fixed* event, deterministically injectable via QMP `system_powerdown` — because QEMU cannot raise GPEs deterministically on this config. Battery/AC/lid and the embedded controller (`_Qxx`) are interface-complete here and validated on real hardware (the laptop) later. ## Ground truth the phases build on (verified 2026-07-13) - `system/devices/power.zig` `shutdown()` is the kernel's S5 write (SLP_TYP|SLP_EN to PM1a/PM1b control); there is no power syscall. - init (`system/services/init/init.zig`) spawns vfs/input/device-manager fire-and-forget — no child ids kept, no signals, no event loop. The whole stop toolkit exists in `runtime.process` (stop/sendSignal/bindSignals). - `test/qemu_test.py` has no QMP channel (serial is a one-way file). - The kernel parses PM1 *control* blocks and SCI_INT from the FADT; the PM1 **event** blocks (offsets 56/60, len at 88) and **GPE0/GPE1** blocks (offsets 80/84, lens 92/93) are unparsed — the service reads them from its FADT copy (decision 3). - The acpi-tables node carries the SCI as its only `len == 1` irq resource (the broad window is len 256) — that is how the service finds it to `irqBind`. - `notify_opcode = 0x86` exists in `system/devices/aml/opcodes.zig` but the interpreter never handles it — a GPE `_Lxx` body containing Notify fails evaluation today. Everything else a GPE handler needs (field access, control flow, method calls) is proven by the ring-3 `_STA`/`_CRS` work. - The dead-code sweep (spawned task) also edits `system/devices/acpi.zig`; M21.0 checks whether it landed and rebases before touching that file. ## Status - [ ] **M21.0** — baseline: rebase over anything newly merged (the dead-code sweep touches acpi.zig); cut `feat/power-events`; add the QMP channel to the harness (`-qmp unix:.../qmp.sock,server,nowait`, a small client with the `qmp_capabilities` handshake, a per-case `qmp_after` hook that sends a command N seconds after boot); existing suite stays green. - [ ] **M21.1** — SCI + the power button: kernel appends the FADT as an acpi-tables memory resource; new `power-protocol` module + `ServiceId.power`; the acpi service converts to the harness, registers `.power`, parses the event/GPE blocks from its FADT copy, enables ACPI mode if needed (SMI dance, spin on SCI_EN), binds the SCI, sets PWRBTN_EN; on SCI reads/clears PM1_STS and publishes `power_button` (log: `power: button pressed`), always irqAck. Scenario `power-button`: `qmp_after system_powerdown` → expect the log line. - [ ] **M21.2** — Notify + GPE dispatch: interpreter handles `notify_opcode` into a bounded queue drained after evaluate(); on GPE status bits the service evaluates `\_GPE._Lxx`/`_Exx`, maps notified nodes to events (PNP0C0A→battery, ACPI0003→ac, PNP0C0D→lid, else generic), clears GPE_STS, acks. EC `_Qxx` explicitly out (hardware track). Host unit tests for Notify in aml.zig; aml.zig joins the `zig build test` loop. - [ ] **M21.3** — orderly shutdown: init keeps child ids (spawnSupervised + exit endpoint), binds signals, subscribes to `.power`; on `power_button` logs `init: shutting down`, runs `stop(child, 2000, endpoint)` in reverse spawn order, then sends `shutdown` to `.power`; the acpi service (sender PID 1 only) logs `power: entering S5` and writes SLP_TYP|SLP_EN from ring 3. Scenario `orderly-shutdown`: boot via init, `qmp_after system_powerdown`, ordered regex button→shutting-down→entering-S5, pass on QEMU exit. Docs + memory updated. - [ ] **merge** `feat/power-events` → main, push, keep the branch — **loop ends here**. --- ## Phase notes **M21.0 QMP:** open the unix socket after Popen, complete the `qmp_capabilities` handshake, then send the hook's command (for these scenarios: `{"execute": "system_powerdown"}`). The socket is additive — no existing case may notice it. Note e3fe3f3 recently reworked how the harness boots; adapt to its current shape rather than the pre-rework description. **M21.1 SCI details:** PM1_STS is at the event block base (write-1-to-clear); PM1_EN at base + block_len/2; PWRBTN bit is 8 in both. If PM1b exists, mirror reads/writes to both blocks. Enable ACPI mode only when SCI_EN (PM1 control bit 0) is clear — OVMF boots may already have it set. The publish path reuses the manager's subscriber table pattern (bounded, drop-on-failed-send). **M21.2 GPE walk:** GPE0_STS bytes live at the GPE0 block base, GPE0_EN in the block's upper half; for a set+enabled bit n, the handler method is `_L%02X` (level) or `_E%02X` (edge) under `\_GPE`. Evaluate, drain the notify queue, clear the status bit, ack. A missing handler method is clear-and-log, not an error. **M21.3 ordering:** init subscribes with retries — the acpi service registers `.power` well after init starts. The stop sequence runs vfs last (other services may flush through it). The S5 write mirrors `power.zig`'s `sleepValue` (SLP_TYP bits [12:10], SLP_EN bit 13); if the write returns, log `power: S5 write did not take` so the scenario fails loudly instead of hanging. **Explicitly out of scope:** the embedded controller and `_Qxx` queries, battery `_BST`/`_BIF` evaluation beyond the interface stubs, lid/AC on QEMU (no emulation), reboot over the power protocol, S3 sleep, per-device D-states (a future lifecycle-vocabulary extension), thermal zones.