Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 withewmh.ActiveWindowGetand iconifies it viajezek/xgbutil(xwindow.Window.Iconify, which sends the ICCCMWM_CHANGE_STATE→IconicStateclient message). Same dependency already ingo.modand used by the evidence adapter. Noxdotoolshell-out. A freshxgbutil.NewConn()failure (no display, Wayland) yields a Guard whoseMinimizeActivereturns an error every call — the controller logs and continues.internal/enforce/guard_other.go(//go:build !linux): a no-op Guard whoseMinimizeActivereturns nil, exactly likeevidence/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:
StartManualCommitmentgains anEnforcementLevelparameter. The web handler reads it from the planning form. (The existingdomain.NewManual/PolicySnapshotalready carry the field; this wires the caller.)- Planning UI: an "Enforce focus" toggle. On →
block; off →warn(today's advisory behavior).observeandlockedare not surfaced in Tier A —lockedis the Tier C entry gate, andobserveadds nothing overwarnfor this milestone. - Effective levels in Tier A: only
warn(advisory, no minimize — current behavior) andblock(minimize on confirmed drift). The Guard acts iff the level isblock.
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:
- Synchronously in
evaluateDriftLocked, on a per-class cache hit. - 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)
}
}
}
RecordWindowalready runs the optional judgelaunch()in a goroutine after unlocking. It additionally capturesenforceActionLocked()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 releasesc.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.enforcedis 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
MinimizeActiveerror → 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'sMinimizeActivereturns nil. (The X11 adapter is integration-tested behind a build tag / display guard likeevidence/x11_integration_test.go, not in unit tests.)session: with afakeGuardrecordingMinimizeActivecalls —- 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
Enforcedflag appears in the projection precisely while drifting atblock; - the chosen
EnforcementLevelsurvives a snapshot round-trip (restart).
- minimize fires on confirmed drift at
web: the planning form's enforce toggle postsblock; the Review/Active payload carriesdrift.enforced; the band note renders.
Out of scope (this tier)
- Tier B (nftables/DNS) and Tier C (entry gate). Separate specs.
observe/lockedlevels in the planning UI.lockedis the Tier C gate;observeis redundant withwarnhere.- 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.