Add M0 walking-skeleton design
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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`).
|
||||||
Reference in New Issue
Block a user