76cdd4c857
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
263 lines
12 KiB
Markdown
263 lines
12 KiB
Markdown
# 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.
|