diff --git a/docs/superpowers/specs/2026-06-01-m8-enforcement-window-minimize-design.md b/docs/superpowers/specs/2026-06-01-m8-enforcement-window-minimize-design.md new file mode 100644 index 0000000..8e7e22c --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-m8-enforcement-window-minimize-design.md @@ -0,0 +1,262 @@ +# M8 (Tier A) — Window-minimize Enforcement: Design + +**Status:** approved +**Date:** 2026-06-01 +**Milestone:** M8 — Enforcement & gate, **Tier A**: the unprivileged +`enforce.Guard` port and its window-minimize adapter + +## Purpose + +Make drift finally *cost something*. Through M7 the system tracks, advises, and +reflects, but drift is purely advisory: `domain.EnforcementLevel` +(observe/warn/block/locked) is defined but **never acted on**, and the drift +judge's verdict only changes what the browser shows. M8 turns "track and advise" +into "you don't drift in the first place." + +Tier A is the first, gentlest, **unprivileged** slice: when the drift judge +confirms the active window is off-task **and** the session opted into +enforcement, a new **Guard** minimizes that window, pushing the user back toward +an allowed context. It activates the dormant `EnforcementLevel` and establishes +the `enforce.Guard` port that the later, privileged tiers reuse. + +It runs entirely in the user's X11 session (no root), follows the port pattern +M1 established, degrades gracefully (no X11 / no Guard / Wayland → exactly +today's behavior), and never blocks a state transition. + +## Scope + +M8 spans three privilege tiers, each its own spec → plan → build cycle: + +- **Tier A (this spec):** window-minimize. Unprivileged X11 adapter. Low risk. +- **Tier B (later):** network blocking via nftables/DNS. Needs root. +- **Tier C (later):** the privileged entry gate — guardian process, root-owned + IPC, break-glass, gating machine usability on a declared intention. The + heaviest step, deliberately last (the original Stage 2 threat boundary). + +This spec covers **only Tier A**. B and C are out of scope here. + +## Architecture shift from the legacy enforcement + +The legacy Rust app was a TUI: `minimize_other(APP_TITLE)` kept *its own window* +foregrounded by minimizing everything else, and explicitly skipped the window +whose title matched `APP_TITLE`. The Go reimagining is a daemon + browser UI with +no single app window to force forward. So Tier A inverts the legacy meaning: +rather than minimize-everything-but-us, it **minimizes the active window when +that window is the confirmed-drifting one**. The drift pipeline already judges +the active window; enforcement simply acts on that judgment. + +## The new port — `enforce.Guard` + +A leaf port mirroring `evidence.Source`: the Guard is a dumb OS primitive that +performs an action when told to. **All policy — whether and when to enforce — +lives in the controller.** The Guard imports neither `session` nor `domain`. + +```go +package enforce + +import "context" + +// Guard makes drift cost something at the OS level. Tier A: minimize the +// active window. +type Guard interface { + // MinimizeActive minimizes the currently-focused window. It is idempotent + // (minimizing an already-minimized window is a no-op) and best-effort: it + // returns an error for diagnostics, but the caller never blocks on it and + // treats failure as "enforcement did nothing this time." + MinimizeActive(ctx context.Context) error +} +``` + +Adapters, mirroring the `evidence` package's split: + +- **`internal/enforce/x11.go`** (`//go:build linux`): resolves the active window + with `ewmh.ActiveWindowGet` and iconifies it via `jezek/xgbutil` + (`xwindow.Window.Iconify`, which sends the ICCCM `WM_CHANGE_STATE` → + `IconicState` client message). Same dependency already in `go.mod` and used by + the evidence adapter. **No `xdotool` shell-out.** A fresh `xgbutil.NewConn()` + failure (no display, Wayland) yields a Guard whose `MinimizeActive` returns an + error every call — the controller logs and continues. +- **`internal/enforce/guard_other.go`** (`//go:build !linux`): a no-op Guard + whose `MinimizeActive` returns nil, exactly like `evidence/source_other.go`. + +A package-level constructor `NewGuard() Guard` is selected by build tag, matching +`evidence.NewSource()`. + +**Rejected alternative:** a policy-aware `Enforce(level, drifting bool, snap)` +Guard that decides internally whether to act. That pushes branching logic into +the platform-specific, hard-to-unit-test adapter and breaks the leaf pattern +`ai` and `evidence` establish. Keeping the Guard a pure primitive keeps all the +testable decision logic in the controller, where a fake Guard makes it trivial +to assert. + +## Activation — the dormant level, switched on + +`EnforcementLevel` already exists in `domain` but is set nowhere. Tier A plumbs +it through: + +- **`StartManualCommitment` gains an `EnforcementLevel` parameter.** The web + handler reads it from the planning form. (The existing + `domain.NewManual`/`PolicySnapshot` already carry the field; this wires the + caller.) +- **Planning UI:** an **"Enforce focus"** toggle. On → `block`; off → `warn` + (today's advisory behavior). `observe` and `locked` are **not** surfaced in + Tier A — `locked` is the Tier C entry gate, and `observe` adds nothing over + `warn` for this milestone. +- **Effective levels in Tier A:** only `warn` (advisory, no minimize — current + behavior) and `block` (minimize on confirmed drift). The Guard acts **iff** + the level is `block`. + +The chosen level **rides the snapshot** (latest-wins persistence) so it survives +a mid-session daemon restart, exactly like the commitment itself. Runtime drift +state remains unpersisted and recomputed after restart, unchanged from M3. + +## Trigger plumbing (`session.Controller`) + +Drift settles as confirmed (`driftStatus = drifting`, via `applyVerdictLocked`) +in two existing places: + +1. **Synchronously** in `evaluateDriftLocked`, on a per-class cache hit. +2. **Asynchronously** inside the drift-judge closure, after the LLM returns. + +A single helper composes the enforcement action so both paths stay DRY: + +```go +// enforceActionLocked returns the minimize thunk when this observation should +// be enforced, else nil. Caller holds mu. The returned func performs blocking +// X11 I/O and MUST run after the lock is released. +func (c *Controller) enforceActionLocked() func() { + if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting { + return nil + } + guard := c.guard + return func() { + ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout) + defer cancel() + if err := guard.MinimizeActive(ctx); err != nil { + log.Printf("session: enforce minimize failed: %v", err) + } + } +} +``` + +- **`RecordWindow`** already runs the optional judge `launch()` in a goroutine + after unlocking. It additionally captures `enforceActionLocked()` under the + lock and runs it in a goroutine after unlock (covering the synchronous + cached-drift path). +- **The judge closure**, after `applyVerdictLocked`, likewise captures and runs + the enforcement thunk after it releases `c.mu` (covering the async path). + +Because the action fires on **every** confirmed-drift observation while at +`block`, re-raising the window while still off-task minimizes it again — the +"repeated while drifting" behavior. `MinimizeActive` is idempotent, so a +redundant call on an already-minimized window is harmless. + +No extra runtime state is stored for the UI: the drift projection **derives** +the `Enforced` flag from the level and drift status (see State projection), so it +is true exactly in the conditions under which the minimize thunk fires. + +### Why off-lock, and the small race we accept + +`MinimizeActive` is an X11 round-trip; running it under `c.mu` would block all +controller state for the duration. It runs after unlock, following the M2 +`RequestCoach` discipline already used by the coach, tasks, knowledge, reviewer, +and drift-judge fetches. Between observing drift and the minimize landing, the +user could Alt-Tab to an allowed window, which would then be the one minimized. +This window is sub-millisecond-to-millisecond; the legacy code had the same +property; we accept it. + +## State projection + +The existing `DriftView` (active-only) gains one field so the browser can +explain enforcement: + +```go +type DriftView struct { + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Nudge string `json:"nudge,omitempty"` + Enforced bool `json:"enforced,omitempty"` // a minimize fired this drift episode +} +``` + +`Enforced` is derived as `level == block && driftStatus == drifting` — no stored +field. It is runtime-only (not persisted), consistent with the rest of the drift +projection. + +## Persistence + +Snapshot-only, latest-wins. The persisted snapshot JSON gains the chosen +`EnforcementLevel` (so a restart mid-session keeps enforcing). There are **no** +changes to `audit.jsonl`, no new files, and no new on-disk format. The +hash-chained `SessionSummary` is untouched. (Recording per-session enforcement +counts in the permanent summary is a possible future addition, out of scope +here.) + +## UI (`web` static assets) + +- **Planning screen:** an **"Enforce focus"** toggle (checkbox), mirroring the + quiet style of the M6/M7 indicators. Checked → the commit posts `block`; + unchecked → `warn`. A one-line hint explains it ("Minimize off-task windows + when you drift."). +- **Active screen:** the existing M3 drift band gains a short line — + **"Off-task window minimized."** — rendered when `drift.enforced` is true. + Reuses the band; no new component. The **End**/**Refocus** buttons are + unaffected and always work. + +## Daemon wiring (`cmd/antidriftd/main.go`) + +Construct the Guard with `enforce.NewGuard()` and inject it with +`ctrl.SetGuard(g)`, alongside the other adapters. On a platform without the X11 +adapter (or with no display), the no-op / erroring Guard means enforcement +silently does nothing. The startup log line notes enforcement availability. + +## Error handling / graceful degradation + +- No Guard wired, no X11 / Wayland, or `MinimizeActive` error → nothing is + enforced; Active, drift, Refocus, and End behave exactly as today. Errors are + logged, never surfaced to the user. +- The Guard **never blocks a transition**. Minimize runs off-lock in a + goroutine under a short timeout. +- At `warn` (toggle off) the Guard is never called — identical to today's + advisory behavior. + +## Known limitation (accepted, by design) + +Unlike the legacy TUI — which protected its own window by a known title — the Go +dashboard lives in a **browser tab with no distinct window**. If the user is +*actively viewing the dashboard* in a browser that is not in their allowed +classes, that browser is the active window and may be minimized when drift is +confirmed. Mitigations: the user adds their browser to allowed classes, and the +SSE-backed state is current the moment the dashboard is reopened. We document +this rather than build unreliable title-based self-protection; a robust solution +belongs to a later tier if it proves necessary. + +## Testing + +- **`enforce`:** the no-op adapter's `MinimizeActive` returns nil. (The X11 + adapter is integration-tested behind a build tag / display guard like + `evidence/x11_integration_test.go`, not in unit tests.) +- **`session`:** with a `fakeGuard` recording `MinimizeActive` calls — + - minimize fires on confirmed drift at `block`, via **both** the per-class + cached path and the async judge path; + - minimize does **not** fire at `warn`, with no Guard wired, or while on-task; + - the `Enforced` flag appears in the projection precisely while drifting at + `block`; + - the chosen `EnforcementLevel` survives a snapshot round-trip (restart). +- **`web`:** the planning form's enforce toggle posts `block`; the Review/Active + payload carries `drift.enforced`; the band note renders. + +## Out of scope (this tier) + +- **Tier B (nftables/DNS) and Tier C (entry gate).** Separate specs. +- **`observe`/`locked` levels in the planning UI.** `locked` is the Tier C gate; + `observe` is redundant with `warn` here. +- **Minimizing all non-allowed windows** (screen-clearing). Tier A acts on the + active drifting window only, matching the existing per-active-window drift + model. Whole-screen enforcement could return later. +- **Per-session enforcement counts in the permanent `SessionSummary`.** Additive + later if wanted. +- **Title-based self-protection of the dashboard** (see Known limitation). +- **Refactoring `session.go`.** Tier A adds one small per-observation hook + following the established pattern; the broader async-fetch consolidation + remains a future target.