Skip to main content

liminal_server/
auth_revocation.rs

1//! Lys grant revocation applied to admission and live rights (ACCESS-005).
2//!
3//! Liminal never decides who may revoke: a revocation reaches this module
4//! only as an authoritative change on the grant log Lys's own `judge_revoke`
5//! admitted (the issuer, a holder of an ancestor, or the root authority).
6//! This module holds what liminal does with it:
7//!
8//! - **The binding (R1).** A connection admitted under a revocation policy
9//!   carries a [`GrantDecisionBinding`]: the grant log it was decided from,
10//!   the receipt revision its pass requires, and every bound right with each
11//!   complete grant chain that allows it. It is built only by
12//!   [`verify_binding`], from an [`AuthenticatedBinding`] the Lys producer
13//!   seam ([`GrantBindingSource`]) authenticated, after its issuer, audience,
14//!   holder and exact canonical pass digest were compared with the presented
15//!   credential. No producer is published yet (Lys DIRECTORY-089 R2), so the
16//!   installed seam is [`UnpublishedGrantBinding`], which refuses
17//!   `revocation_binding_missing` by name.
18//! - **The consumer (R2).** One [`RevocationAuthority`] per configured grant
19//!   log applies the ordered change stream liminal reads from Lys
20//!   ([`GrantFeedEvent`]): a bounded baseline, then every tail revision in
21//!   order, then `Ready`. A missing revision is a `Gap`, a lost transport is
22//!   `Unavailable`, a replaced log is `Reset`, an unknown event is terminal.
23//!   Nothing here polls, sleeps or retries: the reader that owns the stream
24//!   hands each event to [`RevocationAuthority::apply`].
25//! - **The fence (R3).** [`RevocationAuthority::stage`] verifies and, outside
26//!   the guard, acquires any cold decisions; [`RevocationAuthority::commit`]
27//!   is the one ordered transition that checks readiness, log identity, the
28//!   required revision and the admitting right, and binds the connection into
29//!   the reverse index. A reply older than a deny never overwrites it.
30//! - **Live rights (R4).** Applying a revocation visits only the connections
31//!   the reverse index binds to the revoked grant. A lost right that did not
32//!   admit the connection is withdrawn ([`RevocationEffect::Withdraw`]); the
33//!   loss of the admitting right closes it ([`RevocationEffect::Close`]). Hot
34//!   checks read the connection's own [`ConnectionGrants`] without the
35//!   authority's guard and never ask Lys.
36//! - **Cleanup (R6).** [`RevocationAuthority::release`] is called once from the
37//!   connection runtime's single removal funnel; it retires the connection's
38//!   index entries and prunes decisions no binding and no in-flight ask needs.
39//! - **Diagnostics (R7).** [`RevocationAuthority::diagnostics`] reports the
40//!   readiness, cursor, producer high water, gap length (or that it is
41//!   unknown), transitions and per-phase counts.
42//!
43//! The cache and indexes are process memory: nothing here is stored, flushed
44//! or synced.
45
46use std::collections::{BTreeMap, BTreeSet, VecDeque};
47use std::fmt;
48use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
49use std::sync::{Arc, Mutex, MutexGuard};
50
51use liminal::protocol::DisconnectReason;
52
53use crate::auth_lys::HotAction;
54
55/// The longest grant-log identity accepted, in bytes.
56pub const MAX_LOG_IDENTITY_LEN: usize = 256;
57/// The longest grant id accepted, in bytes.
58pub const MAX_GRANT_ID_LEN: usize = 128;
59/// The most rights one binding may carry.
60pub const MAX_BOUND_RIGHTS: usize = 64;
61/// The most independent grant chains one right may carry.
62pub const MAX_CHAINS_PER_RIGHT: usize = 8;
63/// The longest grant chain (leaf to root) one right may carry.
64pub const MAX_CHAIN_LEN: usize = 16;
65/// The longest subject prefix one right may carry, in bytes.
66pub const MAX_RIGHT_PREFIX_LEN: usize = 256;
67/// The most decisions one baseline or one cold reply may carry.
68pub const MAX_DECISIONS_PER_EVENT: usize = 65_536;
69/// How many applied revisions keep their evidence digest, so a duplicate can
70/// be told from a conflicting one.
71pub const EVIDENCE_RETAINED: usize = 1_024;
72/// How many readiness transitions the diagnostic ring keeps; an overflow is
73/// counted and announced, never silent.
74pub const MAX_DIAGNOSTIC_EVENTS: usize = 64;
75
76/// A revocation decision failure, by its stable public name (ACCESS-005 R5).
77///
78/// A refused `Connect` carries the name alone as its `ConnectError` message
79/// under reason `0x0006`; a refused hot operation carries it as its error
80/// message. No variant carries credential, holder, live-prefix or grant-chain
81/// bytes.
82#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, thiserror::Error)]
83pub enum RevocationRefusal {
84    /// The right the operation or connection needs was withdrawn by a Lys
85    /// grant revocation.
86    #[error("grant_revoked")]
87    GrantRevoked,
88    /// No authenticated grant-decision binding matches the presented
89    /// credential, or the binding covers no right for the operation.
90    #[error("revocation_binding_missing")]
91    BindingMissing,
92    /// The local cache has not applied the revision the binding requires, or
93    /// a needed decision could not be acquired at a current revision.
94    #[error("revocation_revision_unavailable")]
95    RevisionUnavailable,
96    /// The grant-change stream is not ready: never ready yet, its transport
97    /// is lost, or it ended on a terminal error.
98    #[error("revocation_stream_unavailable")]
99    StreamUnavailable,
100    /// The grant-change stream skipped a revision no trusted evidence covers.
101    #[error("revocation_stream_gap")]
102    StreamGap,
103    /// The grant log was replaced; its revisions cannot be compared with the
104    /// binding's until the new log's baseline settles.
105    #[error("revocation_log_reset")]
106    LogReset,
107    /// The peer cannot negotiate the reason-bearing close this policy
108    /// requires before admission.
109    #[error("revocation_protocol_unsupported")]
110    ProtocolUnsupported,
111}
112
113impl RevocationRefusal {
114    /// Every refusal, in the order R5 lists them.
115    pub const ALL: [Self; 7] = [
116        Self::GrantRevoked,
117        Self::BindingMissing,
118        Self::RevisionUnavailable,
119        Self::StreamUnavailable,
120        Self::StreamGap,
121        Self::LogReset,
122        Self::ProtocolUnsupported,
123    ];
124
125    /// The stable public name.
126    #[must_use]
127    pub const fn name(self) -> &'static str {
128        match self {
129            Self::GrantRevoked => "grant_revoked",
130            Self::BindingMissing => "revocation_binding_missing",
131            Self::RevisionUnavailable => "revocation_revision_unavailable",
132            Self::StreamUnavailable => "revocation_stream_unavailable",
133            Self::StreamGap => "revocation_stream_gap",
134            Self::LogReset => "revocation_log_reset",
135            Self::ProtocolUnsupported => "revocation_protocol_unsupported",
136        }
137    }
138
139    /// The reason-bearing close this refusal ends a live connection with,
140    /// when it is one a live connection can be closed for.
141    #[must_use]
142    pub const fn close_reason(self) -> Option<DisconnectReason> {
143        match self {
144            Self::GrantRevoked => Some(DisconnectReason::GrantRevoked),
145            Self::LogReset => Some(DisconnectReason::RevocationLogReset),
146            Self::BindingMissing
147            | Self::RevisionUnavailable
148            | Self::StreamUnavailable
149            | Self::StreamGap
150            | Self::ProtocolUnsupported => None,
151        }
152    }
153}
154
155/// A value from the grant stream or the producer that could not be accepted
156/// as an identity: empty, or over its bound.
157#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
158#[error("{what} is empty or longer than its bound")]
159pub struct InvalidGrantInput {
160    /// Which value was refused.
161    pub what: &'static str,
162}
163
164fn bounded(value: &str, max: usize, what: &'static str) -> Result<String, InvalidGrantInput> {
165    if value.is_empty() || value.len() > max {
166        Err(InvalidGrantInput { what })
167    } else {
168        Ok(value.to_owned())
169    }
170}
171
172/// A grant log's identity and its reset epoch: the revision domain a receipt
173/// revision belongs to. Revisions from different identities or epochs never
174/// compare.
175#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
176pub struct GrantLogId {
177    identity: String,
178    epoch: u64,
179}
180
181impl GrantLogId {
182    /// The log `identity` in reset `epoch`.
183    ///
184    /// # Errors
185    /// [`InvalidGrantInput`] when the identity is empty or longer than
186    /// [`MAX_LOG_IDENTITY_LEN`].
187    pub fn new(identity: &str, epoch: u64) -> Result<Self, InvalidGrantInput> {
188        Ok(Self {
189            identity: bounded(identity, MAX_LOG_IDENTITY_LEN, "grant log identity")?,
190            epoch,
191        })
192    }
193
194    /// The log's identity.
195    #[must_use]
196    pub fn identity(&self) -> &str {
197        &self.identity
198    }
199
200    /// The log's reset epoch.
201    #[must_use]
202    pub const fn epoch(&self) -> u64 {
203        self.epoch
204    }
205}
206
207/// A Lys grant id, opaque here. Its `Debug` names its length only, so no
208/// grant-chain bytes reach a log.
209#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
210pub struct GrantId(String);
211
212impl GrantId {
213    /// The grant `id`.
214    ///
215    /// # Errors
216    /// [`InvalidGrantInput`] when the id is empty or longer than
217    /// [`MAX_GRANT_ID_LEN`].
218    pub fn new(id: &str) -> Result<Self, InvalidGrantInput> {
219        bounded(id, MAX_GRANT_ID_LEN, "grant id").map(Self)
220    }
221
222    /// The id's text.
223    #[must_use]
224    pub fn as_str(&self) -> &str {
225        &self.0
226    }
227}
228
229impl fmt::Debug for GrantId {
230    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
231        write!(formatter, "GrantId({} bytes)", self.0.len())
232    }
233}
234
235/// A receipt revision: `GrantReceipt::revision()`, the receipt coordinate's
236/// index plus one. Never an event-format version or a timestamp.
237#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
238pub struct Revision(u64);
239
240impl Revision {
241    /// The revision a decision must stand at to reflect the change logged at
242    /// receipt coordinate `index`: `index + 1`. `None` when that overflows.
243    #[must_use]
244    pub const fn from_receipt_index(index: u64) -> Option<Self> {
245        match index.checked_add(1) {
246            Some(revision) => Some(Self(revision)),
247            None => None,
248        }
249    }
250
251    /// A revision already expressed as `GrantReceipt::revision()`.
252    #[must_use]
253    pub const fn new(revision: u64) -> Self {
254        Self(revision)
255    }
256
257    /// The revision's number.
258    #[must_use]
259    pub const fn get(self) -> u64 {
260        self.0
261    }
262
263    const fn next(self) -> Option<Self> {
264        match self.0.checked_add(1) {
265            Some(next) => Some(Self(next)),
266            None => None,
267        }
268    }
269}
270
271/// The revisions strictly between `from` and `to`: how many are missing when
272/// `to` arrives after `from`.
273const fn missing_between(from: Revision, to: Revision) -> Option<u64> {
274    match to.0.checked_sub(from.0) {
275        Some(distance) => distance.checked_sub(1),
276        None => None,
277    }
278}
279
280/// The action a bound right allows: admission itself, or one hot action.
281#[derive(Clone, Copy, Debug, PartialEq, Eq)]
282pub enum RightAction {
283    /// The right that admits `Connect`. Its loss closes the connection.
284    Connect,
285    /// One hot action on subjects under the right's prefix.
286    Hot(HotAction),
287}
288
289/// One right as the producer bound it.
290///
291/// Its action, the subject prefix it covers (empty for
292/// [`RightAction::Connect`]) and every complete grant chain (leaf first) that
293/// allows it. The right stays effective while any one chain has no revoked
294/// grant.
295#[derive(Clone, PartialEq, Eq)]
296pub struct BoundRightClaim {
297    /// The action allowed.
298    pub action: RightAction,
299    /// The subject prefix covered.
300    pub prefix: String,
301    /// Every independent chain allowing the right.
302    pub chains: Vec<Vec<GrantId>>,
303}
304
305impl fmt::Debug for BoundRightClaim {
306    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
307        formatter
308            .debug_struct("BoundRightClaim")
309            .field("action", &self.action)
310            .field("prefix", &format_args!("{} bytes", self.prefix.len()))
311            .field("chains", &self.chains.len())
312            .finish()
313    }
314}
315
316/// A 32-byte digest or key id, compared exactly, shown by length only.
317#[derive(Clone, Copy, PartialEq, Eq)]
318pub struct Digest32(pub [u8; 32]);
319
320impl fmt::Debug for Digest32 {
321    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
322        formatter.write_str("Digest32(32 bytes)")
323    }
324}
325
326/// A grant-decision binding the Lys producer seam authenticated
327/// (DIRECTORY-089 R2).
328///
329/// Who issued it with which key, for which audience and
330/// holder, over which exact canonical pass, from which log, at which
331/// required revision, with which rights. It is the seam's output, not yet
332/// trusted for this connection: [`verify_binding`] compares it with the
333/// presented credential first.
334#[derive(Clone, PartialEq, Eq)]
335pub struct AuthenticatedBinding {
336    /// The issuing authority's key identity.
337    pub issuer: Digest32,
338    /// The audience the binding was produced for.
339    pub audience: String,
340    /// The holder the binding names, as the pass names it.
341    pub holder: Vec<u8>,
342    /// The exact canonical digest of the pass the binding was produced for.
343    pub pass_digest: Digest32,
344    /// The grant log every dependency comes from.
345    pub log: GrantLogId,
346    /// The receipt revision the binding was decided at.
347    pub required: Revision,
348    /// Every bound right.
349    pub rights: Vec<BoundRightClaim>,
350}
351
352impl fmt::Debug for AuthenticatedBinding {
353    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
354        formatter
355            .debug_struct("AuthenticatedBinding")
356            .field("audience_bytes", &self.audience.len())
357            .field("holder_bytes", &self.holder.len())
358            .field("log", &self.log)
359            .field("required", &self.required)
360            .field("rights", &self.rights.len())
361            .finish_non_exhaustive()
362    }
363}
364
365/// The Lys producer seam (DIRECTORY-089 R2).
366///
367/// It authenticates the separate
368/// versioned binding for a presented credential and computes that
369/// contract's exact canonical pass digest. Its implementation is the trust
370/// anchor; liminal compares what it returns with what it verified itself.
371pub trait GrantBindingSource: Send + Sync + fmt::Debug {
372    /// The contract's canonical digest of `credential`'s exact bytes.
373    ///
374    /// # Errors
375    /// [`RevocationRefusal::BindingMissing`] when no digest contract is
376    /// available.
377    fn pass_digest(&self, credential: &[u8]) -> Result<Digest32, RevocationRefusal>;
378
379    /// The authenticated binding produced for `credential`.
380    ///
381    /// # Errors
382    /// [`RevocationRefusal::BindingMissing`] when no binding exists, its
383    /// signature does not verify, or it is only partly published.
384    fn binding_for(&self, credential: &[u8]) -> Result<AuthenticatedBinding, RevocationRefusal>;
385}
386
387/// The producer seam while Lys publishes none: every credential is refused
388/// `revocation_binding_missing`, never admitted on a guessed binding.
389#[derive(Clone, Copy, Debug, Default)]
390pub struct UnpublishedGrantBinding;
391
392impl GrantBindingSource for UnpublishedGrantBinding {
393    fn pass_digest(&self, _credential: &[u8]) -> Result<Digest32, RevocationRefusal> {
394        Err(RevocationRefusal::BindingMissing)
395    }
396
397    fn binding_for(&self, _credential: &[u8]) -> Result<AuthenticatedBinding, RevocationRefusal> {
398        Err(RevocationRefusal::BindingMissing)
399    }
400}
401
402/// One cold decision reply: whether each asked grant is revoked, decided at
403/// `revision` on `log`.
404#[derive(Clone, Debug, PartialEq, Eq)]
405pub struct DecisionReply {
406    /// The log the decisions come from.
407    pub log: GrantLogId,
408    /// The revision every decision was made at.
409    pub revision: Revision,
410    /// Each grant and whether it is revoked.
411    pub decisions: Vec<(GrantId, bool)>,
412}
413
414/// The cold-decision seam: Lys answers whether grants are revoked on an
415/// identified log at no earlier than `at_least`. Asked only outside the
416/// authority's guard, during staging, never on a hot path.
417pub trait GrantDecisionSource: Send + Sync + fmt::Debug {
418    /// The decisions on `grants`.
419    ///
420    /// # Errors
421    /// The refusal naming why no current decision is available.
422    fn decide(
423        &self,
424        log: &GrantLogId,
425        grants: &[GrantId],
426        at_least: Revision,
427    ) -> Result<DecisionReply, RevocationRefusal>;
428}
429
430/// The decision seam while Lys's batch check carries no log identity: every
431/// cold ask is refused `revocation_revision_unavailable`.
432#[derive(Clone, Copy, Debug, Default)]
433pub struct UnpublishedGrantDecisions;
434
435impl GrantDecisionSource for UnpublishedGrantDecisions {
436    fn decide(
437        &self,
438        _log: &GrantLogId,
439        _grants: &[GrantId],
440        _at_least: Revision,
441    ) -> Result<DecisionReply, RevocationRefusal> {
442        Err(RevocationRefusal::RevisionUnavailable)
443    }
444}
445
446/// What a binding must match to be used here: the configured log, this
447/// server's audience and the issuer key it trusts.
448#[derive(Clone, Debug, PartialEq, Eq)]
449pub struct RevocationPolicy {
450    /// The grant log this authority consumes.
451    pub log: GrantLogId,
452    /// This server's audience.
453    pub audience: String,
454    /// The issuer key identity a binding must name.
455    pub issuer: Digest32,
456}
457
458#[derive(Clone, PartialEq, Eq)]
459struct BoundRight {
460    action: RightAction,
461    prefix: String,
462    chains: Vec<Vec<GrantId>>,
463}
464
465/// The binding one `Connect` verification proved, carried privately into
466/// the connection. Only [`verify_binding`] builds one.
467#[derive(Clone, PartialEq, Eq)]
468pub struct GrantDecisionBinding {
469    log: GrantLogId,
470    required: Revision,
471    rights: Vec<BoundRight>,
472    admitting: usize,
473}
474
475impl GrantDecisionBinding {
476    /// The grant log the binding was decided from.
477    #[must_use]
478    pub const fn log(&self) -> &GrantLogId {
479        &self.log
480    }
481
482    /// The receipt revision the binding requires the cache to have applied.
483    #[must_use]
484    pub const fn required(&self) -> Revision {
485        self.required
486    }
487
488    /// How many rights the binding carries.
489    #[must_use]
490    pub fn right_count(&self) -> usize {
491        self.rights.len()
492    }
493
494    fn grants(&self) -> impl Iterator<Item = &GrantId> {
495        self.rights
496            .iter()
497            .flat_map(|right| right.chains.iter().flatten())
498    }
499}
500
501impl fmt::Debug for GrantDecisionBinding {
502    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
503        formatter
504            .debug_struct("GrantDecisionBinding")
505            .field("log", &self.log)
506            .field("required", &self.required)
507            .field("rights", &self.rights.len())
508            .finish_non_exhaustive()
509    }
510}
511
512/// Verifies the binding for `credential`, presented by `holder`, against
513/// `policy` (ACCESS-005 R1).
514///
515/// The seam authenticates it, then its issuer,
516/// audience, holder and exact canonical pass digest must each equal what was
517/// presented, and every bound must hold, before any dependency is used.
518///
519/// # Errors
520/// [`RevocationRefusal::BindingMissing`] when the seam has no binding, or
521/// any comparison or bound fails: a substituted, partial or over-bound
522/// binding is refused, never repaired.
523pub fn verify_binding(
524    policy: &RevocationPolicy,
525    source: &dyn GrantBindingSource,
526    credential: &[u8],
527    holder: &[u8],
528) -> Result<GrantDecisionBinding, RevocationRefusal> {
529    let claimed = source.binding_for(credential)?;
530    let digest = source.pass_digest(credential)?;
531    let mismatch = if claimed.issuer != policy.issuer {
532        Some("issuer")
533    } else if claimed.audience != policy.audience {
534        Some("audience")
535    } else if claimed.holder.as_slice() != holder {
536        Some("holder")
537    } else if claimed.pass_digest != digest {
538        Some("pass_digest")
539    } else {
540        None
541    };
542    if let Some(field) = mismatch {
543        tracing::warn!(
544            field,
545            "grant-decision binding does not match the presented credential; refused"
546        );
547        return Err(RevocationRefusal::BindingMissing);
548    }
549    bound_rights(claimed)
550}
551
552fn bound_rights(claimed: AuthenticatedBinding) -> Result<GrantDecisionBinding, RevocationRefusal> {
553    let refuse = |what: &'static str| {
554        tracing::warn!(
555            what,
556            "grant-decision binding is over its bound or incomplete"
557        );
558        RevocationRefusal::BindingMissing
559    };
560    if claimed.rights.is_empty() || claimed.rights.len() > MAX_BOUND_RIGHTS {
561        return Err(refuse("rights"));
562    }
563    let mut rights = Vec::with_capacity(claimed.rights.len());
564    for right in claimed.rights {
565        if right.prefix.len() > MAX_RIGHT_PREFIX_LEN {
566            return Err(refuse("prefix"));
567        }
568        if right.chains.is_empty() || right.chains.len() > MAX_CHAINS_PER_RIGHT {
569            return Err(refuse("chains"));
570        }
571        if right
572            .chains
573            .iter()
574            .any(|chain| chain.is_empty() || chain.len() > MAX_CHAIN_LEN)
575        {
576            return Err(refuse("chain"));
577        }
578        if matches!(right.action, RightAction::Hot(_)) && right.prefix.is_empty() {
579            return Err(refuse("hot right without a prefix"));
580        }
581        rights.push(BoundRight {
582            action: right.action,
583            prefix: right.prefix,
584            chains: right.chains,
585        });
586    }
587    let admitting = rights
588        .iter()
589        .position(|right| right.action == RightAction::Connect)
590        .ok_or_else(|| refuse("admitting right"))?;
591    Ok(GrantDecisionBinding {
592        log: claimed.log,
593        required: claimed.required,
594        rights,
595        admitting,
596    })
597}
598
599const CLOSE_NONE: u8 = 0;
600const CLOSE_GRANT_REVOKED: u8 = 1;
601const CLOSE_LOG_RESET: u8 = 2;
602
603const fn close_code(reason: RevocationRefusal) -> u8 {
604    match reason {
605        RevocationRefusal::LogReset => CLOSE_LOG_RESET,
606        RevocationRefusal::GrantRevoked
607        | RevocationRefusal::BindingMissing
608        | RevocationRefusal::RevisionUnavailable
609        | RevocationRefusal::StreamUnavailable
610        | RevocationRefusal::StreamGap
611        | RevocationRefusal::ProtocolUnsupported => CLOSE_GRANT_REVOKED,
612    }
613}
614
615/// The rights one admitted connection holds now, shared between the
616/// authority (which withdraws) and the connection (which reads). Reads take
617/// no lock.
618#[derive(Debug)]
619struct LiveRights {
620    lost: Box<[AtomicBool]>,
621    close: AtomicU8,
622    changes: AtomicU64,
623}
624
625#[derive(Debug, Default)]
626struct HotCounts {
627    allowed: AtomicU64,
628    refused: AtomicU64,
629}
630
631/// One admitted connection's binding and its current rights (ACCESS-005 R3,
632/// R4).
633///
634/// Every hot operation of the connection is decided here, locally, at
635/// the authority's ordered generation, without the authority's guard and
636/// without asking Lys.
637#[derive(Clone)]
638pub struct ConnectionGrants {
639    pid: u64,
640    binding: Arc<GrantDecisionBinding>,
641    live: Arc<LiveRights>,
642    hot: Arc<HotCounts>,
643}
644
645impl fmt::Debug for ConnectionGrants {
646    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
647        formatter
648            .debug_struct("ConnectionGrants")
649            .field("pid", &self.pid)
650            .field("binding", &self.binding)
651            .finish_non_exhaustive()
652    }
653}
654
655impl ConnectionGrants {
656    /// The connection this binding admitted.
657    #[must_use]
658    pub const fn pid(&self) -> u64 {
659        self.pid
660    }
661
662    /// The binding the connection was admitted on; never replaced.
663    #[must_use]
664    pub fn binding(&self) -> &GrantDecisionBinding {
665        &self.binding
666    }
667
668    /// Whether `action` on `subject` is allowed now.
669    ///
670    /// # Errors
671    /// [`RevocationRefusal::GrantRevoked`] when every bound right covering
672    /// it was withdrawn or the connection is closing for a revocation;
673    /// [`RevocationRefusal::LogReset`] when it is closing for a log reset;
674    /// [`RevocationRefusal::BindingMissing`] when no bound right covers it.
675    pub fn allows(&self, action: HotAction, subject: &str) -> Result<(), RevocationRefusal> {
676        let decided = self.decide(action, subject);
677        let counter = if decided.is_ok() {
678            &self.hot.allowed
679        } else {
680            &self.hot.refused
681        };
682        counter.fetch_add(1, Ordering::Relaxed);
683        decided
684    }
685
686    /// Whether `action` on `subject` is allowed now, without counting it as
687    /// an operation: the connection's own sweep reads this to retire the
688    /// work of withdrawn rights.
689    #[must_use]
690    pub fn permits(&self, action: HotAction, subject: &str) -> bool {
691        self.decide(action, subject).is_ok()
692    }
693
694    fn decide(&self, action: HotAction, subject: &str) -> Result<(), RevocationRefusal> {
695        if let Some(reason) = self.close_pending() {
696            return Err(reason);
697        }
698        let mut covered = false;
699        for (right, lost) in self.binding.rights.iter().zip(self.live.lost.iter()) {
700            if right.action == RightAction::Hot(action) && subject.starts_with(&right.prefix) {
701                covered = true;
702                if !lost.load(Ordering::Acquire) {
703                    return Ok(());
704                }
705            }
706        }
707        Err(if covered {
708            RevocationRefusal::GrantRevoked
709        } else {
710            RevocationRefusal::BindingMissing
711        })
712    }
713
714    /// The reason the connection must close for, once its admitting right
715    /// is lost or its log was reset.
716    #[must_use]
717    pub fn close_pending(&self) -> Option<RevocationRefusal> {
718        match self.live.close.load(Ordering::Acquire) {
719            CLOSE_NONE => None,
720            CLOSE_LOG_RESET => Some(RevocationRefusal::LogReset),
721            _ => Some(RevocationRefusal::GrantRevoked),
722        }
723    }
724
725    /// How many withdrawals and closes this connection's rights have seen.
726    /// A connection compares it with the last value it acted on, so a
727    /// sweep runs only after a change.
728    #[must_use]
729    pub fn changes(&self) -> u64 {
730        self.live.changes.load(Ordering::Acquire)
731    }
732}
733
734/// The consumer's readiness (ACCESS-005 R2).
735#[derive(Clone, Copy, Debug, PartialEq, Eq)]
736pub enum Readiness {
737    /// No baseline and cursor have agreed yet.
738    NotReady,
739    /// Baseline, cursor and rights indexes agree; admissions may commit.
740    Ready,
741    /// A revision arrived with no trusted evidence for the interval before it.
742    Gap,
743    /// The stream's transport was lost.
744    Unavailable,
745    /// The log was replaced; the new log's baseline has not settled.
746    Reset,
747    /// The stream ended on an unknown or invalid event, or conflicting
748    /// evidence. Sticky: a new consumer must be started.
749    Terminal,
750}
751
752impl Readiness {
753    const fn refusal(self) -> Option<RevocationRefusal> {
754        match self {
755            Self::Ready => None,
756            Self::NotReady | Self::Unavailable | Self::Terminal => {
757                Some(RevocationRefusal::StreamUnavailable)
758            }
759            Self::Gap => Some(RevocationRefusal::StreamGap),
760            Self::Reset => Some(RevocationRefusal::LogReset),
761        }
762    }
763}
764
765/// One grant change on the stream.
766#[derive(Clone, Debug, PartialEq, Eq)]
767pub enum GrantFeedChange {
768    /// A grant was issued.
769    Issue(GrantId),
770    /// A grant was revoked, and with it everything derived from it.
771    Revoke(GrantId),
772}
773
774/// One event of the consumer-opened Lys grant-change stream (Lys
775/// DIRECTORY-089 R1), as the reader that owns the stream decoded it.
776#[derive(Clone, Debug, PartialEq, Eq)]
777pub enum GrantFeedEvent {
778    /// An authenticated bounded baseline: whether each needed grant is
779    /// revoked, through `through`.
780    Baseline {
781        /// The log.
782        log: GrantLogId,
783        /// The revision the baseline stands at.
784        through: Revision,
785        /// Each grant and whether it is revoked.
786        decisions: Vec<(GrantId, bool)>,
787    },
788    /// One committed change.
789    Change {
790        /// The log.
791        log: GrantLogId,
792        /// The change's receipt revision.
793        revision: Revision,
794        /// The change.
795        change: GrantFeedChange,
796        /// The digest of its signed evidence, telling a duplicate from a
797        /// conflict.
798        evidence: Digest32,
799    },
800    /// Trusted evidence that nothing visible to this consumer changed
801    /// through `through`.
802    Watermark {
803        /// The log.
804        log: GrantLogId,
805        /// The revision covered through.
806        through: Revision,
807    },
808    /// The stream states the baseline and replay are installed through
809    /// `through`.
810    Ready {
811        /// The log.
812        log: GrantLogId,
813        /// The revision installed through.
814        through: Revision,
815    },
816    /// The stream's transport was lost.
817    Lost,
818    /// The log was replaced.
819    Reset {
820        /// The replaced log.
821        from: GrantLogId,
822        /// The new log.
823        to: GrantLogId,
824    },
825    /// An event this consumer cannot read: unknown version or invalid body.
826    Invalid {
827        /// The event's stable error name.
828        reason: &'static str,
829    },
830}
831
832/// What applying an event asks the connection runtime to do.
833#[derive(Clone, Copy, Debug, PartialEq, Eq)]
834pub enum RevocationEffect {
835    /// Retire the work of one lost right that did not admit the connection.
836    Withdraw {
837        /// The connection.
838        pid: u64,
839        /// The right's index in the binding.
840        right: usize,
841    },
842    /// Close the connection with a reason.
843    Close {
844        /// The connection.
845        pid: u64,
846        /// The reason.
847        reason: RevocationRefusal,
848    },
849}
850
851impl RevocationEffect {
852    /// The connection the effect is for.
853    #[must_use]
854    pub const fn pid(self) -> u64 {
855        match self {
856            Self::Withdraw { pid, .. } | Self::Close { pid, .. } => pid,
857        }
858    }
859}
860
861/// How an event was taken.
862#[derive(Clone, Copy, Debug, PartialEq, Eq)]
863pub enum Applied {
864    /// A baseline was installed.
865    Baseline,
866    /// A contiguous change was applied.
867    Change,
868    /// A revision at or below the cursor, with matching evidence.
869    Duplicate,
870    /// A watermark advanced the cursor.
871    Watermark,
872    /// A revision arrived past a missing interval; nothing was applied.
873    Gap,
874    /// `Ready` was published, after rechecking every open connection when a
875    /// resume required it.
876    Ready,
877    /// The transport was lost.
878    Lost,
879    /// The log was replaced.
880    Reset,
881    /// The event was refused, by name.
882    Refused(&'static str),
883    /// The stream ended terminally, by name.
884    Terminal(&'static str),
885}
886
887/// The result of applying one event.
888#[derive(Clone, Debug, PartialEq, Eq)]
889pub struct ApplyOutcome {
890    /// How the event was taken.
891    pub applied: Applied,
892    /// What the connection runtime must do, in order.
893    pub effects: Vec<RevocationEffect>,
894}
895
896/// How a reason-bearing close's write ended, as the connection observed it.
897/// None of these is a claim the peer received it.
898#[derive(Clone, Copy, Debug, PartialEq, Eq)]
899pub enum CloseWrite {
900    /// The frame was written to the socket in full.
901    Flushed,
902    /// Part of the outbound queue was still unwritten when the close ended.
903    Residue,
904    /// The frame could not be queued (outbound full or encode refused).
905    NotQueued,
906    /// The socket write failed.
907    TransportFailed,
908    /// The connection had no stream left to write to.
909    NoStream,
910}
911
912/// Per-phase counts (ACCESS-005 R7).
913///
914/// Every count is incremented at the
915/// phase it names, never inferred from time or a successful exit. Nothing
916/// here touches a store: store calls, flushes and syncs are zero by
917/// construction and have no counter.
918#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
919pub struct RevocationCounts {
920    /// Revocation changes observed on the stream.
921    pub observed_revocations: u64,
922    /// Unique contiguous revisions applied.
923    pub applied_revisions: u64,
924    /// Revisions at or below the cursor, ignored.
925    pub duplicate_revisions: u64,
926    /// Watermarks that advanced the cursor.
927    pub watermark_advances: u64,
928    /// Events refused by name.
929    pub refused_events: u64,
930    /// Connections visited through the reverse index for a change.
931    pub affected_visits: u64,
932    /// Connections rechecked by a settled resume.
933    pub resume_rechecks: u64,
934    /// Admissions committed through the fence.
935    pub admissions_committed: u64,
936    /// Admissions refused, at staging or at the fence.
937    pub admissions_refused: u64,
938    /// Attempts to bind a second binding to a connection, refused.
939    pub binding_replacements_refused: u64,
940    /// Rights withdrawn without closing.
941    pub withdrawals: u64,
942    /// Closes decided.
943    pub closes_decided: u64,
944    /// Closes whose control could not be published; the denied state still
945    /// gates the connection and its own wake delivers the close.
946    pub close_publication_refused: u64,
947    /// Reason-bearing closes the connection attempted to write.
948    pub closes_attempted: u64,
949    /// Of those, the ones written to the socket in full.
950    pub closes_flushed: u64,
951    /// Of those, the ones whose write failed or had no stream.
952    pub close_transport_failures: u64,
953    /// Connections released through the removal funnel.
954    pub cleanups: u64,
955    /// Decisions pruned as unreferenced.
956    pub decisions_pruned: u64,
957    /// Transitions into `Gap`.
958    pub gap_transitions: u64,
959    /// Transitions into `Unavailable`.
960    pub loss_transitions: u64,
961    /// Transitions into `Reset`.
962    pub reset_transitions: u64,
963    /// Terminal stream errors.
964    pub terminal_errors: u64,
965    /// Cold decision asks made to Lys (network calls).
966    pub decision_asks: u64,
967    /// Cold replies older than the revision they were asked at, refused.
968    pub stale_replies_refused: u64,
969    /// Reply decisions that would have overwritten a deny, refused.
970    pub stale_overwrites_prevented: u64,
971    /// Decision rows read from baselines and replies.
972    pub rows_read: u64,
973}
974
975/// Whether the consumer knows how many revisions it is missing.
976#[derive(Clone, Copy, Debug, PartialEq, Eq)]
977pub enum GapLength {
978    /// No interval is missing.
979    None,
980    /// Exactly this many revisions are missing.
981    Known(u64),
982    /// Revisions may be missing, and how many is not known: the producer's
983    /// high water is unknown. Never reported as zero.
984    Unknown,
985}
986
987/// One announced readiness transition.
988#[derive(Clone, Copy, Debug, PartialEq, Eq)]
989pub struct Transition {
990    /// The readiness entered.
991    pub readiness: Readiness,
992    /// Its stable reason, when it is a loss.
993    pub reason: Option<RevocationRefusal>,
994    /// The cursor at the transition.
995    pub applied: Option<Revision>,
996    /// The missing interval at the transition.
997    pub missing: GapLength,
998    /// Cumulative admissions refused at the transition.
999    pub refused_admissions: u64,
1000    /// Cumulative resume rechecks at the transition.
1001    pub rechecks: u64,
1002}
1003
1004/// The revocation authority's lock was poisoned by a panicking holder; its
1005/// state is untrusted and is not read.
1006#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
1007#[error("revocation_authority_poisoned")]
1008pub struct AuthorityPoisoned;
1009
1010/// A bounded snapshot of the consumer (ACCESS-005 R7). It carries no
1011/// credential, holder, prefix or grant bytes.
1012#[derive(Clone, Debug, PartialEq, Eq)]
1013pub struct RevocationDiagnostics {
1014    /// The log consumed now.
1015    pub log: GrantLogId,
1016    /// The readiness.
1017    pub readiness: Readiness,
1018    /// The loss reason new admissions are refused with, if any.
1019    pub reason: Option<RevocationRefusal>,
1020    /// The last applied receipt revision.
1021    pub applied: Option<Revision>,
1022    /// The last trusted producer high-water revision; `None` is unknown.
1023    pub high_water: Option<Revision>,
1024    /// The missing interval.
1025    pub missing: GapLength,
1026    /// Resume generations begun.
1027    pub resume_generation: u64,
1028    /// The resume generation whose rechecks settled.
1029    pub settled_generation: u64,
1030    /// Connections bound now.
1031    pub bound_connections: usize,
1032    /// Decisions retained now.
1033    pub retained_decisions: usize,
1034    /// Reverse-index entries now.
1035    pub index_entries: usize,
1036    /// Cold asks in flight now.
1037    pub inflight_asks: u64,
1038    /// The per-phase counts.
1039    pub counts: RevocationCounts,
1040    /// Hot operations allowed.
1041    pub hot_allowed: u64,
1042    /// Hot operations refused.
1043    pub hot_refused: u64,
1044    /// The retained transitions, oldest first.
1045    pub transitions: Vec<Transition>,
1046    /// Transitions dropped from the bounded ring.
1047    pub transitions_dropped: u64,
1048}
1049
1050#[derive(Clone, Copy, Debug)]
1051struct Decision {
1052    revoked: bool,
1053    at: Revision,
1054    baseline: bool,
1055}
1056
1057#[derive(Debug)]
1058struct Bound {
1059    binding: Arc<GrantDecisionBinding>,
1060    live: Arc<LiveRights>,
1061}
1062
1063#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1064enum RightState {
1065    Allowed,
1066    Lost,
1067    Unknown,
1068}
1069
1070#[derive(Debug)]
1071struct Inner {
1072    link_life: crate::link::LinkRegistry,
1073    log: GrantLogId,
1074    readiness: Readiness,
1075    applied: Option<Revision>,
1076    high_water: Option<Revision>,
1077    pending_resume: bool,
1078    resume_generation: u64,
1079    settled_generation: u64,
1080    decisions: BTreeMap<GrantId, Decision>,
1081    evidence: VecDeque<(Revision, Digest32)>,
1082    connections: BTreeMap<u64, Bound>,
1083    index: BTreeMap<GrantId, BTreeSet<u64>>,
1084    orphans: BTreeSet<GrantId>,
1085    inflight_asks: u64,
1086    counts: RevocationCounts,
1087    transitions: VecDeque<Transition>,
1088    transitions_dropped: u64,
1089    announce: Option<Transition>,
1090}
1091
1092const fn bump(counter: &mut u64) {
1093    *counter = counter.saturating_add(1);
1094}
1095
1096impl Inner {
1097    fn new(log: GrantLogId) -> Self {
1098        Self {
1099            log,
1100            link_life: crate::link::LinkRegistry::default(),
1101            readiness: Readiness::NotReady,
1102            applied: None,
1103            high_water: None,
1104            pending_resume: false,
1105            resume_generation: 0,
1106            settled_generation: 0,
1107            decisions: BTreeMap::new(),
1108            evidence: VecDeque::new(),
1109            connections: BTreeMap::new(),
1110            index: BTreeMap::new(),
1111            orphans: BTreeSet::new(),
1112            inflight_asks: 0,
1113            counts: RevocationCounts::default(),
1114            transitions: VecDeque::new(),
1115            transitions_dropped: 0,
1116            announce: None,
1117        }
1118    }
1119
1120    fn missing(&self) -> GapLength {
1121        if self.readiness == Readiness::Ready {
1122            return GapLength::None;
1123        }
1124        match (self.applied, self.high_water) {
1125            (Some(applied), Some(high)) => match missing_between(applied, high) {
1126                Some(missing) if missing > 0 => GapLength::Known(missing),
1127                _ => GapLength::None,
1128            },
1129            // Not ready and the producer's high water (or the cursor) is not
1130            // known: how much is missing is unknown, never zero.
1131            _ => GapLength::Unknown,
1132        }
1133    }
1134
1135    /// Enters `readiness`, recording one transition when it changes; the
1136    /// transition is announced after the guard is released.
1137    fn transition(&mut self, readiness: Readiness) {
1138        if self.readiness == readiness {
1139            return;
1140        }
1141        self.readiness = readiness;
1142        match readiness {
1143            Readiness::Gap => bump(&mut self.counts.gap_transitions),
1144            Readiness::Unavailable => bump(&mut self.counts.loss_transitions),
1145            Readiness::Reset => bump(&mut self.counts.reset_transitions),
1146            Readiness::Terminal => bump(&mut self.counts.terminal_errors),
1147            Readiness::NotReady | Readiness::Ready => {}
1148        }
1149        let transition = Transition {
1150            readiness,
1151            reason: readiness.refusal(),
1152            applied: self.applied,
1153            missing: self.missing(),
1154            refused_admissions: self.counts.admissions_refused,
1155            rechecks: self.counts.resume_rechecks,
1156        };
1157        if self.transitions.len() >= MAX_DIAGNOSTIC_EVENTS {
1158            self.transitions.pop_front();
1159            bump(&mut self.transitions_dropped);
1160        }
1161        self.transitions.push_back(transition);
1162        self.announce = Some(transition);
1163    }
1164
1165    fn precheck(&self, binding: &GrantDecisionBinding) -> Result<(), RevocationRefusal> {
1166        if let Some(refusal) = self.readiness.refusal() {
1167            return Err(refusal);
1168        }
1169        if binding.log != self.log {
1170            return Err(RevocationRefusal::LogReset);
1171        }
1172        if self
1173            .applied
1174            .is_none_or(|applied| applied < binding.required)
1175        {
1176            return Err(RevocationRefusal::RevisionUnavailable);
1177        }
1178        Ok(())
1179    }
1180
1181    fn missing_grants(&self, binding: &GrantDecisionBinding) -> Vec<GrantId> {
1182        let unknown: BTreeSet<&GrantId> = binding
1183            .grants()
1184            .filter(|grant| !self.decisions.contains_key(*grant))
1185            .collect();
1186        unknown.into_iter().cloned().collect()
1187    }
1188
1189    fn right_state(&self, right: &BoundRight) -> RightState {
1190        let mut unknown = false;
1191        for chain in &right.chains {
1192            let mut chain_state = RightState::Allowed;
1193            for grant in chain {
1194                match self.decisions.get(grant) {
1195                    Some(decision) if decision.revoked => {
1196                        chain_state = RightState::Lost;
1197                        break;
1198                    }
1199                    Some(_) => {}
1200                    None => chain_state = RightState::Unknown,
1201                }
1202            }
1203            match chain_state {
1204                RightState::Allowed => return RightState::Allowed,
1205                RightState::Unknown => unknown = true,
1206                RightState::Lost => {}
1207            }
1208        }
1209        if unknown {
1210            RightState::Unknown
1211        } else {
1212            RightState::Lost
1213        }
1214    }
1215
1216    /// Merges one decision, never letting an older or allowing answer
1217    /// overwrite a deny.
1218    fn merge_decision(&mut self, grant: &GrantId, revoked: bool, at: Revision, baseline: bool) {
1219        match self.decisions.get_mut(grant) {
1220            Some(existing) if existing.revoked => {
1221                if !revoked {
1222                    bump(&mut self.counts.stale_overwrites_prevented);
1223                }
1224                existing.baseline |= baseline;
1225            }
1226            Some(existing) if existing.at >= at && !revoked => {
1227                existing.baseline |= baseline;
1228            }
1229            Some(existing) => {
1230                existing.revoked = revoked;
1231                existing.at = at;
1232                existing.baseline |= baseline;
1233            }
1234            None => {
1235                self.decisions.insert(
1236                    grant.clone(),
1237                    Decision {
1238                        revoked,
1239                        at,
1240                        baseline,
1241                    },
1242                );
1243                if !baseline && !self.index.contains_key(grant) {
1244                    self.orphans.insert(grant.clone());
1245                }
1246            }
1247        }
1248    }
1249
1250    fn install_reply(
1251        &mut self,
1252        reply: &DecisionReply,
1253        at_least: Revision,
1254    ) -> Result<(), RevocationRefusal> {
1255        if reply.log != self.log {
1256            return Err(RevocationRefusal::LogReset);
1257        }
1258        if reply.revision < at_least || reply.decisions.len() > MAX_DECISIONS_PER_EVENT {
1259            bump(&mut self.counts.stale_replies_refused);
1260            return Err(RevocationRefusal::RevisionUnavailable);
1261        }
1262        for (grant, revoked) in &reply.decisions {
1263            bump(&mut self.counts.rows_read);
1264            self.merge_decision(grant, *revoked, reply.revision, false);
1265        }
1266        Ok(())
1267    }
1268
1269    /// The final fence and the binding, in one ordered transition.
1270    fn bind(
1271        &mut self,
1272        pid: u64,
1273        binding: &Arc<GrantDecisionBinding>,
1274        hot: &Arc<HotCounts>,
1275    ) -> Result<ConnectionGrants, RevocationRefusal> {
1276        self.precheck(binding)?;
1277        if self.connections.contains_key(&pid) {
1278            bump(&mut self.counts.binding_replacements_refused);
1279            tracing::warn!(
1280                connection_pid = pid,
1281                "a second grant-decision binding was offered; the first is kept"
1282            );
1283            return Err(RevocationRefusal::BindingMissing);
1284        }
1285        let mut lost = Vec::with_capacity(binding.rights.len());
1286        for (index, right) in binding.rights.iter().enumerate() {
1287            match (self.right_state(right), index == binding.admitting) {
1288                (RightState::Allowed, _) => lost.push(AtomicBool::new(false)),
1289                (RightState::Lost, true) => return Err(RevocationRefusal::GrantRevoked),
1290                (RightState::Lost, false) => lost.push(AtomicBool::new(true)),
1291                (RightState::Unknown, _) => return Err(RevocationRefusal::RevisionUnavailable),
1292            }
1293        }
1294        let live = Arc::new(LiveRights {
1295            lost: lost.into_boxed_slice(),
1296            close: AtomicU8::new(CLOSE_NONE),
1297            changes: AtomicU64::new(0),
1298        });
1299        for grant in binding.grants() {
1300            self.index.entry(grant.clone()).or_default().insert(pid);
1301            self.orphans.remove(grant);
1302        }
1303        self.connections.insert(
1304            pid,
1305            Bound {
1306                binding: Arc::clone(binding),
1307                live: Arc::clone(&live),
1308            },
1309        );
1310        bump(&mut self.counts.admissions_committed);
1311        Ok(ConnectionGrants {
1312            pid,
1313            binding: Arc::clone(binding),
1314            live,
1315            hot: Arc::clone(hot),
1316        })
1317    }
1318
1319    /// Re-evaluates one bound connection, withdrawing newly lost rights and
1320    /// closing it when its admitting right is lost.
1321    fn evaluate(&mut self, pid: u64, effects: &mut Vec<RevocationEffect>) {
1322        let Some(bound) = self.connections.get(&pid) else {
1323            return;
1324        };
1325        let binding = Arc::clone(&bound.binding);
1326        let live = Arc::clone(&bound.live);
1327        if live.close.load(Ordering::Acquire) != CLOSE_NONE {
1328            return;
1329        }
1330        if binding.log != self.log {
1331            self.close(pid, &live, RevocationRefusal::LogReset, effects);
1332            return;
1333        }
1334        for (index, (right, lost)) in binding.rights.iter().zip(live.lost.iter()).enumerate() {
1335            if lost.load(Ordering::Acquire) || self.right_state(right) == RightState::Allowed {
1336                continue;
1337            }
1338            // A miss is never a grant: an undecided chain counts as lost.
1339            lost.store(true, Ordering::Release);
1340            live.changes.fetch_add(1, Ordering::AcqRel);
1341            if index == binding.admitting {
1342                self.close(pid, &live, RevocationRefusal::GrantRevoked, effects);
1343                return;
1344            }
1345            bump(&mut self.counts.withdrawals);
1346            effects.push(RevocationEffect::Withdraw { pid, right: index });
1347        }
1348    }
1349
1350    fn close(
1351        &mut self,
1352        pid: u64,
1353        live: &LiveRights,
1354        reason: RevocationRefusal,
1355        effects: &mut Vec<RevocationEffect>,
1356    ) {
1357        self.link_life.close(pid, crate::link::LinkEnd::Revoked);
1358        live.close.store(close_code(reason), Ordering::Release);
1359        live.changes.fetch_add(1, Ordering::AcqRel);
1360        bump(&mut self.counts.closes_decided);
1361        effects.push(RevocationEffect::Close { pid, reason });
1362    }
1363
1364    fn remember_evidence(&mut self, revision: Revision, evidence: Digest32) {
1365        if self.evidence.len() >= EVIDENCE_RETAINED {
1366            self.evidence.pop_front();
1367        }
1368        self.evidence.push_back((revision, evidence));
1369    }
1370
1371    fn raise_high_water(&mut self, revision: Revision) {
1372        if self.high_water.is_none_or(|high| high < revision) {
1373            self.high_water = Some(revision);
1374        }
1375    }
1376
1377    fn apply_change(
1378        &mut self,
1379        revision: Revision,
1380        change: &GrantFeedChange,
1381        evidence: Digest32,
1382        effects: &mut Vec<RevocationEffect>,
1383    ) -> Applied {
1384        self.raise_high_water(revision);
1385        let Some(applied) = self.applied else {
1386            return Applied::Refused("change before baseline");
1387        };
1388        if revision <= applied {
1389            let conflicting = self
1390                .evidence
1391                .iter()
1392                .any(|(seen, digest)| *seen == revision && *digest != evidence);
1393            if conflicting {
1394                self.transition(Readiness::Terminal);
1395                return Applied::Terminal("conflicting_evidence");
1396            }
1397            bump(&mut self.counts.duplicate_revisions);
1398            return Applied::Duplicate;
1399        }
1400        if applied.next() != Some(revision) {
1401            self.pending_resume = true;
1402            self.transition(Readiness::Gap);
1403            return Applied::Gap;
1404        }
1405        self.applied = Some(revision);
1406        self.remember_evidence(revision, evidence);
1407        bump(&mut self.counts.applied_revisions);
1408        match change {
1409            GrantFeedChange::Issue(grant) => {
1410                if self.decisions.contains_key(grant) || self.index.contains_key(grant) {
1411                    self.merge_decision(grant, false, revision, false);
1412                }
1413            }
1414            GrantFeedChange::Revoke(grant) => {
1415                bump(&mut self.counts.observed_revocations);
1416                let referenced = self.index.contains_key(grant);
1417                if referenced || self.inflight_asks > 0 || self.decisions.contains_key(grant) {
1418                    // A tombstone, kept while a binding or an in-flight ask
1419                    // could still read this grant.
1420                    self.merge_decision(grant, true, revision, false);
1421                }
1422                let affected: Vec<u64> = self
1423                    .index
1424                    .get(grant)
1425                    .map(|pids| pids.iter().copied().collect())
1426                    .unwrap_or_default();
1427                for pid in affected {
1428                    bump(&mut self.counts.affected_visits);
1429                    self.evaluate(pid, effects);
1430                }
1431            }
1432        }
1433        Applied::Change
1434    }
1435
1436    fn apply_ready(&mut self, through: Revision, effects: &mut Vec<RevocationEffect>) -> Applied {
1437        if self.applied != Some(through) {
1438            return Applied::Refused("ready does not match the applied cursor");
1439        }
1440        self.raise_high_water(through);
1441        if self.readiness == Readiness::Ready && !self.pending_resume {
1442            return Applied::Duplicate;
1443        }
1444        self.resume_generation = self.resume_generation.saturating_add(1);
1445        let open: Vec<u64> = self.connections.keys().copied().collect();
1446        for pid in open {
1447            bump(&mut self.counts.resume_rechecks);
1448            self.evaluate(pid, effects);
1449        }
1450        self.pending_resume = false;
1451        self.settled_generation = self.resume_generation;
1452        self.transition(Readiness::Ready);
1453        Applied::Ready
1454    }
1455
1456    fn apply(&mut self, event: &GrantFeedEvent, effects: &mut Vec<RevocationEffect>) -> Applied {
1457        if self.readiness == Readiness::Terminal {
1458            return Applied::Refused("stream is terminal");
1459        }
1460        match event {
1461            GrantFeedEvent::Baseline {
1462                log,
1463                through,
1464                decisions,
1465            } => self.apply_baseline(log, *through, decisions),
1466            GrantFeedEvent::Change {
1467                log,
1468                revision,
1469                change,
1470                evidence,
1471            } => {
1472                if *log != self.log {
1473                    return Applied::Refused("change from another log");
1474                }
1475                self.apply_change(*revision, change, *evidence, effects)
1476            }
1477            GrantFeedEvent::Watermark { log, through } => {
1478                if *log != self.log {
1479                    return Applied::Refused("watermark from another log");
1480                }
1481                self.raise_high_water(*through);
1482                match self.applied {
1483                    None => Applied::Refused("watermark before baseline"),
1484                    Some(applied) if *through <= applied => {
1485                        bump(&mut self.counts.duplicate_revisions);
1486                        Applied::Duplicate
1487                    }
1488                    Some(_) => {
1489                        self.applied = Some(*through);
1490                        bump(&mut self.counts.watermark_advances);
1491                        Applied::Watermark
1492                    }
1493                }
1494            }
1495            GrantFeedEvent::Ready { log, through } => {
1496                if *log != self.log {
1497                    return Applied::Refused("ready from another log");
1498                }
1499                self.apply_ready(*through, effects)
1500            }
1501            GrantFeedEvent::Lost => {
1502                self.high_water = None;
1503                self.pending_resume = true;
1504                self.transition(Readiness::Unavailable);
1505                Applied::Lost
1506            }
1507            GrantFeedEvent::Reset { from, to } => self.apply_reset(from, to),
1508            GrantFeedEvent::Invalid { reason } => {
1509                self.transition(Readiness::Terminal);
1510                Applied::Terminal(reason)
1511            }
1512        }
1513    }
1514
1515    fn apply_baseline(
1516        &mut self,
1517        log: &GrantLogId,
1518        through: Revision,
1519        decisions: &[(GrantId, bool)],
1520    ) -> Applied {
1521        if *log != self.log {
1522            return Applied::Refused("baseline from another log");
1523        }
1524        if decisions.len() > MAX_DECISIONS_PER_EVENT {
1525            return Applied::Refused("baseline over its bound");
1526        }
1527        if self.applied.is_some_and(|applied| through < applied) {
1528            return Applied::Refused("baseline older than the applied cursor");
1529        }
1530        for (grant, revoked) in decisions {
1531            bump(&mut self.counts.rows_read);
1532            self.merge_decision(grant, *revoked, through, true);
1533        }
1534        self.applied = Some(through);
1535        self.raise_high_water(through);
1536        Applied::Baseline
1537    }
1538
1539    fn apply_reset(&mut self, from: &GrantLogId, to: &GrantLogId) -> Applied {
1540        if *from != self.log {
1541            return Applied::Refused("reset from a log not consumed here");
1542        }
1543        self.log = to.clone();
1544        // Revisions of the old log never compare with the new one's. Applied
1545        // denials stay on each connection's own rights and never revert.
1546        self.decisions.clear();
1547        self.evidence.clear();
1548        self.orphans.clear();
1549        self.applied = None;
1550        self.high_water = None;
1551        self.pending_resume = true;
1552        self.transition(Readiness::Reset);
1553        Applied::Reset
1554    }
1555
1556    fn prune_orphans(&mut self) {
1557        if self.inflight_asks > 0 {
1558            return;
1559        }
1560        for grant in std::mem::take(&mut self.orphans) {
1561            if self.index.contains_key(&grant) {
1562                continue;
1563            }
1564            if self
1565                .decisions
1566                .get(&grant)
1567                .is_some_and(|decision| !decision.baseline)
1568            {
1569                self.decisions.remove(&grant);
1570                bump(&mut self.counts.decisions_pruned);
1571            }
1572        }
1573    }
1574
1575    fn finish_ask(&mut self) {
1576        self.inflight_asks = self.inflight_asks.saturating_sub(1);
1577        self.prune_orphans();
1578    }
1579
1580    fn release(&mut self, pid: u64) -> bool {
1581        let Some(bound) = self.connections.remove(&pid) else {
1582            return false;
1583        };
1584        for grant in bound.binding.grants() {
1585            if let Some(pids) = self.index.get_mut(grant) {
1586                pids.remove(&pid);
1587                if pids.is_empty() {
1588                    self.index.remove(grant);
1589                    self.orphans.insert(grant.clone());
1590                }
1591            }
1592        }
1593        bump(&mut self.counts.cleanups);
1594        self.prune_orphans();
1595        true
1596    }
1597}
1598
1599/// A verified binding whose cold decisions are acquired, waiting for the
1600/// fence. While it lives it holds an in-flight ask, so no deny it could read
1601/// is pruned; dropping it uncommitted releases that.
1602pub struct StagedAdmission {
1603    binding: Arc<GrantDecisionBinding>,
1604    inner: Arc<Mutex<Inner>>,
1605    registered: bool,
1606}
1607
1608impl fmt::Debug for StagedAdmission {
1609    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1610        formatter
1611            .debug_struct("StagedAdmission")
1612            .field("binding", &self.binding)
1613            .finish_non_exhaustive()
1614    }
1615}
1616
1617impl Drop for StagedAdmission {
1618    fn drop(&mut self) {
1619        if self.registered
1620            && let Ok(mut inner) = self.inner.lock()
1621        {
1622            inner.finish_ask();
1623        }
1624    }
1625}
1626
1627/// One grant log's revocation consumer, decision cache and admission fence
1628/// (ACCESS-005 R1-R4, R6, R7).
1629pub struct RevocationAuthority {
1630    pub(crate) link_life: crate::link::LinkRegistry,
1631    policy: RevocationPolicy,
1632    bindings: Arc<dyn GrantBindingSource>,
1633    decisions: Arc<dyn GrantDecisionSource>,
1634    inner: Arc<Mutex<Inner>>,
1635    hot: Arc<HotCounts>,
1636}
1637
1638impl fmt::Debug for RevocationAuthority {
1639    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1640        formatter
1641            .debug_struct("RevocationAuthority")
1642            .field("log", &self.policy.log)
1643            .finish_non_exhaustive()
1644    }
1645}
1646
1647impl RevocationAuthority {
1648    /// A consumer for `policy`'s log, `NotReady` until its stream's baseline
1649    /// and `Ready` arrive, with the producer and decision seams given.
1650    #[must_use]
1651    pub fn new(
1652        policy: RevocationPolicy,
1653        bindings: Arc<dyn GrantBindingSource>,
1654        decisions: Arc<dyn GrantDecisionSource>,
1655    ) -> Self {
1656        let state = Inner::new(policy.log.clone());
1657        let link_life = state.link_life.clone();
1658        let inner = Arc::new(Mutex::new(state));
1659        Self {
1660            policy,
1661            link_life,
1662            bindings,
1663            decisions,
1664            inner,
1665            hot: Arc::new(HotCounts::default()),
1666        }
1667    }
1668
1669    /// The authority with the seams Lys publishes today: neither, so every
1670    /// admission is refused by name.
1671    #[must_use]
1672    pub fn unpublished(policy: RevocationPolicy) -> Self {
1673        Self::new(
1674            policy,
1675            Arc::new(UnpublishedGrantBinding),
1676            Arc::new(UnpublishedGrantDecisions),
1677        )
1678    }
1679
1680    fn lock(&self) -> Result<MutexGuard<'_, Inner>, RevocationRefusal> {
1681        self.inner
1682            .lock()
1683            .map_err(|_| RevocationRefusal::StreamUnavailable)
1684    }
1685
1686    fn refused(&self, refusal: RevocationRefusal) -> RevocationRefusal {
1687        if let Ok(mut inner) = self.inner.lock() {
1688            bump(&mut inner.counts.admissions_refused);
1689        }
1690        refusal
1691    }
1692
1693    /// Verifies `credential`'s binding for `holder` and acquires any decision
1694    /// the cache lacks, outside the guard. Nothing is admitted yet.
1695    ///
1696    /// # Errors
1697    /// The refusal by name: the binding is missing or does not match, the
1698    /// stream is not ready, the log was reset, or the required revision or a
1699    /// needed decision is unavailable.
1700    pub fn stage(
1701        &self,
1702        credential: &[u8],
1703        holder: &[u8],
1704    ) -> Result<StagedAdmission, RevocationRefusal> {
1705        let binding = verify_binding(&self.policy, self.bindings.as_ref(), credential, holder)
1706            .map_err(|refusal| self.refused(refusal))?;
1707        let binding = Arc::new(binding);
1708        let (needed, at_least) = {
1709            let mut inner = self.lock()?;
1710            if let Err(refusal) = inner.precheck(&binding) {
1711                bump(&mut inner.counts.admissions_refused);
1712                return Err(refusal);
1713            }
1714            inner.inflight_asks = inner.inflight_asks.saturating_add(1);
1715            let needed = inner.missing_grants(&binding);
1716            let at_least = inner.applied;
1717            drop(inner);
1718            (needed, at_least)
1719        };
1720        let staged = StagedAdmission {
1721            binding,
1722            inner: Arc::clone(&self.inner),
1723            registered: true,
1724        };
1725        if needed.is_empty() {
1726            return Ok(staged);
1727        }
1728        let at_least =
1729            at_least.ok_or_else(|| self.refused(RevocationRefusal::RevisionUnavailable))?;
1730        let reply = self
1731            .decisions
1732            .decide(&staged.binding.log, &needed, at_least);
1733        let installed = {
1734            let mut inner = self.lock()?;
1735            bump(&mut inner.counts.decision_asks);
1736            let installed = reply.and_then(|reply| inner.install_reply(&reply, at_least));
1737            if installed.is_err() {
1738                bump(&mut inner.counts.admissions_refused);
1739            }
1740            drop(inner);
1741            installed
1742        };
1743        installed.map(|()| staged)
1744    }
1745
1746    /// The fence (ACCESS-005 R3): readiness, log identity, the required
1747    /// revision and the admitting right, checked and bound into the reverse
1748    /// index in one ordered transition with change application. A revocation
1749    /// applied between [`Self::stage`] and this call wins.
1750    ///
1751    /// # Errors
1752    /// The refusal by name; nothing is bound on any refusal.
1753    pub fn commit(
1754        &self,
1755        pid: u64,
1756        mut staged: StagedAdmission,
1757    ) -> Result<ConnectionGrants, RevocationRefusal> {
1758        if !Arc::ptr_eq(&staged.inner, &self.inner) {
1759            return Err(self.refused(RevocationRefusal::BindingMissing));
1760        }
1761        staged.registered = false;
1762        let mut inner = self.lock()?;
1763        let bound = inner.bind(pid, &staged.binding, &self.hot);
1764        if bound.is_err() {
1765            bump(&mut inner.counts.admissions_refused);
1766        }
1767        inner.finish_ask();
1768        drop(inner);
1769        bound
1770    }
1771
1772    /// [`Self::stage`] then [`Self::commit`].
1773    ///
1774    /// # Errors
1775    /// As either step.
1776    pub fn admit(
1777        &self,
1778        pid: u64,
1779        credential: &[u8],
1780        holder: &[u8],
1781    ) -> Result<ConnectionGrants, RevocationRefusal> {
1782        let staged = self.stage(credential, holder)?;
1783        self.commit(pid, staged)
1784    }
1785
1786    /// Applies one stream event in order (ACCESS-005 R2, R4). Announcements
1787    /// are logged after the guard is released.
1788    #[must_use]
1789    pub fn apply(&self, event: &GrantFeedEvent) -> ApplyOutcome {
1790        let mut effects = Vec::new();
1791        let Ok(mut inner) = self.inner.lock() else {
1792            return ApplyOutcome {
1793                applied: Applied::Terminal("revocation authority lock poisoned"),
1794                effects,
1795            };
1796        };
1797        let before = inner.transitions_dropped;
1798        inner.announce = None;
1799        let applied = inner.apply(event, &mut effects);
1800        if matches!(applied, Applied::Refused(_)) {
1801            bump(&mut inner.counts.refused_events);
1802        }
1803        let announce = inner.announce.take();
1804        let dropped = inner.transitions_dropped > before;
1805        drop(inner);
1806        announce_outcome(applied, announce, dropped);
1807        ApplyOutcome { applied, effects }
1808    }
1809
1810    /// Releases connection `pid` from the reverse index, once, from the
1811    /// connection runtime's removal funnel (ACCESS-005 R6). Returns whether a
1812    /// binding was released; a second call releases nothing.
1813    #[must_use]
1814    pub fn release(&self, pid: u64) -> bool {
1815        let Ok(mut inner) = self.inner.lock() else {
1816            tracing::error!(
1817                pid,
1818                "revocation authority lock poisoned; the binding is not released"
1819            );
1820            return false;
1821        };
1822        let released = inner.release(pid);
1823        drop(inner);
1824        self.link_life.forget(pid);
1825        released
1826    }
1827
1828    /// Records that the close control for `pid` could not be published. The
1829    /// denied state already gates the connection; its own wake delivers the
1830    /// close.
1831    pub fn record_close_publication_refused(&self, pid: u64) {
1832        if let Ok(mut inner) = self.inner.lock() {
1833            bump(&mut inner.counts.close_publication_refused);
1834        }
1835        tracing::warn!(
1836            connection_pid = pid,
1837            "revocation close control could not be published; the connection's wake delivers it"
1838        );
1839    }
1840
1841    /// Records how connection `pid`'s reason-bearing close write ended.
1842    /// Attempted is not delivered.
1843    pub fn record_close_write(&self, pid: u64, write: CloseWrite) {
1844        if let Ok(mut inner) = self.inner.lock() {
1845            bump(&mut inner.counts.closes_attempted);
1846            match write {
1847                CloseWrite::Flushed => bump(&mut inner.counts.closes_flushed),
1848                CloseWrite::TransportFailed | CloseWrite::NoStream => {
1849                    bump(&mut inner.counts.close_transport_failures);
1850                }
1851                CloseWrite::Residue | CloseWrite::NotQueued => {}
1852            }
1853        }
1854        if matches!(write, CloseWrite::TransportFailed | CloseWrite::NoStream) {
1855            tracing::warn!(
1856                connection_pid = pid,
1857                write = ?write,
1858                "revocation close could not be written; the denied state is kept and cleanup finishes"
1859            );
1860        }
1861    }
1862
1863    /// Drops every decision no binding and no in-flight ask needs. A later
1864    /// need is a miss, answered by a cold ask, never an implicit allow.
1865    pub fn prune_unreferenced(&self) {
1866        let Ok(mut inner) = self.inner.lock() else {
1867            return;
1868        };
1869        if inner.inflight_asks > 0 {
1870            return;
1871        }
1872        let unreferenced: Vec<GrantId> = inner
1873            .decisions
1874            .keys()
1875            .filter(|grant| !inner.index.contains_key(*grant))
1876            .cloned()
1877            .collect();
1878        for grant in unreferenced {
1879            inner.decisions.remove(&grant);
1880            bump(&mut inner.counts.decisions_pruned);
1881        }
1882        inner.orphans.clear();
1883    }
1884
1885    /// A bounded snapshot for diagnostics (ACCESS-005 R7).
1886    ///
1887    /// # Errors
1888    /// [`AuthorityPoisoned`] when a holder of the authority's lock panicked:
1889    /// the state is untrusted and no snapshot of it is answered.
1890    pub fn diagnostics(&self) -> Result<RevocationDiagnostics, AuthorityPoisoned> {
1891        let inner = self.inner.lock().map_err(|_| AuthorityPoisoned)?;
1892        let snapshot = RevocationDiagnostics {
1893            log: inner.log.clone(),
1894            readiness: inner.readiness,
1895            reason: inner.readiness.refusal(),
1896            applied: inner.applied,
1897            high_water: inner.high_water,
1898            missing: inner.missing(),
1899            resume_generation: inner.resume_generation,
1900            settled_generation: inner.settled_generation,
1901            bound_connections: inner.connections.len(),
1902            retained_decisions: inner.decisions.len(),
1903            index_entries: inner.index.values().map(BTreeSet::len).sum(),
1904            inflight_asks: inner.inflight_asks,
1905            counts: inner.counts,
1906            hot_allowed: self.hot.allowed.load(Ordering::Relaxed),
1907            hot_refused: self.hot.refused.load(Ordering::Relaxed),
1908            transitions: inner.transitions.iter().copied().collect(),
1909            transitions_dropped: inner.transitions_dropped,
1910        };
1911        drop(inner);
1912        Ok(snapshot)
1913    }
1914}
1915
1916/// Announces a readiness transition loudly, with its stable reason and the
1917/// cumulative refused-admission and recheck counts, and names a dropped
1918/// diagnostic transition.
1919fn announce_outcome(applied: Applied, transition: Option<Transition>, dropped: bool) {
1920    if let Some(transition) = transition {
1921        match transition.readiness {
1922            Readiness::Ready => tracing::info!(
1923                applied = ?transition.applied.map(Revision::get),
1924                rechecks = transition.rechecks,
1925                "revocation stream ready"
1926            ),
1927            Readiness::NotReady
1928            | Readiness::Gap
1929            | Readiness::Unavailable
1930            | Readiness::Reset
1931            | Readiness::Terminal => tracing::warn!(
1932                readiness = ?transition.readiness,
1933                reason = ?transition.reason.map(RevocationRefusal::name),
1934                applied = ?transition.applied.map(Revision::get),
1935                missing = ?transition.missing,
1936                refused_admissions = transition.refused_admissions,
1937                rechecks = transition.rechecks,
1938                "revocation stream lost readiness; new admissions are refused by name"
1939            ),
1940        }
1941    }
1942    if dropped {
1943        tracing::warn!("revocation diagnostic transition ring overflowed; the oldest was dropped");
1944    }
1945    match applied {
1946        Applied::Refused(reason) => {
1947            tracing::warn!(reason, "revocation stream event refused");
1948        }
1949        Applied::Terminal(reason) => {
1950            tracing::error!(reason, "revocation stream ended on a terminal error");
1951        }
1952        Applied::Baseline
1953        | Applied::Change
1954        | Applied::Duplicate
1955        | Applied::Watermark
1956        | Applied::Gap
1957        | Applied::Ready
1958        | Applied::Lost
1959        | Applied::Reset => {}
1960    }
1961}