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
#[allow(clippy::wildcard_imports)]
use super::*;
impl Store {
/// **The** status write path (WP03, C-003, FR-002, NFR-002).
///
/// Every surface that changes an issue's column comes through here, so the
/// transition service is on the path *structurally* rather than by convention:
/// CLI (`tix mv`, `tix edit --status`), the TUI's move and edit actions, MCP's
/// `move_issue` and `update_issue`, and sync's import. A caller that judges a
/// transition itself is a parity defect, and the two that used to are the
/// reason this function had no production caller at all.
///
/// ## The three outcomes
///
/// | Result | Meaning | Written |
/// |---|---|---|
/// | `Ok(outcome)`, `outcome.written` | accepted, changed column | file + one decision |
/// | `Ok(outcome)`, `!outcome.written` | accepted, same column | **nothing** (idempotent) |
/// | `Err(TixError::Workflow(_))` | refused | **nothing at all** |
///
/// The separation is in the type, not in a runtime check: a refusal is an
/// `Err` carrying the **named** diagnostic, so a caller cannot read it as a
/// write, and cannot mistake `unknown_column` (a typo) for `invalid_transition`
/// (policy) — one message shape for both would lose the distinction the
/// contract's shared vocabulary exists to keep (contract §6).
///
/// ## Rejection is inert
///
/// Nothing is touched before the service has accepted: no issue file, no
/// `tix.toml`, no History entry. The service is pure (`src/workflow.rs` is
/// filesystem-free by design), and the only write in this function is the
/// single `update` at the end — so there is no window in which a refusal
/// leaves a partial change behind.
///
/// ## No repair path
///
/// A file hand-edited into an inconsistent status is read, judged, and
/// reported. This function never corrects one (C-005, decision
/// `01M3C3XBMTR5WFV7059NXM04AJ`); WP06 owns the diagnosis. An auto-repair
/// sneaking in here would be an escalation trigger.
///
/// Routes through [`Store::update`] rather than serialising directly, so the
/// retired `links:` disclosure happens on this path too. Cycle-5 Minor 9: this
/// used to write the file itself, which drops the retired `links:` key
/// because the key is not in the model — with **no disclosure**, since the
/// disclosure lives in `update`. It was dead code reachable only from tests,
/// so nothing caught it, and wiring it up would have silently destroyed the
/// key on every surface at once.
pub fn set_status(&self, filename: &str, request: StatusChange<'_>) -> Result<StatusOutcome> {
let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
// Once per config load, not per issue: the contract's O(V) sweep rests on
// this being a single hash lookup per column rather than a rebuild.
let (_, resolution) = crate::workflow::active_resolution(&self.config);
// The one transition evaluation (C-003). Everything below is the write this
// WP is responsible for; no rule of its own lives here.
let outcome = crate::workflow::validate_transition(
&issue,
request.column,
&resolution,
&self.config,
request.override_reason,
);
// The inert case. Return before any mutation, so there is nothing to undo
// and no History entry to retract.
let accepted = match TransitionAccepted::try_from(outcome) {
Ok(accepted) => accepted,
Err(diagnostic) => return Err(TixError::Workflow(diagnostic)),
};
// Idempotency: a same-column re-apply is accepted but writes nothing.
// `updated` is deliberately *not* bumped either — a no-op that touches the
// file is not a no-op, and bumping the date would make `tix edit --status`
// with the column it already has look like an edit.
if !accepted.changed {
return Ok(StatusOutcome {
issue,
decision: None,
written: false,
});
}
// The completion coupling, which used to live in `mv.rs`. It belongs here
// because it is a rule about the *status* column: reaching completion
// records a commit, and leaving it clears the one it recorded. Keeping it
// in the command layer is what let `tix edit --status` move an issue to
// done without recording a commit, i.e. a second definition of "done".
//
// WP04: both halves of this now ask the **service** whether a column is
// complete, rather than comparing against `effective_done_column()`.
//
// This was a live defect, not a tidy-up. WP03 wrote the coupling against the
// column name; WP04 moved the CLI's *prompt trigger* to the service's
// decision. The two then disagreed on any board whose `category_map` puts
// `completed` somewhere other than its `done_column`: the CLI correctly
// decided the move reached completion and asked for a commit hash, and the
// seam then compared the column to `done_column`, concluded it had not, and
// **silently dropped the hash the user had just typed**. The commit-prompt
// parity test is what found it.
//
// `was_complete` is computed before `issue.status` is reassigned below, so
// it describes where the issue *was*.
let was_complete = crate::workflow::is_completed(&issue, &resolution);
let reaches_complete = resolution
.resolve(&accepted.to)
.is_some_and(crate::workflow::WorkflowCategory::is_complete);
if reaches_complete {
issue.commit = request.commit.map(str::to_string);
} else if was_complete {
issue.commit = None;
}
issue.status = Status::new(accepted.to.clone());
issue.bump_updated();
// Exactly one decision per accepted transition (C-006), appended to the
// issue's existing History — never a second log.
let decision = TransitionDecision::new(
crate::model::utc_now_rfc3339(),
// The existing identity path, not a new one. `actor_identity` is the
// same function the relationship and hierarchy events use, so a
// decision's actor is spelled the way every other History entry's is
// and cannot drift from it (T012 step 4).
actor_identity(),
accepted.from.clone(),
accepted.to.clone(),
accepted.from_category.key(),
accepted.to_category.key(),
accepted.override_reason,
);
issue.history.append(decision.to_event());
issue.filename = self.update(filename, &issue)?;
Ok(StatusOutcome {
issue,
decision: Some(decision),
written: true,
})
}
}