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    /// Remove the typed relationship `a --kind--> b`.
2233    ///
2234    /// Removing an edge that is not there is an idempotent no-op: no file is
2235    /// written and no History event is appended.
2236    pub fn unlink(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
2237        let canonical = self.canonical_request(a, b, kind)?;
2238        let stored = self.find_stored_rows(&canonical)?;
2239
2240        if stored.is_empty() {
2241            // Idempotent no-op: nothing is written and no History event is
2242            // appended. The reported relationship is the canonical form the
2243            // caller asked about.
2244            return Ok(RelationshipOutcome {
2245                written: None,
2246                relationship: canonical_placeholder(&canonical),
2247                changed: false,
2248            });
2249        }
2250
2251        // Remove every row matching the canonical edge, wherever it is stored,
2252        // and record **one** History event for the accepted mutation. Redundant
2253        // rows are one invalid condition to clean up, not N mutations
2254        // (contract §6: one event per accepted mutation).
2255        let mut by_file: HashMap<String, Vec<Relationship>> = HashMap::new();
2256        for (filename, rel) in stored {
2257            by_file.entry(filename).or_default().push(rel);
2258        }
2259
2260        let actor = actor_identity();
2261        // Ascending filename, for the same reproducibility reason as above.
2262        let mut file_order: Vec<String> = by_file.keys().cloned().collect();
2263        file_order.sort_unstable();
2264
2265        // The first row this call removes, captured before anything is written.
2266        // M11: a re-scan *after* removal can never match — every matching row is
2267        // gone — so a successful unlink always fell through to the empty
2268        // placeholder and reported an unaddressable relationship, while `link`'s
2269        // no-op path reported a real one. F15 was applied to one path, not both.
2270        let mut removed_row: Option<Relationship> = None;
2271
2272        // M10: the summary is computed **once**, from every row this call
2273        // removes, and the same event is recorded on each file that changes.
2274        // Appending per file produced two events for one `unlink` when the same
2275        // edge was hand-authored into both endpoints, while the comment above
2276        // claimed one event per accepted mutation. One unlink is one mutation.
2277        let all_removed: Vec<Relationship> = by_file.values().flatten().cloned().collect();
2278        let summary = if all_removed.len() == 1 {
2279            let one = &all_removed[0];
2280            format!("removed {}: {}", one.id, one.display)
2281        } else {
2282            let kinds: Vec<String> = all_removed.iter().map(|r| r.kind.to_string()).collect();
2283            format!(
2284                "removed {} relationships ({})",
2285                all_removed.len(),
2286                kinds.join(", ")
2287            )
2288        };
2289
2290        let mut written: Option<Issue> = None;
2291        for filename in &file_order {
2292            let doomed = &by_file[filename];
2293            let mut owner = self.get(IssueSelector::Filename(filename.clone()))?;
2294            if removed_row.is_none() {
2295                removed_row = doomed.first().cloned();
2296            }
2297            owner.relationships.retain(|r| r.canonical() != canonical);
2298            owner
2299                .history
2300                .append_now(&actor, HistoryEventKind::RelationshipRemoved, &summary);
2301            owner.bump_updated();
2302            owner.filename = self.update(&owner.filename, &owner)?;
2303            written = Some(owner);
2304        }
2305
2306        // The row that *was* removed, captured above — an empty id is not
2307        // addressable by `edit_relation`/`remove_relation`.
2308        let relationship = removed_row.unwrap_or_else(|| canonical_placeholder(&canonical));
2309
2310        Ok(RelationshipOutcome {
2311            written,
2312            relationship,
2313            changed: true,
2314        })
2315    }
2316
2317    /// Retarget and/or re-kind the relationship with this stable id.
2318    ///
2319    /// The id is generated once and **survives** the edit, so a relationship a
2320    /// user has already referenced elsewhere does not change identity when its
2321    /// target moves (contract §1). `new_to` and `new_kind` are both optional and
2322    /// naming neither is the identity edit: nothing is written and no History
2323    /// event is appended.
2324    ///
2325    /// Validation goes through [`RelationshipGraph::validate_edit`], so an edit
2326    /// that would self-link, point at a missing issue, duplicate an existing
2327    /// edge, or close a cycle is refused **before any file is touched**
2328    /// (SC-005). An edit may change the *kind* but never the source: the source
2329    /// is the edge's owner, and moving it would mean deleting a relationship and
2330    /// creating another, which is what `remove` plus `link` is for.
2331    pub fn edit_relation(
2332        &self,
2333        id: &str,
2334        new_to: Option<u32>,
2335        new_kind: Option<RelationKind>,
2336    ) -> Result<RelationshipOutcome> {
2337        let issues = self.list_all()?;
2338        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
2339        let done_column = self.relationship_done_column();
2340        let graph = RelationshipGraph::new(&views, &done_column);
2341
2342        let Some(existing) = graph.find_relationship(id) else {
2343            // An id that does not resolve is a named diagnostic, not a bare
2344            // `NotFound`: the user passed a well-formed argument, and what is
2345            // missing is a relationship, not an issue.
2346            let mut verdict = crate::relationships::RelationshipValidationResult::clean();
2347            verdict.errors.push(Diagnostic::unknown_relation(id));
2348            return Err(relationship_error(&verdict.errors));
2349        };
2350
2351        let verdict = graph.validate_edit(id, new_to, new_kind);
2352        if verdict.blocking() {
2353            return Err(relationship_error(&verdict.errors));
2354        }
2355
2356        let to = new_to.unwrap_or(existing.to);
2357        let kind = new_kind.unwrap_or(existing.kind);
2358        let old_canonical = existing.canonical();
2359
2360        // The new form has to be **canonicalized before anything else is derived
2361        // from it**, not merely validated. `--type blocked_by` and re-kinding
2362        // `related` change which issue owns the edge *and* which issue it points
2363        // at: `A --blocked_by--> B` is stored as `B blocks A`, so the canonical
2364        // target is the authored `from`, not the authored `to`.
2365        //
2366        // Deriving the display name from the *authored* target is what produced
2367        // `blocks #2 Issue A` for a `2 duplicates 0` edge re-kinded to
2368        // `blocked_by`: right endpoints, wrong title, and a stored row that lies
2369        // about the issue it names. Canonicalize first, then read the title off
2370        // the canonical target.
2371        let requested = CanonicalEdge::new(existing.from, to, kind);
2372        let mut updated = existing.clone();
2373        updated.from = requested.from;
2374        updated.to = requested.to;
2375        updated.kind = requested.kind;
2376
2377        // The target's *current* title, from the canonical target. A target that
2378        // does not resolve would otherwise store an edge with an empty display
2379        // name, so this stays lenient and the row carries what it can.
2380        let target_title = self
2381            .get(IssueSelector::Number(updated.to))
2382            .map(|i| i.title)
2383            .unwrap_or_default();
2384        updated.refresh_display(&target_title);
2385        let new_canonical = updated.canonical();
2386
2387        // No-op detection is on the *resolved* edge, not on the arguments: an
2388        // edit naming its own current target and kind is the identity, and so is
2389        // an edit that only re-renders the same display.
2390        if old_canonical == new_canonical && updated.display == existing.display {
2391            return Ok(RelationshipOutcome {
2392                written: None,
2393                relationship: existing.clone(),
2394                changed: false,
2395            });
2396        }
2397
2398        let actor = actor_identity();
2399        let detail = format!("{} → {}: {}", existing.display, updated.display, updated.id);
2400
2401        // Remove the old row from every file that holds it, without recording
2402        // a `relationship_removed` event: this is one edit, not a delete plus a
2403        // create. Only the canonical source is written when ownership does not
2404        // move, so the common case stays a single-file write.
2405        // Removal and insertion are **separate passes**, because the canonical
2406        // source of the new form is not necessarily a file that held the old
2407        // one. Re-kinding can move ownership — `related` is order-normalised and
2408        // `blocked_by` swaps the endpoints — and when it does, a loop that both
2409        // removes and re-adds deletes the row from its old owner and never
2410        // writes it to the new one. The relationship is then destroyed while the
2411        // call reports success, and a `blocked_by` re-kind reports a `blocks`
2412        // edge that does not exist, so every derived read is wrong the same way.
2413        //
2414        // Two passes make that shape unrepresentable: the first only ever
2415        // removes, the second only ever adds.
2416        let mut removed_anywhere = false;
2417        for issue in self.list_all()? {
2418            if !issue
2419                .relationships
2420                .iter()
2421                .any(|r| r.id == id && r.canonical() == old_canonical)
2422            {
2423                continue;
2424            }
2425            let mut owner = self.get(IssueSelector::Filename(issue.filename.clone()))?;
2426            owner
2427                .relationships
2428                .retain(|r| !(r.id == id && r.canonical() == old_canonical));
2429            removed_anywhere = true;
2430            owner.bump_updated();
2431            owner.filename = self.update(&owner.filename, &owner)?;
2432        }
2433        debug_assert!(removed_anywhere, "graph found the row, the scan did not");
2434
2435        // The new canonical source carries the row and the History event. It may
2436        // be the file just written, a different file, or one the old form never
2437        // touched, so all three cases are written here rather than inside the
2438        // removal loop.
2439        let mut new_owner = self.get(IssueSelector::Number(new_canonical.from))?;
2440        new_owner.relationships.push(updated.clone());
2441        new_owner
2442            .history
2443            .append_now(&actor, HistoryEventKind::RelationshipRetargeted, &detail);
2444        new_owner.bump_updated();
2445        new_owner.filename = self.update(&new_owner.filename, &new_owner)?;
2446
2447        Ok(RelationshipOutcome {
2448            written: Some(self.get(IssueSelector::Number(new_canonical.from))?),
2449            relationship: updated,
2450            changed: true,
2451        })
2452    }
2453
2454    /// Remove the relationship with this stable id.
2455    ///
2456    /// An id that is not stored is an **idempotent no-op**: no file is written
2457    /// and no History event is appended, exactly as [`Store::unlink`] treats an
2458    /// absent edge. `tix link remove` is therefore safe to retry.
2459    pub fn remove_relation(&self, id: &str) -> Result<RelationshipOutcome> {
2460        let stored = self.find_stored_rows_by_id(id)?;
2461        if stored.is_empty() {
2462            // Reported as a no-op rather than an error, and the reported
2463            // relationship carries the id the caller asked about so the outcome
2464            // is addressable in the message.
2465            return Ok(RelationshipOutcome {
2466                written: None,
2467                relationship: Relationship {
2468                    id: id.to_string(),
2469                    ..canonical_placeholder(&CanonicalEdge::new(0, 0, RelationKind::Related))
2470                },
2471                changed: false,
2472            });
2473        }
2474
2475        let canonical = stored[0].1.canonical();
2476        // Reuse the edge-keyed removal so both spellings of a remove stay one
2477        // implementation, including redundant-row cleanup and the single
2478        // History event per accepted mutation.
2479        let from = canonical.from.to_string();
2480        let to = canonical.to.to_string();
2481        self.unlink(&from, &to, canonical.kind)
2482    }
2483
2484    /// Every stored row with this id, paired with the store-relative filename
2485    /// it lives in.
2486    ///
2487    /// Ids are unique per relationship, but a hand-edited file can hold the same
2488    /// id twice, so this returns all of them and the caller removes all of them.
2489    fn find_stored_rows_by_id(&self, id: &str) -> Result<Vec<(String, Relationship)>> {
2490        let mut found = Vec::new();
2491        for issue in self.list_all()? {
2492            for rel in &issue.relationships {
2493                if rel.id == id {
2494                    found.push((issue.filename.clone(), rel.clone()));
2495                }
2496            }
2497        }
2498        Ok(found)
2499    }
2500
2501    /// The issue set plus the board's configured completion column, for
2502    /// building a [`RelationshipGraph`] over the whole store.
2503    ///
2504    /// The issue set is returned **alongside** the column rather than as a
2505    /// finished graph because the graph borrows the issues: a caller that wants
2506    /// three derived reads (`relationships_for`, `blocked_state`,
2507    /// `dependency_chain`) over one snapshot would otherwise pay three
2508    /// separate `O(V + E)` builds and risk the three disagreeing.
2509    ///
2510    /// This is the single place a read surface obtains its graph, so no surface
2511    /// can reach past the service to re-derive direction handling, cycle
2512    /// detection, or blocked state (C-003).
2513    ///
2514    /// Reads the **active** set ([`Store::list`]): it is the graph a default
2515    /// view renders — `tix list --show relationships`, `tix relationship list`
2516    /// — so archived issues are deliberately absent from it (issue #1492).
2517    /// Per-issue lookups that must still resolve an archived issue go through
2518    /// [`Store::relationships_for`], which reads [`Store::list_all`].
2519    pub fn graph_source(&self) -> Result<(Vec<Issue>, String)> {
2520        Ok((self.list()?, self.relationship_done_column()))
2521    }
2522
2523    /// Resolve a requested pair into the canonical edge it refers to, without
2524    /// requiring the *target* to exist.
2525    ///
2526    /// A dangling edge is a diagnosed, expected condition (`data-model.md` §4,
2527    /// contract §7), so the removal path must still be able to reach it. Both
2528    /// selectors are therefore resolved leniently: a selector that fails to
2529    /// resolve is accepted if it is a bare issue number, because that is enough
2530    /// to canonicalize the request. Anything else is still `NotFound`.
2531    fn canonical_request(&self, a: &str, b: &str, kind: RelationKind) -> Result<CanonicalEdge> {
2532        let lenient = |sel: &str| -> Result<u32> {
2533            match self.resolve_issue(sel) {
2534                Ok(issue) => Ok(issue.number),
2535                Err(e) => sel.trim().parse::<u32>().map_err(|_| e),
2536            }
2537        };
2538        let from = lenient(a)?;
2539        let to = lenient(b)?;
2540        Ok(CanonicalEdge::new(from, to, kind))
2541    }
2542
2543    /// Every stored row matching `canonical`, paired with the store-relative
2544    /// filename it actually lives in.
2545    ///
2546    /// This is the single answer to "where is this edge stored", and it is the
2547    /// reason the three entry points no longer disagree. It matches on
2548    /// [`Relationship::canonical`] — the service's own normalization — rather
2549    /// than comparing authored fields, so a row is found no matter which file
2550    /// holds it or which endpoint order it was written in. Two defects came
2551    /// from re-deriving that comparison locally: a hand-authored
2552    /// non-canonically-ordered `related` row was un-removable, and a row
2553    /// persisted with the input-only `blocked_by` spelling was un-removable too.
2554    ///
2555    /// Cost is one `O(V + E)` pass, the same order as the validation the write
2556    /// path already performs. Correctness of "which file holds this edge" is
2557    /// worth it; a wrong answer here silently deletes nothing.
2558    fn find_stored_rows(&self, canonical: &CanonicalEdge) -> Result<Vec<(String, Relationship)>> {
2559        let mut found = Vec::new();
2560        for issue in self.list_all()? {
2561            for rel in &issue.relationships {
2562                if rel.canonical() == *canonical {
2563                    found.push((issue.filename.clone(), rel.clone()));
2564                }
2565            }
2566        }
2567        Ok(found)
2568    }
2569
2570    /// Make the issue's relationship set exactly `targets`, with `kind`.
2571    ///
2572    /// A typed relationship is stored **once, in its canonical source's file**.
2573    /// For a directional kind that is the declaring issue; for `related`, which
2574    /// is order-normalized, it is the lower-numbered of the pair. So this method
2575    /// may have to write more than one file: an edge requested from an issue
2576    /// that is not its canonical source belongs to the other issue's frontmatter.
2577    /// That is the same ownership rule [`Store::link`] already follows, and it
2578    /// is what "stored once, no mirrored edge" requires.
2579    ///
2580    /// Every target number must already resolve (`NotFound` otherwise) and a
2581    /// self-link is refused — both before anything is written (SC-005).
2582    /// Relationships that survive keep their stable id and their History
2583    /// entries; removals are recorded.
2584    pub fn set_relationships(
2585        &self,
2586        filename: &str,
2587        targets: &[(u32, RelationKind)],
2588    ) -> Result<Issue> {
2589        let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
2590        let own = issue.number;
2591        let actor = actor_identity();
2592
2593        // Validate every target up front, before any mutation (SC-005).
2594        let mut wanted: Vec<CanonicalEdge> = Vec::with_capacity(targets.len());
2595        for (target, kind) in targets {
2596            if *target == own {
2597                return Err(TixError::InvalidInput(format!(
2598                    "issue #{own} cannot relate to itself"
2599                )));
2600            }
2601            self.get(IssueSelector::Number(*target))?;
2602            let canonical = CanonicalEdge::new(own, *target, *kind);
2603            // Identical canonical triples collapse, so asking for the same edge
2604            // twice is not two writes and not a self-conflict.
2605            if !wanted.contains(&canonical) {
2606                wanted.push(canonical);
2607            }
2608        }
2609
2610        // Every wanted edge is validated **through the service** before the
2611        // first write, exactly as `link` does. This used to re-implement
2612        // existence and the self-link rule locally and skip the rest, so with
2613        // `1 blocks 2` already on disk it would happily store `2 blocks 1` — a
2614        // reciprocal pair that `link` rejects one line earlier and that
2615        // contract §3 rules 3 and 4 require be refused. Re-deriving part of the
2616        // rule set is also the boundary breach C-003 exists to prevent, and it
2617        // becomes user-reachable the moment WP03 wires `--type`.
2618        {
2619            let issues = self.list_all()?;
2620            let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
2621            let done_column = self.relationship_done_column();
2622            let graph = RelationshipGraph::new(&views, &done_column);
2623            for canonical in &wanted {
2624                let verdict = graph.validate_create(canonical.from, canonical.to, canonical.kind);
2625                if verdict.blocking() {
2626                    return Err(relationship_error(&verdict.errors));
2627                }
2628            }
2629        }
2630
2631        // Remove: every existing relationship this issue owns that is not
2632        // wanted, grouped by the file that actually stores it.
2633        //
2634        // "Owns" has two cases, and getting this wrong is a silent data bug:
2635        //
2636        //  - The canonical `from` is this issue. True for every directional
2637        //    kind, and for `related` whenever this issue has the lower number.
2638        //  - The canonical `to` is this issue **and** the kind is
2639        //    non-directional. A `related` association belongs to both
2640        //    endpoints even though it is stored once, in the lower-numbered
2641        //    issue's file. `tix edit 5 --links ""` has to remove a
2642        //    `5 related 3` edge even though it lives in #3's frontmatter and
2643        //    #5's own file is empty.
2644        //
2645        // An *inbound directional* edge is deliberately excluded: if #3 blocks
2646        // #5, that edge is #3's declaration, and #5 clearing its own
2647        // relationship set must not silently rewrite #3's dependency graph.
2648        let issues = self.list_all()?;
2649        let mut removals: HashMap<u32, Vec<Relationship>> = HashMap::new();
2650        for candidate in &issues {
2651            for rel in &candidate.relationships {
2652                let canonical = rel.canonical();
2653                let owned_by_us = canonical.from == own
2654                    || (canonical.to == own && !canonical.kind.is_directional());
2655                if owned_by_us && !wanted.contains(&canonical) {
2656                    removals
2657                        .entry(canonical.from)
2658                        .or_default()
2659                        .push(rel.clone());
2660                }
2661            }
2662        }
2663
2664        // Add: each wanted edge goes into its canonical source's file. One
2665        // already present is left alone, so the stable id survives.
2666        let mut additions: HashMap<u32, Vec<(CanonicalEdge, String)>> = HashMap::new();
2667        for canonical in &wanted {
2668            let owner = match self.get(IssueSelector::Number(canonical.from)) {
2669                Ok(owner) => owner,
2670                Err(TixError::NotFound(_)) => continue,
2671                Err(e) => return Err(e),
2672            };
2673            if owner
2674                .relationships
2675                .iter()
2676                .any(|r| r.canonical() == *canonical)
2677            {
2678                continue;
2679            }
2680            let target_title = self
2681                .get(IssueSelector::Number(canonical.to))
2682                .map(|i| i.title)
2683                .unwrap_or_default();
2684            additions
2685                .entry(canonical.from)
2686                .or_default()
2687                .push((*canonical, target_title));
2688        }
2689
2690        if removals.is_empty() && additions.is_empty() {
2691            return Ok(issue);
2692        }
2693
2694        // Apply removals, then additions. This can write **N** files — the
2695        // removals map is keyed by whichever file actually holds each row, and
2696        // an order-normalized `related` edge is held by the lower-numbered
2697        // issue, not the one named by `filename`. A failure partway through
2698        // would therefore leave the store half-mutated, so every file's
2699        // pre-write contents are captured on the way through and restored if a
2700        // later write fails (SC-005: no file is left partially rewritten).
2701        //
2702        // The restore is best-effort per file and the original error is
2703        // returned either way: a rollback that itself fails must not mask the
2704        // cause.
2705        // Every file's pre-write contents are captured on the way through and
2706        // restored if a later write in the same call fails, so a refused
2707        // mutation leaves nothing behind (SC-005). The restore is best-effort
2708        // per file and the original error is returned either way: a rollback
2709        // that itself fails must not mask the cause.
2710        // Owners are written in ascending number order. `removals` and
2711        // `additions` are `HashMap`s, so iterating them directly makes the write
2712        // order — and therefore which files are committed before a failure —
2713        // differ between runs of the same input.
2714        let mut removal_order: Vec<u32> = removals.keys().copied().collect();
2715        removal_order.sort_unstable();
2716        let mut addition_order: Vec<u32> = additions.keys().copied().collect();
2717        addition_order.sort_unstable();
2718
2719        // Write one owner, remembering how to undo it. A nested `fn` rather than
2720        // a closure so `undo` and `touched_named` can be passed explicitly
2721        // instead of captured, which would make them unborrowable at the
2722        // call sites.
2723        fn apply(
2724            store: &Store,
2725            mut owner: Issue,
2726            undo: &mut Vec<(PathBuf, String)>,
2727            touched_named: &mut bool,
2728            named_path: &Path,
2729        ) -> Result<()> {
2730            let path = store.issues_dir.join(&owner.filename);
2731            if path == named_path {
2732                *touched_named = true;
2733            }
2734            if let Ok(previous) = std::fs::read_to_string(&path) {
2735                undo.push((path, previous));
2736            }
2737            owner.bump_updated();
2738            store.update(&owner.filename, &owner).map(|_| ())
2739        }
2740
2741        let named_path = self.issues_dir.join(&issue.filename);
2742        let mut undo: Vec<(PathBuf, String)> = Vec::new();
2743        let mut touched_named = false;
2744
2745        let applied = (|| -> Result<()> {
2746            for owner_number in &removal_order {
2747                let doomed = &removals[owner_number];
2748                let mut owner = self.get(IssueSelector::Number(*owner_number))?;
2749                let mut changed = false;
2750                for rel in doomed {
2751                    let canonical = rel.canonical();
2752                    let before = owner.relationships.len();
2753                    owner.relationships.retain(|r| r.canonical() != canonical);
2754                    if owner.relationships.len() != before {
2755                        let detail = format!("removed {}: {}", rel.id, rel.display);
2756                        owner.history.append_now(
2757                            &actor,
2758                            HistoryEventKind::RelationshipRemoved,
2759                            &detail,
2760                        );
2761                        changed = true;
2762                    }
2763                }
2764                if changed {
2765                    apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
2766                }
2767            }
2768
2769            for owner_number in &addition_order {
2770                let wanted_here = &additions[owner_number];
2771                let mut owner = self.get(IssueSelector::Number(*owner_number))?;
2772                for (canonical, target_title) in wanted_here {
2773                    let rel = Relationship::new(
2774                        canonical.from,
2775                        canonical.to,
2776                        canonical.kind,
2777                        target_title,
2778                    );
2779                    let detail = format!("created {}: {}", rel.id, rel.display);
2780                    owner
2781                        .history
2782                        .append_now(&actor, HistoryEventKind::RelationshipAdded, &detail);
2783                    owner.relationships.push(rel);
2784                }
2785                apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
2786            }
2787
2788            // F16. The named issue's own frontmatter can be untouched while its
2789            // relationship set changed, because an order-normalized `related`
2790            // edge is stored in the *other* endpoint's file. `tix edit 2 --links
2791            // 1` then left #2 byte-identical with a stale `updated`, so the
2792            // command appeared to do nothing to the issue it named and the audit
2793            // trail lived only on the other endpoint. `updated` tracks the
2794            // issue's last mutation and its relationship set did change.
2795            //
2796            // Inside the rolled-back sequence and through the same `apply`, so
2797            // its file is captured for restore like every other. Placed after the
2798            // rollback check it was a fresh partial-write hole: a failure here
2799            // returned an error with the relationship writes already committed
2800            // and nothing left to restore.
2801            if !touched_named {
2802                let named = self.get(IssueSelector::Filename(filename.to_string()))?;
2803                apply(self, named, &mut undo, &mut touched_named, &named_path)?;
2804            }
2805            Ok(())
2806        })();
2807
2808        if let Err(e) = applied {
2809            for (path, previous) in undo.iter().rev() {
2810                let _ = atomic_write(&self.issues_dir, path, previous.as_bytes());
2811            }
2812            return Err(e);
2813        }
2814
2815        self.get(IssueSelector::Filename(filename.to_string()))
2816    }
2817
2818    /// Resolve a selector string exactly like the selector-taking commands
2819    /// (`parse_selector`) and fetch the issue (D2: "Resolver SHALL match
2820    /// `tix edit`/`tix move`").
2821    ///
2822    /// `pub` because the `--parent` flags (WP03) resolve a parent selector to its
2823    /// **number** through this same path, so `#4`, `4` and a title fragment all work
2824    /// on a parent exactly as they do on any other selector — and a second
2825    /// resolver in the command layer is how two surfaces end up disagreeing about
2826    /// which issue a selector names.
2827    pub fn resolve_issue(&self, raw: &str) -> Result<Issue> {
2828        let selector = crate::commands::parse_selector(self, raw)?;
2829        self.get(selector)
2830    }
2831
2832    /// Physically move an issue file into the assignee segment of a new
2833    /// assignee (FR-031, `contracts/file-format.md` rule 9).
2834    ///
2835    /// `filename` is a path relative to the issues dir: either a legacy flat
2836    /// basename (`2026-05-10-fix-login.md`, C-010) or a grouped path
2837    /// (`alice/2026-05-10-fix-login.md`). It is validated via the relaxed
2838    /// [`validate_filename`] before anything touches the filesystem, so
2839    /// traversal attempts are refused here as everywhere else.
2840    ///
2841    /// The old assignee segment is read from the current relative path, the
2842    /// new one derived with [`derive_segment`]; equal segments are a no-op.
2843    /// The target filename keeps the original `created-slug` stem; when the
2844    /// target already holds `<stem>.md`, a deterministic `-1`, `-2`, …
2845    /// suffix is appended by testing existence — an existing file is never
2846    /// overwritten or deleted. `number`/`created` live in the frontmatter and
2847    /// are untouched, so issue-number stability is guaranteed by construction
2848    /// (C-007).
2849    ///
2850    /// The move is a single `std::fs::rename` (atomic-ish, same filesystem).
2851    /// Returns the **new relative path** (matching how `issue.filename` is
2852    /// stored) on success, including the unchanged relative path for the
2853    /// equal-segment no-op.
2854    pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
2855        // `get` validates `filename` and errors NotFound when it is missing.
2856        let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
2857        let mut updated = issue.clone();
2858        updated.assignee = new_assignee.map(str::to_string);
2859        let target_dir = crate::layout::render_directory(&self.config.layout, &updated);
2860        let (old_dir, base) = split_relative(filename).ok_or_else(|| {
2861            TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
2862        })?;
2863        if old_dir == target_dir {
2864            return Ok(PathBuf::from(filename));
2865        }
2866        let stem = base.strip_suffix(".md").unwrap_or(&base);
2867        self.move_issue_file(filename, &target_dir, stem)
2868    }
2869
2870    /// Relocate an issue file to an exact store-relative `target` (used by
2871    /// `tix layout migrate`). Frontmatter and number are preserved because
2872    /// nothing is rewritten — it is a rename; an occupied target is refused; the
2873    /// `<slug>-assets/` dir moves alongside.
2874    pub fn migrate_issue(&self, filename: &str, target: &str) -> Result<()> {
2875        if target == filename {
2876            return Ok(());
2877        }
2878        validate_filename(target)?;
2879        let from = self.issues_dir.join(filename);
2880        if !from.is_file() {
2881            return Err(TixError::NotFound(format!("file {filename}")));
2882        }
2883        let to = self.issues_dir.join(target);
2884        if to.exists() {
2885            return Err(TixError::InvalidInput(format!(
2886                "cannot migrate {filename}: {target} already exists"
2887            )));
2888        }
2889        if let Some(parent) = to.parent() {
2890            std::fs::create_dir_all(parent)?;
2891        }
2892        std::fs::rename(&from, &to)?;
2893        let (old_dir, base) = split_relative(filename).unwrap_or_default();
2894        let stem = base.strip_suffix(".md").unwrap_or(&base);
2895        let asset_name = format!("{}-assets", slug_from_stem(stem));
2896        let old_asset_dir = self.issues_dir.join(&old_dir).join(&asset_name);
2897        if old_asset_dir.exists()
2898            && let Some(new_dir) = to.parent()
2899        {
2900            let moved = resolve_asset_dir_name(new_dir, &asset_name);
2901            std::fs::rename(&old_asset_dir, new_dir.join(&moved))?;
2902        }
2903        Ok(())
2904    }
2905
2906    /// Move an issue file (and its `<slug>-assets/` dir) from `filename` to
2907    /// `<target_dir>/<stem>[-N].md`, creating the directory as needed. A target
2908    /// collision takes the first free `-N` suffix; nothing is overwritten.
2909    fn move_issue_file(&self, filename: &str, target_dir: &str, stem: &str) -> Result<PathBuf> {
2910        let target_dir_path = self.issues_dir.join(target_dir);
2911        std::fs::create_dir_all(&target_dir_path)?;
2912        let new_name = resolve_target_name(&target_dir_path, stem);
2913        let old_dir = split_relative(filename).map_or_else(String::new, |(d, _)| d);
2914        let from = self.issues_dir.join(filename);
2915        let to = target_dir_path.join(&new_name);
2916        journal_record_move(&from, &to);
2917        std::fs::rename(from, to)?;
2918        // Move the `<slug>-assets/` dir alongside the file (FR-007). On a
2919        // same-named dir in the target, disambiguate rather than overwrite.
2920        let slug = slug_from_stem(stem);
2921        let asset_name = format!("{slug}-assets");
2922        let old_asset_dir = self.issues_dir.join(&old_dir).join(&asset_name);
2923        if old_asset_dir.exists() {
2924            let moved = resolve_asset_dir_name(&target_dir_path, &asset_name);
2925            let asset_target = target_dir_path.join(&moved);
2926            journal_record_move(&old_asset_dir, &asset_target);
2927            std::fs::rename(&old_asset_dir, &asset_target)?;
2928        }
2929        Ok(PathBuf::from(format!("{target_dir}/{new_name}")))
2930    }
2931
2932    /// Whether `filename` already sits at the directory its templated fields
2933    /// render to. Used to decide whether a mutation may relocate the file: only a
2934    /// file already *on* the layout is moved, so a legacy or mismatched path is
2935    /// left to `tix layout migrate`.
2936    #[must_use]
2937    pub fn path_is_laid_out(&self, filename: &str, issue: &Issue) -> bool {
2938        split_relative(filename).is_some_and(|(dir, _)| {
2939            dir == crate::layout::render_directory(&self.config.layout, issue)
2940        })
2941    }
2942
2943    /// Move an issue to the directory its templated fields now render to, when
2944    /// `move_on_change` is set. The basename is preserved. Returns whether it
2945    /// moved; a no-op when already in place or when `move_on_change` is off.
2946    pub fn move_to_layout(&self, issue: &mut Issue) -> Result<bool> {
2947        let before = issue.filename.clone();
2948        let after = self.reconcile_location(&before, issue)?;
2949        issue.filename = after.clone();
2950        Ok(after != before)
2951    }
2952}
2953
2954/// Whether a **relative** path is one [`Store::scan`] would index as an issue
2955/// file, at any depth (issue #10 follow-up; configurable layout).
2956///
2957/// A path is an issue file when every component is a normal name, no component
2958/// is a `.tix-tmp-*` temp dir or a `<slug>-assets/` dir, and the final component
2959/// ends in `.md`. Depth is unbounded so the incremental corpus matches a full
2960/// rebuild under any `[layout]` template.
2961pub(crate) fn is_issue_rel_path(rel: &Path) -> bool {
2962    let mut file_name: Option<&std::ffi::OsStr> = None;
2963    for component in rel.components() {
2964        match component {
2965            std::path::Component::Normal(segment) => {
2966                let Some(s) = segment.to_str() else {
2967                    return false;
2968                };
2969                if s.starts_with(".tix-tmp-") || s.ends_with("-assets") {
2970                    return false;
2971                }
2972                file_name = Some(segment);
2973            }
2974            std::path::Component::CurDir => {}
2975            // RootDir, ParentDir, Prefix: not a store-relative issue path.
2976            _ => return false,
2977        }
2978    }
2979    file_name.is_some_and(|name| name.to_string_lossy().ends_with(".md"))
2980}
2981
2982/// Validate an issue filename. A filename is either a legacy flat basename
2983/// (`2026-05-10-fix-login.md`) or a grouped path with exactly one assignee
2984/// segment prefix (`alice/2026-05-10-fix-login.md`, C-008/C-010).
2985///
2986/// Refused in all cases: NUL bytes, backslashes, the `..` component, empty
2987/// components (leading/trailing/double slashes), absolute / leading-slash
2988/// paths, and nesting deeper than one segment. Every refusal is a hard
2989/// [`TixError::InvalidInput`] raised before any filesystem touch.
2990fn validate_filename(filename: &str) -> Result<()> {
2991    if filename.is_empty() {
2992        return Err(TixError::InvalidInput(format!(
2993            "filename must not be empty: {filename:?}"
2994        )));
2995    }
2996    if filename.starts_with('/') {
2997        return Err(TixError::InvalidInput(format!(
2998            "filename must be relative (no leading `/`): {filename}"
2999        )));
3000    }
3001    if filename.contains('\\') {
3002        return Err(TixError::InvalidInput(format!(
3003            "filename must not contain backslashes: {filename}"
3004        )));
3005    }
3006    if filename.contains("..") {
3007        return Err(TixError::InvalidInput(format!(
3008            "filename must not contain `..`: {filename}"
3009        )));
3010    }
3011    if filename.contains('\0') {
3012        return Err(TixError::InvalidInput(format!(
3013            "filename must not contain NUL: {filename}"
3014        )));
3015    }
3016    if filename.split('/').any(|component| component.is_empty()) {
3017        return Err(TixError::InvalidInput(format!(
3018            "filename path components must not be empty: {filename}"
3019        )));
3020    }
3021    Ok(())
3022}
3023
3024/// Split a validated relative filename into `(segment, basename)`. Legacy
3025/// flat filenames have an empty segment (`("", "2026-05-10-x.md")`), grouped
3026/// ones carry exactly one segment (`("alice", "2026-05-10-x.md")`). [`None`]
3027/// for anything that survived a bad path shape.
3028/// The `number:` field of an issue file's frontmatter, without a full parse.
3029///
3030/// `Store::delete` needs the number of the issue it is removing so it can free that
3031/// number's claim, and it needs it *before* the file is gone. A full `Issue` parse
3032/// would be the obvious way, but this sits on the delete path and only needs one
3033/// scalar, so a scoped scan is enough — and it cannot fail on an issue that would
3034/// fail to load, which keeps `delete`'s existing "removes the file" contract
3035/// (#1352) independent of frontmatter validity.
3036///
3037/// The scan stops at the closing `---` so a `number:` in the **body** cannot be
3038/// mistaken for the field.
3039fn frontmatter_number(text: &str) -> Option<u32> {
3040    let mut lines = text.lines();
3041    // The opening delimiter. A file that does not start with one has no frontmatter.
3042    if lines.next()?.trim() != "---" {
3043        return None;
3044    }
3045    for line in lines {
3046        let line = line.trim_end();
3047        // The closing delimiter ends the frontmatter, so a `number:` after it is
3048        // body text and must not be mistaken for the field.
3049        if line == "---" {
3050            return None;
3051        }
3052        if let Some(rest) = line.strip_prefix("number:") {
3053            return rest.trim().parse().ok();
3054        }
3055    }
3056    None
3057}
3058
3059/// The directory holding one marker per claimed number.
3060///
3061/// **A sibling of the issues directory, not a child of it.** `docs/issues/` is the
3062/// tool's source of truth: people hand-edit it, script against it, and glob it, and
3063/// its contents are committed. Adding an internal directory inside it changes what
3064/// every one of those consumers sees — a test helper that counts segments, a `for`
3065/// loop over `docs/issues/*`, a CI check — for machinery no reader should have to
3066/// know about. Beside it, `docs/issues/` contains exactly what it did before.
3067///
3068/// Derived from the configured `issues_dir` rather than hardcoded, so a repo that
3069/// points `issues_dir` somewhere else still gets a ledger beside that.
3070fn claims_dir(issues_dir: &Path) -> PathBuf {
3071    issues_dir
3072        .parent()
3073        .unwrap_or(issues_dir)
3074        .join(NumberClaim::DIR)
3075}
3076
3077fn split_relative(filename: &str) -> Option<(String, String)> {
3078    let mut parts = filename.rsplitn(2, '/');
3079    let basename = parts.next()?;
3080    let segment = parts.next().unwrap_or("");
3081    Some((segment.to_string(), basename.to_string()))
3082}
3083
3084/// Flip the archived marker on a store-relative issue filename.
3085///
3086/// `alice/2026-01-01-x.md` → `alice/.2026-01-01-x.md` when `archive` is true,
3087/// and the inverse when false. Only the basename's leading dot changes; the
3088/// segment stays put, so the file never moves between directories. `None` when
3089/// the name is already in the requested state.
3090fn flip_archived(filename: &str, archive: bool) -> Option<String> {
3091    let (segment, basename) = split_relative(filename)?;
3092    let flipped = if archive {
3093        if basename.starts_with('.') {
3094            return None;
3095        }
3096        format!(".{basename}")
3097    } else {
3098        basename.strip_prefix('.')?.to_string()
3099    };
3100    Some(if segment.is_empty() {
3101        flipped
3102    } else {
3103        format!("{segment}/{flipped}")
3104    })
3105}
3106
3107/// The first free `<stem>.md` / `<stem>-1.md` / `<stem>-2.md` … name in
3108/// `dir` (deterministic existence probe). Never returns an existing name.
3109fn resolve_target_name(dir: &Path, stem: &str) -> String {
3110    let mut candidate = format!("{stem}.md");
3111    let mut i = 1u32;
3112    while dir.join(&candidate).exists() {
3113        candidate = format!("{stem}-{i}.md");
3114        i += 1;
3115    }
3116    candidate
3117}
3118
3119/// The slug used for the asset dir: the issue filename stem minus any leading
3120/// date and id components.
3121///
3122/// New schema is `YYYY-MM-DD-<id>-<slug>` (`2026-09-13-A1B2C3D4-fix-login-bug` →
3123/// `fix-login-bug`); legacy is `YYYY-MM-DD-<slug>`; an earlier new-schema order
3124/// was `<id>-YYYY-MM-DD-<slug>`. All three are handled so the asset directory is
3125/// the same human slug regardless of which name a file carries. When neither
3126/// component is present the stem is used as-is.
3127fn slug_from_stem(stem: &str) -> String {
3128    let stem = strip_leading_id(stem);
3129    let stem = strip_date_prefix(stem);
3130    strip_leading_id(stem).to_string()
3131}
3132
3133/// Strip a leading 8-character issue id and its separator, if present.
3134fn strip_leading_id(stem: &str) -> &str {
3135    match stem.split_once('-') {
3136        Some((head, rest)) if crate::id::is_issue_id(head) => rest,
3137        _ => stem,
3138    }
3139}
3140
3141/// Strip a leading `YYYY-MM-DD-` date prefix, if present.
3142fn strip_date_prefix(stem: &str) -> &str {
3143    let bytes = stem.as_bytes();
3144    if stem.len() > 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b'-' {
3145        &stem[11..]
3146    } else {
3147        stem
3148    }
3149}
3150
3151/// The first free `<name>` / `<name>-<n>.<ext>` name for an asset in `dir`.
3152/// When `<name>` is taken the extension is kept and a deterministic
3153/// numeric suffix is appended (FR-006).
3154fn resolve_asset_name(dir: &Path, name: &str) -> String {
3155    if !dir.join(name).exists() {
3156        return name.to_string();
3157    }
3158    let (stem, ext) = match name.rsplit_once('.') {
3159        Some((s, e)) => (s, format!(".{e}")),
3160        None => (name, String::new()),
3161    };
3162    let mut i = 1u32;
3163    loop {
3164        let candidate = format!("{stem}-{i}{ext}");
3165        if !dir.join(&candidate).exists() {
3166            return candidate;
3167        }
3168        i += 1;
3169    }
3170}
3171
3172/// The first free `<name>` / `<name>-1` / `<name>-2` … directory name in
3173/// `dir` (mirrors `resolve_target_name` for the `<slug>-assets/` move).
3174fn resolve_asset_dir_name(dir: &Path, name: &str) -> String {
3175    let mut candidate = name.to_string();
3176    let mut i = 1u32;
3177    while dir.join(&candidate).exists() {
3178        candidate = format!("{name}-{i}");
3179        i += 1;
3180    }
3181    candidate
3182}
3183
3184/// Copy a file atomically into `dir` under `target` (temp + rename, NFR-004).
3185/// The temp file is cleaned up on failure and never left behind.
3186fn atomic_copy(dir: &Path, target: &Path, source: &Path) -> Result<()> {
3187    let pid = std::process::id();
3188    let nonce = nanos();
3189    let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
3190    let copy_result = (|| -> std::io::Result<()> {
3191        std::fs::copy(source, &tmp)?;
3192        let f = std::fs::File::open(&tmp)?;
3193        f.sync_all()?;
3194        Ok(())
3195    })();
3196    if let Err(e) = copy_result {
3197        let _ = std::fs::remove_file(&tmp);
3198        return Err(TixError::Io(e));
3199    }
3200    if let Err(e) = std::fs::rename(&tmp, target) {
3201        let _ = std::fs::remove_file(&tmp);
3202        return Err(TixError::Io(e));
3203    }
3204    Ok(())
3205}
3206
3207/// Whether an asset file name is an image by extension (the `![…]` link form).
3208fn is_image_name(name: &str) -> bool {
3209    matches!(
3210        name.rsplit('.')
3211            .next()
3212            .unwrap_or("")
3213            .to_ascii_lowercase()
3214            .as_str(),
3215        "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp"
3216    )
3217}
3218
3219/// `pub(crate)` because the mention store writes its own directory through the same primitive:
3220/// one atomic writer means one place that journals, fsyncs, and renames.
3221pub(crate) fn atomic_write(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
3222    write_file(dir, target, data, Link::Overwrite)
3223}
3224
3225/// Write `data` to `target`, refusing to replace an existing file.
3226///
3227/// [`atomic_write`] uses `rename`, which replaces the target unconditionally and is
3228/// therefore the wrong tool for *creating* an issue: a name collision would destroy
3229/// the file already there. This writes the payload to a temp file, `fsync`s it, and
3230/// then creates `target` with `hard_link`, which is atomic and fails with
3231/// `AlreadyExists` if the name is taken. The temp file is removed either way.
3232///
3233/// A hard link is the portable way to get `O_EXCL`-style semantics on the final
3234/// path (it works on unix and Windows and needs no new dependency). The temp file
3235/// lives in the same directory, so it is always on the same filesystem and the link
3236/// cannot fail for that reason. If the link is refused for any other reason — a
3237/// filesystem without hard links, say — it falls back to `rename`, which keeps
3238/// atomicity but is no longer no-clobber, and the caller's own probe is the
3239/// remaining protection.
3240///
3241/// The directory `fsync` that follows the link is what makes the *name* durable, so
3242/// it is not skippable on the fallback path either.
3243fn write_file_new(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
3244    write_file(dir, target, data, Link::Exclusive)
3245}
3246
3247#[derive(Clone, Copy)]
3248enum Link {
3249    Overwrite,
3250    Exclusive,
3251}
3252
3253fn write_file(dir: &Path, target: &Path, data: &[u8], link: Link) -> Result<()> {
3254    // Every write goes through here, so this is the one place a transaction needs
3255    // to remember the target's pre-image. A no-op outside a transaction.
3256    journal_record_write(target);
3257    let pid = std::process::id();
3258    let nonce = nanos();
3259    let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
3260    let write_result = (|| -> std::io::Result<()> {
3261        let mut f = std::fs::File::create(&tmp)?;
3262        use std::io::Write;
3263        f.write_all(data)?;
3264        f.sync_all()?;
3265        Ok(())
3266    })();
3267    if let Err(e) = write_result {
3268        let _ = std::fs::remove_file(&tmp);
3269        return Err(TixError::Io(e));
3270    }
3271    let link_result = match link {
3272        Link::Overwrite => std::fs::rename(&tmp, target),
3273        Link::Exclusive => match std::fs::hard_link(&tmp, target) {
3274            // The name was taken: the payload is already durable, so only the link
3275            // failed. `AlreadyExists` is the signal `write_new_issue_file` retries on,
3276            // so it is propagated rather than flattened into a generic I/O error.
3277            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
3278            // No hard links on this filesystem. Atomicity survives; the no-clobber
3279            // guarantee does not, and the caller's probe is what is left.
3280            Err(_) => std::fs::rename(&tmp, target),
3281            Ok(()) => {
3282                let _ = std::fs::remove_file(&tmp);
3283                Ok(())
3284            }
3285        },
3286    };
3287    if let Err(e) = link_result {
3288        let _ = std::fs::remove_file(&tmp);
3289        return Err(TixError::Io(e));
3290    }
3291    let _ = std::fs::File::open(dir).and_then(|f| f.sync_all());
3292    Ok(())
3293}
3294
3295fn nanos() -> u64 {
3296    SystemTime::now()
3297        .duration_since(UNIX_EPOCH)
3298        .map(|d| d.as_nanos() as u64)
3299        .unwrap_or(0)
3300}
3301
3302#[cfg(test)]
3303mod tests {
3304    use super::*;
3305    // Only the tests need this; the library path went through `render_diagnostics`.
3306    use crate::config::{EpicConfiguration, ProjectConfiguration};
3307    use crate::model::{Priority, today};
3308    use crate::relationships::DiagnosticKind;
3309    use std::fs;
3310    use tempfile::TempDir;
3311
3312    /// The no-clobber layer, tested directly.
3313    ///
3314    /// `write_new_issue_file`'s **probe** is the common path and is covered end to
3315    /// end by `two_creates_with_the_same_title_both_survive` in `tests/cli.rs`.
3316    /// The probe cannot see a name another process takes between the probe and the
3317    /// write, which is why the final create is exclusive. This is the guarantee that
3318    /// closes that window, so it is asserted on the primitive rather than through a
3319    /// race that would be flaky to provoke.
3320    ///
3321    /// If the exclusive path were replaced by a `rename` — the obvious "simplify
3322    /// this" edit — the planted file below would be silently destroyed, which is
3323    /// precisely the data loss `write_new_issue_file` exists to prevent.
3324    #[test]
3325    fn write_file_new_refuses_an_existing_target() {
3326        let dir = TempDir::new().unwrap();
3327        let target = dir.path().join("issue.md");
3328        write_file_new(dir.path(), &target, b"first").unwrap();
3329        assert_eq!(fs::read_to_string(&target).unwrap(), "first");
3330
3331        let err = write_file_new(dir.path(), &target, b"second").unwrap_err();
3332        let TixError::Io(io_err) = err else {
3333            panic!("expected an I/O error, so the caller can retry on a name collision")
3334        };
3335        assert_eq!(
3336            io_err.kind(),
3337            std::io::ErrorKind::AlreadyExists,
3338            "the error must be AlreadyExists, so the caller can retry on a name \\
3339             collision rather than treating it as a hard failure"
3340        );
3341        assert_eq!(
3342            fs::read_to_string(&target).unwrap(),
3343            "first",
3344            "the existing file must be byte-identical — not truncated, not replaced"
3345        );
3346    }
3347
3348    /// The retry loop turns an `AlreadyExists` into the next free name.
3349    #[test]
3350    fn a_clobbered_name_is_retried_rather_than_failing_or_overwriting() {
3351        let tmp = TempDir::new().unwrap();
3352        let dir = tmp.path();
3353        let store = Store::open(&crate::config::Config {
3354            issues_dir: dir.to_path_buf(),
3355            ..Default::default()
3356        })
3357        .unwrap();
3358
3359        // The probe looks at `<issues_dir>/<segment>/<stem>.md`, so the plants have
3360        // to sit in the segment directory — that is where the collision is.
3361        let segment_dir = dir.join("seg");
3362        fs::create_dir_all(&segment_dir).unwrap();
3363
3364        // Two plants on the exact names the probe would try first, so the retry has
3365        // to walk past both to reach the third.
3366        for name in ["2026-01-01-race.md", "2026-01-01-race-2.md"] {
3367            fs::write(segment_dir.join(name), "planted").unwrap();
3368        }
3369
3370        let (filename, path) = store
3371            .write_new_issue_file("seg", "2026-01-01-race", b"mine")
3372            .unwrap();
3373        assert_eq!(filename, "seg/2026-01-01-race-3.md");
3374        assert_eq!(fs::read_to_string(&path).unwrap(), "mine");
3375        assert_eq!(
3376            fs::read_to_string(segment_dir.join("2026-01-01-race.md")).unwrap(),
3377            "planted"
3378        );
3379        assert_eq!(
3380            fs::read_to_string(segment_dir.join("2026-01-01-race-2.md")).unwrap(),
3381            "planted"
3382        );
3383    }
3384
3385    /// No temp files are left behind on either the success or the collision path.
3386    ///
3387    /// A leftover `.tix-tmp-*` in `docs/issues/<segment>/` would be picked up by the
3388    /// next `tix list` as a malformed issue, and the exclusive path has an extra
3389    /// step (`hard_link` then `remove_file`) where a leak is easy to introduce.
3390    #[test]
3391    fn no_temp_files_are_left_behind_on_success_or_collision() {
3392        let dir = TempDir::new().unwrap();
3393        let target = dir.path().join("issue.md");
3394        write_file_new(dir.path(), &target, b"first").unwrap();
3395        let _ = write_file_new(dir.path(), &target, b"second");
3396        let leftovers: Vec<String> = fs::read_dir(dir.path())
3397            .unwrap()
3398            .filter_map(|e| e.ok())
3399            .map(|e| e.file_name().to_string_lossy().to_string())
3400            .filter(|n| n.starts_with(".tix-tmp-"))
3401            .collect();
3402        assert!(
3403            leftovers.is_empty(),
3404            "temp files must not survive, found {leftovers:?}"
3405        );
3406    }
3407
3408    fn make_store() -> (tempfile::TempDir, Store) {
3409        let tmp = tempfile::TempDir::new().unwrap();
3410        let dir = tmp.path().join("issues");
3411        fs::create_dir_all(&dir).unwrap();
3412        let config_path = tmp.path().join("tix.toml");
3413        let mut config = Config {
3414            issues_dir: dir.clone(),
3415            ..Config::default()
3416        };
3417        // Storage unit tests predate configurable layout and assert the legacy
3418        // one-segment paths; pin it and disable move-on-change so they stay about
3419        // grouping, not layout. Both the default template and auto-migration are
3420        // covered by dedicated tests.
3421        config.layout.directory = "{assignee}".to_string();
3422        config.layout.move_on_change = false;
3423        config.save(&config_path).unwrap();
3424        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
3425        let store = Store {
3426            issues_dir: dir,
3427            config,
3428        };
3429        (tmp, store)
3430    }
3431
3432    fn write_issue(store: &Store, name: &str, contents: &str) {
3433        fs::write(store.issues_dir.join(name), contents).unwrap();
3434    }
3435
3436    fn sample_frontmatter(number: u32, title: &str, status: &str) -> String {
3437        format!(
3438            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
3439        )
3440    }
3441
3442    // ------------------------------------------------------------------
3443    // Issue #1492 — archived issues (dot-prefixed filename, in place)
3444    // ------------------------------------------------------------------
3445
3446    /// The default walk must skip a dot-prefixed file **without opening it**, so
3447    /// an archived file with unparseable frontmatter cannot break `list()`. That
3448    /// an unparseable one is *only* reachable through `list_all()` is the cheapest
3449    /// honest proof the skip happens before the read.
3450    #[test]
3451    fn list_never_opens_an_archived_file_but_list_all_does() {
3452        let (tmp, store) = make_store();
3453        let _ = tmp;
3454        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3455        write_issue(
3456            &store,
3457            "alice/2026-01-01-active.md",
3458            &sample_frontmatter(1, "Active", "todo"),
3459        );
3460        fs::write(
3461            store.issues_dir.join("alice/.2026-01-02-broken.md"),
3462            "this file has no frontmatter at all\n",
3463        )
3464        .unwrap();
3465
3466        let active = store
3467            .list()
3468            .expect("the default walk must not read the archived file");
3469        assert_eq!(active.len(), 1);
3470        assert_eq!(active[0].number, 1);
3471        assert!(!active[0].archived);
3472
3473        let all = store.list_all();
3474        assert!(
3475            matches!(all, Err(TixError::Parse(_))),
3476            "list_all() reads it, so the broken file is only invisible to list(): {all:?}"
3477        );
3478    }
3479
3480    #[test]
3481    fn archive_renames_in_place_and_restore_is_the_inverse() {
3482        let (tmp, store) = make_store();
3483        let _ = tmp;
3484        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3485        write_issue(
3486            &store,
3487            "alice/2026-01-01-x.md",
3488            &sample_frontmatter(5, "X", "todo"),
3489        );
3490
3491        let archived = store.archive("5").unwrap();
3492        assert_eq!(archived.filename, "alice/.2026-01-01-x.md");
3493        assert!(archived.archived);
3494        assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
3495        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
3496        assert!(
3497            store.list().unwrap().is_empty(),
3498            "hidden from the default view"
3499        );
3500
3501        let all = store.list_all().unwrap();
3502        assert_eq!(all.len(), 1);
3503        assert!(all[0].archived);
3504        assert_eq!(all[0].number, 5, "the number is untouched");
3505
3506        // Both selectors still resolve the archived issue.
3507        assert_eq!(
3508            store.get(IssueSelector::Number(5)).unwrap().filename,
3509            "alice/.2026-01-01-x.md"
3510        );
3511        assert!(store.get(IssueSelector::Slug(slugify("X"))).is_ok());
3512
3513        let restored = store.restore("5").unwrap();
3514        assert_eq!(restored.filename, "alice/2026-01-01-x.md");
3515        assert!(!restored.archived);
3516        assert_eq!(store.list().unwrap().len(), 1, "visible again");
3517    }
3518
3519    #[test]
3520    fn archive_appends_one_event_and_leaves_the_frontmatter_untouched() {
3521        let (tmp, store) = make_store();
3522        let _ = tmp;
3523        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3524        write_issue(
3525            &store,
3526            "alice/2026-01-01-x.md",
3527            &sample_frontmatter(5, "X", "todo"),
3528        );
3529        let before = store
3530            .get(IssueSelector::Filename("alice/2026-01-01-x.md".into()))
3531            .unwrap();
3532
3533        let archived = store.archive("5").unwrap();
3534        assert_eq!(archived.history.len(), 1, "exactly one History event");
3535        assert_eq!(
3536            archived.history.events()[0].kind,
3537            HistoryEventKind::Archived
3538        );
3539
3540        let after = store.get(IssueSelector::Number(5)).unwrap();
3541        // Every field a reader cares about survives unchanged. `update` re-renders
3542        // the frontmatter (quoting and key order normalize, as on every tix write),
3543        // so the invariant is the *values*, not the bytes.
3544        assert_eq!(after.number, before.number);
3545        assert_eq!(after.title, before.title);
3546        assert_eq!(after.status, before.status);
3547        assert_eq!(after.labels, before.labels);
3548        assert_eq!(after.priority, before.priority);
3549        assert_eq!(after.assignee, before.assignee);
3550        assert_eq!(after.created, before.created);
3551        assert_eq!(
3552            after.updated, before.updated,
3553            "archiving must not bump `updated`: it is a frontmatter field"
3554        );
3555        // The marker is the filename; nothing was written into the frontmatter.
3556        let raw = read_issue_file(&store, "alice/.2026-01-01-x.md");
3557        let fm = raw.split("\n---").next().unwrap();
3558        assert!(
3559            !fm.contains("archived"),
3560            "the archived flag must never reach the frontmatter: {fm}"
3561        );
3562    }
3563
3564    #[test]
3565    fn restore_appends_one_restored_event() {
3566        let (tmp, store) = make_store();
3567        let _ = tmp;
3568        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3569        write_issue(
3570            &store,
3571            "alice/2026-01-01-x.md",
3572            &sample_frontmatter(5, "X", "todo"),
3573        );
3574        store.archive("5").unwrap();
3575        let restored = store.restore("5").unwrap();
3576        assert_eq!(restored.history.len(), 2, "archive + restore");
3577        assert_eq!(
3578            restored.history.events()[1].kind,
3579            HistoryEventKind::Restored
3580        );
3581    }
3582
3583    #[test]
3584    fn archiving_the_highest_number_does_not_free_it_for_reuse() {
3585        let (tmp, store) = make_store();
3586        let _ = tmp;
3587        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3588        write_issue(
3589            &store,
3590            "alice/2026-01-01-a.md",
3591            &sample_frontmatter(1, "A", "todo"),
3592        );
3593        write_issue(
3594            &store,
3595            "alice/2026-01-01-b.md",
3596            &sample_frontmatter(7, "B", "todo"),
3597        );
3598
3599        store.archive("7").unwrap();
3600
3601        let mut fresh = Issue::parse(&sample_frontmatter(0, "New", "todo")).unwrap();
3602        store.create(&mut fresh, "body", None).unwrap();
3603        assert_eq!(
3604            fresh.number, 8,
3605            "an archived issue keeps its number: `create` must not reuse #7"
3606        );
3607    }
3608
3609    #[test]
3610    fn redundant_archive_and_restore_are_refused_by_name() {
3611        let (tmp, store) = make_store();
3612        let _ = tmp;
3613        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3614        write_issue(
3615            &store,
3616            "alice/2026-01-01-x.md",
3617            &sample_frontmatter(5, "X", "todo"),
3618        );
3619
3620        let err = store.restore("5").unwrap_err();
3621        assert!(
3622            err.to_string().contains("not_archived"),
3623            "an active issue cannot be restored: {err}"
3624        );
3625        assert!(
3626            store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
3627            "the refused restore writes nothing"
3628        );
3629
3630        store.archive("5").unwrap();
3631        let err = store.archive("5").unwrap_err();
3632        assert!(
3633            err.to_string().contains("already_archived"),
3634            "an archived issue cannot be re-archived: {err}"
3635        );
3636        assert!(
3637            store.issues_dir.join("alice/.2026-01-01-x.md").is_file(),
3638            "the refused archive writes nothing"
3639        );
3640    }
3641
3642    #[test]
3643    fn archive_never_clobbers_an_occupied_target_name() {
3644        let (tmp, store) = make_store();
3645        let _ = tmp;
3646        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3647        write_issue(
3648            &store,
3649            "alice/2026-01-01-x.md",
3650            &sample_frontmatter(5, "X", "todo"),
3651        );
3652        // A pre-existing dot-prefixed file would be the target of the rename.
3653        write_issue(
3654            &store,
3655            "alice/.2026-01-01-x.md",
3656            &sample_frontmatter(6, "Other", "todo"),
3657        );
3658        let err = store.archive("5").unwrap_err();
3659        assert!(err.to_string().contains("already exists"), "got: {err}");
3660        assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
3661        assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
3662    }
3663
3664    #[test]
3665    fn archive_and_restore_a_legacy_flat_file_in_place() {
3666        let (tmp, store) = make_store();
3667        let _ = tmp;
3668        write_issue(
3669            &store,
3670            "2026-01-01-legacy.md",
3671            &sample_frontmatter(3, "Legacy", "todo"),
3672        );
3673
3674        let archived = store.archive("3").unwrap();
3675        assert_eq!(archived.filename, ".2026-01-01-legacy.md");
3676        assert!(store.issues_dir.join(".2026-01-01-legacy.md").is_file());
3677        assert_eq!(store.list().unwrap(), [] as [crate::model::Issue; 0]);
3678        assert_eq!(store.list_all().unwrap().len(), 1);
3679
3680        let restored = store.restore("3").unwrap();
3681        assert_eq!(restored.filename, "2026-01-01-legacy.md");
3682        assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
3683    }
3684
3685    #[test]
3686    fn list_returns_sorted_by_filename() {
3687        let (tmp, store) = make_store();
3688        let _ = tmp;
3689        write_issue(
3690            &store,
3691            "2026-03-01-c.md",
3692            &sample_frontmatter(3, "C", "todo"),
3693        );
3694        write_issue(
3695            &store,
3696            "2026-01-01-a.md",
3697            &sample_frontmatter(1, "A", "todo"),
3698        );
3699        write_issue(
3700            &store,
3701            "2026-02-01-b.md",
3702            &sample_frontmatter(2, "B", "todo"),
3703        );
3704        let issues = store.list().unwrap();
3705        assert_eq!(issues.len(), 3);
3706        assert_eq!(issues[0].number, 1);
3707        assert_eq!(issues[1].number, 2);
3708        assert_eq!(issues[2].number, 3);
3709        assert_eq!(issues[0].title, "A");
3710    }
3711
3712    // Issue #10 follow-up — the single-file reader behind `Corpus::apply`.
3713
3714    #[test]
3715    fn is_issue_rel_path_matches_what_scan_walks() {
3716        // `scan` indexes these.
3717        for path in [
3718            "a.md",
3719            "alice/b.md",
3720            ".hidden.md",
3721            "alice/.hidden.md",
3722            "deep/a/b.md",
3723        ] {
3724            assert!(
3725                is_issue_rel_path(Path::new(path)),
3726                "{path} is an issue file `scan` reads"
3727            );
3728        }
3729        // ...and skips these.
3730        for path in [
3731            "notes.txt",
3732            "alice/notes.txt",
3733            "alice",
3734            ".tix-tmp-x/a.md",
3735            "foo-assets/a.md",
3736            "alice/foo-assets/a.md",
3737        ] {
3738            assert!(
3739                !is_issue_rel_path(Path::new(path)),
3740                "{path} is not a path `scan` indexes"
3741            );
3742        }
3743    }
3744
3745    #[test]
3746    fn scan_above_the_parallel_threshold_is_sorted_and_complete() {
3747        // The parallel branch runs only above `PARALLEL_SCAN_THRESHOLD`, so a
3748        // small fixture never reaches it. This pins that the fan-out keeps every
3749        // file, in filename order, with the same numbering a serial walk gives.
3750        let (tmp, store) = make_store();
3751        let _ = tmp;
3752        let n = Store::PARALLEL_SCAN_THRESHOLD + 44;
3753        for i in (1..=n).rev() {
3754            write_issue(
3755                &store,
3756                &format!("2026-01-01-{i:04}.md"),
3757                &sample_frontmatter(i as u32, &format!("T{i}"), "todo"),
3758            );
3759        }
3760        let issues = store.list().unwrap();
3761        assert_eq!(
3762            issues.len(),
3763            n,
3764            "every file is read, none dropped or doubled"
3765        );
3766        let names: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
3767        let mut sorted = names.clone();
3768        sorted.sort_unstable();
3769        assert_eq!(names, sorted, "sorted by filename as `scan` promises");
3770        assert_eq!(issues[0].number, 1, "content came from the first file");
3771        assert_eq!(issues[n - 1].number, n as u32, "and from the last");
3772    }
3773
3774    #[test]
3775    fn read_issue_at_sets_filename_and_archived_like_scan() {
3776        let (tmp, store) = make_store();
3777        let _ = tmp;
3778        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3779        write_issue(
3780            &store,
3781            "alice/2026-01-01-a.md",
3782            &sample_frontmatter(1, "A", "todo"),
3783        );
3784        write_issue(
3785            &store,
3786            "alice/.2026-01-02-b.md",
3787            &sample_frontmatter(2, "B", "todo"),
3788        );
3789
3790        let active = store
3791            .read_issue_at(Path::new("alice/2026-01-01-a.md"))
3792            .unwrap();
3793        assert_eq!(active.number, 1);
3794        assert_eq!(active.filename, "alice/2026-01-01-a.md");
3795        assert!(!active.archived);
3796
3797        let archived = store
3798            .read_issue_at(Path::new("alice/.2026-01-02-b.md"))
3799            .unwrap();
3800        assert_eq!(archived.filename, "alice/.2026-01-02-b.md");
3801        assert!(
3802            archived.archived,
3803            "the leading dot marks it archived, as `scan` does"
3804        );
3805
3806        // A flat file keeps its bare basename, and a missing one is `NotFound`.
3807        write_issue(&store, "flat.md", &sample_frontmatter(3, "F", "todo"));
3808        assert_eq!(
3809            store.read_issue_at(Path::new("flat.md")).unwrap().filename,
3810            "flat.md"
3811        );
3812        assert!(matches!(
3813            store.read_issue_at(Path::new("alice/gone.md")),
3814            Err(TixError::NotFound(_))
3815        ));
3816    }
3817
3818    #[test]
3819    fn list_ignores_non_md_files() {
3820        let (tmp, store) = make_store();
3821        let _ = tmp;
3822        write_issue(
3823            &store,
3824            "2026-01-01-a.md",
3825            &sample_frontmatter(1, "A", "todo"),
3826        );
3827        write_issue(&store, "readme.txt", "not an issue");
3828        write_issue(&store, "2026-01-01-sub", "no extension");
3829        let issues = store.list().unwrap();
3830        assert_eq!(issues.len(), 1);
3831    }
3832
3833    #[test]
3834    fn list_errors_on_malformed_frontmatter() {
3835        let (tmp, store) = make_store();
3836        let _ = tmp;
3837        write_issue(&store, "2026-01-01-bad.md", "no frontmatter here\n");
3838        let err = store.list().unwrap_err();
3839        assert!(matches!(err, TixError::Parse(_)), "got {err:?}");
3840    }
3841
3842    #[test]
3843    fn get_by_number() {
3844        let (tmp, store) = make_store();
3845        let _ = tmp;
3846        write_issue(
3847            &store,
3848            "2026-01-01-x.md",
3849            &sample_frontmatter(5, "X", "todo"),
3850        );
3851        let issue = store.get(IssueSelector::Number(5)).unwrap();
3852        assert_eq!(issue.title, "X");
3853    }
3854
3855    #[test]
3856    fn get_by_filename() {
3857        let (tmp, store) = make_store();
3858        let _ = tmp;
3859        write_issue(
3860            &store,
3861            "2026-01-01-y.md",
3862            &sample_frontmatter(2, "Y", "done"),
3863        );
3864        let issue = store
3865            .get(IssueSelector::Filename("2026-01-01-y.md".into()))
3866            .unwrap();
3867        assert_eq!(issue.status.display(), "done");
3868    }
3869
3870    #[test]
3871    fn get_by_slug() {
3872        let (tmp, store) = make_store();
3873        let _ = tmp;
3874        write_issue(
3875            &store,
3876            "2026-01-01-z.md",
3877            &sample_frontmatter(3, "Z", "todo"),
3878        );
3879        let issue = store.get(IssueSelector::Slug("z".into())).unwrap();
3880        assert_eq!(issue.number, 3);
3881    }
3882
3883    #[test]
3884    fn get_not_found() {
3885        let (tmp, store) = make_store();
3886        let _ = tmp;
3887        assert!(store.get(IssueSelector::Number(99)).is_err());
3888    }
3889
3890    #[test]
3891    fn create_assigns_number_from_live_issues_and_leaves_config_untouched() {
3892        let (tmp, store) = make_store();
3893        let _ = tmp;
3894        assert_eq!(store.config.state.next_number, 0);
3895        let mut issue = Issue {
3896            projects: None,
3897            id: None,
3898            number: 0,
3899            title: "First".into(),
3900            status: Status::default(),
3901            labels: vec![],
3902            priority: Priority::default(),
3903            assignee: None,
3904            estimate: None,
3905            commit: None,
3906            gitlab_id: None,
3907            linear_id: None,
3908            mission: None,
3909            task: None,
3910            relationships: vec![],
3911            related_issues: vec![],
3912            files: vec![],
3913            cycles: None,
3914            milestones: None,
3915            reviewer: None,
3916            start_date: None,
3917            due_date: None,
3918            created: "2026-01-01".into(),
3919            type_: None,
3920            updated: "2026-01-01".into(),
3921            filename: String::new(),
3922            body: String::new(),
3923            history: crate::model::History::new(),
3924            has_retired_links_key: false,
3925            time_entries: vec![],
3926            parent: None,
3927            series: None,
3928            health: None,
3929            epics: None,
3930            extra_frontmatter: std::collections::BTreeMap::new(),
3931            archived: false,
3932            entered_at: None,
3933            source: None,
3934        };
3935        let path = store.create(&mut issue, "Hello world", None).unwrap();
3936        assert!(path.exists());
3937        assert_eq!(
3938            issue.number, 1,
3939            "an empty store starts at 1, not 0 — #0 is indistinguishable from a \
3940             sentinel or a failed read (tix #1376)"
3941        );
3942        // Issue #52: create never rewrites tix.toml with a counter.
3943        let config_path = tmp.path().join("tix.toml");
3944        let on_disk = std::fs::read_to_string(&config_path).unwrap();
3945        assert!(
3946            !on_disk.contains("next_number"),
3947            "create must not persist an issue-number counter:\n{on_disk}"
3948        );
3949        let reloaded = Config::load_with_warning(&config_path, |_| {}).unwrap();
3950        assert_eq!(reloaded.state.next_number, 0, "state is never written");
3951    }
3952
3953    #[test]
3954    fn create_resolves_max_live_number_plus_one() {
3955        let (tmp, store) = make_store();
3956        let _ = tmp;
3957        write_issue(
3958            &store,
3959            "2026-01-01-a.md",
3960            &sample_frontmatter(0, "A", "todo"),
3961        );
3962        write_issue(
3963            &store,
3964            "2026-01-01-b.md",
3965            &sample_frontmatter(1, "B", "todo"),
3966        );
3967        write_issue(
3968            &store,
3969            "2026-01-01-c.md",
3970            &sample_frontmatter(2, "C", "todo"),
3971        );
3972        let mut issue = Issue {
3973            projects: None,
3974            id: None,
3975            number: 0,
3976            title: "D".into(),
3977            status: Status::default(),
3978            labels: vec![],
3979            priority: Priority::default(),
3980            assignee: None,
3981            estimate: None,
3982            commit: None,
3983            gitlab_id: None,
3984            linear_id: None,
3985            mission: None,
3986            task: None,
3987            relationships: vec![],
3988            related_issues: vec![],
3989            files: vec![],
3990            cycles: None,
3991            milestones: None,
3992            reviewer: None,
3993            start_date: None,
3994            due_date: None,
3995            created: "2026-01-01".into(),
3996            type_: None,
3997            updated: "2026-01-01".into(),
3998            filename: String::new(),
3999            body: String::new(),
4000            history: crate::model::History::new(),
4001            has_retired_links_key: false,
4002            time_entries: vec![],
4003            parent: None,
4004            series: None,
4005            health: None,
4006            epics: None,
4007            extra_frontmatter: std::collections::BTreeMap::new(),
4008            archived: false,
4009            entered_at: None,
4010            source: None,
4011        };
4012        store.create(&mut issue, "", None).unwrap();
4013        assert_eq!(issue.number, 3);
4014    }
4015
4016    #[test]
4017    fn create_reuses_number_of_deleted_highest_issue() {
4018        // Issue #52 tradeoff: without a persisted counter the next number is
4019        // re-derived from the live set, so deleting the highest-numbered
4020        // issue lets its number be reused by the next create.
4021        let (_tmp, store) = make_store();
4022        let mut issue_a = Issue {
4023            projects: None,
4024            id: None,
4025            number: 0,
4026            title: "A".into(),
4027            status: Status::default(),
4028            labels: vec![],
4029            priority: Priority::default(),
4030            assignee: None,
4031            estimate: None,
4032            commit: None,
4033            gitlab_id: None,
4034            linear_id: None,
4035            mission: None,
4036            task: None,
4037            relationships: vec![],
4038            related_issues: vec![],
4039            files: vec![],
4040            cycles: None,
4041            milestones: None,
4042            reviewer: None,
4043            start_date: None,
4044            due_date: None,
4045            created: "2026-01-01".into(),
4046            type_: None,
4047            updated: "2026-01-01".into(),
4048            filename: String::new(),
4049            body: String::new(),
4050            history: crate::model::History::new(),
4051            has_retired_links_key: false,
4052            time_entries: vec![],
4053            parent: None,
4054            series: None,
4055            health: None,
4056            epics: None,
4057            extra_frontmatter: std::collections::BTreeMap::new(),
4058            archived: false,
4059            entered_at: None,
4060            source: None,
4061        };
4062        store.create(&mut issue_a, "", None).unwrap();
4063        let filename = issue_a.filename.clone();
4064        assert_eq!(issue_a.number, 1, "the first issue in a fresh store is #1");
4065        store.delete(&filename).unwrap();
4066        let mut issue_b = Issue {
4067            projects: None,
4068            id: None,
4069            number: 0,
4070            title: "B".into(),
4071            status: Status::default(),
4072            labels: vec![],
4073            priority: Priority::default(),
4074            assignee: None,
4075            estimate: None,
4076            commit: None,
4077            gitlab_id: None,
4078            linear_id: None,
4079            mission: None,
4080            task: None,
4081            relationships: vec![],
4082            related_issues: vec![],
4083            files: vec![],
4084            cycles: None,
4085            milestones: None,
4086            reviewer: None,
4087            start_date: None,
4088            due_date: None,
4089            created: "2026-01-02".into(),
4090            type_: None,
4091            updated: "2026-01-02".into(),
4092            filename: String::new(),
4093            body: String::new(),
4094            history: crate::model::History::new(),
4095            has_retired_links_key: false,
4096            time_entries: vec![],
4097            parent: None,
4098            series: None,
4099            health: None,
4100            epics: None,
4101            extra_frontmatter: std::collections::BTreeMap::new(),
4102            archived: false,
4103            entered_at: None,
4104            source: None,
4105        };
4106        store.create(&mut issue_b, "", None).unwrap();
4107        assert_eq!(
4108            issue_b.number, 1,
4109            "an emptied store resumes at the same floor as a fresh one: 1"
4110        );
4111    }
4112
4113    #[test]
4114    fn create_with_custom_filename_stem() {
4115        let (tmp, store) = make_store();
4116        let _ = tmp;
4117        let mut issue = Issue {
4118            projects: None,
4119            id: None,
4120            number: 0,
4121            title: "T".into(),
4122            status: Status::default(),
4123            labels: vec![],
4124            priority: Priority::default(),
4125            assignee: None,
4126            estimate: None,
4127            commit: None,
4128            gitlab_id: None,
4129            linear_id: None,
4130            mission: None,
4131            task: None,
4132            relationships: vec![],
4133            related_issues: vec![],
4134            files: vec![],
4135            cycles: None,
4136            milestones: None,
4137            reviewer: None,
4138            start_date: None,
4139            due_date: None,
4140            created: "2026-01-01".into(),
4141            type_: None,
4142            updated: "2026-01-01".into(),
4143            filename: String::new(),
4144            body: String::new(),
4145            history: crate::model::History::new(),
4146            has_retired_links_key: false,
4147            time_entries: vec![],
4148            parent: None,
4149            series: None,
4150            health: None,
4151            epics: None,
4152            extra_frontmatter: std::collections::BTreeMap::new(),
4153            archived: false,
4154            entered_at: None,
4155            source: None,
4156        };
4157        let path = store
4158            .create(&mut issue, "body", Some("custom-stem"))
4159            .unwrap();
4160        assert!(path.to_string_lossy().contains("custom-stem.md"));
4161    }
4162
4163    #[test]
4164    fn create_ensures_issues_dir_exists() {
4165        let tmp = tempfile::TempDir::new().unwrap();
4166        let dir = tmp.path().join("nested").join("issues");
4167        let config_path = tmp.path().join("tix.toml");
4168        let config = Config {
4169            issues_dir: dir.clone(),
4170            ..Config::default()
4171        };
4172        config.save(&config_path).unwrap();
4173        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
4174        let store = Store {
4175            issues_dir: dir,
4176            config,
4177        };
4178        let mut issue = Issue {
4179            projects: None,
4180            id: None,
4181            number: 0,
4182            title: "T".into(),
4183            status: Status::default(),
4184            labels: vec![],
4185            priority: Priority::default(),
4186            assignee: None,
4187            estimate: None,
4188            commit: None,
4189            gitlab_id: None,
4190            linear_id: None,
4191            mission: None,
4192            task: None,
4193            relationships: vec![],
4194            related_issues: vec![],
4195            files: vec![],
4196            cycles: None,
4197            milestones: None,
4198            reviewer: None,
4199            start_date: None,
4200            due_date: None,
4201            created: "2026-01-01".into(),
4202            type_: None,
4203            updated: "2026-01-01".into(),
4204            filename: String::new(),
4205            body: String::new(),
4206            history: crate::model::History::new(),
4207            has_retired_links_key: false,
4208            time_entries: vec![],
4209            parent: None,
4210            series: None,
4211            health: None,
4212            epics: None,
4213            extra_frontmatter: std::collections::BTreeMap::new(),
4214            archived: false,
4215            entered_at: None,
4216            source: None,
4217        };
4218        store.create(&mut issue, "", None).unwrap();
4219        assert_eq!(issue.number, 1, "the first issue in a fresh store is #1");
4220    }
4221
4222    #[test]
4223    fn update_replaces_content_atomically() {
4224        let (tmp, store) = make_store();
4225        let _ = tmp;
4226        write_issue(
4227            &store,
4228            "2026-01-01-u.md",
4229            &sample_frontmatter(1, "U", "todo"),
4230        );
4231        let mut issue = store
4232            .get(IssueSelector::Filename("2026-01-01-u.md".into()))
4233            .unwrap();
4234        issue.title = "Updated".into();
4235        issue.bump_updated();
4236        store.update("2026-01-01-u.md", &issue).unwrap();
4237        let reloaded = store
4238            .get(IssueSelector::Filename("2026-01-01-u.md".into()))
4239            .unwrap();
4240        assert_eq!(reloaded.title, "Updated");
4241    }
4242
4243    #[test]
4244    fn update_not_found() {
4245        let (tmp, store) = make_store();
4246        let _ = tmp;
4247        let issue = Issue {
4248            projects: None,
4249            id: None,
4250            number: 1,
4251            title: "T".into(),
4252            status: Status::default(),
4253            labels: vec![],
4254            priority: Priority::default(),
4255            assignee: None,
4256            estimate: None,
4257            commit: None,
4258            gitlab_id: None,
4259            linear_id: None,
4260            mission: None,
4261            task: None,
4262            relationships: vec![],
4263            related_issues: vec![],
4264            files: vec![],
4265            cycles: None,
4266            milestones: None,
4267            reviewer: None,
4268            start_date: None,
4269            due_date: None,
4270            created: "2026-01-01".into(),
4271            type_: None,
4272            updated: "2026-01-01".into(),
4273            filename: String::new(),
4274            body: String::new(),
4275            history: crate::model::History::new(),
4276            has_retired_links_key: false,
4277            time_entries: vec![],
4278            parent: None,
4279            series: None,
4280            health: None,
4281            epics: None,
4282            extra_frontmatter: std::collections::BTreeMap::new(),
4283            archived: false,
4284            entered_at: None,
4285            source: None,
4286        };
4287        assert!(store.update("nope.md", &issue).is_err());
4288    }
4289
4290    #[test]
4291    fn delete_removes_file() {
4292        let (tmp, store) = make_store();
4293        let _ = tmp;
4294        write_issue(
4295            &store,
4296            "2026-01-01-d.md",
4297            &sample_frontmatter(1, "D", "todo"),
4298        );
4299        assert!(store.issues_dir.join("2026-01-01-d.md").exists());
4300        store.delete("2026-01-01-d.md").unwrap();
4301        assert!(!store.issues_dir.join("2026-01-01-d.md").exists());
4302    }
4303
4304    #[test]
4305    fn delete_not_found() {
4306        let (tmp, store) = make_store();
4307        let _ = tmp;
4308        assert!(store.delete("nope.md").is_err());
4309    }
4310
4311    #[test]
4312    fn stray_tmp_file_not_treated_as_issue() {
4313        let (tmp, store) = make_store();
4314        let _ = tmp;
4315        write_issue(
4316            &store,
4317            "2026-01-01-a.md",
4318            &sample_frontmatter(1, "A", "todo"),
4319        );
4320        fs::write(store.issues_dir.join(".tix-tmp-9999-0"), "orphaned data").unwrap();
4321        let issues = store.list().unwrap();
4322        assert_eq!(issues.len(), 1);
4323    }
4324
4325    // ------------------------------------------------------------------
4326    // WP03 T015 — the storage seam's contract, as executable evidence
4327    // ------------------------------------------------------------------
4328    //
4329    // Every case asserts the **file bytes**, not just the returned `Result`. A
4330    // refusal that returns an error after having already re-serialised the issue
4331    // would pass an `is_err()` assertion and still have destroyed the audit trail,
4332    // so "did it refuse?" and "did it touch anything?" are separate questions here
4333    // and both are asked.
4334
4335    /// A store whose board **declares a `category_map`**, so category policy —
4336    /// the terminal lock and adjacency — applies.
4337    ///
4338    /// The mapping is `todo=unstarted, doing=active, done=completed`, which is
4339    /// forward-moving in the declared vocabulary order. `triage` is added as a
4340    /// `backlog` column so there is a genuine backwards move to be refused.
4341    fn seam_store() -> (tempfile::TempDir, Store) {
4342        let tmp = tempfile::TempDir::new().unwrap();
4343        let dir = tmp.path().join("issues");
4344        fs::create_dir_all(&dir).unwrap();
4345        let config_path = tmp.path().join("tix.toml");
4346        let mut config = Config {
4347            issues_dir: dir.clone(),
4348            kanban: crate::config::KanbanSection {
4349                configurations: indexmap::IndexMap::from([(
4350                    "default".to_string(),
4351                    crate::config::BoardConfiguration {
4352                        columns: vec![
4353                            "triage".into(),
4354                            "todo".into(),
4355                            "doing".into(),
4356                            "done".into(),
4357                        ],
4358                        done_column: Some("done".to_string()),
4359                        category_map: indexmap::IndexMap::from([
4360                            ("triage".to_string(), "backlog".to_string()),
4361                            ("todo".to_string(), "unstarted".to_string()),
4362                            ("doing".to_string(), "active".to_string()),
4363                            ("done".to_string(), "completed".to_string()),
4364                        ]),
4365                        ..Default::default()
4366                    },
4367                )]),
4368            },
4369            ..Config::default()
4370        };
4371        config.layout.move_on_change = false;
4372        config.save(&config_path).unwrap();
4373        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
4374        (
4375            tmp,
4376            Store {
4377                issues_dir: dir,
4378                config,
4379            },
4380        )
4381    }
4382
4383    /// A seam store holding one issue at `status`, and that file's bytes.
4384    ///
4385    /// The fixture carries every key the model always writes, `files` included.
4386    /// That is not tidiness: `t015_no_new_frontmatter_key_was_introduced` compares
4387    /// the key set before and after, and a fixture that omits `files` makes the
4388    /// comparison report the model's own always-written key as a change — so the
4389    /// test would have been measuring the fixture instead of the seam.
4390    fn seam_issue(status: &str) -> (tempfile::TempDir, Store, String) {
4391        let (tmp, store) = seam_store();
4392        write_issue(
4393            &store,
4394            "2026-01-01-seam.md",
4395            &format!(
4396                "---\nnumber: 1\ntitle: \"Seam\"\nstatus: {status}\nlabels: []\n\
4397                 priority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody."
4398            ),
4399        );
4400        let before = read_issue_file(&store, "2026-01-01-seam.md");
4401        (tmp, store, before)
4402    }
4403
4404    /// Assert a refusal left the file byte-identical.
4405    fn assert_untouched(store: &Store, before: &str, what: &str) {
4406        let after = read_issue_file(store, "2026-01-01-seam.md");
4407        assert_eq!(after, before, "{what}: the file must be byte-identical");
4408    }
4409
4410    #[test]
4411    fn t015_accepted_transition_writes_status_bumps_updated_and_appends_one_decision() {
4412        let (_tmp, store, _before) = seam_issue("todo");
4413        let outcome = store
4414            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4415            .unwrap();
4416        assert!(outcome.written, "an accepted, changing move writes");
4417        let decision = outcome
4418            .decision
4419            .clone()
4420            .expect("an accepted transition appends exactly one decision");
4421        assert_eq!(decision.from, "todo");
4422        assert_eq!(decision.to, "doing");
4423        assert_eq!(decision.from_category, "unstarted");
4424        assert_eq!(decision.to_category, "active");
4425        assert!(
4426            decision.override_reason.is_none(),
4427            "no override was supplied"
4428        );
4429        assert_eq!(
4430            outcome.issue.status.display(),
4431            "doing",
4432            "Issue.status holds the COLUMN name, never a category (C-001)"
4433        );
4434        assert_eq!(outcome.issue.updated, today(), "`updated` is bumped");
4435
4436        // Re-read from disk: the in-memory record is not evidence of what landed.
4437        let reloaded = store
4438            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4439            .unwrap();
4440        assert_eq!(reloaded.status.display(), "doing");
4441        let events = reloaded.history.events();
4442        assert_eq!(
4443            events.len(),
4444            1,
4445            "exactly one History entry, and it is the decision: {events:?}"
4446        );
4447        assert_eq!(events[0].kind, HistoryEventKind::TransitionAccepted);
4448        assert_eq!(
4449            crate::model::TransitionDecision::from_event(events[0])
4450                .unwrap()
4451                .to,
4452            "doing",
4453            "and the decision round-trips out of the file with its fields intact"
4454        );
4455    }
4456
4457    #[test]
4458    fn t015_unknown_column_is_refused_by_name_and_writes_nothing() {
4459        let (_tmp, store, before) = seam_issue("todo");
4460        let err = store
4461            .set_status("2026-01-01-seam.md", StatusChange::to("nope"))
4462            .unwrap_err();
4463        assert!(
4464            matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "unknown_column"),
4465            "the shared named diagnostic, contract §6: {err:?}"
4466        );
4467        assert!(
4468            err.to_string()
4469                .contains("valid columns: triage, todo, doing, done"),
4470            "in BOARD order, not hash order: {err}"
4471        );
4472        assert_untouched(&store, &before, "unknown column");
4473    }
4474
4475    #[test]
4476    fn t015_terminal_exit_is_refused_and_writes_nothing() {
4477        let (_tmp, store, before) = seam_issue("done");
4478        let err = store
4479            .set_status("2026-01-01-seam.md", StatusChange::to("todo"))
4480            .unwrap_err();
4481        assert!(
4482            matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
4483            "{err:?}"
4484        );
4485        assert_untouched(&store, &before, "terminal exit without a reason");
4486    }
4487
4488    #[test]
4489    fn t015_adjacency_restricted_backwards_move_is_refused_and_writes_nothing() {
4490        // `doing` is `active`; `triage` is `backlog`. Forward in the declared
4491        // vocabulary order, so no terminal lock applies — this is adjacency alone,
4492        // and it must name the same `invalid_transition` kind.
4493        let (_tmp, store, before) = seam_issue("doing");
4494        let err = store
4495            .set_status("2026-01-01-seam.md", StatusChange::to("triage"))
4496            .unwrap_err();
4497        assert!(
4498            matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
4499            "{err:?}"
4500        );
4501        assert!(
4502            err.to_string().contains("category policy"),
4503            "the message names the rule that refused it: {err}"
4504        );
4505        assert_untouched(&store, &before, "adjacency refusal");
4506    }
4507
4508    #[test]
4509    fn t015_terminal_exit_with_a_reason_is_accepted_and_the_reason_is_recorded() {
4510        let (_tmp, store, before) = seam_issue("done");
4511        let outcome = store
4512            .set_status(
4513                "2026-01-01-seam.md",
4514                StatusChange::to("todo").with_override(Some("reopening: wrong epic")),
4515            )
4516            .unwrap();
4517        assert!(outcome.written);
4518        let decision = outcome.decision.expect("one decision");
4519        assert_eq!(
4520            decision.override_reason.as_deref(),
4521            Some("reopening: wrong epic"),
4522            "an override with no recorded reason is not an audit record"
4523        );
4524        assert_ne!(
4525            read_issue_file(&store, "2026-01-01-seam.md"),
4526            before,
4527            "sanity: an accepted move must have changed the file at all"
4528        );
4529    }
4530
4531    #[test]
4532    fn t015_a_blank_reason_is_not_an_override() {
4533        // The service trims and drops a blank reason, so a whitespace-only string
4534        // must not unlock a terminal exit. Asserted here as well as in the service
4535        // because "the seam passes the caller's string through untouched" would
4536        // satisfy a service-level test and still be wrong at the boundary.
4537        let (_tmp, store, before) = seam_issue("done");
4538        let err = store
4539            .set_status(
4540                "2026-01-01-seam.md",
4541                StatusChange::to("todo").with_override(Some("   ")),
4542            )
4543            .unwrap_err();
4544        assert!(
4545            matches!(&err, TixError::Workflow(_)),
4546            "a blank reason is not a reason: {err:?}"
4547        );
4548        assert_untouched(&store, &before, "blank override reason");
4549    }
4550
4551    #[test]
4552    fn t015_same_column_is_accepted_and_writes_nothing() {
4553        let (_tmp, store, before) = seam_issue("doing");
4554        let outcome = store
4555            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4556            .unwrap();
4557        assert!(!outcome.written, "a same-column re-apply is a no-op");
4558        assert!(
4559            outcome.decision.is_none(),
4560            "and appends no decision, contract §3.2"
4561        );
4562        assert_untouched(&store, &before, "same column");
4563        // `updated` is not bumped either: a no-op that touched the date would make
4564        // `tix edit --status <current column>` look like an edit.
4565        let reloaded = store
4566            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4567            .unwrap();
4568        assert_eq!(
4569            reloaded.updated, "2026-01-01",
4570            "the fixture's original date"
4571        );
4572    }
4573
4574    #[test]
4575    fn t015_no_new_frontmatter_key_was_introduced() {
4576        // C-001. The mission must be byte-compatible on the file surface, so this
4577        // compares the key set of a file the seam wrote against the key set of the
4578        // fixture it read. A category key on the issue would break here.
4579        let (_tmp, store, before) = seam_issue("todo");
4580        store
4581            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4582            .unwrap();
4583        let after = read_issue_file(&store, "2026-01-01-seam.md");
4584        let keys = |text: &str| -> Vec<String> {
4585            text.split("---\n")
4586                .nth(1)
4587                .unwrap()
4588                .lines()
4589                .filter_map(|l| l.split_once(':').map(|(k, _)| k.trim().to_string()))
4590                .filter(|k| !k.is_empty())
4591                .collect()
4592        };
4593        assert_eq!(
4594            keys(&after),
4595            keys(&before),
4596            "the seam must not add or remove a top-level frontmatter key"
4597        );
4598    }
4599
4600    #[test]
4601    fn t015_a_hand_edited_inconsistent_file_is_read_and_not_repaired() {
4602        // C-005. A file whose `status` was hand-edited to a column the policy would
4603        // never have allowed is *readable* and this path must leave it alone. The
4604        // diagnosis is WP06's job; storage correcting it would be the escalation
4605        // the decision `01M3C3XBMTR5WFV7059NXM04AJ` names.
4606        let (_tmp, store, before) = seam_issue("doing");
4607        // Simulate the hand edit: `doing` (active) becomes `triage` (backlog),
4608        // a move adjacency refuses.
4609        let edited = before.replace("status: doing", "status: triage");
4610        write_issue(&store, "2026-01-01-seam.md", &edited);
4611        let after_edit = read_issue_file(&store, "2026-01-01-seam.md");
4612
4613        let issue = store
4614            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4615            .unwrap();
4616        assert_eq!(
4617            issue.status.display(),
4618            "triage",
4619            "a hand-edited status is read, not rejected at load"
4620        );
4621        assert_eq!(
4622            read_issue_file(&store, "2026-01-01-seam.md"),
4623            after_edit,
4624            "reading must not repair it"
4625        );
4626        // And a legal move away from the hand-edited column proceeds, without the
4627        // seam trying to reconcile the history of how the file got there.
4628        store
4629            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4630            .unwrap();
4631        let moved = store
4632            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4633            .unwrap();
4634        assert_eq!(moved.status.display(), "doing");
4635        assert_eq!(
4636            moved.history.events().len(),
4637            1,
4638            "only the new decision; nothing was written to explain the old one"
4639        );
4640    }
4641
4642    #[test]
4643    fn t015_every_rejection_kind_is_one_of_the_contract_vocabulary() {
4644        // The contract fixes seven names (contract §6). A refusal that invents an
4645        // eighth is a surface that reports a condition under a name no other
4646        // surface knows, which is the exact failure the vocabulary exists to stop.
4647        let allowed = [
4648            "missing_completion_category",
4649            "unknown_category",
4650            "mapping_for_unknown_column",
4651            "unmapped_column_default",
4652            "invalid_transition",
4653            "unknown_column",
4654            "direct_edit_policy_violation",
4655        ];
4656        let cases: Vec<(&str, &str)> = vec![
4657            ("todo", "nope"),    // unknown_column
4658            ("done", "todo"),    // invalid_transition (terminal lock)
4659            ("doing", "triage"), // invalid_transition (adjacency)
4660        ];
4661        for (from, to) in cases {
4662            let (_tmp, store, _before) = seam_issue(from);
4663            let err = store
4664                .set_status("2026-01-01-seam.md", StatusChange::to(to))
4665                .unwrap_err();
4666            let TixError::Workflow(d) = &err else {
4667                panic!("{from} -> {to} must refuse through the workflow variant: {err:?}");
4668            };
4669            assert!(
4670                allowed.contains(&d.kind.as_str()),
4671                "`{}` is not in the contract's vocabulary",
4672                d.kind.as_str()
4673            );
4674        }
4675    }
4676
4677    #[test]
4678    fn t015_a_legacy_board_gains_no_policy_but_keeps_completion() {
4679        // The opt-in this WP decided: a board with no `category_map` gets the
4680        // completion definition and the commit-clearing rule, and no transition
4681        // restriction. Both halves are asserted, because a change that dropped the
4682        // completion rule along with the policy would otherwise look correct.
4683        let (tmp, store) = make_store();
4684        let _ = tmp;
4685        write_issue(
4686            &store,
4687            "2026-01-01-legacy.md",
4688            &sample_frontmatter(1, "Legacy", "done"),
4689        );
4690        let moved = store
4691            .set_status("2026-01-01-legacy.md", StatusChange::to("todo"))
4692            .unwrap();
4693        assert!(moved.written, "a legacy board imposes no policy");
4694        assert_eq!(moved.issue.status.display(), "todo");
4695
4696        // And completion still resolves through the reconciliation, not the string
4697        // "done": this issue is no longer complete, and an untouched `done` one is.
4698        let resolution =
4699            crate::workflow::resolution(&store.config, store.config.default_config(), "default");
4700        assert!(
4701            !resolution.explicit_policy,
4702            "precondition: this board declared no category_map"
4703        );
4704        assert!(
4705            resolution.has_completion(),
4706            "but it still has a done column"
4707        );
4708        assert!(
4709            !crate::workflow::is_completed(&moved.issue, &resolution),
4710            "and `todo` is not complete"
4711        );
4712    }
4713
4714    #[test]
4715    fn t015_a_mapped_board_keeps_the_commit_coupling() {
4716        // The commit rule moved from `mv.rs` into the seam, so it is pinned here
4717        // rather than only in the command layer: reaching completion records the
4718        // commit, leaving it clears the one that was recorded.
4719        let (_tmp, store, _before) = seam_issue("doing");
4720        store
4721            .set_status(
4722                "2026-01-01-seam.md",
4723                StatusChange::to("done").with_commit(Some("abc123")),
4724            )
4725            .unwrap();
4726        let at_done = store
4727            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4728            .unwrap();
4729        assert_eq!(at_done.commit.as_deref(), Some("abc123"));
4730
4731        store
4732            .set_status(
4733                "2026-01-01-seam.md",
4734                StatusChange::to("todo").with_override(Some("not actually finished")),
4735            )
4736            .unwrap();
4737        let off_done = store
4738            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4739            .unwrap();
4740        assert_eq!(
4741            off_done.commit, None,
4742            "leaving completion clears the recorded commit"
4743        );
4744    }
4745
4746    #[test]
4747    fn t015_a_decision_round_trips_through_a_file_unchanged() {
4748        // T012's round trip, end to end rather than in memory: the rendered line
4749        // must parse back to the same fields, or WP06 cannot read a file's last
4750        // accepted column out of it.
4751        let (_tmp, store, _before) = seam_issue("todo");
4752        store
4753            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4754            .unwrap();
4755        let first = store
4756            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4757            .unwrap();
4758        let text = read_issue_file(&store, "2026-01-01-seam.md");
4759        // Parse the file back from its own bytes — not from the in-memory issue.
4760        let parsed = Issue::parse(&text).unwrap();
4761        let events = parsed.history.events();
4762        let decision = crate::model::TransitionDecision::from_event(events[0]).unwrap();
4763        assert_eq!(decision.to, "doing");
4764        assert_eq!(decision.from_category, "unstarted");
4765        assert_eq!(decision.to_category, "active");
4766        // And re-serialising the parsed file reproduces it byte for byte, so the
4767        // grammar is not lossy in a way only a rewrite would reveal.
4768        assert_eq!(
4769            parsed.to_file_contents(),
4770            text,
4771            "a parsed decision must re-render to the same bytes"
4772        );
4773        assert_eq!(parsed.history.events().len(), first.history.events().len());
4774    }
4775
4776    #[test]
4777    fn t015_two_transitions_append_two_decisions_in_order() {
4778        let (_tmp, store, _before) = seam_issue("triage");
4779        for to in ["todo", "doing"] {
4780            store
4781                .set_status("2026-01-01-seam.md", StatusChange::to(to))
4782                .unwrap();
4783        }
4784        let after = store
4785            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4786            .unwrap();
4787        let events = after.history.events();
4788        assert_eq!(events.len(), 2, "one per accepted transition");
4789        let tos: Vec<String> = events
4790            .iter()
4791            .map(|e| crate::model::TransitionDecision::from_event(e).unwrap().to)
4792            .collect();
4793        assert_eq!(tos, vec!["todo", "doing"], "append order, not sorted");
4794    }
4795
4796    #[test]
4797    fn set_status_changes_status_and_bumps_updated() {
4798        let (tmp, store) = make_store();
4799        let _ = tmp;
4800        write_issue(
4801            &store,
4802            "2026-01-01-s.md",
4803            &sample_frontmatter(1, "S", "todo"),
4804        );
4805        let outcome = store
4806            .set_status("2026-01-01-s.md", StatusChange::to("in progress"))
4807            .unwrap();
4808        let issue = &outcome.issue;
4809        assert_eq!(issue.status.display(), "in progress");
4810        assert_eq!(issue.updated, today());
4811        assert_eq!(issue.title, "S");
4812        assert_eq!(issue.body, "Body text.");
4813    }
4814
4815    #[test]
4816    fn set_status_preserves_file_path() {
4817        let (tmp, store) = make_store();
4818        let _ = tmp;
4819        write_issue(
4820            &store,
4821            "2026-01-01-f.md",
4822            &sample_frontmatter(1, "F", "todo"),
4823        );
4824        let _ = store
4825            .set_status("2026-01-01-f.md", StatusChange::to("done"))
4826            .unwrap();
4827        assert!(store.issues_dir.join("2026-01-01-f.md").exists());
4828    }
4829
4830    // ------------------------------------------------------------------
4831    // WP02 T009 — typed relationship writes delegate to the service
4832    // ------------------------------------------------------------------
4833
4834    /// Read an issue file's raw bytes, for the byte-identical assertions.
4835    fn read_issue_file(store: &Store, filename: &str) -> String {
4836        std::fs::read_to_string(store.issues_dir.join(filename)).unwrap()
4837    }
4838
4839    /// A frontmatter fixture with no relationship data, i.e. what every
4840    /// pre-existing issue file in the repository looks like.
4841    fn bare_frontmatter(number: u32, title: &str) -> String {
4842        format!(
4843            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
4844        )
4845    }
4846
4847    /// A frontmatter fixture carrying the **retired** `links:` key.
4848    fn legacy_links_frontmatter(number: u32, title: &str, links: &str) -> String {
4849        format!(
4850            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\nlinks: {links}\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
4851        )
4852    }
4853
4854    /// Two seeded issues, plus the store, for the relationship tests.
4855    fn related_pair() -> (TempDir, Store, Issue, Issue) {
4856        let (tmp, store) = make_store();
4857        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4858        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
4859        let a = store.get(IssueSelector::Number(1)).unwrap();
4860        let b = store.get(IssueSelector::Number(2)).unwrap();
4861        (tmp, store, a, b)
4862    }
4863
4864    #[test]
4865    fn t009_link_records_a_single_directed_edge() {
4866        let (_tmp, store, a, b) = related_pair();
4867        let outcome = store
4868            .link(
4869                &a.number.to_string(),
4870                &b.number.to_string(),
4871                RelationKind::Blocks,
4872            )
4873            .unwrap();
4874        assert!(outcome.changed);
4875
4876        // One edge, stored once, on the blocking side. The old symmetric
4877        // back-reference on the other issue is gone (C-001).
4878        let a_after = store.get(IssueSelector::Number(1)).unwrap();
4879        let b_after = store.get(IssueSelector::Number(2)).unwrap();
4880        assert_eq!(a_after.relationships.len(), 1);
4881        assert!(
4882            b_after.relationships.is_empty(),
4883            "no back-reference is written for a directed edge"
4884        );
4885        assert_eq!(a_after.relationships[0].kind, RelationKind::Blocks);
4886        assert_eq!(
4887            (a_after.relationships[0].from, a_after.relationships[0].to),
4888            (1, 2)
4889        );
4890    }
4891
4892    #[test]
4893    fn t009_link_appends_exactly_one_history_event() {
4894        let (_tmp, store, a, b) = related_pair();
4895        store
4896            .link(
4897                &a.number.to_string(),
4898                &b.number.to_string(),
4899                RelationKind::Blocks,
4900            )
4901            .unwrap();
4902        let a_after = store.get(IssueSelector::Number(1)).unwrap();
4903        assert_eq!(a_after.history.len(), 1);
4904        assert_eq!(
4905            a_after.history.events()[0].kind,
4906            HistoryEventKind::RelationshipAdded
4907        );
4908        // Every event records an actor, or the audit trail is not an audit trail.
4909        assert_ne!(a_after.history.events()[0].actor, "");
4910    }
4911
4912    #[test]
4913    fn t009_idempotent_reapply_writes_nothing_and_appends_no_second_event() {
4914        let (_tmp, store, a, b) = related_pair();
4915        let args = (a.number.to_string(), b.number.to_string());
4916        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4917        let first = store.get(IssueSelector::Number(1)).unwrap();
4918        let bytes_after_first = read_issue_file(&store, &first.filename);
4919
4920        let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4921        assert!(!second.changed, "re-applying an existing edge is a no-op");
4922        assert!(second.written.is_none(), "no file is written for a no-op");
4923
4924        let after = store.get(IssueSelector::Number(1)).unwrap();
4925        assert_eq!(after.relationships.len(), 1, "no duplicate edge");
4926        assert_eq!(after.history.len(), 1, "no second History event");
4927        assert_eq!(
4928            read_issue_file(&store, &after.filename),
4929            bytes_after_first,
4930            "the file is byte-identical after the no-op"
4931        );
4932    }
4933
4934    #[test]
4935    fn t009_inverse_spelling_resolves_to_the_same_stored_edge() {
4936        let (_tmp, store, a, b) = related_pair();
4937        let args = (a.number.to_string(), b.number.to_string());
4938        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4939        // "2 is blocked by 1" is the same edge as "1 blocks 2".
4940        let again = store
4941            .link(&args.1, &args.0, RelationKind::BlockedBy)
4942            .unwrap();
4943        assert!(!again.changed, "the inverse spelling must not add an edge");
4944        let after = store.get(IssueSelector::Number(1)).unwrap();
4945        assert_eq!(after.relationships.len(), 1);
4946        assert_eq!(
4947            after.relationships[0].kind,
4948            RelationKind::Blocks,
4949            "the stored kind is always the canonical one"
4950        );
4951    }
4952
4953    #[test]
4954    fn t009_self_link_is_refused_and_writes_nothing() {
4955        let (_tmp, store, a, _b) = related_pair();
4956        let before = read_issue_file(&store, &a.filename);
4957        let err = store
4958            .link(
4959                &a.number.to_string(),
4960                &a.number.to_string(),
4961                RelationKind::Blocks,
4962            )
4963            .unwrap_err();
4964        assert!(matches!(err, TixError::Refused(_)));
4965        assert!(
4966            err.to_string().contains("self_link"),
4967            "the diagnostic names the rule: {err}"
4968        );
4969        assert_eq!(
4970            read_issue_file(&store, &a.filename),
4971            before,
4972            "SC-005: a rejected mutation leaves the file byte-identical"
4973        );
4974    }
4975
4976    #[test]
4977    fn t009_cycle_closing_edge_is_refused_and_writes_nothing() {
4978        let (tmp, store) = make_store();
4979        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4980        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
4981        write_issue(&store, "2026-01-01-c.md", &bare_frontmatter(3, "C"));
4982        let _ = tmp;
4983        store.link("1", "2", RelationKind::Blocks).unwrap();
4984        store.link("2", "3", RelationKind::Blocks).unwrap();
4985        let c_before = read_issue_file(&store, "2026-01-01-c.md");
4986
4987        let err = store.link("3", "1", RelationKind::Blocks).unwrap_err();
4988        assert!(err.to_string().contains("cycle"), "{err}");
4989        assert_eq!(
4990            read_issue_file(&store, "2026-01-01-c.md"),
4991            c_before,
4992            "SC-005: both files of a rejected two-issue mutation are unchanged"
4993        );
4994    }
4995
4996    #[test]
4997    fn t009_unlink_removes_the_edge_and_is_idempotent() {
4998        let (_tmp, store, a, b) = related_pair();
4999        let args = (a.number.to_string(), b.number.to_string());
5000        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5001
5002        let removed = store
5003            .unlink(&args.0, &args.1, RelationKind::Blocks)
5004            .unwrap();
5005        assert!(removed.changed);
5006        let after = store.get(IssueSelector::Number(1)).unwrap();
5007        assert_eq!(
5008            after.relationships,
5009            [] as [crate::relationships::Relationship; 0]
5010        );
5011        assert_eq!(after.history.len(), 2, "added then removed");
5012
5013        // Removing again is a no-op, not a second History entry.
5014        let again = store
5015            .unlink(&args.0, &args.1, RelationKind::Blocks)
5016            .unwrap();
5017        assert!(!again.changed);
5018        let after2 = store.get(IssueSelector::Number(1)).unwrap();
5019        assert_eq!(after2.history.len(), 2, "no third History event");
5020    }
5021
5022    #[test]
5023    fn t009_unlink_of_a_never_linked_pair_is_a_noop() {
5024        let (_tmp, store, a, b) = related_pair();
5025        let outcome = store
5026            .unlink(
5027                &a.number.to_string(),
5028                &b.number.to_string(),
5029                RelationKind::Blocks,
5030            )
5031            .unwrap();
5032        assert!(!outcome.changed);
5033        assert!(outcome.written.is_none());
5034        let after = store.get(IssueSelector::Number(1)).unwrap();
5035        assert_eq!(
5036            after.relationships,
5037            [] as [crate::relationships::Relationship; 0]
5038        );
5039        assert!(after.history.is_empty(), "a no-op appends nothing");
5040    }
5041
5042    #[test]
5043    fn t009_set_relationships_replaces_the_set_and_keeps_surviving_ids() {
5044        let (_tmp, store, _a, _b) = related_pair();
5045        store.link("1", "2", RelationKind::Blocks).unwrap();
5046        let before = store.get(IssueSelector::Number(1)).unwrap();
5047        let survivor_id = before.relationships[0].id.clone();
5048
5049        // Replace the set with a different target; the old edge goes, recorded.
5050        let updated = store
5051            .set_relationships(&before.filename, &[(2, RelationKind::Blocks)])
5052            .unwrap();
5053        assert_eq!(updated.relationships.len(), 1);
5054        assert_eq!(
5055            updated.relationships[0].id, survivor_id,
5056            "a surviving relationship keeps its stable id"
5057        );
5058    }
5059
5060    #[test]
5061    fn t009_set_relationships_rejects_unknown_target_and_self() {
5062        let (_tmp, store, a, _b) = related_pair();
5063        assert!(
5064            store
5065                .set_relationships(&a.filename, &[(404, RelationKind::Blocks)])
5066                .is_err(),
5067            "an unknown target is refused"
5068        );
5069        assert!(
5070            store
5071                .set_relationships(&a.filename, &[(1, RelationKind::Blocks)])
5072                .is_err(),
5073            "a self-link is refused"
5074        );
5075    }
5076
5077    // ------------------------------------------------------------------
5078    // WP02 review round 1 — F1, F4, F5, F13: canonical storage lookups
5079    // ------------------------------------------------------------------
5080
5081    /// Seed an issue, optionally with raw extra frontmatter.
5082    fn seeded(store: &Store, number: u32, title: &str, extra: &str) {
5083        let slug = title.to_lowercase();
5084        write_issue(
5085            store,
5086            &format!("2026-01-01-{slug}.md"),
5087            &format!(
5088                "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\n{extra}files: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
5089            ),
5090        );
5091    }
5092
5093    #[test]
5094    fn f1_set_relationships_refuses_a_reciprocal_pair_the_service_rejects() {
5095        // F1. `set_relationships` used to re-implement existence and the
5096        // self-link rule locally and skip the rest, so with `1 blocks 2` on disk
5097        // it stored `2 blocks 1` — a reciprocal pair that `link` rejects one
5098        // line earlier and that contract §3 rules 3 and 4 require be refused.
5099        // Latent only because `--links` hard-codes `related`; user-reachable the
5100        // moment WP03 wires `--type`.
5101        let (_tmp, store) = make_store();
5102        seeded(&store, 1, "A", "");
5103        seeded(&store, 2, "B", "");
5104        store.link("1", "2", RelationKind::Blocks).unwrap();
5105
5106        let err = store
5107            .set_relationships("2026-01-01-b.md", &[(1, RelationKind::Blocks)])
5108            .unwrap_err();
5109        assert!(
5110            err.to_string().contains("cycle"),
5111            "the service's verdict is what refuses it: {err}"
5112        );
5113        let two = store.get(IssueSelector::Number(2)).unwrap();
5114        assert!(
5115            two.relationships.is_empty(),
5116            "SC-005: a refused mutation writes nothing"
5117        );
5118    }
5119
5120    #[test]
5121    fn f4_a_hand_ordered_related_row_is_removable() {
5122        // F4. A hand-authored `related` row in the non-canonical endpoint order.
5123        // Removals used to be keyed by where the row *should* live while rows
5124        // were found where they *were* stored, so this row matched no file and
5125        // no code path could delete it.
5126        let (_tmp, store) = make_store();
5127        seeded(&store, 2, "B", "");
5128        seeded(
5129            &store,
5130            5,
5131            "E",
5132            "relationships:\n- id: rel_handauthored\n  from: 5\n  to: 2\n  kind: related\n  display: \"related #2 B\"\n",
5133        );
5134        let out = store.unlink("5", "2", RelationKind::Related).unwrap();
5135        assert!(out.changed, "the row is found and removed");
5136        assert_eq!(
5137            store.get(IssueSelector::Number(5)).unwrap().relationships,
5138            [] as [crate::relationships::Relationship; 0]
5139        );
5140    }
5141
5142    #[test]
5143    fn f5_a_persisted_blocked_by_row_is_removable() {
5144        // F5. `blocked_by` is the input-only spelling and must never be
5145        // persisted, but the format is human-editable so a hand edit can put it
5146        // there. `unlink` compared authored fields while `link` canonicalized,
5147        // so the two disagreed and the row was un-removable — leaving a
5148        // permanently invalid row plus a `tix lint` cycle finding with no way to
5149        // clear it.
5150        let (_tmp, store) = make_store();
5151        seeded(&store, 1, "A", "");
5152        seeded(
5153            &store,
5154            2,
5155            "B",
5156            "relationships:\n- id: rel_bb\n  from: 2\n  to: 1\n  kind: blocked_by\n  display: \"blocks #1 A\"\n",
5157        );
5158        let out = store.unlink("2", "1", RelationKind::BlockedBy).unwrap();
5159        assert!(out.changed, "the row is found and removed");
5160        assert_eq!(
5161            store.get(IssueSelector::Number(2)).unwrap().relationships,
5162            [] as [crate::relationships::Relationship; 0]
5163        );
5164    }
5165
5166    #[test]
5167    fn f13_a_dangling_edge_is_removable() {
5168        // F13. A dangling edge is a diagnosed, expected condition, so the one
5169        // operation that can clean it up must still work. `unlink` resolved
5170        // both endpoints first, so deleting the target made the edge
5171        // permanently unfixable through the tool.
5172        let (_tmp, store) = make_store();
5173        seeded(&store, 1, "A", "");
5174        seeded(&store, 2, "B", "");
5175        store.link("1", "2", RelationKind::Blocks).unwrap();
5176        std::fs::remove_file(store.issues_dir.join("2026-01-01-b.md")).unwrap();
5177
5178        let out = store.unlink("1", "2", RelationKind::Blocks).unwrap();
5179        assert!(out.changed, "a dangling edge is still removable");
5180        let one = store.get(IssueSelector::Number(1)).unwrap();
5181        assert_eq!(
5182            one.relationships,
5183            [] as [crate::relationships::Relationship; 0]
5184        );
5185        assert_eq!(one.history.len(), 2, "and the removal is recorded");
5186    }
5187
5188    #[test]
5189    fn m10_one_unlink_of_a_duplicated_edge_appends_one_event() {
5190        // M10. The same canonical `related` edge hand-authored into **both**
5191        // endpoints — the redundancy the data model says is kept so no stored id
5192        // silently stops resolving. One `unlink` is one accepted mutation, so it
5193        // appends one History event, not one per file written. The previous
5194        // implementation computed the summary per file, and the comment directly
5195        // above it claimed the opposite.
5196        let (_tmp, store) = make_store();
5197        // A redundant pair is one row in **each** endpoint's file — the two
5198        // spellings of a single association. My first version of this fixture
5199        // put both rows in one file, which tested a different thing and then
5200        // asserted an event count for a file that was never written.
5201        seeded(
5202            &store,
5203            1,
5204            "A",
5205            "relationships:\n- id: rel_dup_12\n  from: 1\n  to: 2\n  kind: related\n  display: \"related #2 B\"\n",
5206        );
5207        seeded(
5208            &store,
5209            2,
5210            "B",
5211            "relationships:\n- id: rel_dup_21\n  from: 2\n  to: 1\n  kind: related\n  display: \"related #1 A\"\n",
5212        );
5213
5214        let outcome = store.unlink("1", "2", RelationKind::Related).unwrap();
5215        assert!(outcome.changed, "both redundant rows are removed");
5216        let mut summaries = Vec::new();
5217        for number in [1u32, 2] {
5218            let issue = store.get(IssueSelector::Number(number)).unwrap();
5219            assert!(
5220                issue.relationships.is_empty(),
5221                "issue #{number} keeps no edge"
5222            );
5223            assert_eq!(
5224                issue.history.len(),
5225                1,
5226                "issue #{number} records exactly one event, got {:?}",
5227                issue.history.events()
5228            );
5229            let event = &issue.history.events()[0];
5230            assert!(
5231                event.detail.contains("removed 2 relationships"),
5232                "and the event says both spellings went: {}",
5233                event.detail
5234            );
5235            summaries.push(event.detail.clone());
5236        }
5237        // The point of the fix: one mutation, one wording. The previous version
5238        // computed the summary per file, so each file described only its own row
5239        // and the two events disagreed about what had happened.
5240        assert_eq!(
5241            summaries[0], summaries[1],
5242            "both files record the same single event, not two differing ones"
5243        );
5244    }
5245
5246    #[test]
5247    fn m11_unlink_reports_the_row_it_removed_not_an_empty_placeholder() {
5248        // M11. The post-removal re-scan could never match — every matching row is
5249        // gone by then — so a successful unlink always reported a relationship
5250        // with an empty id, which is not addressable by
5251        // `edit_relation`/`remove_relation`. `link`'s no-op path returned a real
5252        // row; this brings the two into line.
5253        let (_tmp, store, a, b) = related_pair();
5254        let args = (a.number.to_string(), b.number.to_string());
5255        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5256        let before = store.get(IssueSelector::Number(1)).unwrap();
5257        let real_id = before.relationships[0].id.clone();
5258
5259        let outcome = store
5260            .unlink(&args.0, &args.1, RelationKind::Blocks)
5261            .unwrap();
5262        assert!(outcome.changed);
5263        assert_eq!(
5264            outcome.relationship.id, real_id,
5265            "the reported row is the one that was actually removed"
5266        );
5267        assert!(!outcome.relationship.id.is_empty(), "so it is addressable");
5268        assert_eq!(
5269            outcome.relationship.display,
5270            before.relationships[0].display
5271        );
5272    }
5273
5274    #[test]
5275    fn m15_the_history_detail_names_the_relationship_id() {
5276        // M15. `display` already begins with the kind, so naming the kind again
5277        // produced `created blocks: blocks #2 B`. The id is the stable handle and
5278        // is what data-model §8's example shows.
5279        let (_tmp, store, a, b) = related_pair();
5280        let args = (a.number.to_string(), b.number.to_string());
5281        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5282        let one = store.get(IssueSelector::Number(1)).unwrap();
5283        let id = one.relationships[0].id.clone();
5284
5285        let detail = &one.history.events()[0].detail;
5286        assert!(
5287            detail.starts_with(&format!("created {id}:")),
5288            "the detail names the stable id: {detail}"
5289        );
5290        assert!(
5291            !detail.contains(&format!("created {}:", RelationKind::Blocks)),
5292            "and does not stutter the kind, which `display` already carries: {detail}"
5293        );
5294    }
5295
5296    #[test]
5297    fn f17_all_diagnostics_are_reported_not_just_the_first() {
5298        // F17 / M4. The previous version of this test built exactly **one**
5299        // diagnostic and asserted only that the kind prefix was not doubled —
5300        // so it never exercised the multi-diagnostic path the fix exists for.
5301        // Replacing the join with `errors.iter().take(1)` left it green.
5302        //
5303        // `1 blocks 2` plus `2 duplicates 1` makes the same pair carry two
5304        // distinct rule violations at once.
5305        // The corrupt state is hand-authored, because the write path refuses to
5306        // create it — which is the point. A file written before these rules
5307        // existed, or edited by hand, can hold several violations at once, and
5308        // `validate_snapshot` is the surface that must report all of them.
5309        let (_tmp, store) = make_store();
5310        seeded(
5311            &store,
5312            1,
5313            "A",
5314            "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",
5315        );
5316        seeded(
5317            &store,
5318            2,
5319            "B",
5320            "relationships:\n- id: rel_b21\n  from: 2\n  to: 1\n  kind: blocks\n  display: blocks\n",
5321        );
5322
5323        let issues = store.list().unwrap();
5324        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
5325        let verdict = RelationshipGraph::new(&views, "done").validate_snapshot();
5326        assert!(
5327            verdict.errors.len() >= 2,
5328            "the fixture must really produce several diagnostics, got {:?}",
5329            verdict.errors
5330        );
5331
5332        let rendered = relationship_error(&verdict.errors).to_string();
5333        for kind in ["conflicting_edge", "cycle"] {
5334            assert!(
5335                rendered.contains(kind),
5336                "every diagnostic must be reported; {kind} missing from: {rendered}"
5337            );
5338        }
5339        assert!(
5340            !rendered.contains("conflicting_edge: conflicting_edge")
5341                && !rendered.contains("cycle: cycle:"),
5342            "no doubled kind prefix: {rendered}"
5343        );
5344        // M5, tested at the seam. Whether the *service* emits a duplicate
5345        // self-link diagnostic depends on which rules fire, and that has not
5346        // been reproducible from either the library or the binary — so this
5347        // asserts the property where it is decided: `relationship_error` must
5348        // never render the same (kind, message) twice, whatever it is handed.
5349        let dup = vec![
5350            Diagnostic::new(
5351                DiagnosticKind::SelfLink,
5352                "to",
5353                "#1 cannot block itself: a self-link is not a valid relationship",
5354            ),
5355            Diagnostic::new(
5356                DiagnosticKind::SelfLink,
5357                "to",
5358                "#1 cannot block itself: a self-link is not a valid relationship",
5359            ),
5360            Diagnostic::new(DiagnosticKind::Cycle, "to", "cycle: 1 -> 1 -> #1"),
5361        ];
5362        let rendered = relationship_error(&dup).to_string();
5363        assert_eq!(
5364            rendered.matches("cannot block itself").count(),
5365            1,
5366            "an identical diagnostic renders once, not twice: {rendered}"
5367        );
5368        assert!(
5369            rendered.contains("cycle"),
5370            "a distinct diagnostic is still reported: {rendered}"
5371        );
5372        assert!(
5373            !rendered.contains("self_link: self_link") && !rendered.contains("cycle: cycle:"),
5374            "and no kind is prefixed twice: {rendered}"
5375        );
5376    }
5377
5378    #[test]
5379    fn t009_clearing_a_set_removes_a_related_edge_owned_by_another_file() {
5380        // Regression, found in final review. A `related` edge is stored once, in
5381        // the lower-numbered issue's frontmatter, so #5's own file is empty even
5382        // though #5 *is* related to #3. Clearing #5's set must still remove the
5383        // association, or `tix edit 5 --links ""` silently does nothing.
5384        let (_tmp, store) = make_store();
5385        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
5386        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
5387
5388        store.link("5", "3", RelationKind::Related).unwrap();
5389        let three = store.get(IssueSelector::Number(3)).unwrap();
5390        let five = store.get(IssueSelector::Number(5)).unwrap();
5391        assert_eq!(
5392            three.relationships.len(),
5393            1,
5394            "the edge is stored in the lower-numbered issue's file"
5395        );
5396        assert!(
5397            five.relationships.is_empty(),
5398            "and #5's own file holds none"
5399        );
5400
5401        store.set_relationships(&five.filename, &[]).unwrap();
5402
5403        let three_after = store.get(IssueSelector::Number(3)).unwrap();
5404        let five_after = store.get(IssueSelector::Number(5)).unwrap();
5405        assert!(
5406            three_after.relationships.is_empty(),
5407            "the association is gone from the file that actually stored it"
5408        );
5409        assert_eq!(
5410            five_after.relationships,
5411            [] as [crate::relationships::Relationship; 0]
5412        );
5413    }
5414
5415    #[test]
5416    fn t009_clearing_a_set_does_not_remove_an_inbound_directional_edge() {
5417        // The other half of the ownership rule. If #3 blocks #5, that edge is
5418        // #3's declaration; #5 clearing its own relationship set must not
5419        // rewrite #3's dependency graph. Only the non-directional case is
5420        // shared, because only there does the association belong to both ends.
5421        let (_tmp, store) = make_store();
5422        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
5423        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
5424
5425        store.link("3", "5", RelationKind::Blocks).unwrap();
5426        let five = store.get(IssueSelector::Number(5)).unwrap();
5427
5428        store.set_relationships(&five.filename, &[]).unwrap();
5429
5430        let three_after = store.get(IssueSelector::Number(3)).unwrap();
5431        assert!(
5432            three_after
5433                .relationships
5434                .iter()
5435                .any(|r| r.to == 5 && r.kind == RelationKind::Blocks),
5436            "#3's declaration that it blocks #5 survives: a blocked issue \
5437             cannot unilaterally clear the blocker's graph"
5438        );
5439    }
5440
5441    /// The path of a committed relationship fixture.
5442    fn relationship_fixture(name: &str) -> String {
5443        format!(
5444            "{}/tests/fixtures/relationships/{name}",
5445            env!("CARGO_MANIFEST_DIR")
5446        )
5447    }
5448
5449    #[test]
5450    fn t010_typed_fixture_round_trips_byte_stable_including_history() {
5451        // NFR-003, model-level half: a typed fixture survives read -> write ->
5452        // read unchanged, `## History` region included.
5453        let original =
5454            std::fs::read_to_string(relationship_fixture("typed-relationships.md")).unwrap();
5455        let issue = Issue::parse(&original).unwrap();
5456        assert_eq!(issue.relationships.len(), 2);
5457        assert_eq!(issue.history.len(), 2);
5458        let rendered = issue.to_file_contents();
5459        assert_eq!(
5460            rendered, original,
5461            "a typed fixture must not churn on a read/write round trip"
5462        );
5463        assert_eq!(
5464            Issue::parse(&rendered).unwrap().to_file_contents(),
5465            rendered
5466        );
5467        // The stable ids and the section position both survive.
5468        assert_eq!(issue.relationships[0].id, "rel_01JQ8Z4W2M6N7P9R3S5T7V9X1");
5469        assert!(
5470            rendered.find("## History").unwrap() < rendered.find("Prose that follows").unwrap(),
5471            "History sits between the frontmatter and the body"
5472        );
5473    }
5474
5475    #[test]
5476    fn t010_issue_with_no_relationship_data_is_untouched() {
5477        let original =
5478            std::fs::read_to_string(relationship_fixture("no-relationship-data.md")).unwrap();
5479        let issue = Issue::parse(&original).unwrap();
5480        assert_eq!(
5481            issue.relationships,
5482            [] as [crate::relationships::Relationship; 0]
5483        );
5484        assert!(issue.history.is_empty());
5485        assert!(!issue.has_retired_links_key);
5486        let rendered = issue.to_file_contents();
5487        assert_eq!(
5488            rendered, original,
5489            "an issue with no relationship data must not churn"
5490        );
5491        assert!(!rendered.contains("relationships:"));
5492        assert!(!rendered.contains("## History"));
5493    }
5494
5495    #[test]
5496    fn t010_an_empty_history_section_is_dropped_not_preserved() {
5497        // A hand-left `## History` heading with no entries parses to an empty
5498        // log. The section is derived from the log, so an empty one has no
5499        // representation on disk and is omitted on write — diagnosed, never
5500        // silently filled in, and never kept as a bare heading.
5501        let original = std::fs::read_to_string(relationship_fixture("history-empty.md")).unwrap();
5502        assert!(
5503            original.contains("## History"),
5504            "the fixture must actually carry the empty heading"
5505        );
5506        let issue = Issue::parse(&original).unwrap();
5507        assert!(
5508            issue.history.is_empty(),
5509            "a heading with no entries parses to an empty log"
5510        );
5511        assert!(
5512            issue
5513                .body
5514                .contains("An issue whose History section is present but empty."),
5515            "the prose after the heading is body, not a dropped event: {:?}",
5516            issue.body
5517        );
5518        let rendered = issue.to_file_contents();
5519        assert!(
5520            !rendered.contains("## History"),
5521            "the empty section is omitted rather than kept as a bare heading"
5522        );
5523        assert!(rendered.contains("An issue whose History section is present but empty."));
5524    }
5525
5526    #[test]
5527    fn t010_issue_with_no_history_section_is_untouched() {
5528        let original = std::fs::read_to_string(relationship_fixture("history-absent.md")).unwrap();
5529        let issue = Issue::parse(&original).unwrap();
5530        assert!(issue.history.is_empty());
5531        let rendered = issue.to_file_contents();
5532        assert_eq!(
5533            rendered, original,
5534            "a file written before History existed must not gain an empty section"
5535        );
5536    }
5537
5538    #[test]
5539    fn f2_one_bad_history_line_does_not_make_the_store_unreadable() {
5540        // Store-level consequence of the model-level guarantee. Before the parse
5541        // became non-fatal, `Store::list` propagated the error, so a single
5542        // mistyped line in one issue broke `list`, `get`, `view`, `board`,
5543        // `status`, `lint`, `export`, `link`, `unlink` and `set_relationships`
5544        // for *every* issue — including `get` on a completely unrelated,
5545        // perfectly valid one. There is also no recovery through the tool,
5546        // because every command that could rewrite the file was among the
5547        // failures.
5548        let (_tmp, store) = make_store();
5549        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
5550        write_issue(
5551            &store,
5552            "2026-01-01-c.md",
5553            "---\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",
5554        );
5555
5556        let all = store.list().expect("the store must remain listable");
5557        assert_eq!(all.len(), 2, "both issues load");
5558        assert_eq!(
5559            store
5560                .get(IssueSelector::Number(1))
5561                .expect("an unrelated, valid issue must stay readable")
5562                .title,
5563            "A"
5564        );
5565        // And the damaged line is still reachable for diagnosis, which is the
5566        // whole point of the non-fatal parse. An ASCII-hyphen line is not
5567        // *recognised*, so no event is fabricated from it, but it is retained in
5568        // the section verbatim and reported rather than demoted into the body
5569        // (see the F1 trade in `parse_front`).
5570        let three = store.get(IssueSelector::Number(3)).unwrap();
5571        assert!(three.history.events().is_empty(), "no event is fabricated");
5572        assert_eq!(
5573            three.history.unparsed().len(),
5574            1,
5575            "the line is preserved for diagnosis: {:?}",
5576            three.history.unparsed()
5577        );
5578        assert!(
5579            !three.body.contains("ascii hyphens"),
5580            "and is not demoted into the body: {:?}",
5581            three.body
5582        );
5583    }
5584
5585    /// Seed an issue into its own assignee segment, so a write can be failed
5586    /// for one owner and not another.
5587    #[cfg(unix)]
5588    fn seeded_in_segment(store: &Store, number: u32, title: &str, segment: &str) {
5589        let dir = store.issues_dir.join(segment);
5590        std::fs::create_dir_all(&dir).unwrap();
5591        std::fs::write(
5592            dir.join(format!("2026-01-01-{}.md", title.to_lowercase())),
5593            format!(
5594                "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
5595            ),
5596        )
5597        .unwrap();
5598    }
5599
5600    #[cfg(unix)]
5601    #[test]
5602    fn f6_a_mid_sequence_write_failure_rolls_every_file_back() {
5603        use std::os::unix::fs::PermissionsExt;
5604        // F6 / anti-pattern 7. This can write N files, because an edge is stored
5605        // in its canonical source's file, so a failure partway through used to
5606        // leave the store half-mutated while the command reported failure.
5607        //
5608        // `atomic_write` renames a temp file, which needs write permission on
5609        // the *directory*, so the injection makes one owner's segment
5610        // read-only. The owners are 1, 2 and 3 and are written in ascending
5611        // order, so #1 and #2 are committed before #3 fails.
5612        let (tmp, store) = make_store();
5613        let _ = tmp;
5614        seeded_in_segment(&store, 1, "A", "alice");
5615        seeded_in_segment(&store, 2, "B", "bob");
5616        seeded_in_segment(&store, 3, "C", "carol");
5617        seeded_in_segment(&store, 5, "E", "erin");
5618
5619        let paths: Vec<std::path::PathBuf> = [
5620            "alice/2026-01-01-a.md",
5621            "bob/2026-01-01-b.md",
5622            "carol/2026-01-01-c.md",
5623        ]
5624        .iter()
5625        .map(|p| store.issues_dir.join(p))
5626        .collect();
5627        let before: Vec<String> = paths
5628            .iter()
5629            .map(|p| std::fs::read_to_string(p).unwrap())
5630            .collect();
5631
5632        // Order-normalization puts target 5's edge on the lower number, so the
5633        // owners really are 1, 2 and 3 and #3 is written last.
5634        let carol = store.issues_dir.join("carol");
5635        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5636        perms.set_mode(0o555);
5637        std::fs::set_permissions(&carol, perms).unwrap();
5638
5639        let result = store.set_relationships(
5640            "carol/2026-01-01-c.md",
5641            &[
5642                (1, RelationKind::Related),
5643                (2, RelationKind::Related),
5644                (5, RelationKind::Related),
5645            ],
5646        );
5647
5648        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5649        perms.set_mode(0o755);
5650        std::fs::set_permissions(&carol, perms).unwrap();
5651
5652        assert!(result.is_err(), "the blocked write must fail");
5653        for (path, previous) in paths.iter().zip(before.iter()) {
5654            assert_eq!(
5655                std::fs::read_to_string(path).unwrap(),
5656                *previous,
5657                "{} was left mutated by a failed mutation",
5658                path.display()
5659            );
5660        }
5661    }
5662
5663    #[cfg(unix)]
5664    #[test]
5665    fn f6b_the_updated_bump_is_itself_rollback_covered() {
5666        // The `updated` bump on the named issue (F16) is a real write, and it
5667        // used to sit *after* the rollback check — so a failure there returned an
5668        // error with the relationship writes already committed and nothing left
5669        // to restore. That is the same class of defect F6 was about, reintroduced
5670        // by the F16 fix.
5671        //
5672        // Order matters here: the relationship write lands in the *other*
5673        // endpoint's segment, and the bump targets the named issue's own
5674        // segment, so blocking the named segment fails the second write.
5675        use std::os::unix::fs::PermissionsExt;
5676
5677        let (tmp, store) = make_store();
5678        let _ = tmp;
5679        seeded_in_segment(&store, 1, "A", "alice");
5680        seeded_in_segment(&store, 3, "C", "carol");
5681
5682        let alice = store.issues_dir.join("alice/2026-01-01-a.md");
5683        let before = std::fs::read_to_string(&alice).unwrap();
5684
5685        let carol = store.issues_dir.join("carol");
5686        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5687        perms.set_mode(0o555);
5688        std::fs::set_permissions(&carol, perms).unwrap();
5689
5690        let result =
5691            store.set_relationships("carol/2026-01-01-c.md", &[(1, RelationKind::Related)]);
5692
5693        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5694        perms.set_mode(0o755);
5695        std::fs::set_permissions(&carol, perms).unwrap();
5696
5697        assert!(result.is_err(), "the blocked bump must fail the call");
5698        assert_eq!(
5699            std::fs::read_to_string(&alice).unwrap(),
5700            before,
5701            "the relationship write that succeeded earlier must be rolled back \
5702             when the subsequent `updated` bump fails"
5703        );
5704    }
5705
5706    #[test]
5707    fn f9_relationships_for_reports_both_endpoints_of_a_one_sided_edge() {
5708        // F9. An edge is stored once, in its canonical source's file, so the
5709        // issue's own `relationships` field is a *partial* view. Reading it made
5710        // a related issue look unrelated — a user-visible regression against the
5711        // retired symmetric model, which mirrored the link on both sides by
5712        // construction.
5713        let (_tmp, store) = make_store();
5714        seeded(&store, 1, "A", "");
5715        seeded(&store, 2, "B", "");
5716        store.link("2", "1", RelationKind::Related).unwrap();
5717
5718        // The edge is stored once, in the lower-numbered issue's file.
5719        let one = store.get(IssueSelector::Number(1)).unwrap();
5720        let two = store.get(IssueSelector::Number(2)).unwrap();
5721        assert_eq!(one.relationships.len(), 1, "precondition: #1 stores it");
5722        assert!(
5723            two.relationships.is_empty(),
5724            "precondition: #2's own field is the partial view that caused F9"
5725        );
5726
5727        // The service-backed accessor is what a read surface must use, and it
5728        // answers for both endpoints.
5729        for number in [1u32, 2] {
5730            let resolved = store.relationships_for(number).unwrap();
5731            assert_eq!(
5732                resolved.len(),
5733                1,
5734                "issue #{number} must see the relationship that touches it"
5735            );
5736            assert_eq!(resolved[0].kind, RelationKind::Related);
5737        }
5738    }
5739
5740    #[test]
5741    fn f7_writing_an_issue_with_the_retired_key_discloses_that_it_is_removed() {
5742        // F7 / M6. The previous version of this test asserted only the
5743        // *consequence* — the key is gone from the file — which was already true
5744        // before the fix, so removing the disclosure entirely left it green.
5745        // It never checked the thing its name promised.
5746        let (tmp, store) = make_store();
5747        let _ = tmp;
5748        let _ = take_disclosures();
5749        // **Populated**, not `[]`. The disclosure fires because the key's value is
5750        // no longer readable, and an empty value has nothing to lose — reporting
5751        // the key's mere presence made `tix lint` reject every issue file this
5752        // repository has ever written, since they all carry a `links: []`.
5753        write_issue(
5754            &store,
5755            "2026-01-01-legacy.md",
5756            &legacy_links_frontmatter(1, "Legacy", "['7']"),
5757        );
5758        let issue = store.get(IssueSelector::Number(1)).unwrap();
5759        assert!(
5760            issue.has_retired_links_key,
5761            "precondition: the key is present"
5762        );
5763
5764        store.update(&issue.filename, &issue).unwrap();
5765
5766        // The disclosure, which is the whole point of the fix.
5767        let disclosures = take_disclosures();
5768        assert_eq!(
5769            disclosures.len(),
5770            1,
5771            "the write must disclose that it dropped the key: {disclosures:?}"
5772        );
5773        let line = &disclosures[0];
5774        assert!(
5775            line.contains("`links:`"),
5776            "it names what was removed: {line}"
5777        );
5778        // The action leads, so it is the part that survives truncation in the
5779        // TUI's one-row status area. Cycle 6 measured the previous wording being
5780        // cut off at 80, 100 and 120 columns.
5781        assert!(
5782            line.starts_with("remove `links:` by hand"),
5783            "and leads with what to do about it: {line}"
5784        );
5785        assert!(
5786            line.contains(&issue.filename),
5787            "and which file it was removed from: {line}"
5788        );
5789        assert!(
5790            line.contains("C-001"),
5791            "and that no migration was applied, so the user is not left \
5792             wondering where their data went: {line}"
5793        );
5794
5795        // The consequence the disclosure is about.
5796        let after = read_issue_file(&store, &issue.filename);
5797        assert!(
5798            !after.contains("links:"),
5799            "the key is not re-emitted (C-001: no migration)"
5800        );
5801        // And a re-read no longer reports it, which is precisely why the
5802        // disclosure matters: the diagnostic is self-erasing without it.
5803        let reread = store.get(IssueSelector::Number(1)).unwrap();
5804        assert!(!reread.has_retired_links_key);
5805        assert!(
5806            take_disclosures().is_empty(),
5807            "and it is disclosed once, not on every read"
5808        );
5809    }
5810
5811    #[test]
5812    fn f7b_writing_an_issue_without_the_retired_key_is_silent() {
5813        // The disclosure is scoped to the condition that triggers it, so the
5814        // ordinary path stays quiet and this queue cannot become noise.
5815        let (tmp, store) = make_store();
5816        let _ = tmp;
5817        let _ = take_disclosures();
5818        write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
5819        let issue = store.get(IssueSelector::Number(1)).unwrap();
5820        assert!(!issue.has_retired_links_key);
5821        store.update(&issue.filename, &issue).unwrap();
5822        assert!(
5823            take_disclosures().is_empty(),
5824            "an ordinary write discloses nothing"
5825        );
5826    }
5827
5828    #[test]
5829    fn t010_legacy_fixture_reports_the_retired_key_and_yields_no_edges() {
5830        // C-001, the compatibility/error matrix in one place: tolerant read,
5831        // explicit diagnostic, and zero reinterpretation.
5832        let original =
5833            std::fs::read_to_string(relationship_fixture("legacy-links-key.md")).unwrap();
5834        let issue = Issue::parse(&original).unwrap();
5835        assert!(issue.has_retired_links_key, "the retired key is reported");
5836        assert!(
5837            issue.relationships.is_empty(),
5838            "C-001: legacy values are never reinterpreted as typed edges"
5839        );
5840        assert!(
5841            issue.history.is_empty(),
5842            "a legacy file gains no History section from parsing"
5843        );
5844        // The diagnostic names the field and is located in the file, per the
5845        // validation contract. Exercised through the real `tix lint` path so
5846        // the test covers the code that actually ships.
5847        let (_tmp, store) = make_store();
5848        write_issue(&store, "2026-01-01-legacy.md", &original);
5849        let findings = crate::commands::lint::collect(&store.config, &store).unwrap();
5850        let retired: Vec<_> = findings
5851            .iter()
5852            .filter(|f| f.kind == "retired_links_key")
5853            .collect();
5854        assert_eq!(
5855            retired.len(),
5856            1,
5857            "one finding for the retired key: {findings:?}"
5858        );
5859        assert_eq!(retired[0].value, "links", "the finding names the field");
5860        assert!(
5861            retired[0].message.contains("retired"),
5862            "the message says why: {}",
5863            retired[0].message
5864        );
5865        assert!(
5866            retired[0].line > 0,
5867            "the finding points into the frontmatter"
5868        );
5869        // No code path converts the legacy value into a typed edge.
5870        let rewritten = issue.to_file_contents();
5871        assert!(!rewritten.contains("kind: related"));
5872        assert!(!rewritten.contains("kind: blocks"));
5873        assert!(!rewritten.contains("links:"));
5874    }
5875
5876    #[test]
5877    fn c001_a_legacy_links_key_is_reported_and_yields_no_typed_edges() {
5878        let (tmp, store) = make_store();
5879        write_issue(
5880            &store,
5881            "2026-01-01-legacy.md",
5882            &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
5883        );
5884        let _ = tmp;
5885        let issue = store.get(IssueSelector::Number(1)).unwrap();
5886        assert!(
5887            issue.has_retired_links_key,
5888            "the retired key is recorded as a validation result"
5889        );
5890        assert!(
5891            issue.relationships.is_empty(),
5892            "C-001: legacy values are never read as typed edges"
5893        );
5894    }
5895
5896    #[test]
5897    fn c001_there_is_no_migration_path_from_the_retired_key() {
5898        // This test used to say "writing the issue back is the only way a
5899        // migration could occur" and then never write it back, so its
5900        // load-bearing assertion was on an in-memory flag after a pure string
5901        // render — which cannot change it. The actual write-back is where the
5902        // key disappears, so the test as written masked the very thing it
5903        // appeared to guard.
5904        //
5905        // It now performs the write and asserts on the resulting bytes.
5906        let (tmp, store) = make_store();
5907        let _ = tmp;
5908        write_issue(
5909            &store,
5910            "2026-01-01-legacy.md",
5911            &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
5912        );
5913        let issue = store.get(IssueSelector::Number(1)).unwrap();
5914        assert!(
5915            issue.has_retired_links_key,
5916            "precondition: the key is present"
5917        );
5918        assert!(
5919            issue.relationships.is_empty(),
5920            "C-001: legacy values are never read as typed edges"
5921        );
5922
5923        store.update(&issue.filename, &issue).unwrap();
5924        let written = read_issue_file(&store, &issue.filename);
5925
5926        // No migration: not one legacy value became a typed edge, under any kind.
5927        //
5928        // The re-read matters, and this test has already fallen into the trap
5929        // once: asserting on `issue.to_file_contents()` instead of the written
5930        // file cannot fail, because `Issue::parse` never populates
5931        // `relationships`, so the in-memory render is clean whatever the *writer*
5932        // does. Reading the file back off disk is the only form of this check
5933        // that the write-back actually has to satisfy.
5934        let reparsed = store.get(IssueSelector::Number(1)).unwrap();
5935        assert!(
5936            reparsed.relationships.is_empty(),
5937            "the file on disk parses to zero typed edges, not just the in-memory copy"
5938        );
5939        for kind in ["blocks", "blocked_by", "duplicates", "related"] {
5940            assert!(
5941                !written.contains(&format!("kind: {kind}")),
5942                "a legacy link became a `{kind}` edge:\n{written}"
5943            );
5944        }
5945        assert!(
5946            !written.contains("kind:"),
5947            "no typed edge was written at all"
5948        );
5949        // The retired key is not re-emitted, and the write did not manufacture
5950        // a History entry for something the user did not do.
5951        assert!(!written.contains("links:"), "the key is not re-emitted");
5952        let reread = store.get(IssueSelector::Number(1)).unwrap();
5953        assert!(
5954            reread.history.is_empty(),
5955            "a format change is not a relationship mutation, so it records no event"
5956        );
5957    }
5958
5959    #[test]
5960    fn f15_the_idempotent_no_op_reports_the_real_addressable_row() {
5961        // F15. The no-op path returned a synthetic `Relationship` with an empty
5962        // id and display. The contract defines the output as the accepted
5963        // relationship, and an empty id is not addressable by
5964        // `edit_relation`/`remove_relation`, so a caller that persisted it would
5965        // store a dangling reference.
5966        let (_tmp, store, a, b) = related_pair();
5967        let args = (a.number.to_string(), b.number.to_string());
5968        let first = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5969        let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5970
5971        assert!(first.changed);
5972        assert!(!second.changed, "the second apply is a no-op");
5973        assert_eq!(
5974            second.relationship.id, first.relationship.id,
5975            "the no-op reports the real, addressable row"
5976        );
5977        assert!(
5978            !second.relationship.id.is_empty(),
5979            "an empty id is not addressable"
5980        );
5981        assert_eq!(second.relationship.display, first.relationship.display);
5982    }
5983
5984    #[test]
5985    fn f16_clearing_a_set_bumps_updated_on_the_named_issue() {
5986        // F16. The named issue's own frontmatter can be untouched while its
5987        // relationship set changed, because an order-normalized `related` edge
5988        // lives in the other endpoint's file. That left `tix edit <n> --links ""`
5989        // looking like it had done nothing to the issue it named.
5990        let (_tmp, store, _a, _b) = related_pair();
5991        store.link("2", "1", RelationKind::Related).unwrap();
5992        let before = store.get(IssueSelector::Number(2)).unwrap();
5993        let updated_before = before.updated.clone();
5994
5995        store.set_relationships(&before.filename, &[]).unwrap();
5996
5997        let after = store.get(IssueSelector::Number(2)).unwrap();
5998        assert_ne!(
5999            after.updated, updated_before,
6000            "the named issue's relationship set changed, so `updated` must move"
6001        );
6002    }
6003
6004    #[test]
6005    fn c002_an_issue_with_no_relationship_data_round_trips_unchanged() {
6006        let (tmp, store) = make_store();
6007        let _ = tmp;
6008        // A pre-existing issue file: no relationship data, no History. This is
6009        // the shape every file in the repository has today, and reading then
6010        // writing it must not churn it (the WP's "existing files must not be
6011        // rewritten" guard).
6012        write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
6013        let issue = store.get(IssueSelector::Number(1)).unwrap();
6014        let first = issue.to_file_contents();
6015        assert!(
6016            !first.contains("relationships:"),
6017            "the empty typed field is omitted, not written as `[]`"
6018        );
6019        assert!(
6020            !first.contains("## History"),
6021            "an empty History section is omitted entirely"
6022        );
6023        // Byte-stable through read -> write -> read, and no unrelated
6024        // frontmatter key is added, removed, or reordered.
6025        let reparsed = Issue::parse(&first).unwrap();
6026        let second = reparsed.to_file_contents();
6027        assert_eq!(second, first);
6028    }
6029
6030    #[test]
6031    fn c002_history_section_round_trips_byte_stable() {
6032        let (tmp, store) = make_store();
6033        write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
6034        let _ = tmp;
6035        let mut issue = store.get(IssueSelector::Number(1)).unwrap();
6036        issue.history.append_now(
6037            "alice",
6038            HistoryEventKind::RelationshipAdded,
6039            "created blocks: blocks #2 B",
6040        );
6041        issue.history.append_now(
6042            "bob",
6043            HistoryEventKind::BlockedMoveAcknowledged,
6044            "acknowledged 1 open blocker",
6045        );
6046        let first = issue.to_file_contents();
6047        assert!(first.contains("## History"), "{first}");
6048
6049        // Read -> write -> read must be byte-stable.
6050        let reparsed = Issue::parse(&first).unwrap();
6051        assert_eq!(reparsed.to_file_contents(), first);
6052        assert_eq!(reparsed.history.len(), 2);
6053        assert_eq!(reparsed.history.events()[0].actor, "alice");
6054        assert_eq!(
6055            reparsed.history.events()[1].kind,
6056            HistoryEventKind::BlockedMoveAcknowledged
6057        );
6058        assert!(
6059            !reparsed.body.contains("History"),
6060            "the section is stripped from the body: {:?}",
6061            reparsed.body
6062        );
6063    }
6064
6065    #[test]
6066    fn f1_a_hand_edit_last_in_the_section_stays_in_it() {
6067        // Cycle-4 F1. The disambiguation rule carried a `!following.is_empty()`
6068        // guard, added to stop an earlier vacuous-truth defect. It over-corrected:
6069        // a hand-edited line that is the **last** line of the section has nothing
6070        // after it, so the guard forced the section to terminate there. Through
6071        // the real binary that meant `tix move 1 "in progress"` silently
6072        //
6073        //   - deleted the `## History` heading,
6074        //   - demoted the author's line into the issue body, and
6075        //   - rendered the *next* appended event above it, so file order stopped
6076        //     being append order,
6077        //
6078        // and `tix lint` reported nothing, because a demoted line is body prose
6079        // and the unparsed loop no longer sees it. Three explicit data-model §8
6080        // invariants at once.
6081        //
6082        // The distinguishing question is not "is there anything after this?" but
6083        // "is this shaped like a list item?". Nothing-following cannot disqualify
6084        // a line; a bare paragraph at EOF is still plainly prose.
6085        let (tmp, store) = make_store();
6086        let _ = tmp;
6087        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";
6088        write_issue(&store, "2026-01-01-a.md", original);
6089
6090        // Read precondition: the hand edit is inside the section, and the section
6091        // holds the prior event.
6092        let before = store.get(IssueSelector::Number(1)).unwrap();
6093        assert_eq!(
6094            before.history.events().len(),
6095            1,
6096            "precondition: the real event parsed"
6097        );
6098        assert_eq!(
6099            before.history.unparsed(),
6100            vec!["  - my hand edit"],
6101            "precondition: the hand edit is inside the section"
6102        );
6103        assert_eq!(before.body, "", "precondition: body is empty");
6104
6105        // An ordinary command that has nothing to do with relationships.
6106        let mut moved = before.clone();
6107        moved.status = crate::model::Status::new("in progress");
6108        store.update(&moved.filename, &moved).unwrap();
6109
6110        let after = store.get(IssueSelector::Number(1)).unwrap();
6111        assert_eq!(
6112            after.history.unparsed(),
6113            vec!["  - my hand edit"],
6114            "the hand edit is still in the audit log, not demoted to the body"
6115        );
6116        assert_eq!(
6117            after.history.events().len(),
6118            1,
6119            "and the real event survived"
6120        );
6121        assert_eq!(after.body, "", "nothing leaked into the body");
6122
6123        // The heading is still there, and the next append goes *after* what was
6124        // already in the file.
6125        store
6126            .link("1", "2", RelationKind::Related)
6127            .ok()
6128            .or_else(|| {
6129                write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6130                store.link("1", "2", RelationKind::Related).ok()
6131            });
6132        let linked = store.get(IssueSelector::Number(1)).unwrap();
6133        let rendered = linked.to_file_contents();
6134        assert!(
6135            rendered.contains("## History"),
6136            "the heading survived an unrelated write: {rendered}"
6137        );
6138        let hand = rendered
6139            .find("my hand edit")
6140            .expect("the hand edit is present");
6141        let newest = rendered
6142            .find("relationship_added")
6143            .expect("the new event is present");
6144        assert!(
6145            newest < hand,
6146            "the new event is appended AFTER the pre-existing content, so file \
6147             order is still append order: {rendered}"
6148        );
6149    }
6150
6151    #[test]
6152    fn f1_but_a_bare_paragraph_at_end_of_section_is_still_prose() {
6153        // The other half of F1's rule, and the reason the fix is not simply
6154        // "vacuous truth is fine". A non-list line at EOF is body prose: keeping
6155        // it would put ordinary prose into the append-only audit log, which is
6156        // the defect cycle 3 found (N1) travelling in the opposite direction.
6157        let (tmp, store) = make_store();
6158        let _ = tmp;
6159        write_issue(
6160            &store,
6161            "2026-01-01-a.md",
6162            "---\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",
6163        );
6164        let issue = store.get(IssueSelector::Number(1)).unwrap();
6165        assert_eq!(issue.history.events().len(), 1, "the real event parsed");
6166        assert!(
6167            issue.history.unparsed().is_empty(),
6168            "the paragraph is not in the audit log: {:?}",
6169            issue.history.unparsed()
6170        );
6171        assert!(
6172            issue.body.contains("just a closing paragraph"),
6173            "it is body prose: {:?}",
6174            issue.body
6175        );
6176    }
6177
6178    #[test]
6179    fn f1_a_hand_edit_survives_body_prose_and_a_later_body_replacement() {
6180        // Cycle-5 Blocker 1. Cycle 4's F1 fix consulted `is_list_item` *only*
6181        // when nothing followed the ambiguous line, so the whole defect survived
6182        // for any hand-edited entry with body prose after it. The existing F1
6183        // tests could not see it because every one of their fixtures asserts
6184        // `before.body == ""` — the empty body was precisely what hid it.
6185        //
6186        // The consequence was data loss, not just reordering. A demoted line is
6187        // body text, and `tix edit --body` replaces the body wholesale, so:
6188        //
6189        //   ## History / - <event> / - 2026-02-02T00:00:00Z - bob \
6190        //     - relationship_removed - I removed this by hand / (blank) / Notes.
6191        //
6192        // then `tix edit 1 --body='Completely new body text.'` destroyed the
6193        // hand-edited append-only audit entry, and lint was silent.
6194        let (_tmp, store) = make_store();
6195        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6196        let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed - by hand";
6197        write_issue(
6198            &store,
6199            "2026-01-01-a.md",
6200            &format!(
6201                "---\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"
6202            ),
6203        );
6204
6205        let before = store.get(IssueSelector::Number(1)).unwrap();
6206        assert_eq!(before.history.events().len(), 1, "the real event parsed");
6207        assert_eq!(
6208            before.history.unparsed(),
6209            vec![hand],
6210            "the hand-edited entry is retained even though body prose follows"
6211        );
6212        assert!(
6213            before.body.contains("Some body prose."),
6214            "and the prose is still body text: {:?}",
6215            before.body
6216        );
6217
6218        // An ordinary unrelated write must not relocate it.
6219        let mut moved = before.clone();
6220        moved.status = crate::model::Status::new("in progress");
6221        store.update(&moved.filename, &moved).unwrap();
6222        assert_eq!(
6223            store
6224                .get(IssueSelector::Number(1))
6225                .unwrap()
6226                .history
6227                .unparsed(),
6228            vec![hand],
6229            "a `tix move` keeps it in the audit log"
6230        );
6231
6232        // And replacing the body — the operation that used to destroy it — must
6233        // not either.
6234        let mut replaced = store.get(IssueSelector::Number(1)).unwrap();
6235        replaced.body = "Completely new body text.".to_string();
6236        store.update(&replaced.filename, &replaced).unwrap();
6237        let after = store.get(IssueSelector::Number(1)).unwrap();
6238        assert_eq!(
6239            after.history.unparsed(),
6240            vec![hand],
6241            "the hand-edited entry survives a wholesale body replacement"
6242        );
6243        assert_eq!(after.body, "Completely new body text.");
6244
6245        // And the next append still goes after what the file already held.
6246        store.link("1", "2", RelationKind::Related).unwrap();
6247        let rendered = store
6248            .get(IssueSelector::Number(1))
6249            .unwrap()
6250            .to_file_contents();
6251        assert!(
6252            rendered.contains("## History"),
6253            "the heading survived: {rendered}"
6254        );
6255        let hand_at = rendered.find("by hand").expect("the hand edit is present");
6256        let new_at = rendered
6257            .find("created rel_")
6258            .expect("the new event is present");
6259        assert!(
6260            new_at > hand_at,
6261            "the new event is appended AFTER the pre-existing content, so file \
6262             order is still append order: {rendered}"
6263        );
6264    }
6265
6266    #[test]
6267    fn f1_a_well_formed_star_marked_entry_is_retained_with_body_prose_after_it() {
6268        // Cycle-6 F1. A *correctly formatted* entry written with a `*` marker was
6269        // demoted to the body and then destroyed by `tix edit --body`, with lint
6270        // silent. The cause was a collapsed predicate: `looks_like_history_entry`
6271        // and `HistoryEvent::parse` both require the `- ` prefix, so a `*`-marked
6272        // em-dash line was not *recognised*; and the retention test asked
6273        // "does it look like a *misspelled* entry?", which explicitly excludes
6274        // anything containing U+2014. So a well-formed line matched neither.
6275        // Retention now asks its own question — is this an entry attempt under any
6276        // marker?
6277        let (_tmp, store) = make_store();
6278        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6279        let hand =
6280            "* 2026-02-02T00:00:00Z \u{2014} bob \u{2014} relationship_removed \u{2014} by hand";
6281        write_issue(
6282            &store,
6283            "2026-01-01-a.md",
6284            &format!(
6285                "---\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"
6286            ),
6287        );
6288
6289        let before = store.get(IssueSelector::Number(1)).unwrap();
6290        assert_eq!(before.history.events().len(), 1, "the real event parsed");
6291        assert_eq!(
6292            before.history.unparsed(),
6293            vec![hand],
6294            "a well-formed `*` entry is retained even with body prose after it"
6295        );
6296        assert!(
6297            before.body.contains("Some body prose."),
6298            "and the prose is still body text: {:?}",
6299            before.body
6300        );
6301
6302        // The destructive sequence, which is the whole point.
6303        let mut replaced = before.clone();
6304        replaced.body = "Completely new body text.".to_string();
6305        store.update(&replaced.filename, &replaced).unwrap();
6306        assert_eq!(
6307            store
6308                .get(IssueSelector::Number(1))
6309                .unwrap()
6310                .history
6311                .unparsed(),
6312            vec![hand],
6313            "it survives a wholesale body replacement"
6314        );
6315
6316        store.link("1", "2", RelationKind::Related).unwrap();
6317        let rendered = store
6318            .get(IssueSelector::Number(1))
6319            .unwrap()
6320            .to_file_contents();
6321        assert_eq!(
6322            rendered.matches("## History").count(),
6323            1,
6324            "still exactly one section: {rendered}"
6325        );
6326        let hand_at = rendered.find("by hand").expect("the hand edit is present");
6327        let new_at = rendered
6328            .find("created rel_")
6329            .expect("the new event is present");
6330        assert!(
6331            new_at > hand_at,
6332            "appended after, so order is append order: {rendered}"
6333        );
6334    }
6335
6336    #[test]
6337    fn f1_a_three_field_ascii_hyphen_entry_is_retained_with_body_prose_after_it() {
6338        // Cycle-6 F2. `HistoryEvent::parse` accepts three total fields with
6339        // `detail` optional, so `- <ts> - <actor> - <kind>` is a valid entry whose
6340        // only defect is the separator. The retention test required four, so that
6341        // line was demoted and then destroyed while the malformed four-field one
6342        // survived — the recoverable direction was the one that lost.
6343        let (_tmp, store) = make_store();
6344        let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed";
6345        write_issue(
6346            &store,
6347            "2026-01-01-a.md",
6348            &format!(
6349                "---\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"
6350            ),
6351        );
6352
6353        // Precondition: the em-dash twin really is a valid event, so the
6354        // three-field form is in fact part of the format.
6355        let twin = Issue::parse(
6356            "---\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",
6357        )
6358        .unwrap();
6359        assert_eq!(
6360            twin.history.events().len(),
6361            1,
6362            "three fields with em-dashes is a valid event, detail optional"
6363        );
6364
6365        let before = store.get(IssueSelector::Number(1)).unwrap();
6366        assert_eq!(
6367            before.history.unparsed(),
6368            vec![hand],
6369            "so its ASCII-hyphen twin is retained, not demoted"
6370        );
6371        let mut replaced = before.clone();
6372        replaced.body = "Completely new body text.".to_string();
6373        store.update(&replaced.filename, &replaced).unwrap();
6374        assert_eq!(
6375            store
6376                .get(IssueSelector::Number(1))
6377                .unwrap()
6378                .history
6379                .unparsed(),
6380            vec![hand],
6381            "and survives a body replacement"
6382        );
6383    }
6384
6385    #[test]
6386    fn f1_a_heading_with_trailing_spaces_is_still_the_heading() {
6387        // Cycle-6 F3(c). `## History   ` was not recognised, and since the
6388        // heading is then neither the section nor body, the first relationship
6389        // write rendered a **second** `## History` above the original. That was
6390        // filed on #1299 as a CRLF-only consequence, which understated it: a
6391        // trailing space is a far likelier trigger than CRLF.
6392        let (_tmp, store) = make_store();
6393        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6394        for (label, heading) in [
6395            ("plain", "## History"),
6396            ("trailing spaces", "## History   "),
6397        ] {
6398            write_issue(
6399                &store,
6400                "2026-01-01-a.md",
6401                &format!(
6402                    "---\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"
6403                ),
6404            );
6405            let before = store.get(IssueSelector::Number(1)).unwrap();
6406            assert_eq!(
6407                before.history.events().len(),
6408                1,
6409                "{label}: the section is recognised"
6410            );
6411            store.link("1", "2", RelationKind::Related).unwrap();
6412            let rendered = store
6413                .get(IssueSelector::Number(1))
6414                .unwrap()
6415                .to_file_contents();
6416            assert_eq!(
6417                rendered.matches("## History").count(),
6418                1,
6419                "{label}: exactly one section after a write, got {rendered}"
6420            );
6421            assert!(
6422                !rendered.lines().any(|l| l != l.trim_end()),
6423                "{label}: no line keeps trailing whitespace, so the heading is \
6424                 normalised: {rendered}"
6425            );
6426        }
6427    }
6428
6429    #[test]
6430    fn set_status_discloses_the_retired_key_rather_than_dropping_it_silently() {
6431        // Cycle-6 F4. Routing `set_status` through `update` is what makes it
6432        // disclose, and the behavioural difference is real — reverted to a direct
6433        // `atomic_write`, the key is still removed but nothing is disclosed, which
6434        // is silent data loss. Reverting also left the **entire suite green**,
6435        // because no test exercised it. That is the sixth time this package has
6436        // produced a correct fix with no regression protection, so this pins it.
6437        let (_tmp, store) = make_store();
6438        write_issue(
6439            &store,
6440            "2026-01-01-a.md",
6441            "---\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",
6442        );
6443        let before = store.get(IssueSelector::Number(1)).unwrap();
6444        assert!(
6445            before.has_retired_links_key,
6446            "precondition: the file carries the retired key"
6447        );
6448        let _ = crate::storage::take_disclosures();
6449
6450        store
6451            .set_status("2026-01-01-a.md", StatusChange::to("in progress"))
6452            .unwrap();
6453
6454        let disclosures = crate::storage::take_disclosures();
6455        assert_eq!(
6456            disclosures.len(),
6457            1,
6458            "set_status must disclose the removal, got {disclosures:?}"
6459        );
6460        assert!(
6461            disclosures[0].contains("links:") && disclosures[0].contains("a.md"),
6462            "naming the field and the file: {disclosures:?}"
6463        );
6464        assert!(
6465            !store
6466                .get(IssueSelector::Number(1))
6467                .unwrap()
6468                .has_retired_links_key,
6469            "and the key is gone, as C-001 intends"
6470        );
6471    }
6472
6473    #[test]
6474    fn f1_but_ordinary_prose_bullets_still_end_the_section() {
6475        // The other direction, and the reason the retention rule is not simply
6476        // "keep every list item". N1's original trigger: several body bullets in
6477        // a row. Neither is entry-shaped, so neither is plausibly a hand-edited
6478        // entry, and the section ends where it should.
6479        let (_tmp, store) = make_store();
6480        write_issue(
6481            &store,
6482            "2026-01-01-a.md",
6483            concat!(
6484                "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\n",
6485                "created: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n",
6486                "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n\n",
6487                "- step one\n- step two\n",
6488            ),
6489        );
6490        let issue = store.get(IssueSelector::Number(1)).unwrap();
6491        assert_eq!(
6492            issue.history.events().len(),
6493            1,
6494            "the real event is an event"
6495        );
6496        assert!(
6497            issue.history.unparsed().is_empty(),
6498            "ordinary bullets are not retained: {:?}",
6499            issue.history.unparsed()
6500        );
6501        assert!(
6502            issue.body.contains("step one") && issue.body.contains("step two"),
6503            "they stay body text: {:?}",
6504            issue.body
6505        );
6506    }
6507
6508    #[test]
6509    fn a_year_is_not_an_ordered_list_marker() {
6510        // Cycle-5 Minor 7. `is_list_item` accepted any run of digits before `.`,
6511        // so `2026. Q1 goals` counted as a list item and a paragraph of ordinary
6512        // body prose was retained in the append-only audit log.
6513        for prose in [
6514            "2026. Q1 goals",
6515            "2026. roadmap review",
6516            "007. padded",
6517            "99999999999999999999. big",
6518        ] {
6519            assert!(
6520                !crate::model::looks_like_an_unrecognised_entry(prose)
6521                    && Issue::parse(&format!(
6522                        "---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{prose}\n"
6523                    ))
6524                    .unwrap()
6525                    .history
6526                    .is_empty(),
6527                "{prose:?} must be body prose, not a retained entry"
6528            );
6529        }
6530    }
6531
6532    #[test]
6533    fn f2_a_recognised_entry_with_a_bad_kind_keeps_its_trailing_whitespace() {
6534        // Cycle-4 F2. The `Err` arm — a *recognised* entry whose kind is unknown —
6535        // used `trim_end()`, which also eats trailing spaces and tabs. That is
6536        // the auto-repair data-model §8 forbids, and it contradicts §8's
6537        // "preserved byte-for-byte, including its own indentation and trailing
6538        // whitespace". The sibling ambiguous arm was already correct, so the two
6539        // disagreed about what "verbatim" means.
6540        let line = "  - 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} d   ";
6541        let raw = format!(
6542            "---\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"
6543        );
6544        let issue = Issue::parse(&raw).unwrap();
6545        assert!(issue.history.events().is_empty(), "no event was fabricated");
6546        assert_eq!(
6547            issue.history.unparsed(),
6548            vec![line],
6549            "kept byte-for-byte, trailing spaces included"
6550        );
6551
6552        // And a real write must not repair it either.
6553        let rendered = issue.to_file_contents();
6554        assert!(
6555            rendered.contains(&format!("{line}\n")),
6556            "re-emitted exactly, so an unrelated `tix edit` does not rewrite the \
6557             author's audit log: {rendered}"
6558        );
6559        assert_eq!(
6560            Issue::parse(&rendered).unwrap().to_file_contents(),
6561            rendered,
6562            "and the file is byte-stable from here"
6563        );
6564    }
6565
6566    #[test]
6567    fn c002_history_is_append_only() {
6568        let (tmp, store) = make_store();
6569        write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
6570        let _ = tmp;
6571        let mut issue = store.get(IssueSelector::Number(1)).unwrap();
6572        issue
6573            .history
6574            .append_now("alice", HistoryEventKind::RelationshipAdded, "first");
6575        let after_first = issue.history.clone();
6576        issue
6577            .history
6578            .append_now("alice", HistoryEventKind::RelationshipRemoved, "second");
6579
6580        assert_eq!(issue.history.len(), 2, "appending never replaces");
6581        assert_eq!(
6582            &issue.history.events()[..1],
6583            after_first.events(),
6584            "the earlier entry is untouched and still first"
6585        );
6586    }
6587
6588    #[test]
6589    fn c002_an_unknown_history_kind_is_kept_verbatim_not_coerced() {
6590        // The old version of this test asserted `contains("bogus_kind")` against
6591        // an error message that *echoes the offending line*, so it passed for any
6592        // input mentioning the word. It also fed ASCII hyphens, so
6593        // `splitn(4, U+2014)` never split and the real failure was a missing
6594        // field — the kind was never checked at all. Neither defect is a
6595        // hypothetical: the assertion was satisfied by the echo.
6596        //
6597        // Fixed on both counts. The line uses real em dashes, so the parser
6598        // genuinely reaches the kind field, and the assertion is on the parsed
6599        // outcome rather than on a substring of an error string.
6600        let line = "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} something";
6601        let raw = format!(
6602            "---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\n---\n\n## History\n\n{line}\n"
6603        );
6604        let issue = Issue::parse(&raw).expect("an unknown kind must not fail the parse");
6605
6606        assert!(
6607            issue.history.events().is_empty(),
6608            "no event was fabricated for an unknown kind"
6609        );
6610        assert_eq!(
6611            issue.history.unparsed(),
6612            vec![line],
6613            "the line is kept verbatim: not dropped, not repaired"
6614        );
6615        // Idempotence, not equality with the hand-written input: that input
6616        // omits keys serde always emits, so only the rendered form is a
6617        // meaningful byte-stability assertion.
6618        let rendered = issue.to_file_contents();
6619        assert!(rendered.contains(line), "the line survives the write");
6620        assert_eq!(
6621            Issue::parse(&rendered).unwrap().to_file_contents(),
6622            rendered,
6623            "and the file round-trips byte-identically"
6624        );
6625    }
6626
6627    #[test]
6628    fn list_mixes_grouped_and_flat_issues_sorted_by_relative_filename() {
6629        let (tmp, store) = make_store();
6630        let _ = tmp;
6631        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6632        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
6633        write_issue(
6634            &store,
6635            "alice/2026-01-01-a.md",
6636            &sample_frontmatter(1, "A", "todo"),
6637        );
6638        write_issue(
6639            &store,
6640            "bob/2026-05-10-b.md",
6641            &sample_frontmatter(2, "B", "todo"),
6642        );
6643        write_issue(
6644            &store,
6645            "2026-02-01-c.md",
6646            &sample_frontmatter(3, "C", "todo"),
6647        );
6648        let issues = store.list().unwrap();
6649        let filenames: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
6650        assert_eq!(issues.len(), 3);
6651        assert_eq!(
6652            filenames,
6653            vec![
6654                "2026-02-01-c.md",
6655                "alice/2026-01-01-a.md",
6656                "bob/2026-05-10-b.md"
6657            ],
6658            "deterministic sort by relative filename"
6659        );
6660    }
6661
6662    #[test]
6663    fn list_sorts_chronologically_within_a_group() {
6664        let (tmp, store) = make_store();
6665        let _ = tmp;
6666        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6667        write_issue(
6668            &store,
6669            "alice/2026-03-01-c.md",
6670            &sample_frontmatter(3, "C", "todo"),
6671        );
6672        write_issue(
6673            &store,
6674            "alice/2026-01-01-a.md",
6675            &sample_frontmatter(1, "A", "todo"),
6676        );
6677        write_issue(
6678            &store,
6679            "alice/2026-02-01-b.md",
6680            &sample_frontmatter(2, "B", "todo"),
6681        );
6682        let issues = store.list().unwrap();
6683        let numbers: Vec<u32> = issues.iter().map(|i| i.number).collect();
6684        assert_eq!(numbers, vec![1, 2, 3]);
6685    }
6686
6687    #[test]
6688    fn list_ignores_non_md_files_in_segments() {
6689        let (tmp, store) = make_store();
6690        let _ = tmp;
6691        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6692        write_issue(
6693            &store,
6694            "alice/2026-01-01-a.md",
6695            &sample_frontmatter(1, "A", "todo"),
6696        );
6697        fs::write(store.issues_dir.join("alice/notes.txt"), "not an issue").unwrap();
6698        fs::write(store.issues_dir.join("alice/.tix-tmp-123-456"), "orphan").unwrap();
6699        let issues = store.list().unwrap();
6700        assert_eq!(issues.len(), 1);
6701        assert_eq!(issues[0].filename, "alice/2026-01-01-a.md");
6702    }
6703
6704    #[test]
6705    fn list_skips_temp_marker_directories() {
6706        let (tmp, store) = make_store();
6707        let _ = tmp;
6708        fs::create_dir_all(store.issues_dir.join(".tix-tmp-999-0")).unwrap();
6709        write_issue(
6710            &store,
6711            ".tix-tmp-999-0/2026-01-01-junk.md",
6712            &sample_frontmatter(9, "J", "todo"),
6713        );
6714        write_issue(
6715            &store,
6716            "2026-01-01-a.md",
6717            &sample_frontmatter(1, "A", "todo"),
6718        );
6719        let issues = store.list().unwrap();
6720        assert_eq!(issues.len(), 1);
6721        assert_eq!(issues[0].filename, "2026-01-01-a.md");
6722    }
6723
6724    #[test]
6725    fn list_parse_error_carries_grouped_relative_path() {
6726        let (tmp, store) = make_store();
6727        let _ = tmp;
6728        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6729        fs::write(
6730            store.issues_dir.join("alice/2026-01-01-bad.md"),
6731            "no frontmatter here\n",
6732        )
6733        .unwrap();
6734        let err = store.list().unwrap_err();
6735        assert!(
6736            matches!(&err, TixError::Parse(m) if m.starts_with("alice/2026-01-01-bad.md:")),
6737            "parse error must carry the relative path, got {err:?}"
6738        );
6739    }
6740
6741    // ------------------------------------------------------------------
6742    // T007 — grouped create writes into the assignee segment (FR-029/30)
6743    // ------------------------------------------------------------------
6744
6745    #[test]
6746    fn create_uses_the_default_layout_template() {
6747        // The documented default: `{assignee}/{type}/{status}` for the directory,
6748        // `{date}-{id}-{slug}.md` for the filename. With no `type:` attribute the
6749        // type component falls back to `default`. The id is pinned here so the
6750        // path is deterministic; a real create generates a random one.
6751        let tmp = tempfile::TempDir::new().unwrap();
6752        let dir = tmp.path().join("issues");
6753        fs::create_dir_all(&dir).unwrap();
6754        let store = Store {
6755            issues_dir: dir,
6756            config: Config::default(),
6757        };
6758        let mut issue = Issue {
6759            projects: None,
6760            id: Some("A1B2C3D4".into()),
6761            number: 0,
6762            title: "Round trip".into(),
6763            status: Status::new("todo"),
6764            labels: vec![],
6765            priority: Priority::default(),
6766            assignee: Some("@alice".into()),
6767            estimate: None,
6768            commit: None,
6769            gitlab_id: None,
6770            linear_id: None,
6771            mission: None,
6772            task: None,
6773            relationships: vec![],
6774            related_issues: vec![],
6775            parent: None,
6776            series: None,
6777            health: None,
6778            files: vec![],
6779            cycles: None,
6780            milestones: None,
6781            reviewer: None,
6782            start_date: None,
6783            due_date: None,
6784            entered_at: None,
6785            source: None,
6786            created: "2026-01-01".into(),
6787            type_: None,
6788            updated: "2026-01-01".into(),
6789            filename: String::new(),
6790            body: String::new(),
6791            history: crate::model::History::new(),
6792            has_retired_links_key: false,
6793            time_entries: vec![],
6794            epics: None,
6795            extra_frontmatter: std::collections::BTreeMap::new(),
6796            archived: false,
6797        };
6798        store.create(&mut issue, "", None).unwrap();
6799        assert_eq!(
6800            issue.filename,
6801            "alice/default/todo/2026-01-01-A1B2C3D4-round-trip.md"
6802        );
6803        assert!(
6804            store
6805                .issues_dir
6806                .join("alice/default/todo/2026-01-01-A1B2C3D4-round-trip.md")
6807                .is_file()
6808        );
6809    }
6810
6811    #[test]
6812    fn create_assigns_a_valid_id_and_leads_the_filename_with_it() {
6813        let (tmp, store) = make_store();
6814        let _ = tmp;
6815        // Two creates with the same title and day: before ids these shared a
6816        // stem and only the `-2` fallback kept them apart; the id now makes the
6817        // names distinct at the source.
6818        let mut a = crate::model::Issue::parse(
6819            "---\nnumber: 0\ntitle: Same title\nstatus: todo\nlabels: []\npriority: medium\n\
6820             created: 2026-01-01\nupdated: 2026-01-01\n---\n\n",
6821        )
6822        .unwrap();
6823        let mut b = a.clone();
6824        store.create(&mut a, "", None).unwrap();
6825        store.create(&mut b, "", None).unwrap();
6826
6827        let id_a = a.id.clone().expect("an id is assigned at create");
6828        let id_b = b.id.clone().expect("an id is assigned at create");
6829        assert!(crate::id::is_issue_id(&id_a), "shaped id: {id_a}");
6830        assert_ne!(id_a, id_b, "two creates must not share an id");
6831        assert!(
6832            a.filename.contains(&id_a),
6833            "filename must carry the id: {}",
6834            a.filename
6835        );
6836        assert!(
6837            a.to_file_contents().contains(&format!("id: {id_a}\n")),
6838            "the id must reach the frontmatter"
6839        );
6840    }
6841
6842    #[test]
6843    fn slug_from_stem_skips_the_id_and_date_prefixes() {
6844        // Legacy name: date prefix stripped.
6845        assert_eq!(slug_from_stem("2026-01-01-fix-login"), "fix-login");
6846        // New-schema name: date then id stripped.
6847        assert_eq!(slug_from_stem("2026-01-01-A1B2C3D4-fix-login"), "fix-login");
6848        // The earlier new-schema order (id then date) is still handled.
6849        assert_eq!(slug_from_stem("A1B2C3D4-2026-01-01-fix-login"), "fix-login");
6850        // No recognisable prefix: used verbatim.
6851        assert_eq!(slug_from_stem("no-prefix"), "no-prefix");
6852    }
6853
6854    #[test]
6855    fn update_always_reconciles_the_file_location() {
6856        // Unconditional auto-migration: a write moves the file to the directory
6857        // `[layout]` renders, even from a legacy/mismatched path.
6858        let tmp = tempfile::TempDir::new().unwrap();
6859        let dir = tmp.path().join("issues");
6860        fs::create_dir_all(&dir).unwrap();
6861        let store = Store {
6862            issues_dir: dir,
6863            config: Config::default(),
6864        };
6865        let mut issue = Issue {
6866            projects: None,
6867            id: Some("A1B2C3D4".into()),
6868            number: 0,
6869            type_: None,
6870            title: "Round trip".into(),
6871            status: Status::new("todo"),
6872            labels: vec![],
6873            priority: Priority::default(),
6874            assignee: Some("@alice".into()),
6875            estimate: None,
6876            commit: None,
6877            gitlab_id: None,
6878            linear_id: None,
6879            mission: None,
6880            task: None,
6881            relationships: vec![],
6882            related_issues: vec![],
6883            parent: None,
6884            series: None,
6885            health: None,
6886            files: vec![],
6887            cycles: None,
6888            milestones: None,
6889            reviewer: None,
6890            start_date: None,
6891            due_date: None,
6892            entered_at: None,
6893            source: None,
6894            created: "2026-01-01".into(),
6895            updated: "2026-01-01".into(),
6896            filename: String::new(),
6897            body: String::new(),
6898            history: crate::model::History::new(),
6899            has_retired_links_key: false,
6900            time_entries: vec![],
6901            epics: None,
6902            extra_frontmatter: std::collections::BTreeMap::new(),
6903            archived: false,
6904        };
6905        store.create(&mut issue, "", None).unwrap();
6906        let old = issue.filename.clone();
6907
6908        // A status change relocates the laid-out file into the new status dir.
6909        issue.status = Status::new("done");
6910        issue.filename = store.update(&old, &issue).unwrap();
6911        assert_eq!(
6912            issue.filename,
6913            "alice/default/done/2026-01-01-A1B2C3D4-round-trip.md"
6914        );
6915        assert!(!store.issues_dir.join(&old).exists());
6916
6917        // A mismatched (legacy flat) file is left for `tix layout migrate`.
6918        let flat = "2026-01-01-legacy.md";
6919        fs::write(
6920            store.issues_dir.join(flat),
6921            "---\nnumber: 9\ntitle: L\nstatus: todo\nlabels: []\npriority: medium\n\
6922             files: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n",
6923        )
6924        .unwrap();
6925        let mut legacy = store
6926            .get(IssueSelector::Filename(flat.to_string()))
6927            .unwrap();
6928        legacy.status = Status::new("done");
6929        let after = store.update(flat, &legacy).unwrap();
6930        assert_eq!(after, "unassigned/default/done/2026-01-01-legacy.md");
6931        assert!(!store.issues_dir.join(flat).exists(), "migrated, not left");
6932        assert!(store.issues_dir.join(&after).is_file());
6933    }
6934
6935    #[test]
6936    fn create_writes_into_assignee_segment_and_records_relative_filename() {
6937        let (tmp, store) = make_store();
6938        let _ = tmp;
6939        let mut issue = Issue {
6940            projects: None,
6941            id: None,
6942            number: 0,
6943            title: "T".into(),
6944            status: Status::default(),
6945            labels: vec![],
6946            priority: Priority::default(),
6947            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
6948            estimate: None,
6949            commit: None,
6950            gitlab_id: None,
6951            linear_id: None,
6952            mission: None,
6953            task: None,
6954            relationships: vec![],
6955            related_issues: vec![],
6956            files: vec![],
6957            cycles: None,
6958            milestones: None,
6959            reviewer: None,
6960            start_date: None,
6961            due_date: None,
6962            created: "2026-01-01".into(),
6963            type_: None,
6964            updated: "2026-01-01".into(),
6965            filename: String::new(),
6966            body: String::new(),
6967            history: crate::model::History::new(),
6968            has_retired_links_key: false,
6969            time_entries: vec![],
6970            parent: None,
6971            series: None,
6972            health: None,
6973            epics: None,
6974            extra_frontmatter: std::collections::BTreeMap::new(),
6975            archived: false,
6976            entered_at: None,
6977            source: None,
6978        };
6979        let path = store
6980            .create(&mut issue, "body", Some("custom-stem"))
6981            .unwrap();
6982        let rel = "herbert-maschke/custom-stem.md";
6983        assert_eq!(issue.filename, rel);
6984        assert_eq!(path, store.issues_dir.join(rel));
6985        assert!(store.issues_dir.join("herbert-maschke").is_dir());
6986        assert!(store.issues_dir.join(rel).is_file());
6987    }
6988
6989    #[test]
6990    fn create_without_assignee_writes_unassigned_segment() {
6991        let (tmp, store) = make_store();
6992        let _ = tmp;
6993        let mut issue = Issue {
6994            projects: None,
6995            id: None,
6996            number: 0,
6997            title: "T".into(),
6998            status: Status::default(),
6999            labels: vec![],
7000            priority: Priority::default(),
7001            assignee: None,
7002            estimate: None,
7003            commit: None,
7004            gitlab_id: None,
7005            linear_id: None,
7006            mission: None,
7007            task: None,
7008            relationships: vec![],
7009            related_issues: vec![],
7010            files: vec![],
7011            cycles: None,
7012            milestones: None,
7013            reviewer: None,
7014            start_date: None,
7015            due_date: None,
7016            created: "2026-01-01".into(),
7017            type_: None,
7018            updated: "2026-01-01".into(),
7019            filename: String::new(),
7020            body: String::new(),
7021            history: crate::model::History::new(),
7022            has_retired_links_key: false,
7023            time_entries: vec![],
7024            parent: None,
7025            series: None,
7026            health: None,
7027            epics: None,
7028            extra_frontmatter: std::collections::BTreeMap::new(),
7029            archived: false,
7030            entered_at: None,
7031            source: None,
7032        };
7033        let path = store.create(&mut issue, "", Some("stem")).unwrap();
7034        assert_eq!(issue.filename, "unassigned/stem.md");
7035        assert!(store.issues_dir.join("unassigned").is_dir());
7036        assert!(store.issues_dir.join("unassigned/stem.md").is_file());
7037        assert_eq!(path, store.issues_dir.join("unassigned/stem.md"));
7038    }
7039
7040    #[test]
7041    fn create_with_at_handle_derives_bare_segment() {
7042        let (tmp, store) = make_store();
7043        let _ = tmp;
7044        let mut issue = Issue {
7045            projects: None,
7046            id: None,
7047            number: 0,
7048            title: "T".into(),
7049            status: Status::default(),
7050            labels: vec![],
7051            priority: Priority::default(),
7052            assignee: Some("@alice".into()),
7053            estimate: None,
7054            commit: None,
7055            gitlab_id: None,
7056            linear_id: None,
7057            mission: None,
7058            task: None,
7059            relationships: vec![],
7060            related_issues: vec![],
7061            files: vec![],
7062            cycles: None,
7063            milestones: None,
7064            reviewer: None,
7065            start_date: None,
7066            due_date: None,
7067            created: "2026-01-01".into(),
7068            type_: None,
7069            updated: "2026-01-01".into(),
7070            filename: String::new(),
7071            body: String::new(),
7072            history: crate::model::History::new(),
7073            has_retired_links_key: false,
7074            time_entries: vec![],
7075            parent: None,
7076            series: None,
7077            health: None,
7078            epics: None,
7079            extra_frontmatter: std::collections::BTreeMap::new(),
7080            archived: false,
7081            entered_at: None,
7082            source: None,
7083        };
7084        store.create(&mut issue, "", Some("stem")).unwrap();
7085        assert_eq!(issue.filename, "alice/stem.md");
7086        assert!(store.issues_dir.join("alice/stem.md").is_file());
7087    }
7088
7089    // ------------------------------------------------------------------
7090    // T008 — reassign(): move-on-reassign (FR-031), never overwrites
7091    // ------------------------------------------------------------------
7092
7093    #[test]
7094    fn reassign_moves_file_between_segments() {
7095        let (tmp, store) = make_store();
7096        let _ = tmp;
7097        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7098        write_issue(
7099            &store,
7100            "alice/2026-01-01-x.md",
7101            &sample_frontmatter(1, "X", "todo"),
7102        );
7103        let new_path = store
7104            .reassign("alice/2026-01-01-x.md", Some("@bob"))
7105            .unwrap();
7106        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x.md"));
7107        assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
7108        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
7109    }
7110
7111    #[test]
7112    fn reassign_into_occupied_segment_picks_deterministic_suffix() {
7113        let (tmp, store) = make_store();
7114        let _ = tmp;
7115        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7116        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
7117        write_issue(
7118            &store,
7119            "alice/2026-01-01-x.md",
7120            &sample_frontmatter(1, "X", "todo"),
7121        );
7122        write_issue(
7123            &store,
7124            "bob/2026-01-01-x.md",
7125            &sample_frontmatter(2, "X", "todo"),
7126        );
7127        let new_path = store
7128            .reassign("alice/2026-01-01-x.md", Some("@bob"))
7129            .unwrap();
7130        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x-1.md"));
7131        assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
7132        assert!(store.issues_dir.join("bob/2026-01-01-x-1.md").is_file());
7133        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
7134    }
7135
7136    #[test]
7137    fn reassign_legacy_flat_file_into_segment() {
7138        let (tmp, store) = make_store();
7139        let _ = tmp;
7140        write_issue(
7141            &store,
7142            "2026-01-01-y.md",
7143            &sample_frontmatter(1, "Y", "todo"),
7144        );
7145        let new_path = store.reassign("2026-01-01-y.md", Some("alice")).unwrap();
7146        assert_eq!(new_path, PathBuf::from("alice/2026-01-01-y.md"));
7147        assert!(store.issues_dir.join("alice/2026-01-01-y.md").is_file());
7148        assert!(!store.issues_dir.join("2026-01-01-y.md").exists());
7149    }
7150
7151    #[test]
7152    fn reassign_to_none_uses_unassigned_fallback() {
7153        let (tmp, store) = make_store();
7154        let _ = tmp;
7155        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7156        write_issue(
7157            &store,
7158            "alice/2026-01-01-z.md",
7159            &sample_frontmatter(1, "Z", "todo"),
7160        );
7161        let new_path = store.reassign("alice/2026-01-01-z.md", None).unwrap();
7162        assert_eq!(new_path, PathBuf::from("unassigned/2026-01-01-z.md"));
7163        assert!(
7164            store
7165                .issues_dir
7166                .join("unassigned/2026-01-01-z.md")
7167                .is_file()
7168        );
7169        assert!(!store.issues_dir.join("alice/2026-01-01-z.md").exists());
7170    }
7171
7172    #[test]
7173    fn reassign_keeps_number_and_created_stable() {
7174        let (tmp, store) = make_store();
7175        let _ = tmp;
7176        let mut issue = Issue {
7177            projects: None,
7178            id: None,
7179            number: 0,
7180            title: "Stable".into(),
7181            status: Status::default(),
7182            labels: vec![],
7183            priority: Priority::default(),
7184            assignee: Some("alice".into()),
7185            estimate: None,
7186            commit: None,
7187            gitlab_id: None,
7188            linear_id: None,
7189            mission: None,
7190            task: None,
7191            relationships: vec![],
7192            related_issues: vec![],
7193            files: vec![],
7194            cycles: None,
7195            milestones: None,
7196            reviewer: None,
7197            start_date: None,
7198            due_date: None,
7199            created: "2026-01-01".into(),
7200            type_: None,
7201            updated: "2026-01-01".into(),
7202            filename: String::new(),
7203            body: String::new(),
7204            history: crate::model::History::new(),
7205            has_retired_links_key: false,
7206            time_entries: vec![],
7207            parent: None,
7208            series: None,
7209            health: None,
7210            epics: None,
7211            extra_frontmatter: std::collections::BTreeMap::new(),
7212            archived: false,
7213            entered_at: None,
7214            source: None,
7215        };
7216        store.create(&mut issue, "body", None).unwrap();
7217        let number = issue.number;
7218        let created = issue.created.clone();
7219        let before = issue.filename.clone();
7220        let new_path = store.reassign(&before, Some("@bob")).unwrap();
7221        let rescan = store.list().unwrap();
7222        let moved = rescan
7223            .iter()
7224            .find(|i| i.filename == new_path)
7225            .expect("moved file must be listed at its new relative path");
7226        assert_eq!(moved.number, number);
7227        assert_eq!(moved.created, created);
7228        assert_eq!(moved.title, "Stable");
7229    }
7230
7231    #[test]
7232    fn reassign_equal_segment_is_noop() {
7233        let (tmp, store) = make_store();
7234        let _ = tmp;
7235        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7236        write_issue(
7237            &store,
7238            "alice/2026-01-01-x.md",
7239            &sample_frontmatter(1, "X", "todo"),
7240        );
7241        let new_path = store
7242            .reassign("alice/2026-01-01-x.md", Some("alice"))
7243            .unwrap();
7244        assert_eq!(new_path, PathBuf::from("alice/2026-01-01-x.md"));
7245        assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
7246    }
7247
7248    #[test]
7249    fn reassign_missing_file_is_not_found() {
7250        let (tmp, store) = make_store();
7251        let _ = tmp;
7252        let err = store.reassign("nope.md", Some("alice")).unwrap_err();
7253        assert!(matches!(err, TixError::NotFound(_)));
7254    }
7255
7256    // ------------------------------------------------------------------
7257    // T009 — validate_filename relaxation + relative-path ops (C-008/C-010)
7258    // ------------------------------------------------------------------
7259
7260    #[test]
7261    fn validate_filename_allows_flat_and_single_segment_prefix() {
7262        validate_filename("2026-01-01-x.md").unwrap();
7263        validate_filename("alice/2026-01-01-x.md").unwrap();
7264    }
7265
7266    #[test]
7267    fn validate_filename_allows_deeper_segments() {
7268        // Configurable layout: depth is unbounded, so a nested path is valid.
7269        validate_filename("alice/bug/in-progress/2026-01-01-x.md").unwrap();
7270    }
7271
7272    #[test]
7273    fn validate_filename_refuses_leading_and_trailing_slash_and_absolute() {
7274        let err = validate_filename("/alice/2026-01-01-x.md").unwrap_err();
7275        assert!(matches!(err, TixError::InvalidInput(_)));
7276        let err = validate_filename("/etc/passwd").unwrap_err();
7277        assert!(matches!(err, TixError::InvalidInput(_)));
7278        let err = validate_filename("alice/").unwrap_err();
7279        assert!(matches!(err, TixError::InvalidInput(_)));
7280    }
7281
7282    #[test]
7283    fn path_traversal_with_segment_dotdot_refused() {
7284        let err = validate_filename("alice/../../x.md").unwrap_err();
7285        assert!(matches!(err, TixError::InvalidInput(_)));
7286        let err = validate_filename("alice/../x.md").unwrap_err();
7287        assert!(matches!(err, TixError::InvalidInput(_)));
7288    }
7289
7290    #[test]
7291    fn get_update_delete_set_status_work_on_grouped_relative_paths() {
7292        let (tmp, store) = make_store();
7293        let _ = tmp;
7294        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7295        write_issue(
7296            &store,
7297            "alice/2026-01-01-g.md",
7298            &sample_frontmatter(7, "G", "todo"),
7299        );
7300        let got = store
7301            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
7302            .unwrap();
7303        assert_eq!(got.number, 7);
7304        let done = store
7305            .set_status("alice/2026-01-01-g.md", StatusChange::to("done"))
7306            .unwrap();
7307        assert_eq!(done.issue.status.display(), "done");
7308        assert!(store.issues_dir.join("alice/2026-01-01-g.md").is_file());
7309        let mut edited = store
7310            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
7311            .unwrap();
7312        edited.title = "Updated".into();
7313        store.update("alice/2026-01-01-g.md", &edited).unwrap();
7314        let reloaded = store
7315            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
7316            .unwrap();
7317        assert_eq!(reloaded.title, "Updated");
7318        store.delete("alice/2026-01-01-g.md").unwrap();
7319        assert!(!store.issues_dir.join("alice/2026-01-01-g.md").exists());
7320    }
7321
7322    // ------------------------------------------------------------------
7323    // T010 — grouped round-trip sweep
7324    // ------------------------------------------------------------------
7325
7326    #[test]
7327    fn grouped_round_trip_create_list_get_update_delete() {
7328        let (tmp, store) = make_store();
7329        let _ = tmp;
7330        let mut issue = Issue {
7331            projects: None,
7332            id: Some("A1B2C3D4".into()),
7333            number: 0,
7334            title: "Round trip".into(),
7335            status: Status::default(),
7336            labels: vec!["bug".into()],
7337            priority: Priority::default(),
7338            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
7339            estimate: None,
7340            commit: None,
7341            gitlab_id: None,
7342            linear_id: None,
7343            mission: None,
7344            task: None,
7345            relationships: vec![],
7346            related_issues: vec![],
7347            files: vec![],
7348            cycles: None,
7349            milestones: None,
7350            reviewer: None,
7351            start_date: None,
7352            due_date: None,
7353            created: "2026-01-01".into(),
7354            type_: None,
7355            updated: "2026-01-01".into(),
7356            filename: String::new(),
7357            body: String::new(),
7358            history: crate::model::History::new(),
7359            has_retired_links_key: false,
7360            time_entries: vec![],
7361            parent: None,
7362            series: None,
7363            health: None,
7364            epics: None,
7365            extra_frontmatter: std::collections::BTreeMap::new(),
7366            archived: false,
7367            entered_at: None,
7368            source: None,
7369        };
7370        store.create(&mut issue, "initial body", None).unwrap();
7371        let rel = issue.filename.clone();
7372        assert_eq!(rel, "herbert-maschke/2026-01-01-A1B2C3D4-round-trip.md");
7373
7374        let listed = store.list().unwrap();
7375        assert_eq!(listed.len(), 1);
7376        assert_eq!(listed[0].filename, rel);
7377        assert_eq!(listed[0].number, issue.number);
7378
7379        let got = store.get(IssueSelector::Filename(rel.clone())).unwrap();
7380        let mut edited = got.clone();
7381        edited.title = "Renamed".into();
7382        store.update(&rel, &edited).unwrap();
7383        let reloaded = store.get(IssueSelector::Filename(rel.clone())).unwrap();
7384        assert_eq!(reloaded.title, "Renamed");
7385
7386        store.delete(&rel).unwrap();
7387        assert!(!store.issues_dir.join(&rel).exists());
7388        assert_eq!(store.list().unwrap(), [] as [crate::model::Issue; 0]);
7389    }
7390
7391    #[test]
7392    fn legacy_flat_file_edit_and_status_still_work_after_layout_flip() {
7393        let (tmp, store) = make_store();
7394        let _ = tmp;
7395        write_issue(
7396            &store,
7397            "2026-01-01-legacy.md",
7398            &sample_frontmatter(5, "Legacy", "todo"),
7399        );
7400        let listed = store.list().unwrap();
7401        assert_eq!(listed.len(), 1);
7402        assert_eq!(listed[0].filename, "2026-01-01-legacy.md");
7403        let mutated = store
7404            .set_status("2026-01-01-legacy.md", StatusChange::to("in progress"))
7405            .unwrap();
7406        assert_eq!(mutated.issue.status.display(), "in progress");
7407        assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
7408    }
7409
7410    // ------------------------------------------------------------------
7411    // T004 — add_asset(): copy + link insert (FR-006, NFR-004)
7412    // ------------------------------------------------------------------
7413
7414    fn write_asset_source(tmp: &tempfile::TempDir, name: &str, contents: &[u8]) -> PathBuf {
7415        let path = tmp.path().join(name);
7416        fs::write(&path, contents).unwrap();
7417        path
7418    }
7419
7420    fn write_issue_with_basic_body(store: &Store, name: &str, number: u32) {
7421        write_issue(
7422            store,
7423            name,
7424            &sample_frontmatter(number, "Asset host", "todo"),
7425        );
7426    }
7427
7428    #[test]
7429    fn add_asset_image_inserts_image_link_and_writes_file() {
7430        let (tmp, store) = make_store();
7431        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7432        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7433        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7434        let added = store
7435            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7436            .unwrap();
7437        assert_eq!(added.written_name, "diagram.png");
7438        assert_eq!(added.link, "![diagram](fix-login-bug-assets/diagram.png)");
7439        assert!(
7440            store
7441                .issues_dir
7442                .join("alice/fix-login-bug-assets/diagram.png")
7443                .is_file()
7444        );
7445    }
7446
7447    #[test]
7448    fn add_asset_non_image_inserts_generic_markdown_link() {
7449        let (tmp, store) = make_store();
7450        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7451        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7452        let src = write_asset_source(&tmp, "report.pdf", b"%PDF");
7453        let added = store
7454            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7455            .unwrap();
7456        assert_eq!(added.link, "[report](fix-login-bug-assets/report.pdf)");
7457        assert!(
7458            store
7459                .issues_dir
7460                .join("alice/fix-login-bug-assets/report.pdf")
7461                .is_file()
7462        );
7463    }
7464
7465    #[test]
7466    fn add_asset_collision_resolves_with_numeric_suffix_and_link_points_at_it() {
7467        let (tmp, store) = make_store();
7468        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7469        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7470        let src = write_asset_source(&tmp, "x.png", b"first");
7471        let first = store
7472            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7473            .unwrap();
7474        assert_eq!(first.written_name, "x.png");
7475        let again = store
7476            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7477            .unwrap();
7478        assert_eq!(again.written_name, "x-1.png");
7479        assert_eq!(again.link, "![x-1](fix-login-bug-assets/x-1.png)");
7480        assert!(
7481            store
7482                .issues_dir
7483                .join("alice/fix-login-bug-assets/x.png")
7484                .is_file()
7485        );
7486        assert!(
7487            store
7488                .issues_dir
7489                .join("alice/fix-login-bug-assets/x-1.png")
7490                .is_file()
7491        );
7492    }
7493
7494    #[test]
7495    fn add_asset_appends_single_link_and_preserves_description() {
7496        let (tmp, store) = make_store();
7497        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7498        write_issue(
7499            &store,
7500            "alice/2026-01-01-fix-login-bug.md",
7501            "---\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",
7502        );
7503        let src = write_asset_source(&tmp, "diagram.svg", b"<svg/>");
7504        store
7505            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7506            .unwrap();
7507        let reloaded = store
7508            .get(crate::storage::IssueSelector::Filename(
7509                "alice/2026-01-01-fix-login-bug.md".into(),
7510            ))
7511            .unwrap();
7512        // Description (above the `## Comments` boundary) untouched.
7513        assert_eq!(reloaded.description(), "Title line.");
7514        // Exactly one link line, appended after the comments section.
7515        let link_count = reloaded.body.matches("![diagram]").count();
7516        assert_eq!(link_count, 1);
7517        assert!(
7518            reloaded
7519                .body
7520                .ends_with("fix-login-bug-assets/diagram.svg)\n")
7521        );
7522    }
7523
7524    #[test]
7525    fn add_asset_missing_issue_is_not_found() {
7526        let (tmp, store) = make_store();
7527        let _ = tmp;
7528        let src = write_asset_source(&tmp, "x.png", b"x");
7529        let err = store.add_asset("missing.md", &src).unwrap_err();
7530        assert!(matches!(err, TixError::NotFound(_)));
7531    }
7532
7533    #[test]
7534    fn add_asset_legacy_flat_file_uses_stem_assets_under_issues_dir() {
7535        let (tmp, store) = make_store();
7536        write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
7537        let src = write_asset_source(&tmp, "x.png", b"x");
7538        let added = store.add_asset("2026-01-01-legacy.md", &src).unwrap();
7539        assert_eq!(added.link, "![x](legacy-assets/x.png)");
7540        assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
7541    }
7542
7543    // ------------------------------------------------------------------
7544    // T005 — reassign() moves the assets dir (FR-007)
7545    // ------------------------------------------------------------------
7546
7547    #[test]
7548    fn reassign_moves_asset_dir_with_the_file() {
7549        let (tmp, store) = make_store();
7550        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7551        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7552        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7553        store
7554            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7555            .unwrap();
7556        let new_path = store
7557            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
7558            .unwrap();
7559        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
7560        assert!(
7561            store
7562                .issues_dir
7563                .join("bob/fix-login-bug-assets/diagram.png")
7564                .is_file()
7565        );
7566        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
7567    }
7568
7569    #[test]
7570    fn reassign_without_asset_dir_behaves_as_before() {
7571        let (tmp, store) = make_store();
7572        let _ = tmp;
7573        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7574        write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
7575        let new_path = store
7576            .reassign("alice/2026-01-01-plain.md", Some("@bob"))
7577            .unwrap();
7578        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-plain.md"));
7579        assert!(store.issues_dir.join("bob/2026-01-01-plain.md").is_file());
7580        assert!(!store.issues_dir.join("bob/plain-assets").exists());
7581    }
7582
7583    #[test]
7584    fn reassign_into_segment_with_same_named_asset_dir_uses_suffix_no_loss() {
7585        let (tmp, store) = make_store();
7586        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7587        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
7588        // Both segments already hold a same-named asset dir.
7589        let existing = store.issues_dir.join("bob/fix-login-bug-assets");
7590        fs::create_dir_all(&existing).unwrap();
7591        fs::write(existing.join("keep.txt"), "precious").unwrap();
7592        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7593        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7594        store
7595            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7596            .unwrap();
7597        let new_path = store
7598            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
7599            .unwrap();
7600        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
7601        // Pre-existing target assets untouched, moved dir suffixed.
7602        assert!(existing.join("keep.txt").is_file());
7603        assert!(
7604            store
7605                .issues_dir
7606                .join("bob/fix-login-bug-assets-1/diagram.png")
7607                .is_file()
7608        );
7609        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
7610    }
7611
7612    #[test]
7613    fn reassign_preserves_markdown_link_resolution_in_new_segment() {
7614        let (tmp, store) = make_store();
7615        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7616        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7617        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7618        store
7619            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7620            .unwrap();
7621        store
7622            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
7623            .unwrap();
7624        // The asset dir kept its base name, so the relative link
7625        // `fix-login-bug-assets/diagram.png` resolves in the new segment.
7626        assert!(
7627            store
7628                .issues_dir
7629                .join("bob/fix-login-bug-assets/diagram.png")
7630                .is_file()
7631        );
7632    }
7633
7634    // ------------------------------------------------------------------
7635    // T006 — delete() removes the assets dir (FR-008) + sweep
7636    // ------------------------------------------------------------------
7637
7638    #[test]
7639    fn delete_removes_asset_dir_no_orphans() {
7640        let (tmp, store) = make_store();
7641        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7642        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7643        let src = write_asset_source(&tmp, "x.png", b"x");
7644        store
7645            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7646            .unwrap();
7647        store.delete("alice/2026-01-01-fix-login-bug.md").unwrap();
7648        assert!(
7649            !store
7650                .issues_dir
7651                .join("alice/2026-01-01-fix-login-bug.md")
7652                .exists()
7653        );
7654        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
7655    }
7656
7657    #[test]
7658    fn delete_without_asset_dir_still_deletes_file() {
7659        let (tmp, store) = make_store();
7660        let _ = tmp;
7661        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7662        write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
7663        store.delete("alice/2026-01-01-plain.md").unwrap();
7664        assert!(!store.issues_dir.join("alice/2026-01-01-plain.md").exists());
7665    }
7666
7667    #[test]
7668    fn list_ignores_asset_directories() {
7669        let (tmp, store) = make_store();
7670        write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
7671        let src = write_asset_source(&tmp, "x.png", b"x");
7672        store.add_asset("2026-01-01-legacy.md", &src).unwrap();
7673        // Legacy flat file → `legacy-assets/` sits directly under issues_dir,
7674        // where the `-assets` guard in `list()` must skip it.
7675        let issues = store.list().unwrap();
7676        assert_eq!(issues.len(), 1);
7677        assert_eq!(issues[0].filename, "2026-01-01-legacy.md");
7678    }
7679
7680    #[test]
7681    fn list_ignores_nested_asset_directories_under_segment() {
7682        let (tmp, store) = make_store();
7683        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7684        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7685        let src = write_asset_source(&tmp, "x.png", b"x");
7686        store
7687            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7688            .unwrap();
7689        let issues = store.list().unwrap();
7690        assert_eq!(issues.len(), 1);
7691        assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
7692    }
7693
7694    #[test]
7695    fn asset_dir_with_formless_md_child_is_not_scanned_as_segment() {
7696        let (tmp, store) = make_store();
7697        let _ = tmp;
7698        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7699        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7700        // A `.md` straggler inside an `-assets` dir must never surface as an
7701        // issue segment (guard in `list()` for `<slug>-assets/`).
7702        let assets_dir = store.issues_dir.join("alice/fix-login-bug-assets");
7703        fs::create_dir_all(&assets_dir).unwrap();
7704        fs::write(assets_dir.join("notes.md"), "no frontmatter").unwrap();
7705        let issues = store.list().unwrap();
7706        assert_eq!(issues.len(), 1);
7707        assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
7708    }
7709
7710    // -----------------------------------------------------------------------
7711    // WP02 / T008 — hierarchy writes
7712    // -----------------------------------------------------------------------
7713    //
7714    // Requirement ids in the test names: C-001 (at most one parent), C-003 (no
7715    // child data on the parent), C-004 (every change audited), SC-004 (a rejected
7716    // change writes nothing).
7717    //
7718    // These tests assert the *write* behaviour only. Every rule lives in
7719    // `crate::hierarchy`; the last test below is the guard against one of them
7720    // being copied in here.
7721
7722    /// Three seeded issues, for the hierarchy write tests.
7723    fn hierarchy_trio() -> (TempDir, Store) {
7724        let (tmp, store) = make_store();
7725        for (n, title) in [(1, "A"), (2, "B"), (3, "C")] {
7726            write_issue(
7727                &store,
7728                &format!("2026-01-01-{}.md", title.to_lowercase()),
7729                &bare_frontmatter(n, title),
7730            );
7731        }
7732        (tmp, store)
7733    }
7734
7735    /// A store over one checked-in hierarchy fixture, by file name.
7736    ///
7737    /// `tests/fixtures/hierarchy/` rather than a string built inline, because the
7738    /// interesting cases are exactly the states the command layer refuses to
7739    /// create: a self-parent, a dangling reference. Those can only be reached by
7740    /// writing the file, which is the point of the fixtures.
7741    fn store_from_hierarchy_fixture(name: &str) -> (TempDir, Store) {
7742        let (tmp, store) = make_store();
7743        write_issue(
7744            &store,
7745            name,
7746            &std::fs::read_to_string(format!(
7747                "{}/tests/fixtures/hierarchy/{name}",
7748                env!("CARGO_MANIFEST_DIR")
7749            ))
7750            .unwrap(),
7751        );
7752        (tmp, store)
7753    }
7754
7755    fn bytes_of(store: &Store, number: u32) -> String {
7756        let issue = store.get(IssueSelector::Number(number)).unwrap();
7757        read_issue_file(store, &issue.filename)
7758    }
7759
7760    #[test]
7761    fn t008_set_writes_the_field_and_exactly_one_history_event() {
7762        let (_tmp, store) = hierarchy_trio();
7763        let outcome = store.set_parent("2", "1").unwrap();
7764        assert!(outcome.changed);
7765        assert_eq!(outcome.parent, Some(1));
7766
7767        let child = store.get(IssueSelector::Number(2)).unwrap();
7768        assert_eq!(child.parent, Some(1), "C-001: the number is stored");
7769        assert_eq!(child.history.len(), 1, "C-004: exactly one event");
7770        let event = &child.history.events()[0];
7771        assert_eq!(event.kind, HistoryEventKind::ParentSet);
7772        assert!(
7773            !event.actor.is_empty(),
7774            "an event without an actor is not an audit record"
7775        );
7776        // The detail names both sides, read from the *previous* value.
7777        assert_eq!(event.detail, "parent none -> #1", "got: {}", event.detail);
7778    }
7779
7780    #[test]
7781    fn t008_change_repoints_and_records_parent_changed() {
7782        let (_tmp, store) = hierarchy_trio();
7783        store.set_parent("3", "1").unwrap();
7784        let outcome = store.change_parent("3", "2").unwrap();
7785        assert!(outcome.changed);
7786        assert_eq!(outcome.parent, Some(2));
7787
7788        let child = store.get(IssueSelector::Number(3)).unwrap();
7789        assert_eq!(child.parent, Some(2));
7790        assert_eq!(child.history.len(), 2, "one event per accepted change");
7791        let events = child.history.events();
7792        let last = events.last().unwrap();
7793        assert_eq!(last.kind, HistoryEventKind::ParentChanged);
7794        // Regression: the detail used to be built after the assignment, so a
7795        // repoint recorded `parent #1 -> #1` and named neither side.
7796        assert_eq!(last.detail, "parent #1 -> #2", "got: {}", last.detail);
7797    }
7798
7799    #[test]
7800    fn t008_clear_removes_the_field_and_records_parent_cleared() {
7801        let (_tmp, store) = hierarchy_trio();
7802        store.set_parent("2", "1").unwrap();
7803        let outcome = store.clear_parent("2").unwrap();
7804        assert!(outcome.changed);
7805        assert_eq!(outcome.parent, None);
7806
7807        let child = store.get(IssueSelector::Number(2)).unwrap();
7808        assert_eq!(child.parent, None);
7809        assert_eq!(child.history.len(), 2);
7810        let events = child.history.events();
7811        let last = events.last().unwrap();
7812        assert_eq!(last.kind, HistoryEventKind::ParentCleared);
7813        assert_eq!(last.detail, "parent #1 -> none", "got: {}", last.detail);
7814    }
7815
7816    #[test]
7817    fn t008_reapplying_the_same_parent_is_a_no_op_with_no_second_event() {
7818        let (_tmp, store) = hierarchy_trio();
7819        store.set_parent("2", "1").unwrap();
7820        let after_first = bytes_of(&store, 2);
7821
7822        let second = store.set_parent("2", "1").unwrap();
7823        assert!(!second.changed, "an unchanged parent is not a change");
7824        assert!(second.event.is_none(), "a no-op records nothing (C-004)");
7825        assert_eq!(
7826            bytes_of(&store, 2),
7827            after_first,
7828            "a no-op must not rewrite the file at all"
7829        );
7830    }
7831
7832    #[test]
7833    fn t008_clearing_a_child_with_no_parent_is_a_no_op() {
7834        let (_tmp, store) = hierarchy_trio();
7835        let before = bytes_of(&store, 2);
7836        let outcome = store.clear_parent("2").unwrap();
7837        assert!(!outcome.changed);
7838        assert!(outcome.event.is_none());
7839        assert_eq!(bytes_of(&store, 2), before);
7840    }
7841
7842    #[test]
7843    fn t008_sc004_a_rejected_cycle_leaves_every_file_byte_identical() {
7844        // 1 -> 2 -> 3, then ask 1 to become a child of 3: that closes the loop.
7845        let (_tmp, store) = hierarchy_trio();
7846        store.set_parent("2", "1").unwrap();
7847        store.set_parent("3", "2").unwrap();
7848        let before: Vec<String> = (1..=3).map(|n| bytes_of(&store, n)).collect();
7849
7850        let err = store
7851            .set_parent("1", "3")
7852            .expect_err("1 -> 2 -> 3 -> 1 is a cycle and must be refused");
7853        let msg = err.to_string();
7854        assert!(
7855            msg.contains("cycle"),
7856            "the diagnostic must name the rule: {msg}"
7857        );
7858
7859        // SC-004: nothing written, nothing appended, on *any* file.
7860        for n in 1..=3 {
7861            assert_eq!(
7862                bytes_of(&store, n),
7863                before[(n - 1) as usize],
7864                "issue {n} must be byte-identical after a rejected cycle"
7865            );
7866        }
7867    }
7868
7869    #[test]
7870    fn t008_a_self_parent_is_rejected_and_writes_nothing() {
7871        let (_tmp, store) = hierarchy_trio();
7872        let before = bytes_of(&store, 2);
7873        let err = store
7874            .set_parent("2", "2")
7875            .expect_err("an issue cannot be its own parent");
7876        assert!(err.to_string().contains("self_parent"), "got: {err}");
7877        assert_eq!(bytes_of(&store, 2), before);
7878    }
7879
7880    #[test]
7881    fn t008_a_self_parent_already_on_disk_is_still_refused() {
7882        // The laundering case, through the file path rather than the service.
7883        //
7884        // A child that *already* records itself as its own parent looks like a
7885        // redundant request, and a no-op check placed before validation accepted it
7886        // as one — while the identical request against any other child was refused.
7887        // Only a fixture in that state can catch it: the test above seeds a
7888        // parentless child, so the guard never fired.
7889        let (_tmp, store) = store_from_hierarchy_fixture("self-parent.md");
7890        let before = bytes_of(&store, 7);
7891        let err = store
7892            .set_parent("7", "7")
7893            .expect_err("a stored self-parent must still be refused on a re-set");
7894        assert!(
7895            err.to_string().contains("self_parent"),
7896            "refused for the wrong reason: {err}"
7897        );
7898        assert_eq!(
7899            bytes_of(&store, 7),
7900            before,
7901            "and the refused write must leave the file byte-identical"
7902        );
7903        // `change_parent` shares the path and must refuse identically.
7904        assert!(store.change_parent("7", "7").is_err());
7905        // The fixture really is in the state under test.
7906        assert_eq!(store.get(IssueSelector::Number(7)).unwrap().parent, Some(7));
7907    }
7908
7909    #[test]
7910    fn t008_c003_setting_a_child_writes_nothing_to_the_parents_file() {
7911        // Child membership is derived by inverting the child's pointer; the
7912        // parent file must not learn about it at all.
7913        let (_tmp, store) = hierarchy_trio();
7914        let parent_before = bytes_of(&store, 1);
7915        store.set_parent("2", "1").unwrap();
7916        store.set_parent("3", "1").unwrap();
7917        assert_eq!(
7918            bytes_of(&store, 1),
7919            parent_before,
7920            "the parent's file must be untouched by its children appearing"
7921        );
7922        assert!(
7923            !parent_before.contains("parent:"),
7924            "the parent is a top of the hierarchy and has no parent itself"
7925        );
7926    }
7927
7928    #[test]
7929    fn t008_a_legacy_file_without_the_field_is_unchanged_by_a_no_op_write() {
7930        // The file-format contract, seen through the write path.
7931        let (_tmp, store) = hierarchy_trio();
7932        let before = bytes_of(&store, 2);
7933        assert!(!before.contains("parent:"), "the fixture must be legacy");
7934
7935        store.clear_parent("2").unwrap();
7936        assert_eq!(bytes_of(&store, 2), before, "a no-op must not add the key");
7937    }
7938
7939    #[test]
7940    fn t008_an_unresolvable_selector_is_refused_before_any_write() {
7941        let (_tmp, store) = hierarchy_trio();
7942        let before = bytes_of(&store, 2);
7943        assert!(store.set_parent("2", "no such issue").is_err());
7944        assert!(store.change_parent("2", "999").is_err());
7945        assert_eq!(bytes_of(&store, 2), before);
7946    }
7947
7948    #[test]
7949    fn t008_t008_contains_no_hierarchy_rule_of_its_own() {
7950        // T008's own note: "Storage must contain no hierarchy *rules*. If a check
7951        // appears here that also exists in the service, that is a defect."
7952        //
7953        // Asserted behaviourally rather than by reading the source: a storage
7954        // rejection must be **exactly** what the service says for the same
7955        // snapshot. If someone re-implemented a rule here, the two would drift and
7956        // this comparison would stop holding.
7957        //
7958        // Compared against the service *type's own* rendering, reached through the
7959        // `From<HierarchyError> for TixError` conversion the call site uses. An
7960        // earlier version of this test compared storage against a private
7961        // `hierarchy_error` helper — both sides went through the same function, so
7962        // it certified only that the helper was called, and it could not detect the
7963        // duplication it was meant to police (a second renderer that had drifted
7964        // from the first).
7965        let (_tmp, store) = hierarchy_trio();
7966        store.set_parent("2", "1").unwrap();
7967        store.set_parent("3", "2").unwrap();
7968
7969        let issues = store.list().unwrap();
7970        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
7971        let index = HierarchyIndex::build(&views);
7972        let service =
7973            hierarchy::set_parent(&index, 1, 3).expect_err("the service refuses this too");
7974        let storage = store.set_parent("1", "3").expect_err("and so does storage");
7975
7976        assert_eq!(
7977            storage.to_string(),
7978            TixError::from(service).to_string(),
7979            "storage must surface the service's diagnosis verbatim, not its own"
7980        );
7981        // And the rendering is the shared one, not a lookalike: the kind is
7982        // prefixed exactly once and no diagnostic is dropped.
7983        let text = storage.to_string();
7984        assert!(
7985            text.contains("cycle:") && !text.contains("cycle: cycle:"),
7986            "a lookalike renderer or a doubled prefix: {text}"
7987        );
7988        assert!(
7989            text.starts_with("error[E-tix-hierarchy]: "),
7990            "a hierarchy refusal carries the shared rustc-style header: {text}"
7991        );
7992    }
7993
7994    #[test]
7995    fn t008_a_no_op_reports_no_written_issue_but_still_reports_the_current_parent() {
7996        // Pins the `ParentOutcome` contract, which its own doc comment used to
7997        // contradict: `written` is `None` for a no-op (matching
7998        // `RelationshipOutcome`), and the real state comes from `parent`.
7999        let (_tmp, store) = hierarchy_trio();
8000        store.set_parent("2", "1").unwrap();
8001        let again = store.set_parent("2", "1").unwrap();
8002        assert!(!again.changed);
8003        assert!(
8004            again.written.is_none(),
8005            "a no-op wrote nothing, so there is no written issue to hand back"
8006        );
8007        assert!(
8008            again.event.is_none(),
8009            "and no event, or a retry would pad the audit trail"
8010        );
8011        assert_eq!(
8012            again.parent,
8013            Some(1),
8014            "but the resulting state is still reported, so a caller need not re-read"
8015        );
8016    }
8017
8018    // -----------------------------------------------------------------------
8019    // tix #1541 — the command-level write transaction
8020    // -----------------------------------------------------------------------
8021
8022    /// A failed transaction restores the bytes of **every** file it wrote, not
8023    /// only the last. The intermediate write is the one a naive "undo the last
8024    /// step" rollback would leave behind.
8025    #[test]
8026    fn a_failed_transaction_restores_every_file_it_touched() {
8027        let (_tmp, store) = make_store();
8028        write_issue(
8029            &store,
8030            "2026-01-01-a.md",
8031            &sample_frontmatter(1, "A", "todo"),
8032        );
8033        write_issue(
8034            &store,
8035            "2026-01-01-b.md",
8036            &sample_frontmatter(2, "B", "todo"),
8037        );
8038        let before_a = read_issue_file(&store, "2026-01-01-a.md");
8039        let before_b = read_issue_file(&store, "2026-01-01-b.md");
8040
8041        let mut a = store.get(IssueSelector::Number(1)).unwrap();
8042        a.title = "Changed".into();
8043        let mut b = store.get(IssueSelector::Number(2)).unwrap();
8044        b.title = "Also changed".into();
8045
8046        let err = store
8047            .transaction(|| {
8048                store.update("2026-01-01-a.md", &a)?;
8049                store.update("2026-01-01-b.md", &b)?;
8050                Err::<(), _>(TixError::InvalidInput("boom".into()))
8051            })
8052            .unwrap_err();
8053
8054        assert!(err.to_string().contains("boom"), "got: {err}");
8055        assert_eq!(
8056            read_issue_file(&store, "2026-01-01-a.md"),
8057            before_a,
8058            "the first write must be undone too, not only the last"
8059        );
8060        assert_eq!(read_issue_file(&store, "2026-01-01-b.md"), before_b);
8061        assert_eq!(store.get(IssueSelector::Number(1)).unwrap().title, "A");
8062        assert_eq!(store.get(IssueSelector::Number(2)).unwrap().title, "B");
8063    }
8064
8065    /// A transaction that returns `Ok` keeps its writes — the journal is taken
8066    /// and discarded, never applied.
8067    #[test]
8068    fn a_committed_transaction_keeps_its_writes() {
8069        let (_tmp, store) = make_store();
8070        write_issue(
8071            &store,
8072            "2026-01-01-a.md",
8073            &sample_frontmatter(1, "A", "todo"),
8074        );
8075        let mut a = store.get(IssueSelector::Number(1)).unwrap();
8076        a.title = "Changed".into();
8077
8078        store
8079            .transaction(|| {
8080                store.update("2026-01-01-a.md", &a)?;
8081                Ok(())
8082            })
8083            .unwrap();
8084
8085        assert_eq!(
8086            store.get(IssueSelector::Number(1)).unwrap().title,
8087            "Changed"
8088        );
8089    }
8090
8091    /// The window in #1302 was between two steps that touch *different paths*: a
8092    /// reassign moves the file, then the content is written at the new path. A
8093    /// rollback must undo both — move the file back **and** restore its bytes —
8094    /// which only works if the journal records the pre-image of the original path
8095    /// before the move and replays in reverse.
8096    #[test]
8097    fn a_failed_transaction_undoes_a_reassign_move_and_its_content_write() {
8098        let (_tmp, store) = make_store();
8099        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
8100        write_issue(
8101            &store,
8102            "alice/2026-01-01-x.md",
8103            &sample_frontmatter(5, "X", "todo"),
8104        );
8105        let before = read_issue_file(&store, "alice/2026-01-01-x.md");
8106
8107        let mut edited = store.get(IssueSelector::Number(5)).unwrap();
8108        edited.title = "Edited".into();
8109
8110        let err = store
8111            .transaction(|| {
8112                let moved = store.reassign("alice/2026-01-01-x.md", Some("@bob"))?;
8113                store.update(&moved.to_string_lossy(), &edited)?;
8114                Err::<(), _>(TixError::InvalidInput("boom".into()))
8115            })
8116            .unwrap_err();
8117
8118        assert!(err.to_string().contains("boom"));
8119        assert!(
8120            store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
8121            "the moved file must be back under its original segment"
8122        );
8123        assert!(
8124            !store.issues_dir.join("bob/2026-01-01-x.md").exists(),
8125            "the moved copy must not survive the rollback"
8126        );
8127        assert_eq!(read_issue_file(&store, "alice/2026-01-01-x.md"), before);
8128    }
8129
8130    /// A `delete` inside a failing transaction is undone, so the file is not lost.
8131    #[test]
8132    fn a_failed_transaction_restores_a_deleted_file() {
8133        let (_tmp, store) = make_store();
8134        write_issue(
8135            &store,
8136            "2026-01-01-a.md",
8137            &sample_frontmatter(1, "A", "todo"),
8138        );
8139        let before = read_issue_file(&store, "2026-01-01-a.md");
8140
8141        let _ = store
8142            .transaction(|| {
8143                store.delete("2026-01-01-a.md")?;
8144                Err::<(), _>(TixError::InvalidInput("boom".into()))
8145            })
8146            .unwrap_err();
8147
8148        assert!(store.issues_dir.join("2026-01-01-a.md").is_file());
8149        assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
8150    }
8151
8152    /// A nested transaction joins the outer one: an inner failure rolls back the
8153    /// work the outer transaction did *before* the inner call, because the outer
8154    /// call owns the single journal. Without this a command that wraps another
8155    /// transactional command would silently lose atomicity.
8156    #[test]
8157    fn a_nested_transaction_joins_the_outer_one() {
8158        let (_tmp, store) = make_store();
8159        write_issue(
8160            &store,
8161            "2026-01-01-a.md",
8162            &sample_frontmatter(1, "A", "todo"),
8163        );
8164        let before = read_issue_file(&store, "2026-01-01-a.md");
8165        let mut a = store.get(IssueSelector::Number(1)).unwrap();
8166        a.title = "Outer".into();
8167        let mut a2 = a.clone();
8168        a2.title = "Inner".into();
8169
8170        let err = store
8171            .transaction(|| {
8172                store.update("2026-01-01-a.md", &a)?;
8173                // The inner call must NOT commit or clear the outer journal.
8174                store.transaction(|| {
8175                    store.update("2026-01-01-a.md", &a2)?;
8176                    Err::<(), _>(TixError::InvalidInput("inner boom".into()))
8177                })?;
8178                Ok(())
8179            })
8180            .unwrap_err();
8181
8182        assert!(err.to_string().contains("inner boom"));
8183        assert_eq!(
8184            read_issue_file(&store, "2026-01-01-a.md"),
8185            before,
8186            "the outer write must also be undone, so the whole unit is atomic"
8187        );
8188    }
8189
8190    /// Outside a transaction nothing is journaled: a write that is not wrapped
8191    /// takes effect and stays.
8192    #[test]
8193    fn a_write_outside_a_transaction_is_not_journaled() {
8194        let (_tmp, store) = make_store();
8195        write_issue(
8196            &store,
8197            "2026-01-01-a.md",
8198            &sample_frontmatter(1, "A", "todo"),
8199        );
8200        let mut a = store.get(IssueSelector::Number(1)).unwrap();
8201        a.title = "Plain".into();
8202        store.update("2026-01-01-a.md", &a).unwrap();
8203        assert_eq!(store.get(IssueSelector::Number(1)).unwrap().title, "Plain");
8204    }
8205
8206    // ------------------------------------------------------------------
8207    // WP03 — project association writes (mission first-class-projects-01M3BYBW)
8208    // ------------------------------------------------------------------
8209
8210    /// A store whose **persisted** registry declares project `p-1`, holding issue #1.
8211    ///
8212    /// `p-1` is written to `tix.toml`, unlike the in-memory fixtures elsewhere, so
8213    /// the negative assertion below compares a real registry file before and after.
8214    fn project_store() -> (TempDir, Store) {
8215        let tmp = TempDir::new().unwrap();
8216        let dir = tmp.path().join("issues");
8217        fs::create_dir_all(&dir).unwrap();
8218        let config_path = tmp.path().join("tix.toml");
8219        let mut config = Config {
8220            issues_dir: dir.clone(),
8221            ..Config::default()
8222        };
8223        config.layout.directory = "{assignee}".to_string();
8224        config.layout.move_on_change = false;
8225        config.projects.insert(
8226            "p-1".to_string(),
8227            ProjectConfiguration {
8228                name: "One".into(),
8229                ..ProjectConfiguration::default()
8230            },
8231        );
8232        config.save(&config_path).unwrap();
8233        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
8234        let store = Store {
8235            issues_dir: dir,
8236            config,
8237        };
8238        write_issue(
8239            &store,
8240            "2026-01-01-a.md",
8241            &sample_frontmatter(1, "A", "todo"),
8242        );
8243        (tmp, store)
8244    }
8245
8246    /// The issue as written to disk, not as it was held in memory.
8247    fn reloaded(store: &Store, filename: &str) -> Issue {
8248        store
8249            .get(IssueSelector::Filename(filename.to_string()))
8250            .unwrap()
8251    }
8252
8253    /// A fixture from `tests/fixtures/projects/`, read from the crate root so the
8254    /// path is independent of the test's working directory.
8255    fn project_fixture(name: &str) -> String {
8256        let path = format!(
8257            "{}/tests/fixtures/projects/{name}",
8258            env!("CARGO_MANIFEST_DIR")
8259        );
8260        fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading fixture {name}: {e}"))
8261    }
8262
8263    /// Every fixture, sorted, so a new one is picked up without editing a list.
8264    fn all_project_fixtures() -> Vec<String> {
8265        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/projects");
8266        let mut names: Vec<String> = fs::read_dir(&dir)
8267            .unwrap()
8268            .filter_map(|entry| entry.ok())
8269            .map(|entry| entry.file_name().to_string_lossy().into_owned())
8270            .filter(|name| name.ends_with(".md"))
8271            .collect();
8272        names.sort();
8273        names
8274    }
8275
8276    #[test]
8277    fn t013_an_accepted_association_writes_the_list_and_exactly_one_history_entry() {
8278        let (_tmp, store) = project_store();
8279        let before = read_issue_file(&store, "2026-01-01-a.md");
8280        let outcome = store.associate("1", "p-1").unwrap();
8281
8282        assert!(outcome.changed, "an accepted, changing association writes");
8283        assert!(outcome.written.is_some());
8284        assert_eq!(outcome.project_ids, vec!["p-1".to_string()]);
8285        let event = outcome
8286            .event
8287            .clone()
8288            .expect("an accepted change is audited");
8289        assert_eq!(event.detail, "project `p-1` associated");
8290
8291        // Re-read from disk: the in-memory record is not evidence of what landed.
8292        let after = reloaded(&store, "2026-01-01-a.md");
8293        assert_eq!(after.projects, Some(vec!["p-1".to_string()]));
8294        let events = after.history.events();
8295        assert_eq!(events.len(), 1, "exactly one History entry: {events:?}");
8296        assert_eq!(events[0].kind, HistoryEventKind::ProjectAssociated);
8297        assert_eq!(events[0].detail, "project `p-1` associated");
8298        assert_ne!(
8299            read_issue_file(&store, "2026-01-01-a.md"),
8300            before,
8301            "an accepted change is written"
8302        );
8303    }
8304
8305    #[test]
8306    fn t013_a_refused_association_writes_nothing_and_appends_nothing() {
8307        let (_tmp, store) = project_store();
8308        let before = read_issue_file(&store, "2026-01-01-a.md");
8309
8310        let err = store.associate("1", "p-unknown").unwrap_err();
8311        assert!(
8312            matches!(err, TixError::InvalidInput(_)),
8313            "an unknown project is a user input refusal, got {err:?}"
8314        );
8315        assert_eq!(
8316            read_issue_file(&store, "2026-01-01-a.md"),
8317            before,
8318            "a refusal must leave the file byte-identical"
8319        );
8320        assert!(
8321            reloaded(&store, "2026-01-01-a.md").history.is_empty(),
8322            "a refusal must append nothing"
8323        );
8324
8325        // An unknown issue selector is refused too, and also writes nothing.
8326        let err = store.associate("999", "p-1").unwrap_err();
8327        assert!(matches!(err, TixError::NotFound(_)), "got {err:?}");
8328        assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
8329    }
8330
8331    #[test]
8332    fn t013_a_refusal_reports_the_service_wording_not_a_storage_copy() {
8333        // Cross-checker (8): storage holds no project rule, so a refusal must be the
8334        // service's own diagnostic. A second wording here would be a second rule.
8335        let (_tmp, store) = project_store();
8336        let err = store.associate("1", "p-unknown").unwrap_err();
8337        assert!(
8338            err.to_string()
8339                .contains(&crate::projects::unknown_project_reason("p-unknown")),
8340            "the refusal must come from the service, got: {err}"
8341        );
8342    }
8343
8344    #[test]
8345    fn t013_re_associating_is_idempotent_no_second_entry_and_no_second_event() {
8346        let (_tmp, store) = project_store();
8347        store.associate("1", "p-1").unwrap();
8348        let after_first = read_issue_file(&store, "2026-01-01-a.md");
8349
8350        let second = store.associate("1", "p-1").unwrap();
8351        assert!(!second.changed, "the idempotent re-apply changes nothing");
8352        assert!(second.written.is_none(), "and writes no file");
8353        assert!(second.event.is_none(), "and appends no event");
8354        assert_eq!(second.project_ids, vec!["p-1".to_string()]);
8355        assert_eq!(
8356            read_issue_file(&store, "2026-01-01-a.md"),
8357            after_first,
8358            "the file is byte-identical after a repeated association"
8359        );
8360
8361        let after = reloaded(&store, "2026-01-01-a.md");
8362        assert_eq!(after.history.events().len(), 1, "no second History event");
8363        assert_eq!(
8364            after.projects,
8365            Some(vec!["p-1".to_string()]),
8366            "and no duplicate entry: (issue, project_id) is unique"
8367        );
8368    }
8369
8370    #[test]
8371    fn t013_dissociate_drops_only_the_association_and_records_one_event() {
8372        let (_tmp, store) = project_store();
8373        store.associate("1", "p-1").unwrap();
8374
8375        let outcome = store.dissociate("1", "p-1").unwrap();
8376        assert!(outcome.changed);
8377        assert_eq!(outcome.project_ids, [] as [std::string::String; 0]);
8378        let event = outcome
8379            .event
8380            .clone()
8381            .expect("an accepted change is audited");
8382        assert_eq!(event.detail, "project `p-1` dissociated");
8383
8384        let after = reloaded(&store, "2026-01-01-a.md");
8385        assert_eq!(after.projects, None, "the association is dropped");
8386        assert!(
8387            !read_issue_file(&store, "2026-01-01-a.md").contains("projects:"),
8388            "an empty list is written as an absent key"
8389        );
8390        let events = after.history.events();
8391        assert_eq!(events.len(), 2, "associate + dissociate = two entries");
8392        assert_eq!(events[1].kind, HistoryEventKind::ProjectDissociated);
8393
8394        // Dissociating an absent association is an idempotent no-op.
8395        let again = store.dissociate("1", "p-1").unwrap();
8396        assert!(!again.changed);
8397        assert!(again.event.is_none());
8398        assert_eq!(
8399            reloaded(&store, "2026-01-01-a.md").history.events().len(),
8400            2,
8401            "no third History event"
8402        );
8403    }
8404
8405    #[test]
8406    fn t013_bulk_associate_writes_each_affected_file_independently() {
8407        let (_tmp, store) = project_store();
8408        write_issue(
8409            &store,
8410            "2026-01-01-b.md",
8411            &sample_frontmatter(2, "B", "todo"),
8412        );
8413
8414        let outcomes = store
8415            .bulk_associate(
8416                "p-1",
8417                &["1".to_string(), "999".to_string(), "2".to_string()],
8418            )
8419            .unwrap();
8420
8421        assert_eq!(outcomes.len(), 3, "one entry per selector, in input order");
8422        assert_eq!(outcomes[0].selector, "1");
8423        assert!(outcomes[0].outcome.as_ref().unwrap().changed);
8424        assert!(
8425            outcomes[1].outcome.is_err(),
8426            "a bad selector is its own error, not a failure of the run"
8427        );
8428        assert!(outcomes[2].outcome.as_ref().unwrap().changed);
8429
8430        for filename in ["2026-01-01-a.md", "2026-01-01-b.md"] {
8431            let issue = reloaded(&store, filename);
8432            assert_eq!(issue.projects, Some(vec!["p-1".to_string()]));
8433            assert_eq!(issue.history.events().len(), 1, "{filename}");
8434        }
8435
8436        // An unknown project refuses the entry without writing the file.
8437        write_issue(
8438            &store,
8439            "2026-01-01-c.md",
8440            &sample_frontmatter(3, "C", "todo"),
8441        );
8442        let before = read_issue_file(&store, "2026-01-01-c.md");
8443        let refused = store
8444            .bulk_associate("p-unknown", &["3".to_string()])
8445            .unwrap();
8446        assert!(refused[0].outcome.is_err());
8447        assert_eq!(read_issue_file(&store, "2026-01-01-c.md"), before);
8448    }
8449
8450    #[test]
8451    fn t013_bulk_associate_is_idempotent_across_two_selectors_for_one_issue() {
8452        // Regression: the same issue named by number and by filename. Applying both
8453        // against one immutable read made the second miss the first write and append
8454        // a second `project_associated` event; the working copy means the second
8455        // selector sees the association and reports the service's no-op.
8456        let (_tmp, store) = project_store();
8457        let outcomes = store
8458            .bulk_associate("p-1", &["1".to_string(), "2026-01-01-a.md".to_string()])
8459            .unwrap();
8460        assert_eq!(outcomes.len(), 2, "one entry per selector, in input order");
8461
8462        let first = outcomes[0].outcome.as_ref().unwrap();
8463        assert!(first.changed, "the first selector writes");
8464        assert!(first.written.is_some());
8465        assert!(first.event.is_some(), "and appends its one event");
8466
8467        let second = outcomes[1].outcome.as_ref().unwrap();
8468        assert!(
8469            !second.changed,
8470            "the repeated selector is the idempotent no-op"
8471        );
8472        assert!(second.written.is_none(), "and writes nothing a second time");
8473        assert!(second.event.is_none(), "and appends no second event");
8474        assert_eq!(
8475            second.project_ids,
8476            vec!["p-1".to_string()],
8477            "and still reports the current list"
8478        );
8479
8480        let after = reloaded(&store, "2026-01-01-a.md");
8481        assert_eq!(after.projects, Some(vec!["p-1".to_string()]));
8482        assert_eq!(
8483            after.history.events().len(),
8484            1,
8485            "exactly one History entry across both selectors"
8486        );
8487    }
8488
8489    #[test]
8490    fn t014_nfr_002_every_project_fixture_round_trips_with_no_key_added_removed_or_reordered() {
8491        let names = all_project_fixtures();
8492        for required in [
8493            "no-associations.md",
8494            "one-association.md",
8495            "several-associations.md",
8496            "missing-reference.md",
8497            "legacy-no-key.md",
8498        ] {
8499            assert!(
8500                names.iter().any(|name| name == required),
8501                "the fixture set must cover every shape T014 names; {required} is missing from {names:?}"
8502            );
8503        }
8504        for name in names {
8505            let original = project_fixture(&name);
8506            let issue = Issue::parse(&original).expect("fixture parses");
8507            assert_eq!(
8508                issue.to_file_contents(),
8509                original,
8510                "{name} must round-trip byte-identically"
8511            );
8512            assert_eq!(
8513                Issue::parse(&issue.to_file_contents())
8514                    .unwrap()
8515                    .to_file_contents(),
8516                original,
8517                "{name} must be byte-stable once re-read"
8518            );
8519        }
8520    }
8521
8522    #[test]
8523    fn t014_nfr_002_a_legacy_fixture_gains_no_projects_key() {
8524        let original = project_fixture("legacy-no-key.md");
8525        assert!(!original.contains("projects:"));
8526        let issue = Issue::parse(&original).unwrap();
8527        assert_eq!(issue.projects, None);
8528        assert_eq!(
8529            issue.to_file_contents(),
8530            original,
8531            "a pre-project file must not gain a key"
8532        );
8533    }
8534
8535    #[test]
8536    fn t014_c_003_a_multi_membership_fixture_is_byte_stable_and_ordered() {
8537        let original = project_fixture("several-associations.md");
8538        let issue = Issue::parse(&original).unwrap();
8539        assert_eq!(
8540            issue.projects,
8541            Some(vec![
8542                "p-platform".to_string(),
8543                "p-docs".to_string(),
8544                "p-infra".to_string()
8545            ]),
8546            "declaration order is preserved (NFR-002)"
8547        );
8548        assert_eq!(issue.to_file_contents(), original);
8549    }
8550
8551    #[test]
8552    fn t014_c_005_a_missing_reference_round_trips_through_a_store_unchanged() {
8553        // The registry declares no projects, so `p-deleted` cannot resolve. A loader
8554        // that "helpfully" dropped an unknown id would fail here and nowhere else.
8555        let (_tmp, store) = make_store();
8556        write_issue(
8557            &store,
8558            "missing-reference.md",
8559            &project_fixture("missing-reference.md"),
8560        );
8561        assert!(
8562            store.config.projects.is_empty(),
8563            "the reference is missing by construction"
8564        );
8565
8566        let issue = store.get(IssueSelector::Number(4)).unwrap();
8567        assert_eq!(issue.projects, Some(vec!["p-deleted".to_string()]));
8568
8569        // Save the issue unchanged, then re-read: the id is still there.
8570        store.update(&issue.filename, &issue).unwrap();
8571        assert_eq!(
8572            read_issue_file(&store, "missing-reference.md"),
8573            project_fixture("missing-reference.md"),
8574            "saving a missing reference must not drop or rewrite it"
8575        );
8576        let reloaded_issue = store.get(IssueSelector::Number(4)).unwrap();
8577        assert_eq!(reloaded_issue.projects, Some(vec!["p-deleted".to_string()]));
8578    }
8579
8580    #[test]
8581    fn t014_c_003_associating_an_issue_writes_no_member_list_to_the_registry() {
8582        let (tmp, store) = project_store();
8583        let config_path = tmp.path().join("tix.toml");
8584        let before = fs::read_to_string(&config_path).unwrap();
8585        assert!(
8586            before.contains("[projects.p-1]"),
8587            "the record is persisted before the association: {before}"
8588        );
8589
8590        store.associate("1", "p-1").unwrap();
8591
8592        let after = fs::read_to_string(&config_path).unwrap();
8593        assert_eq!(
8594            after, before,
8595            "the project's registry record must not grow a member list"
8596        );
8597        assert!(
8598            !after.contains("members"),
8599            "membership is derived, never stored on the project:\n{after}"
8600        );
8601    }
8602
8603    // ------------------------------------------------------------------
8604    // WP03 — epic reference writes (mission epics-work-items-01M3BYW4)
8605    // ------------------------------------------------------------------
8606
8607    /// A store whose **persisted** registry declares epic `e-7c4a91`, holding
8608    /// issue #1.
8609    ///
8610    /// The epic is written to `tix.toml`, unlike the in-memory fixtures elsewhere,
8611    /// so the negative assertion below compares a real registry file before and
8612    /// after.
8613    fn epic_store() -> (TempDir, Store) {
8614        let tmp = TempDir::new().unwrap();
8615        let dir = tmp.path().join("issues");
8616        fs::create_dir_all(&dir).unwrap();
8617        let config_path = tmp.path().join("tix.toml");
8618        let mut config = Config {
8619            issues_dir: dir.clone(),
8620            ..Config::default()
8621        };
8622        config.layout.directory = "{assignee}".to_string();
8623        config.layout.move_on_change = false;
8624        config.epics.insert(
8625            "e-7c4a91".to_string(),
8626            EpicConfiguration {
8627                name: "One".into(),
8628                ..EpicConfiguration::default()
8629            },
8630        );
8631        config.save(&config_path).unwrap();
8632        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
8633        let store = Store {
8634            issues_dir: dir,
8635            config,
8636        };
8637        write_issue(
8638            &store,
8639            "2026-01-01-a.md",
8640            &sample_frontmatter(1, "A", "todo"),
8641        );
8642        (tmp, store)
8643    }
8644
8645    /// A fixture from `tests/fixtures/epics/`, read from the crate root so the path
8646    /// is independent of the test's working directory.
8647    fn epic_fixture(name: &str) -> String {
8648        let path = format!("{}/tests/fixtures/epics/{name}", env!("CARGO_MANIFEST_DIR"));
8649        fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading fixture {name}: {e}"))
8650    }
8651
8652    /// Every fixture, sorted, so a new one is picked up without editing a list.
8653    fn all_epic_fixtures() -> Vec<String> {
8654        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/epics");
8655        let mut names: Vec<String> = fs::read_dir(&dir)
8656            .unwrap()
8657            .filter_map(|entry| entry.ok())
8658            .map(|entry| entry.file_name().to_string_lossy().into_owned())
8659            .filter(|name| name.ends_with(".md"))
8660            .collect();
8661        names.sort();
8662        names
8663    }
8664
8665    #[test]
8666    fn t015_an_accepted_reference_writes_the_list_and_exactly_one_history_entry() {
8667        let (_tmp, store) = epic_store();
8668        let before = read_issue_file(&store, "2026-01-01-a.md");
8669        let outcome = store.reference_epic("1", "e-7c4a91").unwrap();
8670
8671        assert!(outcome.changed, "an accepted, changing reference writes");
8672        assert!(outcome.written.is_some());
8673        assert_eq!(outcome.epic_ids, vec!["e-7c4a91".to_string()]);
8674        let event = outcome
8675            .event
8676            .clone()
8677            .expect("an accepted change is audited");
8678        assert_eq!(event.detail, "epic `e-7c4a91` referenced");
8679
8680        // Re-read from disk: the in-memory record is not evidence of what landed.
8681        let after = reloaded(&store, "2026-01-01-a.md");
8682        assert_eq!(after.epics, Some(vec!["e-7c4a91".to_string()]));
8683        let events = after.history.events();
8684        assert_eq!(events.len(), 1, "exactly one History entry: {events:?}");
8685        assert_eq!(events[0].kind, HistoryEventKind::EpicReferenced);
8686        assert_eq!(events[0].detail, "epic `e-7c4a91` referenced");
8687        assert_ne!(
8688            read_issue_file(&store, "2026-01-01-a.md"),
8689            before,
8690            "an accepted change is written"
8691        );
8692    }
8693
8694    #[test]
8695    fn t015_a_refused_reference_writes_nothing_and_appends_nothing() {
8696        let (_tmp, store) = epic_store();
8697        let before = read_issue_file(&store, "2026-01-01-a.md");
8698
8699        let err = store.reference_epic("1", "e-unknown").unwrap_err();
8700        assert!(
8701            matches!(err, TixError::InvalidInput(_)),
8702            "an unknown epic is a user input refusal, got {err:?}"
8703        );
8704        assert_eq!(
8705            read_issue_file(&store, "2026-01-01-a.md"),
8706            before,
8707            "a refusal must leave the file byte-identical"
8708        );
8709        assert!(
8710            reloaded(&store, "2026-01-01-a.md").history.is_empty(),
8711            "a refusal must append nothing"
8712        );
8713
8714        // An unknown issue selector is refused too, and also writes nothing.
8715        let err = store.reference_epic("999", "e-7c4a91").unwrap_err();
8716        assert!(matches!(err, TixError::NotFound(_)), "got {err:?}");
8717        assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
8718    }
8719
8720    #[test]
8721    fn t015_a_refusal_reports_the_service_wording_not_a_storage_copy() {
8722        // Storage holds no epic rule, so a refusal must be the service's own
8723        // diagnostic. A second wording here would be a second rule.
8724        let (_tmp, store) = epic_store();
8725        let err = store.reference_epic("1", "e-unknown").unwrap_err();
8726        assert!(
8727            err.to_string()
8728                .contains(&crate::epics::unknown_epic_reason("e-unknown")),
8729            "the refusal must come from the service, got: {err}"
8730        );
8731    }
8732
8733    #[test]
8734    fn t015_re_referencing_is_idempotent_no_second_entry_and_no_second_event() {
8735        let (_tmp, store) = epic_store();
8736        store.reference_epic("1", "e-7c4a91").unwrap();
8737        let after_first = read_issue_file(&store, "2026-01-01-a.md");
8738
8739        let second = store.reference_epic("1", "e-7c4a91").unwrap();
8740        assert!(!second.changed, "the idempotent re-apply changes nothing");
8741        assert!(second.written.is_none(), "and writes no file");
8742        assert!(second.event.is_none(), "and appends no event");
8743        assert_eq!(second.epic_ids, vec!["e-7c4a91".to_string()]);
8744        assert_eq!(
8745            read_issue_file(&store, "2026-01-01-a.md"),
8746            after_first,
8747            "the file is byte-identical after a repeated reference"
8748        );
8749
8750        let after = reloaded(&store, "2026-01-01-a.md");
8751        assert_eq!(after.history.events().len(), 1, "no second History event");
8752        assert_eq!(
8753            after.epics,
8754            Some(vec!["e-7c4a91".to_string()]),
8755            "and no duplicate entry: (issue, epic_id) is unique"
8756        );
8757    }
8758
8759    #[test]
8760    fn t015_dereference_drops_only_the_reference_and_records_one_event() {
8761        let (_tmp, store) = epic_store();
8762        store.reference_epic("1", "e-7c4a91").unwrap();
8763
8764        let outcome = store.dereference_epic("1", "e-7c4a91").unwrap();
8765        assert!(outcome.changed);
8766        assert_eq!(outcome.epic_ids, [] as [std::string::String; 0]);
8767        let event = outcome
8768            .event
8769            .clone()
8770            .expect("an accepted change is audited");
8771        assert_eq!(event.detail, "epic `e-7c4a91` dereferenced");
8772
8773        let after = reloaded(&store, "2026-01-01-a.md");
8774        assert_eq!(after.epics, None, "the reference is dropped");
8775        assert!(
8776            !read_issue_file(&store, "2026-01-01-a.md").contains("epics:"),
8777            "an empty list is written as an absent key"
8778        );
8779        let events = after.history.events();
8780        assert_eq!(events.len(), 2, "reference + dereference = two entries");
8781        assert_eq!(events[1].kind, HistoryEventKind::EpicDereferenced);
8782
8783        // Dereferencing an absent reference is an idempotent no-op.
8784        let again = store.dereference_epic("1", "e-7c4a91").unwrap();
8785        assert!(!again.changed);
8786        assert!(again.event.is_none());
8787        assert_eq!(
8788            reloaded(&store, "2026-01-01-a.md").history.events().len(),
8789            2,
8790            "no third History event"
8791        );
8792    }
8793
8794    #[test]
8795    fn t015_dereference_retains_a_missing_reference_and_removes_it_verbatim() {
8796        // The repair path: a stored id that is no longer a valid shape or no longer
8797        // in the registry is removed by verbatim comparison, not by resolution.
8798        let (_tmp, store) = epic_store();
8799        write_issue(
8800            &store,
8801            "2026-01-01-b.md",
8802            "---\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.",
8803        );
8804        let outcome = store.dereference_epic("2", "e-deleted").unwrap();
8805        assert!(outcome.changed, "a stale reference is removable");
8806        assert_eq!(outcome.epic_ids, [] as [std::string::String; 0]);
8807        assert_eq!(reloaded(&store, "2026-01-01-b.md").epics, None);
8808    }
8809
8810    #[test]
8811    fn t015_bulk_reference_writes_each_affected_file_independently() {
8812        let (_tmp, store) = epic_store();
8813        write_issue(
8814            &store,
8815            "2026-01-01-b.md",
8816            &sample_frontmatter(2, "B", "todo"),
8817        );
8818
8819        let outcomes = store
8820            .bulk_reference(
8821                "e-7c4a91",
8822                &["1".to_string(), "999".to_string(), "2".to_string()],
8823            )
8824            .unwrap();
8825
8826        assert_eq!(outcomes.len(), 3, "one entry per selector, in input order");
8827        assert_eq!(outcomes[0].selector, "1");
8828        assert!(outcomes[0].outcome.as_ref().unwrap().changed);
8829        assert!(
8830            outcomes[1].outcome.is_err(),
8831            "a bad selector is its own error, not a failure of the run"
8832        );
8833        assert!(outcomes[2].outcome.as_ref().unwrap().changed);
8834
8835        for filename in ["2026-01-01-a.md", "2026-01-01-b.md"] {
8836            let issue = reloaded(&store, filename);
8837            assert_eq!(issue.epics, Some(vec!["e-7c4a91".to_string()]));
8838            assert_eq!(issue.history.events().len(), 1, "{filename}");
8839        }
8840
8841        // An unknown epic refuses the entry without writing the file.
8842        write_issue(
8843            &store,
8844            "2026-01-01-c.md",
8845            &sample_frontmatter(3, "C", "todo"),
8846        );
8847        let before = read_issue_file(&store, "2026-01-01-c.md");
8848        let refused = store
8849            .bulk_reference("e-unknown", &["3".to_string()])
8850            .unwrap();
8851        assert!(refused[0].outcome.is_err());
8852        assert_eq!(read_issue_file(&store, "2026-01-01-c.md"), before);
8853    }
8854
8855    #[test]
8856    fn t015_bulk_reference_is_idempotent_across_two_selectors_for_one_issue() {
8857        // Regression guard, mirrored from the project path: the same issue named by
8858        // number and by filename must not append a second `epic_referenced` event.
8859        let (_tmp, store) = epic_store();
8860        let outcomes = store
8861            .bulk_reference(
8862                "e-7c4a91",
8863                &["1".to_string(), "2026-01-01-a.md".to_string()],
8864            )
8865            .unwrap();
8866        assert_eq!(outcomes.len(), 2, "one entry per selector, in input order");
8867
8868        let first = outcomes[0].outcome.as_ref().unwrap();
8869        assert!(first.changed, "the first selector writes");
8870        assert!(first.written.is_some());
8871        assert!(first.event.is_some(), "and appends its one event");
8872
8873        let second = outcomes[1].outcome.as_ref().unwrap();
8874        assert!(
8875            !second.changed,
8876            "the repeated selector is the idempotent no-op"
8877        );
8878        assert!(second.written.is_none(), "and writes nothing a second time");
8879        assert!(second.event.is_none(), "and appends no second event");
8880        assert_eq!(
8881            second.epic_ids,
8882            vec!["e-7c4a91".to_string()],
8883            "and still reports the current list"
8884        );
8885
8886        let after = reloaded(&store, "2026-01-01-a.md");
8887        assert_eq!(after.epics, Some(vec!["e-7c4a91".to_string()]));
8888        assert_eq!(
8889            after.history.events().len(),
8890            1,
8891            "exactly one History entry across both selectors"
8892        );
8893    }
8894
8895    #[test]
8896    fn t016_nfr_002_every_epic_fixture_round_trips_with_no_key_added_removed_or_reordered() {
8897        let names = all_epic_fixtures();
8898        for required in [
8899            "no-references.md",
8900            "one-reference.md",
8901            "several-references.md",
8902            "missing-reference.md",
8903            "legacy-no-key.md",
8904            "task-labelled.md",
8905        ] {
8906            assert!(
8907                names.iter().any(|name| name == required),
8908                "the fixture set must cover every shape T016 names; {required} is missing from {names:?}"
8909            );
8910        }
8911        for name in names {
8912            let original = epic_fixture(&name);
8913            let issue = Issue::parse(&original).expect("fixture parses");
8914            assert_eq!(
8915                issue.to_file_contents(),
8916                original,
8917                "{name} must round-trip byte-identically"
8918            );
8919            assert_eq!(
8920                Issue::parse(&issue.to_file_contents())
8921                    .unwrap()
8922                    .to_file_contents(),
8923                original,
8924                "{name} must be byte-stable once re-read"
8925            );
8926        }
8927    }
8928
8929    #[test]
8930    fn t016_nfr_002_a_legacy_fixture_gains_no_epics_key() {
8931        let original = epic_fixture("legacy-no-key.md");
8932        assert!(!original.contains("epics:"));
8933        let issue = Issue::parse(&original).unwrap();
8934        assert_eq!(issue.epics, None);
8935        assert_eq!(
8936            issue.to_file_contents(),
8937            original,
8938            "a pre-epic file must not gain a key"
8939        );
8940    }
8941
8942    #[test]
8943    fn t016_c_003_a_multi_reference_fixture_is_byte_stable_and_ordered() {
8944        let original = epic_fixture("several-references.md");
8945        let issue = Issue::parse(&original).unwrap();
8946        assert_eq!(
8947            issue.epics,
8948            Some(vec![
8949                "e-platform".to_string(),
8950                "e-docs".to_string(),
8951                "e-infra".to_string()
8952            ]),
8953            "declaration order is preserved (NFR-002)"
8954        );
8955        assert_eq!(issue.to_file_contents(), original);
8956    }
8957
8958    #[test]
8959    fn t016_c_005_a_missing_reference_round_trips_through_a_store_unchanged() {
8960        // The registry declares no epics, so `e-deleted` cannot resolve. A loader
8961        // that "helpfully" dropped an unknown id would fail here and nowhere else.
8962        let (_tmp, store) = make_store();
8963        write_issue(
8964            &store,
8965            "missing-reference.md",
8966            &epic_fixture("missing-reference.md"),
8967        );
8968        assert!(
8969            store.config.epics.is_empty(),
8970            "the reference is missing by construction"
8971        );
8972
8973        let issue = store.get(IssueSelector::Number(4)).unwrap();
8974        assert_eq!(issue.epics, Some(vec!["e-deleted".to_string()]));
8975
8976        // Save the issue unchanged, then re-read: the id is still there.
8977        store.update(&issue.filename, &issue).unwrap();
8978        assert_eq!(
8979            read_issue_file(&store, "missing-reference.md"),
8980            epic_fixture("missing-reference.md"),
8981            "saving a missing reference must not drop or rewrite it"
8982        );
8983        let reloaded_issue = store.get(IssueSelector::Number(4)).unwrap();
8984        assert_eq!(reloaded_issue.epics, Some(vec!["e-deleted".to_string()]));
8985    }
8986
8987    #[test]
8988    fn t016_c_001_a_task_labelled_fixture_round_trips_and_gains_no_task_key() {
8989        // Tasks are issues (C-001): a `task` label and the Spec Kitty `task`
8990        // pointer create no new entity and no new key. This field must not have
8991        // introduced a `task`/`work_item`/`epic_task` key.
8992        let original = epic_fixture("task-labelled.md");
8993        let issue = Issue::parse(&original).unwrap();
8994        assert_eq!(issue.epics, Some(vec!["e-7c4a91".to_string()]));
8995        assert_eq!(issue.task, Some("WP03".to_string()));
8996
8997        let rendered = issue.to_file_contents();
8998        assert_eq!(rendered, original, "the fixture round-trips unchanged");
8999        assert_eq!(
9000            rendered.matches("task:").count(),
9001            1,
9002            "exactly one `task:` key — the Spec Kitty pointer, never a second:\n{rendered}"
9003        );
9004        for forbidden in ["work_item:", "epic_task:"] {
9005            assert!(
9006                !rendered.contains(forbidden),
9007                "`{forbidden}` must not exist (C-001):\n{rendered}"
9008            );
9009        }
9010    }
9011
9012    #[test]
9013    fn t016_c_003_referencing_an_issue_writes_no_member_list_to_the_registry() {
9014        let (tmp, store) = epic_store();
9015        let config_path = tmp.path().join("tix.toml");
9016        let before = fs::read_to_string(&config_path).unwrap();
9017        assert!(
9018            before.contains("[epics") && before.contains("e-7c4a91"),
9019            "the record is persisted before the reference: {before}"
9020        );
9021
9022        store.reference_epic("1", "e-7c4a91").unwrap();
9023
9024        let after = fs::read_to_string(&config_path).unwrap();
9025        assert_eq!(
9026            after, before,
9027            "the epic's registry record must not grow a member list"
9028        );
9029        assert!(
9030            !after.contains("members"),
9031            "membership is derived, never stored on the epic:\n{after}"
9032        );
9033    }
9034}