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::config::Config;
6use crate::error::{Result, TixError, render_diagnostics};
7use crate::hierarchy::{self, HierarchyIndex};
8use crate::model::{HistoryEventKind, Issue, Status, TransitionDecision};
9use crate::relationships::{
10    CanonicalEdge, Diagnostic, IssueView, RelationKind, Relationship, RelationshipGraph,
11};
12use crate::slug::slugify;
13
14pub struct Store {
15    pub issues_dir: PathBuf,
16    pub config: Config,
17}
18
19/// The outcome of one relationship mutation.
20///
21/// A typed relationship is a **single directed edge**, so a mutation normally
22/// writes exactly one issue file — the one whose frontmatter owns the canonical
23/// edge. `written` is `None` when the mutation was a no-op (an
24/// already-present edge, or an absent edge on remove), which is what makes
25/// "an idempotent re-apply appends no second History event" checkable.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RelationshipOutcome {
28    /// The issue whose file was written, if any.
29    pub written: Option<Issue>,
30    /// The relationship the mutation concerned, in canonical form.
31    pub relationship: Relationship,
32    /// Whether a file was actually written.
33    pub changed: bool,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum IssueSelector {
38    Number(u32),
39    Filename(String),
40    Slug(String),
41}
42
43/// Result of `Store::add_asset`: the relative markdown link inserted into the
44/// body and the actual written file name (collision-resolved).
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct AssetAdded {
47    pub link: String,
48    pub written_name: String,
49}
50
51// Disclosures raised by writes, drained by whichever surface is presenting.
52//
53// A disclosure is something the user must be told that is not an error: the removal of the
54// retired `links:` key, or a mention reconciliation that declined to touch a malformed target
55// file. It is queued rather than printed so every surface can render it in its own channel —
56// stderr for the CLI, the MCP result payload for a tool call, the status line for the TUI.
57//
58// Thread-local rather than process-global: a CLI and an MCP request each drain their own, and a
59// process-global queue made concurrent callers — and the test suite, which runs in parallel —
60// see each other's disclosures.
61thread_local! {
62    static WRITE_DISCLOSURES: std::cell::RefCell<Vec<String>> =
63        const { std::cell::RefCell::new(Vec::new()) };
64}
65
66/// Queue a disclosure for the presenting surface to render.
67pub(crate) fn push_disclosure(message: String) {
68    WRITE_DISCLOSURES.with(|queue| queue.borrow_mut().push(message));
69}
70
71/// A mention reconciliation declined to touch a target file, naming the reason.
72pub(crate) fn mention_disclosure(message: String) {
73    push_disclosure(message);
74}
75
76/// Record that a write removed the retired `links:` key from `filename`.
77/// `pub(crate)` so the surface drains in `app` and `mcp` can be tested against
78/// a raised disclosure. Production code reaches it only through `Store::update`.
79pub(crate) fn retired_key_disclosure(filename: &str) {
80    push_disclosure(format!(
81        "remove `links:` by hand to silence \u{2014} {filename} (retired by \
82         C-001; never read as typed data, no migration)"
83    ));
84}
85
86/// Take everything queued since the last drain.
87pub fn take_disclosures() -> Vec<String> {
88    WRITE_DISCLOSURES.with(|queue| std::mem::take(&mut *queue.borrow_mut()))
89}
90
91// A command-level write journal, active only inside a transaction (tix #1541,
92// decision option 2; closes the `tix edit` window in #1302).
93//
94// Thread-local for the same reason the disclosure queue is: a CLI and an MCP
95// request each run on their own thread, and the test suite runs tests in
96// parallel, so a process-global stack would let one caller's rollback undo
97// another's writes.
98//
99// The journal is a **stack of undo records taken before each mutation**, not a
100// copy of the tree. That keeps a transaction cheap for the common case (one
101// file) while still covering the multi-file writes — `set_relationships`, a
102// reassign that moves a file and its asset dir, a status change — that are the
103// reason the layer exists.
104thread_local! {
105    static WRITE_JOURNAL: std::cell::RefCell<Option<Vec<Undo>>> =
106        const { std::cell::RefCell::new(None) };
107}
108
109/// One filesystem change a transaction can undo, recorded **before** it happens.
110enum Undo {
111    /// `path` held `before` (or did not exist in the `None` case) before a
112    /// write or a delete. Undone by restoring the bytes, or removing a file the
113    /// transaction created.
114    Write {
115        path: PathBuf,
116        before: Option<Vec<u8>>,
117    },
118    /// `from` was renamed to `to`. Undone by renaming it back. Used for the
119    /// file **and** its `<slug>-assets/` directory, so a reassign is one unit.
120    Move { from: PathBuf, to: PathBuf },
121}
122
123/// Record `path`'s pre-image, once, if a transaction is active.
124///
125/// The **first** pre-image wins: a file written twice in one transaction must be
126/// restored to the state it had before the transaction, not before the second
127/// write — otherwise rolling back after two writes would leave the first write's
128/// content behind.
129///
130/// `pub(crate)` because the mention store writes outside the issues directory and must
131/// participate in the same journal: a command whose `Store::transaction` rolls back has to
132/// roll its mention files back with its issue files.
133pub(crate) fn journal_record_write(path: &Path) {
134    WRITE_JOURNAL.with(|journal| {
135        let mut borrow = journal.borrow_mut();
136        let Some(entries) = borrow.as_mut() else {
137            return;
138        };
139        if entries
140            .iter()
141            .any(|undo| matches!(undo, Undo::Write { path: seen, .. } if seen == path))
142        {
143            return;
144        }
145        entries.push(Undo::Write {
146            path: path.to_path_buf(),
147            before: std::fs::read(path).ok(),
148        });
149    });
150}
151
152/// Record a rename, if a transaction is active.
153fn journal_record_move(from: &Path, to: &Path) {
154    WRITE_JOURNAL.with(|journal| {
155        let mut borrow = journal.borrow_mut();
156        let Some(entries) = borrow.as_mut() else {
157            return;
158        };
159        entries.push(Undo::Move {
160            from: from.to_path_buf(),
161            to: to.to_path_buf(),
162        });
163    });
164}
165
166/// Undo every recorded change, newest first.
167///
168/// Best-effort and infallible by design: a rollback that itself fails must not
169/// mask the error that triggered it, so each step ignores its own I/O result and
170/// the caller still returns the original error.
171fn rollback(entries: Vec<Undo>) {
172    for entry in entries.into_iter().rev() {
173        match entry {
174            Undo::Write { path, before } => match before {
175                Some(bytes) => {
176                    let _ = std::fs::write(&path, bytes);
177                }
178                None => {
179                    let _ = std::fs::remove_file(&path);
180                }
181            },
182            Undo::Move { from, to } => {
183                let _ = std::fs::rename(&to, &from);
184            }
185        }
186    }
187}
188
189/// The identity recorded as the `actor` of a History event.
190///
191/// Falls back to `unknown` rather than failing a mutation, so a repository with
192/// no configured git identity can still record an audit trail.
193///
194/// Public because the blocked-move guard (WP04) records its own History event
195/// outside the storage layer — the acknowledgement is a *policy* decision, not a
196/// storage step. Duplicating the git-identity resolution in the command layer
197/// would give the audit trail two definitions of "who acted", which is the kind
198/// of drift an audit trail exists to prevent.
199pub fn actor_identity() -> String {
200    crate::assignee::git_identity()
201        .map(|id| id.to_string())
202        .unwrap_or_else(|| "unknown".to_string())
203}
204
205/// A request to move an issue to a column, through the seam.
206///
207/// WP03 T013. A struct rather than a positional list because the three arguments
208/// are all `Option`-shaped and adjacent: `(Some(commit), None, None)` is a mistake
209/// waiting to happen at a call site, and the field names are what stop it.
210///
211/// ## Deliberately absent
212///
213/// **No `actor`.** The seam records the identity itself through
214/// [`actor_identity`], so a caller cannot attribute a decision to somebody else
215/// and the audit trail keeps one definition of "who acted". Passing an actor in
216/// would make the attribution a choice of the caller, which is exactly the thing
217/// an audit trail must not be.
218///
219/// **No `updated`.** The seam bumps it, on exactly the transitions that change
220/// something. A caller supplying it would be able to write a file whose date says
221/// a no-op was an edit.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct StatusChange<'a> {
224    /// The column to move to. A column the board does not configure is refused
225    /// with `unknown_column`; a same-column request is accepted and inert.
226    pub column: &'a str,
227    /// The commit to record when this move reaches the board's completion column.
228    ///
229    /// Ignored on a move that does not reach completion, and a previously recorded
230    /// commit is cleared on a move that *leaves* it. `None` therefore means "record
231    /// nothing", which is distinct from "leave the old value alone" — leaving an
232    /// issue in its completion column with an unrelated commit is what the clear
233    /// exists to prevent.
234    pub commit: Option<&'a str>,
235    /// Why a policy rule was being overridden, when one was.
236    ///
237    /// Without a reason the service refuses a transition out of a terminal
238    /// category outright, so this is the only way out of `done`. The CLI flag that
239    /// supplies it lands in WP04; the seam takes the parameter now so a
240    /// half-built seam — one that can refuse but never accept-with-reason — is
241    /// never what ships.
242    pub override_reason: Option<&'a str>,
243}
244
245impl<'a> StatusChange<'a> {
246    /// A plain move: no commit, no override.
247    ///
248    /// The common case, named so the call site reads as "move it" rather than as
249    /// three `None`s.
250    pub fn to(column: &'a str) -> Self {
251        Self {
252            column,
253            commit: None,
254            override_reason: None,
255        }
256    }
257
258    /// This request with a commit, for a move that reaches completion.
259    pub fn with_commit(mut self, commit: Option<&'a str>) -> Self {
260        self.commit = commit;
261        self
262    }
263
264    /// This request with an override reason, for a move out of a terminal
265    /// category.
266    pub fn with_override(mut self, reason: Option<&'a str>) -> Self {
267        self.override_reason = reason;
268        self
269    }
270}
271
272/// What the seam did (`WP03` T013).
273///
274/// `written` and `decision` are deliberately redundant. Either alone would do:
275/// `written == false` implies `decision.is_none()`, and a decision always implies
276/// a write. They are both here because the two questions a caller asks are
277/// different — "did anything happen?" and "what gets appended?" — and reading the
278/// answer to one off the other is the kind of inference that breaks silently when a
279/// fourth outcome is added later.
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct StatusOutcome {
282    /// The issue as it now stands in the store: the accepted column, the bumped
283    /// `updated`, and the appended decision. On an inert no-op this is the issue
284    /// exactly as it was.
285    pub issue: Issue,
286    /// The decision appended to the issue's History, or `None` when nothing was
287    /// written.
288    pub decision: Option<TransitionDecision>,
289    /// Whether the file was written at all.
290    ///
291    /// `false` for a same-column re-apply and for any path that carries no change.
292    /// A refusal is not represented here — it is an `Err`, so "refused" and
293    /// "accepted but did nothing" are different types rather than one `Outcome`
294    /// with a flag.
295    pub written: bool,
296}
297
298/// The accepted half of [`crate::workflow::TransitionOutcome`], unpacked.
299///
300/// WP03. A private struct so the seam's body reads as ordinary code, and so the
301/// only way to reach the write is to have destructured the service's `Accepted`
302/// variant. The alternative — matching on the enum at each use — is how a future
303/// edit ends up handling a new outcome variant once in this function and not at
304/// all in the next one.
305struct TransitionAccepted {
306    changed: bool,
307    from: String,
308    to: String,
309    from_category: crate::workflow::WorkflowCategory,
310    to_category: crate::workflow::WorkflowCategory,
311    override_reason: Option<String>,
312}
313
314impl TransitionAccepted {
315    /// The accepted transition, or the diagnostic that refused it.
316    fn try_from(
317        outcome: crate::workflow::TransitionOutcome,
318    ) -> std::result::Result<Self, crate::workflow::WorkflowDiagnostic> {
319        match outcome {
320            crate::workflow::TransitionOutcome::Accepted {
321                changed,
322                from,
323                to,
324                from_category,
325                to_category,
326                override_reason,
327            } => Ok(Self {
328                changed,
329                from,
330                to,
331                from_category,
332                to_category,
333                override_reason,
334            }),
335            crate::workflow::TransitionOutcome::Rejected(diagnostic) => Err(diagnostic),
336        }
337    }
338}
339
340/// A [`Relationship`] standing for a canonical edge that has no stored row.
341///
342/// Deliberately local to the storage layer rather than a method on
343/// `CanonicalEdge`: the service's surface is owned by WP01 and is not widened
344/// from here. The id and display are empty because no row exists to carry them
345/// — this value only reports *which* edge a no-op concerned.
346fn canonical_placeholder(canonical: &CanonicalEdge) -> Relationship {
347    Relationship {
348        id: String::new(),
349        from: canonical.from,
350        to: canonical.to,
351        kind: canonical.kind,
352        display: String::new(),
353    }
354}
355
356/// Turn a blocking validation result into the crate error surface.
357///
358/// The first diagnostic's message is preserved verbatim so the user sees the
359/// field-aware text the service produced, prefixed with its diagnostic name
360/// (C-004). A blocking result means **no** file was written (SC-005), so this
361/// is raised before any mutation.
362fn relationship_error(errors: &[Diagnostic]) -> TixError {
363    if errors.is_empty() {
364        return TixError::InvalidInput("relationship rejected".into());
365    }
366    TixError::Refused(render_diagnostics(
367        "E-tix-relationship",
368        errors.iter().map(|d| (d.kind.as_str(), d.message.as_str())),
369    ))
370}
371
372/// A durable, exclusive claim on one issue number.
373///
374/// ## Why this exists (#1352, part 2)
375///
376/// [`Store::resolve_next_number`] reads the directory, takes `max(number)`, and adds
377/// one. That is a **hint**, not an allocation: nothing serialises read-then-write
378/// across processes, so two `tix create` runs that reach it together both get the
379/// same answer and both write an issue with that number.
380///
381/// Measured with 24 concurrent `tix create` subprocesses with distinct titles:
382///
383/// ```text
384/// files: 24   distinct numbers: 8
385///   5 × number: 7    4 × number: 4    4 × number: 6    3 × number: 3
386///   3 × number: 5    2 × number: 0    2 × number: 1    1 × number: 2
387/// ```
388///
389/// 16 of 24 issues shared a number with another file. After that every
390/// number-addressed operation — `tix view 0`, `tix move 0`, `tix edit 0`,
391/// `tix link 0 X`, `tix delete 0` — silently addresses an arbitrary one of several
392/// issues, which is arguably worse than the clobber it accompanies.
393///
394/// It needs **two** processes (an agent's `tix mcp` plus a human's `tix`), which is
395/// why it is rarer and why spacing the calls out appears to fix it. There is no
396/// intra-process race: `src/mcp/mod.rs` handles one message to completion before
397/// reading the next, the TUI is a single event loop, and `Store` is `&self`.
398///
399/// ## Why a claim ledger and not a lockfile
400///
401/// The obvious fix is a lock file held across resolve-and-write, and it was rejected
402/// because **a lock can be lost**: a process that crashes while holding it leaves the
403/// next create to break the lock on a timeout, and breaking a lock on a timeout also
404/// breaks it for a process that is merely slow. Every lock-staleness heuristic trades
405/// mutual exclusion for liveness, and this is the one property that must not be
406/// traded.
407///
408/// A claim ledger cannot lose mutual exclusion. The claim is a zero-byte file created
409/// with `O_EXCL`, and a process that dies holding one leaves an **orphan claim** —
410/// an allocated-but-unused number, never a lost lock. A burned number is a gap, and
411/// this system already produces gaps deliberately: reusing a number requires deleting
412/// every higher-numbered issue first, and deleting an issue leaves its number free.
413/// So the failure mode of the fix matches the existing, documented behaviour of the
414/// allocator rather than introducing a new one.
415///
416/// The cost is one hidden marker file per created issue. That is real, and it is the
417/// price of not needing a lease timeout. The markers live **beside** the issues
418/// directory rather than inside it, so nothing that reads `docs/issues/` — a glob, a
419/// script, a test counting segments — ever sees them; see [`claims_dir`].
420struct NumberClaim {
421    path: PathBuf,
422    /// Whether this claim must be released on drop. `false` once the issue file
423    /// exists, at which point the file — not the marker — is the authority for the
424    /// number.
425    armed: bool,
426}
427
428impl NumberClaim {
429    /// The directory holding one marker per claimed number.
430    const DIR: &'static str = ".tix-numbers";
431
432    /// Claim `start`, or the next free number at or above it.
433    ///
434    /// `start` comes from [`Store::resolve_next_number`] and is already past every
435    /// number any issue file claims, so the first candidate is normally free and the
436    /// loop runs once. The loop exists for the concurrent case, and the climb is
437    /// bounded so a corrupt ledger produces an error rather than an unbounded scan.
438    fn acquire(issues_dir: &Path, start: u32) -> Result<Self> {
439        const MAX_ATTEMPTS: u32 = 10_000;
440        let dir = claims_dir(issues_dir);
441        std::fs::create_dir_all(&dir)?;
442        let mut number = start;
443        for _ in 0..MAX_ATTEMPTS {
444            let path = dir.join(format!("{number}.claim"));
445            match std::fs::OpenOptions::new()
446                .write(true)
447                .create_new(true)
448                .open(&path)
449            {
450                Ok(_) => {
451                    return Ok(Self { path, armed: true });
452                }
453                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
454                    number += 1;
455                }
456                Err(e) => return Err(TixError::Io(e)),
457            }
458        }
459        Err(TixError::Io(std::io::Error::new(
460            std::io::ErrorKind::AlreadyExists,
461            format!(
462                "no free issue number at or above {start} after {MAX_ATTEMPTS} attempts; \
463                 {} is probably corrupt",
464                dir.display()
465            ),
466        )))
467    }
468
469    /// The number this claim holds.
470    fn number(&self) -> Option<u32> {
471        self.path.file_stem()?.to_str()?.parse::<u32>().ok()
472    }
473
474    /// Free a number that is no longer in use.
475    ///
476    /// Called by [`Store::delete`], so the ledger does not permanently reserve the
477    /// number of a deleted issue. Best-effort: if the ledger directory is missing or
478    /// the removal fails, the create path simply climbs past the stale claim — which
479    /// costs a number but never hands the same number to two issues.
480    fn release_number(issues_dir: &Path, number: u32) {
481        let _ = std::fs::remove_file(claims_dir(issues_dir).join(format!("{number}.claim")));
482    }
483
484    /// Give the number back, so a create that failed after claiming it does not burn
485    /// one. Idempotent, and a no-op for an already-committed claim.
486    fn release(&mut self) {
487        if self.armed {
488            let _ = std::fs::remove_file(&self.path);
489            self.armed = false;
490        }
491    }
492
493    /// Keep the claim permanently, because the issue file for it now exists.
494    ///
495    /// Without this, `Drop` would release the marker and the whole mechanism would
496    /// be a no-op — which is exactly what happened the first time this was written.
497    /// The `Drop` backstop below is for frames that *never reach* a commit, so it
498    /// must be disarmed deliberately on the success path rather than left to run
499    /// unconditionally.
500    fn commit(&mut self) {
501        self.armed = false;
502    }
503}
504
505impl Drop for NumberClaim {
506    /// A claim still armed when the `Store::create` frame unwinds — a `write`
507    /// error, or a panic — is released, so a failed create does not leak a number.
508    /// The `is_err` branch in `create` releases on the ordinary path; this is the
509    /// backstop for the paths that never reach it.
510    fn drop(&mut self) {
511        self.release();
512    }
513}
514
515impl std::fmt::Debug for NumberClaim {
516    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
517        f.debug_struct("NumberClaim")
518            .field("path", &self.path)
519            .field("armed", &self.armed)
520            .finish()
521    }
522}
523
524impl PartialEq for NumberClaim {
525    fn eq(&self, other: &Self) -> bool {
526        self.path == other.path
527    }
528}
529impl Eq for NumberClaim {}
530
531/// The outcome of a hierarchy write, mirroring [`RelationshipOutcome`] so a
532/// surface handles both with the same shape.
533#[derive(Debug, Clone)]
534pub struct ParentOutcome {
535    /// The child as written, or `None` when the mutation was a no-op.
536    ///
537    /// `None` for a no-op, matching [`RelationshipOutcome`]: a retried command must
538    /// not pad the audit trail, and a `changed: true` that wrote nothing is a lie.
539    /// A caller that needs the current state reads [`Self::parent`], which is
540    /// reported either way — the same split `RelationshipOutcome` uses for
541    /// `relationship`.
542    pub written: Option<Issue>,
543    /// The child's parent after the call.
544    pub parent: Option<u32>,
545    /// The event appended to `## History`, or `None` for a no-op.
546    ///
547    /// `None` for a no-op is the point: a retried command must not pad the
548    /// audit trail, and a `changed: true` that wrote nothing is a lie.
549    pub event: Option<crate::hierarchy::ParentEvent>,
550    /// Whether anything was written.
551    pub changed: bool,
552}
553
554/// The outcome of one project association mutation (mission
555/// `first-class-projects-01M3BYBW`, WP03).
556///
557/// Shaped like [`ParentOutcome`]: `written` is `None` for a no-op and the current
558/// list is reported either way, so a caller that needs the state does not have to
559/// re-read to learn it, and a `changed: false` is never paired with a write.
560#[derive(Debug, Clone, PartialEq, Eq)]
561pub struct ProjectAssociationOutcome {
562    /// The issue whose file was written, or `None` when the mutation was a no-op.
563    pub written: Option<Issue>,
564    /// The issue's association list after the call, exactly as the service
565    /// returned it.
566    pub project_ids: Vec<String>,
567    /// The History event appended, or `None` for a no-op.
568    pub event: Option<crate::projects::ProjectEvent>,
569    /// Whether anything was written.
570    pub changed: bool,
571}
572
573/// One entry of a bulk association run (WP03).
574///
575/// One entry per input selector, in input order, so a refusal is observable
576/// beside the rest rather than aborting them (contract `project-service.md` §3).
577/// It derives only `Debug` because [`TixError`] is neither `Clone` nor `PartialEq`.
578#[derive(Debug)]
579pub struct BulkAssociationOutcome {
580    /// The selector exactly as the caller wrote it.
581    pub selector: String,
582    /// The change, or the refusal, for that selector.
583    pub outcome: Result<ProjectAssociationOutcome>,
584}
585
586/// The outcome of one epic reference mutation (mission
587/// `epics-work-items-01M3BYW4`, WP03).
588///
589/// Shaped like [`ProjectAssociationOutcome`]: `written` is `None` for a no-op and
590/// the current list is reported either way, so a caller that needs the state does
591/// not have to re-read to learn it, and a `changed: false` is never paired with a
592/// write.
593#[derive(Debug, Clone, PartialEq, Eq)]
594pub struct EpicReferenceOutcome {
595    /// The issue whose file was written, or `None` when the mutation was a no-op.
596    pub written: Option<Issue>,
597    /// The issue's reference list after the call, exactly as the service returned
598    /// it.
599    pub epic_ids: Vec<String>,
600    /// The History event appended, or `None` for a no-op.
601    pub event: Option<crate::epics::EpicEvent>,
602    /// Whether anything was written.
603    pub changed: bool,
604}
605
606/// One entry of a bulk reference run (WP03).
607///
608/// One entry per input selector, in input order, so a refusal is observable
609/// beside the rest rather than aborting them (`contracts/epic-service.md` §5).
610/// It derives only `Debug` because [`TixError`] is neither `Clone` nor `PartialEq`.
611#[derive(Debug)]
612pub struct BulkReferenceOutcome {
613    /// The selector exactly as the caller wrote it.
614    pub selector: String,
615    /// The change, or the refusal, for that selector.
616    pub outcome: Result<EpicReferenceOutcome>,
617}
618
619impl Store {
620    pub fn open(config: &Config) -> Result<Store> {
621        // A relative `issues_dir` is written relative to the **project root** —
622        // the directory holding `tix.toml` — not to the invocation directory
623        // (tix #1584). The root is discovered by walking up from the working
624        // directory, the same walk `Config::project_root` performs for the CLI.
625        //
626        // The resolved directory is kept on the `Store` only; `config.issues_dir`
627        // is left exactly as it was read, so a later `Config::save` (the TUI's
628        // settings writes) round-trips the relative value rather than pinning a
629        // machine-specific absolute path into `tix.toml`.
630        let issues_dir = if config.issues_dir.is_relative() {
631            match Config::project_root(&std::env::current_dir().unwrap_or_default()) {
632                Some(root) => root.join(&config.issues_dir),
633                None => config.issues_dir.clone(),
634            }
635        } else {
636            config.issues_dir.clone()
637        };
638        if !issues_dir.exists() {
639            std::fs::create_dir_all(&issues_dir)?;
640        }
641        Ok(Store {
642            issues_dir,
643            config: config.clone(),
644        })
645    }
646
647    /// Run `f`, undoing every filesystem mutation it made if it returns `Err`.
648    ///
649    /// A command like `tix edit` is several writes in sequence — a reassign that
650    /// moves the file, the merged frontmatter, a `--status` transition, a triage
651    /// stamp — and the writes are not individually atomic with each other. Without
652    /// this, an I/O failure between two of them commits the earlier one and leaves
653    /// the store half-mutated (tix #1302). Wrapping the command in
654    /// [`Store::transaction`] makes the sequence all-or-nothing: on any `Err`, every
655    /// file the command touched is restored to the bytes it had before.
656    ///
657    /// The journal is recorded by the write primitives themselves
658    /// ([`write_file`], the renames in `move_issue_file`, `delete`), so **any**
659    /// store method participates without knowing about transactions — the layer is
660    /// command-level, not a per-method flag.
661    ///
662    /// Nesting: only the outermost call opens a journal, so an inner
663    /// `transaction` joins the outer one and a failure still rolls the whole outer
664    /// unit back. This is what makes it safe to wrap a public command that itself
665    /// calls other transactional commands.
666    ///
667    /// Not covered, stated rather than implied: writes that bypass the primitives
668    /// (a future `fs::write` written by hand) and mutations made after `f` returns.
669    /// The rollback is best-effort per file and the original error is always the
670    /// one returned — a rollback that itself fails must not mask the cause.
671    pub fn transaction<T>(&self, f: impl FnOnce() -> Result<T>) -> Result<T> {
672        if WRITE_JOURNAL.with(|journal| journal.borrow().is_some()) {
673            // Nested: the outermost transaction owns the journal and the rollback.
674            return f();
675        }
676        WRITE_JOURNAL.with(|journal| *journal.borrow_mut() = Some(Vec::new()));
677        let result = f();
678        let entries = WRITE_JOURNAL.with(|journal| journal.borrow_mut().take());
679        if result.is_err() {
680            rollback(entries.unwrap_or_default());
681        }
682        result
683    }
684
685    /// The **active** issue set: every issue file that is not archived.
686    ///
687    /// Archived issues are marked by a dot-prefixed markdown filename
688    /// (`docs/issues/<assignee>/.<file>.md`, issue #1492). They are skipped
689    /// **from the directory entry**, before the file is opened, so the default
690    /// path never parses an archived issue — the performance requirement that
691    /// keeps a repository with thousands of archived issues fast.
692    ///
693    /// Use [`Store::list_all`] when an archived issue must still be reachable.
694    pub fn list(&self) -> Result<Vec<Issue>> {
695        self.scan(false)
696    }
697
698    /// The **exhaustive** issue set: active issues plus archived ones.
699    ///
700    /// Archived issues carry `archived == true`, derived from the leading dot
701    /// of their filename. This is the enumeration for selector resolution and
702    /// number allocation, both of which must see an archived issue; it is
703    /// deliberately *not* the default view.
704    pub fn list_all(&self) -> Result<Vec<Issue>> {
705        self.scan(true)
706    }
707
708    /// Walk the issues tree.
709    ///
710    /// `include_archived == false` is the default path: a dot-prefixed `.md`
711    /// entry is dropped from the directory entry so the file is never read.
712    /// `true` reads them too and marks them with `archived = true`.
713    ///
714    /// The read+parse of every file is the expense of a cold start, so it is
715    /// fanned across threads above [`Store::PARALLEL_SCAN_THRESHOLD`] (see
716    /// [`Store::parse_issue_files`]); the directory walk itself stays serial.
717    fn scan(&self, include_archived: bool) -> Result<Vec<Issue>> {
718        if !self.issues_dir.exists() {
719            return Ok(Vec::new());
720        }
721        let mut files: Vec<(PathBuf, String, bool)> = Vec::new();
722        Self::collect_issue_files(&self.issues_dir, "", include_archived, &mut files)?;
723        // Deterministic order for both the result and any parse error: the walk
724        // itself is `read_dir`-ordered, which differs between filesystems.
725        files.sort_by(|a, b| a.1.cmp(&b.1));
726        let mut issues = Self::parse_issue_files(&files)?;
727        issues.sort_by(|a, b| a.filename.cmp(&b.filename));
728        Ok(issues)
729    }
730
731    /// File counts at or above this fan the scan's read+parse across threads.
732    /// Below it a serial loop is faster than the thread setup, so a small
733    /// repository keeps the boring path.
734    const PARALLEL_SCAN_THRESHOLD: usize = 256;
735    /// Upper bound on scan threads. The work is read- and YAML-parse-bound;
736    /// unbounded fan-out adds scheduling cost without throughput.
737    const PARALLEL_SCAN_THREADS: usize = 8;
738
739    /// Recursive walk used by [`Store::scan`]: any depth, skipping `.tix-tmp-*`
740    /// temp directories, `<slug>-assets/` directories, and hidden directories.
741    ///
742    /// Collects `(full path, relative path, archived)` **without reading a
743    /// file** — the read and parse belong to [`Store::parse_issue_files`], so this
744    /// cheap walk stays single-threaded while the expensive work fans out.
745    fn collect_issue_files(
746        dir: &Path,
747        prefix: &str,
748        include_archived: bool,
749        out: &mut Vec<(PathBuf, String, bool)>,
750    ) -> Result<()> {
751        for entry in std::fs::read_dir(dir)? {
752            let entry = entry?;
753            let path = entry.path();
754            let name = entry.file_name().to_string_lossy().to_string();
755            if path.is_dir() {
756                if name.starts_with('.')
757                    || name.starts_with(".tix-tmp-")
758                    || name.ends_with("-assets")
759                {
760                    continue;
761                }
762                let child = if prefix.is_empty() {
763                    name
764                } else {
765                    format!("{prefix}/{name}")
766                };
767                Self::collect_issue_files(&path, &child, include_archived, out)?;
768            } else if path.is_file() && name.ends_with(".md") {
769                let archived = name.starts_with('.');
770                if archived && !include_archived {
771                    continue;
772                }
773                let rel = if prefix.is_empty() {
774                    name
775                } else {
776                    format!("{prefix}/{name}")
777                };
778                out.push((path, rel, archived));
779            }
780        }
781        Ok(())
782    }
783
784    /// Read and parse every entry from [`Store::collect_issue_files`], keeping
785    /// the input order.
786    ///
787    /// Below [`Store::PARALLEL_SCAN_THRESHOLD`] files this is a plain serial
788    /// loop. At or above it the work is split across up to
789    /// [`Store::PARALLEL_SCAN_THREADS`] scoped threads; each chunk keeps its
790    /// place, so the result is in the same order as `files` and the first error —
791    /// a parse failure names its file — is deterministic.
792    fn parse_issue_files(files: &[(PathBuf, String, bool)]) -> Result<Vec<Issue>> {
793        if files.len() < Self::PARALLEL_SCAN_THRESHOLD {
794            return files
795                .iter()
796                .map(|(path, rel, archived)| Self::read_issue_file(path, rel, *archived))
797                .collect();
798        }
799        let threads = std::thread::available_parallelism()
800            .map(|n| n.get())
801            .unwrap_or(1)
802            .clamp(1, Self::PARALLEL_SCAN_THREADS);
803        if threads < 2 {
804            return files
805                .iter()
806                .map(|(path, rel, archived)| Self::read_issue_file(path, rel, *archived))
807                .collect();
808        }
809        let chunk = files.len().div_ceil(threads);
810        let parts: Vec<Vec<Result<Issue>>> = std::thread::scope(|scope| {
811            let handles: Vec<_> = files
812                .chunks(chunk)
813                .map(|slice| {
814                    scope.spawn(move || {
815                        slice
816                            .iter()
817                            .map(|(path, rel, archived)| {
818                                Self::read_issue_file(path, rel, *archived)
819                            })
820                            .collect::<Vec<_>>()
821                    })
822                })
823                .collect();
824            handles
825                .into_iter()
826                .map(|handle| handle.join().expect("issue-scan worker panicked"))
827                .collect()
828        });
829        let mut issues = Vec::with_capacity(files.len());
830        for part in parts {
831            for issue in part {
832                issues.push(issue?);
833            }
834        }
835        Ok(issues)
836    }
837
838    /// Read one issue file from its full `path` and stamp it with `rel` (the
839    /// store-relative filename) and `archived` (the dot-prefix marker). Shared by
840    /// the serial and parallel scan paths so they cannot diverge.
841    fn read_issue_file(path: &Path, rel: &str, archived: bool) -> Result<Issue> {
842        let contents = std::fs::read_to_string(path)?;
843        let mut issue = Issue::parse(&contents).map_err(|e| match e {
844            TixError::Parse(msg) => TixError::Parse(format!("{rel}: {msg}")),
845            other => other,
846        })?;
847        issue.filename = rel.to_string();
848        issue.archived = archived;
849        Ok(issue)
850    }
851
852    /// The assignee storage folders under the issues directory, sorted.
853    ///
854    /// These are the `docs/issues/<username>/` segments [`Self::scan`] walks —
855    /// the ground truth for who has issues. The reserved segments are excluded:
856    /// `unassigned` is the fallback for "no assignee" and is not a person, and a
857    /// `<slug>-assets/` or `.tix-tmp-*` directory is not a person either. Used by
858    /// the Triage mode's assignee picker (design D6); never creates a directory.
859    pub fn assignee_segments(&self) -> Result<Vec<String>> {
860        let mut segments = Vec::new();
861        if !self.issues_dir.exists() {
862            return Ok(segments);
863        }
864        for entry in std::fs::read_dir(&self.issues_dir)? {
865            let entry = entry?;
866            if !entry.path().is_dir() {
867                continue;
868            }
869            let name = entry.file_name().to_string_lossy().to_string();
870            if name == "unassigned" || name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
871                continue;
872            }
873            segments.push(name);
874        }
875        segments.sort();
876        segments.dedup();
877        Ok(segments)
878    }
879
880    pub fn get(&self, selector: IssueSelector) -> Result<Issue> {
881        match selector {
882            IssueSelector::Number(num) => {
883                let issues = self.list_all()?;
884                issues
885                    .into_iter()
886                    .find(|i| i.number == num)
887                    .ok_or_else(|| TixError::NotFound(format!("issue #{num}")))
888            }
889            IssueSelector::Filename(name) => {
890                validate_filename(&name)?;
891                let path = self.issues_dir.join(&name);
892                if !path.is_file() {
893                    return Err(TixError::NotFound(format!("file {name}")));
894                }
895                let contents = std::fs::read_to_string(&path)?;
896                let mut issue = Issue::parse(&contents)?;
897                issue.filename = name;
898                Ok(issue)
899            }
900            IssueSelector::Slug(slug) => {
901                let issues = self.list_all()?;
902                issues
903                    .into_iter()
904                    .find(|i| slugify(&i.title) == slug)
905                    .ok_or_else(|| TixError::NotFound(format!("slug {slug}")))
906            }
907        }
908    }
909
910    /// Read one issue by its **store-relative** filename (issue #10
911    /// follow-up).
912    ///
913    /// The single-file counterpart of [`Store::scan`], for the incremental
914    /// corpus path: it sets `filename` and `archived` exactly as `scan` does
915    /// (the archived marker is the leading dot of the file name) and applies
916    /// the same parse-error context, so an incrementally updated corpus holds
917    /// byte-identical issues to a full rebuild. It deliberately does **not**
918    /// linear-scan [`IssueSelector`] — the caller already has the relative
919    /// path from the watcher.
920    ///
921    /// A path that is not a file is `NotFound`; the caller decides whether
922    /// that means "removed".
923    pub fn read_issue_at(&self, rel: &Path) -> Result<Issue> {
924        let filename = rel.to_string_lossy().to_string();
925        let path = self.issues_dir.join(rel);
926        if !path.is_file() {
927            return Err(TixError::NotFound(format!("file {filename}")));
928        }
929        let contents = std::fs::read_to_string(&path)?;
930        let mut issue = Issue::parse(&contents).map_err(|e| match e {
931            TixError::Parse(msg) => TixError::Parse(format!("{filename}: {msg}")),
932            other => other,
933        })?;
934        issue.filename = filename;
935        issue.archived = rel
936            .file_name()
937            .and_then(|name| name.to_str())
938            .is_some_and(|name| name.starts_with('.'));
939        Ok(issue)
940    }
941
942    /// Archive an issue: rename its file to a dot-prefixed name **in place**.
943    ///
944    /// The marker is the filename (issue #1492), so this is a rename within the
945    /// same assignee segment (`x.md` ↔ `.x.md`) plus exactly one `## History`
946    /// event. `number`, `status`, `created` and every other frontmatter field
947    /// are left untouched, no data is dropped, and the file is never deleted or
948    /// moved between directories.
949    ///
950    /// An already-archived issue is refused with the `already_archived`
951    /// diagnostic rather than re-archived: the operation is not idempotent by
952    /// design, because a second call would append a second audit entry for a
953    /// mutation that did not happen.
954    pub fn archive(&self, sel: &str) -> Result<Issue> {
955        self.set_archived(sel, true)
956    }
957
958    /// Restore an archived issue: the inverse of [`Store::archive`].
959    ///
960    /// An active issue is refused with the `not_archived` diagnostic.
961    pub fn restore(&self, sel: &str) -> Result<Issue> {
962        self.set_archived(sel, false)
963    }
964
965    fn set_archived(&self, sel: &str, archive: bool) -> Result<Issue> {
966        // `resolve_issue` → `parse_selector` → `list_all`, so an archived issue
967        // resolves here even though the default view hides it.
968        let mut issue = self.resolve_issue(sel)?;
969        if issue.archived == archive {
970            return Err(TixError::InvalidInput(format!(
971                "{}: issue #{} ({}) is already {}",
972                if archive {
973                    "already_archived"
974                } else {
975                    "not_archived"
976                },
977                issue.number,
978                issue.filename,
979                if archive { "archived" } else { "active" },
980            )));
981        }
982        let old_rel = issue.filename.clone();
983        let new_rel = flip_archived(&old_rel, archive).ok_or_else(|| {
984            TixError::InvalidInput(format!(
985                "cannot {} {old_rel}: its filename does not carry the archived marker in the \
986                 expected position",
987                if archive { "archive" } else { "restore" }
988            ))
989        })?;
990        let old_path = self.issues_dir.join(&old_rel);
991        let new_path = self.issues_dir.join(&new_rel);
992        if !old_path.is_file() {
993            return Err(TixError::NotFound(format!("file {old_rel}")));
994        }
995        if new_path.exists() {
996            return Err(TixError::InvalidInput(format!(
997                "cannot {} {old_rel}: {new_rel} already exists",
998                if archive { "archive" } else { "restore" }
999            )));
1000        }
1001        // Rename first: it is atomic, stays in the same directory, and cannot
1002        // lose content. Only then is the audit event written, at the new name.
1003        // A failure between the two leaves the file renamed but without the
1004        // event — never a duplicate, never lost data.
1005        //
1006        // The rename is recorded in the write journal (#1580), so a command-level
1007        // `Store::transaction` around `tix archive`/`tix restore` can undo it: the
1008        // event write below is a *second* mutation, and a failure there must put
1009        // the file back at its active name rather than leave it archived without
1010        // its audit entry.
1011        journal_record_move(&old_path, &new_path);
1012        std::fs::rename(&old_path, &new_path)?;
1013        issue.filename = new_rel.clone();
1014        issue.archived = archive;
1015        issue.history.append_now(
1016            &actor_identity(),
1017            if archive {
1018                HistoryEventKind::Archived
1019            } else {
1020                HistoryEventKind::Restored
1021            },
1022            &format!("{old_rel} -> {new_rel}"),
1023        );
1024        // No `bump_updated`: `updated` is a frontmatter field and archiving must
1025        // leave every frontmatter field byte-identical.
1026        issue.filename = self.update(&new_rel, &issue)?;
1027        Ok(issue)
1028    }
1029
1030    /// Create an issue file atomically and assign its number by scanning the
1031    /// live issue set (issue #52): the highest existing `number` plus one,
1032    /// with a floor of `0` for an empty store.
1033    ///
1034    /// The number is resolved at creation time rather than read from a
1035    /// persisted `[state].next_number` counter, so `tix.toml` is never
1036    /// rewritten by `create`. A deleted high-numbered issue's number can be
1037    /// reused by a later create; the frontmatter `number` stays stable for
1038    /// every surviving issue (C-007).
1039    ///
1040    /// The file is written into the assignee segment derived from
1041    /// `issue.assignee` (`unassigned/` fallback, FR-029/FR-030), creating the
1042    /// segment directory as needed, and `issue.filename` is set to the
1043    /// relative path so `create` output and later edits agree. Atomic-write
1044    /// discipline (temp + rename, NFR-006) is preserved.
1045    pub fn create(
1046        &self,
1047        issue: &mut Issue,
1048        body: &str,
1049        filename_stem: Option<&str>,
1050    ) -> Result<PathBuf> {
1051        issue.body = body.to_string();
1052        issue.bump_updated();
1053
1054        std::fs::create_dir_all(&self.issues_dir)?;
1055        issue.number = self.resolve_next_number()?;
1056
1057        // Claim the number **before** anything is serialised, and hold it across the
1058        // whole write. `resolve_next_number` above reads the directory, takes `max`,
1059        // and adds 1, which is only a *hint*: two processes reaching this line
1060        // together both get the same answer and both proceed. See [`NumberClaim`]
1061        // for what that cost and why an `O_EXCL` marker is the fix.
1062        //
1063        // **Before** the serialisation, specifically. `to_file_contents` embeds
1064        // `number:` in the frontmatter, so serialising first and claiming after —
1065        // which is the order this was first written in — writes a *stale* number into
1066        // the file while the ledger records the correct one. The 24-create test still
1067        // saw 8 distinct numbers with 24 valid claims in place, because every claim
1068        // was correct and every file was wrong. A claim that does not reach the bytes
1069        // on disk is not a claim.
1070        let mut claim = NumberClaim::acquire(&self.issues_dir, issue.number)?;
1071        // Belt and braces (#1352): the number actually claimed must be the number
1072        // about to be written. `acquire` only climbs when the requested number is
1073        // already taken, so this is normally an identity — but it means a future
1074        // change to either side that drifts from the other fails loudly here
1075        // instead of writing an issue whose `number:` disagrees with the ledger.
1076        issue.number = claim.number().ok_or_else(|| {
1077            TixError::Io(std::io::Error::new(
1078                std::io::ErrorKind::InvalidData,
1079                format!("claimed number is unreadable from {}", claim.path.display()),
1080            ))
1081        })?;
1082
1083        // Assign the globally unique identity before anything is serialised. The
1084        // `id:` frontmatter line and the filename prefix both come from it, so it
1085        // must be fixed before `to_file_contents` and before `layout::render`. A
1086        // caller that already set one (an import, or a test) keeps it; this is the
1087        // single place every create path — CLI, MCP, TUI, sync — flows through.
1088        if issue.id.is_none() {
1089            issue.id = Some(crate::id::generate_issue_id());
1090        }
1091
1092        // WP02 layout, now configurable ([layout] in tix.toml): the directory is
1093        // rendered from the configured template and may be nested; `filename_stem`
1094        // still overrides the filename for callers that pin a stem.
1095        let rel = crate::layout::render(&self.config.layout, issue);
1096        let (dir, file) = rel.rsplit_once('/').unwrap_or(("", rel.as_str()));
1097        std::fs::create_dir_all(self.issues_dir.join(dir))?;
1098
1099        let stem = match filename_stem {
1100            Some(s) => s.to_string(),
1101            None => file.strip_suffix(".md").unwrap_or(file).to_string(),
1102        };
1103        // Serialised **after** the number is fixed, so the `number:` in the file is
1104        // the claimed one.
1105        let contents = issue.to_file_contents();
1106
1107        let written = self.write_new_issue_file(dir, &stem, contents.as_bytes());
1108        // The claim is now permanent: the issue file exists, so that file — not the
1109        // marker — is the authority for the number, and dropping the marker would
1110        // reopen the window this exists to close. A create that *failed* releases
1111        // its claim instead, so a failure does not leak a number for nothing.
1112        match written {
1113            Ok((filename, path)) => {
1114                claim.commit();
1115                issue.filename = filename;
1116                self.reconcile_mentions(issue, &issue.filename);
1117                Ok(path)
1118            }
1119            Err(e) => {
1120                claim.release();
1121                Err(e)
1122            }
1123        }
1124    }
1125
1126    /// The mention store, or `None` when `[mentions] enabled = false`.
1127    ///
1128    /// One constructor for every call site — the write seam, the TUI's indicator and the CLI's
1129    /// `tix mentions` — so "disabled" cannot mean different things in different places: no
1130    /// store, no scan, no write.
1131    ///
1132    /// The directory resolves against the **project root**, the same rule `issues_dir` follows,
1133    /// so a `tix` run from a subdirectory reads and writes the same files.
1134    pub fn mention_store(&self) -> Option<crate::mentions::store::MentionStore> {
1135        if !self.config.mentions.enabled {
1136            return None;
1137        }
1138        let dir = self.config.mentions.resolve_dir(&self.issues_dir);
1139        Some(crate::mentions::store::MentionStore::new(dir))
1140    }
1141
1142    /// Reconcile `issue`'s mentions into the store, best-effort.
1143    ///
1144    /// Called from every method that persists issue content ([`Store::create`],
1145    /// [`Store::update`] — which `Store::set_status` reaches — and the clearing side of
1146    /// [`Store::delete`]), so no surface can write an issue without its mentions being
1147    /// reconciled. It never fails the write: a problem is raised as a disclosure and the issue
1148    /// write stands.
1149    fn reconcile_mentions(&self, issue: &Issue, file: &str) {
1150        let Some(store) = self.mention_store() else {
1151            return;
1152        };
1153        // A legacy issue with no `id:` falls back to its path, which is stable for as long as
1154        // the issue is not moved — the same best-effort the model makes everywhere else.
1155        let id = issue.id.as_deref().unwrap_or(file);
1156        store.reconcile(crate::mentions::IssueContent {
1157            id,
1158            number: issue.number,
1159            file,
1160            title: &issue.title,
1161            body: &issue.body,
1162        });
1163    }
1164
1165    /// Drop every stored mention for the issue with `id`, best-effort.
1166    fn clear_mentions(&self, id: &str) {
1167        let Some(store) = self.mention_store() else {
1168            return;
1169        };
1170        if let Err(e) = store.clear_issue(id) {
1171            crate::storage::mention_disclosure(format!("mentions: {e} (left untouched)"));
1172        }
1173    }
1174
1175    /// Write **new** issue file, never overwriting an existing one.
1176    ///
1177    /// The stem was `{created}-{slugified title}` at the time of this bug (a
1178    /// new-schema name now also carries the issue id:
1179    /// `{created}-{id}-{slugified title}`), so it was a function of the issue's
1180    /// creation date, its title, and its assignee segment — three things routinely
1181    /// identical across two issues. Two `tix create --title "Same title"` runs on
1182    /// the same day therefore resolved to the **same path**, and `atomic_write`
1183    /// renames over the target, so the second create silently destroyed the first.
1184    /// No concurrency was required, and both commands reported success:
1185    ///
1186    /// ```text
1187    /// $ tix create --title "Same title" --priority high
1188    /// Created herbert-maschke/2026-09-28-same-title.md
1189    /// $ tix create --title "Same title" --priority low
1190    /// Created herbert-maschke/2026-09-28-same-title.md
1191    /// $ ls docs/issues/*/
1192    /// 2026-09-28-same-title.md            # one file
1193    /// $ tix list
1194    /// NUM  TITLE       STATUS  PRIORITY
1195    /// 2    Same title  todo    low       # issue #1 is gone
1196    /// ```
1197    ///
1198    /// Two layers, because the first alone is still a race:
1199    ///
1200    /// 1. **Probe** for a free name, appending `-2`, `-3`, … to the stem. This
1201    ///    handles the deterministic case, which is the common one.
1202    /// 2. **Create the file with `O_EXCL`** via [`write_file_new`]. Probing cannot
1203    ///    see a file another process creates between the probe and the write, and
1204    ///    `rename` overwrites unconditionally, so the final step is the one that
1205    ///    actually guarantees no clobber. Losing the race retries from step 1.
1206    ///
1207    /// Probing alone is the version that looks correct and is not: a second process
1208    /// that writes the same stem in the same millisecond would still be destroyed,
1209    /// and the retry-on-`AlreadyExists` below exists precisely because a reviewer
1210    /// should not have to take that on trust.
1211    fn write_new_issue_file(
1212        &self,
1213        segment: &str,
1214        stem: &str,
1215        contents: &[u8],
1216    ) -> Result<(String, PathBuf)> {
1217        const MAX_ATTEMPTS: usize = 1_000;
1218        let mut suffix = 1usize;
1219        for _ in 0..MAX_ATTEMPTS {
1220            let filename = if suffix == 1 {
1221                format!("{segment}/{stem}.md")
1222            } else {
1223                format!("{segment}/{stem}-{suffix}.md")
1224            };
1225            let path = self.issues_dir.join(&filename);
1226            match write_file_new(&self.issues_dir, &path, contents) {
1227                Ok(()) => return Ok((filename, path)),
1228                // Someone took this name between the probe and the write. The
1229                // collision is benign — it is only a *name* — so take the next one.
1230                Err(TixError::Io(e)) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1231                    suffix += 1;
1232                }
1233                Err(e) => return Err(e),
1234            }
1235        }
1236        Err(TixError::Io(std::io::Error::new(
1237            std::io::ErrorKind::AlreadyExists,
1238            format!(
1239                "could not find a free filename for {segment}/{stem}.md in {MAX_ATTEMPTS} attempts"
1240            ),
1241        )))
1242    }
1243
1244    /// The number for the next created issue: `max(number)` over the live
1245    /// issue set plus one, or `1` when no issues exist (issue #52 — no
1246    /// persisted counter to advance). Scanning the same list that every
1247    /// other command uses keeps number resolution conservative: reusing a
1248    /// number requires first deleting every higher-numbered issue.
1249    ///
1250    /// **The empty case is `1`, not `0`.** It used to be `0`, because `max()` over
1251    /// an empty set has no natural answer and `0` was the fallback — which made the
1252    /// first issue in every fresh repository `#0`, and that number is then
1253    /// indistinguishable from a sentinel, an uninitialised value, or a field that
1254    /// failed to read. Found by running the binary rather than reading the code
1255    /// (tix #1376); two places in the codebase had already written the opposite
1256    /// assumption into a comment, and a third had used `0` as a
1257    /// `HierarchyNode::NO_ISSUE_YET` marker.
1258    ///
1259    /// Only the empty case changes. With any issue present the answer is `max + 1`,
1260    /// which is unaffected — so no existing repository renumbers, and no migration
1261    /// is implied. (One real issue in this repository is numbered `0`, written
1262    /// before this fix; it keeps its number, because renumbering is a migration and
1263    /// the mission's doctrine makes those an escalation trigger.)
1264    fn resolve_next_number(&self) -> Result<u32> {
1265        // `list_all`, not `list`: an archived issue keeps its number forever.
1266        // Reading only the active set would let a `create` reuse the number of
1267        // the highest-numbered archived issue (issue #1492).
1268        let issues = self.list_all()?;
1269        match issues.iter().map(|i| i.number).max() {
1270            None => Ok(1),
1271            Some(max) => max
1272                .checked_add(1)
1273                .ok_or_else(|| TixError::InvalidInput("issue-number overflow".to_string())),
1274        }
1275    }
1276
1277    pub fn config_path(&self) -> Result<std::path::PathBuf> {
1278        if let Some(root) = Config::project_root(&self.issues_dir) {
1279            Ok(root.join("tix.toml"))
1280        } else {
1281            Err(TixError::Config(
1282                "tix.toml not found in any parent directory of issues_dir".into(),
1283            ))
1284        }
1285    }
1286
1287    /// Write `issue` over the file at `filename`, then reconcile the file's
1288    /// **location** with the configured `[layout]` (`move_on_change`).
1289    ///
1290    /// Returns the store-relative path the issue lives at afterwards — the same
1291    /// one in the common case, a new one when a templated field (status, type,
1292    /// assignee, cycle, …) now renders a different directory. **Every write path
1293    /// goes through here**, so no surface (CLI, MCP, TUI, triage, sync) can
1294    /// leave an issue at a path that disagrees with the layout.
1295    pub fn update(&self, filename: &str, issue: &Issue) -> Result<String> {
1296        validate_filename(filename)?;
1297        let path = self.issues_dir.join(filename);
1298        if !path.is_file() {
1299            return Err(TixError::NotFound(format!("file {filename}")));
1300        }
1301        // The retired `links:` key is not in the model, so re-serializing drops
1302        // it — and with it the `retired_links_key` finding, silently. The
1303        // consequence is that `tix lint` reports a problem, the user runs an
1304        // unrelated `tix edit`, and both the finding and the data vanish with no
1305        // notice. That is the defect; C-001's no-migration intent is not.
1306        //
1307        // The write still proceeds, because refusing would make tix unusable
1308        // across the repository: 1216 of 1290 issue files here carry `links:`.
1309        // What changes is that the removal is *disclosed*, naming the field and
1310        // the file, so nothing is lost quietly. Removing the key by hand first
1311        // silences this.
1312        if issue.has_retired_links_key {
1313            // Recorded on the store, not printed here. `eprintln!` inside a
1314            // library function is invisible to the MCP server and the TUI, so
1315            // the same operation produced different observable output per
1316            // surface, which `contracts/surface-parity.md` forbids.
1317            //
1318            // Drained at the three surface boundaries, not at each write path:
1319            // `main` for every CLI command, the MCP `serve` loop per request,
1320            // and the TUI event loop. Draining per command was tried and reached
1321            // only `link` and `edit`, so every other write path — `mv`,
1322            // `rollover`, planning, sync, the TUI, MCP — warned about nothing.
1323            // That was worse than the `eprintln!` it replaced, which fired from
1324            // inside `update` and so covered all of them. A count of write
1325            // paths is deliberately not stated here: it drifts, and a figure a
1326            // later reader trusts is worse than no figure. `tests/mcp.rs` pins
1327            // the behaviour instead, for `main` and the MCP server.
1328            retired_key_disclosure(filename);
1329        }
1330        let contents = issue.to_file_contents();
1331        atomic_write(&self.issues_dir, &path, contents.as_bytes())?;
1332        // Unconditional auto-migration: after any write, the file is moved to the
1333        // directory `[layout]` renders (subject only to `move_on_change`). A
1334        // legacy or mismatched path is migrated, not left behind.
1335        let relocated = self.reconcile_location(filename, issue)?;
1336        // The mention records name the issue by id and record the path they saw it at, so a
1337        // reconciliation after the move stores the path the issue actually lives at.
1338        self.reconcile_mentions(issue, &relocated);
1339        Ok(relocated)
1340    }
1341
1342    /// Move the file at `filename` so its directory matches what `[layout]`
1343    /// renders for `issue`, when `move_on_change` is set and the directory
1344    /// differs. The basename (including the archived-marker dot) is preserved.
1345    /// Returns the relative path afterwards (unchanged when no move happens).
1346    fn reconcile_location(&self, filename: &str, issue: &Issue) -> Result<String> {
1347        if !self.config.layout.move_on_change {
1348            return Ok(filename.to_string());
1349        }
1350        let target_dir = crate::layout::render_directory(&self.config.layout, issue);
1351        let Some((old_dir, base)) = split_relative(filename) else {
1352            return Ok(filename.to_string());
1353        };
1354        if old_dir == target_dir {
1355            return Ok(filename.to_string());
1356        }
1357        let stem = base.strip_suffix(".md").unwrap_or(&base);
1358        let moved = self.move_issue_file(filename, &target_dir, stem)?;
1359        Ok(moved.to_string_lossy().to_string())
1360    }
1361
1362    /// Copy an asset into the issue's `<slug>-assets/` directory (FR-006) and
1363    /// insert a relative markdown link at the end of the body, persisted via
1364    /// [`Store::update`] (NFR-004).
1365    ///
1366    /// The asset dir is derived from the issue filename per
1367    /// `contracts/comments-format.md`: `<slug>-assets/` where `<slug>` is the
1368    /// basename stem minus the leading `YYYY-MM-DD-` date prefix. The copy is
1369    /// atomic (temp + rename, NFR-004); on a name collision the file is stored
1370    /// as `<stem>-<n>.<ext>` (first free `n`) and the inserted link points at
1371    /// the actual written name. Image extensions (`.png` `.jpg` `.jpeg`
1372    /// `.gif` `.svg` `.webp` `.bmp`) get the `![…](…)` form, everything else
1373    /// the `[…](…)` form. The link path is relative to the issue file, so the
1374    /// segment prefix is never part of it.
1375    pub fn add_asset(&self, filename: &str, source_path: &Path) -> Result<AssetAdded> {
1376        validate_filename(filename)?;
1377        let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1378
1379        let (segment, basename) = split_relative(filename).ok_or_else(|| {
1380            TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
1381        })?;
1382        let stem = basename
1383            .strip_suffix(".md")
1384            .ok_or_else(|| {
1385                TixError::InvalidInput(format!(
1386                    "asset target must be a `.md` issue file: {filename}"
1387                ))
1388            })?
1389            .to_string();
1390        let slug = slug_from_stem(&stem);
1391        let dir_name = format!("{slug}-assets");
1392        let asset_dir = self.issues_dir.join(&segment).join(&dir_name);
1393        std::fs::create_dir_all(&asset_dir)?;
1394
1395        let source_name = source_path
1396            .file_name()
1397            .and_then(|n| n.to_str())
1398            .ok_or_else(|| {
1399                TixError::InvalidInput("asset source must have a file name".to_string())
1400            })?
1401            .to_string();
1402        let written_name = resolve_asset_name(&asset_dir, &source_name);
1403        atomic_copy(&asset_dir, &asset_dir.join(&written_name), source_path)?;
1404
1405        let label = written_name
1406            .rsplit_once('.')
1407            .map(|(stem, _)| stem)
1408            .unwrap_or(&written_name);
1409        let link = if is_image_name(&written_name) {
1410            format!("![{label}]({dir_name}/{written_name})")
1411        } else {
1412            format!("[{label}]({dir_name}/{written_name})")
1413        };
1414
1415        // Append the link as a new line at the end of the body, reusing the
1416        // comment-appender newline discipline so the description is never
1417        // rewritten (FR-007).
1418        if !issue.body.is_empty() {
1419            if !issue.body.ends_with('\n') {
1420                issue.body.push('\n');
1421            }
1422            if !issue.body.ends_with("\n\n") {
1423                issue.body.push('\n');
1424            }
1425        }
1426        issue.body.push_str(&link);
1427        issue.body.push('\n');
1428        issue.bump_updated();
1429        self.update(filename, &issue)?;
1430
1431        Ok(AssetAdded { link, written_name })
1432    }
1433
1434    pub fn delete(&self, filename: &str) -> Result<()> {
1435        validate_filename(filename)?;
1436        let path = self.issues_dir.join(filename);
1437        if !path.is_file() {
1438            return Err(TixError::NotFound(format!("file {filename}")));
1439        }
1440        // Read the number **before** the file goes, so the claim ledger can be told
1441        // the number is free again (#1352).
1442        //
1443        // Releasing the claim is what preserves issue #52's documented behaviour:
1444        // the next number is re-derived from the live set, so deleting the
1445        // highest-numbered issue lets its number be reused. Without this the ledger
1446        // holds that number forever, the next create climbs past it, and the feature
1447        // stops working — which is a regression the existing test caught.
1448        //
1449        // The residual window, stated rather than hidden: if a `create` is in flight
1450        // holding this claim while the `delete` runs, the claim is released and a
1451        // later create could take the number too. That needs a delete and a create to
1452        // interleave, and it was *unconditionally* broken before this ledger existed;
1453        // the ledger removes the plain concurrent-create case, which is the one that
1454        // actually happens. Repairing the delete/create interleave would need a lock,
1455        // and this is the reason the ledger was chosen over one.
1456        let freed = std::fs::read_to_string(&path)
1457            .ok()
1458            .and_then(|text| frontmatter_number(&text));
1459        // The issue's canonical id, best-effort, for clearing its mention records. Read before
1460        // the file goes, and read through the same parse the rest of the store uses rather than
1461        // a second frontmatter scraper.
1462        let identity = self
1463            .get(IssueSelector::Filename(filename.to_string()))
1464            .ok()
1465            .and_then(|issue| issue.id);
1466        journal_record_write(&path);
1467        std::fs::remove_file(&path)?;
1468        if let Some(number) = freed {
1469            NumberClaim::release_number(&self.issues_dir, number);
1470        }
1471        if let Some(id) = identity {
1472            self.clear_mentions(&id);
1473        }
1474        // Best-effort cleanup of the `<slug>-assets/` dir (FR-008): the `.md`
1475        // removal is the contract, so an asset-dir removal failure is only
1476        // reported as a warning, never propagated.
1477        if let Some((segment, basename)) = split_relative(filename) {
1478            let stem = basename.strip_suffix(".md").unwrap_or(&basename);
1479            let slug = slug_from_stem(stem);
1480            let asset_dir = self
1481                .issues_dir
1482                .join(&segment)
1483                .join(format!("{slug}-assets"));
1484            if asset_dir.exists()
1485                && let Err(e) = std::fs::remove_dir_all(&asset_dir)
1486            {
1487                eprintln!(
1488                    "warning: failed to remove asset dir {}: {e}",
1489                    asset_dir.display()
1490                );
1491            }
1492        }
1493        Ok(())
1494    }
1495
1496    /// **The** status write path (WP03, C-003, FR-002, NFR-002).
1497    ///
1498    /// Every surface that changes an issue's column comes through here, so the
1499    /// transition service is on the path *structurally* rather than by convention:
1500    /// CLI (`tix mv`, `tix edit --status`), the TUI's move and edit actions, MCP's
1501    /// `move_issue` and `update_issue`, and sync's import. A caller that judges a
1502    /// transition itself is a parity defect, and the two that used to are the
1503    /// reason this function had no production caller at all.
1504    ///
1505    /// ## The three outcomes
1506    ///
1507    /// | Result | Meaning | Written |
1508    /// |---|---|---|
1509    /// | `Ok(outcome)`, `outcome.written` | accepted, changed column | file + one decision |
1510    /// | `Ok(outcome)`, `!outcome.written` | accepted, same column | **nothing** (idempotent) |
1511    /// | `Err(TixError::Workflow(_))` | refused | **nothing at all** |
1512    ///
1513    /// The separation is in the type, not in a runtime check: a refusal is an
1514    /// `Err` carrying the **named** diagnostic, so a caller cannot read it as a
1515    /// write, and cannot mistake `unknown_column` (a typo) for `invalid_transition`
1516    /// (policy) — one message shape for both would lose the distinction the
1517    /// contract's shared vocabulary exists to keep (contract §6).
1518    ///
1519    /// ## Rejection is inert
1520    ///
1521    /// Nothing is touched before the service has accepted: no issue file, no
1522    /// `tix.toml`, no History entry. The service is pure (`src/workflow.rs` is
1523    /// filesystem-free by design), and the only write in this function is the
1524    /// single `update` at the end — so there is no window in which a refusal
1525    /// leaves a partial change behind.
1526    ///
1527    /// ## No repair path
1528    ///
1529    /// A file hand-edited into an inconsistent status is read, judged, and
1530    /// reported. This function never corrects one (C-005, decision
1531    /// `01M3C3XBMTR5WFV7059NXM04AJ`); WP06 owns the diagnosis. An auto-repair
1532    /// sneaking in here would be an escalation trigger.
1533    ///
1534    /// Routes through [`Store::update`] rather than serialising directly, so the
1535    /// retired `links:` disclosure happens on this path too. Cycle-5 Minor 9: this
1536    /// used to write the file itself, which drops the retired `links:` key
1537    /// because the key is not in the model — with **no disclosure**, since the
1538    /// disclosure lives in `update`. It was dead code reachable only from tests,
1539    /// so nothing caught it, and wiring it up would have silently destroyed the
1540    /// key on every surface at once.
1541    pub fn set_status(&self, filename: &str, request: StatusChange<'_>) -> Result<StatusOutcome> {
1542        let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1543
1544        // Once per config load, not per issue: the contract's O(V) sweep rests on
1545        // this being a single hash lookup per column rather than a rebuild.
1546        let (_, resolution) = crate::workflow::active_resolution(&self.config);
1547
1548        // The one transition evaluation (C-003). Everything below is the write this
1549        // WP is responsible for; no rule of its own lives here.
1550        let outcome = crate::workflow::validate_transition(
1551            &issue,
1552            request.column,
1553            &resolution,
1554            &self.config,
1555            request.override_reason,
1556        );
1557        // The inert case. Return before any mutation, so there is nothing to undo
1558        // and no History entry to retract.
1559        let accepted = match TransitionAccepted::try_from(outcome) {
1560            Ok(accepted) => accepted,
1561            Err(diagnostic) => return Err(TixError::Workflow(diagnostic)),
1562        };
1563
1564        // Idempotency: a same-column re-apply is accepted but writes nothing.
1565        // `updated` is deliberately *not* bumped either — a no-op that touches the
1566        // file is not a no-op, and bumping the date would make `tix edit --status`
1567        // with the column it already has look like an edit.
1568        if !accepted.changed {
1569            return Ok(StatusOutcome {
1570                issue,
1571                decision: None,
1572                written: false,
1573            });
1574        }
1575
1576        // The completion coupling, which used to live in `mv.rs`. It belongs here
1577        // because it is a rule about the *status* column: reaching completion
1578        // records a commit, and leaving it clears the one it recorded. Keeping it
1579        // in the command layer is what let `tix edit --status` move an issue to
1580        // done without recording a commit, i.e. a second definition of "done".
1581        //
1582        // WP04: both halves of this now ask the **service** whether a column is
1583        // complete, rather than comparing against `effective_done_column()`.
1584        //
1585        // This was a live defect, not a tidy-up. WP03 wrote the coupling against the
1586        // column name; WP04 moved the CLI's *prompt trigger* to the service's
1587        // decision. The two then disagreed on any board whose `category_map` puts
1588        // `completed` somewhere other than its `done_column`: the CLI correctly
1589        // decided the move reached completion and asked for a commit hash, and the
1590        // seam then compared the column to `done_column`, concluded it had not, and
1591        // **silently dropped the hash the user had just typed**. The commit-prompt
1592        // parity test is what found it.
1593        //
1594        // `was_complete` is computed before `issue.status` is reassigned below, so
1595        // it describes where the issue *was*.
1596        let was_complete = crate::workflow::is_completed(&issue, &resolution);
1597        let reaches_complete = resolution
1598            .resolve(&accepted.to)
1599            .is_some_and(crate::workflow::WorkflowCategory::is_complete);
1600        if reaches_complete {
1601            issue.commit = request.commit.map(str::to_string);
1602        } else if was_complete {
1603            issue.commit = None;
1604        }
1605
1606        issue.status = Status::new(accepted.to.clone());
1607        issue.bump_updated();
1608
1609        // Exactly one decision per accepted transition (C-006), appended to the
1610        // issue's existing History — never a second log.
1611        let decision = TransitionDecision::new(
1612            crate::model::utc_now_rfc3339(),
1613            // The existing identity path, not a new one. `actor_identity` is the
1614            // same function the relationship and hierarchy events use, so a
1615            // decision's actor is spelled the way every other History entry's is
1616            // and cannot drift from it (T012 step 4).
1617            actor_identity(),
1618            accepted.from.clone(),
1619            accepted.to.clone(),
1620            accepted.from_category.key(),
1621            accepted.to_category.key(),
1622            accepted.override_reason,
1623        );
1624        issue.history.append(decision.to_event());
1625
1626        issue.filename = self.update(filename, &issue)?;
1627        Ok(StatusOutcome {
1628            issue,
1629            decision: Some(decision),
1630            written: true,
1631        })
1632    }
1633
1634    /// Build a [`RelationshipGraph`] over every issue in the store.
1635    ///
1636    /// The service is the only place relationship semantics live, so the write
1637    /// path validates through it rather than re-deriving direction handling,
1638    /// cycle detection, or blocked state locally (C-003).
1639    /// The board's configured completion column.
1640    ///
1641    /// Read from the first configured board. Completion is always resolved
1642    /// from configuration, never by matching the literal string `done`; when no
1643    /// board configures one, the service's `done` fallback applies.
1644    ///
1645    /// Resolved from the **active** board — the one named `default`, falling back
1646    /// to the first declared, which is exactly what
1647    /// [`Config::default_config`](crate::config::Config::default_config) returns
1648    /// and what every other single-surface consumer already uses.
1649    ///
1650    /// It used to take the **first declared** board instead
1651    /// (`configurations.values().next()`). That is a different board whenever a
1652    /// configuration is declared before `default` with a different
1653    /// `done_column`, and it put two different boards inside a single decision:
1654    /// `blocked_move_guard` asked "is the target the completion column?" via
1655    /// `completion_column(config)` — the active board — and then "is this issue
1656    /// blocked?" via this function's graph — the *first declared* board. The
1657    /// guard was reasoning about two different workflows in one call.
1658    ///
1659    /// `data-model.md` §4 says completion "is always resolved through the board's
1660    /// configured `done_column`" and that the predicate is "the only definition
1661    /// used by FR-003, FR-004, the move guard, and every surface". That requires
1662    /// one board, not "whichever is declared first".
1663    pub fn relationship_done_column(&self) -> String {
1664        // WP09: the service's completion column. The name says `done_column` because
1665        // that is what the `RelationshipGraph` and `hierarchy` services take as a
1666        // parameter — re-deriving the *meaning* inside them is what this mission
1667        // ended; supplying that meaning from one place is the whole of the fix, and
1668        // it does not change either service's signature.
1669        //
1670        // Before: `effective_done_column().unwrap_or("done")`, so on a board whose
1671        // `category_map` puts `completed` on a column other than `done_column` the
1672        // blocked marker and the hierarchy rollup were computed against the wrong
1673        // column. `unwrap_or("done")` also invented a completion column on a board
1674        // that declared none.
1675        crate::workflow::completion_column_or_default(self.config.default_config(), &self.config)
1676    }
1677
1678    /// Create the typed relationship `a --kind--> b`.
1679    ///
1680    /// Selectors resolve with the same grammar as the selector-taking
1681    /// commands. The edge is stored **once**, in the frontmatter of whichever
1682    /// issue owns the canonical edge — the `from` side for a directional kind,
1683    /// and the lower-numbered side for `related`, which is order-normalized so
1684    /// both spellings of an association are one edge. No back-reference is
1685    /// written on the other issue: that symmetry was the retired model.
1686    ///
1687    /// Validation runs through the service first, so a self-link, a missing
1688    /// target, a conflicting kind, or a cycle is refused **before any file is
1689    /// touched** (SC-005). Re-asserting an edge that already exists is an
1690    /// idempotent no-op: no file is written and no History event is appended.
1691    pub fn link(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
1692        let issue_a = self.resolve_issue(a)?;
1693        let issue_b = self.resolve_issue(b)?;
1694        // The graph borrows the loaded issues, so both are built here and stay
1695        // alive until this function returns. The service is the only place
1696        // relationship semantics live (C-003).
1697        let issues = self.list_all()?;
1698        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1699        let done_column = self.relationship_done_column();
1700        let graph = RelationshipGraph::new(&views, &done_column);
1701
1702        let verdict = graph.validate_create(issue_a.number, issue_b.number, kind);
1703        if verdict.blocking() {
1704            return Err(relationship_error(&verdict.errors));
1705        }
1706
1707        // Idempotency goes through the service's own lookup, so an inverse
1708        // spelling or a non-canonical stored order still matches.
1709        if let Some(existing) = graph.find_canonical(issue_a.number, issue_b.number, kind) {
1710            // Nothing is written and no History event is appended. The **real
1711            // row** is reported rather than a synthetic one: the contract defines
1712            // the output as the accepted relationship, and an empty id is not
1713            // addressable by `edit_relation`/`remove_relation`, so returning one
1714            // hands the caller a dangling reference (F15).
1715            return Ok(RelationshipOutcome {
1716                written: None,
1717                relationship: existing.clone(),
1718                changed: false,
1719            });
1720        }
1721
1722        let canonical = CanonicalEdge::new(issue_a.number, issue_b.number, kind);
1723        let mut owner = self.get(IssueSelector::Number(canonical.from))?;
1724        let target_title = self
1725            .get(IssueSelector::Number(canonical.to))
1726            .map(|i| i.title)
1727            .unwrap_or_default();
1728        let relationship =
1729            Relationship::new(canonical.from, canonical.to, canonical.kind, &target_title);
1730        let detail = format!("created {}: {}", relationship.id, relationship.display);
1731        owner.relationships.push(relationship.clone());
1732        owner.history.append_now(
1733            &actor_identity(),
1734            HistoryEventKind::RelationshipAdded,
1735            &detail,
1736        );
1737        owner.bump_updated();
1738        owner.filename = self.update(&owner.filename, &owner)?;
1739
1740        Ok(RelationshipOutcome {
1741            written: Some(owner),
1742            relationship,
1743            changed: true,
1744        })
1745    }
1746
1747    // -- T008 — hierarchy writes ------------------------------------------
1748    //
1749    // Validate-then-write, exactly as `link` does it. The service owns the rule
1750    // and produces the History event; this layer performs the write. A blocking
1751    // result writes nothing and appends nothing (contract §4, SC-004).
1752
1753    /// The in-memory snapshot the hierarchy service reads.
1754    ///
1755    /// `pub` because the `--parent` edit flag (WP03) must validate through the
1756    /// service before any write, exactly as this module does. It is a read, so
1757    /// exposing it costs no more than a duplicate `list()` would.
1758    ///
1759    /// One `IssueView` per issue, shared with the relationship service rather than
1760    /// a second projection — two views of one issue type is how two services end up
1761    /// disagreeing about the same snapshot.
1762    ///
1763    /// The `Vec` is returned rather than discarded because the write path needs the
1764    /// child **from this same snapshot**. Re-reading it through a second `list()`
1765    /// would give the write and the validation two different notions of "current":
1766    /// two files carrying the same `number:` (reachable by hand-edit, since
1767    /// `HierarchyIndex::build` keys by number and last-write-wins while
1768    /// `Store::get` is first-match), or a concurrent writer, would make the
1769    /// service decide on one parent and the writer emit another. The invariant is
1770    /// then not merely unasserted, it is false.
1771    pub fn hierarchy_index(&self) -> Result<(HierarchyIndex, Vec<Issue>)> {
1772        let issues = self.list_all()?;
1773        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1774        Ok((HierarchyIndex::build(&views), issues))
1775    }
1776
1777    /// The child, taken from the snapshot the service just validated against.
1778    ///
1779    /// Returns an error rather than a default so a snapshot that somehow lacks the
1780    /// child is a loud failure, not a silent write of an unrelated file.
1781    fn child_in(snapshot: &[Issue], number: u32) -> Result<Issue> {
1782        snapshot
1783            .iter()
1784            .find(|i| i.number == number)
1785            .cloned()
1786            .ok_or_else(|| {
1787                TixError::NotFound(format!(
1788                    "issue #{number} vanished between validating and writing it"
1789                ))
1790            })
1791    }
1792
1793    /// Point `child` at `parent`, or repoint it if it already has one.
1794    ///
1795    /// Both selectors go through [`Store::resolve_issue`], so `#4`, `4` and a title
1796    /// fragment all work and an unresolvable one produces the CLI's own message. The
1797    /// **number** is what lands in the file, because a title is not stable.
1798    pub fn set_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
1799        let child_issue = self.resolve_issue(child)?;
1800        let parent_issue = self.resolve_issue(parent)?;
1801        let (index, issues) = self.hierarchy_index()?;
1802        let change = hierarchy::set_parent(&index, child_issue.number, parent_issue.number)?;
1803        self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1804    }
1805
1806    /// Repoint `child` at a new parent. A no-op when the parent is unchanged.
1807    pub fn change_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
1808        let child_issue = self.resolve_issue(child)?;
1809        let parent_issue = self.resolve_issue(parent)?;
1810        let (index, issues) = self.hierarchy_index()?;
1811        let change = hierarchy::change_parent(&index, child_issue.number, parent_issue.number)?;
1812        self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1813    }
1814
1815    /// Remove `child`'s parent, yielding the absent state.
1816    pub fn clear_parent(&self, child: &str) -> Result<ParentOutcome> {
1817        let child_issue = self.resolve_issue(child)?;
1818        let (index, issues) = self.hierarchy_index()?;
1819        let change = hierarchy::clear_parent(&index, child_issue.number)?;
1820        self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1821    }
1822
1823    /// Apply an accepted change: write the field and append exactly one event.
1824    ///
1825    /// The single write path for all three operations, so they cannot drift in
1826    /// whether they record what they did.
1827    /// The `issue` must come from the same snapshot the service validated against;
1828    /// see [`Store::hierarchy_index`]. That is what makes the assert below sound.
1829    fn commit_parent_change(
1830        &self,
1831        mut issue: Issue,
1832        change: hierarchy::ParentChange,
1833    ) -> Result<ParentOutcome> {
1834        let Some(event) = change.event else {
1835            // A no-op: nothing is written, nothing is read for write-back, and no
1836            // event is appended. `change.parent` is still the real current state,
1837            // because the service derived it from the snapshot, so the caller does
1838            // not have to re-read to learn it.
1839            return Ok(ParentOutcome {
1840                written: None,
1841                parent: change.parent,
1842                event: None,
1843                changed: false,
1844            });
1845        };
1846        // Read the previous value **before** overwriting it. Building the detail
1847        // after the assignment compares the new value with itself, and every
1848        // repoint was recorded as `parent #7 -> #7` — an audit trail that names
1849        // neither the old parent nor the fact that anything moved.
1850        let previous = issue.parent;
1851        let kind = HistoryEventKind::from_parent(event.kind);
1852        // The detail names both sides so a reader can follow the change without
1853        // reconstructing state from the surrounding entries. Rendered by the
1854        // service, because the `--parent` edit flag needs the identical string and
1855        // two copies of a spelling rule drift.
1856        let detail = hierarchy::change_detail(previous, change.parent);
1857        // Sound now, and it is the service's rule rather than storage's: `issue`
1858        // comes from the validated snapshot, so `previous` is by construction what
1859        // the service compared against. It was a `debug_assert_ne!` on a value
1860        // re-read through a *second* snapshot, where two files sharing a `number:`
1861        // made it fire on a correct write — panicking every dev build — and where
1862        // release builds compiled it out and wrote the contentless
1863        // `parent #N -> #N` event it was there to catch. The comment on
1864        // [`Store::hierarchy_index`] is the reason it cannot drift again.
1865        debug_assert_ne!(
1866            previous, change.parent,
1867            "an accepted change must alter the parent, or the event records nothing"
1868        );
1869        // The field only. **Nothing is written to the parent's file**: child
1870        // membership is derived by inverting this pointer, never stored (C-003).
1871        issue.parent = change.parent;
1872        issue.history.append_now(&actor_identity(), kind, &detail);
1873        issue.bump_updated();
1874        self.update(&issue.filename, &issue)?;
1875        Ok(ParentOutcome {
1876            written: Some(issue),
1877            parent: change.parent,
1878            event: Some(event),
1879            changed: true,
1880        })
1881    }
1882
1883    // -- WP03 — project associations ---------------------------------------
1884    //
1885    // Validate-then-write, exactly as `link` and the parent operations do it.
1886    // `crate::projects` is the single owner of membership, uniqueness, and
1887    // reference rules; this layer performs the write the service authorised and
1888    // holds **no project rule of its own**. It applies the list the service
1889    // returned, verbatim, and appends the one event the service produced.
1890
1891    /// Associate an issue with a project.
1892    ///
1893    /// The service resolves the project (by id, or by name as a convenience),
1894    /// enforces uniqueness, and returns the resulting list plus the History event;
1895    /// this method writes them through the existing [`Store::update`] path. A
1896    /// refused association writes nothing and appends nothing, and an
1897    /// already-present association is an idempotent no-op that does the same.
1898    pub fn associate(
1899        &self,
1900        issue_selector: &str,
1901        project_reference: &str,
1902    ) -> Result<ProjectAssociationOutcome> {
1903        let issue = self.resolve_issue(issue_selector)?;
1904        let snapshot = self.list_all()?;
1905        let change =
1906            crate::projects::associate(&self.config, &snapshot, issue.number, project_reference);
1907        self.commit_project_change(&snapshot, change)
1908    }
1909
1910    /// Remove an issue's association with a project id.
1911    ///
1912    /// `project_id` is compared **verbatim** against the stored list, as the
1913    /// service documents: a name is not resolved here, so removing a stale id that
1914    /// is no longer a valid project shape still works — the repair path C-005
1915    /// promises. Dissociating an absent association is an idempotent no-op.
1916    pub fn dissociate(
1917        &self,
1918        issue_selector: &str,
1919        project_id: &str,
1920    ) -> Result<ProjectAssociationOutcome> {
1921        let issue = self.resolve_issue(issue_selector)?;
1922        let snapshot = self.list_all()?;
1923        let change = crate::projects::dissociate(&snapshot, issue.number, project_id);
1924        self.commit_project_change(&snapshot, change)
1925    }
1926
1927    /// Associate each selector independently, writing every accepted change.
1928    ///
1929    /// One rejection must not abort the rest (contract §3): the returned vector
1930    /// has one entry per selector, in input order. Each accepted change is written
1931    /// to its own file and appends exactly one History event; a refusal writes
1932    /// nothing. An unresolvable selector becomes that entry's own error rather than
1933    /// failing the run.
1934    ///
1935    /// ## Two selectors for one issue
1936    ///
1937    /// Selectors are resolved and applied one at a time against a **working copy**
1938    /// of the issue set, advanced after every accepted write. Applying each
1939    /// selector to the one immutable read instead would let a second selector
1940    /// naming the same issue (say `1` and `2026-01-01-a.md`) miss the first write:
1941    /// the service's already-present branch would not fire and a second
1942    /// `project_associated` entry would be appended for an association that is
1943    /// already there. With the working copy the second commit *is* seen, so
1944    /// idempotence stays the service's decision rather than a membership rule
1945    /// re-implemented here, and the repeated selector is reported per selector as
1946    /// the no-op it is (`written: None`, `event: None`, `changed: false`).
1947    pub fn bulk_associate(
1948        &self,
1949        project_reference: &str,
1950        selectors: &[String],
1951    ) -> Result<Vec<BulkAssociationOutcome>> {
1952        let mut working = self.list_all()?;
1953        let mut outcomes = Vec::with_capacity(selectors.len());
1954        for selector in selectors {
1955            let outcome = match self.resolve_issue(selector) {
1956                Err(error) => Err(error),
1957                Ok(issue) => {
1958                    // One issue at a time, through the service's own mutation, so a
1959                    // duplicate selector is the service's idempotent no-op and not a
1960                    // second storage-side decision. One rejection is this entry's
1961                    // error; it never aborts the rest (contract §3).
1962                    let change = crate::projects::associate(
1963                        &self.config,
1964                        &working,
1965                        issue.number,
1966                        project_reference,
1967                    );
1968                    match self.commit_project_change(&working, change) {
1969                        Ok(applied) => {
1970                            // Advance the working copy so a later selector naming the
1971                            // same issue sees this write.
1972                            if let Some(written) = &applied.written {
1973                                if let Some(slot) = working
1974                                    .iter_mut()
1975                                    .find(|candidate| candidate.number == issue.number)
1976                                {
1977                                    *slot = written.clone();
1978                                }
1979                            }
1980                            Ok(applied)
1981                        }
1982                        Err(error) => Err(error),
1983                    }
1984                }
1985            };
1986            outcomes.push(BulkAssociationOutcome {
1987                selector: selector.clone(),
1988                outcome,
1989            });
1990        }
1991        Ok(outcomes)
1992    }
1993
1994    /// Apply a service decision: write only an accepted change and append its event.
1995    ///
1996    /// The single write path for associate/dissociate/bulk, so they cannot drift in
1997    /// whether they record what they did. `snapshot` must be the issue set the
1998    /// service validated against, exactly as [`Store::commit_parent_change`]
1999    /// requires: the written list is then the one the service derived rather than a
2000    /// second, possibly stale, read.
2001    fn commit_project_change(
2002        &self,
2003        snapshot: &[Issue],
2004        change: std::result::Result<crate::projects::ProjectChange, crate::projects::ProjectError>,
2005    ) -> Result<ProjectAssociationOutcome> {
2006        let change = change.map_err(TixError::from)?;
2007        let Some(event) = change.event else {
2008            // A no-op: nothing is written and no event is appended. The current
2009            // list is still reported, so the caller does not have to re-read.
2010            return Ok(ProjectAssociationOutcome {
2011                written: None,
2012                project_ids: change.project_ids,
2013                event: None,
2014                changed: false,
2015            });
2016        };
2017        let mut issue = snapshot
2018            .iter()
2019            .find(|issue| issue.number == change.issue.number())
2020            .cloned()
2021            .ok_or_else(|| {
2022                TixError::NotFound(format!(
2023                    "issue #{} vanished between validating and writing it",
2024                    change.issue.number()
2025                ))
2026            })?;
2027        // The service's list, verbatim: storage does not re-derive uniqueness or
2028        // membership. A rule implemented in two places is a defect, so the only
2029        // copy of it lives in `crate::projects`.
2030        issue.projects = Some(change.project_ids.clone());
2031        // Exactly one event per accepted change, appended to the issue's existing
2032        // History — never a second log (T012/R5).
2033        let kind = HistoryEventKind::from_project(event.kind);
2034        issue
2035            .history
2036            .append_now(&actor_identity(), kind, &event.detail);
2037        issue.bump_updated();
2038        issue.filename = self.update(&issue.filename, &issue)?;
2039        Ok(ProjectAssociationOutcome {
2040            written: Some(issue),
2041            project_ids: change.project_ids,
2042            event: Some(event),
2043            changed: true,
2044        })
2045    }
2046
2047    // -- WP03 — epic references --------------------------------------------
2048    //
2049    // Validate-then-write, exactly as `link`, the parent operations, and the
2050    // project associations do it. `crate::epics` is the single owner of
2051    // reference rules; this layer performs the write the service authorised and
2052    // holds **no epic rule of its own**. It applies the list the service
2053    // returned, verbatim, and appends the one event the service produced.
2054
2055    /// Reference an epic from an issue.
2056    ///
2057    /// The service resolves the epic (by id, or by name as a convenience),
2058    /// enforces `(issue, epic_id)` uniqueness, and returns the resulting list
2059    /// plus the History event; this method writes them through the existing
2060    /// [`Store::update`] path. A refused reference writes nothing and appends
2061    /// nothing, and an already-present reference is an idempotent no-op that does
2062    /// the same.
2063    pub fn reference_epic(
2064        &self,
2065        issue_selector: &str,
2066        epic_reference: &str,
2067    ) -> Result<EpicReferenceOutcome> {
2068        let issue = self.resolve_issue(issue_selector)?;
2069        let snapshot = self.list_all()?;
2070        let change =
2071            crate::epics::reference_epic(&self.config, &snapshot, issue.number, epic_reference);
2072        self.commit_epic_change(&snapshot, change)
2073    }
2074
2075    /// Remove an issue's reference to an epic id.
2076    ///
2077    /// `epic_id` is compared **verbatim** against the stored list, as the service
2078    /// documents: a name is not resolved here, so removing a stale id that is no
2079    /// longer a valid epic shape still works — the repair path C-005 promises.
2080    /// Dereferencing an absent reference is an idempotent no-op.
2081    pub fn dereference_epic(
2082        &self,
2083        issue_selector: &str,
2084        epic_id: &str,
2085    ) -> Result<EpicReferenceOutcome> {
2086        let issue = self.resolve_issue(issue_selector)?;
2087        let snapshot = self.list_all()?;
2088        let change = crate::epics::dereference_epic(&snapshot, issue.number, epic_id);
2089        self.commit_epic_change(&snapshot, change)
2090    }
2091
2092    /// Reference an epic from each selector independently, writing every accepted
2093    /// change.
2094    ///
2095    /// One rejection must not abort the rest (`contracts/epic-service.md` §5): the
2096    /// returned vector has one entry per selector, in input order. Each accepted
2097    /// change is written to its own file and appends exactly one History event; a
2098    /// refusal writes nothing. An unresolvable selector becomes that entry's own
2099    /// error rather than failing the run.
2100    ///
2101    /// ## Two selectors for one issue
2102    ///
2103    /// Selectors are resolved and applied one at a time against a **working copy**
2104    /// of the issue set, advanced after every accepted write — the same reasoning
2105    /// as [`Store::bulk_associate`]. Applying each selector to the one immutable
2106    /// read would let a second selector naming the same issue miss the first
2107    /// write, so the service's already-present branch would not fire and a second
2108    /// `epic_referenced` entry would be appended for a reference that is already
2109    /// there. With the working copy the second commit *is* seen, so idempotence
2110    /// stays the service's decision rather than a membership rule re-implemented
2111    /// here.
2112    pub fn bulk_reference(
2113        &self,
2114        epic_reference: &str,
2115        selectors: &[String],
2116    ) -> Result<Vec<BulkReferenceOutcome>> {
2117        let mut working = self.list_all()?;
2118        let mut outcomes = Vec::with_capacity(selectors.len());
2119        for selector in selectors {
2120            let outcome = match self.resolve_issue(selector) {
2121                Err(error) => Err(error),
2122                Ok(issue) => {
2123                    let change = crate::epics::reference_epic(
2124                        &self.config,
2125                        &working,
2126                        issue.number,
2127                        epic_reference,
2128                    );
2129                    match self.commit_epic_change(&working, change) {
2130                        Ok(applied) => {
2131                            if let Some(written) = &applied.written {
2132                                if let Some(slot) = working
2133                                    .iter_mut()
2134                                    .find(|candidate| candidate.number == issue.number)
2135                                {
2136                                    *slot = written.clone();
2137                                }
2138                            }
2139                            Ok(applied)
2140                        }
2141                        Err(error) => Err(error),
2142                    }
2143                }
2144            };
2145            outcomes.push(BulkReferenceOutcome {
2146                selector: selector.clone(),
2147                outcome,
2148            });
2149        }
2150        Ok(outcomes)
2151    }
2152
2153    /// Apply a service decision: write only an accepted change and append its
2154    /// event.
2155    ///
2156    /// The single write path for reference/dereference/bulk, so they cannot drift
2157    /// in whether they record what they did. `snapshot` must be the issue set the
2158    /// service validated against, exactly as [`Store::commit_project_change`]
2159    /// requires: the written list is then the one the service derived rather than
2160    /// a second, possibly stale, read.
2161    fn commit_epic_change(
2162        &self,
2163        snapshot: &[Issue],
2164        change: std::result::Result<crate::epics::EpicChange, crate::epics::EpicError>,
2165    ) -> Result<EpicReferenceOutcome> {
2166        let change = change.map_err(TixError::from)?;
2167        let Some(event) = change.event else {
2168            // A no-op: nothing is written and no event is appended. The current
2169            // list is still reported, so the caller does not have to re-read.
2170            return Ok(EpicReferenceOutcome {
2171                written: None,
2172                epic_ids: change.epic_ids,
2173                event: None,
2174                changed: false,
2175            });
2176        };
2177        let mut issue = snapshot
2178            .iter()
2179            .find(|issue| issue.number == change.issue.number())
2180            .cloned()
2181            .ok_or_else(|| {
2182                TixError::NotFound(format!(
2183                    "issue #{} vanished between validating and writing it",
2184                    change.issue.number()
2185                ))
2186            })?;
2187        // The service's list, verbatim: storage does not re-derive uniqueness or
2188        // membership. A rule implemented in two places is a defect, so the only
2189        // copy of it lives in `crate::epics`.
2190        issue.epics = Some(change.epic_ids.clone());
2191        // Exactly one event per accepted change, appended to the issue's existing
2192        // History — never a second log.
2193        let kind = HistoryEventKind::from_epic(event.kind);
2194        issue
2195            .history
2196            .append_now(&actor_identity(), kind, &event.detail);
2197        issue.bump_updated();
2198        issue.filename = self.update(&issue.filename, &issue)?;
2199        Ok(EpicReferenceOutcome {
2200            written: Some(issue),
2201            epic_ids: change.epic_ids,
2202            event: Some(event),
2203            changed: true,
2204        })
2205    }
2206
2207    /// Every relationship touching `number`, as the service resolves it.
2208    ///
2209    /// **This is what a read surface must render**, not `issue.relationships`.
2210    /// A relationship is stored once, in its canonical source's frontmatter, so
2211    /// the stored field is a *partial* view of one endpoint: for `2 related 1`
2212    /// the edge lives in #1's file, so #2's own `relationships` is empty even
2213    /// though #2 plainly has a relationship. Reading the raw field made `tix
2214    /// view 2` and the TUI detail pane show nothing where the service reports
2215    /// one, which is a user-visible regression against the retired symmetric
2216    /// model — the old field was mirrored on both sides by construction.
2217    ///
2218    /// Routing every surface through one accessor is also what keeps C-003
2219    /// honest: derived relationship state is computed in exactly one place.
2220    pub fn relationships_for(&self, number: u32) -> Result<Vec<Relationship>> {
2221        let issues = self.list_all()?;
2222        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
2223        let done_column = self.relationship_done_column();
2224        let graph = RelationshipGraph::new(&views, &done_column);
2225        Ok(graph
2226            .relationships_for(number)
2227            .into_iter()
2228            .cloned()
2229            .collect())
2230    }
2231
2232    /// **Every** stored relationship, in deterministic order — the whole graph in
2233    /// one pass.
2234    ///
2235    /// [`Store::relationships_for`] answers the same question for one issue, and
2236    /// **each of its calls reads the whole tree and rebuilds the graph**. A caller
2237    /// that wants every edge must therefore ask once: an `N`-issue loop over
2238    /// `relationships_for` is `N` full scans of `N` files, which at this
2239    /// repository's size is millions of file reads for one export. This is the
2240    /// accessor for that caller.
2241    ///
2242    /// Archived issues are included, exactly as [`Store::relationships_for`]
2243    /// includes them — the graph is the same one, built the same way, so the two
2244    /// agree about which edges exist.
2245    pub fn all_relationships(&self) -> Result<Vec<Relationship>> {
2246        let issues = self.list_all()?;
2247        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
2248        let done_column = self.relationship_done_column();
2249        let graph = RelationshipGraph::new(&views, &done_column);
2250        Ok(graph.all_relationships().to_vec())
2251    }
2252
2253    /// Remove the typed relationship `a --kind--> b`.
2254    ///
2255    /// Removing an edge that is not there is an idempotent no-op: no file is
2256    /// written and no History event is appended.
2257    pub fn unlink(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
2258        let canonical = self.canonical_request(a, b, kind)?;
2259        let stored = self.find_stored_rows(&canonical)?;
2260
2261        if stored.is_empty() {
2262            // Idempotent no-op: nothing is written and no History event is
2263            // appended. The reported relationship is the canonical form the
2264            // caller asked about.
2265            return Ok(RelationshipOutcome {
2266                written: None,
2267                relationship: canonical_placeholder(&canonical),
2268                changed: false,
2269            });
2270        }
2271
2272        // Remove every row matching the canonical edge, wherever it is stored,
2273        // and record **one** History event for the accepted mutation. Redundant
2274        // rows are one invalid condition to clean up, not N mutations
2275        // (contract §6: one event per accepted mutation).
2276        let mut by_file: HashMap<String, Vec<Relationship>> = HashMap::new();
2277        for (filename, rel) in stored {
2278            by_file.entry(filename).or_default().push(rel);
2279        }
2280
2281        let actor = actor_identity();
2282        // Ascending filename, for the same reproducibility reason as above.
2283        let mut file_order: Vec<String> = by_file.keys().cloned().collect();
2284        file_order.sort_unstable();
2285
2286        // The first row this call removes, captured before anything is written.
2287        // M11: a re-scan *after* removal can never match — every matching row is
2288        // gone — so a successful unlink always fell through to the empty
2289        // placeholder and reported an unaddressable relationship, while `link`'s
2290        // no-op path reported a real one. F15 was applied to one path, not both.
2291        let mut removed_row: Option<Relationship> = None;
2292
2293        // M10: the summary is computed **once**, from every row this call
2294        // removes, and the same event is recorded on each file that changes.
2295        // Appending per file produced two events for one `unlink` when the same
2296        // edge was hand-authored into both endpoints, while the comment above
2297        // claimed one event per accepted mutation. One unlink is one mutation.
2298        let all_removed: Vec<Relationship> = by_file.values().flatten().cloned().collect();
2299        let summary = if all_removed.len() == 1 {
2300            let one = &all_removed[0];
2301            format!("removed {}: {}", one.id, one.display)
2302        } else {
2303            let kinds: Vec<String> = all_removed.iter().map(|r| r.kind.to_string()).collect();
2304            format!(
2305                "removed {} relationships ({})",
2306                all_removed.len(),
2307                kinds.join(", ")
2308            )
2309        };
2310
2311        let mut written: Option<Issue> = None;
2312        for filename in &file_order {
2313            let doomed = &by_file[filename];
2314            let mut owner = self.get(IssueSelector::Filename(filename.clone()))?;
2315            if removed_row.is_none() {
2316                removed_row = doomed.first().cloned();
2317            }
2318            owner.relationships.retain(|r| r.canonical() != canonical);
2319            owner
2320                .history
2321                .append_now(&actor, HistoryEventKind::RelationshipRemoved, &summary);
2322            owner.bump_updated();
2323            owner.filename = self.update(&owner.filename, &owner)?;
2324            written = Some(owner);
2325        }
2326
2327        // The row that *was* removed, captured above — an empty id is not
2328        // addressable by `edit_relation`/`remove_relation`.
2329        let relationship = removed_row.unwrap_or_else(|| canonical_placeholder(&canonical));
2330
2331        Ok(RelationshipOutcome {
2332            written,
2333            relationship,
2334            changed: true,
2335        })
2336    }
2337
2338    /// Retarget and/or re-kind the relationship with this stable id.
2339    ///
2340    /// The id is generated once and **survives** the edit, so a relationship a
2341    /// user has already referenced elsewhere does not change identity when its
2342    /// target moves (contract §1). `new_to` and `new_kind` are both optional and
2343    /// naming neither is the identity edit: nothing is written and no History
2344    /// event is appended.
2345    ///
2346    /// Validation goes through [`RelationshipGraph::validate_edit`], so an edit
2347    /// that would self-link, point at a missing issue, duplicate an existing
2348    /// edge, or close a cycle is refused **before any file is touched**
2349    /// (SC-005). An edit may change the *kind* but never the source: the source
2350    /// is the edge's owner, and moving it would mean deleting a relationship and
2351    /// creating another, which is what `remove` plus `link` is for.
2352    pub fn edit_relation(
2353        &self,
2354        id: &str,
2355        new_to: Option<u32>,
2356        new_kind: Option<RelationKind>,
2357    ) -> Result<RelationshipOutcome> {
2358        let issues = self.list_all()?;
2359        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
2360        let done_column = self.relationship_done_column();
2361        let graph = RelationshipGraph::new(&views, &done_column);
2362
2363        let Some(existing) = graph.find_relationship(id) else {
2364            // An id that does not resolve is a named diagnostic, not a bare
2365            // `NotFound`: the user passed a well-formed argument, and what is
2366            // missing is a relationship, not an issue.
2367            let mut verdict = crate::relationships::RelationshipValidationResult::clean();
2368            verdict.errors.push(Diagnostic::unknown_relation(id));
2369            return Err(relationship_error(&verdict.errors));
2370        };
2371
2372        let verdict = graph.validate_edit(id, new_to, new_kind);
2373        if verdict.blocking() {
2374            return Err(relationship_error(&verdict.errors));
2375        }
2376
2377        let to = new_to.unwrap_or(existing.to);
2378        let kind = new_kind.unwrap_or(existing.kind);
2379        let old_canonical = existing.canonical();
2380
2381        // The new form has to be **canonicalized before anything else is derived
2382        // from it**, not merely validated. `--type blocked_by` and re-kinding
2383        // `related` change which issue owns the edge *and* which issue it points
2384        // at: `A --blocked_by--> B` is stored as `B blocks A`, so the canonical
2385        // target is the authored `from`, not the authored `to`.
2386        //
2387        // Deriving the display name from the *authored* target is what produced
2388        // `blocks #2 Issue A` for a `2 duplicates 0` edge re-kinded to
2389        // `blocked_by`: right endpoints, wrong title, and a stored row that lies
2390        // about the issue it names. Canonicalize first, then read the title off
2391        // the canonical target.
2392        let requested = CanonicalEdge::new(existing.from, to, kind);
2393        let mut updated = existing.clone();
2394        updated.from = requested.from;
2395        updated.to = requested.to;
2396        updated.kind = requested.kind;
2397
2398        // The target's *current* title, from the canonical target. A target that
2399        // does not resolve would otherwise store an edge with an empty display
2400        // name, so this stays lenient and the row carries what it can.
2401        let target_title = self
2402            .get(IssueSelector::Number(updated.to))
2403            .map(|i| i.title)
2404            .unwrap_or_default();
2405        updated.refresh_display(&target_title);
2406        let new_canonical = updated.canonical();
2407
2408        // No-op detection is on the *resolved* edge, not on the arguments: an
2409        // edit naming its own current target and kind is the identity, and so is
2410        // an edit that only re-renders the same display.
2411        if old_canonical == new_canonical && updated.display == existing.display {
2412            return Ok(RelationshipOutcome {
2413                written: None,
2414                relationship: existing.clone(),
2415                changed: false,
2416            });
2417        }
2418
2419        let actor = actor_identity();
2420        let detail = format!("{} → {}: {}", existing.display, updated.display, updated.id);
2421
2422        // Remove the old row from every file that holds it, without recording
2423        // a `relationship_removed` event: this is one edit, not a delete plus a
2424        // create. Only the canonical source is written when ownership does not
2425        // move, so the common case stays a single-file write.
2426        // Removal and insertion are **separate passes**, because the canonical
2427        // source of the new form is not necessarily a file that held the old
2428        // one. Re-kinding can move ownership — `related` is order-normalised and
2429        // `blocked_by` swaps the endpoints — and when it does, a loop that both
2430        // removes and re-adds deletes the row from its old owner and never
2431        // writes it to the new one. The relationship is then destroyed while the
2432        // call reports success, and a `blocked_by` re-kind reports a `blocks`
2433        // edge that does not exist, so every derived read is wrong the same way.
2434        //
2435        // Two passes make that shape unrepresentable: the first only ever
2436        // removes, the second only ever adds.
2437        let mut removed_anywhere = false;
2438        for issue in self.list_all()? {
2439            if !issue
2440                .relationships
2441                .iter()
2442                .any(|r| r.id == id && r.canonical() == old_canonical)
2443            {
2444                continue;
2445            }
2446            let mut owner = self.get(IssueSelector::Filename(issue.filename.clone()))?;
2447            owner
2448                .relationships
2449                .retain(|r| !(r.id == id && r.canonical() == old_canonical));
2450            removed_anywhere = true;
2451            owner.bump_updated();
2452            owner.filename = self.update(&owner.filename, &owner)?;
2453        }
2454        debug_assert!(removed_anywhere, "graph found the row, the scan did not");
2455
2456        // The new canonical source carries the row and the History event. It may
2457        // be the file just written, a different file, or one the old form never
2458        // touched, so all three cases are written here rather than inside the
2459        // removal loop.
2460        let mut new_owner = self.get(IssueSelector::Number(new_canonical.from))?;
2461        new_owner.relationships.push(updated.clone());
2462        new_owner
2463            .history
2464            .append_now(&actor, HistoryEventKind::RelationshipRetargeted, &detail);
2465        new_owner.bump_updated();
2466        new_owner.filename = self.update(&new_owner.filename, &new_owner)?;
2467
2468        Ok(RelationshipOutcome {
2469            written: Some(self.get(IssueSelector::Number(new_canonical.from))?),
2470            relationship: updated,
2471            changed: true,
2472        })
2473    }
2474
2475    /// Remove the relationship with this stable id.
2476    ///
2477    /// An id that is not stored is an **idempotent no-op**: no file is written
2478    /// and no History event is appended, exactly as [`Store::unlink`] treats an
2479    /// absent edge. `tix link remove` is therefore safe to retry.
2480    pub fn remove_relation(&self, id: &str) -> Result<RelationshipOutcome> {
2481        let stored = self.find_stored_rows_by_id(id)?;
2482        if stored.is_empty() {
2483            // Reported as a no-op rather than an error, and the reported
2484            // relationship carries the id the caller asked about so the outcome
2485            // is addressable in the message.
2486            return Ok(RelationshipOutcome {
2487                written: None,
2488                relationship: Relationship {
2489                    id: id.to_string(),
2490                    ..canonical_placeholder(&CanonicalEdge::new(0, 0, RelationKind::Related))
2491                },
2492                changed: false,
2493            });
2494        }
2495
2496        let canonical = stored[0].1.canonical();
2497        // Reuse the edge-keyed removal so both spellings of a remove stay one
2498        // implementation, including redundant-row cleanup and the single
2499        // History event per accepted mutation.
2500        let from = canonical.from.to_string();
2501        let to = canonical.to.to_string();
2502        self.unlink(&from, &to, canonical.kind)
2503    }
2504
2505    /// Every stored row with this id, paired with the store-relative filename
2506    /// it lives in.
2507    ///
2508    /// Ids are unique per relationship, but a hand-edited file can hold the same
2509    /// id twice, so this returns all of them and the caller removes all of them.
2510    fn find_stored_rows_by_id(&self, id: &str) -> Result<Vec<(String, Relationship)>> {
2511        let mut found = Vec::new();
2512        for issue in self.list_all()? {
2513            for rel in &issue.relationships {
2514                if rel.id == id {
2515                    found.push((issue.filename.clone(), rel.clone()));
2516                }
2517            }
2518        }
2519        Ok(found)
2520    }
2521
2522    /// The issue set plus the board's configured completion column, for
2523    /// building a [`RelationshipGraph`] over the whole store.
2524    ///
2525    /// The issue set is returned **alongside** the column rather than as a
2526    /// finished graph because the graph borrows the issues: a caller that wants
2527    /// three derived reads (`relationships_for`, `blocked_state`,
2528    /// `dependency_chain`) over one snapshot would otherwise pay three
2529    /// separate `O(V + E)` builds and risk the three disagreeing.
2530    ///
2531    /// This is the single place a read surface obtains its graph, so no surface
2532    /// can reach past the service to re-derive direction handling, cycle
2533    /// detection, or blocked state (C-003).
2534    ///
2535    /// Reads the **active** set ([`Store::list`]): it is the graph a default
2536    /// view renders — `tix list --show relationships`, `tix relationship list`
2537    /// — so archived issues are deliberately absent from it (issue #1492).
2538    /// Per-issue lookups that must still resolve an archived issue go through
2539    /// [`Store::relationships_for`], which reads [`Store::list_all`].
2540    pub fn graph_source(&self) -> Result<(Vec<Issue>, String)> {
2541        Ok((self.list()?, self.relationship_done_column()))
2542    }
2543
2544    /// Resolve a requested pair into the canonical edge it refers to, without
2545    /// requiring the *target* to exist.
2546    ///
2547    /// A dangling edge is a diagnosed, expected condition (`data-model.md` §4,
2548    /// contract §7), so the removal path must still be able to reach it. Both
2549    /// selectors are therefore resolved leniently: a selector that fails to
2550    /// resolve is accepted if it is a bare issue number, because that is enough
2551    /// to canonicalize the request. Anything else is still `NotFound`.
2552    fn canonical_request(&self, a: &str, b: &str, kind: RelationKind) -> Result<CanonicalEdge> {
2553        let lenient = |sel: &str| -> Result<u32> {
2554            match self.resolve_issue(sel) {
2555                Ok(issue) => Ok(issue.number),
2556                Err(e) => sel.trim().parse::<u32>().map_err(|_| e),
2557            }
2558        };
2559        let from = lenient(a)?;
2560        let to = lenient(b)?;
2561        Ok(CanonicalEdge::new(from, to, kind))
2562    }
2563
2564    /// Every stored row matching `canonical`, paired with the store-relative
2565    /// filename it actually lives in.
2566    ///
2567    /// This is the single answer to "where is this edge stored", and it is the
2568    /// reason the three entry points no longer disagree. It matches on
2569    /// [`Relationship::canonical`] — the service's own normalization — rather
2570    /// than comparing authored fields, so a row is found no matter which file
2571    /// holds it or which endpoint order it was written in. Two defects came
2572    /// from re-deriving that comparison locally: a hand-authored
2573    /// non-canonically-ordered `related` row was un-removable, and a row
2574    /// persisted with the input-only `blocked_by` spelling was un-removable too.
2575    ///
2576    /// Cost is one `O(V + E)` pass, the same order as the validation the write
2577    /// path already performs. Correctness of "which file holds this edge" is
2578    /// worth it; a wrong answer here silently deletes nothing.
2579    fn find_stored_rows(&self, canonical: &CanonicalEdge) -> Result<Vec<(String, Relationship)>> {
2580        let mut found = Vec::new();
2581        for issue in self.list_all()? {
2582            for rel in &issue.relationships {
2583                if rel.canonical() == *canonical {
2584                    found.push((issue.filename.clone(), rel.clone()));
2585                }
2586            }
2587        }
2588        Ok(found)
2589    }
2590
2591    /// Make the issue's relationship set exactly `targets`, with `kind`.
2592    ///
2593    /// A typed relationship is stored **once, in its canonical source's file**.
2594    /// For a directional kind that is the declaring issue; for `related`, which
2595    /// is order-normalized, it is the lower-numbered of the pair. So this method
2596    /// may have to write more than one file: an edge requested from an issue
2597    /// that is not its canonical source belongs to the other issue's frontmatter.
2598    /// That is the same ownership rule [`Store::link`] already follows, and it
2599    /// is what "stored once, no mirrored edge" requires.
2600    ///
2601    /// Every target number must already resolve (`NotFound` otherwise) and a
2602    /// self-link is refused — both before anything is written (SC-005).
2603    /// Relationships that survive keep their stable id and their History
2604    /// entries; removals are recorded.
2605    pub fn set_relationships(
2606        &self,
2607        filename: &str,
2608        targets: &[(u32, RelationKind)],
2609    ) -> Result<Issue> {
2610        let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
2611        let own = issue.number;
2612        let actor = actor_identity();
2613
2614        // Validate every target up front, before any mutation (SC-005).
2615        let mut wanted: Vec<CanonicalEdge> = Vec::with_capacity(targets.len());
2616        for (target, kind) in targets {
2617            if *target == own {
2618                return Err(TixError::InvalidInput(format!(
2619                    "issue #{own} cannot relate to itself"
2620                )));
2621            }
2622            self.get(IssueSelector::Number(*target))?;
2623            let canonical = CanonicalEdge::new(own, *target, *kind);
2624            // Identical canonical triples collapse, so asking for the same edge
2625            // twice is not two writes and not a self-conflict.
2626            if !wanted.contains(&canonical) {
2627                wanted.push(canonical);
2628            }
2629        }
2630
2631        // Every wanted edge is validated **through the service** before the
2632        // first write, exactly as `link` does. This used to re-implement
2633        // existence and the self-link rule locally and skip the rest, so with
2634        // `1 blocks 2` already on disk it would happily store `2 blocks 1` — a
2635        // reciprocal pair that `link` rejects one line earlier and that
2636        // contract §3 rules 3 and 4 require be refused. Re-deriving part of the
2637        // rule set is also the boundary breach C-003 exists to prevent, and it
2638        // becomes user-reachable the moment WP03 wires `--type`.
2639        {
2640            let issues = self.list_all()?;
2641            let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
2642            let done_column = self.relationship_done_column();
2643            let graph = RelationshipGraph::new(&views, &done_column);
2644            for canonical in &wanted {
2645                let verdict = graph.validate_create(canonical.from, canonical.to, canonical.kind);
2646                if verdict.blocking() {
2647                    return Err(relationship_error(&verdict.errors));
2648                }
2649            }
2650        }
2651
2652        // Remove: every existing relationship this issue owns that is not
2653        // wanted, grouped by the file that actually stores it.
2654        //
2655        // "Owns" has two cases, and getting this wrong is a silent data bug:
2656        //
2657        //  - The canonical `from` is this issue. True for every directional
2658        //    kind, and for `related` whenever this issue has the lower number.
2659        //  - The canonical `to` is this issue **and** the kind is
2660        //    non-directional. A `related` association belongs to both
2661        //    endpoints even though it is stored once, in the lower-numbered
2662        //    issue's file. `tix edit 5 --links ""` has to remove a
2663        //    `5 related 3` edge even though it lives in #3's frontmatter and
2664        //    #5's own file is empty.
2665        //
2666        // An *inbound directional* edge is deliberately excluded: if #3 blocks
2667        // #5, that edge is #3's declaration, and #5 clearing its own
2668        // relationship set must not silently rewrite #3's dependency graph.
2669        let issues = self.list_all()?;
2670        let mut removals: HashMap<u32, Vec<Relationship>> = HashMap::new();
2671        for candidate in &issues {
2672            for rel in &candidate.relationships {
2673                let canonical = rel.canonical();
2674                let owned_by_us = canonical.from == own
2675                    || (canonical.to == own && !canonical.kind.is_directional());
2676                if owned_by_us && !wanted.contains(&canonical) {
2677                    removals
2678                        .entry(canonical.from)
2679                        .or_default()
2680                        .push(rel.clone());
2681                }
2682            }
2683        }
2684
2685        // Add: each wanted edge goes into its canonical source's file. One
2686        // already present is left alone, so the stable id survives.
2687        let mut additions: HashMap<u32, Vec<(CanonicalEdge, String)>> = HashMap::new();
2688        for canonical in &wanted {
2689            let owner = match self.get(IssueSelector::Number(canonical.from)) {
2690                Ok(owner) => owner,
2691                Err(TixError::NotFound(_)) => continue,
2692                Err(e) => return Err(e),
2693            };
2694            if owner
2695                .relationships
2696                .iter()
2697                .any(|r| r.canonical() == *canonical)
2698            {
2699                continue;
2700            }
2701            let target_title = self
2702                .get(IssueSelector::Number(canonical.to))
2703                .map(|i| i.title)
2704                .unwrap_or_default();
2705            additions
2706                .entry(canonical.from)
2707                .or_default()
2708                .push((*canonical, target_title));
2709        }
2710
2711        if removals.is_empty() && additions.is_empty() {
2712            return Ok(issue);
2713        }
2714
2715        // Apply removals, then additions. This can write **N** files — the
2716        // removals map is keyed by whichever file actually holds each row, and
2717        // an order-normalized `related` edge is held by the lower-numbered
2718        // issue, not the one named by `filename`. A failure partway through
2719        // would therefore leave the store half-mutated, so every file's
2720        // pre-write contents are captured on the way through and restored if a
2721        // later write fails (SC-005: no file is left partially rewritten).
2722        //
2723        // The restore is best-effort per file and the original error is
2724        // returned either way: a rollback that itself fails must not mask the
2725        // cause.
2726        // Every file's pre-write contents are captured on the way through and
2727        // restored if a later write in the same call fails, so a refused
2728        // mutation leaves nothing behind (SC-005). The restore is best-effort
2729        // per file and the original error is returned either way: a rollback
2730        // that itself fails must not mask the cause.
2731        // Owners are written in ascending number order. `removals` and
2732        // `additions` are `HashMap`s, so iterating them directly makes the write
2733        // order — and therefore which files are committed before a failure —
2734        // differ between runs of the same input.
2735        let mut removal_order: Vec<u32> = removals.keys().copied().collect();
2736        removal_order.sort_unstable();
2737        let mut addition_order: Vec<u32> = additions.keys().copied().collect();
2738        addition_order.sort_unstable();
2739
2740        // Write one owner, remembering how to undo it. A nested `fn` rather than
2741        // a closure so `undo` and `touched_named` can be passed explicitly
2742        // instead of captured, which would make them unborrowable at the
2743        // call sites.
2744        fn apply(
2745            store: &Store,
2746            mut owner: Issue,
2747            undo: &mut Vec<(PathBuf, String)>,
2748            touched_named: &mut bool,
2749            named_path: &Path,
2750        ) -> Result<()> {
2751            let path = store.issues_dir.join(&owner.filename);
2752            if path == named_path {
2753                *touched_named = true;
2754            }
2755            if let Ok(previous) = std::fs::read_to_string(&path) {
2756                undo.push((path, previous));
2757            }
2758            owner.bump_updated();
2759            store.update(&owner.filename, &owner).map(|_| ())
2760        }
2761
2762        let named_path = self.issues_dir.join(&issue.filename);
2763        let mut undo: Vec<(PathBuf, String)> = Vec::new();
2764        let mut touched_named = false;
2765
2766        let applied = (|| -> Result<()> {
2767            for owner_number in &removal_order {
2768                let doomed = &removals[owner_number];
2769                let mut owner = self.get(IssueSelector::Number(*owner_number))?;
2770                let mut changed = false;
2771                for rel in doomed {
2772                    let canonical = rel.canonical();
2773                    let before = owner.relationships.len();
2774                    owner.relationships.retain(|r| r.canonical() != canonical);
2775                    if owner.relationships.len() != before {
2776                        let detail = format!("removed {}: {}", rel.id, rel.display);
2777                        owner.history.append_now(
2778                            &actor,
2779                            HistoryEventKind::RelationshipRemoved,
2780                            &detail,
2781                        );
2782                        changed = true;
2783                    }
2784                }
2785                if changed {
2786                    apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
2787                }
2788            }
2789
2790            for owner_number in &addition_order {
2791                let wanted_here = &additions[owner_number];
2792                let mut owner = self.get(IssueSelector::Number(*owner_number))?;
2793                for (canonical, target_title) in wanted_here {
2794                    let rel = Relationship::new(
2795                        canonical.from,
2796                        canonical.to,
2797                        canonical.kind,
2798                        target_title,
2799                    );
2800                    let detail = format!("created {}: {}", rel.id, rel.display);
2801                    owner
2802                        .history
2803                        .append_now(&actor, HistoryEventKind::RelationshipAdded, &detail);
2804                    owner.relationships.push(rel);
2805                }
2806                apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
2807            }
2808
2809            // F16. The named issue's own frontmatter can be untouched while its
2810            // relationship set changed, because an order-normalized `related`
2811            // edge is stored in the *other* endpoint's file. `tix edit 2 --links
2812            // 1` then left #2 byte-identical with a stale `updated`, so the
2813            // command appeared to do nothing to the issue it named and the audit
2814            // trail lived only on the other endpoint. `updated` tracks the
2815            // issue's last mutation and its relationship set did change.
2816            //
2817            // Inside the rolled-back sequence and through the same `apply`, so
2818            // its file is captured for restore like every other. Placed after the
2819            // rollback check it was a fresh partial-write hole: a failure here
2820            // returned an error with the relationship writes already committed
2821            // and nothing left to restore.
2822            if !touched_named {
2823                let named = self.get(IssueSelector::Filename(filename.to_string()))?;
2824                apply(self, named, &mut undo, &mut touched_named, &named_path)?;
2825            }
2826            Ok(())
2827        })();
2828
2829        if let Err(e) = applied {
2830            for (path, previous) in undo.iter().rev() {
2831                let _ = atomic_write(&self.issues_dir, path, previous.as_bytes());
2832            }
2833            return Err(e);
2834        }
2835
2836        self.get(IssueSelector::Filename(filename.to_string()))
2837    }
2838
2839    /// Resolve a selector string exactly like the selector-taking commands
2840    /// (`parse_selector`) and fetch the issue (D2: "Resolver SHALL match
2841    /// `tix edit`/`tix move`").
2842    ///
2843    /// `pub` because the `--parent` flags (WP03) resolve a parent selector to its
2844    /// **number** through this same path, so `#4`, `4` and a title fragment all work
2845    /// on a parent exactly as they do on any other selector — and a second
2846    /// resolver in the command layer is how two surfaces end up disagreeing about
2847    /// which issue a selector names.
2848    pub fn resolve_issue(&self, raw: &str) -> Result<Issue> {
2849        let selector = crate::commands::parse_selector(self, raw)?;
2850        self.get(selector)
2851    }
2852
2853    /// Physically move an issue file into the assignee segment of a new
2854    /// assignee (FR-031, `contracts/file-format.md` rule 9).
2855    ///
2856    /// `filename` is a path relative to the issues dir: either a legacy flat
2857    /// basename (`2026-05-10-fix-login.md`, C-010) or a grouped path
2858    /// (`alice/2026-05-10-fix-login.md`). It is validated via the relaxed
2859    /// [`validate_filename`] before anything touches the filesystem, so
2860    /// traversal attempts are refused here as everywhere else.
2861    ///
2862    /// The old assignee segment is read from the current relative path, the
2863    /// new one derived with [`derive_segment`]; equal segments are a no-op.
2864    /// The target filename keeps the original `created-slug` stem; when the
2865    /// target already holds `<stem>.md`, a deterministic `-1`, `-2`, …
2866    /// suffix is appended by testing existence — an existing file is never
2867    /// overwritten or deleted. `number`/`created` live in the frontmatter and
2868    /// are untouched, so issue-number stability is guaranteed by construction
2869    /// (C-007).
2870    ///
2871    /// The move is a single `std::fs::rename` (atomic-ish, same filesystem).
2872    /// Returns the **new relative path** (matching how `issue.filename` is
2873    /// stored) on success, including the unchanged relative path for the
2874    /// equal-segment no-op.
2875    pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
2876        // `get` validates `filename` and errors NotFound when it is missing.
2877        let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
2878        let mut updated = issue.clone();
2879        updated.assignee = new_assignee.map(str::to_string);
2880        let target_dir = crate::layout::render_directory(&self.config.layout, &updated);
2881        let (old_dir, base) = split_relative(filename).ok_or_else(|| {
2882            TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
2883        })?;
2884        if old_dir == target_dir {
2885            return Ok(PathBuf::from(filename));
2886        }
2887        let stem = base.strip_suffix(".md").unwrap_or(&base);
2888        self.move_issue_file(filename, &target_dir, stem)
2889    }
2890
2891    /// Relocate an issue file to an exact store-relative `target` (used by
2892    /// `tix layout migrate`). Frontmatter and number are preserved because
2893    /// nothing is rewritten — it is a rename; an occupied target is refused; the
2894    /// `<slug>-assets/` dir moves alongside.
2895    pub fn migrate_issue(&self, filename: &str, target: &str) -> Result<()> {
2896        if target == filename {
2897            return Ok(());
2898        }
2899        validate_filename(target)?;
2900        let from = self.issues_dir.join(filename);
2901        if !from.is_file() {
2902            return Err(TixError::NotFound(format!("file {filename}")));
2903        }
2904        let to = self.issues_dir.join(target);
2905        if to.exists() {
2906            return Err(TixError::InvalidInput(format!(
2907                "cannot migrate {filename}: {target} already exists"
2908            )));
2909        }
2910        if let Some(parent) = to.parent() {
2911            std::fs::create_dir_all(parent)?;
2912        }
2913        std::fs::rename(&from, &to)?;
2914        let (old_dir, base) = split_relative(filename).unwrap_or_default();
2915        let stem = base.strip_suffix(".md").unwrap_or(&base);
2916        let asset_name = format!("{}-assets", slug_from_stem(stem));
2917        let old_asset_dir = self.issues_dir.join(&old_dir).join(&asset_name);
2918        if old_asset_dir.exists()
2919            && let Some(new_dir) = to.parent()
2920        {
2921            let moved = resolve_asset_dir_name(new_dir, &asset_name);
2922            std::fs::rename(&old_asset_dir, new_dir.join(&moved))?;
2923        }
2924        Ok(())
2925    }
2926
2927    /// Move an issue file (and its `<slug>-assets/` dir) from `filename` to
2928    /// `<target_dir>/<stem>[-N].md`, creating the directory as needed. A target
2929    /// collision takes the first free `-N` suffix; nothing is overwritten.
2930    fn move_issue_file(&self, filename: &str, target_dir: &str, stem: &str) -> Result<PathBuf> {
2931        let target_dir_path = self.issues_dir.join(target_dir);
2932        std::fs::create_dir_all(&target_dir_path)?;
2933        let new_name = resolve_target_name(&target_dir_path, stem);
2934        let old_dir = split_relative(filename).map_or_else(String::new, |(d, _)| d);
2935        let from = self.issues_dir.join(filename);
2936        let to = target_dir_path.join(&new_name);
2937        journal_record_move(&from, &to);
2938        std::fs::rename(from, to)?;
2939        // Move the `<slug>-assets/` dir alongside the file (FR-007). On a
2940        // same-named dir in the target, disambiguate rather than overwrite.
2941        let slug = slug_from_stem(stem);
2942        let asset_name = format!("{slug}-assets");
2943        let old_asset_dir = self.issues_dir.join(&old_dir).join(&asset_name);
2944        if old_asset_dir.exists() {
2945            let moved = resolve_asset_dir_name(&target_dir_path, &asset_name);
2946            let asset_target = target_dir_path.join(&moved);
2947            journal_record_move(&old_asset_dir, &asset_target);
2948            std::fs::rename(&old_asset_dir, &asset_target)?;
2949        }
2950        Ok(PathBuf::from(format!("{target_dir}/{new_name}")))
2951    }
2952
2953    /// Whether `filename` already sits at the directory its templated fields
2954    /// render to. Used to decide whether a mutation may relocate the file: only a
2955    /// file already *on* the layout is moved, so a legacy or mismatched path is
2956    /// left to `tix layout migrate`.
2957    #[must_use]
2958    pub fn path_is_laid_out(&self, filename: &str, issue: &Issue) -> bool {
2959        split_relative(filename).is_some_and(|(dir, _)| {
2960            dir == crate::layout::render_directory(&self.config.layout, issue)
2961        })
2962    }
2963
2964    /// Move an issue to the directory its templated fields now render to, when
2965    /// `move_on_change` is set. The basename is preserved. Returns whether it
2966    /// moved; a no-op when already in place or when `move_on_change` is off.
2967    pub fn move_to_layout(&self, issue: &mut Issue) -> Result<bool> {
2968        let before = issue.filename.clone();
2969        let after = self.reconcile_location(&before, issue)?;
2970        issue.filename = after.clone();
2971        Ok(after != before)
2972    }
2973}
2974
2975/// Whether a **relative** path is one [`Store::scan`] would index as an issue
2976/// file, at any depth (issue #10 follow-up; configurable layout).
2977///
2978/// A path is an issue file when every component is a normal name, no component
2979/// is a `.tix-tmp-*` temp dir or a `<slug>-assets/` dir, and the final component
2980/// ends in `.md`. Depth is unbounded so the incremental corpus matches a full
2981/// rebuild under any `[layout]` template.
2982pub(crate) fn is_issue_rel_path(rel: &Path) -> bool {
2983    let mut file_name: Option<&std::ffi::OsStr> = None;
2984    for component in rel.components() {
2985        match component {
2986            std::path::Component::Normal(segment) => {
2987                let Some(s) = segment.to_str() else {
2988                    return false;
2989                };
2990                if s.starts_with(".tix-tmp-") || s.ends_with("-assets") {
2991                    return false;
2992                }
2993                file_name = Some(segment);
2994            }
2995            std::path::Component::CurDir => {}
2996            // RootDir, ParentDir, Prefix: not a store-relative issue path.
2997            _ => return false,
2998        }
2999    }
3000    file_name.is_some_and(|name| name.to_string_lossy().ends_with(".md"))
3001}
3002
3003/// Validate an issue filename. A filename is either a legacy flat basename
3004/// (`2026-05-10-fix-login.md`) or a grouped path with exactly one assignee
3005/// segment prefix (`alice/2026-05-10-fix-login.md`, C-008/C-010).
3006///
3007/// Refused in all cases: NUL bytes, backslashes, the `..` component, empty
3008/// components (leading/trailing/double slashes), absolute / leading-slash
3009/// paths, and nesting deeper than one segment. Every refusal is a hard
3010/// [`TixError::InvalidInput`] raised before any filesystem touch.
3011fn validate_filename(filename: &str) -> Result<()> {
3012    if filename.is_empty() {
3013        return Err(TixError::InvalidInput(format!(
3014            "filename must not be empty: {filename:?}"
3015        )));
3016    }
3017    if filename.starts_with('/') {
3018        return Err(TixError::InvalidInput(format!(
3019            "filename must be relative (no leading `/`): {filename}"
3020        )));
3021    }
3022    if filename.contains('\\') {
3023        return Err(TixError::InvalidInput(format!(
3024            "filename must not contain backslashes: {filename}"
3025        )));
3026    }
3027    if filename.contains("..") {
3028        return Err(TixError::InvalidInput(format!(
3029            "filename must not contain `..`: {filename}"
3030        )));
3031    }
3032    if filename.contains('\0') {
3033        return Err(TixError::InvalidInput(format!(
3034            "filename must not contain NUL: {filename}"
3035        )));
3036    }
3037    if filename.split('/').any(|component| component.is_empty()) {
3038        return Err(TixError::InvalidInput(format!(
3039            "filename path components must not be empty: {filename}"
3040        )));
3041    }
3042    Ok(())
3043}
3044
3045/// Split a validated relative filename into `(segment, basename)`. Legacy
3046/// flat filenames have an empty segment (`("", "2026-05-10-x.md")`), grouped
3047/// ones carry exactly one segment (`("alice", "2026-05-10-x.md")`). [`None`]
3048/// for anything that survived a bad path shape.
3049/// The `number:` field of an issue file's frontmatter, without a full parse.
3050///
3051/// `Store::delete` needs the number of the issue it is removing so it can free that
3052/// number's claim, and it needs it *before* the file is gone. A full `Issue` parse
3053/// would be the obvious way, but this sits on the delete path and only needs one
3054/// scalar, so a scoped scan is enough — and it cannot fail on an issue that would
3055/// fail to load, which keeps `delete`'s existing "removes the file" contract
3056/// (#1352) independent of frontmatter validity.
3057///
3058/// The scan stops at the closing `---` so a `number:` in the **body** cannot be
3059/// mistaken for the field.
3060fn frontmatter_number(text: &str) -> Option<u32> {
3061    let mut lines = text.lines();
3062    // The opening delimiter. A file that does not start with one has no frontmatter.
3063    if lines.next()?.trim() != "---" {
3064        return None;
3065    }
3066    for line in lines {
3067        let line = line.trim_end();
3068        // The closing delimiter ends the frontmatter, so a `number:` after it is
3069        // body text and must not be mistaken for the field.
3070        if line == "---" {
3071            return None;
3072        }
3073        if let Some(rest) = line.strip_prefix("number:") {
3074            return rest.trim().parse().ok();
3075        }
3076    }
3077    None
3078}
3079
3080/// The directory holding one marker per claimed number.
3081///
3082/// **A sibling of the issues directory, not a child of it.** `docs/issues/` is the
3083/// tool's source of truth: people hand-edit it, script against it, and glob it, and
3084/// its contents are committed. Adding an internal directory inside it changes what
3085/// every one of those consumers sees — a test helper that counts segments, a `for`
3086/// loop over `docs/issues/*`, a CI check — for machinery no reader should have to
3087/// know about. Beside it, `docs/issues/` contains exactly what it did before.
3088///
3089/// Derived from the configured `issues_dir` rather than hardcoded, so a repo that
3090/// points `issues_dir` somewhere else still gets a ledger beside that.
3091fn claims_dir(issues_dir: &Path) -> PathBuf {
3092    issues_dir
3093        .parent()
3094        .unwrap_or(issues_dir)
3095        .join(NumberClaim::DIR)
3096}
3097
3098fn split_relative(filename: &str) -> Option<(String, String)> {
3099    let mut parts = filename.rsplitn(2, '/');
3100    let basename = parts.next()?;
3101    let segment = parts.next().unwrap_or("");
3102    Some((segment.to_string(), basename.to_string()))
3103}
3104
3105/// Flip the archived marker on a store-relative issue filename.
3106///
3107/// `alice/2026-01-01-x.md` → `alice/.2026-01-01-x.md` when `archive` is true,
3108/// and the inverse when false. Only the basename's leading dot changes; the
3109/// segment stays put, so the file never moves between directories. `None` when
3110/// the name is already in the requested state.
3111fn flip_archived(filename: &str, archive: bool) -> Option<String> {
3112    let (segment, basename) = split_relative(filename)?;
3113    let flipped = if archive {
3114        if basename.starts_with('.') {
3115            return None;
3116        }
3117        format!(".{basename}")
3118    } else {
3119        basename.strip_prefix('.')?.to_string()
3120    };
3121    Some(if segment.is_empty() {
3122        flipped
3123    } else {
3124        format!("{segment}/{flipped}")
3125    })
3126}
3127
3128/// The first free `<stem>.md` / `<stem>-1.md` / `<stem>-2.md` … name in
3129/// `dir` (deterministic existence probe). Never returns an existing name.
3130fn resolve_target_name(dir: &Path, stem: &str) -> String {
3131    let mut candidate = format!("{stem}.md");
3132    let mut i = 1u32;
3133    while dir.join(&candidate).exists() {
3134        candidate = format!("{stem}-{i}.md");
3135        i += 1;
3136    }
3137    candidate
3138}
3139
3140/// The slug used for the asset dir: the issue filename stem minus any leading
3141/// date and id components.
3142///
3143/// New schema is `YYYY-MM-DD-<id>-<slug>` (`2026-09-13-A1B2C3D4-fix-login-bug` →
3144/// `fix-login-bug`); legacy is `YYYY-MM-DD-<slug>`; an earlier new-schema order
3145/// was `<id>-YYYY-MM-DD-<slug>`. All three are handled so the asset directory is
3146/// the same human slug regardless of which name a file carries. When neither
3147/// component is present the stem is used as-is.
3148fn slug_from_stem(stem: &str) -> String {
3149    let stem = strip_leading_id(stem);
3150    let stem = strip_date_prefix(stem);
3151    strip_leading_id(stem).to_string()
3152}
3153
3154/// Strip a leading 8-character issue id and its separator, if present.
3155fn strip_leading_id(stem: &str) -> &str {
3156    match stem.split_once('-') {
3157        Some((head, rest)) if crate::id::is_issue_id(head) => rest,
3158        _ => stem,
3159    }
3160}
3161
3162/// Strip a leading `YYYY-MM-DD-` date prefix, if present.
3163fn strip_date_prefix(stem: &str) -> &str {
3164    let bytes = stem.as_bytes();
3165    if stem.len() > 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b'-' {
3166        &stem[11..]
3167    } else {
3168        stem
3169    }
3170}
3171
3172/// The first free `<name>` / `<name>-<n>.<ext>` name for an asset in `dir`.
3173/// When `<name>` is taken the extension is kept and a deterministic
3174/// numeric suffix is appended (FR-006).
3175fn resolve_asset_name(dir: &Path, name: &str) -> String {
3176    if !dir.join(name).exists() {
3177        return name.to_string();
3178    }
3179    let (stem, ext) = match name.rsplit_once('.') {
3180        Some((s, e)) => (s, format!(".{e}")),
3181        None => (name, String::new()),
3182    };
3183    let mut i = 1u32;
3184    loop {
3185        let candidate = format!("{stem}-{i}{ext}");
3186        if !dir.join(&candidate).exists() {
3187            return candidate;
3188        }
3189        i += 1;
3190    }
3191}
3192
3193/// The first free `<name>` / `<name>-1` / `<name>-2` … directory name in
3194/// `dir` (mirrors `resolve_target_name` for the `<slug>-assets/` move).
3195fn resolve_asset_dir_name(dir: &Path, name: &str) -> String {
3196    let mut candidate = name.to_string();
3197    let mut i = 1u32;
3198    while dir.join(&candidate).exists() {
3199        candidate = format!("{name}-{i}");
3200        i += 1;
3201    }
3202    candidate
3203}
3204
3205/// Copy a file atomically into `dir` under `target` (temp + rename, NFR-004).
3206/// The temp file is cleaned up on failure and never left behind.
3207fn atomic_copy(dir: &Path, target: &Path, source: &Path) -> Result<()> {
3208    let pid = std::process::id();
3209    let nonce = nanos();
3210    let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
3211    let copy_result = (|| -> std::io::Result<()> {
3212        std::fs::copy(source, &tmp)?;
3213        let f = std::fs::File::open(&tmp)?;
3214        f.sync_all()?;
3215        Ok(())
3216    })();
3217    if let Err(e) = copy_result {
3218        let _ = std::fs::remove_file(&tmp);
3219        return Err(TixError::Io(e));
3220    }
3221    if let Err(e) = std::fs::rename(&tmp, target) {
3222        let _ = std::fs::remove_file(&tmp);
3223        return Err(TixError::Io(e));
3224    }
3225    Ok(())
3226}
3227
3228/// Whether an asset file name is an image by extension (the `![…]` link form).
3229fn is_image_name(name: &str) -> bool {
3230    matches!(
3231        name.rsplit('.')
3232            .next()
3233            .unwrap_or("")
3234            .to_ascii_lowercase()
3235            .as_str(),
3236        "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp"
3237    )
3238}
3239
3240/// `pub(crate)` because the mention store writes its own directory through the same primitive:
3241/// one atomic writer means one place that journals, fsyncs, and renames.
3242pub(crate) fn atomic_write(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
3243    write_file(dir, target, data, Link::Overwrite)
3244}
3245
3246/// Write `data` to `target`, refusing to replace an existing file.
3247///
3248/// [`atomic_write`] uses `rename`, which replaces the target unconditionally and is
3249/// therefore the wrong tool for *creating* an issue: a name collision would destroy
3250/// the file already there. This writes the payload to a temp file, `fsync`s it, and
3251/// then creates `target` with `hard_link`, which is atomic and fails with
3252/// `AlreadyExists` if the name is taken. The temp file is removed either way.
3253///
3254/// A hard link is the portable way to get `O_EXCL`-style semantics on the final
3255/// path (it works on unix and Windows and needs no new dependency). The temp file
3256/// lives in the same directory, so it is always on the same filesystem and the link
3257/// cannot fail for that reason. If the link is refused for any other reason — a
3258/// filesystem without hard links, say — it falls back to `rename`, which keeps
3259/// atomicity but is no longer no-clobber, and the caller's own probe is the
3260/// remaining protection.
3261///
3262/// The directory `fsync` that follows the link is what makes the *name* durable, so
3263/// it is not skippable on the fallback path either.
3264fn write_file_new(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
3265    write_file(dir, target, data, Link::Exclusive)
3266}
3267
3268#[derive(Clone, Copy)]
3269enum Link {
3270    Overwrite,
3271    Exclusive,
3272}
3273
3274fn write_file(dir: &Path, target: &Path, data: &[u8], link: Link) -> Result<()> {
3275    // Every write goes through here, so this is the one place a transaction needs
3276    // to remember the target's pre-image. A no-op outside a transaction.
3277    journal_record_write(target);
3278    let pid = std::process::id();
3279    let nonce = nanos();
3280    let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
3281    let write_result = (|| -> std::io::Result<()> {
3282        let mut f = std::fs::File::create(&tmp)?;
3283        use std::io::Write;
3284        f.write_all(data)?;
3285        f.sync_all()?;
3286        Ok(())
3287    })();
3288    if let Err(e) = write_result {
3289        let _ = std::fs::remove_file(&tmp);
3290        return Err(TixError::Io(e));
3291    }
3292    let link_result = match link {
3293        Link::Overwrite => std::fs::rename(&tmp, target),
3294        Link::Exclusive => match std::fs::hard_link(&tmp, target) {
3295            // The name was taken: the payload is already durable, so only the link
3296            // failed. `AlreadyExists` is the signal `write_new_issue_file` retries on,
3297            // so it is propagated rather than flattened into a generic I/O error.
3298            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
3299            // No hard links on this filesystem. Atomicity survives; the no-clobber
3300            // guarantee does not, and the caller's probe is what is left.
3301            Err(_) => std::fs::rename(&tmp, target),
3302            Ok(()) => {
3303                let _ = std::fs::remove_file(&tmp);
3304                Ok(())
3305            }
3306        },
3307    };
3308    if let Err(e) = link_result {
3309        let _ = std::fs::remove_file(&tmp);
3310        return Err(TixError::Io(e));
3311    }
3312    let _ = std::fs::File::open(dir).and_then(|f| f.sync_all());
3313    Ok(())
3314}
3315
3316fn nanos() -> u64 {
3317    SystemTime::now()
3318        .duration_since(UNIX_EPOCH)
3319        .map(|d| d.as_nanos() as u64)
3320        .unwrap_or(0)
3321}
3322
3323#[cfg(test)]
3324mod tests {
3325    use super::*;
3326    // Only the tests need this; the library path went through `render_diagnostics`.
3327    use crate::config::{EpicConfiguration, ProjectConfiguration};
3328    use crate::model::{Priority, today};
3329    use crate::relationships::DiagnosticKind;
3330    use std::fs;
3331    use tempfile::TempDir;
3332
3333    /// The no-clobber layer, tested directly.
3334    ///
3335    /// `write_new_issue_file`'s **probe** is the common path and is covered end to
3336    /// end by `two_creates_with_the_same_title_both_survive` in `tests/cli.rs`.
3337    /// The probe cannot see a name another process takes between the probe and the
3338    /// write, which is why the final create is exclusive. This is the guarantee that
3339    /// closes that window, so it is asserted on the primitive rather than through a
3340    /// race that would be flaky to provoke.
3341    ///
3342    /// If the exclusive path were replaced by a `rename` — the obvious "simplify
3343    /// this" edit — the planted file below would be silently destroyed, which is
3344    /// precisely the data loss `write_new_issue_file` exists to prevent.
3345    #[test]
3346    fn write_file_new_refuses_an_existing_target() {
3347        let dir = TempDir::new().unwrap();
3348        let target = dir.path().join("issue.md");
3349        write_file_new(dir.path(), &target, b"first").unwrap();
3350        assert_eq!(fs::read_to_string(&target).unwrap(), "first");
3351
3352        let err = write_file_new(dir.path(), &target, b"second").unwrap_err();
3353        let TixError::Io(io_err) = err else {
3354            panic!("expected an I/O error, so the caller can retry on a name collision")
3355        };
3356        assert_eq!(
3357            io_err.kind(),
3358            std::io::ErrorKind::AlreadyExists,
3359            "the error must be AlreadyExists, so the caller can retry on a name \\
3360             collision rather than treating it as a hard failure"
3361        );
3362        assert_eq!(
3363            fs::read_to_string(&target).unwrap(),
3364            "first",
3365            "the existing file must be byte-identical — not truncated, not replaced"
3366        );
3367    }
3368
3369    /// The retry loop turns an `AlreadyExists` into the next free name.
3370    #[test]
3371    fn a_clobbered_name_is_retried_rather_than_failing_or_overwriting() {
3372        let tmp = TempDir::new().unwrap();
3373        let dir = tmp.path();
3374        let store = Store::open(&crate::config::Config {
3375            issues_dir: dir.to_path_buf(),
3376            ..Default::default()
3377        })
3378        .unwrap();
3379
3380        // The probe looks at `<issues_dir>/<segment>/<stem>.md`, so the plants have
3381        // to sit in the segment directory — that is where the collision is.
3382        let segment_dir = dir.join("seg");
3383        fs::create_dir_all(&segment_dir).unwrap();
3384
3385        // Two plants on the exact names the probe would try first, so the retry has
3386        // to walk past both to reach the third.
3387        for name in ["2026-01-01-race.md", "2026-01-01-race-2.md"] {
3388            fs::write(segment_dir.join(name), "planted").unwrap();
3389        }
3390
3391        let (filename, path) = store
3392            .write_new_issue_file("seg", "2026-01-01-race", b"mine")
3393            .unwrap();
3394        assert_eq!(filename, "seg/2026-01-01-race-3.md");
3395        assert_eq!(fs::read_to_string(&path).unwrap(), "mine");
3396        assert_eq!(
3397            fs::read_to_string(segment_dir.join("2026-01-01-race.md")).unwrap(),
3398            "planted"
3399        );
3400        assert_eq!(
3401            fs::read_to_string(segment_dir.join("2026-01-01-race-2.md")).unwrap(),
3402            "planted"
3403        );
3404    }
3405
3406    /// No temp files are left behind on either the success or the collision path.
3407    ///
3408    /// A leftover `.tix-tmp-*` in `docs/issues/<segment>/` would be picked up by the
3409    /// next `tix list` as a malformed issue, and the exclusive path has an extra
3410    /// step (`hard_link` then `remove_file`) where a leak is easy to introduce.
3411    #[test]
3412    fn no_temp_files_are_left_behind_on_success_or_collision() {
3413        let dir = TempDir::new().unwrap();
3414        let target = dir.path().join("issue.md");
3415        write_file_new(dir.path(), &target, b"first").unwrap();
3416        let _ = write_file_new(dir.path(), &target, b"second");
3417        let leftovers: Vec<String> = fs::read_dir(dir.path())
3418            .unwrap()
3419            .filter_map(|e| e.ok())
3420            .map(|e| e.file_name().to_string_lossy().to_string())
3421            .filter(|n| n.starts_with(".tix-tmp-"))
3422            .collect();
3423        assert!(
3424            leftovers.is_empty(),
3425            "temp files must not survive, found {leftovers:?}"
3426        );
3427    }
3428
3429    fn make_store() -> (tempfile::TempDir, Store) {
3430        let tmp = tempfile::TempDir::new().unwrap();
3431        let dir = tmp.path().join("issues");
3432        fs::create_dir_all(&dir).unwrap();
3433        let config_path = tmp.path().join("tix.toml");
3434        let mut config = Config {
3435            issues_dir: dir.clone(),
3436            ..Config::default()
3437        };
3438        // Storage unit tests predate configurable layout and assert the legacy
3439        // one-segment paths; pin it and disable move-on-change so they stay about
3440        // grouping, not layout. Both the default template and auto-migration are
3441        // covered by dedicated tests.
3442        config.layout.directory = "{assignee}".to_string();
3443        config.layout.move_on_change = false;
3444        config.save(&config_path).unwrap();
3445        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
3446        let store = Store {
3447            issues_dir: dir,
3448            config,
3449        };
3450        (tmp, store)
3451    }
3452
3453    fn write_issue(store: &Store, name: &str, contents: &str) {
3454        fs::write(store.issues_dir.join(name), contents).unwrap();
3455    }
3456
3457    fn sample_frontmatter(number: u32, title: &str, status: &str) -> String {
3458        format!(
3459            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
3460        )
3461    }
3462
3463    // ------------------------------------------------------------------
3464    // Issue #1492 — archived issues (dot-prefixed filename, in place)
3465    // ------------------------------------------------------------------
3466
3467    /// The default walk must skip a dot-prefixed file **without opening it**, so
3468    /// an archived file with unparseable frontmatter cannot break `list()`. That
3469    /// an unparseable one is *only* reachable through `list_all()` is the cheapest
3470    /// honest proof the skip happens before the read.
3471    #[test]
3472    fn list_never_opens_an_archived_file_but_list_all_does() {
3473        let (tmp, store) = make_store();
3474        let _ = tmp;
3475        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3476        write_issue(
3477            &store,
3478            "alice/2026-01-01-active.md",
3479            &sample_frontmatter(1, "Active", "todo"),
3480        );
3481        fs::write(
3482            store.issues_dir.join("alice/.2026-01-02-broken.md"),
3483            "this file has no frontmatter at all\n",
3484        )
3485        .unwrap();
3486
3487        let active = store
3488            .list()
3489            .expect("the default walk must not read the archived file");
3490        assert_eq!(active.len(), 1);
3491        assert_eq!(active[0].number, 1);
3492        assert!(!active[0].archived);
3493
3494        let all = store.list_all();
3495        assert!(
3496            matches!(all, Err(TixError::Parse(_))),
3497            "list_all() reads it, so the broken file is only invisible to list(): {all:?}"
3498        );
3499    }
3500
3501    #[test]
3502    fn archive_renames_in_place_and_restore_is_the_inverse() {
3503        let (tmp, store) = make_store();
3504        let _ = tmp;
3505        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3506        write_issue(
3507            &store,
3508            "alice/2026-01-01-x.md",
3509            &sample_frontmatter(5, "X", "todo"),
3510        );
3511
3512        let archived = store.archive("5").unwrap();
3513        assert_eq!(archived.filename, "alice/.2026-01-01-x.md");
3514        assert!(archived.archived);
3515        assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
3516        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
3517        assert!(
3518            store.list().unwrap().is_empty(),
3519            "hidden from the default view"
3520        );
3521
3522        let all = store.list_all().unwrap();
3523        assert_eq!(all.len(), 1);
3524        assert!(all[0].archived);
3525        assert_eq!(all[0].number, 5, "the number is untouched");
3526
3527        // Both selectors still resolve the archived issue.
3528        assert_eq!(
3529            store.get(IssueSelector::Number(5)).unwrap().filename,
3530            "alice/.2026-01-01-x.md"
3531        );
3532        assert!(store.get(IssueSelector::Slug(slugify("X"))).is_ok());
3533
3534        let restored = store.restore("5").unwrap();
3535        assert_eq!(restored.filename, "alice/2026-01-01-x.md");
3536        assert!(!restored.archived);
3537        assert_eq!(store.list().unwrap().len(), 1, "visible again");
3538    }
3539
3540    #[test]
3541    fn archive_appends_one_event_and_leaves_the_frontmatter_untouched() {
3542        let (tmp, store) = make_store();
3543        let _ = tmp;
3544        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3545        write_issue(
3546            &store,
3547            "alice/2026-01-01-x.md",
3548            &sample_frontmatter(5, "X", "todo"),
3549        );
3550        let before = store
3551            .get(IssueSelector::Filename("alice/2026-01-01-x.md".into()))
3552            .unwrap();
3553
3554        let archived = store.archive("5").unwrap();
3555        assert_eq!(archived.history.len(), 1, "exactly one History event");
3556        assert_eq!(
3557            archived.history.events()[0].kind,
3558            HistoryEventKind::Archived
3559        );
3560
3561        let after = store.get(IssueSelector::Number(5)).unwrap();
3562        // Every field a reader cares about survives unchanged. `update` re-renders
3563        // the frontmatter (quoting and key order normalize, as on every tix write),
3564        // so the invariant is the *values*, not the bytes.
3565        assert_eq!(after.number, before.number);
3566        assert_eq!(after.title, before.title);
3567        assert_eq!(after.status, before.status);
3568        assert_eq!(after.labels, before.labels);
3569        assert_eq!(after.priority, before.priority);
3570        assert_eq!(after.assignee, before.assignee);
3571        assert_eq!(after.created, before.created);
3572        assert_eq!(
3573            after.updated, before.updated,
3574            "archiving must not bump `updated`: it is a frontmatter field"
3575        );
3576        // The marker is the filename; nothing was written into the frontmatter.
3577        let raw = read_issue_file(&store, "alice/.2026-01-01-x.md");
3578        let fm = raw.split("\n---").next().unwrap();
3579        assert!(
3580            !fm.contains("archived"),
3581            "the archived flag must never reach the frontmatter: {fm}"
3582        );
3583    }
3584
3585    #[test]
3586    fn restore_appends_one_restored_event() {
3587        let (tmp, store) = make_store();
3588        let _ = tmp;
3589        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3590        write_issue(
3591            &store,
3592            "alice/2026-01-01-x.md",
3593            &sample_frontmatter(5, "X", "todo"),
3594        );
3595        store.archive("5").unwrap();
3596        let restored = store.restore("5").unwrap();
3597        assert_eq!(restored.history.len(), 2, "archive + restore");
3598        assert_eq!(
3599            restored.history.events()[1].kind,
3600            HistoryEventKind::Restored
3601        );
3602    }
3603
3604    #[test]
3605    fn archiving_the_highest_number_does_not_free_it_for_reuse() {
3606        let (tmp, store) = make_store();
3607        let _ = tmp;
3608        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3609        write_issue(
3610            &store,
3611            "alice/2026-01-01-a.md",
3612            &sample_frontmatter(1, "A", "todo"),
3613        );
3614        write_issue(
3615            &store,
3616            "alice/2026-01-01-b.md",
3617            &sample_frontmatter(7, "B", "todo"),
3618        );
3619
3620        store.archive("7").unwrap();
3621
3622        let mut fresh = Issue::parse(&sample_frontmatter(0, "New", "todo")).unwrap();
3623        store.create(&mut fresh, "body", None).unwrap();
3624        assert_eq!(
3625            fresh.number, 8,
3626            "an archived issue keeps its number: `create` must not reuse #7"
3627        );
3628    }
3629
3630    #[test]
3631    fn redundant_archive_and_restore_are_refused_by_name() {
3632        let (tmp, store) = make_store();
3633        let _ = tmp;
3634        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3635        write_issue(
3636            &store,
3637            "alice/2026-01-01-x.md",
3638            &sample_frontmatter(5, "X", "todo"),
3639        );
3640
3641        let err = store.restore("5").unwrap_err();
3642        assert!(
3643            err.to_string().contains("not_archived"),
3644            "an active issue cannot be restored: {err}"
3645        );
3646        assert!(
3647            store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
3648            "the refused restore writes nothing"
3649        );
3650
3651        store.archive("5").unwrap();
3652        let err = store.archive("5").unwrap_err();
3653        assert!(
3654            err.to_string().contains("already_archived"),
3655            "an archived issue cannot be re-archived: {err}"
3656        );
3657        assert!(
3658            store.issues_dir.join("alice/.2026-01-01-x.md").is_file(),
3659            "the refused archive writes nothing"
3660        );
3661    }
3662
3663    #[test]
3664    fn archive_never_clobbers_an_occupied_target_name() {
3665        let (tmp, store) = make_store();
3666        let _ = tmp;
3667        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3668        write_issue(
3669            &store,
3670            "alice/2026-01-01-x.md",
3671            &sample_frontmatter(5, "X", "todo"),
3672        );
3673        // A pre-existing dot-prefixed file would be the target of the rename.
3674        write_issue(
3675            &store,
3676            "alice/.2026-01-01-x.md",
3677            &sample_frontmatter(6, "Other", "todo"),
3678        );
3679        let err = store.archive("5").unwrap_err();
3680        assert!(err.to_string().contains("already exists"), "got: {err}");
3681        assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
3682        assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
3683    }
3684
3685    #[test]
3686    fn archive_and_restore_a_legacy_flat_file_in_place() {
3687        let (tmp, store) = make_store();
3688        let _ = tmp;
3689        write_issue(
3690            &store,
3691            "2026-01-01-legacy.md",
3692            &sample_frontmatter(3, "Legacy", "todo"),
3693        );
3694
3695        let archived = store.archive("3").unwrap();
3696        assert_eq!(archived.filename, ".2026-01-01-legacy.md");
3697        assert!(store.issues_dir.join(".2026-01-01-legacy.md").is_file());
3698        assert_eq!(store.list().unwrap(), [] as [crate::model::Issue; 0]);
3699        assert_eq!(store.list_all().unwrap().len(), 1);
3700
3701        let restored = store.restore("3").unwrap();
3702        assert_eq!(restored.filename, "2026-01-01-legacy.md");
3703        assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
3704    }
3705
3706    #[test]
3707    fn list_returns_sorted_by_filename() {
3708        let (tmp, store) = make_store();
3709        let _ = tmp;
3710        write_issue(
3711            &store,
3712            "2026-03-01-c.md",
3713            &sample_frontmatter(3, "C", "todo"),
3714        );
3715        write_issue(
3716            &store,
3717            "2026-01-01-a.md",
3718            &sample_frontmatter(1, "A", "todo"),
3719        );
3720        write_issue(
3721            &store,
3722            "2026-02-01-b.md",
3723            &sample_frontmatter(2, "B", "todo"),
3724        );
3725        let issues = store.list().unwrap();
3726        assert_eq!(issues.len(), 3);
3727        assert_eq!(issues[0].number, 1);
3728        assert_eq!(issues[1].number, 2);
3729        assert_eq!(issues[2].number, 3);
3730        assert_eq!(issues[0].title, "A");
3731    }
3732
3733    // Issue #10 follow-up — the single-file reader behind `Corpus::apply`.
3734
3735    #[test]
3736    fn is_issue_rel_path_matches_what_scan_walks() {
3737        // `scan` indexes these.
3738        for path in [
3739            "a.md",
3740            "alice/b.md",
3741            ".hidden.md",
3742            "alice/.hidden.md",
3743            "deep/a/b.md",
3744        ] {
3745            assert!(
3746                is_issue_rel_path(Path::new(path)),
3747                "{path} is an issue file `scan` reads"
3748            );
3749        }
3750        // ...and skips these.
3751        for path in [
3752            "notes.txt",
3753            "alice/notes.txt",
3754            "alice",
3755            ".tix-tmp-x/a.md",
3756            "foo-assets/a.md",
3757            "alice/foo-assets/a.md",
3758        ] {
3759            assert!(
3760                !is_issue_rel_path(Path::new(path)),
3761                "{path} is not a path `scan` indexes"
3762            );
3763        }
3764    }
3765
3766    #[test]
3767    fn scan_above_the_parallel_threshold_is_sorted_and_complete() {
3768        // The parallel branch runs only above `PARALLEL_SCAN_THRESHOLD`, so a
3769        // small fixture never reaches it. This pins that the fan-out keeps every
3770        // file, in filename order, with the same numbering a serial walk gives.
3771        let (tmp, store) = make_store();
3772        let _ = tmp;
3773        let n = Store::PARALLEL_SCAN_THRESHOLD + 44;
3774        for i in (1..=n).rev() {
3775            write_issue(
3776                &store,
3777                &format!("2026-01-01-{i:04}.md"),
3778                &sample_frontmatter(i as u32, &format!("T{i}"), "todo"),
3779            );
3780        }
3781        let issues = store.list().unwrap();
3782        assert_eq!(
3783            issues.len(),
3784            n,
3785            "every file is read, none dropped or doubled"
3786        );
3787        let names: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
3788        let mut sorted = names.clone();
3789        sorted.sort_unstable();
3790        assert_eq!(names, sorted, "sorted by filename as `scan` promises");
3791        assert_eq!(issues[0].number, 1, "content came from the first file");
3792        assert_eq!(issues[n - 1].number, n as u32, "and from the last");
3793    }
3794
3795    #[test]
3796    fn read_issue_at_sets_filename_and_archived_like_scan() {
3797        let (tmp, store) = make_store();
3798        let _ = tmp;
3799        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3800        write_issue(
3801            &store,
3802            "alice/2026-01-01-a.md",
3803            &sample_frontmatter(1, "A", "todo"),
3804        );
3805        write_issue(
3806            &store,
3807            "alice/.2026-01-02-b.md",
3808            &sample_frontmatter(2, "B", "todo"),
3809        );
3810
3811        let active = store
3812            .read_issue_at(Path::new("alice/2026-01-01-a.md"))
3813            .unwrap();
3814        assert_eq!(active.number, 1);
3815        assert_eq!(active.filename, "alice/2026-01-01-a.md");
3816        assert!(!active.archived);
3817
3818        let archived = store
3819            .read_issue_at(Path::new("alice/.2026-01-02-b.md"))
3820            .unwrap();
3821        assert_eq!(archived.filename, "alice/.2026-01-02-b.md");
3822        assert!(
3823            archived.archived,
3824            "the leading dot marks it archived, as `scan` does"
3825        );
3826
3827        // A flat file keeps its bare basename, and a missing one is `NotFound`.
3828        write_issue(&store, "flat.md", &sample_frontmatter(3, "F", "todo"));
3829        assert_eq!(
3830            store.read_issue_at(Path::new("flat.md")).unwrap().filename,
3831            "flat.md"
3832        );
3833        assert!(matches!(
3834            store.read_issue_at(Path::new("alice/gone.md")),
3835            Err(TixError::NotFound(_))
3836        ));
3837    }
3838
3839    #[test]
3840    fn list_ignores_non_md_files() {
3841        let (tmp, store) = make_store();
3842        let _ = tmp;
3843        write_issue(
3844            &store,
3845            "2026-01-01-a.md",
3846            &sample_frontmatter(1, "A", "todo"),
3847        );
3848        write_issue(&store, "readme.txt", "not an issue");
3849        write_issue(&store, "2026-01-01-sub", "no extension");
3850        let issues = store.list().unwrap();
3851        assert_eq!(issues.len(), 1);
3852    }
3853
3854    #[test]
3855    fn list_errors_on_malformed_frontmatter() {
3856        let (tmp, store) = make_store();
3857        let _ = tmp;
3858        write_issue(&store, "2026-01-01-bad.md", "no frontmatter here\n");
3859        let err = store.list().unwrap_err();
3860        assert!(matches!(err, TixError::Parse(_)), "got {err:?}");
3861    }
3862
3863    #[test]
3864    fn get_by_number() {
3865        let (tmp, store) = make_store();
3866        let _ = tmp;
3867        write_issue(
3868            &store,
3869            "2026-01-01-x.md",
3870            &sample_frontmatter(5, "X", "todo"),
3871        );
3872        let issue = store.get(IssueSelector::Number(5)).unwrap();
3873        assert_eq!(issue.title, "X");
3874    }
3875
3876    #[test]
3877    fn get_by_filename() {
3878        let (tmp, store) = make_store();
3879        let _ = tmp;
3880        write_issue(
3881            &store,
3882            "2026-01-01-y.md",
3883            &sample_frontmatter(2, "Y", "done"),
3884        );
3885        let issue = store
3886            .get(IssueSelector::Filename("2026-01-01-y.md".into()))
3887            .unwrap();
3888        assert_eq!(issue.status.display(), "done");
3889    }
3890
3891    #[test]
3892    fn get_by_slug() {
3893        let (tmp, store) = make_store();
3894        let _ = tmp;
3895        write_issue(
3896            &store,
3897            "2026-01-01-z.md",
3898            &sample_frontmatter(3, "Z", "todo"),
3899        );
3900        let issue = store.get(IssueSelector::Slug("z".into())).unwrap();
3901        assert_eq!(issue.number, 3);
3902    }
3903
3904    #[test]
3905    fn get_not_found() {
3906        let (tmp, store) = make_store();
3907        let _ = tmp;
3908        assert!(store.get(IssueSelector::Number(99)).is_err());
3909    }
3910
3911    #[test]
3912    fn create_assigns_number_from_live_issues_and_leaves_config_untouched() {
3913        let (tmp, store) = make_store();
3914        let _ = tmp;
3915        assert_eq!(store.config.state.next_number, 0);
3916        let mut issue = Issue {
3917            projects: None,
3918            id: None,
3919            number: 0,
3920            title: "First".into(),
3921            status: Status::default(),
3922            labels: vec![],
3923            priority: Priority::default(),
3924            assignee: None,
3925            estimate: None,
3926            commit: None,
3927            gitlab_id: None,
3928            linear_id: None,
3929            mission: None,
3930            task: None,
3931            relationships: vec![],
3932            related_issues: vec![],
3933            files: vec![],
3934            cycles: None,
3935            milestones: None,
3936            reviewer: None,
3937            start_date: None,
3938            due_date: None,
3939            created: "2026-01-01".into(),
3940            type_: None,
3941            updated: "2026-01-01".into(),
3942            filename: String::new(),
3943            body: String::new(),
3944            history: crate::model::History::new(),
3945            has_retired_links_key: false,
3946            time_entries: vec![],
3947            parent: None,
3948            series: None,
3949            health: None,
3950            epics: None,
3951            extra_frontmatter: std::collections::BTreeMap::new(),
3952            archived: false,
3953            entered_at: None,
3954            source: None,
3955        };
3956        let path = store.create(&mut issue, "Hello world", None).unwrap();
3957        assert!(path.exists());
3958        assert_eq!(
3959            issue.number, 1,
3960            "an empty store starts at 1, not 0 — #0 is indistinguishable from a \
3961             sentinel or a failed read (tix #1376)"
3962        );
3963        // Issue #52: create never rewrites tix.toml with a counter.
3964        let config_path = tmp.path().join("tix.toml");
3965        let on_disk = std::fs::read_to_string(&config_path).unwrap();
3966        assert!(
3967            !on_disk.contains("next_number"),
3968            "create must not persist an issue-number counter:\n{on_disk}"
3969        );
3970        let reloaded = Config::load_with_warning(&config_path, |_| {}).unwrap();
3971        assert_eq!(reloaded.state.next_number, 0, "state is never written");
3972    }
3973
3974    #[test]
3975    fn create_resolves_max_live_number_plus_one() {
3976        let (tmp, store) = make_store();
3977        let _ = tmp;
3978        write_issue(
3979            &store,
3980            "2026-01-01-a.md",
3981            &sample_frontmatter(0, "A", "todo"),
3982        );
3983        write_issue(
3984            &store,
3985            "2026-01-01-b.md",
3986            &sample_frontmatter(1, "B", "todo"),
3987        );
3988        write_issue(
3989            &store,
3990            "2026-01-01-c.md",
3991            &sample_frontmatter(2, "C", "todo"),
3992        );
3993        let mut issue = Issue {
3994            projects: None,
3995            id: None,
3996            number: 0,
3997            title: "D".into(),
3998            status: Status::default(),
3999            labels: vec![],
4000            priority: Priority::default(),
4001            assignee: None,
4002            estimate: None,
4003            commit: None,
4004            gitlab_id: None,
4005            linear_id: None,
4006            mission: None,
4007            task: None,
4008            relationships: vec![],
4009            related_issues: vec![],
4010            files: vec![],
4011            cycles: None,
4012            milestones: None,
4013            reviewer: None,
4014            start_date: None,
4015            due_date: None,
4016            created: "2026-01-01".into(),
4017            type_: None,
4018            updated: "2026-01-01".into(),
4019            filename: String::new(),
4020            body: String::new(),
4021            history: crate::model::History::new(),
4022            has_retired_links_key: false,
4023            time_entries: vec![],
4024            parent: None,
4025            series: None,
4026            health: None,
4027            epics: None,
4028            extra_frontmatter: std::collections::BTreeMap::new(),
4029            archived: false,
4030            entered_at: None,
4031            source: None,
4032        };
4033        store.create(&mut issue, "", None).unwrap();
4034        assert_eq!(issue.number, 3);
4035    }
4036
4037    #[test]
4038    fn create_reuses_number_of_deleted_highest_issue() {
4039        // Issue #52 tradeoff: without a persisted counter the next number is
4040        // re-derived from the live set, so deleting the highest-numbered
4041        // issue lets its number be reused by the next create.
4042        let (_tmp, store) = make_store();
4043        let mut issue_a = Issue {
4044            projects: None,
4045            id: None,
4046            number: 0,
4047            title: "A".into(),
4048            status: Status::default(),
4049            labels: vec![],
4050            priority: Priority::default(),
4051            assignee: None,
4052            estimate: None,
4053            commit: None,
4054            gitlab_id: None,
4055            linear_id: None,
4056            mission: None,
4057            task: None,
4058            relationships: vec![],
4059            related_issues: vec![],
4060            files: vec![],
4061            cycles: None,
4062            milestones: None,
4063            reviewer: None,
4064            start_date: None,
4065            due_date: None,
4066            created: "2026-01-01".into(),
4067            type_: None,
4068            updated: "2026-01-01".into(),
4069            filename: String::new(),
4070            body: String::new(),
4071            history: crate::model::History::new(),
4072            has_retired_links_key: false,
4073            time_entries: vec![],
4074            parent: None,
4075            series: None,
4076            health: None,
4077            epics: None,
4078            extra_frontmatter: std::collections::BTreeMap::new(),
4079            archived: false,
4080            entered_at: None,
4081            source: None,
4082        };
4083        store.create(&mut issue_a, "", None).unwrap();
4084        let filename = issue_a.filename.clone();
4085        assert_eq!(issue_a.number, 1, "the first issue in a fresh store is #1");
4086        store.delete(&filename).unwrap();
4087        let mut issue_b = Issue {
4088            projects: None,
4089            id: None,
4090            number: 0,
4091            title: "B".into(),
4092            status: Status::default(),
4093            labels: vec![],
4094            priority: Priority::default(),
4095            assignee: None,
4096            estimate: None,
4097            commit: None,
4098            gitlab_id: None,
4099            linear_id: None,
4100            mission: None,
4101            task: None,
4102            relationships: vec![],
4103            related_issues: vec![],
4104            files: vec![],
4105            cycles: None,
4106            milestones: None,
4107            reviewer: None,
4108            start_date: None,
4109            due_date: None,
4110            created: "2026-01-02".into(),
4111            type_: None,
4112            updated: "2026-01-02".into(),
4113            filename: String::new(),
4114            body: String::new(),
4115            history: crate::model::History::new(),
4116            has_retired_links_key: false,
4117            time_entries: vec![],
4118            parent: None,
4119            series: None,
4120            health: None,
4121            epics: None,
4122            extra_frontmatter: std::collections::BTreeMap::new(),
4123            archived: false,
4124            entered_at: None,
4125            source: None,
4126        };
4127        store.create(&mut issue_b, "", None).unwrap();
4128        assert_eq!(
4129            issue_b.number, 1,
4130            "an emptied store resumes at the same floor as a fresh one: 1"
4131        );
4132    }
4133
4134    #[test]
4135    fn create_with_custom_filename_stem() {
4136        let (tmp, store) = make_store();
4137        let _ = tmp;
4138        let mut issue = Issue {
4139            projects: None,
4140            id: None,
4141            number: 0,
4142            title: "T".into(),
4143            status: Status::default(),
4144            labels: vec![],
4145            priority: Priority::default(),
4146            assignee: None,
4147            estimate: None,
4148            commit: None,
4149            gitlab_id: None,
4150            linear_id: None,
4151            mission: None,
4152            task: None,
4153            relationships: vec![],
4154            related_issues: vec![],
4155            files: vec![],
4156            cycles: None,
4157            milestones: None,
4158            reviewer: None,
4159            start_date: None,
4160            due_date: None,
4161            created: "2026-01-01".into(),
4162            type_: None,
4163            updated: "2026-01-01".into(),
4164            filename: String::new(),
4165            body: String::new(),
4166            history: crate::model::History::new(),
4167            has_retired_links_key: false,
4168            time_entries: vec![],
4169            parent: None,
4170            series: None,
4171            health: None,
4172            epics: None,
4173            extra_frontmatter: std::collections::BTreeMap::new(),
4174            archived: false,
4175            entered_at: None,
4176            source: None,
4177        };
4178        let path = store
4179            .create(&mut issue, "body", Some("custom-stem"))
4180            .unwrap();
4181        assert!(path.to_string_lossy().contains("custom-stem.md"));
4182    }
4183
4184    #[test]
4185    fn create_ensures_issues_dir_exists() {
4186        let tmp = tempfile::TempDir::new().unwrap();
4187        let dir = tmp.path().join("nested").join("issues");
4188        let config_path = tmp.path().join("tix.toml");
4189        let config = Config {
4190            issues_dir: dir.clone(),
4191            ..Config::default()
4192        };
4193        config.save(&config_path).unwrap();
4194        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
4195        let store = Store {
4196            issues_dir: dir,
4197            config,
4198        };
4199        let mut issue = Issue {
4200            projects: None,
4201            id: None,
4202            number: 0,
4203            title: "T".into(),
4204            status: Status::default(),
4205            labels: vec![],
4206            priority: Priority::default(),
4207            assignee: None,
4208            estimate: None,
4209            commit: None,
4210            gitlab_id: None,
4211            linear_id: None,
4212            mission: None,
4213            task: None,
4214            relationships: vec![],
4215            related_issues: vec![],
4216            files: vec![],
4217            cycles: None,
4218            milestones: None,
4219            reviewer: None,
4220            start_date: None,
4221            due_date: None,
4222            created: "2026-01-01".into(),
4223            type_: None,
4224            updated: "2026-01-01".into(),
4225            filename: String::new(),
4226            body: String::new(),
4227            history: crate::model::History::new(),
4228            has_retired_links_key: false,
4229            time_entries: vec![],
4230            parent: None,
4231            series: None,
4232            health: None,
4233            epics: None,
4234            extra_frontmatter: std::collections::BTreeMap::new(),
4235            archived: false,
4236            entered_at: None,
4237            source: None,
4238        };
4239        store.create(&mut issue, "", None).unwrap();
4240        assert_eq!(issue.number, 1, "the first issue in a fresh store is #1");
4241    }
4242
4243    #[test]
4244    fn update_replaces_content_atomically() {
4245        let (tmp, store) = make_store();
4246        let _ = tmp;
4247        write_issue(
4248            &store,
4249            "2026-01-01-u.md",
4250            &sample_frontmatter(1, "U", "todo"),
4251        );
4252        let mut issue = store
4253            .get(IssueSelector::Filename("2026-01-01-u.md".into()))
4254            .unwrap();
4255        issue.title = "Updated".into();
4256        issue.bump_updated();
4257        store.update("2026-01-01-u.md", &issue).unwrap();
4258        let reloaded = store
4259            .get(IssueSelector::Filename("2026-01-01-u.md".into()))
4260            .unwrap();
4261        assert_eq!(reloaded.title, "Updated");
4262    }
4263
4264    #[test]
4265    fn update_not_found() {
4266        let (tmp, store) = make_store();
4267        let _ = tmp;
4268        let issue = Issue {
4269            projects: None,
4270            id: None,
4271            number: 1,
4272            title: "T".into(),
4273            status: Status::default(),
4274            labels: vec![],
4275            priority: Priority::default(),
4276            assignee: None,
4277            estimate: None,
4278            commit: None,
4279            gitlab_id: None,
4280            linear_id: None,
4281            mission: None,
4282            task: None,
4283            relationships: vec![],
4284            related_issues: vec![],
4285            files: vec![],
4286            cycles: None,
4287            milestones: None,
4288            reviewer: None,
4289            start_date: None,
4290            due_date: None,
4291            created: "2026-01-01".into(),
4292            type_: None,
4293            updated: "2026-01-01".into(),
4294            filename: String::new(),
4295            body: String::new(),
4296            history: crate::model::History::new(),
4297            has_retired_links_key: false,
4298            time_entries: vec![],
4299            parent: None,
4300            series: None,
4301            health: None,
4302            epics: None,
4303            extra_frontmatter: std::collections::BTreeMap::new(),
4304            archived: false,
4305            entered_at: None,
4306            source: None,
4307        };
4308        assert!(store.update("nope.md", &issue).is_err());
4309    }
4310
4311    #[test]
4312    fn delete_removes_file() {
4313        let (tmp, store) = make_store();
4314        let _ = tmp;
4315        write_issue(
4316            &store,
4317            "2026-01-01-d.md",
4318            &sample_frontmatter(1, "D", "todo"),
4319        );
4320        assert!(store.issues_dir.join("2026-01-01-d.md").exists());
4321        store.delete("2026-01-01-d.md").unwrap();
4322        assert!(!store.issues_dir.join("2026-01-01-d.md").exists());
4323    }
4324
4325    #[test]
4326    fn delete_not_found() {
4327        let (tmp, store) = make_store();
4328        let _ = tmp;
4329        assert!(store.delete("nope.md").is_err());
4330    }
4331
4332    #[test]
4333    fn stray_tmp_file_not_treated_as_issue() {
4334        let (tmp, store) = make_store();
4335        let _ = tmp;
4336        write_issue(
4337            &store,
4338            "2026-01-01-a.md",
4339            &sample_frontmatter(1, "A", "todo"),
4340        );
4341        fs::write(store.issues_dir.join(".tix-tmp-9999-0"), "orphaned data").unwrap();
4342        let issues = store.list().unwrap();
4343        assert_eq!(issues.len(), 1);
4344    }
4345
4346    // ------------------------------------------------------------------
4347    // WP03 T015 — the storage seam's contract, as executable evidence
4348    // ------------------------------------------------------------------
4349    //
4350    // Every case asserts the **file bytes**, not just the returned `Result`. A
4351    // refusal that returns an error after having already re-serialised the issue
4352    // would pass an `is_err()` assertion and still have destroyed the audit trail,
4353    // so "did it refuse?" and "did it touch anything?" are separate questions here
4354    // and both are asked.
4355
4356    /// A store whose board **declares a `category_map`**, so category policy —
4357    /// the terminal lock and adjacency — applies.
4358    ///
4359    /// The mapping is `todo=unstarted, doing=active, done=completed`, which is
4360    /// forward-moving in the declared vocabulary order. `triage` is added as a
4361    /// `backlog` column so there is a genuine backwards move to be refused.
4362    fn seam_store() -> (tempfile::TempDir, Store) {
4363        let tmp = tempfile::TempDir::new().unwrap();
4364        let dir = tmp.path().join("issues");
4365        fs::create_dir_all(&dir).unwrap();
4366        let config_path = tmp.path().join("tix.toml");
4367        let mut config = Config {
4368            issues_dir: dir.clone(),
4369            kanban: crate::config::KanbanSection {
4370                configurations: indexmap::IndexMap::from([(
4371                    "default".to_string(),
4372                    crate::config::BoardConfiguration {
4373                        columns: vec![
4374                            "triage".into(),
4375                            "todo".into(),
4376                            "doing".into(),
4377                            "done".into(),
4378                        ],
4379                        done_column: Some("done".to_string()),
4380                        category_map: indexmap::IndexMap::from([
4381                            ("triage".to_string(), "backlog".to_string()),
4382                            ("todo".to_string(), "unstarted".to_string()),
4383                            ("doing".to_string(), "active".to_string()),
4384                            ("done".to_string(), "completed".to_string()),
4385                        ]),
4386                        ..Default::default()
4387                    },
4388                )]),
4389            },
4390            ..Config::default()
4391        };
4392        config.layout.move_on_change = false;
4393        config.save(&config_path).unwrap();
4394        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
4395        (
4396            tmp,
4397            Store {
4398                issues_dir: dir,
4399                config,
4400            },
4401        )
4402    }
4403
4404    /// A seam store holding one issue at `status`, and that file's bytes.
4405    ///
4406    /// The fixture carries every key the model always writes, `files` included.
4407    /// That is not tidiness: `t015_no_new_frontmatter_key_was_introduced` compares
4408    /// the key set before and after, and a fixture that omits `files` makes the
4409    /// comparison report the model's own always-written key as a change — so the
4410    /// test would have been measuring the fixture instead of the seam.
4411    fn seam_issue(status: &str) -> (tempfile::TempDir, Store, String) {
4412        let (tmp, store) = seam_store();
4413        write_issue(
4414            &store,
4415            "2026-01-01-seam.md",
4416            &format!(
4417                "---\nnumber: 1\ntitle: \"Seam\"\nstatus: {status}\nlabels: []\n\
4418                 priority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody."
4419            ),
4420        );
4421        let before = read_issue_file(&store, "2026-01-01-seam.md");
4422        (tmp, store, before)
4423    }
4424
4425    /// Assert a refusal left the file byte-identical.
4426    fn assert_untouched(store: &Store, before: &str, what: &str) {
4427        let after = read_issue_file(store, "2026-01-01-seam.md");
4428        assert_eq!(after, before, "{what}: the file must be byte-identical");
4429    }
4430
4431    #[test]
4432    fn t015_accepted_transition_writes_status_bumps_updated_and_appends_one_decision() {
4433        let (_tmp, store, _before) = seam_issue("todo");
4434        let outcome = store
4435            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4436            .unwrap();
4437        assert!(outcome.written, "an accepted, changing move writes");
4438        let decision = outcome
4439            .decision
4440            .clone()
4441            .expect("an accepted transition appends exactly one decision");
4442        assert_eq!(decision.from, "todo");
4443        assert_eq!(decision.to, "doing");
4444        assert_eq!(decision.from_category, "unstarted");
4445        assert_eq!(decision.to_category, "active");
4446        assert!(
4447            decision.override_reason.is_none(),
4448            "no override was supplied"
4449        );
4450        assert_eq!(
4451            outcome.issue.status.display(),
4452            "doing",
4453            "Issue.status holds the COLUMN name, never a category (C-001)"
4454        );
4455        assert_eq!(outcome.issue.updated, today(), "`updated` is bumped");
4456
4457        // Re-read from disk: the in-memory record is not evidence of what landed.
4458        let reloaded = store
4459            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4460            .unwrap();
4461        assert_eq!(reloaded.status.display(), "doing");
4462        let events = reloaded.history.events();
4463        assert_eq!(
4464            events.len(),
4465            1,
4466            "exactly one History entry, and it is the decision: {events:?}"
4467        );
4468        assert_eq!(events[0].kind, HistoryEventKind::TransitionAccepted);
4469        assert_eq!(
4470            crate::model::TransitionDecision::from_event(events[0])
4471                .unwrap()
4472                .to,
4473            "doing",
4474            "and the decision round-trips out of the file with its fields intact"
4475        );
4476    }
4477
4478    #[test]
4479    fn t015_unknown_column_is_refused_by_name_and_writes_nothing() {
4480        let (_tmp, store, before) = seam_issue("todo");
4481        let err = store
4482            .set_status("2026-01-01-seam.md", StatusChange::to("nope"))
4483            .unwrap_err();
4484        assert!(
4485            matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "unknown_column"),
4486            "the shared named diagnostic, contract §6: {err:?}"
4487        );
4488        assert!(
4489            err.to_string()
4490                .contains("valid columns: triage, todo, doing, done"),
4491            "in BOARD order, not hash order: {err}"
4492        );
4493        assert_untouched(&store, &before, "unknown column");
4494    }
4495
4496    #[test]
4497    fn t015_terminal_exit_is_refused_and_writes_nothing() {
4498        let (_tmp, store, before) = seam_issue("done");
4499        let err = store
4500            .set_status("2026-01-01-seam.md", StatusChange::to("todo"))
4501            .unwrap_err();
4502        assert!(
4503            matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
4504            "{err:?}"
4505        );
4506        assert_untouched(&store, &before, "terminal exit without a reason");
4507    }
4508
4509    #[test]
4510    fn t015_adjacency_restricted_backwards_move_is_refused_and_writes_nothing() {
4511        // `doing` is `active`; `triage` is `backlog`. Forward in the declared
4512        // vocabulary order, so no terminal lock applies — this is adjacency alone,
4513        // and it must name the same `invalid_transition` kind.
4514        let (_tmp, store, before) = seam_issue("doing");
4515        let err = store
4516            .set_status("2026-01-01-seam.md", StatusChange::to("triage"))
4517            .unwrap_err();
4518        assert!(
4519            matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
4520            "{err:?}"
4521        );
4522        assert!(
4523            err.to_string().contains("category policy"),
4524            "the message names the rule that refused it: {err}"
4525        );
4526        assert_untouched(&store, &before, "adjacency refusal");
4527    }
4528
4529    #[test]
4530    fn t015_terminal_exit_with_a_reason_is_accepted_and_the_reason_is_recorded() {
4531        let (_tmp, store, before) = seam_issue("done");
4532        let outcome = store
4533            .set_status(
4534                "2026-01-01-seam.md",
4535                StatusChange::to("todo").with_override(Some("reopening: wrong epic")),
4536            )
4537            .unwrap();
4538        assert!(outcome.written);
4539        let decision = outcome.decision.expect("one decision");
4540        assert_eq!(
4541            decision.override_reason.as_deref(),
4542            Some("reopening: wrong epic"),
4543            "an override with no recorded reason is not an audit record"
4544        );
4545        assert_ne!(
4546            read_issue_file(&store, "2026-01-01-seam.md"),
4547            before,
4548            "sanity: an accepted move must have changed the file at all"
4549        );
4550    }
4551
4552    #[test]
4553    fn t015_a_blank_reason_is_not_an_override() {
4554        // The service trims and drops a blank reason, so a whitespace-only string
4555        // must not unlock a terminal exit. Asserted here as well as in the service
4556        // because "the seam passes the caller's string through untouched" would
4557        // satisfy a service-level test and still be wrong at the boundary.
4558        let (_tmp, store, before) = seam_issue("done");
4559        let err = store
4560            .set_status(
4561                "2026-01-01-seam.md",
4562                StatusChange::to("todo").with_override(Some("   ")),
4563            )
4564            .unwrap_err();
4565        assert!(
4566            matches!(&err, TixError::Workflow(_)),
4567            "a blank reason is not a reason: {err:?}"
4568        );
4569        assert_untouched(&store, &before, "blank override reason");
4570    }
4571
4572    #[test]
4573    fn t015_same_column_is_accepted_and_writes_nothing() {
4574        let (_tmp, store, before) = seam_issue("doing");
4575        let outcome = store
4576            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4577            .unwrap();
4578        assert!(!outcome.written, "a same-column re-apply is a no-op");
4579        assert!(
4580            outcome.decision.is_none(),
4581            "and appends no decision, contract §3.2"
4582        );
4583        assert_untouched(&store, &before, "same column");
4584        // `updated` is not bumped either: a no-op that touched the date would make
4585        // `tix edit --status <current column>` look like an edit.
4586        let reloaded = store
4587            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4588            .unwrap();
4589        assert_eq!(
4590            reloaded.updated, "2026-01-01",
4591            "the fixture's original date"
4592        );
4593    }
4594
4595    #[test]
4596    fn t015_no_new_frontmatter_key_was_introduced() {
4597        // C-001. The mission must be byte-compatible on the file surface, so this
4598        // compares the key set of a file the seam wrote against the key set of the
4599        // fixture it read. A category key on the issue would break here.
4600        let (_tmp, store, before) = seam_issue("todo");
4601        store
4602            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4603            .unwrap();
4604        let after = read_issue_file(&store, "2026-01-01-seam.md");
4605        let keys = |text: &str| -> Vec<String> {
4606            text.split("---\n")
4607                .nth(1)
4608                .unwrap()
4609                .lines()
4610                .filter_map(|l| l.split_once(':').map(|(k, _)| k.trim().to_string()))
4611                .filter(|k| !k.is_empty())
4612                .collect()
4613        };
4614        assert_eq!(
4615            keys(&after),
4616            keys(&before),
4617            "the seam must not add or remove a top-level frontmatter key"
4618        );
4619    }
4620
4621    #[test]
4622    fn t015_a_hand_edited_inconsistent_file_is_read_and_not_repaired() {
4623        // C-005. A file whose `status` was hand-edited to a column the policy would
4624        // never have allowed is *readable* and this path must leave it alone. The
4625        // diagnosis is WP06's job; storage correcting it would be the escalation
4626        // the decision `01M3C3XBMTR5WFV7059NXM04AJ` names.
4627        let (_tmp, store, before) = seam_issue("doing");
4628        // Simulate the hand edit: `doing` (active) becomes `triage` (backlog),
4629        // a move adjacency refuses.
4630        let edited = before.replace("status: doing", "status: triage");
4631        write_issue(&store, "2026-01-01-seam.md", &edited);
4632        let after_edit = read_issue_file(&store, "2026-01-01-seam.md");
4633
4634        let issue = store
4635            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4636            .unwrap();
4637        assert_eq!(
4638            issue.status.display(),
4639            "triage",
4640            "a hand-edited status is read, not rejected at load"
4641        );
4642        assert_eq!(
4643            read_issue_file(&store, "2026-01-01-seam.md"),
4644            after_edit,
4645            "reading must not repair it"
4646        );
4647        // And a legal move away from the hand-edited column proceeds, without the
4648        // seam trying to reconcile the history of how the file got there.
4649        store
4650            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4651            .unwrap();
4652        let moved = store
4653            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4654            .unwrap();
4655        assert_eq!(moved.status.display(), "doing");
4656        assert_eq!(
4657            moved.history.events().len(),
4658            1,
4659            "only the new decision; nothing was written to explain the old one"
4660        );
4661    }
4662
4663    #[test]
4664    fn t015_every_rejection_kind_is_one_of_the_contract_vocabulary() {
4665        // The contract fixes seven names (contract §6). A refusal that invents an
4666        // eighth is a surface that reports a condition under a name no other
4667        // surface knows, which is the exact failure the vocabulary exists to stop.
4668        let allowed = [
4669            "missing_completion_category",
4670            "unknown_category",
4671            "mapping_for_unknown_column",
4672            "unmapped_column_default",
4673            "invalid_transition",
4674            "unknown_column",
4675            "direct_edit_policy_violation",
4676        ];
4677        let cases: Vec<(&str, &str)> = vec![
4678            ("todo", "nope"),    // unknown_column
4679            ("done", "todo"),    // invalid_transition (terminal lock)
4680            ("doing", "triage"), // invalid_transition (adjacency)
4681        ];
4682        for (from, to) in cases {
4683            let (_tmp, store, _before) = seam_issue(from);
4684            let err = store
4685                .set_status("2026-01-01-seam.md", StatusChange::to(to))
4686                .unwrap_err();
4687            let TixError::Workflow(d) = &err else {
4688                panic!("{from} -> {to} must refuse through the workflow variant: {err:?}");
4689            };
4690            assert!(
4691                allowed.contains(&d.kind.as_str()),
4692                "`{}` is not in the contract's vocabulary",
4693                d.kind.as_str()
4694            );
4695        }
4696    }
4697
4698    #[test]
4699    fn t015_a_legacy_board_gains_no_policy_but_keeps_completion() {
4700        // The opt-in this WP decided: a board with no `category_map` gets the
4701        // completion definition and the commit-clearing rule, and no transition
4702        // restriction. Both halves are asserted, because a change that dropped the
4703        // completion rule along with the policy would otherwise look correct.
4704        let (tmp, store) = make_store();
4705        let _ = tmp;
4706        write_issue(
4707            &store,
4708            "2026-01-01-legacy.md",
4709            &sample_frontmatter(1, "Legacy", "done"),
4710        );
4711        let moved = store
4712            .set_status("2026-01-01-legacy.md", StatusChange::to("todo"))
4713            .unwrap();
4714        assert!(moved.written, "a legacy board imposes no policy");
4715        assert_eq!(moved.issue.status.display(), "todo");
4716
4717        // And completion still resolves through the reconciliation, not the string
4718        // "done": this issue is no longer complete, and an untouched `done` one is.
4719        let resolution =
4720            crate::workflow::resolution(&store.config, store.config.default_config(), "default");
4721        assert!(
4722            !resolution.explicit_policy,
4723            "precondition: this board declared no category_map"
4724        );
4725        assert!(
4726            resolution.has_completion(),
4727            "but it still has a done column"
4728        );
4729        assert!(
4730            !crate::workflow::is_completed(&moved.issue, &resolution),
4731            "and `todo` is not complete"
4732        );
4733    }
4734
4735    #[test]
4736    fn t015_a_mapped_board_keeps_the_commit_coupling() {
4737        // The commit rule moved from `mv.rs` into the seam, so it is pinned here
4738        // rather than only in the command layer: reaching completion records the
4739        // commit, leaving it clears the one that was recorded.
4740        let (_tmp, store, _before) = seam_issue("doing");
4741        store
4742            .set_status(
4743                "2026-01-01-seam.md",
4744                StatusChange::to("done").with_commit(Some("abc123")),
4745            )
4746            .unwrap();
4747        let at_done = store
4748            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4749            .unwrap();
4750        assert_eq!(at_done.commit.as_deref(), Some("abc123"));
4751
4752        store
4753            .set_status(
4754                "2026-01-01-seam.md",
4755                StatusChange::to("todo").with_override(Some("not actually finished")),
4756            )
4757            .unwrap();
4758        let off_done = store
4759            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4760            .unwrap();
4761        assert_eq!(
4762            off_done.commit, None,
4763            "leaving completion clears the recorded commit"
4764        );
4765    }
4766
4767    #[test]
4768    fn t015_a_decision_round_trips_through_a_file_unchanged() {
4769        // T012's round trip, end to end rather than in memory: the rendered line
4770        // must parse back to the same fields, or WP06 cannot read a file's last
4771        // accepted column out of it.
4772        let (_tmp, store, _before) = seam_issue("todo");
4773        store
4774            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4775            .unwrap();
4776        let first = store
4777            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4778            .unwrap();
4779        let text = read_issue_file(&store, "2026-01-01-seam.md");
4780        // Parse the file back from its own bytes — not from the in-memory issue.
4781        let parsed = Issue::parse(&text).unwrap();
4782        let events = parsed.history.events();
4783        let decision = crate::model::TransitionDecision::from_event(events[0]).unwrap();
4784        assert_eq!(decision.to, "doing");
4785        assert_eq!(decision.from_category, "unstarted");
4786        assert_eq!(decision.to_category, "active");
4787        // And re-serialising the parsed file reproduces it byte for byte, so the
4788        // grammar is not lossy in a way only a rewrite would reveal.
4789        assert_eq!(
4790            parsed.to_file_contents(),
4791            text,
4792            "a parsed decision must re-render to the same bytes"
4793        );
4794        assert_eq!(parsed.history.events().len(), first.history.events().len());
4795    }
4796
4797    #[test]
4798    fn t015_two_transitions_append_two_decisions_in_order() {
4799        let (_tmp, store, _before) = seam_issue("triage");
4800        for to in ["todo", "doing"] {
4801            store
4802                .set_status("2026-01-01-seam.md", StatusChange::to(to))
4803                .unwrap();
4804        }
4805        let after = store
4806            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4807            .unwrap();
4808        let events = after.history.events();
4809        assert_eq!(events.len(), 2, "one per accepted transition");
4810        let tos: Vec<String> = events
4811            .iter()
4812            .map(|e| crate::model::TransitionDecision::from_event(e).unwrap().to)
4813            .collect();
4814        assert_eq!(tos, vec!["todo", "doing"], "append order, not sorted");
4815    }
4816
4817    #[test]
4818    fn set_status_changes_status_and_bumps_updated() {
4819        let (tmp, store) = make_store();
4820        let _ = tmp;
4821        write_issue(
4822            &store,
4823            "2026-01-01-s.md",
4824            &sample_frontmatter(1, "S", "todo"),
4825        );
4826        let outcome = store
4827            .set_status("2026-01-01-s.md", StatusChange::to("in progress"))
4828            .unwrap();
4829        let issue = &outcome.issue;
4830        assert_eq!(issue.status.display(), "in progress");
4831        assert_eq!(issue.updated, today());
4832        assert_eq!(issue.title, "S");
4833        assert_eq!(issue.body, "Body text.");
4834    }
4835
4836    #[test]
4837    fn set_status_preserves_file_path() {
4838        let (tmp, store) = make_store();
4839        let _ = tmp;
4840        write_issue(
4841            &store,
4842            "2026-01-01-f.md",
4843            &sample_frontmatter(1, "F", "todo"),
4844        );
4845        let _ = store
4846            .set_status("2026-01-01-f.md", StatusChange::to("done"))
4847            .unwrap();
4848        assert!(store.issues_dir.join("2026-01-01-f.md").exists());
4849    }
4850
4851    // ------------------------------------------------------------------
4852    // WP02 T009 — typed relationship writes delegate to the service
4853    // ------------------------------------------------------------------
4854
4855    /// Read an issue file's raw bytes, for the byte-identical assertions.
4856    fn read_issue_file(store: &Store, filename: &str) -> String {
4857        std::fs::read_to_string(store.issues_dir.join(filename)).unwrap()
4858    }
4859
4860    /// A frontmatter fixture with no relationship data, i.e. what every
4861    /// pre-existing issue file in the repository looks like.
4862    fn bare_frontmatter(number: u32, title: &str) -> String {
4863        format!(
4864            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
4865        )
4866    }
4867
4868    /// A frontmatter fixture carrying the **retired** `links:` key.
4869    fn legacy_links_frontmatter(number: u32, title: &str, links: &str) -> String {
4870        format!(
4871            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\nlinks: {links}\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
4872        )
4873    }
4874
4875    /// Two seeded issues, plus the store, for the relationship tests.
4876    fn related_pair() -> (TempDir, Store, Issue, Issue) {
4877        let (tmp, store) = make_store();
4878        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4879        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
4880        let a = store.get(IssueSelector::Number(1)).unwrap();
4881        let b = store.get(IssueSelector::Number(2)).unwrap();
4882        (tmp, store, a, b)
4883    }
4884
4885    #[test]
4886    fn t009_link_records_a_single_directed_edge() {
4887        let (_tmp, store, a, b) = related_pair();
4888        let outcome = store
4889            .link(
4890                &a.number.to_string(),
4891                &b.number.to_string(),
4892                RelationKind::Blocks,
4893            )
4894            .unwrap();
4895        assert!(outcome.changed);
4896
4897        // One edge, stored once, on the blocking side. The old symmetric
4898        // back-reference on the other issue is gone (C-001).
4899        let a_after = store.get(IssueSelector::Number(1)).unwrap();
4900        let b_after = store.get(IssueSelector::Number(2)).unwrap();
4901        assert_eq!(a_after.relationships.len(), 1);
4902        assert!(
4903            b_after.relationships.is_empty(),
4904            "no back-reference is written for a directed edge"
4905        );
4906        assert_eq!(a_after.relationships[0].kind, RelationKind::Blocks);
4907        assert_eq!(
4908            (a_after.relationships[0].from, a_after.relationships[0].to),
4909            (1, 2)
4910        );
4911    }
4912
4913    #[test]
4914    fn t009_link_appends_exactly_one_history_event() {
4915        let (_tmp, store, a, b) = related_pair();
4916        store
4917            .link(
4918                &a.number.to_string(),
4919                &b.number.to_string(),
4920                RelationKind::Blocks,
4921            )
4922            .unwrap();
4923        let a_after = store.get(IssueSelector::Number(1)).unwrap();
4924        assert_eq!(a_after.history.len(), 1);
4925        assert_eq!(
4926            a_after.history.events()[0].kind,
4927            HistoryEventKind::RelationshipAdded
4928        );
4929        // Every event records an actor, or the audit trail is not an audit trail.
4930        assert_ne!(a_after.history.events()[0].actor, "");
4931    }
4932
4933    #[test]
4934    fn t009_idempotent_reapply_writes_nothing_and_appends_no_second_event() {
4935        let (_tmp, store, a, b) = related_pair();
4936        let args = (a.number.to_string(), b.number.to_string());
4937        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4938        let first = store.get(IssueSelector::Number(1)).unwrap();
4939        let bytes_after_first = read_issue_file(&store, &first.filename);
4940
4941        let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4942        assert!(!second.changed, "re-applying an existing edge is a no-op");
4943        assert!(second.written.is_none(), "no file is written for a no-op");
4944
4945        let after = store.get(IssueSelector::Number(1)).unwrap();
4946        assert_eq!(after.relationships.len(), 1, "no duplicate edge");
4947        assert_eq!(after.history.len(), 1, "no second History event");
4948        assert_eq!(
4949            read_issue_file(&store, &after.filename),
4950            bytes_after_first,
4951            "the file is byte-identical after the no-op"
4952        );
4953    }
4954
4955    #[test]
4956    fn t009_inverse_spelling_resolves_to_the_same_stored_edge() {
4957        let (_tmp, store, a, b) = related_pair();
4958        let args = (a.number.to_string(), b.number.to_string());
4959        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4960        // "2 is blocked by 1" is the same edge as "1 blocks 2".
4961        let again = store
4962            .link(&args.1, &args.0, RelationKind::BlockedBy)
4963            .unwrap();
4964        assert!(!again.changed, "the inverse spelling must not add an edge");
4965        let after = store.get(IssueSelector::Number(1)).unwrap();
4966        assert_eq!(after.relationships.len(), 1);
4967        assert_eq!(
4968            after.relationships[0].kind,
4969            RelationKind::Blocks,
4970            "the stored kind is always the canonical one"
4971        );
4972    }
4973
4974    #[test]
4975    fn t009_self_link_is_refused_and_writes_nothing() {
4976        let (_tmp, store, a, _b) = related_pair();
4977        let before = read_issue_file(&store, &a.filename);
4978        let err = store
4979            .link(
4980                &a.number.to_string(),
4981                &a.number.to_string(),
4982                RelationKind::Blocks,
4983            )
4984            .unwrap_err();
4985        assert!(matches!(err, TixError::Refused(_)));
4986        assert!(
4987            err.to_string().contains("self_link"),
4988            "the diagnostic names the rule: {err}"
4989        );
4990        assert_eq!(
4991            read_issue_file(&store, &a.filename),
4992            before,
4993            "SC-005: a rejected mutation leaves the file byte-identical"
4994        );
4995    }
4996
4997    #[test]
4998    fn t009_cycle_closing_edge_is_refused_and_writes_nothing() {
4999        let (tmp, store) = make_store();
5000        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
5001        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5002        write_issue(&store, "2026-01-01-c.md", &bare_frontmatter(3, "C"));
5003        let _ = tmp;
5004        store.link("1", "2", RelationKind::Blocks).unwrap();
5005        store.link("2", "3", RelationKind::Blocks).unwrap();
5006        let c_before = read_issue_file(&store, "2026-01-01-c.md");
5007
5008        let err = store.link("3", "1", RelationKind::Blocks).unwrap_err();
5009        assert!(err.to_string().contains("cycle"), "{err}");
5010        assert_eq!(
5011            read_issue_file(&store, "2026-01-01-c.md"),
5012            c_before,
5013            "SC-005: both files of a rejected two-issue mutation are unchanged"
5014        );
5015    }
5016
5017    #[test]
5018    fn t009_unlink_removes_the_edge_and_is_idempotent() {
5019        let (_tmp, store, a, b) = related_pair();
5020        let args = (a.number.to_string(), b.number.to_string());
5021        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5022
5023        let removed = store
5024            .unlink(&args.0, &args.1, RelationKind::Blocks)
5025            .unwrap();
5026        assert!(removed.changed);
5027        let after = store.get(IssueSelector::Number(1)).unwrap();
5028        assert_eq!(
5029            after.relationships,
5030            [] as [crate::relationships::Relationship; 0]
5031        );
5032        assert_eq!(after.history.len(), 2, "added then removed");
5033
5034        // Removing again is a no-op, not a second History entry.
5035        let again = store
5036            .unlink(&args.0, &args.1, RelationKind::Blocks)
5037            .unwrap();
5038        assert!(!again.changed);
5039        let after2 = store.get(IssueSelector::Number(1)).unwrap();
5040        assert_eq!(after2.history.len(), 2, "no third History event");
5041    }
5042
5043    #[test]
5044    fn t009_unlink_of_a_never_linked_pair_is_a_noop() {
5045        let (_tmp, store, a, b) = related_pair();
5046        let outcome = store
5047            .unlink(
5048                &a.number.to_string(),
5049                &b.number.to_string(),
5050                RelationKind::Blocks,
5051            )
5052            .unwrap();
5053        assert!(!outcome.changed);
5054        assert!(outcome.written.is_none());
5055        let after = store.get(IssueSelector::Number(1)).unwrap();
5056        assert_eq!(
5057            after.relationships,
5058            [] as [crate::relationships::Relationship; 0]
5059        );
5060        assert!(after.history.is_empty(), "a no-op appends nothing");
5061    }
5062
5063    #[test]
5064    fn t009_set_relationships_replaces_the_set_and_keeps_surviving_ids() {
5065        let (_tmp, store, _a, _b) = related_pair();
5066        store.link("1", "2", RelationKind::Blocks).unwrap();
5067        let before = store.get(IssueSelector::Number(1)).unwrap();
5068        let survivor_id = before.relationships[0].id.clone();
5069
5070        // Replace the set with a different target; the old edge goes, recorded.
5071        let updated = store
5072            .set_relationships(&before.filename, &[(2, RelationKind::Blocks)])
5073            .unwrap();
5074        assert_eq!(updated.relationships.len(), 1);
5075        assert_eq!(
5076            updated.relationships[0].id, survivor_id,
5077            "a surviving relationship keeps its stable id"
5078        );
5079    }
5080
5081    #[test]
5082    fn t009_set_relationships_rejects_unknown_target_and_self() {
5083        let (_tmp, store, a, _b) = related_pair();
5084        assert!(
5085            store
5086                .set_relationships(&a.filename, &[(404, RelationKind::Blocks)])
5087                .is_err(),
5088            "an unknown target is refused"
5089        );
5090        assert!(
5091            store
5092                .set_relationships(&a.filename, &[(1, RelationKind::Blocks)])
5093                .is_err(),
5094            "a self-link is refused"
5095        );
5096    }
5097
5098    // ------------------------------------------------------------------
5099    // WP02 review round 1 — F1, F4, F5, F13: canonical storage lookups
5100    // ------------------------------------------------------------------
5101
5102    /// Seed an issue, optionally with raw extra frontmatter.
5103    fn seeded(store: &Store, number: u32, title: &str, extra: &str) {
5104        let slug = title.to_lowercase();
5105        write_issue(
5106            store,
5107            &format!("2026-01-01-{slug}.md"),
5108            &format!(
5109                "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\n{extra}files: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
5110            ),
5111        );
5112    }
5113
5114    #[test]
5115    fn f1_set_relationships_refuses_a_reciprocal_pair_the_service_rejects() {
5116        // F1. `set_relationships` used to re-implement existence and the
5117        // self-link rule locally and skip the rest, so with `1 blocks 2` on disk
5118        // it stored `2 blocks 1` — a reciprocal pair that `link` rejects one
5119        // line earlier and that contract §3 rules 3 and 4 require be refused.
5120        // Latent only because `--links` hard-codes `related`; user-reachable the
5121        // moment WP03 wires `--type`.
5122        let (_tmp, store) = make_store();
5123        seeded(&store, 1, "A", "");
5124        seeded(&store, 2, "B", "");
5125        store.link("1", "2", RelationKind::Blocks).unwrap();
5126
5127        let err = store
5128            .set_relationships("2026-01-01-b.md", &[(1, RelationKind::Blocks)])
5129            .unwrap_err();
5130        assert!(
5131            err.to_string().contains("cycle"),
5132            "the service's verdict is what refuses it: {err}"
5133        );
5134        let two = store.get(IssueSelector::Number(2)).unwrap();
5135        assert!(
5136            two.relationships.is_empty(),
5137            "SC-005: a refused mutation writes nothing"
5138        );
5139    }
5140
5141    #[test]
5142    fn f4_a_hand_ordered_related_row_is_removable() {
5143        // F4. A hand-authored `related` row in the non-canonical endpoint order.
5144        // Removals used to be keyed by where the row *should* live while rows
5145        // were found where they *were* stored, so this row matched no file and
5146        // no code path could delete it.
5147        let (_tmp, store) = make_store();
5148        seeded(&store, 2, "B", "");
5149        seeded(
5150            &store,
5151            5,
5152            "E",
5153            "relationships:\n- id: rel_handauthored\n  from: 5\n  to: 2\n  kind: related\n  display: \"related #2 B\"\n",
5154        );
5155        let out = store.unlink("5", "2", RelationKind::Related).unwrap();
5156        assert!(out.changed, "the row is found and removed");
5157        assert_eq!(
5158            store.get(IssueSelector::Number(5)).unwrap().relationships,
5159            [] as [crate::relationships::Relationship; 0]
5160        );
5161    }
5162
5163    #[test]
5164    fn f5_a_persisted_blocked_by_row_is_removable() {
5165        // F5. `blocked_by` is the input-only spelling and must never be
5166        // persisted, but the format is human-editable so a hand edit can put it
5167        // there. `unlink` compared authored fields while `link` canonicalized,
5168        // so the two disagreed and the row was un-removable — leaving a
5169        // permanently invalid row plus a `tix lint` cycle finding with no way to
5170        // clear it.
5171        let (_tmp, store) = make_store();
5172        seeded(&store, 1, "A", "");
5173        seeded(
5174            &store,
5175            2,
5176            "B",
5177            "relationships:\n- id: rel_bb\n  from: 2\n  to: 1\n  kind: blocked_by\n  display: \"blocks #1 A\"\n",
5178        );
5179        let out = store.unlink("2", "1", RelationKind::BlockedBy).unwrap();
5180        assert!(out.changed, "the row is found and removed");
5181        assert_eq!(
5182            store.get(IssueSelector::Number(2)).unwrap().relationships,
5183            [] as [crate::relationships::Relationship; 0]
5184        );
5185    }
5186
5187    #[test]
5188    fn f13_a_dangling_edge_is_removable() {
5189        // F13. A dangling edge is a diagnosed, expected condition, so the one
5190        // operation that can clean it up must still work. `unlink` resolved
5191        // both endpoints first, so deleting the target made the edge
5192        // permanently unfixable through the tool.
5193        let (_tmp, store) = make_store();
5194        seeded(&store, 1, "A", "");
5195        seeded(&store, 2, "B", "");
5196        store.link("1", "2", RelationKind::Blocks).unwrap();
5197        std::fs::remove_file(store.issues_dir.join("2026-01-01-b.md")).unwrap();
5198
5199        let out = store.unlink("1", "2", RelationKind::Blocks).unwrap();
5200        assert!(out.changed, "a dangling edge is still removable");
5201        let one = store.get(IssueSelector::Number(1)).unwrap();
5202        assert_eq!(
5203            one.relationships,
5204            [] as [crate::relationships::Relationship; 0]
5205        );
5206        assert_eq!(one.history.len(), 2, "and the removal is recorded");
5207    }
5208
5209    #[test]
5210    fn m10_one_unlink_of_a_duplicated_edge_appends_one_event() {
5211        // M10. The same canonical `related` edge hand-authored into **both**
5212        // endpoints — the redundancy the data model says is kept so no stored id
5213        // silently stops resolving. One `unlink` is one accepted mutation, so it
5214        // appends one History event, not one per file written. The previous
5215        // implementation computed the summary per file, and the comment directly
5216        // above it claimed the opposite.
5217        let (_tmp, store) = make_store();
5218        // A redundant pair is one row in **each** endpoint's file — the two
5219        // spellings of a single association. My first version of this fixture
5220        // put both rows in one file, which tested a different thing and then
5221        // asserted an event count for a file that was never written.
5222        seeded(
5223            &store,
5224            1,
5225            "A",
5226            "relationships:\n- id: rel_dup_12\n  from: 1\n  to: 2\n  kind: related\n  display: \"related #2 B\"\n",
5227        );
5228        seeded(
5229            &store,
5230            2,
5231            "B",
5232            "relationships:\n- id: rel_dup_21\n  from: 2\n  to: 1\n  kind: related\n  display: \"related #1 A\"\n",
5233        );
5234
5235        let outcome = store.unlink("1", "2", RelationKind::Related).unwrap();
5236        assert!(outcome.changed, "both redundant rows are removed");
5237        let mut summaries = Vec::new();
5238        for number in [1u32, 2] {
5239            let issue = store.get(IssueSelector::Number(number)).unwrap();
5240            assert!(
5241                issue.relationships.is_empty(),
5242                "issue #{number} keeps no edge"
5243            );
5244            assert_eq!(
5245                issue.history.len(),
5246                1,
5247                "issue #{number} records exactly one event, got {:?}",
5248                issue.history.events()
5249            );
5250            let event = &issue.history.events()[0];
5251            assert!(
5252                event.detail.contains("removed 2 relationships"),
5253                "and the event says both spellings went: {}",
5254                event.detail
5255            );
5256            summaries.push(event.detail.clone());
5257        }
5258        // The point of the fix: one mutation, one wording. The previous version
5259        // computed the summary per file, so each file described only its own row
5260        // and the two events disagreed about what had happened.
5261        assert_eq!(
5262            summaries[0], summaries[1],
5263            "both files record the same single event, not two differing ones"
5264        );
5265    }
5266
5267    #[test]
5268    fn m11_unlink_reports_the_row_it_removed_not_an_empty_placeholder() {
5269        // M11. The post-removal re-scan could never match — every matching row is
5270        // gone by then — so a successful unlink always reported a relationship
5271        // with an empty id, which is not addressable by
5272        // `edit_relation`/`remove_relation`. `link`'s no-op path returned a real
5273        // row; this brings the two into line.
5274        let (_tmp, store, a, b) = related_pair();
5275        let args = (a.number.to_string(), b.number.to_string());
5276        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5277        let before = store.get(IssueSelector::Number(1)).unwrap();
5278        let real_id = before.relationships[0].id.clone();
5279
5280        let outcome = store
5281            .unlink(&args.0, &args.1, RelationKind::Blocks)
5282            .unwrap();
5283        assert!(outcome.changed);
5284        assert_eq!(
5285            outcome.relationship.id, real_id,
5286            "the reported row is the one that was actually removed"
5287        );
5288        assert!(!outcome.relationship.id.is_empty(), "so it is addressable");
5289        assert_eq!(
5290            outcome.relationship.display,
5291            before.relationships[0].display
5292        );
5293    }
5294
5295    #[test]
5296    fn m15_the_history_detail_names_the_relationship_id() {
5297        // M15. `display` already begins with the kind, so naming the kind again
5298        // produced `created blocks: blocks #2 B`. The id is the stable handle and
5299        // is what data-model §8's example shows.
5300        let (_tmp, store, a, b) = related_pair();
5301        let args = (a.number.to_string(), b.number.to_string());
5302        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5303        let one = store.get(IssueSelector::Number(1)).unwrap();
5304        let id = one.relationships[0].id.clone();
5305
5306        let detail = &one.history.events()[0].detail;
5307        assert!(
5308            detail.starts_with(&format!("created {id}:")),
5309            "the detail names the stable id: {detail}"
5310        );
5311        assert!(
5312            !detail.contains(&format!("created {}:", RelationKind::Blocks)),
5313            "and does not stutter the kind, which `display` already carries: {detail}"
5314        );
5315    }
5316
5317    #[test]
5318    fn f17_all_diagnostics_are_reported_not_just_the_first() {
5319        // F17 / M4. The previous version of this test built exactly **one**
5320        // diagnostic and asserted only that the kind prefix was not doubled —
5321        // so it never exercised the multi-diagnostic path the fix exists for.
5322        // Replacing the join with `errors.iter().take(1)` left it green.
5323        //
5324        // `1 blocks 2` plus `2 duplicates 1` makes the same pair carry two
5325        // distinct rule violations at once.
5326        // The corrupt state is hand-authored, because the write path refuses to
5327        // create it — which is the point. A file written before these rules
5328        // existed, or edited by hand, can hold several violations at once, and
5329        // `validate_snapshot` is the surface that must report all of them.
5330        let (_tmp, store) = make_store();
5331        seeded(
5332            &store,
5333            1,
5334            "A",
5335            "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",
5336        );
5337        seeded(
5338            &store,
5339            2,
5340            "B",
5341            "relationships:\n- id: rel_b21\n  from: 2\n  to: 1\n  kind: blocks\n  display: blocks\n",
5342        );
5343
5344        let issues = store.list().unwrap();
5345        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
5346        let verdict = RelationshipGraph::new(&views, "done").validate_snapshot();
5347        assert!(
5348            verdict.errors.len() >= 2,
5349            "the fixture must really produce several diagnostics, got {:?}",
5350            verdict.errors
5351        );
5352
5353        let rendered = relationship_error(&verdict.errors).to_string();
5354        for kind in ["conflicting_edge", "cycle"] {
5355            assert!(
5356                rendered.contains(kind),
5357                "every diagnostic must be reported; {kind} missing from: {rendered}"
5358            );
5359        }
5360        assert!(
5361            !rendered.contains("conflicting_edge: conflicting_edge")
5362                && !rendered.contains("cycle: cycle:"),
5363            "no doubled kind prefix: {rendered}"
5364        );
5365        // M5, tested at the seam. Whether the *service* emits a duplicate
5366        // self-link diagnostic depends on which rules fire, and that has not
5367        // been reproducible from either the library or the binary — so this
5368        // asserts the property where it is decided: `relationship_error` must
5369        // never render the same (kind, message) twice, whatever it is handed.
5370        let dup = vec![
5371            Diagnostic::new(
5372                DiagnosticKind::SelfLink,
5373                "to",
5374                "#1 cannot block itself: a self-link is not a valid relationship",
5375            ),
5376            Diagnostic::new(
5377                DiagnosticKind::SelfLink,
5378                "to",
5379                "#1 cannot block itself: a self-link is not a valid relationship",
5380            ),
5381            Diagnostic::new(DiagnosticKind::Cycle, "to", "cycle: 1 -> 1 -> #1"),
5382        ];
5383        let rendered = relationship_error(&dup).to_string();
5384        assert_eq!(
5385            rendered.matches("cannot block itself").count(),
5386            1,
5387            "an identical diagnostic renders once, not twice: {rendered}"
5388        );
5389        assert!(
5390            rendered.contains("cycle"),
5391            "a distinct diagnostic is still reported: {rendered}"
5392        );
5393        assert!(
5394            !rendered.contains("self_link: self_link") && !rendered.contains("cycle: cycle:"),
5395            "and no kind is prefixed twice: {rendered}"
5396        );
5397    }
5398
5399    #[test]
5400    fn t009_clearing_a_set_removes_a_related_edge_owned_by_another_file() {
5401        // Regression, found in final review. A `related` edge is stored once, in
5402        // the lower-numbered issue's frontmatter, so #5's own file is empty even
5403        // though #5 *is* related to #3. Clearing #5's set must still remove the
5404        // association, or `tix edit 5 --links ""` silently does nothing.
5405        let (_tmp, store) = make_store();
5406        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
5407        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
5408
5409        store.link("5", "3", RelationKind::Related).unwrap();
5410        let three = store.get(IssueSelector::Number(3)).unwrap();
5411        let five = store.get(IssueSelector::Number(5)).unwrap();
5412        assert_eq!(
5413            three.relationships.len(),
5414            1,
5415            "the edge is stored in the lower-numbered issue's file"
5416        );
5417        assert!(
5418            five.relationships.is_empty(),
5419            "and #5's own file holds none"
5420        );
5421
5422        store.set_relationships(&five.filename, &[]).unwrap();
5423
5424        let three_after = store.get(IssueSelector::Number(3)).unwrap();
5425        let five_after = store.get(IssueSelector::Number(5)).unwrap();
5426        assert!(
5427            three_after.relationships.is_empty(),
5428            "the association is gone from the file that actually stored it"
5429        );
5430        assert_eq!(
5431            five_after.relationships,
5432            [] as [crate::relationships::Relationship; 0]
5433        );
5434    }
5435
5436    #[test]
5437    fn t009_clearing_a_set_does_not_remove_an_inbound_directional_edge() {
5438        // The other half of the ownership rule. If #3 blocks #5, that edge is
5439        // #3's declaration; #5 clearing its own relationship set must not
5440        // rewrite #3's dependency graph. Only the non-directional case is
5441        // shared, because only there does the association belong to both ends.
5442        let (_tmp, store) = make_store();
5443        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
5444        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
5445
5446        store.link("3", "5", RelationKind::Blocks).unwrap();
5447        let five = store.get(IssueSelector::Number(5)).unwrap();
5448
5449        store.set_relationships(&five.filename, &[]).unwrap();
5450
5451        let three_after = store.get(IssueSelector::Number(3)).unwrap();
5452        assert!(
5453            three_after
5454                .relationships
5455                .iter()
5456                .any(|r| r.to == 5 && r.kind == RelationKind::Blocks),
5457            "#3's declaration that it blocks #5 survives: a blocked issue \
5458             cannot unilaterally clear the blocker's graph"
5459        );
5460    }
5461
5462    /// The path of a committed relationship fixture.
5463    fn relationship_fixture(name: &str) -> String {
5464        format!(
5465            "{}/tests/fixtures/relationships/{name}",
5466            env!("CARGO_MANIFEST_DIR")
5467        )
5468    }
5469
5470    #[test]
5471    fn t010_typed_fixture_round_trips_byte_stable_including_history() {
5472        // NFR-003, model-level half: a typed fixture survives read -> write ->
5473        // read unchanged, `## History` region included.
5474        let original =
5475            std::fs::read_to_string(relationship_fixture("typed-relationships.md")).unwrap();
5476        let issue = Issue::parse(&original).unwrap();
5477        assert_eq!(issue.relationships.len(), 2);
5478        assert_eq!(issue.history.len(), 2);
5479        let rendered = issue.to_file_contents();
5480        assert_eq!(
5481            rendered, original,
5482            "a typed fixture must not churn on a read/write round trip"
5483        );
5484        assert_eq!(
5485            Issue::parse(&rendered).unwrap().to_file_contents(),
5486            rendered
5487        );
5488        // The stable ids and the section position both survive.
5489        assert_eq!(issue.relationships[0].id, "rel_01JQ8Z4W2M6N7P9R3S5T7V9X1");
5490        assert!(
5491            rendered.find("## History").unwrap() < rendered.find("Prose that follows").unwrap(),
5492            "History sits between the frontmatter and the body"
5493        );
5494    }
5495
5496    #[test]
5497    fn t010_issue_with_no_relationship_data_is_untouched() {
5498        let original =
5499            std::fs::read_to_string(relationship_fixture("no-relationship-data.md")).unwrap();
5500        let issue = Issue::parse(&original).unwrap();
5501        assert_eq!(
5502            issue.relationships,
5503            [] as [crate::relationships::Relationship; 0]
5504        );
5505        assert!(issue.history.is_empty());
5506        assert!(!issue.has_retired_links_key);
5507        let rendered = issue.to_file_contents();
5508        assert_eq!(
5509            rendered, original,
5510            "an issue with no relationship data must not churn"
5511        );
5512        assert!(!rendered.contains("relationships:"));
5513        assert!(!rendered.contains("## History"));
5514    }
5515
5516    #[test]
5517    fn t010_an_empty_history_section_is_dropped_not_preserved() {
5518        // A hand-left `## History` heading with no entries parses to an empty
5519        // log. The section is derived from the log, so an empty one has no
5520        // representation on disk and is omitted on write — diagnosed, never
5521        // silently filled in, and never kept as a bare heading.
5522        let original = std::fs::read_to_string(relationship_fixture("history-empty.md")).unwrap();
5523        assert!(
5524            original.contains("## History"),
5525            "the fixture must actually carry the empty heading"
5526        );
5527        let issue = Issue::parse(&original).unwrap();
5528        assert!(
5529            issue.history.is_empty(),
5530            "a heading with no entries parses to an empty log"
5531        );
5532        assert!(
5533            issue
5534                .body
5535                .contains("An issue whose History section is present but empty."),
5536            "the prose after the heading is body, not a dropped event: {:?}",
5537            issue.body
5538        );
5539        let rendered = issue.to_file_contents();
5540        assert!(
5541            !rendered.contains("## History"),
5542            "the empty section is omitted rather than kept as a bare heading"
5543        );
5544        assert!(rendered.contains("An issue whose History section is present but empty."));
5545    }
5546
5547    #[test]
5548    fn t010_issue_with_no_history_section_is_untouched() {
5549        let original = std::fs::read_to_string(relationship_fixture("history-absent.md")).unwrap();
5550        let issue = Issue::parse(&original).unwrap();
5551        assert!(issue.history.is_empty());
5552        let rendered = issue.to_file_contents();
5553        assert_eq!(
5554            rendered, original,
5555            "a file written before History existed must not gain an empty section"
5556        );
5557    }
5558
5559    #[test]
5560    fn f2_one_bad_history_line_does_not_make_the_store_unreadable() {
5561        // Store-level consequence of the model-level guarantee. Before the parse
5562        // became non-fatal, `Store::list` propagated the error, so a single
5563        // mistyped line in one issue broke `list`, `get`, `view`, `board`,
5564        // `status`, `lint`, `export`, `link`, `unlink` and `set_relationships`
5565        // for *every* issue — including `get` on a completely unrelated,
5566        // perfectly valid one. There is also no recovery through the tool,
5567        // because every command that could rewrite the file was among the
5568        // failures.
5569        let (_tmp, store) = make_store();
5570        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
5571        write_issue(
5572            &store,
5573            "2026-01-01-c.md",
5574            "---\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",
5575        );
5576
5577        let all = store.list().expect("the store must remain listable");
5578        assert_eq!(all.len(), 2, "both issues load");
5579        assert_eq!(
5580            store
5581                .get(IssueSelector::Number(1))
5582                .expect("an unrelated, valid issue must stay readable")
5583                .title,
5584            "A"
5585        );
5586        // And the damaged line is still reachable for diagnosis, which is the
5587        // whole point of the non-fatal parse. An ASCII-hyphen line is not
5588        // *recognised*, so no event is fabricated from it, but it is retained in
5589        // the section verbatim and reported rather than demoted into the body
5590        // (see the F1 trade in `parse_front`).
5591        let three = store.get(IssueSelector::Number(3)).unwrap();
5592        assert!(three.history.events().is_empty(), "no event is fabricated");
5593        assert_eq!(
5594            three.history.unparsed().len(),
5595            1,
5596            "the line is preserved for diagnosis: {:?}",
5597            three.history.unparsed()
5598        );
5599        assert!(
5600            !three.body.contains("ascii hyphens"),
5601            "and is not demoted into the body: {:?}",
5602            three.body
5603        );
5604    }
5605
5606    /// Seed an issue into its own assignee segment, so a write can be failed
5607    /// for one owner and not another.
5608    #[cfg(unix)]
5609    fn seeded_in_segment(store: &Store, number: u32, title: &str, segment: &str) {
5610        let dir = store.issues_dir.join(segment);
5611        std::fs::create_dir_all(&dir).unwrap();
5612        std::fs::write(
5613            dir.join(format!("2026-01-01-{}.md", title.to_lowercase())),
5614            format!(
5615                "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
5616            ),
5617        )
5618        .unwrap();
5619    }
5620
5621    #[cfg(unix)]
5622    #[test]
5623    fn f6_a_mid_sequence_write_failure_rolls_every_file_back() {
5624        use std::os::unix::fs::PermissionsExt;
5625        // F6 / anti-pattern 7. This can write N files, because an edge is stored
5626        // in its canonical source's file, so a failure partway through used to
5627        // leave the store half-mutated while the command reported failure.
5628        //
5629        // `atomic_write` renames a temp file, which needs write permission on
5630        // the *directory*, so the injection makes one owner's segment
5631        // read-only. The owners are 1, 2 and 3 and are written in ascending
5632        // order, so #1 and #2 are committed before #3 fails.
5633        let (tmp, store) = make_store();
5634        let _ = tmp;
5635        seeded_in_segment(&store, 1, "A", "alice");
5636        seeded_in_segment(&store, 2, "B", "bob");
5637        seeded_in_segment(&store, 3, "C", "carol");
5638        seeded_in_segment(&store, 5, "E", "erin");
5639
5640        let paths: Vec<std::path::PathBuf> = [
5641            "alice/2026-01-01-a.md",
5642            "bob/2026-01-01-b.md",
5643            "carol/2026-01-01-c.md",
5644        ]
5645        .iter()
5646        .map(|p| store.issues_dir.join(p))
5647        .collect();
5648        let before: Vec<String> = paths
5649            .iter()
5650            .map(|p| std::fs::read_to_string(p).unwrap())
5651            .collect();
5652
5653        // Order-normalization puts target 5's edge on the lower number, so the
5654        // owners really are 1, 2 and 3 and #3 is written last.
5655        let carol = store.issues_dir.join("carol");
5656        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5657        perms.set_mode(0o555);
5658        std::fs::set_permissions(&carol, perms).unwrap();
5659
5660        let result = store.set_relationships(
5661            "carol/2026-01-01-c.md",
5662            &[
5663                (1, RelationKind::Related),
5664                (2, RelationKind::Related),
5665                (5, RelationKind::Related),
5666            ],
5667        );
5668
5669        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5670        perms.set_mode(0o755);
5671        std::fs::set_permissions(&carol, perms).unwrap();
5672
5673        assert!(result.is_err(), "the blocked write must fail");
5674        for (path, previous) in paths.iter().zip(before.iter()) {
5675            assert_eq!(
5676                std::fs::read_to_string(path).unwrap(),
5677                *previous,
5678                "{} was left mutated by a failed mutation",
5679                path.display()
5680            );
5681        }
5682    }
5683
5684    #[cfg(unix)]
5685    #[test]
5686    fn f6b_the_updated_bump_is_itself_rollback_covered() {
5687        // The `updated` bump on the named issue (F16) is a real write, and it
5688        // used to sit *after* the rollback check — so a failure there returned an
5689        // error with the relationship writes already committed and nothing left
5690        // to restore. That is the same class of defect F6 was about, reintroduced
5691        // by the F16 fix.
5692        //
5693        // Order matters here: the relationship write lands in the *other*
5694        // endpoint's segment, and the bump targets the named issue's own
5695        // segment, so blocking the named segment fails the second write.
5696        use std::os::unix::fs::PermissionsExt;
5697
5698        let (tmp, store) = make_store();
5699        let _ = tmp;
5700        seeded_in_segment(&store, 1, "A", "alice");
5701        seeded_in_segment(&store, 3, "C", "carol");
5702
5703        let alice = store.issues_dir.join("alice/2026-01-01-a.md");
5704        let before = std::fs::read_to_string(&alice).unwrap();
5705
5706        let carol = store.issues_dir.join("carol");
5707        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5708        perms.set_mode(0o555);
5709        std::fs::set_permissions(&carol, perms).unwrap();
5710
5711        let result =
5712            store.set_relationships("carol/2026-01-01-c.md", &[(1, RelationKind::Related)]);
5713
5714        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5715        perms.set_mode(0o755);
5716        std::fs::set_permissions(&carol, perms).unwrap();
5717
5718        assert!(result.is_err(), "the blocked bump must fail the call");
5719        assert_eq!(
5720            std::fs::read_to_string(&alice).unwrap(),
5721            before,
5722            "the relationship write that succeeded earlier must be rolled back \
5723             when the subsequent `updated` bump fails"
5724        );
5725    }
5726
5727    #[test]
5728    fn f9_relationships_for_reports_both_endpoints_of_a_one_sided_edge() {
5729        // F9. An edge is stored once, in its canonical source's file, so the
5730        // issue's own `relationships` field is a *partial* view. Reading it made
5731        // a related issue look unrelated — a user-visible regression against the
5732        // retired symmetric model, which mirrored the link on both sides by
5733        // construction.
5734        let (_tmp, store) = make_store();
5735        seeded(&store, 1, "A", "");
5736        seeded(&store, 2, "B", "");
5737        store.link("2", "1", RelationKind::Related).unwrap();
5738
5739        // The edge is stored once, in the lower-numbered issue's file.
5740        let one = store.get(IssueSelector::Number(1)).unwrap();
5741        let two = store.get(IssueSelector::Number(2)).unwrap();
5742        assert_eq!(one.relationships.len(), 1, "precondition: #1 stores it");
5743        assert!(
5744            two.relationships.is_empty(),
5745            "precondition: #2's own field is the partial view that caused F9"
5746        );
5747
5748        // The service-backed accessor is what a read surface must use, and it
5749        // answers for both endpoints.
5750        for number in [1u32, 2] {
5751            let resolved = store.relationships_for(number).unwrap();
5752            assert_eq!(
5753                resolved.len(),
5754                1,
5755                "issue #{number} must see the relationship that touches it"
5756            );
5757            assert_eq!(resolved[0].kind, RelationKind::Related);
5758        }
5759    }
5760
5761    #[test]
5762    fn f7_writing_an_issue_with_the_retired_key_discloses_that_it_is_removed() {
5763        // F7 / M6. The previous version of this test asserted only the
5764        // *consequence* — the key is gone from the file — which was already true
5765        // before the fix, so removing the disclosure entirely left it green.
5766        // It never checked the thing its name promised.
5767        let (tmp, store) = make_store();
5768        let _ = tmp;
5769        let _ = take_disclosures();
5770        // **Populated**, not `[]`. The disclosure fires because the key's value is
5771        // no longer readable, and an empty value has nothing to lose — reporting
5772        // the key's mere presence made `tix lint` reject every issue file this
5773        // repository has ever written, since they all carry a `links: []`.
5774        write_issue(
5775            &store,
5776            "2026-01-01-legacy.md",
5777            &legacy_links_frontmatter(1, "Legacy", "['7']"),
5778        );
5779        let issue = store.get(IssueSelector::Number(1)).unwrap();
5780        assert!(
5781            issue.has_retired_links_key,
5782            "precondition: the key is present"
5783        );
5784
5785        store.update(&issue.filename, &issue).unwrap();
5786
5787        // The disclosure, which is the whole point of the fix.
5788        let disclosures = take_disclosures();
5789        assert_eq!(
5790            disclosures.len(),
5791            1,
5792            "the write must disclose that it dropped the key: {disclosures:?}"
5793        );
5794        let line = &disclosures[0];
5795        assert!(
5796            line.contains("`links:`"),
5797            "it names what was removed: {line}"
5798        );
5799        // The action leads, so it is the part that survives truncation in the
5800        // TUI's one-row status area. Cycle 6 measured the previous wording being
5801        // cut off at 80, 100 and 120 columns.
5802        assert!(
5803            line.starts_with("remove `links:` by hand"),
5804            "and leads with what to do about it: {line}"
5805        );
5806        assert!(
5807            line.contains(&issue.filename),
5808            "and which file it was removed from: {line}"
5809        );
5810        assert!(
5811            line.contains("C-001"),
5812            "and that no migration was applied, so the user is not left \
5813             wondering where their data went: {line}"
5814        );
5815
5816        // The consequence the disclosure is about.
5817        let after = read_issue_file(&store, &issue.filename);
5818        assert!(
5819            !after.contains("links:"),
5820            "the key is not re-emitted (C-001: no migration)"
5821        );
5822        // And a re-read no longer reports it, which is precisely why the
5823        // disclosure matters: the diagnostic is self-erasing without it.
5824        let reread = store.get(IssueSelector::Number(1)).unwrap();
5825        assert!(!reread.has_retired_links_key);
5826        assert!(
5827            take_disclosures().is_empty(),
5828            "and it is disclosed once, not on every read"
5829        );
5830    }
5831
5832    #[test]
5833    fn f7b_writing_an_issue_without_the_retired_key_is_silent() {
5834        // The disclosure is scoped to the condition that triggers it, so the
5835        // ordinary path stays quiet and this queue cannot become noise.
5836        let (tmp, store) = make_store();
5837        let _ = tmp;
5838        let _ = take_disclosures();
5839        write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
5840        let issue = store.get(IssueSelector::Number(1)).unwrap();
5841        assert!(!issue.has_retired_links_key);
5842        store.update(&issue.filename, &issue).unwrap();
5843        assert!(
5844            take_disclosures().is_empty(),
5845            "an ordinary write discloses nothing"
5846        );
5847    }
5848
5849    #[test]
5850    fn t010_legacy_fixture_reports_the_retired_key_and_yields_no_edges() {
5851        // C-001, the compatibility/error matrix in one place: tolerant read,
5852        // explicit diagnostic, and zero reinterpretation.
5853        let original =
5854            std::fs::read_to_string(relationship_fixture("legacy-links-key.md")).unwrap();
5855        let issue = Issue::parse(&original).unwrap();
5856        assert!(issue.has_retired_links_key, "the retired key is reported");
5857        assert!(
5858            issue.relationships.is_empty(),
5859            "C-001: legacy values are never reinterpreted as typed edges"
5860        );
5861        assert!(
5862            issue.history.is_empty(),
5863            "a legacy file gains no History section from parsing"
5864        );
5865        // The diagnostic names the field and is located in the file, per the
5866        // validation contract. Exercised through the real `tix lint` path so
5867        // the test covers the code that actually ships.
5868        let (_tmp, store) = make_store();
5869        write_issue(&store, "2026-01-01-legacy.md", &original);
5870        let findings = crate::commands::lint::collect(&store.config, &store).unwrap();
5871        let retired: Vec<_> = findings
5872            .iter()
5873            .filter(|f| f.kind == "retired_links_key")
5874            .collect();
5875        assert_eq!(
5876            retired.len(),
5877            1,
5878            "one finding for the retired key: {findings:?}"
5879        );
5880        assert_eq!(retired[0].value, "links", "the finding names the field");
5881        assert!(
5882            retired[0].message.contains("retired"),
5883            "the message says why: {}",
5884            retired[0].message
5885        );
5886        assert!(
5887            retired[0].line > 0,
5888            "the finding points into the frontmatter"
5889        );
5890        // No code path converts the legacy value into a typed edge.
5891        let rewritten = issue.to_file_contents();
5892        assert!(!rewritten.contains("kind: related"));
5893        assert!(!rewritten.contains("kind: blocks"));
5894        assert!(!rewritten.contains("links:"));
5895    }
5896
5897    #[test]
5898    fn c001_a_legacy_links_key_is_reported_and_yields_no_typed_edges() {
5899        let (tmp, store) = make_store();
5900        write_issue(
5901            &store,
5902            "2026-01-01-legacy.md",
5903            &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
5904        );
5905        let _ = tmp;
5906        let issue = store.get(IssueSelector::Number(1)).unwrap();
5907        assert!(
5908            issue.has_retired_links_key,
5909            "the retired key is recorded as a validation result"
5910        );
5911        assert!(
5912            issue.relationships.is_empty(),
5913            "C-001: legacy values are never read as typed edges"
5914        );
5915    }
5916
5917    #[test]
5918    fn c001_there_is_no_migration_path_from_the_retired_key() {
5919        // This test used to say "writing the issue back is the only way a
5920        // migration could occur" and then never write it back, so its
5921        // load-bearing assertion was on an in-memory flag after a pure string
5922        // render — which cannot change it. The actual write-back is where the
5923        // key disappears, so the test as written masked the very thing it
5924        // appeared to guard.
5925        //
5926        // It now performs the write and asserts on the resulting bytes.
5927        let (tmp, store) = make_store();
5928        let _ = tmp;
5929        write_issue(
5930            &store,
5931            "2026-01-01-legacy.md",
5932            &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
5933        );
5934        let issue = store.get(IssueSelector::Number(1)).unwrap();
5935        assert!(
5936            issue.has_retired_links_key,
5937            "precondition: the key is present"
5938        );
5939        assert!(
5940            issue.relationships.is_empty(),
5941            "C-001: legacy values are never read as typed edges"
5942        );
5943
5944        store.update(&issue.filename, &issue).unwrap();
5945        let written = read_issue_file(&store, &issue.filename);
5946
5947        // No migration: not one legacy value became a typed edge, under any kind.
5948        //
5949        // The re-read matters, and this test has already fallen into the trap
5950        // once: asserting on `issue.to_file_contents()` instead of the written
5951        // file cannot fail, because `Issue::parse` never populates
5952        // `relationships`, so the in-memory render is clean whatever the *writer*
5953        // does. Reading the file back off disk is the only form of this check
5954        // that the write-back actually has to satisfy.
5955        let reparsed = store.get(IssueSelector::Number(1)).unwrap();
5956        assert!(
5957            reparsed.relationships.is_empty(),
5958            "the file on disk parses to zero typed edges, not just the in-memory copy"
5959        );
5960        for kind in ["blocks", "blocked_by", "duplicates", "related"] {
5961            assert!(
5962                !written.contains(&format!("kind: {kind}")),
5963                "a legacy link became a `{kind}` edge:\n{written}"
5964            );
5965        }
5966        assert!(
5967            !written.contains("kind:"),
5968            "no typed edge was written at all"
5969        );
5970        // The retired key is not re-emitted, and the write did not manufacture
5971        // a History entry for something the user did not do.
5972        assert!(!written.contains("links:"), "the key is not re-emitted");
5973        let reread = store.get(IssueSelector::Number(1)).unwrap();
5974        assert!(
5975            reread.history.is_empty(),
5976            "a format change is not a relationship mutation, so it records no event"
5977        );
5978    }
5979
5980    #[test]
5981    fn f15_the_idempotent_no_op_reports_the_real_addressable_row() {
5982        // F15. The no-op path returned a synthetic `Relationship` with an empty
5983        // id and display. The contract defines the output as the accepted
5984        // relationship, and an empty id is not addressable by
5985        // `edit_relation`/`remove_relation`, so a caller that persisted it would
5986        // store a dangling reference.
5987        let (_tmp, store, a, b) = related_pair();
5988        let args = (a.number.to_string(), b.number.to_string());
5989        let first = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5990        let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5991
5992        assert!(first.changed);
5993        assert!(!second.changed, "the second apply is a no-op");
5994        assert_eq!(
5995            second.relationship.id, first.relationship.id,
5996            "the no-op reports the real, addressable row"
5997        );
5998        assert!(
5999            !second.relationship.id.is_empty(),
6000            "an empty id is not addressable"
6001        );
6002        assert_eq!(second.relationship.display, first.relationship.display);
6003    }
6004
6005    #[test]
6006    fn f16_clearing_a_set_bumps_updated_on_the_named_issue() {
6007        // F16. The named issue's own frontmatter can be untouched while its
6008        // relationship set changed, because an order-normalized `related` edge
6009        // lives in the other endpoint's file. That left `tix edit <n> --links ""`
6010        // looking like it had done nothing to the issue it named.
6011        let (_tmp, store, _a, _b) = related_pair();
6012        store.link("2", "1", RelationKind::Related).unwrap();
6013        let before = store.get(IssueSelector::Number(2)).unwrap();
6014        let updated_before = before.updated.clone();
6015
6016        store.set_relationships(&before.filename, &[]).unwrap();
6017
6018        let after = store.get(IssueSelector::Number(2)).unwrap();
6019        assert_ne!(
6020            after.updated, updated_before,
6021            "the named issue's relationship set changed, so `updated` must move"
6022        );
6023    }
6024
6025    #[test]
6026    fn c002_an_issue_with_no_relationship_data_round_trips_unchanged() {
6027        let (tmp, store) = make_store();
6028        let _ = tmp;
6029        // A pre-existing issue file: no relationship data, no History. This is
6030        // the shape every file in the repository has today, and reading then
6031        // writing it must not churn it (the WP's "existing files must not be
6032        // rewritten" guard).
6033        write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
6034        let issue = store.get(IssueSelector::Number(1)).unwrap();
6035        let first = issue.to_file_contents();
6036        assert!(
6037            !first.contains("relationships:"),
6038            "the empty typed field is omitted, not written as `[]`"
6039        );
6040        assert!(
6041            !first.contains("## History"),
6042            "an empty History section is omitted entirely"
6043        );
6044        // Byte-stable through read -> write -> read, and no unrelated
6045        // frontmatter key is added, removed, or reordered.
6046        let reparsed = Issue::parse(&first).unwrap();
6047        let second = reparsed.to_file_contents();
6048        assert_eq!(second, first);
6049    }
6050
6051    #[test]
6052    fn c002_history_section_round_trips_byte_stable() {
6053        let (tmp, store) = make_store();
6054        write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
6055        let _ = tmp;
6056        let mut issue = store.get(IssueSelector::Number(1)).unwrap();
6057        issue.history.append_now(
6058            "alice",
6059            HistoryEventKind::RelationshipAdded,
6060            "created blocks: blocks #2 B",
6061        );
6062        issue.history.append_now(
6063            "bob",
6064            HistoryEventKind::BlockedMoveAcknowledged,
6065            "acknowledged 1 open blocker",
6066        );
6067        let first = issue.to_file_contents();
6068        assert!(first.contains("## History"), "{first}");
6069
6070        // Read -> write -> read must be byte-stable.
6071        let reparsed = Issue::parse(&first).unwrap();
6072        assert_eq!(reparsed.to_file_contents(), first);
6073        assert_eq!(reparsed.history.len(), 2);
6074        assert_eq!(reparsed.history.events()[0].actor, "alice");
6075        assert_eq!(
6076            reparsed.history.events()[1].kind,
6077            HistoryEventKind::BlockedMoveAcknowledged
6078        );
6079        assert!(
6080            !reparsed.body.contains("History"),
6081            "the section is stripped from the body: {:?}",
6082            reparsed.body
6083        );
6084    }
6085
6086    #[test]
6087    fn f1_a_hand_edit_last_in_the_section_stays_in_it() {
6088        // Cycle-4 F1. The disambiguation rule carried a `!following.is_empty()`
6089        // guard, added to stop an earlier vacuous-truth defect. It over-corrected:
6090        // a hand-edited line that is the **last** line of the section has nothing
6091        // after it, so the guard forced the section to terminate there. Through
6092        // the real binary that meant `tix move 1 "in progress"` silently
6093        //
6094        //   - deleted the `## History` heading,
6095        //   - demoted the author's line into the issue body, and
6096        //   - rendered the *next* appended event above it, so file order stopped
6097        //     being append order,
6098        //
6099        // and `tix lint` reported nothing, because a demoted line is body prose
6100        // and the unparsed loop no longer sees it. Three explicit data-model §8
6101        // invariants at once.
6102        //
6103        // The distinguishing question is not "is there anything after this?" but
6104        // "is this shaped like a list item?". Nothing-following cannot disqualify
6105        // a line; a bare paragraph at EOF is still plainly prose.
6106        let (tmp, store) = make_store();
6107        let _ = tmp;
6108        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";
6109        write_issue(&store, "2026-01-01-a.md", original);
6110
6111        // Read precondition: the hand edit is inside the section, and the section
6112        // holds the prior event.
6113        let before = store.get(IssueSelector::Number(1)).unwrap();
6114        assert_eq!(
6115            before.history.events().len(),
6116            1,
6117            "precondition: the real event parsed"
6118        );
6119        assert_eq!(
6120            before.history.unparsed(),
6121            vec!["  - my hand edit"],
6122            "precondition: the hand edit is inside the section"
6123        );
6124        assert_eq!(before.body, "", "precondition: body is empty");
6125
6126        // An ordinary command that has nothing to do with relationships.
6127        let mut moved = before.clone();
6128        moved.status = crate::model::Status::new("in progress");
6129        store.update(&moved.filename, &moved).unwrap();
6130
6131        let after = store.get(IssueSelector::Number(1)).unwrap();
6132        assert_eq!(
6133            after.history.unparsed(),
6134            vec!["  - my hand edit"],
6135            "the hand edit is still in the audit log, not demoted to the body"
6136        );
6137        assert_eq!(
6138            after.history.events().len(),
6139            1,
6140            "and the real event survived"
6141        );
6142        assert_eq!(after.body, "", "nothing leaked into the body");
6143
6144        // The heading is still there, and the next append goes *after* what was
6145        // already in the file.
6146        store
6147            .link("1", "2", RelationKind::Related)
6148            .ok()
6149            .or_else(|| {
6150                write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6151                store.link("1", "2", RelationKind::Related).ok()
6152            });
6153        let linked = store.get(IssueSelector::Number(1)).unwrap();
6154        let rendered = linked.to_file_contents();
6155        assert!(
6156            rendered.contains("## History"),
6157            "the heading survived an unrelated write: {rendered}"
6158        );
6159        let hand = rendered
6160            .find("my hand edit")
6161            .expect("the hand edit is present");
6162        let newest = rendered
6163            .find("relationship_added")
6164            .expect("the new event is present");
6165        assert!(
6166            newest < hand,
6167            "the new event is appended AFTER the pre-existing content, so file \
6168             order is still append order: {rendered}"
6169        );
6170    }
6171
6172    #[test]
6173    fn f1_but_a_bare_paragraph_at_end_of_section_is_still_prose() {
6174        // The other half of F1's rule, and the reason the fix is not simply
6175        // "vacuous truth is fine". A non-list line at EOF is body prose: keeping
6176        // it would put ordinary prose into the append-only audit log, which is
6177        // the defect cycle 3 found (N1) travelling in the opposite direction.
6178        let (tmp, store) = make_store();
6179        let _ = tmp;
6180        write_issue(
6181            &store,
6182            "2026-01-01-a.md",
6183            "---\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",
6184        );
6185        let issue = store.get(IssueSelector::Number(1)).unwrap();
6186        assert_eq!(issue.history.events().len(), 1, "the real event parsed");
6187        assert!(
6188            issue.history.unparsed().is_empty(),
6189            "the paragraph is not in the audit log: {:?}",
6190            issue.history.unparsed()
6191        );
6192        assert!(
6193            issue.body.contains("just a closing paragraph"),
6194            "it is body prose: {:?}",
6195            issue.body
6196        );
6197    }
6198
6199    #[test]
6200    fn f1_a_hand_edit_survives_body_prose_and_a_later_body_replacement() {
6201        // Cycle-5 Blocker 1. Cycle 4's F1 fix consulted `is_list_item` *only*
6202        // when nothing followed the ambiguous line, so the whole defect survived
6203        // for any hand-edited entry with body prose after it. The existing F1
6204        // tests could not see it because every one of their fixtures asserts
6205        // `before.body == ""` — the empty body was precisely what hid it.
6206        //
6207        // The consequence was data loss, not just reordering. A demoted line is
6208        // body text, and `tix edit --body` replaces the body wholesale, so:
6209        //
6210        //   ## History / - <event> / - 2026-02-02T00:00:00Z - bob \
6211        //     - relationship_removed - I removed this by hand / (blank) / Notes.
6212        //
6213        // then `tix edit 1 --body='Completely new body text.'` destroyed the
6214        // hand-edited append-only audit entry, and lint was silent.
6215        let (_tmp, store) = make_store();
6216        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6217        let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed - by hand";
6218        write_issue(
6219            &store,
6220            "2026-01-01-a.md",
6221            &format!(
6222                "---\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"
6223            ),
6224        );
6225
6226        let before = store.get(IssueSelector::Number(1)).unwrap();
6227        assert_eq!(before.history.events().len(), 1, "the real event parsed");
6228        assert_eq!(
6229            before.history.unparsed(),
6230            vec![hand],
6231            "the hand-edited entry is retained even though body prose follows"
6232        );
6233        assert!(
6234            before.body.contains("Some body prose."),
6235            "and the prose is still body text: {:?}",
6236            before.body
6237        );
6238
6239        // An ordinary unrelated write must not relocate it.
6240        let mut moved = before.clone();
6241        moved.status = crate::model::Status::new("in progress");
6242        store.update(&moved.filename, &moved).unwrap();
6243        assert_eq!(
6244            store
6245                .get(IssueSelector::Number(1))
6246                .unwrap()
6247                .history
6248                .unparsed(),
6249            vec![hand],
6250            "a `tix move` keeps it in the audit log"
6251        );
6252
6253        // And replacing the body — the operation that used to destroy it — must
6254        // not either.
6255        let mut replaced = store.get(IssueSelector::Number(1)).unwrap();
6256        replaced.body = "Completely new body text.".to_string();
6257        store.update(&replaced.filename, &replaced).unwrap();
6258        let after = store.get(IssueSelector::Number(1)).unwrap();
6259        assert_eq!(
6260            after.history.unparsed(),
6261            vec![hand],
6262            "the hand-edited entry survives a wholesale body replacement"
6263        );
6264        assert_eq!(after.body, "Completely new body text.");
6265
6266        // And the next append still goes after what the file already held.
6267        store.link("1", "2", RelationKind::Related).unwrap();
6268        let rendered = store
6269            .get(IssueSelector::Number(1))
6270            .unwrap()
6271            .to_file_contents();
6272        assert!(
6273            rendered.contains("## History"),
6274            "the heading survived: {rendered}"
6275        );
6276        let hand_at = rendered.find("by hand").expect("the hand edit is present");
6277        let new_at = rendered
6278            .find("created rel_")
6279            .expect("the new event is present");
6280        assert!(
6281            new_at > hand_at,
6282            "the new event is appended AFTER the pre-existing content, so file \
6283             order is still append order: {rendered}"
6284        );
6285    }
6286
6287    #[test]
6288    fn f1_a_well_formed_star_marked_entry_is_retained_with_body_prose_after_it() {
6289        // Cycle-6 F1. A *correctly formatted* entry written with a `*` marker was
6290        // demoted to the body and then destroyed by `tix edit --body`, with lint
6291        // silent. The cause was a collapsed predicate: `looks_like_history_entry`
6292        // and `HistoryEvent::parse` both require the `- ` prefix, so a `*`-marked
6293        // em-dash line was not *recognised*; and the retention test asked
6294        // "does it look like a *misspelled* entry?", which explicitly excludes
6295        // anything containing U+2014. So a well-formed line matched neither.
6296        // Retention now asks its own question — is this an entry attempt under any
6297        // marker?
6298        let (_tmp, store) = make_store();
6299        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6300        let hand =
6301            "* 2026-02-02T00:00:00Z \u{2014} bob \u{2014} relationship_removed \u{2014} by hand";
6302        write_issue(
6303            &store,
6304            "2026-01-01-a.md",
6305            &format!(
6306                "---\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"
6307            ),
6308        );
6309
6310        let before = store.get(IssueSelector::Number(1)).unwrap();
6311        assert_eq!(before.history.events().len(), 1, "the real event parsed");
6312        assert_eq!(
6313            before.history.unparsed(),
6314            vec![hand],
6315            "a well-formed `*` entry is retained even with body prose after it"
6316        );
6317        assert!(
6318            before.body.contains("Some body prose."),
6319            "and the prose is still body text: {:?}",
6320            before.body
6321        );
6322
6323        // The destructive sequence, which is the whole point.
6324        let mut replaced = before.clone();
6325        replaced.body = "Completely new body text.".to_string();
6326        store.update(&replaced.filename, &replaced).unwrap();
6327        assert_eq!(
6328            store
6329                .get(IssueSelector::Number(1))
6330                .unwrap()
6331                .history
6332                .unparsed(),
6333            vec![hand],
6334            "it survives a wholesale body replacement"
6335        );
6336
6337        store.link("1", "2", RelationKind::Related).unwrap();
6338        let rendered = store
6339            .get(IssueSelector::Number(1))
6340            .unwrap()
6341            .to_file_contents();
6342        assert_eq!(
6343            rendered.matches("## History").count(),
6344            1,
6345            "still exactly one section: {rendered}"
6346        );
6347        let hand_at = rendered.find("by hand").expect("the hand edit is present");
6348        let new_at = rendered
6349            .find("created rel_")
6350            .expect("the new event is present");
6351        assert!(
6352            new_at > hand_at,
6353            "appended after, so order is append order: {rendered}"
6354        );
6355    }
6356
6357    #[test]
6358    fn f1_a_three_field_ascii_hyphen_entry_is_retained_with_body_prose_after_it() {
6359        // Cycle-6 F2. `HistoryEvent::parse` accepts three total fields with
6360        // `detail` optional, so `- <ts> - <actor> - <kind>` is a valid entry whose
6361        // only defect is the separator. The retention test required four, so that
6362        // line was demoted and then destroyed while the malformed four-field one
6363        // survived — the recoverable direction was the one that lost.
6364        let (_tmp, store) = make_store();
6365        let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed";
6366        write_issue(
6367            &store,
6368            "2026-01-01-a.md",
6369            &format!(
6370                "---\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"
6371            ),
6372        );
6373
6374        // Precondition: the em-dash twin really is a valid event, so the
6375        // three-field form is in fact part of the format.
6376        let twin = Issue::parse(
6377            "---\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",
6378        )
6379        .unwrap();
6380        assert_eq!(
6381            twin.history.events().len(),
6382            1,
6383            "three fields with em-dashes is a valid event, detail optional"
6384        );
6385
6386        let before = store.get(IssueSelector::Number(1)).unwrap();
6387        assert_eq!(
6388            before.history.unparsed(),
6389            vec![hand],
6390            "so its ASCII-hyphen twin is retained, not demoted"
6391        );
6392        let mut replaced = before.clone();
6393        replaced.body = "Completely new body text.".to_string();
6394        store.update(&replaced.filename, &replaced).unwrap();
6395        assert_eq!(
6396            store
6397                .get(IssueSelector::Number(1))
6398                .unwrap()
6399                .history
6400                .unparsed(),
6401            vec![hand],
6402            "and survives a body replacement"
6403        );
6404    }
6405
6406    #[test]
6407    fn f1_a_heading_with_trailing_spaces_is_still_the_heading() {
6408        // Cycle-6 F3(c). `## History   ` was not recognised, and since the
6409        // heading is then neither the section nor body, the first relationship
6410        // write rendered a **second** `## History` above the original. That was
6411        // filed on #1299 as a CRLF-only consequence, which understated it: a
6412        // trailing space is a far likelier trigger than CRLF.
6413        let (_tmp, store) = make_store();
6414        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6415        for (label, heading) in [
6416            ("plain", "## History"),
6417            ("trailing spaces", "## History   "),
6418        ] {
6419            write_issue(
6420                &store,
6421                "2026-01-01-a.md",
6422                &format!(
6423                    "---\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"
6424                ),
6425            );
6426            let before = store.get(IssueSelector::Number(1)).unwrap();
6427            assert_eq!(
6428                before.history.events().len(),
6429                1,
6430                "{label}: the section is recognised"
6431            );
6432            store.link("1", "2", RelationKind::Related).unwrap();
6433            let rendered = store
6434                .get(IssueSelector::Number(1))
6435                .unwrap()
6436                .to_file_contents();
6437            assert_eq!(
6438                rendered.matches("## History").count(),
6439                1,
6440                "{label}: exactly one section after a write, got {rendered}"
6441            );
6442            assert!(
6443                !rendered.lines().any(|l| l != l.trim_end()),
6444                "{label}: no line keeps trailing whitespace, so the heading is \
6445                 normalised: {rendered}"
6446            );
6447        }
6448    }
6449
6450    #[test]
6451    fn set_status_discloses_the_retired_key_rather_than_dropping_it_silently() {
6452        // Cycle-6 F4. Routing `set_status` through `update` is what makes it
6453        // disclose, and the behavioural difference is real — reverted to a direct
6454        // `atomic_write`, the key is still removed but nothing is disclosed, which
6455        // is silent data loss. Reverting also left the **entire suite green**,
6456        // because no test exercised it. That is the sixth time this package has
6457        // produced a correct fix with no regression protection, so this pins it.
6458        let (_tmp, store) = make_store();
6459        write_issue(
6460            &store,
6461            "2026-01-01-a.md",
6462            "---\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",
6463        );
6464        let before = store.get(IssueSelector::Number(1)).unwrap();
6465        assert!(
6466            before.has_retired_links_key,
6467            "precondition: the file carries the retired key"
6468        );
6469        let _ = crate::storage::take_disclosures();
6470
6471        store
6472            .set_status("2026-01-01-a.md", StatusChange::to("in progress"))
6473            .unwrap();
6474
6475        let disclosures = crate::storage::take_disclosures();
6476        assert_eq!(
6477            disclosures.len(),
6478            1,
6479            "set_status must disclose the removal, got {disclosures:?}"
6480        );
6481        assert!(
6482            disclosures[0].contains("links:") && disclosures[0].contains("a.md"),
6483            "naming the field and the file: {disclosures:?}"
6484        );
6485        assert!(
6486            !store
6487                .get(IssueSelector::Number(1))
6488                .unwrap()
6489                .has_retired_links_key,
6490            "and the key is gone, as C-001 intends"
6491        );
6492    }
6493
6494    #[test]
6495    fn f1_but_ordinary_prose_bullets_still_end_the_section() {
6496        // The other direction, and the reason the retention rule is not simply
6497        // "keep every list item". N1's original trigger: several body bullets in
6498        // a row. Neither is entry-shaped, so neither is plausibly a hand-edited
6499        // entry, and the section ends where it should.
6500        let (_tmp, store) = make_store();
6501        write_issue(
6502            &store,
6503            "2026-01-01-a.md",
6504            concat!(
6505                "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\n",
6506                "created: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n",
6507                "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n\n",
6508                "- step one\n- step two\n",
6509            ),
6510        );
6511        let issue = store.get(IssueSelector::Number(1)).unwrap();
6512        assert_eq!(
6513            issue.history.events().len(),
6514            1,
6515            "the real event is an event"
6516        );
6517        assert!(
6518            issue.history.unparsed().is_empty(),
6519            "ordinary bullets are not retained: {:?}",
6520            issue.history.unparsed()
6521        );
6522        assert!(
6523            issue.body.contains("step one") && issue.body.contains("step two"),
6524            "they stay body text: {:?}",
6525            issue.body
6526        );
6527    }
6528
6529    #[test]
6530    fn a_year_is_not_an_ordered_list_marker() {
6531        // Cycle-5 Minor 7. `is_list_item` accepted any run of digits before `.`,
6532        // so `2026. Q1 goals` counted as a list item and a paragraph of ordinary
6533        // body prose was retained in the append-only audit log.
6534        for prose in [
6535            "2026. Q1 goals",
6536            "2026. roadmap review",
6537            "007. padded",
6538            "99999999999999999999. big",
6539        ] {
6540            assert!(
6541                !crate::model::looks_like_an_unrecognised_entry(prose)
6542                    && Issue::parse(&format!(
6543                        "---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{prose}\n"
6544                    ))
6545                    .unwrap()
6546                    .history
6547                    .is_empty(),
6548                "{prose:?} must be body prose, not a retained entry"
6549            );
6550        }
6551    }
6552
6553    #[test]
6554    fn f2_a_recognised_entry_with_a_bad_kind_keeps_its_trailing_whitespace() {
6555        // Cycle-4 F2. The `Err` arm — a *recognised* entry whose kind is unknown —
6556        // used `trim_end()`, which also eats trailing spaces and tabs. That is
6557        // the auto-repair data-model §8 forbids, and it contradicts §8's
6558        // "preserved byte-for-byte, including its own indentation and trailing
6559        // whitespace". The sibling ambiguous arm was already correct, so the two
6560        // disagreed about what "verbatim" means.
6561        let line = "  - 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} d   ";
6562        let raw = format!(
6563            "---\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"
6564        );
6565        let issue = Issue::parse(&raw).unwrap();
6566        assert!(issue.history.events().is_empty(), "no event was fabricated");
6567        assert_eq!(
6568            issue.history.unparsed(),
6569            vec![line],
6570            "kept byte-for-byte, trailing spaces included"
6571        );
6572
6573        // And a real write must not repair it either.
6574        let rendered = issue.to_file_contents();
6575        assert!(
6576            rendered.contains(&format!("{line}\n")),
6577            "re-emitted exactly, so an unrelated `tix edit` does not rewrite the \
6578             author's audit log: {rendered}"
6579        );
6580        assert_eq!(
6581            Issue::parse(&rendered).unwrap().to_file_contents(),
6582            rendered,
6583            "and the file is byte-stable from here"
6584        );
6585    }
6586
6587    #[test]
6588    fn c002_history_is_append_only() {
6589        let (tmp, store) = make_store();
6590        write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
6591        let _ = tmp;
6592        let mut issue = store.get(IssueSelector::Number(1)).unwrap();
6593        issue
6594            .history
6595            .append_now("alice", HistoryEventKind::RelationshipAdded, "first");
6596        let after_first = issue.history.clone();
6597        issue
6598            .history
6599            .append_now("alice", HistoryEventKind::RelationshipRemoved, "second");
6600
6601        assert_eq!(issue.history.len(), 2, "appending never replaces");
6602        assert_eq!(
6603            &issue.history.events()[..1],
6604            after_first.events(),
6605            "the earlier entry is untouched and still first"
6606        );
6607    }
6608
6609    #[test]
6610    fn c002_an_unknown_history_kind_is_kept_verbatim_not_coerced() {
6611        // The old version of this test asserted `contains("bogus_kind")` against
6612        // an error message that *echoes the offending line*, so it passed for any
6613        // input mentioning the word. It also fed ASCII hyphens, so
6614        // `splitn(4, U+2014)` never split and the real failure was a missing
6615        // field — the kind was never checked at all. Neither defect is a
6616        // hypothetical: the assertion was satisfied by the echo.
6617        //
6618        // Fixed on both counts. The line uses real em dashes, so the parser
6619        // genuinely reaches the kind field, and the assertion is on the parsed
6620        // outcome rather than on a substring of an error string.
6621        let line = "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} something";
6622        let raw = format!(
6623            "---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\n---\n\n## History\n\n{line}\n"
6624        );
6625        let issue = Issue::parse(&raw).expect("an unknown kind must not fail the parse");
6626
6627        assert!(
6628            issue.history.events().is_empty(),
6629            "no event was fabricated for an unknown kind"
6630        );
6631        assert_eq!(
6632            issue.history.unparsed(),
6633            vec![line],
6634            "the line is kept verbatim: not dropped, not repaired"
6635        );
6636        // Idempotence, not equality with the hand-written input: that input
6637        // omits keys serde always emits, so only the rendered form is a
6638        // meaningful byte-stability assertion.
6639        let rendered = issue.to_file_contents();
6640        assert!(rendered.contains(line), "the line survives the write");
6641        assert_eq!(
6642            Issue::parse(&rendered).unwrap().to_file_contents(),
6643            rendered,
6644            "and the file round-trips byte-identically"
6645        );
6646    }
6647
6648    #[test]
6649    fn list_mixes_grouped_and_flat_issues_sorted_by_relative_filename() {
6650        let (tmp, store) = make_store();
6651        let _ = tmp;
6652        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6653        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
6654        write_issue(
6655            &store,
6656            "alice/2026-01-01-a.md",
6657            &sample_frontmatter(1, "A", "todo"),
6658        );
6659        write_issue(
6660            &store,
6661            "bob/2026-05-10-b.md",
6662            &sample_frontmatter(2, "B", "todo"),
6663        );
6664        write_issue(
6665            &store,
6666            "2026-02-01-c.md",
6667            &sample_frontmatter(3, "C", "todo"),
6668        );
6669        let issues = store.list().unwrap();
6670        let filenames: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
6671        assert_eq!(issues.len(), 3);
6672        assert_eq!(
6673            filenames,
6674            vec![
6675                "2026-02-01-c.md",
6676                "alice/2026-01-01-a.md",
6677                "bob/2026-05-10-b.md"
6678            ],
6679            "deterministic sort by relative filename"
6680        );
6681    }
6682
6683    #[test]
6684    fn list_sorts_chronologically_within_a_group() {
6685        let (tmp, store) = make_store();
6686        let _ = tmp;
6687        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6688        write_issue(
6689            &store,
6690            "alice/2026-03-01-c.md",
6691            &sample_frontmatter(3, "C", "todo"),
6692        );
6693        write_issue(
6694            &store,
6695            "alice/2026-01-01-a.md",
6696            &sample_frontmatter(1, "A", "todo"),
6697        );
6698        write_issue(
6699            &store,
6700            "alice/2026-02-01-b.md",
6701            &sample_frontmatter(2, "B", "todo"),
6702        );
6703        let issues = store.list().unwrap();
6704        let numbers: Vec<u32> = issues.iter().map(|i| i.number).collect();
6705        assert_eq!(numbers, vec![1, 2, 3]);
6706    }
6707
6708    #[test]
6709    fn list_ignores_non_md_files_in_segments() {
6710        let (tmp, store) = make_store();
6711        let _ = tmp;
6712        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6713        write_issue(
6714            &store,
6715            "alice/2026-01-01-a.md",
6716            &sample_frontmatter(1, "A", "todo"),
6717        );
6718        fs::write(store.issues_dir.join("alice/notes.txt"), "not an issue").unwrap();
6719        fs::write(store.issues_dir.join("alice/.tix-tmp-123-456"), "orphan").unwrap();
6720        let issues = store.list().unwrap();
6721        assert_eq!(issues.len(), 1);
6722        assert_eq!(issues[0].filename, "alice/2026-01-01-a.md");
6723    }
6724
6725    #[test]
6726    fn list_skips_temp_marker_directories() {
6727        let (tmp, store) = make_store();
6728        let _ = tmp;
6729        fs::create_dir_all(store.issues_dir.join(".tix-tmp-999-0")).unwrap();
6730        write_issue(
6731            &store,
6732            ".tix-tmp-999-0/2026-01-01-junk.md",
6733            &sample_frontmatter(9, "J", "todo"),
6734        );
6735        write_issue(
6736            &store,
6737            "2026-01-01-a.md",
6738            &sample_frontmatter(1, "A", "todo"),
6739        );
6740        let issues = store.list().unwrap();
6741        assert_eq!(issues.len(), 1);
6742        assert_eq!(issues[0].filename, "2026-01-01-a.md");
6743    }
6744
6745    #[test]
6746    fn list_parse_error_carries_grouped_relative_path() {
6747        let (tmp, store) = make_store();
6748        let _ = tmp;
6749        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6750        fs::write(
6751            store.issues_dir.join("alice/2026-01-01-bad.md"),
6752            "no frontmatter here\n",
6753        )
6754        .unwrap();
6755        let err = store.list().unwrap_err();
6756        assert!(
6757            matches!(&err, TixError::Parse(m) if m.starts_with("alice/2026-01-01-bad.md:")),
6758            "parse error must carry the relative path, got {err:?}"
6759        );
6760    }
6761
6762    // ------------------------------------------------------------------
6763    // T007 — grouped create writes into the assignee segment (FR-029/30)
6764    // ------------------------------------------------------------------
6765
6766    #[test]
6767    fn create_uses_the_default_layout_template() {
6768        // The documented default: `{assignee}/{type}/{status}` for the directory,
6769        // `{date}-{id}-{slug}.md` for the filename. With no `type:` attribute the
6770        // type component falls back to `default`. The id is pinned here so the
6771        // path is deterministic; a real create generates a random one.
6772        let tmp = tempfile::TempDir::new().unwrap();
6773        let dir = tmp.path().join("issues");
6774        fs::create_dir_all(&dir).unwrap();
6775        let store = Store {
6776            issues_dir: dir,
6777            config: Config::default(),
6778        };
6779        let mut issue = Issue {
6780            projects: None,
6781            id: Some("A1B2C3D4".into()),
6782            number: 0,
6783            title: "Round trip".into(),
6784            status: Status::new("todo"),
6785            labels: vec![],
6786            priority: Priority::default(),
6787            assignee: Some("@alice".into()),
6788            estimate: None,
6789            commit: None,
6790            gitlab_id: None,
6791            linear_id: None,
6792            mission: None,
6793            task: None,
6794            relationships: vec![],
6795            related_issues: vec![],
6796            parent: None,
6797            series: None,
6798            health: None,
6799            files: vec![],
6800            cycles: None,
6801            milestones: None,
6802            reviewer: None,
6803            start_date: None,
6804            due_date: None,
6805            entered_at: None,
6806            source: None,
6807            created: "2026-01-01".into(),
6808            type_: None,
6809            updated: "2026-01-01".into(),
6810            filename: String::new(),
6811            body: String::new(),
6812            history: crate::model::History::new(),
6813            has_retired_links_key: false,
6814            time_entries: vec![],
6815            epics: None,
6816            extra_frontmatter: std::collections::BTreeMap::new(),
6817            archived: false,
6818        };
6819        store.create(&mut issue, "", None).unwrap();
6820        assert_eq!(
6821            issue.filename,
6822            "alice/default/todo/2026-01-01-A1B2C3D4-round-trip.md"
6823        );
6824        assert!(
6825            store
6826                .issues_dir
6827                .join("alice/default/todo/2026-01-01-A1B2C3D4-round-trip.md")
6828                .is_file()
6829        );
6830    }
6831
6832    #[test]
6833    fn create_assigns_a_valid_id_and_leads_the_filename_with_it() {
6834        let (tmp, store) = make_store();
6835        let _ = tmp;
6836        // Two creates with the same title and day: before ids these shared a
6837        // stem and only the `-2` fallback kept them apart; the id now makes the
6838        // names distinct at the source.
6839        let mut a = crate::model::Issue::parse(
6840            "---\nnumber: 0\ntitle: Same title\nstatus: todo\nlabels: []\npriority: medium\n\
6841             created: 2026-01-01\nupdated: 2026-01-01\n---\n\n",
6842        )
6843        .unwrap();
6844        let mut b = a.clone();
6845        store.create(&mut a, "", None).unwrap();
6846        store.create(&mut b, "", None).unwrap();
6847
6848        let id_a = a.id.clone().expect("an id is assigned at create");
6849        let id_b = b.id.clone().expect("an id is assigned at create");
6850        assert!(crate::id::is_issue_id(&id_a), "shaped id: {id_a}");
6851        assert_ne!(id_a, id_b, "two creates must not share an id");
6852        assert!(
6853            a.filename.contains(&id_a),
6854            "filename must carry the id: {}",
6855            a.filename
6856        );
6857        assert!(
6858            a.to_file_contents().contains(&format!("id: {id_a}\n")),
6859            "the id must reach the frontmatter"
6860        );
6861    }
6862
6863    #[test]
6864    fn slug_from_stem_skips_the_id_and_date_prefixes() {
6865        // Legacy name: date prefix stripped.
6866        assert_eq!(slug_from_stem("2026-01-01-fix-login"), "fix-login");
6867        // New-schema name: date then id stripped.
6868        assert_eq!(slug_from_stem("2026-01-01-A1B2C3D4-fix-login"), "fix-login");
6869        // The earlier new-schema order (id then date) is still handled.
6870        assert_eq!(slug_from_stem("A1B2C3D4-2026-01-01-fix-login"), "fix-login");
6871        // No recognisable prefix: used verbatim.
6872        assert_eq!(slug_from_stem("no-prefix"), "no-prefix");
6873    }
6874
6875    #[test]
6876    fn update_always_reconciles_the_file_location() {
6877        // Unconditional auto-migration: a write moves the file to the directory
6878        // `[layout]` renders, even from a legacy/mismatched path.
6879        let tmp = tempfile::TempDir::new().unwrap();
6880        let dir = tmp.path().join("issues");
6881        fs::create_dir_all(&dir).unwrap();
6882        let store = Store {
6883            issues_dir: dir,
6884            config: Config::default(),
6885        };
6886        let mut issue = Issue {
6887            projects: None,
6888            id: Some("A1B2C3D4".into()),
6889            number: 0,
6890            type_: None,
6891            title: "Round trip".into(),
6892            status: Status::new("todo"),
6893            labels: vec![],
6894            priority: Priority::default(),
6895            assignee: Some("@alice".into()),
6896            estimate: None,
6897            commit: None,
6898            gitlab_id: None,
6899            linear_id: None,
6900            mission: None,
6901            task: None,
6902            relationships: vec![],
6903            related_issues: vec![],
6904            parent: None,
6905            series: None,
6906            health: None,
6907            files: vec![],
6908            cycles: None,
6909            milestones: None,
6910            reviewer: None,
6911            start_date: None,
6912            due_date: None,
6913            entered_at: None,
6914            source: None,
6915            created: "2026-01-01".into(),
6916            updated: "2026-01-01".into(),
6917            filename: String::new(),
6918            body: String::new(),
6919            history: crate::model::History::new(),
6920            has_retired_links_key: false,
6921            time_entries: vec![],
6922            epics: None,
6923            extra_frontmatter: std::collections::BTreeMap::new(),
6924            archived: false,
6925        };
6926        store.create(&mut issue, "", None).unwrap();
6927        let old = issue.filename.clone();
6928
6929        // A status change relocates the laid-out file into the new status dir.
6930        issue.status = Status::new("done");
6931        issue.filename = store.update(&old, &issue).unwrap();
6932        assert_eq!(
6933            issue.filename,
6934            "alice/default/done/2026-01-01-A1B2C3D4-round-trip.md"
6935        );
6936        assert!(!store.issues_dir.join(&old).exists());
6937
6938        // A mismatched (legacy flat) file is left for `tix layout migrate`.
6939        let flat = "2026-01-01-legacy.md";
6940        fs::write(
6941            store.issues_dir.join(flat),
6942            "---\nnumber: 9\ntitle: L\nstatus: todo\nlabels: []\npriority: medium\n\
6943             files: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n",
6944        )
6945        .unwrap();
6946        let mut legacy = store
6947            .get(IssueSelector::Filename(flat.to_string()))
6948            .unwrap();
6949        legacy.status = Status::new("done");
6950        let after = store.update(flat, &legacy).unwrap();
6951        assert_eq!(after, "unassigned/default/done/2026-01-01-legacy.md");
6952        assert!(!store.issues_dir.join(flat).exists(), "migrated, not left");
6953        assert!(store.issues_dir.join(&after).is_file());
6954    }
6955
6956    #[test]
6957    fn create_writes_into_assignee_segment_and_records_relative_filename() {
6958        let (tmp, store) = make_store();
6959        let _ = tmp;
6960        let mut issue = Issue {
6961            projects: None,
6962            id: None,
6963            number: 0,
6964            title: "T".into(),
6965            status: Status::default(),
6966            labels: vec![],
6967            priority: Priority::default(),
6968            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
6969            estimate: None,
6970            commit: None,
6971            gitlab_id: None,
6972            linear_id: None,
6973            mission: None,
6974            task: None,
6975            relationships: vec![],
6976            related_issues: vec![],
6977            files: vec![],
6978            cycles: None,
6979            milestones: None,
6980            reviewer: None,
6981            start_date: None,
6982            due_date: None,
6983            created: "2026-01-01".into(),
6984            type_: None,
6985            updated: "2026-01-01".into(),
6986            filename: String::new(),
6987            body: String::new(),
6988            history: crate::model::History::new(),
6989            has_retired_links_key: false,
6990            time_entries: vec![],
6991            parent: None,
6992            series: None,
6993            health: None,
6994            epics: None,
6995            extra_frontmatter: std::collections::BTreeMap::new(),
6996            archived: false,
6997            entered_at: None,
6998            source: None,
6999        };
7000        let path = store
7001            .create(&mut issue, "body", Some("custom-stem"))
7002            .unwrap();
7003        let rel = "herbert-maschke/custom-stem.md";
7004        assert_eq!(issue.filename, rel);
7005        assert_eq!(path, store.issues_dir.join(rel));
7006        assert!(store.issues_dir.join("herbert-maschke").is_dir());
7007        assert!(store.issues_dir.join(rel).is_file());
7008    }
7009
7010    #[test]
7011    fn create_without_assignee_writes_unassigned_segment() {
7012        let (tmp, store) = make_store();
7013        let _ = tmp;
7014        let mut issue = Issue {
7015            projects: None,
7016            id: None,
7017            number: 0,
7018            title: "T".into(),
7019            status: Status::default(),
7020            labels: vec![],
7021            priority: Priority::default(),
7022            assignee: None,
7023            estimate: None,
7024            commit: None,
7025            gitlab_id: None,
7026            linear_id: None,
7027            mission: None,
7028            task: None,
7029            relationships: vec![],
7030            related_issues: vec![],
7031            files: vec![],
7032            cycles: None,
7033            milestones: None,
7034            reviewer: None,
7035            start_date: None,
7036            due_date: None,
7037            created: "2026-01-01".into(),
7038            type_: None,
7039            updated: "2026-01-01".into(),
7040            filename: String::new(),
7041            body: String::new(),
7042            history: crate::model::History::new(),
7043            has_retired_links_key: false,
7044            time_entries: vec![],
7045            parent: None,
7046            series: None,
7047            health: None,
7048            epics: None,
7049            extra_frontmatter: std::collections::BTreeMap::new(),
7050            archived: false,
7051            entered_at: None,
7052            source: None,
7053        };
7054        let path = store.create(&mut issue, "", Some("stem")).unwrap();
7055        assert_eq!(issue.filename, "unassigned/stem.md");
7056        assert!(store.issues_dir.join("unassigned").is_dir());
7057        assert!(store.issues_dir.join("unassigned/stem.md").is_file());
7058        assert_eq!(path, store.issues_dir.join("unassigned/stem.md"));
7059    }
7060
7061    #[test]
7062    fn create_with_at_handle_derives_bare_segment() {
7063        let (tmp, store) = make_store();
7064        let _ = tmp;
7065        let mut issue = Issue {
7066            projects: None,
7067            id: None,
7068            number: 0,
7069            title: "T".into(),
7070            status: Status::default(),
7071            labels: vec![],
7072            priority: Priority::default(),
7073            assignee: Some("@alice".into()),
7074            estimate: None,
7075            commit: None,
7076            gitlab_id: None,
7077            linear_id: None,
7078            mission: None,
7079            task: None,
7080            relationships: vec![],
7081            related_issues: vec![],
7082            files: vec![],
7083            cycles: None,
7084            milestones: None,
7085            reviewer: None,
7086            start_date: None,
7087            due_date: None,
7088            created: "2026-01-01".into(),
7089            type_: None,
7090            updated: "2026-01-01".into(),
7091            filename: String::new(),
7092            body: String::new(),
7093            history: crate::model::History::new(),
7094            has_retired_links_key: false,
7095            time_entries: vec![],
7096            parent: None,
7097            series: None,
7098            health: None,
7099            epics: None,
7100            extra_frontmatter: std::collections::BTreeMap::new(),
7101            archived: false,
7102            entered_at: None,
7103            source: None,
7104        };
7105        store.create(&mut issue, "", Some("stem")).unwrap();
7106        assert_eq!(issue.filename, "alice/stem.md");
7107        assert!(store.issues_dir.join("alice/stem.md").is_file());
7108    }
7109
7110    // ------------------------------------------------------------------
7111    // T008 — reassign(): move-on-reassign (FR-031), never overwrites
7112    // ------------------------------------------------------------------
7113
7114    #[test]
7115    fn reassign_moves_file_between_segments() {
7116        let (tmp, store) = make_store();
7117        let _ = tmp;
7118        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7119        write_issue(
7120            &store,
7121            "alice/2026-01-01-x.md",
7122            &sample_frontmatter(1, "X", "todo"),
7123        );
7124        let new_path = store
7125            .reassign("alice/2026-01-01-x.md", Some("@bob"))
7126            .unwrap();
7127        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x.md"));
7128        assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
7129        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
7130    }
7131
7132    #[test]
7133    fn reassign_into_occupied_segment_picks_deterministic_suffix() {
7134        let (tmp, store) = make_store();
7135        let _ = tmp;
7136        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7137        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
7138        write_issue(
7139            &store,
7140            "alice/2026-01-01-x.md",
7141            &sample_frontmatter(1, "X", "todo"),
7142        );
7143        write_issue(
7144            &store,
7145            "bob/2026-01-01-x.md",
7146            &sample_frontmatter(2, "X", "todo"),
7147        );
7148        let new_path = store
7149            .reassign("alice/2026-01-01-x.md", Some("@bob"))
7150            .unwrap();
7151        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x-1.md"));
7152        assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
7153        assert!(store.issues_dir.join("bob/2026-01-01-x-1.md").is_file());
7154        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
7155    }
7156
7157    #[test]
7158    fn reassign_legacy_flat_file_into_segment() {
7159        let (tmp, store) = make_store();
7160        let _ = tmp;
7161        write_issue(
7162            &store,
7163            "2026-01-01-y.md",
7164            &sample_frontmatter(1, "Y", "todo"),
7165        );
7166        let new_path = store.reassign("2026-01-01-y.md", Some("alice")).unwrap();
7167        assert_eq!(new_path, PathBuf::from("alice/2026-01-01-y.md"));
7168        assert!(store.issues_dir.join("alice/2026-01-01-y.md").is_file());
7169        assert!(!store.issues_dir.join("2026-01-01-y.md").exists());
7170    }
7171
7172    #[test]
7173    fn reassign_to_none_uses_unassigned_fallback() {
7174        let (tmp, store) = make_store();
7175        let _ = tmp;
7176        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7177        write_issue(
7178            &store,
7179            "alice/2026-01-01-z.md",
7180            &sample_frontmatter(1, "Z", "todo"),
7181        );
7182        let new_path = store.reassign("alice/2026-01-01-z.md", None).unwrap();
7183        assert_eq!(new_path, PathBuf::from("unassigned/2026-01-01-z.md"));
7184        assert!(
7185            store
7186                .issues_dir
7187                .join("unassigned/2026-01-01-z.md")
7188                .is_file()
7189        );
7190        assert!(!store.issues_dir.join("alice/2026-01-01-z.md").exists());
7191    }
7192
7193    #[test]
7194    fn reassign_keeps_number_and_created_stable() {
7195        let (tmp, store) = make_store();
7196        let _ = tmp;
7197        let mut issue = Issue {
7198            projects: None,
7199            id: None,
7200            number: 0,
7201            title: "Stable".into(),
7202            status: Status::default(),
7203            labels: vec![],
7204            priority: Priority::default(),
7205            assignee: Some("alice".into()),
7206            estimate: None,
7207            commit: None,
7208            gitlab_id: None,
7209            linear_id: None,
7210            mission: None,
7211            task: None,
7212            relationships: vec![],
7213            related_issues: vec![],
7214            files: vec![],
7215            cycles: None,
7216            milestones: None,
7217            reviewer: None,
7218            start_date: None,
7219            due_date: None,
7220            created: "2026-01-01".into(),
7221            type_: None,
7222            updated: "2026-01-01".into(),
7223            filename: String::new(),
7224            body: String::new(),
7225            history: crate::model::History::new(),
7226            has_retired_links_key: false,
7227            time_entries: vec![],
7228            parent: None,
7229            series: None,
7230            health: None,
7231            epics: None,
7232            extra_frontmatter: std::collections::BTreeMap::new(),
7233            archived: false,
7234            entered_at: None,
7235            source: None,
7236        };
7237        store.create(&mut issue, "body", None).unwrap();
7238        let number = issue.number;
7239        let created = issue.created.clone();
7240        let before = issue.filename.clone();
7241        let new_path = store.reassign(&before, Some("@bob")).unwrap();
7242        let rescan = store.list().unwrap();
7243        let moved = rescan
7244            .iter()
7245            .find(|i| i.filename == new_path)
7246            .expect("moved file must be listed at its new relative path");
7247        assert_eq!(moved.number, number);
7248        assert_eq!(moved.created, created);
7249        assert_eq!(moved.title, "Stable");
7250    }
7251
7252    #[test]
7253    fn reassign_equal_segment_is_noop() {
7254        let (tmp, store) = make_store();
7255        let _ = tmp;
7256        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7257        write_issue(
7258            &store,
7259            "alice/2026-01-01-x.md",
7260            &sample_frontmatter(1, "X", "todo"),
7261        );
7262        let new_path = store
7263            .reassign("alice/2026-01-01-x.md", Some("alice"))
7264            .unwrap();
7265        assert_eq!(new_path, PathBuf::from("alice/2026-01-01-x.md"));
7266        assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
7267    }
7268
7269    #[test]
7270    fn reassign_missing_file_is_not_found() {
7271        let (tmp, store) = make_store();
7272        let _ = tmp;
7273        let err = store.reassign("nope.md", Some("alice")).unwrap_err();
7274        assert!(matches!(err, TixError::NotFound(_)));
7275    }
7276
7277    // ------------------------------------------------------------------
7278    // T009 — validate_filename relaxation + relative-path ops (C-008/C-010)
7279    // ------------------------------------------------------------------
7280
7281    #[test]
7282    fn validate_filename_allows_flat_and_single_segment_prefix() {
7283        validate_filename("2026-01-01-x.md").unwrap();
7284        validate_filename("alice/2026-01-01-x.md").unwrap();
7285    }
7286
7287    #[test]
7288    fn validate_filename_allows_deeper_segments() {
7289        // Configurable layout: depth is unbounded, so a nested path is valid.
7290        validate_filename("alice/bug/in-progress/2026-01-01-x.md").unwrap();
7291    }
7292
7293    #[test]
7294    fn validate_filename_refuses_leading_and_trailing_slash_and_absolute() {
7295        let err = validate_filename("/alice/2026-01-01-x.md").unwrap_err();
7296        assert!(matches!(err, TixError::InvalidInput(_)));
7297        let err = validate_filename("/etc/passwd").unwrap_err();
7298        assert!(matches!(err, TixError::InvalidInput(_)));
7299        let err = validate_filename("alice/").unwrap_err();
7300        assert!(matches!(err, TixError::InvalidInput(_)));
7301    }
7302
7303    #[test]
7304    fn path_traversal_with_segment_dotdot_refused() {
7305        let err = validate_filename("alice/../../x.md").unwrap_err();
7306        assert!(matches!(err, TixError::InvalidInput(_)));
7307        let err = validate_filename("alice/../x.md").unwrap_err();
7308        assert!(matches!(err, TixError::InvalidInput(_)));
7309    }
7310
7311    #[test]
7312    fn get_update_delete_set_status_work_on_grouped_relative_paths() {
7313        let (tmp, store) = make_store();
7314        let _ = tmp;
7315        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7316        write_issue(
7317            &store,
7318            "alice/2026-01-01-g.md",
7319            &sample_frontmatter(7, "G", "todo"),
7320        );
7321        let got = store
7322            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
7323            .unwrap();
7324        assert_eq!(got.number, 7);
7325        let done = store
7326            .set_status("alice/2026-01-01-g.md", StatusChange::to("done"))
7327            .unwrap();
7328        assert_eq!(done.issue.status.display(), "done");
7329        assert!(store.issues_dir.join("alice/2026-01-01-g.md").is_file());
7330        let mut edited = store
7331            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
7332            .unwrap();
7333        edited.title = "Updated".into();
7334        store.update("alice/2026-01-01-g.md", &edited).unwrap();
7335        let reloaded = store
7336            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
7337            .unwrap();
7338        assert_eq!(reloaded.title, "Updated");
7339        store.delete("alice/2026-01-01-g.md").unwrap();
7340        assert!(!store.issues_dir.join("alice/2026-01-01-g.md").exists());
7341    }
7342
7343    // ------------------------------------------------------------------
7344    // T010 — grouped round-trip sweep
7345    // ------------------------------------------------------------------
7346
7347    #[test]
7348    fn grouped_round_trip_create_list_get_update_delete() {
7349        let (tmp, store) = make_store();
7350        let _ = tmp;
7351        let mut issue = Issue {
7352            projects: None,
7353            id: Some("A1B2C3D4".into()),
7354            number: 0,
7355            title: "Round trip".into(),
7356            status: Status::default(),
7357            labels: vec!["bug".into()],
7358            priority: Priority::default(),
7359            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
7360            estimate: None,
7361            commit: None,
7362            gitlab_id: None,
7363            linear_id: None,
7364            mission: None,
7365            task: None,
7366            relationships: vec![],
7367            related_issues: vec![],
7368            files: vec![],
7369            cycles: None,
7370            milestones: None,
7371            reviewer: None,
7372            start_date: None,
7373            due_date: None,
7374            created: "2026-01-01".into(),
7375            type_: None,
7376            updated: "2026-01-01".into(),
7377            filename: String::new(),
7378            body: String::new(),
7379            history: crate::model::History::new(),
7380            has_retired_links_key: false,
7381            time_entries: vec![],
7382            parent: None,
7383            series: None,
7384            health: None,
7385            epics: None,
7386            extra_frontmatter: std::collections::BTreeMap::new(),
7387            archived: false,
7388            entered_at: None,
7389            source: None,
7390        };
7391        store.create(&mut issue, "initial body", None).unwrap();
7392        let rel = issue.filename.clone();
7393        assert_eq!(rel, "herbert-maschke/2026-01-01-A1B2C3D4-round-trip.md");
7394
7395        let listed = store.list().unwrap();
7396        assert_eq!(listed.len(), 1);
7397        assert_eq!(listed[0].filename, rel);
7398        assert_eq!(listed[0].number, issue.number);
7399
7400        let got = store.get(IssueSelector::Filename(rel.clone())).unwrap();
7401        let mut edited = got.clone();
7402        edited.title = "Renamed".into();
7403        store.update(&rel, &edited).unwrap();
7404        let reloaded = store.get(IssueSelector::Filename(rel.clone())).unwrap();
7405        assert_eq!(reloaded.title, "Renamed");
7406
7407        store.delete(&rel).unwrap();
7408        assert!(!store.issues_dir.join(&rel).exists());
7409        assert_eq!(store.list().unwrap(), [] as [crate::model::Issue; 0]);
7410    }
7411
7412    #[test]
7413    fn legacy_flat_file_edit_and_status_still_work_after_layout_flip() {
7414        let (tmp, store) = make_store();
7415        let _ = tmp;
7416        write_issue(
7417            &store,
7418            "2026-01-01-legacy.md",
7419            &sample_frontmatter(5, "Legacy", "todo"),
7420        );
7421        let listed = store.list().unwrap();
7422        assert_eq!(listed.len(), 1);
7423        assert_eq!(listed[0].filename, "2026-01-01-legacy.md");
7424        let mutated = store
7425            .set_status("2026-01-01-legacy.md", StatusChange::to("in progress"))
7426            .unwrap();
7427        assert_eq!(mutated.issue.status.display(), "in progress");
7428        assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
7429    }
7430
7431    // ------------------------------------------------------------------
7432    // T004 — add_asset(): copy + link insert (FR-006, NFR-004)
7433    // ------------------------------------------------------------------
7434
7435    fn write_asset_source(tmp: &tempfile::TempDir, name: &str, contents: &[u8]) -> PathBuf {
7436        let path = tmp.path().join(name);
7437        fs::write(&path, contents).unwrap();
7438        path
7439    }
7440
7441    fn write_issue_with_basic_body(store: &Store, name: &str, number: u32) {
7442        write_issue(
7443            store,
7444            name,
7445            &sample_frontmatter(number, "Asset host", "todo"),
7446        );
7447    }
7448
7449    #[test]
7450    fn add_asset_image_inserts_image_link_and_writes_file() {
7451        let (tmp, store) = make_store();
7452        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7453        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7454        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7455        let added = store
7456            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7457            .unwrap();
7458        assert_eq!(added.written_name, "diagram.png");
7459        assert_eq!(added.link, "![diagram](fix-login-bug-assets/diagram.png)");
7460        assert!(
7461            store
7462                .issues_dir
7463                .join("alice/fix-login-bug-assets/diagram.png")
7464                .is_file()
7465        );
7466    }
7467
7468    #[test]
7469    fn add_asset_non_image_inserts_generic_markdown_link() {
7470        let (tmp, store) = make_store();
7471        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7472        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7473        let src = write_asset_source(&tmp, "report.pdf", b"%PDF");
7474        let added = store
7475            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7476            .unwrap();
7477        assert_eq!(added.link, "[report](fix-login-bug-assets/report.pdf)");
7478        assert!(
7479            store
7480                .issues_dir
7481                .join("alice/fix-login-bug-assets/report.pdf")
7482                .is_file()
7483        );
7484    }
7485
7486    #[test]
7487    fn add_asset_collision_resolves_with_numeric_suffix_and_link_points_at_it() {
7488        let (tmp, store) = make_store();
7489        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7490        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7491        let src = write_asset_source(&tmp, "x.png", b"first");
7492        let first = store
7493            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7494            .unwrap();
7495        assert_eq!(first.written_name, "x.png");
7496        let again = store
7497            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7498            .unwrap();
7499        assert_eq!(again.written_name, "x-1.png");
7500        assert_eq!(again.link, "![x-1](fix-login-bug-assets/x-1.png)");
7501        assert!(
7502            store
7503                .issues_dir
7504                .join("alice/fix-login-bug-assets/x.png")
7505                .is_file()
7506        );
7507        assert!(
7508            store
7509                .issues_dir
7510                .join("alice/fix-login-bug-assets/x-1.png")
7511                .is_file()
7512        );
7513    }
7514
7515    #[test]
7516    fn add_asset_appends_single_link_and_preserves_description() {
7517        let (tmp, store) = make_store();
7518        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7519        write_issue(
7520            &store,
7521            "alice/2026-01-01-fix-login-bug.md",
7522            "---\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",
7523        );
7524        let src = write_asset_source(&tmp, "diagram.svg", b"<svg/>");
7525        store
7526            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7527            .unwrap();
7528        let reloaded = store
7529            .get(crate::storage::IssueSelector::Filename(
7530                "alice/2026-01-01-fix-login-bug.md".into(),
7531            ))
7532            .unwrap();
7533        // Description (above the `## Comments` boundary) untouched.
7534        assert_eq!(reloaded.description(), "Title line.");
7535        // Exactly one link line, appended after the comments section.
7536        let link_count = reloaded.body.matches("![diagram]").count();
7537        assert_eq!(link_count, 1);
7538        assert!(
7539            reloaded
7540                .body
7541                .ends_with("fix-login-bug-assets/diagram.svg)\n")
7542        );
7543    }
7544
7545    #[test]
7546    fn add_asset_missing_issue_is_not_found() {
7547        let (tmp, store) = make_store();
7548        let _ = tmp;
7549        let src = write_asset_source(&tmp, "x.png", b"x");
7550        let err = store.add_asset("missing.md", &src).unwrap_err();
7551        assert!(matches!(err, TixError::NotFound(_)));
7552    }
7553
7554    #[test]
7555    fn add_asset_legacy_flat_file_uses_stem_assets_under_issues_dir() {
7556        let (tmp, store) = make_store();
7557        write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
7558        let src = write_asset_source(&tmp, "x.png", b"x");
7559        let added = store.add_asset("2026-01-01-legacy.md", &src).unwrap();
7560        assert_eq!(added.link, "![x](legacy-assets/x.png)");
7561        assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
7562    }
7563
7564    // ------------------------------------------------------------------
7565    // T005 — reassign() moves the assets dir (FR-007)
7566    // ------------------------------------------------------------------
7567
7568    #[test]
7569    fn reassign_moves_asset_dir_with_the_file() {
7570        let (tmp, store) = make_store();
7571        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7572        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7573        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7574        store
7575            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7576            .unwrap();
7577        let new_path = store
7578            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
7579            .unwrap();
7580        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
7581        assert!(
7582            store
7583                .issues_dir
7584                .join("bob/fix-login-bug-assets/diagram.png")
7585                .is_file()
7586        );
7587        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
7588    }
7589
7590    #[test]
7591    fn reassign_without_asset_dir_behaves_as_before() {
7592        let (tmp, store) = make_store();
7593        let _ = tmp;
7594        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7595        write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
7596        let new_path = store
7597            .reassign("alice/2026-01-01-plain.md", Some("@bob"))
7598            .unwrap();
7599        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-plain.md"));
7600        assert!(store.issues_dir.join("bob/2026-01-01-plain.md").is_file());
7601        assert!(!store.issues_dir.join("bob/plain-assets").exists());
7602    }
7603
7604    #[test]
7605    fn reassign_into_segment_with_same_named_asset_dir_uses_suffix_no_loss() {
7606        let (tmp, store) = make_store();
7607        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7608        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
7609        // Both segments already hold a same-named asset dir.
7610        let existing = store.issues_dir.join("bob/fix-login-bug-assets");
7611        fs::create_dir_all(&existing).unwrap();
7612        fs::write(existing.join("keep.txt"), "precious").unwrap();
7613        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7614        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7615        store
7616            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7617            .unwrap();
7618        let new_path = store
7619            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
7620            .unwrap();
7621        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
7622        // Pre-existing target assets untouched, moved dir suffixed.
7623        assert!(existing.join("keep.txt").is_file());
7624        assert!(
7625            store
7626                .issues_dir
7627                .join("bob/fix-login-bug-assets-1/diagram.png")
7628                .is_file()
7629        );
7630        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
7631    }
7632
7633    #[test]
7634    fn reassign_preserves_markdown_link_resolution_in_new_segment() {
7635        let (tmp, store) = make_store();
7636        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7637        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7638        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7639        store
7640            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7641            .unwrap();
7642        store
7643            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
7644            .unwrap();
7645        // The asset dir kept its base name, so the relative link
7646        // `fix-login-bug-assets/diagram.png` resolves in the new segment.
7647        assert!(
7648            store
7649                .issues_dir
7650                .join("bob/fix-login-bug-assets/diagram.png")
7651                .is_file()
7652        );
7653    }
7654
7655    // ------------------------------------------------------------------
7656    // T006 — delete() removes the assets dir (FR-008) + sweep
7657    // ------------------------------------------------------------------
7658
7659    #[test]
7660    fn delete_removes_asset_dir_no_orphans() {
7661        let (tmp, store) = make_store();
7662        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7663        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7664        let src = write_asset_source(&tmp, "x.png", b"x");
7665        store
7666            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7667            .unwrap();
7668        store.delete("alice/2026-01-01-fix-login-bug.md").unwrap();
7669        assert!(
7670            !store
7671                .issues_dir
7672                .join("alice/2026-01-01-fix-login-bug.md")
7673                .exists()
7674        );
7675        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
7676    }
7677
7678    #[test]
7679    fn delete_without_asset_dir_still_deletes_file() {
7680        let (tmp, store) = make_store();
7681        let _ = tmp;
7682        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7683        write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
7684        store.delete("alice/2026-01-01-plain.md").unwrap();
7685        assert!(!store.issues_dir.join("alice/2026-01-01-plain.md").exists());
7686    }
7687
7688    #[test]
7689    fn list_ignores_asset_directories() {
7690        let (tmp, store) = make_store();
7691        write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
7692        let src = write_asset_source(&tmp, "x.png", b"x");
7693        store.add_asset("2026-01-01-legacy.md", &src).unwrap();
7694        // Legacy flat file → `legacy-assets/` sits directly under issues_dir,
7695        // where the `-assets` guard in `list()` must skip it.
7696        let issues = store.list().unwrap();
7697        assert_eq!(issues.len(), 1);
7698        assert_eq!(issues[0].filename, "2026-01-01-legacy.md");
7699    }
7700
7701    #[test]
7702    fn list_ignores_nested_asset_directories_under_segment() {
7703        let (tmp, store) = make_store();
7704        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7705        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7706        let src = write_asset_source(&tmp, "x.png", b"x");
7707        store
7708            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7709            .unwrap();
7710        let issues = store.list().unwrap();
7711        assert_eq!(issues.len(), 1);
7712        assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
7713    }
7714
7715    #[test]
7716    fn asset_dir_with_formless_md_child_is_not_scanned_as_segment() {
7717        let (tmp, store) = make_store();
7718        let _ = tmp;
7719        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7720        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7721        // A `.md` straggler inside an `-assets` dir must never surface as an
7722        // issue segment (guard in `list()` for `<slug>-assets/`).
7723        let assets_dir = store.issues_dir.join("alice/fix-login-bug-assets");
7724        fs::create_dir_all(&assets_dir).unwrap();
7725        fs::write(assets_dir.join("notes.md"), "no frontmatter").unwrap();
7726        let issues = store.list().unwrap();
7727        assert_eq!(issues.len(), 1);
7728        assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
7729    }
7730
7731    // -----------------------------------------------------------------------
7732    // WP02 / T008 — hierarchy writes
7733    // -----------------------------------------------------------------------
7734    //
7735    // Requirement ids in the test names: C-001 (at most one parent), C-003 (no
7736    // child data on the parent), C-004 (every change audited), SC-004 (a rejected
7737    // change writes nothing).
7738    //
7739    // These tests assert the *write* behaviour only. Every rule lives in
7740    // `crate::hierarchy`; the last test below is the guard against one of them
7741    // being copied in here.
7742
7743    /// Three seeded issues, for the hierarchy write tests.
7744    fn hierarchy_trio() -> (TempDir, Store) {
7745        let (tmp, store) = make_store();
7746        for (n, title) in [(1, "A"), (2, "B"), (3, "C")] {
7747            write_issue(
7748                &store,
7749                &format!("2026-01-01-{}.md", title.to_lowercase()),
7750                &bare_frontmatter(n, title),
7751            );
7752        }
7753        (tmp, store)
7754    }
7755
7756    /// A store over one checked-in hierarchy fixture, by file name.
7757    ///
7758    /// `tests/fixtures/hierarchy/` rather than a string built inline, because the
7759    /// interesting cases are exactly the states the command layer refuses to
7760    /// create: a self-parent, a dangling reference. Those can only be reached by
7761    /// writing the file, which is the point of the fixtures.
7762    fn store_from_hierarchy_fixture(name: &str) -> (TempDir, Store) {
7763        let (tmp, store) = make_store();
7764        write_issue(
7765            &store,
7766            name,
7767            &std::fs::read_to_string(format!(
7768                "{}/tests/fixtures/hierarchy/{name}",
7769                env!("CARGO_MANIFEST_DIR")
7770            ))
7771            .unwrap(),
7772        );
7773        (tmp, store)
7774    }
7775
7776    fn bytes_of(store: &Store, number: u32) -> String {
7777        let issue = store.get(IssueSelector::Number(number)).unwrap();
7778        read_issue_file(store, &issue.filename)
7779    }
7780
7781    #[test]
7782    fn t008_set_writes_the_field_and_exactly_one_history_event() {
7783        let (_tmp, store) = hierarchy_trio();
7784        let outcome = store.set_parent("2", "1").unwrap();
7785        assert!(outcome.changed);
7786        assert_eq!(outcome.parent, Some(1));
7787
7788        let child = store.get(IssueSelector::Number(2)).unwrap();
7789        assert_eq!(child.parent, Some(1), "C-001: the number is stored");
7790        assert_eq!(child.history.len(), 1, "C-004: exactly one event");
7791        let event = &child.history.events()[0];
7792        assert_eq!(event.kind, HistoryEventKind::ParentSet);
7793        assert!(
7794            !event.actor.is_empty(),
7795            "an event without an actor is not an audit record"
7796        );
7797        // The detail names both sides, read from the *previous* value.
7798        assert_eq!(event.detail, "parent none -> #1", "got: {}", event.detail);
7799    }
7800
7801    #[test]
7802    fn t008_change_repoints_and_records_parent_changed() {
7803        let (_tmp, store) = hierarchy_trio();
7804        store.set_parent("3", "1").unwrap();
7805        let outcome = store.change_parent("3", "2").unwrap();
7806        assert!(outcome.changed);
7807        assert_eq!(outcome.parent, Some(2));
7808
7809        let child = store.get(IssueSelector::Number(3)).unwrap();
7810        assert_eq!(child.parent, Some(2));
7811        assert_eq!(child.history.len(), 2, "one event per accepted change");
7812        let events = child.history.events();
7813        let last = events.last().unwrap();
7814        assert_eq!(last.kind, HistoryEventKind::ParentChanged);
7815        // Regression: the detail used to be built after the assignment, so a
7816        // repoint recorded `parent #1 -> #1` and named neither side.
7817        assert_eq!(last.detail, "parent #1 -> #2", "got: {}", last.detail);
7818    }
7819
7820    #[test]
7821    fn t008_clear_removes_the_field_and_records_parent_cleared() {
7822        let (_tmp, store) = hierarchy_trio();
7823        store.set_parent("2", "1").unwrap();
7824        let outcome = store.clear_parent("2").unwrap();
7825        assert!(outcome.changed);
7826        assert_eq!(outcome.parent, None);
7827
7828        let child = store.get(IssueSelector::Number(2)).unwrap();
7829        assert_eq!(child.parent, None);
7830        assert_eq!(child.history.len(), 2);
7831        let events = child.history.events();
7832        let last = events.last().unwrap();
7833        assert_eq!(last.kind, HistoryEventKind::ParentCleared);
7834        assert_eq!(last.detail, "parent #1 -> none", "got: {}", last.detail);
7835    }
7836
7837    #[test]
7838    fn t008_reapplying_the_same_parent_is_a_no_op_with_no_second_event() {
7839        let (_tmp, store) = hierarchy_trio();
7840        store.set_parent("2", "1").unwrap();
7841        let after_first = bytes_of(&store, 2);
7842
7843        let second = store.set_parent("2", "1").unwrap();
7844        assert!(!second.changed, "an unchanged parent is not a change");
7845        assert!(second.event.is_none(), "a no-op records nothing (C-004)");
7846        assert_eq!(
7847            bytes_of(&store, 2),
7848            after_first,
7849            "a no-op must not rewrite the file at all"
7850        );
7851    }
7852
7853    #[test]
7854    fn t008_clearing_a_child_with_no_parent_is_a_no_op() {
7855        let (_tmp, store) = hierarchy_trio();
7856        let before = bytes_of(&store, 2);
7857        let outcome = store.clear_parent("2").unwrap();
7858        assert!(!outcome.changed);
7859        assert!(outcome.event.is_none());
7860        assert_eq!(bytes_of(&store, 2), before);
7861    }
7862
7863    #[test]
7864    fn t008_sc004_a_rejected_cycle_leaves_every_file_byte_identical() {
7865        // 1 -> 2 -> 3, then ask 1 to become a child of 3: that closes the loop.
7866        let (_tmp, store) = hierarchy_trio();
7867        store.set_parent("2", "1").unwrap();
7868        store.set_parent("3", "2").unwrap();
7869        let before: Vec<String> = (1..=3).map(|n| bytes_of(&store, n)).collect();
7870
7871        let err = store
7872            .set_parent("1", "3")
7873            .expect_err("1 -> 2 -> 3 -> 1 is a cycle and must be refused");
7874        let msg = err.to_string();
7875        assert!(
7876            msg.contains("cycle"),
7877            "the diagnostic must name the rule: {msg}"
7878        );
7879
7880        // SC-004: nothing written, nothing appended, on *any* file.
7881        for n in 1..=3 {
7882            assert_eq!(
7883                bytes_of(&store, n),
7884                before[(n - 1) as usize],
7885                "issue {n} must be byte-identical after a rejected cycle"
7886            );
7887        }
7888    }
7889
7890    #[test]
7891    fn t008_a_self_parent_is_rejected_and_writes_nothing() {
7892        let (_tmp, store) = hierarchy_trio();
7893        let before = bytes_of(&store, 2);
7894        let err = store
7895            .set_parent("2", "2")
7896            .expect_err("an issue cannot be its own parent");
7897        assert!(err.to_string().contains("self_parent"), "got: {err}");
7898        assert_eq!(bytes_of(&store, 2), before);
7899    }
7900
7901    #[test]
7902    fn t008_a_self_parent_already_on_disk_is_still_refused() {
7903        // The laundering case, through the file path rather than the service.
7904        //
7905        // A child that *already* records itself as its own parent looks like a
7906        // redundant request, and a no-op check placed before validation accepted it
7907        // as one — while the identical request against any other child was refused.
7908        // Only a fixture in that state can catch it: the test above seeds a
7909        // parentless child, so the guard never fired.
7910        let (_tmp, store) = store_from_hierarchy_fixture("self-parent.md");
7911        let before = bytes_of(&store, 7);
7912        let err = store
7913            .set_parent("7", "7")
7914            .expect_err("a stored self-parent must still be refused on a re-set");
7915        assert!(
7916            err.to_string().contains("self_parent"),
7917            "refused for the wrong reason: {err}"
7918        );
7919        assert_eq!(
7920            bytes_of(&store, 7),
7921            before,
7922            "and the refused write must leave the file byte-identical"
7923        );
7924        // `change_parent` shares the path and must refuse identically.
7925        assert!(store.change_parent("7", "7").is_err());
7926        // The fixture really is in the state under test.
7927        assert_eq!(store.get(IssueSelector::Number(7)).unwrap().parent, Some(7));
7928    }
7929
7930    #[test]
7931    fn t008_c003_setting_a_child_writes_nothing_to_the_parents_file() {
7932        // Child membership is derived by inverting the child's pointer; the
7933        // parent file must not learn about it at all.
7934        let (_tmp, store) = hierarchy_trio();
7935        let parent_before = bytes_of(&store, 1);
7936        store.set_parent("2", "1").unwrap();
7937        store.set_parent("3", "1").unwrap();
7938        assert_eq!(
7939            bytes_of(&store, 1),
7940            parent_before,
7941            "the parent's file must be untouched by its children appearing"
7942        );
7943        assert!(
7944            !parent_before.contains("parent:"),
7945            "the parent is a top of the hierarchy and has no parent itself"
7946        );
7947    }
7948
7949    #[test]
7950    fn t008_a_legacy_file_without_the_field_is_unchanged_by_a_no_op_write() {
7951        // The file-format contract, seen through the write path.
7952        let (_tmp, store) = hierarchy_trio();
7953        let before = bytes_of(&store, 2);
7954        assert!(!before.contains("parent:"), "the fixture must be legacy");
7955
7956        store.clear_parent("2").unwrap();
7957        assert_eq!(bytes_of(&store, 2), before, "a no-op must not add the key");
7958    }
7959
7960    #[test]
7961    fn t008_an_unresolvable_selector_is_refused_before_any_write() {
7962        let (_tmp, store) = hierarchy_trio();
7963        let before = bytes_of(&store, 2);
7964        assert!(store.set_parent("2", "no such issue").is_err());
7965        assert!(store.change_parent("2", "999").is_err());
7966        assert_eq!(bytes_of(&store, 2), before);
7967    }
7968
7969    #[test]
7970    fn t008_t008_contains_no_hierarchy_rule_of_its_own() {
7971        // T008's own note: "Storage must contain no hierarchy *rules*. If a check
7972        // appears here that also exists in the service, that is a defect."
7973        //
7974        // Asserted behaviourally rather than by reading the source: a storage
7975        // rejection must be **exactly** what the service says for the same
7976        // snapshot. If someone re-implemented a rule here, the two would drift and
7977        // this comparison would stop holding.
7978        //
7979        // Compared against the service *type's own* rendering, reached through the
7980        // `From<HierarchyError> for TixError` conversion the call site uses. An
7981        // earlier version of this test compared storage against a private
7982        // `hierarchy_error` helper — both sides went through the same function, so
7983        // it certified only that the helper was called, and it could not detect the
7984        // duplication it was meant to police (a second renderer that had drifted
7985        // from the first).
7986        let (_tmp, store) = hierarchy_trio();
7987        store.set_parent("2", "1").unwrap();
7988        store.set_parent("3", "2").unwrap();
7989
7990        let issues = store.list().unwrap();
7991        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
7992        let index = HierarchyIndex::build(&views);
7993        let service =
7994            hierarchy::set_parent(&index, 1, 3).expect_err("the service refuses this too");
7995        let storage = store.set_parent("1", "3").expect_err("and so does storage");
7996
7997        assert_eq!(
7998            storage.to_string(),
7999            TixError::from(service).to_string(),
8000            "storage must surface the service's diagnosis verbatim, not its own"
8001        );
8002        // And the rendering is the shared one, not a lookalike: the kind is
8003        // prefixed exactly once and no diagnostic is dropped.
8004        let text = storage.to_string();
8005        assert!(
8006            text.contains("cycle:") && !text.contains("cycle: cycle:"),
8007            "a lookalike renderer or a doubled prefix: {text}"
8008        );
8009        assert!(
8010            text.starts_with("error[E-tix-hierarchy]: "),
8011            "a hierarchy refusal carries the shared rustc-style header: {text}"
8012        );
8013    }
8014
8015    #[test]
8016    fn t008_a_no_op_reports_no_written_issue_but_still_reports_the_current_parent() {
8017        // Pins the `ParentOutcome` contract, which its own doc comment used to
8018        // contradict: `written` is `None` for a no-op (matching
8019        // `RelationshipOutcome`), and the real state comes from `parent`.
8020        let (_tmp, store) = hierarchy_trio();
8021        store.set_parent("2", "1").unwrap();
8022        let again = store.set_parent("2", "1").unwrap();
8023        assert!(!again.changed);
8024        assert!(
8025            again.written.is_none(),
8026            "a no-op wrote nothing, so there is no written issue to hand back"
8027        );
8028        assert!(
8029            again.event.is_none(),
8030            "and no event, or a retry would pad the audit trail"
8031        );
8032        assert_eq!(
8033            again.parent,
8034            Some(1),
8035            "but the resulting state is still reported, so a caller need not re-read"
8036        );
8037    }
8038
8039    // -----------------------------------------------------------------------
8040    // tix #1541 — the command-level write transaction
8041    // -----------------------------------------------------------------------
8042
8043    /// A failed transaction restores the bytes of **every** file it wrote, not
8044    /// only the last. The intermediate write is the one a naive "undo the last
8045    /// step" rollback would leave behind.
8046    #[test]
8047    fn a_failed_transaction_restores_every_file_it_touched() {
8048        let (_tmp, store) = make_store();
8049        write_issue(
8050            &store,
8051            "2026-01-01-a.md",
8052            &sample_frontmatter(1, "A", "todo"),
8053        );
8054        write_issue(
8055            &store,
8056            "2026-01-01-b.md",
8057            &sample_frontmatter(2, "B", "todo"),
8058        );
8059        let before_a = read_issue_file(&store, "2026-01-01-a.md");
8060        let before_b = read_issue_file(&store, "2026-01-01-b.md");
8061
8062        let mut a = store.get(IssueSelector::Number(1)).unwrap();
8063        a.title = "Changed".into();
8064        let mut b = store.get(IssueSelector::Number(2)).unwrap();
8065        b.title = "Also changed".into();
8066
8067        let err = store
8068            .transaction(|| {
8069                store.update("2026-01-01-a.md", &a)?;
8070                store.update("2026-01-01-b.md", &b)?;
8071                Err::<(), _>(TixError::InvalidInput("boom".into()))
8072            })
8073            .unwrap_err();
8074
8075        assert!(err.to_string().contains("boom"), "got: {err}");
8076        assert_eq!(
8077            read_issue_file(&store, "2026-01-01-a.md"),
8078            before_a,
8079            "the first write must be undone too, not only the last"
8080        );
8081        assert_eq!(read_issue_file(&store, "2026-01-01-b.md"), before_b);
8082        assert_eq!(store.get(IssueSelector::Number(1)).unwrap().title, "A");
8083        assert_eq!(store.get(IssueSelector::Number(2)).unwrap().title, "B");
8084    }
8085
8086    /// A transaction that returns `Ok` keeps its writes — the journal is taken
8087    /// and discarded, never applied.
8088    #[test]
8089    fn a_committed_transaction_keeps_its_writes() {
8090        let (_tmp, store) = make_store();
8091        write_issue(
8092            &store,
8093            "2026-01-01-a.md",
8094            &sample_frontmatter(1, "A", "todo"),
8095        );
8096        let mut a = store.get(IssueSelector::Number(1)).unwrap();
8097        a.title = "Changed".into();
8098
8099        store
8100            .transaction(|| {
8101                store.update("2026-01-01-a.md", &a)?;
8102                Ok(())
8103            })
8104            .unwrap();
8105
8106        assert_eq!(
8107            store.get(IssueSelector::Number(1)).unwrap().title,
8108            "Changed"
8109        );
8110    }
8111
8112    /// The window in #1302 was between two steps that touch *different paths*: a
8113    /// reassign moves the file, then the content is written at the new path. A
8114    /// rollback must undo both — move the file back **and** restore its bytes —
8115    /// which only works if the journal records the pre-image of the original path
8116    /// before the move and replays in reverse.
8117    #[test]
8118    fn a_failed_transaction_undoes_a_reassign_move_and_its_content_write() {
8119        let (_tmp, store) = make_store();
8120        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
8121        write_issue(
8122            &store,
8123            "alice/2026-01-01-x.md",
8124            &sample_frontmatter(5, "X", "todo"),
8125        );
8126        let before = read_issue_file(&store, "alice/2026-01-01-x.md");
8127
8128        let mut edited = store.get(IssueSelector::Number(5)).unwrap();
8129        edited.title = "Edited".into();
8130
8131        let err = store
8132            .transaction(|| {
8133                let moved = store.reassign("alice/2026-01-01-x.md", Some("@bob"))?;
8134                store.update(&moved.to_string_lossy(), &edited)?;
8135                Err::<(), _>(TixError::InvalidInput("boom".into()))
8136            })
8137            .unwrap_err();
8138
8139        assert!(err.to_string().contains("boom"));
8140        assert!(
8141            store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
8142            "the moved file must be back under its original segment"
8143        );
8144        assert!(
8145            !store.issues_dir.join("bob/2026-01-01-x.md").exists(),
8146            "the moved copy must not survive the rollback"
8147        );
8148        assert_eq!(read_issue_file(&store, "alice/2026-01-01-x.md"), before);
8149    }
8150
8151    /// A `delete` inside a failing transaction is undone, so the file is not lost.
8152    #[test]
8153    fn a_failed_transaction_restores_a_deleted_file() {
8154        let (_tmp, store) = make_store();
8155        write_issue(
8156            &store,
8157            "2026-01-01-a.md",
8158            &sample_frontmatter(1, "A", "todo"),
8159        );
8160        let before = read_issue_file(&store, "2026-01-01-a.md");
8161
8162        let _ = store
8163            .transaction(|| {
8164                store.delete("2026-01-01-a.md")?;
8165                Err::<(), _>(TixError::InvalidInput("boom".into()))
8166            })
8167            .unwrap_err();
8168
8169        assert!(store.issues_dir.join("2026-01-01-a.md").is_file());
8170        assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
8171    }
8172
8173    /// A nested transaction joins the outer one: an inner failure rolls back the
8174    /// work the outer transaction did *before* the inner call, because the outer
8175    /// call owns the single journal. Without this a command that wraps another
8176    /// transactional command would silently lose atomicity.
8177    #[test]
8178    fn a_nested_transaction_joins_the_outer_one() {
8179        let (_tmp, store) = make_store();
8180        write_issue(
8181            &store,
8182            "2026-01-01-a.md",
8183            &sample_frontmatter(1, "A", "todo"),
8184        );
8185        let before = read_issue_file(&store, "2026-01-01-a.md");
8186        let mut a = store.get(IssueSelector::Number(1)).unwrap();
8187        a.title = "Outer".into();
8188        let mut a2 = a.clone();
8189        a2.title = "Inner".into();
8190
8191        let err = store
8192            .transaction(|| {
8193                store.update("2026-01-01-a.md", &a)?;
8194                // The inner call must NOT commit or clear the outer journal.
8195                store.transaction(|| {
8196                    store.update("2026-01-01-a.md", &a2)?;
8197                    Err::<(), _>(TixError::InvalidInput("inner boom".into()))
8198                })?;
8199                Ok(())
8200            })
8201            .unwrap_err();
8202
8203        assert!(err.to_string().contains("inner boom"));
8204        assert_eq!(
8205            read_issue_file(&store, "2026-01-01-a.md"),
8206            before,
8207            "the outer write must also be undone, so the whole unit is atomic"
8208        );
8209    }
8210
8211    /// Outside a transaction nothing is journaled: a write that is not wrapped
8212    /// takes effect and stays.
8213    #[test]
8214    fn a_write_outside_a_transaction_is_not_journaled() {
8215        let (_tmp, store) = make_store();
8216        write_issue(
8217            &store,
8218            "2026-01-01-a.md",
8219            &sample_frontmatter(1, "A", "todo"),
8220        );
8221        let mut a = store.get(IssueSelector::Number(1)).unwrap();
8222        a.title = "Plain".into();
8223        store.update("2026-01-01-a.md", &a).unwrap();
8224        assert_eq!(store.get(IssueSelector::Number(1)).unwrap().title, "Plain");
8225    }
8226
8227    // ------------------------------------------------------------------
8228    // WP03 — project association writes (mission first-class-projects-01M3BYBW)
8229    // ------------------------------------------------------------------
8230
8231    /// A store whose **persisted** registry declares project `p-1`, holding issue #1.
8232    ///
8233    /// `p-1` is written to `tix.toml`, unlike the in-memory fixtures elsewhere, so
8234    /// the negative assertion below compares a real registry file before and after.
8235    fn project_store() -> (TempDir, Store) {
8236        let tmp = TempDir::new().unwrap();
8237        let dir = tmp.path().join("issues");
8238        fs::create_dir_all(&dir).unwrap();
8239        let config_path = tmp.path().join("tix.toml");
8240        let mut config = Config {
8241            issues_dir: dir.clone(),
8242            ..Config::default()
8243        };
8244        config.layout.directory = "{assignee}".to_string();
8245        config.layout.move_on_change = false;
8246        config.projects.insert(
8247            "p-1".to_string(),
8248            ProjectConfiguration {
8249                name: "One".into(),
8250                ..ProjectConfiguration::default()
8251            },
8252        );
8253        config.save(&config_path).unwrap();
8254        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
8255        let store = Store {
8256            issues_dir: dir,
8257            config,
8258        };
8259        write_issue(
8260            &store,
8261            "2026-01-01-a.md",
8262            &sample_frontmatter(1, "A", "todo"),
8263        );
8264        (tmp, store)
8265    }
8266
8267    /// The issue as written to disk, not as it was held in memory.
8268    fn reloaded(store: &Store, filename: &str) -> Issue {
8269        store
8270            .get(IssueSelector::Filename(filename.to_string()))
8271            .unwrap()
8272    }
8273
8274    /// A fixture from `tests/fixtures/projects/`, read from the crate root so the
8275    /// path is independent of the test's working directory.
8276    fn project_fixture(name: &str) -> String {
8277        let path = format!(
8278            "{}/tests/fixtures/projects/{name}",
8279            env!("CARGO_MANIFEST_DIR")
8280        );
8281        fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading fixture {name}: {e}"))
8282    }
8283
8284    /// Every fixture, sorted, so a new one is picked up without editing a list.
8285    fn all_project_fixtures() -> Vec<String> {
8286        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/projects");
8287        let mut names: Vec<String> = fs::read_dir(&dir)
8288            .unwrap()
8289            .filter_map(|entry| entry.ok())
8290            .map(|entry| entry.file_name().to_string_lossy().into_owned())
8291            .filter(|name| name.ends_with(".md"))
8292            .collect();
8293        names.sort();
8294        names
8295    }
8296
8297    #[test]
8298    fn t013_an_accepted_association_writes_the_list_and_exactly_one_history_entry() {
8299        let (_tmp, store) = project_store();
8300        let before = read_issue_file(&store, "2026-01-01-a.md");
8301        let outcome = store.associate("1", "p-1").unwrap();
8302
8303        assert!(outcome.changed, "an accepted, changing association writes");
8304        assert!(outcome.written.is_some());
8305        assert_eq!(outcome.project_ids, vec!["p-1".to_string()]);
8306        let event = outcome
8307            .event
8308            .clone()
8309            .expect("an accepted change is audited");
8310        assert_eq!(event.detail, "project `p-1` associated");
8311
8312        // Re-read from disk: the in-memory record is not evidence of what landed.
8313        let after = reloaded(&store, "2026-01-01-a.md");
8314        assert_eq!(after.projects, Some(vec!["p-1".to_string()]));
8315        let events = after.history.events();
8316        assert_eq!(events.len(), 1, "exactly one History entry: {events:?}");
8317        assert_eq!(events[0].kind, HistoryEventKind::ProjectAssociated);
8318        assert_eq!(events[0].detail, "project `p-1` associated");
8319        assert_ne!(
8320            read_issue_file(&store, "2026-01-01-a.md"),
8321            before,
8322            "an accepted change is written"
8323        );
8324    }
8325
8326    #[test]
8327    fn t013_a_refused_association_writes_nothing_and_appends_nothing() {
8328        let (_tmp, store) = project_store();
8329        let before = read_issue_file(&store, "2026-01-01-a.md");
8330
8331        let err = store.associate("1", "p-unknown").unwrap_err();
8332        assert!(
8333            matches!(err, TixError::InvalidInput(_)),
8334            "an unknown project is a user input refusal, got {err:?}"
8335        );
8336        assert_eq!(
8337            read_issue_file(&store, "2026-01-01-a.md"),
8338            before,
8339            "a refusal must leave the file byte-identical"
8340        );
8341        assert!(
8342            reloaded(&store, "2026-01-01-a.md").history.is_empty(),
8343            "a refusal must append nothing"
8344        );
8345
8346        // An unknown issue selector is refused too, and also writes nothing.
8347        let err = store.associate("999", "p-1").unwrap_err();
8348        assert!(matches!(err, TixError::NotFound(_)), "got {err:?}");
8349        assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
8350    }
8351
8352    #[test]
8353    fn t013_a_refusal_reports_the_service_wording_not_a_storage_copy() {
8354        // Cross-checker (8): storage holds no project rule, so a refusal must be the
8355        // service's own diagnostic. A second wording here would be a second rule.
8356        let (_tmp, store) = project_store();
8357        let err = store.associate("1", "p-unknown").unwrap_err();
8358        assert!(
8359            err.to_string()
8360                .contains(&crate::projects::unknown_project_reason("p-unknown")),
8361            "the refusal must come from the service, got: {err}"
8362        );
8363    }
8364
8365    #[test]
8366    fn t013_re_associating_is_idempotent_no_second_entry_and_no_second_event() {
8367        let (_tmp, store) = project_store();
8368        store.associate("1", "p-1").unwrap();
8369        let after_first = read_issue_file(&store, "2026-01-01-a.md");
8370
8371        let second = store.associate("1", "p-1").unwrap();
8372        assert!(!second.changed, "the idempotent re-apply changes nothing");
8373        assert!(second.written.is_none(), "and writes no file");
8374        assert!(second.event.is_none(), "and appends no event");
8375        assert_eq!(second.project_ids, vec!["p-1".to_string()]);
8376        assert_eq!(
8377            read_issue_file(&store, "2026-01-01-a.md"),
8378            after_first,
8379            "the file is byte-identical after a repeated association"
8380        );
8381
8382        let after = reloaded(&store, "2026-01-01-a.md");
8383        assert_eq!(after.history.events().len(), 1, "no second History event");
8384        assert_eq!(
8385            after.projects,
8386            Some(vec!["p-1".to_string()]),
8387            "and no duplicate entry: (issue, project_id) is unique"
8388        );
8389    }
8390
8391    #[test]
8392    fn t013_dissociate_drops_only_the_association_and_records_one_event() {
8393        let (_tmp, store) = project_store();
8394        store.associate("1", "p-1").unwrap();
8395
8396        let outcome = store.dissociate("1", "p-1").unwrap();
8397        assert!(outcome.changed);
8398        assert_eq!(outcome.project_ids, [] as [std::string::String; 0]);
8399        let event = outcome
8400            .event
8401            .clone()
8402            .expect("an accepted change is audited");
8403        assert_eq!(event.detail, "project `p-1` dissociated");
8404
8405        let after = reloaded(&store, "2026-01-01-a.md");
8406        assert_eq!(after.projects, None, "the association is dropped");
8407        assert!(
8408            !read_issue_file(&store, "2026-01-01-a.md").contains("projects:"),
8409            "an empty list is written as an absent key"
8410        );
8411        let events = after.history.events();
8412        assert_eq!(events.len(), 2, "associate + dissociate = two entries");
8413        assert_eq!(events[1].kind, HistoryEventKind::ProjectDissociated);
8414
8415        // Dissociating an absent association is an idempotent no-op.
8416        let again = store.dissociate("1", "p-1").unwrap();
8417        assert!(!again.changed);
8418        assert!(again.event.is_none());
8419        assert_eq!(
8420            reloaded(&store, "2026-01-01-a.md").history.events().len(),
8421            2,
8422            "no third History event"
8423        );
8424    }
8425
8426    #[test]
8427    fn t013_bulk_associate_writes_each_affected_file_independently() {
8428        let (_tmp, store) = project_store();
8429        write_issue(
8430            &store,
8431            "2026-01-01-b.md",
8432            &sample_frontmatter(2, "B", "todo"),
8433        );
8434
8435        let outcomes = store
8436            .bulk_associate(
8437                "p-1",
8438                &["1".to_string(), "999".to_string(), "2".to_string()],
8439            )
8440            .unwrap();
8441
8442        assert_eq!(outcomes.len(), 3, "one entry per selector, in input order");
8443        assert_eq!(outcomes[0].selector, "1");
8444        assert!(outcomes[0].outcome.as_ref().unwrap().changed);
8445        assert!(
8446            outcomes[1].outcome.is_err(),
8447            "a bad selector is its own error, not a failure of the run"
8448        );
8449        assert!(outcomes[2].outcome.as_ref().unwrap().changed);
8450
8451        for filename in ["2026-01-01-a.md", "2026-01-01-b.md"] {
8452            let issue = reloaded(&store, filename);
8453            assert_eq!(issue.projects, Some(vec!["p-1".to_string()]));
8454            assert_eq!(issue.history.events().len(), 1, "{filename}");
8455        }
8456
8457        // An unknown project refuses the entry without writing the file.
8458        write_issue(
8459            &store,
8460            "2026-01-01-c.md",
8461            &sample_frontmatter(3, "C", "todo"),
8462        );
8463        let before = read_issue_file(&store, "2026-01-01-c.md");
8464        let refused = store
8465            .bulk_associate("p-unknown", &["3".to_string()])
8466            .unwrap();
8467        assert!(refused[0].outcome.is_err());
8468        assert_eq!(read_issue_file(&store, "2026-01-01-c.md"), before);
8469    }
8470
8471    #[test]
8472    fn t013_bulk_associate_is_idempotent_across_two_selectors_for_one_issue() {
8473        // Regression: the same issue named by number and by filename. Applying both
8474        // against one immutable read made the second miss the first write and append
8475        // a second `project_associated` event; the working copy means the second
8476        // selector sees the association and reports the service's no-op.
8477        let (_tmp, store) = project_store();
8478        let outcomes = store
8479            .bulk_associate("p-1", &["1".to_string(), "2026-01-01-a.md".to_string()])
8480            .unwrap();
8481        assert_eq!(outcomes.len(), 2, "one entry per selector, in input order");
8482
8483        let first = outcomes[0].outcome.as_ref().unwrap();
8484        assert!(first.changed, "the first selector writes");
8485        assert!(first.written.is_some());
8486        assert!(first.event.is_some(), "and appends its one event");
8487
8488        let second = outcomes[1].outcome.as_ref().unwrap();
8489        assert!(
8490            !second.changed,
8491            "the repeated selector is the idempotent no-op"
8492        );
8493        assert!(second.written.is_none(), "and writes nothing a second time");
8494        assert!(second.event.is_none(), "and appends no second event");
8495        assert_eq!(
8496            second.project_ids,
8497            vec!["p-1".to_string()],
8498            "and still reports the current list"
8499        );
8500
8501        let after = reloaded(&store, "2026-01-01-a.md");
8502        assert_eq!(after.projects, Some(vec!["p-1".to_string()]));
8503        assert_eq!(
8504            after.history.events().len(),
8505            1,
8506            "exactly one History entry across both selectors"
8507        );
8508    }
8509
8510    #[test]
8511    fn t014_nfr_002_every_project_fixture_round_trips_with_no_key_added_removed_or_reordered() {
8512        let names = all_project_fixtures();
8513        for required in [
8514            "no-associations.md",
8515            "one-association.md",
8516            "several-associations.md",
8517            "missing-reference.md",
8518            "legacy-no-key.md",
8519        ] {
8520            assert!(
8521                names.iter().any(|name| name == required),
8522                "the fixture set must cover every shape T014 names; {required} is missing from {names:?}"
8523            );
8524        }
8525        for name in names {
8526            let original = project_fixture(&name);
8527            let issue = Issue::parse(&original).expect("fixture parses");
8528            assert_eq!(
8529                issue.to_file_contents(),
8530                original,
8531                "{name} must round-trip byte-identically"
8532            );
8533            assert_eq!(
8534                Issue::parse(&issue.to_file_contents())
8535                    .unwrap()
8536                    .to_file_contents(),
8537                original,
8538                "{name} must be byte-stable once re-read"
8539            );
8540        }
8541    }
8542
8543    #[test]
8544    fn t014_nfr_002_a_legacy_fixture_gains_no_projects_key() {
8545        let original = project_fixture("legacy-no-key.md");
8546        assert!(!original.contains("projects:"));
8547        let issue = Issue::parse(&original).unwrap();
8548        assert_eq!(issue.projects, None);
8549        assert_eq!(
8550            issue.to_file_contents(),
8551            original,
8552            "a pre-project file must not gain a key"
8553        );
8554    }
8555
8556    #[test]
8557    fn t014_c_003_a_multi_membership_fixture_is_byte_stable_and_ordered() {
8558        let original = project_fixture("several-associations.md");
8559        let issue = Issue::parse(&original).unwrap();
8560        assert_eq!(
8561            issue.projects,
8562            Some(vec![
8563                "p-platform".to_string(),
8564                "p-docs".to_string(),
8565                "p-infra".to_string()
8566            ]),
8567            "declaration order is preserved (NFR-002)"
8568        );
8569        assert_eq!(issue.to_file_contents(), original);
8570    }
8571
8572    #[test]
8573    fn t014_c_005_a_missing_reference_round_trips_through_a_store_unchanged() {
8574        // The registry declares no projects, so `p-deleted` cannot resolve. A loader
8575        // that "helpfully" dropped an unknown id would fail here and nowhere else.
8576        let (_tmp, store) = make_store();
8577        write_issue(
8578            &store,
8579            "missing-reference.md",
8580            &project_fixture("missing-reference.md"),
8581        );
8582        assert!(
8583            store.config.projects.is_empty(),
8584            "the reference is missing by construction"
8585        );
8586
8587        let issue = store.get(IssueSelector::Number(4)).unwrap();
8588        assert_eq!(issue.projects, Some(vec!["p-deleted".to_string()]));
8589
8590        // Save the issue unchanged, then re-read: the id is still there.
8591        store.update(&issue.filename, &issue).unwrap();
8592        assert_eq!(
8593            read_issue_file(&store, "missing-reference.md"),
8594            project_fixture("missing-reference.md"),
8595            "saving a missing reference must not drop or rewrite it"
8596        );
8597        let reloaded_issue = store.get(IssueSelector::Number(4)).unwrap();
8598        assert_eq!(reloaded_issue.projects, Some(vec!["p-deleted".to_string()]));
8599    }
8600
8601    #[test]
8602    fn t014_c_003_associating_an_issue_writes_no_member_list_to_the_registry() {
8603        let (tmp, store) = project_store();
8604        let config_path = tmp.path().join("tix.toml");
8605        let before = fs::read_to_string(&config_path).unwrap();
8606        assert!(
8607            before.contains("[projects.p-1]"),
8608            "the record is persisted before the association: {before}"
8609        );
8610
8611        store.associate("1", "p-1").unwrap();
8612
8613        let after = fs::read_to_string(&config_path).unwrap();
8614        assert_eq!(
8615            after, before,
8616            "the project's registry record must not grow a member list"
8617        );
8618        assert!(
8619            !after.contains("members"),
8620            "membership is derived, never stored on the project:\n{after}"
8621        );
8622    }
8623
8624    // ------------------------------------------------------------------
8625    // WP03 — epic reference writes (mission epics-work-items-01M3BYW4)
8626    // ------------------------------------------------------------------
8627
8628    /// A store whose **persisted** registry declares epic `e-7c4a91`, holding
8629    /// issue #1.
8630    ///
8631    /// The epic is written to `tix.toml`, unlike the in-memory fixtures elsewhere,
8632    /// so the negative assertion below compares a real registry file before and
8633    /// after.
8634    fn epic_store() -> (TempDir, Store) {
8635        let tmp = TempDir::new().unwrap();
8636        let dir = tmp.path().join("issues");
8637        fs::create_dir_all(&dir).unwrap();
8638        let config_path = tmp.path().join("tix.toml");
8639        let mut config = Config {
8640            issues_dir: dir.clone(),
8641            ..Config::default()
8642        };
8643        config.layout.directory = "{assignee}".to_string();
8644        config.layout.move_on_change = false;
8645        config.epics.insert(
8646            "e-7c4a91".to_string(),
8647            EpicConfiguration {
8648                name: "One".into(),
8649                ..EpicConfiguration::default()
8650            },
8651        );
8652        config.save(&config_path).unwrap();
8653        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
8654        let store = Store {
8655            issues_dir: dir,
8656            config,
8657        };
8658        write_issue(
8659            &store,
8660            "2026-01-01-a.md",
8661            &sample_frontmatter(1, "A", "todo"),
8662        );
8663        (tmp, store)
8664    }
8665
8666    /// A fixture from `tests/fixtures/epics/`, read from the crate root so the path
8667    /// is independent of the test's working directory.
8668    fn epic_fixture(name: &str) -> String {
8669        let path = format!("{}/tests/fixtures/epics/{name}", env!("CARGO_MANIFEST_DIR"));
8670        fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading fixture {name}: {e}"))
8671    }
8672
8673    /// Every fixture, sorted, so a new one is picked up without editing a list.
8674    fn all_epic_fixtures() -> Vec<String> {
8675        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/epics");
8676        let mut names: Vec<String> = fs::read_dir(&dir)
8677            .unwrap()
8678            .filter_map(|entry| entry.ok())
8679            .map(|entry| entry.file_name().to_string_lossy().into_owned())
8680            .filter(|name| name.ends_with(".md"))
8681            .collect();
8682        names.sort();
8683        names
8684    }
8685
8686    #[test]
8687    fn t015_an_accepted_reference_writes_the_list_and_exactly_one_history_entry() {
8688        let (_tmp, store) = epic_store();
8689        let before = read_issue_file(&store, "2026-01-01-a.md");
8690        let outcome = store.reference_epic("1", "e-7c4a91").unwrap();
8691
8692        assert!(outcome.changed, "an accepted, changing reference writes");
8693        assert!(outcome.written.is_some());
8694        assert_eq!(outcome.epic_ids, vec!["e-7c4a91".to_string()]);
8695        let event = outcome
8696            .event
8697            .clone()
8698            .expect("an accepted change is audited");
8699        assert_eq!(event.detail, "epic `e-7c4a91` referenced");
8700
8701        // Re-read from disk: the in-memory record is not evidence of what landed.
8702        let after = reloaded(&store, "2026-01-01-a.md");
8703        assert_eq!(after.epics, Some(vec!["e-7c4a91".to_string()]));
8704        let events = after.history.events();
8705        assert_eq!(events.len(), 1, "exactly one History entry: {events:?}");
8706        assert_eq!(events[0].kind, HistoryEventKind::EpicReferenced);
8707        assert_eq!(events[0].detail, "epic `e-7c4a91` referenced");
8708        assert_ne!(
8709            read_issue_file(&store, "2026-01-01-a.md"),
8710            before,
8711            "an accepted change is written"
8712        );
8713    }
8714
8715    #[test]
8716    fn t015_a_refused_reference_writes_nothing_and_appends_nothing() {
8717        let (_tmp, store) = epic_store();
8718        let before = read_issue_file(&store, "2026-01-01-a.md");
8719
8720        let err = store.reference_epic("1", "e-unknown").unwrap_err();
8721        assert!(
8722            matches!(err, TixError::InvalidInput(_)),
8723            "an unknown epic is a user input refusal, got {err:?}"
8724        );
8725        assert_eq!(
8726            read_issue_file(&store, "2026-01-01-a.md"),
8727            before,
8728            "a refusal must leave the file byte-identical"
8729        );
8730        assert!(
8731            reloaded(&store, "2026-01-01-a.md").history.is_empty(),
8732            "a refusal must append nothing"
8733        );
8734
8735        // An unknown issue selector is refused too, and also writes nothing.
8736        let err = store.reference_epic("999", "e-7c4a91").unwrap_err();
8737        assert!(matches!(err, TixError::NotFound(_)), "got {err:?}");
8738        assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
8739    }
8740
8741    #[test]
8742    fn t015_a_refusal_reports_the_service_wording_not_a_storage_copy() {
8743        // Storage holds no epic rule, so a refusal must be the service's own
8744        // diagnostic. A second wording here would be a second rule.
8745        let (_tmp, store) = epic_store();
8746        let err = store.reference_epic("1", "e-unknown").unwrap_err();
8747        assert!(
8748            err.to_string()
8749                .contains(&crate::epics::unknown_epic_reason("e-unknown")),
8750            "the refusal must come from the service, got: {err}"
8751        );
8752    }
8753
8754    #[test]
8755    fn t015_re_referencing_is_idempotent_no_second_entry_and_no_second_event() {
8756        let (_tmp, store) = epic_store();
8757        store.reference_epic("1", "e-7c4a91").unwrap();
8758        let after_first = read_issue_file(&store, "2026-01-01-a.md");
8759
8760        let second = store.reference_epic("1", "e-7c4a91").unwrap();
8761        assert!(!second.changed, "the idempotent re-apply changes nothing");
8762        assert!(second.written.is_none(), "and writes no file");
8763        assert!(second.event.is_none(), "and appends no event");
8764        assert_eq!(second.epic_ids, vec!["e-7c4a91".to_string()]);
8765        assert_eq!(
8766            read_issue_file(&store, "2026-01-01-a.md"),
8767            after_first,
8768            "the file is byte-identical after a repeated reference"
8769        );
8770
8771        let after = reloaded(&store, "2026-01-01-a.md");
8772        assert_eq!(after.history.events().len(), 1, "no second History event");
8773        assert_eq!(
8774            after.epics,
8775            Some(vec!["e-7c4a91".to_string()]),
8776            "and no duplicate entry: (issue, epic_id) is unique"
8777        );
8778    }
8779
8780    #[test]
8781    fn t015_dereference_drops_only_the_reference_and_records_one_event() {
8782        let (_tmp, store) = epic_store();
8783        store.reference_epic("1", "e-7c4a91").unwrap();
8784
8785        let outcome = store.dereference_epic("1", "e-7c4a91").unwrap();
8786        assert!(outcome.changed);
8787        assert_eq!(outcome.epic_ids, [] as [std::string::String; 0]);
8788        let event = outcome
8789            .event
8790            .clone()
8791            .expect("an accepted change is audited");
8792        assert_eq!(event.detail, "epic `e-7c4a91` dereferenced");
8793
8794        let after = reloaded(&store, "2026-01-01-a.md");
8795        assert_eq!(after.epics, None, "the reference is dropped");
8796        assert!(
8797            !read_issue_file(&store, "2026-01-01-a.md").contains("epics:"),
8798            "an empty list is written as an absent key"
8799        );
8800        let events = after.history.events();
8801        assert_eq!(events.len(), 2, "reference + dereference = two entries");
8802        assert_eq!(events[1].kind, HistoryEventKind::EpicDereferenced);
8803
8804        // Dereferencing an absent reference is an idempotent no-op.
8805        let again = store.dereference_epic("1", "e-7c4a91").unwrap();
8806        assert!(!again.changed);
8807        assert!(again.event.is_none());
8808        assert_eq!(
8809            reloaded(&store, "2026-01-01-a.md").history.events().len(),
8810            2,
8811            "no third History event"
8812        );
8813    }
8814
8815    #[test]
8816    fn t015_dereference_retains_a_missing_reference_and_removes_it_verbatim() {
8817        // The repair path: a stored id that is no longer a valid shape or no longer
8818        // in the registry is removed by verbatim comparison, not by resolution.
8819        let (_tmp, store) = epic_store();
8820        write_issue(
8821            &store,
8822            "2026-01-01-b.md",
8823            "---\nnumber: 2\ntitle: B\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\nepics:\n- e-deleted\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.",
8824        );
8825        let outcome = store.dereference_epic("2", "e-deleted").unwrap();
8826        assert!(outcome.changed, "a stale reference is removable");
8827        assert_eq!(outcome.epic_ids, [] as [std::string::String; 0]);
8828        assert_eq!(reloaded(&store, "2026-01-01-b.md").epics, None);
8829    }
8830
8831    #[test]
8832    fn t015_bulk_reference_writes_each_affected_file_independently() {
8833        let (_tmp, store) = epic_store();
8834        write_issue(
8835            &store,
8836            "2026-01-01-b.md",
8837            &sample_frontmatter(2, "B", "todo"),
8838        );
8839
8840        let outcomes = store
8841            .bulk_reference(
8842                "e-7c4a91",
8843                &["1".to_string(), "999".to_string(), "2".to_string()],
8844            )
8845            .unwrap();
8846
8847        assert_eq!(outcomes.len(), 3, "one entry per selector, in input order");
8848        assert_eq!(outcomes[0].selector, "1");
8849        assert!(outcomes[0].outcome.as_ref().unwrap().changed);
8850        assert!(
8851            outcomes[1].outcome.is_err(),
8852            "a bad selector is its own error, not a failure of the run"
8853        );
8854        assert!(outcomes[2].outcome.as_ref().unwrap().changed);
8855
8856        for filename in ["2026-01-01-a.md", "2026-01-01-b.md"] {
8857            let issue = reloaded(&store, filename);
8858            assert_eq!(issue.epics, Some(vec!["e-7c4a91".to_string()]));
8859            assert_eq!(issue.history.events().len(), 1, "{filename}");
8860        }
8861
8862        // An unknown epic refuses the entry without writing the file.
8863        write_issue(
8864            &store,
8865            "2026-01-01-c.md",
8866            &sample_frontmatter(3, "C", "todo"),
8867        );
8868        let before = read_issue_file(&store, "2026-01-01-c.md");
8869        let refused = store
8870            .bulk_reference("e-unknown", &["3".to_string()])
8871            .unwrap();
8872        assert!(refused[0].outcome.is_err());
8873        assert_eq!(read_issue_file(&store, "2026-01-01-c.md"), before);
8874    }
8875
8876    #[test]
8877    fn t015_bulk_reference_is_idempotent_across_two_selectors_for_one_issue() {
8878        // Regression guard, mirrored from the project path: the same issue named by
8879        // number and by filename must not append a second `epic_referenced` event.
8880        let (_tmp, store) = epic_store();
8881        let outcomes = store
8882            .bulk_reference(
8883                "e-7c4a91",
8884                &["1".to_string(), "2026-01-01-a.md".to_string()],
8885            )
8886            .unwrap();
8887        assert_eq!(outcomes.len(), 2, "one entry per selector, in input order");
8888
8889        let first = outcomes[0].outcome.as_ref().unwrap();
8890        assert!(first.changed, "the first selector writes");
8891        assert!(first.written.is_some());
8892        assert!(first.event.is_some(), "and appends its one event");
8893
8894        let second = outcomes[1].outcome.as_ref().unwrap();
8895        assert!(
8896            !second.changed,
8897            "the repeated selector is the idempotent no-op"
8898        );
8899        assert!(second.written.is_none(), "and writes nothing a second time");
8900        assert!(second.event.is_none(), "and appends no second event");
8901        assert_eq!(
8902            second.epic_ids,
8903            vec!["e-7c4a91".to_string()],
8904            "and still reports the current list"
8905        );
8906
8907        let after = reloaded(&store, "2026-01-01-a.md");
8908        assert_eq!(after.epics, Some(vec!["e-7c4a91".to_string()]));
8909        assert_eq!(
8910            after.history.events().len(),
8911            1,
8912            "exactly one History entry across both selectors"
8913        );
8914    }
8915
8916    #[test]
8917    fn t016_nfr_002_every_epic_fixture_round_trips_with_no_key_added_removed_or_reordered() {
8918        let names = all_epic_fixtures();
8919        for required in [
8920            "no-references.md",
8921            "one-reference.md",
8922            "several-references.md",
8923            "missing-reference.md",
8924            "legacy-no-key.md",
8925            "task-labelled.md",
8926        ] {
8927            assert!(
8928                names.iter().any(|name| name == required),
8929                "the fixture set must cover every shape T016 names; {required} is missing from {names:?}"
8930            );
8931        }
8932        for name in names {
8933            let original = epic_fixture(&name);
8934            let issue = Issue::parse(&original).expect("fixture parses");
8935            assert_eq!(
8936                issue.to_file_contents(),
8937                original,
8938                "{name} must round-trip byte-identically"
8939            );
8940            assert_eq!(
8941                Issue::parse(&issue.to_file_contents())
8942                    .unwrap()
8943                    .to_file_contents(),
8944                original,
8945                "{name} must be byte-stable once re-read"
8946            );
8947        }
8948    }
8949
8950    #[test]
8951    fn t016_nfr_002_a_legacy_fixture_gains_no_epics_key() {
8952        let original = epic_fixture("legacy-no-key.md");
8953        assert!(!original.contains("epics:"));
8954        let issue = Issue::parse(&original).unwrap();
8955        assert_eq!(issue.epics, None);
8956        assert_eq!(
8957            issue.to_file_contents(),
8958            original,
8959            "a pre-epic file must not gain a key"
8960        );
8961    }
8962
8963    #[test]
8964    fn t016_c_003_a_multi_reference_fixture_is_byte_stable_and_ordered() {
8965        let original = epic_fixture("several-references.md");
8966        let issue = Issue::parse(&original).unwrap();
8967        assert_eq!(
8968            issue.epics,
8969            Some(vec![
8970                "e-platform".to_string(),
8971                "e-docs".to_string(),
8972                "e-infra".to_string()
8973            ]),
8974            "declaration order is preserved (NFR-002)"
8975        );
8976        assert_eq!(issue.to_file_contents(), original);
8977    }
8978
8979    #[test]
8980    fn t016_c_005_a_missing_reference_round_trips_through_a_store_unchanged() {
8981        // The registry declares no epics, so `e-deleted` cannot resolve. A loader
8982        // that "helpfully" dropped an unknown id would fail here and nowhere else.
8983        let (_tmp, store) = make_store();
8984        write_issue(
8985            &store,
8986            "missing-reference.md",
8987            &epic_fixture("missing-reference.md"),
8988        );
8989        assert!(
8990            store.config.epics.is_empty(),
8991            "the reference is missing by construction"
8992        );
8993
8994        let issue = store.get(IssueSelector::Number(4)).unwrap();
8995        assert_eq!(issue.epics, Some(vec!["e-deleted".to_string()]));
8996
8997        // Save the issue unchanged, then re-read: the id is still there.
8998        store.update(&issue.filename, &issue).unwrap();
8999        assert_eq!(
9000            read_issue_file(&store, "missing-reference.md"),
9001            epic_fixture("missing-reference.md"),
9002            "saving a missing reference must not drop or rewrite it"
9003        );
9004        let reloaded_issue = store.get(IssueSelector::Number(4)).unwrap();
9005        assert_eq!(reloaded_issue.epics, Some(vec!["e-deleted".to_string()]));
9006    }
9007
9008    #[test]
9009    fn t016_c_001_a_task_labelled_fixture_round_trips_and_gains_no_task_key() {
9010        // Tasks are issues (C-001): a `task` label and the Spec Kitty `task`
9011        // pointer create no new entity and no new key. This field must not have
9012        // introduced a `task`/`work_item`/`epic_task` key.
9013        let original = epic_fixture("task-labelled.md");
9014        let issue = Issue::parse(&original).unwrap();
9015        assert_eq!(issue.epics, Some(vec!["e-7c4a91".to_string()]));
9016        assert_eq!(issue.task, Some("WP03".to_string()));
9017
9018        let rendered = issue.to_file_contents();
9019        assert_eq!(rendered, original, "the fixture round-trips unchanged");
9020        assert_eq!(
9021            rendered.matches("task:").count(),
9022            1,
9023            "exactly one `task:` key — the Spec Kitty pointer, never a second:\n{rendered}"
9024        );
9025        for forbidden in ["work_item:", "epic_task:"] {
9026            assert!(
9027                !rendered.contains(forbidden),
9028                "`{forbidden}` must not exist (C-001):\n{rendered}"
9029            );
9030        }
9031    }
9032
9033    #[test]
9034    fn t016_c_003_referencing_an_issue_writes_no_member_list_to_the_registry() {
9035        let (tmp, store) = epic_store();
9036        let config_path = tmp.path().join("tix.toml");
9037        let before = fs::read_to_string(&config_path).unwrap();
9038        assert!(
9039            before.contains("[epics") && before.contains("e-7c4a91"),
9040            "the record is persisted before the reference: {before}"
9041        );
9042
9043        store.reference_epic("1", "e-7c4a91").unwrap();
9044
9045        let after = fs::read_to_string(&config_path).unwrap();
9046        assert_eq!(
9047            after, before,
9048            "the epic's registry record must not grow a member list"
9049        );
9050        assert!(
9051            !after.contains("members"),
9052            "membership is derived, never stored on the epic:\n{after}"
9053        );
9054    }
9055}