Files
antidrift/docs/superpowers/specs/2026-05-31-m0-walking-skeleton-design.md
T
2026-05-31 11:25:10 -04:00

8.8 KiB

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 Activates 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:

{
  "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).