onevcs-testing 0.19.1

In-memory and file-backed implementations of onevcs's Vcs and RemoteHost interfaces, for driving a real onevcs in a test.
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
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
//! What a provider knows, in a shape a journey can write down and read back.
//!
//! One state type per interface, shared by both flavours of it — the in-memory
//! provider and the file-backed one differ in where the state lives and in nothing
//! else, so a scenario seeded for one is the same scenario for the other.

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use onevcs::rules::{Approvals, Drafts};
use onevcs::{
    ChangeId, ChangeRequest, Check, CheckSource, CommentId, CommentKind, DraftReason, Error,
    Identity, Landed, MergeOutcome, ReadCost, Recoverable, Result, Sha,
};
use onevcs::{MergePolicy, Publication, RequiredChecks, Session, SessionRequest, SessionToken};

use crate::events;
use crate::store::Checked;

/// The version of the state document this build writes.
///
/// A file-backed state outlives the process that wrote it and is read by the next
/// one, which makes it a stored contract like `onevcs`'s own registry document —
/// and like that document, the version a document declares decides what is done
/// with it rather than being guessed at. [`STATE_VERSION`] is the shape the goldens in
/// `tests/golden/` hold, and those goldens are compared byte for byte, so a change
/// to the document cannot reach a consumer without the diff saying so.
///
/// `2` was what both sides learned when publishing and closing a session came
/// through the interface: [`VcsState::policy`], [`VcsState::closed_sessions`],
/// [`VcsState::publications`], and [`HostState::titles`]. `3` is
/// [`HostState::bodies`], the body a change request was opened with. `4` is the two
/// fields a `Recoverable` gained inside [`VcsState::preserved`] — the live session
/// holding a branch, and the lines it would land when it removes more than it adds.
/// `5` is one more of those: whether the branch's work reached its base, and what
/// says so. `6` is the two a `Check` gained inside [`HostState::checks`] — the
/// commit the host attached that check to, and where the check is on the host. `7`
/// is the two a draft change request needs — [`HostState::drafts`], the reason each
/// change request was opened as a draft with, and [`HostState::made_ready`], the
/// `ready_for_review` calls it took. `8` is the two an [`Identity`] inside
/// [`VcsState::identities`] **lost** — `workflow` and `repo_type`, the registry's
/// inferences from whether an origin had a host, which every decision they made now
/// takes from the resolved publication policy instead. `9` is the draft reason inside
/// [`HostState::drafts`] becoming one of two kinds and saying which — `kind` is
/// `awaiting-release` or `held` — and [`HostState::described`], the descriptions a
/// host was handed for a change request after it was opened. `10` is the failure
/// vocabulary a publication's outcome inside [`VcsState::publications`] may name
/// widening by one kind, `host-prerequisite` — no field moved, but a document naming
/// it is one a version 9 build cannot read, and the version is how it says so. `11` is
/// one more field a `Recoverable` gained inside [`VcsState::preserved`], the same way
/// `4` and `5` were: where `onevcs preserve` put that branch on its identity's origin,
/// and the commit the origin carries it at. `12` is the labels a session is opened
/// with — [`VcsState::session_labels`], keyed by token the way its identity is — and
/// the two fields a `Recoverable` gained inside [`VcsState::preserved`] to carry them:
/// the session that answers for the row, and that session's labels. `13` is one more
/// field a `Recoverable` gained there: the branch's retirement classification — whether
/// it provably holds nothing beyond its base, was superseded by a retry that landed, or
/// is kept, and the evidence for it. No provider here classifies a row, so a row this
/// crate writes carries none; a scenario may seed one. `14` is the draft lifecycle:
/// [`HostState::awaiting_checks`], the change requests opened as a draft while their
/// required checks run, [`HostState::checks_after_lift`], the checks a host registers
/// only once a change is lifted, and [`HostState::required_checks`], what the host
/// says a merge into a base requires — beside [`VcsState::approvals`] and
/// [`VcsState::drafts`], the two policy fields a provider's publication now routes
/// the lifecycle on. A publication outcome inside [`VcsState::publications`] may also
/// be `change-review-draft`. `15` is the field a `Session` gained inside
/// [`VcsState::sessions`]: `conflict`, the merge a continued branch's session opened
/// over and left in progress. No provider here merges a base into a branch, so a
/// session this crate opens carries none; a scenario may seed one. `16` is the failure
/// vocabulary a publication's outcome inside [`VcsState::publications`] may name
/// widening by one kind, `cancelled`, the way `10` widened it — no field moved, but a
/// document naming it is one a version 15 build cannot read. No provider here waits on
/// anything, so a publication this crate runs is never cancelled; a scenario may seed
/// one. `17` is the field a `Retirement` gained inside a row of
/// [`VcsState::preserved`]: `cause`, the read that failed for a branch kept as
/// `unknown`, which every retirement writes — `null` for every other reason. No
/// provider here classifies a branch, so a row this crate writes carries none; a
/// scenario may seed one. `18` is what the record of a gate run and of a landing needs
/// from a host: the field a `Check` gained inside [`HostState::checks`] —
/// `completed_at`, when the host says that run completed — and
/// [`HostState::merge_times`], when each merge this host performed reached its base.
///
/// **Every change to the document is versioned, an added field included.** A field
/// that only ever appears when it holds something is *compatible* — that is what
/// [`OLDEST_READABLE_VERSION`] is for — but compatible is not the same as
/// unremarkable: the version is how a consumer's checked-in scenario says which
/// shape it was written against, and a document that gained a field without saying
/// so leaves nothing able to tell "this build wrote no body" from "this document
/// predates bodies". The two answers differ for exactly the journey this crate
/// exists to support.
/// `19` adds the recovery branch tip; older rows default it to `None`. `20` is the
/// review feedback a host holds: [`HostState::review_comments`], each change
/// request's comments of every kind, [`HostState::replies`], every reply this host
/// posted, and [`HostState::review_charges`], what it charges a read. `21` is what a
/// stack of drafts needs from a host while people merge some of them on it:
/// [`HostState::closed`], the change requests closed without merging,
/// [`HostState::deleted_branches`], the branches gone from the origin,
/// [`HostState::head_checks`], each required check's state per head commit, and
/// [`HostState::ledger`], every call the host answered with what GitHub charges for it.
/// `22` is what an elapsed watch arms: [`HostState::up_to_date_required`],
/// [`HostState::allow_auto_merge`] and [`HostState::armed`], and a failed publication's
/// armed merge. `23` is what a draft's watch reads per poll:
/// [`HostState::check_readings`] and [`HostState::check_readings_after_lift`], the
/// checks each reading of a change request reports in turn on each side of its lift,
/// and [`HostState::head_activity`], what the host answers in turn about the work
/// still running on its head. `24` is a ledger that may name `merged_head`, the
/// question a retirement asks a host before it calls a branch merged.
pub const STATE_VERSION: u32 = 24;

/// The oldest document version this build reads.
///
/// Every version from here to [`STATE_VERSION`] is read and carried forward on the
/// way in, which is how `onevcs`'s own registry document treats its older
/// versions: what separates a readable older version from a refused one is whether
/// its every field still means here what it meant there. `2` to `3` added a field
/// and changed none, so a version 2 document reads as one whose change requests
/// were opened with no body — which is what they were; `3` to `4` added two the same
/// way, so a version 3 document reads as one whose preserved rows say nothing about a
/// hold or a line count, which is what they said. `4` to `5` added one that every row
/// answers rather than one that appears when it holds something, so a version 4
/// document's rows are carried forward as the answer that list *was*: preserved work
/// nobody published, which is work that did not land. `5` to `6` added two that
/// appear only when they hold something, so a version 5 document's checks read as
/// checks whose commit and address that build never recorded — which is what they
/// were, and the one thing that must not be filled in from the change request's own
/// head. `6` to `7` added two more of that kind, so a version 6 document reads as one
/// whose change requests were opened before this crate could draft one — which is what
/// they were: no draft reason recorded for any of them, and no lift ever asked for.
/// `7` to `8` *removed* two from each identity, and a version 7 document reads past
/// them: an identity is its origin and its gate here as it is in `onevcs`, and the two
/// keys beside them are ones this build has no opinion on. `8` to `9` gave every
/// draft reason a `kind`, and a reason written without one is read as the one kind
/// there was — `awaiting-release`, whose four fields it carries unchanged — which is
/// `drafts_of_any_version`'s whole job; the descriptions beside it are absent from a
/// document written before them, which is the answer, since that build could describe
/// nothing. `9` to `10` changed no field and no meaning: every failure kind a version 9
/// document can hold is spelled here as it was there and means what it meant, so it
/// reads unchanged and needs no carrying forward beyond its version. `10` to `11` added
/// one that appears only when it holds something, so a version 10 document's preserved
/// rows read as rows saying nothing about an origin — which is what they said, since
/// that build had no verb that could put a branch on one. `11` to `12` added the
/// labels, which appear only where a session was opened with some, and the two row
/// fields that carry them, which a version 11 row reads as absent: a session nobody
/// labelled, and a row that names no session — which is what that build's rows said,
/// since it recorded neither. `12` to `13` added the row's retirement, which appears only
/// where a row holds one, so a version 12 row reads as one nothing classified — which
/// is what it was, since that build had no classification to record. `13` to `14`
/// added fields that appear only when they hold something, so a version 13 document
/// reads as one whose change requests were opened before the lifecycle drafted any,
/// whose checks never change on a lift, whose host declares its required checks by
/// the checks it was seeded with, and whose policy leaves approvals and drafts at
/// their defaults — which is what that build published under. `14` to `15` added the
/// session's conflict, which appears only where a session holds one, so a version 14
/// document's sessions read as sessions that opened clean — which is what they were,
/// since that build opened none over a merge. `15` to `16` changed no field and no
/// meaning, as `9` to `10` did not: every failure kind a version 15 document can hold is
/// spelled here as it was there, so it reads unchanged. `16` to `17` added the
/// retirement's cause, which a version 16 retirement reads as `null` — which is what it
/// said, since that build named no read that failed. `17` to `18` added two fields that
/// appear only when they hold something, so a version 17 document's checks read as
/// checks whose completion that build never recorded, and its merges as merges whose
/// time it never recorded — which is what they were. `19` to `20` added three fields
/// that appear only when they hold something, so a version 19 document reads as a
/// host whose change requests carry no review feedback and that has posted no reply —
/// which is what that build could hold. `20` to `21` added four more of that kind, so a
/// version 20 document reads as a host on which nobody closed or deleted anything, whose
/// checks are its change requests' own, and which has answered no call — which is what
/// that build recorded. `21` to `22` added three host fields of that kind and one field
/// of a failed publication, so a version 21 document reads as a host that requires no
/// up-to-date branch, allows auto-merge and holds no armed merge, and as publications
/// none of which left a merge armed — which is what that build could do. `22` to `23`
/// added three host fields of that kind, so a version 22 document reads as a host whose
/// checks hold still between readings and that reports nothing running on any head
/// beyond its checks — which is what that build could answer. `23` to `24` added a word a
/// ledger entry may name and no field, so a version 23 document reads as it was — a
/// build that could not be asked which head merged recorded no such call.
///
/// `1` is refused rather than read for the opposite reason: it describes a provider
/// that could not publish, and every session in it would read back as open — a
/// wrong answer where a journey asserting on a session it had closed needs a
/// refusal.
pub const OLDEST_READABLE_VERSION: u32 = 2;

/// Everything the repository side of a run knows about itself.
///
/// Every field is public and serializable, so a journey both seeds a scenario and
/// asserts on what a run left behind. Everything but the version is omitted when
/// it holds nothing, so a hand-written document names only the part of a scenario
/// that matters — and a document written by a build that knew fewer fields still
/// reads here.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VcsState {
    /// The schema version this state was written at. A document that names none is
    /// the one this build writes.
    // llmlint: ignore[boundary_inputs_validated] deciding what to do with a version this
    // build does not read is the whole of the check — and it is in `Checked::check` below,
    // where a document is read, rather than here where serde only proves the shape.
    pub version: u32,
    /// The repository identities this provider can resolve. A
    /// [`SessionRequest::repo`] naming none of them is refused, the way an
    /// unregistered repository is.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub identities: Vec<Identity>,
    /// Every session opened or seeded, in the order they were opened.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub sessions: Vec<Session>,
    /// Which identity each session belongs to.
    ///
    /// Beyond the sketch this crate was specified from, and unavoidable: a
    /// [`Session`] carries no identity, and a [`Recoverable`] must name one — so
    /// preserving a session's branch could not answer the question `recoverable`
    /// asks without this. `open_session` records it; nothing else writes it.
    // llmlint: ignore[invalid_states_unrepresentable] an identity key is a `String`
    // everywhere the crate this mirrors spells one — `Recoverable.identity`,
    // `Identity.origin`, the registry document's own map key — and a newtype here would
    // make a seeded state disagree with the types it is made of. Every value written to
    // this map came out of `identity_of`, so it names an identity this provider holds.
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub session_identities: BTreeMap<SessionToken, String>,
    /// The labels each session was opened with, for the sessions opened with any.
    ///
    /// Keyed by token the way [`session_identities`](Self::session_identities) is,
    /// and for the same reason: a [`Session`] carries no labels, and both `session
    /// holders` and every `recoverable` row report them. `open_session` records
    /// them; nothing else writes it.
    // llmlint: ignore[invalid_states_unrepresentable] keyed by session token, exactly as
    // `session_identities` above is and for the same reason it gives.
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub session_labels: BTreeMap<SessionToken, BTreeMap<String, String>>,
    /// Preserved work, newest last, as `recoverable` reports it.
    ///
    /// [`Recoverable`] rather than `PreservedBranch` — it *contains* the preserved
    /// branch and adds the identity, the checkout, why the workstream stopped, and
    /// the command that lands it, none of which are derivable from the branch
    /// alone. One list rather than two that could disagree.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub preserved: Vec<Recoverable>,
    /// The sessions that have been closed. Every other session here is open.
    ///
    /// One way to say closed rather than two, so a scenario written by hand names
    /// only the sessions whose lifecycle is not the one they were opened in.
    // llmlint: ignore[invalid_states_unrepresentable] keyed by session token, exactly as
    // `session_identities` above is and for the same reason: this document is a scenario
    // somebody writes by hand, and a session is named once under `sessions` with the rest
    // of the state keyed to it rather than nested inside a shape that could hold only one
    // arrangement. A token here that names no opened session is refused in
    // `Checked::check`, where the document is read — the same trust boundary every other
    // cross-reference in it is checked at.
    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
    pub closed_sessions: BTreeSet<SessionToken>,
    /// The policy this provider publishes under.
    ///
    /// The answer a rules file gives the real implementation, which a provider has
    /// none of — so a journey states it, and unset is the policy the contract's own
    /// `default:` names ([`DEFAULT_PUBLICATION`](crate::DEFAULT_PUBLICATION)). A
    /// per-run policy narrows it through [`MergePolicy::narrow`], which is the
    /// rules system's rule rather than a restatement of it here.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub policy: Option<MergePolicy>,
    /// Whether this provider's changes need an approval, which is the other half of
    /// what decides a green draft's ending: `change-open` with approvals required
    /// keeps it for its user's review. Unset is the contract's own `default:`,
    /// [`DEFAULT_APPROVALS`](crate::DEFAULT_APPROVALS).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub approvals: Option<Approvals>,
    /// The `drafts:` a rules file would give this provider's identity. Unset, and
    /// each key it leaves unset, is the shipped default: the lifecycle on and the
    /// early-lift warning on.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub drafts: Option<Drafts>,
    /// Every publication this provider performed, in the order it performed them.
    ///
    /// Both a record a journey asserts on and the answer to "has this session been
    /// published already": a second publication of a session that landed has
    /// nothing the base does not already carry, which is what the real
    /// implementation reports for the same reason.
    // llmlint: ignore[invalid_states_unrepresentable] this holds `onevcs::Publication`
    // verbatim — the value `Vcs::publish` handed back, carrying its own session and branch
    // — so a journey asserts on exactly what a caller would receive. A shape that made
    // "this publication is of some other session's branch" unrepresentable could not hold
    // that type, and would be a second spelling of the answer the crate next door already
    // has. The cross-reference is checked in `Checked::check` instead, where the document
    // is read.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub publications: Vec<Publication>,
}

/// A repository side that knows nothing, at the version this build writes.
impl Default for VcsState {
    fn default() -> Self {
        Self {
            version: STATE_VERSION,
            identities: Vec::new(),
            sessions: Vec::new(),
            session_identities: BTreeMap::new(),
            session_labels: BTreeMap::new(),
            preserved: Vec::new(),
            closed_sessions: BTreeSet::new(),
            policy: None,
            approvals: None,
            drafts: None,
            publications: Vec::new(),
        }
    }
}

/// Everything the remote-host side of a run knows about itself.
///
/// Omitted-when-empty and versioned for the same reasons [`VcsState`] is.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HostState {
    /// The schema version this state was written at. A document that names none is
    /// the one this build writes.
    // llmlint: ignore[boundary_inputs_validated] as on `VcsState::version`: the decision
    // about an unreadable version is made in `Checked::check`, where the document is read.
    pub version: u32,
    /// Who the host says is calling. Empty is refused, exactly as a `gh` that
    /// reports no authenticated user is.
    // llmlint: ignore[invalid_states_unrepresentable] the interface this satisfies is
    // `authenticated_user() -> Result<String>`, so the login is a `String` by contract and
    // the one unusable value — a host that names nobody — is refused where it is read
    // rather than made unrepresentable in a state a journey writes by hand.
    pub authenticated_user: String,
    /// Every change request that has been opened or seeded.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub changes: Vec<ChangeRequest>,
    /// The head branch each change request was opened from.
    ///
    /// Beyond the sketch, and unavoidable: [`ChangeRequest`] records only the base
    /// it targets, and `find_changes` matches on the head as well.
    // llmlint: ignore[invalid_states_unrepresentable] the matching `ChangeSpec.head` and
    // `ChangeRequest.base` are `String` in the contract this mirrors, and a validated ref
    // type here would disagree with them. Every value written to this map went through
    // `addressable` in `open_change` first, which is the same refusal the real
    // implementation makes at the same point.
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub heads: BTreeMap<ChangeId, String>,
    /// The title each change request was opened under.
    ///
    /// Beyond the sketch, and for the same reason [`heads`](HostState::heads) is:
    /// [`ChangeRequest`] records neither, and the title is what a publication's
    /// commit subject becomes — so a journey asserting that the subject it asked
    /// for is the one the host was given has nowhere else to read it.
    // llmlint: ignore[invalid_states_unrepresentable] this records the `ChangeSpec.title`
    // the contract fixes as a `String`, so a validated type here would disagree with the
    // one it mirrors. `Subject` is not that type: it is `onevcs`'s rule for a *commit
    // subject*, and a host's own limit is its own — spelling it here would
    // refuse a seeded title a real host accepts, which is drift in the direction that
    // looks like rigour. What the host itself refuses is a title that names nothing, and
    // that is refused below and in `open_change`, at the boundary the value arrives at.
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub titles: BTreeMap<ChangeId, String>,
    /// The body each change request was opened with, for the change requests that
    /// were opened with one.
    ///
    /// Beside [`titles`](HostState::titles) and for its reason: the body is what a
    /// caller passes on the `PublishRequest` and nothing else records it, so a
    /// journey asserting that the body it drafted is the one the host was given has
    /// nowhere else to read it. A change request with no entry was opened with no
    /// body at all, which is what a publication nobody gave one does — the two are
    /// different scenarios, and an empty string is the first rather than the second.
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub bodies: BTreeMap<ChangeId, String>,
    /// The checks the host reports on each change request. A change with no entry
    /// has no checks, which is what a repository with no CI reports.
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub checks: BTreeMap<ChangeId, Vec<Check>>,
    /// The log the host hands over for a check, keyed by change request and then by
    /// check name. Beyond the sketch: `check_log` is one of the six methods, and
    /// without this the only log a journey could asssert on is a synthesized one.
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub check_logs: BTreeMap<ChangeId, BTreeMap<String, String>>,
    /// The reason each change request that was opened as a **draft** was drafted
    /// with, for the change requests that were.
    ///
    /// Beside [`bodies`](HostState::bodies) and for its reason: the reason is what a
    /// caller passes on the `PublishRequest` and this is where a journey reads back
    /// that the host was given it. A change with no entry is not a draft, which is
    /// what every change request opened without one is.
    ///
    /// Nothing is refused about the reason itself here. Its shape — that every field
    /// of it renders as the one line it is printed on — is `onevcs`'s own rule, and
    /// it is applied where a publication takes one in, before any host is asked.
    #[serde(
        skip_serializing_if = "BTreeMap::is_empty",
        deserialize_with = "drafts_of_any_version"
    )]
    pub drafts: BTreeMap<ChangeId, DraftReason>,
    /// Every `ready_for_review` call this host has taken, in the order it took them
    /// — the lifts it was asked to perform, never a request for a *reviewer*.
    ///
    /// A list rather than a set, and beyond the sketch for a reason a set could not
    /// serve: lifting a draft is required to be *idempotent*, so what a journey
    /// asserts is that a second publication asked the host for nothing — and only a
    /// record of the calls themselves can say that. A change named here is not a
    /// draft any more, whatever [`drafts`](HostState::drafts) says it was opened as.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub made_ready: Vec<ChangeId>,
    /// The change requests opened as a draft **while their required checks run** —
    /// the lifecycle's draft, which no caller gave a reason for and so is not in
    /// [`drafts`](HostState::drafts). A change named here is a draft until
    /// [`made_ready`](HostState::made_ready) names it.
    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
    pub awaiting_checks: BTreeSet<ChangeId>,
    /// The checks the host reports on a change request once it has been lifted out of
    /// its draft, in place of [`checks`](HostState::checks).
    ///
    /// What a repository whose workflow skips drafts looks like from outside: the
    /// draft's checks are skipped or absent, and the run the lift triggers registers
    /// after it. A change with no entry reports the same checks either side of a lift.
    ///
    /// A publication tells the run a lift triggered from the draft's by the run's own
    /// identity, as it would on a real host: a check named here that the draft also
    /// reported is a new run — running or settled — only where its
    /// [`started_at`](Check::started_at) is one no draft-era run of that check reported. So seed a re-run with a start of its own — an entry repeating
    /// the draft's start, or reporting none, is the draft's run still standing.
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub checks_after_lift: BTreeMap<ChangeId, Vec<Check>>,
    /// What this host answers when asked which checks a merge into a base requires.
    ///
    /// Unset is a host whose branch protection names exactly the checks it was seeded
    /// as required, on any change request — a complete answer, and an empty one where
    /// none is seeded required. Seeded, it is the answer for every base: an empty
    /// complete one is a repository declaring no required check, and one naming an
    /// `unconsulted` source is the incomplete answer a narrower credential gets.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub required_checks: Option<RequiredChecks>,
    /// Every `describe_change` call this host has taken, in the order it took them:
    /// which change request, the title it was handed when it was handed one, and the
    /// body.
    ///
    /// The calls themselves, for the reason [`made_ready`](HostState::made_ready) is:
    /// what a journey asks is what a closeout *wrote*, call by call, and the current
    /// title and body — which each call also writes into
    /// [`titles`](HostState::titles) and [`bodies`](HostState::bodies), so
    /// `change_description` answers from the same record `open_change` wrote — can
    /// only say where the description ended up.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub described: Vec<Described>,
    /// Which sources this host answers about its checks from, which is what the
    /// real implementation reports alongside them.
    ///
    /// Unset is a credential allowed to read everything: the whole rollup, exactly
    /// what a host with nothing to hide reports. A journey states a narrower set to
    /// be the credential the real one meets in CI — a fine-grained token, which
    /// cannot read check runs at all and sees GitHub Actions and nothing else — and
    /// states an *empty* one to be a credential that can read no source at all,
    /// which is a refusal rather than "no checks". Unset and empty are therefore
    /// different scenarios, which is why this is an `Option` rather than a set whose
    /// emptiness means "not stated".
    #[serde(skip_serializing_if = "Option::is_none")]
    pub check_sources: Option<BTreeSet<CheckSource>>,
    /// What merging each change request did.
    ///
    /// Both a script and a record: an entry seeded here is what `merge` answers,
    /// whatever the policy asks for — which is how a journey expresses a host that
    /// queues or refuses — and a merge the policy decided is written back here.
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub merges: BTreeMap<ChangeId, MergeOutcome>,
    /// When each merged change request reached its base, as this host spells the
    /// moment — what `merge_time` answers.
    ///
    /// Written beside [`merges`](HostState::merges) whenever this host performs a
    /// merge, at the moment it performs it, and seedable for a merge a journey
    /// seeded: a host that merged on its own clock, earlier than anybody asked, is
    /// exactly what a late merge's record must report the time of. A merged change
    /// with no entry is one whose time this host cannot say.
    // llmlint: ignore[invalid_states_unrepresentable] this mirrors the answer
    // `RemoteHost::merge_time` gives, which the contract fixes as the host's own spelling
    // in an `Option<String>`; a timestamp type here would disagree with the seam it
    // answers. A value that is not an RFC3339 moment is refused in `check`, where the
    // document is read, and every one this host writes is `events::timestamp`'s.
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub merge_times: BTreeMap<ChangeId, String>,
    /// Each change request's review feedback — review-thread comments, review
    /// summaries and conversation comments — in the order it was posted.
    ///
    /// Both a script and a record: what a journey seeds here is what
    /// `review_comments` reads, [`Host::add_comment`](crate::Host::add_comment),
    /// [`Host::edit_comment`](crate::Host::edit_comment) and
    /// [`Host::set_thread`](crate::Host::set_thread) move it as a reviewer would, and
    /// every reply `reply_to_comment` posts is written back here beside them.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub review_comments: BTreeMap<ChangeId, Vec<HostComment>>,
    /// Every reply this host posted, in the order it posted them.
    ///
    /// The calls themselves, for the reason [`described`](HostState::described) is:
    /// what a journey about idempotency asks is how many replies were *posted*, and
    /// the comments a read returns can only say what is there now.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub replies: Vec<Replied>,
    /// What this host charges for each read of review feedback, one entry a read, in
    /// order; a read with none left is charged the one GraphQL point a page that
    /// `spike-review-loop` measured, and no REST request.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub review_charges: Vec<ReadCost>,
    /// The change requests closed without merging — by a person on the host, or because
    /// the branch they target was deleted from under them.
    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
    pub closed: BTreeSet<ChangeId>,
    /// The branches gone from the origin: a merged change request's head a person
    /// deleted. A closed change request targeting one reads `base_deleted`, and nothing
    /// is retargeted onto one.
    // llmlint: ignore[invalid_states_unrepresentable] branch names, as `heads` holds them;
    // each is held to git's grammar in `check`, where the document is read.
    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
    pub deleted_branches: BTreeSet<String>,
    /// Each check's state on one head commit, in place of the change request's own
    /// [`checks`](HostState::checks): what a host reports for a head pushed since, and
    /// how a journey gives each change request of a stack its own verdict.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub head_checks: BTreeMap<Sha, Vec<Check>>,
    /// Every host call this host answered, in the order it answered them: the method,
    /// the change requests it names and what GitHub charges the call the real host
    /// sends — which is what a consumer sums over a whole review round.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub ledger: Vec<HostCall>,
    /// Whether this host requires a branch to be up to date with its base before it
    /// merges one into it — GitHub branch protection's `strict` — which is what
    /// `requires_up_to_date` answers. Unset is a host that requires it of no branch.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub up_to_date_required: bool,
    /// Whether the repository lets a change request be armed to merge itself once its
    /// required checks pass — GitHub's `allow_auto_merge` — which is what
    /// `allows_auto_merge` answers. Unset is a repository that allows it, so a
    /// publication whose watch elapses arms the merge; `false` is one that does not,
    /// so it is left as the elapsed watch left it. Only `false` is written, so allowed
    /// has the one spelling.
    #[serde(default = "allowed", skip_serializing_if = "is_allowed")]
    pub allow_auto_merge: bool,
    /// The change requests whose own merge is armed — by a `change-auto` publication,
    /// or by one whose watch elapsed with no required check red. The host lands one
    /// the moment its required checks are green, whoever asks next: a publication
    /// watching it, or one asking for its merge again.
    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
    pub armed: BTreeSet<ChangeId>,
    /// The checks each reading of a change request reports, in turn: the first
    /// `change_checks` call about it answers the first entry, the second the second,
    /// and the last entry answers every call after it — so a journey scripts each poll
    /// of a watch rather than racing a clock. Seeded, it stands in for that change
    /// request's [`checks`](HostState::checks), and for its
    /// [`checks_after_lift`](HostState::checks_after_lift) unless
    /// [`check_readings_after_lift`](HostState::check_readings_after_lift) scripts the
    /// lift's side; a merge is judged on the entry the latest reading answered.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub check_readings: BTreeMap<ChangeId, Vec<Vec<Check>>>,
    /// The same script for the readings after a change request is lifted, counted from
    /// its lift: the first `change_checks` call after `ready_for_review` answers the
    /// first entry. A lift happens on a clock — the grace window — so the readings on
    /// each side of it are scripted apart, and neither has to guess on which poll it
    /// came.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub check_readings_after_lift: BTreeMap<ChangeId, Vec<Vec<Check>>>,
    /// What `head_activity` answers about a change request, in turn: one answer per
    /// call, and the last answering every call after it. A change request with none
    /// answers, completely, that nothing is running on the commit it was asked about —
    /// a host whose checks are the whole of what runs, which is what every journey
    /// written before this answer existed describes.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub head_activity: BTreeMap<ChangeId, Vec<ActivityAnswer>>,
}

/// One answer a host gives about the work still running on a change request's head —
/// what [`HostState::head_activity`] scripts per call.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "answer")]
pub enum ActivityAnswer {
    /// An answer about the commit it was asked about, naming the work in progress on
    /// it — none, where `in_progress` is empty.
    Head {
        /// The host's name for each piece of work still in progress.
        // llmlint: ignore[invalid_states_unrepresentable] a scripted copy of
        // `onevcs::HeadActivity::in_progress`, whose contract-fixed type it must hand
        // back unchanged; any name a host could report is a valid one.
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        in_progress: Vec<String>,
        /// Whether every source of such work was read: `false` is a credential that
        /// could read the workflow runs and not the check suites.
        // llmlint: ignore[invalid_states_unrepresentable] a scripted copy of the
        // contract-fixed `onevcs::HeadActivity::complete: bool`, with the same two states.
        #[serde(default = "allowed", skip_serializing_if = "is_allowed")]
        complete: bool,
    },
    /// An answer about `head`, a commit other than the one asked about — a host that
    /// has not processed the push yet.
    Other {
        /// The commit the answer is about.
        head: Sha,
        /// The host's name for each piece of work still in progress on it.
        // llmlint: ignore[invalid_states_unrepresentable] as on `Head::in_progress`: the
        // contract-fixed `HeadActivity::in_progress` this answer is handed back as.
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        in_progress: Vec<String>,
    },
    /// A refusal: a credential that may read neither source.
    Refused,
}

/// Every method a ledger entry may name: each `RemoteHost` method this host answers.
/// `Host::called` refuses to record any other, and
/// `the_ledger_records_a_call_to_every_method_the_host_interface_declares` holds this
/// list equal to the trait's own declaration, read out of its source.
pub(crate) const LEDGER_METHODS: &[&str] = &[
    "allows_auto_merge",
    "authenticated_user",
    "change_checks",
    "change_description",
    "change_standings",
    "check_log",
    "describe_change",
    "find_changes",
    "head_activity",
    "is_draft",
    "merge",
    "merge_time",
    "merged_at",
    "merged_head",
    "mergeability",
    "open_change",
    "ready_for_review",
    "reopen_change",
    "reply_to_comment",
    "required_checks_on",
    "requires_up_to_date",
    "retarget_change",
    "review_comments",
    "review_standings",
    "visibility",
];

/// One host call a host answered, as its ledger records it.
// llmlint: ignore[invalid_states_unrepresentable] `method` is the name of the
// `RemoteHost` method that was called; `HostCall::checked` holds it to `LEDGER_METHODS`
// where the document is read and where a call is recorded, and nothing reads a decision
// out of the name.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostCall {
    /// The `RemoteHost` method called, by its name.
    pub method: String,
    /// The change requests the call names, in the order it names them.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub changes: Vec<ChangeId>,
    /// What GitHub charges the call the real host sends.
    pub charge: ReadCost,
}

impl HostCall {
    /// Refuse an entry no call to this host could have written: a method it does not
    /// answer, or a change request named by no identifier at all. A change request it
    /// never opened is not refused — the real host charges a call it then refuses, and
    /// so the ledger records one.
    pub(crate) fn checked(&self) -> Result<()> {
        if !LEDGER_METHODS.contains(&self.method.as_str()) {
            return Err(Error::Invalid {
                reason: format!(
                    "the call ledger records a call to {:?}, which is no method this host \
                     answers",
                    self.method
                ),
            });
        }
        if self.changes.iter().any(|id| id.0.trim().is_empty()) {
            return Err(Error::Invalid {
                reason: format!(
                    "the call ledger records a call to {:?} naming a change request with no \
                     identifier",
                    self.method
                ),
            });
        }
        Ok(())
    }
}

/// One comment a host holds on a change request.
///
/// What `review_comments` answers a [`ReviewComment`](onevcs::ReviewComment) from:
/// everything but `ours` and `reply_marker`, which are read out of the body exactly as
/// the real host's are, and with the one thing a host keeps that a read does not
/// show — when it last changed.
// llmlint: ignore[invalid_states_unrepresentable] every text field is what the real host
// reports as a string and `ReviewComment` carries as one; a seeded comment is the
// journey's own scenario, and `check` refuses the two things no host could hold — an id
// held twice, and a reply to a comment the change request does not carry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostComment {
    /// The host's id for it.
    pub id: CommentId,
    /// What kind it is, and — for a thread comment — its thread's flags.
    pub kind: CommentKind,
    /// Who wrote it.
    pub author: String,
    /// What it says.
    pub body: String,
    /// Where a person reads it.
    pub url: String,
    /// When it was written.
    pub created_at: String,
    /// When it last changed.
    pub updated_at: String,
    /// The comment it replies to in its thread.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub in_reply_to: Option<CommentId>,
    /// When, in this host's own count of changes to the change request's feedback, it
    /// last changed — what a read marker is compared against. A seeded comment at `0`
    /// is one every read from a marker has already seen.
    #[serde(default)]
    pub revision: u64,
}

/// One reply a host posted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Replied {
    /// The change request it was posted on.
    pub change: ChangeId,
    /// The comment it was posted against.
    pub comment: CommentId,
    /// The idempotency key the call carried.
    pub key: String,
    /// The reply's own id.
    pub reply: CommentId,
    /// Whether it was posted in the comment's thread.
    pub threaded: bool,
}

/// One description a host was handed for a change request after it was opened.
///
/// What `RemoteHost::describe_change` was given, verbatim: the title only where the
/// call carried one, because a call that replaced the body alone left the title as
/// it was, and a record that filled it in would say the caller wrote a title nobody
/// wrote.
// llmlint: ignore[invalid_states_unrepresentable] the title and body are the `&str`s the
// seam hands over — a host places no shape on prose, and `onevcs`'s own `Subject` rule
// was applied where the description was composed, before any host was asked.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Described {
    /// The change request the description was written to.
    pub id: ChangeId,
    /// The title the call replaced the change request's with, when it replaced one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    /// The body the call replaced the change request's with.
    pub body: String,
}

/// The `drafts` map of a document at any readable version.
///
/// A document written before version 9 spells a draft reason as the four fields of
/// the one kind there was and no `kind` beside them; this build's reason is one of
/// two kinds and says which. So a reason arriving without a `kind` is read as
/// `awaiting-release` — exactly what it was — rather than refused, which is what
/// keeps a consumer's checked-in scenario readable across the bump. A reason that
/// *names* a kind is read as it names it, so nothing written by this build is
/// reinterpreted.
fn drafts_of_any_version<'de, D>(
    deserializer: D,
) -> std::result::Result<BTreeMap<ChangeId, DraftReason>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let raw: BTreeMap<ChangeId, serde_json::Map<String, serde_json::Value>> =
        Deserialize::deserialize(deserializer)?;
    raw.into_iter()
        .map(|(id, mut fields)| {
            fields
                .entry("kind")
                .or_insert_with(|| serde_json::Value::from("awaiting-release"));
            serde_json::from_value::<DraftReason>(serde_json::Value::Object(fields))
                .map(|reason| (id, reason))
                .map_err(serde::de::Error::custom)
        })
        .collect()
}

/// Who a host with nothing seeded says is calling.
///
/// A host that answers nobody is refused by the real implementation, so a default
/// state that answered nobody would be a provider that cannot run a publication
/// until it is configured.
pub const DEFAULT_AUTHENTICATED_USER: &str = "onevcs-testing";

impl Default for HostState {
    fn default() -> Self {
        Self {
            version: STATE_VERSION,
            authenticated_user: DEFAULT_AUTHENTICATED_USER.to_owned(),
            changes: Vec::new(),
            heads: BTreeMap::new(),
            titles: BTreeMap::new(),
            bodies: BTreeMap::new(),
            drafts: BTreeMap::new(),
            made_ready: Vec::new(),
            awaiting_checks: BTreeSet::new(),
            checks_after_lift: BTreeMap::new(),
            required_checks: None,
            described: Vec::new(),
            checks: BTreeMap::new(),
            check_logs: BTreeMap::new(),
            check_sources: None,
            merges: BTreeMap::new(),
            merge_times: BTreeMap::new(),
            review_comments: BTreeMap::new(),
            replies: Vec::new(),
            review_charges: Vec::new(),
            closed: BTreeSet::new(),
            deleted_branches: BTreeSet::new(),
            head_checks: BTreeMap::new(),
            ledger: Vec::new(),
            up_to_date_required: false,
            allow_auto_merge: true,
            armed: BTreeSet::new(),
            check_readings: BTreeMap::new(),
            check_readings_after_lift: BTreeMap::new(),
            head_activity: BTreeMap::new(),
        }
    }
}

/// What a host document that says nothing about auto-merge allows: GitHub's default.
fn allowed() -> bool {
    true
}

/// Whether `allow_auto_merge` holds the default, which a written document omits.
#[expect(
    clippy::trivially_copy_pass_by_ref,
    reason = "serde's skip_serializing_if passes a reference"
)]
fn is_allowed(allow: &bool) -> bool {
    *allow
}

/// The identity a session request names, or the reason none of them is it.
///
/// Three ways to name one, mirroring what the registry accepts: the identity key
/// itself, the `owner/name` tail of it, or the bare repository name.
pub(crate) fn identity_of<'a>(state: &'a VcsState, origin_or_path: &str) -> Option<&'a Identity> {
    let wanted = origin_or_path.trim_end_matches('/');
    state
        .identities
        .iter()
        .find(|identity| identity.origin == wanted)
        .or_else(|| {
            state.identities.iter().find(|identity| {
                identity
                    .origin
                    .rsplit('/')
                    .next()
                    .is_some_and(|name| name == wanted)
                    || identity.origin.ends_with(&format!("/{wanted}"))
            })
        })
}

/// The known identities, as a refusal names them.
pub(crate) fn known(state: &VcsState) -> String {
    if state.identities.is_empty() {
        return "this provider was seeded with no identities".to_owned();
    }
    let names: Vec<&str> = state
        .identities
        .iter()
        .map(|identity| identity.origin.as_str())
        .collect();
    format!("it knows {}", names.join(", "))
}

/// The identity a session belongs to, or the reason this provider cannot say.
///
/// `open_session` records it and nothing else writes it, so a session that arrived
/// in a hand-written scenario without one is refused here rather than published
/// against a repository nobody named.
pub(crate) fn identity_for(state: &VcsState, token: &SessionToken) -> Result<String> {
    state
        .session_identities
        .get(token)
        .cloned()
        .ok_or_else(|| Error::Invalid {
            reason: format!(
                "this provider has no record of session {:?}, so it cannot say which identity \
                 its work belongs to",
                token.0
            ),
        })
}

/// The session a token names.
pub(crate) fn session_of<'a>(state: &'a VcsState, token: &SessionToken) -> Option<&'a Session> {
    state
        .sessions
        .iter()
        .find(|session| session.token == *token)
}

/// The branch a request asks for, or the one that is derived from the token.
///
/// `branch` and `branch_name` are two answers to one question — *continue this* and
/// *cut one at this* — so a request naming both is refused here as the real
/// implementation refuses it: that refusal is about the shape of the request rather
/// than about anything a repository does, and a consumer proving it must meet the
/// same answer on either side.
///
/// **What a provider deliberately does not do to `branch_name` is the rest**, and it
/// is not approximated: the host's branch prefix is read out of an `onevcs` state
/// root no provider has, and the first-free suffix is searched against an identity's
/// checkouts and its origin, which a provider has neither of. So the name is taken as
/// it was proposed and refused — naming it — where git would refuse it, rather than
/// being sanitized by a second copy of a grammar that would then have to be held to
/// the first. A consumer proving what the prefix, the sanitizer and the collision
/// suffix do drives the real `onevcs`, which is where all three live.
pub(crate) fn requested_branch(req: &SessionRequest, token: &SessionToken) -> Result<String> {
    if let (Some(pinned), Some(proposed)) = (&req.branch, &req.branch_name) {
        return Err(Error::Invalid {
            reason: format!(
                "this request names both a branch to continue ({pinned:?}) and a name to cut \
                 ({proposed:?}), which are two answers to one question"
            ),
        });
    }
    let name = req
        .branch
        .clone()
        .or_else(|| req.branch_name.clone())
        .unwrap_or_else(|| format!("onevcs/{}", token.0));
    named_branch(&name, "the branch")?;
    Ok(name)
}

/// A branch name, refused here if git would refuse it.
///
/// The real implementation asks `git check-ref-format`, which is the parser that
/// decides; a provider with no git carries `git-check-ref-format(1)`'s rules
/// instead. That is a restatement, so it is gated rather than trusted:
/// `refs.rs` in the suite runs both this and git itself over a table of names
/// and holds them to each other, because a copy of somebody else's grammar with
/// no gate is a copy that drifts.
///
/// One deliberate difference, and the gate knows about it: a leading `-` is
/// refused here even though git accepts it as a ref, because such a name reaches
/// a command line as an option rather than as the branch it spells.
pub(crate) fn named_branch(value: &str, what: &str) -> Result<()> {
    // Rule 1 is per slash-separated component; the rest are about the whole name.
    let components_usable = !value.is_empty()
        && value.split('/').all(|component| {
            !component.is_empty() && !component.starts_with('.') && !component.ends_with(".lock")
        });
    let usable = components_usable
        && !value.starts_with('-')
        && !value.contains("..")
        && !value.contains("@{")
        && !value.ends_with('.')
        && !value.ends_with('/')
        && !value.chars().any(|c| {
            c.is_whitespace() || c.is_ascii_control() || c == '\u{7f}' || "~^:?*[\\".contains(c)
        });
    if !usable {
        return Err(Error::Invalid {
            reason: format!("{what} {value:?} is a name git would not accept"),
        });
    }
    Ok(())
}

/// A label, refused here where the real implementation refuses one: a key is one or
/// more of `A-Z`, `a-z`, `0-9`, `_` and `-`, and a value is one line. The same rule
/// spelled twice on purpose, for the reason [`named_branch`] gives — a provider with
/// no `onevcs` state root carries the grammar, and the journeys over both providers
/// hold the two to each other.
pub(crate) fn label_pair(key: &str, value: &str) -> Result<()> {
    let key_usable = !key.is_empty()
        && key
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
    if !key_usable {
        return Err(Error::Invalid {
            reason: format!(
                "{key:?} is not a label key; a key is one or more of A-Z, a-z, 0-9, `_` and `-`"
            ),
        });
    }
    if value.contains('\n') {
        return Err(Error::Invalid {
            reason: format!(
                "the value of label {key:?} holds a newline; a label's value is one line"
            ),
        });
    }
    Ok(())
}

/// A seeded repository side is refused if it holds a session nothing could act on.
impl Checked for VcsState {
    fn check(&self) -> Result<()> {
        readable_version(self.version)?;
        for session in &self.sessions {
            // The token names a file under the state root, and a branch goes on to
            // spell a ref; both arrive from whoever wrote the document.
            if !events::is_safe_name(&session.token.0) {
                return Err(Error::Invalid {
                    reason: format!("{:?} is not a session token", session.token.0),
                });
            }
            named_branch(&session.branch, "the branch")?;
            named_branch(&session.base, "the base")?;
        }
        for row in &self.preserved {
            known_identity(self, &row.identity, "preserved work")?;
            named_branch(&row.branch.branch, "the preserved branch")?;
            named_branch(&row.branch.base, "the preserved branch's base")?;
            // A hold that names a session is checked like the three below: a row
            // held by a session nobody opened is the same fiction, and the costly one
            // — it is the session an operator is told to wait for or close. A running
            // publication holds a branch under no session and names none.
            if let Some(token) = row.held_by.as_ref().and_then(|held| held.token.as_ref()) {
                opened(self, token, "holding preserved work")?;
            }
        }
        // Both of these name a session, so both are checked twice over: the token
        // has to be a plain name, because it goes on to spell the file its stream is
        // written in, and it has to name a session this state actually holds. A
        // document that closes or publishes a session nobody opened describes a run
        // that could not have happened, and answering `recoverable` or `session`
        // from it would be answering from a fiction rather than refusing one.
        for token in &self.closed_sessions {
            opened(self, token, "closed")?;
        }
        for (token, labels) in &self.session_labels {
            opened(self, token, "labelled")?;
            for (key, value) in labels {
                label_pair(key, value)?;
            }
        }
        for (token, origin) in &self.session_identities {
            opened(self, token, "given an identity")?;
            // The value as well as the key: this is what `identity_for` answers with,
            // and it goes on to spell the slug a change request is opened against and
            // the label every one of that session's events carries. An identity this
            // provider does not know is one it could not have opened the session for.
            known_identity(self, origin, &format!("session {:?}", token.0))?;
        }
        for publication in &self.publications {
            let session = opened(self, &publication.session, "published")?;
            named_branch(&publication.branch, "the published branch")?;
            // A publication of some other branch than the one the session is on is
            // the same kind of fiction, and the harder one to spot afterwards: the
            // branch is what a journey asserts the publication was of.
            if publication.branch != session.branch {
                return Err(Error::Invalid {
                    reason: format!(
                        "the publication of session {:?} names branch {:?}, but that session is \
                         on {:?}",
                        publication.session.0, publication.branch, session.branch
                    ),
                });
            }
        }
        Ok(())
    }

    /// Every field an older readable document could hold means here what it meant
    /// there, and the two the repository side gained in version 4 are absent from one
    /// written before them — which is the answer, since that build held no hold and
    /// counted no lines.
    ///
    /// Version 5's field is the exception, and it is carried rather than left at its
    /// default: a document written before it *listed preserved work nobody had
    /// published*, which is the `no` this build spells out. Left at the default it
    /// would read as "nothing here can say", and a consumer's checked-in scenario
    /// would start reporting work it had always called unpublished as work that may
    /// have landed.
    fn carry_forward(&mut self) {
        if self.version < 5 {
            for row in &mut self.preserved {
                row.landed = Landed::No;
            }
        }
        self.version = STATE_VERSION;
    }
}

/// Refuse a record kept about a change request this state does not hold.
fn opened_change(state: &HostState, id: &ChangeId, what: &str) -> Result<()> {
    if state.changes.iter().any(|change| change.id == *id) {
        return Ok(());
    }
    Err(Error::Invalid {
        reason: format!(
            "{what} is recorded for change request {:?}, but no change request by that \
             identifier was opened",
            id.0
        ),
    })
}

/// Refuse an identity key this provider was not seeded with.
fn known_identity(state: &VcsState, origin: &str, what: &str) -> Result<()> {
    if state
        .identities
        .iter()
        .any(|identity| identity.origin == origin)
    {
        return Ok(());
    }
    Err(Error::Invalid {
        reason: format!(
            "{what} belongs to identity {origin:?}, which this provider does not know; {}",
            known(state)
        ),
    })
}

/// A change request's title, refused when it names nothing.
///
/// The one thing a real host refuses about a title, and the only one this provider
/// may: how long a title may be is the host's own rule rather than `onevcs`'s
/// commit-subject rule, and a provider applying the stricter of the two would
/// refuse what the host it stands in for accepts.
pub(crate) fn titled(title: &str) -> Result<()> {
    if title.trim().is_empty() {
        return Err(Error::Invalid {
            reason: "a change request's title is blank, so it names no change".to_owned(),
        });
    }
    Ok(())
}

/// The session a token names, refused when this state does not hold one.
fn opened<'a>(state: &'a VcsState, token: &SessionToken, what: &str) -> Result<&'a Session> {
    if !events::is_safe_name(&token.0) {
        return Err(Error::Invalid {
            reason: format!("{:?} is not a session token", token.0),
        });
    }
    session_of(state, token).ok_or_else(|| Error::Invalid {
        reason: format!(
            "session {:?} is {what} here, but no session by that token was opened",
            token.0
        ),
    })
}

/// A seeded host side is refused if it holds a change nothing could address.
impl Checked for HostState {
    fn check(&self) -> Result<()> {
        readable_version(self.version)?;
        for change in &self.changes {
            named_branch(&change.base, "the base of a seeded change request")?;
            if change.id.0.is_empty() {
                return Err(Error::Invalid {
                    reason: "a seeded change request carries no identifier".to_owned(),
                });
            }
            // The commit a change request's checks are reported against is the whole
            // evidence that a change reached anything, and the real implementation
            // refuses a host answer that names none rather than passing a blank one
            // through. A seeded one is refused for the same reason.
            if change.head_sha.0.trim().is_empty() {
                return Err(Error::Invalid {
                    reason: format!(
                        "the seeded change request {:?} names no commit its checks are \
                         reported against",
                        change.id.0
                    ),
                });
            }
        }
        // Both of these are recorded *about* a change request, by `open_change` and
        // by nothing else, and both are read back by the id they are keyed under. An
        // entry for a change nobody opened is one no call could ever reach, so it is
        // refused rather than carried — unlike the checks, logs, and merge outcomes
        // below it, which a journey deliberately seeds for a change it has not
        // opened yet.
        for (id, head) in &self.heads {
            opened_change(self, id, "a head")?;
            named_branch(head, "the head of a seeded change request")?;
        }
        for (id, title) in &self.titles {
            opened_change(self, id, "a title")?;
            // The real host refuses a title that names nothing, so a seeded one is
            // refused for the same reason.
            titled(title)?;
        }
        for id in self.bodies.keys() {
            // Nothing is refused about the body itself: a host places no shape on
            // prose, and a change request opened with an empty one is a scenario.
            opened_change(self, id, "a body")?;
        }
        for (id, reason) in &self.drafts {
            opened_change(self, id, "a draft reason")?;
            // The publication rule the crate next door states, applied rather than
            // restated: a seeded reason no publication could have carried is refused
            // where the document is read, not answered from later.
            reason.checked()?;
        }
        for id in &self.made_ready {
            opened_change(self, id, "a lifted draft")?;
        }
        for id in &self.awaiting_checks {
            opened_change(self, id, "a draft awaiting its checks")?;
        }
        for described in &self.described {
            opened_change(self, &described.id, "a description")?;
            // The real host refuses a title that names nothing, so a seeded one is
            // refused for the same reason — and a body is prose, refused for nothing.
            if let Some(title) = &described.title {
                titled(title)?;
            }
        }
        // Review feedback is read by the change request it is keyed under, and a reply
        // names the comment it answers, so a comment on a change nobody opened, an id
        // held twice, or a reply to a comment that is not there is one no read could
        // answer as the real host would.
        for (id, comments) in &self.review_comments {
            opened_change(self, id, "review feedback")?;
            let mut held = BTreeSet::new();
            for comment in comments {
                if comment.id.0.trim().is_empty() || !held.insert(&comment.id) {
                    return Err(Error::Invalid {
                        reason: format!(
                            "the review feedback seeded for change request {:?} holds the \
                             comment id {:?} more than once, or an empty one",
                            id.0, comment.id.0
                        ),
                    });
                }
            }
            for comment in comments {
                // A read hands these on as the RFC 3339 moments the contract promises.
                for (field, at) in [
                    ("created_at", &comment.created_at),
                    ("updated_at", &comment.updated_at),
                ] {
                    if events::moment(at).is_none() {
                        return Err(Error::Invalid {
                            reason: format!(
                                "comment {:?} on change request {:?} has {field} {at:?}, which \
                                 is not an RFC 3339 moment",
                                comment.id.0, id.0
                            ),
                        });
                    }
                }
                if let Some(answered) = &comment.in_reply_to {
                    if !held.contains(answered) {
                        return Err(Error::Invalid {
                            reason: format!(
                                "comment {:?} on change request {:?} replies to {:?}, which \
                                 that change request does not carry",
                                comment.id.0, id.0, answered.0
                            ),
                        });
                    }
                }
            }
        }
        for id in &self.closed {
            opened_change(self, id, "a closing")?;
        }
        for id in &self.armed {
            opened_change(self, id, "an armed merge")?;
        }
        // Scripted, like the checks, for a change a journey has not opened yet. What is
        // refused is a script no call could answer from: one with no entry, which would
        // read as unseeded while saying it was seeded, and an answer about a commit
        // that names none.
        for (id, readings) in self
            .check_readings
            .iter()
            .chain(&self.check_readings_after_lift)
        {
            if readings.is_empty() {
                return Err(Error::Invalid {
                    reason: format!(
                        "the checks scripted for change request {:?} hold no reading",
                        id.0
                    ),
                });
            }
        }
        for (id, answers) in &self.head_activity {
            if answers.is_empty() {
                return Err(Error::Invalid {
                    reason: format!(
                        "the head activity scripted for change request {:?} holds no answer",
                        id.0
                    ),
                });
            }
            for answer in answers {
                if let ActivityAnswer::Other { head, .. } = answer {
                    if head.0.trim().is_empty() {
                        return Err(Error::Invalid {
                            reason: format!(
                                "the head activity scripted for change request {:?} answers \
                                 about a commit it does not name",
                                id.0
                            ),
                        });
                    }
                }
            }
        }
        for branch in &self.deleted_branches {
            named_branch(branch, "a deleted branch")?;
        }
        for call in &self.ledger {
            call.checked()?;
        }
        // A merge time is what `merge_time` answers and what a landing is recorded
        // with, so a seeded one that is not a moment is refused here, where the
        // document is read, rather than recorded as one.
        for (id, at) in &self.merge_times {
            if events::moment(at).is_none() {
                return Err(Error::Invalid {
                    reason: format!(
                        "the merge time seeded for change request {:?} is {at:?}, which is \
                         not an RFC3339 moment",
                        id.0
                    ),
                });
            }
        }
        Ok(())
    }

    /// A version 2 document held no bodies, and a version 5 document's checks named
    /// no commit; each reads as what it was — change requests opened with none, and
    /// checks whose head that build never recorded. There is nothing to fill in — an
    /// absent field is already that answer, and a check's commit is the one thing
    /// that must never be inferred — so this is the version and nothing else. The one
    /// carry a version 8 document needs, the `kind` its draft reasons lack, is done
    /// where those are read (`drafts_of_any_version`), because it is a fact about
    /// one value's shape rather than about the document.
    fn carry_forward(&mut self) {
        self.version = STATE_VERSION;
    }
}

/// Refuse a document written at a version this build does not read.
///
/// Named rather than guessed at: a state whose shape is a later build's reads one
/// way here and another way where that version is understood, and for a seeded
/// scenario those two readings are two different tests. The refusal names the
/// range as well as the version, because "too old" and "too new" are two different
/// things to do about it — one is a document to re-seed, the other a build to
/// update.
fn readable_version(declared: u32) -> Result<()> {
    if !(OLDEST_READABLE_VERSION..=STATE_VERSION).contains(&declared) {
        return Err(Error::Invalid {
            reason: format!(
                "the document declares version {declared}; this build reads versions \
                 {OLDEST_READABLE_VERSION} to {STATE_VERSION}"
            ),
        });
    }
    Ok(())
}