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    log: GrantLogId,
1073    readiness: Readiness,
1074    applied: Option<Revision>,
1075    high_water: Option<Revision>,
1076    pending_resume: bool,
1077    resume_generation: u64,
1078    settled_generation: u64,
1079    decisions: BTreeMap<GrantId, Decision>,
1080    evidence: VecDeque<(Revision, Digest32)>,
1081    connections: BTreeMap<u64, Bound>,
1082    index: BTreeMap<GrantId, BTreeSet<u64>>,
1083    orphans: BTreeSet<GrantId>,
1084    inflight_asks: u64,
1085    counts: RevocationCounts,
1086    transitions: VecDeque<Transition>,
1087    transitions_dropped: u64,
1088    announce: Option<Transition>,
1089}
1090
1091const fn bump(counter: &mut u64) {
1092    *counter = counter.saturating_add(1);
1093}
1094
1095impl Inner {
1096    fn new(log: GrantLogId) -> Self {
1097        Self {
1098            log,
1099            readiness: Readiness::NotReady,
1100            applied: None,
1101            high_water: None,
1102            pending_resume: false,
1103            resume_generation: 0,
1104            settled_generation: 0,
1105            decisions: BTreeMap::new(),
1106            evidence: VecDeque::new(),
1107            connections: BTreeMap::new(),
1108            index: BTreeMap::new(),
1109            orphans: BTreeSet::new(),
1110            inflight_asks: 0,
1111            counts: RevocationCounts::default(),
1112            transitions: VecDeque::new(),
1113            transitions_dropped: 0,
1114            announce: None,
1115        }
1116    }
1117
1118    fn missing(&self) -> GapLength {
1119        if self.readiness == Readiness::Ready {
1120            return GapLength::None;
1121        }
1122        match (self.applied, self.high_water) {
1123            (Some(applied), Some(high)) => match missing_between(applied, high) {
1124                Some(missing) if missing > 0 => GapLength::Known(missing),
1125                _ => GapLength::None,
1126            },
1127            // Not ready and the producer's high water (or the cursor) is not
1128            // known: how much is missing is unknown, never zero.
1129            _ => GapLength::Unknown,
1130        }
1131    }
1132
1133    /// Enters `readiness`, recording one transition when it changes; the
1134    /// transition is announced after the guard is released.
1135    fn transition(&mut self, readiness: Readiness) {
1136        if self.readiness == readiness {
1137            return;
1138        }
1139        self.readiness = readiness;
1140        match readiness {
1141            Readiness::Gap => bump(&mut self.counts.gap_transitions),
1142            Readiness::Unavailable => bump(&mut self.counts.loss_transitions),
1143            Readiness::Reset => bump(&mut self.counts.reset_transitions),
1144            Readiness::Terminal => bump(&mut self.counts.terminal_errors),
1145            Readiness::NotReady | Readiness::Ready => {}
1146        }
1147        let transition = Transition {
1148            readiness,
1149            reason: readiness.refusal(),
1150            applied: self.applied,
1151            missing: self.missing(),
1152            refused_admissions: self.counts.admissions_refused,
1153            rechecks: self.counts.resume_rechecks,
1154        };
1155        if self.transitions.len() >= MAX_DIAGNOSTIC_EVENTS {
1156            self.transitions.pop_front();
1157            bump(&mut self.transitions_dropped);
1158        }
1159        self.transitions.push_back(transition);
1160        self.announce = Some(transition);
1161    }
1162
1163    fn precheck(&self, binding: &GrantDecisionBinding) -> Result<(), RevocationRefusal> {
1164        if let Some(refusal) = self.readiness.refusal() {
1165            return Err(refusal);
1166        }
1167        if binding.log != self.log {
1168            return Err(RevocationRefusal::LogReset);
1169        }
1170        if self
1171            .applied
1172            .is_none_or(|applied| applied < binding.required)
1173        {
1174            return Err(RevocationRefusal::RevisionUnavailable);
1175        }
1176        Ok(())
1177    }
1178
1179    fn missing_grants(&self, binding: &GrantDecisionBinding) -> Vec<GrantId> {
1180        let unknown: BTreeSet<&GrantId> = binding
1181            .grants()
1182            .filter(|grant| !self.decisions.contains_key(*grant))
1183            .collect();
1184        unknown.into_iter().cloned().collect()
1185    }
1186
1187    fn right_state(&self, right: &BoundRight) -> RightState {
1188        let mut unknown = false;
1189        for chain in &right.chains {
1190            let mut chain_state = RightState::Allowed;
1191            for grant in chain {
1192                match self.decisions.get(grant) {
1193                    Some(decision) if decision.revoked => {
1194                        chain_state = RightState::Lost;
1195                        break;
1196                    }
1197                    Some(_) => {}
1198                    None => chain_state = RightState::Unknown,
1199                }
1200            }
1201            match chain_state {
1202                RightState::Allowed => return RightState::Allowed,
1203                RightState::Unknown => unknown = true,
1204                RightState::Lost => {}
1205            }
1206        }
1207        if unknown {
1208            RightState::Unknown
1209        } else {
1210            RightState::Lost
1211        }
1212    }
1213
1214    /// Merges one decision, never letting an older or allowing answer
1215    /// overwrite a deny.
1216    fn merge_decision(&mut self, grant: &GrantId, revoked: bool, at: Revision, baseline: bool) {
1217        match self.decisions.get_mut(grant) {
1218            Some(existing) if existing.revoked => {
1219                if !revoked {
1220                    bump(&mut self.counts.stale_overwrites_prevented);
1221                }
1222                existing.baseline |= baseline;
1223            }
1224            Some(existing) if existing.at >= at && !revoked => {
1225                existing.baseline |= baseline;
1226            }
1227            Some(existing) => {
1228                existing.revoked = revoked;
1229                existing.at = at;
1230                existing.baseline |= baseline;
1231            }
1232            None => {
1233                self.decisions.insert(
1234                    grant.clone(),
1235                    Decision {
1236                        revoked,
1237                        at,
1238                        baseline,
1239                    },
1240                );
1241                if !baseline && !self.index.contains_key(grant) {
1242                    self.orphans.insert(grant.clone());
1243                }
1244            }
1245        }
1246    }
1247
1248    fn install_reply(
1249        &mut self,
1250        reply: &DecisionReply,
1251        at_least: Revision,
1252    ) -> Result<(), RevocationRefusal> {
1253        if reply.log != self.log {
1254            return Err(RevocationRefusal::LogReset);
1255        }
1256        if reply.revision < at_least || reply.decisions.len() > MAX_DECISIONS_PER_EVENT {
1257            bump(&mut self.counts.stale_replies_refused);
1258            return Err(RevocationRefusal::RevisionUnavailable);
1259        }
1260        for (grant, revoked) in &reply.decisions {
1261            bump(&mut self.counts.rows_read);
1262            self.merge_decision(grant, *revoked, reply.revision, false);
1263        }
1264        Ok(())
1265    }
1266
1267    /// The final fence and the binding, in one ordered transition.
1268    fn bind(
1269        &mut self,
1270        pid: u64,
1271        binding: &Arc<GrantDecisionBinding>,
1272        hot: &Arc<HotCounts>,
1273    ) -> Result<ConnectionGrants, RevocationRefusal> {
1274        self.precheck(binding)?;
1275        if self.connections.contains_key(&pid) {
1276            bump(&mut self.counts.binding_replacements_refused);
1277            tracing::warn!(
1278                connection_pid = pid,
1279                "a second grant-decision binding was offered; the first is kept"
1280            );
1281            return Err(RevocationRefusal::BindingMissing);
1282        }
1283        let mut lost = Vec::with_capacity(binding.rights.len());
1284        for (index, right) in binding.rights.iter().enumerate() {
1285            match (self.right_state(right), index == binding.admitting) {
1286                (RightState::Allowed, _) => lost.push(AtomicBool::new(false)),
1287                (RightState::Lost, true) => return Err(RevocationRefusal::GrantRevoked),
1288                (RightState::Lost, false) => lost.push(AtomicBool::new(true)),
1289                (RightState::Unknown, _) => return Err(RevocationRefusal::RevisionUnavailable),
1290            }
1291        }
1292        let live = Arc::new(LiveRights {
1293            lost: lost.into_boxed_slice(),
1294            close: AtomicU8::new(CLOSE_NONE),
1295            changes: AtomicU64::new(0),
1296        });
1297        for grant in binding.grants() {
1298            self.index.entry(grant.clone()).or_default().insert(pid);
1299            self.orphans.remove(grant);
1300        }
1301        self.connections.insert(
1302            pid,
1303            Bound {
1304                binding: Arc::clone(binding),
1305                live: Arc::clone(&live),
1306            },
1307        );
1308        bump(&mut self.counts.admissions_committed);
1309        Ok(ConnectionGrants {
1310            pid,
1311            binding: Arc::clone(binding),
1312            live,
1313            hot: Arc::clone(hot),
1314        })
1315    }
1316
1317    /// Re-evaluates one bound connection, withdrawing newly lost rights and
1318    /// closing it when its admitting right is lost.
1319    fn evaluate(&mut self, pid: u64, effects: &mut Vec<RevocationEffect>) {
1320        let Some(bound) = self.connections.get(&pid) else {
1321            return;
1322        };
1323        let binding = Arc::clone(&bound.binding);
1324        let live = Arc::clone(&bound.live);
1325        if live.close.load(Ordering::Acquire) != CLOSE_NONE {
1326            return;
1327        }
1328        if binding.log != self.log {
1329            self.close(pid, &live, RevocationRefusal::LogReset, effects);
1330            return;
1331        }
1332        for (index, (right, lost)) in binding.rights.iter().zip(live.lost.iter()).enumerate() {
1333            if lost.load(Ordering::Acquire) || self.right_state(right) == RightState::Allowed {
1334                continue;
1335            }
1336            // A miss is never a grant: an undecided chain counts as lost.
1337            lost.store(true, Ordering::Release);
1338            live.changes.fetch_add(1, Ordering::AcqRel);
1339            if index == binding.admitting {
1340                self.close(pid, &live, RevocationRefusal::GrantRevoked, effects);
1341                return;
1342            }
1343            bump(&mut self.counts.withdrawals);
1344            effects.push(RevocationEffect::Withdraw { pid, right: index });
1345        }
1346    }
1347
1348    fn close(
1349        &mut self,
1350        pid: u64,
1351        live: &LiveRights,
1352        reason: RevocationRefusal,
1353        effects: &mut Vec<RevocationEffect>,
1354    ) {
1355        live.close.store(close_code(reason), Ordering::Release);
1356        live.changes.fetch_add(1, Ordering::AcqRel);
1357        bump(&mut self.counts.closes_decided);
1358        effects.push(RevocationEffect::Close { pid, reason });
1359    }
1360
1361    fn remember_evidence(&mut self, revision: Revision, evidence: Digest32) {
1362        if self.evidence.len() >= EVIDENCE_RETAINED {
1363            self.evidence.pop_front();
1364        }
1365        self.evidence.push_back((revision, evidence));
1366    }
1367
1368    fn raise_high_water(&mut self, revision: Revision) {
1369        if self.high_water.is_none_or(|high| high < revision) {
1370            self.high_water = Some(revision);
1371        }
1372    }
1373
1374    fn apply_change(
1375        &mut self,
1376        revision: Revision,
1377        change: &GrantFeedChange,
1378        evidence: Digest32,
1379        effects: &mut Vec<RevocationEffect>,
1380    ) -> Applied {
1381        self.raise_high_water(revision);
1382        let Some(applied) = self.applied else {
1383            return Applied::Refused("change before baseline");
1384        };
1385        if revision <= applied {
1386            let conflicting = self
1387                .evidence
1388                .iter()
1389                .any(|(seen, digest)| *seen == revision && *digest != evidence);
1390            if conflicting {
1391                self.transition(Readiness::Terminal);
1392                return Applied::Terminal("conflicting_evidence");
1393            }
1394            bump(&mut self.counts.duplicate_revisions);
1395            return Applied::Duplicate;
1396        }
1397        if applied.next() != Some(revision) {
1398            self.pending_resume = true;
1399            self.transition(Readiness::Gap);
1400            return Applied::Gap;
1401        }
1402        self.applied = Some(revision);
1403        self.remember_evidence(revision, evidence);
1404        bump(&mut self.counts.applied_revisions);
1405        match change {
1406            GrantFeedChange::Issue(grant) => {
1407                if self.decisions.contains_key(grant) || self.index.contains_key(grant) {
1408                    self.merge_decision(grant, false, revision, false);
1409                }
1410            }
1411            GrantFeedChange::Revoke(grant) => {
1412                bump(&mut self.counts.observed_revocations);
1413                let referenced = self.index.contains_key(grant);
1414                if referenced || self.inflight_asks > 0 || self.decisions.contains_key(grant) {
1415                    // A tombstone, kept while a binding or an in-flight ask
1416                    // could still read this grant.
1417                    self.merge_decision(grant, true, revision, false);
1418                }
1419                let affected: Vec<u64> = self
1420                    .index
1421                    .get(grant)
1422                    .map(|pids| pids.iter().copied().collect())
1423                    .unwrap_or_default();
1424                for pid in affected {
1425                    bump(&mut self.counts.affected_visits);
1426                    self.evaluate(pid, effects);
1427                }
1428            }
1429        }
1430        Applied::Change
1431    }
1432
1433    fn apply_ready(&mut self, through: Revision, effects: &mut Vec<RevocationEffect>) -> Applied {
1434        if self.applied != Some(through) {
1435            return Applied::Refused("ready does not match the applied cursor");
1436        }
1437        self.raise_high_water(through);
1438        if self.readiness == Readiness::Ready && !self.pending_resume {
1439            return Applied::Duplicate;
1440        }
1441        self.resume_generation = self.resume_generation.saturating_add(1);
1442        let open: Vec<u64> = self.connections.keys().copied().collect();
1443        for pid in open {
1444            bump(&mut self.counts.resume_rechecks);
1445            self.evaluate(pid, effects);
1446        }
1447        self.pending_resume = false;
1448        self.settled_generation = self.resume_generation;
1449        self.transition(Readiness::Ready);
1450        Applied::Ready
1451    }
1452
1453    fn apply(&mut self, event: &GrantFeedEvent, effects: &mut Vec<RevocationEffect>) -> Applied {
1454        if self.readiness == Readiness::Terminal {
1455            return Applied::Refused("stream is terminal");
1456        }
1457        match event {
1458            GrantFeedEvent::Baseline {
1459                log,
1460                through,
1461                decisions,
1462            } => self.apply_baseline(log, *through, decisions),
1463            GrantFeedEvent::Change {
1464                log,
1465                revision,
1466                change,
1467                evidence,
1468            } => {
1469                if *log != self.log {
1470                    return Applied::Refused("change from another log");
1471                }
1472                self.apply_change(*revision, change, *evidence, effects)
1473            }
1474            GrantFeedEvent::Watermark { log, through } => {
1475                if *log != self.log {
1476                    return Applied::Refused("watermark from another log");
1477                }
1478                self.raise_high_water(*through);
1479                match self.applied {
1480                    None => Applied::Refused("watermark before baseline"),
1481                    Some(applied) if *through <= applied => {
1482                        bump(&mut self.counts.duplicate_revisions);
1483                        Applied::Duplicate
1484                    }
1485                    Some(_) => {
1486                        self.applied = Some(*through);
1487                        bump(&mut self.counts.watermark_advances);
1488                        Applied::Watermark
1489                    }
1490                }
1491            }
1492            GrantFeedEvent::Ready { log, through } => {
1493                if *log != self.log {
1494                    return Applied::Refused("ready from another log");
1495                }
1496                self.apply_ready(*through, effects)
1497            }
1498            GrantFeedEvent::Lost => {
1499                self.high_water = None;
1500                self.pending_resume = true;
1501                self.transition(Readiness::Unavailable);
1502                Applied::Lost
1503            }
1504            GrantFeedEvent::Reset { from, to } => self.apply_reset(from, to),
1505            GrantFeedEvent::Invalid { reason } => {
1506                self.transition(Readiness::Terminal);
1507                Applied::Terminal(reason)
1508            }
1509        }
1510    }
1511
1512    fn apply_baseline(
1513        &mut self,
1514        log: &GrantLogId,
1515        through: Revision,
1516        decisions: &[(GrantId, bool)],
1517    ) -> Applied {
1518        if *log != self.log {
1519            return Applied::Refused("baseline from another log");
1520        }
1521        if decisions.len() > MAX_DECISIONS_PER_EVENT {
1522            return Applied::Refused("baseline over its bound");
1523        }
1524        if self.applied.is_some_and(|applied| through < applied) {
1525            return Applied::Refused("baseline older than the applied cursor");
1526        }
1527        for (grant, revoked) in decisions {
1528            bump(&mut self.counts.rows_read);
1529            self.merge_decision(grant, *revoked, through, true);
1530        }
1531        self.applied = Some(through);
1532        self.raise_high_water(through);
1533        Applied::Baseline
1534    }
1535
1536    fn apply_reset(&mut self, from: &GrantLogId, to: &GrantLogId) -> Applied {
1537        if *from != self.log {
1538            return Applied::Refused("reset from a log not consumed here");
1539        }
1540        self.log = to.clone();
1541        // Revisions of the old log never compare with the new one's. Applied
1542        // denials stay on each connection's own rights and never revert.
1543        self.decisions.clear();
1544        self.evidence.clear();
1545        self.orphans.clear();
1546        self.applied = None;
1547        self.high_water = None;
1548        self.pending_resume = true;
1549        self.transition(Readiness::Reset);
1550        Applied::Reset
1551    }
1552
1553    fn prune_orphans(&mut self) {
1554        if self.inflight_asks > 0 {
1555            return;
1556        }
1557        for grant in std::mem::take(&mut self.orphans) {
1558            if self.index.contains_key(&grant) {
1559                continue;
1560            }
1561            if self
1562                .decisions
1563                .get(&grant)
1564                .is_some_and(|decision| !decision.baseline)
1565            {
1566                self.decisions.remove(&grant);
1567                bump(&mut self.counts.decisions_pruned);
1568            }
1569        }
1570    }
1571
1572    fn finish_ask(&mut self) {
1573        self.inflight_asks = self.inflight_asks.saturating_sub(1);
1574        self.prune_orphans();
1575    }
1576
1577    fn release(&mut self, pid: u64) -> bool {
1578        let Some(bound) = self.connections.remove(&pid) else {
1579            return false;
1580        };
1581        for grant in bound.binding.grants() {
1582            if let Some(pids) = self.index.get_mut(grant) {
1583                pids.remove(&pid);
1584                if pids.is_empty() {
1585                    self.index.remove(grant);
1586                    self.orphans.insert(grant.clone());
1587                }
1588            }
1589        }
1590        bump(&mut self.counts.cleanups);
1591        self.prune_orphans();
1592        true
1593    }
1594}
1595
1596/// A verified binding whose cold decisions are acquired, waiting for the
1597/// fence. While it lives it holds an in-flight ask, so no deny it could read
1598/// is pruned; dropping it uncommitted releases that.
1599pub struct StagedAdmission {
1600    binding: Arc<GrantDecisionBinding>,
1601    inner: Arc<Mutex<Inner>>,
1602    registered: bool,
1603}
1604
1605impl fmt::Debug for StagedAdmission {
1606    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1607        formatter
1608            .debug_struct("StagedAdmission")
1609            .field("binding", &self.binding)
1610            .finish_non_exhaustive()
1611    }
1612}
1613
1614impl Drop for StagedAdmission {
1615    fn drop(&mut self) {
1616        if self.registered
1617            && let Ok(mut inner) = self.inner.lock()
1618        {
1619            inner.finish_ask();
1620        }
1621    }
1622}
1623
1624/// One grant log's revocation consumer, decision cache and admission fence
1625/// (ACCESS-005 R1-R4, R6, R7).
1626pub struct RevocationAuthority {
1627    policy: RevocationPolicy,
1628    bindings: Arc<dyn GrantBindingSource>,
1629    decisions: Arc<dyn GrantDecisionSource>,
1630    inner: Arc<Mutex<Inner>>,
1631    hot: Arc<HotCounts>,
1632}
1633
1634impl fmt::Debug for RevocationAuthority {
1635    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1636        formatter
1637            .debug_struct("RevocationAuthority")
1638            .field("log", &self.policy.log)
1639            .finish_non_exhaustive()
1640    }
1641}
1642
1643impl RevocationAuthority {
1644    /// A consumer for `policy`'s log, `NotReady` until its stream's baseline
1645    /// and `Ready` arrive, with the producer and decision seams given.
1646    #[must_use]
1647    pub fn new(
1648        policy: RevocationPolicy,
1649        bindings: Arc<dyn GrantBindingSource>,
1650        decisions: Arc<dyn GrantDecisionSource>,
1651    ) -> Self {
1652        let inner = Arc::new(Mutex::new(Inner::new(policy.log.clone())));
1653        Self {
1654            policy,
1655            bindings,
1656            decisions,
1657            inner,
1658            hot: Arc::new(HotCounts::default()),
1659        }
1660    }
1661
1662    /// The authority with the seams Lys publishes today: neither, so every
1663    /// admission is refused by name.
1664    #[must_use]
1665    pub fn unpublished(policy: RevocationPolicy) -> Self {
1666        Self::new(
1667            policy,
1668            Arc::new(UnpublishedGrantBinding),
1669            Arc::new(UnpublishedGrantDecisions),
1670        )
1671    }
1672
1673    fn lock(&self) -> Result<MutexGuard<'_, Inner>, RevocationRefusal> {
1674        self.inner
1675            .lock()
1676            .map_err(|_| RevocationRefusal::StreamUnavailable)
1677    }
1678
1679    fn refused(&self, refusal: RevocationRefusal) -> RevocationRefusal {
1680        if let Ok(mut inner) = self.inner.lock() {
1681            bump(&mut inner.counts.admissions_refused);
1682        }
1683        refusal
1684    }
1685
1686    /// Verifies `credential`'s binding for `holder` and acquires any decision
1687    /// the cache lacks, outside the guard. Nothing is admitted yet.
1688    ///
1689    /// # Errors
1690    /// The refusal by name: the binding is missing or does not match, the
1691    /// stream is not ready, the log was reset, or the required revision or a
1692    /// needed decision is unavailable.
1693    pub fn stage(
1694        &self,
1695        credential: &[u8],
1696        holder: &[u8],
1697    ) -> Result<StagedAdmission, RevocationRefusal> {
1698        let binding = verify_binding(&self.policy, self.bindings.as_ref(), credential, holder)
1699            .map_err(|refusal| self.refused(refusal))?;
1700        let binding = Arc::new(binding);
1701        let (needed, at_least) = {
1702            let mut inner = self.lock()?;
1703            if let Err(refusal) = inner.precheck(&binding) {
1704                bump(&mut inner.counts.admissions_refused);
1705                return Err(refusal);
1706            }
1707            inner.inflight_asks = inner.inflight_asks.saturating_add(1);
1708            let needed = inner.missing_grants(&binding);
1709            let at_least = inner.applied;
1710            drop(inner);
1711            (needed, at_least)
1712        };
1713        let staged = StagedAdmission {
1714            binding,
1715            inner: Arc::clone(&self.inner),
1716            registered: true,
1717        };
1718        if needed.is_empty() {
1719            return Ok(staged);
1720        }
1721        let at_least =
1722            at_least.ok_or_else(|| self.refused(RevocationRefusal::RevisionUnavailable))?;
1723        let reply = self
1724            .decisions
1725            .decide(&staged.binding.log, &needed, at_least);
1726        let installed = {
1727            let mut inner = self.lock()?;
1728            bump(&mut inner.counts.decision_asks);
1729            let installed = reply.and_then(|reply| inner.install_reply(&reply, at_least));
1730            if installed.is_err() {
1731                bump(&mut inner.counts.admissions_refused);
1732            }
1733            drop(inner);
1734            installed
1735        };
1736        installed.map(|()| staged)
1737    }
1738
1739    /// The fence (ACCESS-005 R3): readiness, log identity, the required
1740    /// revision and the admitting right, checked and bound into the reverse
1741    /// index in one ordered transition with change application. A revocation
1742    /// applied between [`Self::stage`] and this call wins.
1743    ///
1744    /// # Errors
1745    /// The refusal by name; nothing is bound on any refusal.
1746    pub fn commit(
1747        &self,
1748        pid: u64,
1749        mut staged: StagedAdmission,
1750    ) -> Result<ConnectionGrants, RevocationRefusal> {
1751        if !Arc::ptr_eq(&staged.inner, &self.inner) {
1752            return Err(self.refused(RevocationRefusal::BindingMissing));
1753        }
1754        staged.registered = false;
1755        let mut inner = self.lock()?;
1756        let bound = inner.bind(pid, &staged.binding, &self.hot);
1757        if bound.is_err() {
1758            bump(&mut inner.counts.admissions_refused);
1759        }
1760        inner.finish_ask();
1761        drop(inner);
1762        bound
1763    }
1764
1765    /// [`Self::stage`] then [`Self::commit`].
1766    ///
1767    /// # Errors
1768    /// As either step.
1769    pub fn admit(
1770        &self,
1771        pid: u64,
1772        credential: &[u8],
1773        holder: &[u8],
1774    ) -> Result<ConnectionGrants, RevocationRefusal> {
1775        let staged = self.stage(credential, holder)?;
1776        self.commit(pid, staged)
1777    }
1778
1779    /// Applies one stream event in order (ACCESS-005 R2, R4). Announcements
1780    /// are logged after the guard is released.
1781    #[must_use]
1782    pub fn apply(&self, event: &GrantFeedEvent) -> ApplyOutcome {
1783        let mut effects = Vec::new();
1784        let Ok(mut inner) = self.inner.lock() else {
1785            return ApplyOutcome {
1786                applied: Applied::Terminal("revocation authority lock poisoned"),
1787                effects,
1788            };
1789        };
1790        let before = inner.transitions_dropped;
1791        inner.announce = None;
1792        let applied = inner.apply(event, &mut effects);
1793        if matches!(applied, Applied::Refused(_)) {
1794            bump(&mut inner.counts.refused_events);
1795        }
1796        let announce = inner.announce.take();
1797        let dropped = inner.transitions_dropped > before;
1798        drop(inner);
1799        announce_outcome(applied, announce, dropped);
1800        ApplyOutcome { applied, effects }
1801    }
1802
1803    /// Releases connection `pid` from the reverse index, once, from the
1804    /// connection runtime's removal funnel (ACCESS-005 R6). Returns whether a
1805    /// binding was released; a second call releases nothing.
1806    #[must_use]
1807    pub fn release(&self, pid: u64) -> bool {
1808        let Ok(mut inner) = self.inner.lock() else {
1809            tracing::error!(
1810                pid,
1811                "revocation authority lock poisoned; the binding is not released"
1812            );
1813            return false;
1814        };
1815        inner.release(pid)
1816    }
1817
1818    /// Records that the close control for `pid` could not be published. The
1819    /// denied state already gates the connection; its own wake delivers the
1820    /// close.
1821    pub fn record_close_publication_refused(&self, pid: u64) {
1822        if let Ok(mut inner) = self.inner.lock() {
1823            bump(&mut inner.counts.close_publication_refused);
1824        }
1825        tracing::warn!(
1826            connection_pid = pid,
1827            "revocation close control could not be published; the connection's wake delivers it"
1828        );
1829    }
1830
1831    /// Records how connection `pid`'s reason-bearing close write ended.
1832    /// Attempted is not delivered.
1833    pub fn record_close_write(&self, pid: u64, write: CloseWrite) {
1834        if let Ok(mut inner) = self.inner.lock() {
1835            bump(&mut inner.counts.closes_attempted);
1836            match write {
1837                CloseWrite::Flushed => bump(&mut inner.counts.closes_flushed),
1838                CloseWrite::TransportFailed | CloseWrite::NoStream => {
1839                    bump(&mut inner.counts.close_transport_failures);
1840                }
1841                CloseWrite::Residue | CloseWrite::NotQueued => {}
1842            }
1843        }
1844        if matches!(write, CloseWrite::TransportFailed | CloseWrite::NoStream) {
1845            tracing::warn!(
1846                connection_pid = pid,
1847                write = ?write,
1848                "revocation close could not be written; the denied state is kept and cleanup finishes"
1849            );
1850        }
1851    }
1852
1853    /// Drops every decision no binding and no in-flight ask needs. A later
1854    /// need is a miss, answered by a cold ask, never an implicit allow.
1855    pub fn prune_unreferenced(&self) {
1856        let Ok(mut inner) = self.inner.lock() else {
1857            return;
1858        };
1859        if inner.inflight_asks > 0 {
1860            return;
1861        }
1862        let unreferenced: Vec<GrantId> = inner
1863            .decisions
1864            .keys()
1865            .filter(|grant| !inner.index.contains_key(*grant))
1866            .cloned()
1867            .collect();
1868        for grant in unreferenced {
1869            inner.decisions.remove(&grant);
1870            bump(&mut inner.counts.decisions_pruned);
1871        }
1872        inner.orphans.clear();
1873    }
1874
1875    /// A bounded snapshot for diagnostics (ACCESS-005 R7).
1876    ///
1877    /// # Errors
1878    /// [`AuthorityPoisoned`] when a holder of the authority's lock panicked:
1879    /// the state is untrusted and no snapshot of it is answered.
1880    pub fn diagnostics(&self) -> Result<RevocationDiagnostics, AuthorityPoisoned> {
1881        let inner = self.inner.lock().map_err(|_| AuthorityPoisoned)?;
1882        let snapshot = RevocationDiagnostics {
1883            log: inner.log.clone(),
1884            readiness: inner.readiness,
1885            reason: inner.readiness.refusal(),
1886            applied: inner.applied,
1887            high_water: inner.high_water,
1888            missing: inner.missing(),
1889            resume_generation: inner.resume_generation,
1890            settled_generation: inner.settled_generation,
1891            bound_connections: inner.connections.len(),
1892            retained_decisions: inner.decisions.len(),
1893            index_entries: inner.index.values().map(BTreeSet::len).sum(),
1894            inflight_asks: inner.inflight_asks,
1895            counts: inner.counts,
1896            hot_allowed: self.hot.allowed.load(Ordering::Relaxed),
1897            hot_refused: self.hot.refused.load(Ordering::Relaxed),
1898            transitions: inner.transitions.iter().copied().collect(),
1899            transitions_dropped: inner.transitions_dropped,
1900        };
1901        drop(inner);
1902        Ok(snapshot)
1903    }
1904}
1905
1906/// Announces a readiness transition loudly, with its stable reason and the
1907/// cumulative refused-admission and recheck counts, and names a dropped
1908/// diagnostic transition.
1909fn announce_outcome(applied: Applied, transition: Option<Transition>, dropped: bool) {
1910    if let Some(transition) = transition {
1911        match transition.readiness {
1912            Readiness::Ready => tracing::info!(
1913                applied = ?transition.applied.map(Revision::get),
1914                rechecks = transition.rechecks,
1915                "revocation stream ready"
1916            ),
1917            Readiness::NotReady
1918            | Readiness::Gap
1919            | Readiness::Unavailable
1920            | Readiness::Reset
1921            | Readiness::Terminal => tracing::warn!(
1922                readiness = ?transition.readiness,
1923                reason = ?transition.reason.map(RevocationRefusal::name),
1924                applied = ?transition.applied.map(Revision::get),
1925                missing = ?transition.missing,
1926                refused_admissions = transition.refused_admissions,
1927                rechecks = transition.rechecks,
1928                "revocation stream lost readiness; new admissions are refused by name"
1929            ),
1930        }
1931    }
1932    if dropped {
1933        tracing::warn!("revocation diagnostic transition ring overflowed; the oldest was dropped");
1934    }
1935    match applied {
1936        Applied::Refused(reason) => {
1937            tracing::warn!(reason, "revocation stream event refused");
1938        }
1939        Applied::Terminal(reason) => {
1940            tracing::error!(reason, "revocation stream ended on a terminal error");
1941        }
1942        Applied::Baseline
1943        | Applied::Change
1944        | Applied::Duplicate
1945        | Applied::Watermark
1946        | Applied::Gap
1947        | Applied::Ready
1948        | Applied::Lost
1949        | Applied::Reset => {}
1950    }
1951}