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, P1, L1 and V1), 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-nine 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

staged

unstaged

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

resource_wait

worktree_discarded

retro

recommitted

owns_checked

inspected

ci_failed

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 --shipped (every verify rule plus S1, which also refuses a unit with no inspected pass: one that was only planned was built outside flywheel, issue #861), or flywheel verify --log finds the event log’s hash chain broken. An existing hook is never overwritten: to get the --shipped check on a repository whose pre-push hook predates it, delete pre-push from the hooks directory and rerun flywheel init --git-hooks.

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 ten rules against every task it is asked about — VerifyTasks calls ruleT1, ruleT3, ruleT4, ruleT5, ruleT8, ruleR1, ruleW1, ruleP1, ruleL1, ruleV1 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, P1, L1 and V1, §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.

Ledger directory (ledger.dir)

Both layouts live in .flywheel/ by default, so a project that commits the log sees it rewritten by every branch switch and conflicted by every merge. The opt-in configuration key "ledger": {"dir": "<value>"} in .flywheel/config.json moves the log (events.jsonl or events/) outside the checkout (issue #464). Values:

The environment variable FLYWHEEL_LEDGER_DIR, when non-empty, overrides the config value (same value forms). Unset, every path is exactly the in-tree one. The log’s location is read from the ledger key alone, never through the full config validation, so a broken unrelated key never hides the log. Refusals, each an error naming the path and ledger.dir:

flywheel ledger move [--dir DIR] [--json] is the one supported migration of the in-tree log into the ledger directory. It holds the in-tree .flywheel/events.lock for the whole move, copies events.jsonl and every file under events/ (the relative layout kept, @floor.jsonl included, *.tmp skipped; a symlink is refused) into a temp directory beside the destination, fsyncs each copy and compares it byte for byte with its source, then renames the temp directory onto the destination (a missing or empty one; a destination holding only locks/ keeps it and takes the copy’s entries). It then sets the in-tree files aside under .flywheel/ledger-move.old/, runs the chain verification against the destination and only then removes them from the working tree; a verification error puts them back and exits 1. A destination already holding byte-identical copies (a crash between the rename and the removal) resumes straight to the removal, and a crash after the set-aside is put back and resumed on the next run. A destination holding any other log or file (except locks/) is refused with exit 6, rule two-live-logs, and both are left untouched; the temp directory never survives a failure. With ledger.dir unset, or refused, it exits 2; with no in-tree log it prints nothing to move and exits 0. Git is never run for writing: when git tracked the in-tree log, the command says to commit its removal (git rm --cached is not run).

Readers never create the directory; writers create it. The locks that guard the log move with it: <ledger dir>/locks/@events.lock (in-tree: .flywheel/events.lock) and the per-shard <ledger dir>/locks/<shard>.lock (in-tree: .flywheel/locks/<shard>.lock), so two checkouts that share one ledger directory serialise their appends. The other .flywheel/ locks stay in the checkout. The pre-push hook verifies the log when ledger.dir or FLYWHEEL_LEDGER_DIR is set.

flywheel doctor follows ledger.dir: a refused value or two live logs is a warning carrying the error; with it set the git-tracking and merge=union checks are skipped (the log is meant to be outside git) and it warns only when the resolved directory exists and is not a directory or not writable (probed with a temp file it removes; a missing directory is fine, the first append creates it). In both modes a leftover .flywheel/ledger-move.old/ warns that a flywheel ledger move was interrupted after setting the in-tree log aside: run flywheel ledger move again before any other flywheel command. flywheel fleet counts a directory whose ledger directory holds events.jsonl or events/, and one whose ledger.dir is set but refused (its row carries the error); it de-duplicates by the event log’s location, so the worktrees of one clone sharing a ledger directory are listed once, under the first path reaching it. flywheel init with ledger.dir set (config or env) creates no .flywheel/events.jsonl and omits that piece, and fails on a refused value. A new .flywheel/.gitignore from init lists runs/, worktrees/, locks/, claims/ and leases/ (per-host runtime state); an existing one is never rewritten.

flywheel verify with ledger.dir set and a resolved directory that holds neither events.jsonl nor events/ (a CI runner, or any host but the one the log lives on) is inconclusive, never a vacuous pass: before any task or chain check it prints flywheel verify: inconclusive: ledger.dir places the event log at <dir>, which holds no log on this host (it is outside the checkout, so this checkout cannot be audited) to stderr (with --json, the result JSON with one LOG item, pass false, inconclusive true and that reason) and exits 8; a refused ledger.dir exits 1. Unset, or an external directory that holds a log, verifies as before. The flywheel init --ci workflow skips its “the event log is not committed” pre-check when .flywheel/config.json has a "ledger" key or FLYWHEEL_LEDGER_DIR is set, so that exit 8 reaches its ::warning:: inconclusive branch (which names both causes: a tree this checkout cannot resolve, or ledger.dir keeping the log outside the repository) and the job passes without establishing anything. An existing workflow is never rewritten: rerun flywheel init --ci after deleting it to get the guard.

flywheel ledger backup with ledger.dir set also copies the external events.jsonl and every file under events/ (complete lines only, *.tmp and symlinks skipped as in .flywheel/, the directory’s locks/ and anything else left out) into the copy’s .flywheel/, so manifest paths stay .flywheel/events.jsonl and .flywheel/events/..., and records the resolved directory as the manifest’s (and --json result’s) ledger_dir. A ledger directory that does not exist yet is fine: no events are copied. A destination inside the ledger directory is refused like one inside .flywheel/. The copy’s chain is checked by its own files, never by the live directory its copied config.json names. To restore, copy <path>/.flywheel back, except that with ledger_dir in the manifest the event-log files go back into that directory (or flywheel ledger move them there).

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] [--shipped] [--workdir PATH] runs T1/T3/T4/T5/T8/R1/W1/P1/L1/V1 for the requested tasks (--all derives the task list from every task seen in the log), plus S1 with --shipped (every task needs an inspected pass or a landing, issue #861; §2), 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> (or --group) every gate passed, nothing outside owns: 5 — a gate failed, stayed host-blocked after one rerun, a changed path is outside owns:, or a claude worker never loaded a skill its brief named (skills-not-loaded, issue #695; skills.require_loaded false turns it off); 6 — RuleRefusal naming needs-env, stage (an invalid stage: line, issue #781), full-suite, required-gates, required-live-gates (issue #845), required-observes (issue #848), owns-companions (issue #785), or group-owns (validate --group: two members own one owns: entry or one changed path, issue #775) 2 usage, 1 other error
flywheel inspect <task> --verdict ... --session ... inspection recorded 6 — RuleRefusal naming T3, T4, T8, review (an open blocking review finding), red-first (a kind: fix pass with no gate red on the base tree before dispatch, issue #648; lint.red_first false turns it off), ci-escape (CI failed after every gate in the brief passed and the brief has gained no gate since the newest ci_failed event, issue #776), skills-not-loaded (a claude worker never loaded a skill its brief named, issue #695; skills.require_loaded false turns it off), denied-write (a path whose write the harness denied changed anyway with no lead claim, issue #757), panel or lead-built (a pass on a unit with no dispatched attempt over lead_built.max_changed_lines, issue #722; --exception records why) 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; a commit that resolves but whose tree cannot be computed reports git’s own error naming the step, such as read-tree, #907)
flywheel observe <task> --check N --result pass\|fail --evidence REF --session S observed reading recorded (#842) 6 — RuleRefusal naming T4 or T5 2 usage, 1 other error (e.g., a check outside the brief’s observe lines)
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 inspect --group <group> --verdict ... --session ... every member’s inspection recorded 6 — the first member’s RuleRefusal (any rule inspect <task> refuses), naming the member; nothing recorded 2 usage (a task id with --group), 1 other error
flywheel land --group <group> --commit <sha> every member landed, or already landed on the commit 6 — RuleRefusal naming T5 (a member not passed: nothing lands) or a member’s land --commit refusal 8 inconclusive, 2 usage (a task id, or --merge, --by-lead, --exception, --correct), 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.