tix-tracker 0.10.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
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
//! The local portable capacity document (mission `cycle-capacity-planning-01M3C85C`,
//! WP08, T040–T044; C-001, C-007, C-009, FR-001–FR-005, NFR-003).
//!
//! [`PortableCapacityDocument`] is the **local, loss-aware interchange form** of the
//! capacity surfaces: a versioned JSON document carrying every configured scope with its
//! configuration, the shared [`CapacityCalculation`] and its findings, every closed
//! cycle's immutable [`CapacitySnapshot`] evidence and its close/reopen/edit `## History`
//! summaries, and the remote-target diagnostics that say what a remote tracker cannot
//! carry. It is a *projection* of the shared types — never a second model, never a second
//! formula: every number is a field the DTO held, every identity is the library's
//! resolution, and no arithmetic happens here at all.
//!
//! # The approved interchange contracts, and what they fix
//!
//! Issues 191/192 (the approved local interchange contracts) fix the *properties* this
//! format must have, not its field names: a versioned machine-readable schema, a round
//! trip that **preserves every supported field without loss**, explicit item-level
//! diagnostics instead of a silent drop, and no external CLI or hosted service required.
//! The field set is therefore the shared DTO's own field set, in the house shape the
//! sibling portable documents already use ([`crate::health::portable`],
//! [`crate::roadmap::portable`], [`crate::views::portable`]: a `format` marker, a
//! `version`, ordered lists, a `diagnostics` vocabulary, and a preserved-extensions bag).
//!
//! # One DTO, no private formula (C-007/NFR-003)
//!
//! Every value in this document is read from a shared value: the configuration from
//! [`crate::capacity::config::CapacityEntry`], the numbers from
//! [`CapacityCalculation`] (the same value `tix capacity calculate`, the TUI and MCP
//! consume), and the history from the closed-cycle evidence WP04 writes. There is no
//! formula, no rounding, no unit fold and no status comparison in this module — a number
//! that is not a field of one of those values cannot be printed here. That is what makes
//! the parity obligation structural: `tix capacity calculate` and
//! `tix export --target capacity` cannot disagree, because there is no second
//! implementation to disagree with.
//!
//! # An absent value is absent, never zero
//!
//! Every derived field is `Option`: it is serialized as an explicit JSON `null`
//! (deliberately not omitted, so the schema is self-describing) and read back as `None`.
//! `0` is only ever a value that was counted and is zero — `state` says which of the two
//! states applies ([`CapacityState::Unconfigured`] from the shared calculation), so
//! "no ceiling is configured" is an explicit state and never a blank or a zero. The
//! CLI's `-` convention is a *text* rendering; JSON's `null` is the same statement.
//!
//! # Two namespaces, so a preserved key can never shadow a value
//!
//! Three nodes carry preserved data — the document, a configured scope, and a snapshot
//! payload — and each one keeps it in **two separate channels** rather than one shared
//! namespace:
//!
//! * `extensions` — a real nested object ([`PortableExtensions`]). This is the channel
//!   this build *writes*: the `[capacity.<id>]` keys it does not interpret, the snapshot
//!   `measurements` keys it does not interpret, and whatever a caller adds through
//!   [`PortableCapacityDocument::insert_extension`]. Because it is its own object, a key
//!   named `calculation`, `capacity` or `scope` lands *inside* it and can never duplicate
//!   or replace the projected field beside it — for this reader **or for a lenient one**
//!   (a last-key-wins JSON parser would otherwise read metadata where a value belongs).
//! * the flattened catch-all ([`PortableExtensions`] again, field `unknown`, never a wire
//!   key) — the channel this build *only reads*. serde delivers a key a newer build put
//!   directly on the node here, and it is written back verbatim; this build never writes
//!   to it, so it cannot collide with anything. Dropping it would be the silent loss
//!   T040 forbids.
//!
//! A reader therefore cannot make this build emit a duplicate key, and this build cannot
//! fail to re-read its own output. [`PortableScope`], [`PortableSource`],
//! [`PortableCalculation`], [`PortableCapacityDiagnostic`] and
//! [`PortableCapacityHistoryEvent`] are pure projections of shared values with no
//! extension channel at all, deliberately: there is nothing a writer could have preserved
//! in them.
//!
//! # Preserved values are projected faithfully, or diagnosed
//!
//! The source metadata is TOML (`tix.toml`) and YAML (a snapshot payload), and both are
//! converted **by hand** rather than through `serde_json`'s serializer, because that
//! serializer is not faithful for three shapes: a `toml` date would arrive as the crate's
//! private `$__toml_private_datetime` marker object (unreadable to any other tool), a
//! non-finite float (`.nan`, `.inf`) has no JSON number, and a YAML tag or a non-string
//! mapping key has no JSON form. A date is projected as its RFC 3339 **string** — the
//! value every consumer can read. A value that genuinely cannot be represented is
//! **withheld and reported** as a [`METADATA_NOT_CARRIED`] diagnostic; it is never silently
//! turned into `null`.
//!
//! Withholding is **per top-level key**: a preserved table containing one unrepresentable
//! leaf is withheld *whole* rather than partially projected, and the diagnostic names that
//! top-level key and the offending value — not a path into it. "Preserved verbatim" is
//! therefore a claim about the keys a document carries, not a promise that a partially
//! representable value survives in part.
//!
//! # The version marker: read first, checked, and a mismatch is a diagnostic
//!
//! The document carries a [`FORMAT`] marker and a [`VERSION`]. The marker is read
//! **before** the document is deserialized, so a document that is not a capacity document
//! at all (a `tix-health`, `tix-roadmap` or `tix.saved-views` payload, whose fields
//! overlap structurally) is **refused by name** rather than by whatever shape error its
//! fields happened to raise. A document of a *different version* is read — its own fields
//! and its preserved keys are kept — and the mismatch is reported as a
//! [`VERSION_MISMATCH`] diagnostic. Refusing it would make forward compatibility
//! impossible, and dropping the newer fields is exactly what the interchange contract
//! forbids; the diagnostic is the honest third answer, and it is merged (never appended
//! twice), so the document stabilizes after one write instead of growing on every read.
//!
//! One shape narrowed inside `version = 1`: [`PortableCapacitySnapshot::breakdown`] is a
//! nested bag (`Option<PortableExtensions>`), not a bare JSON value, so its contents get
//! the same faithful projection as the rest. A non-object breakdown in a hand-written
//! document is now refused by the reader. No document this build can write is affected —
//! the writer only ever produced an object (`CapacitySnapshot::breakdown` is a map) — and
//! the format is not released, so the version stays [`VERSION`].
//!
//! # Remote namespaces are separate (T043, FR-005)
//!
//! `tix export --target gitlab|linear` mirrors **issues** to a remote tracker
//! ([`crate::sync`], `[export.*]`): it writes remote ids back into issue files and shells
//! out to `glab`/`linear-cli`. This document is a *local* interchange form and never
//! reaches either tool. Where a remote target is configured, the target's inability to
//! carry capacity is **diagnosed explicitly** — `unsupported_remote_field`, naming the
//! target and the field, stating that the values stay local — because a silent drop and an
//! invented mapping are both worse than a refusal. The remote export path itself is
//! untouched: `capacity` is dispatched by name before `Tool::from_target` is consulted.
//!
//! # Read-only, on every path
//!
//! [`PortableCapacityDocument::build`] and its narrowed sibling
//! [`PortableCapacityDocument::build_for_cycle`] only read: the issue snapshot and index
//! the caller already built, the immutable snapshot store, and the cycle records' bodies.
//! Nothing here opens a file for writing, saves a configuration, appends a `History`
//! event, or rewrites a snapshot — and an overflow remains a report in this document
//! exactly as it is on every other surface (C-006).
#![allow(clippy::module_name_repetitions)]

use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::capacity::calculate::{CapacityCalculation, CapacityScope, CapacitySource};
use crate::capacity::config::CapacityScopeKind;
use crate::capacity::resolve_cycle;
use crate::capacity::scope::IssueSnapshot;
use crate::capacity::snapshot::CapacitySnapshot;
use crate::capacity::warning::CapacityWarning;
use crate::config::Config;
use crate::cycle_schedule::identity::{CycleReference, GeneratedCycleId, ScheduleIndex};
use crate::cycle_schedule::snapshot::{CycleSnapshotMetadata, CycleSnapshotStore};
use crate::cycle_store::{CycleRecord, CycleStore};
use crate::error::{Result, TixError};
use crate::model::{History, HistoryEventKind};
use crate::records::RecordStore;

/// The document's format marker. A consumer reads this before treating the payload as a
/// capacity document; a document with another marker is refused by name.
pub const FORMAT: &str = "tix-capacity";

/// The document's format version. Bumped when the shape changes incompatibly.
pub const VERSION: u32 = 1;

/// The stable kind spelling of a remote-mapping diagnostic.
///
/// The same spelling the health, roadmap and views portable documents use for the same
/// finding, so a reader who learned one vocabulary reads the others.
pub const UNSUPPORTED_REMOTE_FIELD: &str = "unsupported_remote_field";

/// The stable kind spelling of an "unknown metadata preserved" finding.
pub const UNKNOWN_METADATA: &str = "unknown_metadata";

/// The stable kind spelling of a "metadata could not be represented" finding.
pub const METADATA_NOT_CARRIED: &str = "metadata_not_carried";

/// The stable kind spelling of a "different document version" finding.
pub const VERSION_MISMATCH: &str = "version_mismatch";

// ---------------------------------------------------------------------------
// The preserved-metadata bags
// ---------------------------------------------------------------------------

/// A bag of preserved `(key, value)` pairs, in the document's own vocabulary.
///
/// Used for both channels (see the module docs): the nested `extensions` object this
/// build writes, and the flattened catch-all it only reads. A newtype over a JSON value
/// map, rather than a bare `Map`, so the one inserter is private and every write goes
/// through [`PortableCapacityDocument::insert_extension`]. A JSON map (not `toml::Table`)
/// because TOML has no null: a newer build writing `"future": null` must survive a round
/// trip through this one, and a TOML value cannot hold a null at all. The map is sorted
/// (`serde_json`'s `preserve_order` feature is off), so key *order* is normalised while the
/// values — the thing a round trip preserves — are kept exactly.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PortableExtensions(serde_json::Map<String, serde_json::Value>);

impl PortableExtensions {
    /// An empty bag.
    #[must_use]
    pub fn new() -> Self {
        Self(serde_json::Map::new())
    }

    /// Whether no metadata is present.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    /// The number of preserved keys.
    #[must_use]
    pub fn len(&self) -> usize {
        self.0.len()
    }

    /// Look up a preserved key.
    #[must_use]
    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
        self.0.get(key)
    }

    /// The preserved keys, in canonical (sorted) order.
    pub fn keys(&self) -> impl Iterator<Item = &str> {
        self.0.keys().map(String::as_str)
    }

    /// The underlying map, for a caller that must inspect it whole.
    #[must_use]
    pub fn as_map(&self) -> &serde_json::Map<String, serde_json::Value> {
        &self.0
    }

    /// Preserve one `(key, value)` pair, returning the previous value if any.
    ///
    /// Deliberately **private**: a preserved key is only safe inside a bag of its own (the
    /// nested `extensions` object), so the only public way in is the checked
    /// [`PortableCapacityDocument::insert_extension`]. A public raw inserter would let a
    /// caller write a key a document field already owns and make this build refuse its own
    /// output.
    fn insert(
        &mut self,
        key: impl Into<String>,
        value: serde_json::Value,
    ) -> Option<serde_json::Value> {
        self.0.insert(key.into(), value)
    }

    /// Carry a `[capacity.<id>]` entry's unknown keys (`CapacityEntry::extensions`).
    ///
    /// Returns the bag and the keys that had to be **withheld** because JSON cannot
    /// represent their value faithfully (see [`toml_to_json`]).
    fn from_toml(table: &toml::Table) -> (Self, Vec<NotCarried>) {
        let mut bag = Self::new();
        let mut not_carried = Vec::new();
        for (key, value) in table {
            match toml_to_json(value) {
                Ok(json) => {
                    bag.insert(key.clone(), json);
                }
                Err(reason) => not_carried.push(NotCarried {
                    key: key.clone(),
                    reason,
                }),
            }
        }
        (bag, not_carried)
    }

    /// Carry a snapshot payload's unknown keys (`CapacitySnapshot::extensions`).
    fn from_yaml(metadata: &CycleSnapshotMetadata) -> (Self, Vec<NotCarried>) {
        let mut bag = Self::new();
        let mut not_carried = Vec::new();
        for (key, value) in metadata {
            match yaml_to_json(value) {
                Ok(json) => {
                    bag.insert(key.clone(), json);
                }
                Err(reason) => not_carried.push(NotCarried {
                    key: key.clone(),
                    reason,
                }),
            }
        }
        (bag, not_carried)
    }
}

/// One preserved key whose value could not be projected, and why.
#[derive(Debug, Clone)]
struct NotCarried {
    /// The metadata key.
    key: String,
    /// Why JSON cannot represent it faithfully.
    reason: String,
}

/// Project one `toml` value into JSON, or say why it cannot be carried.
///
/// Hand-written rather than `serde_json::to_value`: the `toml` crate's own serializer
/// renders a [`toml::Value::Datetime`] as its private `$__toml_private_datetime` marker
/// object, which a consumer outside `toml` cannot read as a date. A date is projected as
/// its RFC 3339 string instead, and a non-finite float (which has no JSON number) is an
/// error the caller reports rather than a silent `null`.
fn toml_to_json(value: &toml::Value) -> std::result::Result<serde_json::Value, String> {
    Ok(match value {
        toml::Value::String(text) => serde_json::Value::String(text.clone()),
        toml::Value::Integer(number) => serde_json::Value::from(*number),
        toml::Value::Float(number) => {
            let Some(number) = serde_json::Number::from_f64(*number) else {
                return Err(format!(
                    "the float `{number}` is not finite, and JSON has no number for it"
                ));
            };
            serde_json::Value::Number(number)
        }
        toml::Value::Boolean(flag) => serde_json::Value::Bool(*flag),
        toml::Value::Datetime(datetime) => serde_json::Value::String(datetime.to_string()),
        toml::Value::Array(items) => {
            let mut array = Vec::with_capacity(items.len());
            for item in items {
                array.push(toml_to_json(item)?);
            }
            serde_json::Value::Array(array)
        }
        toml::Value::Table(table) => {
            let mut map = serde_json::Map::new();
            for (key, item) in table {
                map.insert(key.clone(), toml_to_json(item)?);
            }
            serde_json::Value::Object(map)
        }
    })
}

/// Project one `serde_yaml` value into JSON, or say why it cannot be carried.
///
/// The YAML bridge has the same three gaps as the TOML one: a non-finite number, a
/// non-string mapping key, and a tag have no JSON form. Null *is* representable and is
/// kept as JSON `null`.
fn yaml_to_json(value: &serde_yaml::Value) -> std::result::Result<serde_json::Value, String> {
    Ok(match value {
        serde_yaml::Value::Null => serde_json::Value::Null,
        serde_yaml::Value::Bool(flag) => serde_json::Value::Bool(*flag),
        serde_yaml::Value::Number(number) => {
            if let Some(signed) = number.as_i64() {
                serde_json::Value::from(signed)
            } else if let Some(unsigned) = number.as_u64() {
                serde_json::Value::from(unsigned)
            } else {
                let Some(float) = number.as_f64() else {
                    return Err(format!("the number `{number:?}` has no JSON form"));
                };
                let Some(number) = serde_json::Number::from_f64(float) else {
                    return Err(format!(
                        "the number `{float}` is not finite, and JSON has no number for it"
                    ));
                };
                serde_json::Value::Number(number)
            }
        }
        serde_yaml::Value::String(text) => serde_json::Value::String(text.clone()),
        serde_yaml::Value::Sequence(items) => {
            let mut array = Vec::with_capacity(items.len());
            for item in items {
                array.push(yaml_to_json(item)?);
            }
            serde_json::Value::Array(array)
        }
        serde_yaml::Value::Mapping(mapping) => {
            let mut map = serde_json::Map::new();
            for (key, item) in mapping {
                let serde_yaml::Value::String(key) = key else {
                    return Err(
                        "a mapping key is not a string, and JSON cannot carry one".to_string()
                    );
                };
                map.insert(key.clone(), yaml_to_json(item)?);
            }
            serde_json::Value::Object(map)
        }
        serde_yaml::Value::Tagged(tagged) => {
            return Err(format!(
                "the value carries the YAML tag `{}`, which JSON cannot carry",
                tagged.tag
            ));
        }
    })
}

// ---------------------------------------------------------------------------
// T040/T041 — the projected shared values
// ---------------------------------------------------------------------------

/// The resolved scope one document row describes, projected from [`CapacityScope`].
///
/// All three halves are carried: `reference` is what the configuration wrote, `display` is
/// what it resolves to, and `kind` is the dimension. Keeping both spellings is what lets a
/// reader see that a generated cycle's stable id and its name are **one** scope (C-003) —
/// the parity property earlier reviews made a blocker.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableScope {
    /// The dimension (`cycle`/`team`/`assignee`).
    pub kind: String,
    /// The reference as configured.
    pub reference: String,
    /// The reference as it resolves (a generated cycle's name, an assignee's handle).
    pub display: String,
}

impl PortableScope {
    /// Project the shared scope value.
    #[must_use]
    pub fn from_scope(scope: &CapacityScope) -> Self {
        Self {
            kind: scope.kind.as_str().to_string(),
            reference: scope.reference.clone(),
            display: scope.display.clone(),
        }
    }

    /// The scope as one message reads it: `cycle \`Cycle 1\``.
    #[must_use]
    pub fn describe(&self) -> String {
        format!("{} `{}`", self.kind, self.display)
    }
}

/// Which snapshot reading the numbers came from, projected from [`CapacitySource`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableSource {
    /// How many issues the snapshot indexed.
    pub issues: usize,
    /// The units the snapshot resolved conversions for.
    #[serde(default)]
    pub units: Vec<String>,
}

impl PortableSource {
    /// Project the shared source value.
    #[must_use]
    fn from_source(source: &CapacitySource) -> Self {
        Self {
            issues: source.issues,
            units: source.units.clone(),
        }
    }
}

/// One finding, in the document's vocabulary.
///
/// Used for **both** halves of the document: a [`CapacityWarning`] the shared calculation
/// raised (kind, scope, issues, the numbers it compares, its reason and its remedy) and a
/// document-level finding (preserved metadata, an unsupported remote field, a version
/// mismatch). One shape means a reader learns one vocabulary, the way the health and
/// roadmap documents do.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableCapacityDiagnostic {
    /// The stable contract name (`capacity_overflow`, `missing_conversion`,
    /// `unknown_metadata`, `metadata_not_carried`, `unsupported_remote_field`,
    /// `version_mismatch`, …).
    ///
    /// A plain string, so a kind a newer build adds survives a round trip as a string
    /// instead of failing to parse.
    pub kind: String,
    /// The scope the finding is about, when it is about one.
    #[serde(default)]
    pub scope: Option<PortableScope>,
    /// The issues to look at, in issue order.
    #[serde(default)]
    pub issues: Vec<u32>,
    /// The committed total the finding compares, when it compares one.
    #[serde(default)]
    pub current: Option<u64>,
    /// The configured ceiling, when the finding is about a ceiling.
    #[serde(default)]
    pub limit: Option<u64>,
    /// How far over the ceiling `current` is, when the finding is an overflow.
    #[serde(default)]
    pub overflow: Option<u64>,
    /// The unit the numbers are counted in, when the finding is about a count.
    #[serde(default)]
    pub unit: Option<String>,
    /// The record-relative field the problem sits at, when it is field-shaped
    /// (`capacity.zulu`, `capacity.zulu.future_float`, `snapshots.sprint:000001`).
    #[serde(default)]
    pub field: Option<String>,
    /// The remote target, when the finding is about a mapping a target cannot carry.
    #[serde(default)]
    pub target: Option<String>,
    /// What is wrong, in one sentence.
    pub reason: String,
    /// What the reader can do about it, when there is something.
    #[serde(default)]
    pub remedy: Option<String>,
}

impl PortableCapacityDiagnostic {
    /// Project one shared [`CapacityWarning`] into the wire vocabulary.
    ///
    /// A pure field copy: the kind spelling is the registry's own (`as_str`), the numbers
    /// are the warning's own, and the reason/remedy are the sentence pair the warning
    /// already carries. Nothing is recomputed and nothing is added.
    #[must_use]
    pub fn from_warning(warning: &CapacityWarning) -> Self {
        Self {
            kind: warning.kind.as_str().to_string(),
            scope: Some(PortableScope::from_scope(&warning.scope)),
            issues: warning.issues.clone(),
            current: warning.current,
            limit: warning.limit,
            overflow: warning.overflow,
            unit: warning.unit.clone(),
            field: None,
            target: None,
            reason: warning.reason.clone(),
            remedy: warning.remedy.clone(),
        }
    }

    /// A document-level finding about metadata this build preserved.
    fn unknown_metadata(field: String, scope: PortableScope, keys: Vec<String>) -> Self {
        Self {
            kind: UNKNOWN_METADATA.to_string(),
            scope: Some(scope),
            issues: Vec::new(),
            current: None,
            limit: None,
            overflow: None,
            unit: None,
            field: Some(field),
            target: None,
            reason: format!(
                "{} key(s) this build does not interpret are preserved verbatim under \
                 `extensions`: {}",
                keys.len(),
                keys.join(", ")
            ),
            remedy: Some(
                "keep the metadata: a build that understands the key reads it back unchanged"
                    .to_string(),
            ),
        }
    }

    /// A document-level finding about a preserved key that could not be carried.
    fn metadata_not_carried(field: String, scope: PortableScope, not_carried: &NotCarried) -> Self {
        Self {
            kind: METADATA_NOT_CARRIED.to_string(),
            scope: Some(scope),
            issues: Vec::new(),
            current: None,
            limit: None,
            overflow: None,
            unit: None,
            field: Some(format!("{field}.{}", not_carried.key)),
            target: None,
            reason: format!(
                "`{key}` is not carried: {reason}",
                key = not_carried.key,
                reason = not_carried.reason
            ),
            remedy: Some(
                "rewrite the value in a form JSON can represent (a finite number, a date as an \
                 RFC 3339 string), or remove the key"
                    .to_string(),
            ),
        }
    }

    /// A document-level finding about a remote target that cannot carry capacity.
    fn unsupported_remote(target: &str, scopes: usize, snapshots: usize) -> Self {
        Self {
            kind: UNSUPPORTED_REMOTE_FIELD.to_string(),
            scope: None,
            issues: Vec::new(),
            current: None,
            limit: None,
            overflow: None,
            unit: None,
            field: Some("capacity".to_string()),
            target: Some(target.to_string()),
            reason: format!(
                "`{target}` has no capacity field, so the {scopes} configured scope(s) and \
                 {snapshots} closed-cycle snapshot(s) in this document stay local; nothing is \
                 mapped and nothing is dropped"
            ),
            remedy: Some(format!(
                "keep the local document: `tix export --target {target}` mirrors issues, and \
                 capacity is a local interchange value rather than a remote field"
            )),
        }
    }

    /// A document-level finding about a version this build does not write.
    fn version_mismatch(found: u32) -> Self {
        Self {
            kind: VERSION_MISMATCH.to_string(),
            scope: None,
            issues: Vec::new(),
            current: None,
            limit: None,
            overflow: None,
            unit: None,
            field: Some("version".to_string()),
            target: None,
            reason: format!(
                "the document declares version {found}, but this build writes version {VERSION}; \
                 its fields are preserved rather than refused or dropped"
            ),
            remedy: Some(
                "read it with a build that writes that version; this one preserves the fields it \
                 does not interpret"
                    .to_string(),
            ),
        }
    }

    /// The stable contract name.
    #[must_use]
    pub fn kind(&self) -> &str {
        &self.kind
    }

    /// One line, `kind [field]: reason` — what a surface prints.
    #[must_use]
    pub fn render(&self) -> String {
        match self.field.as_deref() {
            Some(field) => format!("{} [{field}]: {}", self.kind, self.reason),
            None => format!("{}: {}", self.kind, self.reason),
        }
    }

    /// Whether two diagnostics describe the same problem, which is how a derived
    /// diagnostic is merged rather than appended twice on a repeated round trip.
    #[must_use]
    fn same_as(&self, other: &Self) -> bool {
        self.kind == other.kind
            && self.field == other.field
            && self.target == other.target
            && self.reason == other.reason
    }
}

/// The shared calculation, projected field for field (T041).
///
/// Every field is the DTO's own value; the `Option`s are the DTO's `Option`s, so an absent
/// value stays absent and unconfigured stays an explicit state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableCalculation {
    /// The resolved scope the numbers describe.
    pub scope: PortableScope,
    /// Whether the scope had a ceiling it could count against.
    pub state: String,
    /// The ceiling in force, or `null` when the scope was unconfigured.
    #[serde(default)]
    pub capacity: Option<u64>,
    /// The unit every number is counted in, or `null` when unconfigured.
    #[serde(default)]
    pub unit: Option<String>,
    /// The sum of the valid, converted estimates in scope.
    #[serde(default)]
    pub committed: Option<u64>,
    /// The committed part the shared completion service calls complete.
    #[serde(default)]
    pub completed: Option<u64>,
    /// `committed - completed`.
    #[serde(default)]
    pub remaining: Option<u64>,
    /// `max(committed - capacity, 0)`.
    #[serde(default)]
    pub overflow: Option<u64>,
    /// `committed` as a floored whole percentage of `capacity`.
    #[serde(default)]
    pub utilization: Option<u32>,
    /// Issues whose estimate was counted into `committed`.
    #[serde(default)]
    pub counted_issues: Option<u32>,
    /// Issues that state an estimate which could not be counted in the unit.
    #[serde(default)]
    pub uncounted_issues: Option<u32>,
    /// Issues that state no estimate at all.
    #[serde(default)]
    pub unestimated_issues: Option<u32>,
    /// The reading the numbers came from.
    pub source: PortableSource,
    /// The findings, in the DTO's derivation order.
    #[serde(default)]
    pub diagnostics: Vec<PortableCapacityDiagnostic>,
}

impl PortableCalculation {
    /// Project one shared [`CapacityCalculation`].
    #[must_use]
    fn from_calculation(calculation: &CapacityCalculation) -> Self {
        Self {
            scope: PortableScope::from_scope(&calculation.scope),
            state: calculation.state().as_str().to_string(),
            capacity: calculation.capacity,
            unit: calculation.unit.clone(),
            committed: calculation.committed,
            completed: calculation.completed,
            remaining: calculation.remaining,
            overflow: calculation.overflow,
            utilization: calculation.utilization,
            counted_issues: calculation.counted_issues,
            uncounted_issues: calculation.uncounted_issues,
            unestimated_issues: calculation.unestimated_issues,
            source: PortableSource::from_source(&calculation.source),
            diagnostics: calculation
                .diagnostics
                .iter()
                .map(PortableCapacityDiagnostic::from_warning)
                .collect(),
        }
    }
}

/// One configured scope: its configuration, the shared calculation, and its preserved
/// metadata (T040).
///
/// The configuration fields are the entry **verbatim** (`capacity` as the `i64` the entry
/// stores, `unit`, `enabled`), so a disabled entry's retained ceiling is carried rather
/// than dropped; `effective` is the entry's own answer for whether a ceiling is in force.
/// The calculation is the shared DTO, and it reports `state = "unconfigured"` for an entry
/// that is not in force — the explicit state, never a zero.
///
/// `extensions` and `unknown` are the two preservation channels the module docs describe:
/// the entry's unknown keys live in the nested object, so one named `calculation` cannot
/// shadow the real one.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableCapacityScope {
    /// The `[capacity.<id>]` handle the entry is stored under.
    pub id: String,
    /// The scope the entry names.
    pub scope: PortableScope,
    /// Whether the entry is enabled.
    pub enabled: bool,
    /// Whether a ceiling is in force (`enabled` with a value and a unit).
    pub effective: bool,
    /// The ceiling the entry stores, verbatim, or `null` when it stores none.
    #[serde(default)]
    pub capacity: Option<i64>,
    /// The unit the entry stores, verbatim, or `null` when it stores none.
    #[serde(default)]
    pub unit: Option<String>,
    /// The shared calculation for this scope.
    pub calculation: PortableCalculation,
    /// The entry's unknown keys, preserved verbatim in a namespace of their own.
    #[serde(default)]
    pub extensions: PortableExtensions,
    /// Keys a newer writer placed directly on this node, preserved verbatim.
    #[serde(flatten)]
    pub unknown: PortableExtensions,
}

/// One closed cycle's immutable capacity evidence, projected from [`CapacitySnapshot`]
/// (T042).
///
/// The identity (`snapshot_id`, `cycle_id`, `closed_at`, `closed_by`) and the data are the
/// evidence's own values, read through the WP04 reader: nothing is recomputed from the
/// live schedule or the current configuration, which are exactly what a later edit is
/// allowed to change. `cycle_name` is the display label the identity index resolves the
/// stable id to; it is carried *beside* the identity, never as one.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableCapacitySnapshot {
    /// The stable snapshot identity (`<cycle-id>@<close-time>`).
    pub snapshot_id: String,
    /// The generated cycle this evidence belongs to, by its stable id.
    pub cycle_id: String,
    /// The generated cycle's display label at read time, when the identity resolves.
    #[serde(default)]
    pub cycle_name: Option<String>,
    /// The explicit close time, RFC 3339.
    pub closed_at: String,
    /// The audit actor who closed the cycle.
    pub closed_by: String,
    /// The scope the calculation ran for.
    pub scope: PortableScope,
    /// Whether the scope had a ceiling it could count against at close.
    pub state: String,
    /// The ceiling in force at close.
    #[serde(default)]
    pub capacity: Option<u64>,
    /// The unit every number is counted in at close.
    #[serde(default)]
    pub unit: Option<String>,
    /// The committed total at close.
    #[serde(default)]
    pub committed: Option<u64>,
    /// The completed part of it at close.
    #[serde(default)]
    pub completed: Option<u64>,
    /// `committed - completed` at close.
    #[serde(default)]
    pub remaining: Option<u64>,
    /// `max(committed - capacity, 0)` at close.
    #[serde(default)]
    pub overflow: Option<u64>,
    /// The floored utilization percentage at close.
    #[serde(default)]
    pub utilization: Option<u32>,
    /// How many issues were counted into `committed`.
    #[serde(default)]
    pub counted_issues: Option<u32>,
    /// How many issues stated an estimate that could not be counted.
    #[serde(default)]
    pub uncounted_issues: Option<u32>,
    /// How many issues stated no estimate at all.
    #[serde(default)]
    pub unestimated_issues: Option<u32>,
    /// The reading the numbers came from.
    pub source: PortableSource,
    /// The findings the close recorded, in derivation order.
    #[serde(default)]
    pub diagnostics: Vec<PortableCapacityDiagnostic>,
    /// The optional team/assignee breakdown, persisted verbatim in its own namespace.
    #[serde(default)]
    pub breakdown: Option<PortableExtensions>,
    /// The payload's unknown `measurements` keys, preserved verbatim.
    #[serde(default)]
    pub extensions: PortableExtensions,
    /// Keys a newer writer placed directly on this node, preserved verbatim.
    #[serde(flatten)]
    pub unknown: PortableExtensions,
}

impl PortableCapacitySnapshot {
    /// Project one immutable [`CapacitySnapshot`], resolving its display label when the
    /// index answers for the identity.
    fn from_snapshot(
        evidence: &CapacitySnapshot,
        index: &ScheduleIndex,
    ) -> Result<(Self, Vec<(String, NotCarried)>)> {
        let cycle_name = GeneratedCycleId::parse(&evidence.cycle_id)
            .ok()
            .and_then(|id| index.cycle(&id))
            .map(|cycle| cycle.name.clone());
        let (extensions, mut not_carried) = PortableExtensions::from_yaml(&evidence.extensions);
        let mut fields = Vec::new();
        for missing in not_carried.drain(..) {
            fields.push((String::from("extensions"), missing));
        }
        let breakdown = match &evidence.breakdown {
            Some(metadata) => {
                let (bag, missing) = PortableExtensions::from_yaml(metadata);
                for not_carried in missing {
                    fields.push((String::from("breakdown"), not_carried));
                }
                Some(bag)
            }
            None => None,
        };
        Ok((
            Self {
                snapshot_id: evidence.snapshot_id.clone(),
                cycle_id: evidence.cycle_id.clone(),
                cycle_name,
                closed_at: evidence.closed_at.clone(),
                closed_by: evidence.closed_by.clone(),
                scope: PortableScope::from_scope(&evidence.scope),
                state: evidence.state.as_str().to_string(),
                capacity: evidence.capacity,
                unit: evidence.unit.clone(),
                committed: evidence.committed,
                completed: evidence.completed,
                remaining: evidence.remaining,
                overflow: evidence.overflow,
                utilization: evidence.utilization,
                counted_issues: evidence.counted_issues,
                uncounted_issues: evidence.uncounted_issues,
                unestimated_issues: evidence.unestimated_issues,
                source: PortableSource::from_source(&evidence.source),
                diagnostics: evidence
                    .diagnostics
                    .iter()
                    .map(PortableCapacityDiagnostic::from_warning)
                    .collect(),
                breakdown,
                extensions,
                unknown: PortableExtensions::new(),
            },
            fields,
        ))
    }
}

/// One `## History` entry about a cycle's capacity lifecycle (T042).
///
/// The close/reopen/edit vocabulary WP04 writes is carried verbatim: the kind, the
/// timestamp, the actor and the detail line exactly as the append-only log records them.
/// The entry is **never re-parsed** here, so a detail this build does not understand is
/// preserved rather than interpreted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableCapacityHistoryEvent {
    /// The cycle's stable generated id, when it has one; its record name otherwise.
    pub cycle_id: String,
    /// The cycle's record name (a mutable display label).
    pub cycle_name: String,
    /// The event kind (`cycle_closed`, `cycle_reopened`, `cycle_capacity_edited`).
    pub kind: String,
    /// When it happened, RFC 3339, as recorded.
    pub timestamp: String,
    /// Who did it, as recorded.
    pub actor: String,
    /// The detail line, verbatim.
    pub detail: String,
}

// ---------------------------------------------------------------------------
// T040 — the document
// ---------------------------------------------------------------------------

/// Everything the local capacity surfaces know, in one versioned document.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableCapacityDocument {
    /// The format marker: [`FORMAT`].
    pub format: String,
    /// The format version: [`VERSION`].
    pub version: u32,
    /// One row per configured scope, in `[capacity]` declaration order.
    #[serde(default)]
    pub scopes: Vec<PortableCapacityScope>,
    /// Every closed cycle's immutable evidence, in snapshot order.
    #[serde(default)]
    pub snapshots: Vec<PortableCapacitySnapshot>,
    /// Every capacity lifecycle event, in cycle order then file (append) order.
    #[serde(default)]
    pub history: Vec<PortableCapacityHistoryEvent>,
    /// Document-level findings (preserved metadata, unsupported remote fields, version).
    #[serde(default)]
    pub diagnostics: Vec<PortableCapacityDiagnostic>,
    /// Document-level keys this build preserved, in a namespace of their own.
    #[serde(default)]
    pub extensions: PortableExtensions,
    /// Keys a newer writer placed directly on the document, preserved verbatim.
    #[serde(flatten)]
    pub unknown: PortableExtensions,
}

/// The marker-only prefix of a document, read before the document itself.
///
/// The same shape [`crate::roadmap::portable`] reads first: a payload that is not a
/// capacity document is refused **by name** rather than by whatever shape error its fields
/// happened to raise against this one's.
#[derive(Deserialize)]
struct Marker {
    format: String,
}

/// The one cycle a narrowed document describes.
struct CycleFilter {
    /// The stable id, when the reference resolves to a generated cycle.
    id: Option<String>,
    /// The resolved name.
    name: String,
    /// The label a cycle scope resolves to (the identity the scope matches on).
    label: String,
}

impl CycleFilter {
    /// Resolve `reference` through the library's own adapter.
    fn resolve(index: &ScheduleIndex, reference: &str) -> Result<Self> {
        let (id, name) = match resolve_cycle(index, reference) {
            CycleReference::Generated { id, name, .. } => (Some(id.as_str().to_string()), name),
            CycleReference::Legacy { name } => (None, name),
            CycleReference::Unknown { name } => {
                return Err(TixError::NotFound(format!(
                    "no cycle `{name}` resolves to a record, so there is no capacity to export \
                     for it"
                )));
            }
        };
        let label = CapacityScope::new(CapacityScopeKind::Cycle, reference, index).display;
        Ok(Self { id, name, label })
    }

    /// Whether a configured scope is this cycle's.
    fn keeps_scope(&self, scope: &PortableScope) -> bool {
        scope.kind == CapacityScopeKind::Cycle.as_str() && scope.display == self.label
    }

    /// Whether a snapshot belongs to this cycle.
    fn keeps_snapshot(&self, snapshot: &PortableCapacitySnapshot) -> bool {
        self.id.as_deref() == Some(snapshot.cycle_id.as_str())
    }

    /// Whether a history entry belongs to this cycle.
    fn keeps_history(&self, event: &PortableCapacityHistoryEvent) -> bool {
        self.id.as_deref() == Some(event.cycle_id.as_str()) || event.cycle_name == self.name
    }
}

impl PortableCapacityDocument {
    /// Build the whole local capacity document: every configured scope with its
    /// calculation, every closed cycle's evidence, every capacity lifecycle event, and the
    /// remote-target diagnostics.
    ///
    /// `snapshot` is the one-pass issue snapshot the caller already built with `index` (the
    /// same pair `tix capacity calculate` uses), so a scope's numbers are the shared
    /// calculation's and a multi-scope export still indexes the issues **once** (NFR-001).
    /// The read path touches the snapshot store and the cycle records and writes nothing.
    pub fn build(
        config: &Config,
        root: &Path,
        index: &ScheduleIndex,
        snapshot: &IssueSnapshot,
    ) -> Result<Self> {
        Self::assemble(config, root, index, snapshot, None)
    }

    /// Build the document for **one** cycle: the scope that configures it, its immutable
    /// evidence and its lifecycle history.
    ///
    /// The reference is what the caller wrote — a generated cycle's name or its stable id —
    /// and it is folded through the same adapter every other surface uses, so the two
    /// spellings select one document (C-003). A reference nothing resolves to is a
    /// `not_found`, not an empty document.
    pub fn build_for_cycle(
        config: &Config,
        root: &Path,
        index: &ScheduleIndex,
        snapshot: &IssueSnapshot,
        reference: &str,
    ) -> Result<Self> {
        let filter = CycleFilter::resolve(index, reference)?;
        Self::assemble(config, root, index, snapshot, Some(filter))
    }

    /// The one assembly path, optionally narrowed to a cycle.
    fn assemble(
        config: &Config,
        root: &Path,
        index: &ScheduleIndex,
        snapshot: &IssueSnapshot,
        filter: Option<CycleFilter>,
    ) -> Result<Self> {
        let mut document = Self {
            format: FORMAT.to_string(),
            version: VERSION,
            scopes: Vec::new(),
            snapshots: Vec::new(),
            history: Vec::new(),
            diagnostics: Vec::new(),
            extensions: PortableExtensions::new(),
            unknown: PortableExtensions::new(),
        };

        // The configured scopes, in declaration order; the calculation is the shared DTO's.
        for (id, entry) in config.capacity.iter() {
            let Some(kind) = entry.scope_kind() else {
                continue;
            };
            let reference = entry.scope_id().unwrap_or_default();
            let scope = CapacityScope::new(kind, reference, index);
            let projected = PortableScope::from_scope(&scope);
            if filter
                .as_ref()
                .is_some_and(|filter| !filter.keeps_scope(&projected))
            {
                continue;
            }
            let calculation = CapacityCalculation::for_scope(snapshot, index, config, scope);
            let (extensions, not_carried) = PortableExtensions::from_toml(entry.extensions.table());
            if !extensions.is_empty() {
                document
                    .diagnostics
                    .push(PortableCapacityDiagnostic::unknown_metadata(
                        format!("capacity.{id}"),
                        projected.clone(),
                        extensions.keys().map(str::to_string).collect(),
                    ));
            }
            for missing in &not_carried {
                document
                    .diagnostics
                    .push(PortableCapacityDiagnostic::metadata_not_carried(
                        format!("capacity.{id}"),
                        projected.clone(),
                        missing,
                    ));
            }
            document.scopes.push(PortableCapacityScope {
                id: id.to_string(),
                scope: projected,
                enabled: entry.enabled,
                effective: entry.effective(),
                capacity: entry.capacity,
                unit: entry.unit.clone(),
                calculation: PortableCalculation::from_calculation(&calculation),
                extensions,
                unknown: PortableExtensions::new(),
            });
        }

        // The immutable evidence, read through the WP04 reader; a snapshot with no capacity
        // payload (written before this mission) contributes nothing and is not an error —
        // nothing is migrated in place.
        let store = CycleSnapshotStore::new(root);
        for stored in store.list()? {
            let Some(evidence) = CapacitySnapshot::from_cycle_snapshot(&stored)? else {
                continue;
            };
            let (projected, not_carried) =
                PortableCapacitySnapshot::from_snapshot(&evidence, index)?;
            if filter
                .as_ref()
                .is_some_and(|filter| !filter.keeps_snapshot(&projected))
            {
                continue;
            }
            let field = format!("snapshots.{}", projected.snapshot_id);
            if !projected.extensions.is_empty() {
                document
                    .diagnostics
                    .push(PortableCapacityDiagnostic::unknown_metadata(
                        field.clone(),
                        projected.scope.clone(),
                        projected.extensions.keys().map(str::to_string).collect(),
                    ));
            }
            for (channel, missing) in &not_carried {
                document
                    .diagnostics
                    .push(PortableCapacityDiagnostic::metadata_not_carried(
                        format!("{field}.{channel}"),
                        projected.scope.clone(),
                        missing,
                    ));
            }
            document.snapshots.push(projected);
        }

        // The lifecycle history: the capacity kinds only, verbatim, in file order.
        for record in RecordStore::new(CycleStore::new(root).dir()).list::<CycleRecord>()? {
            let (history, _) = History::parse_front(&record.body);
            let cycle_id = match index.resolve(&record.value.name) {
                CycleReference::Generated { id, .. } => id.as_str().to_string(),
                _ => record.value.name.clone(),
            };
            for event in history.events() {
                if !matches!(
                    event.kind,
                    HistoryEventKind::CycleClosed
                        | HistoryEventKind::CycleReopened
                        | HistoryEventKind::CycleCapacityEdited
                ) {
                    continue;
                }
                let projected = PortableCapacityHistoryEvent {
                    cycle_id: cycle_id.clone(),
                    cycle_name: record.value.name.clone(),
                    kind: event.kind.as_str().to_string(),
                    timestamp: event.timestamp.clone(),
                    actor: event.actor.clone(),
                    detail: event.detail.clone(),
                };
                if filter
                    .as_ref()
                    .is_some_and(|filter| !filter.keeps_history(&projected))
                {
                    continue;
                }
                document.history.push(projected);
            }
        }

        // The remote half: a remote tracker has no capacity field, and saying so is the
        // deliverable. One finding per configured target — the values stay local.
        document.diagnostics.extend(remote_diagnostics(
            config,
            document.scopes.len(),
            document.snapshots.len(),
        ));

        Ok(document)
    }

    /// Whether this document carries no capacity at all.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.scopes.is_empty() && self.snapshots.is_empty() && self.history.is_empty()
    }

    /// The document's format marker.
    #[must_use]
    pub fn format(&self) -> &str {
        &self.format
    }

    /// The document's format version.
    #[must_use]
    pub const fn version(&self) -> u32 {
        self.version
    }

    /// Preserve one document-level key, in the nested `extensions` object.
    ///
    /// The **only** public write path into a bag, and it cannot produce a document this
    /// build cannot read: the key lands in a namespace of its own, so it can never
    /// duplicate — or be read in place of — a document field.
    pub fn insert_extension(&mut self, key: impl Into<String>, value: serde_json::Value) {
        self.extensions.insert(key, value);
    }

    /// Serialize the document as compact JSON.
    pub fn to_json(&self) -> Result<String> {
        serde_json::to_string(self).map_err(|error| {
            TixError::Parse(format!("capacity document cannot be serialized: {error}"))
        })
    }

    /// Serialize the document as pretty JSON — the export surface's shape.
    pub fn to_json_pretty(&self) -> Result<String> {
        serde_json::to_string_pretty(self).map_err(|error| {
            TixError::Parse(format!("capacity document cannot be serialized: {error}"))
        })
    }

    /// Read a document back.
    ///
    /// The [`Marker`] is read **first**: a document that is not a capacity document is
    /// refused by name (its format marker) rather than by whatever shape error its fields
    /// happened to raise — a `tix-health` or `tix-roadmap` payload overlaps structurally and
    /// used to produce a misleading field error. A document of another *version* is read,
    /// its own fields and preserved keys kept, and the mismatch is merged into the
    /// diagnostics once — the checked-and-reported third answer between refusing a newer
    /// writer and silently dropping its fields.
    pub fn from_json(raw: &str) -> Result<Self> {
        let marker: Marker = serde_json::from_str(raw).map_err(|error| {
            TixError::Parse(format!("not a portable capacity document: {error}"))
        })?;
        if marker.format != FORMAT {
            return Err(TixError::Parse(format!(
                "portable capacity document has format `{}`, but this build reads `{FORMAT}`",
                marker.format
            )));
        }
        let mut document: Self = serde_json::from_str(raw).map_err(|error| {
            TixError::Parse(format!(
                "portable capacity document is not valid JSON of this shape: {error}"
            ))
        })?;
        if document.version != VERSION {
            let diagnostic = PortableCapacityDiagnostic::version_mismatch(document.version);
            if !document
                .diagnostics
                .iter()
                .any(|existing| existing.same_as(&diagnostic))
            {
                document.diagnostics.push(diagnostic);
            }
        }
        Ok(document)
    }
}

/// The remote-target findings: one per configured `[export.*]` target.
///
/// The targets are the configuration's own (`config.export.gitlab`/`.linear`), and the
/// finding names the target and the field it cannot carry. It is **not** a mapping: no
/// remote id is invented, no value is converted, and nothing is sent anywhere.
fn remote_diagnostics(
    config: &Config,
    scopes: usize,
    snapshots: usize,
) -> Vec<PortableCapacityDiagnostic> {
    let mut targets = Vec::new();
    if config.export.gitlab.is_some() {
        targets.push("gitlab");
    }
    if config.export.linear.is_some() {
        targets.push("linear");
    }
    if targets.is_empty() {
        return Vec::new();
    }
    targets
        .into_iter()
        .map(|target| PortableCapacityDiagnostic::unsupported_remote(target, scopes, snapshots))
        .collect()
}