Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 alsoActivates 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) withNewManual(...)validation: non-empty next action, non-empty success condition, non-zero timebox.RuntimeState(Locked, Planning, Active, Transition, Review, AdminOverride) andCommitmentState(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 gatesActivate. 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
runtimeStateandactiveCommitmentin memory, guarded by async.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 defaultLockedsnapshot, 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.Controllerand 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:
- Loads the snapshot (or starts
Locked). - If the loaded state is
Activewith a future deadline, re-arms the expiry timer; if the deadline has already passed, transitions toReview. - Starts Gin on
localhost:7777. - 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
/eventsviaEventSource. - 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).
- Locked — status + a "Plan" button (POST
- 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:httptestchecks that POST/planningthen POST/commitmentmoves the controller to Active, that/commitmentwith invalid input returns 400, and that/eventsemits 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 shareddocs/at the root. - Code layout:
cmd/antidriftd/(main),internal/domain,internal/statemachine,internal/session,internal/store,internal/web(with the static UI underinternal/web/static).