minerva 0.2.0

Causal ordering for distributed systems
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
extern crate alloc;

use alloc::collections::BTreeMap;

use thiserror::Error;

use crate::metis::{Dot, DotSet, DotSetStation, Vouched};

/// The departure round: the survivors agree what a departing member minted,
/// and promise to admit nothing above it.
///
/// [`Stability::abandon`](super::Stability::abandon) narrows the family a
/// meet ranges over and stops there, because a locally computed bound is one
/// honest survivors disagree about and nothing in the protocol may consume
/// (`docs/metis-membership-departure.adoc`). This is the object that closes
/// that gap: one round over the surviving family, whose outcome is a
/// [`Departed`] every survivor computes identically and the epoch rounds may
/// substitute for the testimony the departed member will never give.
///
/// # The round
///
/// Each survivor [`propose`](Self::propose)s the gap-free prefix of the
/// departing station's dots it holds, *and thereby fences*: from that call
/// on, nothing of that station's above the proposal may be admitted. Peers'
/// proposals fold by max ([`fold`](Self::fold)). When every survivor has
/// proposed, [`try_seal`](Self::try_seal) mints the [`Departed`] at their
/// join and the fence rises to it, permanently.
///
/// The two phases are one mechanism. The proposal is a *promise* and the
/// fence is what makes it true: without it a survivor could absorb more of
/// the departing station's traffic after proposing, and the agreed bound
/// would not bound anything.
///
/// # What the bound is worth
///
/// Between the two doors the fence holds *below* the eventual bound, so a
/// dot a peer already lawfully holds may be refused for the length of the
/// round and admitted after it. That is a hold, never a verdict. Above the
/// sealed bound the refusal is terminal, and it is the crate's only
/// *evidence* of resurgence: an eviction that wrote off live traffic, named
/// at the dot ([`Fenced::is_resurgence`]).
///
/// # Mechanism, not policy
///
/// Whether to evict, how proposals travel, and what a resurgence verdict
/// obliges are the caller's. The round verifies visible evidence. It verifies
/// that a proposal is gap-free ([`DepartureRefusal::Ragged`]), that a survivor does
/// not raise its own promise ([`DepartureRefusal::FenceBroken`]), and that a
/// survivor's own slot is filled by its own door
/// ([`DepartureRefusal::Impersonated`]). It states the rest as duties in
/// the [`Stability`](super::Stability) posture.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Departure {
    /// The departing station.
    station: u32,
    /// Per surviving member, its proposed gap-free prefix of `station`'s
    /// dots; `None` until it proposes.
    proposals: BTreeMap<u32, Option<u64>>,
    /// This replica's own promise, `None` until it proposes and raised only
    /// by the seal.
    fence: Option<u64>,
    /// The station this round belongs to. Its slot is filled by
    /// [`propose`](Self::propose) and never by a report, and the seal waits
    /// for it, so a sealed round is one this replica fenced for.
    own: u32,
    /// The attestation, once every survivor has proposed.
    sealed: Option<Departed>,
}

impl Departure {
    /// Opens the round `own` runs over `survivors`, the family that remains
    /// once `station` leaves.
    ///
    /// The local station is named here rather than learned at the first
    /// proposal, because the slot it owns is the one no report may fill and
    /// the seal waits for. A round that did not know which slot that was
    /// could be driven to a `Departed` entirely from the wire, by a replica
    /// that never fenced.
    ///
    /// # Errors
    ///
    /// [`DepartureRefusal::SelfDeparture`] when the departing station is
    /// itself named a survivor --- the bound reads the survivors, so
    /// including the evictee would let its own claim price its departure.
    /// [`DepartureRefusal::EmptyFamily`] for an empty survivor set, whose
    /// round would complete vacuously at bottom.
    /// [`DepartureRefusal::UnknownStation`] when `own` is not among the
    /// survivors: a replica that is not in the family cannot run its round.
    pub fn opened(
        station: u32,
        own: u32,
        survivors: impl IntoIterator<Item = u32>,
    ) -> Result<Self, DepartureRefusal> {
        let proposals: BTreeMap<u32, Option<u64>> = survivors
            .into_iter()
            .map(|surviving| (surviving, None))
            .collect();
        if proposals.contains_key(&station) {
            return Err(DepartureRefusal::SelfDeparture { station });
        }
        if proposals.is_empty() {
            return Err(DepartureRefusal::EmptyFamily { station });
        }
        if !proposals.contains_key(&own) {
            return Err(DepartureRefusal::UnknownStation { station: own });
        }
        Ok(Self {
            station,
            proposals,
            fence: None,
            own,
            sealed: None,
        })
    }

    /// Proposes this replica's own bound out of what it holds, and fences
    /// there.
    ///
    /// `held` is this replica's possession witness over the plane the
    /// departure is judged in and `successor` over the plane the current
    /// epoch founds, empty where no window is open; only the departing
    /// station's row is read from either. The proposal is `held`'s gap-free
    /// floor, and the promise the caller makes by taking it is that nothing
    /// of that station's above the returned counter enters the old plane,
    /// and nothing of its at all enters the successor one.
    ///
    /// Latched: a repeat of the same proposal absorbs. The returned counter
    /// is the caller's to vouch and replicate; peers fold it through
    /// [`fold`](Self::fold).
    ///
    /// # Errors
    ///
    /// [`DepartureRefusal::Ragged`] when this replica holds a dot of the
    /// departing station above its own gap-free floor: the hole is above
    /// every survivor's proposal but below a dot somebody holds, so no bound
    /// could be both enforced and completed. Redelivery cures it.
    /// [`DepartureRefusal::FenceBroken`] when a second proposal exceeds the
    /// first: the fence was promised and then broken, which no later
    /// agreement can repair. [`DepartureRefusal::SuccessorHeld`] when this
    /// replica already folded successor-plane traffic from the departing
    /// station, which no bound agreed here can cover; sealing the window
    /// first cures it.
    pub fn propose(&mut self, held: &DotSet, successor: &DotSet) -> Result<u64, DepartureRefusal> {
        if let Some(row) = successor.station(self.station) {
            return Err(DepartureRefusal::SuccessorHeld {
                station: self.station,
                held: row.high_water(),
            });
        }
        let row = held.station(self.station);
        let floor = row.map_or(0, DotSetStation::floor);
        if let Some(row) = row
            && row.high_water() > floor
        {
            return Err(DepartureRefusal::Ragged {
                station: self.station,
                floor,
                held: row.high_water(),
            });
        }
        if let Some(promised) = self.fence {
            if floor > promised {
                return Err(DepartureRefusal::FenceBroken {
                    station: self.station,
                    promised,
                    held: floor,
                });
            }
            return Ok(promised);
        }
        self.fence = Some(floor);
        self.fold_proposal(self.own, floor);
        Ok(floor)
    }

    /// Re-installs a proposal this station durably made, at its recorded
    /// value.
    ///
    /// # The escape
    ///
    /// The only door that sets the fence without reading a holding, and it
    /// exists for one caller: a replica rebuilding from its own durable
    /// record after a crash. The promise is the *emitted* number, not
    /// whatever the rebuilt state happens to hold, and peers have already
    /// priced their bound on it --- so re-deriving it would be the one way a
    /// restart could lower a fence somebody else is relying on. A caller must
    /// therefore fence the proposal durably before emitting it, and replay it
    /// through here (the stable-before-emit duty, in its sharpest form).
    ///
    /// Idempotent at the same value, and it never *moves* a promise: the
    /// local proposal is latched exactly as [`propose`](Self::propose)
    /// latches it, and a sealed fence is the family's agreement rather than
    /// this replica's record, so a replay cannot raise it.
    ///
    /// # Errors
    ///
    /// [`DepartureRefusal::FenceBroken`] when the recorded proposal
    /// contradicts the promise in force: below it or above it before the
    /// seal, above the agreed bound after it. The live fence has already
    /// been acted on, so the durable record loses.
    pub fn reinstate(&mut self, proposal: u64) -> Result<(), DepartureRefusal> {
        if let Some(sealed) = &self.sealed {
            // The agreed bound outranks any local record, and the recorded
            // proposal is one of the values it is the join of, so it can only
            // be at or below it.
            return if proposal <= sealed.bound() {
                Ok(())
            } else {
                Err(DepartureRefusal::FenceBroken {
                    station: self.station,
                    promised: sealed.bound(),
                    held: proposal,
                })
            };
        }
        if let Some(promised) = self.fence
            && promised != proposal
        {
            return Err(DepartureRefusal::FenceBroken {
                station: self.station,
                promised,
                held: proposal,
            });
        }
        self.fence = Some(proposal);
        self.fold_proposal(self.own, proposal);
        Ok(())
    }

    /// Folds a survivor's proposal into its slot, by max.
    ///
    /// The [`Vouched`] counter binds the proposal to the survivor that made
    /// it: the bound the epoch rounds substitute is built from these, so this
    /// door carries the same grade as [`Epochs::adopt_report`].
    ///
    /// Duplicated, reordered, and stale proposals absorb.
    ///
    /// [`Epochs::adopt_report`]: crate::metis::Epochs::adopt_report
    ///
    /// # Errors
    ///
    /// [`DepartureRefusal::UnknownStation`] for a report from outside the
    /// surviving family --- the departing station's own claim included,
    /// which is the point: only what somebody else can still serve survives
    /// its departure. [`DepartureRefusal::Impersonated`] for a report
    /// carrying this replica's own station, which is the one slot a report
    /// may never fill.
    pub fn fold(&mut self, report: &Vouched<u64>) -> Result<(), DepartureRefusal> {
        let by = report.by();
        if !self.proposals.contains_key(&by) {
            return Err(DepartureRefusal::UnknownStation { station: by });
        }
        if self.own == by {
            return Err(DepartureRefusal::Impersonated { station: by });
        }
        self.fold_proposal(by, *report.claim());
        Ok(())
    }

    fn fold_proposal(&mut self, by: u32, proposal: u64) {
        let slot = self
            .proposals
            .get_mut(&by)
            .expect("the caller checked family membership");
        *slot = Some(slot.map_or(proposal, |held| held.max(proposal)));
    }

    /// Seals the round once every survivor has proposed: the bound is their
    /// join, and the fence rises to it.
    ///
    /// `None` while any survivor is silent. Monotone, so call again as
    /// proposals arrive; idempotent once sealed.
    ///
    /// A sealed round implies *this* replica proposed, and therefore fenced.
    /// Its own slot is the one no report can fill
    /// ([`DepartureRefusal::Impersonated`]), and the only two doors that fill
    /// it --- [`propose`](Self::propose) and [`reinstate`](Self::reinstate)
    /// --- set the fence in the same act. So the completeness check below
    /// carries the implication and a separate fence guard here would be dead
    /// defence.
    pub fn try_seal(&mut self) -> Option<&Departed> {
        if self.sealed.is_none() {
            let mut join = 0;
            for slot in self.proposals.values() {
                join = join.max((*slot)?);
            }
            self.sealed = Some(Departed {
                station: self.station,
                bound: join,
                proposals: self
                    .proposals
                    .iter()
                    .map(|(&station, slot)| (station, slot.unwrap_or_default()))
                    .collect(),
            });
            self.fence = Some(join);
        }
        self.sealed.as_ref()
    }

    /// The sealed attestation, or `None` while the round is open.
    #[must_use]
    pub const fn sealed(&self) -> Option<&Departed> {
        self.sealed.as_ref()
    }

    /// The same departure, carried across a re-foundation: sealed at the
    /// departed station's compacted prefix in `base` (the consigned base,
    /// derived inside the door exactly as [`Departed::refounded`] does),
    /// fence and attestation together, re-keyed over `family`, the family
    /// the new plane opens with.
    ///
    /// `None` for a round still open, whose proposals are counters in the
    /// plane the re-foundation just re-minted and mean nothing in the new
    /// one. Re-opening it there is the caller's, and costs nothing: every
    /// survivor's proposal is the base prefix it already holds.
    ///
    /// The [`Departed::refounded`] argument applied to the object that
    /// *enforces* the attestation rather than the one that carries it. They
    /// move together or the fence would admit, in the new plane, exactly the
    /// dots the new attestation writes off --- or refuse the base's own
    /// compacted dots, whose redelivery is lawful.
    #[must_use]
    pub fn refounded(&self, family: impl IntoIterator<Item = u32>, base: &DotSet) -> Option<Self> {
        let sealed = self.sealed.as_ref()?.refounded(family, base);
        Some(Self {
            station: self.station,
            proposals: sealed
                .family()
                .map(|station| (station, sealed.proposal_of(station)))
                .collect(),
            fence: Some(sealed.bound()),
            own: self.own,
            sealed: Some(sealed),
        })
    }

    /// The departing station.
    #[must_use]
    pub const fn station(&self) -> u32 {
        self.station
    }

    /// The promise in force: this replica's own proposal while the round is
    /// open, the agreed bound once it seals, and `None` before it has
    /// proposed at all.
    ///
    /// The durable half of a departure. A caller fences it before emitting
    /// the proposal and replays it through [`reinstate`](Self::reinstate).
    #[must_use]
    pub const fn fence(&self) -> Option<u64> {
        self.fence
    }

    /// The agreed bound, or `None` while the round is open.
    #[must_use]
    pub fn bound(&self) -> Option<u64> {
        self.sealed.as_ref().map(Departed::bound)
    }

    /// The survivors' proposals, ascending by station, `None` for a survivor
    /// yet to propose: the observability read, so the operator can see which
    /// survivor a stalled eviction is waiting on.
    pub fn proposals(&self) -> impl Iterator<Item = (u32, Option<u64>)> + '_ {
        self.proposals
            .iter()
            .map(|(&station, &proposal)| (station, proposal))
    }

    /// The fence verdict for one dot of the departing station **in the plane
    /// the current epoch founds**: refused wholesale, from the moment the
    /// round opens.
    ///
    /// Two coordinates, deliberately distinct. The *sealed* attestation
    /// crosses a boundary at the consigned base's compacted prefix
    /// ([`Departed::refounded`]): the evictee's surviving elements lawfully
    /// exist there, at `1..=held`, and redelivery of them is admitted by
    /// the refounded old-plane fence. A *native mint* of the departing
    /// station is different: its allocator opens past its own compacted
    /// survivors, so any successor-plane dot it genuinely speaks sits
    /// *above* the prefix the next generation's record will name --- and a
    /// survivor that folded one would carry, across the seal, a dot above
    /// that record's coordinate. That is the same `BeyondSeal` wedge the
    /// old-plane fence exists to prevent, one generation later, and it is
    /// reachable *while the round is still open* unless this door refuses
    /// from the start rather than from the seal.
    ///
    /// So this door refuses every dot, conservatively, and the phase
    /// decides only what the refusal *means*: a hold while the round is
    /// open, since abandoning the eviction and redelivering admits it, and
    /// a resurgence verdict once it has sealed.
    ///
    /// # Errors
    ///
    /// [`Fenced`] for any dot of the departing station.
    pub const fn admits_successor(&self, dot: Dot) -> Result<(), Fenced> {
        // Only the station mismatch escapes early. The old non-dot `0` arm
        // is unrepresentable: the type carries that law now (ruling R-91).
        if dot.station() != self.station {
            return Ok(());
        }
        Err(Fenced {
            station: dot.station(),
            counter: dot.counter(),
            fence: 0,
            sealed: self.sealed.is_some(),
        })
    }

    /// The fence verdict for one dot of the departing station.
    ///
    /// `Ok(())` for any dot of any other station: this object judges one
    /// coordinate and refuses to be read as a general admission policy.
    ///
    /// # Errors
    ///
    /// [`Fenced`] when the dot lies above the promise in force. Before the
    /// seal that is a hold and redelivery after the seal may admit it; after
    /// it, the refusal is terminal and is a resurgence verdict.
    pub const fn admits(&self, dot: Dot) -> Result<(), Fenced> {
        if dot.station() != self.station {
            return Ok(());
        }
        match self.fence {
            Some(fence) if dot.counter() > fence => Err(Fenced {
                station: dot.station(),
                counter: dot.counter(),
                fence,
                sealed: self.sealed.is_some(),
            }),
            _ => Ok(()),
        }
    }
}

/// The sealed departure attestation: a station left the family, and the
/// survivors agree it minted nothing above [`bound`](Self::bound).
///
/// Minted only by [`Departure::try_seal`], so holding one is evidence the
/// round completed. Two things follow from that and from nothing weaker:
/// every survivor computes the same value, so the seal records they build
/// over it agree; and the fence makes the claim true going forward rather
/// than merely hoping it.
///
/// It carries the family it ranged over for the same reason it carries the
/// bound: "every survivor proposed" is a claim about *which* survivors, and a
/// round over a smaller family agrees a bound the members outside it never
/// fenced at. [`Stability::abandon_attested`](super::Stability::abandon_attested)
/// checks it against the family it is about to narrow.
///
/// That door is what consumes this, and through it the epoch rounds
/// substitute the bound for the departed member's testimony.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Departed {
    station: u32,
    bound: u64,
    /// What each survivor proposed. The family is its keys, and the bound is
    /// its join; keeping the whole map is what lets a consumer ask *which*
    /// survivor can still serve the bound, rather than inferring it from
    /// reports that lag what a member holds.
    proposals: BTreeMap<u32, u64>,
}

impl Departed {
    /// The station that left the family.
    #[must_use]
    pub const fn station(&self) -> u32 {
        self.station
    }

    /// The family that agreed it: the survivors the round ranged over,
    /// ascending.
    pub fn family(&self) -> impl Iterator<Item = u32> + '_ {
        self.proposals.keys().copied()
    }

    /// What `station` proposed in the round, or `None` for a station outside
    /// the family.
    ///
    /// The read that answers "who can still serve this bound": a survivor
    /// that proposed through it held the traffic at round time, which is
    /// evidence a lagging report cannot supply.
    #[must_use]
    pub fn proposal_of(&self, station: u32) -> Option<u64> {
        self.proposals.get(&station).copied()
    }

    /// Whether this attestation is the one `family` would have agreed:
    /// every member of it promised something in the round, and their
    /// proposals join to exactly this bound.
    ///
    /// Two halves, and each answers a different failure.
    ///
    /// *Coverage* is what a consumer needs of the round: its own survivors
    /// --- the members whose shadows will have to consign the record this
    /// bound goes into --- must each have fenced. A round missing one of them
    /// agrees a bound that member never promised anything about.
    ///
    /// *The join* is what agreement needs. Coverage alone admits a round that
    /// ranged *wider*, and a wider round can agree a higher bound because a
    /// member since departed proposed it. Two honest replicas holding
    /// different such rounds for the same station would then retain different
    /// bounds over the same surviving family, substitute different
    /// coordinates into their seal records, and diverge --- the one failure
    /// this whole object exists to make unreachable. Requiring the bound to
    /// be the join over the family that *remains* makes the retained value a
    /// function of that family and the proposals, which every replica has the
    /// same view of, and never of which round happened to be held.
    ///
    /// A wider round whose extra members proposed nothing higher still binds,
    /// which is what lets an attestation outlive a later departure. One whose
    /// bound rested on a departed member does not, and the cure is to re-run
    /// the round over the family that is actually there.
    pub(super) fn binds(&self, family: impl IntoIterator<Item = u32>) -> bool {
        let mut join = 0;
        for member in family {
            let Some(proposal) = self.proposal_of(member) else {
                return false;
            };
            join = join.max(proposal);
        }
        join == self.bound
    }

    /// The agreed bound: the join of the survivors' gap-free prefixes of the
    /// departing station's dots.
    ///
    /// Everything above it is written off, and every dot at or below it is
    /// held by some survivor, so the survivors can complete each other
    /// through the repair lane.
    #[must_use]
    pub const fn bound(&self) -> u64 {
        self.bound
    }

    /// Whether the attestation covers a dot of its own station. `true` for
    /// any other station's dot: the attestation speaks about one coordinate.
    #[must_use]
    pub const fn covers(&self, dot: Dot) -> bool {
        dot.station() != self.station || dot.counter() <= self.bound
    }

    /// The same departure, carried across a re-foundation: re-keyed over
    /// `family`, the family the new plane opens with, at `held`, the
    /// departed station's gap-free prefix in the consigned base.
    ///
    /// # The escape
    ///
    /// The only mint of a [`Departed`] outside a completed round, and it is
    /// audited rather than convenient. A bound is a counter in one plane's
    /// dot space, and a re-foundation re-mints that space, so carrying the
    /// number across a boundary would substitute a coordinate that means
    /// nothing there. The re-keyed value is the machine's own arithmetic:
    /// the re-foundation compacts the departed station's surviving
    /// elements to `1..=held` in the plane the seal opens, every member
    /// holds them through the consigned base it agrees on, and the member
    /// minted nothing since --- so `held` is testimony every survivor can
    /// honestly give without running a round, and the epoch rounds may
    /// substitute it exactly as they substituted the agreed bound. Bottom
    /// is the special case where nothing of the member's survived. A
    /// carried bound *below* the compacted prefix would understate the
    /// coordinate and wedge the next seal at its consignment door
    /// (`BeyondSeal` over a base dot), which is how the original
    /// bottom-only carry was caught
    /// (`fleet::scenarios::arrival::an_evictees_survivors_cross_the_second_boundary`).
    ///
    /// The family is a parameter because a re-foundation can *widen* the
    /// roster: an admission-bearing seal adds joiners (PRD 0028 R1), and an
    /// attestation re-keyed over the old family would fail
    /// [`Stability::abandon_attested`](super::Stability::abandon_attested)'s
    /// family check at every seal after the first admission. Coverage and
    /// possession part here, deliberately. A member of the *old* family
    /// carries the prefix as its proposal: it sealed over the consigned
    /// base, so it holds those dots. A member new to the family --- an
    /// admitted joiner --- enters at zero: its admission proves nothing
    /// about possession (a joiner may not have bootstrapped yet, PRD 0027
    /// R5), so its entry is family coverage for the
    /// `binds` check and never possession evidence for
    /// `Stability`'s serving check --- it earns holder status through its
    /// own report, once its floor genuinely covers the bound. The join is
    /// unmoved (zeros join to nothing), so the re-keyed attestation still
    /// binds. The departing station is excluded from the keys whether or
    /// not `family` names it, so a roster (which never removes a seat)
    /// passes directly.
    ///
    /// What survives the boundary is therefore the *departure* and the
    /// base's compacted prefix, never the old plane's number. Re-declaring
    /// it is still the caller's duty, exactly as
    /// [`Stability::abandon`](super::Stability::abandon) is
    /// (`docs/metis-membership-departure.adoc`).
    ///
    /// The coordinate is derived *inside* this door from the base the
    /// caller hands it --- the gap-free prefix of the departed station's
    /// dots in `base` --- never accepted as a number. A raw counter here
    /// would let any holder of a valid attestation manufacture an
    /// arbitrarily high one that passes every downstream check and raises
    /// the fence over dots the family never wrote off; deriving from the
    /// base narrows the trusted input to the plane the caller opened,
    /// the same trust class as the roster it rebuilds with.
    #[must_use]
    pub fn refounded(&self, family: impl IntoIterator<Item = u32>, base: &DotSet) -> Self {
        let held = base.floor().get(self.station);
        Self {
            station: self.station,
            bound: held,
            proposals: family
                .into_iter()
                .filter(|&station| station != self.station)
                .map(|station| {
                    let holds = self.proposals.contains_key(&station);
                    (station, if holds { held } else { 0 })
                })
                .collect(),
        }
    }
}

/// The refused outcome of a [`Departure`] door: the round is unchanged.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum DepartureRefusal {
    /// A station outside the surviving family proposed or was named.
    #[error("station {station} is not in the surviving family")]
    UnknownStation {
        /// The station named.
        station: u32,
    },
    /// The departing station was named a survivor of its own departure.
    #[error("station {station} cannot survive its own departure")]
    SelfDeparture {
        /// The departing station.
        station: u32,
    },
    /// No survivors: the round would seal vacuously at bottom, writing off
    /// everything.
    #[error("station {station} cannot depart an empty family")]
    EmptyFamily {
        /// The departing station.
        station: u32,
    },
    /// The proposer holds a dot of the departing station above its own
    /// gap-free floor. A bound below the hole cannot be completed by the
    /// survivors; one above it cannot be enforced. Redelivery cures it.
    #[error(
        "cannot propose for station {station}: holding {held} above the gap-free floor {floor}"
    )]
    Ragged {
        /// The departing station.
        station: u32,
        /// The proposer's gap-free floor for it.
        floor: u64,
        /// The highest dot of it the proposer holds.
        held: u64,
    },
    /// The proposer already holds successor-plane traffic from the departing
    /// station.
    ///
    /// The successor-plane twin of [`Ragged`](Self::Ragged), and it refuses
    /// for the same reason: the departing station's coordinate in the plane
    /// the epoch founds is bottom, so a survivor that has already folded a
    /// dot of its there would carry that dot across the boundary and sit it
    /// above the coordinate the next generation's record names. No bound this
    /// round could agree covers it, because this round is agreeing about the
    /// *old* plane.
    ///
    /// Recoverable, and the cure is to wait: once the window seals, that
    /// plane is the next generation's old plane, and a round opened there
    /// proposes a prefix that covers the dot.
    #[error("cannot propose for station {station}: already holding {held} in the successor plane")]
    SuccessorHeld {
        /// The departing station.
        station: u32,
        /// The highest successor-plane dot of it the proposer holds.
        held: u64,
    },
    /// A survivor's own slot was filled by a report rather than by its own
    /// [`Departure::propose`], or a report carried the station whose door
    /// already filled it.
    ///
    /// Not equivocation detection, which needs cross-member evidence this
    /// crate structurally cannot hold. It is the local guard that a promise
    /// is made where it can be kept: a slot filled from the wire would let
    /// the round seal on a bound nothing here ever fenced at.
    #[error("station {station}'s own proposal may not arrive as a report")]
    Impersonated {
        /// The station named.
        station: u32,
    },
    /// A second proposal exceeded the first: traffic was admitted above a
    /// fence this replica had already promised.
    #[error("fence for station {station} promised {promised}, now holding {held}")]
    FenceBroken {
        /// The departing station.
        station: u32,
        /// The promise in force.
        promised: u64,
        /// What the proposer now holds.
        held: u64,
    },
}

/// A dot of the departing station refused by the fence.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("dot ({station}, {counter}) lies above the departure fence {fence}")]
pub struct Fenced {
    /// The departing station.
    pub station: u32,
    /// The refused counter.
    pub counter: u64,
    /// The promise in force.
    pub fence: u64,
    /// Whether that promise is the sealed bound.
    pub sealed: bool,
}

impl Fenced {
    /// Whether this refusal is a resurgence verdict: the round sealed, and
    /// the departed station's traffic arrived above the bound the survivors
    /// agreed and wrote off.
    ///
    /// Read it exactly. It proves the eviction discarded traffic that
    /// existed, which is a statement about the *decision*, not a statement
    /// that the station is alive now: an old delta can arrive by any route.
    /// Its complement proves nothing either --- a station evicted correctly
    /// and one whose tail simply never reaches this replica look identical.
    /// What it does have, and [`Stability::resurgent`](super::Stability::resurgent)
    /// does not, is a fence: no honest route delivers this dot here, so it is
    /// evidence rather than a prompt.
    #[must_use]
    pub const fn is_resurgence(&self) -> bool {
        self.sealed
    }
}