tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
#[allow(clippy::wildcard_imports)]
use super::*;

impl Store {
    /// Create the typed relationship `a --kind--> b`.
    ///
    /// Selectors resolve with the same grammar as the selector-taking
    /// commands. The edge is stored **once**, in the frontmatter of whichever
    /// issue owns the canonical edge — the `from` side for a directional kind,
    /// and the lower-numbered side for `related`, which is order-normalized so
    /// both spellings of an association are one edge. No back-reference is
    /// written on the other issue: that symmetry was the retired model.
    ///
    /// Validation runs through the service first, so a self-link, a missing
    /// target, a conflicting kind, or a cycle is refused **before any file is
    /// touched** (SC-005). Re-asserting an edge that already exists is an
    /// idempotent no-op: no file is written and no History event is appended.
    pub fn link(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
        let issue_a = self.resolve_issue(a)?;
        let issue_b = self.resolve_issue(b)?;
        // The graph borrows the loaded issues, so both are built here and stay
        // alive until this function returns. The service is the only place
        // relationship semantics live (C-003).
        let issues = self.list_all()?;
        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
        let done_column = self.relationship_done_column();
        let graph = RelationshipGraph::new(&views, &done_column);

        let verdict = graph.validate_create(issue_a.number, issue_b.number, kind);
        if verdict.blocking() {
            return Err(relationship_error(&verdict.errors));
        }

        // Idempotency goes through the service's own lookup, so an inverse
        // spelling or a non-canonical stored order still matches.
        if let Some(existing) = graph.find_canonical(issue_a.number, issue_b.number, kind) {
            // Nothing is written and no History event is appended. The **real
            // row** is reported rather than a synthetic one: the contract defines
            // the output as the accepted relationship, and an empty id is not
            // addressable by `edit_relation`/`remove_relation`, so returning one
            // hands the caller a dangling reference (F15).
            return Ok(RelationshipOutcome {
                written: None,
                relationship: existing.clone(),
                changed: false,
            });
        }

        let canonical = CanonicalEdge::new(issue_a.number, issue_b.number, kind);
        let mut owner = self.get(IssueSelector::Number(canonical.from))?;
        let target_title = self
            .get(IssueSelector::Number(canonical.to))
            .map(|i| i.title)
            .unwrap_or_default();
        let relationship =
            Relationship::new(canonical.from, canonical.to, canonical.kind, &target_title);
        let detail = format!("created {}: {}", relationship.id, relationship.display);
        owner.relationships.push(relationship.clone());
        owner.history.append_now(
            &actor_identity(),
            HistoryEventKind::RelationshipAdded,
            &detail,
        );
        owner.bump_updated();
        owner.filename = self.update(&owner.filename, &owner)?;

        Ok(RelationshipOutcome {
            written: Some(owner),
            relationship,
            changed: true,
        })
    }

    /// Remove the typed relationship `a --kind--> b`.
    ///
    /// Removing an edge that is not there is an idempotent no-op: no file is
    /// written and no History event is appended.
    pub fn unlink(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
        let canonical = self.canonical_request(a, b, kind)?;
        let stored = self.find_stored_rows(&canonical)?;

        if stored.is_empty() {
            // Idempotent no-op: nothing is written and no History event is
            // appended. The reported relationship is the canonical form the
            // caller asked about.
            return Ok(RelationshipOutcome {
                written: None,
                relationship: canonical_placeholder(&canonical),
                changed: false,
            });
        }

        // Remove every row matching the canonical edge, wherever it is stored,
        // and record **one** History event for the accepted mutation. Redundant
        // rows are one invalid condition to clean up, not N mutations
        // (contract §6: one event per accepted mutation).
        let mut by_file: HashMap<String, Vec<Relationship>> = HashMap::new();
        for (filename, rel) in stored {
            by_file.entry(filename).or_default().push(rel);
        }

        let actor = actor_identity();
        // Ascending filename, for the same reproducibility reason as above.
        let mut file_order: Vec<String> = by_file.keys().cloned().collect();
        file_order.sort_unstable();

        // The first row this call removes, captured before anything is written.
        // M11: a re-scan *after* removal can never match — every matching row is
        // gone — so a successful unlink always fell through to the empty
        // placeholder and reported an unaddressable relationship, while `link`'s
        // no-op path reported a real one. F15 was applied to one path, not both.
        let mut removed_row: Option<Relationship> = None;

        // M10: the summary is computed **once**, from every row this call
        // removes, and the same event is recorded on each file that changes.
        // Appending per file produced two events for one `unlink` when the same
        // edge was hand-authored into both endpoints, while the comment above
        // claimed one event per accepted mutation. One unlink is one mutation.
        let all_removed: Vec<Relationship> = by_file.values().flatten().cloned().collect();
        let summary = if all_removed.len() == 1 {
            let one = &all_removed[0];
            format!("removed {}: {}", one.id, one.display)
        } else {
            let kinds: Vec<String> = all_removed.iter().map(|r| r.kind.to_string()).collect();
            format!(
                "removed {} relationships ({})",
                all_removed.len(),
                kinds.join(", ")
            )
        };

        let mut written: Option<Issue> = None;
        for filename in &file_order {
            let doomed = &by_file[filename];
            let mut owner = self.get(IssueSelector::Filename(filename.clone()))?;
            if removed_row.is_none() {
                removed_row = doomed.first().cloned();
            }
            owner.relationships.retain(|r| r.canonical() != canonical);
            owner
                .history
                .append_now(&actor, HistoryEventKind::RelationshipRemoved, &summary);
            owner.bump_updated();
            owner.filename = self.update(&owner.filename, &owner)?;
            written = Some(owner);
        }

        // The row that *was* removed, captured above — an empty id is not
        // addressable by `edit_relation`/`remove_relation`.
        let relationship = removed_row.unwrap_or_else(|| canonical_placeholder(&canonical));

        Ok(RelationshipOutcome {
            written,
            relationship,
            changed: true,
        })
    }

    /// Retarget and/or re-kind the relationship with this stable id.
    ///
    /// The id is generated once and **survives** the edit, so a relationship a
    /// user has already referenced elsewhere does not change identity when its
    /// target moves (contract §1). `new_to` and `new_kind` are both optional and
    /// naming neither is the identity edit: nothing is written and no History
    /// event is appended.
    ///
    /// Validation goes through [`RelationshipGraph::validate_edit`], so an edit
    /// that would self-link, point at a missing issue, duplicate an existing
    /// edge, or close a cycle is refused **before any file is touched**
    /// (SC-005). An edit may change the *kind* but never the source: the source
    /// is the edge's owner, and moving it would mean deleting a relationship and
    /// creating another, which is what `remove` plus `link` is for.
    pub fn edit_relation(
        &self,
        id: &str,
        new_to: Option<u32>,
        new_kind: Option<RelationKind>,
    ) -> Result<RelationshipOutcome> {
        let issues = self.list_all()?;
        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
        let done_column = self.relationship_done_column();
        let graph = RelationshipGraph::new(&views, &done_column);

        let Some(existing) = graph.find_relationship(id) else {
            // An id that does not resolve is a named diagnostic, not a bare
            // `NotFound`: the user passed a well-formed argument, and what is
            // missing is a relationship, not an issue.
            let mut verdict = crate::relationships::RelationshipValidationResult::clean();
            verdict.errors.push(Diagnostic::unknown_relation(id));
            return Err(relationship_error(&verdict.errors));
        };

        let verdict = graph.validate_edit(id, new_to, new_kind);
        if verdict.blocking() {
            return Err(relationship_error(&verdict.errors));
        }

        let to = new_to.unwrap_or(existing.to);
        let kind = new_kind.unwrap_or(existing.kind);
        let old_canonical = existing.canonical();

        // The new form has to be **canonicalized before anything else is derived
        // from it**, not merely validated. `--type blocked_by` and re-kinding
        // `related` change which issue owns the edge *and* which issue it points
        // at: `A --blocked_by--> B` is stored as `B blocks A`, so the canonical
        // target is the authored `from`, not the authored `to`.
        //
        // Deriving the display name from the *authored* target is what produced
        // `blocks #2 Issue A` for a `2 duplicates 0` edge re-kinded to
        // `blocked_by`: right endpoints, wrong title, and a stored row that lies
        // about the issue it names. Canonicalize first, then read the title off
        // the canonical target.
        let requested = CanonicalEdge::new(existing.from, to, kind);
        let mut updated = existing.clone();
        updated.from = requested.from;
        updated.to = requested.to;
        updated.kind = requested.kind;

        // The target's *current* title, from the canonical target. A target that
        // does not resolve would otherwise store an edge with an empty display
        // name, so this stays lenient and the row carries what it can.
        let target_title = self
            .get(IssueSelector::Number(updated.to))
            .map(|i| i.title)
            .unwrap_or_default();
        updated.refresh_display(&target_title);
        let new_canonical = updated.canonical();

        // No-op detection is on the *resolved* edge, not on the arguments: an
        // edit naming its own current target and kind is the identity, and so is
        // an edit that only re-renders the same display.
        if old_canonical == new_canonical && updated.display == existing.display {
            return Ok(RelationshipOutcome {
                written: None,
                relationship: existing.clone(),
                changed: false,
            });
        }

        let actor = actor_identity();
        let detail = format!("{} → {}: {}", existing.display, updated.display, updated.id);

        // Remove the old row from every file that holds it, without recording
        // a `relationship_removed` event: this is one edit, not a delete plus a
        // create. Only the canonical source is written when ownership does not
        // move, so the common case stays a single-file write.
        // Removal and insertion are **separate passes**, because the canonical
        // source of the new form is not necessarily a file that held the old
        // one. Re-kinding can move ownership — `related` is order-normalised and
        // `blocked_by` swaps the endpoints — and when it does, a loop that both
        // removes and re-adds deletes the row from its old owner and never
        // writes it to the new one. The relationship is then destroyed while the
        // call reports success, and a `blocked_by` re-kind reports a `blocks`
        // edge that does not exist, so every derived read is wrong the same way.
        //
        // Two passes make that shape unrepresentable: the first only ever
        // removes, the second only ever adds.
        let mut removed_anywhere = false;
        for issue in self.list_all()? {
            if !issue
                .relationships
                .iter()
                .any(|r| r.id == id && r.canonical() == old_canonical)
            {
                continue;
            }
            let mut owner = self.get(IssueSelector::Filename(issue.filename.clone()))?;
            owner
                .relationships
                .retain(|r| !(r.id == id && r.canonical() == old_canonical));
            removed_anywhere = true;
            owner.bump_updated();
            owner.filename = self.update(&owner.filename, &owner)?;
        }
        debug_assert!(removed_anywhere, "graph found the row, the scan did not");

        // The new canonical source carries the row and the History event. It may
        // be the file just written, a different file, or one the old form never
        // touched, so all three cases are written here rather than inside the
        // removal loop.
        let mut new_owner = self.get(IssueSelector::Number(new_canonical.from))?;
        new_owner.relationships.push(updated.clone());
        new_owner
            .history
            .append_now(&actor, HistoryEventKind::RelationshipRetargeted, &detail);
        new_owner.bump_updated();
        new_owner.filename = self.update(&new_owner.filename, &new_owner)?;

        Ok(RelationshipOutcome {
            written: Some(self.get(IssueSelector::Number(new_canonical.from))?),
            relationship: updated,
            changed: true,
        })
    }

    /// Remove the relationship with this stable id.
    ///
    /// An id that is not stored is an **idempotent no-op**: no file is written
    /// and no History event is appended, exactly as [`Store::unlink`] treats an
    /// absent edge. `tix link remove` is therefore safe to retry.
    pub fn remove_relation(&self, id: &str) -> Result<RelationshipOutcome> {
        let stored = self.find_stored_rows_by_id(id)?;
        if stored.is_empty() {
            // Reported as a no-op rather than an error, and the reported
            // relationship carries the id the caller asked about so the outcome
            // is addressable in the message.
            return Ok(RelationshipOutcome {
                written: None,
                relationship: Relationship {
                    id: id.to_string(),
                    ..canonical_placeholder(&CanonicalEdge::new(0, 0, RelationKind::Related))
                },
                changed: false,
            });
        }

        let canonical = stored[0].1.canonical();
        // Reuse the edge-keyed removal so both spellings of a remove stay one
        // implementation, including redundant-row cleanup and the single
        // History event per accepted mutation.
        let from = canonical.from.to_string();
        let to = canonical.to.to_string();
        self.unlink(&from, &to, canonical.kind)
    }

    /// Every stored row with this id, paired with the store-relative filename
    /// it lives in.
    ///
    /// Ids are unique per relationship, but a hand-edited file can hold the same
    /// id twice, so this returns all of them and the caller removes all of them.
    pub(in crate::storage) fn find_stored_rows_by_id(
        &self,
        id: &str,
    ) -> Result<Vec<(String, Relationship)>> {
        let mut found = Vec::new();
        for issue in self.list_all()? {
            for rel in &issue.relationships {
                if rel.id == id {
                    found.push((issue.filename.clone(), rel.clone()));
                }
            }
        }
        Ok(found)
    }

    /// The issue set plus the board's configured completion column, for
    /// building a [`RelationshipGraph`] over the whole store.
    ///
    /// The issue set is returned **alongside** the column rather than as a
    /// finished graph because the graph borrows the issues: a caller that wants
    /// three derived reads (`relationships_for`, `blocked_state`,
    /// `dependency_chain`) over one snapshot would otherwise pay three
    /// separate `O(V + E)` builds and risk the three disagreeing.
    ///
    /// This is the single place a read surface obtains its graph, so no surface
    /// can reach past the service to re-derive direction handling, cycle
    /// detection, or blocked state (C-003).
    ///
    /// Reads the **active** set ([`Store::list`]): it is the graph a default
    /// view renders — `tix list --show relationships`, `tix relationship list`
    /// — so archived issues are deliberately absent from it (issue #1492).
    /// Per-issue lookups that must still resolve an archived issue go through
    /// [`Store::relationships_for`], which reads [`Store::list_all`].
    pub fn graph_source(&self) -> Result<(Vec<Issue>, String)> {
        Ok((self.list()?, self.relationship_done_column()))
    }

    /// Resolve a requested pair into the canonical edge it refers to, without
    /// requiring the *target* to exist.
    ///
    /// A dangling edge is a diagnosed, expected condition (`data-model.md` §4,
    /// contract §7), so the removal path must still be able to reach it. Both
    /// selectors are therefore resolved leniently: a selector that fails to
    /// resolve is accepted if it is a bare issue number, because that is enough
    /// to canonicalize the request. Anything else is still `NotFound`.
    pub(in crate::storage) fn canonical_request(
        &self,
        a: &str,
        b: &str,
        kind: RelationKind,
    ) -> Result<CanonicalEdge> {
        let lenient = |sel: &str| -> Result<u32> {
            match self.resolve_issue(sel) {
                Ok(issue) => Ok(issue.number),
                Err(e) => sel.trim().parse::<u32>().map_err(|_| e),
            }
        };
        let from = lenient(a)?;
        let to = lenient(b)?;
        Ok(CanonicalEdge::new(from, to, kind))
    }

    /// Every stored row matching `canonical`, paired with the store-relative
    /// filename it actually lives in.
    ///
    /// This is the single answer to "where is this edge stored", and it is the
    /// reason the three entry points no longer disagree. It matches on
    /// [`Relationship::canonical`] — the service's own normalization — rather
    /// than comparing authored fields, so a row is found no matter which file
    /// holds it or which endpoint order it was written in. Two defects came
    /// from re-deriving that comparison locally: a hand-authored
    /// non-canonically-ordered `related` row was un-removable, and a row
    /// persisted with the input-only `blocked_by` spelling was un-removable too.
    ///
    /// Cost is one `O(V + E)` pass, the same order as the validation the write
    /// path already performs. Correctness of "which file holds this edge" is
    /// worth it; a wrong answer here silently deletes nothing.
    pub(in crate::storage) fn find_stored_rows(
        &self,
        canonical: &CanonicalEdge,
    ) -> Result<Vec<(String, Relationship)>> {
        let mut found = Vec::new();
        for issue in self.list_all()? {
            for rel in &issue.relationships {
                if rel.canonical() == *canonical {
                    found.push((issue.filename.clone(), rel.clone()));
                }
            }
        }
        Ok(found)
    }

    /// Make the issue's relationship set exactly `targets`, with `kind`.
    ///
    /// A typed relationship is stored **once, in its canonical source's file**.
    /// For a directional kind that is the declaring issue; for `related`, which
    /// is order-normalized, it is the lower-numbered of the pair. So this method
    /// may have to write more than one file: an edge requested from an issue
    /// that is not its canonical source belongs to the other issue's frontmatter.
    /// That is the same ownership rule [`Store::link`] already follows, and it
    /// is what "stored once, no mirrored edge" requires.
    ///
    /// Every target number must already resolve (`NotFound` otherwise) and a
    /// self-link is refused — both before anything is written (SC-005).
    /// Relationships that survive keep their stable id and their History
    /// entries; removals are recorded.
    #[allow(clippy::too_many_lines)]
    pub fn set_relationships(
        &self,
        filename: &str,
        targets: &[(u32, RelationKind)],
    ) -> Result<Issue> {
        let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
        let own = issue.number;
        let actor = actor_identity();

        // Validate every target up front, before any mutation (SC-005).
        let mut wanted: Vec<CanonicalEdge> = Vec::with_capacity(targets.len());
        for (target, kind) in targets {
            if *target == own {
                return Err(TixError::InvalidInput(format!(
                    "issue #{own} cannot relate to itself"
                )));
            }
            self.get(IssueSelector::Number(*target))?;
            let canonical = CanonicalEdge::new(own, *target, *kind);
            // Identical canonical triples collapse, so asking for the same edge
            // twice is not two writes and not a self-conflict.
            if !wanted.contains(&canonical) {
                wanted.push(canonical);
            }
        }

        // Every wanted edge is validated **through the service** before the
        // first write, exactly as `link` does. This used to re-implement
        // existence and the self-link rule locally and skip the rest, so with
        // `1 blocks 2` already on disk it would happily store `2 blocks 1` — a
        // reciprocal pair that `link` rejects one line earlier and that
        // contract §3 rules 3 and 4 require be refused. Re-deriving part of the
        // rule set is also the boundary breach C-003 exists to prevent, and it
        // becomes user-reachable the moment WP03 wires `--type`.
        {
            let issues = self.list_all()?;
            let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
            let done_column = self.relationship_done_column();
            let graph = RelationshipGraph::new(&views, &done_column);
            for canonical in &wanted {
                let verdict = graph.validate_create(canonical.from, canonical.to, canonical.kind);
                if verdict.blocking() {
                    return Err(relationship_error(&verdict.errors));
                }
            }
        }

        // Remove: every existing relationship this issue owns that is not
        // wanted, grouped by the file that actually stores it.
        //
        // "Owns" has two cases, and getting this wrong is a silent data bug:
        //
        //  - The canonical `from` is this issue. True for every directional
        //    kind, and for `related` whenever this issue has the lower number.
        //  - The canonical `to` is this issue **and** the kind is
        //    non-directional. A `related` association belongs to both
        //    endpoints even though it is stored once, in the lower-numbered
        //    issue's file. `tix edit 5 --links ""` has to remove a
        //    `5 related 3` edge even though it lives in #3's frontmatter and
        //    #5's own file is empty.
        //
        // An *inbound directional* edge is deliberately excluded: if #3 blocks
        // #5, that edge is #3's declaration, and #5 clearing its own
        // relationship set must not silently rewrite #3's dependency graph.
        let issues = self.list_all()?;
        let mut removals: HashMap<u32, Vec<Relationship>> = HashMap::new();
        for candidate in &issues {
            for rel in &candidate.relationships {
                let canonical = rel.canonical();
                let owned_by_us = canonical.from == own
                    || (canonical.to == own && !canonical.kind.is_directional());
                if owned_by_us && !wanted.contains(&canonical) {
                    removals
                        .entry(canonical.from)
                        .or_default()
                        .push(rel.clone());
                }
            }
        }

        // Add: each wanted edge goes into its canonical source's file. One
        // already present is left alone, so the stable id survives.
        let mut additions: HashMap<u32, Vec<(CanonicalEdge, String)>> = HashMap::new();
        for canonical in &wanted {
            let owner = match self.get(IssueSelector::Number(canonical.from)) {
                Ok(owner) => owner,
                Err(TixError::NotFound(_)) => continue,
                Err(e) => return Err(e),
            };
            if owner
                .relationships
                .iter()
                .any(|r| r.canonical() == *canonical)
            {
                continue;
            }
            let target_title = self
                .get(IssueSelector::Number(canonical.to))
                .map(|i| i.title)
                .unwrap_or_default();
            additions
                .entry(canonical.from)
                .or_default()
                .push((*canonical, target_title));
        }

        if removals.is_empty() && additions.is_empty() {
            return Ok(issue);
        }

        // Apply removals, then additions. This can write **N** files — the
        // removals map is keyed by whichever file actually holds each row, and
        // an order-normalized `related` edge is held by the lower-numbered
        // issue, not the one named by `filename`. A failure partway through
        // would therefore leave the store half-mutated, so every file's
        // pre-write contents are captured on the way through and restored if a
        // later write fails (SC-005: no file is left partially rewritten).
        //
        // The restore is best-effort per file and the original error is
        // returned either way: a rollback that itself fails must not mask the
        // cause.
        // Every file's pre-write contents are captured on the way through and
        // restored if a later write in the same call fails, so a refused
        // mutation leaves nothing behind (SC-005). The restore is best-effort
        // per file and the original error is returned either way: a rollback
        // that itself fails must not mask the cause.
        // Owners are written in ascending number order. `removals` and
        // `additions` are `HashMap`s, so iterating them directly makes the write
        // order — and therefore which files are committed before a failure —
        // differ between runs of the same input.
        let mut removal_order: Vec<u32> = removals.keys().copied().collect();
        removal_order.sort_unstable();
        let mut addition_order: Vec<u32> = additions.keys().copied().collect();
        addition_order.sort_unstable();

        // Write one owner, remembering how to undo it. A nested `fn` rather than
        // a closure so `undo` and `touched_named` can be passed explicitly
        // instead of captured, which would make them unborrowable at the
        // call sites.
        fn apply(
            store: &Store,
            mut owner: Issue,
            undo: &mut Vec<(PathBuf, String)>,
            touched_named: &mut bool,
            named_path: &Path,
        ) -> Result<()> {
            let path = store.issues_dir.join(&owner.filename);
            if path == named_path {
                *touched_named = true;
            }
            if let Ok(previous) = std::fs::read_to_string(&path) {
                undo.push((path, previous));
            }
            owner.bump_updated();
            store.update(&owner.filename, &owner).map(|_| ())
        }

        let named_path = self.issues_dir.join(&issue.filename);
        let mut undo: Vec<(PathBuf, String)> = Vec::new();
        let mut touched_named = false;

        let applied = (|| -> Result<()> {
            for owner_number in &removal_order {
                let doomed = &removals[owner_number];
                let mut owner = self.get(IssueSelector::Number(*owner_number))?;
                let mut changed = false;
                for rel in doomed {
                    let canonical = rel.canonical();
                    let before = owner.relationships.len();
                    owner.relationships.retain(|r| r.canonical() != canonical);
                    if owner.relationships.len() != before {
                        let detail = format!("removed {}: {}", rel.id, rel.display);
                        owner.history.append_now(
                            &actor,
                            HistoryEventKind::RelationshipRemoved,
                            &detail,
                        );
                        changed = true;
                    }
                }
                if changed {
                    apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
                }
            }

            for owner_number in &addition_order {
                let wanted_here = &additions[owner_number];
                let mut owner = self.get(IssueSelector::Number(*owner_number))?;
                for (canonical, target_title) in wanted_here {
                    let rel = Relationship::new(
                        canonical.from,
                        canonical.to,
                        canonical.kind,
                        target_title,
                    );
                    let detail = format!("created {}: {}", rel.id, rel.display);
                    owner
                        .history
                        .append_now(&actor, HistoryEventKind::RelationshipAdded, &detail);
                    owner.relationships.push(rel);
                }
                apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
            }

            // F16. The named issue's own frontmatter can be untouched while its
            // relationship set changed, because an order-normalized `related`
            // edge is stored in the *other* endpoint's file. `tix edit 2 --links
            // 1` then left #2 byte-identical with a stale `updated`, so the
            // command appeared to do nothing to the issue it named and the audit
            // trail lived only on the other endpoint. `updated` tracks the
            // issue's last mutation and its relationship set did change.
            //
            // Inside the rolled-back sequence and through the same `apply`, so
            // its file is captured for restore like every other. Placed after the
            // rollback check it was a fresh partial-write hole: a failure here
            // returned an error with the relationship writes already committed
            // and nothing left to restore.
            if !touched_named {
                let named = self.get(IssueSelector::Filename(filename.to_string()))?;
                apply(self, named, &mut undo, &mut touched_named, &named_path)?;
            }
            Ok(())
        })();

        if let Err(e) = applied {
            for (path, previous) in undo.iter().rev() {
                let _ = atomic_write(&self.issues_dir, path, previous.as_bytes());
            }
            return Err(e);
        }

        self.get(IssueSelector::Filename(filename.to_string()))
    }

    /// Every relationship touching `number`, as the service resolves it.
    ///
    /// **This is what a read surface must render**, not `issue.relationships`.
    /// A relationship is stored once, in its canonical source's frontmatter, so
    /// the stored field is a *partial* view of one endpoint: for `2 related 1`
    /// the edge lives in #1's file, so #2's own `relationships` is empty even
    /// though #2 plainly has a relationship. Reading the raw field made `tix
    /// view 2` and the TUI detail pane show nothing where the service reports
    /// one, which is a user-visible regression against the retired symmetric
    /// model — the old field was mirrored on both sides by construction.
    ///
    /// Routing every surface through one accessor is also what keeps C-003
    /// honest: derived relationship state is computed in exactly one place.
    pub fn relationships_for(&self, number: u32) -> Result<Vec<Relationship>> {
        let issues = self.list_all()?;
        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
        let done_column = self.relationship_done_column();
        let graph = RelationshipGraph::new(&views, &done_column);
        Ok(graph
            .relationships_for(number)
            .into_iter()
            .cloned()
            .collect())
    }

    /// **Every** stored relationship, in deterministic order — the whole graph in
    /// one pass.
    ///
    /// [`Store::relationships_for`] answers the same question for one issue, and
    /// **each of its calls reads the whole tree and rebuilds the graph**. A caller
    /// that wants every edge must therefore ask once: an `N`-issue loop over
    /// `relationships_for` is `N` full scans of `N` files, which at this
    /// repository's size is millions of file reads for one export. This is the
    /// accessor for that caller.
    ///
    /// Archived issues are included, exactly as [`Store::relationships_for`]
    /// includes them — the graph is the same one, built the same way, so the two
    /// agree about which edges exist.
    pub fn all_relationships(&self) -> Result<Vec<Relationship>> {
        let issues = self.list_all()?;
        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
        let done_column = self.relationship_done_column();
        let graph = RelationshipGraph::new(&views, &done_column);
        Ok(graph.all_relationships().to_vec())
    }
}