Skip to main content

tix/
storage.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use crate::assignee::derive_segment;
6use crate::config::Config;
7use crate::error::{Result, TixError, render_diagnostics};
8use crate::hierarchy::{self, HierarchyIndex};
9use crate::model::{HistoryEventKind, Issue, Status, TransitionDecision};
10use crate::relationships::{
11    CanonicalEdge, Diagnostic, IssueView, RelationKind, Relationship, RelationshipGraph,
12};
13use crate::slug::slugify;
14
15pub struct Store {
16    pub issues_dir: PathBuf,
17    pub config: Config,
18}
19
20/// The outcome of one relationship mutation.
21///
22/// A typed relationship is a **single directed edge**, so a mutation normally
23/// writes exactly one issue file — the one whose frontmatter owns the canonical
24/// edge. `written` is `None` when the mutation was a no-op (an
25/// already-present edge, or an absent edge on remove), which is what makes
26/// "an idempotent re-apply appends no second History event" checkable.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct RelationshipOutcome {
29    /// The issue whose file was written, if any.
30    pub written: Option<Issue>,
31    /// The relationship the mutation concerned, in canonical form.
32    pub relationship: Relationship,
33    /// Whether a file was actually written.
34    pub changed: bool,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum IssueSelector {
39    Number(u32),
40    Filename(String),
41    Slug(String),
42}
43
44/// Result of `Store::add_asset`: the relative markdown link inserted into the
45/// body and the actual written file name (collision-resolved).
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct AssetAdded {
48    pub link: String,
49    pub written_name: String,
50}
51
52// Disclosures raised by writes, drained by whichever surface is presenting.
53//
54// A disclosure is something the user must be told that is not an error: today
55// that is only the removal of the retired `links:` key. It is queued rather than
56// printed so every surface can render it in its own channel — stderr for the
57// CLI, the MCP result payload for a tool call, the status line for the TUI.
58//
59// Thread-local rather than process-global: a CLI and an MCP request each drain
60// their own, and a process-global queue made concurrent callers — and the test
61// suite, which runs in parallel — see each other's disclosures.
62thread_local! {
63    static RETIRED_KEY_DISCLOSURES: std::cell::RefCell<Vec<String>> =
64        const { std::cell::RefCell::new(Vec::new()) };
65}
66
67/// Record that a write removed the retired `links:` key from `filename`.
68/// `pub(crate)` so the surface drains in `app` and `mcp` can be tested against
69/// a raised disclosure. Production code reaches it only through `Store::update`.
70pub(crate) fn retired_key_disclosure(filename: &str) {
71    RETIRED_KEY_DISCLOSURES.with(|queue| {
72        queue.borrow_mut().push(format!(
73            "remove `links:` by hand to silence \u{2014} {filename} (retired by \
74             C-001; never read as typed data, no migration)"
75        ));
76    });
77}
78
79/// Take everything queued since the last drain.
80pub fn take_disclosures() -> Vec<String> {
81    RETIRED_KEY_DISCLOSURES.with(|queue| std::mem::take(&mut *queue.borrow_mut()))
82}
83
84/// The identity recorded as the `actor` of a History event.
85///
86/// Falls back to `unknown` rather than failing a mutation, so a repository with
87/// no configured git identity can still record an audit trail.
88///
89/// Public because the blocked-move guard (WP04) records its own History event
90/// outside the storage layer — the acknowledgement is a *policy* decision, not a
91/// storage step. Duplicating the git-identity resolution in the command layer
92/// would give the audit trail two definitions of "who acted", which is the kind
93/// of drift an audit trail exists to prevent.
94pub fn actor_identity() -> String {
95    crate::assignee::git_identity()
96        .map(|id| id.to_string())
97        .unwrap_or_else(|| "unknown".to_string())
98}
99
100/// A request to move an issue to a column, through the seam.
101///
102/// WP03 T013. A struct rather than a positional list because the three arguments
103/// are all `Option`-shaped and adjacent: `(Some(commit), None, None)` is a mistake
104/// waiting to happen at a call site, and the field names are what stop it.
105///
106/// ## Deliberately absent
107///
108/// **No `actor`.** The seam records the identity itself through
109/// [`actor_identity`], so a caller cannot attribute a decision to somebody else
110/// and the audit trail keeps one definition of "who acted". Passing an actor in
111/// would make the attribution a choice of the caller, which is exactly the thing
112/// an audit trail must not be.
113///
114/// **No `updated`.** The seam bumps it, on exactly the transitions that change
115/// something. A caller supplying it would be able to write a file whose date says
116/// a no-op was an edit.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct StatusChange<'a> {
119    /// The column to move to. A column the board does not configure is refused
120    /// with `unknown_column`; a same-column request is accepted and inert.
121    pub column: &'a str,
122    /// The commit to record when this move reaches the board's completion column.
123    ///
124    /// Ignored on a move that does not reach completion, and a previously recorded
125    /// commit is cleared on a move that *leaves* it. `None` therefore means "record
126    /// nothing", which is distinct from "leave the old value alone" — leaving an
127    /// issue in its completion column with an unrelated commit is what the clear
128    /// exists to prevent.
129    pub commit: Option<&'a str>,
130    /// Why a policy rule was being overridden, when one was.
131    ///
132    /// Without a reason the service refuses a transition out of a terminal
133    /// category outright, so this is the only way out of `done`. The CLI flag that
134    /// supplies it lands in WP04; the seam takes the parameter now so a
135    /// half-built seam — one that can refuse but never accept-with-reason — is
136    /// never what ships.
137    pub override_reason: Option<&'a str>,
138}
139
140impl<'a> StatusChange<'a> {
141    /// A plain move: no commit, no override.
142    ///
143    /// The common case, named so the call site reads as "move it" rather than as
144    /// three `None`s.
145    pub fn to(column: &'a str) -> Self {
146        Self {
147            column,
148            commit: None,
149            override_reason: None,
150        }
151    }
152
153    /// This request with a commit, for a move that reaches completion.
154    pub fn with_commit(mut self, commit: Option<&'a str>) -> Self {
155        self.commit = commit;
156        self
157    }
158
159    /// This request with an override reason, for a move out of a terminal
160    /// category.
161    pub fn with_override(mut self, reason: Option<&'a str>) -> Self {
162        self.override_reason = reason;
163        self
164    }
165}
166
167/// What the seam did (`WP03` T013).
168///
169/// `written` and `decision` are deliberately redundant. Either alone would do:
170/// `written == false` implies `decision.is_none()`, and a decision always implies
171/// a write. They are both here because the two questions a caller asks are
172/// different — "did anything happen?" and "what gets appended?" — and reading the
173/// answer to one off the other is the kind of inference that breaks silently when a
174/// fourth outcome is added later.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct StatusOutcome {
177    /// The issue as it now stands in the store: the accepted column, the bumped
178    /// `updated`, and the appended decision. On an inert no-op this is the issue
179    /// exactly as it was.
180    pub issue: Issue,
181    /// The decision appended to the issue's History, or `None` when nothing was
182    /// written.
183    pub decision: Option<TransitionDecision>,
184    /// Whether the file was written at all.
185    ///
186    /// `false` for a same-column re-apply and for any path that carries no change.
187    /// A refusal is not represented here — it is an `Err`, so "refused" and
188    /// "accepted but did nothing" are different types rather than one `Outcome`
189    /// with a flag.
190    pub written: bool,
191}
192
193/// The accepted half of [`crate::workflow::TransitionOutcome`], unpacked.
194///
195/// WP03. A private struct so the seam's body reads as ordinary code, and so the
196/// only way to reach the write is to have destructured the service's `Accepted`
197/// variant. The alternative — matching on the enum at each use — is how a future
198/// edit ends up handling a new outcome variant once in this function and not at
199/// all in the next one.
200struct TransitionAccepted {
201    changed: bool,
202    from: String,
203    to: String,
204    from_category: crate::workflow::WorkflowCategory,
205    to_category: crate::workflow::WorkflowCategory,
206    override_reason: Option<String>,
207}
208
209impl TransitionAccepted {
210    /// The accepted transition, or the diagnostic that refused it.
211    fn try_from(
212        outcome: crate::workflow::TransitionOutcome,
213    ) -> std::result::Result<Self, crate::workflow::WorkflowDiagnostic> {
214        match outcome {
215            crate::workflow::TransitionOutcome::Accepted {
216                changed,
217                from,
218                to,
219                from_category,
220                to_category,
221                override_reason,
222            } => Ok(Self {
223                changed,
224                from,
225                to,
226                from_category,
227                to_category,
228                override_reason,
229            }),
230            crate::workflow::TransitionOutcome::Rejected(diagnostic) => Err(diagnostic),
231        }
232    }
233}
234
235/// A [`Relationship`] standing for a canonical edge that has no stored row.
236///
237/// Deliberately local to the storage layer rather than a method on
238/// `CanonicalEdge`: the service's surface is owned by WP01 and is not widened
239/// from here. The id and display are empty because no row exists to carry them
240/// — this value only reports *which* edge a no-op concerned.
241fn canonical_placeholder(canonical: &CanonicalEdge) -> Relationship {
242    Relationship {
243        id: String::new(),
244        from: canonical.from,
245        to: canonical.to,
246        kind: canonical.kind,
247        display: String::new(),
248    }
249}
250
251/// Turn a blocking validation result into the crate error surface.
252///
253/// The first diagnostic's message is preserved verbatim so the user sees the
254/// field-aware text the service produced, prefixed with its diagnostic name
255/// (C-004). A blocking result means **no** file was written (SC-005), so this
256/// is raised before any mutation.
257fn relationship_error(errors: &[Diagnostic]) -> TixError {
258    if errors.is_empty() {
259        return TixError::InvalidInput("relationship rejected".into());
260    }
261    TixError::InvalidInput(render_diagnostics(
262        errors.iter().map(|d| (d.kind.as_str(), d.message.as_str())),
263    ))
264}
265
266/// A durable, exclusive claim on one issue number.
267///
268/// ## Why this exists (#1352, part 2)
269///
270/// [`Store::resolve_next_number`] reads the directory, takes `max(number)`, and adds
271/// one. That is a **hint**, not an allocation: nothing serialises read-then-write
272/// across processes, so two `tix create` runs that reach it together both get the
273/// same answer and both write an issue with that number.
274///
275/// Measured with 24 concurrent `tix create` subprocesses with distinct titles:
276///
277/// ```text
278/// files: 24   distinct numbers: 8
279///   5 × number: 7    4 × number: 4    4 × number: 6    3 × number: 3
280///   3 × number: 5    2 × number: 0    2 × number: 1    1 × number: 2
281/// ```
282///
283/// 16 of 24 issues shared a number with another file. After that every
284/// number-addressed operation — `tix view 0`, `tix move 0`, `tix edit 0`,
285/// `tix link 0 X`, `tix delete 0` — silently addresses an arbitrary one of several
286/// issues, which is arguably worse than the clobber it accompanies.
287///
288/// It needs **two** processes (an agent's `tix mcp` plus a human's `tix`), which is
289/// why it is rarer and why spacing the calls out appears to fix it. There is no
290/// intra-process race: `src/mcp/mod.rs` handles one message to completion before
291/// reading the next, the TUI is a single event loop, and `Store` is `&self`.
292///
293/// ## Why a claim ledger and not a lockfile
294///
295/// The obvious fix is a lock file held across resolve-and-write, and it was rejected
296/// because **a lock can be lost**: a process that crashes while holding it leaves the
297/// next create to break the lock on a timeout, and breaking a lock on a timeout also
298/// breaks it for a process that is merely slow. Every lock-staleness heuristic trades
299/// mutual exclusion for liveness, and this is the one property that must not be
300/// traded.
301///
302/// A claim ledger cannot lose mutual exclusion. The claim is a zero-byte file created
303/// with `O_EXCL`, and a process that dies holding one leaves an **orphan claim** —
304/// an allocated-but-unused number, never a lost lock. A burned number is a gap, and
305/// this system already produces gaps deliberately: reusing a number requires deleting
306/// every higher-numbered issue first, and deleting an issue leaves its number free.
307/// So the failure mode of the fix matches the existing, documented behaviour of the
308/// allocator rather than introducing a new one.
309///
310/// The cost is one hidden marker file per created issue. That is real, and it is the
311/// price of not needing a lease timeout. The markers live **beside** the issues
312/// directory rather than inside it, so nothing that reads `docs/issues/` — a glob, a
313/// script, a test counting segments — ever sees them; see [`claims_dir`].
314struct NumberClaim {
315    path: PathBuf,
316    /// Whether this claim must be released on drop. `false` once the issue file
317    /// exists, at which point the file — not the marker — is the authority for the
318    /// number.
319    armed: bool,
320}
321
322impl NumberClaim {
323    /// The directory holding one marker per claimed number.
324    const DIR: &'static str = ".tix-numbers";
325
326    /// Claim `start`, or the next free number at or above it.
327    ///
328    /// `start` comes from [`Store::resolve_next_number`] and is already past every
329    /// number any issue file claims, so the first candidate is normally free and the
330    /// loop runs once. The loop exists for the concurrent case, and the climb is
331    /// bounded so a corrupt ledger produces an error rather than an unbounded scan.
332    fn acquire(issues_dir: &Path, start: u32) -> Result<Self> {
333        const MAX_ATTEMPTS: u32 = 10_000;
334        let dir = claims_dir(issues_dir);
335        std::fs::create_dir_all(&dir)?;
336        let mut number = start;
337        for _ in 0..MAX_ATTEMPTS {
338            let path = dir.join(format!("{number}.claim"));
339            match std::fs::OpenOptions::new()
340                .write(true)
341                .create_new(true)
342                .open(&path)
343            {
344                Ok(_) => {
345                    return Ok(Self { path, armed: true });
346                }
347                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
348                    number += 1;
349                }
350                Err(e) => return Err(TixError::Io(e)),
351            }
352        }
353        Err(TixError::Io(std::io::Error::new(
354            std::io::ErrorKind::AlreadyExists,
355            format!(
356                "no free issue number at or above {start} after {MAX_ATTEMPTS} attempts; \
357                 {} is probably corrupt",
358                dir.display()
359            ),
360        )))
361    }
362
363    /// The number this claim holds.
364    fn number(&self) -> Option<u32> {
365        self.path.file_stem()?.to_str()?.parse::<u32>().ok()
366    }
367
368    /// Free a number that is no longer in use.
369    ///
370    /// Called by [`Store::delete`], so the ledger does not permanently reserve the
371    /// number of a deleted issue. Best-effort: if the ledger directory is missing or
372    /// the removal fails, the create path simply climbs past the stale claim — which
373    /// costs a number but never hands the same number to two issues.
374    fn release_number(issues_dir: &Path, number: u32) {
375        let _ = std::fs::remove_file(claims_dir(issues_dir).join(format!("{number}.claim")));
376    }
377
378    /// Give the number back, so a create that failed after claiming it does not burn
379    /// one. Idempotent, and a no-op for an already-committed claim.
380    fn release(&mut self) {
381        if self.armed {
382            let _ = std::fs::remove_file(&self.path);
383            self.armed = false;
384        }
385    }
386
387    /// Keep the claim permanently, because the issue file for it now exists.
388    ///
389    /// Without this, `Drop` would release the marker and the whole mechanism would
390    /// be a no-op — which is exactly what happened the first time this was written.
391    /// The `Drop` backstop below is for frames that *never reach* a commit, so it
392    /// must be disarmed deliberately on the success path rather than left to run
393    /// unconditionally.
394    fn commit(&mut self) {
395        self.armed = false;
396    }
397}
398
399impl Drop for NumberClaim {
400    /// A claim still armed when the `Store::create` frame unwinds — a `write`
401    /// error, or a panic — is released, so a failed create does not leak a number.
402    /// The `is_err` branch in `create` releases on the ordinary path; this is the
403    /// backstop for the paths that never reach it.
404    fn drop(&mut self) {
405        self.release();
406    }
407}
408
409impl std::fmt::Debug for NumberClaim {
410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411        f.debug_struct("NumberClaim")
412            .field("path", &self.path)
413            .field("armed", &self.armed)
414            .finish()
415    }
416}
417
418impl PartialEq for NumberClaim {
419    fn eq(&self, other: &Self) -> bool {
420        self.path == other.path
421    }
422}
423impl Eq for NumberClaim {}
424
425/// The outcome of a hierarchy write, mirroring [`RelationshipOutcome`] so a
426/// surface handles both with the same shape.
427#[derive(Debug, Clone)]
428pub struct ParentOutcome {
429    /// The child as written, or `None` when the mutation was a no-op.
430    ///
431    /// `None` for a no-op, matching [`RelationshipOutcome`]: a retried command must
432    /// not pad the audit trail, and a `changed: true` that wrote nothing is a lie.
433    /// A caller that needs the current state reads [`Self::parent`], which is
434    /// reported either way — the same split `RelationshipOutcome` uses for
435    /// `relationship`.
436    pub written: Option<Issue>,
437    /// The child's parent after the call.
438    pub parent: Option<u32>,
439    /// The event appended to `## History`, or `None` for a no-op.
440    ///
441    /// `None` for a no-op is the point: a retried command must not pad the
442    /// audit trail, and a `changed: true` that wrote nothing is a lie.
443    pub event: Option<crate::hierarchy::ParentEvent>,
444    /// Whether anything was written.
445    pub changed: bool,
446}
447
448impl Store {
449    pub fn open(config: &Config) -> Result<Store> {
450        let issues_dir = &config.issues_dir;
451        if !issues_dir.exists() {
452            std::fs::create_dir_all(issues_dir)?;
453        }
454        Ok(Store {
455            issues_dir: issues_dir.clone(),
456            config: config.clone(),
457        })
458    }
459
460    /// The **active** issue set: every issue file that is not archived.
461    ///
462    /// Archived issues are marked by a dot-prefixed markdown filename
463    /// (`docs/issues/<assignee>/.<file>.md`, issue #1492). They are skipped
464    /// **from the directory entry**, before the file is opened, so the default
465    /// path never parses an archived issue — the performance requirement that
466    /// keeps a repository with thousands of archived issues fast.
467    ///
468    /// Use [`Store::list_all`] when an archived issue must still be reachable.
469    pub fn list(&self) -> Result<Vec<Issue>> {
470        self.scan(false)
471    }
472
473    /// The **exhaustive** issue set: active issues plus archived ones.
474    ///
475    /// Archived issues carry `archived == true`, derived from the leading dot
476    /// of their filename. This is the enumeration for selector resolution and
477    /// number allocation, both of which must see an archived issue; it is
478    /// deliberately *not* the default view.
479    pub fn list_all(&self) -> Result<Vec<Issue>> {
480        self.scan(true)
481    }
482
483    /// Walk the issues tree.
484    ///
485    /// `include_archived == false` is the default path: a dot-prefixed `.md`
486    /// entry is dropped from the directory entry so the file is never read.
487    /// `true` reads them too and marks them with `archived = true`.
488    fn scan(&self, include_archived: bool) -> Result<Vec<Issue>> {
489        let mut issues: Vec<Issue> = Vec::new();
490        if !self.issues_dir.exists() {
491            return Ok(issues);
492        }
493        for entry in std::fs::read_dir(&self.issues_dir)? {
494            let entry = entry?;
495            let path = entry.path();
496            let name = entry.file_name().to_string_lossy().to_string();
497            if path.is_dir() {
498                // One level of assignee segments. Temp/backup marker dirs are
499                // never treated as segments (their .md children are clutter);
500                // neither are `<slug>-assets/` dirs (FR-006) — they hold
501                // binary assets, never issues.
502                if name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
503                    continue;
504                }
505                for child in std::fs::read_dir(&path)? {
506                    let child = child?;
507                    let child_path = child.path();
508                    if !child_path.is_file() {
509                        continue;
510                    }
511                    let child_name = child.file_name().to_string_lossy().to_string();
512                    if !child_name.ends_with(".md") {
513                        continue;
514                    }
515                    // The archived marker lives on the **file name**, so this is
516                    // decided from the directory entry and the skip happens
517                    // before `read_to_string` below.
518                    let archived = child_name.starts_with('.');
519                    if archived && !include_archived {
520                        continue;
521                    }
522                    // Grouped issue: relative filename is `<segment>/<basename>`.
523                    let rel = format!("{name}/{child_name}");
524                    let contents = std::fs::read_to_string(&child_path)?;
525                    let mut issue = Issue::parse(&contents).map_err(|e| match e {
526                        TixError::Parse(msg) => TixError::Parse(format!("{rel}: {msg}")),
527                        other => other,
528                    })?;
529                    issue.filename = rel;
530                    issue.archived = archived;
531                    issues.push(issue);
532                }
533            } else if path.is_file() && name.ends_with(".md") {
534                // Legacy flat file directly under the issues dir (C-010).
535                let archived = name.starts_with('.');
536                if archived && !include_archived {
537                    continue;
538                }
539                let contents = std::fs::read_to_string(&path)?;
540                let mut issue = Issue::parse(&contents).map_err(|e| match e {
541                    TixError::Parse(msg) => TixError::Parse(format!("{name}: {msg}")),
542                    other => other,
543                })?;
544                issue.filename = name;
545                issue.archived = archived;
546                issues.push(issue);
547            }
548        }
549        issues.sort_by(|a, b| a.filename.cmp(&b.filename));
550        Ok(issues)
551    }
552
553    pub fn get(&self, selector: IssueSelector) -> Result<Issue> {
554        match selector {
555            IssueSelector::Number(num) => {
556                let issues = self.list_all()?;
557                issues
558                    .into_iter()
559                    .find(|i| i.number == num)
560                    .ok_or_else(|| TixError::NotFound(format!("issue #{num}")))
561            }
562            IssueSelector::Filename(name) => {
563                validate_filename(&name)?;
564                let path = self.issues_dir.join(&name);
565                if !path.is_file() {
566                    return Err(TixError::NotFound(format!("file {name}")));
567                }
568                let contents = std::fs::read_to_string(&path)?;
569                let mut issue = Issue::parse(&contents)?;
570                issue.filename = name;
571                Ok(issue)
572            }
573            IssueSelector::Slug(slug) => {
574                let issues = self.list_all()?;
575                issues
576                    .into_iter()
577                    .find(|i| slugify(&i.title) == slug)
578                    .ok_or_else(|| TixError::NotFound(format!("slug {slug}")))
579            }
580        }
581    }
582
583    /// Archive an issue: rename its file to a dot-prefixed name **in place**.
584    ///
585    /// The marker is the filename (issue #1492), so this is a rename within the
586    /// same assignee segment (`x.md` ↔ `.x.md`) plus exactly one `## History`
587    /// event. `number`, `status`, `created` and every other frontmatter field
588    /// are left untouched, no data is dropped, and the file is never deleted or
589    /// moved between directories.
590    ///
591    /// An already-archived issue is refused with the `already_archived`
592    /// diagnostic rather than re-archived: the operation is not idempotent by
593    /// design, because a second call would append a second audit entry for a
594    /// mutation that did not happen.
595    pub fn archive(&self, sel: &str) -> Result<Issue> {
596        self.set_archived(sel, true)
597    }
598
599    /// Restore an archived issue: the inverse of [`Store::archive`].
600    ///
601    /// An active issue is refused with the `not_archived` diagnostic.
602    pub fn restore(&self, sel: &str) -> Result<Issue> {
603        self.set_archived(sel, false)
604    }
605
606    fn set_archived(&self, sel: &str, archive: bool) -> Result<Issue> {
607        // `resolve_issue` → `parse_selector` → `list_all`, so an archived issue
608        // resolves here even though the default view hides it.
609        let mut issue = self.resolve_issue(sel)?;
610        if issue.archived == archive {
611            return Err(TixError::InvalidInput(format!(
612                "{}: issue #{} ({}) is already {}",
613                if archive {
614                    "already_archived"
615                } else {
616                    "not_archived"
617                },
618                issue.number,
619                issue.filename,
620                if archive { "archived" } else { "active" },
621            )));
622        }
623        let old_rel = issue.filename.clone();
624        let new_rel = flip_archived(&old_rel, archive).ok_or_else(|| {
625            TixError::InvalidInput(format!(
626                "cannot {} {old_rel}: its filename does not carry the archived marker in the \
627                 expected position",
628                if archive { "archive" } else { "restore" }
629            ))
630        })?;
631        let old_path = self.issues_dir.join(&old_rel);
632        let new_path = self.issues_dir.join(&new_rel);
633        if !old_path.is_file() {
634            return Err(TixError::NotFound(format!("file {old_rel}")));
635        }
636        if new_path.exists() {
637            return Err(TixError::InvalidInput(format!(
638                "cannot {} {old_rel}: {new_rel} already exists",
639                if archive { "archive" } else { "restore" }
640            )));
641        }
642        // Rename first: it is atomic, stays in the same directory, and cannot
643        // lose content. Only then is the audit event written, at the new name.
644        // A failure between the two leaves the file renamed but without the
645        // event — never a duplicate, never lost data.
646        std::fs::rename(&old_path, &new_path)?;
647        issue.filename = new_rel.clone();
648        issue.archived = archive;
649        issue.history.append_now(
650            &actor_identity(),
651            if archive {
652                HistoryEventKind::Archived
653            } else {
654                HistoryEventKind::Restored
655            },
656            &format!("{old_rel} -> {new_rel}"),
657        );
658        // No `bump_updated`: `updated` is a frontmatter field and archiving must
659        // leave every frontmatter field byte-identical.
660        self.update(&new_rel, &issue)?;
661        Ok(issue)
662    }
663
664    /// Create an issue file atomically and assign its number by scanning the
665    /// live issue set (issue #52): the highest existing `number` plus one,
666    /// with a floor of `0` for an empty store.
667    ///
668    /// The number is resolved at creation time rather than read from a
669    /// persisted `[state].next_number` counter, so `tix.toml` is never
670    /// rewritten by `create`. A deleted high-numbered issue's number can be
671    /// reused by a later create; the frontmatter `number` stays stable for
672    /// every surviving issue (C-007).
673    ///
674    /// The file is written into the assignee segment derived from
675    /// `issue.assignee` (`unassigned/` fallback, FR-029/FR-030), creating the
676    /// segment directory as needed, and `issue.filename` is set to the
677    /// relative path so `create` output and later edits agree. Atomic-write
678    /// discipline (temp + rename, NFR-006) is preserved.
679    pub fn create(
680        &self,
681        issue: &mut Issue,
682        body: &str,
683        filename_stem: Option<&str>,
684    ) -> Result<PathBuf> {
685        issue.body = body.to_string();
686        issue.bump_updated();
687
688        std::fs::create_dir_all(&self.issues_dir)?;
689        issue.number = self.resolve_next_number()?;
690
691        // Claim the number **before** anything is serialised, and hold it across the
692        // whole write. `resolve_next_number` above reads the directory, takes `max`,
693        // and adds 1, which is only a *hint*: two processes reaching this line
694        // together both get the same answer and both proceed. See [`NumberClaim`]
695        // for what that cost and why an `O_EXCL` marker is the fix.
696        //
697        // **Before** the serialisation, specifically. `to_file_contents` embeds
698        // `number:` in the frontmatter, so serialising first and claiming after —
699        // which is the order this was first written in — writes a *stale* number into
700        // the file while the ledger records the correct one. The 24-create test still
701        // saw 8 distinct numbers with 24 valid claims in place, because every claim
702        // was correct and every file was wrong. A claim that does not reach the bytes
703        // on disk is not a claim.
704        let mut claim = NumberClaim::acquire(&self.issues_dir, issue.number)?;
705        // Belt and braces (#1352): the number actually claimed must be the number
706        // about to be written. `acquire` only climbs when the requested number is
707        // already taken, so this is normally an identity — but it means a future
708        // change to either side that drifts from the other fails loudly here
709        // instead of writing an issue whose `number:` disagrees with the ledger.
710        issue.number = claim.number().ok_or_else(|| {
711            TixError::Io(std::io::Error::new(
712                std::io::ErrorKind::InvalidData,
713                format!("claimed number is unreadable from {}", claim.path.display()),
714            ))
715        })?;
716
717        // WP02 layout: files live under `<assignee-segment>/` with `unassigned/`
718        // as the fallback (FR-029, FR-030). The segment is generated by
719        // `derive_segment`, which is filesystem-safe by construction.
720        let segment = derive_segment(issue.assignee.as_deref());
721        std::fs::create_dir_all(self.issues_dir.join(&segment))?;
722
723        let stem = match filename_stem {
724            Some(s) => s.to_string(),
725            None => issue.filename_stem(),
726        };
727        // Serialised **after** the number is fixed, so the `number:` in the file is
728        // the claimed one.
729        let contents = issue.to_file_contents();
730
731        let written = self.write_new_issue_file(&segment, &stem, contents.as_bytes());
732        // The claim is now permanent: the issue file exists, so that file — not the
733        // marker — is the authority for the number, and dropping the marker would
734        // reopen the window this exists to close. A create that *failed* releases
735        // its claim instead, so a failure does not leak a number for nothing.
736        match written {
737            Ok((filename, path)) => {
738                claim.commit();
739                issue.filename = filename;
740                Ok(path)
741            }
742            Err(e) => {
743                claim.release();
744                Err(e)
745            }
746        }
747    }
748
749    /// Write a **new** issue file, never overwriting an existing one.
750    ///
751    /// The stem is `{created}-{slugified title}`, so it is a function of the issue's
752    /// creation date, its title, and its assignee segment — three things routinely
753    /// identical across two issues. Two `tix create --title "Same title"` runs on
754    /// the same day therefore resolved to the **same path**, and `atomic_write`
755    /// renames over the target, so the second create silently destroyed the first.
756    /// No concurrency was required, and both commands reported success:
757    ///
758    /// ```text
759    /// $ tix create --title "Same title" --priority high
760    /// Created herbert-maschke/2026-09-28-same-title.md
761    /// $ tix create --title "Same title" --priority low
762    /// Created herbert-maschke/2026-09-28-same-title.md
763    /// $ ls docs/issues/*/
764    /// 2026-09-28-same-title.md            # one file
765    /// $ tix list
766    /// NUM  TITLE       STATUS  PRIORITY
767    /// 2    Same title  todo    low       # issue #1 is gone
768    /// ```
769    ///
770    /// Two layers, because the first alone is still a race:
771    ///
772    /// 1. **Probe** for a free name, appending `-2`, `-3`, … to the stem. This
773    ///    handles the deterministic case, which is the common one.
774    /// 2. **Create the file with `O_EXCL`** via [`write_file_new`]. Probing cannot
775    ///    see a file another process creates between the probe and the write, and
776    ///    `rename` overwrites unconditionally, so the final step is the one that
777    ///    actually guarantees no clobber. Losing the race retries from step 1.
778    ///
779    /// Probing alone is the version that looks correct and is not: a second process
780    /// that writes the same stem in the same millisecond would still be destroyed,
781    /// and the retry-on-`AlreadyExists` below exists precisely because a reviewer
782    /// should not have to take that on trust.
783    fn write_new_issue_file(
784        &self,
785        segment: &str,
786        stem: &str,
787        contents: &[u8],
788    ) -> Result<(String, PathBuf)> {
789        const MAX_ATTEMPTS: usize = 1_000;
790        let mut suffix = 1usize;
791        for _ in 0..MAX_ATTEMPTS {
792            let filename = if suffix == 1 {
793                format!("{segment}/{stem}.md")
794            } else {
795                format!("{segment}/{stem}-{suffix}.md")
796            };
797            let path = self.issues_dir.join(&filename);
798            match write_file_new(&self.issues_dir, &path, contents) {
799                Ok(()) => return Ok((filename, path)),
800                // Someone took this name between the probe and the write. The
801                // collision is benign — it is only a *name* — so take the next one.
802                Err(TixError::Io(e)) if e.kind() == std::io::ErrorKind::AlreadyExists => {
803                    suffix += 1;
804                }
805                Err(e) => return Err(e),
806            }
807        }
808        Err(TixError::Io(std::io::Error::new(
809            std::io::ErrorKind::AlreadyExists,
810            format!(
811                "could not find a free filename for {segment}/{stem}.md in {MAX_ATTEMPTS} attempts"
812            ),
813        )))
814    }
815
816    /// The number for the next created issue: `max(number)` over the live
817    /// issue set plus one, or `1` when no issues exist (issue #52 — no
818    /// persisted counter to advance). Scanning the same list that every
819    /// other command uses keeps number resolution conservative: reusing a
820    /// number requires first deleting every higher-numbered issue.
821    ///
822    /// **The empty case is `1`, not `0`.** It used to be `0`, because `max()` over
823    /// an empty set has no natural answer and `0` was the fallback — which made the
824    /// first issue in every fresh repository `#0`, and that number is then
825    /// indistinguishable from a sentinel, an uninitialised value, or a field that
826    /// failed to read. Found by running the binary rather than reading the code
827    /// (tix #1376); two places in the codebase had already written the opposite
828    /// assumption into a comment, and a third had used `0` as a
829    /// `HierarchyNode::NO_ISSUE_YET` marker.
830    ///
831    /// Only the empty case changes. With any issue present the answer is `max + 1`,
832    /// which is unaffected — so no existing repository renumbers, and no migration
833    /// is implied. (One real issue in this repository is numbered `0`, written
834    /// before this fix; it keeps its number, because renumbering is a migration and
835    /// the mission's doctrine makes those an escalation trigger.)
836    fn resolve_next_number(&self) -> Result<u32> {
837        // `list_all`, not `list`: an archived issue keeps its number forever.
838        // Reading only the active set would let a `create` reuse the number of
839        // the highest-numbered archived issue (issue #1492).
840        let issues = self.list_all()?;
841        match issues.iter().map(|i| i.number).max() {
842            None => Ok(1),
843            Some(max) => max
844                .checked_add(1)
845                .ok_or_else(|| TixError::InvalidInput("issue-number overflow".to_string())),
846        }
847    }
848
849    pub fn config_path(&self) -> Result<std::path::PathBuf> {
850        if let Some(root) = Config::project_root(&self.issues_dir) {
851            Ok(root.join("tix.toml"))
852        } else {
853            Err(TixError::Config(
854                "tix.toml not found in any parent directory of issues_dir".into(),
855            ))
856        }
857    }
858
859    pub fn update(&self, filename: &str, issue: &Issue) -> Result<()> {
860        validate_filename(filename)?;
861        let path = self.issues_dir.join(filename);
862        if !path.is_file() {
863            return Err(TixError::NotFound(format!("file {filename}")));
864        }
865        // The retired `links:` key is not in the model, so re-serializing drops
866        // it — and with it the `retired_links_key` finding, silently. The
867        // consequence is that `tix lint` reports a problem, the user runs an
868        // unrelated `tix edit`, and both the finding and the data vanish with no
869        // notice. That is the defect; C-001's no-migration intent is not.
870        //
871        // The write still proceeds, because refusing would make tix unusable
872        // across the repository: 1216 of 1290 issue files here carry `links:`.
873        // What changes is that the removal is *disclosed*, naming the field and
874        // the file, so nothing is lost quietly. Removing the key by hand first
875        // silences this.
876        if issue.has_retired_links_key {
877            // Recorded on the store, not printed here. `eprintln!` inside a
878            // library function is invisible to the MCP server and the TUI, so
879            // the same operation produced different observable output per
880            // surface, which `contracts/surface-parity.md` forbids.
881            //
882            // Drained at the three surface boundaries, not at each write path:
883            // `main` for every CLI command, the MCP `serve` loop per request,
884            // and the TUI event loop. Draining per command was tried and reached
885            // only `link` and `edit`, so every other write path — `mv`,
886            // `rollover`, planning, sync, the TUI, MCP — warned about nothing.
887            // That was worse than the `eprintln!` it replaced, which fired from
888            // inside `update` and so covered all of them. A count of write
889            // paths is deliberately not stated here: it drifts, and a figure a
890            // later reader trusts is worse than no figure. `tests/mcp.rs` pins
891            // the behaviour instead, for `main` and the MCP server.
892            retired_key_disclosure(filename);
893        }
894        let contents = issue.to_file_contents();
895        atomic_write(&self.issues_dir, &path, contents.as_bytes())
896    }
897
898    /// Copy an asset into the issue's `<slug>-assets/` directory (FR-006) and
899    /// insert a relative markdown link at the end of the body, persisted via
900    /// [`Store::update`] (NFR-004).
901    ///
902    /// The asset dir is derived from the issue filename per
903    /// `contracts/comments-format.md`: `<slug>-assets/` where `<slug>` is the
904    /// basename stem minus the leading `YYYY-MM-DD-` date prefix. The copy is
905    /// atomic (temp + rename, NFR-004); on a name collision the file is stored
906    /// as `<stem>-<n>.<ext>` (first free `n`) and the inserted link points at
907    /// the actual written name. Image extensions (`.png` `.jpg` `.jpeg`
908    /// `.gif` `.svg` `.webp` `.bmp`) get the `![…](…)` form, everything else
909    /// the `[…](…)` form. The link path is relative to the issue file, so the
910    /// segment prefix is never part of it.
911    pub fn add_asset(&self, filename: &str, source_path: &Path) -> Result<AssetAdded> {
912        validate_filename(filename)?;
913        let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
914
915        let (segment, basename) = split_relative(filename).ok_or_else(|| {
916            TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
917        })?;
918        let stem = basename
919            .strip_suffix(".md")
920            .ok_or_else(|| {
921                TixError::InvalidInput(format!(
922                    "asset target must be a `.md` issue file: {filename}"
923                ))
924            })?
925            .to_string();
926        let slug = slug_from_stem(&stem);
927        let dir_name = format!("{slug}-assets");
928        let asset_dir = self.issues_dir.join(&segment).join(&dir_name);
929        std::fs::create_dir_all(&asset_dir)?;
930
931        let source_name = source_path
932            .file_name()
933            .and_then(|n| n.to_str())
934            .ok_or_else(|| {
935                TixError::InvalidInput("asset source must have a file name".to_string())
936            })?
937            .to_string();
938        let written_name = resolve_asset_name(&asset_dir, &source_name);
939        atomic_copy(&asset_dir, &asset_dir.join(&written_name), source_path)?;
940
941        let label = written_name
942            .rsplit_once('.')
943            .map(|(stem, _)| stem)
944            .unwrap_or(&written_name);
945        let link = if is_image_name(&written_name) {
946            format!("![{label}]({dir_name}/{written_name})")
947        } else {
948            format!("[{label}]({dir_name}/{written_name})")
949        };
950
951        // Append the link as a new line at the end of the body, reusing the
952        // comment-appender newline discipline so the description is never
953        // rewritten (FR-007).
954        if !issue.body.is_empty() {
955            if !issue.body.ends_with('\n') {
956                issue.body.push('\n');
957            }
958            if !issue.body.ends_with("\n\n") {
959                issue.body.push('\n');
960            }
961        }
962        issue.body.push_str(&link);
963        issue.body.push('\n');
964        issue.bump_updated();
965        self.update(filename, &issue)?;
966
967        Ok(AssetAdded { link, written_name })
968    }
969
970    pub fn delete(&self, filename: &str) -> Result<()> {
971        validate_filename(filename)?;
972        let path = self.issues_dir.join(filename);
973        if !path.is_file() {
974            return Err(TixError::NotFound(format!("file {filename}")));
975        }
976        // Read the number **before** the file goes, so the claim ledger can be told
977        // the number is free again (#1352).
978        //
979        // Releasing the claim is what preserves issue #52's documented behaviour:
980        // the next number is re-derived from the live set, so deleting the
981        // highest-numbered issue lets its number be reused. Without this the ledger
982        // holds that number forever, the next create climbs past it, and the feature
983        // stops working — which is a regression the existing test caught.
984        //
985        // The residual window, stated rather than hidden: if a `create` is in flight
986        // holding this claim while the `delete` runs, the claim is released and a
987        // later create could take the number too. That needs a delete and a create to
988        // interleave, and it was *unconditionally* broken before this ledger existed;
989        // the ledger removes the plain concurrent-create case, which is the one that
990        // actually happens. Repairing the delete/create interleave would need a lock,
991        // and this is the reason the ledger was chosen over one.
992        let freed = std::fs::read_to_string(&path)
993            .ok()
994            .and_then(|text| frontmatter_number(&text));
995        std::fs::remove_file(&path)?;
996        if let Some(number) = freed {
997            NumberClaim::release_number(&self.issues_dir, number);
998        }
999        // Best-effort cleanup of the `<slug>-assets/` dir (FR-008): the `.md`
1000        // removal is the contract, so an asset-dir removal failure is only
1001        // reported as a warning, never propagated.
1002        if let Some((segment, basename)) = split_relative(filename) {
1003            let stem = basename.strip_suffix(".md").unwrap_or(&basename);
1004            let slug = slug_from_stem(stem);
1005            let asset_dir = self
1006                .issues_dir
1007                .join(&segment)
1008                .join(format!("{slug}-assets"));
1009            if asset_dir.exists()
1010                && let Err(e) = std::fs::remove_dir_all(&asset_dir)
1011            {
1012                eprintln!(
1013                    "warning: failed to remove asset dir {}: {e}",
1014                    asset_dir.display()
1015                );
1016            }
1017        }
1018        Ok(())
1019    }
1020
1021    /// **The** status write path (WP03, C-003, FR-002, NFR-002).
1022    ///
1023    /// Every surface that changes an issue's column comes through here, so the
1024    /// transition service is on the path *structurally* rather than by convention:
1025    /// CLI (`tix mv`, `tix edit --status`), the TUI's move and edit actions, MCP's
1026    /// `move_issue` and `update_issue`, and sync's import. A caller that judges a
1027    /// transition itself is a parity defect, and the two that used to are the
1028    /// reason this function had no production caller at all.
1029    ///
1030    /// ## The three outcomes
1031    ///
1032    /// | Result | Meaning | Written |
1033    /// |---|---|---|
1034    /// | `Ok(outcome)`, `outcome.written` | accepted, changed column | file + one decision |
1035    /// | `Ok(outcome)`, `!outcome.written` | accepted, same column | **nothing** (idempotent) |
1036    /// | `Err(TixError::Workflow(_))` | refused | **nothing at all** |
1037    ///
1038    /// The separation is in the type, not in a runtime check: a refusal is an
1039    /// `Err` carrying the **named** diagnostic, so a caller cannot read it as a
1040    /// write, and cannot mistake `unknown_column` (a typo) for `invalid_transition`
1041    /// (policy) — one message shape for both would lose the distinction the
1042    /// contract's shared vocabulary exists to keep (contract §6).
1043    ///
1044    /// ## Rejection is inert
1045    ///
1046    /// Nothing is touched before the service has accepted: no issue file, no
1047    /// `tix.toml`, no History entry. The service is pure (`src/workflow.rs` is
1048    /// filesystem-free by design), and the only write in this function is the
1049    /// single `update` at the end — so there is no window in which a refusal
1050    /// leaves a partial change behind.
1051    ///
1052    /// ## No repair path
1053    ///
1054    /// A file hand-edited into an inconsistent status is read, judged, and
1055    /// reported. This function never corrects one (C-005, decision
1056    /// `01M3C3XBMTR5WFV7059NXM04AJ`); WP06 owns the diagnosis. An auto-repair
1057    /// sneaking in here would be an escalation trigger.
1058    ///
1059    /// Routes through [`Store::update`] rather than serialising directly, so the
1060    /// retired `links:` disclosure happens on this path too. Cycle-5 Minor 9: this
1061    /// used to write the file itself, which drops the retired `links:` key
1062    /// because the key is not in the model — with **no disclosure**, since the
1063    /// disclosure lives in `update`. It was dead code reachable only from tests,
1064    /// so nothing caught it, and wiring it up would have silently destroyed the
1065    /// key on every surface at once.
1066    pub fn set_status(&self, filename: &str, request: StatusChange<'_>) -> Result<StatusOutcome> {
1067        let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1068
1069        // Once per config load, not per issue: the contract's O(V) sweep rests on
1070        // this being a single hash lookup per column rather than a rebuild.
1071        let (_, resolution) = crate::workflow::active_resolution(&self.config);
1072
1073        // The one transition evaluation (C-003). Everything below is the write this
1074        // WP is responsible for; no rule of its own lives here.
1075        let outcome = crate::workflow::validate_transition(
1076            &issue,
1077            request.column,
1078            &resolution,
1079            &self.config,
1080            request.override_reason,
1081        );
1082        // The inert case. Return before any mutation, so there is nothing to undo
1083        // and no History entry to retract.
1084        let accepted = match TransitionAccepted::try_from(outcome) {
1085            Ok(accepted) => accepted,
1086            Err(diagnostic) => return Err(TixError::Workflow(diagnostic)),
1087        };
1088
1089        // Idempotency: a same-column re-apply is accepted but writes nothing.
1090        // `updated` is deliberately *not* bumped either — a no-op that touches the
1091        // file is not a no-op, and bumping the date would make `tix edit --status`
1092        // with the column it already has look like an edit.
1093        if !accepted.changed {
1094            return Ok(StatusOutcome {
1095                issue,
1096                decision: None,
1097                written: false,
1098            });
1099        }
1100
1101        // The completion coupling, which used to live in `mv.rs`. It belongs here
1102        // because it is a rule about the *status* column: reaching completion
1103        // records a commit, and leaving it clears the one it recorded. Keeping it
1104        // in the command layer is what let `tix edit --status` move an issue to
1105        // done without recording a commit, i.e. a second definition of "done".
1106        //
1107        // WP04: both halves of this now ask the **service** whether a column is
1108        // complete, rather than comparing against `effective_done_column()`.
1109        //
1110        // This was a live defect, not a tidy-up. WP03 wrote the coupling against the
1111        // column name; WP04 moved the CLI's *prompt trigger* to the service's
1112        // decision. The two then disagreed on any board whose `category_map` puts
1113        // `completed` somewhere other than its `done_column`: the CLI correctly
1114        // decided the move reached completion and asked for a commit hash, and the
1115        // seam then compared the column to `done_column`, concluded it had not, and
1116        // **silently dropped the hash the user had just typed**. The commit-prompt
1117        // parity test is what found it.
1118        //
1119        // `was_complete` is computed before `issue.status` is reassigned below, so
1120        // it describes where the issue *was*.
1121        let was_complete = crate::workflow::is_completed(&issue, &resolution);
1122        let reaches_complete = resolution
1123            .resolve(&accepted.to)
1124            .is_some_and(crate::workflow::WorkflowCategory::is_complete);
1125        if reaches_complete {
1126            issue.commit = request.commit.map(str::to_string);
1127        } else if was_complete {
1128            issue.commit = None;
1129        }
1130
1131        issue.status = Status::new(accepted.to.clone());
1132        issue.bump_updated();
1133
1134        // Exactly one decision per accepted transition (C-006), appended to the
1135        // issue's existing History — never a second log.
1136        let decision = TransitionDecision::new(
1137            crate::model::utc_now_rfc3339(),
1138            // The existing identity path, not a new one. `actor_identity` is the
1139            // same function the relationship and hierarchy events use, so a
1140            // decision's actor is spelled the way every other History entry's is
1141            // and cannot drift from it (T012 step 4).
1142            actor_identity(),
1143            accepted.from.clone(),
1144            accepted.to.clone(),
1145            accepted.from_category.key(),
1146            accepted.to_category.key(),
1147            accepted.override_reason,
1148        );
1149        issue.history.append(decision.to_event());
1150
1151        self.update(filename, &issue)?;
1152        Ok(StatusOutcome {
1153            issue,
1154            decision: Some(decision),
1155            written: true,
1156        })
1157    }
1158
1159    /// Build a [`RelationshipGraph`] over every issue in the store.
1160    ///
1161    /// The service is the only place relationship semantics live, so the write
1162    /// path validates through it rather than re-deriving direction handling,
1163    /// cycle detection, or blocked state locally (C-003).
1164    /// The board's configured completion column.
1165    ///
1166    /// Read from the first configured board. Completion is always resolved
1167    /// from configuration, never by matching the literal string `done`; when no
1168    /// board configures one, the service's `done` fallback applies.
1169    ///
1170    /// Resolved from the **active** board — the one named `default`, falling back
1171    /// to the first declared, which is exactly what
1172    /// [`Config::default_config`](crate::config::Config::default_config) returns
1173    /// and what every other single-surface consumer already uses.
1174    ///
1175    /// It used to take the **first declared** board instead
1176    /// (`configurations.values().next()`). That is a different board whenever a
1177    /// configuration is declared before `default` with a different
1178    /// `done_column`, and it put two different boards inside a single decision:
1179    /// `blocked_move_guard` asked "is the target the completion column?" via
1180    /// `completion_column(config)` — the active board — and then "is this issue
1181    /// blocked?" via this function's graph — the *first declared* board. The
1182    /// guard was reasoning about two different workflows in one call.
1183    ///
1184    /// `data-model.md` §4 says completion "is always resolved through the board's
1185    /// configured `done_column`" and that the predicate is "the only definition
1186    /// used by FR-003, FR-004, the move guard, and every surface". That requires
1187    /// one board, not "whichever is declared first".
1188    pub fn relationship_done_column(&self) -> String {
1189        // WP09: the service's completion column. The name says `done_column` because
1190        // that is what the `RelationshipGraph` and `hierarchy` services take as a
1191        // parameter — re-deriving the *meaning* inside them is what this mission
1192        // ended; supplying that meaning from one place is the whole of the fix, and
1193        // it does not change either service's signature.
1194        //
1195        // Before: `effective_done_column().unwrap_or("done")`, so on a board whose
1196        // `category_map` puts `completed` on a column other than `done_column` the
1197        // blocked marker and the hierarchy rollup were computed against the wrong
1198        // column. `unwrap_or("done")` also invented a completion column on a board
1199        // that declared none.
1200        crate::workflow::completion_column_or_default(self.config.default_config(), &self.config)
1201    }
1202
1203    /// Create the typed relationship `a --kind--> b`.
1204    ///
1205    /// Selectors resolve with the same grammar as the selector-taking
1206    /// commands. The edge is stored **once**, in the frontmatter of whichever
1207    /// issue owns the canonical edge — the `from` side for a directional kind,
1208    /// and the lower-numbered side for `related`, which is order-normalized so
1209    /// both spellings of an association are one edge. No back-reference is
1210    /// written on the other issue: that symmetry was the retired model.
1211    ///
1212    /// Validation runs through the service first, so a self-link, a missing
1213    /// target, a conflicting kind, or a cycle is refused **before any file is
1214    /// touched** (SC-005). Re-asserting an edge that already exists is an
1215    /// idempotent no-op: no file is written and no History event is appended.
1216    pub fn link(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
1217        let issue_a = self.resolve_issue(a)?;
1218        let issue_b = self.resolve_issue(b)?;
1219        // The graph borrows the loaded issues, so both are built here and stay
1220        // alive until this function returns. The service is the only place
1221        // relationship semantics live (C-003).
1222        let issues = self.list_all()?;
1223        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1224        let done_column = self.relationship_done_column();
1225        let graph = RelationshipGraph::new(&views, &done_column);
1226
1227        let verdict = graph.validate_create(issue_a.number, issue_b.number, kind);
1228        if verdict.blocking() {
1229            return Err(relationship_error(&verdict.errors));
1230        }
1231
1232        // Idempotency goes through the service's own lookup, so an inverse
1233        // spelling or a non-canonical stored order still matches.
1234        if let Some(existing) = graph.find_canonical(issue_a.number, issue_b.number, kind) {
1235            // Nothing is written and no History event is appended. The **real
1236            // row** is reported rather than a synthetic one: the contract defines
1237            // the output as the accepted relationship, and an empty id is not
1238            // addressable by `edit_relation`/`remove_relation`, so returning one
1239            // hands the caller a dangling reference (F15).
1240            return Ok(RelationshipOutcome {
1241                written: None,
1242                relationship: existing.clone(),
1243                changed: false,
1244            });
1245        }
1246
1247        let canonical = CanonicalEdge::new(issue_a.number, issue_b.number, kind);
1248        let mut owner = self.get(IssueSelector::Number(canonical.from))?;
1249        let target_title = self
1250            .get(IssueSelector::Number(canonical.to))
1251            .map(|i| i.title)
1252            .unwrap_or_default();
1253        let relationship =
1254            Relationship::new(canonical.from, canonical.to, canonical.kind, &target_title);
1255        let detail = format!("created {}: {}", relationship.id, relationship.display);
1256        owner.relationships.push(relationship.clone());
1257        owner.history.append_now(
1258            &actor_identity(),
1259            HistoryEventKind::RelationshipAdded,
1260            &detail,
1261        );
1262        owner.bump_updated();
1263        self.update(&owner.filename, &owner)?;
1264
1265        Ok(RelationshipOutcome {
1266            written: Some(owner),
1267            relationship,
1268            changed: true,
1269        })
1270    }
1271
1272    // -- T008 — hierarchy writes ------------------------------------------
1273    //
1274    // Validate-then-write, exactly as `link` does it. The service owns the rule
1275    // and produces the History event; this layer performs the write. A blocking
1276    // result writes nothing and appends nothing (contract §4, SC-004).
1277
1278    /// The in-memory snapshot the hierarchy service reads.
1279    ///
1280    /// `pub` because the `--parent` edit flag (WP03) must validate through the
1281    /// service before any write, exactly as this module does. It is a read, so
1282    /// exposing it costs no more than a duplicate `list()` would.
1283    ///
1284    /// One `IssueView` per issue, shared with the relationship service rather than
1285    /// a second projection — two views of one issue type is how two services end up
1286    /// disagreeing about the same snapshot.
1287    ///
1288    /// The `Vec` is returned rather than discarded because the write path needs the
1289    /// child **from this same snapshot**. Re-reading it through a second `list()`
1290    /// would give the write and the validation two different notions of "current":
1291    /// two files carrying the same `number:` (reachable by hand-edit, since
1292    /// `HierarchyIndex::build` keys by number and last-write-wins while
1293    /// `Store::get` is first-match), or a concurrent writer, would make the
1294    /// service decide on one parent and the writer emit another. The invariant is
1295    /// then not merely unasserted, it is false.
1296    pub fn hierarchy_index(&self) -> Result<(HierarchyIndex, Vec<Issue>)> {
1297        let issues = self.list_all()?;
1298        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1299        Ok((HierarchyIndex::build(&views), issues))
1300    }
1301
1302    /// The child, taken from the snapshot the service just validated against.
1303    ///
1304    /// Returns an error rather than a default so a snapshot that somehow lacks the
1305    /// child is a loud failure, not a silent write of an unrelated file.
1306    fn child_in(snapshot: &[Issue], number: u32) -> Result<Issue> {
1307        snapshot
1308            .iter()
1309            .find(|i| i.number == number)
1310            .cloned()
1311            .ok_or_else(|| {
1312                TixError::NotFound(format!(
1313                    "issue #{number} vanished between validating and writing it"
1314                ))
1315            })
1316    }
1317
1318    /// Point `child` at `parent`, or repoint it if it already has one.
1319    ///
1320    /// Both selectors go through [`Store::resolve_issue`], so `#4`, `4` and a title
1321    /// fragment all work and an unresolvable one produces the CLI's own message. The
1322    /// **number** is what lands in the file, because a title is not stable.
1323    pub fn set_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
1324        let child_issue = self.resolve_issue(child)?;
1325        let parent_issue = self.resolve_issue(parent)?;
1326        let (index, issues) = self.hierarchy_index()?;
1327        let change = hierarchy::set_parent(&index, child_issue.number, parent_issue.number)?;
1328        self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1329    }
1330
1331    /// Repoint `child` at a new parent. A no-op when the parent is unchanged.
1332    pub fn change_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
1333        let child_issue = self.resolve_issue(child)?;
1334        let parent_issue = self.resolve_issue(parent)?;
1335        let (index, issues) = self.hierarchy_index()?;
1336        let change = hierarchy::change_parent(&index, child_issue.number, parent_issue.number)?;
1337        self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1338    }
1339
1340    /// Remove `child`'s parent, yielding the absent state.
1341    pub fn clear_parent(&self, child: &str) -> Result<ParentOutcome> {
1342        let child_issue = self.resolve_issue(child)?;
1343        let (index, issues) = self.hierarchy_index()?;
1344        let change = hierarchy::clear_parent(&index, child_issue.number)?;
1345        self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1346    }
1347
1348    /// Apply an accepted change: write the field and append exactly one event.
1349    ///
1350    /// The single write path for all three operations, so they cannot drift in
1351    /// whether they record what they did.
1352    /// The `issue` must come from the same snapshot the service validated against;
1353    /// see [`Store::hierarchy_index`]. That is what makes the assert below sound.
1354    fn commit_parent_change(
1355        &self,
1356        mut issue: Issue,
1357        change: hierarchy::ParentChange,
1358    ) -> Result<ParentOutcome> {
1359        let Some(event) = change.event else {
1360            // A no-op: nothing is written, nothing is read for write-back, and no
1361            // event is appended. `change.parent` is still the real current state,
1362            // because the service derived it from the snapshot, so the caller does
1363            // not have to re-read to learn it.
1364            return Ok(ParentOutcome {
1365                written: None,
1366                parent: change.parent,
1367                event: None,
1368                changed: false,
1369            });
1370        };
1371        // Read the previous value **before** overwriting it. Building the detail
1372        // after the assignment compares the new value with itself, and every
1373        // repoint was recorded as `parent #7 -> #7` — an audit trail that names
1374        // neither the old parent nor the fact that anything moved.
1375        let previous = issue.parent;
1376        let kind = HistoryEventKind::from_parent(event.kind);
1377        // The detail names both sides so a reader can follow the change without
1378        // reconstructing state from the surrounding entries. Rendered by the
1379        // service, because the `--parent` edit flag needs the identical string and
1380        // two copies of a spelling rule drift.
1381        let detail = hierarchy::change_detail(previous, change.parent);
1382        // Sound now, and it is the service's rule rather than storage's: `issue`
1383        // comes from the validated snapshot, so `previous` is by construction what
1384        // the service compared against. It was a `debug_assert_ne!` on a value
1385        // re-read through a *second* snapshot, where two files sharing a `number:`
1386        // made it fire on a correct write — panicking every dev build — and where
1387        // release builds compiled it out and wrote the contentless
1388        // `parent #N -> #N` event it was there to catch. The comment on
1389        // [`Store::hierarchy_index`] is the reason it cannot drift again.
1390        debug_assert_ne!(
1391            previous, change.parent,
1392            "an accepted change must alter the parent, or the event records nothing"
1393        );
1394        // The field only. **Nothing is written to the parent's file**: child
1395        // membership is derived by inverting this pointer, never stored (C-003).
1396        issue.parent = change.parent;
1397        issue.history.append_now(&actor_identity(), kind, &detail);
1398        issue.bump_updated();
1399        self.update(&issue.filename, &issue)?;
1400        Ok(ParentOutcome {
1401            written: Some(issue),
1402            parent: change.parent,
1403            event: Some(event),
1404            changed: true,
1405        })
1406    }
1407
1408    /// Every relationship touching `number`, as the service resolves it.
1409    ///
1410    /// **This is what a read surface must render**, not `issue.relationships`.
1411    /// A relationship is stored once, in its canonical source's frontmatter, so
1412    /// the stored field is a *partial* view of one endpoint: for `2 related 1`
1413    /// the edge lives in #1's file, so #2's own `relationships` is empty even
1414    /// though #2 plainly has a relationship. Reading the raw field made `tix
1415    /// view 2` and the TUI detail pane show nothing where the service reports
1416    /// one, which is a user-visible regression against the retired symmetric
1417    /// model — the old field was mirrored on both sides by construction.
1418    ///
1419    /// Routing every surface through one accessor is also what keeps C-003
1420    /// honest: derived relationship state is computed in exactly one place.
1421    pub fn relationships_for(&self, number: u32) -> Result<Vec<Relationship>> {
1422        let issues = self.list_all()?;
1423        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1424        let done_column = self.relationship_done_column();
1425        let graph = RelationshipGraph::new(&views, &done_column);
1426        Ok(graph
1427            .relationships_for(number)
1428            .into_iter()
1429            .cloned()
1430            .collect())
1431    }
1432
1433    /// Remove the typed relationship `a --kind--> b`.
1434    ///
1435    /// Removing an edge that is not there is an idempotent no-op: no file is
1436    /// written and no History event is appended.
1437    pub fn unlink(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
1438        let canonical = self.canonical_request(a, b, kind)?;
1439        let stored = self.find_stored_rows(&canonical)?;
1440
1441        if stored.is_empty() {
1442            // Idempotent no-op: nothing is written and no History event is
1443            // appended. The reported relationship is the canonical form the
1444            // caller asked about.
1445            return Ok(RelationshipOutcome {
1446                written: None,
1447                relationship: canonical_placeholder(&canonical),
1448                changed: false,
1449            });
1450        }
1451
1452        // Remove every row matching the canonical edge, wherever it is stored,
1453        // and record **one** History event for the accepted mutation. Redundant
1454        // rows are one invalid condition to clean up, not N mutations
1455        // (contract §6: one event per accepted mutation).
1456        let mut by_file: HashMap<String, Vec<Relationship>> = HashMap::new();
1457        for (filename, rel) in stored {
1458            by_file.entry(filename).or_default().push(rel);
1459        }
1460
1461        let actor = actor_identity();
1462        // Ascending filename, for the same reproducibility reason as above.
1463        let mut file_order: Vec<String> = by_file.keys().cloned().collect();
1464        file_order.sort_unstable();
1465
1466        // The first row this call removes, captured before anything is written.
1467        // M11: a re-scan *after* removal can never match — every matching row is
1468        // gone — so a successful unlink always fell through to the empty
1469        // placeholder and reported an unaddressable relationship, while `link`'s
1470        // no-op path reported a real one. F15 was applied to one path, not both.
1471        let mut removed_row: Option<Relationship> = None;
1472
1473        // M10: the summary is computed **once**, from every row this call
1474        // removes, and the same event is recorded on each file that changes.
1475        // Appending per file produced two events for one `unlink` when the same
1476        // edge was hand-authored into both endpoints, while the comment above
1477        // claimed one event per accepted mutation. One unlink is one mutation.
1478        let all_removed: Vec<Relationship> = by_file.values().flatten().cloned().collect();
1479        let summary = if all_removed.len() == 1 {
1480            let one = &all_removed[0];
1481            format!("removed {}: {}", one.id, one.display)
1482        } else {
1483            let kinds: Vec<String> = all_removed.iter().map(|r| r.kind.to_string()).collect();
1484            format!(
1485                "removed {} relationships ({})",
1486                all_removed.len(),
1487                kinds.join(", ")
1488            )
1489        };
1490
1491        let mut written: Option<Issue> = None;
1492        for filename in &file_order {
1493            let doomed = &by_file[filename];
1494            let mut owner = self.get(IssueSelector::Filename(filename.clone()))?;
1495            if removed_row.is_none() {
1496                removed_row = doomed.first().cloned();
1497            }
1498            owner.relationships.retain(|r| r.canonical() != canonical);
1499            owner
1500                .history
1501                .append_now(&actor, HistoryEventKind::RelationshipRemoved, &summary);
1502            owner.bump_updated();
1503            self.update(&owner.filename, &owner)?;
1504            written = Some(owner);
1505        }
1506
1507        // The row that *was* removed, captured above — an empty id is not
1508        // addressable by `edit_relation`/`remove_relation`.
1509        let relationship = removed_row.unwrap_or_else(|| canonical_placeholder(&canonical));
1510
1511        Ok(RelationshipOutcome {
1512            written,
1513            relationship,
1514            changed: true,
1515        })
1516    }
1517
1518    /// Retarget and/or re-kind the relationship with this stable id.
1519    ///
1520    /// The id is generated once and **survives** the edit, so a relationship a
1521    /// user has already referenced elsewhere does not change identity when its
1522    /// target moves (contract §1). `new_to` and `new_kind` are both optional and
1523    /// naming neither is the identity edit: nothing is written and no History
1524    /// event is appended.
1525    ///
1526    /// Validation goes through [`RelationshipGraph::validate_edit`], so an edit
1527    /// that would self-link, point at a missing issue, duplicate an existing
1528    /// edge, or close a cycle is refused **before any file is touched**
1529    /// (SC-005). An edit may change the *kind* but never the source: the source
1530    /// is the edge's owner, and moving it would mean deleting a relationship and
1531    /// creating another, which is what `remove` plus `link` is for.
1532    pub fn edit_relation(
1533        &self,
1534        id: &str,
1535        new_to: Option<u32>,
1536        new_kind: Option<RelationKind>,
1537    ) -> Result<RelationshipOutcome> {
1538        let issues = self.list_all()?;
1539        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1540        let done_column = self.relationship_done_column();
1541        let graph = RelationshipGraph::new(&views, &done_column);
1542
1543        let Some(existing) = graph.find_relationship(id) else {
1544            // An id that does not resolve is a named diagnostic, not a bare
1545            // `NotFound`: the user passed a well-formed argument, and what is
1546            // missing is a relationship, not an issue.
1547            let mut verdict = crate::relationships::RelationshipValidationResult::clean();
1548            verdict.errors.push(Diagnostic::unknown_relation(id));
1549            return Err(relationship_error(&verdict.errors));
1550        };
1551
1552        let verdict = graph.validate_edit(id, new_to, new_kind);
1553        if verdict.blocking() {
1554            return Err(relationship_error(&verdict.errors));
1555        }
1556
1557        let to = new_to.unwrap_or(existing.to);
1558        let kind = new_kind.unwrap_or(existing.kind);
1559        let old_canonical = existing.canonical();
1560
1561        // The new form has to be **canonicalized before anything else is derived
1562        // from it**, not merely validated. `--type blocked_by` and re-kinding
1563        // `related` change which issue owns the edge *and* which issue it points
1564        // at: `A --blocked_by--> B` is stored as `B blocks A`, so the canonical
1565        // target is the authored `from`, not the authored `to`.
1566        //
1567        // Deriving the display name from the *authored* target is what produced
1568        // `blocks #2 Issue A` for a `2 duplicates 0` edge re-kinded to
1569        // `blocked_by`: right endpoints, wrong title, and a stored row that lies
1570        // about the issue it names. Canonicalize first, then read the title off
1571        // the canonical target.
1572        let requested = CanonicalEdge::new(existing.from, to, kind);
1573        let mut updated = existing.clone();
1574        updated.from = requested.from;
1575        updated.to = requested.to;
1576        updated.kind = requested.kind;
1577
1578        // The target's *current* title, from the canonical target. A target that
1579        // does not resolve would otherwise store an edge with an empty display
1580        // name, so this stays lenient and the row carries what it can.
1581        let target_title = self
1582            .get(IssueSelector::Number(updated.to))
1583            .map(|i| i.title)
1584            .unwrap_or_default();
1585        updated.refresh_display(&target_title);
1586        let new_canonical = updated.canonical();
1587
1588        // No-op detection is on the *resolved* edge, not on the arguments: an
1589        // edit naming its own current target and kind is the identity, and so is
1590        // an edit that only re-renders the same display.
1591        if old_canonical == new_canonical && updated.display == existing.display {
1592            return Ok(RelationshipOutcome {
1593                written: None,
1594                relationship: existing.clone(),
1595                changed: false,
1596            });
1597        }
1598
1599        let actor = actor_identity();
1600        let detail = format!("{} → {}: {}", existing.display, updated.display, updated.id);
1601
1602        // Remove the old row from every file that holds it, without recording
1603        // a `relationship_removed` event: this is one edit, not a delete plus a
1604        // create. Only the canonical source is written when ownership does not
1605        // move, so the common case stays a single-file write.
1606        // Removal and insertion are **separate passes**, because the canonical
1607        // source of the new form is not necessarily a file that held the old
1608        // one. Re-kinding can move ownership — `related` is order-normalised and
1609        // `blocked_by` swaps the endpoints — and when it does, a loop that both
1610        // removes and re-adds deletes the row from its old owner and never
1611        // writes it to the new one. The relationship is then destroyed while the
1612        // call reports success, and a `blocked_by` re-kind reports a `blocks`
1613        // edge that does not exist, so every derived read is wrong the same way.
1614        //
1615        // Two passes make that shape unrepresentable: the first only ever
1616        // removes, the second only ever adds.
1617        let mut removed_anywhere = false;
1618        for issue in self.list_all()? {
1619            if !issue
1620                .relationships
1621                .iter()
1622                .any(|r| r.id == id && r.canonical() == old_canonical)
1623            {
1624                continue;
1625            }
1626            let mut owner = self.get(IssueSelector::Filename(issue.filename.clone()))?;
1627            owner
1628                .relationships
1629                .retain(|r| !(r.id == id && r.canonical() == old_canonical));
1630            removed_anywhere = true;
1631            owner.bump_updated();
1632            self.update(&owner.filename, &owner)?;
1633        }
1634        debug_assert!(removed_anywhere, "graph found the row, the scan did not");
1635
1636        // The new canonical source carries the row and the History event. It may
1637        // be the file just written, a different file, or one the old form never
1638        // touched, so all three cases are written here rather than inside the
1639        // removal loop.
1640        let mut new_owner = self.get(IssueSelector::Number(new_canonical.from))?;
1641        new_owner.relationships.push(updated.clone());
1642        new_owner
1643            .history
1644            .append_now(&actor, HistoryEventKind::RelationshipRetargeted, &detail);
1645        new_owner.bump_updated();
1646        self.update(&new_owner.filename, &new_owner)?;
1647
1648        Ok(RelationshipOutcome {
1649            written: Some(self.get(IssueSelector::Number(new_canonical.from))?),
1650            relationship: updated,
1651            changed: true,
1652        })
1653    }
1654
1655    /// Remove the relationship with this stable id.
1656    ///
1657    /// An id that is not stored is an **idempotent no-op**: no file is written
1658    /// and no History event is appended, exactly as [`Store::unlink`] treats an
1659    /// absent edge. `tix link remove` is therefore safe to retry.
1660    pub fn remove_relation(&self, id: &str) -> Result<RelationshipOutcome> {
1661        let stored = self.find_stored_rows_by_id(id)?;
1662        if stored.is_empty() {
1663            // Reported as a no-op rather than an error, and the reported
1664            // relationship carries the id the caller asked about so the outcome
1665            // is addressable in the message.
1666            return Ok(RelationshipOutcome {
1667                written: None,
1668                relationship: Relationship {
1669                    id: id.to_string(),
1670                    ..canonical_placeholder(&CanonicalEdge::new(0, 0, RelationKind::Related))
1671                },
1672                changed: false,
1673            });
1674        }
1675
1676        let canonical = stored[0].1.canonical();
1677        // Reuse the edge-keyed removal so both spellings of a remove stay one
1678        // implementation, including redundant-row cleanup and the single
1679        // History event per accepted mutation.
1680        let from = canonical.from.to_string();
1681        let to = canonical.to.to_string();
1682        self.unlink(&from, &to, canonical.kind)
1683    }
1684
1685    /// Every stored row with this id, paired with the store-relative filename
1686    /// it lives in.
1687    ///
1688    /// Ids are unique per relationship, but a hand-edited file can hold the same
1689    /// id twice, so this returns all of them and the caller removes all of them.
1690    fn find_stored_rows_by_id(&self, id: &str) -> Result<Vec<(String, Relationship)>> {
1691        let mut found = Vec::new();
1692        for issue in self.list_all()? {
1693            for rel in &issue.relationships {
1694                if rel.id == id {
1695                    found.push((issue.filename.clone(), rel.clone()));
1696                }
1697            }
1698        }
1699        Ok(found)
1700    }
1701
1702    /// The issue set plus the board's configured completion column, for
1703    /// building a [`RelationshipGraph`] over the whole store.
1704    ///
1705    /// The issue set is returned **alongside** the column rather than as a
1706    /// finished graph because the graph borrows the issues: a caller that wants
1707    /// three derived reads (`relationships_for`, `blocked_state`,
1708    /// `dependency_chain`) over one snapshot would otherwise pay three
1709    /// separate `O(V + E)` builds and risk the three disagreeing.
1710    ///
1711    /// This is the single place a read surface obtains its graph, so no surface
1712    /// can reach past the service to re-derive direction handling, cycle
1713    /// detection, or blocked state (C-003).
1714    ///
1715    /// Reads the **active** set ([`Store::list`]): it is the graph a default
1716    /// view renders — `tix list --show relationships`, `tix relationship list`
1717    /// — so archived issues are deliberately absent from it (issue #1492).
1718    /// Per-issue lookups that must still resolve an archived issue go through
1719    /// [`Store::relationships_for`], which reads [`Store::list_all`].
1720    pub fn graph_source(&self) -> Result<(Vec<Issue>, String)> {
1721        Ok((self.list()?, self.relationship_done_column()))
1722    }
1723
1724    /// Resolve a requested pair into the canonical edge it refers to, without
1725    /// requiring the *target* to exist.
1726    ///
1727    /// A dangling edge is a diagnosed, expected condition (`data-model.md` §4,
1728    /// contract §7), so the removal path must still be able to reach it. Both
1729    /// selectors are therefore resolved leniently: a selector that fails to
1730    /// resolve is accepted if it is a bare issue number, because that is enough
1731    /// to canonicalize the request. Anything else is still `NotFound`.
1732    fn canonical_request(&self, a: &str, b: &str, kind: RelationKind) -> Result<CanonicalEdge> {
1733        let lenient = |sel: &str| -> Result<u32> {
1734            match self.resolve_issue(sel) {
1735                Ok(issue) => Ok(issue.number),
1736                Err(e) => sel.trim().parse::<u32>().map_err(|_| e),
1737            }
1738        };
1739        let from = lenient(a)?;
1740        let to = lenient(b)?;
1741        Ok(CanonicalEdge::new(from, to, kind))
1742    }
1743
1744    /// Every stored row matching `canonical`, paired with the store-relative
1745    /// filename it actually lives in.
1746    ///
1747    /// This is the single answer to "where is this edge stored", and it is the
1748    /// reason the three entry points no longer disagree. It matches on
1749    /// [`Relationship::canonical`] — the service's own normalization — rather
1750    /// than comparing authored fields, so a row is found no matter which file
1751    /// holds it or which endpoint order it was written in. Two defects came
1752    /// from re-deriving that comparison locally: a hand-authored
1753    /// non-canonically-ordered `related` row was un-removable, and a row
1754    /// persisted with the input-only `blocked_by` spelling was un-removable too.
1755    ///
1756    /// Cost is one `O(V + E)` pass, the same order as the validation the write
1757    /// path already performs. Correctness of "which file holds this edge" is
1758    /// worth it; a wrong answer here silently deletes nothing.
1759    fn find_stored_rows(&self, canonical: &CanonicalEdge) -> Result<Vec<(String, Relationship)>> {
1760        let mut found = Vec::new();
1761        for issue in self.list_all()? {
1762            for rel in &issue.relationships {
1763                if rel.canonical() == *canonical {
1764                    found.push((issue.filename.clone(), rel.clone()));
1765                }
1766            }
1767        }
1768        Ok(found)
1769    }
1770
1771    /// Make the issue's relationship set exactly `targets`, with `kind`.
1772    ///
1773    /// A typed relationship is stored **once, in its canonical source's file**.
1774    /// For a directional kind that is the declaring issue; for `related`, which
1775    /// is order-normalized, it is the lower-numbered of the pair. So this method
1776    /// may have to write more than one file: an edge requested from an issue
1777    /// that is not its canonical source belongs to the other issue's frontmatter.
1778    /// That is the same ownership rule [`Store::link`] already follows, and it
1779    /// is what "stored once, no mirrored edge" requires.
1780    ///
1781    /// Every target number must already resolve (`NotFound` otherwise) and a
1782    /// self-link is refused — both before anything is written (SC-005).
1783    /// Relationships that survive keep their stable id and their History
1784    /// entries; removals are recorded.
1785    pub fn set_relationships(
1786        &self,
1787        filename: &str,
1788        targets: &[(u32, RelationKind)],
1789    ) -> Result<Issue> {
1790        let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1791        let own = issue.number;
1792        let actor = actor_identity();
1793
1794        // Validate every target up front, before any mutation (SC-005).
1795        let mut wanted: Vec<CanonicalEdge> = Vec::with_capacity(targets.len());
1796        for (target, kind) in targets {
1797            if *target == own {
1798                return Err(TixError::InvalidInput(format!(
1799                    "issue #{own} cannot relate to itself"
1800                )));
1801            }
1802            self.get(IssueSelector::Number(*target))?;
1803            let canonical = CanonicalEdge::new(own, *target, *kind);
1804            // Identical canonical triples collapse, so asking for the same edge
1805            // twice is not two writes and not a self-conflict.
1806            if !wanted.contains(&canonical) {
1807                wanted.push(canonical);
1808            }
1809        }
1810
1811        // Every wanted edge is validated **through the service** before the
1812        // first write, exactly as `link` does. This used to re-implement
1813        // existence and the self-link rule locally and skip the rest, so with
1814        // `1 blocks 2` already on disk it would happily store `2 blocks 1` — a
1815        // reciprocal pair that `link` rejects one line earlier and that
1816        // contract §3 rules 3 and 4 require be refused. Re-deriving part of the
1817        // rule set is also the boundary breach C-003 exists to prevent, and it
1818        // becomes user-reachable the moment WP03 wires `--type`.
1819        {
1820            let issues = self.list_all()?;
1821            let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1822            let done_column = self.relationship_done_column();
1823            let graph = RelationshipGraph::new(&views, &done_column);
1824            for canonical in &wanted {
1825                let verdict = graph.validate_create(canonical.from, canonical.to, canonical.kind);
1826                if verdict.blocking() {
1827                    return Err(relationship_error(&verdict.errors));
1828                }
1829            }
1830        }
1831
1832        // Remove: every existing relationship this issue owns that is not
1833        // wanted, grouped by the file that actually stores it.
1834        //
1835        // "Owns" has two cases, and getting this wrong is a silent data bug:
1836        //
1837        //  - The canonical `from` is this issue. True for every directional
1838        //    kind, and for `related` whenever this issue has the lower number.
1839        //  - The canonical `to` is this issue **and** the kind is
1840        //    non-directional. A `related` association belongs to both
1841        //    endpoints even though it is stored once, in the lower-numbered
1842        //    issue's file. `tix edit 5 --links ""` has to remove a
1843        //    `5 related 3` edge even though it lives in #3's frontmatter and
1844        //    #5's own file is empty.
1845        //
1846        // An *inbound directional* edge is deliberately excluded: if #3 blocks
1847        // #5, that edge is #3's declaration, and #5 clearing its own
1848        // relationship set must not silently rewrite #3's dependency graph.
1849        let issues = self.list_all()?;
1850        let mut removals: HashMap<u32, Vec<Relationship>> = HashMap::new();
1851        for candidate in &issues {
1852            for rel in &candidate.relationships {
1853                let canonical = rel.canonical();
1854                let owned_by_us = canonical.from == own
1855                    || (canonical.to == own && !canonical.kind.is_directional());
1856                if owned_by_us && !wanted.contains(&canonical) {
1857                    removals
1858                        .entry(canonical.from)
1859                        .or_default()
1860                        .push(rel.clone());
1861                }
1862            }
1863        }
1864
1865        // Add: each wanted edge goes into its canonical source's file. One
1866        // already present is left alone, so the stable id survives.
1867        let mut additions: HashMap<u32, Vec<(CanonicalEdge, String)>> = HashMap::new();
1868        for canonical in &wanted {
1869            let owner = match self.get(IssueSelector::Number(canonical.from)) {
1870                Ok(owner) => owner,
1871                Err(TixError::NotFound(_)) => continue,
1872                Err(e) => return Err(e),
1873            };
1874            if owner
1875                .relationships
1876                .iter()
1877                .any(|r| r.canonical() == *canonical)
1878            {
1879                continue;
1880            }
1881            let target_title = self
1882                .get(IssueSelector::Number(canonical.to))
1883                .map(|i| i.title)
1884                .unwrap_or_default();
1885            additions
1886                .entry(canonical.from)
1887                .or_default()
1888                .push((*canonical, target_title));
1889        }
1890
1891        if removals.is_empty() && additions.is_empty() {
1892            return Ok(issue);
1893        }
1894
1895        // Apply removals, then additions. This can write **N** files — the
1896        // removals map is keyed by whichever file actually holds each row, and
1897        // an order-normalized `related` edge is held by the lower-numbered
1898        // issue, not the one named by `filename`. A failure partway through
1899        // would therefore leave the store half-mutated, so every file's
1900        // pre-write contents are captured on the way through and restored if a
1901        // later write fails (SC-005: no file is left partially rewritten).
1902        //
1903        // The restore is best-effort per file and the original error is
1904        // returned either way: a rollback that itself fails must not mask the
1905        // cause.
1906        // Every file's pre-write contents are captured on the way through and
1907        // restored if a later write in the same call fails, so a refused
1908        // mutation leaves nothing behind (SC-005). The restore is best-effort
1909        // per file and the original error is returned either way: a rollback
1910        // that itself fails must not mask the cause.
1911        // Owners are written in ascending number order. `removals` and
1912        // `additions` are `HashMap`s, so iterating them directly makes the write
1913        // order — and therefore which files are committed before a failure —
1914        // differ between runs of the same input.
1915        let mut removal_order: Vec<u32> = removals.keys().copied().collect();
1916        removal_order.sort_unstable();
1917        let mut addition_order: Vec<u32> = additions.keys().copied().collect();
1918        addition_order.sort_unstable();
1919
1920        // Write one owner, remembering how to undo it. A nested `fn` rather than
1921        // a closure so `undo` and `touched_named` can be passed explicitly
1922        // instead of captured, which would make them unborrowable at the
1923        // call sites.
1924        fn apply(
1925            store: &Store,
1926            mut owner: Issue,
1927            undo: &mut Vec<(PathBuf, String)>,
1928            touched_named: &mut bool,
1929            named_path: &Path,
1930        ) -> Result<()> {
1931            let path = store.issues_dir.join(&owner.filename);
1932            if path == named_path {
1933                *touched_named = true;
1934            }
1935            if let Ok(previous) = std::fs::read_to_string(&path) {
1936                undo.push((path, previous));
1937            }
1938            owner.bump_updated();
1939            store.update(&owner.filename, &owner)
1940        }
1941
1942        let named_path = self.issues_dir.join(&issue.filename);
1943        let mut undo: Vec<(PathBuf, String)> = Vec::new();
1944        let mut touched_named = false;
1945
1946        let applied = (|| -> Result<()> {
1947            for owner_number in &removal_order {
1948                let doomed = &removals[owner_number];
1949                let mut owner = self.get(IssueSelector::Number(*owner_number))?;
1950                let mut changed = false;
1951                for rel in doomed {
1952                    let canonical = rel.canonical();
1953                    let before = owner.relationships.len();
1954                    owner.relationships.retain(|r| r.canonical() != canonical);
1955                    if owner.relationships.len() != before {
1956                        let detail = format!("removed {}: {}", rel.id, rel.display);
1957                        owner.history.append_now(
1958                            &actor,
1959                            HistoryEventKind::RelationshipRemoved,
1960                            &detail,
1961                        );
1962                        changed = true;
1963                    }
1964                }
1965                if changed {
1966                    apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
1967                }
1968            }
1969
1970            for owner_number in &addition_order {
1971                let wanted_here = &additions[owner_number];
1972                let mut owner = self.get(IssueSelector::Number(*owner_number))?;
1973                for (canonical, target_title) in wanted_here {
1974                    let rel = Relationship::new(
1975                        canonical.from,
1976                        canonical.to,
1977                        canonical.kind,
1978                        target_title,
1979                    );
1980                    let detail = format!("created {}: {}", rel.id, rel.display);
1981                    owner
1982                        .history
1983                        .append_now(&actor, HistoryEventKind::RelationshipAdded, &detail);
1984                    owner.relationships.push(rel);
1985                }
1986                apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
1987            }
1988
1989            // F16. The named issue's own frontmatter can be untouched while its
1990            // relationship set changed, because an order-normalized `related`
1991            // edge is stored in the *other* endpoint's file. `tix edit 2 --links
1992            // 1` then left #2 byte-identical with a stale `updated`, so the
1993            // command appeared to do nothing to the issue it named and the audit
1994            // trail lived only on the other endpoint. `updated` tracks the
1995            // issue's last mutation and its relationship set did change.
1996            //
1997            // Inside the rolled-back sequence and through the same `apply`, so
1998            // its file is captured for restore like every other. Placed after the
1999            // rollback check it was a fresh partial-write hole: a failure here
2000            // returned an error with the relationship writes already committed
2001            // and nothing left to restore.
2002            if !touched_named {
2003                let named = self.get(IssueSelector::Filename(filename.to_string()))?;
2004                apply(self, named, &mut undo, &mut touched_named, &named_path)?;
2005            }
2006            Ok(())
2007        })();
2008
2009        if let Err(e) = applied {
2010            for (path, previous) in undo.iter().rev() {
2011                let _ = atomic_write(&self.issues_dir, path, previous.as_bytes());
2012            }
2013            return Err(e);
2014        }
2015
2016        self.get(IssueSelector::Filename(filename.to_string()))
2017    }
2018
2019    /// Resolve a selector string exactly like the selector-taking commands
2020    /// (`parse_selector`) and fetch the issue (D2: "Resolver SHALL match
2021    /// `tix edit`/`tix move`").
2022    ///
2023    /// `pub` because the `--parent` flags (WP03) resolve a parent selector to its
2024    /// **number** through this same path, so `#4`, `4` and a title fragment all work
2025    /// on a parent exactly as they do on any other selector — and a second
2026    /// resolver in the command layer is how two surfaces end up disagreeing about
2027    /// which issue a selector names.
2028    pub fn resolve_issue(&self, raw: &str) -> Result<Issue> {
2029        let selector = crate::commands::parse_selector(self, raw)?;
2030        self.get(selector)
2031    }
2032
2033    /// Physically move an issue file into the assignee segment of a new
2034    /// assignee (FR-031, `contracts/file-format.md` rule 9).
2035    ///
2036    /// `filename` is a path relative to the issues dir: either a legacy flat
2037    /// basename (`2026-05-10-fix-login.md`, C-010) or a grouped path
2038    /// (`alice/2026-05-10-fix-login.md`). It is validated via the relaxed
2039    /// [`validate_filename`] before anything touches the filesystem, so
2040    /// traversal attempts are refused here as everywhere else.
2041    ///
2042    /// The old assignee segment is read from the current relative path, the
2043    /// new one derived with [`derive_segment`]; equal segments are a no-op.
2044    /// The target filename keeps the original `created-slug` stem; when the
2045    /// target already holds `<stem>.md`, a deterministic `-1`, `-2`, …
2046    /// suffix is appended by testing existence — an existing file is never
2047    /// overwritten or deleted. `number`/`created` live in the frontmatter and
2048    /// are untouched, so issue-number stability is guaranteed by construction
2049    /// (C-007).
2050    ///
2051    /// The move is a single `std::fs::rename` (atomic-ish, same filesystem).
2052    /// Returns the **new relative path** (matching how `issue.filename` is
2053    /// stored) on success, including the unchanged relative path for the
2054    /// equal-segment no-op.
2055    pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
2056        // `get` validates `filename` and errors NotFound when it is missing.
2057        self.get(IssueSelector::Filename(filename.to_string()))?;
2058
2059        let (old_segment, file_name) = split_relative(filename).ok_or_else(|| {
2060            TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
2061        })?;
2062        let new_segment = derive_segment(new_assignee);
2063        if old_segment == new_segment {
2064            return Ok(PathBuf::from(filename));
2065        }
2066
2067        let stem = file_name
2068            .strip_suffix(".md")
2069            .unwrap_or(&file_name)
2070            .to_string();
2071        let target_dir = self.issues_dir.join(&new_segment);
2072        std::fs::create_dir_all(&target_dir)?;
2073        let new_name = resolve_target_name(&target_dir, &stem);
2074        std::fs::rename(self.issues_dir.join(filename), target_dir.join(&new_name))?;
2075
2076        // Move the `<slug>-assets/` dir alongside the file (FR-007). With no
2077        // asset dir this is a no-op and reassign behaves exactly as before.
2078        // On a same-named dir in the target segment, disambiguate with a
2079        // suffixed dir name (deterministic existence probe, mirroring
2080        // `resolve_target_name`) rather than overwriting.
2081        let slug = slug_from_stem(&stem);
2082        let asset_name = format!("{slug}-assets");
2083        let old_asset_dir = self.issues_dir.join(&old_segment).join(&asset_name);
2084        if old_asset_dir.exists() {
2085            let moved = resolve_asset_dir_name(&target_dir, &asset_name);
2086            std::fs::rename(&old_asset_dir, target_dir.join(&moved))?;
2087        }
2088        Ok(PathBuf::from(format!("{new_segment}/{new_name}")))
2089    }
2090}
2091
2092/// Validate an issue filename. A filename is either a legacy flat basename
2093/// (`2026-05-10-fix-login.md`) or a grouped path with exactly one assignee
2094/// segment prefix (`alice/2026-05-10-fix-login.md`, C-008/C-010).
2095///
2096/// Refused in all cases: NUL bytes, backslashes, the `..` component, empty
2097/// components (leading/trailing/double slashes), absolute / leading-slash
2098/// paths, and nesting deeper than one segment. Every refusal is a hard
2099/// [`TixError::InvalidInput`] raised before any filesystem touch.
2100fn validate_filename(filename: &str) -> Result<()> {
2101    if filename.is_empty() {
2102        return Err(TixError::InvalidInput(format!(
2103            "filename must not be empty: {filename:?}"
2104        )));
2105    }
2106    if filename.starts_with('/') {
2107        return Err(TixError::InvalidInput(format!(
2108            "filename must be relative (no leading `/`): {filename}"
2109        )));
2110    }
2111    if filename.contains('\\') {
2112        return Err(TixError::InvalidInput(format!(
2113            "filename must not contain backslashes: {filename}"
2114        )));
2115    }
2116    if filename.contains("..") {
2117        return Err(TixError::InvalidInput(format!(
2118            "filename must not contain `..`: {filename}"
2119        )));
2120    }
2121    if filename.contains('\0') {
2122        return Err(TixError::InvalidInput(format!(
2123            "filename must not contain NUL: {filename}"
2124        )));
2125    }
2126    if filename.split('/').count() > 2 {
2127        return Err(TixError::InvalidInput(format!(
2128            "filename may have at most one segment prefix: {filename}"
2129        )));
2130    }
2131    if filename.split('/').any(|component| component.is_empty()) {
2132        return Err(TixError::InvalidInput(format!(
2133            "filename path components must not be empty: {filename}"
2134        )));
2135    }
2136    Ok(())
2137}
2138
2139/// Split a validated relative filename into `(segment, basename)`. Legacy
2140/// flat filenames have an empty segment (`("", "2026-05-10-x.md")`), grouped
2141/// ones carry exactly one segment (`("alice", "2026-05-10-x.md")`). [`None`]
2142/// for anything that survived a bad path shape.
2143/// The `number:` field of an issue file's frontmatter, without a full parse.
2144///
2145/// `Store::delete` needs the number of the issue it is removing so it can free that
2146/// number's claim, and it needs it *before* the file is gone. A full `Issue` parse
2147/// would be the obvious way, but this sits on the delete path and only needs one
2148/// scalar, so a scoped scan is enough — and it cannot fail on an issue that would
2149/// fail to load, which keeps `delete`'s existing "removes the file" contract
2150/// (#1352) independent of frontmatter validity.
2151///
2152/// The scan stops at the closing `---` so a `number:` in the **body** cannot be
2153/// mistaken for the field.
2154fn frontmatter_number(text: &str) -> Option<u32> {
2155    let mut lines = text.lines();
2156    // The opening delimiter. A file that does not start with one has no frontmatter.
2157    if lines.next()?.trim() != "---" {
2158        return None;
2159    }
2160    for line in lines {
2161        let line = line.trim_end();
2162        // The closing delimiter ends the frontmatter, so a `number:` after it is
2163        // body text and must not be mistaken for the field.
2164        if line == "---" {
2165            return None;
2166        }
2167        if let Some(rest) = line.strip_prefix("number:") {
2168            return rest.trim().parse().ok();
2169        }
2170    }
2171    None
2172}
2173
2174/// The directory holding one marker per claimed number.
2175///
2176/// **A sibling of the issues directory, not a child of it.** `docs/issues/` is the
2177/// tool's source of truth: people hand-edit it, script against it, and glob it, and
2178/// its contents are committed. Adding an internal directory inside it changes what
2179/// every one of those consumers sees — a test helper that counts segments, a `for`
2180/// loop over `docs/issues/*`, a CI check — for machinery no reader should have to
2181/// know about. Beside it, `docs/issues/` contains exactly what it did before.
2182///
2183/// Derived from the configured `issues_dir` rather than hardcoded, so a repo that
2184/// points `issues_dir` somewhere else still gets a ledger beside that.
2185fn claims_dir(issues_dir: &Path) -> PathBuf {
2186    issues_dir
2187        .parent()
2188        .unwrap_or(issues_dir)
2189        .join(NumberClaim::DIR)
2190}
2191
2192fn split_relative(filename: &str) -> Option<(String, String)> {
2193    let mut parts = filename.rsplitn(2, '/');
2194    let basename = parts.next()?;
2195    let segment = parts.next().unwrap_or("");
2196    Some((segment.to_string(), basename.to_string()))
2197}
2198
2199/// Flip the archived marker on a store-relative issue filename.
2200///
2201/// `alice/2026-01-01-x.md` → `alice/.2026-01-01-x.md` when `archive` is true,
2202/// and the inverse when false. Only the basename's leading dot changes; the
2203/// segment stays put, so the file never moves between directories. `None` when
2204/// the name is already in the requested state.
2205fn flip_archived(filename: &str, archive: bool) -> Option<String> {
2206    let (segment, basename) = split_relative(filename)?;
2207    let flipped = if archive {
2208        if basename.starts_with('.') {
2209            return None;
2210        }
2211        format!(".{basename}")
2212    } else {
2213        basename.strip_prefix('.')?.to_string()
2214    };
2215    Some(if segment.is_empty() {
2216        flipped
2217    } else {
2218        format!("{segment}/{flipped}")
2219    })
2220}
2221
2222/// The first free `<stem>.md` / `<stem>-1.md` / `<stem>-2.md` … name in
2223/// `dir` (deterministic existence probe). Never returns an existing name.
2224fn resolve_target_name(dir: &Path, stem: &str) -> String {
2225    let mut candidate = format!("{stem}.md");
2226    let mut i = 1u32;
2227    while dir.join(&candidate).exists() {
2228        candidate = format!("{stem}-{i}.md");
2229        i += 1;
2230    }
2231    candidate
2232}
2233
2234/// The slug used for the asset dir: the issue filename stem minus the leading
2235/// `YYYY-MM-DD-` date prefix (`2026-09-13-fix-login-bug` → `fix-login-bug`).
2236/// When the stem has no date prefix it is used as-is (legacy fallback).
2237fn slug_from_stem(stem: &str) -> String {
2238    let bytes = stem.as_bytes();
2239    if stem.len() > 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b'-' {
2240        stem[11..].to_string()
2241    } else {
2242        stem.to_string()
2243    }
2244}
2245
2246/// The first free `<name>` / `<name>-<n>.<ext>` name for an asset in `dir`.
2247/// When `<name>` is taken the extension is kept and a deterministic
2248/// numeric suffix is appended (FR-006).
2249fn resolve_asset_name(dir: &Path, name: &str) -> String {
2250    if !dir.join(name).exists() {
2251        return name.to_string();
2252    }
2253    let (stem, ext) = match name.rsplit_once('.') {
2254        Some((s, e)) => (s, format!(".{e}")),
2255        None => (name, String::new()),
2256    };
2257    let mut i = 1u32;
2258    loop {
2259        let candidate = format!("{stem}-{i}{ext}");
2260        if !dir.join(&candidate).exists() {
2261            return candidate;
2262        }
2263        i += 1;
2264    }
2265}
2266
2267/// The first free `<name>` / `<name>-1` / `<name>-2` … directory name in
2268/// `dir` (mirrors `resolve_target_name` for the `<slug>-assets/` move).
2269fn resolve_asset_dir_name(dir: &Path, name: &str) -> String {
2270    let mut candidate = name.to_string();
2271    let mut i = 1u32;
2272    while dir.join(&candidate).exists() {
2273        candidate = format!("{name}-{i}");
2274        i += 1;
2275    }
2276    candidate
2277}
2278
2279/// Copy a file atomically into `dir` under `target` (temp + rename, NFR-004).
2280/// The temp file is cleaned up on failure and never left behind.
2281fn atomic_copy(dir: &Path, target: &Path, source: &Path) -> Result<()> {
2282    let pid = std::process::id();
2283    let nonce = nanos();
2284    let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
2285    let copy_result = (|| -> std::io::Result<()> {
2286        std::fs::copy(source, &tmp)?;
2287        let f = std::fs::File::open(&tmp)?;
2288        f.sync_all()?;
2289        Ok(())
2290    })();
2291    if let Err(e) = copy_result {
2292        let _ = std::fs::remove_file(&tmp);
2293        return Err(TixError::Io(e));
2294    }
2295    if let Err(e) = std::fs::rename(&tmp, target) {
2296        let _ = std::fs::remove_file(&tmp);
2297        return Err(TixError::Io(e));
2298    }
2299    Ok(())
2300}
2301
2302/// Whether an asset file name is an image by extension (the `![…]` link form).
2303fn is_image_name(name: &str) -> bool {
2304    matches!(
2305        name.rsplit('.')
2306            .next()
2307            .unwrap_or("")
2308            .to_ascii_lowercase()
2309            .as_str(),
2310        "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp"
2311    )
2312}
2313
2314fn atomic_write(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
2315    write_file(dir, target, data, Link::Overwrite)
2316}
2317
2318/// Write `data` to `target`, refusing to replace an existing file.
2319///
2320/// [`atomic_write`] uses `rename`, which replaces the target unconditionally and is
2321/// therefore the wrong tool for *creating* an issue: a name collision would destroy
2322/// the file already there. This writes the payload to a temp file, `fsync`s it, and
2323/// then creates `target` with `hard_link`, which is atomic and fails with
2324/// `AlreadyExists` if the name is taken. The temp file is removed either way.
2325///
2326/// A hard link is the portable way to get `O_EXCL`-style semantics on the final
2327/// path (it works on unix and Windows and needs no new dependency). The temp file
2328/// lives in the same directory, so it is always on the same filesystem and the link
2329/// cannot fail for that reason. If the link is refused for any other reason — a
2330/// filesystem without hard links, say — it falls back to `rename`, which keeps
2331/// atomicity but is no longer no-clobber, and the caller's own probe is the
2332/// remaining protection.
2333///
2334/// The directory `fsync` that follows the link is what makes the *name* durable, so
2335/// it is not skippable on the fallback path either.
2336fn write_file_new(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
2337    write_file(dir, target, data, Link::Exclusive)
2338}
2339
2340#[derive(Clone, Copy)]
2341enum Link {
2342    Overwrite,
2343    Exclusive,
2344}
2345
2346fn write_file(dir: &Path, target: &Path, data: &[u8], link: Link) -> Result<()> {
2347    let pid = std::process::id();
2348    let nonce = nanos();
2349    let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
2350    let write_result = (|| -> std::io::Result<()> {
2351        let mut f = std::fs::File::create(&tmp)?;
2352        use std::io::Write;
2353        f.write_all(data)?;
2354        f.sync_all()?;
2355        Ok(())
2356    })();
2357    if let Err(e) = write_result {
2358        let _ = std::fs::remove_file(&tmp);
2359        return Err(TixError::Io(e));
2360    }
2361    let link_result = match link {
2362        Link::Overwrite => std::fs::rename(&tmp, target),
2363        Link::Exclusive => match std::fs::hard_link(&tmp, target) {
2364            // The name was taken: the payload is already durable, so only the link
2365            // failed. `AlreadyExists` is the signal `write_new_issue_file` retries on,
2366            // so it is propagated rather than flattened into a generic I/O error.
2367            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
2368            // No hard links on this filesystem. Atomicity survives; the no-clobber
2369            // guarantee does not, and the caller's probe is what is left.
2370            Err(_) => std::fs::rename(&tmp, target),
2371            Ok(()) => {
2372                let _ = std::fs::remove_file(&tmp);
2373                Ok(())
2374            }
2375        },
2376    };
2377    if let Err(e) = link_result {
2378        let _ = std::fs::remove_file(&tmp);
2379        return Err(TixError::Io(e));
2380    }
2381    let _ = std::fs::File::open(dir).and_then(|f| f.sync_all());
2382    Ok(())
2383}
2384
2385fn nanos() -> u64 {
2386    SystemTime::now()
2387        .duration_since(UNIX_EPOCH)
2388        .map(|d| d.as_nanos() as u64)
2389        .unwrap_or(0)
2390}
2391
2392#[cfg(test)]
2393mod tests {
2394    use super::*;
2395    // Only the tests need this; the library path went through `render_diagnostics`.
2396    use crate::model::{Priority, today};
2397    use crate::relationships::DiagnosticKind;
2398    use std::fs;
2399    use tempfile::TempDir;
2400
2401    /// The no-clobber layer, tested directly.
2402    ///
2403    /// `write_new_issue_file`'s **probe** is the common path and is covered end to
2404    /// end by `two_creates_with_the_same_title_both_survive` in `tests/cli.rs`.
2405    /// The probe cannot see a name another process takes between the probe and the
2406    /// write, which is why the final create is exclusive. This is the guarantee that
2407    /// closes that window, so it is asserted on the primitive rather than through a
2408    /// race that would be flaky to provoke.
2409    ///
2410    /// If the exclusive path were replaced by a `rename` — the obvious "simplify
2411    /// this" edit — the planted file below would be silently destroyed, which is
2412    /// precisely the data loss `write_new_issue_file` exists to prevent.
2413    #[test]
2414    fn write_file_new_refuses_an_existing_target() {
2415        let dir = TempDir::new().unwrap();
2416        let target = dir.path().join("issue.md");
2417        write_file_new(dir.path(), &target, b"first").unwrap();
2418        assert_eq!(fs::read_to_string(&target).unwrap(), "first");
2419
2420        let err = write_file_new(dir.path(), &target, b"second").unwrap_err();
2421        let TixError::Io(io_err) = err else {
2422            panic!("expected an I/O error, so the caller can retry on a name collision")
2423        };
2424        assert_eq!(
2425            io_err.kind(),
2426            std::io::ErrorKind::AlreadyExists,
2427            "the error must be AlreadyExists, so the caller can retry on a name \\
2428             collision rather than treating it as a hard failure"
2429        );
2430        assert_eq!(
2431            fs::read_to_string(&target).unwrap(),
2432            "first",
2433            "the existing file must be byte-identical — not truncated, not replaced"
2434        );
2435    }
2436
2437    /// The retry loop turns an `AlreadyExists` into the next free name.
2438    #[test]
2439    fn a_clobbered_name_is_retried_rather_than_failing_or_overwriting() {
2440        let tmp = TempDir::new().unwrap();
2441        let dir = tmp.path();
2442        let store = Store::open(&crate::config::Config {
2443            issues_dir: dir.to_path_buf(),
2444            ..Default::default()
2445        })
2446        .unwrap();
2447
2448        // The probe looks at `<issues_dir>/<segment>/<stem>.md`, so the plants have
2449        // to sit in the segment directory — that is where the collision is.
2450        let segment_dir = dir.join("seg");
2451        fs::create_dir_all(&segment_dir).unwrap();
2452
2453        // Two plants on the exact names the probe would try first, so the retry has
2454        // to walk past both to reach the third.
2455        for name in ["2026-01-01-race.md", "2026-01-01-race-2.md"] {
2456            fs::write(segment_dir.join(name), "planted").unwrap();
2457        }
2458
2459        let (filename, path) = store
2460            .write_new_issue_file("seg", "2026-01-01-race", b"mine")
2461            .unwrap();
2462        assert_eq!(filename, "seg/2026-01-01-race-3.md");
2463        assert_eq!(fs::read_to_string(&path).unwrap(), "mine");
2464        assert_eq!(
2465            fs::read_to_string(segment_dir.join("2026-01-01-race.md")).unwrap(),
2466            "planted"
2467        );
2468        assert_eq!(
2469            fs::read_to_string(segment_dir.join("2026-01-01-race-2.md")).unwrap(),
2470            "planted"
2471        );
2472    }
2473
2474    /// No temp files are left behind on either the success or the collision path.
2475    ///
2476    /// A leftover `.tix-tmp-*` in `docs/issues/<segment>/` would be picked up by the
2477    /// next `tix list` as a malformed issue, and the exclusive path has an extra
2478    /// step (`hard_link` then `remove_file`) where a leak is easy to introduce.
2479    #[test]
2480    fn no_temp_files_are_left_behind_on_success_or_collision() {
2481        let dir = TempDir::new().unwrap();
2482        let target = dir.path().join("issue.md");
2483        write_file_new(dir.path(), &target, b"first").unwrap();
2484        let _ = write_file_new(dir.path(), &target, b"second");
2485        let leftovers: Vec<String> = fs::read_dir(dir.path())
2486            .unwrap()
2487            .filter_map(|e| e.ok())
2488            .map(|e| e.file_name().to_string_lossy().to_string())
2489            .filter(|n| n.starts_with(".tix-tmp-"))
2490            .collect();
2491        assert!(
2492            leftovers.is_empty(),
2493            "temp files must not survive, found {leftovers:?}"
2494        );
2495    }
2496
2497    fn make_store() -> (tempfile::TempDir, Store) {
2498        let tmp = tempfile::TempDir::new().unwrap();
2499        let dir = tmp.path().join("issues");
2500        fs::create_dir_all(&dir).unwrap();
2501        let config_path = tmp.path().join("tix.toml");
2502        let config = Config {
2503            issues_dir: dir.clone(),
2504            ..Config::default()
2505        };
2506        config.save(&config_path).unwrap();
2507        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
2508        let store = Store {
2509            issues_dir: dir,
2510            config,
2511        };
2512        (tmp, store)
2513    }
2514
2515    fn write_issue(store: &Store, name: &str, contents: &str) {
2516        fs::write(store.issues_dir.join(name), contents).unwrap();
2517    }
2518
2519    fn sample_frontmatter(number: u32, title: &str, status: &str) -> String {
2520        format!(
2521            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
2522        )
2523    }
2524
2525    // ------------------------------------------------------------------
2526    // Issue #1492 — archived issues (dot-prefixed filename, in place)
2527    // ------------------------------------------------------------------
2528
2529    /// The default walk must skip a dot-prefixed file **without opening it**, so
2530    /// an archived file with unparseable frontmatter cannot break `list()`. That
2531    /// an unparseable one is *only* reachable through `list_all()` is the cheapest
2532    /// honest proof the skip happens before the read.
2533    #[test]
2534    fn list_never_opens_an_archived_file_but_list_all_does() {
2535        let (tmp, store) = make_store();
2536        let _ = tmp;
2537        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2538        write_issue(
2539            &store,
2540            "alice/2026-01-01-active.md",
2541            &sample_frontmatter(1, "Active", "todo"),
2542        );
2543        fs::write(
2544            store.issues_dir.join("alice/.2026-01-02-broken.md"),
2545            "this file has no frontmatter at all\n",
2546        )
2547        .unwrap();
2548
2549        let active = store
2550            .list()
2551            .expect("the default walk must not read the archived file");
2552        assert_eq!(active.len(), 1);
2553        assert_eq!(active[0].number, 1);
2554        assert!(!active[0].archived);
2555
2556        let all = store.list_all();
2557        assert!(
2558            matches!(all, Err(TixError::Parse(_))),
2559            "list_all() reads it, so the broken file is only invisible to list(): {all:?}"
2560        );
2561    }
2562
2563    #[test]
2564    fn archive_renames_in_place_and_restore_is_the_inverse() {
2565        let (tmp, store) = make_store();
2566        let _ = tmp;
2567        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2568        write_issue(
2569            &store,
2570            "alice/2026-01-01-x.md",
2571            &sample_frontmatter(5, "X", "todo"),
2572        );
2573
2574        let archived = store.archive("5").unwrap();
2575        assert_eq!(archived.filename, "alice/.2026-01-01-x.md");
2576        assert!(archived.archived);
2577        assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
2578        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
2579        assert!(
2580            store.list().unwrap().is_empty(),
2581            "hidden from the default view"
2582        );
2583
2584        let all = store.list_all().unwrap();
2585        assert_eq!(all.len(), 1);
2586        assert!(all[0].archived);
2587        assert_eq!(all[0].number, 5, "the number is untouched");
2588
2589        // Both selectors still resolve the archived issue.
2590        assert_eq!(
2591            store.get(IssueSelector::Number(5)).unwrap().filename,
2592            "alice/.2026-01-01-x.md"
2593        );
2594        assert!(store.get(IssueSelector::Slug(slugify("X"))).is_ok());
2595
2596        let restored = store.restore("5").unwrap();
2597        assert_eq!(restored.filename, "alice/2026-01-01-x.md");
2598        assert!(!restored.archived);
2599        assert_eq!(store.list().unwrap().len(), 1, "visible again");
2600    }
2601
2602    #[test]
2603    fn archive_appends_one_event_and_leaves_the_frontmatter_untouched() {
2604        let (tmp, store) = make_store();
2605        let _ = tmp;
2606        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2607        write_issue(
2608            &store,
2609            "alice/2026-01-01-x.md",
2610            &sample_frontmatter(5, "X", "todo"),
2611        );
2612        let before = store
2613            .get(IssueSelector::Filename("alice/2026-01-01-x.md".into()))
2614            .unwrap();
2615
2616        let archived = store.archive("5").unwrap();
2617        assert_eq!(archived.history.len(), 1, "exactly one History event");
2618        assert_eq!(
2619            archived.history.events()[0].kind,
2620            HistoryEventKind::Archived
2621        );
2622
2623        let after = store.get(IssueSelector::Number(5)).unwrap();
2624        // Every field a reader cares about survives unchanged. `update` re-renders
2625        // the frontmatter (quoting and key order normalize, as on every tix write),
2626        // so the invariant is the *values*, not the bytes.
2627        assert_eq!(after.number, before.number);
2628        assert_eq!(after.title, before.title);
2629        assert_eq!(after.status, before.status);
2630        assert_eq!(after.labels, before.labels);
2631        assert_eq!(after.priority, before.priority);
2632        assert_eq!(after.assignee, before.assignee);
2633        assert_eq!(after.created, before.created);
2634        assert_eq!(
2635            after.updated, before.updated,
2636            "archiving must not bump `updated`: it is a frontmatter field"
2637        );
2638        // The marker is the filename; nothing was written into the frontmatter.
2639        let raw = read_issue_file(&store, "alice/.2026-01-01-x.md");
2640        let fm = raw.split("\n---").next().unwrap();
2641        assert!(
2642            !fm.contains("archived"),
2643            "the archived flag must never reach the frontmatter: {fm}"
2644        );
2645    }
2646
2647    #[test]
2648    fn restore_appends_one_restored_event() {
2649        let (tmp, store) = make_store();
2650        let _ = tmp;
2651        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2652        write_issue(
2653            &store,
2654            "alice/2026-01-01-x.md",
2655            &sample_frontmatter(5, "X", "todo"),
2656        );
2657        store.archive("5").unwrap();
2658        let restored = store.restore("5").unwrap();
2659        assert_eq!(restored.history.len(), 2, "archive + restore");
2660        assert_eq!(
2661            restored.history.events()[1].kind,
2662            HistoryEventKind::Restored
2663        );
2664    }
2665
2666    #[test]
2667    fn archiving_the_highest_number_does_not_free_it_for_reuse() {
2668        let (tmp, store) = make_store();
2669        let _ = tmp;
2670        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2671        write_issue(
2672            &store,
2673            "alice/2026-01-01-a.md",
2674            &sample_frontmatter(1, "A", "todo"),
2675        );
2676        write_issue(
2677            &store,
2678            "alice/2026-01-01-b.md",
2679            &sample_frontmatter(7, "B", "todo"),
2680        );
2681
2682        store.archive("7").unwrap();
2683
2684        let mut fresh = Issue::parse(&sample_frontmatter(0, "New", "todo")).unwrap();
2685        store.create(&mut fresh, "body", None).unwrap();
2686        assert_eq!(
2687            fresh.number, 8,
2688            "an archived issue keeps its number: `create` must not reuse #7"
2689        );
2690    }
2691
2692    #[test]
2693    fn redundant_archive_and_restore_are_refused_by_name() {
2694        let (tmp, store) = make_store();
2695        let _ = tmp;
2696        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2697        write_issue(
2698            &store,
2699            "alice/2026-01-01-x.md",
2700            &sample_frontmatter(5, "X", "todo"),
2701        );
2702
2703        let err = store.restore("5").unwrap_err();
2704        assert!(
2705            err.to_string().contains("not_archived"),
2706            "an active issue cannot be restored: {err}"
2707        );
2708        assert!(
2709            store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
2710            "the refused restore writes nothing"
2711        );
2712
2713        store.archive("5").unwrap();
2714        let err = store.archive("5").unwrap_err();
2715        assert!(
2716            err.to_string().contains("already_archived"),
2717            "an archived issue cannot be re-archived: {err}"
2718        );
2719        assert!(
2720            store.issues_dir.join("alice/.2026-01-01-x.md").is_file(),
2721            "the refused archive writes nothing"
2722        );
2723    }
2724
2725    #[test]
2726    fn archive_never_clobbers_an_occupied_target_name() {
2727        let (tmp, store) = make_store();
2728        let _ = tmp;
2729        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2730        write_issue(
2731            &store,
2732            "alice/2026-01-01-x.md",
2733            &sample_frontmatter(5, "X", "todo"),
2734        );
2735        // A pre-existing dot-prefixed file would be the target of the rename.
2736        write_issue(
2737            &store,
2738            "alice/.2026-01-01-x.md",
2739            &sample_frontmatter(6, "Other", "todo"),
2740        );
2741        let err = store.archive("5").unwrap_err();
2742        assert!(err.to_string().contains("already exists"), "got: {err}");
2743        assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
2744        assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
2745    }
2746
2747    #[test]
2748    fn archive_and_restore_a_legacy_flat_file_in_place() {
2749        let (tmp, store) = make_store();
2750        let _ = tmp;
2751        write_issue(
2752            &store,
2753            "2026-01-01-legacy.md",
2754            &sample_frontmatter(3, "Legacy", "todo"),
2755        );
2756
2757        let archived = store.archive("3").unwrap();
2758        assert_eq!(archived.filename, ".2026-01-01-legacy.md");
2759        assert!(store.issues_dir.join(".2026-01-01-legacy.md").is_file());
2760        assert!(store.list().unwrap().is_empty());
2761        assert_eq!(store.list_all().unwrap().len(), 1);
2762
2763        let restored = store.restore("3").unwrap();
2764        assert_eq!(restored.filename, "2026-01-01-legacy.md");
2765        assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
2766    }
2767
2768    #[test]
2769    fn list_returns_sorted_by_filename() {
2770        let (tmp, store) = make_store();
2771        let _ = tmp;
2772        write_issue(
2773            &store,
2774            "2026-03-01-c.md",
2775            &sample_frontmatter(3, "C", "todo"),
2776        );
2777        write_issue(
2778            &store,
2779            "2026-01-01-a.md",
2780            &sample_frontmatter(1, "A", "todo"),
2781        );
2782        write_issue(
2783            &store,
2784            "2026-02-01-b.md",
2785            &sample_frontmatter(2, "B", "todo"),
2786        );
2787        let issues = store.list().unwrap();
2788        assert_eq!(issues.len(), 3);
2789        assert_eq!(issues[0].number, 1);
2790        assert_eq!(issues[1].number, 2);
2791        assert_eq!(issues[2].number, 3);
2792        assert_eq!(issues[0].title, "A");
2793    }
2794
2795    #[test]
2796    fn list_ignores_non_md_files() {
2797        let (tmp, store) = make_store();
2798        let _ = tmp;
2799        write_issue(
2800            &store,
2801            "2026-01-01-a.md",
2802            &sample_frontmatter(1, "A", "todo"),
2803        );
2804        write_issue(&store, "readme.txt", "not an issue");
2805        write_issue(&store, "2026-01-01-sub", "no extension");
2806        let issues = store.list().unwrap();
2807        assert_eq!(issues.len(), 1);
2808    }
2809
2810    #[test]
2811    fn list_errors_on_malformed_frontmatter() {
2812        let (tmp, store) = make_store();
2813        let _ = tmp;
2814        write_issue(&store, "2026-01-01-bad.md", "no frontmatter here\n");
2815        let err = store.list().unwrap_err();
2816        assert!(matches!(err, TixError::Parse(_)), "got {err:?}");
2817    }
2818
2819    #[test]
2820    fn get_by_number() {
2821        let (tmp, store) = make_store();
2822        let _ = tmp;
2823        write_issue(
2824            &store,
2825            "2026-01-01-x.md",
2826            &sample_frontmatter(5, "X", "todo"),
2827        );
2828        let issue = store.get(IssueSelector::Number(5)).unwrap();
2829        assert_eq!(issue.title, "X");
2830    }
2831
2832    #[test]
2833    fn get_by_filename() {
2834        let (tmp, store) = make_store();
2835        let _ = tmp;
2836        write_issue(
2837            &store,
2838            "2026-01-01-y.md",
2839            &sample_frontmatter(2, "Y", "done"),
2840        );
2841        let issue = store
2842            .get(IssueSelector::Filename("2026-01-01-y.md".into()))
2843            .unwrap();
2844        assert_eq!(issue.status.display(), "done");
2845    }
2846
2847    #[test]
2848    fn get_by_slug() {
2849        let (tmp, store) = make_store();
2850        let _ = tmp;
2851        write_issue(
2852            &store,
2853            "2026-01-01-z.md",
2854            &sample_frontmatter(3, "Z", "todo"),
2855        );
2856        let issue = store.get(IssueSelector::Slug("z".into())).unwrap();
2857        assert_eq!(issue.number, 3);
2858    }
2859
2860    #[test]
2861    fn get_not_found() {
2862        let (tmp, store) = make_store();
2863        let _ = tmp;
2864        assert!(store.get(IssueSelector::Number(99)).is_err());
2865    }
2866
2867    #[test]
2868    fn create_assigns_number_from_live_issues_and_leaves_config_untouched() {
2869        let (tmp, store) = make_store();
2870        let _ = tmp;
2871        assert_eq!(store.config.state.next_number, 0);
2872        let mut issue = Issue {
2873            number: 0,
2874            title: "First".into(),
2875            status: Status::default(),
2876            labels: vec![],
2877            priority: Priority::default(),
2878            assignee: None,
2879            estimate: None,
2880            commit: None,
2881            gitlab_id: None,
2882            linear_id: None,
2883            mission: None,
2884            task: None,
2885            relationships: vec![],
2886            files: vec![],
2887            cycles: None,
2888            milestones: None,
2889            reviewer: None,
2890            start_date: None,
2891            due_date: None,
2892            created: "2026-01-01".into(),
2893            updated: "2026-01-01".into(),
2894            filename: String::new(),
2895            body: String::new(),
2896            history: crate::model::History::new(),
2897            has_retired_links_key: false,
2898            parent: None,
2899            extra_frontmatter: std::collections::BTreeMap::new(),
2900            archived: false,
2901            entered_at: None,
2902            source: None,
2903        };
2904        let path = store.create(&mut issue, "Hello world", None).unwrap();
2905        assert!(path.exists());
2906        assert_eq!(
2907            issue.number, 1,
2908            "an empty store starts at 1, not 0 — #0 is indistinguishable from a \
2909             sentinel or a failed read (tix #1376)"
2910        );
2911        // Issue #52: create never rewrites tix.toml with a counter.
2912        let config_path = tmp.path().join("tix.toml");
2913        let on_disk = std::fs::read_to_string(&config_path).unwrap();
2914        assert!(
2915            !on_disk.contains("next_number"),
2916            "create must not persist an issue-number counter:\n{on_disk}"
2917        );
2918        let reloaded = Config::load_with_warning(&config_path, |_| {}).unwrap();
2919        assert_eq!(reloaded.state.next_number, 0, "state is never written");
2920    }
2921
2922    #[test]
2923    fn create_resolves_max_live_number_plus_one() {
2924        let (tmp, store) = make_store();
2925        let _ = tmp;
2926        write_issue(
2927            &store,
2928            "2026-01-01-a.md",
2929            &sample_frontmatter(0, "A", "todo"),
2930        );
2931        write_issue(
2932            &store,
2933            "2026-01-01-b.md",
2934            &sample_frontmatter(1, "B", "todo"),
2935        );
2936        write_issue(
2937            &store,
2938            "2026-01-01-c.md",
2939            &sample_frontmatter(2, "C", "todo"),
2940        );
2941        let mut issue = Issue {
2942            number: 0,
2943            title: "D".into(),
2944            status: Status::default(),
2945            labels: vec![],
2946            priority: Priority::default(),
2947            assignee: None,
2948            estimate: None,
2949            commit: None,
2950            gitlab_id: None,
2951            linear_id: None,
2952            mission: None,
2953            task: None,
2954            relationships: vec![],
2955            files: vec![],
2956            cycles: None,
2957            milestones: None,
2958            reviewer: None,
2959            start_date: None,
2960            due_date: None,
2961            created: "2026-01-01".into(),
2962            updated: "2026-01-01".into(),
2963            filename: String::new(),
2964            body: String::new(),
2965            history: crate::model::History::new(),
2966            has_retired_links_key: false,
2967            parent: None,
2968            extra_frontmatter: std::collections::BTreeMap::new(),
2969            archived: false,
2970            entered_at: None,
2971            source: None,
2972        };
2973        store.create(&mut issue, "", None).unwrap();
2974        assert_eq!(issue.number, 3);
2975    }
2976
2977    #[test]
2978    fn create_reuses_number_of_deleted_highest_issue() {
2979        // Issue #52 tradeoff: without a persisted counter the next number is
2980        // re-derived from the live set, so deleting the highest-numbered
2981        // issue lets its number be reused by the next create.
2982        let (_tmp, store) = make_store();
2983        let mut issue_a = Issue {
2984            number: 0,
2985            title: "A".into(),
2986            status: Status::default(),
2987            labels: vec![],
2988            priority: Priority::default(),
2989            assignee: None,
2990            estimate: None,
2991            commit: None,
2992            gitlab_id: None,
2993            linear_id: None,
2994            mission: None,
2995            task: None,
2996            relationships: vec![],
2997            files: vec![],
2998            cycles: None,
2999            milestones: None,
3000            reviewer: None,
3001            start_date: None,
3002            due_date: None,
3003            created: "2026-01-01".into(),
3004            updated: "2026-01-01".into(),
3005            filename: String::new(),
3006            body: String::new(),
3007            history: crate::model::History::new(),
3008            has_retired_links_key: false,
3009            parent: None,
3010            extra_frontmatter: std::collections::BTreeMap::new(),
3011            archived: false,
3012            entered_at: None,
3013            source: None,
3014        };
3015        store.create(&mut issue_a, "", None).unwrap();
3016        let filename = issue_a.filename.clone();
3017        assert_eq!(issue_a.number, 1, "the first issue in a fresh store is #1");
3018        store.delete(&filename).unwrap();
3019        let mut issue_b = Issue {
3020            number: 0,
3021            title: "B".into(),
3022            status: Status::default(),
3023            labels: vec![],
3024            priority: Priority::default(),
3025            assignee: None,
3026            estimate: None,
3027            commit: None,
3028            gitlab_id: None,
3029            linear_id: None,
3030            mission: None,
3031            task: None,
3032            relationships: vec![],
3033            files: vec![],
3034            cycles: None,
3035            milestones: None,
3036            reviewer: None,
3037            start_date: None,
3038            due_date: None,
3039            created: "2026-01-02".into(),
3040            updated: "2026-01-02".into(),
3041            filename: String::new(),
3042            body: String::new(),
3043            history: crate::model::History::new(),
3044            has_retired_links_key: false,
3045            parent: None,
3046            extra_frontmatter: std::collections::BTreeMap::new(),
3047            archived: false,
3048            entered_at: None,
3049            source: None,
3050        };
3051        store.create(&mut issue_b, "", None).unwrap();
3052        assert_eq!(
3053            issue_b.number, 1,
3054            "an emptied store resumes at the same floor as a fresh one: 1"
3055        );
3056    }
3057
3058    #[test]
3059    fn create_with_custom_filename_stem() {
3060        let (tmp, store) = make_store();
3061        let _ = tmp;
3062        let mut issue = Issue {
3063            number: 0,
3064            title: "T".into(),
3065            status: Status::default(),
3066            labels: vec![],
3067            priority: Priority::default(),
3068            assignee: None,
3069            estimate: None,
3070            commit: None,
3071            gitlab_id: None,
3072            linear_id: None,
3073            mission: None,
3074            task: None,
3075            relationships: vec![],
3076            files: vec![],
3077            cycles: None,
3078            milestones: None,
3079            reviewer: None,
3080            start_date: None,
3081            due_date: None,
3082            created: "2026-01-01".into(),
3083            updated: "2026-01-01".into(),
3084            filename: String::new(),
3085            body: String::new(),
3086            history: crate::model::History::new(),
3087            has_retired_links_key: false,
3088            parent: None,
3089            extra_frontmatter: std::collections::BTreeMap::new(),
3090            archived: false,
3091            entered_at: None,
3092            source: None,
3093        };
3094        let path = store
3095            .create(&mut issue, "body", Some("custom-stem"))
3096            .unwrap();
3097        assert!(path.to_string_lossy().contains("custom-stem.md"));
3098    }
3099
3100    #[test]
3101    fn create_ensures_issues_dir_exists() {
3102        let tmp = tempfile::TempDir::new().unwrap();
3103        let dir = tmp.path().join("nested").join("issues");
3104        let config_path = tmp.path().join("tix.toml");
3105        let config = Config {
3106            issues_dir: dir.clone(),
3107            ..Config::default()
3108        };
3109        config.save(&config_path).unwrap();
3110        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
3111        let store = Store {
3112            issues_dir: dir,
3113            config,
3114        };
3115        let mut issue = Issue {
3116            number: 0,
3117            title: "T".into(),
3118            status: Status::default(),
3119            labels: vec![],
3120            priority: Priority::default(),
3121            assignee: None,
3122            estimate: None,
3123            commit: None,
3124            gitlab_id: None,
3125            linear_id: None,
3126            mission: None,
3127            task: None,
3128            relationships: vec![],
3129            files: vec![],
3130            cycles: None,
3131            milestones: None,
3132            reviewer: None,
3133            start_date: None,
3134            due_date: None,
3135            created: "2026-01-01".into(),
3136            updated: "2026-01-01".into(),
3137            filename: String::new(),
3138            body: String::new(),
3139            history: crate::model::History::new(),
3140            has_retired_links_key: false,
3141            parent: None,
3142            extra_frontmatter: std::collections::BTreeMap::new(),
3143            archived: false,
3144            entered_at: None,
3145            source: None,
3146        };
3147        store.create(&mut issue, "", None).unwrap();
3148        assert_eq!(issue.number, 1, "the first issue in a fresh store is #1");
3149    }
3150
3151    #[test]
3152    fn update_replaces_content_atomically() {
3153        let (tmp, store) = make_store();
3154        let _ = tmp;
3155        write_issue(
3156            &store,
3157            "2026-01-01-u.md",
3158            &sample_frontmatter(1, "U", "todo"),
3159        );
3160        let mut issue = store
3161            .get(IssueSelector::Filename("2026-01-01-u.md".into()))
3162            .unwrap();
3163        issue.title = "Updated".into();
3164        issue.bump_updated();
3165        store.update("2026-01-01-u.md", &issue).unwrap();
3166        let reloaded = store
3167            .get(IssueSelector::Filename("2026-01-01-u.md".into()))
3168            .unwrap();
3169        assert_eq!(reloaded.title, "Updated");
3170    }
3171
3172    #[test]
3173    fn update_not_found() {
3174        let (tmp, store) = make_store();
3175        let _ = tmp;
3176        let issue = Issue {
3177            number: 1,
3178            title: "T".into(),
3179            status: Status::default(),
3180            labels: vec![],
3181            priority: Priority::default(),
3182            assignee: None,
3183            estimate: None,
3184            commit: None,
3185            gitlab_id: None,
3186            linear_id: None,
3187            mission: None,
3188            task: None,
3189            relationships: vec![],
3190            files: vec![],
3191            cycles: None,
3192            milestones: None,
3193            reviewer: None,
3194            start_date: None,
3195            due_date: None,
3196            created: "2026-01-01".into(),
3197            updated: "2026-01-01".into(),
3198            filename: String::new(),
3199            body: String::new(),
3200            history: crate::model::History::new(),
3201            has_retired_links_key: false,
3202            parent: None,
3203            extra_frontmatter: std::collections::BTreeMap::new(),
3204            archived: false,
3205            entered_at: None,
3206            source: None,
3207        };
3208        assert!(store.update("nope.md", &issue).is_err());
3209    }
3210
3211    #[test]
3212    fn delete_removes_file() {
3213        let (tmp, store) = make_store();
3214        let _ = tmp;
3215        write_issue(
3216            &store,
3217            "2026-01-01-d.md",
3218            &sample_frontmatter(1, "D", "todo"),
3219        );
3220        assert!(store.issues_dir.join("2026-01-01-d.md").exists());
3221        store.delete("2026-01-01-d.md").unwrap();
3222        assert!(!store.issues_dir.join("2026-01-01-d.md").exists());
3223    }
3224
3225    #[test]
3226    fn delete_not_found() {
3227        let (tmp, store) = make_store();
3228        let _ = tmp;
3229        assert!(store.delete("nope.md").is_err());
3230    }
3231
3232    #[test]
3233    fn stray_tmp_file_not_treated_as_issue() {
3234        let (tmp, store) = make_store();
3235        let _ = tmp;
3236        write_issue(
3237            &store,
3238            "2026-01-01-a.md",
3239            &sample_frontmatter(1, "A", "todo"),
3240        );
3241        fs::write(store.issues_dir.join(".tix-tmp-9999-0"), "orphaned data").unwrap();
3242        let issues = store.list().unwrap();
3243        assert_eq!(issues.len(), 1);
3244    }
3245
3246    // ------------------------------------------------------------------
3247    // WP03 T015 — the storage seam's contract, as executable evidence
3248    // ------------------------------------------------------------------
3249    //
3250    // Every case asserts the **file bytes**, not just the returned `Result`. A
3251    // refusal that returns an error after having already re-serialised the issue
3252    // would pass an `is_err()` assertion and still have destroyed the audit trail,
3253    // so "did it refuse?" and "did it touch anything?" are separate questions here
3254    // and both are asked.
3255
3256    /// A store whose board **declares a `category_map`**, so category policy —
3257    /// the terminal lock and adjacency — applies.
3258    ///
3259    /// The mapping is `todo=unstarted, doing=active, done=completed`, which is
3260    /// forward-moving in the declared vocabulary order. `triage` is added as a
3261    /// `backlog` column so there is a genuine backwards move to be refused.
3262    fn seam_store() -> (tempfile::TempDir, Store) {
3263        let tmp = tempfile::TempDir::new().unwrap();
3264        let dir = tmp.path().join("issues");
3265        fs::create_dir_all(&dir).unwrap();
3266        let config_path = tmp.path().join("tix.toml");
3267        let config = Config {
3268            issues_dir: dir.clone(),
3269            kanban: crate::config::KanbanSection {
3270                configurations: indexmap::IndexMap::from([(
3271                    "default".to_string(),
3272                    crate::config::BoardConfiguration {
3273                        columns: vec![
3274                            "triage".into(),
3275                            "todo".into(),
3276                            "doing".into(),
3277                            "done".into(),
3278                        ],
3279                        done_column: Some("done".to_string()),
3280                        category_map: indexmap::IndexMap::from([
3281                            ("triage".to_string(), "backlog".to_string()),
3282                            ("todo".to_string(), "unstarted".to_string()),
3283                            ("doing".to_string(), "active".to_string()),
3284                            ("done".to_string(), "completed".to_string()),
3285                        ]),
3286                        ..Default::default()
3287                    },
3288                )]),
3289            },
3290            ..Config::default()
3291        };
3292        config.save(&config_path).unwrap();
3293        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
3294        (
3295            tmp,
3296            Store {
3297                issues_dir: dir,
3298                config,
3299            },
3300        )
3301    }
3302
3303    /// A seam store holding one issue at `status`, and that file's bytes.
3304    ///
3305    /// The fixture carries every key the model always writes, `files` included.
3306    /// That is not tidiness: `t015_no_new_frontmatter_key_was_introduced` compares
3307    /// the key set before and after, and a fixture that omits `files` makes the
3308    /// comparison report the model's own always-written key as a change — so the
3309    /// test would have been measuring the fixture instead of the seam.
3310    fn seam_issue(status: &str) -> (tempfile::TempDir, Store, String) {
3311        let (tmp, store) = seam_store();
3312        write_issue(
3313            &store,
3314            "2026-01-01-seam.md",
3315            &format!(
3316                "---\nnumber: 1\ntitle: \"Seam\"\nstatus: {status}\nlabels: []\n\
3317                 priority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody."
3318            ),
3319        );
3320        let before = read_issue_file(&store, "2026-01-01-seam.md");
3321        (tmp, store, before)
3322    }
3323
3324    /// Assert a refusal left the file byte-identical.
3325    fn assert_untouched(store: &Store, before: &str, what: &str) {
3326        let after = read_issue_file(store, "2026-01-01-seam.md");
3327        assert_eq!(after, before, "{what}: the file must be byte-identical");
3328    }
3329
3330    #[test]
3331    fn t015_accepted_transition_writes_status_bumps_updated_and_appends_one_decision() {
3332        let (_tmp, store, _before) = seam_issue("todo");
3333        let outcome = store
3334            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3335            .unwrap();
3336        assert!(outcome.written, "an accepted, changing move writes");
3337        let decision = outcome
3338            .decision
3339            .clone()
3340            .expect("an accepted transition appends exactly one decision");
3341        assert_eq!(decision.from, "todo");
3342        assert_eq!(decision.to, "doing");
3343        assert_eq!(decision.from_category, "unstarted");
3344        assert_eq!(decision.to_category, "active");
3345        assert!(
3346            decision.override_reason.is_none(),
3347            "no override was supplied"
3348        );
3349        assert_eq!(
3350            outcome.issue.status.display(),
3351            "doing",
3352            "Issue.status holds the COLUMN name, never a category (C-001)"
3353        );
3354        assert_eq!(outcome.issue.updated, today(), "`updated` is bumped");
3355
3356        // Re-read from disk: the in-memory record is not evidence of what landed.
3357        let reloaded = store
3358            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3359            .unwrap();
3360        assert_eq!(reloaded.status.display(), "doing");
3361        let events = reloaded.history.events();
3362        assert_eq!(
3363            events.len(),
3364            1,
3365            "exactly one History entry, and it is the decision: {events:?}"
3366        );
3367        assert_eq!(events[0].kind, HistoryEventKind::TransitionAccepted);
3368        assert_eq!(
3369            crate::model::TransitionDecision::from_event(events[0])
3370                .unwrap()
3371                .to,
3372            "doing",
3373            "and the decision round-trips out of the file with its fields intact"
3374        );
3375    }
3376
3377    #[test]
3378    fn t015_unknown_column_is_refused_by_name_and_writes_nothing() {
3379        let (_tmp, store, before) = seam_issue("todo");
3380        let err = store
3381            .set_status("2026-01-01-seam.md", StatusChange::to("nope"))
3382            .unwrap_err();
3383        assert!(
3384            matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "unknown_column"),
3385            "the shared named diagnostic, contract §6: {err:?}"
3386        );
3387        assert!(
3388            err.to_string()
3389                .contains("valid columns: triage, todo, doing, done"),
3390            "in BOARD order, not hash order: {err}"
3391        );
3392        assert_untouched(&store, &before, "unknown column");
3393    }
3394
3395    #[test]
3396    fn t015_terminal_exit_is_refused_and_writes_nothing() {
3397        let (_tmp, store, before) = seam_issue("done");
3398        let err = store
3399            .set_status("2026-01-01-seam.md", StatusChange::to("todo"))
3400            .unwrap_err();
3401        assert!(
3402            matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
3403            "{err:?}"
3404        );
3405        assert_untouched(&store, &before, "terminal exit without a reason");
3406    }
3407
3408    #[test]
3409    fn t015_adjacency_restricted_backwards_move_is_refused_and_writes_nothing() {
3410        // `doing` is `active`; `triage` is `backlog`. Forward in the declared
3411        // vocabulary order, so no terminal lock applies — this is adjacency alone,
3412        // and it must name the same `invalid_transition` kind.
3413        let (_tmp, store, before) = seam_issue("doing");
3414        let err = store
3415            .set_status("2026-01-01-seam.md", StatusChange::to("triage"))
3416            .unwrap_err();
3417        assert!(
3418            matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
3419            "{err:?}"
3420        );
3421        assert!(
3422            err.to_string().contains("category policy"),
3423            "the message names the rule that refused it: {err}"
3424        );
3425        assert_untouched(&store, &before, "adjacency refusal");
3426    }
3427
3428    #[test]
3429    fn t015_terminal_exit_with_a_reason_is_accepted_and_the_reason_is_recorded() {
3430        let (_tmp, store, before) = seam_issue("done");
3431        let outcome = store
3432            .set_status(
3433                "2026-01-01-seam.md",
3434                StatusChange::to("todo").with_override(Some("reopening: wrong epic")),
3435            )
3436            .unwrap();
3437        assert!(outcome.written);
3438        let decision = outcome.decision.expect("one decision");
3439        assert_eq!(
3440            decision.override_reason.as_deref(),
3441            Some("reopening: wrong epic"),
3442            "an override with no recorded reason is not an audit record"
3443        );
3444        assert_ne!(
3445            read_issue_file(&store, "2026-01-01-seam.md"),
3446            before,
3447            "sanity: an accepted move must have changed the file at all"
3448        );
3449    }
3450
3451    #[test]
3452    fn t015_a_blank_reason_is_not_an_override() {
3453        // The service trims and drops a blank reason, so a whitespace-only string
3454        // must not unlock a terminal exit. Asserted here as well as in the service
3455        // because "the seam passes the caller's string through untouched" would
3456        // satisfy a service-level test and still be wrong at the boundary.
3457        let (_tmp, store, before) = seam_issue("done");
3458        let err = store
3459            .set_status(
3460                "2026-01-01-seam.md",
3461                StatusChange::to("todo").with_override(Some("   ")),
3462            )
3463            .unwrap_err();
3464        assert!(
3465            matches!(&err, TixError::Workflow(_)),
3466            "a blank reason is not a reason: {err:?}"
3467        );
3468        assert_untouched(&store, &before, "blank override reason");
3469    }
3470
3471    #[test]
3472    fn t015_same_column_is_accepted_and_writes_nothing() {
3473        let (_tmp, store, before) = seam_issue("doing");
3474        let outcome = store
3475            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3476            .unwrap();
3477        assert!(!outcome.written, "a same-column re-apply is a no-op");
3478        assert!(
3479            outcome.decision.is_none(),
3480            "and appends no decision, contract §3.2"
3481        );
3482        assert_untouched(&store, &before, "same column");
3483        // `updated` is not bumped either: a no-op that touched the date would make
3484        // `tix edit --status <current column>` look like an edit.
3485        let reloaded = store
3486            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3487            .unwrap();
3488        assert_eq!(
3489            reloaded.updated, "2026-01-01",
3490            "the fixture's original date"
3491        );
3492    }
3493
3494    #[test]
3495    fn t015_no_new_frontmatter_key_was_introduced() {
3496        // C-001. The mission must be byte-compatible on the file surface, so this
3497        // compares the key set of a file the seam wrote against the key set of the
3498        // fixture it read. A category key on the issue would break here.
3499        let (_tmp, store, before) = seam_issue("todo");
3500        store
3501            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3502            .unwrap();
3503        let after = read_issue_file(&store, "2026-01-01-seam.md");
3504        let keys = |text: &str| -> Vec<String> {
3505            text.split("---\n")
3506                .nth(1)
3507                .unwrap()
3508                .lines()
3509                .filter_map(|l| l.split_once(':').map(|(k, _)| k.trim().to_string()))
3510                .filter(|k| !k.is_empty())
3511                .collect()
3512        };
3513        assert_eq!(
3514            keys(&after),
3515            keys(&before),
3516            "the seam must not add or remove a top-level frontmatter key"
3517        );
3518    }
3519
3520    #[test]
3521    fn t015_a_hand_edited_inconsistent_file_is_read_and_not_repaired() {
3522        // C-005. A file whose `status` was hand-edited to a column the policy would
3523        // never have allowed is *readable* and this path must leave it alone. The
3524        // diagnosis is WP06's job; storage correcting it would be the escalation
3525        // the decision `01M3C3XBMTR5WFV7059NXM04AJ` names.
3526        let (_tmp, store, before) = seam_issue("doing");
3527        // Simulate the hand edit: `doing` (active) becomes `triage` (backlog),
3528        // a move adjacency refuses.
3529        let edited = before.replace("status: doing", "status: triage");
3530        write_issue(&store, "2026-01-01-seam.md", &edited);
3531        let after_edit = read_issue_file(&store, "2026-01-01-seam.md");
3532
3533        let issue = store
3534            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3535            .unwrap();
3536        assert_eq!(
3537            issue.status.display(),
3538            "triage",
3539            "a hand-edited status is read, not rejected at load"
3540        );
3541        assert_eq!(
3542            read_issue_file(&store, "2026-01-01-seam.md"),
3543            after_edit,
3544            "reading must not repair it"
3545        );
3546        // And a legal move away from the hand-edited column proceeds, without the
3547        // seam trying to reconcile the history of how the file got there.
3548        store
3549            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3550            .unwrap();
3551        let moved = store
3552            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3553            .unwrap();
3554        assert_eq!(moved.status.display(), "doing");
3555        assert_eq!(
3556            moved.history.events().len(),
3557            1,
3558            "only the new decision; nothing was written to explain the old one"
3559        );
3560    }
3561
3562    #[test]
3563    fn t015_every_rejection_kind_is_one_of_the_contract_vocabulary() {
3564        // The contract fixes seven names (contract §6). A refusal that invents an
3565        // eighth is a surface that reports a condition under a name no other
3566        // surface knows, which is the exact failure the vocabulary exists to stop.
3567        let allowed = [
3568            "missing_completion_category",
3569            "unknown_category",
3570            "mapping_for_unknown_column",
3571            "unmapped_column_default",
3572            "invalid_transition",
3573            "unknown_column",
3574            "direct_edit_policy_violation",
3575        ];
3576        let cases: Vec<(&str, &str)> = vec![
3577            ("todo", "nope"),    // unknown_column
3578            ("done", "todo"),    // invalid_transition (terminal lock)
3579            ("doing", "triage"), // invalid_transition (adjacency)
3580        ];
3581        for (from, to) in cases {
3582            let (_tmp, store, _before) = seam_issue(from);
3583            let err = store
3584                .set_status("2026-01-01-seam.md", StatusChange::to(to))
3585                .unwrap_err();
3586            let TixError::Workflow(d) = &err else {
3587                panic!("{from} -> {to} must refuse through the workflow variant: {err:?}");
3588            };
3589            assert!(
3590                allowed.contains(&d.kind.as_str()),
3591                "`{}` is not in the contract's vocabulary",
3592                d.kind.as_str()
3593            );
3594        }
3595    }
3596
3597    #[test]
3598    fn t015_a_legacy_board_gains_no_policy_but_keeps_completion() {
3599        // The opt-in this WP decided: a board with no `category_map` gets the
3600        // completion definition and the commit-clearing rule, and no transition
3601        // restriction. Both halves are asserted, because a change that dropped the
3602        // completion rule along with the policy would otherwise look correct.
3603        let (tmp, store) = make_store();
3604        let _ = tmp;
3605        write_issue(
3606            &store,
3607            "2026-01-01-legacy.md",
3608            &sample_frontmatter(1, "Legacy", "done"),
3609        );
3610        let moved = store
3611            .set_status("2026-01-01-legacy.md", StatusChange::to("todo"))
3612            .unwrap();
3613        assert!(moved.written, "a legacy board imposes no policy");
3614        assert_eq!(moved.issue.status.display(), "todo");
3615
3616        // And completion still resolves through the reconciliation, not the string
3617        // "done": this issue is no longer complete, and an untouched `done` one is.
3618        let resolution =
3619            crate::workflow::resolution(&store.config, store.config.default_config(), "default");
3620        assert!(
3621            !resolution.explicit_policy,
3622            "precondition: this board declared no category_map"
3623        );
3624        assert!(
3625            resolution.has_completion(),
3626            "but it still has a done column"
3627        );
3628        assert!(
3629            !crate::workflow::is_completed(&moved.issue, &resolution),
3630            "and `todo` is not complete"
3631        );
3632    }
3633
3634    #[test]
3635    fn t015_a_mapped_board_keeps_the_commit_coupling() {
3636        // The commit rule moved from `mv.rs` into the seam, so it is pinned here
3637        // rather than only in the command layer: reaching completion records the
3638        // commit, leaving it clears the one that was recorded.
3639        let (_tmp, store, _before) = seam_issue("doing");
3640        store
3641            .set_status(
3642                "2026-01-01-seam.md",
3643                StatusChange::to("done").with_commit(Some("abc123")),
3644            )
3645            .unwrap();
3646        let at_done = store
3647            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3648            .unwrap();
3649        assert_eq!(at_done.commit.as_deref(), Some("abc123"));
3650
3651        store
3652            .set_status(
3653                "2026-01-01-seam.md",
3654                StatusChange::to("todo").with_override(Some("not actually finished")),
3655            )
3656            .unwrap();
3657        let off_done = store
3658            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3659            .unwrap();
3660        assert_eq!(
3661            off_done.commit, None,
3662            "leaving completion clears the recorded commit"
3663        );
3664    }
3665
3666    #[test]
3667    fn t015_a_decision_round_trips_through_a_file_unchanged() {
3668        // T012's round trip, end to end rather than in memory: the rendered line
3669        // must parse back to the same fields, or WP06 cannot read a file's last
3670        // accepted column out of it.
3671        let (_tmp, store, _before) = seam_issue("todo");
3672        store
3673            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3674            .unwrap();
3675        let first = store
3676            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3677            .unwrap();
3678        let text = read_issue_file(&store, "2026-01-01-seam.md");
3679        // Parse the file back from its own bytes — not from the in-memory issue.
3680        let parsed = Issue::parse(&text).unwrap();
3681        let events = parsed.history.events();
3682        let decision = crate::model::TransitionDecision::from_event(events[0]).unwrap();
3683        assert_eq!(decision.to, "doing");
3684        assert_eq!(decision.from_category, "unstarted");
3685        assert_eq!(decision.to_category, "active");
3686        // And re-serialising the parsed file reproduces it byte for byte, so the
3687        // grammar is not lossy in a way only a rewrite would reveal.
3688        assert_eq!(
3689            parsed.to_file_contents(),
3690            text,
3691            "a parsed decision must re-render to the same bytes"
3692        );
3693        assert_eq!(parsed.history.events().len(), first.history.events().len());
3694    }
3695
3696    #[test]
3697    fn t015_two_transitions_append_two_decisions_in_order() {
3698        let (_tmp, store, _before) = seam_issue("triage");
3699        for to in ["todo", "doing"] {
3700            store
3701                .set_status("2026-01-01-seam.md", StatusChange::to(to))
3702                .unwrap();
3703        }
3704        let after = store
3705            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3706            .unwrap();
3707        let events = after.history.events();
3708        assert_eq!(events.len(), 2, "one per accepted transition");
3709        let tos: Vec<String> = events
3710            .iter()
3711            .map(|e| crate::model::TransitionDecision::from_event(e).unwrap().to)
3712            .collect();
3713        assert_eq!(tos, vec!["todo", "doing"], "append order, not sorted");
3714    }
3715
3716    #[test]
3717    fn set_status_changes_status_and_bumps_updated() {
3718        let (tmp, store) = make_store();
3719        let _ = tmp;
3720        write_issue(
3721            &store,
3722            "2026-01-01-s.md",
3723            &sample_frontmatter(1, "S", "todo"),
3724        );
3725        let outcome = store
3726            .set_status("2026-01-01-s.md", StatusChange::to("in progress"))
3727            .unwrap();
3728        let issue = &outcome.issue;
3729        assert_eq!(issue.status.display(), "in progress");
3730        assert_eq!(issue.updated, today());
3731        assert_eq!(issue.title, "S");
3732        assert_eq!(issue.body, "Body text.");
3733    }
3734
3735    #[test]
3736    fn set_status_preserves_file_path() {
3737        let (tmp, store) = make_store();
3738        let _ = tmp;
3739        write_issue(
3740            &store,
3741            "2026-01-01-f.md",
3742            &sample_frontmatter(1, "F", "todo"),
3743        );
3744        let _ = store
3745            .set_status("2026-01-01-f.md", StatusChange::to("done"))
3746            .unwrap();
3747        assert!(store.issues_dir.join("2026-01-01-f.md").exists());
3748    }
3749
3750    // ------------------------------------------------------------------
3751    // WP02 T009 — typed relationship writes delegate to the service
3752    // ------------------------------------------------------------------
3753
3754    /// Read an issue file's raw bytes, for the byte-identical assertions.
3755    fn read_issue_file(store: &Store, filename: &str) -> String {
3756        std::fs::read_to_string(store.issues_dir.join(filename)).unwrap()
3757    }
3758
3759    /// A frontmatter fixture with no relationship data, i.e. what every
3760    /// pre-existing issue file in the repository looks like.
3761    fn bare_frontmatter(number: u32, title: &str) -> String {
3762        format!(
3763            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
3764        )
3765    }
3766
3767    /// A frontmatter fixture carrying the **retired** `links:` key.
3768    fn legacy_links_frontmatter(number: u32, title: &str, links: &str) -> String {
3769        format!(
3770            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\nlinks: {links}\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
3771        )
3772    }
3773
3774    /// Two seeded issues, plus the store, for the relationship tests.
3775    fn related_pair() -> (TempDir, Store, Issue, Issue) {
3776        let (tmp, store) = make_store();
3777        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
3778        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
3779        let a = store.get(IssueSelector::Number(1)).unwrap();
3780        let b = store.get(IssueSelector::Number(2)).unwrap();
3781        (tmp, store, a, b)
3782    }
3783
3784    #[test]
3785    fn t009_link_records_a_single_directed_edge() {
3786        let (_tmp, store, a, b) = related_pair();
3787        let outcome = store
3788            .link(
3789                &a.number.to_string(),
3790                &b.number.to_string(),
3791                RelationKind::Blocks,
3792            )
3793            .unwrap();
3794        assert!(outcome.changed);
3795
3796        // One edge, stored once, on the blocking side. The old symmetric
3797        // back-reference on the other issue is gone (C-001).
3798        let a_after = store.get(IssueSelector::Number(1)).unwrap();
3799        let b_after = store.get(IssueSelector::Number(2)).unwrap();
3800        assert_eq!(a_after.relationships.len(), 1);
3801        assert!(
3802            b_after.relationships.is_empty(),
3803            "no back-reference is written for a directed edge"
3804        );
3805        assert_eq!(a_after.relationships[0].kind, RelationKind::Blocks);
3806        assert_eq!(
3807            (a_after.relationships[0].from, a_after.relationships[0].to),
3808            (1, 2)
3809        );
3810    }
3811
3812    #[test]
3813    fn t009_link_appends_exactly_one_history_event() {
3814        let (_tmp, store, a, b) = related_pair();
3815        store
3816            .link(
3817                &a.number.to_string(),
3818                &b.number.to_string(),
3819                RelationKind::Blocks,
3820            )
3821            .unwrap();
3822        let a_after = store.get(IssueSelector::Number(1)).unwrap();
3823        assert_eq!(a_after.history.len(), 1);
3824        assert_eq!(
3825            a_after.history.events()[0].kind,
3826            HistoryEventKind::RelationshipAdded
3827        );
3828        // Every event records an actor, or the audit trail is not an audit trail.
3829        assert!(!a_after.history.events()[0].actor.is_empty());
3830    }
3831
3832    #[test]
3833    fn t009_idempotent_reapply_writes_nothing_and_appends_no_second_event() {
3834        let (_tmp, store, a, b) = related_pair();
3835        let args = (a.number.to_string(), b.number.to_string());
3836        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
3837        let first = store.get(IssueSelector::Number(1)).unwrap();
3838        let bytes_after_first = read_issue_file(&store, &first.filename);
3839
3840        let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
3841        assert!(!second.changed, "re-applying an existing edge is a no-op");
3842        assert!(second.written.is_none(), "no file is written for a no-op");
3843
3844        let after = store.get(IssueSelector::Number(1)).unwrap();
3845        assert_eq!(after.relationships.len(), 1, "no duplicate edge");
3846        assert_eq!(after.history.len(), 1, "no second History event");
3847        assert_eq!(
3848            read_issue_file(&store, &after.filename),
3849            bytes_after_first,
3850            "the file is byte-identical after the no-op"
3851        );
3852    }
3853
3854    #[test]
3855    fn t009_inverse_spelling_resolves_to_the_same_stored_edge() {
3856        let (_tmp, store, a, b) = related_pair();
3857        let args = (a.number.to_string(), b.number.to_string());
3858        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
3859        // "2 is blocked by 1" is the same edge as "1 blocks 2".
3860        let again = store
3861            .link(&args.1, &args.0, RelationKind::BlockedBy)
3862            .unwrap();
3863        assert!(!again.changed, "the inverse spelling must not add an edge");
3864        let after = store.get(IssueSelector::Number(1)).unwrap();
3865        assert_eq!(after.relationships.len(), 1);
3866        assert_eq!(
3867            after.relationships[0].kind,
3868            RelationKind::Blocks,
3869            "the stored kind is always the canonical one"
3870        );
3871    }
3872
3873    #[test]
3874    fn t009_self_link_is_refused_and_writes_nothing() {
3875        let (_tmp, store, a, _b) = related_pair();
3876        let before = read_issue_file(&store, &a.filename);
3877        let err = store
3878            .link(
3879                &a.number.to_string(),
3880                &a.number.to_string(),
3881                RelationKind::Blocks,
3882            )
3883            .unwrap_err();
3884        assert!(matches!(err, TixError::InvalidInput(_)));
3885        assert!(
3886            err.to_string().contains("self_link"),
3887            "the diagnostic names the rule: {err}"
3888        );
3889        assert_eq!(
3890            read_issue_file(&store, &a.filename),
3891            before,
3892            "SC-005: a rejected mutation leaves the file byte-identical"
3893        );
3894    }
3895
3896    #[test]
3897    fn t009_cycle_closing_edge_is_refused_and_writes_nothing() {
3898        let (tmp, store) = make_store();
3899        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
3900        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
3901        write_issue(&store, "2026-01-01-c.md", &bare_frontmatter(3, "C"));
3902        let _ = tmp;
3903        store.link("1", "2", RelationKind::Blocks).unwrap();
3904        store.link("2", "3", RelationKind::Blocks).unwrap();
3905        let c_before = read_issue_file(&store, "2026-01-01-c.md");
3906
3907        let err = store.link("3", "1", RelationKind::Blocks).unwrap_err();
3908        assert!(err.to_string().contains("cycle"), "{err}");
3909        assert_eq!(
3910            read_issue_file(&store, "2026-01-01-c.md"),
3911            c_before,
3912            "SC-005: both files of a rejected two-issue mutation are unchanged"
3913        );
3914    }
3915
3916    #[test]
3917    fn t009_unlink_removes_the_edge_and_is_idempotent() {
3918        let (_tmp, store, a, b) = related_pair();
3919        let args = (a.number.to_string(), b.number.to_string());
3920        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
3921
3922        let removed = store
3923            .unlink(&args.0, &args.1, RelationKind::Blocks)
3924            .unwrap();
3925        assert!(removed.changed);
3926        let after = store.get(IssueSelector::Number(1)).unwrap();
3927        assert!(after.relationships.is_empty());
3928        assert_eq!(after.history.len(), 2, "added then removed");
3929
3930        // Removing again is a no-op, not a second History entry.
3931        let again = store
3932            .unlink(&args.0, &args.1, RelationKind::Blocks)
3933            .unwrap();
3934        assert!(!again.changed);
3935        let after2 = store.get(IssueSelector::Number(1)).unwrap();
3936        assert_eq!(after2.history.len(), 2, "no third History event");
3937    }
3938
3939    #[test]
3940    fn t009_unlink_of_a_never_linked_pair_is_a_noop() {
3941        let (_tmp, store, a, b) = related_pair();
3942        let outcome = store
3943            .unlink(
3944                &a.number.to_string(),
3945                &b.number.to_string(),
3946                RelationKind::Blocks,
3947            )
3948            .unwrap();
3949        assert!(!outcome.changed);
3950        assert!(outcome.written.is_none());
3951        let after = store.get(IssueSelector::Number(1)).unwrap();
3952        assert!(after.relationships.is_empty());
3953        assert!(after.history.is_empty(), "a no-op appends nothing");
3954    }
3955
3956    #[test]
3957    fn t009_set_relationships_replaces_the_set_and_keeps_surviving_ids() {
3958        let (_tmp, store, _a, _b) = related_pair();
3959        store.link("1", "2", RelationKind::Blocks).unwrap();
3960        let before = store.get(IssueSelector::Number(1)).unwrap();
3961        let survivor_id = before.relationships[0].id.clone();
3962
3963        // Replace the set with a different target; the old edge goes, recorded.
3964        let updated = store
3965            .set_relationships(&before.filename, &[(2, RelationKind::Blocks)])
3966            .unwrap();
3967        assert_eq!(updated.relationships.len(), 1);
3968        assert_eq!(
3969            updated.relationships[0].id, survivor_id,
3970            "a surviving relationship keeps its stable id"
3971        );
3972    }
3973
3974    #[test]
3975    fn t009_set_relationships_rejects_unknown_target_and_self() {
3976        let (_tmp, store, a, _b) = related_pair();
3977        assert!(
3978            store
3979                .set_relationships(&a.filename, &[(404, RelationKind::Blocks)])
3980                .is_err(),
3981            "an unknown target is refused"
3982        );
3983        assert!(
3984            store
3985                .set_relationships(&a.filename, &[(1, RelationKind::Blocks)])
3986                .is_err(),
3987            "a self-link is refused"
3988        );
3989    }
3990
3991    // ------------------------------------------------------------------
3992    // WP02 review round 1 — F1, F4, F5, F13: canonical storage lookups
3993    // ------------------------------------------------------------------
3994
3995    /// Seed an issue, optionally with raw extra frontmatter.
3996    fn seeded(store: &Store, number: u32, title: &str, extra: &str) {
3997        let slug = title.to_lowercase();
3998        write_issue(
3999            store,
4000            &format!("2026-01-01-{slug}.md"),
4001            &format!(
4002                "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\n{extra}files: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
4003            ),
4004        );
4005    }
4006
4007    #[test]
4008    fn f1_set_relationships_refuses_a_reciprocal_pair_the_service_rejects() {
4009        // F1. `set_relationships` used to re-implement existence and the
4010        // self-link rule locally and skip the rest, so with `1 blocks 2` on disk
4011        // it stored `2 blocks 1` — a reciprocal pair that `link` rejects one
4012        // line earlier and that contract §3 rules 3 and 4 require be refused.
4013        // Latent only because `--links` hard-codes `related`; user-reachable the
4014        // moment WP03 wires `--type`.
4015        let (_tmp, store) = make_store();
4016        seeded(&store, 1, "A", "");
4017        seeded(&store, 2, "B", "");
4018        store.link("1", "2", RelationKind::Blocks).unwrap();
4019
4020        let err = store
4021            .set_relationships("2026-01-01-b.md", &[(1, RelationKind::Blocks)])
4022            .unwrap_err();
4023        assert!(
4024            err.to_string().contains("cycle"),
4025            "the service's verdict is what refuses it: {err}"
4026        );
4027        let two = store.get(IssueSelector::Number(2)).unwrap();
4028        assert!(
4029            two.relationships.is_empty(),
4030            "SC-005: a refused mutation writes nothing"
4031        );
4032    }
4033
4034    #[test]
4035    fn f4_a_hand_ordered_related_row_is_removable() {
4036        // F4. A hand-authored `related` row in the non-canonical endpoint order.
4037        // Removals used to be keyed by where the row *should* live while rows
4038        // were found where they *were* stored, so this row matched no file and
4039        // no code path could delete it.
4040        let (_tmp, store) = make_store();
4041        seeded(&store, 2, "B", "");
4042        seeded(
4043            &store,
4044            5,
4045            "E",
4046            "relationships:\n- id: rel_handauthored\n  from: 5\n  to: 2\n  kind: related\n  display: \"related #2 B\"\n",
4047        );
4048        let out = store.unlink("5", "2", RelationKind::Related).unwrap();
4049        assert!(out.changed, "the row is found and removed");
4050        assert!(
4051            store
4052                .get(IssueSelector::Number(5))
4053                .unwrap()
4054                .relationships
4055                .is_empty()
4056        );
4057    }
4058
4059    #[test]
4060    fn f5_a_persisted_blocked_by_row_is_removable() {
4061        // F5. `blocked_by` is the input-only spelling and must never be
4062        // persisted, but the format is human-editable so a hand edit can put it
4063        // there. `unlink` compared authored fields while `link` canonicalized,
4064        // so the two disagreed and the row was un-removable — leaving a
4065        // permanently invalid row plus a `tix lint` cycle finding with no way to
4066        // clear it.
4067        let (_tmp, store) = make_store();
4068        seeded(&store, 1, "A", "");
4069        seeded(
4070            &store,
4071            2,
4072            "B",
4073            "relationships:\n- id: rel_bb\n  from: 2\n  to: 1\n  kind: blocked_by\n  display: \"blocks #1 A\"\n",
4074        );
4075        let out = store.unlink("2", "1", RelationKind::BlockedBy).unwrap();
4076        assert!(out.changed, "the row is found and removed");
4077        assert!(
4078            store
4079                .get(IssueSelector::Number(2))
4080                .unwrap()
4081                .relationships
4082                .is_empty()
4083        );
4084    }
4085
4086    #[test]
4087    fn f13_a_dangling_edge_is_removable() {
4088        // F13. A dangling edge is a diagnosed, expected condition, so the one
4089        // operation that can clean it up must still work. `unlink` resolved
4090        // both endpoints first, so deleting the target made the edge
4091        // permanently unfixable through the tool.
4092        let (_tmp, store) = make_store();
4093        seeded(&store, 1, "A", "");
4094        seeded(&store, 2, "B", "");
4095        store.link("1", "2", RelationKind::Blocks).unwrap();
4096        std::fs::remove_file(store.issues_dir.join("2026-01-01-b.md")).unwrap();
4097
4098        let out = store.unlink("1", "2", RelationKind::Blocks).unwrap();
4099        assert!(out.changed, "a dangling edge is still removable");
4100        let one = store.get(IssueSelector::Number(1)).unwrap();
4101        assert!(one.relationships.is_empty());
4102        assert_eq!(one.history.len(), 2, "and the removal is recorded");
4103    }
4104
4105    #[test]
4106    fn m10_one_unlink_of_a_duplicated_edge_appends_one_event() {
4107        // M10. The same canonical `related` edge hand-authored into **both**
4108        // endpoints — the redundancy the data model says is kept so no stored id
4109        // silently stops resolving. One `unlink` is one accepted mutation, so it
4110        // appends one History event, not one per file written. The previous
4111        // implementation computed the summary per file, and the comment directly
4112        // above it claimed the opposite.
4113        let (_tmp, store) = make_store();
4114        // A redundant pair is one row in **each** endpoint's file — the two
4115        // spellings of a single association. My first version of this fixture
4116        // put both rows in one file, which tested a different thing and then
4117        // asserted an event count for a file that was never written.
4118        seeded(
4119            &store,
4120            1,
4121            "A",
4122            "relationships:\n- id: rel_dup_12\n  from: 1\n  to: 2\n  kind: related\n  display: \"related #2 B\"\n",
4123        );
4124        seeded(
4125            &store,
4126            2,
4127            "B",
4128            "relationships:\n- id: rel_dup_21\n  from: 2\n  to: 1\n  kind: related\n  display: \"related #1 A\"\n",
4129        );
4130
4131        let outcome = store.unlink("1", "2", RelationKind::Related).unwrap();
4132        assert!(outcome.changed, "both redundant rows are removed");
4133        let mut summaries = Vec::new();
4134        for number in [1u32, 2] {
4135            let issue = store.get(IssueSelector::Number(number)).unwrap();
4136            assert!(
4137                issue.relationships.is_empty(),
4138                "issue #{number} keeps no edge"
4139            );
4140            assert_eq!(
4141                issue.history.len(),
4142                1,
4143                "issue #{number} records exactly one event, got {:?}",
4144                issue.history.events()
4145            );
4146            let event = &issue.history.events()[0];
4147            assert!(
4148                event.detail.contains("removed 2 relationships"),
4149                "and the event says both spellings went: {}",
4150                event.detail
4151            );
4152            summaries.push(event.detail.clone());
4153        }
4154        // The point of the fix: one mutation, one wording. The previous version
4155        // computed the summary per file, so each file described only its own row
4156        // and the two events disagreed about what had happened.
4157        assert_eq!(
4158            summaries[0], summaries[1],
4159            "both files record the same single event, not two differing ones"
4160        );
4161    }
4162
4163    #[test]
4164    fn m11_unlink_reports_the_row_it_removed_not_an_empty_placeholder() {
4165        // M11. The post-removal re-scan could never match — every matching row is
4166        // gone by then — so a successful unlink always reported a relationship
4167        // with an empty id, which is not addressable by
4168        // `edit_relation`/`remove_relation`. `link`'s no-op path returned a real
4169        // row; this brings the two into line.
4170        let (_tmp, store, a, b) = related_pair();
4171        let args = (a.number.to_string(), b.number.to_string());
4172        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4173        let before = store.get(IssueSelector::Number(1)).unwrap();
4174        let real_id = before.relationships[0].id.clone();
4175
4176        let outcome = store
4177            .unlink(&args.0, &args.1, RelationKind::Blocks)
4178            .unwrap();
4179        assert!(outcome.changed);
4180        assert_eq!(
4181            outcome.relationship.id, real_id,
4182            "the reported row is the one that was actually removed"
4183        );
4184        assert!(!outcome.relationship.id.is_empty(), "so it is addressable");
4185        assert_eq!(
4186            outcome.relationship.display,
4187            before.relationships[0].display
4188        );
4189    }
4190
4191    #[test]
4192    fn m15_the_history_detail_names_the_relationship_id() {
4193        // M15. `display` already begins with the kind, so naming the kind again
4194        // produced `created blocks: blocks #2 B`. The id is the stable handle and
4195        // is what data-model §8's example shows.
4196        let (_tmp, store, a, b) = related_pair();
4197        let args = (a.number.to_string(), b.number.to_string());
4198        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4199        let one = store.get(IssueSelector::Number(1)).unwrap();
4200        let id = one.relationships[0].id.clone();
4201
4202        let detail = &one.history.events()[0].detail;
4203        assert!(
4204            detail.starts_with(&format!("created {id}:")),
4205            "the detail names the stable id: {detail}"
4206        );
4207        assert!(
4208            !detail.contains(&format!("created {}:", RelationKind::Blocks)),
4209            "and does not stutter the kind, which `display` already carries: {detail}"
4210        );
4211    }
4212
4213    #[test]
4214    fn f17_all_diagnostics_are_reported_not_just_the_first() {
4215        // F17 / M4. The previous version of this test built exactly **one**
4216        // diagnostic and asserted only that the kind prefix was not doubled —
4217        // so it never exercised the multi-diagnostic path the fix exists for.
4218        // Replacing the join with `errors.iter().take(1)` left it green.
4219        //
4220        // `1 blocks 2` plus `2 duplicates 1` makes the same pair carry two
4221        // distinct rule violations at once.
4222        // The corrupt state is hand-authored, because the write path refuses to
4223        // create it — which is the point. A file written before these rules
4224        // existed, or edited by hand, can hold several violations at once, and
4225        // `validate_snapshot` is the surface that must report all of them.
4226        let (_tmp, store) = make_store();
4227        seeded(
4228            &store,
4229            1,
4230            "A",
4231            "relationships:\n- id: rel_b12\n  from: 1\n  to: 2\n  kind: blocks\n  display: blocks\n- id: rel_d12\n  from: 1\n  to: 2\n  kind: duplicates\n  display: dup\n",
4232        );
4233        seeded(
4234            &store,
4235            2,
4236            "B",
4237            "relationships:\n- id: rel_b21\n  from: 2\n  to: 1\n  kind: blocks\n  display: blocks\n",
4238        );
4239
4240        let issues = store.list().unwrap();
4241        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
4242        let verdict = RelationshipGraph::new(&views, "done").validate_snapshot();
4243        assert!(
4244            verdict.errors.len() >= 2,
4245            "the fixture must really produce several diagnostics, got {:?}",
4246            verdict.errors
4247        );
4248
4249        let rendered = relationship_error(&verdict.errors).to_string();
4250        for kind in ["conflicting_edge", "cycle"] {
4251            assert!(
4252                rendered.contains(kind),
4253                "every diagnostic must be reported; {kind} missing from: {rendered}"
4254            );
4255        }
4256        assert!(
4257            !rendered.contains("conflicting_edge: conflicting_edge")
4258                && !rendered.contains("cycle: cycle:"),
4259            "no doubled kind prefix: {rendered}"
4260        );
4261        // M5, tested at the seam. Whether the *service* emits a duplicate
4262        // self-link diagnostic depends on which rules fire, and that has not
4263        // been reproducible from either the library or the binary — so this
4264        // asserts the property where it is decided: `relationship_error` must
4265        // never render the same (kind, message) twice, whatever it is handed.
4266        let dup = vec![
4267            Diagnostic::new(
4268                DiagnosticKind::SelfLink,
4269                "to",
4270                "#1 cannot block itself: a self-link is not a valid relationship",
4271            ),
4272            Diagnostic::new(
4273                DiagnosticKind::SelfLink,
4274                "to",
4275                "#1 cannot block itself: a self-link is not a valid relationship",
4276            ),
4277            Diagnostic::new(DiagnosticKind::Cycle, "to", "cycle: 1 -> 1 -> #1"),
4278        ];
4279        let rendered = relationship_error(&dup).to_string();
4280        assert_eq!(
4281            rendered.matches("cannot block itself").count(),
4282            1,
4283            "an identical diagnostic renders once, not twice: {rendered}"
4284        );
4285        assert!(
4286            rendered.contains("cycle"),
4287            "a distinct diagnostic is still reported: {rendered}"
4288        );
4289        assert!(
4290            !rendered.contains("self_link: self_link") && !rendered.contains("cycle: cycle:"),
4291            "and no kind is prefixed twice: {rendered}"
4292        );
4293    }
4294
4295    #[test]
4296    fn t009_clearing_a_set_removes_a_related_edge_owned_by_another_file() {
4297        // Regression, found in final review. A `related` edge is stored once, in
4298        // the lower-numbered issue's frontmatter, so #5's own file is empty even
4299        // though #5 *is* related to #3. Clearing #5's set must still remove the
4300        // association, or `tix edit 5 --links ""` silently does nothing.
4301        let (_tmp, store) = make_store();
4302        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
4303        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
4304
4305        store.link("5", "3", RelationKind::Related).unwrap();
4306        let three = store.get(IssueSelector::Number(3)).unwrap();
4307        let five = store.get(IssueSelector::Number(5)).unwrap();
4308        assert_eq!(
4309            three.relationships.len(),
4310            1,
4311            "the edge is stored in the lower-numbered issue's file"
4312        );
4313        assert!(
4314            five.relationships.is_empty(),
4315            "and #5's own file holds none"
4316        );
4317
4318        store.set_relationships(&five.filename, &[]).unwrap();
4319
4320        let three_after = store.get(IssueSelector::Number(3)).unwrap();
4321        let five_after = store.get(IssueSelector::Number(5)).unwrap();
4322        assert!(
4323            three_after.relationships.is_empty(),
4324            "the association is gone from the file that actually stored it"
4325        );
4326        assert!(five_after.relationships.is_empty());
4327    }
4328
4329    #[test]
4330    fn t009_clearing_a_set_does_not_remove_an_inbound_directional_edge() {
4331        // The other half of the ownership rule. If #3 blocks #5, that edge is
4332        // #3's declaration; #5 clearing its own relationship set must not
4333        // rewrite #3's dependency graph. Only the non-directional case is
4334        // shared, because only there does the association belong to both ends.
4335        let (_tmp, store) = make_store();
4336        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
4337        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
4338
4339        store.link("3", "5", RelationKind::Blocks).unwrap();
4340        let five = store.get(IssueSelector::Number(5)).unwrap();
4341
4342        store.set_relationships(&five.filename, &[]).unwrap();
4343
4344        let three_after = store.get(IssueSelector::Number(3)).unwrap();
4345        assert!(
4346            three_after
4347                .relationships
4348                .iter()
4349                .any(|r| r.to == 5 && r.kind == RelationKind::Blocks),
4350            "#3's declaration that it blocks #5 survives: a blocked issue \
4351             cannot unilaterally clear the blocker's graph"
4352        );
4353    }
4354
4355    /// The path of a committed relationship fixture.
4356    fn relationship_fixture(name: &str) -> String {
4357        format!(
4358            "{}/tests/fixtures/relationships/{name}",
4359            env!("CARGO_MANIFEST_DIR")
4360        )
4361    }
4362
4363    #[test]
4364    fn t010_typed_fixture_round_trips_byte_stable_including_history() {
4365        // NFR-003, model-level half: a typed fixture survives read -> write ->
4366        // read unchanged, `## History` region included.
4367        let original =
4368            std::fs::read_to_string(relationship_fixture("typed-relationships.md")).unwrap();
4369        let issue = Issue::parse(&original).unwrap();
4370        assert_eq!(issue.relationships.len(), 2);
4371        assert_eq!(issue.history.len(), 2);
4372        let rendered = issue.to_file_contents();
4373        assert_eq!(
4374            rendered, original,
4375            "a typed fixture must not churn on a read/write round trip"
4376        );
4377        assert_eq!(
4378            Issue::parse(&rendered).unwrap().to_file_contents(),
4379            rendered
4380        );
4381        // The stable ids and the section position both survive.
4382        assert_eq!(issue.relationships[0].id, "rel_01JQ8Z4W2M6N7P9R3S5T7V9X1");
4383        assert!(
4384            rendered.find("## History").unwrap() < rendered.find("Prose that follows").unwrap(),
4385            "History sits between the frontmatter and the body"
4386        );
4387    }
4388
4389    #[test]
4390    fn t010_issue_with_no_relationship_data_is_untouched() {
4391        let original =
4392            std::fs::read_to_string(relationship_fixture("no-relationship-data.md")).unwrap();
4393        let issue = Issue::parse(&original).unwrap();
4394        assert!(issue.relationships.is_empty());
4395        assert!(issue.history.is_empty());
4396        assert!(!issue.has_retired_links_key);
4397        let rendered = issue.to_file_contents();
4398        assert_eq!(
4399            rendered, original,
4400            "an issue with no relationship data must not churn"
4401        );
4402        assert!(!rendered.contains("relationships:"));
4403        assert!(!rendered.contains("## History"));
4404    }
4405
4406    #[test]
4407    fn t010_an_empty_history_section_is_dropped_not_preserved() {
4408        // A hand-left `## History` heading with no entries parses to an empty
4409        // log. The section is derived from the log, so an empty one has no
4410        // representation on disk and is omitted on write — diagnosed, never
4411        // silently filled in, and never kept as a bare heading.
4412        let original = std::fs::read_to_string(relationship_fixture("history-empty.md")).unwrap();
4413        assert!(
4414            original.contains("## History"),
4415            "the fixture must actually carry the empty heading"
4416        );
4417        let issue = Issue::parse(&original).unwrap();
4418        assert!(
4419            issue.history.is_empty(),
4420            "a heading with no entries parses to an empty log"
4421        );
4422        assert!(
4423            issue
4424                .body
4425                .contains("An issue whose History section is present but empty."),
4426            "the prose after the heading is body, not a dropped event: {:?}",
4427            issue.body
4428        );
4429        let rendered = issue.to_file_contents();
4430        assert!(
4431            !rendered.contains("## History"),
4432            "the empty section is omitted rather than kept as a bare heading"
4433        );
4434        assert!(rendered.contains("An issue whose History section is present but empty."));
4435    }
4436
4437    #[test]
4438    fn t010_issue_with_no_history_section_is_untouched() {
4439        let original = std::fs::read_to_string(relationship_fixture("history-absent.md")).unwrap();
4440        let issue = Issue::parse(&original).unwrap();
4441        assert!(issue.history.is_empty());
4442        let rendered = issue.to_file_contents();
4443        assert_eq!(
4444            rendered, original,
4445            "a file written before History existed must not gain an empty section"
4446        );
4447    }
4448
4449    #[test]
4450    fn f2_one_bad_history_line_does_not_make_the_store_unreadable() {
4451        // Store-level consequence of the model-level guarantee. Before the parse
4452        // became non-fatal, `Store::list` propagated the error, so a single
4453        // mistyped line in one issue broke `list`, `get`, `view`, `board`,
4454        // `status`, `lint`, `export`, `link`, `unlink` and `set_relationships`
4455        // for *every* issue — including `get` on a completely unrelated,
4456        // perfectly valid one. There is also no recovery through the tool,
4457        // because every command that could rewrite the file was among the
4458        // failures.
4459        let (_tmp, store) = make_store();
4460        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4461        write_issue(
4462            &store,
4463            "2026-01-01-c.md",
4464            "---\nnumber: 3\ntitle: \"C\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\n## History\n\n- 2026-01-01T00:00:00Z - alice - relationship_added - ascii hyphens\n",
4465        );
4466
4467        let all = store.list().expect("the store must remain listable");
4468        assert_eq!(all.len(), 2, "both issues load");
4469        assert_eq!(
4470            store
4471                .get(IssueSelector::Number(1))
4472                .expect("an unrelated, valid issue must stay readable")
4473                .title,
4474            "A"
4475        );
4476        // And the damaged line is still reachable for diagnosis, which is the
4477        // whole point of the non-fatal parse. An ASCII-hyphen line is not
4478        // *recognised*, so no event is fabricated from it, but it is retained in
4479        // the section verbatim and reported rather than demoted into the body
4480        // (see the F1 trade in `parse_front`).
4481        let three = store.get(IssueSelector::Number(3)).unwrap();
4482        assert!(three.history.events().is_empty(), "no event is fabricated");
4483        assert_eq!(
4484            three.history.unparsed().len(),
4485            1,
4486            "the line is preserved for diagnosis: {:?}",
4487            three.history.unparsed()
4488        );
4489        assert!(
4490            !three.body.contains("ascii hyphens"),
4491            "and is not demoted into the body: {:?}",
4492            three.body
4493        );
4494    }
4495
4496    /// Seed an issue into its own assignee segment, so a write can be failed
4497    /// for one owner and not another.
4498    #[cfg(unix)]
4499    fn seeded_in_segment(store: &Store, number: u32, title: &str, segment: &str) {
4500        let dir = store.issues_dir.join(segment);
4501        std::fs::create_dir_all(&dir).unwrap();
4502        std::fs::write(
4503            dir.join(format!("2026-01-01-{}.md", title.to_lowercase())),
4504            format!(
4505                "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
4506            ),
4507        )
4508        .unwrap();
4509    }
4510
4511    #[cfg(unix)]
4512    #[test]
4513    fn f6_a_mid_sequence_write_failure_rolls_every_file_back() {
4514        use std::os::unix::fs::PermissionsExt;
4515        // F6 / anti-pattern 7. This can write N files, because an edge is stored
4516        // in its canonical source's file, so a failure partway through used to
4517        // leave the store half-mutated while the command reported failure.
4518        //
4519        // `atomic_write` renames a temp file, which needs write permission on
4520        // the *directory*, so the injection makes one owner's segment
4521        // read-only. The owners are 1, 2 and 3 and are written in ascending
4522        // order, so #1 and #2 are committed before #3 fails.
4523        let (tmp, store) = make_store();
4524        let _ = tmp;
4525        seeded_in_segment(&store, 1, "A", "alice");
4526        seeded_in_segment(&store, 2, "B", "bob");
4527        seeded_in_segment(&store, 3, "C", "carol");
4528        seeded_in_segment(&store, 5, "E", "erin");
4529
4530        let paths: Vec<std::path::PathBuf> = [
4531            "alice/2026-01-01-a.md",
4532            "bob/2026-01-01-b.md",
4533            "carol/2026-01-01-c.md",
4534        ]
4535        .iter()
4536        .map(|p| store.issues_dir.join(p))
4537        .collect();
4538        let before: Vec<String> = paths
4539            .iter()
4540            .map(|p| std::fs::read_to_string(p).unwrap())
4541            .collect();
4542
4543        // Order-normalization puts target 5's edge on the lower number, so the
4544        // owners really are 1, 2 and 3 and #3 is written last.
4545        let carol = store.issues_dir.join("carol");
4546        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4547        perms.set_mode(0o555);
4548        std::fs::set_permissions(&carol, perms).unwrap();
4549
4550        let result = store.set_relationships(
4551            "carol/2026-01-01-c.md",
4552            &[
4553                (1, RelationKind::Related),
4554                (2, RelationKind::Related),
4555                (5, RelationKind::Related),
4556            ],
4557        );
4558
4559        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4560        perms.set_mode(0o755);
4561        std::fs::set_permissions(&carol, perms).unwrap();
4562
4563        assert!(result.is_err(), "the blocked write must fail");
4564        for (path, previous) in paths.iter().zip(before.iter()) {
4565            assert_eq!(
4566                std::fs::read_to_string(path).unwrap(),
4567                *previous,
4568                "{} was left mutated by a failed mutation",
4569                path.display()
4570            );
4571        }
4572    }
4573
4574    #[cfg(unix)]
4575    #[test]
4576    fn f6b_the_updated_bump_is_itself_rollback_covered() {
4577        // The `updated` bump on the named issue (F16) is a real write, and it
4578        // used to sit *after* the rollback check — so a failure there returned an
4579        // error with the relationship writes already committed and nothing left
4580        // to restore. That is the same class of defect F6 was about, reintroduced
4581        // by the F16 fix.
4582        //
4583        // Order matters here: the relationship write lands in the *other*
4584        // endpoint's segment, and the bump targets the named issue's own
4585        // segment, so blocking the named segment fails the second write.
4586        use std::os::unix::fs::PermissionsExt;
4587
4588        let (tmp, store) = make_store();
4589        let _ = tmp;
4590        seeded_in_segment(&store, 1, "A", "alice");
4591        seeded_in_segment(&store, 3, "C", "carol");
4592
4593        let alice = store.issues_dir.join("alice/2026-01-01-a.md");
4594        let before = std::fs::read_to_string(&alice).unwrap();
4595
4596        let carol = store.issues_dir.join("carol");
4597        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4598        perms.set_mode(0o555);
4599        std::fs::set_permissions(&carol, perms).unwrap();
4600
4601        let result =
4602            store.set_relationships("carol/2026-01-01-c.md", &[(1, RelationKind::Related)]);
4603
4604        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4605        perms.set_mode(0o755);
4606        std::fs::set_permissions(&carol, perms).unwrap();
4607
4608        assert!(result.is_err(), "the blocked bump must fail the call");
4609        assert_eq!(
4610            std::fs::read_to_string(&alice).unwrap(),
4611            before,
4612            "the relationship write that succeeded earlier must be rolled back \
4613             when the subsequent `updated` bump fails"
4614        );
4615    }
4616
4617    #[test]
4618    fn f9_relationships_for_reports_both_endpoints_of_a_one_sided_edge() {
4619        // F9. An edge is stored once, in its canonical source's file, so the
4620        // issue's own `relationships` field is a *partial* view. Reading it made
4621        // a related issue look unrelated — a user-visible regression against the
4622        // retired symmetric model, which mirrored the link on both sides by
4623        // construction.
4624        let (_tmp, store) = make_store();
4625        seeded(&store, 1, "A", "");
4626        seeded(&store, 2, "B", "");
4627        store.link("2", "1", RelationKind::Related).unwrap();
4628
4629        // The edge is stored once, in the lower-numbered issue's file.
4630        let one = store.get(IssueSelector::Number(1)).unwrap();
4631        let two = store.get(IssueSelector::Number(2)).unwrap();
4632        assert_eq!(one.relationships.len(), 1, "precondition: #1 stores it");
4633        assert!(
4634            two.relationships.is_empty(),
4635            "precondition: #2's own field is the partial view that caused F9"
4636        );
4637
4638        // The service-backed accessor is what a read surface must use, and it
4639        // answers for both endpoints.
4640        for number in [1u32, 2] {
4641            let resolved = store.relationships_for(number).unwrap();
4642            assert_eq!(
4643                resolved.len(),
4644                1,
4645                "issue #{number} must see the relationship that touches it"
4646            );
4647            assert_eq!(resolved[0].kind, RelationKind::Related);
4648        }
4649    }
4650
4651    #[test]
4652    fn f7_writing_an_issue_with_the_retired_key_discloses_that_it_is_removed() {
4653        // F7 / M6. The previous version of this test asserted only the
4654        // *consequence* — the key is gone from the file — which was already true
4655        // before the fix, so removing the disclosure entirely left it green.
4656        // It never checked the thing its name promised.
4657        let (tmp, store) = make_store();
4658        let _ = tmp;
4659        let _ = take_disclosures();
4660        // **Populated**, not `[]`. The disclosure fires because the key's value is
4661        // no longer readable, and an empty value has nothing to lose — reporting
4662        // the key's mere presence made `tix lint` reject every issue file this
4663        // repository has ever written, since they all carry a `links: []`.
4664        write_issue(
4665            &store,
4666            "2026-01-01-legacy.md",
4667            &legacy_links_frontmatter(1, "Legacy", "['7']"),
4668        );
4669        let issue = store.get(IssueSelector::Number(1)).unwrap();
4670        assert!(
4671            issue.has_retired_links_key,
4672            "precondition: the key is present"
4673        );
4674
4675        store.update(&issue.filename, &issue).unwrap();
4676
4677        // The disclosure, which is the whole point of the fix.
4678        let disclosures = take_disclosures();
4679        assert_eq!(
4680            disclosures.len(),
4681            1,
4682            "the write must disclose that it dropped the key: {disclosures:?}"
4683        );
4684        let line = &disclosures[0];
4685        assert!(
4686            line.contains("`links:`"),
4687            "it names what was removed: {line}"
4688        );
4689        // The action leads, so it is the part that survives truncation in the
4690        // TUI's one-row status area. Cycle 6 measured the previous wording being
4691        // cut off at 80, 100 and 120 columns.
4692        assert!(
4693            line.starts_with("remove `links:` by hand"),
4694            "and leads with what to do about it: {line}"
4695        );
4696        assert!(
4697            line.contains(&issue.filename),
4698            "and which file it was removed from: {line}"
4699        );
4700        assert!(
4701            line.contains("C-001"),
4702            "and that no migration was applied, so the user is not left \
4703             wondering where their data went: {line}"
4704        );
4705
4706        // The consequence the disclosure is about.
4707        let after = read_issue_file(&store, &issue.filename);
4708        assert!(
4709            !after.contains("links:"),
4710            "the key is not re-emitted (C-001: no migration)"
4711        );
4712        // And a re-read no longer reports it, which is precisely why the
4713        // disclosure matters: the diagnostic is self-erasing without it.
4714        let reread = store.get(IssueSelector::Number(1)).unwrap();
4715        assert!(!reread.has_retired_links_key);
4716        assert!(
4717            take_disclosures().is_empty(),
4718            "and it is disclosed once, not on every read"
4719        );
4720    }
4721
4722    #[test]
4723    fn f7b_writing_an_issue_without_the_retired_key_is_silent() {
4724        // The disclosure is scoped to the condition that triggers it, so the
4725        // ordinary path stays quiet and this queue cannot become noise.
4726        let (tmp, store) = make_store();
4727        let _ = tmp;
4728        let _ = take_disclosures();
4729        write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
4730        let issue = store.get(IssueSelector::Number(1)).unwrap();
4731        assert!(!issue.has_retired_links_key);
4732        store.update(&issue.filename, &issue).unwrap();
4733        assert!(
4734            take_disclosures().is_empty(),
4735            "an ordinary write discloses nothing"
4736        );
4737    }
4738
4739    #[test]
4740    fn t010_legacy_fixture_reports_the_retired_key_and_yields_no_edges() {
4741        // C-001, the compatibility/error matrix in one place: tolerant read,
4742        // explicit diagnostic, and zero reinterpretation.
4743        let original =
4744            std::fs::read_to_string(relationship_fixture("legacy-links-key.md")).unwrap();
4745        let issue = Issue::parse(&original).unwrap();
4746        assert!(issue.has_retired_links_key, "the retired key is reported");
4747        assert!(
4748            issue.relationships.is_empty(),
4749            "C-001: legacy values are never reinterpreted as typed edges"
4750        );
4751        assert!(
4752            issue.history.is_empty(),
4753            "a legacy file gains no History section from parsing"
4754        );
4755        // The diagnostic names the field and is located in the file, per the
4756        // validation contract. Exercised through the real `tix lint` path so
4757        // the test covers the code that actually ships.
4758        let (_tmp, store) = make_store();
4759        write_issue(&store, "2026-01-01-legacy.md", &original);
4760        let findings = crate::commands::lint::collect(&store.config, &store).unwrap();
4761        let retired: Vec<_> = findings
4762            .iter()
4763            .filter(|f| f.kind == "retired_links_key")
4764            .collect();
4765        assert_eq!(
4766            retired.len(),
4767            1,
4768            "one finding for the retired key: {findings:?}"
4769        );
4770        assert_eq!(retired[0].value, "links", "the finding names the field");
4771        assert!(
4772            retired[0].message.contains("retired"),
4773            "the message says why: {}",
4774            retired[0].message
4775        );
4776        assert!(
4777            retired[0].line > 0,
4778            "the finding points into the frontmatter"
4779        );
4780        // No code path converts the legacy value into a typed edge.
4781        let rewritten = issue.to_file_contents();
4782        assert!(!rewritten.contains("kind: related"));
4783        assert!(!rewritten.contains("kind: blocks"));
4784        assert!(!rewritten.contains("links:"));
4785    }
4786
4787    #[test]
4788    fn c001_a_legacy_links_key_is_reported_and_yields_no_typed_edges() {
4789        let (tmp, store) = make_store();
4790        write_issue(
4791            &store,
4792            "2026-01-01-legacy.md",
4793            &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
4794        );
4795        let _ = tmp;
4796        let issue = store.get(IssueSelector::Number(1)).unwrap();
4797        assert!(
4798            issue.has_retired_links_key,
4799            "the retired key is recorded as a validation result"
4800        );
4801        assert!(
4802            issue.relationships.is_empty(),
4803            "C-001: legacy values are never read as typed edges"
4804        );
4805    }
4806
4807    #[test]
4808    fn c001_there_is_no_migration_path_from_the_retired_key() {
4809        // This test used to say "writing the issue back is the only way a
4810        // migration could occur" and then never write it back, so its
4811        // load-bearing assertion was on an in-memory flag after a pure string
4812        // render — which cannot change it. The actual write-back is where the
4813        // key disappears, so the test as written masked the very thing it
4814        // appeared to guard.
4815        //
4816        // It now performs the write and asserts on the resulting bytes.
4817        let (tmp, store) = make_store();
4818        let _ = tmp;
4819        write_issue(
4820            &store,
4821            "2026-01-01-legacy.md",
4822            &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
4823        );
4824        let issue = store.get(IssueSelector::Number(1)).unwrap();
4825        assert!(
4826            issue.has_retired_links_key,
4827            "precondition: the key is present"
4828        );
4829        assert!(
4830            issue.relationships.is_empty(),
4831            "C-001: legacy values are never read as typed edges"
4832        );
4833
4834        store.update(&issue.filename, &issue).unwrap();
4835        let written = read_issue_file(&store, &issue.filename);
4836
4837        // No migration: not one legacy value became a typed edge, under any kind.
4838        //
4839        // The re-read matters, and this test has already fallen into the trap
4840        // once: asserting on `issue.to_file_contents()` instead of the written
4841        // file cannot fail, because `Issue::parse` never populates
4842        // `relationships`, so the in-memory render is clean whatever the *writer*
4843        // does. Reading the file back off disk is the only form of this check
4844        // that the write-back actually has to satisfy.
4845        let reparsed = store.get(IssueSelector::Number(1)).unwrap();
4846        assert!(
4847            reparsed.relationships.is_empty(),
4848            "the file on disk parses to zero typed edges, not just the in-memory copy"
4849        );
4850        for kind in ["blocks", "blocked_by", "duplicates", "related"] {
4851            assert!(
4852                !written.contains(&format!("kind: {kind}")),
4853                "a legacy link became a `{kind}` edge:\n{written}"
4854            );
4855        }
4856        assert!(
4857            !written.contains("kind:"),
4858            "no typed edge was written at all"
4859        );
4860        // The retired key is not re-emitted, and the write did not manufacture
4861        // a History entry for something the user did not do.
4862        assert!(!written.contains("links:"), "the key is not re-emitted");
4863        let reread = store.get(IssueSelector::Number(1)).unwrap();
4864        assert!(
4865            reread.history.is_empty(),
4866            "a format change is not a relationship mutation, so it records no event"
4867        );
4868    }
4869
4870    #[test]
4871    fn f15_the_idempotent_no_op_reports_the_real_addressable_row() {
4872        // F15. The no-op path returned a synthetic `Relationship` with an empty
4873        // id and display. The contract defines the output as the accepted
4874        // relationship, and an empty id is not addressable by
4875        // `edit_relation`/`remove_relation`, so a caller that persisted it would
4876        // store a dangling reference.
4877        let (_tmp, store, a, b) = related_pair();
4878        let args = (a.number.to_string(), b.number.to_string());
4879        let first = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4880        let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4881
4882        assert!(first.changed);
4883        assert!(!second.changed, "the second apply is a no-op");
4884        assert_eq!(
4885            second.relationship.id, first.relationship.id,
4886            "the no-op reports the real, addressable row"
4887        );
4888        assert!(
4889            !second.relationship.id.is_empty(),
4890            "an empty id is not addressable"
4891        );
4892        assert_eq!(second.relationship.display, first.relationship.display);
4893    }
4894
4895    #[test]
4896    fn f16_clearing_a_set_bumps_updated_on_the_named_issue() {
4897        // F16. The named issue's own frontmatter can be untouched while its
4898        // relationship set changed, because an order-normalized `related` edge
4899        // lives in the other endpoint's file. That left `tix edit <n> --links ""`
4900        // looking like it had done nothing to the issue it named.
4901        let (_tmp, store, _a, _b) = related_pair();
4902        store.link("2", "1", RelationKind::Related).unwrap();
4903        let before = store.get(IssueSelector::Number(2)).unwrap();
4904        let updated_before = before.updated.clone();
4905
4906        store.set_relationships(&before.filename, &[]).unwrap();
4907
4908        let after = store.get(IssueSelector::Number(2)).unwrap();
4909        assert_ne!(
4910            after.updated, updated_before,
4911            "the named issue's relationship set changed, so `updated` must move"
4912        );
4913    }
4914
4915    #[test]
4916    fn c002_an_issue_with_no_relationship_data_round_trips_unchanged() {
4917        let (tmp, store) = make_store();
4918        let _ = tmp;
4919        // A pre-existing issue file: no relationship data, no History. This is
4920        // the shape every file in the repository has today, and reading then
4921        // writing it must not churn it (the WP's "existing files must not be
4922        // rewritten" guard).
4923        write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
4924        let issue = store.get(IssueSelector::Number(1)).unwrap();
4925        let first = issue.to_file_contents();
4926        assert!(
4927            !first.contains("relationships:"),
4928            "the empty typed field is omitted, not written as `[]`"
4929        );
4930        assert!(
4931            !first.contains("## History"),
4932            "an empty History section is omitted entirely"
4933        );
4934        // Byte-stable through read -> write -> read, and no unrelated
4935        // frontmatter key is added, removed, or reordered.
4936        let reparsed = Issue::parse(&first).unwrap();
4937        let second = reparsed.to_file_contents();
4938        assert_eq!(second, first);
4939    }
4940
4941    #[test]
4942    fn c002_history_section_round_trips_byte_stable() {
4943        let (tmp, store) = make_store();
4944        write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
4945        let _ = tmp;
4946        let mut issue = store.get(IssueSelector::Number(1)).unwrap();
4947        issue.history.append_now(
4948            "alice",
4949            HistoryEventKind::RelationshipAdded,
4950            "created blocks: blocks #2 B",
4951        );
4952        issue.history.append_now(
4953            "bob",
4954            HistoryEventKind::BlockedMoveAcknowledged,
4955            "acknowledged 1 open blocker",
4956        );
4957        let first = issue.to_file_contents();
4958        assert!(first.contains("## History"), "{first}");
4959
4960        // Read -> write -> read must be byte-stable.
4961        let reparsed = Issue::parse(&first).unwrap();
4962        assert_eq!(reparsed.to_file_contents(), first);
4963        assert_eq!(reparsed.history.len(), 2);
4964        assert_eq!(reparsed.history.events()[0].actor, "alice");
4965        assert_eq!(
4966            reparsed.history.events()[1].kind,
4967            HistoryEventKind::BlockedMoveAcknowledged
4968        );
4969        assert!(
4970            !reparsed.body.contains("History"),
4971            "the section is stripped from the body: {:?}",
4972            reparsed.body
4973        );
4974    }
4975
4976    #[test]
4977    fn f1_a_hand_edit_last_in_the_section_stays_in_it() {
4978        // Cycle-4 F1. The disambiguation rule carried a `!following.is_empty()`
4979        // guard, added to stop an earlier vacuous-truth defect. It over-corrected:
4980        // a hand-edited line that is the **last** line of the section has nothing
4981        // after it, so the guard forced the section to terminate there. Through
4982        // the real binary that meant `tix move 1 "in progress"` silently
4983        //
4984        //   - deleted the `## History` heading,
4985        //   - demoted the author's line into the issue body, and
4986        //   - rendered the *next* appended event above it, so file order stopped
4987        //     being append order,
4988        //
4989        // and `tix lint` reported nothing, because a demoted line is body prose
4990        // and the unparsed loop no longer sees it. Three explicit data-model §8
4991        // invariants at once.
4992        //
4993        // The distinguishing question is not "is there anything after this?" but
4994        // "is this shaped like a list item?". Nothing-following cannot disqualify
4995        // a line; a bare paragraph at EOF is still plainly prose.
4996        let (tmp, store) = make_store();
4997        let _ = tmp;
4998        let original = "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n  - my hand edit\n";
4999        write_issue(&store, "2026-01-01-a.md", original);
5000
5001        // Read precondition: the hand edit is inside the section, and the section
5002        // holds the prior event.
5003        let before = store.get(IssueSelector::Number(1)).unwrap();
5004        assert_eq!(
5005            before.history.events().len(),
5006            1,
5007            "precondition: the real event parsed"
5008        );
5009        assert_eq!(
5010            before.history.unparsed(),
5011            vec!["  - my hand edit"],
5012            "precondition: the hand edit is inside the section"
5013        );
5014        assert_eq!(before.body, "", "precondition: body is empty");
5015
5016        // An ordinary command that has nothing to do with relationships.
5017        let mut moved = before.clone();
5018        moved.status = crate::model::Status::new("in progress");
5019        store.update(&moved.filename, &moved).unwrap();
5020
5021        let after = store.get(IssueSelector::Number(1)).unwrap();
5022        assert_eq!(
5023            after.history.unparsed(),
5024            vec!["  - my hand edit"],
5025            "the hand edit is still in the audit log, not demoted to the body"
5026        );
5027        assert_eq!(
5028            after.history.events().len(),
5029            1,
5030            "and the real event survived"
5031        );
5032        assert_eq!(after.body, "", "nothing leaked into the body");
5033
5034        // The heading is still there, and the next append goes *after* what was
5035        // already in the file.
5036        store
5037            .link("1", "2", RelationKind::Related)
5038            .ok()
5039            .or_else(|| {
5040                write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5041                store.link("1", "2", RelationKind::Related).ok()
5042            });
5043        let linked = store.get(IssueSelector::Number(1)).unwrap();
5044        let rendered = linked.to_file_contents();
5045        assert!(
5046            rendered.contains("## History"),
5047            "the heading survived an unrelated write: {rendered}"
5048        );
5049        let hand = rendered
5050            .find("my hand edit")
5051            .expect("the hand edit is present");
5052        let newest = rendered
5053            .find("relationship_added")
5054            .expect("the new event is present");
5055        assert!(
5056            newest < hand,
5057            "the new event is appended AFTER the pre-existing content, so file \
5058             order is still append order: {rendered}"
5059        );
5060    }
5061
5062    #[test]
5063    fn f1_but_a_bare_paragraph_at_end_of_section_is_still_prose() {
5064        // The other half of F1's rule, and the reason the fix is not simply
5065        // "vacuous truth is fine". A non-list line at EOF is body prose: keeping
5066        // it would put ordinary prose into the append-only audit log, which is
5067        // the defect cycle 3 found (N1) travelling in the opposite direction.
5068        let (tmp, store) = make_store();
5069        let _ = tmp;
5070        write_issue(
5071            &store,
5072            "2026-01-01-a.md",
5073            "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n\njust a closing paragraph\n",
5074        );
5075        let issue = store.get(IssueSelector::Number(1)).unwrap();
5076        assert_eq!(issue.history.events().len(), 1, "the real event parsed");
5077        assert!(
5078            issue.history.unparsed().is_empty(),
5079            "the paragraph is not in the audit log: {:?}",
5080            issue.history.unparsed()
5081        );
5082        assert!(
5083            issue.body.contains("just a closing paragraph"),
5084            "it is body prose: {:?}",
5085            issue.body
5086        );
5087    }
5088
5089    #[test]
5090    fn f1_a_hand_edit_survives_body_prose_and_a_later_body_replacement() {
5091        // Cycle-5 Blocker 1. Cycle 4's F1 fix consulted `is_list_item` *only*
5092        // when nothing followed the ambiguous line, so the whole defect survived
5093        // for any hand-edited entry with body prose after it. The existing F1
5094        // tests could not see it because every one of their fixtures asserts
5095        // `before.body == ""` — the empty body was precisely what hid it.
5096        //
5097        // The consequence was data loss, not just reordering. A demoted line is
5098        // body text, and `tix edit --body` replaces the body wholesale, so:
5099        //
5100        //   ## History / - <event> / - 2026-02-02T00:00:00Z - bob \
5101        //     - relationship_removed - I removed this by hand / (blank) / Notes.
5102        //
5103        // then `tix edit 1 --body='Completely new body text.'` destroyed the
5104        // hand-edited append-only audit entry, and lint was silent.
5105        let (_tmp, store) = make_store();
5106        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5107        let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed - by hand";
5108        write_issue(
5109            &store,
5110            "2026-01-01-a.md",
5111            &format!(
5112                "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n{hand}\n\nSome body prose.\n"
5113            ),
5114        );
5115
5116        let before = store.get(IssueSelector::Number(1)).unwrap();
5117        assert_eq!(before.history.events().len(), 1, "the real event parsed");
5118        assert_eq!(
5119            before.history.unparsed(),
5120            vec![hand],
5121            "the hand-edited entry is retained even though body prose follows"
5122        );
5123        assert!(
5124            before.body.contains("Some body prose."),
5125            "and the prose is still body text: {:?}",
5126            before.body
5127        );
5128
5129        // An ordinary unrelated write must not relocate it.
5130        let mut moved = before.clone();
5131        moved.status = crate::model::Status::new("in progress");
5132        store.update(&moved.filename, &moved).unwrap();
5133        assert_eq!(
5134            store
5135                .get(IssueSelector::Number(1))
5136                .unwrap()
5137                .history
5138                .unparsed(),
5139            vec![hand],
5140            "a `tix move` keeps it in the audit log"
5141        );
5142
5143        // And replacing the body — the operation that used to destroy it — must
5144        // not either.
5145        let mut replaced = store.get(IssueSelector::Number(1)).unwrap();
5146        replaced.body = "Completely new body text.".to_string();
5147        store.update(&replaced.filename, &replaced).unwrap();
5148        let after = store.get(IssueSelector::Number(1)).unwrap();
5149        assert_eq!(
5150            after.history.unparsed(),
5151            vec![hand],
5152            "the hand-edited entry survives a wholesale body replacement"
5153        );
5154        assert_eq!(after.body, "Completely new body text.");
5155
5156        // And the next append still goes after what the file already held.
5157        store.link("1", "2", RelationKind::Related).unwrap();
5158        let rendered = store
5159            .get(IssueSelector::Number(1))
5160            .unwrap()
5161            .to_file_contents();
5162        assert!(
5163            rendered.contains("## History"),
5164            "the heading survived: {rendered}"
5165        );
5166        let hand_at = rendered.find("by hand").expect("the hand edit is present");
5167        let new_at = rendered
5168            .find("created rel_")
5169            .expect("the new event is present");
5170        assert!(
5171            new_at > hand_at,
5172            "the new event is appended AFTER the pre-existing content, so file \
5173             order is still append order: {rendered}"
5174        );
5175    }
5176
5177    #[test]
5178    fn f1_a_well_formed_star_marked_entry_is_retained_with_body_prose_after_it() {
5179        // Cycle-6 F1. A *correctly formatted* entry written with a `*` marker was
5180        // demoted to the body and then destroyed by `tix edit --body`, with lint
5181        // silent. The cause was a collapsed predicate: `looks_like_history_entry`
5182        // and `HistoryEvent::parse` both require the `- ` prefix, so a `*`-marked
5183        // em-dash line was not *recognised*; and the retention test asked
5184        // "does it look like a *misspelled* entry?", which explicitly excludes
5185        // anything containing U+2014. So a well-formed line matched neither.
5186        // Retention now asks its own question — is this an entry attempt under any
5187        // marker?
5188        let (_tmp, store) = make_store();
5189        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5190        let hand =
5191            "* 2026-02-02T00:00:00Z \u{2014} bob \u{2014} relationship_removed \u{2014} by hand";
5192        write_issue(
5193            &store,
5194            "2026-01-01-a.md",
5195            &format!(
5196                "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n{hand}\n\nSome body prose.\n"
5197            ),
5198        );
5199
5200        let before = store.get(IssueSelector::Number(1)).unwrap();
5201        assert_eq!(before.history.events().len(), 1, "the real event parsed");
5202        assert_eq!(
5203            before.history.unparsed(),
5204            vec![hand],
5205            "a well-formed `*` entry is retained even with body prose after it"
5206        );
5207        assert!(
5208            before.body.contains("Some body prose."),
5209            "and the prose is still body text: {:?}",
5210            before.body
5211        );
5212
5213        // The destructive sequence, which is the whole point.
5214        let mut replaced = before.clone();
5215        replaced.body = "Completely new body text.".to_string();
5216        store.update(&replaced.filename, &replaced).unwrap();
5217        assert_eq!(
5218            store
5219                .get(IssueSelector::Number(1))
5220                .unwrap()
5221                .history
5222                .unparsed(),
5223            vec![hand],
5224            "it survives a wholesale body replacement"
5225        );
5226
5227        store.link("1", "2", RelationKind::Related).unwrap();
5228        let rendered = store
5229            .get(IssueSelector::Number(1))
5230            .unwrap()
5231            .to_file_contents();
5232        assert_eq!(
5233            rendered.matches("## History").count(),
5234            1,
5235            "still exactly one section: {rendered}"
5236        );
5237        let hand_at = rendered.find("by hand").expect("the hand edit is present");
5238        let new_at = rendered
5239            .find("created rel_")
5240            .expect("the new event is present");
5241        assert!(
5242            new_at > hand_at,
5243            "appended after, so order is append order: {rendered}"
5244        );
5245    }
5246
5247    #[test]
5248    fn f1_a_three_field_ascii_hyphen_entry_is_retained_with_body_prose_after_it() {
5249        // Cycle-6 F2. `HistoryEvent::parse` accepts three total fields with
5250        // `detail` optional, so `- <ts> - <actor> - <kind>` is a valid entry whose
5251        // only defect is the separator. The retention test required four, so that
5252        // line was demoted and then destroyed while the malformed four-field one
5253        // survived — the recoverable direction was the one that lost.
5254        let (_tmp, store) = make_store();
5255        let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed";
5256        write_issue(
5257            &store,
5258            "2026-01-01-a.md",
5259            &format!(
5260                "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n{hand}\n\nSome body prose.\n"
5261            ),
5262        );
5263
5264        // Precondition: the em-dash twin really is a valid event, so the
5265        // three-field form is in fact part of the format.
5266        let twin = Issue::parse(
5267            "---\nnumber: 2\ntitle: \"T\"\nstatus: todo\n---\n\n## History\n\n- 2026-02-02T00:00:00Z \u{2014} bob \u{2014} relationship_removed\n",
5268        )
5269        .unwrap();
5270        assert_eq!(
5271            twin.history.events().len(),
5272            1,
5273            "three fields with em-dashes is a valid event, detail optional"
5274        );
5275
5276        let before = store.get(IssueSelector::Number(1)).unwrap();
5277        assert_eq!(
5278            before.history.unparsed(),
5279            vec![hand],
5280            "so its ASCII-hyphen twin is retained, not demoted"
5281        );
5282        let mut replaced = before.clone();
5283        replaced.body = "Completely new body text.".to_string();
5284        store.update(&replaced.filename, &replaced).unwrap();
5285        assert_eq!(
5286            store
5287                .get(IssueSelector::Number(1))
5288                .unwrap()
5289                .history
5290                .unparsed(),
5291            vec![hand],
5292            "and survives a body replacement"
5293        );
5294    }
5295
5296    #[test]
5297    fn f1_a_heading_with_trailing_spaces_is_still_the_heading() {
5298        // Cycle-6 F3(c). `## History   ` was not recognised, and since the
5299        // heading is then neither the section nor body, the first relationship
5300        // write rendered a **second** `## History` above the original. That was
5301        // filed on #1299 as a CRLF-only consequence, which understated it: a
5302        // trailing space is a far likelier trigger than CRLF.
5303        let (_tmp, store) = make_store();
5304        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5305        for (label, heading) in [
5306            ("plain", "## History"),
5307            ("trailing spaces", "## History   "),
5308        ] {
5309            write_issue(
5310                &store,
5311                "2026-01-01-a.md",
5312                &format!(
5313                    "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n{heading}\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n"
5314                ),
5315            );
5316            let before = store.get(IssueSelector::Number(1)).unwrap();
5317            assert_eq!(
5318                before.history.events().len(),
5319                1,
5320                "{label}: the section is recognised"
5321            );
5322            store.link("1", "2", RelationKind::Related).unwrap();
5323            let rendered = store
5324                .get(IssueSelector::Number(1))
5325                .unwrap()
5326                .to_file_contents();
5327            assert_eq!(
5328                rendered.matches("## History").count(),
5329                1,
5330                "{label}: exactly one section after a write, got {rendered}"
5331            );
5332            assert!(
5333                !rendered.lines().any(|l| l != l.trim_end()),
5334                "{label}: no line keeps trailing whitespace, so the heading is \
5335                 normalised: {rendered}"
5336            );
5337        }
5338    }
5339
5340    #[test]
5341    fn set_status_discloses_the_retired_key_rather_than_dropping_it_silently() {
5342        // Cycle-6 F4. Routing `set_status` through `update` is what makes it
5343        // disclose, and the behavioural difference is real — reverted to a direct
5344        // `atomic_write`, the key is still removed but nothing is disclosed, which
5345        // is silent data loss. Reverting also left the **entire suite green**,
5346        // because no test exercised it. That is the sixth time this package has
5347        // produced a correct fix with no regression protection, so this pins it.
5348        let (_tmp, store) = make_store();
5349        write_issue(
5350            &store,
5351            "2026-01-01-a.md",
5352            "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nlinks:\n- \"2\"\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n",
5353        );
5354        let before = store.get(IssueSelector::Number(1)).unwrap();
5355        assert!(
5356            before.has_retired_links_key,
5357            "precondition: the file carries the retired key"
5358        );
5359        let _ = crate::storage::take_disclosures();
5360
5361        store
5362            .set_status("2026-01-01-a.md", StatusChange::to("in progress"))
5363            .unwrap();
5364
5365        let disclosures = crate::storage::take_disclosures();
5366        assert_eq!(
5367            disclosures.len(),
5368            1,
5369            "set_status must disclose the removal, got {disclosures:?}"
5370        );
5371        assert!(
5372            disclosures[0].contains("links:") && disclosures[0].contains("a.md"),
5373            "naming the field and the file: {disclosures:?}"
5374        );
5375        assert!(
5376            !store
5377                .get(IssueSelector::Number(1))
5378                .unwrap()
5379                .has_retired_links_key,
5380            "and the key is gone, as C-001 intends"
5381        );
5382    }
5383
5384    #[test]
5385    fn f1_but_ordinary_prose_bullets_still_end_the_section() {
5386        // The other direction, and the reason the retention rule is not simply
5387        // "keep every list item". N1's original trigger: several body bullets in
5388        // a row. Neither is entry-shaped, so neither is plausibly a hand-edited
5389        // entry, and the section ends where it should.
5390        let (_tmp, store) = make_store();
5391        write_issue(
5392            &store,
5393            "2026-01-01-a.md",
5394            concat!(
5395                "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\n",
5396                "created: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n",
5397                "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n\n",
5398                "- step one\n- step two\n",
5399            ),
5400        );
5401        let issue = store.get(IssueSelector::Number(1)).unwrap();
5402        assert_eq!(
5403            issue.history.events().len(),
5404            1,
5405            "the real event is an event"
5406        );
5407        assert!(
5408            issue.history.unparsed().is_empty(),
5409            "ordinary bullets are not retained: {:?}",
5410            issue.history.unparsed()
5411        );
5412        assert!(
5413            issue.body.contains("step one") && issue.body.contains("step two"),
5414            "they stay body text: {:?}",
5415            issue.body
5416        );
5417    }
5418
5419    #[test]
5420    fn a_year_is_not_an_ordered_list_marker() {
5421        // Cycle-5 Minor 7. `is_list_item` accepted any run of digits before `.`,
5422        // so `2026. Q1 goals` counted as a list item and a paragraph of ordinary
5423        // body prose was retained in the append-only audit log.
5424        for prose in [
5425            "2026. Q1 goals",
5426            "2026. roadmap review",
5427            "007. padded",
5428            "99999999999999999999. big",
5429        ] {
5430            assert!(
5431                !crate::model::looks_like_an_unrecognised_entry(prose)
5432                    && Issue::parse(&format!(
5433                        "---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{prose}\n"
5434                    ))
5435                    .unwrap()
5436                    .history
5437                    .is_empty(),
5438                "{prose:?} must be body prose, not a retained entry"
5439            );
5440        }
5441    }
5442
5443    #[test]
5444    fn f2_a_recognised_entry_with_a_bad_kind_keeps_its_trailing_whitespace() {
5445        // Cycle-4 F2. The `Err` arm — a *recognised* entry whose kind is unknown —
5446        // used `trim_end()`, which also eats trailing spaces and tabs. That is
5447        // the auto-repair data-model §8 forbids, and it contradicts §8's
5448        // "preserved byte-for-byte, including its own indentation and trailing
5449        // whitespace". The sibling ambiguous arm was already correct, so the two
5450        // disagreed about what "verbatim" means.
5451        let line = "  - 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} d   ";
5452        let raw = format!(
5453            "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n{line}\n"
5454        );
5455        let issue = Issue::parse(&raw).unwrap();
5456        assert!(issue.history.events().is_empty(), "no event was fabricated");
5457        assert_eq!(
5458            issue.history.unparsed(),
5459            vec![line],
5460            "kept byte-for-byte, trailing spaces included"
5461        );
5462
5463        // And a real write must not repair it either.
5464        let rendered = issue.to_file_contents();
5465        assert!(
5466            rendered.contains(&format!("{line}\n")),
5467            "re-emitted exactly, so an unrelated `tix edit` does not rewrite the \
5468             author's audit log: {rendered}"
5469        );
5470        assert_eq!(
5471            Issue::parse(&rendered).unwrap().to_file_contents(),
5472            rendered,
5473            "and the file is byte-stable from here"
5474        );
5475    }
5476
5477    #[test]
5478    fn c002_history_is_append_only() {
5479        let (tmp, store) = make_store();
5480        write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
5481        let _ = tmp;
5482        let mut issue = store.get(IssueSelector::Number(1)).unwrap();
5483        issue
5484            .history
5485            .append_now("alice", HistoryEventKind::RelationshipAdded, "first");
5486        let after_first = issue.history.clone();
5487        issue
5488            .history
5489            .append_now("alice", HistoryEventKind::RelationshipRemoved, "second");
5490
5491        assert_eq!(issue.history.len(), 2, "appending never replaces");
5492        assert_eq!(
5493            &issue.history.events()[..1],
5494            after_first.events(),
5495            "the earlier entry is untouched and still first"
5496        );
5497    }
5498
5499    #[test]
5500    fn c002_an_unknown_history_kind_is_kept_verbatim_not_coerced() {
5501        // The old version of this test asserted `contains("bogus_kind")` against
5502        // an error message that *echoes the offending line*, so it passed for any
5503        // input mentioning the word. It also fed ASCII hyphens, so
5504        // `splitn(4, U+2014)` never split and the real failure was a missing
5505        // field — the kind was never checked at all. Neither defect is a
5506        // hypothetical: the assertion was satisfied by the echo.
5507        //
5508        // Fixed on both counts. The line uses real em dashes, so the parser
5509        // genuinely reaches the kind field, and the assertion is on the parsed
5510        // outcome rather than on a substring of an error string.
5511        let line = "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} something";
5512        let raw = format!(
5513            "---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\n---\n\n## History\n\n{line}\n"
5514        );
5515        let issue = Issue::parse(&raw).expect("an unknown kind must not fail the parse");
5516
5517        assert!(
5518            issue.history.events().is_empty(),
5519            "no event was fabricated for an unknown kind"
5520        );
5521        assert_eq!(
5522            issue.history.unparsed(),
5523            vec![line],
5524            "the line is kept verbatim: not dropped, not repaired"
5525        );
5526        // Idempotence, not equality with the hand-written input: that input
5527        // omits keys serde always emits, so only the rendered form is a
5528        // meaningful byte-stability assertion.
5529        let rendered = issue.to_file_contents();
5530        assert!(rendered.contains(line), "the line survives the write");
5531        assert_eq!(
5532            Issue::parse(&rendered).unwrap().to_file_contents(),
5533            rendered,
5534            "and the file round-trips byte-identically"
5535        );
5536    }
5537
5538    #[test]
5539    fn list_mixes_grouped_and_flat_issues_sorted_by_relative_filename() {
5540        let (tmp, store) = make_store();
5541        let _ = tmp;
5542        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5543        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
5544        write_issue(
5545            &store,
5546            "alice/2026-01-01-a.md",
5547            &sample_frontmatter(1, "A", "todo"),
5548        );
5549        write_issue(
5550            &store,
5551            "bob/2026-05-10-b.md",
5552            &sample_frontmatter(2, "B", "todo"),
5553        );
5554        write_issue(
5555            &store,
5556            "2026-02-01-c.md",
5557            &sample_frontmatter(3, "C", "todo"),
5558        );
5559        let issues = store.list().unwrap();
5560        let filenames: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
5561        assert_eq!(issues.len(), 3);
5562        assert_eq!(
5563            filenames,
5564            vec![
5565                "2026-02-01-c.md",
5566                "alice/2026-01-01-a.md",
5567                "bob/2026-05-10-b.md"
5568            ],
5569            "deterministic sort by relative filename"
5570        );
5571    }
5572
5573    #[test]
5574    fn list_sorts_chronologically_within_a_group() {
5575        let (tmp, store) = make_store();
5576        let _ = tmp;
5577        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5578        write_issue(
5579            &store,
5580            "alice/2026-03-01-c.md",
5581            &sample_frontmatter(3, "C", "todo"),
5582        );
5583        write_issue(
5584            &store,
5585            "alice/2026-01-01-a.md",
5586            &sample_frontmatter(1, "A", "todo"),
5587        );
5588        write_issue(
5589            &store,
5590            "alice/2026-02-01-b.md",
5591            &sample_frontmatter(2, "B", "todo"),
5592        );
5593        let issues = store.list().unwrap();
5594        let numbers: Vec<u32> = issues.iter().map(|i| i.number).collect();
5595        assert_eq!(numbers, vec![1, 2, 3]);
5596    }
5597
5598    #[test]
5599    fn list_ignores_non_md_files_in_segments() {
5600        let (tmp, store) = make_store();
5601        let _ = tmp;
5602        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5603        write_issue(
5604            &store,
5605            "alice/2026-01-01-a.md",
5606            &sample_frontmatter(1, "A", "todo"),
5607        );
5608        fs::write(store.issues_dir.join("alice/notes.txt"), "not an issue").unwrap();
5609        fs::write(store.issues_dir.join("alice/.tix-tmp-123-456"), "orphan").unwrap();
5610        let issues = store.list().unwrap();
5611        assert_eq!(issues.len(), 1);
5612        assert_eq!(issues[0].filename, "alice/2026-01-01-a.md");
5613    }
5614
5615    #[test]
5616    fn list_skips_temp_marker_directories() {
5617        let (tmp, store) = make_store();
5618        let _ = tmp;
5619        fs::create_dir_all(store.issues_dir.join(".tix-tmp-999-0")).unwrap();
5620        write_issue(
5621            &store,
5622            ".tix-tmp-999-0/2026-01-01-junk.md",
5623            &sample_frontmatter(9, "J", "todo"),
5624        );
5625        write_issue(
5626            &store,
5627            "2026-01-01-a.md",
5628            &sample_frontmatter(1, "A", "todo"),
5629        );
5630        let issues = store.list().unwrap();
5631        assert_eq!(issues.len(), 1);
5632        assert_eq!(issues[0].filename, "2026-01-01-a.md");
5633    }
5634
5635    #[test]
5636    fn list_parse_error_carries_grouped_relative_path() {
5637        let (tmp, store) = make_store();
5638        let _ = tmp;
5639        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5640        fs::write(
5641            store.issues_dir.join("alice/2026-01-01-bad.md"),
5642            "no frontmatter here\n",
5643        )
5644        .unwrap();
5645        let err = store.list().unwrap_err();
5646        assert!(
5647            matches!(&err, TixError::Parse(m) if m.starts_with("alice/2026-01-01-bad.md:")),
5648            "parse error must carry the relative path, got {err:?}"
5649        );
5650    }
5651
5652    // ------------------------------------------------------------------
5653    // T007 — grouped create writes into the assignee segment (FR-029/30)
5654    // ------------------------------------------------------------------
5655
5656    #[test]
5657    fn create_writes_into_assignee_segment_and_records_relative_filename() {
5658        let (tmp, store) = make_store();
5659        let _ = tmp;
5660        let mut issue = Issue {
5661            number: 0,
5662            title: "T".into(),
5663            status: Status::default(),
5664            labels: vec![],
5665            priority: Priority::default(),
5666            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
5667            estimate: None,
5668            commit: None,
5669            gitlab_id: None,
5670            linear_id: None,
5671            mission: None,
5672            task: None,
5673            relationships: vec![],
5674            files: vec![],
5675            cycles: None,
5676            milestones: None,
5677            reviewer: None,
5678            start_date: None,
5679            due_date: None,
5680            created: "2026-01-01".into(),
5681            updated: "2026-01-01".into(),
5682            filename: String::new(),
5683            body: String::new(),
5684            history: crate::model::History::new(),
5685            has_retired_links_key: false,
5686            parent: None,
5687            extra_frontmatter: std::collections::BTreeMap::new(),
5688            archived: false,
5689            entered_at: None,
5690            source: None,
5691        };
5692        let path = store
5693            .create(&mut issue, "body", Some("custom-stem"))
5694            .unwrap();
5695        let rel = "herbert-maschke/custom-stem.md";
5696        assert_eq!(issue.filename, rel);
5697        assert_eq!(path, store.issues_dir.join(rel));
5698        assert!(store.issues_dir.join("herbert-maschke").is_dir());
5699        assert!(store.issues_dir.join(rel).is_file());
5700    }
5701
5702    #[test]
5703    fn create_without_assignee_writes_unassigned_segment() {
5704        let (tmp, store) = make_store();
5705        let _ = tmp;
5706        let mut issue = Issue {
5707            number: 0,
5708            title: "T".into(),
5709            status: Status::default(),
5710            labels: vec![],
5711            priority: Priority::default(),
5712            assignee: None,
5713            estimate: None,
5714            commit: None,
5715            gitlab_id: None,
5716            linear_id: None,
5717            mission: None,
5718            task: None,
5719            relationships: vec![],
5720            files: vec![],
5721            cycles: None,
5722            milestones: None,
5723            reviewer: None,
5724            start_date: None,
5725            due_date: None,
5726            created: "2026-01-01".into(),
5727            updated: "2026-01-01".into(),
5728            filename: String::new(),
5729            body: String::new(),
5730            history: crate::model::History::new(),
5731            has_retired_links_key: false,
5732            parent: None,
5733            extra_frontmatter: std::collections::BTreeMap::new(),
5734            archived: false,
5735            entered_at: None,
5736            source: None,
5737        };
5738        let path = store.create(&mut issue, "", Some("stem")).unwrap();
5739        assert_eq!(issue.filename, "unassigned/stem.md");
5740        assert!(store.issues_dir.join("unassigned").is_dir());
5741        assert!(store.issues_dir.join("unassigned/stem.md").is_file());
5742        assert_eq!(path, store.issues_dir.join("unassigned/stem.md"));
5743    }
5744
5745    #[test]
5746    fn create_with_at_handle_derives_bare_segment() {
5747        let (tmp, store) = make_store();
5748        let _ = tmp;
5749        let mut issue = Issue {
5750            number: 0,
5751            title: "T".into(),
5752            status: Status::default(),
5753            labels: vec![],
5754            priority: Priority::default(),
5755            assignee: Some("@alice".into()),
5756            estimate: None,
5757            commit: None,
5758            gitlab_id: None,
5759            linear_id: None,
5760            mission: None,
5761            task: None,
5762            relationships: vec![],
5763            files: vec![],
5764            cycles: None,
5765            milestones: None,
5766            reviewer: None,
5767            start_date: None,
5768            due_date: None,
5769            created: "2026-01-01".into(),
5770            updated: "2026-01-01".into(),
5771            filename: String::new(),
5772            body: String::new(),
5773            history: crate::model::History::new(),
5774            has_retired_links_key: false,
5775            parent: None,
5776            extra_frontmatter: std::collections::BTreeMap::new(),
5777            archived: false,
5778            entered_at: None,
5779            source: None,
5780        };
5781        store.create(&mut issue, "", Some("stem")).unwrap();
5782        assert_eq!(issue.filename, "alice/stem.md");
5783        assert!(store.issues_dir.join("alice/stem.md").is_file());
5784    }
5785
5786    // ------------------------------------------------------------------
5787    // T008 — reassign(): move-on-reassign (FR-031), never overwrites
5788    // ------------------------------------------------------------------
5789
5790    #[test]
5791    fn reassign_moves_file_between_segments() {
5792        let (tmp, store) = make_store();
5793        let _ = tmp;
5794        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5795        write_issue(
5796            &store,
5797            "alice/2026-01-01-x.md",
5798            &sample_frontmatter(1, "X", "todo"),
5799        );
5800        let new_path = store
5801            .reassign("alice/2026-01-01-x.md", Some("@bob"))
5802            .unwrap();
5803        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x.md"));
5804        assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
5805        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
5806    }
5807
5808    #[test]
5809    fn reassign_into_occupied_segment_picks_deterministic_suffix() {
5810        let (tmp, store) = make_store();
5811        let _ = tmp;
5812        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5813        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
5814        write_issue(
5815            &store,
5816            "alice/2026-01-01-x.md",
5817            &sample_frontmatter(1, "X", "todo"),
5818        );
5819        write_issue(
5820            &store,
5821            "bob/2026-01-01-x.md",
5822            &sample_frontmatter(2, "X", "todo"),
5823        );
5824        let new_path = store
5825            .reassign("alice/2026-01-01-x.md", Some("@bob"))
5826            .unwrap();
5827        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x-1.md"));
5828        assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
5829        assert!(store.issues_dir.join("bob/2026-01-01-x-1.md").is_file());
5830        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
5831    }
5832
5833    #[test]
5834    fn reassign_legacy_flat_file_into_segment() {
5835        let (tmp, store) = make_store();
5836        let _ = tmp;
5837        write_issue(
5838            &store,
5839            "2026-01-01-y.md",
5840            &sample_frontmatter(1, "Y", "todo"),
5841        );
5842        let new_path = store.reassign("2026-01-01-y.md", Some("alice")).unwrap();
5843        assert_eq!(new_path, PathBuf::from("alice/2026-01-01-y.md"));
5844        assert!(store.issues_dir.join("alice/2026-01-01-y.md").is_file());
5845        assert!(!store.issues_dir.join("2026-01-01-y.md").exists());
5846    }
5847
5848    #[test]
5849    fn reassign_to_none_uses_unassigned_fallback() {
5850        let (tmp, store) = make_store();
5851        let _ = tmp;
5852        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5853        write_issue(
5854            &store,
5855            "alice/2026-01-01-z.md",
5856            &sample_frontmatter(1, "Z", "todo"),
5857        );
5858        let new_path = store.reassign("alice/2026-01-01-z.md", None).unwrap();
5859        assert_eq!(new_path, PathBuf::from("unassigned/2026-01-01-z.md"));
5860        assert!(
5861            store
5862                .issues_dir
5863                .join("unassigned/2026-01-01-z.md")
5864                .is_file()
5865        );
5866        assert!(!store.issues_dir.join("alice/2026-01-01-z.md").exists());
5867    }
5868
5869    #[test]
5870    fn reassign_keeps_number_and_created_stable() {
5871        let (tmp, store) = make_store();
5872        let _ = tmp;
5873        let mut issue = Issue {
5874            number: 0,
5875            title: "Stable".into(),
5876            status: Status::default(),
5877            labels: vec![],
5878            priority: Priority::default(),
5879            assignee: Some("alice".into()),
5880            estimate: None,
5881            commit: None,
5882            gitlab_id: None,
5883            linear_id: None,
5884            mission: None,
5885            task: None,
5886            relationships: vec![],
5887            files: vec![],
5888            cycles: None,
5889            milestones: None,
5890            reviewer: None,
5891            start_date: None,
5892            due_date: None,
5893            created: "2026-01-01".into(),
5894            updated: "2026-01-01".into(),
5895            filename: String::new(),
5896            body: String::new(),
5897            history: crate::model::History::new(),
5898            has_retired_links_key: false,
5899            parent: None,
5900            extra_frontmatter: std::collections::BTreeMap::new(),
5901            archived: false,
5902            entered_at: None,
5903            source: None,
5904        };
5905        store.create(&mut issue, "body", None).unwrap();
5906        let number = issue.number;
5907        let created = issue.created.clone();
5908        let before = issue.filename.clone();
5909        let new_path = store.reassign(&before, Some("@bob")).unwrap();
5910        let rescan = store.list().unwrap();
5911        let moved = rescan
5912            .iter()
5913            .find(|i| i.filename == new_path)
5914            .expect("moved file must be listed at its new relative path");
5915        assert_eq!(moved.number, number);
5916        assert_eq!(moved.created, created);
5917        assert_eq!(moved.title, "Stable");
5918    }
5919
5920    #[test]
5921    fn reassign_equal_segment_is_noop() {
5922        let (tmp, store) = make_store();
5923        let _ = tmp;
5924        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5925        write_issue(
5926            &store,
5927            "alice/2026-01-01-x.md",
5928            &sample_frontmatter(1, "X", "todo"),
5929        );
5930        let new_path = store
5931            .reassign("alice/2026-01-01-x.md", Some("alice"))
5932            .unwrap();
5933        assert_eq!(new_path, PathBuf::from("alice/2026-01-01-x.md"));
5934        assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
5935    }
5936
5937    #[test]
5938    fn reassign_missing_file_is_not_found() {
5939        let (tmp, store) = make_store();
5940        let _ = tmp;
5941        let err = store.reassign("nope.md", Some("alice")).unwrap_err();
5942        assert!(matches!(err, TixError::NotFound(_)));
5943    }
5944
5945    // ------------------------------------------------------------------
5946    // T009 — validate_filename relaxation + relative-path ops (C-008/C-010)
5947    // ------------------------------------------------------------------
5948
5949    #[test]
5950    fn validate_filename_allows_flat_and_single_segment_prefix() {
5951        validate_filename("2026-01-01-x.md").unwrap();
5952        validate_filename("alice/2026-01-01-x.md").unwrap();
5953    }
5954
5955    #[test]
5956    fn validate_filename_refuses_deeper_segments() {
5957        let err = validate_filename("alice/bob/2026-01-01-x.md").unwrap_err();
5958        assert!(matches!(err, TixError::InvalidInput(_)));
5959    }
5960
5961    #[test]
5962    fn validate_filename_refuses_leading_and_trailing_slash_and_absolute() {
5963        let err = validate_filename("/alice/2026-01-01-x.md").unwrap_err();
5964        assert!(matches!(err, TixError::InvalidInput(_)));
5965        let err = validate_filename("/etc/passwd").unwrap_err();
5966        assert!(matches!(err, TixError::InvalidInput(_)));
5967        let err = validate_filename("alice/").unwrap_err();
5968        assert!(matches!(err, TixError::InvalidInput(_)));
5969    }
5970
5971    #[test]
5972    fn path_traversal_with_segment_dotdot_refused() {
5973        let err = validate_filename("alice/../../x.md").unwrap_err();
5974        assert!(matches!(err, TixError::InvalidInput(_)));
5975        let err = validate_filename("alice/../x.md").unwrap_err();
5976        assert!(matches!(err, TixError::InvalidInput(_)));
5977    }
5978
5979    #[test]
5980    fn get_update_delete_set_status_work_on_grouped_relative_paths() {
5981        let (tmp, store) = make_store();
5982        let _ = tmp;
5983        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5984        write_issue(
5985            &store,
5986            "alice/2026-01-01-g.md",
5987            &sample_frontmatter(7, "G", "todo"),
5988        );
5989        let got = store
5990            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
5991            .unwrap();
5992        assert_eq!(got.number, 7);
5993        let done = store
5994            .set_status("alice/2026-01-01-g.md", StatusChange::to("done"))
5995            .unwrap();
5996        assert_eq!(done.issue.status.display(), "done");
5997        assert!(store.issues_dir.join("alice/2026-01-01-g.md").is_file());
5998        let mut edited = store
5999            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
6000            .unwrap();
6001        edited.title = "Updated".into();
6002        store.update("alice/2026-01-01-g.md", &edited).unwrap();
6003        let reloaded = store
6004            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
6005            .unwrap();
6006        assert_eq!(reloaded.title, "Updated");
6007        store.delete("alice/2026-01-01-g.md").unwrap();
6008        assert!(!store.issues_dir.join("alice/2026-01-01-g.md").exists());
6009    }
6010
6011    // ------------------------------------------------------------------
6012    // T010 — grouped round-trip sweep
6013    // ------------------------------------------------------------------
6014
6015    #[test]
6016    fn grouped_round_trip_create_list_get_update_delete() {
6017        let (tmp, store) = make_store();
6018        let _ = tmp;
6019        let mut issue = Issue {
6020            number: 0,
6021            title: "Round trip".into(),
6022            status: Status::default(),
6023            labels: vec!["bug".into()],
6024            priority: Priority::default(),
6025            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
6026            estimate: None,
6027            commit: None,
6028            gitlab_id: None,
6029            linear_id: None,
6030            mission: None,
6031            task: None,
6032            relationships: vec![],
6033            files: vec![],
6034            cycles: None,
6035            milestones: None,
6036            reviewer: None,
6037            start_date: None,
6038            due_date: None,
6039            created: "2026-01-01".into(),
6040            updated: "2026-01-01".into(),
6041            filename: String::new(),
6042            body: String::new(),
6043            history: crate::model::History::new(),
6044            has_retired_links_key: false,
6045            parent: None,
6046            extra_frontmatter: std::collections::BTreeMap::new(),
6047            archived: false,
6048            entered_at: None,
6049            source: None,
6050        };
6051        store.create(&mut issue, "initial body", None).unwrap();
6052        let rel = issue.filename.clone();
6053        assert_eq!(rel, "herbert-maschke/2026-01-01-round-trip.md");
6054
6055        let listed = store.list().unwrap();
6056        assert_eq!(listed.len(), 1);
6057        assert_eq!(listed[0].filename, rel);
6058        assert_eq!(listed[0].number, issue.number);
6059
6060        let got = store.get(IssueSelector::Filename(rel.clone())).unwrap();
6061        let mut edited = got.clone();
6062        edited.title = "Renamed".into();
6063        store.update(&rel, &edited).unwrap();
6064        let reloaded = store.get(IssueSelector::Filename(rel.clone())).unwrap();
6065        assert_eq!(reloaded.title, "Renamed");
6066
6067        store.delete(&rel).unwrap();
6068        assert!(!store.issues_dir.join(&rel).exists());
6069        assert!(store.list().unwrap().is_empty());
6070    }
6071
6072    #[test]
6073    fn legacy_flat_file_edit_and_status_still_work_after_layout_flip() {
6074        let (tmp, store) = make_store();
6075        let _ = tmp;
6076        write_issue(
6077            &store,
6078            "2026-01-01-legacy.md",
6079            &sample_frontmatter(5, "Legacy", "todo"),
6080        );
6081        let listed = store.list().unwrap();
6082        assert_eq!(listed.len(), 1);
6083        assert_eq!(listed[0].filename, "2026-01-01-legacy.md");
6084        let mutated = store
6085            .set_status("2026-01-01-legacy.md", StatusChange::to("in progress"))
6086            .unwrap();
6087        assert_eq!(mutated.issue.status.display(), "in progress");
6088        assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
6089    }
6090
6091    // ------------------------------------------------------------------
6092    // T004 — add_asset(): copy + link insert (FR-006, NFR-004)
6093    // ------------------------------------------------------------------
6094
6095    fn write_asset_source(tmp: &tempfile::TempDir, name: &str, contents: &[u8]) -> PathBuf {
6096        let path = tmp.path().join(name);
6097        fs::write(&path, contents).unwrap();
6098        path
6099    }
6100
6101    fn write_issue_with_basic_body(store: &Store, name: &str, number: u32) {
6102        write_issue(
6103            store,
6104            name,
6105            &sample_frontmatter(number, "Asset host", "todo"),
6106        );
6107    }
6108
6109    #[test]
6110    fn add_asset_image_inserts_image_link_and_writes_file() {
6111        let (tmp, store) = make_store();
6112        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6113        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6114        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6115        let added = store
6116            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6117            .unwrap();
6118        assert_eq!(added.written_name, "diagram.png");
6119        assert_eq!(added.link, "![diagram](fix-login-bug-assets/diagram.png)");
6120        assert!(
6121            store
6122                .issues_dir
6123                .join("alice/fix-login-bug-assets/diagram.png")
6124                .is_file()
6125        );
6126    }
6127
6128    #[test]
6129    fn add_asset_non_image_inserts_generic_markdown_link() {
6130        let (tmp, store) = make_store();
6131        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6132        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6133        let src = write_asset_source(&tmp, "report.pdf", b"%PDF");
6134        let added = store
6135            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6136            .unwrap();
6137        assert_eq!(added.link, "[report](fix-login-bug-assets/report.pdf)");
6138        assert!(
6139            store
6140                .issues_dir
6141                .join("alice/fix-login-bug-assets/report.pdf")
6142                .is_file()
6143        );
6144    }
6145
6146    #[test]
6147    fn add_asset_collision_resolves_with_numeric_suffix_and_link_points_at_it() {
6148        let (tmp, store) = make_store();
6149        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6150        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6151        let src = write_asset_source(&tmp, "x.png", b"first");
6152        let first = store
6153            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6154            .unwrap();
6155        assert_eq!(first.written_name, "x.png");
6156        let again = store
6157            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6158            .unwrap();
6159        assert_eq!(again.written_name, "x-1.png");
6160        assert_eq!(again.link, "![x-1](fix-login-bug-assets/x-1.png)");
6161        assert!(
6162            store
6163                .issues_dir
6164                .join("alice/fix-login-bug-assets/x.png")
6165                .is_file()
6166        );
6167        assert!(
6168            store
6169                .issues_dir
6170                .join("alice/fix-login-bug-assets/x-1.png")
6171                .is_file()
6172        );
6173    }
6174
6175    #[test]
6176    fn add_asset_appends_single_link_and_preserves_description() {
6177        let (tmp, store) = make_store();
6178        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6179        write_issue(
6180            &store,
6181            "alice/2026-01-01-fix-login-bug.md",
6182            "---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nTitle line.\n\n## Comments\n\n### alice (2026-01-01)\n\nExisting comment.\n",
6183        );
6184        let src = write_asset_source(&tmp, "diagram.svg", b"<svg/>");
6185        store
6186            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6187            .unwrap();
6188        let reloaded = store
6189            .get(crate::storage::IssueSelector::Filename(
6190                "alice/2026-01-01-fix-login-bug.md".into(),
6191            ))
6192            .unwrap();
6193        // Description (above the `## Comments` boundary) untouched.
6194        assert_eq!(reloaded.description(), "Title line.");
6195        // Exactly one link line, appended after the comments section.
6196        let link_count = reloaded.body.matches("![diagram]").count();
6197        assert_eq!(link_count, 1);
6198        assert!(
6199            reloaded
6200                .body
6201                .ends_with("fix-login-bug-assets/diagram.svg)\n")
6202        );
6203    }
6204
6205    #[test]
6206    fn add_asset_missing_issue_is_not_found() {
6207        let (tmp, store) = make_store();
6208        let _ = tmp;
6209        let src = write_asset_source(&tmp, "x.png", b"x");
6210        let err = store.add_asset("missing.md", &src).unwrap_err();
6211        assert!(matches!(err, TixError::NotFound(_)));
6212    }
6213
6214    #[test]
6215    fn add_asset_legacy_flat_file_uses_stem_assets_under_issues_dir() {
6216        let (tmp, store) = make_store();
6217        write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
6218        let src = write_asset_source(&tmp, "x.png", b"x");
6219        let added = store.add_asset("2026-01-01-legacy.md", &src).unwrap();
6220        assert_eq!(added.link, "![x](legacy-assets/x.png)");
6221        assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
6222    }
6223
6224    // ------------------------------------------------------------------
6225    // T005 — reassign() moves the assets dir (FR-007)
6226    // ------------------------------------------------------------------
6227
6228    #[test]
6229    fn reassign_moves_asset_dir_with_the_file() {
6230        let (tmp, store) = make_store();
6231        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6232        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6233        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6234        store
6235            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6236            .unwrap();
6237        let new_path = store
6238            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6239            .unwrap();
6240        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
6241        assert!(
6242            store
6243                .issues_dir
6244                .join("bob/fix-login-bug-assets/diagram.png")
6245                .is_file()
6246        );
6247        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6248    }
6249
6250    #[test]
6251    fn reassign_without_asset_dir_behaves_as_before() {
6252        let (tmp, store) = make_store();
6253        let _ = tmp;
6254        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6255        write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
6256        let new_path = store
6257            .reassign("alice/2026-01-01-plain.md", Some("@bob"))
6258            .unwrap();
6259        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-plain.md"));
6260        assert!(store.issues_dir.join("bob/2026-01-01-plain.md").is_file());
6261        assert!(!store.issues_dir.join("bob/plain-assets").exists());
6262    }
6263
6264    #[test]
6265    fn reassign_into_segment_with_same_named_asset_dir_uses_suffix_no_loss() {
6266        let (tmp, store) = make_store();
6267        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6268        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
6269        // Both segments already hold a same-named asset dir.
6270        let existing = store.issues_dir.join("bob/fix-login-bug-assets");
6271        fs::create_dir_all(&existing).unwrap();
6272        fs::write(existing.join("keep.txt"), "precious").unwrap();
6273        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6274        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6275        store
6276            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6277            .unwrap();
6278        let new_path = store
6279            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6280            .unwrap();
6281        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
6282        // Pre-existing target assets untouched, moved dir suffixed.
6283        assert!(existing.join("keep.txt").is_file());
6284        assert!(
6285            store
6286                .issues_dir
6287                .join("bob/fix-login-bug-assets-1/diagram.png")
6288                .is_file()
6289        );
6290        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6291    }
6292
6293    #[test]
6294    fn reassign_preserves_markdown_link_resolution_in_new_segment() {
6295        let (tmp, store) = make_store();
6296        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6297        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6298        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6299        store
6300            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6301            .unwrap();
6302        store
6303            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6304            .unwrap();
6305        // The asset dir kept its base name, so the relative link
6306        // `fix-login-bug-assets/diagram.png` resolves in the new segment.
6307        assert!(
6308            store
6309                .issues_dir
6310                .join("bob/fix-login-bug-assets/diagram.png")
6311                .is_file()
6312        );
6313    }
6314
6315    // ------------------------------------------------------------------
6316    // T006 — delete() removes the assets dir (FR-008) + sweep
6317    // ------------------------------------------------------------------
6318
6319    #[test]
6320    fn delete_removes_asset_dir_no_orphans() {
6321        let (tmp, store) = make_store();
6322        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6323        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6324        let src = write_asset_source(&tmp, "x.png", b"x");
6325        store
6326            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6327            .unwrap();
6328        store.delete("alice/2026-01-01-fix-login-bug.md").unwrap();
6329        assert!(
6330            !store
6331                .issues_dir
6332                .join("alice/2026-01-01-fix-login-bug.md")
6333                .exists()
6334        );
6335        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6336    }
6337
6338    #[test]
6339    fn delete_without_asset_dir_still_deletes_file() {
6340        let (tmp, store) = make_store();
6341        let _ = tmp;
6342        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6343        write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
6344        store.delete("alice/2026-01-01-plain.md").unwrap();
6345        assert!(!store.issues_dir.join("alice/2026-01-01-plain.md").exists());
6346    }
6347
6348    #[test]
6349    fn list_ignores_asset_directories() {
6350        let (tmp, store) = make_store();
6351        write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
6352        let src = write_asset_source(&tmp, "x.png", b"x");
6353        store.add_asset("2026-01-01-legacy.md", &src).unwrap();
6354        // Legacy flat file → `legacy-assets/` sits directly under issues_dir,
6355        // where the `-assets` guard in `list()` must skip it.
6356        let issues = store.list().unwrap();
6357        assert_eq!(issues.len(), 1);
6358        assert_eq!(issues[0].filename, "2026-01-01-legacy.md");
6359    }
6360
6361    #[test]
6362    fn list_ignores_nested_asset_directories_under_segment() {
6363        let (tmp, store) = make_store();
6364        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6365        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6366        let src = write_asset_source(&tmp, "x.png", b"x");
6367        store
6368            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6369            .unwrap();
6370        let issues = store.list().unwrap();
6371        assert_eq!(issues.len(), 1);
6372        assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
6373    }
6374
6375    #[test]
6376    fn asset_dir_with_formless_md_child_is_not_scanned_as_segment() {
6377        let (tmp, store) = make_store();
6378        let _ = tmp;
6379        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6380        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6381        // A `.md` straggler inside an `-assets` dir must never surface as an
6382        // issue segment (guard in `list()` for `<slug>-assets/`).
6383        let assets_dir = store.issues_dir.join("alice/fix-login-bug-assets");
6384        fs::create_dir_all(&assets_dir).unwrap();
6385        fs::write(assets_dir.join("notes.md"), "no frontmatter").unwrap();
6386        let issues = store.list().unwrap();
6387        assert_eq!(issues.len(), 1);
6388        assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
6389    }
6390
6391    // -----------------------------------------------------------------------
6392    // WP02 / T008 — hierarchy writes
6393    // -----------------------------------------------------------------------
6394    //
6395    // Requirement ids in the test names: C-001 (at most one parent), C-003 (no
6396    // child data on the parent), C-004 (every change audited), SC-004 (a rejected
6397    // change writes nothing).
6398    //
6399    // These tests assert the *write* behaviour only. Every rule lives in
6400    // `crate::hierarchy`; the last test below is the guard against one of them
6401    // being copied in here.
6402
6403    /// Three seeded issues, for the hierarchy write tests.
6404    fn hierarchy_trio() -> (TempDir, Store) {
6405        let (tmp, store) = make_store();
6406        for (n, title) in [(1, "A"), (2, "B"), (3, "C")] {
6407            write_issue(
6408                &store,
6409                &format!("2026-01-01-{}.md", title.to_lowercase()),
6410                &bare_frontmatter(n, title),
6411            );
6412        }
6413        (tmp, store)
6414    }
6415
6416    /// A store over one checked-in hierarchy fixture, by file name.
6417    ///
6418    /// `tests/fixtures/hierarchy/` rather than a string built inline, because the
6419    /// interesting cases are exactly the states the command layer refuses to
6420    /// create: a self-parent, a dangling reference. Those can only be reached by
6421    /// writing the file, which is the point of the fixtures.
6422    fn store_from_hierarchy_fixture(name: &str) -> (TempDir, Store) {
6423        let (tmp, store) = make_store();
6424        write_issue(
6425            &store,
6426            name,
6427            &std::fs::read_to_string(format!(
6428                "{}/tests/fixtures/hierarchy/{name}",
6429                env!("CARGO_MANIFEST_DIR")
6430            ))
6431            .unwrap(),
6432        );
6433        (tmp, store)
6434    }
6435
6436    fn bytes_of(store: &Store, number: u32) -> String {
6437        let issue = store.get(IssueSelector::Number(number)).unwrap();
6438        read_issue_file(store, &issue.filename)
6439    }
6440
6441    #[test]
6442    fn t008_set_writes_the_field_and_exactly_one_history_event() {
6443        let (_tmp, store) = hierarchy_trio();
6444        let outcome = store.set_parent("2", "1").unwrap();
6445        assert!(outcome.changed);
6446        assert_eq!(outcome.parent, Some(1));
6447
6448        let child = store.get(IssueSelector::Number(2)).unwrap();
6449        assert_eq!(child.parent, Some(1), "C-001: the number is stored");
6450        assert_eq!(child.history.len(), 1, "C-004: exactly one event");
6451        let event = &child.history.events()[0];
6452        assert_eq!(event.kind, HistoryEventKind::ParentSet);
6453        assert!(
6454            !event.actor.is_empty(),
6455            "an event without an actor is not an audit record"
6456        );
6457        // The detail names both sides, read from the *previous* value.
6458        assert_eq!(event.detail, "parent none -> #1", "got: {}", event.detail);
6459    }
6460
6461    #[test]
6462    fn t008_change_repoints_and_records_parent_changed() {
6463        let (_tmp, store) = hierarchy_trio();
6464        store.set_parent("3", "1").unwrap();
6465        let outcome = store.change_parent("3", "2").unwrap();
6466        assert!(outcome.changed);
6467        assert_eq!(outcome.parent, Some(2));
6468
6469        let child = store.get(IssueSelector::Number(3)).unwrap();
6470        assert_eq!(child.parent, Some(2));
6471        assert_eq!(child.history.len(), 2, "one event per accepted change");
6472        let events = child.history.events();
6473        let last = events.last().unwrap();
6474        assert_eq!(last.kind, HistoryEventKind::ParentChanged);
6475        // Regression: the detail used to be built after the assignment, so a
6476        // repoint recorded `parent #1 -> #1` and named neither side.
6477        assert_eq!(last.detail, "parent #1 -> #2", "got: {}", last.detail);
6478    }
6479
6480    #[test]
6481    fn t008_clear_removes_the_field_and_records_parent_cleared() {
6482        let (_tmp, store) = hierarchy_trio();
6483        store.set_parent("2", "1").unwrap();
6484        let outcome = store.clear_parent("2").unwrap();
6485        assert!(outcome.changed);
6486        assert_eq!(outcome.parent, None);
6487
6488        let child = store.get(IssueSelector::Number(2)).unwrap();
6489        assert_eq!(child.parent, None);
6490        assert_eq!(child.history.len(), 2);
6491        let events = child.history.events();
6492        let last = events.last().unwrap();
6493        assert_eq!(last.kind, HistoryEventKind::ParentCleared);
6494        assert_eq!(last.detail, "parent #1 -> none", "got: {}", last.detail);
6495    }
6496
6497    #[test]
6498    fn t008_reapplying_the_same_parent_is_a_no_op_with_no_second_event() {
6499        let (_tmp, store) = hierarchy_trio();
6500        store.set_parent("2", "1").unwrap();
6501        let after_first = bytes_of(&store, 2);
6502
6503        let second = store.set_parent("2", "1").unwrap();
6504        assert!(!second.changed, "an unchanged parent is not a change");
6505        assert!(second.event.is_none(), "a no-op records nothing (C-004)");
6506        assert_eq!(
6507            bytes_of(&store, 2),
6508            after_first,
6509            "a no-op must not rewrite the file at all"
6510        );
6511    }
6512
6513    #[test]
6514    fn t008_clearing_a_child_with_no_parent_is_a_no_op() {
6515        let (_tmp, store) = hierarchy_trio();
6516        let before = bytes_of(&store, 2);
6517        let outcome = store.clear_parent("2").unwrap();
6518        assert!(!outcome.changed);
6519        assert!(outcome.event.is_none());
6520        assert_eq!(bytes_of(&store, 2), before);
6521    }
6522
6523    #[test]
6524    fn t008_sc004_a_rejected_cycle_leaves_every_file_byte_identical() {
6525        // 1 -> 2 -> 3, then ask 1 to become a child of 3: that closes the loop.
6526        let (_tmp, store) = hierarchy_trio();
6527        store.set_parent("2", "1").unwrap();
6528        store.set_parent("3", "2").unwrap();
6529        let before: Vec<String> = (1..=3).map(|n| bytes_of(&store, n)).collect();
6530
6531        let err = store
6532            .set_parent("1", "3")
6533            .expect_err("1 -> 2 -> 3 -> 1 is a cycle and must be refused");
6534        let msg = err.to_string();
6535        assert!(
6536            msg.contains("cycle"),
6537            "the diagnostic must name the rule: {msg}"
6538        );
6539
6540        // SC-004: nothing written, nothing appended, on *any* file.
6541        for n in 1..=3 {
6542            assert_eq!(
6543                bytes_of(&store, n),
6544                before[(n - 1) as usize],
6545                "issue {n} must be byte-identical after a rejected cycle"
6546            );
6547        }
6548    }
6549
6550    #[test]
6551    fn t008_a_self_parent_is_rejected_and_writes_nothing() {
6552        let (_tmp, store) = hierarchy_trio();
6553        let before = bytes_of(&store, 2);
6554        let err = store
6555            .set_parent("2", "2")
6556            .expect_err("an issue cannot be its own parent");
6557        assert!(err.to_string().contains("self_parent"), "got: {err}");
6558        assert_eq!(bytes_of(&store, 2), before);
6559    }
6560
6561    #[test]
6562    fn t008_a_self_parent_already_on_disk_is_still_refused() {
6563        // The laundering case, through the file path rather than the service.
6564        //
6565        // A child that *already* records itself as its own parent looks like a
6566        // redundant request, and a no-op check placed before validation accepted it
6567        // as one — while the identical request against any other child was refused.
6568        // Only a fixture in that state can catch it: the test above seeds a
6569        // parentless child, so the guard never fired.
6570        let (_tmp, store) = store_from_hierarchy_fixture("self-parent.md");
6571        let before = bytes_of(&store, 7);
6572        let err = store
6573            .set_parent("7", "7")
6574            .expect_err("a stored self-parent must still be refused on a re-set");
6575        assert!(
6576            err.to_string().contains("self_parent"),
6577            "refused for the wrong reason: {err}"
6578        );
6579        assert_eq!(
6580            bytes_of(&store, 7),
6581            before,
6582            "and the refused write must leave the file byte-identical"
6583        );
6584        // `change_parent` shares the path and must refuse identically.
6585        assert!(store.change_parent("7", "7").is_err());
6586        // The fixture really is in the state under test.
6587        assert_eq!(store.get(IssueSelector::Number(7)).unwrap().parent, Some(7));
6588    }
6589
6590    #[test]
6591    fn t008_c003_setting_a_child_writes_nothing_to_the_parents_file() {
6592        // Child membership is derived by inverting the child's pointer; the
6593        // parent file must not learn about it at all.
6594        let (_tmp, store) = hierarchy_trio();
6595        let parent_before = bytes_of(&store, 1);
6596        store.set_parent("2", "1").unwrap();
6597        store.set_parent("3", "1").unwrap();
6598        assert_eq!(
6599            bytes_of(&store, 1),
6600            parent_before,
6601            "the parent's file must be untouched by its children appearing"
6602        );
6603        assert!(
6604            !parent_before.contains("parent:"),
6605            "the parent is a top of the hierarchy and has no parent itself"
6606        );
6607    }
6608
6609    #[test]
6610    fn t008_a_legacy_file_without_the_field_is_unchanged_by_a_no_op_write() {
6611        // The file-format contract, seen through the write path.
6612        let (_tmp, store) = hierarchy_trio();
6613        let before = bytes_of(&store, 2);
6614        assert!(!before.contains("parent:"), "the fixture must be legacy");
6615
6616        store.clear_parent("2").unwrap();
6617        assert_eq!(bytes_of(&store, 2), before, "a no-op must not add the key");
6618    }
6619
6620    #[test]
6621    fn t008_an_unresolvable_selector_is_refused_before_any_write() {
6622        let (_tmp, store) = hierarchy_trio();
6623        let before = bytes_of(&store, 2);
6624        assert!(store.set_parent("2", "no such issue").is_err());
6625        assert!(store.change_parent("2", "999").is_err());
6626        assert_eq!(bytes_of(&store, 2), before);
6627    }
6628
6629    #[test]
6630    fn t008_t008_contains_no_hierarchy_rule_of_its_own() {
6631        // T008's own note: "Storage must contain no hierarchy *rules*. If a check
6632        // appears here that also exists in the service, that is a defect."
6633        //
6634        // Asserted behaviourally rather than by reading the source: a storage
6635        // rejection must be **exactly** what the service says for the same
6636        // snapshot. If someone re-implemented a rule here, the two would drift and
6637        // this comparison would stop holding.
6638        //
6639        // Compared against the service *type's own* rendering, reached through the
6640        // `From<HierarchyError> for TixError` conversion the call site uses. An
6641        // earlier version of this test compared storage against a private
6642        // `hierarchy_error` helper — both sides went through the same function, so
6643        // it certified only that the helper was called, and it could not detect the
6644        // duplication it was meant to police (a second renderer that had drifted
6645        // from the first).
6646        let (_tmp, store) = hierarchy_trio();
6647        store.set_parent("2", "1").unwrap();
6648        store.set_parent("3", "2").unwrap();
6649
6650        let issues = store.list().unwrap();
6651        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
6652        let index = HierarchyIndex::build(&views);
6653        let service =
6654            hierarchy::set_parent(&index, 1, 3).expect_err("the service refuses this too");
6655        let storage = store.set_parent("1", "3").expect_err("and so does storage");
6656
6657        assert_eq!(
6658            storage.to_string(),
6659            TixError::from(service).to_string(),
6660            "storage must surface the service's diagnosis verbatim, not its own"
6661        );
6662        // And the rendering is the shared one, not a lookalike: the kind is
6663        // prefixed exactly once and no diagnostic is dropped.
6664        let text = storage.to_string();
6665        assert!(
6666            text.contains("cycle:") && !text.contains("cycle: cycle:"),
6667            "a lookalike renderer or a doubled prefix: {text}"
6668        );
6669        assert!(
6670            text.starts_with("invalid input: "),
6671            "a hierarchy refusal must carry the same prefix as every other: {text}"
6672        );
6673    }
6674
6675    #[test]
6676    fn t008_a_no_op_reports_no_written_issue_but_still_reports_the_current_parent() {
6677        // Pins the `ParentOutcome` contract, which its own doc comment used to
6678        // contradict: `written` is `None` for a no-op (matching
6679        // `RelationshipOutcome`), and the real state comes from `parent`.
6680        let (_tmp, store) = hierarchy_trio();
6681        store.set_parent("2", "1").unwrap();
6682        let again = store.set_parent("2", "1").unwrap();
6683        assert!(!again.changed);
6684        assert!(
6685            again.written.is_none(),
6686            "a no-op wrote nothing, so there is no written issue to hand back"
6687        );
6688        assert!(
6689            again.event.is_none(),
6690            "and no event, or a retry would pad the audit trail"
6691        );
6692        assert_eq!(
6693            again.parent,
6694            Some(1),
6695            "but the resulting state is still reported, so a caller need not re-read"
6696        );
6697    }
6698}