Status Model: Operator Documentation
Mission of record: 034-feature-status-state-model-remediation (legacy slug)
Terminology note
- Domain model:
Mission Type -> Mission -> Mission Run. --missionis the only tracked-mission selector. The old--featurealias has been removed from every user-facing command (#1060, the selector hard-removal); passing--featureexits with code 2.- A mission's canonical machine identity is
mission_id(a ULID, since mission083-mission-id-canonical-identity-migration). The--missionflag acceptsmission_id,mid8(first 8 chars of the ULID), ormission_slug. The numeric prefix in slug examples below (e.g.034-feature-name) is display-only metadata — the event log's aggregate key ismission_id, not the prefix. See the mission identity migration runbook.
Overview
The status model uses a single canonical append-only event log per mission as the sole authority for work package status. Every lane transition is an immutable StatusEvent in status.events.jsonl. A deterministic reducer produces status.json snapshots.
Which reducer runs? One: the wall-clock
spec_kitty_events.diary.reduce_parsed, reached throughstatus.reducer.reduceand throughmaterialize_snapshot/materialize. It is deterministic but not Lamport-ordered; see Known limitations. Every other event log core writes and reads is mapped in Event ledger writers and readers.
This event log is the open-core producer's read model (2026-10-12 direction). The Mission Status Read API — the open-core producer surface (ADR
2026-10-01-2, Accepted) — is conformance-gated againststatus.reducer.materialize_snapshot, the same wall-clock reducer every CLI reader uses, so its answer is meant to matchstatus.json. Its Java port does not yet carry the #4941 approved-departure lift (#6268). Its external Java form is to start in-repo and be extracted when its contract freezes;contracts/mission-status/and the conformance fixtures stay canonical beside this Python reducer. Consumers (Team Kitty / "Multiplayer Mode") read that API across its contract, never this log directly.
Key principles:
status.events.jsonlis the sole source of truth for WP lane statestatus.jsonis a derived materialized snapshot (regenerable)- WP frontmatter is for static definition only (title, dependencies, subtasks) -- the
lanefield is no longer written or read by active runtime code finalize-tasksis the canonical bootstrap point -- it creates initial WP definitions; status transitions are tracked exclusively in the event log- Frontmatter
laneis a historical/migration-only concept retained in migration code paths for backward compatibility
Read-only commands stay read-only: status commands (including materialize() and spec-kitty agent status materialize) no longer dirty the git working tree. status.json is only written when there is a new event to materialize. The materialized_at field in status.json reflects the timestamp of the last event in the log, not the wall clock at the time the command was run.
Forward-looking (Proposed): the "frontmatter is static-only" principle above is only half-delivered today —
lanewas evicted, butshell_pid, subtask-checkbox state, review-cycle fields,agent/assignee, and the activity-log narrative are still written intotasks/WP##.mdat runtime. The WP runtime-state eviction mission generalises thelaneretirement to all of these, introducing a non-transition annotation event class (shell_refresh/subtask_marked/activity_note) alongside the transition ledger so the 9-lane FSM stays unchanged. After it lands,WP##.mdholds only static intent and hashes stably across every runtime mutation. See ADR 2026-07-16-1 and the eviction design.
Known limitations
- One reducer, ordered by wall clock.
status.reducer.reducecallsspec_kitty_events.diary.reduce_parseddirectly (status/reducer.py:205), andmaterialize_snapshotcallsreduce_shared_state(:401), which isspec_kitty_events.status.reduce, a re-export ofdiary.reduce(parse_diary, thenreduce_parsed). Lane transitions are ordered by the writer'sat, thenevent_id; CLI lane rows carry no Lamport clock, and the Lamport reducerreduce_status_eventsis not on the CLI path. So the CLI does not implement the Lamport-primary order of ADR2026-02-09-3. The consolidation reconciliation gate reads through the same reducer. Two ordering cases are fixed in the library: a stale approval overriding a committed rejection (#4990, 10.4.0) and a rejection minted after a clock-ahead approval (#4941, 10.5.1: a non-seed departure fromapprovedsorts after the approval it followed). A non-approval transition minted on a clock behind the event it follows still sorts before that event.
CLI Commands
All status commands live under spec-kitty agent status.
spec-kitty agent status emit
Record a lane transition event for a work package.
# Move WP01 to claimed (assigns to an actor)
spec-kitty agent status emit WP01 --to claimed --actor claude
# Move WP01 to in_progress (begin implementation)
spec-kitty agent status emit WP01 --to in_progress --actor claude
# "doing" is accepted as an alias for "in_progress"
spec-kitty agent status emit WP01 --to doing --actor claude
# Move to for_review (submit for review)
spec-kitty agent status emit WP01 --to for_review --actor claude
# Move to done with reviewer evidence (required unless forced)
spec-kitty agent status emit WP01 --to done --actor claude \
--evidence-json '{"review": {"reviewer": "alice", "verdict": "approved", "reference": "PR#42"}}'
# Return from review to in_progress (changes requested -- requires review_ref)
spec-kitty agent status emit WP01 --to in_progress --actor reviewer \
--review-ref "PR#42-comment-7"
# Force a transition that bypasses guard conditions (requires actor + reason)
spec-kitty agent status emit WP01 --to in_progress --actor admin \
--force --reason "Reopening after incorrectly marked done"
# Block a work package
spec-kitty agent status emit WP01 --to blocked --actor claude \
--reason "Waiting on upstream dependency"
# Machine-readable JSON output
spec-kitty agent status emit WP01 --to claimed --actor claude --json
Options:
| Option | Required | Description |
|---|---|---|
WP_ID (argument) |
Yes | work package ID (e.g., WP01) |
--to |
Yes | Target lane (canonical or alias) |
--actor |
Yes | Who is making this transition |
--mission |
No | Mission slug |
--force |
No | Bypass guard conditions |
--reason |
When --force |
Reason for forced transition |
--evidence-json |
When --to done |
JSON string with DoneEvidence |
--review-ref |
When for_review -> in_progress |
Review feedback reference |
--execution-mode |
No | worktree (default) or direct_repo |
--json |
No | Machine-readable JSON output |
On a coordination-routed Mission the write can be refused with
COORD_STATUS_SURFACE_DIVERGED or COORD_STATUS_SURFACE_UNREADABLE; see
Coordination status-write guard.
spec-kitty agent status materialize
Rebuild status.json from the canonical event log.
# Rebuild snapshot (auto-detects mission)
spec-kitty agent status materialize
# Specify mission explicitly
spec-kitty agent status materialize --mission 034-feature-name
# JSON output (full snapshot)
spec-kitty agent status materialize --mission 034-feature-name --json
When to use: After manual edits to status.events.jsonl, after resolving merge conflicts in the event log, or after running status validate reports materialization drift.
spec-kitty agent status validate
Check event log integrity, transition legality, done-evidence completeness, and drift detection.
# Validate event log for a mission
spec-kitty agent status validate --mission 034-feature-name
# JSON output for CI integration
spec-kitty agent status validate --mission 034-feature-name --json
Checks performed:
- Schema validation: All required fields present, ULID format, canonical lane values, ISO 8601 timestamps
- Transition legality: Every
(from_lane, to_lane)pair is in the allowed transitions set (force transitions are always legal) - Done-evidence completeness: Every done transition has evidence or force flag
- Materialization drift: Compares
status.jsonon disk with reducer output from event log - Derived-view drift: Compares materialized
status.jsonagainst canonical event log (error if diverged)
spec-kitty agent status reconcile
Scan target repositories for WP-linked branches and commits, detect planning-vs-implementation drift, and optionally emit reconciliation events.
# Preview reconciliation suggestions (dry-run is the default)
spec-kitty agent status reconcile --mission 034-feature-name --dry-run
# Scan a specific target repository
spec-kitty agent status reconcile --mission 034-feature-name \
--target-repo /path/to/implementation-repo --dry-run
# Apply reconciliation events (2.x only; disabled on 0.1x)
spec-kitty agent status reconcile --mission 034-feature-name --apply
How it works:
- Scans target repos for branches matching
*<feature-slug>*WP##* - Scans commit messages containing
WP## - Checks which lane or mission branches are merged into the target branch
- Compares implementation evidence against canonical snapshot state
- Generates legal transition events to align planning with reality
Limitations on 0.1x: --apply is disabled. Reconciliation is dry-run only.
spec-kitty agent status doctor
Run health checks detecting stale claims, orphan workspaces, and unresolved drift.
# Run all health checks for a mission
spec-kitty agent status doctor --mission 034-feature-name
Health checks:
| Check | Severity | Description |
|---|---|---|
| Event-log read failure | Error | Corrupt or unreadable status.events.jsonl, regardless of snapshot presence |
| Stale claims | Warning | WPs in claimed for >7 days or in_progress for >14 days |
| Orphan workspaces | Warning | Worktrees existing for features where all WPs are terminal (done/canceled) |
| Materialization drift | Warning | status.json does not match reducer output |
| Derived-view drift | Error | Materialized snapshot differs from canonical event log |
Doctor reads the canonical event log before trusting status.json. If the log is corrupt or unreadable it reports event_log_error (ERROR, exit 1, JSON healthy: false) naming the resolved path, the reason, and the 1-based line for malformed records. Log-dependent checks (stale claims, drift, reviewer self-approval, WP reconciliation) are skipped; independent checks still run. Doctor never modifies either file: preserve both, restore trusted history or fix file access, then rerun. Backfill and materialization do not substitute for a readable log.
spec-kitty agent status migrate
Bootstrap canonical event logs from existing frontmatter lane state.
# Preview migration for a single feature
spec-kitty agent status migrate --mission 034-feature-name --dry-run
# Execute migration for a single feature
spec-kitty agent status migrate --mission 034-feature-name
# Migrate all features
spec-kitty agent status migrate --all
# Preview all migrations
spec-kitty agent status migrate --all --dry-run
Migration behavior (for pre-3.0 missions):
- Reads current frontmatter
lanevalues from all WP files in the feature - Resolves aliases (
doing->in_progress) before creating events - Generates one bootstrap event per WP:
from_lane=planned, to_lane=<current_lane> - WPs already at
plannedproduce no events (no transition occurred) - Idempotent: features with existing non-empty
status.events.jsonlare skipped - Verification: reads back persisted events and confirms count matches
For new missions (3.0+): finalize-tasks bootstraps WP definitions. All subsequent status transitions are validated once in the status-owned transition_pipeline and appended to the event log by one of its two shells — the flat/primary emit_status_transition() or the transactional shell in coordination/status_transition.py. No frontmatter lane is written.
Legacy Compatibility
The existing move-task command still works and internally delegates to the status emit pipeline:
# This still works -- delegates to status emit internally
spec-kitty agent tasks move-task WP01 --to doing
# "doing" is accepted as alias, persists as "in_progress" in the event log
9-Lane State Machine
Canonical Lanes
| Lane | Description | Terminal |
|---|---|---|
planned |
WP defined, not yet claimed | No |
claimed |
WP assigned to an actor, not yet started | No |
in_progress |
Active implementation underway | No |
for_review |
Implementation complete, awaiting review | No |
in_review |
Reviewer actively examining implementation | No |
approved |
Review passed, awaiting merge | No |
done |
Merged/integrated into the mission target branch | Yes (unless forced) |
blocked |
Blocked by external dependency or issue | No |
canceled |
Permanently abandoned | Yes |
Alias: doing -> in_progress (resolved at input boundaries, never persisted in events)
Display: The kanban board shows 6 columns (Planned, Doing, For Review, In Review, Approved, Done). planned WPs appear in Planned; claimed and in_progress appear in Doing, with claimed still preserved as a distinct canonical lane for ownership/stale-claim diagnostics. blocked/canceled WPs are shown separately below the board.
Allowed Transitions (27 pairs)
# Normal flow (implementation progression)
planned -> claimed (requires actor)
claimed -> in_progress (workspace context)
in_progress -> for_review (subtasks check)
# Review progression
for_review -> in_review (reviewer claims; actor required with conflict detection)
in_review -> approved (ReviewResult required)
in_review -> done (ReviewResult required)
# Direct approval paths (legacy, kept for backward compat)
in_progress -> approved (direct approval path)
approved -> done (merge verified)
# Feedback loops
in_review -> in_progress (changes requested, ReviewResult required)
in_review -> planned (rejection with feedback, ReviewResult required)
approved -> in_progress (rework after approval, requires review_ref)
approved -> planned (rejection after approval, requires review_ref)
in_progress -> planned (abandon/reassign, requires reason)
# Blocking
planned -> blocked
claimed -> blocked
in_progress -> blocked
for_review -> blocked
in_review -> blocked (ReviewResult required)
approved -> blocked
blocked -> in_progress
# Cancellation
planned -> canceled
claimed -> canceled
in_progress -> canceled
for_review -> canceled
in_review -> canceled (ReviewResult required)
approved -> canceled
blocked -> canceled
Force override: Any transition can be forced with --force --actor <name> --reason <text>. Forced transitions from terminal states (done, canceled) are allowed. All force events carry a full audit trail.
Guard Conditions
| Transition | Guard | Error if Violated |
|---|---|---|
planned -> claimed |
Actor identity required | "Transition planned -> claimed requires actor identity" |
claimed -> in_progress |
Workspace context (placeholder, always passes) | "No workspace context" |
in_progress -> for_review |
Subtask completion check (placeholder) | "Unchecked subtasks" |
in_progress -> approved |
Reviewer approval evidence required | "Missing review approval evidence" |
for_review -> in_review |
Actor identity required (conflict detection) | "Transition for_review -> in_review requires actor identity" |
in_review -> * (all outbound) |
ReviewResult required in TransitionContext | "in_review outbound transitions require ReviewResult" |
approved -> done |
Merge/integration evidence required | "Missing merge evidence" |
approved -> in_progress |
Review feedback reference required | "Missing review feedback reference" |
approved -> planned |
Review feedback reference required | "Missing review feedback reference" |
in_progress -> planned |
Reason required | "Transition in_progress -> planned requires reason" |
| Any forced transition | Actor AND reason required | "Force transitions require actor and reason" |
Migration Phases
The status model used a phased rollout. As of 3.0, Phase 2 is the active and only supported model. Phases 0 and 1 are historical and no longer apply to new missions.
| Phase | Name | Behavior | Status |
|---|---|---|---|
| 0 | Hardening | Transition matrix enforced, no event log. Frontmatter was sole authority. | Historical |
| 1 | Dual-write | Events AND frontmatter updated on every transition. Reads came from frontmatter. | Historical |
| 2 | Read-cutover | status.events.jsonl is sole authority. status.json is derived snapshot. |
Active |
Default: Phase 2 (event-log authority). Frontmatter lane is no longer written or read by active runtime commands.
Configuration
Global default (.kittify/config.yaml):
status:
phase: 1 # 0=hardening, 1=dual-write, 2=read-cutover
Per-feature override (kitty-specs/<feature>/meta.json):
{
"status_phase": 2
}
Precedence: meta.json > config.yaml > built-in default (1)
On 0.1x branches: Phase is capped at 2 (maximum). Reconcile --apply is disabled.
Migration Workflow
To migrate existing features to the canonical event log:
- Preview: Run
spec-kitty agent status migrate --all --dry-runto see what would happen - Execute: Run
spec-kitty agent status migrate --allto bootstrap event logs from frontmatter - Verify: Run
spec-kitty agent status validate --mission <slug>for each mission to confirm integrity - Optionally advance to Phase 2: Set
status.phase: 2in config.yaml or per-feature in meta.json
Canonical Event Log Format
Events are stored in kitty-specs/<feature>/status.events.jsonl as one JSON object per line:
{"actor":"claude","at":"2026-02-08T12:00:00+00:00","event_id":"01HXYZ...","evidence":null,"execution_mode":"worktree","mission_slug":"034-feature-name","force":false,"from_lane":"planned","reason":null,"review_ref":null,"to_lane":"claimed","wp_id":"WP01"}
Keys are always sorted (sort_keys=True) for deterministic, merge-friendly output.
Commit attribution stamp (policy_metadata.lane_head)
Every persisted lifecycle transition of a WP mapped to a non-planning
execution lane whose branch exists is stamped, best-effort, with
policy_metadata["lane_head"] -- that lane branch's git rev-parse HEAD sha
at the moment the transition is written. Both status shells inject the same
probe (specify_cli.status.lane_head.probe_lane_head): the flat/primary
shell (status.emit.emit_status_transition) and the coordination
transactional shell (coordination.status_transition. emit_status_transition_transactional). The stamp is a local, free-form
sidecar on StatusEvent.policy_metadata -- it does not change the
spec_kitty_events schema.
Best-effort, never blocking. The probe never raises and never refuses a
transition: no lanes.json, an unassigned WP, a planning lane, or a missing
lane branch all resolve to "no stamp" (policy_metadata stays absent or
unset for that key), and the transition still persists. A mission created
before this stamp existed, or a transition made outside the governed
workflow, therefore carries no stamp on some or all of its events -- this is
expected, not corruption.
Read only by the consolidation reconciliation gate, and only for mixed
lanes. A mixed lane is an execution lane with at least one approved WP
and at least one WP canceled with operator provenance. For every such lane,
the gate resolves each WP's implementation/review windows from its own
lane_head stamps (the SHA an opening transition -- claimed/
in_progress -- and a closing transition -- for_review/cancel --
carried), bounding exactly which lane commits belong to which WP's work
sessions. Events synthesized by a migration (actor migration:..., e.g. the
birth-cutover backfill seeds) never open, close or extend a window. Lanes that
are not mixed never read this stamp -- the ordinary consolidation path is
unchanged for them.
Verdict rules (summary). For a mixed lane, the gate compares each canceled WP's own unsuperseded content against the target, and checks that every lane commit belongs to some WP's window:
| Verdict | When | Effect and recovery |
|---|---|---|
| PASS | No mixed lane, or every canceled WP's content was superseded by a later, non-canceled commit on the same lane (a rework, a revert, or content the target already carried), and every lane content commit lies in some WP's window | Consolidation proceeds |
| FAIL | A canceled WP's content (an addition, a modification, or a deletion) is still present on the target, unsuperseded | Target restored (compare-and-swap); names the lane, WP and every offending path. Recovery: revert the change on the lane through a surviving WP's governed work, re-run. Not overridable. |
FAIL (CANCELED_REACHABLE_VIA_DEPENDENCY) |
A fully-canceled dependency lane's content reached an approved lane through the dependency step, is still live there and is on the target (#5569, #5613). Commits that every carrying lane fully superseded are not counted | Target restored; names the canceled WPs, both lanes and the path, alongside the strategy check's own message. Recovery: undo the change on the carrying lane through a surviving WP's governed work, re-run. Not overridable. |
FAIL (APPROVED_CONTENT_MISSING) |
An approved code lane's own net change to a path is not on the target, the target left that path alone since the lane was cut, and no later approved lane built atop it superseded the path (#5571, #5613). Applies to every lane, mixed or not, under both strategies. A path the target also changed is not judged | Target restored; names the approved WP, its lane and the path. Recovery: restore the change on the mission branch (for example revert a commit that recorded the staged deletions of a lagging worktree), re-run. Not overridable. |
| REFUSE (attribution evidence) | The canceled WP's commits cannot be bounded: no stamp, a window that never closed, a stamp that is not an ancestor of the lane tip, or a commit contested between two WPs' windows | Target restored. The evidence cannot appear later (the log is append-only), so the message names the override: verify by hand that the canceled content is absent or superseded, then re-run with --attest-canceled-superseded <WP> --attest-reason "<what you checked>" |
| REFUSE (closed world) | Every window resolved, yet a non-merge, non-bookkeeping lane commit lies in no WP's window and after the lane's own base (a straggler after the cancel, a commit by a WP that never entered implementation). Commits reachable from the lane head at its first claim, a dependency-lane tip, or the target's pre-consolidation tip are not outside | Target restored; names the lane, up to three short commit shas and a path. Recovery: verify by hand that those commits carry no canceled work, then attest as above; re-attesting after a later straggler records a fresh attestation whose stamp covers it |
| REFUSE (merged with an independent change) | The target is neither the canceled state, its pre-state, nor the window base's state | Target restored. Recovery: supersede through a surviving WP and re-run, or attest after verifying by hand |
| REFUSE (infrastructure) | The status event log or the lane's git history cannot be read | Target restored. Repair the log or history and re-run. Not overridable. |
"Target restored" in this table is the minimum: every FAIL/REFUSE (and every
squash-projection refusal) also runs the single rollback authority
(consolidation/rollback.py), which restores every branch the run moved —
target, mission branch, coordination branch — to its pre-consolidation commit
with compare-and-swap and prints a per-branch report; a branch another actor
moved is named, never overwritten. A claim whose integrity already fails
(explicit refusal, unresolved surface, empty claim) is refused before any
mutation instead (ADR 2026-09-19-1, Amendment 2026-09-29; #5338, #5318).
REFUSE takes precedence over FAIL: an attribution failure is always reported as missing evidence, never silently downgraded to (or masked by) a content verdict.
Operator-attested override (FR-012). spec-kitty consolidate --attest-canceled-superseded <WP> --attest-reason "<text>" (the WP id is
repeatable; the reason is required) records, through the canonical status
write seam, a forced canceled -> canceled transition of that canceled WP
carrying the actor, the reason, the timestamp, reason_source: operator and
policy_metadata.attestation: canceled_superseded. No event-schema change is
involved and the event log stays the only authority. For an attested WP the
gate lifts the attribution-evidence and "merged with an independent change"
REFUSEs. For the closed world the attestation is bounded in time: its own
lane_head stamp exempts the lane commits made up to it, and a straggler
committed afterwards still REFUSEs until the operator checks it and attests
again. Attesting again is not enough when the straggler was committed after
an approved WP's approval: that WP must also be approved again, or the lane
refuses with LANE_MOVED_AFTER_APPROVAL (ADR
2026-10-04-5). Every explicit --attest-canceled-superseded records a fresh
attestation (a new operator act with its own reason and stamp); the latest one
per WP is the one the gate reads. A FAIL and an
infrastructure REFUSE still stand. A later governed transition of the WP voids
the attestation. The attestation applies only to a WP canceled with operator
provenance; any other WP id is refused before anything is recorded. --dry-run
records nothing and says so. The attestation's policy_metadata key also counts
as event-log runtime evidence for the birth cutover
(status/cutover_eligibility.py), like any key other than lane_head. See ADR
2026-09-29-1.
Known, accepted residuals. Attribution works per commit window and per path, not per hunk or per author, and the closed world trusts its anchors, so four shapes remain documented limitations. Two of them over-block (the gate FAILs when it ideally would not, or names the wrong WP), which is the safe direction:
- content that a separate approved lane independently authored under the identical path and bytes can still FAIL;
- an out-of-workflow commit by a sibling WP that never entered implementation, landing inside a canceled WP's open window, is attributed to the canceled WP -- the gate still FAILs, but the finding names the wrong WP.
Two of them can let canceled or unowned content reach the target under a PASS:
- a survivor's rework that only partially overwrites a canceled WP's change marks the whole path superseded, so the kept hunks ship;
- an out-of-workflow commit made on the lane before the first governed claim is exempt via the first-claim anchor;
A fully-canceled dependency lane whose content fast-forwards into a dependent
lane is no longer a residual: its commits are subtracted from the dependency-tip
exemption (#5569), and a fully-canceled dependency lane whose branch is deleted
or unreadable REFUSEs at claim time, unless the status event log proves the lane
never started (every WP canceled from planned/blocked and none ever moved
into any other lane than planned, blocked or canceled, so no branch was
created) and no lane-tip ref is recorded for it (#5937). A
canceled lane nobody depends on is still tolerated when its branch is gone.
Since #5613 a canceled commit that every carrying lane fully superseded stays in
the claim, live content FAILs with CANCELED_REACHABLE_VIA_DEPENDENCY, and a
canceled dependency WP without a lane_head stamp REFUSEs at claim time; the
attestation lifts that refusal only, per attested WP, and never the FAIL.
Supersession is per path here too: an approved WP that touches a canceled WP's
file supersedes the whole path.
Each is pinned by a strict expected-failure test that asserts the ideal outcome, and is tracked as follow-up work under the parent epic. Two former residuals are closed by the closed-world check and are pinned as REFUSE: an out-of-workflow commit on the lane after a cancel, and a commit by a WP that never entered implementation.
Mission write lock and rollback
Several agents can write one Mission checkout at once (#5819). Two rules keep their writes apart.
One lock per Mission, one key. Every writer of a Mission's status files and shared planning files takes <git common dir>/spec-kitty-locks/<Mission directory name>.status.lock. The key is computed by the pure, git-free mission_lock_key(feature_dir): when the Mission is coordination-routed (a coordination_branch is recorded) and a mid8 is recorded, the key is the coordination directory name, otherwise it is the Mission directory name — never the bare slug. Every lock door and the status transaction resolve the same key through this one function, so a legacy 060-test primary directory and its 060-test-<mid8> coordination surface lock the same file. The key is computed once before the lock is entered and passed down; a thread already holding the lock reuses the held key, so a writer that changes coordination_branch or mid8 inside the hold never takes a second, different lock. The decisions lock (decisions/index.json.lock) is taken before the Mission status lock, never the reverse: a resolve, defer or cancel holds it across its artifact write and its terminal event append, which takes the Mission status lock inside (#5681, the terminal half of #4848). The primitive is status/mission_write.py: mission_write_lock(feature_dir) (re-entrant per thread; bounded wait by default, while the agent action implement / review claim windows and implement's claim hold wait without a limit), locked_rewrite_text(path, transform, feature_dir=...) (read, transform and atomic write under the lock), capture_rollback_point, rollback_events_log and rollback_status_artifacts. Non-status writers resolve the directory with mission_write_lock_dir (missions/_read_path_resolver.py).
Locked helpers for meta.json and frontmatter. A read-modify-write of a Mission file runs inside one lock hold through a registered helper, so a concurrent locked write is never erased. locked_update_meta(feature_dir, mutate, ...) (mission_metadata) takes the lock, re-reads meta.json, applies the mutation and writes atomically; every meta.json setter uses it. locked_update_frontmatter(wp_path, mutate, *, feature_dir, ...) (frontmatter) does the same for a work package's frontmatter, so map-requirements and the finalize-tasks flush keep each other's refs and history notes. The issue-matrix scaffold runs its existence check and its write under the lock, and the acceptance-matrix re-read helper, the acceptance verdict guard and the issue-verdict helper lock through the same door so writers in two worktrees of one Mission exclude each other.
Rollback is verified. capture_rollback_point records the log size and status.json bytes and refuses unless the caller holds the lock. A rollback cuts only the rows its own operation appended and that are not committed at HEAD. It refuses on any doubt: the log vanished or shrank, the tail is not whole JSON rows, the tail is not the rows the caller appended, the tail is already committed, or HEAD cannot be read (including a committed log that holds a non-JSON line). A refusal leaves the files unchanged and reports STATUS_ROLLBACK_REFUSED with the reason and a git diff HEAD -- <log> command naming the log it checked (on a coordination Mission, the coordination worktree's log); agent action prints it, and the coordination fallback appends it to the commit error it re-raises. The exception is a tail already committed at HEAD (always so on a coordination Mission): agent action implement / review then print <WP> claim was committed; the follow-up <operation> commit failed: <error>, record the claim as committed and exit 1, with no refusal code. Nothing is cut and there is nothing to revert. The rollback never extends the file. Only this module truncates or unlinks status.events.jsonl.
Checkout claim lock. On single_branch the write checkout has one occupant. write_checkout_claim_lock(write_checkout) (status/locking.py, beside feature_status_lock) is a sibling of the Mission lock in the same lock hierarchy: it serializes the occupancy scan and the claim emit, keyed on the resolved checkout root. The order is fixed, checkout lock then Mission lock. Taking the checkout lock while the thread holds any other status lock raises RuntimeError naming the held lock keys. That is an internal error (a caller broke the order), so the orchestrator API lets it propagate. A checkout lock that stays held past its bound is the retryable case: agent action implement, implement and the orchestrator API start-implementation report it as STATUS_LOCK_HELD (the API as an envelope, the CLI as a printed code with exit 1). agent action implement and agent action review also warn, without refusing, when shared_workspace_writers (lanes/checkout_occupancy.py) finds another actor's work package active in the same checkout.
Gate. tests/architectural/test_mission_write_discipline.py keeps the writer class closed by construction, with an empty allowlist. Its four rules fail the build on: (1) a truncate, unlink or whole-file rewrite (open(..., "w"), write_text, write_bytes) of a status log or run log outside the primitive; (2) a callable passed into a lock region that is stored, returned or captured so it could run after the lock is released; (3) a Mission lock keyed by anything other than a known Mission-directory-name expression (a slug-keyed or subscript key is refused); and Rule 4 — a write to meta.json, a tasks/WP*.md frontmatter file or tasks.md that sits outside a lock region and does not go through a registered locked helper. A lock region is a lexical with of a lock context manager (and its ExitStack / assigned-name forms); the status-core regions (BookkeepingTransaction and the emit lock) are recognised structurally, and migrations get no exemption — they write through the helpers.
File Layout (per feature)
kitty-specs/<feature>/
status.events.jsonl # CANONICAL: append-only event log
status.json # DERIVED: materialized snapshot (regenerable)
meta.json # Mission metadata (includes optional status_phase)
tasks/
WP01-name.md # DERIVED: frontmatter lane is compatibility view
WP02-name.md
tasks.md # DERIVED: status sections from snapshot
Authority hierarchy:
status.events.jsonl-- canonical truth (append-only, immutable events)status.json-- derived snapshot (regenerable viastatus materialize)- WP frontmatter -- static definition only (title, dependencies, subtasks);
lanefield is historical/migration-only tasks.mdstatus sections -- human view (regenerable)
Troubleshooting
"Illegal transition" error: The transition is not in the allowed transitions matrix. Use --force --actor <name> --reason <text> to override, or check that the from_lane matches what you expect (run status materialize --json to see current state).
Materialization drift detected: Run spec-kitty agent status materialize to regenerate status.json from the event log.
Frontmatter lane drift (legacy missions only): Frontmatter lane is no longer part of the active status model. For pre-3.0 missions that still have frontmatter lane values, run spec-kitty agent status migrate --mission <slug> to bootstrap the event log, then status is managed exclusively via events.
"No event log found": Run spec-kitty agent status migrate --mission <slug> to bootstrap from existing frontmatter state.
Stale claims reported by doctor: Either continue work on the WP or release the claim by moving it back to planned (requires reason).
Coordination status-write guard
On a Mission with a coordination topology, a status write (agent status emit, agent tasks move-task, and any other command that records a lane
transition) is committed from the coordination worktree. Before a write that
goes through the status transaction (BookkeepingTransaction.acquire) opens,
the status log in that worktree is compared with the log its branch HEAD has
committed (#5572, #5613). The write is refused, and nothing is written, in two
cases:
| Code | Condition | Recovery |
|---|---|---|
COORD_STATUS_SURFACE_DIVERGED |
The worktree's status.events.jsonl no longer holds events that HEAD has committed, for example after a rolled-back consolidation restored the file to its earlier bytes and the rollback could not bring the checkout back to the coordination tip (since #5638 it does so whenever only status residue differs). Writing on top of it would commit a log without those events |
The message names up to three of the missing event ids and offers two remedies. spec-kitty doctor coordination --fix --mission <slug> reverts the stranded done, so the Mission is no longer recorded as done; spec-kitty consolidate --resume --mission <slug> does the same heal and then completes the consolidation. Always pass --mission: without it doctor coordination --fix acts on every Mission and flattens those with a stale coordination_branch. git -C <coordination worktree> checkout HEAD -- <mission dir>/status.events.jsonl <mission dir>/status.json keeps the committed events and discards only the stale working-tree bytes; once a later status write lands on top of them, consolidate --resume and doctor coordination --fix decline the automatic heal (the log holds a commit the marker did not record) and ask for a manual reconcile. Choose one, then retry the write |
COORD_STATUS_SURFACE_UNREADABLE |
The check cannot be answered: HEAD or its committed log cannot be read, the committed log has a malformed line or repeats an event id, or the working-tree log cannot be read | Repair the committed log or the checkout, then retry. The message names the reason |
Extra, uncommitted lines in the worktree log are not a divergence: an interrupted append is repaired by the write's own rollback. A Mission without a coordination topology is not checked. The caller-visible shape of the refusal is in the agent subcommand reference.
The guard does not cover every writer. The fallback coordination arm
(_emit_on_coord_then_commit in coordination/status_transition.py) and the
writers that append raw decision and retrospective rows (append_raw_rows_atomic)
do not pass through the status transaction, so they do not run this check
(#5644).
Pre-3.0 layout rejection
Active spec-kitty commands (task, status, acceptance) require a post-3.0
project layout — flat tasks/WP*.md files and status.events.jsonl as the
status source of truth. Commands that encounter a pre-3.0 lane-directory layout
(tasks/planned/, tasks/doing/, tasks/for_review/, tasks/done/
containing .md files) will refuse to proceed:
Pre-3.0 layout detected (tasks/planned/ directories or frontmatter lane state).
Run `spec-kitty upgrade` to migrate before continuing.
Migration path: Run spec-kitty upgrade (or
spec-kitty upgrade --migration 0.9.0_frontmatter_only_lanes) to move WP
files from lane subdirectories to flat tasks/. After upgrade, all active
commands will work normally.
The lane frontmatter field is historical/migration-only and is not written
or read by any active command. Status is tracked exclusively through
status.events.jsonl.