1use 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#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
19#[serde(transparent)]
20pub struct ScopeDefinitionId(u32);
21
22impl ScopeDefinitionId {
23 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 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 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#[derive(
128 Clone, Copy, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
129)]
130#[serde(transparent)]
131pub struct ProjectionVersion(pub u32);
132
133#[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 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#[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#[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 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#[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 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#[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#[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 #[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 #[must_use]
256 pub const fn legacy_cursor(self) -> Cursor {
257 Cursor::legacy(self.scope_id, self.sequence)
258 }
259}
260
261#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
263pub enum CursorValidity {
264 Valid,
265 ScopeChanged,
266 ResyncRequired,
267 UpgradeRequired,
268 Forbidden,
269}
270
271#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
273pub struct EvaluatedJournalEntry<E> {
274 pub sequence: Sequence,
275 pub value: E,
276}
277
278#[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
286pub 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#[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#[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#[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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
382pub enum ScopeBootstrapMode {
383 FullScope,
384 AddedPartitions,
385}
386
387#[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 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
419pub trait ScopeResourceAuthorizer<R>: Send + Sync {
422 fn authorized(&self, principal: ScopePrincipal, scope: &ResolvedScope, resource: &R) -> bool;
423}
424
425#[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#[derive(Default)]
439pub struct ScopeRegistry {
440 definitions: BTreeMap<ScopeDefinitionId, Arc<dyn ScopeResolver>>,
441}
442
443impl ScopeRegistry {
444 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 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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
484pub enum MembershipDecision {
485 Include,
486 Exclude,
487 RequiresProjection,
488}
489
490pub trait MembershipEvaluator<E>: Send + Sync {
492 fn membership(&self, scope: &ResolvedScope, entity: &E) -> MembershipDecision;
493}
494
495pub trait ProjectionRule<E>: Send + Sync {
497 type Projected;
498
499 fn project(&self, scope: &ResolvedScope, entity: &E) -> Option<Self::Projected>;
500}
501
502#[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#[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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
528pub enum ScopeRemovalReason {
529 MembershipChanged,
530 PermissionDowngrade,
531 SubscriptionEnded,
532 Revoked,
533}
534
535#[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#[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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
555pub enum PendingIntentDisposition {
556 AuthorizationLost,
557 ScopeRevoked,
558 ScopeRemoved,
559}
560
561#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
563pub enum LocalRetentionPolicy {
564 RemoveImmediately,
565 RetainReadOnly,
566 RetainUntilUnixMs(u64),
567 ApplicationManaged,
568}
569
570#[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#[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 pub staging_complete: bool,
601}
602
603impl ScopeTransition {
604 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#[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#[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 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 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(¤t)?;
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#[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}