Files
antidrift/docs/superpowers/specs/2026-05-31-m1-evidence-audit-design.md
T
felixm e68b7437fa Spec M1: evidence & audit
The activity port (evidence.Source + X11 adapter + fake), a two-tier
evidence store (disposable per-session raw log + permanent hash-chained
session summaries), live SSE evidence updates, and crash-recovery replay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:22:51 -04:00

16 KiB
Raw Blame History

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/<id>.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 (M2M3).
  • 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/<session-id>.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.

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.ActiveWindowGetewmh.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:

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/<session-id>.jsonl:

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).
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:
{
  "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/<id>.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)