# Roundhouse MVP4 — Build Architecture & Work Breakdown

**File: `mvp1/MVP4-SPEC.md`** (beside `roundhouse.py`; `MVP4.md` at repo root stays the contract — its acceptance checklist is the definition of done, its Out-of-scope list is binding).

Grounded in: `MVP4.md` (contract), E-series (MVP2) and F-series (MVP3) — all stand, nothing here re-opens them; `mvp1/roundhouse.py` @ 0ef23ca (5788 lines, Sections A → B → C → E [PARTs 1–3] → D); `mvp1/static/index.html` (2103 lines); 268 green tests. Recon findings that shaped this spec: **(1)** `'enabled'` already reaches snapshot rows (`unit_file_state == 'enabled'`, line 1566) via the 3 s tick's `UnitFileState` property (line 5479) — no new sensing for the checkbox itself, only for its *latency* (G5). **(2)** `run_actuate`'s len-5 branch (line 2996) accepts any verb in `ACTUATE_SYSTEMCTL_VERBS`, which contains `daemon-reload` — so `["systemctl","--user","daemon-reload","--",unit]` passes shape today; the G2 per-verb table closes this latent wart for free. **(3)** The test suite's `WRITE_VERBS` (test_server.py line 582) already lists `enable`/`disable` — guard legs (a)/(b) apply to the new literals with zero test edits; only leg (c)'s call-site set grows. **(4)** The four genuinely unlocked `snapshot()` sites are lines 2610 (`handle_switch`), 3805 (`preflight_port`), 3890 (`preflight_memory`), 4966 (`switch_preflight`); every other site (4065, 4286, 4372, 4463, 4515, 4574, 4611, 4744, 5535, 5614, 2892) already sits inside `with watcher_lock:`. The six call sites named in MVP4.md's debt row (handle_edit, `_run_switch`, etc.) all route through those four functions. **(5)** `_fail_rollout` does not record *which* phase failed — the stepper-honesty cosmetic needs one additive record field. **(6)** `select_units` has a `# roundhouse: manage` override, but selecting `roundhouse.service` would push a non-llama unit through the rung ladder — rejected (G8). **(7)** User units surviving a container reboot require `loginctl enable-linger` — currently absent from container-setup.sh and **load-bearing for the entire milestone** (Risk 1). **(8)** Live boltzmann audit 2026-08-13: exactly 3 enabled LLM units (llama-embed, llama-task, qwen3.6-coding), 20 disabled — the fleet already conforms; **no migration step exists in this milestone.**

## 1. GLOBAL DECISIONS (G-series; implementers must not re-open them)

- **G1 — Enablement actuation is `RolloutEngine._set_enablement(unit_name, enable: bool)`, the single new `run_actuate` call site; it takes NO slot.** A plain pass-through method beside `_stop_unit`/`_start_unit` (same shape: one `run_actuate` call, nothing else). It never reads or writes `self.current`/`self.rollouts`/`self.counter`, takes no lock, and holds no state — "slotless" is the *absence* of engine coupling, not a new mechanism. Module-level function rejected: the engine is where every actuation already lives, MVP2 §2.1's sentence "`run_actuate` is called only by `RolloutEngine` methods" stays true verbatim, and the handler already holds the engine reference. `ROLLOUT_CALLSITES` becomes `frozenset({'_stop_unit','_start_unit','_daemon_reload','_set_enablement'})` — growth by exactly one name, per the contract's acceptance row. All preflight lives *outside* the method (G3), in the route handler — `_set_enablement` is a gateway wrapper, not a policy point (run_actuate's own membership/retired check is the last-line backstop, as everywhere).
- **G2 — Verb sets stay separate; `run_actuate` becomes a per-verb shape table.** `ACTUATE_SYSTEMCTL_VERBS = {"stop","start","daemon-reload"}` is **unchanged** (frozen by MVP2 tests and by the rollout/switch shape story). New constant beside it: `ENABLEMENT_VERBS = {"enable","disable"}`, disjoint by test. `run_actuate` shape rule, restated as a table (one legal argv per verb):
  - `daemon-reload` → exactly `["systemctl","--user","daemon-reload"]` (len 3, no unit — the len-5 branch now **excludes** `daemon-reload`, closing recon finding 2; regression-pin it).
  - `stop|start|enable|disable` → exactly `["systemctl","--user",verb,"--",unit]`, len 5, `unit in units`, `units[unit].retired is False`.
  Implementation: the existing len-5 branch condition becomes `argv[2] in (ACTUATE_SYSTEMCTL_VERBS | ENABLEMENT_VERBS) and argv[2] != "daemon-reload"`; membership + retired checks are the existing lines, so the RETIRED backstop covers the new verbs **by construction** (verified: lines 2998–3001 are verb-agnostic). `--now` is structurally impossible: it would be a sixth argv element or displace `"--"` — the exact-length/exact-position rule already excludes it; **stated, not re-implemented.** Growing `ACTUATE_SYSTEMCTL_VERBS` instead was rejected: it would put enablement verbs inside the set the rollout/switch story quantifies over ("lifecycle verbs only"), forcing every F10-adjacent assertion to carve exceptions.
- **G3 — Enable-collision preflight: boot-claims only, rung-blind, defaults count, gates don't exempt claimants, self-port blocks; disable always passes.** `enablement_preflight(unit_name, enable, snapshot, units, self_port)` — pure, snapshot-in (supports G9(i): the caller supplies a locked snapshot; the function never touches the watcher). Rules, in order: (1) target retired → fail (`retired` check row, MVP2 wording) — for *both* directions (RETIRED renders no checkbox; the route is belt, this is braces, `run_actuate` is suspenders). (2) `enable == False` → ok, no further checks (contract: disabling removes a boot claim; it cannot create a race). (3) `enable == True` → collect claimants: every **other** snapshot row with `retired == False`, `enabled == True`, and `row['port'] == target_row['port']` — **rung is deliberately ignored** (this is a boot-time race; runtime state is irrelevant — code comment says so), **`port_source == 'default'` counts** (two units both defaulting :8080 really collide at boot; same rule, no special case), **a claimant's kernel gate does NOT exempt it** (decided: complementary-`ExecCondition` pairs are a hand-maintained pattern Roundhouse cannot prove safe; the interlock is deliberately conservative — the refusal names the claimant's gate so the operator understands, and hand `systemctl` remains available for the pattern; note the target's own gate never blocks *its* enable, per the contract). Additionally `target_row['port'] == self_port` → pseudo-claimant `roundhouse (self)` (a dogfooded Roundhouse is itself an enabled boot claimant on that port). Any claimant → fail. Corollary, stated: enabling an *already-enabled* unit that sits in a pre-existing hand-made double-enable also answers 422 naming the claimants — the interlock surfaces the existing race rather than rubber-stamping it; disable is the way out. Returns `{"ok": bool, "checks": [...], "port": int, "claimants": [{"unit","alias","port","rung","enabled":true,"gate":<gate|null>}...]}`; fail detail frozen: `port {p} is already a boot claim of: {unit} (enabled, {rung}[, kernel-gated])…` / self-port: `port {p} is roundhouse's own port`.
- **G4 — Route `POST /api/units/<name>/enablement`, body `{"enabled": true|false}` (JSON bool, strictly).** Success `200 {"unit","enabled","was","changed","strategy_note"}` — `was` = snapshot `enabled` at preflight, `changed = (was != enabled_after)`. **Idempotent toggles execute anyway** (systemctl enable is idempotent and self-healing for degenerate `UnitFileState`s; one code path, no skip branch) and answer 200 with `changed: false`. Errors: 400 `bad_json` / `bad_body` (`enabled must be true or false` — rejects strings, numbers, null, absent); 404 unknown unit; **422 `enable_collision`** `{"error","port","claimants","detail"}` (its own error code — the UI renders claimants specially, per the acceptance row); 422 `preflight_failed` with the retired check row (RETIRED target, either direction — consistent with the edit route's shape); 403/401 per E8; 405 on GET; 500 `enablement_error` with systemctl's stderr when `run_actuate` fails at execute (host malfunction, not client error). **No slot check anywhere** — the contract's both-directions row is satisfied by *absence of code*, and a test pins that `engine.current` is untouched. `FROZEN_POST_ROUTES` grows to 7 entries; `do_POST` gains the `endswith('/enablement')` branch; the structural guard's fragment set gains `'/enablement'`.
- **G5 — Immediate read-back + a new SSE event `'enablement'`.** Without it the row would lag ≤ 3 s *for other viewers* and **forever** for the toggling one — the tick only emits `rung` events on rung/badge change, and enablement changes neither. Handler sequence after a successful `_set_enablement`: run `run_ro(['systemctl','--user','show','-p','UnitFileState','--',unit])` (read verb, in `READONLY_SYSTEMCTL_VERBS`), parse `UnitFileState=`, apply via new `Watcher.apply_unit_file_state(unit_name, value)` under `watcher.lock`, then publish `event: enablement` with `{"unit","enabled","strategy_note","ts"}` (note computed server-side per G6 — the payload carries it because the UI stays dumb and no rung event will refresh the row). `apply_unit_file_state` sets **only** `_state[unit]['unit_file_state']` — it MUST NOT stamp `sensed_at` (that would launder a stale `ActiveState` sample as fresh past `_confirm_off`'s freshness gate; docstring says so). Read-back failure degrades gracefully: respond with the requested value, leave watcher state to the tick (documented; no retry loop). Riding the `rung` event was rejected as a lie (rung didn't change) with the wrong payload shape.
- **G6 — Drift notes are server-computed: snapshot rows gain `'strategy_note'`.** New pure function in Section B beside `classify_port_claims`:
  ```python
  def strategy_note(enabled, rung, retired):
      if retired: return None
      if enabled and rung in ('OFF', 'STANDBY', 'FAILED'): return "returns at boot"
      if not enabled and rung in ACTIVE_RUNGS: return "manual — will not survive reboot"
      return None
  ```
  Exactly the contract's two strings; no third wording invented. `enabled + FAILED` → `returns at boot` is decided true-and-sufficient: systemd *will* retry it at boot, the FAILED rung already carries the failure signal, and the note answers only "does boot bring it back?". `enabled + STANDBY` → `returns at boot` (pinned by the acceptance row; the gate note stays separate and unchanged). Called once per unit row in `Watcher.snapshot()`; the detail route inherits it via `**row` (line 2717) with zero code; `serve_deployments`' `load_strategy` dict gains `'strategy_note': row.get('strategy_note')`. `test_snapshot_shape`'s frozen key set gains the key (same-task rule as MVP3).
- **G7 — Checkbox in the unit row, all non-RETIRED sections, confirm-less, truth-showing when read-only.** A `<label class="enable-toggle">` with `<input type="checkbox">` + text `boot`, rendered in `renderUnitList` for categories `active|standby|off|failed` (never `retired`), right-aligned after `unit-row-left`. `checked = unit.enabled` — **always the real state**, including read-only mode where the input gets the `disabled` attribute (greyed): a checkbox showing the truth *is* the glanceable audit column; showing unchecked would falsify it. Click handling: `stopPropagation` (the row's onclick opens the detail pane), input disabled while the POST is in flight. **Confirm-less both directions** (decided: preflight blocks the only dangerous case, disable is safe by contract, and a checkbox behind a modal is miserable UX). On 200: update `unit.enabled` + `unit.strategy_note` from the body, re-render. On 422 `enable_collision`: revert the checkbox, toast naming the claimants. On 401: revert + `showTokenError()`. On 500: revert + toast with detail. Never render optimistic state — the checkbox reflects `state.units` only. Detail pane: a `Load strategy` row (`on-boot (enabled)` / `manual (disabled)`) plus the note line when non-null; **no second checkbox** (one control per unit; the pane is reached from the row). The drift note also renders in the row's `detailSpan` as `· <note>` — neutral color, no new red/amber sharers. 44 px: `.enable-toggle` joins the frozen selector rule with a 24 px input (same pattern as `.stop-tick-row`).
- **G8 — Dogfood: `roundhouse.service` is NOT a selected unit; its strategy is sensed cheaply and shown on the port board's self cell.** Selecting it (via `# roundhouse: manage`) was rejected: the llama pipeline would misrender it (LOADING forever, `no_ready_marker`), the edit/switch paths would accept a non-llama unit, and a checkbox that can disable Roundhouse's own boot-return from its own UI is a footgun with no acceptance row. Instead: the 3 s tick appends `'roundhouse.service'` to the `systemctl show` unit list (query only — never in `watcher.units`); `apply_systemctl_show` pops that key from `props` into a new watcher field `self_unit_file_state: str = ''` before the per-unit loop; snapshot gains top-level `'self_unit': {"unit":"roundhouse.service","unit_file_state":s,"enabled": s=='enabled'}`; the port board self cell note becomes `roundhouse (self) · boot: enabled|manual|not installed` (NOTE, post-review: both /api/ports and the UI derive this from the same snapshot self_unit field by different routes — deliberate, they cannot drift) (empty state → `not installed`). ~20 lines total, and it is what lets the reboot drill verify the dogfood row from the phone. Container mechanics + drill in §6.
- **G9 — Debt executions are pinned in §7 exactly:** (i) lock unification = `Watcher.lock` dataclass field + `locked_snapshot()` helper + **four** call-site edits + one `cmd_serve` line; (ii) `os.system`/`os.popen` (zero-tolerance, gateways included) + `Path.open` write-mode attribute form in the guards, each with a seeded-violation entry; (iii) `_start_unit` gains `timeout=START_TIMEOUT_SEC`, `_watch_unit`'s literal `timeout = 900` → `WATCH_TIMEOUT_SEC` (behavior note: start budget drops 90 s → 30 s — for `Type=simple`, `systemctl start` returns after fork; the model load lives in `watching`; this is what F2 already specified); (iv) cosmetics: `GB` → `GiB` in the switch-modal fit line (index.html ~1918–1920, the only offenders), stepper reached-phase honesty (needs `_fail_rollout` to store `failure['phase'] = phase` — additive record field, rides through `rollout_public_record` untouched), `#token` joins the 44 px rule.

## 2. FILE / SECTION LAYOUT

```
mvp1/
  MVP4-SPEC.md                  # this file
  roundhouse.py                 # extended: SECTION E PART 4 (enablement); small edits in B/C/E as listed
  roundhouse.service            # comment block rewritten: enable-based install flow (incl. linger note)
  static/index.html             # extended in place (one file, textContent-only)
  scripts/
    container-setup.sh          # extended: linger, unit install, printed flow rewritten (nohup gone)
    enablement-drill.sh         # NEW: container reboot drill + live boltzmann phone drill checklist
  tests/
    test_parser.py              # UNTOUCHED
    test_watcher.py             # extended: strategy_note table, apply_unit_file_state, self_unit, snapshot shape
    test_server.py              # extended: TestWriteGuards evolution (§7), route table, static UI checks
    test_actuation.py           # extended: TestEnablement* classes (§8)
```

`roundhouse.py` gains, after `suggest_stops` (~line 5297, before the Section D banner):

```python
# ===== SECTION E PART 4: ENABLEMENT (boot strategy; enable/disable only; slotless; no file writes, no git, no daemon-reload) =====
```

(The banner matches `^# ===== SECTION ([A-E])\b`, so Section E stays one span for the guards.) **Part 4 contents:** `enablement_preflight()` only. Everything else lands in existing homes — **exhaustive edit list:**
- **Section B:** `strategy_note()` + `locked_snapshot()` (module level, near `classify_port_claims`); `Watcher` gains dataclass fields `lock: threading.Lock = field(default_factory=threading.Lock)` and `self_unit_file_state: str = ''`; `apply_unit_file_state()` method; `apply_systemctl_show` pops the `roundhouse.service` props block (G8); `snapshot()` unit rows gain `'strategy_note'`, top level gains `'self_unit'`; `preflight_port` (3805) and `preflight_memory` (3890) switch to `locked_snapshot(watcher)`. *(The two preflight functions are lexically in Section E Part 2 — listed here by owner-task, see §8.)*
- **Section C:** `do_POST` `is_post_route` + dispatch gain `/enablement`; `handle_enablement()`; `do_GET` answers 405 for `/api/units/<name>/enablement` (already covered: `startswith('/api/units/')` GETs route to detail — verify a name literally ending `/enablement` 404s as a unit name, which is correct and needs no code; the POST-only 405 leg lives in `do_POST`'s existing `is_get_route` logic — **no change needed**, state it); `handle_switch` line 2610 → `fleet_fingerprint(locked_snapshot(watcher))`; `serve_deployments` `load_strategy` gains `strategy_note`; `_build_port_board` self-cell note gains the boot suffix (function is Section D-adjacent, line 5742 — same owner).
- **Section E Part 1:** `ENABLEMENT_VERBS` beside `ACTUATE_SYSTEMCTL_VERBS` (2947); `run_actuate` per-verb table (G2); `FROZEN_POST_ROUTES` 7th entry (2953).
- **Section E Part 2:** `RolloutEngine._set_enablement` beside `_daemon_reload` (~4263); `_start_unit` timeout; `_watch_unit` constant; `_fail_rollout` stores `failure['phase']`; `switch_preflight` (4966) → `locked_snapshot`.
- **Section D:** `cmd_serve`: `watcher_lock = watcher.lock` replaces `threading.Lock()` (5460); tick's show call appends `'roundhouse.service'` to the queried names and `parse_show_blocks`' `unit_order`.
- **Section A: unchanged.**

## 3. ENABLEMENT SPEC (exact)

**Gateway.** `_set_enablement(self, unit_name: str, enable: bool) -> None` → `run_actuate(["systemctl","--user","enable" if enable else "disable","--",unit_name], self.units)` — one line, default timeout (90 s; enablement is a symlink operation, no new constant). `run_actuate` accepts exactly the G2 table; rejects by shape: `--now` (len 6), unit lists (len > 5), missing `--`, `daemon-reload` with a unit; rejects by membership: non-selected, RETIRED.

**Preflight.** G3, verbatim. Called only from `handle_enablement` with `locked_snapshot(watcher)` — one snapshot per request, no torn reads (same doctrine as F4/§3.3).

**Handler `handle_enablement(unit_name)` — exact sequence:**
1. Parse body; `data.get('enabled')` must be `True` or `False` (`isinstance(v, bool)`) → else 400 `bad_body`.
2. `unit_name not in watcher.units` → 404.
3. `engine = self.server.rollout_engine`; absent → 500 `no_engine` (unreachable when unarmed — 403 fires first; same guard as the switch handlers).
4. `snap = locked_snapshot(watcher)`; `pf = enablement_preflight(unit_name, enabled, snap, watcher.units, self.server.port)`; not ok → 422 (`enable_collision` with claimants, or `preflight_failed` with the retired row).
5. `was` = target row's `enabled` from `snap`.
6. `engine._set_enablement(unit_name, enabled)`; `ActuationError` → 500 `enablement_error` (stderr in detail; e.g. a unit with no `[Install]` section surfaces here honestly — no special code).
7. Read-back per G5; `enabled_after` from it (or the requested value on read failure); apply under `watcher.lock`.
8. `note = strategy_note(enabled_after, snap-row rung, retired=<the row's real retired>)` (implementation passes the real value — provably identical since retired 422'd at step 4, and more defensive).
9. `event_bus.publish('enablement', {"unit": unit_name, "enabled": enabled_after, "strategy_note": note, "ts": time.time()})`.
10. 200 `{"unit","enabled": enabled_after,"was","changed": was != enabled_after,"strategy_note": note}`.

Zero file writes, zero git: the handler's only subprocess traffic is one `run_actuate` + one `run_ro` — proven three ways (§7.3).

**Route table addition (frozen):**

| route | method | success | errors |
|---|---|---|---|
| `/api/units/<name>/enablement` | POST | 200 (body above) | 400 `bad_json`/`bad_body`; 404; 422 `enable_collision` \| `preflight_failed`(retired); 403/401 per E8; GET falls through to unit-detail lookup and 404s as a unit name (spec'd in §2 Section C — the earlier '405 GET' cell was self-contradictory); 500 `enablement_error` |

`FROZEN_POST_ROUTES = (…MVP3 six…, "/api/units/<name>/enablement")`. Status doctrine unchanged; no 409 exists for this route (slotless — decided, tested).

**SSE.** New event name `enablement`, payload `{"unit","enabled","strategy_note","ts"}`. Existing events untouched. The snapshot (initial SSE frame + `/api/units`) carries `enabled` + `strategy_note` per row, so refresh/reconnect agrees with the stream. The port board's `armed` class self-corrects on the next 3 s tick via the existing `ports` diff event — accepted 3 s lag, stated.

## 4. UI SPEC (`static/index.html`; textContent-only; no localStorage)

1. **Checkbox** per G7 in `renderUnitList` (~1033): after `row.appendChild(leftDiv)`, for `category !== 'retired'` build `<label class="enable-toggle"><input type="checkbox">boot</label>`; `input.checked = unit.enabled`; `input.disabled = (state.mode !== 'actuate') || inFlight`; `label.onclick = e => e.stopPropagation()`; `input.onchange` POSTs `/api/units/<name>/enablement` with `makeAuthHeaders()` and `{enabled: input.checked}`, then per-status handling per G7 (revert = re-render from unchanged `state`).
2. **Toast**: one `#toast` fixed-bottom div, textContent-only, amber border from the existing palette, auto-hide 6 s; used by 422 (`'enable refused: ' + detail`), 500, and available to future errors. No new red sharers.
3. **Drift note in row**: `detailSpan` appends `' · ' + unit.strategy_note` when non-null.
4. **Detail pane**: `Load strategy` table row (`on-boot (enabled)` / `manual (disabled)`); note line beneath when non-null; RETIRED panes show nothing new.
5. **SSE listener**: `eventSource.addEventListener('enablement', e => { const d = JSON.parse(e.data); const u = state.units.find(x => x.unit === d.unit); if (u) { u.enabled = d.enabled; u.strategy_note = d.strategy_note; renderUnits(); } })`.
6. **Port board self cell** (G8): append `' · boot: ' + (state.self_unit ? (state.self_unit.unit_file_state ? (state.self_unit.enabled ? 'enabled' : 'manual') : 'not installed') : '?')` — snapshot-driven, no arithmetic.
7. **Cosmetics (G9-iv)**: fit-line `GB` → `GiB` (4 occurrences ~1918–1920); stepper honesty in `renderRolloutStepper`: mid-flight, chips **before** `currentPhase` get class `done` + `✓` (today they show `·`); on `failed`, use `rollout.failure && rollout.failure.phase`: chips before it `✓`, at it `✗`, after it `·`; absent (old records) → all `✗` fallback. `#token { min-height: 44px; }` joins the 44 px rule alongside `.enable-toggle`; `.enable-toggle input[type="checkbox"] { width: 24px; height: 24px; }` inside the 700 px block.
8. Mode read-only: checkboxes render disabled+truthful (G7); no token → no POST possible anyway. Desktop ≥ 1200 px: only additive elements change rendering (checkbox column, toast); no existing rule outside the media queries is edited.

## 5. DOGFOOD + REBOOT DRILL SPEC

**`mvp1/roundhouse.service`** — comment block rewritten to the enable-based flow (systemd-native per the owner decision): copy to `~/.config/systemd/user/`, adjust `ExecStart` (add `--actuate --port <p>` as desired), then `systemctl --user daemon-reload && systemctl --user enable --now roundhouse.service`; note that boot-time start requires `loginctl enable-linger $USER`; token location note. Unit body itself unchanged (Restart=on-failure stands).

**`scripts/container-setup.sh` changes (exact):**
1. After the git check: `incus exec "$CONTAINER" -- loginctl enable-linger "$CUSER"` (implementation runs this before the git check — order is behaviorally irrelevant and arguably safer) — **unconditional; without linger no user unit starts at container boot and the entire milestone's reboot row fails** (this includes llama-task, the existing enabled canary).
2. Generate `~/.config/systemd/user/roundhouse.service` in the container from the repo's `mvp1/roundhouse.service` with `ExecStart=/usr/bin/python3 $DEST/mvp1/roundhouse.py --serve --actuate --port 8090` (sed on the ExecStart line; chown to `$CUSER`). Do **not** enable it here: `--actuate` exits 2 until the operator's git init (E2), and an enabled-but-refusing unit under `Restart=on-failure` is a boot crash-loop.
3. Printed instructions rewritten — the nohup block is **deleted**. New flow: (1) git init (unchanged text); (2) `pkill -u $CUSER -f 'roundhouse.py --serve' || true`, then `systemctl --user daemon-reload && systemctl --user enable --now roundhouse.service`; (3) token cat (unchanged); (4) read-only variant: same unit without `--actuate` (edit ExecStart), never nohup.

**`scripts/enablement-drill.sh`** (NEW; checklist pattern of switch-drill.sh — the script performs zero actuation beyond `curl` state capture and prompts; `incus restart` is typed by the operator):
1. **Pre-state**: `/api/units` capture; assert `self_unit.enabled == true` (dogfood) and `systemctl --user is-enabled roundhouse.service` via `incus exec` agrees.
2. **Gated-enable + interlock sequence** (uses the existing :8086 armed pair): disable `llama-task` via checkbox (always allowed) → enable `llama-server-qwen35-npu` (gated) → **succeeds**, row STANDBY + `returns at boot` (acceptance row 3) → re-enable `llama-task` → **422** naming the npu unit (interlock is symmetric) → disable npu, re-enable llama-task (restore).
3. **Drift notes**: enable `llama-server-fake-b` (OFF, :8093, no claimant) → row shows `returns at boot`; start `qwen3.6-coding` by hand (turntable) while disabled → `manual — will not survive reboot`. Both notes on screen (acceptance row 4).
4. **Zero-write leg**: record `git -C ~/.config/systemd/user rev-parse HEAD` + unit-file mtimes before step 2, compare after step 3 — identical.
5. **Reboot leg A**: with fake-b enabled and qwen3.6 running-but-disabled: `incus restart roundhouse-test`; wait for `/api/units` to answer (**the API answering at all is the dogfood proof** — roundhouse returned as a unit); assert fake-b active (came back), qwen3.6 OFF (stayed down), llama-task back (canary), `self_unit.enabled` true.
6. **Reboot leg B**: disable fake-b via checkbox; `incus restart`; assert fake-b stayed OFF.
7. **RETIRED**: confirm mixperten renders no checkbox; `curl -X POST .../mixperten.service/enablement` → 422 (route-level retired refusal — the container half of the double-refusal fixture; the collision half is unit-tested, §7).
8. **Live boltzmann** (operator-authorized, may remain open at push): from the phone, flip `llama-server-gemma4`'s checkbox on → verify `systemctl --user is-enabled` says enabled → flip off → verify disabled; fleet ends in the audited state (3 enabled: llama-embed, llama-task, qwen3.6-coding). No migration step — the fleet already conforms (recon 8).

## 6. DEBT SPEC (G9, exact change lists)

**(i) Snapshot-lock unification.**
- `Watcher` gains `lock: threading.Lock = field(default_factory=threading.Lock)` (existing test constructions unaffected — default factory).
- `cmd_serve` line 5460: `watcher_lock = threading.Lock()` → `watcher_lock = watcher.lock` (server, engine, tick, backfill all keep receiving `watcher_lock` — now the same object as `watcher.lock`).
- New helper (Section B): `locked_snapshot(watcher)` = `with watcher.lock: return watcher.snapshot()`. Docstring: *the only sanctioned way to take a snapshot from an unlocked context; NEVER call while holding the lock (non-reentrant — deadlock).*
- Call-site edits, exhaustive: `preflight_port` 3805, `preflight_memory` 3890, `switch_preflight` 4966 → `snapshot = locked_snapshot(watcher)`; `handle_switch` 2610 → `fleet_fingerprint(locked_snapshot(watcher))`. This transitively covers every site the MVP4.md ledger names (handle_edit and `_run_rollout` via `preflight()`; handle_switch_preview, handle_switch, `_run_switch` via `switch_preflight`). **Verified lock-free at each call point:** handlers hold no lock; `_run_switch` calls `switch_preflight` outside any `with` (4455); `start_switch` takes the lock only after preflight has run at the route. Sites already inside `with watcher_lock:` (2892, 4065, 4286, 4372, 4463, 4515, 4574, 4611, 4744, 5535, 5614) **keep their direct `snapshot()` calls — do not "unify" them; that is the deadlock** (Risk 2).

**(ii) Guard additions** (`test_server.py::TestWriteGuards`; docstrings say "regression guard, not anti-malice" per the contract):
- `test_only_subprocess_gateways_spawn` gains: collect every `ast.Attribute` `os.system` / `os.popen` **anywhere in the file, gateways included** → assert zero (the gateways use `subprocess`; there is no legitimate use).
- `test_file_writes_confined` gains the pathlib name-form: every `Call` whose func is an `Attribute` with `attr == 'open'` and whose **first positional** arg or `mode=` kwarg is a string constant containing any of `w a x +` must be inside `WRITE_FUNCS = {'_atomic_write'}` (note the arg-position difference from builtin `open`, whose mode is the *second* positional — the existing check keeps args[1]). `.write_text`/`.write_bytes` are already covered for arbitrary receivers (lines 984–994) — no change, stated.

**(iii) Timeouts.** `_start_unit(self, unit_name, timeout: float = START_TIMEOUT_SEC)` passes `timeout=timeout` to `run_actuate`; `_watch_unit` (~4290): `timeout = 900` → `timeout = WATCH_TIMEOUT_SEC`. Test: engine harness recorder asserts the start call's `timeout == START_TIMEOUT_SEC`; a source assertion that the literal `900` no longer appears inside `_watch_unit`'s body.

**(iv) UI cosmetics.** §4.7 above; plus `_fail_rollout` stores `failure = {'reason': reason, 'detail': text, 'phase': phase}` (additive — record consumers spread the dict; `rollout_public_record` passes `failure` through untouched, verified line 2918).

## 7. TEST / GUARD SPEC

**7.1 Guard evolution (`TestWriteGuards`):**
1. `ROLLOUT_CALLSITES` (test line 584) → `{'_stop_unit','_start_unit','_daemon_reload','_set_enablement'}` — the acceptance row's "grows by exactly one" is asserted by the frozen set literal itself; leg (c) then admits the new method, legs (a)/(b) already cover the new `enable`/`disable` literals (recon 3), leg (d) unchanged.
2. Route-table test: behavioral list gains `'/api/units/dummy.service/enablement'` (→ 403 unarmed); expected frozen set gains the 7th entry; `from_frozen` fragments gain `'/enablement'`.
3. New `test_snapshot_calls_locked`: every `Call` with callee attr `snapshot` must be (a) lexically inside an `ast.With`, or (b) inside a `FunctionDef` named `locked_snapshot`. Docstring honest: proves with-block presence, not lock identity.
4. §6(ii) extensions above.
5. Gateway-set and arming tests: unchanged (no new gateways, no new arming).

**Seeded-violation acceptance** (one-off at integration, seed → red → unseed, red names in the commit message): (s1) `run_actuate(["systemctl","--user","enable","--","x.service"], ...)` inside a Section C helper → red (integration note: the verb literal necessarily trips leg (a) before leg (c) can run; an s1b variant with the verb in a variable proved leg (c) independently — guard sound, original wording imprecise); (s2) `os.system("systemctl --user restart x")` in Section B → subprocess guard red; (s3) `Path(p).open("w")` in Section B → file-write guard red; (s4) a bare `watcher.snapshot()` in a new Section C helper, no `with` → snapshot-lock guard red; (s5) an `'/api/units/<name>/kick'` dispatch branch → route-table guard red.

**7.2 New unit tests** (ownership in §8):
- `TestEnablementPreflight` (test_actuation.py): the G3 matrix — no-claimant enable ok; enabled non-retired same-port claimant → fail, claimants named with rung; disabled claimant ok; retired claimant ok; **gated enabled claimant → fail** (gate does not exempt); **two defaulted-:8080 units: first ok, second (after first enabled) → fail**; self-port → fail with the roundhouse pseudo-claimant; **claimant rung irrelevance** (claimant OFF-but-enabled still fails); target rung irrelevance (FAILED/READY targets toggle freely); disable always ok even amid collisions; retired target fails both directions; **the mixperten double-refusal fixture**: (leg 1) retired mixperten → `preflight_failed(retired)`; (leg 2) a non-retired clone of mixperten on :8085 with qwen3.6-coding enabled → `enable_collision` naming it — refused twice over, per the acceptance row.
- `TestEnablementGateway`: run_actuate accepts exactly the two new shapes; rejects `--now` (len 6), two units, missing `--`, non-selected, retired; **regression-pins** that `daemon-reload` with a trailing unit is now rejected; `ENABLEMENT_VERBS == {"enable","disable"}`, disjoint from `ACTUATE_SYSTEMCTL_VERBS`, which still equals `{"stop","start","daemon-reload"}`.
- `TestEnablementEngine`: `_set_enablement` recorder test — exactly one argv, correct shape, no lock held during the call; with `run_git` and `_atomic_write` monkeypatched to raise, both directions complete (engine-leg zero-writes).
- `TestEnablementRoutes` (route level, `TestRoutesAuth` harness): 403 unarmed / 401 bad token; 404; 400 for `"enabled": "true"`, `1`, `null`, absent; 405 GET; 200 body keys frozen `{unit,enabled,was,changed,strategy_note}`; idempotent re-enable → `changed: false` and systemctl **was** invoked (recorder); 422 `enable_collision` shape; 422 retired; read-back applied — after a toggle the next `/api/units` shows the new `enabled` with **no tick having run** (stub); route-leg zero-writes (raising `run_git`/`_atomic_write`, recorder shows exactly one `enable|disable` + one `show`, no stop/start/daemon-reload); SSE recorder saw one `enablement` event with `strategy_note`.
- `TestEnablementSlotless`: scripted non-terminal rollout in `engine.current` → toggle answers 200 and `engine.current` is the identical object after; scripted non-terminal switch → same; a rollout submitted mid-toggle is governed only by its own slot rules.
- `test_watcher.py`: `strategy_note` truth table (the two exact strings incl. the em-dash; null for enabled+READY, disabled+OFF, retired-anything); `apply_unit_file_state` sets only `unit_file_state`, **not** `sensed_at`; snapshot-shape gains `strategy_note` (row) + `self_unit` (top level — update the frozen top-level key set too); `parse_show_blocks` with `roundhouse.service` appended; `apply_systemctl_show` pops the self block without creating phantom unit state.
- `_fail_rollout` stores `failure.phase` (extend an existing failure-path test).
- Static UI (test_server.py::TestMobileStatic): `'enablement'` listener string; `.enable-toggle` present and in the 44 px rule; `#token` in the 44 px rule; `stopPropagation` present; `#toast` present; `' GB '` absent from index.html; `failure.phase` string present; existing no-innerHTML/no-localStorage assertions re-run over the grown file.

## 8. WORK BREAKDOWN — 3 tasks, composable without coder contact

All cross-task interfaces frozen in §§3–6 (signatures, JSON/SSE shapes, route table, constants, exact strings). Merge order **T1 → (T2 ∥ T3) → integration** (seeded run §7.1 + container drill §5 + live drill).

**T1 — Watcher + gateway + engine + Section-E debt** *(first; T2/T3 depend only on its frozen shapes)*
- **Writes:** all Section B items (§2 list: `strategy_note`, `locked_snapshot`, `lock` + `self_unit_file_state` fields, `apply_unit_file_state`, self-unit pop, snapshot keys); Section E: `ENABLEMENT_VERBS`, `run_actuate` per-verb table, `_set_enablement`, `enablement_preflight` + Part 4 banner, `_start_unit`/`_watch_unit` timeout fixes, `_fail_rollout` `failure.phase`, `locked_snapshot` edits in `preflight_port`/`preflight_memory`/`switch_preflight`; Section D: `watcher_lock = watcher.lock`, tick show-list + `parse_show_blocks` order arg.
- **Tests owned:** `TestEnablementPreflight/Gateway/Engine`; all `test_watcher.py` items; timeout + `failure.phase` assertions. **Must not touch:** Section C, `index.html`, `TestWriteGuards`.
- **Self-test:** `cd mvp1 && python3 -m unittest discover -s tests -v` — every pre-existing test green unmodified **except** `test_snapshot_shape` (the two-key update); plus `python3 -c` proof that unarmed `run_actuate(["systemctl","--user","enable","--","x.service"],{})` raises.

**T2 — Route + handler + guards** *(parallel with T3)*
- **Writes:** Section C: `do_POST` branch, `handle_enablement`, `handle_switch` locked-snapshot line, `serve_deployments` note, `_build_port_board` boot suffix; `FROZEN_POST_ROUTES` 7th entry.
- **Tests owned:** `TestEnablementRoutes`, `TestEnablementSlotless`; all `TestWriteGuards` evolution (§7.1 items 1–4); documents the seeded-violation additions (s1–s5) without running them.
- **Self-test:** full discover green; curl smoke against `--serve --actuate` in a scratch repo: toggle 200 both ways, `is-enabled` agrees, 422 on a seeded two-unit collision fixture, 401/403 legs.

**T3 — UI + scripts** *(depends only on frozen JSON/SSE shapes)*
- **Writes:** `index.html` complete §4; `container-setup.sh` per §5 (linger, unit install, printed flow); `roundhouse.service` comment rewrite; `scripts/enablement-drill.sh`.
- **Tests owned:** the static UI assertions (§7.2 last block).
- **Self-test:** `python3 -m unittest tests.test_server -v`; container drill §5 steps 1–7 end-to-end; desktop ≥ 1200 px side-by-side check.

**Shared-file ownership:** `roundhouse.py` — T1 owns Sections B/E/D edits, T2 owns Section C (+ port-board function); `test_actuation.py` — T1 owns Preflight/Gateway/Engine classes, T2 owns Routes/Slotless; `test_server.py` — T2 owns `TestWriteGuards`, T3 owns `TestMobileStatic`; neither edits the other's classes nor any MVP2/MVP3 class beyond the listed frozen-set updates.

## 9. TEST PLAN — mapped 1:1 to MVP4.md's acceptance checklist

| criterion | proven by |
|---|---|
| Checkbox on every selected non-RETIRED unit, live UnitFileState; RETIRED none | **Static:** `.enable-toggle` in all four category branches, absent for retired. **Container:** count checkboxes = 5 units (mixperten none); toggle reflects `is-enabled`. |
| Enable into declared collision → 422 with claimants; mixperten refused twice over; disable always succeeds | **Unit:** `TestEnablementPreflight` matrix + double-refusal fixture; `TestEnablementRoutes` 422 shapes. **Container:** drill steps 2 + 7. |
| Gated unit enables freely → STANDBY + `returns at boot` | **Unit:** preflight matrix (target gate never checked) + `strategy_note` table. **Container:** drill step 2 (npu unit). |
| Both drift notes render; both appear in the container drill | **Unit:** `strategy_note` exact strings. **Container:** drill step 3. |
| `run_actuate` exact shapes; `--now`/lists/non-selected/RETIRED rejected; whitelist +1 name | **Unit:** `TestEnablementGateway`; `TestWriteGuards` frozen 4-name set. |
| 403/401 rows; zero writes + zero git (three-leg proof) | **Unit:** `TestEnablementRoutes` auth + raising-stub legs; **AST:** call-site whitelist; **Container:** drill step 4 rev-parse + mtimes. |
| Toggle allowed during rollout/switch and vice versa (no slot) | **Unit:** `TestEnablementSlotless` both directions. **Container:** toggle during a long fake switch. |
| Container reboot: enabled came back, disabled stayed down | **Container:** drill legs A + B (two `incus restart`s; linger installed by setup). |
| Debt rows: lock unification, two guard seeds red, timeout constants | **Unit:** `test_snapshot_calls_locked`, extended guards green-on-clean; **Integration:** seeds s2/s3/s4 red; recorder timeout assertion. |
| `roundhouse.service` enabled in container; demo survives reboot | **Container:** setup installs unit; drill step 1 + leg A (`self_unit.enabled`, API answers post-reboot). |
| Live boltzmann phone drill (may remain open at push) | `scripts/enablement-drill.sh` step 8. |
| No build step; stdlib only; no German; no throughput figures | Existing stdlib AST test green; review grep; no new numeric surfaces. |

## 10. RISKS — top 3 mechanical-coder failure modes and the guards placed

1. **The reboot drill fails for environmental reasons and gets "fixed" in code** — the classic trap: without `loginctl enable-linger`, *no* user unit starts at container boot, and a coder facing "enabled unit didn't come back" may bolt reconciliation logic onto Roundhouse (violating the milestone's entire premise) instead of fixing the container. **Guards:** linger is an unconditional setup-script step with a comment naming this exact failure mode; the drill asserts llama-task (the pre-existing enabled canary) returned *before* judging any MVP4 behavior; the contract's "no autonomous action — ever" plus the AST guards leave no sanctioned place for reconciliation code to live.
2. **Lock unification over-applied → deadlock, or under-applied → the debt survives.** A coder "unifying" the engine's already-locked sites onto `locked_snapshot` deadlocks a worker thread on the non-reentrant lock while it holds the lock the tick and every GET need — freezing the whole process; or converts only some of the four sites. **Guards:** §6(i) enumerates exactly four edit sites and explicitly forbids touching in-`with` sites; `locked_snapshot`'s docstring states the never-under-the-lock rule; `test_snapshot_calls_locked` catches new unlocked sites; the route-test harness's socket timeouts turn a deadlock into a red test, not a hang.
3. **Scope creep on the toggle** — `--now`, a "helpful" daemon-reload after enabling, a slot check "for safety", or optimistic checkbox state in the UI. **Guards:** the G2 shape table makes `--now` unrepresentable; the route-leg zero-write test asserts the recorder saw *exactly* one enable/disable plus one show (any extra verb fails it); `TestEnablementSlotless` asserts `engine.current` identity across a toggle; the UI spec renders only from `state` updated by 200/SSE payloads, and the static tests pin the listener + stopPropagation so the row cannot silently diverge from systemd's truth.

**Out of scope (do not build, per contract):** autonomous reconciliation beyond systemd's own, any desired-state store, `--now` semantics, mask/unmask, system-level units, multi-host, openrc or any init abstraction (decided: never), template/instance units, migration steps for the live fleet (it already conforms), a checkbox for `roundhouse.service` itself (G8).

---

**Relay-worthy findings for the committer:** (1) `loginctl enable-linger` is missing from `mvp1/scripts/container-setup.sh` and is load-bearing for the reboot acceptance row — without it the drill fails for reasons unrelated to any MVP4 code (Risk 1). (2) `run_actuate` (`roundhouse.py` line 2996) today accepts `["systemctl","--user","daemon-reload","--",unit]` because the len-5 branch checks the whole `ACTUATE_SYSTEMCTL_VERBS` set; the G2 per-verb table closes this and the spec regression-pins it. (3) The tests' `WRITE_VERBS` set already contains `enable`/`disable`, so the existing write-verb guard extends to the new verbs with no test edit — only `ROLLOUT_CALLSITES` grows (to exactly four names). (4) Only four `snapshot()` call sites are actually unlocked (2610, 3805, 3890, 4966); the ledger's "six" counts callers that route through them — the concrete fix is four one-line edits plus the `Watcher.lock` field and helper. (5) `_fail_rollout` does not record the failing phase, so the stepper-honesty cosmetic requires the additive `failure['phase']` record field specified in §6(iv).
