Skip to main content

liminal_server/
auth_membership.rs

1//! Channel membership bound to the verified holder and the placed resource
2//! (ACCESS-006).
3//!
4//! A membership is Lys's answer to one question asked through the one shared
5//! contract, `lys_pass::membership`: may this verified subject read, or post
6//! on, this placed channel resource within this workspace, at least at this
7//! grant revision. It is never a liminal roster, a guess from a subject
8//! string, a readable parent taken as membership of a child, or a consequence
9//! of signing in.
10//!
11//! - **The mapping is explicit** ([`crate::config::types::MembershipConfig`]):
12//!   a channel is membership-protected only when the operator's product
13//!   mapping lists it with its workspace and placed resource; a participant
14//!   conversation is placed under a channel only when the mapping lists it.
15//!   Every other channel keeps its prior contract, byte for byte.
16//! - **The subject is the verified holder** ([`MembershipSubject`]): built only
17//!   from the admitted Lys session, with the kind the signed pass states, so an
18//!   AI is asked about as its own agent identity and never as its responsible
19//!   person. A connection that proved no Lys holder has no membership at all.
20//! - **The provider is a named seam** ([`MembershipProvider`]); the live one is
21//!   [`HttpMembershipProvider`] over Lys's `POST /grants/membership` routes.
22//!   Nothing here answers a membership question itself.
23//! - **The authority is the ACCESS-005 grant owner** ([`MembershipAuthority`]):
24//!   a protected act proceeds only while it states `Ready` on the configured
25//!   grant log. `NotReady`, `Unavailable`, `Gap` and `Reset` refuse every
26//!   protected act and hold every protected delivery with that cause; no last
27//!   applied allow is a fallback (stricter than ACCESS-005 R2's legacy
28//!   continuation, and only for protected channels). The owner drives ordered
29//!   changes and resumes into the gate through [`MembershipListener`].
30//! - **Hot checks are local** ([`MembershipGate::check`]): a decision is asked
31//!   once per (holder, channel, action) and retained in a bounded index at the
32//!   authority's generation; later acts and delivery releases read it under a
33//!   short lock and never ask Lys. A change re-decides only the bindings its
34//!   resource reaches (a reverse index), a resume re-decides every retained
35//!   binding once, and a binding is released when the last connection holding
36//!   it closes.
37//! - **Pages are bounded** ([`MembershipGate::resource_page`],
38//!   [`MembershipGate::recipient_page`]): both bounds are the operator's,
39//!   every row is checked against the question, and a continuation is bound
40//!   to its exact question and generation.
41
42use std::collections::{BTreeMap, BTreeSet};
43use std::sync::atomic::{AtomicU64, Ordering};
44use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
45
46use lys_pass::membership::{
47    CONTRACT_UNSUPPORTED, CONTRACT_VERSION, GrantLog, LOG_MISMATCH, MembershipDecision,
48    MembershipRequest, Subject, Verdict,
49};
50use lys_pass::membership_admission::{AdmissionDecision, AdmissionRequest};
51use lys_pass::membership_pages::{
52    CURSOR_FOREIGN, CURSOR_RESET, MembershipPage, PageBounds, PageOutcome, PageRow,
53    RecipientPageRequest, ResourcePageRequest,
54};
55use lys_pass::rights::{Mode, Resource};
56
57use crate::ServerError;
58use crate::auth_lys::HotAction;
59use crate::auth_lys::session::LysSession;
60use crate::config::types::MembershipConfig;
61
62/// The Lys route answering one membership decision.
63pub const DECISION_ROUTE: &str = "grants/membership";
64/// The Lys route answering scoped guest admission.
65pub const ADMISSION_ROUTE: &str = "grants/membership/admission";
66/// The Lys route answering a page of a subject's permitted resources.
67pub const RESOURCES_ROUTE: &str = "grants/membership/resources";
68/// The Lys route answering a page of a channel's permitted recipients.
69pub const RECIPIENTS_ROUTE: &str = "grants/membership/recipients";
70
71/// The two membership actions. Read and post are separate: an answer about
72/// one says nothing of the other (ACCESS-006 R1).
73#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
74pub enum MembershipAction {
75    /// Reading a channel: a subscription, a delivered reply, result or
76    /// request.
77    Read,
78    /// Posting on a channel: a publish or an outbound service effect.
79    Post,
80}
81
82impl MembershipAction {
83    /// The contract's action word.
84    #[must_use]
85    pub const fn as_str(self) -> &'static str {
86        match self {
87            Self::Read => "read",
88            Self::Post => "post",
89        }
90    }
91
92    /// The membership action a hot route needs: a publish and a call post,
93    /// a subscription and a served request read. A call or serve still needs
94    /// its own operation grant beside the membership; membership never
95    /// implies it.
96    #[must_use]
97    pub const fn for_hot(action: HotAction) -> Self {
98        match action {
99            HotAction::Publish | HotAction::Call => Self::Post,
100            HotAction::Subscribe | HotAction::Serve => Self::Read,
101        }
102    }
103}
104
105/// Why a protected act, delivery or page was refused.
106///
107/// Every variant has a
108/// stable [`Self::name`]; none carries a payload, a credential, a resource
109/// id or a channel name, so a refusal can cross any boundary whole.
110#[derive(Clone, Debug, PartialEq, Eq)]
111pub enum MembershipRefusal {
112    /// The connection proved no Lys holder, so it has no membership.
113    HolderUnverified,
114    /// The admitted pass did not name its holder's directory kind.
115    HolderKindUnknown,
116    /// The verified holder's id or kind is empty or carries control
117    /// characters.
118    HolderMalformed,
119    /// No ACCESS-005 grant authority is installed, so no membership is
120    /// current.
121    AuthorityMissing,
122    /// The grant authority has not established its verified baseline.
123    NotReady,
124    /// The grant authority or the provider could not be asked, by cause.
125    Unavailable {
126        /// The stable cause, naming no response body.
127        cause: String,
128    },
129    /// The grant authority's change feed has an uncovered interval.
130    Gap {
131        /// The last revision applied before the gap.
132        after: u64,
133        /// How many revisions are missing.
134        missing: u64,
135    },
136    /// The grant log was reset; its revisions no longer compare.
137    Reset,
138    /// The authority moved to a generation this index has not yet resumed.
139    ResumePending,
140    /// A change or a resume is re-deciding this binding.
141    ChangeApplying,
142    /// The answer or the authority names another grant log or epoch.
143    LogMismatch,
144    /// The provider answered under a contract this liminal does not speak.
145    ContractUnsupported {
146        /// The contract version the provider named.
147        contract: u32,
148    },
149    /// The answer echoes a request other than the one asked: a substituted
150    /// subject, resource, workspace or action.
151    AnswerSubstituted,
152    /// The answer reflects an older revision than the one required.
153    RevisionStale {
154        /// The least revision required.
155        required: u64,
156        /// The revision decided at, if any.
157        decided: Option<u64>,
158    },
159    /// The allowing scope is neither the mapped channel nor its workspace.
160    ScopeOutsideMapping,
161    /// Lys refused the membership by its stable name.
162    Refused {
163        /// The provider's stable refusal name.
164        refusal: String,
165    },
166    /// A grant reaches the act, but only by approval.
167    Held {
168        /// The approval the grant requires.
169        mode: Mode,
170    },
171    /// The index is at its configured bound; nothing was allocated.
172    CapacityExceeded {
173        /// The configured bound.
174        limit: usize,
175    },
176    /// The index lock was poisoned by a panic elsewhere.
177    IndexPoisoned,
178    /// The workspace asked about is not in the product mapping.
179    WorkspaceNotMapped,
180    /// The channel asked about is not in the product mapping.
181    ChannelNotMapped,
182    /// The continuation was issued for another question.
183    CursorForeign,
184    /// The continuation was issued at another generation or log epoch.
185    CursorStale,
186    /// A page exceeded its row or byte bound.
187    PageOverBound {
188        /// The rows the page carried.
189        rows: u64,
190        /// The encoded bytes the rows carried.
191        bytes: u64,
192    },
193    /// A page row answers another subject, kind, resource or workspace.
194    PageForeignRow,
195    /// A page listed one row twice.
196    PageDuplicateRow,
197    /// A page's counts, completion and continuation disagree.
198    PageInconsistent,
199}
200
201impl MembershipRefusal {
202    /// Whether this refusal holds rather than denies (ACCESS-006 R2): the
203    /// authority or the index is not current, so a queued protected delivery
204    /// waits, releasing nothing, rather than being retired. Every other
205    /// refusal is a decided deny.
206    #[must_use]
207    pub const fn holds(&self) -> bool {
208        matches!(
209            self,
210            Self::AuthorityMissing
211                | Self::NotReady
212                | Self::Unavailable { .. }
213                | Self::Gap { .. }
214                | Self::Reset
215                | Self::ResumePending
216                | Self::ChangeApplying
217                | Self::IndexPoisoned
218        )
219    }
220
221    /// The stable machine name.
222    #[must_use]
223    pub fn name(&self) -> &str {
224        match self {
225            Self::HolderUnverified => "membership_holder_unverified",
226            Self::HolderKindUnknown => "membership_holder_kind_unknown",
227            Self::HolderMalformed => "membership_holder_malformed",
228            Self::AuthorityMissing => "membership_authority_missing",
229            Self::NotReady => "membership_not_ready",
230            Self::Unavailable { .. } => "membership_unavailable",
231            Self::Gap { .. } => "membership_gap",
232            Self::Reset => "membership_log_reset",
233            Self::ResumePending => "membership_resume_pending",
234            Self::ChangeApplying => "membership_change_applying",
235            Self::LogMismatch => LOG_MISMATCH,
236            Self::ContractUnsupported { .. } => CONTRACT_UNSUPPORTED,
237            Self::AnswerSubstituted => "membership_answer_substituted",
238            Self::RevisionStale { .. } => "membership_revision_stale",
239            Self::ScopeOutsideMapping => "membership_scope_outside_mapping",
240            Self::Refused { refusal } => refusal,
241            Self::Held { .. } => "membership_held",
242            Self::CapacityExceeded { .. } => "membership_capacity_exceeded",
243            Self::IndexPoisoned => "membership_index_poisoned",
244            Self::WorkspaceNotMapped => "membership_workspace_not_mapped",
245            Self::ChannelNotMapped => "membership_channel_not_mapped",
246            Self::CursorForeign => CURSOR_FOREIGN,
247            Self::CursorStale => "membership_cursor_stale",
248            Self::PageOverBound { .. } => "membership_page_over_bound",
249            Self::PageForeignRow => "membership_page_foreign_row",
250            Self::PageDuplicateRow => "membership_page_duplicate_row",
251            Self::PageInconsistent => "membership_page_inconsistent",
252        }
253    }
254}
255
256impl std::fmt::Display for MembershipRefusal {
257    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        match self {
259            Self::Unavailable { cause } => write!(formatter, "{}: {cause}", self.name()),
260            Self::Gap { after, missing } => write!(
261                formatter,
262                "{}: {missing} revisions missing after {after}",
263                self.name()
264            ),
265            Self::ContractUnsupported { contract } => write!(
266                formatter,
267                "{}: contract {contract}; this liminal speaks {CONTRACT_VERSION}",
268                self.name()
269            ),
270            Self::RevisionStale { required, decided } => write!(
271                formatter,
272                "{}: required {required}, decided {decided:?}",
273                self.name()
274            ),
275            Self::Held { mode } => write!(formatter, "{}: {mode:?}", self.name()),
276            Self::CapacityExceeded { limit } => {
277                write!(formatter, "{}: limit {limit}", self.name())
278            }
279            Self::PageOverBound { rows, bytes } => {
280                write!(formatter, "{}: {rows} rows, {bytes} bytes", self.name())
281            }
282            _ => formatter.write_str(self.name()),
283        }
284    }
285}
286
287impl std::error::Error for MembershipRefusal {}
288
289fn malformed(value: &str) -> bool {
290    value.is_empty() || value.chars().any(char::is_control)
291}
292
293fn invalid(message: impl Into<String>) -> ServerError {
294    ServerError::ConfigValidation {
295        message: message.into(),
296    }
297}
298
299fn bump(counter: &AtomicU64) {
300    counter.fetch_add(1, Ordering::Relaxed);
301}
302
303fn bump_by(counter: &AtomicU64, count: u64) {
304    counter.fetch_add(count, Ordering::Relaxed);
305}
306
307/// The verified subject a membership is asked about: the holder the one
308/// `Connect` verification proved, with the directory kind its signed pass
309/// states. Only [`Self::from_session`] makes one.
310#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
311pub struct MembershipSubject {
312    id: String,
313    kind: String,
314}
315
316impl MembershipSubject {
317    /// The subject an admitted Lys session proved.
318    ///
319    /// # Errors
320    /// [`MembershipRefusal::HolderKindUnknown`] when the session cannot name
321    /// its holder's kind; [`MembershipRefusal::HolderMalformed`] when the id or
322    /// kind is empty or carries control characters.
323    pub fn from_session(session: &dyn LysSession) -> Result<Self, MembershipRefusal> {
324        let kind = session
325            .holder_kind()
326            .ok_or(MembershipRefusal::HolderKindUnknown)?;
327        let id = session.holder();
328        if malformed(id) || malformed(kind) {
329            return Err(MembershipRefusal::HolderMalformed);
330        }
331        Ok(Self {
332            id: id.to_owned(),
333            kind: kind.to_owned(),
334        })
335    }
336
337    /// The directory kind, as the signed pass states it.
338    #[must_use]
339    pub fn kind(&self) -> &str {
340        &self.kind
341    }
342
343    /// The contract's subject.
344    #[must_use]
345    pub fn to_contract(&self) -> Subject {
346        Subject {
347            id: self.id.clone(),
348            kind: self.kind.clone(),
349        }
350    }
351
352    fn is(&self, subject: &Subject) -> bool {
353        self.id == subject.id && self.kind == subject.kind
354    }
355}
356
357impl std::fmt::Debug for MembershipSubject {
358    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        formatter
360            .debug_struct("MembershipSubject")
361            .field("id", &crate::auth_pass::ByteLength(self.id.len()))
362            .field("kind", &self.kind)
363            .finish()
364    }
365}
366
367/// A protected channel's placement as the product mapping states it.
368#[derive(Clone, Debug, PartialEq, Eq)]
369pub struct MappedChannel {
370    workspace: Resource,
371    resource: Resource,
372}
373
374impl MappedChannel {
375    /// The workspace the channel is placed under.
376    #[must_use]
377    pub const fn workspace(&self) -> &Resource {
378        &self.workspace
379    }
380
381    /// The placed channel resource.
382    #[must_use]
383    pub const fn resource(&self) -> &Resource {
384        &self.resource
385    }
386}
387
388/// The validated product mapping.
389#[derive(Debug)]
390struct ChannelMap {
391    channels: BTreeMap<String, MappedChannel>,
392    conversations: BTreeMap<u64, String>,
393    by_resource: BTreeMap<String, String>,
394    workspaces: BTreeSet<String>,
395}
396
397fn channel_map(config: &MembershipConfig) -> Result<ChannelMap, ServerError> {
398    if config.channels.is_empty() {
399        return Err(invalid(
400            "auth.lys.membership.channels: at least one protected channel is required",
401        ));
402    }
403    let mut channels = BTreeMap::new();
404    let mut by_resource = BTreeMap::new();
405    let mut workspaces = BTreeSet::new();
406    for entry in &config.channels {
407        for (member, value) in [
408            ("channel", entry.channel.as_str()),
409            ("workspace", entry.workspace.as_str()),
410            ("resource", entry.resource.as_str()),
411        ] {
412            if malformed(value) {
413                return Err(invalid(format!(
414                    "auth.lys.membership.channels: a {member} is empty or contains control characters"
415                )));
416            }
417        }
418        let mapped = MappedChannel {
419            workspace: Resource {
420                kind: config.workspace_kind.clone(),
421                id: entry.workspace.clone(),
422            },
423            resource: Resource {
424                kind: config.channel_kind.clone(),
425                id: entry.resource.clone(),
426            },
427        };
428        if channels.insert(entry.channel.clone(), mapped).is_some() {
429            return Err(invalid(
430                "auth.lys.membership.channels: a channel is listed twice",
431            ));
432        }
433        if by_resource
434            .insert(entry.resource.clone(), entry.channel.clone())
435            .is_some()
436        {
437            return Err(invalid(
438                "auth.lys.membership.channels: a placed resource is mapped to two channels",
439            ));
440        }
441        workspaces.insert(entry.workspace.clone());
442    }
443    let mut conversations = BTreeMap::new();
444    for entry in &config.conversations {
445        if !channels.contains_key(&entry.channel) {
446            return Err(invalid(
447                "auth.lys.membership.conversations: a conversation names an unlisted channel",
448            ));
449        }
450        if conversations
451            .insert(entry.conversation, entry.channel.clone())
452            .is_some()
453        {
454            return Err(invalid(
455                "auth.lys.membership.conversations: a conversation is listed twice",
456            ));
457        }
458    }
459    Ok(ChannelMap {
460        channels,
461        conversations,
462        by_resource,
463        workspaces,
464    })
465}
466
467fn validated_bounds(config: &MembershipConfig) -> Result<PageBounds, ServerError> {
468    if config.contract != CONTRACT_VERSION {
469        return Err(invalid(format!(
470            "auth.lys.membership.contract {} is not served; this liminal speaks {CONTRACT_VERSION}",
471            config.contract
472        )));
473    }
474    for (member, value) in [
475        ("log_identity", config.log_identity.as_str()),
476        ("workspace_kind", config.workspace_kind.as_str()),
477        ("channel_kind", config.channel_kind.as_str()),
478    ] {
479        if malformed(value) {
480            return Err(invalid(format!(
481                "auth.lys.membership.{member} is empty or contains control characters"
482            )));
483        }
484    }
485    if config.workspace_kind == config.channel_kind {
486        return Err(invalid(
487            "auth.lys.membership: workspace_kind and channel_kind must differ",
488        ));
489    }
490    if config.page_rows == 0 || config.page_bytes == 0 || config.max_decisions == 0 {
491        return Err(invalid(
492            "auth.lys.membership: page_rows, page_bytes and max_decisions must each be positive",
493        ));
494    }
495    Ok(PageBounds {
496        rows: config.page_rows,
497        bytes: config.page_bytes,
498    })
499}
500
501/// A provider failure before a contract answer was received.
502#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
503#[error("membership provider could not be asked: {cause}")]
504pub struct ProviderError {
505    /// The stable cause, naming no response body and no credential.
506    pub cause: String,
507}
508
509fn provider_cause(cause: &str) -> ProviderError {
510    ProviderError {
511        cause: cause.to_owned(),
512    }
513}
514
515/// Lys's shared membership provider behind a synchronous seam
516/// (ACCESS-006 R1, R4).
517///
518/// The live implementation is
519/// [`HttpMembershipProvider`]; a fixture answers from a seeded world. No
520/// implementation may answer from a liminal-side membership list.
521pub trait MembershipProvider: Send + Sync + std::fmt::Debug {
522    /// Asks one membership decision.
523    ///
524    /// # Errors
525    /// [`ProviderError`] when Lys could not be asked or its answer could not
526    /// be read.
527    fn decide(&self, request: &MembershipRequest) -> Result<MembershipDecision, ProviderError>;
528
529    /// Asks scoped guest admission to one workspace.
530    ///
531    /// # Errors
532    /// [`ProviderError`] as for [`Self::decide`].
533    fn admission(&self, request: &AdmissionRequest) -> Result<AdmissionDecision, ProviderError>;
534
535    /// Asks one page of the resources a subject may act on.
536    ///
537    /// # Errors
538    /// [`ProviderError`] as for [`Self::decide`].
539    fn resources(&self, request: &ResourcePageRequest) -> Result<MembershipPage, ProviderError>;
540
541    /// Asks one page of the subjects who may act on a resource.
542    ///
543    /// # Errors
544    /// [`ProviderError`] as for [`Self::decide`].
545    fn recipients(&self, request: &RecipientPageRequest) -> Result<MembershipPage, ProviderError>;
546}
547
548/// Lys's membership routes over HTTP, asked as this liminal's app with its
549/// credential.
550///
551/// Each ask runs to completion on a scoped thread against the
552/// server's runtime, as `HttpLysAsk` does; nothing is cached here and no
553/// failure becomes an answer.
554pub struct HttpMembershipProvider {
555    http: reqwest::Client,
556    base: reqwest::Url,
557    credential: zeroize::Zeroizing<String>,
558    handle: tokio::runtime::Handle,
559}
560
561impl std::fmt::Debug for HttpMembershipProvider {
562    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
563        formatter
564            .debug_struct("HttpMembershipProvider")
565            .field("base", &self.base.as_str())
566            .field(
567                "credential",
568                &crate::auth_pass::ByteLength(self.credential.len()),
569            )
570            .finish_non_exhaustive()
571    }
572}
573
574impl HttpMembershipProvider {
575    /// Asks the Lys `issuer` as the app holding `credential`, on `handle`'s
576    /// runtime.
577    ///
578    /// # Errors
579    /// [`ProviderError`] when the issuer is not an HTTP origin, the credential
580    /// is empty, or the HTTP client cannot be built.
581    pub fn new(
582        issuer: &str,
583        credential: zeroize::Zeroizing<String>,
584        handle: tokio::runtime::Handle,
585    ) -> Result<Self, ProviderError> {
586        let mut base =
587            reqwest::Url::parse(issuer).map_err(|_| provider_cause("issuer_not_a_url"))?;
588        if !matches!(base.scheme(), "http" | "https")
589            || base.host_str().is_none()
590            || !base.username().is_empty()
591            || base.password().is_some()
592            || base.query().is_some()
593            || base.fragment().is_some()
594        {
595            return Err(provider_cause("issuer_not_an_http_origin"));
596        }
597        if !base.path().ends_with('/') {
598            let path = format!("{}/", base.path());
599            base.set_path(&path);
600        }
601        if credential.trim().is_empty() {
602            return Err(provider_cause("membership_credential_empty"));
603        }
604        let http = reqwest::Client::builder()
605            .build()
606            .map_err(|_| provider_cause("http_client_unavailable"))?;
607        Ok(Self {
608            http,
609            base,
610            credential,
611            handle,
612        })
613    }
614
615    fn post<Q, A>(&self, route: &str, question: &Q) -> Result<A, ProviderError>
616    where
617        Q: serde::Serialize + ?Sized,
618        A: serde::de::DeserializeOwned + Send,
619    {
620        let url = self
621            .base
622            .join(route)
623            .map_err(|_| provider_cause("membership_route_unjoinable"))?;
624        let request = self
625            .http
626            .post(url)
627            .bearer_auth(self.credential.as_str())
628            .json(question);
629        let handle = &self.handle;
630        std::thread::scope(|scope| {
631            scope
632                .spawn(move || {
633                    handle.block_on(async move {
634                        let response = request
635                            .send()
636                            .await
637                            .map_err(|_| provider_cause("lys_could_not_be_asked"))?;
638                        let status = response.status();
639                        if !status.is_success() {
640                            return Err(ProviderError {
641                                cause: format!("lys_http_status_{}", status.as_u16()),
642                            });
643                        }
644                        response
645                            .json::<A>()
646                            .await
647                            .map_err(|_| provider_cause("membership_answer_unreadable"))
648                    })
649                })
650                .join()
651        })
652        .unwrap_or_else(|_| Err(provider_cause("membership_ask_did_not_complete")))
653    }
654}
655
656impl MembershipProvider for HttpMembershipProvider {
657    fn decide(&self, request: &MembershipRequest) -> Result<MembershipDecision, ProviderError> {
658        self.post(DECISION_ROUTE, request)
659    }
660
661    fn admission(&self, request: &AdmissionRequest) -> Result<AdmissionDecision, ProviderError> {
662        self.post(ADMISSION_ROUTE, request)
663    }
664
665    fn resources(&self, request: &ResourcePageRequest) -> Result<MembershipPage, ProviderError> {
666        self.post(RESOURCES_ROUTE, request)
667    }
668
669    fn recipients(&self, request: &RecipientPageRequest) -> Result<MembershipPage, ProviderError> {
670        self.post(RECIPIENTS_ROUTE, request)
671    }
672}
673
674/// The ACCESS-005 grant authority's state, as the membership gate reads it
675/// (ACCESS-006 R2).
676#[derive(Clone, Debug, PartialEq, Eq)]
677pub enum AuthorityState {
678    /// No verified baseline yet.
679    NotReady,
680    /// Baseline, cursor and indexes agree on `log` at `revision`, in resume
681    /// generation `generation`.
682    Ready {
683        /// The grant log the authority is current on.
684        log: GrantLog,
685        /// The last applied receipt revision.
686        revision: u64,
687        /// The settled resume generation.
688        generation: u64,
689    },
690    /// The change feed or the provider is lost.
691    Unavailable {
692        /// The stable cause.
693        cause: String,
694    },
695    /// The change feed has an uncovered interval.
696    Gap {
697        /// The last revision applied before the gap.
698        after: u64,
699        /// How many revisions are missing.
700        missing: u64,
701    },
702    /// The grant log was reset and the new baseline is not yet verified.
703    Reset,
704}
705
706/// The seam ACCESS-005's versioned grant owner implements for membership
707/// (ACCESS-006 R2): its current state, read locally with no network call and
708/// no wait.
709///
710/// The one owner per grant log drives ordered changes and resumes
711/// into the gate through [`MembershipListener`]; there is no second feed.
712pub trait MembershipAuthority: Send + Sync + std::fmt::Debug {
713    /// The authority's current state.
714    fn state(&self) -> AuthorityState;
715}
716
717/// One applied grant change, in the owner's order.
718#[derive(Clone, Debug, PartialEq, Eq)]
719pub struct MembershipChange {
720    /// The grant log the change belongs to.
721    pub log: GrantLog,
722    /// Its receipt revision.
723    pub revision: u64,
724    /// The subject whose grant changed; `None` when every holder on the
725    /// resource is affected.
726    pub subject: Option<Subject>,
727    /// The resource the changed grant is held on: a channel or a workspace.
728    pub resource: Resource,
729}
730
731/// What applying one change did.
732#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
733pub struct AppliedChange {
734    /// The revision was already applied: nothing was touched.
735    pub duplicate: bool,
736    /// Retained bindings the change's resource reaches.
737    pub affected: u64,
738    /// Of those, re-decided at the change's revision.
739    pub redecided: u64,
740    /// Of those, released because the provider could not answer; the next
741    /// act asks again.
742    pub failed: u64,
743}
744
745/// What one resume did.
746#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
747pub struct ResumeReport {
748    /// Retained bindings re-decided once for the generation.
749    pub rechecked: u64,
750    /// Of those, released because the provider could not answer.
751    pub failed: u64,
752    /// Whether every binding agreed and the gate adopted the generation:
753    /// the owner republishes `Ready` only when this holds.
754    pub settled: bool,
755}
756
757/// The gate's side of the ACCESS-005 owner's ordered consumer.
758///
759/// The owner
760/// calls [`Self::changed`] after applying each change and before it admits
761/// any later act, and [`Self::resumed`] after a resume's contiguous tail and
762/// before it republishes `Ready`.
763pub trait MembershipListener: Send + Sync {
764    /// Re-decides the retained bindings `change` reaches.
765    ///
766    /// # Errors
767    /// [`MembershipRefusal::LogMismatch`] for another log; the index's own
768    /// refusals.
769    fn changed(&self, change: &MembershipChange) -> Result<AppliedChange, MembershipRefusal>;
770
771    /// Re-decides every retained binding once at `revision` and adopts
772    /// `generation` when all agree.
773    ///
774    /// # Errors
775    /// The index's own refusals.
776    fn resumed(&self, generation: u64, revision: u64) -> Result<ResumeReport, MembershipRefusal>;
777}
778
779/// The result of a membership check.
780#[derive(Clone, Debug, PartialEq, Eq)]
781pub enum Decided {
782    /// The channel is not membership-protected: its prior contract applies.
783    NotProtected,
784    /// Allowed by `grant`, decided at `revision`.
785    Allowed {
786        /// The granting receipt.
787        grant: String,
788        /// The revision decided at.
789        revision: u64,
790    },
791}
792
793/// Every count the gate keeps, by owned phase (ACCESS-006 R6). There is no
794/// history, roster or whole-state structure to traverse or clone, so no such
795/// count exists; a page's rows are counted as received.
796#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
797pub struct MembershipCounts {
798    /// Membership decisions asked of the provider.
799    pub decisions_asked: u64,
800    /// Admission decisions asked of the provider.
801    pub admissions_asked: u64,
802    /// Pages asked of the provider.
803    pub pages_asked: u64,
804    /// Page rows received.
805    pub page_rows: u64,
806    /// Checks answered from the index.
807    pub local_lookups: u64,
808    /// Release checks made at a delivery seam.
809    pub release_checks: u64,
810    /// Refused checks.
811    pub refused: u64,
812    /// Bindings a change's reverse index visited.
813    pub affected_visited: u64,
814    /// Bindings a resume re-decided.
815    pub resume_rechecks: u64,
816    /// Bindings released when their last connection closed.
817    pub released: u64,
818    /// Index lock acquisitions.
819    pub locks: u64,
820}
821
822#[derive(Debug, Default)]
823struct Counters {
824    decisions_asked: AtomicU64,
825    admissions_asked: AtomicU64,
826    pages_asked: AtomicU64,
827    page_rows: AtomicU64,
828    local_lookups: AtomicU64,
829    release_checks: AtomicU64,
830    refused: AtomicU64,
831    affected_visited: AtomicU64,
832    resume_rechecks: AtomicU64,
833    released: AtomicU64,
834    locks: AtomicU64,
835}
836
837impl Counters {
838    fn snapshot(&self) -> MembershipCounts {
839        let read = |counter: &AtomicU64| counter.load(Ordering::Relaxed);
840        MembershipCounts {
841            decisions_asked: read(&self.decisions_asked),
842            admissions_asked: read(&self.admissions_asked),
843            pages_asked: read(&self.pages_asked),
844            page_rows: read(&self.page_rows),
845            local_lookups: read(&self.local_lookups),
846            release_checks: read(&self.release_checks),
847            refused: read(&self.refused),
848            affected_visited: read(&self.affected_visited),
849            resume_rechecks: read(&self.resume_rechecks),
850            released: read(&self.released),
851            locks: read(&self.locks),
852        }
853    }
854}
855
856#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
857struct DecisionKey {
858    subject: MembershipSubject,
859    channel: String,
860    action: MembershipAction,
861}
862
863/// A retained decision, or the ticket of the ask settling it.
864#[derive(Clone, Debug, PartialEq, Eq)]
865enum Indexed {
866    Applying { ticket: u64 },
867    Allowed { grant: String, revision: u64 },
868    Refused { refusal: String },
869    Held { mode: Mode },
870}
871
872impl Indexed {
873    fn result(&self) -> Result<Decided, MembershipRefusal> {
874        match self {
875            Self::Applying { .. } => Err(MembershipRefusal::ChangeApplying),
876            Self::Allowed { grant, revision } => Ok(Decided::Allowed {
877                grant: grant.clone(),
878                revision: *revision,
879            }),
880            Self::Refused { refusal } => Err(MembershipRefusal::Refused {
881                refusal: refusal.clone(),
882            }),
883            Self::Held { mode } => Err(MembershipRefusal::Held { mode: *mode }),
884        }
885    }
886}
887
888type ResourceKey = (String, String);
889
890fn resource_key(resource: &Resource) -> ResourceKey {
891    (resource.kind.clone(), resource.id.clone())
892}
893
894#[derive(Debug)]
895struct Entry {
896    verdict: Indexed,
897    connections: BTreeSet<u64>,
898    reached_by: [ResourceKey; 2],
899}
900
901#[derive(Debug, Default)]
902struct DecisionIndex {
903    generation: Option<u64>,
904    applied: u64,
905    next_ticket: u64,
906    entries: BTreeMap<DecisionKey, Entry>,
907    by_resource: BTreeMap<ResourceKey, BTreeSet<DecisionKey>>,
908    by_connection: BTreeMap<u64, BTreeSet<DecisionKey>>,
909}
910
911impl DecisionIndex {
912    const fn adopt(&mut self, generation: u64) -> Result<(), MembershipRefusal> {
913        match self.generation {
914            None => {
915                self.generation = Some(generation);
916                Ok(())
917            }
918            Some(current) if current == generation => Ok(()),
919            Some(_) => Err(MembershipRefusal::ResumePending),
920        }
921    }
922
923    fn ticket(&mut self) -> Result<u64, MembershipRefusal> {
924        let ticket = self.next_ticket;
925        self.next_ticket = ticket
926            .checked_add(1)
927            .ok_or(MembershipRefusal::CapacityExceeded { limit: usize::MAX })?;
928        Ok(ticket)
929    }
930
931    /// The retained verdict for `key`, binding `pid` to it.
932    fn known(&mut self, key: &DecisionKey, pid: u64) -> Option<Indexed> {
933        let verdict = self.entries.get_mut(key).map(|entry| {
934            entry.connections.insert(pid);
935            entry.verdict.clone()
936        })?;
937        self.by_connection
938            .entry(pid)
939            .or_default()
940            .insert(key.clone());
941        Some(verdict)
942    }
943
944    /// Reserves `key` for one ask, refusing at `limit` before allocating.
945    fn reserve(
946        &mut self,
947        key: &DecisionKey,
948        mapped: &MappedChannel,
949        pid: u64,
950        limit: usize,
951    ) -> Result<u64, MembershipRefusal> {
952        if self.entries.len() >= limit {
953            return Err(MembershipRefusal::CapacityExceeded { limit });
954        }
955        let ticket = self.ticket()?;
956        let reached_by = [
957            resource_key(&mapped.resource),
958            resource_key(&mapped.workspace),
959        ];
960        for reach in &reached_by {
961            self.by_resource
962                .entry(reach.clone())
963                .or_default()
964                .insert(key.clone());
965        }
966        self.by_connection
967            .entry(pid)
968            .or_default()
969            .insert(key.clone());
970        self.entries.insert(
971            key.clone(),
972            Entry {
973                verdict: Indexed::Applying { ticket },
974                connections: BTreeSet::from([pid]),
975                reached_by,
976            },
977        );
978        Ok(ticket)
979    }
980
981    /// Re-reserves an existing binding for a change or resume ask.
982    fn reapply(&mut self, key: &DecisionKey) -> Result<Option<u64>, MembershipRefusal> {
983        if !self.entries.contains_key(key) {
984            return Ok(None);
985        }
986        let ticket = self.ticket()?;
987        if let Some(entry) = self.entries.get_mut(key) {
988            entry.verdict = Indexed::Applying { ticket };
989        }
990        Ok(Some(ticket))
991    }
992
993    /// Settles the ask holding `ticket`; `false` when a later change or
994    /// resume overtook it.
995    fn settle(&mut self, key: &DecisionKey, ticket: u64, verdict: Indexed) -> bool {
996        match self.entries.get_mut(key) {
997            Some(entry) if entry.verdict == (Indexed::Applying { ticket }) => {
998                entry.verdict = verdict;
999                true
1000            }
1001            Some(_) | None => false,
1002        }
1003    }
1004
1005    /// Forgets the binding still held by `ticket`'s ask.
1006    fn forget(&mut self, key: &DecisionKey, ticket: u64) {
1007        let held = self
1008            .entries
1009            .get(key)
1010            .is_some_and(|entry| entry.verdict == (Indexed::Applying { ticket }));
1011        if held {
1012            self.remove(key);
1013        }
1014    }
1015
1016    fn remove(&mut self, key: &DecisionKey) {
1017        let Some(entry) = self.entries.remove(key) else {
1018            return;
1019        };
1020        for reach in &entry.reached_by {
1021            let emptied = self.by_resource.get_mut(reach).is_some_and(|keys| {
1022                keys.remove(key);
1023                keys.is_empty()
1024            });
1025            if emptied {
1026                self.by_resource.remove(reach);
1027            }
1028        }
1029        for pid in &entry.connections {
1030            let emptied = self.by_connection.get_mut(pid).is_some_and(|keys| {
1031                keys.remove(key);
1032                keys.is_empty()
1033            });
1034            if emptied {
1035                self.by_connection.remove(pid);
1036            }
1037        }
1038    }
1039
1040    /// Releases every binding `pid` held; a binding no connection holds is
1041    /// removed. Visits only `pid`'s own bindings.
1042    fn release(&mut self, pid: u64) -> u64 {
1043        let Some(keys) = self.by_connection.remove(&pid) else {
1044            return 0;
1045        };
1046        let mut released = 0_u64;
1047        for key in keys {
1048            let unheld = self.entries.get_mut(&key).is_some_and(|entry| {
1049                entry.connections.remove(&pid);
1050                entry.connections.is_empty()
1051            });
1052            if unheld {
1053                self.remove(&key);
1054                released = released.saturating_add(1);
1055            }
1056        }
1057        released
1058    }
1059}
1060
1061/// The question a page continuation is bound to.
1062#[derive(Clone, Debug, PartialEq, Eq)]
1063enum PageQuestion {
1064    Resources {
1065        workspace: String,
1066        subject: MembershipSubject,
1067        action: MembershipAction,
1068    },
1069    Recipients {
1070        channel: String,
1071        action: MembershipAction,
1072    },
1073}
1074
1075/// An opaque page continuation, bound to its exact question, grant log and
1076/// generation; it grants nothing (ACCESS-006 R4).
1077#[derive(Clone, PartialEq, Eq)]
1078pub struct PageCursor {
1079    token: String,
1080    question: PageQuestion,
1081    generation: u64,
1082}
1083
1084impl std::fmt::Debug for PageCursor {
1085    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1086        formatter
1087            .debug_struct("PageCursor")
1088            .field("token", &crate::auth_pass::ByteLength(self.token.len()))
1089            .field("generation", &self.generation)
1090            .finish_non_exhaustive()
1091    }
1092}
1093
1094/// One page of the protected channels a holder may act on in a workspace.
1095#[derive(Clone, Debug, PartialEq, Eq)]
1096pub struct ChannelPage {
1097    /// The liminal channels, in the provider's stable order.
1098    pub channels: Vec<String>,
1099    /// Rows the provider returned.
1100    pub returned: u64,
1101    /// Candidates the provider decided and left out.
1102    pub skipped: u64,
1103    /// Rows held for approval: a membership exists, the act still waits.
1104    pub held: u64,
1105    /// Rows naming a resource the product mapping does not list.
1106    pub unmapped: u64,
1107    /// The rows' encoded bytes.
1108    pub bytes: u64,
1109    /// The revision every row was decided at.
1110    pub revision: u64,
1111    /// Whether no row remains.
1112    pub complete: bool,
1113    /// The continuation, absent when complete.
1114    pub next: Option<PageCursor>,
1115}
1116
1117/// One page of the subjects who may act on a protected channel.
1118#[derive(Clone, Debug, PartialEq, Eq)]
1119pub struct RecipientPage {
1120    /// The subjects, in the provider's stable order.
1121    pub recipients: Vec<Subject>,
1122    /// Rows the provider returned.
1123    pub returned: u64,
1124    /// Candidates the provider decided and left out.
1125    pub skipped: u64,
1126    /// Rows held for approval.
1127    pub held: u64,
1128    /// The rows' encoded bytes.
1129    pub bytes: u64,
1130    /// The revision every row was decided at.
1131    pub revision: u64,
1132    /// Whether no row remains.
1133    pub complete: bool,
1134    /// The continuation, absent when complete.
1135    pub next: Option<PageCursor>,
1136}
1137
1138/// A validated page's parts.
1139struct PageParts<'p> {
1140    rows: &'p [PageRow],
1141    skipped: u64,
1142    complete: bool,
1143    next: Option<String>,
1144    revision: u64,
1145    bytes: u64,
1146}
1147
1148/// The membership gate: the product mapping, the provider seam, the
1149/// authority seam and the bounded decision index (ACCESS-006 R1, R2, R6).
1150pub struct MembershipGate {
1151    served: GrantLog,
1152    workspace_kind: String,
1153    channel_kind: String,
1154    map: ChannelMap,
1155    bounds: PageBounds,
1156    max_decisions: usize,
1157    connect_workspace: Option<String>,
1158    provider: Arc<dyn MembershipProvider>,
1159    authority: OnceLock<Arc<dyn MembershipAuthority>>,
1160    index: Mutex<DecisionIndex>,
1161    counters: Counters,
1162}
1163
1164impl std::fmt::Debug for MembershipGate {
1165    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1166        formatter
1167            .debug_struct("MembershipGate")
1168            .field("served", &self.served)
1169            .field("channels", &self.map.channels.len())
1170            .field("bounds", &self.bounds)
1171            .field("max_decisions", &self.max_decisions)
1172            .field("authority", &self.authority.get().is_some())
1173            .finish_non_exhaustive()
1174    }
1175}
1176
1177impl MembershipGate {
1178    /// Builds the gate from the validated product mapping over `provider`.
1179    /// No authority is installed: until one is, every protected act refuses
1180    /// [`MembershipRefusal::AuthorityMissing`].
1181    ///
1182    /// # Errors
1183    /// [`ServerError::ConfigValidation`] naming the first invalid member: a
1184    /// contract other than [`CONTRACT_VERSION`], an empty or control-bearing
1185    /// kind, log identity or mapping member, equal workspace and channel
1186    /// kinds, a zero bound, no protected channel, a channel or resource
1187    /// mapped twice, a conversation naming an unlisted channel, or a
1188    /// `connect_workspace` the mapping does not list.
1189    pub fn new(
1190        config: &MembershipConfig,
1191        provider: Arc<dyn MembershipProvider>,
1192    ) -> Result<Self, ServerError> {
1193        let bounds = validated_bounds(config)?;
1194        let map = channel_map(config)?;
1195        if let Some(workspace) = &config.connect_workspace
1196            && !map.workspaces.contains(workspace)
1197        {
1198            return Err(invalid(
1199                "auth.lys.membership.connect_workspace is not a mapped workspace",
1200            ));
1201        }
1202        Ok(Self {
1203            served: GrantLog {
1204                identity: config.log_identity.clone(),
1205                epoch: config.log_epoch,
1206            },
1207            workspace_kind: config.workspace_kind.clone(),
1208            channel_kind: config.channel_kind.clone(),
1209            map,
1210            bounds,
1211            max_decisions: config.max_decisions,
1212            connect_workspace: config.connect_workspace.clone(),
1213            provider,
1214            authority: OnceLock::new(),
1215            index: Mutex::new(DecisionIndex::default()),
1216            counters: Counters::default(),
1217        })
1218    }
1219
1220    /// Installs the ACCESS-005 grant authority once.
1221    ///
1222    /// # Errors
1223    /// Returns the authority back when one is already installed, so one
1224    /// owner cannot be swapped for another under live bindings.
1225    pub fn install_authority(
1226        &self,
1227        authority: Arc<dyn MembershipAuthority>,
1228    ) -> Result<(), Arc<dyn MembershipAuthority>> {
1229        self.authority.set(authority)
1230    }
1231
1232    /// The grant log this gate asks from.
1233    #[must_use]
1234    pub const fn served_log(&self) -> &GrantLog {
1235        &self.served
1236    }
1237
1238    /// Whether `channel` is membership-protected.
1239    #[must_use]
1240    pub fn protects(&self, channel: &str) -> bool {
1241        self.map.channels.contains_key(channel)
1242    }
1243
1244    /// The protected channel's placement, as the mapping states it.
1245    #[must_use]
1246    pub fn mapped(&self, channel: &str) -> Option<&MappedChannel> {
1247        self.map.channels.get(channel)
1248    }
1249
1250    /// The protected channel a participant conversation is placed under, as
1251    /// the mapping states it; `None` is unmapped, never guessed.
1252    #[must_use]
1253    pub fn channel_for_conversation(&self, conversation: u64) -> Option<&str> {
1254        self.map
1255            .conversations
1256            .get(&conversation)
1257            .map(String::as_str)
1258    }
1259
1260    /// Every count, as of now.
1261    #[must_use]
1262    pub fn counts(&self) -> MembershipCounts {
1263        self.counters.snapshot()
1264    }
1265
1266    /// How many decisions the index retains.
1267    ///
1268    /// # Errors
1269    /// [`MembershipRefusal::IndexPoisoned`].
1270    pub fn retained(&self) -> Result<usize, MembershipRefusal> {
1271        Ok(self.lock()?.entries.len())
1272    }
1273
1274    fn lock(&self) -> Result<MutexGuard<'_, DecisionIndex>, MembershipRefusal> {
1275        bump(&self.counters.locks);
1276        self.index
1277            .lock()
1278            .map_err(|_| MembershipRefusal::IndexPoisoned)
1279    }
1280
1281    /// The authority's revision and generation while it is `Ready` on the
1282    /// served log; its exact cause otherwise.
1283    fn ready(&self) -> Result<(u64, u64), MembershipRefusal> {
1284        let authority = self
1285            .authority
1286            .get()
1287            .ok_or(MembershipRefusal::AuthorityMissing)?;
1288        match authority.state() {
1289            AuthorityState::Ready {
1290                log,
1291                revision,
1292                generation,
1293            } => {
1294                if log == self.served {
1295                    Ok((revision, generation))
1296                } else {
1297                    Err(MembershipRefusal::LogMismatch)
1298                }
1299            }
1300            AuthorityState::NotReady => Err(MembershipRefusal::NotReady),
1301            AuthorityState::Unavailable { cause } => Err(MembershipRefusal::Unavailable { cause }),
1302            AuthorityState::Gap { after, missing } => {
1303                Err(MembershipRefusal::Gap { after, missing })
1304            }
1305            AuthorityState::Reset => Err(MembershipRefusal::Reset),
1306        }
1307    }
1308
1309    /// Decides `action` on `channel` for connection `pid`'s verified
1310    /// `holder`: [`Decided::NotProtected`] for a channel the mapping does
1311    /// not list; otherwise the current decision, from the index when it
1312    /// holds one at the authority's generation, asked of the provider once
1313    /// when it does not. The lock is never held across the ask.
1314    ///
1315    /// # Errors
1316    /// The exact [`MembershipRefusal`]: no verified holder, the authority's
1317    /// state, the index bound, the provider's refusal or failure, or an
1318    /// answer that is not the one asked.
1319    pub fn check(
1320        &self,
1321        pid: u64,
1322        holder: Option<&MembershipSubject>,
1323        channel: &str,
1324        action: MembershipAction,
1325    ) -> Result<Decided, MembershipRefusal> {
1326        let Some(mapped) = self.map.channels.get(channel) else {
1327            return Ok(Decided::NotProtected);
1328        };
1329        let decided = self.check_mapped(pid, holder, channel, mapped, action);
1330        if decided.is_err() {
1331            bump(&self.counters.refused);
1332        }
1333        decided
1334    }
1335
1336    /// [`Self::check`] at a final data-release seam (ACCESS-006 R3), counted
1337    /// as a release check.
1338    ///
1339    /// # Errors
1340    /// As for [`Self::check`].
1341    pub fn check_release(
1342        &self,
1343        pid: u64,
1344        holder: Option<&MembershipSubject>,
1345        channel: &str,
1346        action: MembershipAction,
1347    ) -> Result<Decided, MembershipRefusal> {
1348        bump(&self.counters.release_checks);
1349        self.check(pid, holder, channel, action)
1350    }
1351
1352    fn check_mapped(
1353        &self,
1354        pid: u64,
1355        holder: Option<&MembershipSubject>,
1356        channel: &str,
1357        mapped: &MappedChannel,
1358        action: MembershipAction,
1359    ) -> Result<Decided, MembershipRefusal> {
1360        let holder = holder.ok_or(MembershipRefusal::HolderUnverified)?;
1361        let (revision, generation) = self.ready()?;
1362        let key = DecisionKey {
1363            subject: holder.clone(),
1364            channel: channel.to_owned(),
1365            action,
1366        };
1367        let (ticket, at_least) = {
1368            let mut index = self.lock()?;
1369            index.adopt(generation)?;
1370            if let Some(verdict) = index.known(&key, pid) {
1371                drop(index);
1372                bump(&self.counters.local_lookups);
1373                return verdict.result();
1374            }
1375            let at_least = revision.max(index.applied);
1376            let ticket = index.reserve(&key, mapped, pid, self.max_decisions)?;
1377            drop(index);
1378            (ticket, at_least)
1379        };
1380        let asked = self.ask(holder, mapped, action, at_least);
1381        let mut index = self.lock()?;
1382        match asked {
1383            Ok(verdict) => {
1384                let current = index.generation == Some(generation)
1385                    && index.settle(&key, ticket, verdict.clone());
1386                if !current {
1387                    // A change or a resume overtook this ask: its answer is not
1388                    // the current one, and nothing is released on it.
1389                    index.forget(&key, ticket);
1390                    return Err(MembershipRefusal::ChangeApplying);
1391                }
1392                drop(index);
1393                verdict.result()
1394            }
1395            Err(refusal) => {
1396                index.forget(&key, ticket);
1397                drop(index);
1398                Err(refusal)
1399            }
1400        }
1401    }
1402
1403    fn ask(
1404        &self,
1405        holder: &MembershipSubject,
1406        mapped: &MappedChannel,
1407        action: MembershipAction,
1408        at_least: u64,
1409    ) -> Result<Indexed, MembershipRefusal> {
1410        let request = MembershipRequest {
1411            contract: CONTRACT_VERSION,
1412            log: self.served.clone(),
1413            workspace: mapped.workspace.clone(),
1414            subject: holder.to_contract(),
1415            resource: mapped.resource.clone(),
1416            action: action.as_str().to_owned(),
1417            at_least,
1418        };
1419        bump(&self.counters.decisions_asked);
1420        let decision = self
1421            .provider
1422            .decide(&request)
1423            .map_err(|error| MembershipRefusal::Unavailable { cause: error.cause })?;
1424        judged(&request, &self.served, mapped, &decision)
1425    }
1426
1427    /// Scoped guest admission at `Connect` (ACCESS-006 R1): when the mapping
1428    /// names a `connect_workspace`, the holder must hold a current grant
1429    /// somewhere in it. Admission opens no channel; every act after it is
1430    /// decided on its own. `Ok(None)` when no admission is configured.
1431    ///
1432    /// # Errors
1433    /// The authority's state, the provider's failure, a substituted or stale
1434    /// answer, or Lys's refusal by name (`membership_no_current_grant`).
1435    pub fn admit_connect(
1436        &self,
1437        holder: &MembershipSubject,
1438    ) -> Result<Option<u64>, MembershipRefusal> {
1439        let Some(workspace) = &self.connect_workspace else {
1440            return Ok(None);
1441        };
1442        let (revision, _) = self.ready()?;
1443        let request = AdmissionRequest {
1444            contract: CONTRACT_VERSION,
1445            log: self.served.clone(),
1446            workspace: Resource {
1447                kind: self.workspace_kind.clone(),
1448                id: workspace.clone(),
1449            },
1450            subject: holder.to_contract(),
1451            at_least: revision,
1452        };
1453        bump(&self.counters.admissions_asked);
1454        let decision = self
1455            .provider
1456            .admission(&request)
1457            .map_err(|error| MembershipRefusal::Unavailable { cause: error.cause })?;
1458        admitted(&request, &self.served, &decision).map(Some)
1459    }
1460
1461    /// Releases every binding connection `pid` held (ACCESS-006 R6); a
1462    /// binding no connection holds leaves the index. A poisoned index is
1463    /// logged and left: it already refuses every check.
1464    pub fn release_connection(&self, pid: u64) {
1465        match self.lock() {
1466            Ok(mut index) => {
1467                let released = index.release(pid);
1468                drop(index);
1469                bump_by(&self.counters.released, released);
1470            }
1471            Err(refusal) => {
1472                tracing::warn!(
1473                    pid,
1474                    refusal = refusal.name(),
1475                    "membership bindings could not be released"
1476                );
1477            }
1478        }
1479    }
1480
1481    /// Re-asks every key in `tickets` at `at_least`, settling each; returns
1482    /// how many settled and how many failed and were released.
1483    fn redecide(&self, tickets: Vec<(DecisionKey, u64)>, at_least: u64) -> (u64, u64) {
1484        let mut settled = 0_u64;
1485        let mut failed = 0_u64;
1486        for (key, ticket) in tickets {
1487            let asked = self
1488                .map
1489                .channels
1490                .get(&key.channel)
1491                .ok_or(MembershipRefusal::ChannelNotMapped)
1492                .and_then(|mapped| self.ask(&key.subject, mapped, key.action, at_least));
1493            let Ok(mut index) = self.lock() else {
1494                failed = failed.saturating_add(1);
1495                continue;
1496            };
1497            let kept = asked.is_ok_and(|verdict| index.settle(&key, ticket, verdict));
1498            if kept {
1499                settled = settled.saturating_add(1);
1500            } else {
1501                index.forget(&key, ticket);
1502                failed = failed.saturating_add(1);
1503            }
1504        }
1505        (settled, failed)
1506    }
1507
1508    /// One page of the protected channels `holder` may `action` within
1509    /// `workspace` (ACCESS-006 R4): both bounds the operator's, every row
1510    /// checked against the question, the continuation bound to it.
1511    ///
1512    /// # Errors
1513    /// The exact [`MembershipRefusal`]: no verified holder, an unmapped
1514    /// workspace, the authority's state, a foreign or stale continuation,
1515    /// the provider's failure or refusal, or a page that is not the one
1516    /// asked (over bound, foreign or duplicate row, inconsistent counts).
1517    pub fn resource_page(
1518        &self,
1519        holder: Option<&MembershipSubject>,
1520        workspace: &str,
1521        action: MembershipAction,
1522        after: Option<&PageCursor>,
1523    ) -> Result<ChannelPage, MembershipRefusal> {
1524        let holder = holder.ok_or(MembershipRefusal::HolderUnverified)?;
1525        if !self.map.workspaces.contains(workspace) {
1526            return Err(MembershipRefusal::WorkspaceNotMapped);
1527        }
1528        let (revision, generation) = self.ready()?;
1529        let question = PageQuestion::Resources {
1530            workspace: workspace.to_owned(),
1531            subject: holder.clone(),
1532            action,
1533        };
1534        let request = ResourcePageRequest {
1535            contract: CONTRACT_VERSION,
1536            log: self.served.clone(),
1537            workspace: Resource {
1538                kind: self.workspace_kind.clone(),
1539                id: workspace.to_owned(),
1540            },
1541            subject: holder.to_contract(),
1542            kind: self.channel_kind.clone(),
1543            action: action.as_str().to_owned(),
1544            at_least: revision,
1545            bounds: self.bounds,
1546            after: continuation(after, &question, generation)?,
1547        };
1548        bump(&self.counters.pages_asked);
1549        let page = self
1550            .provider
1551            .resources(&request)
1552            .map_err(|error| MembershipRefusal::Unavailable { cause: error.cause })?;
1553        let parts = self.page_parts(&page, revision)?;
1554        let mut channels = Vec::with_capacity(parts.rows.len());
1555        let mut seen = BTreeSet::new();
1556        let mut held = 0_u64;
1557        let mut unmapped = 0_u64;
1558        for row in parts.rows {
1559            if !holder.is(&row.subject) || row.resource.kind != self.channel_kind {
1560                return Err(MembershipRefusal::PageForeignRow);
1561            }
1562            if !seen.insert(row.resource.id.as_str()) {
1563                return Err(MembershipRefusal::PageDuplicateRow);
1564            }
1565            let Some(channel) = self.map.by_resource.get(&row.resource.id) else {
1566                unmapped = unmapped.saturating_add(1);
1567                continue;
1568            };
1569            let placed_here = self
1570                .map
1571                .channels
1572                .get(channel)
1573                .is_some_and(|mapped| mapped.workspace.id == workspace);
1574            if !placed_here {
1575                return Err(MembershipRefusal::PageForeignRow);
1576            }
1577            if row.mode == Mode::Outright {
1578                channels.push(channel.clone());
1579            } else {
1580                held = held.saturating_add(1);
1581            }
1582        }
1583        Ok(ChannelPage {
1584            returned: u64::try_from(parts.rows.len())
1585                .map_err(|_| MembershipRefusal::PageInconsistent)?,
1586            channels,
1587            skipped: parts.skipped,
1588            held,
1589            unmapped,
1590            bytes: parts.bytes,
1591            revision: parts.revision,
1592            complete: parts.complete,
1593            next: parts.next.map(|token| PageCursor {
1594                token,
1595                question,
1596                generation,
1597            }),
1598        })
1599    }
1600
1601    /// One page of the subjects who may `action` on the protected `channel`
1602    /// (ACCESS-006 R4), checked as [`Self::resource_page`] is.
1603    ///
1604    /// # Errors
1605    /// As for [`Self::resource_page`], with
1606    /// [`MembershipRefusal::ChannelNotMapped`] for an unmapped channel.
1607    pub fn recipient_page(
1608        &self,
1609        channel: &str,
1610        action: MembershipAction,
1611        after: Option<&PageCursor>,
1612    ) -> Result<RecipientPage, MembershipRefusal> {
1613        let mapped = self
1614            .map
1615            .channels
1616            .get(channel)
1617            .ok_or(MembershipRefusal::ChannelNotMapped)?;
1618        let (revision, generation) = self.ready()?;
1619        let question = PageQuestion::Recipients {
1620            channel: channel.to_owned(),
1621            action,
1622        };
1623        let request = RecipientPageRequest {
1624            contract: CONTRACT_VERSION,
1625            log: self.served.clone(),
1626            workspace: mapped.workspace.clone(),
1627            resource: mapped.resource.clone(),
1628            action: action.as_str().to_owned(),
1629            at_least: revision,
1630            bounds: self.bounds,
1631            after: continuation(after, &question, generation)?,
1632        };
1633        bump(&self.counters.pages_asked);
1634        let page = self
1635            .provider
1636            .recipients(&request)
1637            .map_err(|error| MembershipRefusal::Unavailable { cause: error.cause })?;
1638        let parts = self.page_parts(&page, revision)?;
1639        let mut recipients = Vec::with_capacity(parts.rows.len());
1640        let mut seen = BTreeSet::new();
1641        let mut held = 0_u64;
1642        for row in parts.rows {
1643            if row.resource != mapped.resource {
1644                return Err(MembershipRefusal::PageForeignRow);
1645            }
1646            if !seen.insert((row.subject.id.as_str(), row.subject.kind.as_str())) {
1647                return Err(MembershipRefusal::PageDuplicateRow);
1648            }
1649            if row.mode == Mode::Outright {
1650                recipients.push(row.subject.clone());
1651            } else {
1652                held = held.saturating_add(1);
1653            }
1654        }
1655        Ok(RecipientPage {
1656            returned: u64::try_from(parts.rows.len())
1657                .map_err(|_| MembershipRefusal::PageInconsistent)?,
1658            recipients,
1659            skipped: parts.skipped,
1660            held,
1661            bytes: parts.bytes,
1662            revision: parts.revision,
1663            complete: parts.complete,
1664            next: parts.next.map(|token| PageCursor {
1665                token,
1666                question,
1667                generation,
1668            }),
1669        })
1670    }
1671
1672    fn page_parts<'p>(
1673        &self,
1674        page: &'p MembershipPage,
1675        at_least: u64,
1676    ) -> Result<PageParts<'p>, MembershipRefusal> {
1677        if page.contract != CONTRACT_VERSION {
1678            return Err(MembershipRefusal::ContractUnsupported {
1679                contract: page.contract,
1680            });
1681        }
1682        if page.log != self.served {
1683            return Err(MembershipRefusal::LogMismatch);
1684        }
1685        let (rows, returned, skipped, complete, next) = match &page.outcome {
1686            PageOutcome::Refused { refusal, .. } => return Err(page_refusal(refusal)),
1687            PageOutcome::Page {
1688                rows,
1689                returned,
1690                skipped,
1691                complete,
1692                next,
1693            } => (rows, *returned, *skipped, *complete, next),
1694        };
1695        let revision = page.revision.ok_or(MembershipRefusal::RevisionStale {
1696            required: at_least,
1697            decided: None,
1698        })?;
1699        if revision < at_least {
1700            return Err(MembershipRefusal::RevisionStale {
1701                required: at_least,
1702                decided: Some(revision),
1703            });
1704        }
1705        let count = u64::try_from(rows.len()).map_err(|_| MembershipRefusal::PageInconsistent)?;
1706        bump_by(&self.counters.page_rows, count);
1707        if count != returned || complete == next.is_some() {
1708            return Err(MembershipRefusal::PageInconsistent);
1709        }
1710        let bytes = encoded_bytes(rows)?;
1711        if count > u64::from(self.bounds.rows) || bytes > u64::from(self.bounds.bytes) {
1712            return Err(MembershipRefusal::PageOverBound { rows: count, bytes });
1713        }
1714        Ok(PageParts {
1715            rows,
1716            skipped,
1717            complete,
1718            next: next.clone(),
1719            revision,
1720            bytes,
1721        })
1722    }
1723}
1724
1725impl MembershipListener for MembershipGate {
1726    fn changed(&self, change: &MembershipChange) -> Result<AppliedChange, MembershipRefusal> {
1727        if change.log != self.served {
1728            return Err(MembershipRefusal::LogMismatch);
1729        }
1730        let reached = resource_key(&change.resource);
1731        let tickets = {
1732            let mut index = self.lock()?;
1733            if change.revision <= index.applied {
1734                // A duplicate or older notification touches nothing, so no
1735                // withdrawn allow can return through it.
1736                return Ok(AppliedChange {
1737                    duplicate: true,
1738                    ..AppliedChange::default()
1739                });
1740            }
1741            index.applied = change.revision;
1742            let keys: Vec<DecisionKey> = index
1743                .by_resource
1744                .get(&reached)
1745                .map(|keys| {
1746                    keys.iter()
1747                        .filter(|key| {
1748                            change
1749                                .subject
1750                                .as_ref()
1751                                .is_none_or(|subject| key.subject.is(subject))
1752                        })
1753                        .cloned()
1754                        .collect()
1755                })
1756                .unwrap_or_default();
1757            let mut tickets = Vec::with_capacity(keys.len());
1758            for key in keys {
1759                if let Some(ticket) = index.reapply(&key)? {
1760                    tickets.push((key, ticket));
1761                }
1762            }
1763            tickets
1764        };
1765        let affected = u64::try_from(tickets.len()).unwrap_or(u64::MAX);
1766        bump_by(&self.counters.affected_visited, affected);
1767        let (redecided, failed) = self.redecide(tickets, change.revision);
1768        Ok(AppliedChange {
1769            duplicate: false,
1770            affected,
1771            redecided,
1772            failed,
1773        })
1774    }
1775
1776    fn resumed(&self, generation: u64, revision: u64) -> Result<ResumeReport, MembershipRefusal> {
1777        let tickets = {
1778            let mut index = self.lock()?;
1779            let keys: Vec<DecisionKey> = index.entries.keys().cloned().collect();
1780            let mut tickets = Vec::with_capacity(keys.len());
1781            for key in keys {
1782                if let Some(ticket) = index.reapply(&key)? {
1783                    tickets.push((key, ticket));
1784                }
1785            }
1786            tickets
1787        };
1788        let rechecked = u64::try_from(tickets.len()).unwrap_or(u64::MAX);
1789        bump_by(&self.counters.resume_rechecks, rechecked);
1790        let (_, failed) = self.redecide(tickets, revision);
1791        let settled = failed == 0;
1792        if settled {
1793            let mut index = self.lock()?;
1794            index.generation = Some(generation);
1795            index.applied = index.applied.max(revision);
1796        }
1797        Ok(ResumeReport {
1798            rechecked,
1799            failed,
1800            settled,
1801        })
1802    }
1803}
1804
1805/// The page continuation token for `after`, refusing one issued for another
1806/// question or at another generation.
1807fn continuation(
1808    after: Option<&PageCursor>,
1809    question: &PageQuestion,
1810    generation: u64,
1811) -> Result<Option<String>, MembershipRefusal> {
1812    let Some(cursor) = after else {
1813        return Ok(None);
1814    };
1815    if &cursor.question != question {
1816        return Err(MembershipRefusal::CursorForeign);
1817    }
1818    if cursor.generation != generation {
1819        return Err(MembershipRefusal::CursorStale);
1820    }
1821    Ok(Some(cursor.token.clone()))
1822}
1823
1824fn page_refusal(refusal: &str) -> MembershipRefusal {
1825    match refusal {
1826        CURSOR_FOREIGN => MembershipRefusal::CursorForeign,
1827        CURSOR_RESET => MembershipRefusal::CursorStale,
1828        LOG_MISMATCH => MembershipRefusal::LogMismatch,
1829        other => MembershipRefusal::Refused {
1830            refusal: other.to_owned(),
1831        },
1832    }
1833}
1834
1835fn encoded_bytes(rows: &[PageRow]) -> Result<u64, MembershipRefusal> {
1836    rows.iter().try_fold(0_u64, |total, row| {
1837        let length = serde_json::to_vec(row)
1838            .map(|encoded| encoded.len())
1839            .map_err(|_| MembershipRefusal::PageInconsistent)?;
1840        u64::try_from(length)
1841            .ok()
1842            .and_then(|length| total.checked_add(length))
1843            .ok_or(MembershipRefusal::PageInconsistent)
1844    })
1845}
1846
1847/// Judges one provider answer against the exact request asked
1848/// (ACCESS-006 R1, R5): the contract, the grant log, the echoed request, the
1849/// revision fence and the allowing scope. A refusal decided before any
1850/// grant was asked carries no revision: it is returned, never retained, so
1851/// a transient provider state is never frozen into the index.
1852fn judged(
1853    request: &MembershipRequest,
1854    served: &GrantLog,
1855    mapped: &MappedChannel,
1856    decision: &MembershipDecision,
1857) -> Result<Indexed, MembershipRefusal> {
1858    if decision.contract != CONTRACT_VERSION {
1859        return Err(MembershipRefusal::ContractUnsupported {
1860            contract: decision.contract,
1861        });
1862    }
1863    if &decision.log != served {
1864        return Err(MembershipRefusal::LogMismatch);
1865    }
1866    if &decision.request != request {
1867        return Err(MembershipRefusal::AnswerSubstituted);
1868    }
1869    let Some(revision) = decision.revision else {
1870        return Err(match &decision.verdict {
1871            Verdict::Refused { refusal, .. } => page_refusal(refusal),
1872            Verdict::Allowed { .. } | Verdict::Held { .. } => MembershipRefusal::RevisionStale {
1873                required: request.at_least,
1874                decided: None,
1875            },
1876        });
1877    };
1878    if revision < request.at_least {
1879        return Err(MembershipRefusal::RevisionStale {
1880            required: request.at_least,
1881            decided: Some(revision),
1882        });
1883    }
1884    match &decision.verdict {
1885        Verdict::Allowed { grant, scope, .. } => {
1886            if scope == &mapped.resource || scope == &mapped.workspace {
1887                Ok(Indexed::Allowed {
1888                    grant: grant.clone(),
1889                    revision,
1890                })
1891            } else {
1892                Err(MembershipRefusal::ScopeOutsideMapping)
1893            }
1894        }
1895        Verdict::Held { mode, .. } => Ok(Indexed::Held { mode: *mode }),
1896        Verdict::Refused { refusal, .. } => Ok(Indexed::Refused {
1897            refusal: refusal.clone(),
1898        }),
1899    }
1900}
1901
1902/// Judges one admission answer against the exact request asked.
1903fn admitted(
1904    request: &AdmissionRequest,
1905    served: &GrantLog,
1906    decision: &AdmissionDecision,
1907) -> Result<u64, MembershipRefusal> {
1908    if decision.contract != CONTRACT_VERSION {
1909        return Err(MembershipRefusal::ContractUnsupported {
1910            contract: decision.contract,
1911        });
1912    }
1913    if &decision.log != served {
1914        return Err(MembershipRefusal::LogMismatch);
1915    }
1916    if &decision.request != request {
1917        return Err(MembershipRefusal::AnswerSubstituted);
1918    }
1919    match (&decision.verdict, decision.revision) {
1920        (Verdict::Refused { refusal, .. }, _) => Err(page_refusal(refusal)),
1921        (Verdict::Held { mode, .. }, _) => Err(MembershipRefusal::Held { mode: *mode }),
1922        (Verdict::Allowed { .. }, Some(revision)) if revision >= request.at_least => Ok(revision),
1923        (Verdict::Allowed { .. }, decided) => Err(MembershipRefusal::RevisionStale {
1924            required: request.at_least,
1925            decided,
1926        }),
1927    }
1928}
1929
1930/// One connection's membership binding (ACCESS-006 R1, R6): the gate, the
1931/// connection's id and the verified subject from its one `Connect`, or the
1932/// exact reason it has none.
1933///
1934/// Dropped with the connection's state on every
1935/// teardown path, releasing the bindings it held.
1936#[derive(Debug, Default)]
1937pub struct ConnectionMembership {
1938    bound: Option<BoundMembership>,
1939}
1940
1941#[derive(Debug)]
1942struct BoundMembership {
1943    gate: Arc<MembershipGate>,
1944    pid: u64,
1945    holder: Result<MembershipSubject, MembershipRefusal>,
1946}
1947
1948impl ConnectionMembership {
1949    /// Binds connection `pid` at `Connect`: the subject is the admitted Lys
1950    /// `session`'s holder, or none. Without a gate nothing is protected.
1951    #[must_use]
1952    pub fn bind(
1953        gate: Option<Arc<MembershipGate>>,
1954        pid: u64,
1955        session: Option<&dyn LysSession>,
1956    ) -> Self {
1957        Self {
1958            bound: gate.map(|gate| BoundMembership {
1959                gate,
1960                pid,
1961                holder: session.map_or(
1962                    Err(MembershipRefusal::HolderUnverified),
1963                    MembershipSubject::from_session,
1964                ),
1965            }),
1966        }
1967    }
1968
1969    /// The gate, when one is bound.
1970    #[must_use]
1971    pub fn gate(&self) -> Option<&Arc<MembershipGate>> {
1972        self.bound.as_ref().map(|bound| &bound.gate)
1973    }
1974
1975    /// The verified subject, or why there is none.
1976    ///
1977    /// # Errors
1978    /// [`MembershipRefusal::HolderUnverified`] without a gate or a holder;
1979    /// the subject's own refusal otherwise.
1980    pub fn holder(&self) -> Result<&MembershipSubject, MembershipRefusal> {
1981        self.bound
1982            .as_ref()
1983            .ok_or(MembershipRefusal::HolderUnverified)
1984            .and_then(|bound| bound.holder.as_ref().map_err(Clone::clone))
1985    }
1986
1987    /// Scoped guest admission for this connection's holder at `Connect`.
1988    ///
1989    /// # Errors
1990    /// As for [`MembershipGate::admit_connect`]; the holder's own refusal.
1991    pub fn admit_connect(&self) -> Result<Option<u64>, MembershipRefusal> {
1992        let Some(bound) = &self.bound else {
1993            return Ok(None);
1994        };
1995        if bound.gate.connect_workspace.is_none() {
1996            return Ok(None);
1997        }
1998        let holder = bound.holder.as_ref().map_err(Clone::clone)?;
1999        bound.gate.admit_connect(holder)
2000    }
2001
2002    /// Decides `action` on `channel` for this connection.
2003    ///
2004    /// # Errors
2005    /// As for [`MembershipGate::check`]; a protected channel on a connection
2006    /// with no verified subject refuses with that subject's cause.
2007    pub fn check(
2008        &self,
2009        channel: &str,
2010        action: MembershipAction,
2011    ) -> Result<Decided, MembershipRefusal> {
2012        self.decide(channel, action, false)
2013    }
2014
2015    /// [`Self::check`] at a final data-release seam.
2016    ///
2017    /// # Errors
2018    /// As for [`Self::check`].
2019    pub fn release(
2020        &self,
2021        channel: &str,
2022        action: MembershipAction,
2023    ) -> Result<Decided, MembershipRefusal> {
2024        self.decide(channel, action, true)
2025    }
2026
2027    /// [`Self::release`] for a participant conversation, on the channel the
2028    /// mapping places it under; an unmapped conversation is not protected.
2029    ///
2030    /// # Errors
2031    /// As for [`Self::check`].
2032    pub fn release_conversation(&self, conversation: u64) -> Result<Decided, MembershipRefusal> {
2033        let Some(bound) = &self.bound else {
2034            return Ok(Decided::NotProtected);
2035        };
2036        let Some(channel) = bound.gate.channel_for_conversation(conversation) else {
2037            return Ok(Decided::NotProtected);
2038        };
2039        self.decide(channel, MembershipAction::Read, true)
2040    }
2041
2042    fn decide(
2043        &self,
2044        channel: &str,
2045        action: MembershipAction,
2046        release: bool,
2047    ) -> Result<Decided, MembershipRefusal> {
2048        let Some(bound) = &self.bound else {
2049            return Ok(Decided::NotProtected);
2050        };
2051        if !bound.gate.protects(channel) {
2052            return Ok(Decided::NotProtected);
2053        }
2054        let holder = match &bound.holder {
2055            Ok(holder) => holder,
2056            Err(refusal) => {
2057                bump(&bound.gate.counters.refused);
2058                return Err(refusal.clone());
2059            }
2060        };
2061        if release {
2062            bound
2063                .gate
2064                .check_release(bound.pid, Some(holder), channel, action)
2065        } else {
2066            bound.gate.check(bound.pid, Some(holder), channel, action)
2067        }
2068    }
2069}
2070
2071impl ConnectionMembership {
2072    /// The final release check a queued delivery owner carries for
2073    /// `action` on the protected `channel` (ACCESS-006 R3): `None` when the
2074    /// channel is not protected or the connection has no verified subject
2075    /// (its admission was already refused, so no such owner exists).
2076    #[must_use]
2077    pub fn release_check(&self, channel: &str, action: MembershipAction) -> Option<ReleaseCheck> {
2078        let bound = self.bound.as_ref()?;
2079        if !bound.gate.protects(channel) {
2080            return None;
2081        }
2082        let holder = bound.holder.as_ref().ok()?;
2083        Some(ReleaseCheck {
2084            gate: Arc::clone(&bound.gate),
2085            pid: bound.pid,
2086            holder: holder.clone(),
2087            channel: channel.to_owned(),
2088            action,
2089        })
2090    }
2091}
2092
2093/// The membership check a queued delivery owner carries to its final release
2094/// (ACCESS-006 R3).
2095///
2096/// It is the same gate, connection, verified subject, channel and action as
2097/// its admission, decided again at release against the current generation,
2098/// so a deny applied after admission releases nothing.
2099#[derive(Clone, Debug)]
2100pub struct ReleaseCheck {
2101    gate: Arc<MembershipGate>,
2102    pid: u64,
2103    holder: MembershipSubject,
2104    channel: String,
2105    action: MembershipAction,
2106}
2107
2108impl ReleaseCheck {
2109    /// Decides the release now.
2110    ///
2111    /// # Errors
2112    /// As for [`MembershipGate::check`].
2113    pub fn check(&self) -> Result<Decided, MembershipRefusal> {
2114        self.gate
2115            .check_release(self.pid, Some(&self.holder), &self.channel, self.action)
2116    }
2117}
2118
2119impl Drop for ConnectionMembership {
2120    fn drop(&mut self) {
2121        if let Some(bound) = &self.bound {
2122            bound.gate.release_connection(bound.pid);
2123        }
2124    }
2125}
2126
2127/// Builds the membership gate `[auth.lys.membership]` configures over the
2128/// live HTTP provider at `issuer`, reading the app credential once.
2129///
2130/// # Errors
2131/// [`ServerError::ConfigValidation`] for an unreadable credential, a server
2132/// started outside its tokio runtime, a refused issuer, or an invalid
2133/// mapping (see [`MembershipGate::new`]).
2134pub fn configured_gate(
2135    config: &MembershipConfig,
2136    issuer: &str,
2137) -> Result<Arc<MembershipGate>, ServerError> {
2138    let raw = zeroize::Zeroizing::new(std::fs::read_to_string(&config.credential_path).map_err(
2139        |error| {
2140            invalid(format!(
2141                "auth.lys.membership.credential_path {}: {}",
2142                config.credential_path.display(),
2143                error.kind()
2144            ))
2145        },
2146    )?);
2147    let credential = zeroize::Zeroizing::new(raw.trim().to_owned());
2148    let handle = tokio::runtime::Handle::try_current()
2149        .map_err(|_| invalid("auth.lys.membership needs the server's tokio runtime at start"))?;
2150    let provider = HttpMembershipProvider::new(issuer, credential, handle).map_err(|error| {
2151        invalid(format!(
2152            "auth.lys.membership provider refused: {}",
2153            error.cause
2154        ))
2155    })?;
2156    MembershipGate::new(config, Arc::new(provider)).map(Arc::new)
2157}
2158
2159/// Fixtures for the membership tests: a seeded world, a settable authority
2160/// and a verified session. Never compiled into a production build.
2161#[cfg(any(test, feature = "test-support"))]
2162pub mod fixture;
2163
2164#[cfg(test)]
2165#[path = "auth_membership_tests.rs"]
2166mod tests;