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

382 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```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/<session-id>.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: <reason>" 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`)