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>
This commit is contained in:
@@ -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/<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 (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/<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`)
|
||||||
Reference in New Issue
Block a user