diff --git a/docs/superpowers/specs/2026-05-31-m1-evidence-audit-design.md b/docs/superpowers/specs/2026-05-31-m1-evidence-audit-design.md new file mode 100644 index 0000000..f86605d --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-m1-evidence-audit-design.md @@ -0,0 +1,381 @@ +# M1 — Evidence & Audit Design + +Date: 2026-05-31 + +Parent design: `2026-05-31-go-focus-os-design.md` + +## Purpose + +M1 gives the daemon eyes and a memory. It adds a continuous active-window +sensor, a two-tier evidence store, and live updates of what you are doing — then +seals each finished session into a tamper-evident, hash-chained audit trail. + +M1 is **observe & record only**. It makes no judgment about whether the current +window is on-task; that is the advisor's job in M3. M1 is honest +instrumentation: the trustworthy foundation that later milestones read from. + +In the architecture of the parent design, M1 builds the first real **port** end +to end — the activity port (`evidence.Source`) with its X11 adapter and a fake +for tests — establishing the interface + adapter + fake pattern every later port +copies. + +## Scope + +In scope: + +- `evidence` package: an X11 (xgbutil) active-window sensor behind a `Source` + interface, pushing a `WindowSnapshot` on every focus change; evidence health; + the legacy title-scrubbing regex as a tested `ScrubTitle` function. +- `store` extensions: a per-session raw focus-event log (`sessions/.jsonl`, + appended live, pruned after a retention window) and a permanent hash-chained + audit log (`audit.jsonl`, one linked `SessionSummary` per completed session). +- `session.Controller` extensions: in-memory per-session evidence stats, an + injectable clock, focus accumulation while `Active`, crash-recovery replay, + and writing the hashed summary at session end. +- `web` extensions: the SSE payload carries an `evidence` object; the Active and + Review views surface current window, per-bucket time, context-switch count, + and an evidence-health indicator. A broadcast callback registered on the + controller so every state change (focus, expiry, user action) fans out over + one path. +- Unit tests for `evidence` (scrub + interface), `store` (audit chain + + evidence log), `session` (accumulation via fake source + injectable clock), + and `web` (evidence payload over SSE). + +Out of scope (deferred): + +- Any AI / drift judgment / on-task vs off-task classification (M2–M3). +- Allowed-context matching and violation friction (M3). +- Enforcement of any kind, including window-minimize (M7). +- Wayland active-window support beyond degraded `Unavailable` reporting. +- Visual design polish (M4) — M1 surfaces data legibly, nothing more. + +## Architecture Fit + +Per the parent design's "ports around a decision core": + +- **Skeleton** (`domain`, `statemachine`) is unchanged by M1. +- **Nervous system** (`session.Controller`) gains evidence ownership: it + receives sensor events, decides relevance by current runtime state, + accumulates stats, persists raw events, and writes the audit summary. It + remains the single hub. +- **Activity port** (`evidence.Source`) is new — a dumb sensor that makes no + decisions. `session` decides what each event means. + +The sensor never judges; the orchestrator never talks to X11 directly. This is +the port boundary M1 exists to establish. + +## The Two-Tier Evidence Model + +Window focus can change dozens of times a minute. Keeping every focus event in +the permanent hash chain forever would bloat it. Discarding detail loses the +ability to compute things like context-switch frequency. M1 resolves this with +two tiers: + +1. **Raw, per-session, disposable.** Every focus change is appended live to + `sessions/.jsonl` as it happens (crash-durable). This is the + firehose: full titles, millisecond timestamps. It is the recovery source for + in-memory stats and the basis for end-of-session analytics. It is **pruned + after 30 days**. +2. **Summarized, permanent, tamper-evident.** At session end the raw stream is + rolled up into one `SessionSummary` (per-bucket totals, switch count, + duration, outcome) which is hash-chained into `audit.jsonl` and **kept + forever**. The chain is coarse — one linked entry per session — so tamper + evidence is cheap and the log stays small. + +## Components + +### `evidence` (new package) + +A dumb X11 sensor. One long-lived xgbutil connection is opened at daemon start +and kept open for the whole process. It subscribes to `PropertyNotify` on the +root window's `_NET_ACTIVE_WINDOW` and emits a `WindowSnapshot` on every active +window change (and once immediately with the current window). It makes no +relevance decisions. + +```go +type EvidenceHealth struct { + Available bool + Reason string // empty when Available; populated when not +} + +type WindowSnapshot struct { + Title string // full _NET_WM_NAME (raw; used in live view + raw log) + Class string // WM_CLASS + Health EvidenceHealth +} + +type Source interface { + // Watch runs until ctx is cancelled, invoking onChange on every active + // window change, and once immediately with the current window. + Watch(ctx context.Context, onChange func(WindowSnapshot)) +} +``` + +- Real implementation `x11Source` uses xgbutil: + `ewmh.ActiveWindowGet` → `ewmh.WmNameGet` (title) + `icccm.WmClassGet` (class). +- On X failure, no active window, or unset `DISPLAY`: + `Health{Available: false, Reason: "..."}` with empty title/class. This mirrors + the legacy degraded reporting and covers Wayland implicitly. +- `ScrubTitle(string) string` is the legacy digit/percent regex + (`-?\d+([:.]\d+)+%?`) ported as its own tested function. Used to compute + bucket keys; the raw log keeps the unscrubbed title. + +### `store` (extended) + +Two new files beside `snapshot.go`. `store` is infrastructure, not a port. + +`store/audit.go` — the permanent hash-chained log at `~/.antidrift/audit.jsonl`: + +```go +type BucketTotal struct { + Class string `json:"class"` + Title string `json:"title"` // scrubbed + Seconds int64 `json:"seconds"` +} + +type SessionSummary struct { + Seq int `json:"seq"` + PrevHash string `json:"prev_hash"` + SessionID string `json:"session_id"` + NextAction string `json:"next_action"` + SuccessCond string `json:"success_condition"` + Outcome string `json:"outcome"` // "completed" | "expired" + StartedUnix int64 `json:"started_unix"` + EndedUnix int64 `json:"ended_unix"` + SwitchCount int `json:"switch_count"` + Buckets []BucketTotal `json:"buckets"` // sorted desc by seconds + Hash string `json:"hash"` +} + +func AppendSession(path string, s SessionSummary) error +func VerifyChain(path string) error +``` + +- `AppendSession` reads the last line's `Hash` as this entry's `PrevHash` + (genesis = 64 hex zeros), assigns `Seq = lastSeq + 1`, computes + `Hash = SHA-256(prevHash || canonicalJSON(fields-except-Hash))`, and appends + one JSON line atomically (append + fsync). Canonical serialization uses the + struct with `Hash` zeroed and stable field order (encoding/json is stable for + structs). +- `VerifyChain` re-walks every line: recompute each hash, confirm each + `PrevHash` equals the prior `Hash`, confirm `Seq` is contiguous. Returns an + error naming the first broken line; nil if intact or empty. + +`store/evidence_log.go` — the per-session raw stream at +`~/.antidrift/sessions/.jsonl`: + +```go +type FocusEvent struct { + AtUnixMillis int64 `json:"at_unix_millis"` + Class string `json:"class"` + Title string `json:"title"` // full, unscrubbed + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` +} + +func AppendFocus(dir, sessionID string, e FocusEvent) error +func ReplaySession(dir, sessionID string) ([]FocusEvent, error) +func PruneOlderThan(dir string, age time.Duration, now time.Time) error +``` + +- `AppendFocus` opens the session file `O_APPEND|O_CREATE`, writes one JSON + line, closes. Creates `sessions/` if needed. +- `ReplaySession` reads all events in order (used to rebuild stats after a + crash). +- `PruneOlderThan` deletes session files whose modification time is older than + `now - age`. `now` is injected for testability. + +### `session.Controller` (extended) + +Gains: + +- `clock func() time.Time` — injectable; defaults to `time.Now`. Used for all + accumulation and timestamps so tests are deterministic. +- `stats *EvidenceStats` — in-memory, current session only (nil when not in a + live session). +- `onChange func()` — a notification callback the web layer registers; invoked + after any state change so the browser is pushed fresh state. +- A reference to the store paths (audit file, sessions dir). + +```go +type bucketKey struct{ Class, Title string } // Title is scrubbed + +type EvidenceStats struct { + SessionID string + StartedUnix int64 + Buckets map[bucketKey]time.Duration + SwitchCount int + Current evidence.WindowSnapshot + lastFocusAt time.Time + lastKey bucketKey + hasLast bool +} +``` + +`RecordWindow(snap evidence.WindowSnapshot)`: + +1. Lock. Always update `c.latestWindow = snap` (for the live indicator outside + Active). +2. If `runtimeState != Active` or `stats == nil`: fire `onChange`; return. + (Tracked for display, not accounted.) +3. `now := c.clock()`. If `hasLast`: add `now - lastFocusAt` to + `Buckets[lastKey]`. (If the prior snapshot was unavailable, `lastKey` is the + reserved `(evidence unavailable)` key.) +4. Append a `FocusEvent` to the session log. +5. Compute the new key: unavailable snapshot → reserved key + `{Class: "", Title: "(evidence unavailable)"}`; else + `{snap.Class, ScrubTitle(snap.Title)}`. If `hasLast && newKey != lastKey`, + increment `SwitchCount`. +6. Set `lastKey = newKey`, `lastFocusAt = now`, `hasLast = true`, + `Current = snap`. Fire `onChange`. + +Lifecycle hooks (all under the existing mutex): + +- **`StartManualCommitment`** (→ Active): mint `session_id` (UUIDv7), create + `stats` with `StartedUnix = clock()`, seed `Current`/`lastKey`/`lastFocusAt` + from `latestWindow` (so the first segment counts from start), `hasLast = true`. + Persist snapshot (now carrying `session_id`). +- **`Complete`** / **timebox expiry** (→ Review): flush final segment + (`clock() - lastFocusAt` into `lastKey`), freeze `stats` (stop accounting). + Consistent with M0's "freeze active time during review." +- **`End`** (→ Locked): build `SessionSummary` from frozen `stats` + (`Outcome` = "completed" if user-completed, "expired" if timebox fired — + tracked via a field set at the Active→Review transition), `AppendSession` to + the audit chain, then clear `stats`. + +Read view: `State()` is extended to include an evidence projection (current +window, buckets sorted desc, switch count, health) for the SSE payload. + +### Crash Recovery + +The snapshot (`state.json`) gains `session_id` and `outcome_pending` fields. + +On startup, after loading the snapshot: + +- Run `store.PruneOlderThan(sessionsDir, 30*24*time.Hour, time.Now())`. +- If runtime state is `Active` with a `session_id`: `ReplaySession` the raw log + and rebuild `EvidenceStats` exactly (sum segments between consecutive events; + the final open segment resumes from the last event's timestamp). The raw log + is the recovery source of truth for stats; the snapshot only points at it. +- The M0 deadline re-arm / expire-to-Review behavior is unchanged and runs + after stats are rebuilt. + +### `web` (extended) + +- At startup the `Server` registers `ctrl.SetOnChange(func(){ broadcast() })`. + Every state change — focus update, timebox expiry, user action — flows out + through this single path. This also moves M0's expiry broadcast onto the same + callback rather than an inline broadcast. +- The SSE/POST payload gains an `evidence` object: + +```json +{ + "runtime_state": "active", + "commitment": { "next_action": "...", "success_condition": "...", + "timebox_secs": 1500, "deadline_unix_secs": 1748725200 }, + "evidence": { + "available": true, + "reason": "", + "current": { "class": "code", "title": "m1 design - antidrift" }, + "switch_count": 7, + "buckets": [ + { "class": "code", "title": "antidrift", "seconds": 540 }, + { "class": "firefox", "title": "docs", "seconds": 120 } + ] + } +} +``` + +`evidence` is null when there is no live session (Locked/Planning). In Review it +carries the frozen stats. + +### Browser UI (data, not polish) + +The page stays a pure renderer of pushed state. Additions: + +- **Active view:** current window line (`class · title`); a per-bucket time + breakdown list (sorted desc, `mm:ss`); the context-switch count; an + evidence-health pill — green "tracking" when available, amber + "evidence unavailable: " when not. +- **Review view:** the session summary about to be hashed — total active time, + switch count, top buckets. +- Visual treatment is minimal and legible; the real design pass is M4. + +## Data Flow + +``` +X server ──PropertyNotify──▶ evidence.x11Source.Watch (goroutine) + │ onChange(WindowSnapshot) + ▼ + session.Controller.RecordWindow + │ (accumulate if Active) │ append raw event + ▼ ▼ + EvidenceStats (memory) sessions/.jsonl + │ onChange() + ▼ + web broadcast ──SSE──▶ browser + ... + End: SessionSummary ──hash-chain──▶ audit.jsonl (permanent) +``` + +## Error Handling + +- **Evidence unavailable** (X error, no active window, Wayland): snapshot has + `Health.Available = false`; time during the gap accrues to the reserved + `(evidence unavailable)` bucket so totals always equal wall-clock duration. + The watch goroutine logs and continues; it never crashes the daemon. +- **Session log append failure:** logged; accounting continues in memory (the + raw log is best-effort detail, not the state of truth). Crash recovery for + that session would be incomplete, which is acceptable for disposable detail. +- **Audit append failure at End:** logged and surfaced; the transition to Locked + still completes (state integrity over audit completeness). A retry-on-next- + start is out of scope. +- **Corrupt audit chain:** `VerifyChain` reports the first broken line; M1 only + exposes verification, it does not auto-repair. + +## Testing + +- `evidence`: `ScrubTitle` table tests porting the legacy regex cases + (percentages, ratios, leading sign, plain titles untouched). The `x11Source` + sits behind `Source`; a `//go:build` integration smoke test queries the live + server and is skipped when `DISPLAY` is unset. +- `store/audit`: append N summaries then `VerifyChain` passes; genesis + `PrevHash` is 64 zeros; `Seq` is contiguous; a tamper test mutates a middle + line's field and asserts `VerifyChain` names that line. +- `store/evidence_log`: `AppendFocus` then `ReplaySession` round-trips event + order/values; `PruneOlderThan` deletes only files older than the cutoff + (modification time controlled). +- `session`: a `fakeSource` plus injectable `clock` drive scripted focus + sequences with controlled timestamps. Assert: bucket totals and switch count; + events outside Active are not accounted; unavailable snapshots accrue to the + reserved bucket; crash-replay rebuilds identical stats; `End` writes a + summary whose buckets/switch-count match and which extends the chain. +- `web`: httptest confirms `/events` emits an evidence-bearing payload and that + a simulated focus change pushes an updated payload. + +## Done When + +`go run ./cmd/antidriftd`: start a commitment, switch between a few windows, and +watch the Active view update live with the current window, per-app time, and +switch count. Let it complete (or expire) into Review and see the session +summary. End it; `audit.jsonl` gains one hash-chained line and `VerifyChain` +passes. Kill the daemon mid-session and restart: the per-window stats are +rebuilt from the session log and tracking resumes. Session files older than 30 +days are gone on startup. `go test ./...` passes. + +## File Structure + +- Create: `internal/evidence/evidence.go` (types, `Source`, `ScrubTitle`) +- Create: `internal/evidence/x11.go` (`x11Source`, xgbutil; build-tagged) +- Create: `internal/evidence/evidence_test.go` +- Create: `internal/store/audit.go`, `internal/store/evidence_log.go` +- Create: `internal/store/audit_test.go`, `internal/store/evidence_log_test.go` +- Modify: `internal/store/store.go` (snapshot gains `session_id`, + `outcome_pending`) +- Modify: `internal/session/session.go` (clock, stats, RecordWindow, lifecycle + hooks, onChange, recovery), `internal/session/session_test.go` +- Modify: `internal/web/web.go` (SetOnChange wiring, evidence payload), + `internal/web/web_test.go`, `internal/web/static/index.html` +- Modify: `cmd/antidriftd/main.go` (construct `evidence.Source`, start `Watch`, + wire to controller) +- Modify: `go.mod` (add `github.com/jezek/xgb`, `github.com/jezek/xgbutil`)