tix-tracker 0.12.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
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
#[allow(clippy::wildcard_imports)]
use super::*;

/// The heading that introduces the History section, per `data-model.md` §8.
///
/// The section is recognised by a line whose trimmed content is exactly this —
/// nothing else introduces it.
pub const HISTORY_HEADING: &str = "## History";

/// What kind of mutation a [`HistoryEvent`] records.
///
/// Closed set per `data-model.md` §8. `blocked_move_acknowledged` is defined
/// here so WP04 consumes it without editing this file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HistoryEventKind {
    /// A relationship was created.
    RelationshipAdded,
    /// A relationship was removed.
    RelationshipRemoved,
    /// A relationship was retargeted or re-kinded.
    RelationshipRetargeted,
    /// A move toward completion was confirmed while blockers were open.
    BlockedMoveAcknowledged,
    /// An issue gained its first parent.
    ParentSet,
    /// An existing parent reference was repointed.
    ParentChanged,
    /// A parent reference was removed.
    ParentCleared,
    /// A status change was accepted by the transition service and applied.
    ///
    /// WP03. The name says *accepted* because acceptance is the only outcome that
    /// reaches the log: a rejected transition is completely inert and appends
    /// nothing (contract §3.5), and a re-applied transition is idempotent and also
    /// appends nothing. So the presence of this kind in a file **is** the record
    /// that the change was accepted — a name like `transition_attempted` would
    /// imply rejections are logged here too, and they are not.
    TransitionAccepted,
    /// A triage inbox issue was accepted into the normal workflow.
    ///
    /// WP03 (mission `triage-inbox-review-flow-01M3C600`). Four triage kinds
    /// rather than one `triage_decided` for the reason the parent kinds are
    /// separate: the *decision* is the thing an audit reader is looking for, and
    /// `triage_decided — decline …` makes them read the detail to learn which of
    /// four mutually exclusive actions was taken. They extend the one #169 log
    /// (research R5) — there is no second log and no triage-specific section.
    ///
    /// Only **applied** decisions reach the log: a refusal appends nothing and a
    /// repeated identical decision is a no-op (contract `decision-history.md`),
    /// exactly as with [`HistoryEventKind::TransitionAccepted`].
    TriageAccepted,
    /// A triage inbox issue was declined: moved to the configured declined
    /// column, never deleted or archived (C-003).
    TriageDeclined,
    /// A triage inbox issue was marked a duplicate of another issue.
    ///
    /// The event records the decision; the typed `duplicates` edge is the
    /// [`HistoryEventKind::RelationshipAdded`] entry. One decision, two kinds
    /// *only* because the relation already has its own kind and inventing a
    /// second spelling for it would leave the graph and the log disagreeing.
    TriageDuplicated,
    /// A triage inbox issue was deferred: it stays in the inbox with a
    /// refreshed entry time (data-model §5).
    TriageDeferred,
    /// The issue file was archived: renamed in place to a dot-prefixed name
    /// (issue #1492).
    Archived,
    /// An archived issue was restored to its active name (issue #1492).
    Restored,
    /// An issue's health state was updated, with an actor, a timestamp and a
    /// rationale (mission `project-issue-health-status-01M3C8HX`, WP02).
    ///
    /// One kind for an update and one for an override rather than one
    /// `health_changed` with a flag: the *decision* is what an audit reader looks
    /// for, and "somebody changed the health" makes them read the detail to learn
    /// whether it was a measurement or a person overruling one.
    HealthUpdated,
    /// A manual health override was applied over a derived value (WP02).
    HealthOverridden,
    /// An issue gained a project association (mission
    /// `first-class-projects-01M3BYBW`, WP03).
    ///
    /// Two dedicated kinds rather than one generic `projects_changed`, matching
    /// the three parent kinds and the four triage kinds: the *direction* of the
    /// change is what an audit reader is looking for, and `projects_changed —
    /// …` would make them read the detail to learn whether an association was
    /// added or removed. The service declares exactly these two as
    /// [`crate::projects::ProjectEventKind`], so the vocabulary is decided once
    /// and mapped here by [`Self::from_project`].
    ProjectAssociated,
    /// An issue lost a project association (WP03).
    ProjectDissociated,
    /// An issue gained an epic reference (mission
    /// `epics-work-items-01M3BYW4`, WP03).
    ///
    /// Two dedicated kinds rather than one generic `epics_changed`, matching the
    /// three parent kinds, the four triage kinds, and the two project kinds: the
    /// *direction* of the change is what an audit reader is looking for, and
    /// `epics_changed — …` would make them read the detail to learn whether a
    /// reference was added or removed. The service declares exactly these two as
    /// [`crate::epics::EpicEventKind`], so the vocabulary is decided once and
    /// mapped here by [`Self::from_epic`].
    EpicReferenced,
    /// An issue lost an epic reference (WP03).
    EpicDereferenced,
    /// A generated cycle was explicitly closed, capturing its snapshot.
    ///
    /// WP05 of mission `repeating-cycle-schedules-01M3C7W1`. These six kinds carry
    /// the same append-only grammar as every issue event, but they are written to a
    /// **generated cycle record's own body** (`docs/cycles/<stem>.md`), never to an
    /// issue: a cycle is not an issue, and the mission's evidence for one is its
    /// record, its snapshot and the cursor. One kind per action rather than one
    /// `cycle_lifecycle` with a flag, for the same reason the parent/triage/project
    /// kinds are split: the *decision* is what an audit reader looks for, and a
    /// generic kind would make them read the detail to learn which of five
    /// mutually-exclusive actions was taken.
    CycleClosed,
    /// A generated cycle's due date was extended by one cadence period.
    CycleExtended,
    /// A generated cycle's sequence was recorded as skipped.
    CycleSkipped,
    /// A schedule (and its affected instances) was paused.
    CyclePaused,
    /// A schedule (and its affected instances) was resumed.
    CycleResumed,
    /// A schedule's future instances were re-derived from its definition.
    CycleAdjusted,
    /// A closed cycle was explicitly reopened.
    ///
    /// WP04 of mission `cycle-capacity-planning-01M3C85C`. A closed cycle's capacity is
    /// the immutable snapshot captured at close (C-008), so reopening it — the one way
    /// its capacity may be configured again — is a capacity-affecting change and is
    /// recorded rather than performed silently. It extends the same cycle family as
    /// [`HistoryEventKind::CycleClosed`] and is written to the **generated cycle
    /// record's own body**, beside `cycle_closed`: a cycle's evidence is its record, its
    /// snapshot and the cursor, and there is no second log. One kind rather than a flag
    /// on `cycle_closed`, for the reason the other cycle kinds are split — the *decision*
    /// is what an audit reader looks for. An applied reopen appends exactly one entry;
    /// the prior snapshot and every prior event are retained verbatim.
    CycleReopened,
    /// A time entry was created on an issue (mission
    /// `per-issue-time-tracking-01M3C9A4`, WP01 T047).
    ///
    /// Pre-declared here, like `blocked_move_acknowledged` above, so WP02's mutation
    /// service appends the event without editing this file. Three kinds rather than one
    /// `time_changed` for the reason the parent, triage, project and epic families are
    /// split: the *decision* is what an audit reader is looking for, and a generic kind
    /// would make them read the detail to learn whether an entry was added, corrected or
    /// removed. Only an **applied** mutation appends one (`contracts/time-entry.md`,
    /// data-model §2): a refused write appends nothing, and the `detail` carries the
    /// entry id and the created value so the entry is attributable after any later edit.
    TimeEntryCreated,
    /// A time entry's duration, date, author or note was corrected (WP01 T047).
    ///
    /// The `detail` carries the entry id and the before/after values, so the prior value
    /// remains attributable after the correction (C-004). Pre-declared with its siblings
    /// so WP02 never edits this file.
    TimeEntryUpdated,
    /// A time entry was deleted from an issue's current projection (WP01 T047).
    ///
    /// The entry leaves the issue's frontmatter and the event, with its prior value,
    /// stays in History — a deletion removes the current projection, never the evidence
    /// (C-004). Pre-declared with its siblings so WP02 never edits this file.
    TimeEntryDeleted,
    /// An issue's `estimate` value changed (mission
    /// `burndown-scope-analytics-01M3C9J1`, WP01 T046).
    ///
    /// FR-002 distinguishes scope growth from completion, and an estimate change moves
    /// the planned *size* of the work without changing how many issues are in scope —
    /// so it is a kind of its own rather than a flag on a membership or a status event,
    /// and the analytics classification folds it as `estimate_change`. This is the one
    /// class the mission's own table would otherwise never produce: no surface used to
    /// append an event for an estimate edit at all, so the class would have stayed
    /// permanently unknown before an explicit event kind was added for it.
    ///
    /// Appended by `tix edit --estimate` at the seam `--parent` already appends to,
    /// with the before/after values in `detail` (`estimate: <before> -> <after>`,
    /// `none` for an absent or blank value). An unchanged value appends **nothing**,
    /// and a refused edit appends nothing — the entry exists only for a change that
    /// was applied.
    EstimateChanged,
    /// An issue gained or lost a cycle membership (WP01 T046).
    ///
    /// One kind rather than two direction-split ones, and that is the documented
    /// exception to the split-by-direction convention the parent/triage/project/epic
    /// families follow: a single `--cycles` edit routinely adds one cycle *and* removes
    /// another, so "which direction?" has no single answer to split on. The direction is
    /// therefore in `detail` — `cycles: <before> -> <after>`, `none` for an empty list —
    /// which is what makes the change readable without replaying state.
    ///
    /// Appended by `tix edit --cycles` at the same seam, and only when the membership
    /// actually changed: an unchanged list appends nothing.
    CycleMembershipChanged,
}

impl HistoryEventKind {
    /// The canonical snake_case spelling written to the file.
    pub fn as_str(self) -> &'static str {
        match self {
            HistoryEventKind::RelationshipAdded => "relationship_added",
            HistoryEventKind::RelationshipRemoved => "relationship_removed",
            HistoryEventKind::RelationshipRetargeted => "relationship_retargeted",
            HistoryEventKind::BlockedMoveAcknowledged => "blocked_move_acknowledged",
            // Three dedicated kinds rather than one generic `parent_changed` (research
            // open question 2, resolved in `data-model.md` §6.1): "set" and "cleared" are
            // not changes *of* a parent, they are its beginning and its end, and
            // collapsing them leaves the audit trail unable to distinguish "gained a
            // parent" from "moved to a different one" without reconstructing state from
            // the detail text.
            HistoryEventKind::ParentSet => "parent_set",
            HistoryEventKind::ParentChanged => "parent_changed",
            HistoryEventKind::ParentCleared => "parent_cleared",
            HistoryEventKind::TransitionAccepted => "transition_accepted",
            HistoryEventKind::TriageAccepted => "triage_accepted",
            HistoryEventKind::TriageDeclined => "triage_declined",
            HistoryEventKind::TriageDuplicated => "triage_duplicated",
            HistoryEventKind::TriageDeferred => "triage_deferred",
            HistoryEventKind::Archived => "archived",
            HistoryEventKind::Restored => "restored",
            HistoryEventKind::HealthUpdated => "health_updated",
            HistoryEventKind::HealthOverridden => "health_overridden",
            HistoryEventKind::ProjectAssociated => "project_associated",
            HistoryEventKind::ProjectDissociated => "project_dissociated",
            HistoryEventKind::EpicReferenced => "epic_referenced",
            HistoryEventKind::EpicDereferenced => "epic_dereferenced",
            HistoryEventKind::CycleClosed => "cycle_closed",
            HistoryEventKind::CycleExtended => "cycle_extended",
            HistoryEventKind::CycleSkipped => "cycle_skipped",
            HistoryEventKind::CyclePaused => "cycle_paused",
            HistoryEventKind::CycleResumed => "cycle_resumed",
            HistoryEventKind::CycleAdjusted => "cycle_adjusted",
            HistoryEventKind::CycleReopened => "cycle_reopened",
            HistoryEventKind::TimeEntryCreated => "time_entry_created",
            HistoryEventKind::TimeEntryUpdated => "time_entry_updated",
            HistoryEventKind::TimeEntryDeleted => "time_entry_deleted",
            HistoryEventKind::EstimateChanged => "estimate_changed",
            HistoryEventKind::CycleMembershipChanged => "cycle_membership_changed",
        }
    }

    /// Whether this kind records a triage decision (WP03's four).
    ///
    /// The membership test the decision service's idempotence check and
    /// `not_in_triage`/`already_decided` split both ask, so "is this a triage
    /// decision?" has one answer rather than a four-way `matches!` at every
    /// call site — where a fifth kind would be forgotten first.
    #[must_use]
    pub fn is_triage_decision(self) -> bool {
        matches!(
            self,
            HistoryEventKind::TriageAccepted
                | HistoryEventKind::TriageDeclined
                | HistoryEventKind::TriageDuplicated
                | HistoryEventKind::TriageDeferred
        )
    }
}

impl fmt::Display for HistoryEventKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl FromStr for HistoryEventKind {
    type Err = TixError;

    /// Parse a kind spelling. An unknown kind is **rejected**, never coerced
    /// to a default: a silently reclassified audit entry is worse than a
    /// parse failure, and the field is part of an append-only log.
    fn from_str(s: &str) -> Result<Self> {
        match s {
            "relationship_added" => Ok(HistoryEventKind::RelationshipAdded),
            "relationship_removed" => Ok(HistoryEventKind::RelationshipRemoved),
            "relationship_retargeted" => Ok(HistoryEventKind::RelationshipRetargeted),
            "blocked_move_acknowledged" => Ok(HistoryEventKind::BlockedMoveAcknowledged),
            "parent_set" => Ok(HistoryEventKind::ParentSet),
            "parent_changed" => Ok(HistoryEventKind::ParentChanged),
            "parent_cleared" => Ok(HistoryEventKind::ParentCleared),
            "transition_accepted" => Ok(HistoryEventKind::TransitionAccepted),
            "triage_accepted" => Ok(HistoryEventKind::TriageAccepted),
            "triage_declined" => Ok(HistoryEventKind::TriageDeclined),
            "triage_duplicated" => Ok(HistoryEventKind::TriageDuplicated),
            "triage_deferred" => Ok(HistoryEventKind::TriageDeferred),
            "archived" => Ok(HistoryEventKind::Archived),
            "restored" => Ok(HistoryEventKind::Restored),
            "health_updated" => Ok(HistoryEventKind::HealthUpdated),
            "health_overridden" => Ok(HistoryEventKind::HealthOverridden),
            "project_associated" => Ok(HistoryEventKind::ProjectAssociated),
            "project_dissociated" => Ok(HistoryEventKind::ProjectDissociated),
            "epic_referenced" => Ok(HistoryEventKind::EpicReferenced),
            "epic_dereferenced" => Ok(HistoryEventKind::EpicDereferenced),
            "cycle_closed" => Ok(HistoryEventKind::CycleClosed),
            "cycle_extended" => Ok(HistoryEventKind::CycleExtended),
            "cycle_skipped" => Ok(HistoryEventKind::CycleSkipped),
            "cycle_paused" => Ok(HistoryEventKind::CyclePaused),
            "cycle_resumed" => Ok(HistoryEventKind::CycleResumed),
            "cycle_adjusted" => Ok(HistoryEventKind::CycleAdjusted),
            "cycle_reopened" => Ok(HistoryEventKind::CycleReopened),
            "time_entry_created" => Ok(HistoryEventKind::TimeEntryCreated),
            "time_entry_updated" => Ok(HistoryEventKind::TimeEntryUpdated),
            "time_entry_deleted" => Ok(HistoryEventKind::TimeEntryDeleted),
            "estimate_changed" => Ok(HistoryEventKind::EstimateChanged),
            "cycle_membership_changed" => Ok(HistoryEventKind::CycleMembershipChanged),
            other => Err(TixError::Parse(format!(
                "unknown History event kind `{other}`"
            ))),
        }
    }
}

impl HistoryEventKind {
    /// The model kind for a hierarchy event.
    ///
    /// Vocabulary translation, not a rule: the service decides *which* transition
    /// happened, and this maps that decision onto the log's spelling. It lives on
    /// the type that owns the log rather than at each call site, because three
    /// callers need it — the storage write path, the `--parent` edit flag, and MCP's
    /// `update_issue` in WP07 — and a mapping repeated three times is one that will
    /// be completed three times and fixed once.
    pub fn from_parent(kind: crate::hierarchy::ParentEventKind) -> Self {
        match kind {
            crate::hierarchy::ParentEventKind::ParentSet => HistoryEventKind::ParentSet,
            crate::hierarchy::ParentEventKind::ParentChanged => HistoryEventKind::ParentChanged,
            crate::hierarchy::ParentEventKind::ParentCleared => HistoryEventKind::ParentCleared,
        }
    }

    /// The model kind for a project association event (WP03).
    ///
    /// The same vocabulary translation as [`Self::from_parent`]: the project
    /// service decides *which* mutation happened by returning a
    /// [`crate::projects::ProjectEventKind`], and this maps that decision onto the
    /// log's spelling. It lives on the type that owns the log because more than one
    /// caller needs it — the storage write path, and the CLI/TUI/MCP surfaces that
    /// follow — and a mapping repeated per call site is one that will be completed
    /// per call site and fixed once.
    #[must_use]
    pub fn from_project(kind: crate::projects::ProjectEventKind) -> Self {
        match kind {
            crate::projects::ProjectEventKind::ProjectAssociated => {
                HistoryEventKind::ProjectAssociated
            }
            crate::projects::ProjectEventKind::ProjectDissociated => {
                HistoryEventKind::ProjectDissociated
            }
        }
    }

    /// The model kind for an epic reference event (WP03).
    ///
    /// The same vocabulary translation as [`Self::from_project`]: the epic service
    /// decides *which* mutation happened by returning a
    /// [`crate::epics::EpicEventKind`], and this maps that decision onto the log's
    /// spelling. It lives on the type that owns the log because more than one caller
    /// needs it — the storage write path, and the CLI/TUI/MCP surfaces that follow —
    /// and a mapping repeated per call site is one that will be completed per call
    /// site and fixed once.
    #[must_use]
    pub fn from_epic(kind: crate::epics::EpicEventKind) -> Self {
        match kind {
            crate::epics::EpicEventKind::EpicReferenced => HistoryEventKind::EpicReferenced,
            crate::epics::EpicEventKind::EpicDereferenced => HistoryEventKind::EpicDereferenced,
        }
    }
}

/// The auditable record of one **accepted** status transition (`data-model.md`
/// §5, WP03 T012).
///
/// ## Why this is not a `HistoryEventKind` alone
///
/// A [`HistoryEvent`] has exactly four fields — timestamp, actor, kind, detail —
/// and the on-disk line format is fixed at four by the U+2014 split in
/// [`HistoryEvent::parse`]. `data-model.md` §5 wants seven. The three surplus
/// fields therefore live in `detail`, and this struct is what converts between
/// the two: [`Self::to_event`] and [`Self::from_event`] are the only places that
/// encoding is written or read.
///
/// ## The `detail` grammar
///
/// ```text
/// detail := head [ "; override: " reason ]
/// head   := from " -> " to " (" from_category " -> " to_category ")"
/// ```
///
/// e.g. `todo -> in progress (unstarted -> active)`, or
/// `done -> in progress (completed -> active); override: mis-filed`.
///
/// **It is a grammar, not prose**, and that is a load-bearing choice rather than a
/// tidiness one. WP06's `lint_direct_edits` has to compare a file's *current*
/// `status` against the column its last accepted transition landed in; recovering
/// that column out of free-form English is guesswork, and a heuristic that
/// misreads it produces a lint result nobody can trust. A fixed grammar makes the
/// comparison exact, and makes "this detail is not a transition record"
/// detectable rather than a guess.
///
/// The parse is deliberately anchored rather than free-floating: the override is
/// split off at the first `") ; override: "`, and the categories are taken from
/// between the last `" ("` and the closing `")"`, so a **reason** may contain the
/// bare `"-> "` and the bare `"; override: "` without disturbing the fields. That
/// anchoring is the whole reason the marker includes the paren — see
/// [`OVERRIDE_MARKER`].
///
/// ## Known limit: a column name containing `" -> "`
///
/// The two column names are separated by the first `" -> "`, and nothing escapes
/// it, so a column literally named `a -> b` would render as
/// `a -> b -> in progress (unstarted -> active)` and parse back with `from == "a"`.
/// The categories still parse correctly, and the *categories* are what an audit
/// reader wants, but a WP06 that recovers `to` from a file would recover `b`.
///
/// This is inherent to a delimiter-based grammar, not a bug in this
/// implementation: any unescaped separator has a name it cannot represent, and
/// escaping would put backslashes into a line a human reads. A board column named
/// with an embedded arrow is not a realistic configuration, so the limit is
/// documented and pinned by a test rather than designed around. WP06 reads `to`
/// from these records, so it inherits the assumption and should not be surprised
/// by it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransitionDecision {
    /// When the transition was accepted, RFC 3339 — the same shape and clock as
    /// every other History entry.
    pub timestamp: String,
    /// Who performed it.
    ///
    /// A plain `String`, carried through to [`HistoryEvent::actor`] untouched, so
    /// identity handling is the History log's existing one: a bare handle or a full
    /// `Name <email>`, with a legacy `@alice` tolerated on read. This struct
    /// re-implements none of that and cannot drift from it.
    pub actor: String,
    /// The column the issue was in, as stored.
    pub from: String,
    /// The column it was moved to, as stored.
    pub to: String,
    /// The category `from` resolved to, recorded for auditability.
    ///
    /// **Derived, never stored on the issue and never re-derived on read** — that
    /// is the point. If a board's `category_map` is later edited, the historical
    /// record still says what the category *was* when the move was accepted, which
    /// is the only way the audit trail stays true.
    pub from_category: String,
    /// The category `to` resolved to, recorded for the same reason.
    pub to_category: String,
    /// Why a policy rule was overridden, when one was.
    ///
    /// Required to be `Some` for an override to exist at all: the service refuses a
    /// bare "I did it anyway" (see `TransitionOverride`), so this is never `Some`
    /// with an empty or blank reason.
    pub override_reason: Option<String>,
}

impl TransitionDecision {
    /// Build a decision from an accepted transition.
    pub fn new(
        timestamp: impl Into<String>,
        actor: impl Into<String>,
        from: impl Into<String>,
        to: impl Into<String>,
        from_category: impl Into<String>,
        to_category: impl Into<String>,
        override_reason: Option<String>,
    ) -> Self {
        Self {
            timestamp: timestamp.into(),
            actor: actor.into(),
            from: from.into(),
            to: to.into(),
            from_category: from_category.into(),
            to_category: to_category.into(),
            override_reason,
        }
    }

    /// Render to the single `detail` string the History line carries.
    pub fn detail(&self) -> String {
        let mut out = format!(
            "{from}{arrow}{to} ({fc}{arrow}{tc})",
            from = self.from,
            to = self.to,
            fc = self.from_category,
            tc = self.to_category,
            arrow = DETAIL_ARROW,
        );
        // A blank reason renders as no marker at all rather than as an empty field,
        // so `from_event` can never come back with `Some("")` — the same reasoning
        // that makes a blank actor render as an empty field rather than being
        // dropped, applied in the other direction.
        if let Some(reason) = self.override_reason.as_deref().map(str::trim) {
            if !reason.is_empty() {
                // The head already ends in the `)` the marker expects, so the
                // marker supplies the semicolon and the space only. Writing the
                // marker as a whole and *also* leaving the head's `)` is what
                // produced `...active)); override:` on the first pass.
                out.push_str(OVERRIDE_MARKER.trim_start_matches(')'));
                out.push_str(reason);
            }
        }
        out
    }

    /// Recover a decision from a History entry.
    ///
    /// `Err` when `event` is not a `transition_accepted` entry or its `detail` does
    /// not match the grammar — never a partially-filled struct. A caller can
    /// therefore treat a successful parse as "this file records a transition to
    /// `to`", which is exactly the question WP06 asks.
    pub fn from_event(event: &HistoryEvent) -> Result<Self> {
        if event.kind != HistoryEventKind::TransitionAccepted {
            return Err(TixError::Parse(format!(
                "History entry is a `{}`, not a `transition_accepted`",
                event.kind
            )));
        }
        let malformed =
            |what: &str| TixError::Parse(format!("transition record {what}: `{}`", event.detail));
        // The reason is split off at the FIRST anchored marker: the head is the
        // only `)` followed by `; override: `, so a reason containing the bare
        // marker survives. See `OVERRIDE_MARKER`.
        let (head, override_reason) = match event.detail.split_once(OVERRIDE_MARKER) {
            Some((head, reason)) => (head, Some(reason.trim().to_string())),
            None => (event.detail.as_str(), None),
        };
        // The marker *includes* the head's closing paren, so put it back and let
        // the grammar below see one shape whether or not a reason was present. The
        // alternative — two separate shapes to parse — is how the two halves of a
        // grammar drift apart.
        let head = match &override_reason {
            Some(_) => format!("{head})"),
            None => head.to_string(),
        };
        let head = head.as_str();
        let (from, rest) = head
            .split_once(DETAIL_ARROW)
            .ok_or_else(|| malformed("has no `from -> to`"))?;
        let rest = rest
            .strip_suffix(')')
            .ok_or_else(|| malformed("has no closing `)` after the categories"))?;
        let (to, categories) = rest
            .split_once(CATEGORY_OPEN)
            .ok_or_else(|| malformed("has no ` (` before the categories"))?;
        let (from_category, to_category) = categories
            .split_once(DETAIL_ARROW)
            .ok_or_else(|| malformed("has no `from_category -> to_category`"))?;
        for (name, value) in [
            ("from", from),
            ("to", to),
            ("from_category", from_category),
            ("to_category", to_category),
        ] {
            if value.trim().is_empty() {
                return Err(malformed(name));
            }
        }
        Ok(Self {
            timestamp: event.timestamp.clone(),
            actor: event.actor.clone(),
            from: from.to_string(),
            to: to.to_string(),
            from_category: from_category.to_string(),
            to_category: to_category.to_string(),
            override_reason,
        })
    }

    /// The History entry to append for this decision.
    pub fn to_event(&self) -> HistoryEvent {
        HistoryEvent::new(
            self.timestamp.clone(),
            self.actor.clone(),
            HistoryEventKind::TransitionAccepted,
            self.detail(),
        )
    }

    /// Whether this decision is a same-column no-op record.
    ///
    /// The seam never appends one — idempotency is decided by
    /// `TransitionOutcome::Accepted { changed: false }` — but a **hand-edited** file
    /// can contain one, and WP06's `lint_direct_edits` needs to tell that apart
    /// from a real move rather than reading it as "status became todo".
    pub fn is_no_op(&self) -> bool {
        self.from == self.to
    }
}

/// One recorded mutation, and one line of the `## History` section.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HistoryEvent {
    /// When the mutation happened, RFC 3339.
    pub timestamp: String,
    /// Who performed it. Handled like [`Issue::assignee`]: a bare handle or a
    /// full identity, with a legacy `@alice` tolerated on read.
    pub actor: String,
    /// What kind of mutation this was.
    pub kind: HistoryEventKind,
    /// The field-aware description shown in detail views.
    pub detail: String,
}

impl HistoryEvent {
    /// Build an event.
    pub fn new(
        timestamp: impl Into<String>,
        actor: impl Into<String>,
        kind: HistoryEventKind,
        detail: impl Into<String>,
    ) -> Self {
        Self {
            timestamp: timestamp.into(),
            actor: actor.into(),
            kind,
            detail: detail.into(),
        }
    }

    /// The one-line form written to the file:
    /// `- <timestamp> — <actor> — <kind> — <detail>`.
    ///
    /// Four fields, not the three an earlier draft of `data-model.md` §8
    /// specified: a three-field form cannot carry `actor`, so the struct field
    /// would not survive a round trip and the audit trail would silently lose
    /// who performed each mutation. `data-model.md` §8 is corrected to match on
    /// the base and mission branches — it is a protected path, so the fix is not
    /// in this lane. **Read §8 there, not this comment**, if the two ever
    /// disagree.
    ///
    /// A blank actor renders as an empty field rather than being dropped, so
    /// the field count stays fixed and parsing never has to guess.
    pub(super) fn render(&self) -> String {
        format!(
            "- {t} {h} {a} {h} {k} {h} {d}",
            t = self.timestamp.trim(),
            h = HISTORY_SEP,
            a = self.actor.trim(),
            k = self.kind.as_str(),
            d = self.detail
        )
    }

    /// Parse one rendered list item.
    ///
    /// The four fields are split on the **first three** separators only, so a
    /// detail or actor containing an em dash survives intact.
    pub(super) fn parse(line: &str) -> Result<Self> {
        let item = line
            .trim()
            .strip_prefix("- ")
            .ok_or_else(|| TixError::Parse(format!("History entry is not a list item: {line}")))?;
        // Split on the first three separators only, so a detail containing an
        // em dash survives intact. Fields are read **positionally** rather than
        // as "the next non-empty value": the actor may legitimately be blank,
        // which is exactly what `render` writes when no identity was recorded,
        // so demanding a non-empty value there would make the writer's own
        // output unparseable.
        let parts: Vec<&str> = item.splitn(4, HISTORY_SEP).collect();
        let missing =
            |what: &str| TixError::Parse(format!("History entry is missing `{what}`: {line}"));
        if parts.len() < 3 {
            return Err(missing("a field separator"));
        }
        let timestamp = parts[0].trim();
        if timestamp.is_empty() {
            return Err(missing("timestamp"));
        }
        let actor = parts[1].trim().to_string();
        let kind_text = parts[2].trim();
        if kind_text.is_empty() {
            return Err(missing("kind"));
        }
        let kind = kind_text.parse::<HistoryEventKind>()?;
        let detail = parts.get(3).copied().unwrap_or_default().trim().to_string();
        Ok(Self {
            timestamp: timestamp.to_string(),
            actor,
            kind,
            detail,
        })
    }
}

/// Whether a line is a **recognised** History entry attempt.
///
/// Recognition requires the U+2014 separator, and nothing else. That is
/// deliberately stricter than "looks like" one: a looser test here is not a
/// nicety, it decides whether a line is *moved into the audit log* or left in
/// the body. A timestamp-shaped heuristic accepted a body to-do item
/// (`- 2026-02-02 review the migration`) as an entry attempt, which emptied
/// `body` and made `tix lint` blame the author — the original M3 defect arriving
/// through a different door, while the documentation claimed the opposite.
///
/// A line that is genuinely an entry but was typed with ASCII hyphens is
/// therefore *not* recognised, and never becomes an event. It may still be
/// *retained* in the section; see [`looks_like_an_unrecognised_entry`].
pub fn looks_like_history_entry(line: &str) -> bool {
    let item = line.trim();
    let Some(rest) = item.strip_prefix("- ") else {
        return false;
    };
    rest.contains(HISTORY_SEP)
}

/// Whether a line looks like a History entry that was **not** recognised — an
/// entry hand-typed with ASCII hyphens instead of U+2014.
///
/// This is the shape test behind two decisions, and they use it differently:
///
/// - `parse_front` asks whether an ambiguous line is a hand-edited *entry* rather
///   than body prose. Being wrong in that direction costs the author's bytes,
///   because a demoted line is body text and a later `tix edit --body` destroys
///   it with no trace.
/// - `tix lint` asks whether to *report* a line, where a false positive costs a
///   user-visible diagnostic on every run and a false negative costs a silent
///   typo.
///
/// A mistyped entry has the whole shape of one: a date, then three further
/// hyphen-separated fields — `<timestamp> - <actor> - <kind> - <detail>`. Both
/// halves are required. An earlier version accepted any 10-character run of
/// digits, dashes and punctuation, which flagged `- 10:00-11:00 sync with infra`,
/// `- 1234567890 is the upstream ticket`, `+49-30-12345678` and `1.2.3.4.5.6`; a
/// later one required only three fields, which flagged `- 2026-01-01 - Bob -
/// Done`. Four fields is what the format actually specifies, so that is what is
/// required.
pub fn looks_like_an_unrecognised_entry(line: &str) -> bool {
    let Some(rest) = strip_list_marker(line) else {
        return false;
    };
    if rest.contains(HISTORY_SEP) {
        return false; // genuinely recognised; not this function's business
    }
    entry_shaped_by_ascii_hyphens(rest, STRICT_TAIL_FIELDS)
}

/// Whether a line is an **entry attempt**: a list item whose content is shaped
/// like an entry, under any accepted marker.
///
/// This is the parser's question, and it is deliberately looser than
/// [`looks_like_an_unrecognised_entry`]'s, because the two have opposite costs.
/// Retaining a line that turns out to be prose moves it into the audit log *and
/// says so*; demoting a line that turns out to be an entry puts it in the body,
/// where the next `tix edit --body` deletes it with no trace. Retention is the
/// recoverable direction, so the parser errs towards retaining and the
/// diagnostic errs towards silence.
/// Whether a line introduces the `## History` section, under the same
/// tolerance the parser applies: trailing spaces or tabs are ignored, leading
/// whitespace is not.
///
/// Public so `tix lint` can report a **misplaced** section using the parser's own
/// rule. A second copy of this test could disagree with the parser about which
/// lines are headings, and then lint would report violations that do not exist
/// while missing ones that do.
pub fn is_history_heading(line: &str) -> bool {
    line.trim_end_matches([' ', '\t']) == HISTORY_HEADING
}

/// One line of the `## History` section.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum HistoryEntry {
    /// A well-formed event.
    Event(HistoryEvent),
    /// A line that could not be parsed, preserved **verbatim**.
    ///
    /// The section is documented as human-editable, so a hand edit will
    /// eventually produce a line this parser cannot read — ASCII hyphens
    /// instead of the U+2014 separators is the obvious one. Such a line is
    /// never dropped, never coerced, and never auto-repaired: it is carried
    /// through a read/write cycle unchanged and reported by `tix lint` as a
    /// `history` finding on that issue only. It must never fail the
    /// parse of the whole file, because one mistyped line in one issue would
    /// otherwise make every issue in the repository unreadable.
    Unparsed(String),
}

/// The append-only event log of an issue (`data-model.md` §8).
///
/// Append-only is a hard guarantee of this type: the only mutating operation
/// is [`History::append`]. There is deliberately no remove, no reorder, no
/// trim, and no compaction — those are out of scope, but the container is
/// shaped so a future retention policy has a seam.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct History {
    /// Every line of the section, in file order, which is append order.
    ///
    /// Held as an ordered list of entries rather than a flat `Vec<HistoryEvent>`
    /// so an **unparseable** line keeps its position and its exact text. That
    /// is what lets the section round-trip byte-stably without ever dropping or
    /// repairing a line a human hand-edited.
    pub entries: Vec<HistoryEntry>,
}

impl History {
    /// An empty log.
    pub fn new() -> Self {
        Self::default()
    }

    /// Append one event. Never rewrites, reorders, or removes an existing line.
    pub fn append(&mut self, event: HistoryEvent) {
        self.entries.push(HistoryEntry::Event(event));
    }

    /// Append with the current UTC timestamp.
    pub fn append_now(&mut self, actor: &str, kind: HistoryEventKind, detail: &str) {
        self.append(HistoryEvent::new(utc_now_rfc3339(), actor, kind, detail));
    }

    /// The parsed events, in file order.
    ///
    /// Unparseable lines are excluded here; see [`History::unparsed`].
    pub fn events(&self) -> Vec<&HistoryEvent> {
        self.entries
            .iter()
            .filter_map(|e| match e {
                HistoryEntry::Event(event) => Some(event),
                HistoryEntry::Unparsed(_) => None,
            })
            .collect()
    }

    /// Lines that could not be parsed, verbatim and in file order.
    ///
    /// Non-empty means the section was hand-edited into a shape this parser
    /// does not accept. `tix lint` reports each as a `history`
    /// finding; nothing else treats it as an error.
    pub fn unparsed(&self) -> Vec<&str> {
        self.entries
            .iter()
            .filter_map(|e| match e {
                HistoryEntry::Event(_) => None,
                HistoryEntry::Unparsed(line) => Some(line.as_str()),
            })
            .collect()
    }

    /// Whether the section has no lines at all.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// How many lines the section holds, parsed or not.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// Render the section, including its heading and the blank line after it.
    ///
    /// An empty log renders to the empty string, so the section is *omitted*
    /// from the file entirely rather than written as an empty heading. An
    /// unparseable line is re-emitted exactly as it was read, in place, so a
    /// read/write cycle is byte-stable whether or not the section is clean.
    pub fn render(&self) -> String {
        if self.is_empty() {
            return String::new();
        }
        let mut out = String::from(HISTORY_HEADING);
        out.push_str("\n\n");
        for entry in &self.entries {
            match entry {
                HistoryEntry::Event(event) => {
                    out.push_str(&event.render());
                    out.push('\n');
                }
                HistoryEntry::Unparsed(line) => {
                    out.push_str(line);
                    out.push('\n');
                }
            }
        }
        out.push('\n');
        out
    }

    /// Parse a leading `## History` section, returning the log and whatever body
    /// text followed it.
    ///
    /// Only a section at the *front* of the body is recognised, matching the
    /// position `data-model.md` §8 pins (immediately after the frontmatter). A
    /// `## History` heading appearing later in the prose is left alone as
    /// ordinary body text.
    ///
    /// The section ends at the first line that is neither blank nor a list item,
    /// so a **blank line between entries does not end it** — a blank line inside
    /// a list is the most ordinary thing a human or a Markdown formatter
    /// produces, and terminating there would silently demote audit entries to
    /// body prose and reorder the log. Unparseable list items are kept verbatim
    /// rather than failing the file.
    pub fn parse_front(body: &str) -> (Self, String) {
        let lead = body.len() - body.trim_start_matches('\n').len();
        let trimmed = &body[lead..];
        let Some(rest) = trimmed.strip_prefix(HISTORY_HEADING) else {
            return (History::new(), body.to_string());
        };
        // The heading must be alone on its line — but trailing spaces and tabs on
        // it are still that line. Requiring a bare `\n` meant `## History   ` was
        // not recognised at all, and since the heading is then neither the
        // section nor body, the *first* relationship write rendered a second
        // `## History` above the original. Cycle 6 executed that; it had been
        // filed against #1299 as a CRLF-only consequence, which understated it —
        // a trailing space is a far likelier trigger than CRLF.
        let rest = rest.trim_start_matches([' ', '\t']);
        // #1299: a CRLF file spells the heading's line ending `\r\n`.
        let Some(after_heading) = rest
            .strip_prefix("\r\n")
            .or_else(|| rest.strip_prefix('\n'))
        else {
            return (History::new(), body.to_string());
        };
        let after_heading = after_heading.trim_start_matches(['\r', '\n']);

        let mut history = History::new();
        let mut offset = 0usize;
        // Whether the section carried a list item that was **judged prose and
        // dropped by the `break` below**. That is the #1336 shape — a list line
        // above ordinary prose empties the section and deletes the heading — and
        // it is what the empty-history guard restores. A section of pure prose (a
        // bare `## History` heading above a paragraph) carries no list item, so
        // dropping the derived heading stays correct.
        let mut saw_rejected_list_item = false;
        for line in after_heading.split_inclusive('\n') {
            // `item` is the trimmed form used for *shape* decisions; `raw_line`
            // is what a preserved `Unparsed` entry stores, so a hand edit keeps
            // its own bytes.
            let raw_line = line;
            let item = raw_line.trim();
            if item.is_empty() {
                // A blank line *inside* the section is skipped, not treated as
                // the end of it. It is consumed, so the body that follows does
                // not start with stray blank lines.
                offset += line.len();
                continue;
            }
            if !looks_like_history_entry(item) {
                // The section has no end-of-section delimiter, so this line is
                // ambiguous: either body prose, or a hand-edited line *inside*
                // the section. The two are told apart by what follows.
                //
                // If every remaining non-blank line is entry-shaped, the
                // ambiguous line was a hand edit inside the section, so the
                // section continues and the line is kept verbatim as
                // `Unparsed` — nothing is dropped and the real events after it
                // stay events. If anything after it is not entry-shaped, this
                // is body prose and the section genuinely ends here.
                //
                // The alternative — absorbing any `- ` line — moved body content
                // into the audit log, deleted the blank line separating them on
                // the first write, and then had `tix lint` blaming the author's
                // prose as unparseable entries. Trigger was ordinary:
                // `tix edit --body "- step one\n- step two"`.
                // Collected rather than consumed with `next()` + `all()`: that
                // skips judging the *first* following line, and `all()` over an
                // empty remainder is vacuously true — so a section followed by a
                // single non-entry line was absorbed. Both mistakes are the same
                // one: judging nothing and concluding "all of it is entries".
                let following: Vec<&str> = after_heading[offset + line.len()..]
                    .lines()
                    .filter(|l| !l.trim().is_empty())
                    .collect();
                // Two different questions, and conflating them is what broke this
                // twice.
                //
                // *Does anything after this disqualify it as body prose?* When
                // nothing follows, nothing can — `all()` is then vacuously true
                // and that is the **correct** answer, not a bug. A guard
                // requiring a non-empty remainder (added to stop an earlier
                // vacuous-truth defect) made the last line in a section always
                // terminate it, which deleted the `## History` heading, demoted
                // the author's line into the body, and rendered the next append
                // *above* it — file order stopped being append order, and
                // `tix lint` said nothing because a demoted line is body prose
                // and the unparsed loop no longer sees it.
                //
                // *Is this line shaped like a list item at all?* That still
                // decides, at end of section, whether keeping it is defensible.
                // `- step one` at EOF is far more likely a hand-edited entry than
                // the only body content of an issue, and keeping it *discloses*
                // the problem. A bare paragraph at EOF is prose, full stop, and
                // absorbing it would pollute the audit log.
                // Three inputs, and which ones matter depends on whether
                // anything follows. Cycle 5 found this half-fixed: consulting
                // only `is_list_item`, and only when nothing followed, left the
                // whole defect alive for any ambiguous line with body prose
                // after it.
                //
                // The asymmetry that decides the design: a *demoted* line is body
                // text, and `tix edit --body` replaces the body wholesale — so a
                // hand-edited entry that was demoted is destroyed by an ordinary
                // command, with lint silent. A *retained* line is kept verbatim
                // inside the section and reported. Given the choice, retaining is
                // the recoverable one, so the tie only goes to "this is prose"
                // when the line is not plausibly an entry at all.
                let plausible_entry = is_entry_attempt(item);
                let after_all_entries = following.iter().all(|l| looks_like_history_entry(l));
                let keep = if following.is_empty() {
                    // Nothing after to judge by, so shape alone decides. A bare
                    // paragraph at end of section is prose; anything list-shaped
                    // is kept, because there is no following content that could
                    // have been prose instead.
                    is_list_item(item)
                } else {
                    plausible_entry || after_all_entries
                };
                if !keep {
                    // A list-shaped line is the one case where the `break`
                    // discards something that could have been an audit entry, so
                    // record it for the empty-history guard after the loop.
                    saw_rejected_list_item |= is_list_item(item);
                    break;
                }
                // The **raw** line, not the trimmed one: `Unparsed` promises a
                // hand edit is carried through a read/write cycle unchanged, and
                // trimming silently repaired leading indentation and trailing
                // whitespace — exactly the auto-repair data-model §8 forbids.
                history.entries.push(HistoryEntry::Unparsed(
                    raw_line.trim_end_matches('\n').to_string(),
                ));
                offset += line.len();
                continue;
            }
            match HistoryEvent::parse(item) {
                Ok(event) => history.entries.push(HistoryEntry::Event(event)),
                // Kept verbatim. Diagnosed by `tix lint`, never repaired, never
                // dropped, and never fatal to the file.
                // Raw line, for the same reason as above.
                // Terminator only. `trim_end()` also eats trailing spaces and
                // tabs, which is the auto-repair data-model §8 forbids — and
                // `Err` is the arm a *recognised* entry lands in, so it is the
                // more likely one to carry real trailing whitespace.
                Err(_) => history.entries.push(HistoryEntry::Unparsed(
                    raw_line.trim_end_matches('\n').to_string(),
                )),
            }
            offset += line.len();
        }

        if history.entries.is_empty() && saw_rejected_list_item {
            // Nothing was retained and a list-shaped line was dropped. The
            // heading was consumed above, so the current return
            // (`after_heading[offset..]`) would delete it while leaving every
            // line after it as body prose: `tix view` loses the section and only
            // a later append renders a new heading above the demoted text. That
            // is #1336 — a list-shaped line judged prose empties the whole
            // section and deletes the audit trail with no diagnostic. Return the
            // original body verbatim (heading and all) so the file round-trips.
            //
            // A section of **pure prose** (no list item) is deliberately not
            // restored: the heading is derived from the log, and `storage`'s
            // `t010_an_empty_history_section_is_dropped_not_preserved` pins that
            // a heading with nothing to record is omitted rather than kept as a
            // bare heading. There is no audit entry to lose there.
            return (history, body.to_string());
        }
        (history, after_heading[offset..].to_string())
    }
}