bacnet-endpoint-core 0.15.0

Shared BACnet endpoint ownership and transaction coordination
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
use std::fmt;
use std::sync::Mutex;

use bacnet_encoding::apdu::Apdu;
use bacnet_encoding::npdu::NpduAddress;
use bacnet_types::MacAddr;
use bacnet_types::enums::ConfirmedServiceChoice;

const INVOKE_ID_COUNT: usize = 256;

/// Stable peer identity used to correlate a received response.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum CanonicalPeer {
    /// A device reached directly on the attached data link.
    Direct(MacAddr),
    /// The originating network and address carried by a routed NPDU.
    Routed {
        /// Originating BACnet network number.
        network: u16,
        /// Originating address on that network.
        address: MacAddr,
    },
}

impl CanonicalPeer {
    /// Creates a direct peer from its data-link address.
    pub fn direct(mac: &[u8]) -> Self {
        Self::Direct(MacAddr::from_slice(mac))
    }

    /// Creates a routed peer from its originating network and address.
    pub fn routed(network: u16, address: &[u8]) -> Self {
        Self::Routed {
            network,
            address: MacAddr::from_slice(address),
        }
    }

    /// The peer an inbound APDU came from, which every role matches its
    /// answers by: the SNET/SADR a router added when there is one, ignoring
    /// the immediate router, otherwise the immediate link MAC.
    ///
    /// `local_network` is the number of the network the receiving link is
    /// attached to, once known. A router attached to it that passes a
    /// station's NPDU back onto it adds that number and the station's MAC as
    /// SNET/SADR (Clause 6.5.4), and network numbers are unique, so the
    /// source is that station: the same direct peer as an NPDU from its MAC
    /// with no SNET (#1465), as the server already reads a request's source
    /// (#1404). Any other SNET, and every SNET while the number is unknown,
    /// stays a routed peer. A routed source with no SADR names no station, so
    /// the immediate MAC stands.
    ///
    /// This changes what a direct transaction trusts. Before, an answer
    /// completed one only when its link source was the station's own MAC.
    /// Now any node that can put a frame on this link, including any peer
    /// the hub admits on BACnet/SC, can complete it by naming this network
    /// as SNET and the station as SADR, provided the invoke ID, service and
    /// owner match. The link source of such an answer is not checked. That
    /// is the trust every routed transaction already places in a claimed
    /// SNET/SADR, whichever router relays it, and the trust the server's
    /// request-source rule (#1404) places in the same pairing. SNET/SADR is
    /// never a credential here.
    pub fn from_source(
        immediate_mac: &[u8],
        routed_source: Option<&NpduAddress>,
        local_network: Option<u16>,
    ) -> Self {
        match routed_source {
            Some(source) if source.mac_address.is_empty() => Self::direct(immediate_mac),
            Some(source) if Some(source.network) == local_network => {
                Self::direct(&source.mac_address)
            }
            Some(source) => Self::routed(source.network, &source.mac_address),
            None => Self::direct(immediate_mac),
        }
    }

    /// The routed form of a source that [`Self::from_source`] reads as the
    /// direct station: `Routed(SNET, SADR)` when SNET is the known
    /// `local_network` and SADR is not empty, otherwise `None`. A request
    /// routed to this network while its number was still unknown is keyed to
    /// that form, and keeps that key once the number is learned, so its
    /// relayed answer has to find it there (#1465). It is exactly the peer
    /// such an answer matched before the number was known.
    pub fn routed_alias(
        routed_source: Option<&NpduAddress>,
        local_network: Option<u16>,
    ) -> Option<Self> {
        routed_source
            .filter(|source| {
                !source.mac_address.is_empty() && Some(source.network) == local_network
            })
            .map(|source| Self::routed(source.network, &source.mac_address))
    }
}

/// Workspace role that owns an outbound confirmed transaction.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LeaseOwner {
    /// A local client-side confirmed request.
    Requester,
    /// A confirmed request the local server role initiates: a notification,
    /// a write a Command or Channel object makes in another device, or the
    /// read a Channel makes there to learn a member's datatype. The recipient
    /// answers it as that transaction's server.
    Notification,
}

/// Successful acknowledgment shape accepted for a lease.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TerminalPolicy {
    /// The service completes with a SimpleACK.
    SimpleAck,
    /// The service completes with an unsegmented or reassembled ComplexACK.
    ComplexAck,
    /// The service may complete with either a SimpleACK or ComplexACK.
    EitherAck,
}

impl TerminalPolicy {
    fn accepts_simple_ack(self) -> bool {
        matches!(self, Self::SimpleAck | Self::EitherAck)
    }

    fn accepts_complex_ack(self) -> bool {
        matches!(self, Self::ComplexAck | Self::EitherAck)
    }
}

/// Correlation and response policy retained for one active invoke ID.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LeaseMetadata {
    owner: LeaseOwner,
    peer: CanonicalPeer,
    service_choice: ConfirmedServiceChoice,
    terminal_policy: TerminalPolicy,
    segmented_request: bool,
}

impl LeaseMetadata {
    /// Metadata for an unsegmented requester transaction.
    pub fn requester(
        peer: CanonicalPeer,
        service_choice: ConfirmedServiceChoice,
        terminal_policy: TerminalPolicy,
    ) -> Self {
        Self {
            owner: LeaseOwner::Requester,
            peer,
            service_choice,
            terminal_policy,
            segmented_request: false,
        }
    }

    /// Metadata for a requester transaction that sends request segments.
    pub fn segmented_requester(
        peer: CanonicalPeer,
        service_choice: ConfirmedServiceChoice,
        terminal_policy: TerminalPolicy,
    ) -> Self {
        Self {
            owner: LeaseOwner::Requester,
            peer,
            service_choice,
            terminal_policy,
            segmented_request: true,
        }
    }

    /// Metadata for a request the server role initiates, whose successful
    /// terminal is SimpleACK.
    pub fn notification(peer: CanonicalPeer, service_choice: ConfirmedServiceChoice) -> Self {
        Self {
            owner: LeaseOwner::Notification,
            peer,
            service_choice,
            terminal_policy: TerminalPolicy::SimpleAck,
            segmented_request: false,
        }
    }

    /// Metadata for a read the server role initiates, whose successful
    /// terminal is an unsegmented ComplexACK. The server role reassembles
    /// nothing: it asks for an unsegmented answer and admits no segment.
    pub fn server_read(peer: CanonicalPeer, service_choice: ConfirmedServiceChoice) -> Self {
        Self {
            owner: LeaseOwner::Notification,
            peer,
            service_choice,
            terminal_policy: TerminalPolicy::ComplexAck,
            segmented_request: false,
        }
    }

    /// Returns the role that owns the transaction.
    pub fn owner(&self) -> LeaseOwner {
        self.owner
    }

    /// Returns the canonical response peer.
    pub fn peer(&self) -> &CanonicalPeer {
        &self.peer
    }

    /// Returns the confirmed service expected in service-labelled responses.
    pub fn service_choice(&self) -> ConfirmedServiceChoice {
        self.service_choice
    }

    /// Returns the accepted successful acknowledgment policy.
    pub fn terminal_policy(&self) -> TerminalPolicy {
        self.terminal_policy
    }

    /// Reports whether the outbound confirmed request is segmented.
    pub fn is_segmented_request(&self) -> bool {
        self.segmented_request
    }
}

/// Exact local identity for one lease of a reusable wire invoke ID.
///
/// The generation fences delayed local cleanup, timers, and reassembly work.
/// It is not sent on the wire and cannot distinguish an old wire response after
/// the same invoke ID is reused.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct LeaseToken {
    invoke_id: u8,
    generation: u64,
}

impl LeaseToken {
    /// Returns the invoke ID to encode in the confirmed request APDU.
    pub fn invoke_id(self) -> u8 {
        self.invoke_id
    }
}

/// Reservation failures that leave coordinator state unchanged.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReserveError {
    /// All 256 device-wide invoke IDs are active.
    Exhausted,
    /// The monotonic generation counter cannot advance without wrapping.
    GenerationExhausted,
    /// A panic while holding the coordinator mutex poisoned shared state.
    StatePoisoned,
}

impl fmt::Display for ReserveError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Exhausted => formatter.write_str("all invoke IDs are leased"),
            Self::GenerationExhausted => formatter.write_str("lease generation exhausted"),
            Self::StatePoisoned => formatter.write_str("coordinator state is poisoned"),
        }
    }
}

impl std::error::Error for ReserveError {}

/// Shared-state failures for non-reservation operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CoordinatorError {
    /// A panic while holding the coordinator mutex poisoned shared state.
    StatePoisoned,
}

impl fmt::Display for CoordinatorError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("coordinator state is poisoned")
    }
}

impl std::error::Error for CoordinatorError {}

/// Result of exact-token completion, cancellation, or release.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReleaseOutcome {
    /// The exact active lease was released.
    Released,
    /// This exact lease had already been released and no ID reuse intervened.
    AlreadyReleased,
    /// The token does not identify the current lease of this invoke ID.
    StaleToken,
}

/// Whether an admitted APDU claims the terminal delivery slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AdmissionKind {
    /// The APDU claims this lease's one terminal admission.
    Terminal,
    /// The APDU reports segmentation progress and leaves the lease active.
    NonTerminal,
}

/// Exact lease and metadata selected for an admitted APDU.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Admission {
    token: LeaseToken,
    metadata: LeaseMetadata,
    kind: AdmissionKind,
}

impl Admission {
    /// Returns the exact-generation token for later completion or cleanup.
    pub fn token(&self) -> LeaseToken {
        self.token
    }

    /// Returns the active lease's role and correlation policy.
    pub fn metadata(&self) -> &LeaseMetadata {
        &self.metadata
    }

    /// Returns whether the event is terminal at coordinator level.
    pub fn kind(&self) -> AdmissionKind {
        self.kind
    }
}

/// Bounded outcome of matching one decoded APDU to an outbound lease.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AdmissionOutcome {
    /// The APDU matched an active lease.
    Admitted(Admission),
    /// Requests do not belong to the outbound coordinator.
    NotOutbound,
    /// No lease is active for the APDU's invoke ID.
    UnknownInvokeId,
    /// The source identity does not match the active lease.
    PeerMismatch,
    /// The APDU is not valid for the role or request segmentation mode.
    OwnerMismatch,
    /// An unsegmented successful acknowledgment named a different service.
    ServiceMismatch {
        /// Service retained by the active lease.
        expected: ConfirmedServiceChoice,
        /// Service carried by the received APDU.
        observed: ConfirmedServiceChoice,
    },
    /// The acknowledgment shape is excluded by the lease's terminal policy.
    PolicyMismatch,
    /// The APDU's server bit routes it to another transaction role.
    DirectionMismatch,
    /// A terminal APDU already claimed this lease.
    DuplicateTerminal,
}

#[derive(Clone, Debug)]
struct ActiveLease {
    generation: u64,
    metadata: LeaseMetadata,
    terminal_claimed: bool,
}

// The per-invoke-ID tables live on the heap. Inline, they made the state about
// 18 KB, and a debug build built and moved it through several stack frames
// (about 110 KB of stack) on every server, client and endpoint start (#953).
struct CoordinatorState {
    slots: Box<[Option<ActiveLease>]>,
    last_released: Box<[Option<u64>]>,
    next_invoke_id: usize,
    last_generation: u64,
    active_count: usize,
}

impl CoordinatorState {
    fn new() -> Self {
        Self {
            slots: vec![None; INVOKE_ID_COUNT].into_boxed_slice(),
            last_released: vec![None; INVOKE_ID_COUNT].into_boxed_slice(),
            next_invoke_id: 0,
            last_generation: 0,
            active_count: 0,
        }
    }

    fn available_slot(&self) -> Option<usize> {
        (0..INVOKE_ID_COUNT)
            .map(|offset| (self.next_invoke_id + offset) % INVOKE_ID_COUNT)
            .find(|index| self.slots[*index].is_none())
    }

    fn release_exact(&mut self, token: LeaseToken) -> ReleaseOutcome {
        let index = usize::from(token.invoke_id);
        match self.slots[index].as_ref() {
            Some(active) if active.generation == token.generation => {
                self.slots[index] = None;
                self.last_released[index] = Some(token.generation);
                self.active_count -= 1;
                ReleaseOutcome::Released
            }
            Some(_) => ReleaseOutcome::StaleToken,
            None if self.last_released[index] == Some(token.generation) => {
                ReleaseOutcome::AlreadyReleased
            }
            None => ReleaseOutcome::StaleToken,
        }
    }
}

/// Thread-safe device-wide invoke-ID lease coordinator.
///
/// Every method holds a synchronous mutex only while inspecting or updating
/// bounded in-memory state. Callers never carry a coordinator lock across an
/// await point.
pub struct OutboundTransactionCoordinator {
    state: Mutex<CoordinatorState>,
    released: tokio::sync::Notify,
}

impl Default for OutboundTransactionCoordinator {
    fn default() -> Self {
        Self::new()
    }
}

impl OutboundTransactionCoordinator {
    /// Creates an empty coordinator with one global pool of 256 invoke IDs.
    pub fn new() -> Self {
        Self {
            state: Mutex::new(CoordinatorState::new()),
            released: tokio::sync::Notify::new(),
        }
    }

    /// Reserves one device-wide invoke ID and issues a new exact-generation token.
    pub fn reserve(&self, metadata: LeaseMetadata) -> Result<LeaseToken, ReserveError> {
        let mut state = self.state.lock().map_err(|_| ReserveError::StatePoisoned)?;
        let index = state.available_slot().ok_or(ReserveError::Exhausted)?;
        let generation = state
            .last_generation
            .checked_add(1)
            .ok_or(ReserveError::GenerationExhausted)?;
        let token = LeaseToken {
            invoke_id: index as u8,
            generation,
        };

        state.last_generation = generation;
        state.next_invoke_id = (index + 1) % INVOKE_ID_COUNT;
        state.slots[index] = Some(ActiveLease {
            generation,
            metadata,
            terminal_claimed: false,
        });
        state.active_count += 1;
        Ok(token)
    }

    /// Matches an APDU without releasing its lease.
    ///
    /// A successful terminal match claims exact-once delivery. Segmented
    /// ComplexACK and server SegmentACK matches are non-terminal.
    pub fn admit(
        &self,
        peer: &CanonicalPeer,
        apdu: &Apdu,
    ) -> Result<AdmissionOutcome, CoordinatorError> {
        let Some(invoke_id) = response_invoke_id(apdu) else {
            return Ok(AdmissionOutcome::NotOutbound);
        };
        let mut state = self
            .state
            .lock()
            .map_err(|_| CoordinatorError::StatePoisoned)?;
        let Some(active) = state.slots[usize::from(invoke_id)].as_mut() else {
            return Ok(AdmissionOutcome::UnknownInvokeId);
        };
        if active.metadata.peer != *peer {
            return Ok(AdmissionOutcome::PeerMismatch);
        }

        let kind = match validate_apdu(&active.metadata, apdu) {
            Ok(kind) => kind,
            Err(outcome) => return Ok(outcome),
        };
        if kind == AdmissionKind::Terminal && active.terminal_claimed {
            return Ok(AdmissionOutcome::DuplicateTerminal);
        }
        if kind == AdmissionKind::Terminal {
            active.terminal_claimed = true;
        }

        Ok(AdmissionOutcome::Admitted(Admission {
            token: LeaseToken {
                invoke_id,
                generation: active.generation,
            },
            metadata: active.metadata.clone(),
            kind,
        }))
    }

    /// [`Self::admit`] for an answer, by its envelope: the link MAC, the
    /// SNET/SADR, and `local_network`, the receiving link's known network
    /// number. The answer is matched against [`CanonicalPeer::from_source`].
    /// Only when that misses on the peer, and the answer has a
    /// [`CanonicalPeer::routed_alias`], is the alias tried: a lease reserved
    /// while the number was unknown then gets the answer it would have
    /// matched before (#1465). The admission's metadata names the peer that
    /// matched, which the owner keys its completion to.
    pub fn admit_from_source(
        &self,
        immediate_mac: &[u8],
        routed_source: Option<&NpduAddress>,
        local_network: Option<u16>,
        apdu: &Apdu,
    ) -> Result<AdmissionOutcome, CoordinatorError> {
        let peer = CanonicalPeer::from_source(immediate_mac, routed_source, local_network);
        match self.admit(&peer, apdu)? {
            AdmissionOutcome::PeerMismatch => {
                match CanonicalPeer::routed_alias(routed_source, local_network) {
                    Some(alias) => self.admit(&alias, apdu),
                    None => Ok(AdmissionOutcome::PeerMismatch),
                }
            }
            outcome => Ok(outcome),
        }
    }

    /// Releases a lease after its admitted terminal has been delivered.
    pub fn complete(&self, token: LeaseToken) -> Result<ReleaseOutcome, CoordinatorError> {
        self.release_token(token)
    }

    /// Releases a lease because its owning operation was cancelled.
    pub fn cancel(&self, token: LeaseToken) -> Result<ReleaseOutcome, CoordinatorError> {
        self.release_token(token)
    }

    /// Releases a lease for timeout or other owner-directed cleanup.
    pub fn release(&self, token: LeaseToken) -> Result<ReleaseOutcome, CoordinatorError> {
        self.release_token(token)
    }

    /// Returns the number of device-wide active leases.
    pub fn active_count(&self) -> Result<usize, CoordinatorError> {
        self.state
            .lock()
            .map(|state| state.active_count)
            .map_err(|_| CoordinatorError::StatePoisoned)
    }

    fn release_token(&self, token: LeaseToken) -> Result<ReleaseOutcome, CoordinatorError> {
        let outcome = self
            .state
            .lock()
            .map(|mut state| state.release_exact(token))
            .map_err(|_| CoordinatorError::StatePoisoned)?;
        // Wake after releasing the state lock, and only for actual capacity.
        if outcome == ReleaseOutcome::Released {
            self.released.notify_waiters();
        }
        Ok(outcome)
    }

    /// Internal passive capacity wait. Register before checking reservation.
    #[doc(hidden)]
    pub fn released(&self) -> tokio::sync::futures::Notified<'_> {
        self.released.notified()
    }
}

/// The invoke ID an answer carries, or `None` for a request, which no
/// outbound lease owns.
pub fn response_invoke_id(apdu: &Apdu) -> Option<u8> {
    match apdu {
        Apdu::SimpleAck(pdu) => Some(pdu.invoke_id),
        Apdu::ComplexAck(pdu) => Some(pdu.invoke_id),
        Apdu::SegmentAck(pdu) => Some(pdu.invoke_id),
        Apdu::Error(pdu) => Some(pdu.invoke_id),
        Apdu::Reject(pdu) => Some(pdu.invoke_id),
        Apdu::Abort(pdu) => Some(pdu.invoke_id),
        Apdu::ConfirmedRequest(_) | Apdu::UnconfirmedRequest(_) => None,
    }
}

fn validate_apdu(metadata: &LeaseMetadata, apdu: &Apdu) -> Result<AdmissionKind, AdmissionOutcome> {
    match apdu {
        Apdu::SimpleAck(pdu) => {
            validate_service(metadata, pdu.service_choice)?;
            if !metadata.terminal_policy.accepts_simple_ack() {
                return Err(AdmissionOutcome::PolicyMismatch);
            }
            Ok(AdmissionKind::Terminal)
        }
        Apdu::ComplexAck(pdu) => {
            if !pdu.segmented {
                validate_service(metadata, pdu.service_choice)?;
            }
            // A server-role lease takes a ComplexACK only when it asked for
            // one ([`LeaseMetadata::server_read`]), and never a segment.
            let server_read = !pdu.segmented && metadata.terminal_policy.accepts_complex_ack();
            if metadata.owner != LeaseOwner::Requester && !server_read {
                return Err(AdmissionOutcome::OwnerMismatch);
            }
            if !metadata.terminal_policy.accepts_complex_ack() {
                return Err(AdmissionOutcome::PolicyMismatch);
            }
            if pdu.segmented {
                Ok(AdmissionKind::NonTerminal)
            } else {
                Ok(AdmissionKind::Terminal)
            }
        }
        Apdu::Error(pdu) => {
            validate_service(metadata, pdu.service_choice)?;
            Ok(AdmissionKind::Terminal)
        }
        Apdu::Reject(_) => Ok(AdmissionKind::Terminal),
        Apdu::Abort(pdu) => {
            // Every lease is the requesting side of its transaction, a
            // confirmed notification included, so the peer's Abort comes from
            // the responding side and has the server flag set (Clause 5.4).
            // A clear flag marks an Abort meant for a transaction this device
            // serves, whatever the lease owner.
            if !pdu.sent_by_server {
                return Err(AdmissionOutcome::DirectionMismatch);
            }
            Ok(AdmissionKind::Terminal)
        }
        Apdu::SegmentAck(pdu) => {
            if !pdu.sent_by_server {
                return Err(AdmissionOutcome::DirectionMismatch);
            }
            if metadata.owner != LeaseOwner::Requester || !metadata.segmented_request {
                return Err(AdmissionOutcome::OwnerMismatch);
            }
            Ok(AdmissionKind::NonTerminal)
        }
        Apdu::ConfirmedRequest(_) | Apdu::UnconfirmedRequest(_) => {
            Err(AdmissionOutcome::NotOutbound)
        }
    }
}

fn validate_service(
    metadata: &LeaseMetadata,
    observed: ConfirmedServiceChoice,
) -> Result<(), AdmissionOutcome> {
    if metadata.service_choice == observed {
        Ok(())
    } else {
        Err(AdmissionOutcome::ServiceMismatch {
            expected: metadata.service_choice,
            observed,
        })
    }
}

#[cfg(test)]
#[path = "coordinator_tests.rs"]
mod tests;

#[cfg(test)]
#[path = "coordinator_peer_tests.rs"]
mod peer_tests;