Skip to main content

aequora_scope/
lib.rs

1//! Authorized, versioned, database-neutral synchronization datasets.
2//!
3//! A scope is a server-issued authorization contract, never an arbitrary client query. This crate
4//! defines identity, cursor validation, resolver, membership, projection, subscription, and
5//! transition semantics without importing a database or transport implementation.
6
7use std::{collections::BTreeMap, collections::BTreeSet, sync::Arc};
8
9use aequora_types::{
10    ActorId, Cursor, DeviceId, EntityRef, OperationId, Sequence, SyncScopeId, TenantId,
11};
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15use uuid::Uuid;
16
17/// Stable application-assigned scope-definition identity. Zero is reserved.
18#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
19#[serde(transparent)]
20pub struct ScopeDefinitionId(u32);
21
22impl ScopeDefinitionId {
23    /// Creates a non-zero definition identity.
24    ///
25    /// # Errors
26    ///
27    /// Returns [`ScopeError::ZeroIdentity`] for the reserved zero value.
28    pub const fn new(value: u32) -> Result<Self, ScopeError> {
29        if value == 0 {
30            Err(ScopeError::ZeroIdentity)
31        } else {
32            Ok(Self(value))
33        }
34    }
35
36    #[must_use]
37    pub const fn get(self) -> u32 {
38        self.0
39    }
40}
41
42macro_rules! monotonic_id {
43    ($name:ident, $doc:literal) => {
44        #[doc = $doc]
45        #[derive(
46            Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
47        )]
48        #[serde(transparent)]
49        pub struct $name(u64);
50
51        impl $name {
52            pub const INITIAL: Self = Self(1);
53
54            /// Creates a non-zero monotonic identity.
55            ///
56            /// # Errors
57            ///
58            /// Returns [`ScopeError::ZeroIdentity`] for the reserved zero value.
59            pub const fn new(value: u64) -> Result<Self, ScopeError> {
60                if value == 0 {
61                    Err(ScopeError::ZeroIdentity)
62                } else {
63                    Ok(Self(value))
64                }
65            }
66
67            #[must_use]
68            pub const fn get(self) -> u64 {
69                self.0
70            }
71
72            /// Advances without wrapping.
73            ///
74            /// # Errors
75            ///
76            /// Returns [`ScopeError::VersionExhausted`] at `u64::MAX`.
77            pub const fn next(self) -> Result<Self, ScopeError> {
78                match self.0.checked_add(1) {
79                    Some(value) => Ok(Self(value)),
80                    None => Err(ScopeError::VersionExhausted),
81                }
82            }
83        }
84    };
85}
86
87monotonic_id!(ScopeVersion, "Version of membership rules for one scope.");
88monotonic_id!(
89    ScopeGeneration,
90    "Incompatible authority timeline generation for one scope."
91);
92
93macro_rules! uuid_id {
94    ($name:ident, $doc:literal) => {
95        #[doc = $doc]
96        #[derive(
97            Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
98        )]
99        #[serde(transparent)]
100        pub struct $name(Uuid);
101
102        impl $name {
103            #[must_use]
104            pub fn new() -> Self {
105                Self(Uuid::now_v7())
106            }
107        }
108
109        impl Default for $name {
110            fn default() -> Self {
111                Self::new()
112            }
113        }
114    };
115}
116
117uuid_id!(
118    SubscriptionId,
119    "Durable identity of one client subscription."
120);
121uuid_id!(
122    ScopeTransitionId,
123    "Retry-stable identity of one scope transition."
124);
125
126/// Versioned projection namespace. Different field visibility must never be merged accidentally.
127#[derive(
128    Clone, Copy, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
129)]
130#[serde(transparent)]
131pub struct ProjectionVersion(pub u32);
132
133/// Stable, bounded partition selected by a server-owned scope definition.
134#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
135pub struct DatasetPartitionId {
136    pub kind: u16,
137    pub value: Vec<u8>,
138}
139
140impl DatasetPartitionId {
141    pub const MAX_VALUE_BYTES: usize = 1_024;
142
143    /// Validates the portable partition representation.
144    ///
145    /// # Errors
146    ///
147    /// Returns a typed error for a reserved kind or unbounded value.
148    pub fn validate(&self) -> Result<(), ScopeError> {
149        if self.kind == 0 || self.value.is_empty() || self.value.len() > Self::MAX_VALUE_BYTES {
150            return Err(ScopeError::InvalidPartition);
151        }
152        Ok(())
153    }
154}
155
156/// Trusted authenticated identity supplied to a scope resolver by the server boundary.
157#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
158pub struct ScopePrincipal {
159    pub tenant_id: TenantId,
160    pub actor_id: ActorId,
161    pub device_id: DeviceId,
162}
163
164/// Bounded client request for one registered definition. Parameters are opaque to the core.
165#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
166pub struct ScopeRequest {
167    pub definition: ScopeDefinitionId,
168    pub parameters: Vec<DatasetPartitionId>,
169}
170
171impl ScopeRequest {
172    pub const MAX_PARAMETERS: usize = 32;
173
174    /// Enforces structural request limits before application resolution.
175    ///
176    /// # Errors
177    ///
178    /// Returns a typed error when parameters are excessive or malformed.
179    pub fn validate(&self) -> Result<(), ScopeError> {
180        if self.parameters.len() > Self::MAX_PARAMETERS {
181            return Err(ScopeError::TooManyParameters);
182        }
183        self.parameters
184            .iter()
185            .try_for_each(DatasetPartitionId::validate)
186    }
187}
188
189/// Canonical server-owned dataset descriptor; it contains no raw SQL or executable predicate.
190#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
191pub struct ScopeDescriptor {
192    pub tenant_id: TenantId,
193    pub definition: ScopeDefinitionId,
194    pub partitions: BTreeSet<DatasetPartitionId>,
195    pub policy_version: u32,
196    pub projection: ProjectionVersion,
197}
198
199impl ScopeDescriptor {
200    /// Validates canonical identity and bounds.
201    ///
202    /// # Errors
203    ///
204    /// Returns a typed error for tenant mismatch, zero policy, or malformed partitions.
205    pub fn validate_for(&self, principal: ScopePrincipal) -> Result<(), ScopeError> {
206        if self.tenant_id != principal.tenant_id {
207            return Err(ScopeError::TenantMismatch);
208        }
209        if self.policy_version == 0 || self.partitions.len() > ScopeRequest::MAX_PARAMETERS {
210            return Err(ScopeError::InvalidDescriptor);
211        }
212        self.partitions
213            .iter()
214            .try_for_each(DatasetPartitionId::validate)
215    }
216}
217
218/// Complete authoritative binding returned by a registered resolver.
219#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
220pub struct ResolvedScope {
221    pub scope_id: SyncScopeId,
222    pub version: ScopeVersion,
223    pub generation: ScopeGeneration,
224    pub descriptor: ScopeDescriptor,
225}
226
227/// A journal watermark meaningful only with its complete scope binding.
228#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
229pub struct ScopeCursor {
230    pub scope_id: SyncScopeId,
231    pub version: ScopeVersion,
232    pub generation: ScopeGeneration,
233    pub sequence: Sequence,
234}
235
236impl ScopeCursor {
237    /// Validates a cursor against the currently authorized binding and retention floor.
238    #[must_use]
239    pub fn validate(self, current: &ResolvedScope, retained_floor: Sequence) -> CursorValidity {
240        if self.scope_id != current.scope_id {
241            CursorValidity::ScopeChanged
242        } else if self.generation != current.generation {
243            CursorValidity::ResyncRequired
244        } else if self.version != current.version {
245            CursorValidity::ScopeChanged
246        } else if self.sequence < retained_floor {
247            CursorValidity::ResyncRequired
248        } else {
249            CursorValidity::Valid
250        }
251    }
252
253    /// Narrows a validated binding to the legacy cursor consumed by journal, bootstrap, and
254    /// anti-entropy adapters. Callers retain this full binding beside the returned value.
255    #[must_use]
256    pub const fn legacy_cursor(self) -> Cursor {
257        Cursor::legacy(self.scope_id, self.sequence)
258    }
259}
260
261/// Server result for one bound cursor.
262#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
263pub enum CursorValidity {
264    Valid,
265    ScopeChanged,
266    ResyncRequired,
267    UpgradeRequired,
268    Forbidden,
269}
270
271/// One authoritative journal entry evaluated by a server-owned scope filter.
272#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
273pub struct EvaluatedJournalEntry<E> {
274    pub sequence: Sequence,
275    pub value: E,
276}
277
278/// Filtered delivery plus a watermark through all evaluated entries, including excluded ones.
279#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
280pub struct FilteredScopePage<P> {
281    pub projected: Vec<P>,
282    pub next_cursor: ScopeCursor,
283    pub has_more: bool,
284}
285
286/// Applies a deterministic authorized projection while advancing through intentionally excluded
287/// journal entries.
288///
289/// `evaluated_through` is the greatest global sequence the authoritative store proved complete for
290/// this page. Therefore the returned scope cursor is a watermark, not merely the last delivered
291/// event.
292///
293/// # Errors
294///
295/// Returns a typed error for a mismatched prior cursor, decreasing/incomplete watermark, or
296/// unordered journal input.
297pub fn project_filtered_page<E, P, F>(
298    scope: &ResolvedScope,
299    prior: Option<ScopeCursor>,
300    evaluated_through: Sequence,
301    has_more: bool,
302    entries: impl IntoIterator<Item = EvaluatedJournalEntry<E>>,
303    mut project: F,
304) -> Result<FilteredScopePage<P>, ScopeError>
305where
306    F: FnMut(&ResolvedScope, E) -> Option<P>,
307{
308    let prior_sequence = if let Some(cursor) = prior {
309        if cursor.validate(scope, Sequence(0)) != CursorValidity::Valid {
310            return Err(ScopeError::CursorMismatch);
311        }
312        cursor.sequence
313    } else {
314        Sequence(0)
315    };
316    if evaluated_through < prior_sequence {
317        return Err(ScopeError::CursorMismatch);
318    }
319    let mut last = prior_sequence;
320    let mut projected = Vec::new();
321    for entry in entries {
322        if entry.sequence <= last || entry.sequence > evaluated_through {
323            return Err(ScopeError::UnorderedJournal);
324        }
325        last = entry.sequence;
326        if let Some(value) = project(scope, entry.value) {
327            projected.push(value);
328        }
329    }
330    Ok(FilteredScopePage {
331        projected,
332        next_cursor: ScopeCursor {
333            scope_id: scope.scope_id,
334            version: scope.version,
335            generation: scope.generation,
336            sequence: evaluated_through,
337        },
338        has_more,
339    })
340}
341
342/// Current server view of one subscription.
343#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
344pub enum ScopeServerState {
345    Active,
346    Expanded,
347    Contracted,
348    Suspended,
349    Revoked,
350    GenerationChanged,
351}
352
353/// Payload-free status suitable for a negotiated scope control message.
354#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
355pub struct ScopeStatus {
356    pub scope_id: SyncScopeId,
357    pub version: ScopeVersion,
358    pub generation: ScopeGeneration,
359    pub state: ScopeServerState,
360}
361
362/// Versioned server instruction. Legacy peers safely map non-continue instructions to full resync.
363#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
364pub enum ScopeTransitionInstruction {
365    Continue,
366    ExpandWithBootstrap {
367        transition_id: ScopeTransitionId,
368        boundary: ScopeCursor,
369    },
370    ContractWithRemovals {
371        transition_id: ScopeTransitionId,
372        boundary: ScopeCursor,
373        removals: Vec<ScopeRemoval>,
374    },
375    FullResync,
376    Revoke,
377    Suspend,
378}
379
380/// Safe bootstrap strategy for a version transition.
381#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
382pub enum ScopeBootstrapMode {
383    FullScope,
384    AddedPartitions,
385}
386
387/// Scope-bound bootstrap plan; activation still requires an atomic [`ScopeTransition`].
388#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
389pub struct ScopeBootstrapPlan {
390    pub transition_id: ScopeTransitionId,
391    pub target: ResolvedScope,
392    pub boundary: ScopeCursor,
393    pub mode: ScopeBootstrapMode,
394    pub partitions: BTreeSet<DatasetPartitionId>,
395}
396
397impl ScopeBootstrapPlan {
398    /// Validates snapshot identity, version, generation, and partial partitions together.
399    ///
400    /// # Errors
401    ///
402    /// Returns a typed error instead of allowing a mixed-scope bootstrap.
403    pub fn validate(&self) -> Result<(), ScopeError> {
404        if self.boundary.scope_id != self.target.scope_id
405            || self.boundary.version != self.target.version
406            || self.boundary.generation != self.target.generation
407            || (self.mode == ScopeBootstrapMode::FullScope && !self.partitions.is_empty())
408            || (self.mode == ScopeBootstrapMode::AddedPartitions && self.partitions.is_empty())
409            || !self
410                .partitions
411                .is_subset(&self.target.descriptor.partitions)
412        {
413            return Err(ScopeError::InvalidTransition);
414        }
415        Ok(())
416    }
417}
418
419/// Authorization hook for blobs, integrity manifests, search indexes, or other scope-bound
420/// resources. Possessing a stale resource identifier never grants access by itself.
421pub trait ScopeResourceAuthorizer<R>: Send + Sync {
422    fn authorized(&self, principal: ScopePrincipal, scope: &ResolvedScope, resource: &R) -> bool;
423}
424
425/// Server-owned asynchronous scope resolution contract.
426#[async_trait]
427pub trait ScopeResolver: Send + Sync {
428    fn definition_id(&self) -> ScopeDefinitionId;
429
430    async fn resolve(
431        &self,
432        principal: ScopePrincipal,
433        request: &ScopeRequest,
434    ) -> Result<ResolvedScope, ScopeError>;
435}
436
437/// Definition registry that rejects duplicate identifiers and unknown client requests.
438#[derive(Default)]
439pub struct ScopeRegistry {
440    definitions: BTreeMap<ScopeDefinitionId, Arc<dyn ScopeResolver>>,
441}
442
443impl ScopeRegistry {
444    /// Registers one definition.
445    ///
446    /// # Errors
447    ///
448    /// Returns [`ScopeError::DuplicateDefinition`] instead of silently replacing policy.
449    pub fn register(&mut self, resolver: Arc<dyn ScopeResolver>) -> Result<(), ScopeError> {
450        let id = resolver.definition_id();
451        if self.definitions.contains_key(&id) {
452            return Err(ScopeError::DuplicateDefinition(id));
453        }
454        self.definitions.insert(id, resolver);
455        Ok(())
456    }
457
458    /// Resolves and validates a requested scope after authentication.
459    ///
460    /// # Errors
461    ///
462    /// Returns a typed request, lookup, authorization, or descriptor error.
463    pub async fn resolve(
464        &self,
465        principal: ScopePrincipal,
466        request: &ScopeRequest,
467    ) -> Result<ResolvedScope, ScopeError> {
468        request.validate()?;
469        let definition = self
470            .definitions
471            .get(&request.definition)
472            .ok_or(ScopeError::UnknownDefinition(request.definition))?;
473        let resolved = definition.resolve(principal, request).await?;
474        if resolved.descriptor.definition != request.definition {
475            return Err(ScopeError::InvalidDescriptor);
476        }
477        resolved.descriptor.validate_for(principal)?;
478        Ok(resolved)
479    }
480}
481
482/// Deterministic membership result for an authoritative entity/event.
483#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
484pub enum MembershipDecision {
485    Include,
486    Exclude,
487    RequiresProjection,
488}
489
490/// Pure application membership boundary.
491pub trait MembershipEvaluator<E>: Send + Sync {
492    fn membership(&self, scope: &ResolvedScope, entity: &E) -> MembershipDecision;
493}
494
495/// Authorized projection boundary. `None` means the entity must not be delivered.
496pub trait ProjectionRule<E>: Send + Sync {
497    type Projected;
498
499    fn project(&self, scope: &ResolvedScope, entity: &E) -> Option<Self::Projected>;
500}
501
502/// Durable client subscription lifecycle.
503#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
504pub enum SubscriptionState {
505    Requested,
506    Resolved,
507    Bootstrapping,
508    Active,
509    Expanding,
510    Contracting,
511    Suspended,
512    Revoked,
513    ResyncRequired,
514}
515
516/// Durable relationship between a client and one resolved scope.
517#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
518pub struct Subscription {
519    pub subscription_id: SubscriptionId,
520    pub scope: ResolvedScope,
521    pub state: SubscriptionState,
522    pub cursor: Option<ScopeCursor>,
523    pub pending_transition: Option<ScopeTransitionId>,
524}
525
526/// Why membership ended without deleting the authoritative domain entity.
527#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
528pub enum ScopeRemovalReason {
529    MembershipChanged,
530    PermissionDowngrade,
531    SubscriptionEnded,
532    Revoked,
533}
534
535/// Scope-local removal, deliberately distinct from a domain tombstone.
536#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
537pub struct ScopeRemoval {
538    pub scope_id: SyncScopeId,
539    pub projection: ProjectionVersion,
540    pub entity: EntityRef,
541    pub reason: ScopeRemovalReason,
542}
543
544/// One active entity-to-scope reference.
545#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
546pub struct MembershipRecord {
547    pub scope_id: SyncScopeId,
548    pub projection: ProjectionVersion,
549    pub entity: EntityRef,
550    pub membership_version: ScopeVersion,
551}
552
553/// Explicit disposition of pending intent affected by authorization loss.
554#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
555pub enum PendingIntentDisposition {
556    AuthorizationLost,
557    ScopeRevoked,
558    ScopeRemoved,
559}
560
561/// Local retention behavior for data leaving an active scope.
562#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
563pub enum LocalRetentionPolicy {
564    RemoveImmediately,
565    RetainReadOnly,
566    RetainUntilUnixMs(u64),
567    ApplicationManaged,
568}
569
570/// Coherent transition category.
571#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
572pub enum ScopeTransitionKind {
573    FullBootstrap,
574    Expansion {
575        added_partitions: BTreeSet<DatasetPartitionId>,
576    },
577    Contraction {
578        removed_partitions: BTreeSet<DatasetPartitionId>,
579        retention: LocalRetentionPolicy,
580    },
581    Revocation,
582    Suspension,
583    GenerationReset,
584}
585
586/// Retry-stable atomic transition input for a local adapter.
587#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
588pub struct ScopeTransition {
589    pub transition_id: ScopeTransitionId,
590    pub subscription_id: SubscriptionId,
591    pub from_version: ScopeVersion,
592    pub from_generation: ScopeGeneration,
593    pub target: Option<ResolvedScope>,
594    pub boundary: Option<ScopeCursor>,
595    pub kind: ScopeTransitionKind,
596    pub additions: Vec<MembershipRecord>,
597    pub removals: Vec<ScopeRemoval>,
598    pub affected_pending_operations: Vec<OperationId>,
599    /// False means expansion/bootstrap staging may persist but cannot become active.
600    pub staging_complete: bool,
601}
602
603impl ScopeTransition {
604    /// Validates identity/version/generation coherence before any adapter mutation.
605    ///
606    /// # Errors
607    ///
608    /// Returns a typed error for mixed scopes, stale versions, or unsafe activation.
609    pub fn validate(&self, current: &Subscription) -> Result<(), ScopeError> {
610        if current.subscription_id != self.subscription_id
611            || current.scope.version != self.from_version
612            || current.scope.generation != self.from_generation
613        {
614            return Err(ScopeError::StaleTransition);
615        }
616        if matches!(
617            self.kind,
618            ScopeTransitionKind::Revocation | ScopeTransitionKind::Suspension
619        ) {
620            if self.target.is_some() || self.boundary.is_some() {
621                return Err(ScopeError::InvalidTransition);
622            }
623        } else {
624            let target = self.target.as_ref().ok_or(ScopeError::InvalidTransition)?;
625            let boundary = self.boundary.ok_or(ScopeError::InvalidTransition)?;
626            if target.scope_id != current.scope.scope_id
627                || boundary.scope_id != target.scope_id
628                || boundary.version != target.version
629                || boundary.generation != target.generation
630                || target.descriptor.tenant_id != current.scope.descriptor.tenant_id
631            {
632                return Err(ScopeError::InvalidTransition);
633            }
634            if target.generation == self.from_generation {
635                if target.version <= self.from_version {
636                    return Err(ScopeError::StaleTransition);
637                }
638            } else if target.generation <= self.from_generation
639                || !matches!(
640                    self.kind,
641                    ScopeTransitionKind::GenerationReset | ScopeTransitionKind::FullBootstrap
642                )
643            {
644                return Err(ScopeError::InvalidTransition);
645            }
646        }
647        let scope = current.scope.scope_id;
648        if self.additions.iter().any(|item| item.scope_id != scope)
649            || self.removals.iter().any(|item| item.scope_id != scope)
650        {
651            return Err(ScopeError::InvalidTransition);
652        }
653        Ok(())
654    }
655}
656
657/// Result of one idempotent transition application.
658#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
659pub struct ScopeTransitionOutcome {
660    pub transition_id: ScopeTransitionId,
661    pub applied: bool,
662    pub physical_removals: BTreeSet<EntityRef>,
663    pub quarantined_operations: BTreeSet<OperationId>,
664}
665
666/// Serializable reference model used by adapters and compliance tests.
667#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
668pub struct LocalScopeState {
669    subscriptions: BTreeMap<SubscriptionId, Subscription>,
670    memberships: BTreeMap<(ProjectionVersion, EntityRef), BTreeSet<SyncScopeId>>,
671    applied_transitions: BTreeSet<ScopeTransitionId>,
672    quarantined_operations: BTreeMap<OperationId, PendingIntentDisposition>,
673}
674
675impl LocalScopeState {
676    /// Installs a newly server-resolved subscription without activating data.
677    ///
678    /// # Errors
679    ///
680    /// Returns an error when either identity is already present.
681    pub fn install(&mut self, subscription: Subscription) -> Result<(), ScopeError> {
682        if self
683            .subscriptions
684            .contains_key(&subscription.subscription_id)
685            || self
686                .subscriptions
687                .values()
688                .any(|known| known.scope.scope_id == subscription.scope.scope_id)
689        {
690            return Err(ScopeError::DuplicateSubscription);
691        }
692        self.subscriptions
693            .insert(subscription.subscription_id, subscription);
694        Ok(())
695    }
696
697    #[must_use]
698    pub fn subscription(&self, id: SubscriptionId) -> Option<&Subscription> {
699        self.subscriptions.get(&id)
700    }
701
702    #[must_use]
703    pub fn active_scopes(
704        &self,
705        projection: ProjectionVersion,
706        entity: EntityRef,
707    ) -> BTreeSet<SyncScopeId> {
708        self.memberships
709            .get(&(projection, entity))
710            .cloned()
711            .unwrap_or_default()
712    }
713
714    #[must_use]
715    pub fn pending_disposition(&self, operation: OperationId) -> Option<PendingIntentDisposition> {
716        self.quarantined_operations.get(&operation).copied()
717    }
718
719    #[must_use]
720    pub fn active_subscription_count(&self) -> usize {
721        self.subscriptions
722            .values()
723            .filter(|item| item.state == SubscriptionState::Active)
724            .count()
725    }
726
727    #[must_use]
728    pub fn pending_transition_count(&self) -> usize {
729        self.subscriptions
730            .values()
731            .filter(|item| item.pending_transition.is_some())
732            .count()
733    }
734
735    #[must_use]
736    pub fn membership_reference_count(&self) -> usize {
737        self.memberships.values().map(BTreeSet::len).sum()
738    }
739
740    #[must_use]
741    pub fn quarantined_operation_count(&self) -> usize {
742        self.quarantined_operations.len()
743    }
744
745    /// Applies a transition atomically to a cloned model, swapping only after all checks pass.
746    ///
747    /// # Errors
748    ///
749    /// Returns a typed error without changing state when the transition is invalid or stale.
750    pub fn apply(
751        &mut self,
752        transition: &ScopeTransition,
753    ) -> Result<ScopeTransitionOutcome, ScopeError> {
754        if self.applied_transitions.contains(&transition.transition_id) {
755            return Ok(ScopeTransitionOutcome {
756                transition_id: transition.transition_id,
757                applied: false,
758                physical_removals: BTreeSet::new(),
759                quarantined_operations: BTreeSet::new(),
760            });
761        }
762        let mut next = self.clone();
763        let outcome = next.apply_checked(transition)?;
764        *self = next;
765        Ok(outcome)
766    }
767
768    #[allow(clippy::too_many_lines)]
769    fn apply_checked(
770        &mut self,
771        transition: &ScopeTransition,
772    ) -> Result<ScopeTransitionOutcome, ScopeError> {
773        let current = self
774            .subscriptions
775            .get(&transition.subscription_id)
776            .cloned()
777            .ok_or(ScopeError::UnknownSubscription)?;
778        transition.validate(&current)?;
779
780        if !transition.staging_complete
781            && matches!(
782                transition.kind,
783                ScopeTransitionKind::Expansion { .. }
784                    | ScopeTransitionKind::FullBootstrap
785                    | ScopeTransitionKind::GenerationReset
786            )
787        {
788            let subscription = self
789                .subscriptions
790                .get_mut(&transition.subscription_id)
791                .ok_or(ScopeError::UnknownSubscription)?;
792            subscription.state = if matches!(transition.kind, ScopeTransitionKind::Expansion { .. })
793            {
794                SubscriptionState::Expanding
795            } else {
796                SubscriptionState::Bootstrapping
797            };
798            subscription.pending_transition = Some(transition.transition_id);
799            return Ok(ScopeTransitionOutcome {
800                transition_id: transition.transition_id,
801                applied: false,
802                physical_removals: BTreeSet::new(),
803                quarantined_operations: BTreeSet::new(),
804            });
805        }
806
807        let mut physical_removals = BTreeSet::new();
808        let disposition = match transition.kind {
809            ScopeTransitionKind::Revocation => PendingIntentDisposition::ScopeRevoked,
810            ScopeTransitionKind::Contraction { .. } => PendingIntentDisposition::ScopeRemoved,
811            _ => PendingIntentDisposition::AuthorizationLost,
812        };
813        let scope_id = current.scope.scope_id;
814
815        if matches!(
816            transition.kind,
817            ScopeTransitionKind::FullBootstrap
818                | ScopeTransitionKind::GenerationReset
819                | ScopeTransitionKind::Revocation
820        ) {
821            let keys = self
822                .memberships
823                .iter()
824                .filter(|(_, scopes)| scopes.contains(&scope_id))
825                .map(|(key, _)| *key)
826                .collect::<Vec<_>>();
827            for key in keys {
828                self.remove_reference(key.0, key.1, scope_id, &mut physical_removals);
829            }
830        }
831        for removal in &transition.removals {
832            self.remove_reference(
833                removal.projection,
834                removal.entity,
835                scope_id,
836                &mut physical_removals,
837            );
838        }
839        for addition in &transition.additions {
840            self.memberships
841                .entry((addition.projection, addition.entity))
842                .or_default()
843                .insert(scope_id);
844        }
845        let mut quarantined_operations = BTreeSet::new();
846        for operation in &transition.affected_pending_operations {
847            self.quarantined_operations.insert(*operation, disposition);
848            quarantined_operations.insert(*operation);
849        }
850
851        let subscription = self
852            .subscriptions
853            .get_mut(&transition.subscription_id)
854            .ok_or(ScopeError::UnknownSubscription)?;
855        match transition.kind {
856            ScopeTransitionKind::Revocation => {
857                subscription.state = SubscriptionState::Revoked;
858                subscription.cursor = None;
859            }
860            ScopeTransitionKind::Suspension => subscription.state = SubscriptionState::Suspended,
861            _ => {
862                subscription.scope = transition
863                    .target
864                    .clone()
865                    .ok_or(ScopeError::InvalidTransition)?;
866                subscription.cursor = transition.boundary;
867                subscription.state = SubscriptionState::Active;
868            }
869        }
870        subscription.pending_transition = None;
871        self.applied_transitions.insert(transition.transition_id);
872        Ok(ScopeTransitionOutcome {
873            transition_id: transition.transition_id,
874            applied: true,
875            physical_removals,
876            quarantined_operations,
877        })
878    }
879
880    fn remove_reference(
881        &mut self,
882        projection: ProjectionVersion,
883        entity: EntityRef,
884        scope: SyncScopeId,
885        physical_removals: &mut BTreeSet<EntityRef>,
886    ) {
887        let key = (projection, entity);
888        if let Some(scopes) = self.memberships.get_mut(&key) {
889            scopes.remove(&scope);
890            if scopes.is_empty() {
891                self.memberships.remove(&key);
892                physical_removals.insert(entity);
893            }
894        }
895    }
896}
897
898/// Scope semantic failure.
899#[derive(Clone, Debug, Error, Eq, PartialEq)]
900pub enum ScopeError {
901    #[error("zero is reserved for scope identities and versions")]
902    ZeroIdentity,
903    #[error("scope version space is exhausted")]
904    VersionExhausted,
905    #[error("scope partition is empty, unbounded, or uses a reserved kind")]
906    InvalidPartition,
907    #[error("scope request has too many parameters")]
908    TooManyParameters,
909    #[error("resolved scope tenant differs from authenticated tenant")]
910    TenantMismatch,
911    #[error("resolved scope descriptor is inconsistent")]
912    InvalidDescriptor,
913    #[error("scope definition {0:?} is not registered")]
914    UnknownDefinition(ScopeDefinitionId),
915    #[error("scope definition {0:?} is already registered")]
916    DuplicateDefinition(ScopeDefinitionId),
917    #[error("subscription identity or scope is already installed")]
918    DuplicateSubscription,
919    #[error("subscription is not installed")]
920    UnknownSubscription,
921    #[error("scope transition does not match current version or generation")]
922    StaleTransition,
923    #[error("scope transition mixes identities or violates activation rules")]
924    InvalidTransition,
925    #[error("scope is not authorized for this principal")]
926    Forbidden,
927    #[error("scope cursor does not match the current authorized binding")]
928    CursorMismatch,
929    #[error("scope-filtered journal input is unordered or exceeds its evaluated watermark")]
930    UnorderedJournal,
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936    use aequora_types::{EntityId, EntityType};
937    use proptest::prelude::*;
938
939    struct TenantResolver(ScopeDefinitionId);
940
941    #[async_trait]
942    impl ScopeResolver for TenantResolver {
943        fn definition_id(&self) -> ScopeDefinitionId {
944            self.0
945        }
946
947        async fn resolve(
948            &self,
949            principal: ScopePrincipal,
950            _request: &ScopeRequest,
951        ) -> Result<ResolvedScope, ScopeError> {
952            Ok(ResolvedScope {
953                scope_id: SyncScopeId::new(),
954                version: ScopeVersion::INITIAL,
955                generation: ScopeGeneration::INITIAL,
956                descriptor: ScopeDescriptor {
957                    tenant_id: principal.tenant_id,
958                    definition: self.0,
959                    partitions: BTreeSet::new(),
960                    policy_version: 1,
961                    projection: ProjectionVersion(1),
962                },
963            })
964        }
965    }
966
967    fn definition() -> ScopeDefinitionId {
968        ScopeDefinitionId::new(1).unwrap_or_else(|error| panic!("{error}"))
969    }
970
971    fn entity() -> EntityRef {
972        EntityRef {
973            entity_type: EntityType::new(1).unwrap_or_else(|error| panic!("{error}")),
974            entity_id: EntityId::new(),
975        }
976    }
977
978    fn resolved(scope_id: SyncScopeId, version: ScopeVersion) -> ResolvedScope {
979        ResolvedScope {
980            scope_id,
981            version,
982            generation: ScopeGeneration::INITIAL,
983            descriptor: ScopeDescriptor {
984                tenant_id: TenantId::new(),
985                definition: definition(),
986                partitions: BTreeSet::new(),
987                policy_version: 1,
988                projection: ProjectionVersion(1),
989            },
990        }
991    }
992
993    fn subscription(scope: ResolvedScope) -> Subscription {
994        Subscription {
995            subscription_id: SubscriptionId::new(),
996            scope,
997            state: SubscriptionState::Active,
998            cursor: None,
999            pending_transition: None,
1000        }
1001    }
1002
1003    #[test]
1004    fn cursor_requires_exact_identity_version_generation_and_retention() {
1005        let scope = resolved(SyncScopeId::new(), ScopeVersion::INITIAL);
1006        let cursor = ScopeCursor {
1007            scope_id: scope.scope_id,
1008            version: scope.version,
1009            generation: scope.generation,
1010            sequence: Sequence(9),
1011        };
1012        assert_eq!(cursor.validate(&scope, Sequence(8)), CursorValidity::Valid);
1013        assert_eq!(
1014            cursor.validate(&scope, Sequence(10)),
1015            CursorValidity::ResyncRequired
1016        );
1017        let newer = resolved(
1018            scope.scope_id,
1019            scope
1020                .version
1021                .next()
1022                .unwrap_or_else(|error| panic!("{error}")),
1023        );
1024        assert_eq!(
1025            cursor.validate(&newer, Sequence(0)),
1026            CursorValidity::ScopeChanged
1027        );
1028    }
1029
1030    #[tokio::test]
1031    async fn registry_uses_authenticated_tenant_and_rejects_duplicate_policy() {
1032        let mut registry = ScopeRegistry::default();
1033        registry
1034            .register(Arc::new(TenantResolver(definition())))
1035            .unwrap_or_else(|error| panic!("{error}"));
1036        assert_eq!(
1037            registry.register(Arc::new(TenantResolver(definition()))),
1038            Err(ScopeError::DuplicateDefinition(definition()))
1039        );
1040        let principal = ScopePrincipal {
1041            tenant_id: TenantId::new(),
1042            actor_id: ActorId::new(),
1043            device_id: DeviceId::new(),
1044        };
1045        let resolved = registry
1046            .resolve(
1047                principal,
1048                &ScopeRequest {
1049                    definition: definition(),
1050                    parameters: Vec::new(),
1051                },
1052            )
1053            .await
1054            .unwrap_or_else(|error| panic!("{error}"));
1055        assert_eq!(resolved.descriptor.tenant_id, principal.tenant_id);
1056    }
1057
1058    #[test]
1059    fn filtered_cursor_advances_across_excluded_entries() {
1060        let scope = resolved(SyncScopeId::new(), ScopeVersion::INITIAL);
1061        let page = project_filtered_page(
1062            &scope,
1063            None,
1064            Sequence(3),
1065            false,
1066            [
1067                EvaluatedJournalEntry {
1068                    sequence: Sequence(1),
1069                    value: 1_u8,
1070                },
1071                EvaluatedJournalEntry {
1072                    sequence: Sequence(2),
1073                    value: 2_u8,
1074                },
1075                EvaluatedJournalEntry {
1076                    sequence: Sequence(3),
1077                    value: 3_u8,
1078                },
1079            ],
1080            |_scope, value| (value != 2).then_some(value),
1081        )
1082        .unwrap_or_else(|error| panic!("{error}"));
1083        assert_eq!(page.projected, vec![1, 3]);
1084        assert_eq!(page.next_cursor.sequence, Sequence(3));
1085    }
1086
1087    #[test]
1088    fn contraction_preserves_an_entity_referenced_by_another_scope() {
1089        let shared = entity();
1090        let first = resolved(SyncScopeId::new(), ScopeVersion::INITIAL);
1091        let second = resolved(SyncScopeId::new(), ScopeVersion::INITIAL);
1092        let first_subscription = subscription(first.clone());
1093        let second_subscription = subscription(second.clone());
1094        let mut state = LocalScopeState::default();
1095        state
1096            .install(first_subscription.clone())
1097            .unwrap_or_else(|error| panic!("{error}"));
1098        state
1099            .install(second_subscription)
1100            .unwrap_or_else(|error| panic!("{error}"));
1101        state.memberships.insert(
1102            (ProjectionVersion(1), shared),
1103            BTreeSet::from([first.scope_id, second.scope_id]),
1104        );
1105        let next_version = first
1106            .version
1107            .next()
1108            .unwrap_or_else(|error| panic!("{error}"));
1109        let mut target = first.clone();
1110        target.version = next_version;
1111        let transition = ScopeTransition {
1112            transition_id: ScopeTransitionId::new(),
1113            subscription_id: first_subscription.subscription_id,
1114            from_version: first.version,
1115            from_generation: first.generation,
1116            target: Some(target),
1117            boundary: Some(ScopeCursor {
1118                scope_id: first.scope_id,
1119                version: next_version,
1120                generation: first.generation,
1121                sequence: Sequence(10),
1122            }),
1123            kind: ScopeTransitionKind::Contraction {
1124                removed_partitions: BTreeSet::new(),
1125                retention: LocalRetentionPolicy::RemoveImmediately,
1126            },
1127            additions: Vec::new(),
1128            removals: vec![ScopeRemoval {
1129                scope_id: first.scope_id,
1130                projection: ProjectionVersion(1),
1131                entity: shared,
1132                reason: ScopeRemovalReason::MembershipChanged,
1133            }],
1134            affected_pending_operations: Vec::new(),
1135            staging_complete: true,
1136        };
1137        let outcome = state
1138            .apply(&transition)
1139            .unwrap_or_else(|error| panic!("{error}"));
1140        assert!(outcome.physical_removals.is_empty());
1141        assert_eq!(
1142            state.active_scopes(ProjectionVersion(1), shared),
1143            BTreeSet::from([second.scope_id])
1144        );
1145    }
1146
1147    #[test]
1148    fn incomplete_expansion_never_activates_membership_and_retry_is_idempotent() {
1149        let original = resolved(SyncScopeId::new(), ScopeVersion::INITIAL);
1150        let subscription = subscription(original.clone());
1151        let mut state = LocalScopeState::default();
1152        state
1153            .install(subscription.clone())
1154            .unwrap_or_else(|error| panic!("{error}"));
1155        let added = entity();
1156        let target_version = original
1157            .version
1158            .next()
1159            .unwrap_or_else(|error| panic!("{error}"));
1160        let mut target = original.clone();
1161        target.version = target_version;
1162        let mut transition = ScopeTransition {
1163            transition_id: ScopeTransitionId::new(),
1164            subscription_id: subscription.subscription_id,
1165            from_version: original.version,
1166            from_generation: original.generation,
1167            target: Some(target),
1168            boundary: Some(ScopeCursor {
1169                scope_id: original.scope_id,
1170                version: target_version,
1171                generation: original.generation,
1172                sequence: Sequence(20),
1173            }),
1174            kind: ScopeTransitionKind::Expansion {
1175                added_partitions: BTreeSet::new(),
1176            },
1177            additions: vec![MembershipRecord {
1178                scope_id: original.scope_id,
1179                projection: ProjectionVersion(1),
1180                entity: added,
1181                membership_version: target_version,
1182            }],
1183            removals: Vec::new(),
1184            affected_pending_operations: Vec::new(),
1185            staging_complete: false,
1186        };
1187        assert!(
1188            !state
1189                .apply(&transition)
1190                .unwrap_or_else(|error| panic!("{error}"))
1191                .applied
1192        );
1193        assert!(state.active_scopes(ProjectionVersion(1), added).is_empty());
1194        transition.staging_complete = true;
1195        assert!(
1196            state
1197                .apply(&transition)
1198                .unwrap_or_else(|error| panic!("{error}"))
1199                .applied
1200        );
1201        assert!(
1202            !state
1203                .apply(&transition)
1204                .unwrap_or_else(|error| panic!("{error}"))
1205                .applied
1206        );
1207    }
1208
1209    #[test]
1210    fn revocation_deactivates_data_and_quarantines_pending_intent() {
1211        let scope = resolved(SyncScopeId::new(), ScopeVersion::INITIAL);
1212        let subscription = subscription(scope.clone());
1213        let operation = OperationId::new();
1214        let member = entity();
1215        let mut state = LocalScopeState::default();
1216        state
1217            .install(subscription.clone())
1218            .unwrap_or_else(|error| panic!("{error}"));
1219        state.memberships.insert(
1220            (ProjectionVersion(1), member),
1221            BTreeSet::from([scope.scope_id]),
1222        );
1223        let transition = ScopeTransition {
1224            transition_id: ScopeTransitionId::new(),
1225            subscription_id: subscription.subscription_id,
1226            from_version: scope.version,
1227            from_generation: scope.generation,
1228            target: None,
1229            boundary: None,
1230            kind: ScopeTransitionKind::Revocation,
1231            additions: Vec::new(),
1232            removals: Vec::new(),
1233            affected_pending_operations: vec![operation],
1234            staging_complete: true,
1235        };
1236        let outcome = state
1237            .apply(&transition)
1238            .unwrap_or_else(|error| panic!("{error}"));
1239        assert_eq!(outcome.physical_removals, BTreeSet::from([member]));
1240        assert_eq!(
1241            state.pending_disposition(operation),
1242            Some(PendingIntentDisposition::ScopeRevoked)
1243        );
1244        assert_eq!(
1245            state
1246                .subscription(subscription.subscription_id)
1247                .map(|item| item.state),
1248            Some(SubscriptionState::Revoked)
1249        );
1250    }
1251
1252    proptest! {
1253        #[test]
1254        fn scope_versions_never_wrap(start in 1_u64..u64::MAX) {
1255            let version = ScopeVersion::new(start).unwrap_or_else(|error| panic!("{error}"));
1256            let next = version.next();
1257            if start == u64::MAX {
1258                prop_assert_eq!(next, Err(ScopeError::VersionExhausted));
1259            } else {
1260                prop_assert_eq!(next.map(ScopeVersion::get), Ok(start + 1));
1261            }
1262        }
1263    }
1264
1265    #[test]
1266    fn state_round_trips_without_losing_transition_guards() {
1267        let scope = resolved(SyncScopeId::new(), ScopeVersion::INITIAL);
1268        let mut state = LocalScopeState::default();
1269        state
1270            .install(subscription(scope))
1271            .unwrap_or_else(|error| panic!("{error}"));
1272        let encoded = postcard::to_stdvec(&state).unwrap_or_else(|error| panic!("{error}"));
1273        let decoded: LocalScopeState =
1274            postcard::from_bytes(&encoded).unwrap_or_else(|error| panic!("{error}"));
1275        assert_eq!(decoded, state);
1276    }
1277}