1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
// SPDX-License-Identifier: GPL-3.0-only
//! `lifecycle` — the pure slice lifecycle FSM (axis A of ADR-009, SL-028 design
//! §5.4). Sibling to [`crate::conduct`] (axis B): *what* a change is — its movement
//! along `proposed → design → plan → ready → started → audit → reconcile → done`
//! (plus `abandoned`) — is modelled here as pure data over the edge table; *how* a
//! change is conducted lives in `conduct`.
//!
//! **Pure leaf tier (ADR-001).** No clock / disk / rng / git, and no kind module —
//! the FSM is total over its `&str` edges. The `slice` command shell reads the
//! authored status, injects the date, and writes; this module only classifies.
/// How a `from → to` slice-status move classifies under the lifecycle FSM
/// (design §5.4). Pure data over the edge table — no clock/disk; the verb stamps
/// the shell-injected date. `classify` is total over its `&str` inputs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Transition {
/// A forward step along the chain, or the legitimate seam edges.
Advance,
/// A correction edge that walks back to re-do an invalidated stage.
BackEdge,
/// A move neither forward nor a named back-edge — written and surfaced.
Skip,
/// `* → abandoned` from any non-terminal source.
Abandon,
/// `from == to`; the writer no-ops.
Noop,
/// Leaving a terminal source (`{done, abandoned}`) — refused (reopening
/// deferred).
FromTerminal,
/// A closure-seam breach (F12): `→ reconcile` from a non-`audit` source, or
/// `→ done` from a non-`reconcile` source — refused structurally.
SeamBreach,
}
/// Whether leaving this status is refused by the transition verb — the
/// *reopening-refusal* set (`{done, abandoned}`), F13. A **third**, distinct
/// slice-status predicate: it is NOT `is_terminal_status` (divergence,
/// `{done}` — adding `abandoned` there false-flags `⚠`) nor `is_hidden`
/// (presentation, semantically unrelated). Reopening a closed/abandoned slice is
/// deliberately deferred, so `set_slice_status` refuses a move out of either
/// (`FromTerminal`); the three predicates diverge by design (design §5.2/§5.3).
pub(crate) fn is_transition_terminal(status: &str) -> bool {
matches!(status, "done" | "abandoned")
}
/// Whether a `from → to` move crosses the **closure seam** (design §7, D8): the
/// two legitimate terminal advances `audit → reconcile` and `reconcile → done`.
/// Pure — the reverse close-gate (`slice::run_status`) fires the RV-blocker scan
/// ONLY on these edges, never on any other transition (VT-4). These are exactly the
/// `to`-targets the `SeamBreach` guard protects (§5.5), taken from their one legal
/// source: structurally, `set_slice_status` is the sole writer of these moves, so
/// that shell is the sole seam-crosser (VT-5).
pub(crate) fn crosses_closure_seam(from: &str, to: &str) -> bool {
matches!((from, to), ("audit", "reconcile") | ("reconcile", "done"))
}
/// Classify a `from → to` slice-status move against the FSM (design §5.4),
/// edge-table driven (NOT index arithmetic — `abandoned` is last in the const but
/// is not "after `done`" in the FSM). `to` is assumed in-vocab (the verb boundary
/// guards an out-of-vocab target); `from` may be drifted (out-of-vocab), in which
/// case a non-seam, non-terminal move falls through to `Skip` — but the seam still
/// binds by *target* edge (`→ reconcile`/`→ done` from a drifted source is a
/// `SeamBreach`, §5.5). Precedence: no-op → from-terminal → closure-seam (by
/// target) → abandon → forward/back edges → skip.
pub(crate) fn classify(from: &str, to: &str) -> Transition {
if from == to {
return Transition::Noop;
}
if is_transition_terminal(from) {
return Transition::FromTerminal;
}
// Closure seam (F12), gated by the *target* edge — binds even from a drifted
// `from`. The legitimate seam entries are the only way in.
if to == "reconcile" {
return if from == "audit" {
Transition::Advance
} else {
Transition::SeamBreach
};
}
if to == "done" {
return if from == "reconcile" {
Transition::Advance
} else {
Transition::SeamBreach
};
}
if to == "abandoned" {
return Transition::Abandon;
}
// Forward chain (the non-seam advances) and the named back-edges.
match (from, to) {
("proposed", "design")
| ("design", "plan")
| ("plan", "ready")
| ("ready", "started")
| ("started", "audit") => Transition::Advance,
("audit", "started" | "design") | ("reconcile", "audit" | "design") => Transition::BackEdge,
_ => Transition::Skip,
}
}
#[cfg(test)]
mod tests {
use super::*;
// --- SL-028 PHASE-01: lifecycle FSM ---
// VT-1: classify table (design §5.4/§9). Edge-table driven; covers advance,
// each back-edge, skip, abandon, noop, from-terminal, seam-breach (incl. from
// a drifted source), and the legit seam path audit→reconcile→done = Advance.
#[test]
fn classify_forward_chain_is_advance() {
for (from, to) in [
("proposed", "design"),
("design", "plan"),
("plan", "ready"),
("ready", "started"),
("started", "audit"),
] {
assert_eq!(classify(from, to), Transition::Advance, "{from} → {to}");
}
}
#[test]
fn classify_legit_closure_seam_path_is_advance() {
// audit → reconcile → done — the ADR-003 §7/§8 spine.
assert_eq!(classify("audit", "reconcile"), Transition::Advance);
assert_eq!(classify("reconcile", "done"), Transition::Advance);
}
#[test]
fn classify_named_back_edges() {
for (from, to) in [
("audit", "started"),
("audit", "design"),
("reconcile", "audit"),
("reconcile", "design"),
] {
assert_eq!(classify(from, to), Transition::BackEdge, "{from} → {to}");
}
}
#[test]
fn classify_abandon_from_each_non_terminal() {
for from in [
"proposed",
"design",
"plan",
"ready",
"started",
"audit",
"reconcile",
] {
assert_eq!(
classify(from, "abandoned"),
Transition::Abandon,
"{from} → abandoned"
);
}
}
#[test]
fn classify_noop_when_unchanged() {
assert_eq!(classify("started", "started"), Transition::Noop);
// No-op precedes from-terminal: done → done is a no-op, not a refusal.
assert_eq!(classify("done", "done"), Transition::Noop);
}
#[test]
fn classify_from_terminal_refused() {
for from in ["done", "abandoned"] {
assert_eq!(
classify(from, "design"),
Transition::FromTerminal,
"{from} → design"
);
}
}
#[test]
fn classify_seam_breach_to_reconcile_from_non_audit() {
for from in ["proposed", "design", "plan", "ready", "started"] {
assert_eq!(
classify(from, "reconcile"),
Transition::SeamBreach,
"{from} → reconcile"
);
}
}
#[test]
fn classify_seam_breach_to_done_from_non_reconcile() {
for from in ["proposed", "design", "plan", "ready", "started", "audit"] {
assert_eq!(
classify(from, "done"),
Transition::SeamBreach,
"{from} → done"
);
}
}
#[test]
fn classify_seam_binds_even_from_a_drifted_source() {
// The seam is about the target edge, not the source's validity (§5.5).
assert_eq!(classify("bogus", "reconcile"), Transition::SeamBreach);
assert_eq!(classify("bogus", "done"), Transition::SeamBreach);
}
#[test]
fn classify_move_out_of_drift_is_skip_not_refused() {
// Out-of-vocab `from`, non-seam, non-terminal target → Skip (allowed).
assert_eq!(classify("bogus", "started"), Transition::Skip);
}
#[test]
fn classify_non_chain_move_is_skip() {
// A legal-vocab pair the FSM never names (and not a seam target) → Skip.
assert_eq!(classify("proposed", "started"), Transition::Skip);
assert_eq!(classify("design", "started"), Transition::Skip);
}
}