From bb97a374da3d62348fe65638ad22ae9452cb5034 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 11:25:10 -0400 Subject: [PATCH] Add M0 walking-skeleton design Co-Authored-By: Claude Opus 4.8 --- .../2026-05-31-m0-walking-skeleton-design.md | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-31-m0-walking-skeleton-design.md diff --git a/docs/superpowers/specs/2026-05-31-m0-walking-skeleton-design.md b/docs/superpowers/specs/2026-05-31-m0-walking-skeleton-design.md new file mode 100644 index 0000000..84f8650 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-m0-walking-skeleton-design.md @@ -0,0 +1,209 @@ +# 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`).