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>
16 KiB
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:
evidencepackage: an X11 (xgbutil) active-window sensor behind aSourceinterface, pushing aWindowSnapshoton every focus change; evidence health; the legacy title-scrubbing regex as a testedScrubTitlefunction.storeextensions: 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 linkedSessionSummaryper completed session).session.Controllerextensions: in-memory per-session evidence stats, an injectable clock, focus accumulation whileActive, crash-recovery replay, and writing the hashed summary at session end.webextensions: the SSE payload carries anevidenceobject; 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), andweb(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
Unavailablereporting. - 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.sessiondecides 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:
- Raw, per-session, disposable. Every focus change is appended live to
sessions/<session-id>.jsonlas 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. - 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 intoaudit.jsonland 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
x11Sourceuses 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) stringis 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
AppendSessionreads the last line'sHashas this entry'sPrevHash(genesis = 64 hex zeros), assignsSeq = lastSeq + 1, computesHash = SHA-256(prevHash || canonicalJSON(fields-except-Hash)), and appends one JSON line atomically (append + fsync). Canonical serialization uses the struct withHashzeroed and stable field order (encoding/json is stable for structs).VerifyChainre-walks every line: recompute each hash, confirm eachPrevHashequals the priorHash, confirmSeqis 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
AppendFocusopens the session fileO_APPEND|O_CREATE, writes one JSON line, closes. Createssessions/if needed.ReplaySessionreads all events in order (used to rebuild stats after a crash).PruneOlderThandeletes session files whose modification time is older thannow - age.nowis injected for testability.
session.Controller (extended)
Gains:
clock func() time.Time— injectable; defaults totime.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):
- Lock. Always update
c.latestWindow = snap(for the live indicator outside Active). - If
runtimeState != Activeorstats == nil: fireonChange; return. (Tracked for display, not accounted.) now := c.clock(). IfhasLast: addnow - lastFocusAttoBuckets[lastKey]. (If the prior snapshot was unavailable,lastKeyis the reserved(evidence unavailable)key.)- Append a
FocusEventto the session log. - Compute the new key: unavailable snapshot → reserved key
{Class: "", Title: "(evidence unavailable)"}; else{snap.Class, ScrubTitle(snap.Title)}. IfhasLast && newKey != lastKey, incrementSwitchCount. - Set
lastKey = newKey,lastFocusAt = now,hasLast = true,Current = snap. FireonChange.
Lifecycle hooks (all under the existing mutex):
StartManualCommitment(→ Active): mintsession_id(UUIDv7), createstatswithStartedUnix = clock(), seedCurrent/lastKey/lastFocusAtfromlatestWindow(so the first segment counts from start),hasLast = true. Persist snapshot (now carryingsession_id).Complete/ timebox expiry (→ Review): flush final segment (clock() - lastFocusAtintolastKey), freezestats(stop accounting). Consistent with M0's "freeze active time during review."End(→ Locked): buildSessionSummaryfrom frozenstats(Outcome= "completed" if user-completed, "expired" if timebox fired — tracked via a field set at the Active→Review transition),AppendSessionto the audit chain, then clearstats.
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
Activewith asession_id:ReplaySessionthe raw log and rebuildEvidenceStatsexactly (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
Serverregistersctrl.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
evidenceobject:
{
"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:
VerifyChainreports the first broken line; M1 only exposes verification, it does not auto-repair.
Testing
evidence:ScrubTitletable tests porting the legacy regex cases (percentages, ratios, leading sign, plain titles untouched). Thex11Sourcesits behindSource; a//go:buildintegration smoke test queries the live server and is skipped whenDISPLAYis unset.store/audit: append N summaries thenVerifyChainpasses; genesisPrevHashis 64 zeros;Seqis contiguous; a tamper test mutates a middle line's field and assertsVerifyChainnames that line.store/evidence_log:AppendFocusthenReplaySessionround-trips event order/values;PruneOlderThandeletes only files older than the cutoff (modification time controlled).session: afakeSourceplus injectableclockdrive 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;Endwrites a summary whose buckets/switch-count match and which extends the chain.web: httptest confirms/eventsemits 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 gainssession_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(constructevidence.Source, startWatch, wire to controller) - Modify:
go.mod(addgithub.com/jezek/xgb,github.com/jezek/xgbutil)