vtc-service 0.12.0

Service for Verifiable Trust Communities
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
//! Artifact lifecycle: the vocabulary, and the one rule that says which state
//! controls when an artifact and a later event about it disagree.
//!
//! ## The gap this closes
//!
//! #1075 fixed an instance — an expired VRC became a permanent graph edge
//! because `routes/relationships.rs` read neither `validFrom` nor
//! `validUntil`. It did not fix the class. Nothing in this workspace
//! established **precedence between a cryptographically valid but stale
//! artifact and a later lifecycle event**, and the relationship graph had no
//! vocabulary beyond "the row exists" and "the row was deleted" (#1079).
//!
//! The absence of revocation is what makes precedence load-bearing rather than
//! decorative. A VRC deliberately carries no `credentialStatus` — planning
//! review D7, because a status-list host learns which verifier checked which
//! credential and when, which is the correlation the pairwise work exists to
//! remove. Where a credential type omits status for privacy reasons, effective
//! time and event precedence are not a refinement layered on top of revocation;
//! they are the only lifecycle mechanism left.
//!
//! ## Two clocks, and which one wins
//!
//! An artifact carries two independent notions of time, and conflating them is
//! how "still valid" and "no longer in force" got treated as one question:
//!
//! - **validity time** — the window the issuer stated *inside* the artifact,
//!   fixed at issuance and covered by its signature. [`ValidityWindow`].
//! - **transaction time** — when this service *recorded* something about the
//!   artifact afterwards. [`LifecycleLog`], append-only.
//!
//! [`resolve`] combines them under one rule, stated once here so no call site
//! has to re-derive it:
//!
//! > A later recorded event takes precedence over the artifact's own earlier
//! > assertion, but only in the restrictive direction. An event can put an
//! > artifact out of force at any time; **no event can put an artifact into
//! > force that its own window excludes.**
//!
//! The asymmetry is the whole design. Precedence exists so that a community
//! can end an artifact's effect without waiting for its window and without a
//! status list; letting it run the other way would let a recorded event
//! manufacture validity the issuer never signed for, which is a far worse
//! failure than an edge that lingers. So [`LifecycleEventKind::Restored`]
//! returns an artifact to the governance of its own window — it does not
//! extend it. Restoring a credential that expired while suspended yields
//! [`InForce::Expired`], not [`InForce::Yes`].
//!
//! ## Restoration versus replacement
//!
//! [`LifecycleEventKind::Restored`] reverses a suspension and nothing else.
//! [`LifecycleEventKind::Withdrawn`] and [`LifecycleEventKind::Superseded`]
//! are terminal: once recorded, no further event is accepted against that
//! artifact.
//!
//! This is a deliberate boundary, not a limitation. "Un-withdraw" and
//! "un-supersede" read like the inverse of the verbs they undo, but they are
//! not: the party relying on the artifact between the withdrawal and the
//! reversal was entitled to treat it as gone, and a log that can take a
//! terminal state back cannot be used to answer "was this in force at time T"
//! — which is the question the log exists to answer. The supported way to give
//! effect back to a withdrawn or superseded relationship is to issue a fresh
//! artifact, which produces a new signature, a new window, and a record that
//! says a *new* assertion was made rather than an old one quietly resumed.
//!
//! ## Deliberately undiscoverable current state
//!
//! [`resolve`] is a *local* answer: it reports what this service has recorded.
//! A verifier elsewhere holding the same VRC has no way to reach this log, by
//! design — that is the same privacy decision that removed `credentialStatus`.
//! Such a verifier is entitled to conclude only that the artifact was validly
//! issued and is inside its window; it is **not** entitled to conclude the
//! artifact is in force, because it cannot see whether a later event displaced
//! it. Anything that must be relied on without discovery has to carry its own
//! bound window, which is why the VIC and recognition paths *require*
//! `validUntil` where this module treats it as optional
//! (`credentials::ingress::check_validity_window`).
//!
//! ## Where it is applied
//!
//! Relationships are wired up: [`crate::relationships::Relationship`] carries
//! a log, the graph view resolves both halves before calling an edge complete,
//! and the suspend / restore / supersede routes are the producers. The types
//! here are deliberately artifact-agnostic — they take a window and a log, not
//! a `Relationship` — so a VPC, a VDC or a VMC can be brought under the same
//! rule without a second precedence implementation appearing.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use vti_common::error::AppError;

/// The validity window an artifact asserts about itself.
///
/// Read from a credential by `credentials::ingress::validity_window`, which
/// owns the parsing (both the VC 2.0 and VC 1.1 spellings, and the rejection
/// of a document stating both). The type lives here rather than there because
/// the window is half of the vocabulary this module defines; the reader lives
/// there because ingress is where documents from outside are parsed.
///
/// Both bounds are optional, because both are optional in W3C VC 2.0. An
/// absent bound states no bound — it is not treated as "now".
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ValidityWindow {
    pub valid_from: Option<DateTime<Utc>>,
    pub valid_until: Option<DateTime<Utc>>,
}

impl ValidityWindow {
    /// An artifact that states neither bound. Its window can never exclude it,
    /// so its state is decided entirely by its lifecycle log.
    pub const UNBOUNDED: Self = Self {
        valid_from: None,
        valid_until: None,
    };

    /// Where `now` sits relative to the stated bounds.
    ///
    /// **Half-open: `validFrom <= now < validUntil`.** The same boundary
    /// `credentials::ingress::check_validity_window`,
    /// `credentials::invitation_verify` and `recognition::verify` all use. A
    /// credential whose `validUntil` is exactly `now` is expired. Disagreeing
    /// with those three by a single instant would make an artifact publishable
    /// and simultaneously out of force, which no caller could act on.
    pub fn state_at(&self, now: DateTime<Utc>) -> InForce {
        if let Some(from) = self.valid_from
            && now < from
        {
            return InForce::NotYetValid { valid_from: from };
        }
        if let Some(until) = self.valid_until
            && now >= until
        {
            return InForce::Expired { valid_until: until };
        }
        InForce::Yes
    }
}

/// A lifecycle event recorded against an artifact after it was issued.
///
/// The variants are the vocabulary #1079 says is missing. They are separate
/// variants rather than a `status: String` because the transition rules in
/// [`LifecycleLog::record`] have to distinguish them, and a stringly-typed
/// state machine is one typo away from accepting a transition it rejects.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase", tag = "event")]
pub enum LifecycleEventKind {
    /// Temporarily ineffective, not withdrawn. The distinction matters to the
    /// party being suspended as much as to a verifier: a suspension says the
    /// relying party should stop relying *for now*, and leaves a supported
    /// route back.
    Suspended {
        #[serde(default, skip_serializing_if = "Option::is_none")]
        reason: Option<String>,
    },
    /// Reverses a suspension, returning the artifact to the governance of its
    /// own window. Never valid against any other standing state — see the
    /// module doc on restoration versus replacement.
    Restored {
        #[serde(default, skip_serializing_if = "Option::is_none")]
        reason: Option<String>,
    },
    /// A later artifact has displaced this one. Terminal.
    ///
    /// `by` names the displacing artifact by digest rather than by row id, so
    /// the record stays meaningful to a reader who holds the credential but
    /// not this service's storage — the same reason `vrcDigestMultibase` is
    /// the identifier the publish response returns.
    Superseded { by: String },
    /// Withdrawn by the party who controls it, or by the community. Terminal.
    ///
    /// Distinct from deleting the row: a withdrawal that is *recorded* can be
    /// resolved against a past instant, where a deletion can only be observed
    /// as an absence.
    Withdrawn {
        #[serde(default, skip_serializing_if = "Option::is_none")]
        reason: Option<String>,
    },
}

impl LifecycleEventKind {
    /// Whether this event ends the artifact's life permanently.
    ///
    /// Terminal states are the ones no later event may follow. Asked as a
    /// predicate rather than matched at each call site so that adding a fifth
    /// verb cannot silently be treated as reversible by one caller and
    /// terminal by another.
    pub fn is_terminal(&self) -> bool {
        matches!(self, Self::Superseded { .. } | Self::Withdrawn { .. })
    }

    /// Wire/operator name of the verb, for audit entries and log lines.
    pub fn verb(&self) -> &'static str {
        match self {
            Self::Suspended { .. } => "suspended",
            Self::Restored { .. } => "restored",
            Self::Superseded { .. } => "superseded",
            Self::Withdrawn { .. } => "withdrawn",
        }
    }
}

/// One entry in an artifact's lifecycle log: what happened, and when this
/// service recorded it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct LifecycleEvent {
    #[serde(flatten)]
    pub kind: LifecycleEventKind,
    /// Transaction time: when this service recorded the event, **not** when
    /// the underlying decision was taken. [`resolve`] ignores events recorded
    /// after the instant being asked about, which is what makes "was this in
    /// force at T" answerable at all.
    pub recorded_at: DateTime<Utc>,
}

/// The append-only lifecycle log of one artifact, oldest first.
///
/// Append-only and monotonic in `recorded_at`, both enforced by
/// [`Self::record`]. Order in the vector *is* the precedence order, so an
/// out-of-order insert would silently change the answer for every instant
/// after it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(transparent)]
pub struct LifecycleLog(Vec<LifecycleEvent>);

impl LifecycleLog {
    /// No events recorded. Also the `skip_serializing_if` predicate on the
    /// stored relationship row, so an artifact that has had no lifecycle
    /// event serialises exactly as it did before this module existed.
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    pub fn events(&self) -> &[LifecycleEvent] {
        &self.0
    }

    /// The event that controls at `now`: the most recent one recorded at or
    /// before that instant.
    ///
    /// Filtering on `recorded_at` rather than taking the last entry is what
    /// makes this bitemporal. Resolving a past instant must not be affected by
    /// what has been learned since — otherwise a suspension recorded today
    /// would retroactively make yesterday's decision look wrong, and the audit
    /// trail could no longer be reconciled against the decisions it records.
    pub fn standing_at(&self, now: DateTime<Utc>) -> Option<&LifecycleEvent> {
        self.0.iter().rev().find(|e| e.recorded_at <= now)
    }

    /// The last event recorded, whenever that was. This is the state the next
    /// [`Self::record`] transitions *from*, which is deliberately a different
    /// question from [`Self::standing_at`]: appending is about the log's head,
    /// resolving is about a point in time.
    fn latest(&self) -> Option<&LifecycleEvent> {
        self.0.last()
    }

    /// Whether a terminal event has been recorded, so this artifact can never
    /// take another one.
    pub fn is_terminal(&self) -> bool {
        self.latest().is_some_and(|e| e.kind.is_terminal())
    }

    /// Append an event, enforcing the transition rules.
    ///
    /// Rejections are [`AppError::Conflict`] rather than `Validation`: the
    /// request is well-formed and the caller may well be entitled to make it —
    /// it is the artifact's current state that refuses, and a retry after the
    /// state changes could succeed. That is the distinction the workspace's
    /// error type already draws (`vti_common::error::AppError::Gone` exists
    /// precisely because `Conflict` promises a retry might work).
    ///
    /// `now` is passed in rather than read here so that every check in one
    /// request evaluates at a single instant — the same rule
    /// `credentials::ingress::require_dtg_type` and the VRC publish handler
    /// follow.
    pub fn record(
        &mut self,
        kind: LifecycleEventKind,
        now: DateTime<Utc>,
    ) -> Result<&LifecycleEvent, AppError> {
        if let Some(last) = self.latest() {
            // Monotonic, because the vector order is the precedence order. A
            // backwards timestamp would produce a log in which `standing_at`
            // returns an event that a *later* index has already superseded,
            // and every answer after that point would depend on which of two
            // equally "current" entries was scanned first.
            if now < last.recorded_at {
                return Err(AppError::Conflict(format!(
                    "lifecycle event recorded at {now} predates the last recorded \
                     event at {} — the log is append-only and ordered by record time",
                    last.recorded_at
                )));
            }
            if last.kind.is_terminal() {
                return Err(AppError::Conflict(format!(
                    "artifact was {} at {} and that is terminal — give effect back \
                     by issuing a fresh artifact, not by reversing the record",
                    last.kind.verb(),
                    last.recorded_at
                )));
            }
        }

        let standing_is_suspension = matches!(
            self.latest().map(|e| &e.kind),
            Some(LifecycleEventKind::Suspended { .. })
        );
        match &kind {
            LifecycleEventKind::Suspended { .. } if standing_is_suspension => {
                return Err(AppError::Conflict(
                    "artifact is already suspended; a second suspension records \
                     nothing a reader could act on"
                        .into(),
                ));
            }
            LifecycleEventKind::Restored { .. } if !standing_is_suspension => {
                return Err(AppError::Conflict(
                    "restoration reverses a suspension, and this artifact is not \
                     suspended — an artifact that is merely expired is restored by \
                     re-issuance, not by a lifecycle event"
                        .into(),
                ));
            }
            _ => {}
        }

        self.0.push(LifecycleEvent {
            kind,
            recorded_at: now,
        });
        Ok(self.0.last().expect("just pushed"))
    }
}

/// Whether an artifact is currently in force, and if not, why not.
///
/// A single enum rather than a `bool` plus a reason string, for the reason
/// `credentials::witness::WitnessBinding` gives for the same choice: the
/// variants distinguish situations a caller genuinely needs to tell apart, and
/// collapsing them loses the one distinction that matters most — between an
/// artifact whose own issuer bounded it and one this community stopped.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase", tag = "state")]
pub enum InForce {
    /// Inside its window, with no standing event against it.
    Yes,
    /// Its `validFrom` has not arrived.
    #[serde(rename_all = "camelCase")]
    NotYetValid { valid_from: DateTime<Utc> },
    /// Its `validUntil` has passed. Reported even when a `Restored` event
    /// stands, because restoration returns an artifact to its window rather
    /// than extending it.
    #[serde(rename_all = "camelCase")]
    Expired { valid_until: DateTime<Utc> },
    /// Temporarily ineffective. Reversible, and the only non-`Yes` state that
    /// is.
    Suspended { since: DateTime<Utc> },
    /// Displaced by a later artifact, named by digest.
    Superseded { at: DateTime<Utc>, by: String },
    /// Withdrawn. Terminal.
    Withdrawn { at: DateTime<Utc> },
    /// The artifact's own window could not be read, so no honest answer is
    /// available.
    ///
    /// Reachable on rows written before #1075, which entered the graph without
    /// their window ever being parsed. Surfaced rather than defaulted, for the
    /// reason `credentials::ingress::rejects_an_unparseable_bound` pins: the
    /// failure mode that makes a temporal guard useless is treating an
    /// unreadable bound as "no bound stated". [`Self::is_in_force`] is false
    /// here, so the safe reading is the automatic one.
    Indeterminate { reason: String },
}

impl InForce {
    /// The one predicate most callers want. Every non-`Yes` variant answers
    /// false, including [`Self::Indeterminate`].
    pub fn is_in_force(&self) -> bool {
        matches!(self, Self::Yes)
    }
}

/// Resolve an artifact's current state from its own window and everything
/// recorded against it — the precedence rule, in one place.
///
/// The rule, restated so it can be checked against the code beneath it: a
/// standing event decides, except that a standing `Restored` hands the
/// decision back to the window. Nothing here can return [`InForce::Yes`] for
/// an artifact whose window excludes `now`.
///
/// Call sites do not re-implement any part of this. That is the point: before
/// #1079 the VRC publish path, the VIC path and the recognition path each had
/// their own temporal check and the graph read had none at all, so the same
/// credential could be in force on one surface and not on another.
pub fn resolve(window: &ValidityWindow, log: &LifecycleLog, now: DateTime<Utc>) -> InForce {
    if let Some(event) = log.standing_at(now) {
        match &event.kind {
            LifecycleEventKind::Suspended { .. } => {
                return InForce::Suspended {
                    since: event.recorded_at,
                };
            }
            LifecycleEventKind::Superseded { by } => {
                return InForce::Superseded {
                    at: event.recorded_at,
                    by: by.clone(),
                };
            }
            LifecycleEventKind::Withdrawn { .. } => {
                return InForce::Withdrawn {
                    at: event.recorded_at,
                };
            }
            // Restoration returns the artifact to its window; it does not
            // grant it anything the window does not.
            LifecycleEventKind::Restored { .. } => {}
        }
    }
    window.state_at(now)
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Duration;

    fn window(from_days_ago: i64, until_days_ahead: Option<i64>) -> ValidityWindow {
        let now = Utc::now();
        ValidityWindow {
            valid_from: Some(now - Duration::days(from_days_ago)),
            valid_until: until_days_ahead.map(|d| now + Duration::days(d)),
        }
    }

    fn suspended() -> LifecycleEventKind {
        LifecycleEventKind::Suspended { reason: None }
    }

    fn restored() -> LifecycleEventKind {
        LifecycleEventKind::Restored { reason: None }
    }

    // ─── The precedence rule itself ──────────────────────────

    /// The claim #1079 says nothing in the workspace establishes: a later
    /// event beats a still-valid artifact. The credential's signature is
    /// intact and its window is open, and it is still not in force.
    #[test]
    fn a_later_event_beats_an_artifact_that_is_still_inside_its_window() {
        let now = Utc::now();
        let w = window(30, Some(365));
        assert_eq!(
            w.state_at(now),
            InForce::Yes,
            "precondition: window is open"
        );

        let mut log = LifecycleLog::default();
        log.record(suspended(), now).unwrap();
        assert!(matches!(resolve(&w, &log, now), InForce::Suspended { .. }));
    }

    /// The asymmetry that makes the rule safe. An artifact whose window has
    /// closed cannot be brought back by anything recorded against it — a
    /// restoration returns it to a window that has already ended.
    #[test]
    fn no_event_can_put_an_artifact_back_inside_a_window_that_has_closed() {
        let now = Utc::now();
        // Suspended while valid, then expires, then restored.
        let w = ValidityWindow {
            valid_from: Some(now - Duration::days(30)),
            valid_until: Some(now - Duration::days(1)),
        };
        let mut log = LifecycleLog::default();
        log.record(suspended(), now - Duration::days(10)).unwrap();
        log.record(restored(), now).unwrap();

        match resolve(&w, &log, now) {
            InForce::Expired { .. } => {}
            other => panic!("restoration must not extend a closed window: {other:?}"),
        }
    }

    /// The converse direction, which is the one that must keep working:
    /// restoration inside an open window genuinely restores.
    #[test]
    fn restoration_inside_an_open_window_returns_the_artifact_to_force() {
        let now = Utc::now();
        let w = window(30, Some(365));
        let mut log = LifecycleLog::default();
        log.record(suspended(), now - Duration::days(2)).unwrap();
        assert!(!resolve(&w, &log, now).is_in_force());
        log.record(restored(), now - Duration::days(1)).unwrap();
        assert_eq!(resolve(&w, &log, now), InForce::Yes);
    }

    /// With no events at all the answer is exactly the window's — so wiring a
    /// log onto an existing artifact type changes nothing for artifacts that
    /// have never had an event.
    #[test]
    fn an_empty_log_defers_entirely_to_the_window() {
        let now = Utc::now();
        let empty = LifecycleLog::default();
        assert_eq!(resolve(&window(30, Some(1)), &empty, now), InForce::Yes);
        assert!(matches!(
            resolve(&window(30, Some(-1)), &empty, now),
            InForce::Expired { .. }
        ));
        assert!(matches!(
            resolve(&window(-1, Some(30)), &empty, now),
            InForce::NotYetValid { .. }
        ));
    }

    /// An artifact stating no bounds is decided entirely by its log. This is
    /// the shape most VRC fixtures in this repo have (`valid_until: None`),
    /// and it is the case where precedence is the *only* lifecycle mechanism
    /// there is.
    #[test]
    fn an_unbounded_artifact_is_governed_only_by_its_log() {
        let now = Utc::now();
        let mut log = LifecycleLog::default();
        assert_eq!(resolve(&ValidityWindow::UNBOUNDED, &log, now), InForce::Yes);
        log.record(LifecycleEventKind::Withdrawn { reason: None }, now)
            .unwrap();
        assert!(matches!(
            resolve(&ValidityWindow::UNBOUNDED, &log, now),
            InForce::Withdrawn { .. }
        ));
    }

    /// Half-open, matching `credentials::ingress::check_validity_window` and
    /// the two paths it was written to agree with. A one-instant disagreement
    /// here would make an artifact publishable and simultaneously out of
    /// force.
    #[test]
    fn window_boundaries_are_valid_from_inclusive_and_valid_until_exclusive() {
        let now = Utc::now();
        let w = ValidityWindow {
            valid_from: Some(now),
            valid_until: Some(now + Duration::days(1)),
        };
        assert_eq!(w.state_at(now), InForce::Yes, "validFrom == now is inside");

        let ends_now = ValidityWindow {
            valid_from: Some(now - Duration::days(1)),
            valid_until: Some(now),
        };
        assert!(
            matches!(ends_now.state_at(now), InForce::Expired { .. }),
            "validUntil == now is outside"
        );
    }

    // ─── Transaction time ────────────────────────────────────

    /// Resolving a past instant must not see an event recorded after it.
    /// Without this the audit trail could not be reconciled against the
    /// decisions it records: every past decision would be re-judged against
    /// facts that did not exist when it was taken.
    #[test]
    fn an_event_does_not_apply_before_it_was_recorded() {
        let now = Utc::now();
        let w = window(30, Some(365));
        let mut log = LifecycleLog::default();
        log.record(suspended(), now).unwrap();

        assert_eq!(
            resolve(&w, &log, now - Duration::hours(1)),
            InForce::Yes,
            "an hour before the suspension was recorded, the edge was in force"
        );
        assert!(!resolve(&w, &log, now).is_in_force());
    }

    /// The standing event at an instant is the latest one recorded at or
    /// before it — not the latest one in the log.
    #[test]
    fn the_standing_event_is_the_latest_one_recorded_by_that_instant() {
        let now = Utc::now();
        let w = window(30, Some(365));
        let mut log = LifecycleLog::default();
        log.record(suspended(), now - Duration::days(3)).unwrap();
        log.record(restored(), now - Duration::days(1)).unwrap();

        assert!(matches!(
            resolve(&w, &log, now - Duration::days(2)),
            InForce::Suspended { .. }
        ));
        assert_eq!(resolve(&w, &log, now), InForce::Yes);
    }

    #[test]
    fn a_backwards_timestamp_is_refused() {
        let now = Utc::now();
        let mut log = LifecycleLog::default();
        log.record(suspended(), now).unwrap();
        let err = log
            .record(restored(), now - Duration::days(1))
            .expect_err("the log is ordered by record time");
        assert!(format!("{err:?}").contains("append-only"), "{err:?}");
    }

    // ─── Restoration versus replacement ──────────────────────

    /// Nothing follows a withdrawal — including a second withdrawal, which
    /// would otherwise be a silent no-op that moved `recordedAt` forward and
    /// changed the answer to "when did this stop being in force".
    #[test]
    fn a_withdrawal_is_terminal() {
        let now = Utc::now();
        let mut log = LifecycleLog::default();
        log.record(LifecycleEventKind::Withdrawn { reason: None }, now)
            .unwrap();
        for attempt in [
            suspended(),
            restored(),
            LifecycleEventKind::Superseded { by: "zX".into() },
            LifecycleEventKind::Withdrawn { reason: None },
        ] {
            let verb = attempt.verb();
            assert!(
                log.record(attempt, now).is_err(),
                "`{verb}` after a withdrawal must be refused"
            );
        }
        assert_eq!(log.events().len(), 1, "no refused event may have landed");
    }

    #[test]
    fn a_supersession_is_terminal() {
        let now = Utc::now();
        let mut log = LifecycleLog::default();
        log.record(LifecycleEventKind::Superseded { by: "zNew".into() }, now)
            .unwrap();
        let err = log
            .record(restored(), now)
            .expect_err("a superseded artifact is replaced, not restorable");
        assert!(format!("{err:?}").contains("terminal"), "{err:?}");
        assert!(log.is_terminal());
    }

    /// Restoration reverses a suspension and nothing else. An artifact that
    /// merely expired has no suspension to reverse, and letting this through
    /// would be the exact inversion the module doc rules out — an event
    /// manufacturing validity the issuer never signed for.
    #[test]
    fn restoration_requires_a_standing_suspension() {
        let now = Utc::now();
        let mut fresh = LifecycleLog::default();
        let err = fresh
            .record(restored(), now)
            .expect_err("nothing to restore");
        assert!(
            format!("{err:?}").contains("reverses a suspension"),
            "{err:?}"
        );

        let mut reinstated = LifecycleLog::default();
        reinstated.record(suspended(), now).unwrap();
        reinstated.record(restored(), now).unwrap();
        let err = reinstated
            .record(restored(), now)
            .expect_err("already restored");
        assert!(
            format!("{err:?}").contains("reverses a suspension"),
            "{err:?}"
        );
    }

    #[test]
    fn a_second_suspension_is_refused() {
        let now = Utc::now();
        let mut log = LifecycleLog::default();
        log.record(suspended(), now).unwrap();
        let err = log.record(suspended(), now).expect_err("already suspended");
        assert!(format!("{err:?}").contains("already suspended"), "{err:?}");
    }

    /// A suspended artifact may still be withdrawn or superseded — suspension
    /// is not a dead end, it is the one reversible state.
    #[test]
    fn a_suspended_artifact_can_still_be_withdrawn_or_superseded() {
        let now = Utc::now();
        for terminal in [
            LifecycleEventKind::Withdrawn { reason: None },
            LifecycleEventKind::Superseded { by: "zNew".into() },
        ] {
            let mut log = LifecycleLog::default();
            log.record(suspended(), now).unwrap();
            log.record(terminal, now)
                .expect("a suspension does not block a terminal event");
            assert!(log.is_terminal());
        }
    }

    // ─── Wire shape ──────────────────────────────────────────

    /// The log serialises as a bare array and an absent log is `is_empty`, so
    /// a stored row that has never had a lifecycle event serialises exactly as
    /// it did before this module existed. Rows written before it must decode.
    #[test]
    fn an_absent_log_decodes_as_an_empty_one() {
        let log: LifecycleLog =
            serde_json::from_value(serde_json::json!([])).expect("empty array decodes");
        assert!(log.is_empty());
        assert_eq!(
            serde_json::to_value(LifecycleLog::default()).unwrap(),
            serde_json::json!([])
        );
    }

    /// Field names are camelCase and the verb is the discriminator, so an
    /// operator reading a stored row sees `{"event":"suspended","recordedAt":…}`
    /// rather than a positional shape only this code can interpret.
    #[test]
    fn events_serialise_camel_case_with_the_verb_as_discriminator() {
        let now = Utc::now();
        let mut log = LifecycleLog::default();
        log.record(
            LifecycleEventKind::Suspended {
                reason: Some("under moderation".into()),
            },
            now,
        )
        .unwrap();
        let v = serde_json::to_value(&log).unwrap();
        assert_eq!(v[0]["event"], "suspended");
        assert_eq!(v[0]["reason"], "under moderation");
        assert!(v[0]["recordedAt"].is_string());

        let round_tripped: LifecycleLog = serde_json::from_value(v).unwrap();
        assert_eq!(round_tripped, log);
    }

    #[test]
    fn resolved_state_serialises_with_a_readable_discriminator() {
        let now = Utc::now();
        let v = serde_json::to_value(InForce::Superseded {
            at: now,
            by: "zNew".into(),
        })
        .unwrap();
        assert_eq!(v["state"], "superseded");
        assert_eq!(v["by"], "zNew");
        assert_eq!(serde_json::to_value(InForce::Yes).unwrap()["state"], "yes");
    }

    /// Every non-`Yes` variant answers false, including `Indeterminate` — the
    /// safe reading has to be the automatic one, because the case exists
    /// precisely for rows nobody has looked at.
    #[test]
    fn only_yes_is_in_force() {
        let now = Utc::now();
        assert!(InForce::Yes.is_in_force());
        for state in [
            InForce::NotYetValid { valid_from: now },
            InForce::Expired { valid_until: now },
            InForce::Suspended { since: now },
            InForce::Superseded {
                at: now,
                by: "zX".into(),
            },
            InForce::Withdrawn { at: now },
            InForce::Indeterminate {
                reason: "unreadable".into(),
            },
        ] {
            assert!(!state.is_in_force(), "{state:?} must not be in force");
        }
    }
}