tix/wip/policy.rs
1//! The **one** move decision every surface asks (mission
2//! `configurable-wip-limits-01M3C6WX`, WP03 T012-T016).
3//!
4//! # One decision, or the feature is a suggestion
5//!
6//! C-006 says a surface-local WIP bypass is a defect. The way to make that true is
7//! to have exactly one function that answers "may this move happen?", with no
8//! second copy of the rule in a command, a TUI handler or an MCP tool: those call
9//! [`evaluate`] and act on the [`WipDecision`] it returns. This module therefore
10//! does not write, read a file, read a clock or know what a keystroke is.
11//!
12//! # The order, and why it is this order
13//!
14//! 1. **The transition first** — [`workflow::validate_transition`], untouched. #183
15//! owns what a column *means* and which moves are legal (C-002); WIP is an
16//! additional question asked of a legal move, never a substitute for asking it.
17//! A refused transition is [`WipOutcome::Rejected`] with #183's own diagnostic.
18//! 2. **A same-column move is a no-op** — #183 already says so (`changed: false`).
19//! It returns [`WipOutcome::Allow`] immediately: a move that moves nothing must
20//! not warn, must not block, and must not append an override event.
21//! 3. **The limit and its unit** — from the board's configuration, resolved once
22//! through the aggregate snapshot, which is also where the before/after figures
23//! come from. The moving issue's own estimate is converted into the limit's unit;
24//! an estimate that cannot be counted **rejects** the move rather than counting
25//! as zero (C-003).
26//! 4. **The configured policy** — within the limit is `allow`; over it is `warn` or
27//! `block` according to the configuration, never according to a default.
28//!
29//! # What an outcome means
30//!
31//! | outcome | may the caller write? | History |
32//! |---|---|---|
33//! | `allow` | yes | none — a move within the limit is not a decision |
34//! | `warn` | yes | none — a warning is not an override (C-005 names overrides) |
35//! | `block` | **no** | none — nothing was decided |
36//! | `override` | yes | exactly one event, carrying actor/time/reason/from/to/limit/before/after |
37//! | `rejected` | **no** | none |
38//!
39//! `block` is the refusal a caller can *try to lift*: it carries
40//! [`WipDecision::requires_override`] and the exact [`BoundLimit`](super::r#override::BoundLimit)
41//! an override must be authorized against. A caller that supplies that override
42//! gets `override` on the same move; a caller that supplies an incomplete or stale
43//! one gets `rejected` — inert, and named field by field.
44
45use crate::config::Config;
46use crate::model::{HistoryEventKind, Issue};
47use crate::workflow::{self, TransitionOutcome};
48
49use super::aggregate::{ColumnUsage, LimitState, WipSnapshot};
50use super::config::{ColumnLimit, Estimate, LimitPolicy, WipDiagnostic, WipDiagnosticKind};
51use super::r#override::OverrideRequest;
52
53/// What a surface may do with a move.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum WipOutcome {
56 /// The move is within every limit (or there is no limit): write it.
57 Allow,
58 /// The move is over a limit configured to warn: write it and report.
59 Warn,
60 /// The move is over a limit configured to block, and no override was supplied.
61 /// Nothing may be written; an authorized override would carry it.
62 Block,
63 /// The move is over a blocking limit and a complete, current override carried
64 /// it. The one History event is in [`WipDecision::history`].
65 Override,
66 /// The transition was refused, the estimate could not be counted, or the
67 /// override was incomplete or stale. Nothing may be written.
68 Rejected,
69}
70
71impl WipOutcome {
72 /// The stable spelling every surface prints and every test asserts.
73 pub const fn as_str(self) -> &'static str {
74 match self {
75 Self::Allow => "allow",
76 Self::Warn => "warn",
77 Self::Block => "block",
78 Self::Override => "override",
79 Self::Rejected => "rejected",
80 }
81 }
82
83 /// Whether the caller may write the move.
84 pub const fn is_committable(self) -> bool {
85 matches!(self, Self::Allow | Self::Warn | Self::Override)
86 }
87
88 /// Whether this refusal is one an authorized override could lift.
89 pub const fn requires_override(self) -> bool {
90 matches!(self, Self::Block)
91 }
92}
93
94/// What the policy needs to decide: the configuration and the issues in hand.
95///
96/// Both are already loaded by the caller, which is what keeps this service
97/// filesystem-free — and what makes a decision reproducible in a test without a
98/// repository.
99pub struct MoveContext<'a> {
100 /// The project configuration (the board, and #183's policy it carries).
101 pub config: &'a Config,
102 /// The issues as read, so the aggregate is the one every surface sees.
103 pub issues: &'a [Issue],
104}
105
106impl<'a> MoveContext<'a> {
107 /// A context over already-loaded state.
108 pub const fn new(config: &'a Config, issues: &'a [Issue]) -> Self {
109 Self { config, issues }
110 }
111}
112
113/// One move, as a surface asks for it.
114pub struct MoveRequest<'a> {
115 /// The issue being moved.
116 pub issue: &'a Issue,
117 /// The column it is being moved to.
118 pub to: &'a str,
119 /// A reason for a **transition** override (#183's own gate, e.g. leaving a
120 /// terminal column). Separate from the WIP override below: they are two
121 /// different gates, and a caller may need either, both or neither.
122 pub transition_override: Option<&'a str>,
123 /// An authorized override of a blocking **WIP** limit, when the caller has one.
124 pub over: Option<&'a OverrideRequest>,
125}
126
127impl<'a> MoveRequest<'a> {
128 /// A move with no override of either kind.
129 pub const fn new(issue: &'a Issue, to: &'a str) -> Self {
130 Self {
131 issue,
132 to,
133 transition_override: None,
134 over: None,
135 }
136 }
137
138 /// Attach a reason for #183's own transition gate.
139 pub const fn because(mut self, reason: &'a str) -> Self {
140 self.transition_override = Some(reason);
141 self
142 }
143
144 /// Attach a WIP override.
145 pub const fn overriding(mut self, over: &'a OverrideRequest) -> Self {
146 self.over = Some(over);
147 self
148 }
149}
150
151/// The History entry an applied override writes.
152///
153/// An **intent**, not a write: the caller appends it through the existing grammar
154/// ([`crate::model::History::append_now`] or its `at`-stamped sibling), so there is
155/// no second audit log and the service stays inert (T016, C-005).
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct HistoryIntent {
158 /// The existing #169 event kind an override is recorded as.
159 pub kind: HistoryEventKind,
160 /// Who overrode, as the request stated it.
161 pub actor: String,
162 /// When, as the request stated it.
163 pub timestamp: String,
164 /// The one sentence: reason, columns, limit and the before/after figures.
165 pub detail: String,
166}
167
168/// The decision.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct WipDecision {
171 /// What the caller may do.
172 pub outcome: WipOutcome,
173 /// The column the issue is in.
174 pub from: String,
175 /// The column the move targets.
176 pub to: String,
177 /// The limit in force for the target column, when it has one.
178 pub limit: Option<ColumnLimit>,
179 /// The target column's state before the move.
180 pub state_before: LimitState,
181 /// The target column's state **if** the move happens.
182 pub state_after: LimitState,
183 /// The target column's usage, when it has a limit.
184 pub usage: Option<ColumnUsage>,
185 /// Everything that made the decision what it is.
186 pub diagnostics: Vec<WipDiagnostic>,
187 /// The one History entry an applied override writes; `None` otherwise.
188 pub history: Option<HistoryIntent>,
189}
190
191impl WipDecision {
192 /// Whether an authorized override could carry this refusal.
193 pub const fn requires_override(&self) -> bool {
194 self.outcome.requires_override()
195 }
196
197 /// The outcome's stable spelling.
198 pub const fn as_str(&self) -> &'static str {
199 self.outcome.as_str()
200 }
201
202 /// The [`BoundLimit`](super::r#override::BoundLimit) an override must be
203 /// authorized against, when this is a `block`.
204 pub fn bound_limit(&self) -> Option<super::r#override::BoundLimit> {
205 self.limit
206 .as_ref()
207 .map(|limit| super::r#override::BoundLimit::of(&self.to, limit))
208 }
209
210 /// Whether the caller may write the move.
211 pub const fn is_committable(&self) -> bool {
212 self.outcome.is_committable()
213 }
214}
215
216/// Judge one move (T012).
217#[allow(clippy::too_many_lines)]
218pub fn evaluate(context: &MoveContext<'_>, request: &MoveRequest<'_>) -> WipDecision {
219 let from = request.issue.status.display().to_string();
220 let to = request.to.to_string();
221 let allow = |state: LimitState, diagnostics| WipDecision {
222 outcome: WipOutcome::Allow,
223 from: from.clone(),
224 to: to.clone(),
225 limit: None,
226 state_before: state,
227 state_after: state,
228 usage: None,
229 diagnostics,
230 history: None,
231 };
232
233 // 1. The transition, through #183's own service (C-002).
234 let (_, resolution) = workflow::active_resolution(context.config);
235 match workflow::validate_transition(
236 request.issue,
237 request.to,
238 &resolution,
239 context.config,
240 request.transition_override,
241 ) {
242 TransitionOutcome::Rejected(diagnostic) => {
243 return WipDecision {
244 outcome: WipOutcome::Rejected,
245 from,
246 to,
247 limit: None,
248 state_before: LimitState::Within,
249 state_after: LimitState::Within,
250 usage: None,
251 diagnostics: vec![WipDiagnostic::new(
252 WipDiagnosticKind::RejectedTransition,
253 diagnostic.message,
254 )],
255 history: None,
256 };
257 }
258 // 2. A same-column move decides nothing and therefore warns and blocks
259 // nothing: #183 already calls it permitted and idempotent.
260 TransitionOutcome::Accepted { changed: false, .. } => {
261 return allow(LimitState::Within, Vec::new());
262 }
263 TransitionOutcome::Accepted { .. } => {}
264 }
265
266 // 3. The limit and its unit, from the one shared snapshot.
267 let snapshot = WipSnapshot::build(context.config.default_config(), context.issues);
268 let Some(usage) = snapshot.column(&to).cloned() else {
269 // A column the board does not configure. #183 would have refused it above,
270 // so this is unreachable in practice; treating it as "no limit" keeps the
271 // policy from inventing a refusal #183 did not make.
272 return allow(LimitState::Within, Vec::new());
273 };
274 let Some(limit) = usage.limit.clone() else {
275 return allow(LimitState::Within, Vec::new());
276 };
277
278 // The moving issue's own contribution: one card, and the estimate it states
279 // **counted in the limit's unit**. An estimate that cannot be counted rejects
280 // the move (T014) — never zero, never a guess.
281 let unit = usage.unit().unwrap_or_default().to_string();
282 let mut diagnostics = Vec::new();
283 let add_estimate = match request
284 .issue
285 .estimate
286 .as_deref()
287 .map(str::trim)
288 .filter(|raw| !raw.is_empty())
289 {
290 None => 0,
291 Some(raw) => match Estimate::parse_terms(raw).and_then(|terms| {
292 context
293 .config
294 .default_config()
295 .wip
296 .convert_terms(&terms, &unit)
297 }) {
298 Ok(value) => value,
299 Err(error) => {
300 let first = error.first();
301 diagnostics.push(
302 WipDiagnostic::new(
303 first.kind,
304 format!(
305 "issue #{} states `{raw}`, which this board cannot count in \
306 `{unit}`: {}",
307 request.issue.number, first.message
308 ),
309 )
310 .because(
311 "the move is refused rather than counted as zero: an uncountable \
312 estimate must not pass a limit silently (C-003)",
313 ),
314 );
315 return WipDecision {
316 outcome: WipOutcome::Rejected,
317 from,
318 to,
319 limit: Some(limit),
320 state_before: usage.state(),
321 state_after: LimitState::Unknown,
322 usage: Some(usage),
323 diagnostics,
324 history: None,
325 };
326 }
327 },
328 };
329
330 let state_before = usage.state();
331 let state_after = usage.prospective(1, add_estimate);
332
333 // An aggregate nobody can trust is a refusal, not a pass: WP02 already made the
334 // column's own state `Unknown` in this case, so the move inherits it.
335 if state_after == LimitState::Unknown {
336 diagnostics.push(
337 WipDiagnostic::new(
338 WipDiagnosticKind::AmbiguousConversion,
339 format!(
340 "`{to}` has an estimate ceiling of {} that cannot be compared against its \
341 current contents",
342 limit.max_estimate.unwrap_or_default()
343 ),
344 )
345 .because("fix the uncountable estimate first; the move is refused meanwhile"),
346 );
347 return WipDecision {
348 outcome: WipOutcome::Rejected,
349 from,
350 to,
351 limit: Some(limit),
352 state_before,
353 state_after,
354 usage: Some(usage),
355 diagnostics,
356 history: None,
357 };
358 }
359
360 // 4. Within the limit: allowed, and deliberately not an event (C-004/C-005).
361 if state_after == LimitState::Within {
362 return WipDecision {
363 outcome: WipOutcome::Allow,
364 from,
365 to,
366 limit: Some(limit),
367 state_before,
368 state_after,
369 usage: Some(usage),
370 diagnostics,
371 history: None,
372 };
373 }
374
375 // Over the limit: the configuration decides whether that warns or blocks.
376 let policy = limit.policy.unwrap_or(LimitPolicy::Warn);
377 let mut decision = WipDecision {
378 outcome: match policy {
379 LimitPolicy::Warn => WipOutcome::Warn,
380 LimitPolicy::Block => WipOutcome::Block,
381 },
382 from: from.clone(),
383 to: to.clone(),
384 limit: Some(limit.clone()),
385 state_before,
386 state_after,
387 usage: Some(usage.clone()),
388 diagnostics: vec![WipDiagnostic::new(
389 WipDiagnosticKind::OverLimit,
390 format!(
391 "`{to}` would hold {} against a limit of {} ({})",
392 describe_after(&usage, add_estimate),
393 describe_limit(&limit),
394 policy.as_str()
395 ),
396 )],
397 history: None,
398 };
399
400 if policy == LimitPolicy::Warn {
401 return decision;
402 }
403
404 // A blocking limit: only a complete, current override carries it.
405 let Some(over) = request.over else {
406 decision.diagnostics.push(
407 WipDiagnostic::new(
408 WipDiagnosticKind::OverrideRequired,
409 format!("`{to}` is configured to block; an authorized override is required"),
410 )
411 .because("nothing was changed — supply a confirmed override to proceed"),
412 );
413 return decision;
414 };
415 let findings = over.findings(&to, &limit);
416 if !findings.is_empty() {
417 decision.outcome = WipOutcome::Rejected;
418 decision.diagnostics = findings;
419 return decision;
420 }
421
422 decision.outcome = WipOutcome::Override;
423 decision.history = Some(HistoryIntent {
424 kind: HistoryEventKind::TransitionAccepted,
425 actor: over.actor.clone(),
426 timestamp: over.timestamp.clone(),
427 detail: format!(
428 "wip override: `{from}` -> `{to}` against {}; {} -> {}: {}",
429 super::r#override::BoundLimit::of(&to, &limit).describe(),
430 usage.cell(),
431 describe_after(&usage, add_estimate),
432 over.reason.trim()
433 ),
434 });
435 decision
436}
437
438/// The target column's figures **after** the move, as text.
439fn describe_after(usage: &ColumnUsage, add_estimate: u32) -> String {
440 let items = usage.items.saturating_add(1);
441 match usage.max_estimate() {
442 Some(max) => format!(
443 "{items} cards, {}/{}",
444 usage
445 .estimate
446 .unwrap_or_default()
447 .saturating_add(add_estimate),
448 max
449 ),
450 None => format!("{items} cards"),
451 }
452}
453
454/// The limit as text.
455fn describe_limit(limit: &ColumnLimit) -> String {
456 let mut parts = Vec::new();
457 if let Some(max) = limit.max_items {
458 parts.push(format!("max_items {max}"));
459 }
460 if let Some(max) = limit.max_estimate {
461 match limit.unit.as_deref() {
462 Some(unit) => parts.push(format!("max_estimate {max}{unit}")),
463 None => parts.push(format!("max_estimate {max}")),
464 }
465 }
466 parts.join(", ")
467}