# M0 — Walking Skeleton Design Date: 2026-05-31 Parent design: `2026-05-31-go-focus-os-design.md` ## Purpose M0 proves the entire Go stack end-to-end with the smallest possible surface: a single Go daemon (`antidriftd`) that serves a local web UI, drives one manual commitment through the ported state machine, and persists a snapshot that survives restart. M0 explicitly excludes AI, active-window tracking, and the hash-chained audit log (those arrive in M1+). What M0 establishes is the real architecture — the daemon process model, the ported domain/state-machine, snapshot persistence, and the SSE sync channel — so later milestones add features to a working spine rather than scaffolding. ## Scope In scope: - Port `domain` (Commitment, runtime/commitment states, EnforcementLevel, minimal PolicySnapshot, validation). - Port `statemachine` (pure runtime/commitment transition functions). - `session.Controller`: in-memory state of truth behind a mutex; snapshot on every change. - `store`: snapshot load/save as JSON. - `web`: Gin server with the M0 HTTP surface and an SSE stream. - A single-page vanilla-JS browser UI covering the M0 state flow. - Unit tests for domain, statemachine, session; httptest tests for web. Out of scope (deferred): - AI of any kind (M2+). - Active-window tracking and evidence health (M1). - Hash-chained append-only audit log (M1). - Allowed-context matching and violation friction (M3). - Transition (break) flow, admin override, planner — full machine edges beyond the M0 subset. - Visual design polish (M4). ## State Flow M0 exercises a subset of the full runtime state machine: ``` Locked ──Plan──▶ Planning ──Start──▶ Active ──Complete / timebox expiry──▶ Review ──End──▶ Locked ``` All transitions go through the ported pure transition functions, so behavior is correct from day one even though fewer edges are exercised. Mapping to the ported actions: - Locked → Planning: `EnterPlanning` - Planning → Active: `Activate { policy_accepted: true }` (commitment also `Activate`s draft → active) - Active → Review: `CompleteForReview` (triggered by user "Complete" or by server-side timebox expiry) - Review → Locked: `EndWorkPeriod` ## Components ### `domain` Direct port of the valuable Rust types: - `Commitment` (id, createdAt, source, next action, success condition, timebox seconds, state) with `NewManual(...)` validation: non-empty next action, non-empty success condition, non-zero timebox. - `RuntimeState` (Locked, Planning, Active, Transition, Review, AdminOverride) and `CommitmentState` (Draft, Active, Paused, Completed, Abandoned, Violated) — full enums, even though M0 uses a subset, so later milestones need no changes here. - `EnforcementLevel` (Observe, Warn, Block, Locked). - `PolicySnapshot`: minimal — enough to represent the accepted policy that gates `Activate`. Full enforcement fields can stay zero/empty in M0. - IDs use UUIDv7 (or equivalent monotonic unique id); JSON tags use snake_case to match the existing on-disk vocabulary. ### `statemachine` Port of `state_machine.rs`: - `TransitionRuntime(current RuntimeState, action RuntimeAction) (RuntimeState, error)` - `TransitionCommitment(current CommitmentState, action CommitmentAction) (CommitmentState, error)` - Illegal transitions return a typed error identifying current state + action. Pure functions, no I/O. ### `session.Controller` - Holds `runtimeState` and `activeCommitment` in memory, guarded by a `sync.Mutex`, as the single source of truth. - Methods: `EnterPlanning()`, `StartManualCommitment(nextAction, successCond string, timebox time.Duration)`, `Complete()`, `End()`. - Each method applies the relevant transition(s), updates in-memory state, and persists a snapshot via `store`. On any transition error, state is left unchanged and the error is returned. - Exposes a read method returning a snapshot-shaped view for broadcasting. ### `store` - Snapshot is the current state: runtime state + active commitment (+ deadline). - `Load(path) (Snapshot, error)` — missing file yields a default `Locked` snapshot, not an error. - `Save(path, Snapshot) error` — atomic write (temp file + rename) to `~/.antidrift/state.json`, creating the directory if needed. - No hash chaining in M0; that belongs to the M1 audit log. ### `web` - Gin server bound to `localhost:7777`. - Holds the `session.Controller` and an SSE broadcaster (set of subscriber channels). - Each mutating handler: acquire controller mutex → apply transition → persist snapshot → broadcast new state to all SSE subscribers → return. ## Daemon Behavior On start, `antidriftd`: 1. Loads the snapshot (or starts `Locked`). 2. If the loaded state is `Active` with a future deadline, re-arms the expiry timer; if the deadline has already passed, transitions to `Review`. 3. Starts Gin on `localhost:7777`. 4. Attempts to open the default browser at that URL (best-effort; logs and continues if it fails). Timebox expiry is **server-authoritative**: on entering `Active`, the daemon arms a `time.AfterFunc` at the deadline that fires `Active → Review` and broadcasts. The browser countdown is cosmetic and derived from the deadline timestamp in the state payload. ## HTTP Surface | Method | Route | Body | Effect | | ------ | ------------- | ------------------------------------------------- | ------ | | GET | `/` | — | Serves the single-page UI | | GET | `/events` | — | SSE stream; emits the current state immediately, then on every change | | POST | `/planning` | — | Locked → Planning | | POST | `/commitment` | `{next_action, success_condition, timebox_secs}` | Planning → Active (validates; 400 on invalid) | | POST | `/complete` | — | Active → Review | | POST | `/end` | — | Review → Locked | State payload (SSE `data:` and POST responses) is JSON: ```json { "runtime_state": "active", "commitment": { "next_action": "Port the domain package", "success_condition": "domain tests pass", "timebox_secs": 1500, "deadline_unix_secs": 1748725200 } } ``` `commitment` is null when there is no active commitment. Invalid transitions requested via HTTP return 409 (illegal transition) or 400 (invalid input); state is unchanged. ## Browser UI Single HTML page served from `/`, using vanilla JavaScript with no build step (no bundler, no framework) to keep it dependency-free and token-light. It: - Subscribes to `/events` via `EventSource`. - Renders exactly one view based on `runtime_state`: - **Locked** — status + a "Plan" button (POST `/planning`). - **Planning** — a form with next action, success condition, and minutes; "Start" is disabled until all three are valid (POST `/commitment`). - **Active** — next action, success condition, a live countdown derived from `deadline_unix_secs`, and a "Complete" button (POST `/complete`). - **Review** — a short summary of the just-ended commitment and an "End" button (POST `/end`). - Is a pure renderer of pushed state; it holds no authoritative state of its own. Styling is clean and legible but not the focus — the real visual pass is M4. ## Testing - `domain`: validation rejects empty next action / success condition / zero timebox; JSON round-trips with snake_case tags. - `statemachine`: legal transitions for the M0 subset succeed; representative illegal transitions (e.g. Locked → Active) return typed errors. Port the intent of the existing Rust tests. - `session`: planning → start → complete → end happy path drives the expected states; snapshot save/load round-trips and restores the controller. - `web`: `httptest` checks that POST `/planning` then POST `/commitment` moves the controller to Active, that `/commitment` with invalid input returns 400, and that `/events` emits a state payload. ## Done When `go run ./cmd/antidriftd` opens a browser; the user creates a commitment, watches the timebox count down, completes it (or lets it expire into Review), and ends the session back to Locked. Killing and restarting the daemon restores the persisted state from `~/.antidrift/state.json`. `go test ./...` passes. ## Repo Setup (first task of M0) - Initialize the Go module at the repository root. - Move the existing Rust sources into `legacy/` so they remain available as reference (Cargo.toml, src/, etc.), keeping the shared `docs/` at the root. - Code layout: `cmd/antidriftd/` (main), `internal/domain`, `internal/statemachine`, `internal/session`, `internal/store`, `internal/web` (with the static UI under `internal/web/static`).