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).
| Edge | Required evidence |
|---|---|
in_progress → planned | reason |
approved → in_progress | review_ref |
approved → planned | review_ref |
in_review → in_progress | structured review_result (reviewer+verdict+reference) |
in_review → planned | structured 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_dictMUST NOT be used to parse it (it hard-requires store.is_non_lane_eventMUST classify it soread_eventssurfaces it toreduce()(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
InnerStateChangedevent can never mutatelane(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+InnerStateChangedevents - 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.