flywheel protocol v1

This is the protocol flywheel enforces today, in code — not the fuller factory model it is building toward. The authority for everything below is the code itself: internal/flywheel/events.go (the kinds map and Validate), internal/flywheel/state.go (Derive’s status transitions), internal/flywheel/verify.go (flywheel verify: rules T1, T3, T4, T5, T8, R1, W1 and P1), internal/flywheel/land.go (flywheel land enforces T7 and T9 live) and internal/flywheel/chain.go (the hash-chained log, checked by flywheel verify --log, with a chain per shard in the sharded layout). docs/design/autonomous-shipping.md describes a larger design — audits, nonconformances, andon signals, a hash-chained log — and much of it is built now (audited and signal events, first-article audits, the chain); §3 below says exactly which parts of that design are still aspiration (T2, T6 and parts of T9 and T10), so a reader never has to guess.

Every record is one JSON line appended to .flywheel/events.jsonl by AppendEvent, and the log is never rewritten — flywheel log (or a command that calls AppendEvent internally) is the only way to add a line. flywheel state derives .flywheel/state.json and the status block in flywheel.md from the log alone (Derive): the log is the one source of truth, everything else is a read-only projection of it. A command whose --dir is inside a unit’s worktree (<root>/.flywheel/worktrees/<task>, made by flywheel run --worktree) uses the main checkout’s ledger at <root>, never the worktree’s stale copy (issue #395).

Every skill in skills/ that drives this loop cites protocol v1 and links back here; cmd/flywheel/docs_test.go fails the build the moment a skill stops citing it, or this file’s first line stops matching ^# flywheel protocol v.

Contents

1. Required entries per task

Forty-three event kinds exist; events.go’s kinds map is the authority for the list, and Validate rejects anything else. A stamped event’s ts is strictly after the previous event in its log (issue #650), so replay never sorts a re-plan before the finish it follows. Nine of the kinds carry a task’s status (state.go’s kindRank orders them, together with some status-neutral kinds, for replay); the rest — worker_plan, no-plan, off-course, report, validated, owns_checked, amended, sharded and the others — change other fields but never the status itself. staffed, goal, session_start, session_command and session_end carry no task at all, and Validate also refuses a task on health, release_audited, reanchored and recovered (events.go).

planned

dispatched

dispatch_refused

worktree_setup

started

worker_plan

no-plan

off-course

finished

report

reviewed

review_finding

finding_response

panel_scoped

group_reviewed

blocked

lost

withdrawn

rebased

recovered

landed

land_corrected

excepted

allow_untriaged

audited

release_audited

health

suspended

unsuspended

probed

gate_probed

amended

Because planned, amended and dispatched events carry the parsed header, the log is self-contained: a pass is measured against the header recorded in it, so a brief edited on disk after the fact — even one re-recorded through flywheel log --kind amended with the same path — no longer changes what any recorded pass is measured against. On a dispatched attempt, owns widen with --kind amended; gates change only with flywheel run <task> --delta <file>, which is what flywheel run’s brief-drift message names (issue #387). An amended event whose brief matches the file on disk records the change, as a later planned one does, so later dispatches neither warn brief-drift nor refuse under --strict-brief (issue #617). An event without a header falls back to reading the file at its brief path, so ledgers written before this field existed keep working exactly as before.

validated

owns_checked

inspected

staffed

session_start

flywheel init --git-hooks adds the git layer (issue #56): a commit-msg hook refuses a commit without a Flywheel-Task: <id> trailer (merges, reverts and fixup/squash commits are exempt), and a pre-push hook refuses a push while a unit named in the pushed commits fails flywheel verify, or flywheel verify --log finds the event log’s hash chain broken.

flywheel init --ci adds the CI layer: a flywheel-audit job running flywheel verify --all --log on every pull request. Made a required status check in the branch ruleset, it cannot be bypassed locally; it needs the event log committed, and an inconclusive check (exit 8) only warns.

session_command

session_end

learning

note

lead_edit

goal

2. Transitions the code enforces

flywheel verify runs eight rules against every task it is asked about — VerifyTasks calls ruleT1, ruleT3, ruleT4, ruleT5, ruleT8, ruleR1, ruleW1, ruleP1 in that order — and the same rules are enforced live, before the record is written, inside InspectTask (T3, T4, T8, R1 as the refusal rule review, and P1 as the refusal rule panel) and LandTask (T5). ValidateTask produces the readings T3 needs but enforces nothing itself; it can fail its own gates (exit 5) without touching the log’s legality.

3. Designed, not enforced

docs/design/autonomous-shipping.md describes ten transition rules, T1-T10, and a fuller event vocabulary (audited, signal, dismissed, learning, “by” attribution blocks). Of the T rules, T1, T3, T4, T5 and T8 exist in verify.go (alongside R1, W1 and P1, §2), and T7 and T9 are enforced live by flywheel land (land.go, below). Only the kinds in events.go’s known-kinds map exist at all — Validate rejects any other kind by name — and audited, signal, dismissed and learning are all in that map, so each is a recognized record (audited is written by flywheel audit, §1). Concretely, still design-only or only partly built:

Until these land, the factory-role table, the andon cord, sampling, and nonconformance handling in docs/design/autonomous-shipping.md and skills/flywheel/references/factory.md describe intent and skill-level convention, not something flywheel verify can fail on.

4. Who may write which event

Verify’s T8 is the only persona check in the code, and it covers exactly three kinds:

The one place “a worker never inspects its own work” is a real, live check rather than a skill convention is T4: InspectTask and ruleT4 both refuse an inspected event whose --session was ever a worker session (one that wrote that task’s started, finished, dispatched, report, or worker_plan event). Put plainly, in this codebase: a worker never writes a validated, owns_checked, inspected, or landed event because no worker-facing tool writes them and the worker’s own permission policy does not need to block commands it is never given; the supervisor (the gauges) never writes an inspected event because flywheel validate has no verdict to record; and the inspector never writes a validated event because flywheel inspect never touches gate output — only flywheel validate does, and it always signs its own readings "supervisor", never "inspector".

Log layout

The event log has two layouts, both append-only: the legacy single-file layout (.flywheel/events.jsonl) and the sharded layout (files under .flywheel/events/). Both are opt-in; flywheel log --shard is the only way to switch, and the switch is one-way: once a repository uses the sharded layout, older binaries cannot read it safely (they reject the unknown log.shards config field). The config key log.shards is written by --shard and fences out older binaries.

Legacy layout

The legacy layout is a single .flywheel/events.jsonl file, one JSON event object per line, appended by AppendEvent.

Sharded layout

The sharded layout puts events under .flywheel/events/:

Readers merge events in this order: legacy file first, then stable by each shard’s running-max timestamp (the timestamp of the last appended event in that shard), which preserves the global linearized order while allowing shards to operate concurrently. Each shard has its own hash chain with a genesis block (the first event appended to that shard) and a sharded event at the moment the layout is switched (kind sharded, with fields identical to other seal events). flywheel verify --log checks each shard’s chain separately and also the merged order across shards.

Transient locks under .flywheel/locks/ guard concurrent writes (one per shard, named <task>.lock); they are git-ignored. The locks enforce per-shard write order and are consulted by readers to detect in-flight appends, but readers never wait — a slow reader may observe partial state, and consistency is per-file, not cross-shard. Deletions and trimmed tails in a shard are not detected by the chain (the seal block lives at insertion time, not at mutation time); only appends are tracked.

Backups

flywheel ledger backup <path> (#464) is the supported way to keep an extra, point-in-time copy of the ledger; it is not an alternative to committing it (the ledger is committed with merge=union, and flywheel doctor warns when it is untracked or lacks that attribute). It copies .flywheel/ to <path>/.flywheel/ but leaves out the top-level worktrees/ and locks/ (live process state, not records), every *.tmp file, and symlinks or junctions (it lists them instead of following them). events.jsonl and every shard under events/ are copied by complete lines only: a trailing partial line still being appended is left out and counted. The copy is built in a temp directory next to <path>, then renamed onto it. <path>/flywheel-backup.json records the creation time, the source, each file’s path, size and sha256, the skipped paths, the partial-tail bytes, and the result of VerifyLogChain on the copy. A broken chain is recorded, not fatal. To restore, copy <path>/.flywheel back.

5. flywheel verify and exit codes

flywheel verify [<task>...|--all] [--json] [--log] [--workdir PATH] runs T1/T3/T4/T5/T8/R1/W1/P1 for the requested tasks (--all derives the task list from every task seen in the log) and prints one PASS/FAIL/INCONCLUSIVE line per rule per task, or the same result as JSON ({"passed": bool, "items": [{"task","rule","pass","inconclusive","reason"}]}; inconclusive is omitted when false, so --json consumers of the existing fields keep working). --workdir names the repository to resolve tree objects in when the readings were taken in an external clone (issue #244); without it, a workdir recorded on the task’s reading events is used when that path still exists. --log checks the event log’s hash chain (T10, issue #57): every event’s prev must match the SHA-256 of some earlier complete line; --log fails (exit 6) at the first line whose prev matches none, indicating a line was edited or removed. When that dangling prev is the hash of a LATER line in the same file, the break reason is reordered: line N chains to line M, which comes after it (a git merge or an edit reordered the log; no record is missing). A git merge or conflict resolution of a committed ledger does this. It is still a break, but no record is missing (issue #422); otherwise the reason is prev matches no earlier line. Either layout prints <file> line N: <reason> (prev <12 hex>).

Acknowledged breaks (issue #436). The ledger is append-only, so an explained break is never repaired by an edit: flywheel log --reanchor --note "<why>" [--force] [--session S] [--dir DIR] appends a reanchored event (floor level, no task) through the normal append path, so the acknowledgement is itself chained. It carries file (the log file as the chain check names it: events.jsonl or events/<task>.jsonl), line_no (the 1-based break line, display only), break_prev (the dangling prev, full hex), sha256 (the break line’s hash — the identity of the acknowledged line), reason (reordered when the break reason starts reordered:, else removed) and note (why, required). The command refuses (exit 6) when the chain is intact, when the break is not a dangling prev (a missing seal, a line with no prev, a wrong shard genesis), and when the break classifies as removed without --force: a removed break may be a real edit or deletion, and --force records the decision that it is explained. --reanchor does not combine with --kind, --task or --json, and --note is required (exit 2). The chain check (all layouts) first reads the log’s reanchored events; a break is acknowledged when one has the same file, the same break_prev, a sha256 equal to the break line’s hash, and a reason equal to the classification computed now — a reordered acknowledgement never covers a line that now classifies as removed. An acknowledged break does not stop the scan; each later break needs its own acknowledgement. --json lists them under acknowledged (file, line, reason, session, note), and a passing --log and flywheel recover’s integrity line append ; acknowledged break at <file> line N (<reason>), by <session>: <note> for each.

Preventing reorders. A repository that commits .flywheel/ should merge the event log with git’s union driver, which keeps each side’s appended lines in order, so every prev still resolves to an earlier line. flywheel init writes .flywheel/.gitattributes with events.jsonl merge=union and events/*.jsonl merge=union; it never rewrites an existing file, so an older repository adds those two lines to .flywheel/.gitattributes by hand and commits it. An INCONCLUSIVE item is pass:false with inconclusive:true: the pass’s tree could not be resolved in any repository this verifier can see, so T3 can neither confirm the readings nor assert a breach. Naming a task explicitly still runs every rule for it even if the log has never heard of it — a missing planned brief, for instance, fails T3 by name rather than being skipped. An empty log verified with --all passes vacuously; verifying with no tasks and no --all is a usage error — except --log alone, which checks only the chain.

Exit codes follow the repo-wide convention from AGENTS.md: 0 ok, 1 error, 2 usage, 5 gauges failed, 6 rule refusal, 8 inconclusive. The enforcing commands:

Command Success (0) Refusal Other
flywheel validate <task> every gate passed, nothing outside owns: 5 — a gate failed, stayed host-blocked after one rerun, or a changed path is outside owns: 2 usage, 1 other error
flywheel inspect <task> --verdict ... --session ... inspection recorded 6 — RuleRefusal naming T3, T4, T8, or review (an open blocking review finding) and its fix 2 usage, 1 other error
flywheel attest <task> --commit <sha> --evidence URL --session S external readings recorded 6 — RuleRefusal naming T3, T4 or T5 2 usage, 1 other error (e.g., the commit is not in the repository)
flywheel verify [...] [--json] every requested check passes 6 — any check fails (FAIL <task> <rule>: <reason>) 8 — every failing check is INCONCLUSIVE (no violation established, the tree could not be resolved); 2 usage, 1 other error
flywheel land <task> --commit <sha> [--exception TEXT --session S] landing recorded, or repeats an already-landed commit 6 — RuleRefusal naming T5 or T4 (T5 includes a commit off the integration branch or touching none of the unit’s files) 8 inconclusive (the commit or every integration ref does not resolve: run git fetch), 2 usage (e.g., –exception without –session), 1 other error
flywheel land <task> --correct <sha> --reason TEXT --session S land_corrected recorded 6 — RuleRefusal naming T5 (no landed event, the commit is already the effective one, or it fails land’s commit checks) or T4 (a worker session) 8 inconclusive (the commit does not resolve: run git fetch), 2 usage (a conflicting flag, or no –reason or –session), 1 other error
flywheel run <task> rc == 0 and finish reason was stop — 3 silent (no output within the start timeout); 7 stalled (the run-file gap watchdog fired mid-stream, issue #158); 6 suspended (a flywheel suspend --stop stopped the worker, issue #572); 4 any other outcome (nonzero rc, or reason length/error/start-failed); 2 usage or no worker configured; 1 other error

flywheel run’s own codes (3, 4, 7, and 6 for suspended) are not part of the repo-wide list: they are ExitCode’s reading of one Result, keyed by exit number instead of by command, and AGENTS.md records them as run-specific. Exit 7 means stalled — a mid-stream gap — and nothing else, so a consumer scripts exit codes per command, never globally:

Exit flywheel run reason
3 silent — no stdout line arrived within the start timeout
4 any other non-clean outcome — nonzero rc, or finish reason length, error, rate-limited (after any resumes), abandoned-job (after its one resume), or start-failed
6 suspended — a flywheel suspend --stop stopped the worker at a lease tick (issue #572); flywheel resume continues it
7 stalled — the run had started but the run file stopped growing for the stall timeout (issue #158)

Everything upstream of these five commands — writing a brief, deciding what belongs in owns:, choosing which task to dispatch next — is judgment the protocol does not check; the CLI enforces only what is above.