tix/triage/decision.rs
1//! The triage decision service: accept, decline, duplicate, defer.
2//!
3//! Mission `triage-inbox-review-flow-01M3C600`, WP03 (T012–T016). One typed
4//! request in, either one append-only `History` event intent out or a refusal —
5//! and **nothing else**. The service never opens, reads or writes a file:
6//! [`DecisionService::apply`] takes `&Issue` and returns a *clone* carrying the
7//! decision, so a rejected or repeated decision cannot have side effects that
8//! someone would have to undo (data-model §3, contract `decision-history.md`).
9//!
10//! What it deliberately does **not** do:
11//!
12//! - **No second workflow.** Every status target goes through
13//! [`crate::workflow::validate_transition`]; the completion and terminal rules
14//! are #183's and are not restated here (C-002).
15//! - **No second History log.** A decision is one line in the existing
16//! `## History` section, rendered by [`crate::triage::history`] (C-004).
17//! - **No destructive outcome.** Decline moves to the configured `canceled`
18//! column; duplicate returns a typed relation and never merges, deletes or
19//! moves a file (C-003, C-006). Merging is issue 189's.
20//!
21//! ## Order of checks
22//!
23//! The refusal order is fixed and each step is observable, because two
24//! refusals that both apply must always produce the *same* one:
25//!
26//! 1. required fields — [`Refusal::missing_reason`], [`Refusal::missing_actor`];
27//! 2. the requested target — [`Refusal::missing_target`],
28//! [`Refusal::invalid_duplicate_target`], [`Refusal::unknown_issue`];
29//! 3. idempotence — [`DecisionOutcome::NoOp`];
30//! 4. queue membership — [`Refusal::not_in_triage`] or
31//! [`Refusal::already_decided`];
32//! 5. the shared transition policy — [`Refusal::from_workflow`] (which surfaces
33//! #183's `unknown_column` / `invalid_transition` unchanged).
34//!
35//! Step 3 before step 4 is load-bearing: declining twice is a no-op **even
36//! though the first decline took the issue out of the inbox**, so the
37//! idempotence question has to be asked before "is it still queued?". Step 2
38//! before both means a malformed request is refused identically whatever the
39//! issue happens to look like — a typo is not a state-dependent error.
40
41use std::fmt;
42use std::str::FromStr;
43
44use crate::config::Config;
45use crate::error::{Result, TixError};
46use crate::model::{HistoryEvent, HistoryEventKind, Issue, Status};
47use crate::relationships::{CanonicalEdge, RelationKind, Relationship};
48use crate::triage::history::{self, TriageDecisionRecord};
49use crate::triage::inbox::InboxScope;
50use crate::triage::record_entry;
51use crate::workflow::{self, DiagnosticKind, TransitionOutcome, WorkflowDiagnostic};
52
53/// This service's fallible type: a [`Refusal`] is the only failure it produces.
54///
55/// Deliberately not [`crate::error::Result`]. A refusal carries a *contract*
56/// diagnostic name and an issue identity, and collapsing it into a bare
57/// [`TixError`] at the boundary where it is raised would mean the CLI, TUI and
58/// MCP surfaces (NFR-003) each had to re-derive which condition was hit from
59/// prose. [`Refusal`] converts with `?`/`Into` when a surface wants the crate's
60/// error type.
61type DecisionResult<T> = std::result::Result<T, Refusal>;
62
63/// The four decisions (data-model §3, `kind`).
64///
65/// A closed set with no "other": there is no automatic decision path (C-008),
66/// so every decision is one of these four, named explicitly by the caller.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum DecisionKind {
69 /// Move into the normal workflow: `--to`, or `[triage].accepted_status`.
70 Accept,
71 /// Move to the configured declined column. Never delete, never archive.
72 Decline,
73 /// Record a typed `duplicates` relation against another issue.
74 Duplicate,
75 /// Stay in the inbox with a refreshed entry time.
76 Defer,
77}
78
79impl DecisionKind {
80 /// The canonical lowercase spelling used on the CLI and in messages.
81 #[must_use]
82 pub fn as_str(self) -> &'static str {
83 match self {
84 DecisionKind::Accept => "accept",
85 DecisionKind::Decline => "decline",
86 DecisionKind::Duplicate => "duplicate",
87 DecisionKind::Defer => "defer",
88 }
89 }
90
91 /// The `## History` kind an applied decision of this kind appends.
92 ///
93 /// The mapping lives here rather than at each call site so that "which
94 /// history entry does this decision write?" has one answer — the service
95 /// asks it to compare against the last recorded decision, and a test asks it
96 /// to assert the event that landed.
97 #[must_use]
98 pub fn event_kind(self) -> HistoryEventKind {
99 match self {
100 DecisionKind::Accept => HistoryEventKind::TriageAccepted,
101 DecisionKind::Decline => HistoryEventKind::TriageDeclined,
102 DecisionKind::Duplicate => HistoryEventKind::TriageDuplicated,
103 DecisionKind::Defer => HistoryEventKind::TriageDeferred,
104 }
105 }
106
107 /// Whether this decision refuses to proceed without a reason.
108 ///
109 /// **Decline, duplicate and defer require one; accept does not.** The
110 /// contract says decline/duplicate/defer "require a reason" and that accept
111 /// "records a reason when supplied", and data-model §3 repeats the split —
112 /// so an accept with no reason produces an event whose `reason` field is
113 /// absent rather than a refusal. Whether *the CLI* insists on `--reason` for
114 /// accept is the surface's own spelling question (contract `cli-surface.md`
115 /// defers the reason rules to `decision-history.md`), not a rule about what
116 /// a decision is.
117 #[must_use]
118 pub fn requires_reason(self) -> bool {
119 !matches!(self, DecisionKind::Accept)
120 }
121}
122
123impl fmt::Display for DecisionKind {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 f.write_str(self.as_str())
126 }
127}
128
129impl FromStr for DecisionKind {
130 type Err = TixError;
131
132 /// Parse a decision name. Unknown names are rejected rather than coerced:
133 /// guessing between `decline` and `defer` on a batch would apply a decision
134 /// nobody chose.
135 fn from_str(s: &str) -> Result<Self> {
136 match s.trim().to_ascii_lowercase().as_str() {
137 "accept" => Ok(DecisionKind::Accept),
138 "decline" => Ok(DecisionKind::Decline),
139 "duplicate" => Ok(DecisionKind::Duplicate),
140 "defer" => Ok(DecisionKind::Defer),
141 other => Err(TixError::InvalidInput(format!(
142 "unknown triage decision `{other}` — expected accept, decline, \
143 duplicate, or defer"
144 ))),
145 }
146 }
147}
148
149/// A typed decision request (data-model §3).
150///
151/// Everything a decision needs to be judged **without** touching the filesystem:
152/// the decision itself, who made it, when, and the optional targets. The issue
153/// under decision is not a field — it is the `issue` argument of
154/// [`DecisionService::apply`], because one batch decision is applied to many
155/// issues and a request that embedded its subject would have to be rebuilt per
156/// item (contract `decision-history.md`, §Batch behavior).
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct TriageDecision {
159 /// Which decision this is.
160 pub kind: DecisionKind,
161 /// The recorded reason, verbatim. Required for decline, duplicate and
162 /// defer; see [`DecisionKind::requires_reason`].
163 pub reason: String,
164 /// Who decided. Required: an audit entry with no actor is worse than no
165 /// entry (contract §Required reason and actor).
166 pub actor: String,
167 /// The duplicate target's issue number, for a duplicate decision.
168 pub target_issue: Option<u32>,
169 /// The accept target column (`--to`). Absent means "the configured
170 /// `[triage].accepted_status`".
171 pub target_status: Option<String>,
172 /// The decision time, RFC 3339. Supplied rather than read from the clock so
173 /// a batch stamps every event identically and a test is deterministic.
174 pub timestamp: String,
175}
176
177impl TriageDecision {
178 /// A decision with no optional target and an explicit timestamp.
179 ///
180 /// The timestamp is a parameter rather than a default for the same reason
181 /// [`crate::triage::inbox::collect`] takes `today`: a service that reads the
182 /// wall clock cannot be tested against a pinned time, and a batch would
183 /// otherwise hand `now()` to a loop that runs it once per item.
184 #[must_use]
185 pub fn new(
186 kind: DecisionKind,
187 timestamp: impl Into<String>,
188 actor: impl Into<String>,
189 reason: impl Into<String>,
190 ) -> Self {
191 Self {
192 kind,
193 reason: reason.into(),
194 actor: actor.into(),
195 target_issue: None,
196 target_status: None,
197 timestamp: timestamp.into(),
198 }
199 }
200}
201
202/// How the caller resolved the duplicate target's identity.
203///
204/// The service validates *identities*; resolving a selector (`--of some title`)
205/// against the store is the caller's job, because that is the part that needs
206/// the files. This enum carries the outcome of that resolution so the service
207/// can still name the failure itself — a duplicate decision's diagnostics are
208/// the contract's, not the caller's, and "matched three issues" is one of them.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum DuplicateTarget<'a> {
211 /// The selector resolved to exactly one issue.
212 Resolved(&'a Issue),
213 /// The selector resolved to no issue at all.
214 Unknown,
215 /// The selector matched more than one issue, so no single identity exists.
216 Ambiguous,
217}
218
219/// What a decision plans to do, decided before any state is read.
220///
221/// The intermediate between "what was asked for" and "what will change" exists
222/// so that steps 2 and 3 of the order can be answered *before* step 5 validates
223/// the move: idempotence has to be judged on the resolved target (an accept
224/// whose `--to` equals the current column is a no-op), and a duplicate's
225/// relation needs its target's title, which only the resolution has.
226#[derive(Debug, Clone, PartialEq, Eq)]
227enum Plan {
228 /// Move to this column, once #183 accepts the transition.
229 Move(String),
230 /// Create this typed relation; the issue's column does not change.
231 Duplicate {
232 target_number: u32,
233 target_title: String,
234 },
235}
236
237/// Why a decision was refused, and for which issue (contract §Diagnostics).
238///
239/// One struct rather than an enum of refusals: the contract fixes a *stable set
240/// of names* (`missing_reason`, `unknown_issue`, `unknown_column`,
241/// `invalid_transition`, `invalid_duplicate_target`, `not_in_triage`,
242/// `already_decided`, `write_failed` — "include", so the list is a vocabulary
243/// and not a closed set) and a message, and a surface that matches on eight
244/// variants gains nothing over one that matches on the name string it has to
245/// render anyway. The names are pinned by tests, which is what makes them
246/// stable.
247///
248/// `issue` is carried alongside the name because NFR-003 requires CLI, TUI and
249/// MCP to report "the same kind **and item identity**": a batch refusal with no
250/// item cannot be pointed at.
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct Refusal {
253 kind: &'static str,
254 issue: Option<u32>,
255 message: String,
256}
257
258impl Refusal {
259 /// Build a refusal directly. Public so a caller that resolves the *input*
260 /// (a selector that matched nothing, a malformed batch flag) reports the
261 /// same contract name the service would.
262 #[must_use]
263 pub fn new(kind: &'static str, issue: Option<u32>, message: impl Into<String>) -> Self {
264 Self {
265 kind,
266 issue,
267 message: message.into(),
268 }
269 }
270
271 /// The contract's diagnostic name for this refusal.
272 #[must_use]
273 pub fn kind(&self) -> &'static str {
274 self.kind
275 }
276
277 /// The issue the refusal is about, when there is one.
278 #[must_use]
279 pub fn issue(&self) -> Option<u32> {
280 self.issue
281 }
282
283 /// The human-readable part, without the kind prefix.
284 #[must_use]
285 pub fn message(&self) -> &str {
286 &self.message
287 }
288
289 /// A required reason is absent (`missing_reason`).
290 fn missing_reason(issue: u32, kind: DecisionKind) -> Self {
291 Self::new(
292 "missing_reason",
293 Some(issue),
294 format!("`#{issue}` {kind} requires a reason"),
295 )
296 }
297
298 /// No actor was supplied (`missing_actor`).
299 fn missing_actor(issue: u32) -> Self {
300 Self::new(
301 "missing_actor",
302 Some(issue),
303 format!(
304 "`#{issue}` needs an actor: a triage decision is never recorded \
305 anonymously"
306 ),
307 )
308 }
309
310 /// Accept was given neither `--to` nor `[triage].accepted_status`
311 /// (`missing_target`).
312 ///
313 /// The contract's stable list does not spell this name out, and no other
314 /// name fits: `unknown_column` would claim a column was named when none was,
315 /// and `invalid_transition` would claim #183 refused a move it was never
316 /// asked about. "Stable kinds *include*" makes the list non-exhaustive, so
317 /// the vocabulary grows by one rather than borrowing a name that lies.
318 fn missing_target(issue: u32) -> Self {
319 Self::new(
320 "missing_target",
321 Some(issue),
322 format!(
323 "`#{issue}` accept has no target column: pass `--to`, or set \
324 `[triage].accepted_status`"
325 ),
326 )
327 }
328
329 /// The duplicate target is structurally wrong: absent, a self link, or
330 /// ambiguous (`invalid_duplicate_target`).
331 fn invalid_duplicate_target(issue: u32, message: impl Into<String>) -> Self {
332 Self::new("invalid_duplicate_target", Some(issue), message)
333 }
334
335 /// The requested identity resolves to no issue (`unknown_issue`).
336 fn unknown_issue(issue: u32, message: impl Into<String>) -> Self {
337 Self::new("unknown_issue", Some(issue), message)
338 }
339
340 /// `--of` matched more than one issue.
341 fn ambiguous(issue: u32) -> Self {
342 Self::invalid_duplicate_target(
343 issue,
344 "`--of` matched more than one issue; name the duplicate target by \
345 its number",
346 )
347 }
348
349 /// The issue is not in the triage inbox (`not_in_triage`).
350 fn not_in_triage(issue: u32) -> Self {
351 Self::new(
352 "not_in_triage",
353 Some(issue),
354 format!(
355 "`#{issue}` is not in the triage inbox: decisions are made from \
356 the queue, and this issue is in a normal column"
357 ),
358 )
359 }
360
361 /// The issue already carries a triage decision (`already_decided`).
362 ///
363 /// Told apart from [`Self::not_in_triage`] by whether the file records a
364 /// decision at all: an issue that never was triaged and sits in a normal
365 /// column was simply never queued, while one that left the queue *with* a
366 /// decision has used its triage review. Changing it again is a normal status
367 /// change (`tix move`), which is where that belongs.
368 fn already_decided(issue: u32) -> Self {
369 Self::new(
370 "already_decided",
371 Some(issue),
372 format!(
373 "`#{issue}` already carries a triage decision and has left the \
374 inbox; change it with a normal status command instead"
375 ),
376 )
377 }
378
379 /// A refusal from #183's transition service, passed through with its own
380 /// name (`unknown_column`, `invalid_transition`).
381 ///
382 /// The workflow diagnostic is **not** re-interpreted: #183 owns what a legal
383 /// transition is (C-002), so the only thing done here is translating its
384 /// [`DiagnosticKind`] into this contract's spelling — and two of the seven
385 /// workflow kinds are the only ones `validate_transition` can produce. Every
386 /// other refusal it could ever be handed — the terminal lock, adjacency, the
387 /// completion guard — is one thing to a triage caller: the move is against
388 /// policy, which is the contract's `invalid_transition`.
389 fn from_workflow(issue: u32, diagnostic: &WorkflowDiagnostic) -> Self {
390 let kind = match diagnostic.kind {
391 DiagnosticKind::UnknownColumn => "unknown_column",
392 _ => "invalid_transition",
393 };
394 Self::new(
395 kind,
396 Some(issue),
397 format!("{}: {}", diagnostic.subject, diagnostic.message),
398 )
399 }
400}
401
402impl fmt::Display for Refusal {
403 /// `kind: message`, rendered by the crate's one refusal renderer so a
404 /// message that already leads with its kind is not prefixed twice.
405 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
406 f.write_str(&crate::error::render_diagnostics([(
407 self.kind,
408 self.message.as_str(),
409 )]))
410 }
411}
412
413impl From<Refusal> for TixError {
414 /// Refusals reach surfaces that take the crate's error type as
415 /// `invalid input`, the variant WP01 and WP02 already use for triage
416 /// validation failures — no new variant, and no new name in
417 /// [`crate::workflow::DiagnosticKind`], whose `ALL` stays at seven.
418 fn from(refusal: Refusal) -> Self {
419 TixError::InvalidInput(refusal.to_string())
420 }
421}
422
423/// The result of one applied decision: the updated issue and the event intent
424/// (data-model §4).
425///
426/// The issue is a **clone the caller still has to write** — the service hands
427/// back what *would* be on disk, and storage performs the write. That is the
428/// whole filesystem-free claim: every rejection and every no-op returns before
429/// this variant exists, so "did anything change?" is answerable from the return
430/// value alone.
431///
432/// `Applied` is far larger than `NoOp`, which trips `clippy::large_enum_variant`
433/// — and boxing would add an allocation to a value that is *moved*, not copied,
434/// on the only path that constructs it. The size comes from `Issue`, which the
435/// service clones by necessity (see [`DecisionService::apply`]): the caller must
436/// be able to discard the result without having given anything up.
437#[allow(clippy::large_enum_variant)]
438#[derive(Debug, Clone, PartialEq, Eq)]
439pub enum DecisionOutcome {
440 /// One decision applied: exactly one event, appended to the issue's own
441 /// History (NFR-002).
442 Applied {
443 /// The issue as the decision leaves it: status moved, entry metadata
444 /// updated, and `event` appended to its History.
445 issue: Issue,
446 /// The event intent. Always the *last* entry of `issue.history` —
447 /// one applied decision produces exactly one event, and there is no
448 /// second copy of it anywhere.
449 event: HistoryEvent,
450 /// The typed relation a duplicate decision creates. `None` for the
451 /// other three: only a duplicate adds an edge, and it adds no file
452 /// change at all (C-006).
453 relation: Option<Relationship>,
454 },
455 /// Already satisfied: no mutation, no event, no write (contract
456 /// §Idempotence and append-only behavior).
457 NoOp,
458}
459
460impl DecisionOutcome {
461 /// Whether this outcome changed nothing.
462 #[must_use]
463 pub fn is_noop(&self) -> bool {
464 matches!(self, DecisionOutcome::NoOp)
465 }
466
467 /// The applied decision, when there was one.
468 #[must_use]
469 pub fn applied(&self) -> Option<(&Issue, &HistoryEvent, Option<&Relationship>)> {
470 match self {
471 DecisionOutcome::Applied {
472 issue,
473 event,
474 relation,
475 } => Some((issue, event, relation.as_ref())),
476 DecisionOutcome::NoOp => None,
477 }
478 }
479}
480
481/// The decision service: one validated inbox configuration plus the caller's
482/// duplicate-target resolution.
483///
484/// Built once per command and reused across a batch, so a selection of fifty
485/// issues is validated against one scope rather than fifty.
486pub struct DecisionService<'a> {
487 /// The config `validate_transition` needs; also the source of
488 /// `[triage].accepted_status` and `[triage].declined_status`.
489 config: &'a Config,
490 /// The inbox: membership, and the board's category resolution.
491 scope: InboxScope,
492 /// The optional accept target from `[triage]`.
493 accepted_status: Option<String>,
494 /// The required decline target from `[triage]`.
495 declined_status: String,
496 /// How the caller resolved `--of`.
497 target: DuplicateTarget<'a>,
498}
499
500impl<'a> DecisionService<'a> {
501 /// Build a service over an already-resolved inbox scope.
502 ///
503 /// `scope` comes from the caller rather than from `config` because the
504 /// caller needs the same `Option<InboxScope>` answer for every `tix triage`
505 /// subcommand — "does this project have an inbox at all?" is the first thing
506 /// `list` asks too, and resolving it twice would give two chances to
507 /// disagree.
508 ///
509 /// The one thing checked here is the decline column, and it is checked once
510 /// rather than on every decision: a service that cannot name the column a
511 /// decline would move to has no business accepting a decline, and failing at
512 /// construction means no issue in a batch is decided before the defect shows.
513 pub fn new(
514 config: &'a Config,
515 scope: InboxScope,
516 target: DuplicateTarget<'a>,
517 ) -> DecisionResult<Self> {
518 let declined_status = config
519 .triage
520 .as_ref()
521 .and_then(|triage| triage.declined_status.clone())
522 .ok_or_else(|| {
523 Refusal::new(
524 "unknown_column",
525 None,
526 "`[triage].declined_status` names no column, so a decline \
527 would have nowhere to go",
528 )
529 })?;
530 let accepted_status = config
531 .triage
532 .as_ref()
533 .and_then(|t| t.accepted_status.clone());
534 Ok(Self {
535 config,
536 scope,
537 accepted_status,
538 declined_status,
539 target,
540 })
541 }
542
543 /// Apply one decision to one issue.
544 ///
545 /// Returns the issue *as it would be written*, plus the event intent — or a
546 /// refusal, or [`DecisionOutcome::NoOp`]. The input is borrowed, so a
547 /// rejected decision provably cannot have modified it, and the caller decides
548 /// whether and when to write (contract §Diagnostics: the service produces a
549 /// refusal, the surface reports it).
550 pub fn apply(
551 &self,
552 issue: &Issue,
553 request: &TriageDecision,
554 ) -> DecisionResult<DecisionOutcome> {
555 // 1 — required fields, before the issue is looked at: a missing reason
556 // is refused the same way whatever the queue happens to contain.
557 check_required(issue, request)?;
558
559 // 2 — the target, resolved and validated against the issue's identity.
560 let plan = self.plan(issue, request)?;
561
562 // 3 — idempotence, before membership: declining twice is a no-op even
563 // though the first decline left the inbox.
564 if is_noop(issue, request, &plan) {
565 return Ok(DecisionOutcome::NoOp);
566 }
567
568 // 4 — still in the queue?
569 if !self.scope.matches(issue) {
570 return Err(if history::has_triage_decision(issue) {
571 Refusal::already_decided(issue.number)
572 } else {
573 Refusal::not_in_triage(issue.number)
574 });
575 }
576
577 let prior_state = issue.status.display().to_string();
578 let mut updated = issue.clone();
579
580 // 5 — the one transition evaluation. Nothing below re-derives a rule.
581 let (result, relation) = match plan {
582 Plan::Move(column) => {
583 let outcome = workflow::validate_transition(
584 issue,
585 &column,
586 &self.scope.resolution,
587 self.config,
588 None,
589 );
590 let to = match outcome {
591 TransitionOutcome::Accepted { to, .. } => to,
592 TransitionOutcome::Rejected(diagnostic) => {
593 return Err(Refusal::from_workflow(issue.number, &diagnostic));
594 }
595 };
596 updated.status = Status::new(&to);
597 (to, None)
598 }
599 Plan::Duplicate {
600 target_number,
601 target_title,
602 } => {
603 // The relation, not a file operation: the intent is returned and
604 // storage writes it. No merge, no delete, no move (C-006).
605 let relation = Relationship::new(
606 issue.number,
607 target_number,
608 RelationKind::Duplicates,
609 &target_title,
610 );
611 let result = format!("{} #{}", relation.kind, relation.to);
612 (result, Some(relation))
613 }
614 };
615
616 // Entry metadata (data-model §2). A defer re-enters, so the wait
617 // restarts; a decision that *takes the issue out of the queue* leaves no
618 // stamp behind, because a stamp that measures a queue the issue is no
619 // longer in would age it wrongly the moment it came back — and the
620 // honest answer to "when did it enter?" is then "unknown", not a date
621 // that measures something else (research R4). The test is against the
622 // updated issue, so a decline that is configured to land in an inbox
623 // column keeps its stamp: it never left.
624 match request.kind {
625 DecisionKind::Defer => {
626 record_entry(&mut updated, &request.timestamp, Some("defer"));
627 }
628 DecisionKind::Accept | DecisionKind::Decline if !self.scope.matches(&updated) => {
629 updated.entered_at = None;
630 updated.source = None;
631 }
632 _ => {}
633 }
634
635 let record = TriageDecisionRecord::new(
636 &request.timestamp,
637 &request.actor,
638 request.kind.event_kind(),
639 prior_state,
640 result,
641 Some(request.reason.clone()),
642 request.target_issue,
643 crate::triage::entry_source(&updated).map(str::to_string),
644 );
645 let event = record.to_event();
646 updated.history.append(event.clone());
647
648 Ok(DecisionOutcome::Applied {
649 issue: updated,
650 event,
651 relation,
652 })
653 }
654
655 /// Resolve what this decision would change, and refuse it if it cannot
656 /// (step 2 of the order).
657 fn plan(&self, issue: &Issue, request: &TriageDecision) -> DecisionResult<Plan> {
658 match request.kind {
659 DecisionKind::Accept => {
660 // `--to` wins when given; `[triage].accepted_status` is the
661 // configured fallback; neither is a refusal rather than a guess.
662 // A blank `--to` is treated as absent, because a flag that
663 // carried only whitespace did not name a column.
664 let named = request
665 .target_status
666 .as_deref()
667 .map(str::trim)
668 .filter(|target| !target.is_empty())
669 .map(str::to_string);
670 named
671 .or_else(|| self.accepted_status.clone())
672 .map(Plan::Move)
673 .ok_or_else(|| Refusal::missing_target(issue.number))
674 }
675 DecisionKind::Decline => Ok(Plan::Move(self.declined_status.clone())),
676 DecisionKind::Defer => Ok(Plan::Move(self.scope.deferred_status.clone())),
677 DecisionKind::Duplicate => {
678 let target = self.resolve_duplicate(issue, request)?;
679 Ok(Plan::Duplicate {
680 target_number: target.number,
681 target_title: target.title.clone(),
682 })
683 }
684 }
685 }
686
687 /// Validate `--of` and return the issue it names (T014).
688 ///
689 /// The self-link test goes through [`CanonicalEdge`] rather than comparing
690 /// two numbers, so "is this a self link?" is the same question the
691 /// relationship service asks of an authored edge and has one answer. The
692 /// target itself is only ever *read*: this service creates a relation
693 /// intent, never a file operation.
694 fn resolve_duplicate(
695 &self,
696 issue: &Issue,
697 request: &TriageDecision,
698 ) -> DecisionResult<&'a Issue> {
699 // Ambiguity first: it is the only refusal about the *selector* rather
700 // than the target, and reporting "you gave no target" to someone who
701 // supplied one that matched three issues sends them looking for a flag
702 // they already used.
703 if let DuplicateTarget::Ambiguous = self.target {
704 return Err(Refusal::ambiguous(issue.number));
705 }
706 let asked = request.target_issue.ok_or_else(|| {
707 Refusal::invalid_duplicate_target(
708 issue.number,
709 format!(
710 "`#{}` is a duplicate decision with no target (`--of`)",
711 issue.number
712 ),
713 )
714 })?;
715 if CanonicalEdge::new(issue.number, asked, RelationKind::Duplicates).is_self_link() {
716 return Err(Refusal::invalid_duplicate_target(
717 issue.number,
718 format!("`#{asked}` cannot be a duplicate of itself"),
719 ));
720 }
721 match self.target {
722 DuplicateTarget::Resolved(target) if target.number == asked => Ok(target),
723 DuplicateTarget::Resolved(target) => Err(Refusal::unknown_issue(
724 issue.number,
725 format!(
726 "`--of` resolved to `#{}`, but `#{asked}` was asked for",
727 target.number
728 ),
729 )),
730 DuplicateTarget::Unknown => Err(Refusal::unknown_issue(
731 issue.number,
732 format!("`#{asked}` does not exist"),
733 )),
734 // Unreachable: `Ambiguous` returns above. Written as a real arm
735 // rather than `unreachable!()` so the match stays total and a
736 // future reordering cannot introduce a panic.
737 DuplicateTarget::Ambiguous => Err(Refusal::ambiguous(issue.number)),
738 }
739 }
740}
741
742/// Step 3: whether this decision is already satisfied, so nothing is changed
743/// and no event is appended.
744///
745/// Two different questions with one answer each, because "already done" means
746/// different things for a decision that moves an issue and one that records an
747/// edge:
748///
749/// - **duplicate**: the canonical `duplicates` edge already exists. The reason
750/// is deliberately ignored — an edge is an edge, and re-declaring the same
751/// duplicate with a sharper justification is a comment, not a second relation.
752/// - **move**: the issue is already in the target column **and** the most recent
753/// decision recorded is this kind with this reason. The History half matters
754/// for `defer`, where the column does not change at all: a first defer must
755/// refresh the entry and append, and an identical repeat must do neither. It
756/// also means a *changed* reason on an unchanged column is a new decision,
757/// which is quickstart §7's rule.
758///
759/// The consequence worth naming: an issue moved into the target column by a
760/// plain `tix move`, whose last triage decision happens to be this kind and
761/// reason, reads as a no-op. The requested final state and reason both hold, so
762/// nothing would change — and inventing an event to mark the difference would be
763/// the log recording a decision the user did not make.
764///
765/// A free function for the same reason as [`check_required`]: it reads nothing
766/// from the service, and the plan it is handed already carries the resolved
767/// target.
768fn is_noop(issue: &Issue, request: &TriageDecision, plan: &Plan) -> bool {
769 match plan {
770 Plan::Duplicate { target_number, .. } => {
771 let wanted = CanonicalEdge::new(issue.number, *target_number, RelationKind::Duplicates);
772 issue
773 .relationships
774 .iter()
775 .any(|relation| relation.canonical() == wanted)
776 }
777 Plan::Move(column) => {
778 if !issue.status.is(column) {
779 return false;
780 }
781 history::last_decision(issue).is_some_and(|record| {
782 record.is_same_decision(request.kind.event_kind(), Some(request.reason.as_str()))
783 })
784 }
785 }
786}
787
788/// Step 1: the fields every decision must carry (contract §Required reason and
789/// actor).
790///
791/// A free function rather than a method because it reads nothing from the
792/// service — and a check that ignores `self` but takes `&self` says the opposite
793/// about where the rule lives.
794fn check_required(issue: &Issue, request: &TriageDecision) -> DecisionResult<()> {
795 if request.kind.requires_reason() && request.reason.trim().is_empty() {
796 return Err(Refusal::missing_reason(issue.number, request.kind));
797 }
798 if request.actor.trim().is_empty() {
799 return Err(Refusal::missing_actor(issue.number));
800 }
801 // One decision is one `## History` **line**, and the grammar is line-based: a
802 // newline in the reason or the actor ends the line and starts another, which the
803 // next reader parses as a second event. That is a forged audit entry, written by
804 // the decision the user is making:
805 //
806 // $ tix triage decline 1 --reason $'no budget\n- 2026-10-01T09:00:00Z — root — triage_accepted — triage -> done'
807 // # two events in ## History; the second one is a lie
808 //
809 // Refused rather than stripped: a reason silently altered on the way to the
810 // audit trail is the same class of problem in a quieter colour — the file would
811 // no longer record what the user decided.
812 //
813 // Checked here, at the one boundary every surface crosses (the CLI's two
814 // spellings, the TUI's modal, the MCP tools), because this is the rule about what
815 // a decision *is*. (Found by the mission's post-merge review, and filed as tix
816 // #1493 for the pre-existing `--reason` on `tix move`/`tix edit` and comment
817 // bodies, which share the grammar but not this service.)
818 if request.reason.chars().any(is_line_break) {
819 return Err(Refusal::new(
820 "invalid_reason",
821 Some(issue.number),
822 format!(
823 "`#{}` {} has a line break in its reason; a reason is written to one \
824 History line, so a line break would look like a second decision",
825 issue.number, request.kind
826 ),
827 ));
828 }
829 if request.actor.chars().any(is_line_break) {
830 return Err(Refusal::new(
831 "invalid_actor",
832 Some(issue.number),
833 format!(
834 "`#{}` has a line break in its actor; the actor is written to one History \
835 line, so a line break would look like a second decision",
836 issue.number
837 ),
838 ));
839 }
840 Ok(())
841}
842
843/// A character that ends a `## History` line, or would move the cursor within one.
844///
845/// `\n` and `\r` are the forging vector; `\u{85}` (NEL), `\u{2028}` (line separator)
846/// and `\u{2029}` (paragraph separator) are the same thing in other encodings, and a
847/// bare `\t` is left alone because it cannot start a new entry.
848fn is_line_break(c: char) -> bool {
849 matches!(c, '\n' | '\r' | '\u{85}' | '\u{2028}' | '\u{2029}')
850}