Files
antidrift/docs/superpowers/specs/2026-06-01-m8-enforcement-window-minimize-design.md
T
2026-06-01 12:11:56 -04:00

12 KiB

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.

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_STATEIconicState 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:

// 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:

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.