Contracts

emit-force.md

Contract: force-provenance on backward transitions (FR-015)

Rule

build_transition_plan (tasks_transition_core.py:218-219) MUST NOT auto-promote emit_force on a backward edge that the FSM accepts force-free given the supplied evidence. Decide by asking the FSM, not by a hard-coded edge list:

if not force and _is_backward_transition(old, target):
    legal_force_free, _ = validate_transition(old, target, ctx_with_evidence)  # force=False
    emit_force = not legal_force_free      # only promote when the FSM genuinely requires it

The five evidence-gated exempt edges (confirmatory/illustrative — NOT the implementation surface)

> The mechanism is the FSM query above; this table documents the expected result and MUST NOT be > encoded as a hard-coded edge list (it would rot if the matrix changes — FR-015 / close-by-construction).

EdgeRequired evidence
in_progress → plannedreason
approved → in_progressreview_ref
approved → plannedreview_ref
in_review → in_progressstructured review_result (reviewer+verdict+reference)
in_review → plannedstructured review_result

Evidence is edge-specific and not interchangeable (a scalar reason is rejected on the in_review→ edges, wp_state.py:624). Genuinely force-requiring backward edges (e.g. leaving terminal done/canceled, and all for_review→/claimed→* rewinds) keep force truthfully.

Persisted-layer assertion (SC-007)

The gate is the persisted StatusEvent.force on status.events.jsonl after driving the real move-task entry point — not the plan object. Existing plan-level assertions (test_tasks_transition_core.py:527,532,542, test_tasks_backward_emit.py, test_status_e2e_integration.py, test_status_cli.py) are re-pointed to the correct expected values (falsy for the five edges; a retained genuine-force edge as a truthy positive control) — never deleted. Distinct from spec-kitty-saas#509 (server transition matrix).

innerstatechanged-event.md

Contract: InnerStateChanged event + reducer fold

Wire shape (append-only status.events.jsonl)

{
  "event_id": "01KX...",          // ULID; deterministic-namespaced for backfill seeds
  "kind": "annotation",           // discriminator; NOT a lane transition
  "wp_id": "WP01",
  "at": "2026-07-19T07:00:00Z",
  "actor": "claude",
  "delta": { "shell_pid": 12345, "shell_pid_created_at": "..." }
}

the lane keys); a distinct decoder handles kind == "annotation".

method skips any event_type-bearing event — reconcile the discriminator).

  • No from_lane/to_lane. StatusEvent.from_dict MUST NOT be used to parse it (it hard-requires
  • store.is_non_lane_event MUST classify it so read_events surfaces it to reduce() (today the

Reducer fold contract

1. Read events; partition into transition and annotation kinds. 2. Fold all transitions in (at, event_id) order — each transition preserves the per-WP runtime slots it does not set (do NOT rebuild the dict dropping the runtime slots). The planned→claimed transition additionally extracts shell_pid/shell_pid_created_at/agent from its policy_metadata sidecar into the snapshot slots (FR-004 claim path — the only transition that writes a runtime slot). 3. Fold all annotations after transitions, applying WPInnerStateDelta per-field merge (replace / per-subtask replace / append / union). Never increment force_count. 4. Complexity: O(events), single pass per partition, no additional full re-reduction (NFR-005).

Invariants (architectural tests)

  • An InnerStateChanged event can never mutate lane (never reduced as a transition).
  • No runtime slot is erased by a subsequent lane transition (per-field preservation).
  • At an equal at, a backfill seed annotation folds after the transition it annotates.

migration.md

Contract: migration backfill + cutover (FR-010, FR-011)

Strict order (both clobber-windows closed)

backfill → verify(FAIL-CLOSED) → reader cutover → writer cutover → delete fallbacks → land hash guard

reconstructing frontmatter/checkbox runtime state. Seed event_id = deterministic namespaced ULID (mission_id+wp_id+field); idempotent (re-run seeds nothing). Subtask marks clamp at to the WP's claimed; the seed ULID orders after the claimed transition at that equal at.

reader produces, by count + value parity. Any mismatch (or a fault-injected corrupt seed) MUST abort before reader cutover.

path switches atomically with the reader per field (or dual-writes during the window) so a fresh runtime write is never invisible to a snapshot-first reader.

done_bookkeeping.py:104-105 fallbacks — only now, because they synthesise done-evidence for un-migrated on-disk WPs until backfill seeds those approvals as events.

  • backfill: for every WP in the live corpus, emit seed transition + InnerStateChanged events
  • verify (fail-closed): assert the reduced snapshot equals the value the OLD frontmatter/checkbox
  • reader cutover: readers resolve from the snapshot; the frontmatter fallback stays behind a flag.
  • writer cutover: writers stop touching the WP file. To avoid the symmetric split-brain, the emit
  • delete fallbacks: remove the FR-005 flag and the legacy workflow_cores.py:340-348 /

MUTABLE_FIELDS extension (do not fork)

Add shell_pid_created_at, review_artifact_override_*, reviewer_shell_pid; move history out of STATIC_FIELDS; retire progress. history[] and progress are deleted outright after a zero-reader verification (no live reader anywhere, not merely no authority-read).

Honesty bound (no-data-loss)

"No data loss" is asserted against count+value parity of the reduced snapshot, not temporal fidelity: backfilled subtask-completion timestamps are clamped (fictional), and seed ULIDs are content-namespaced (not chronological). The contract holds only because no consumer reads subtask-completion time or relies on seed-ULID chronological order — asserted as a precondition.