# 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.