Skip to main content

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