Skip to main content

aequora_executor/
lib.rs

1//! Application-owned authorization and domain execution boundary.
2
3use aequora_protocol::{ChangeKind, OperationEnvelope, RejectionCode, SessionMetadata};
4use aequora_types::{
5    ActorId, DeviceId, EventId, JobId, LineageContext, LineageRef, OperationId, SchemaVersion,
6    TenantId,
7};
8use async_trait::async_trait;
9use serde::de::DeserializeOwned;
10use std::{
11    cmp::Reverse,
12    collections::{BinaryHeap, HashMap},
13    sync::Arc,
14};
15use thiserror::Error;
16
17/// Server-derived authenticated identity. Client claims must match it.
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub struct AuthContext {
20    /// Authenticated actor.
21    pub actor_id: ActorId,
22    /// Authenticated tenant.
23    pub tenant_id: TenantId,
24    /// Authenticated device.
25    pub device_id: DeviceId,
26}
27
28impl AuthContext {
29    /// Converts a policy-validated security identity into the mandatory sync identity.
30    ///
31    /// This is the intended bridge from transport authentication into authoritative execution.
32    /// A protected sync request cannot proceed without an active, server-validated device binding.
33    ///
34    /// # Errors
35    ///
36    /// Returns [`aequora_security::SecurityError::DeviceBindingRequired`] when the validated
37    /// authentication context does not bind an active device required by synchronization.
38    pub fn from_validated_security(
39        context: &aequora_security::ValidatedAuthContext,
40    ) -> Result<Self, aequora_security::SecurityError> {
41        let device_id = context
42            .device_id()
43            .ok_or(aequora_security::SecurityError::DeviceBindingRequired)?;
44        Ok(Self {
45            actor_id: context.principal_id(),
46            tenant_id: context.tenant_id(),
47            device_id,
48        })
49    }
50}
51
52/// Untrusted operation before authenticated identity claims are checked.
53#[derive(Clone, Copy, Debug)]
54pub struct IncomingOperation<'a>(&'a OperationEnvelope);
55
56impl<'a> IncomingOperation<'a> {
57    /// Wraps one structurally validated but still untrusted operation.
58    #[must_use]
59    pub const fn new(operation: &'a OperationEnvelope) -> Self {
60        Self(operation)
61    }
62
63    /// Checks every claimed identity against the connection-derived context.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`ExecutionError`] when tenant, actor, or device claims differ.
68    pub fn authenticate(
69        self,
70        auth: &AuthContext,
71    ) -> Result<AuthenticatedOperation<'a>, ExecutionError> {
72        if self.0.tenant_id != auth.tenant_id
73            || self.0.actor_id != auth.actor_id
74            || self.0.device_id != auth.device_id
75        {
76            return Err(ExecutionError::identity_mismatch(
77                "operation identity does not match the authenticated context",
78            ));
79        }
80        Ok(AuthenticatedOperation(self.0))
81    }
82}
83
84/// Operation whose identity claims match the authenticated connection.
85#[derive(Clone, Copy, Debug)]
86pub struct AuthenticatedOperation<'a>(&'a OperationEnvelope);
87
88impl<'a> AuthenticatedOperation<'a> {
89    /// Borrows the underlying wire envelope for application authorization.
90    #[must_use]
91    pub const fn envelope(self) -> &'a OperationEnvelope {
92        self.0
93    }
94
95    /// Builds server-trusted identity and causal metadata after claim authentication succeeds.
96    #[must_use]
97    pub fn provenance(self, auth: &AuthContext) -> TrustedProvenance {
98        TrustedProvenance {
99            actor_id: auth.actor_id,
100            tenant_id: auth.tenant_id,
101            device_id: auth.device_id,
102            operation_id: self.0.operation_id,
103            operation_lineage: self
104                .0
105                .metadata
106                .lineage
107                .resolved_for_operation(self.0.operation_id),
108        }
109    }
110
111    /// Marks successful application authorization. Callers should invoke this only after their
112    /// policy has approved the operation.
113    #[must_use]
114    pub const fn authorize(self) -> AuthorizedOperation<'a> {
115        AuthorizedOperation(self.0)
116    }
117}
118
119/// Server-trusted provenance created only from an authenticated operation and connection context.
120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
121pub struct TrustedProvenance {
122    actor_id: ActorId,
123    tenant_id: TenantId,
124    device_id: DeviceId,
125    operation_id: OperationId,
126    operation_lineage: LineageContext,
127}
128
129impl TrustedProvenance {
130    /// Authenticated actor responsible for the originating command.
131    #[must_use]
132    pub const fn actor_id(self) -> ActorId {
133        self.actor_id
134    }
135
136    /// Authenticated tenant that owns every derived artifact.
137    #[must_use]
138    pub const fn tenant_id(self) -> TenantId {
139        self.tenant_id
140    }
141
142    /// Authenticated device that submitted the originating command.
143    #[must_use]
144    pub const fn device_id(self) -> DeviceId {
145        self.device_id
146    }
147
148    /// Original operation identifier.
149    #[must_use]
150    pub const fn operation_id(self) -> OperationId {
151        self.operation_id
152    }
153
154    /// Client-originating lineage retained by the operation ledger and audit record.
155    #[must_use]
156    pub const fn operation_lineage(self) -> LineageContext {
157        self.operation_lineage
158    }
159
160    /// Creates the primary authoritative event lineage with the operation as direct cause.
161    #[must_use]
162    pub const fn primary_event(self, event_id: EventId) -> DerivedEventProvenance {
163        DerivedEventProvenance {
164            event_id,
165            lineage: self
166                .operation_lineage
167                .derived(LineageRef::Operation(self.operation_id)),
168        }
169    }
170
171    /// Creates durable job/outbox metadata while retaining the originating correlation chain.
172    #[must_use]
173    pub const fn job(self, job_id: JobId, caused_by: LineageRef) -> JobProvenance {
174        JobProvenance {
175            job_id,
176            lineage: self.operation_lineage.derived(caused_by),
177        }
178    }
179}
180
181/// Identity and inherited lineage for a server-derived authoritative event.
182#[derive(Clone, Copy, Debug, Eq, PartialEq)]
183pub struct DerivedEventProvenance {
184    /// Stable event identity allocated by the server.
185    pub event_id: EventId,
186    /// Correlation-preserving direct causal reference.
187    pub lineage: LineageContext,
188}
189
190impl DerivedEventProvenance {
191    /// Derives a follow-up event whose direct cause is this event.
192    #[must_use]
193    pub const fn derive(self, event_id: EventId) -> Self {
194        Self {
195            event_id,
196            lineage: self.lineage.derived(LineageRef::Event(self.event_id)),
197        }
198    }
199}
200
201/// Required durable metadata for a job/outbox record derived from an authenticated operation.
202#[derive(Clone, Copy, Debug, Eq, PartialEq)]
203pub struct JobProvenance {
204    /// Stable job identity.
205    pub job_id: JobId,
206    /// Inherited correlation and direct cause; persist this alongside the job payload.
207    pub lineage: LineageContext,
208}
209
210/// Authenticated operation approved by application authorization policy.
211#[derive(Clone, Copy, Debug)]
212pub struct AuthorizedOperation<'a>(&'a OperationEnvelope);
213
214impl<'a> AuthorizedOperation<'a> {
215    /// Borrows the underlying envelope for conflict and authoritative-state validation.
216    #[must_use]
217    pub const fn envelope(self) -> &'a OperationEnvelope {
218        self.0
219    }
220
221    /// Marks successful structural, business-precondition, and conflict validation.
222    #[must_use]
223    pub const fn validate(self) -> ValidatedOperation<'a> {
224        ValidatedOperation(self.0)
225    }
226}
227
228/// Authorized operation whose authoritative preconditions were validated.
229#[derive(Clone, Copy, Debug)]
230pub struct ValidatedOperation<'a>(&'a OperationEnvelope);
231
232impl<'a> ValidatedOperation<'a> {
233    /// Converts a validated operation into the only type accepted by execution.
234    #[must_use]
235    pub const fn executable(self) -> ExecutableOperation<'a> {
236        ExecutableOperation(self.0)
237    }
238}
239
240/// Fully planned operation accepted by [`OperationExecutor::execute`].
241#[derive(Clone, Copy, Debug)]
242pub struct ExecutableOperation<'a>(&'a OperationEnvelope);
243
244impl<'a> ExecutableOperation<'a> {
245    /// Borrows the underlying envelope for decoding and domain execution.
246    #[must_use]
247    pub const fn envelope(self) -> &'a OperationEnvelope {
248        self.0
249    }
250}
251
252/// Current authoritative snapshot passed into an application handler.
253#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct CurrentEntity {
255    /// Current version.
256    pub version: aequora_types::EntityVersion,
257    /// Opaque application-owned snapshot bytes.
258    pub payload: Vec<u8>,
259    /// Whether the snapshot is a tombstone.
260    pub tombstone: bool,
261}
262
263/// Mutation produced only after application authorization and validation.
264#[derive(Clone, Debug, Eq, PartialEq)]
265pub struct AuthoritativeMutation {
266    /// Authoritative payload to persist and journal.
267    pub payload: Vec<u8>,
268    /// Upsert or tombstone.
269    pub change_kind: ChangeKind,
270}
271
272/// Typed application rejection.
273#[derive(Clone, Debug, Error, Eq, PartialEq)]
274#[error("{message}")]
275pub struct ExecutionError {
276    /// Stable rejection category.
277    pub code: RejectionCode,
278    /// Bounded, non-sensitive explanation.
279    pub message: String,
280}
281
282impl ExecutionError {
283    /// Constructs an authenticated-identity mismatch.
284    #[must_use]
285    pub fn identity_mismatch(message: impl Into<String>) -> Self {
286        Self {
287            code: RejectionCode::IdentityMismatch,
288            message: message.into(),
289        }
290    }
291
292    /// Constructs an authorization rejection.
293    #[must_use]
294    pub fn unauthorized(message: impl Into<String>) -> Self {
295        Self {
296            code: RejectionCode::Unauthorized,
297            message: message.into(),
298        }
299    }
300
301    /// Constructs a business-rule rejection.
302    #[must_use]
303    pub fn business_rule(message: impl Into<String>) -> Self {
304        Self {
305            code: RejectionCode::BusinessRule,
306            message: message.into(),
307        }
308    }
309
310    /// Constructs a malformed or unknown operation rejection.
311    #[must_use]
312    pub fn invalid_operation(message: impl Into<String>) -> Self {
313        Self {
314            code: RejectionCode::InvalidOperation,
315            message: message.into(),
316        }
317    }
318
319    /// Constructs an unsupported domain-schema rejection.
320    #[must_use]
321    pub fn schema_incompatible(message: impl Into<String>) -> Self {
322        Self {
323            code: RejectionCode::SchemaIncompatible,
324            message: message.into(),
325        }
326    }
327}
328
329/// Application operation registry/dispatcher.
330///
331/// Implementations decode `operation.payload` according to `operation_kind`, authorize
332/// against `AuthContext`, validate business rules, and return an authoritative mutation.
333#[async_trait]
334pub trait OperationExecutor: Send + Sync {
335    /// Authorizes a requested partial sync scope before any entity or journal data is read.
336    async fn authorize_scope(
337        &self,
338        auth: &AuthContext,
339        session: &SessionMetadata,
340    ) -> Result<(), ExecutionError>;
341
342    /// Authorizes an operation before authoritative state is disclosed or conflict-tested.
343    async fn authorize<'a>(
344        &self,
345        auth: &AuthContext,
346        operation: AuthenticatedOperation<'a>,
347    ) -> Result<AuthorizedOperation<'a>, ExecutionError>;
348
349    /// Validates business rules and executes an authorized operation into a
350    /// store-independent mutation.
351    async fn execute(
352        &self,
353        auth: &AuthContext,
354        operation: ExecutableOperation<'_>,
355        current: Option<&CurrentEntity>,
356    ) -> Result<AuthoritativeMutation, ExecutionError>;
357}
358
359/// Strongly typed domain command registered with [`OperationRegistry`].
360pub trait DomainOperation: DeserializeOwned + Send + Sync + 'static {
361    /// Stable operation kind on the wire.
362    const KIND: u16;
363    /// Current application payload schema understood by its handler.
364    const CURRENT_SCHEMA: u16;
365}
366
367/// Authorization and execution logic for one typed domain command.
368///
369/// This compatibility boundary does not receive a captured execution context and therefore does
370/// not, by itself, qualify for full deterministic replay. New replayable authority paths should
371/// implement `aequora_replay::ReplayHandler`, persist its `ExecutionPlan` through a native atomic
372/// commit, and keep this handler only as an integration facade where required.
373#[async_trait]
374pub trait OperationHandler<O>: Send + Sync
375where
376    O: DomainOperation,
377{
378    /// Authorizes the decoded command before authoritative entity state is read.
379    async fn authorize(
380        &self,
381        auth: &AuthContext,
382        operation: &O,
383        envelope: &OperationEnvelope,
384    ) -> Result<(), ExecutionError>;
385
386    /// Validates and executes the decoded command against current authoritative state.
387    async fn execute(
388        &self,
389        auth: &AuthContext,
390        operation: &O,
391        envelope: &OperationEnvelope,
392        current: Option<&CurrentEntity>,
393    ) -> Result<AuthoritativeMutation, ExecutionError>;
394}
395
396/// Application authorization for a requested partial synchronization scope.
397#[async_trait]
398pub trait ScopeAuthorizer: Send + Sync {
399    /// Authorizes all requested opaque partition selectors before data access.
400    async fn authorize_scope(
401        &self,
402        auth: &AuthContext,
403        session: &SessionMetadata,
404    ) -> Result<(), ExecutionError>;
405}
406
407/// Pure adapter that upgrades one supported historical payload directly to the handler's
408/// current schema. Database migrations remain separate.
409pub trait PayloadMigrator: Send + Sync {
410    /// Returns current-schema Postcard bytes for a historical payload.
411    ///
412    /// # Errors
413    ///
414    /// Returns [`ExecutionError`] when the historical payload is malformed or cannot be upgraded.
415    fn migrate(&self, from: SchemaVersion, payload: &[u8]) -> Result<Vec<u8>, ExecutionError>;
416}
417
418#[async_trait]
419trait ErasedOperationHandler: Send + Sync {
420    async fn authorize(
421        &self,
422        auth: &AuthContext,
423        envelope: &OperationEnvelope,
424    ) -> Result<(), ExecutionError>;
425
426    async fn execute(
427        &self,
428        auth: &AuthContext,
429        envelope: &OperationEnvelope,
430        current: Option<&CurrentEntity>,
431    ) -> Result<AuthoritativeMutation, ExecutionError>;
432}
433
434struct TypedHandler<O, H> {
435    handler: H,
436    minimum_schema: u16,
437    migrator: Option<Arc<dyn PayloadMigrator>>,
438    marker: std::marker::PhantomData<fn() -> O>,
439}
440
441impl<O, H> TypedHandler<O, H>
442where
443    O: DomainOperation,
444{
445    fn decode(&self, envelope: &OperationEnvelope) -> Result<O, ExecutionError> {
446        let schema = envelope.schema_version.0;
447        if schema < self.minimum_schema || schema > O::CURRENT_SCHEMA {
448            return Err(ExecutionError::schema_incompatible(format!(
449                "operation schema {schema} is outside supported range {}..={}",
450                self.minimum_schema,
451                O::CURRENT_SCHEMA
452            )));
453        }
454        if schema == O::CURRENT_SCHEMA {
455            return postcard::from_bytes(&envelope.payload).map_err(|_| {
456                ExecutionError::invalid_operation("current operation payload is malformed")
457            });
458        }
459        let migrator = self.migrator.as_ref().ok_or_else(|| {
460            ExecutionError::schema_incompatible("historical operation requires a migration adapter")
461        })?;
462        let current = migrator.migrate(envelope.schema_version, &envelope.payload)?;
463        postcard::from_bytes(&current).map_err(|_| {
464            ExecutionError::invalid_operation("migrated operation payload is malformed")
465        })
466    }
467}
468
469#[async_trait]
470impl<O, H> ErasedOperationHandler for TypedHandler<O, H>
471where
472    O: DomainOperation,
473    H: OperationHandler<O>,
474{
475    async fn authorize(
476        &self,
477        auth: &AuthContext,
478        envelope: &OperationEnvelope,
479    ) -> Result<(), ExecutionError> {
480        let operation = self.decode(envelope)?;
481        self.handler.authorize(auth, &operation, envelope).await
482    }
483
484    async fn execute(
485        &self,
486        auth: &AuthContext,
487        envelope: &OperationEnvelope,
488        current: Option<&CurrentEntity>,
489    ) -> Result<AuthoritativeMutation, ExecutionError> {
490        let operation = self.decode(envelope)?;
491        self.handler
492            .execute(auth, &operation, envelope, current)
493            .await
494    }
495}
496
497/// Invalid typed registration configuration.
498#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
499pub enum RegistrationError {
500    /// An operation kind already has a handler.
501    #[error("operation kind {0} is already registered")]
502    Duplicate(u16),
503    /// The minimum supported schema is zero or newer than the command's current schema.
504    #[error("operation schema window is invalid")]
505    SchemaWindow,
506}
507
508/// Typed operation dispatcher with an explicit scope authorizer and schema compatibility window.
509pub struct OperationRegistry {
510    scope_authorizer: Arc<dyn ScopeAuthorizer>,
511    handlers: HashMap<u16, Arc<dyn ErasedOperationHandler>>,
512}
513
514impl OperationRegistry {
515    /// Creates an empty registry. Requiring an authorizer prevents an accidental allow-all scope.
516    #[must_use]
517    pub fn new<A>(scope_authorizer: A) -> Self
518    where
519        A: ScopeAuthorizer + 'static,
520    {
521        Self {
522            scope_authorizer: Arc::new(scope_authorizer),
523            handlers: HashMap::new(),
524        }
525    }
526
527    /// Registers a handler for only its current payload schema.
528    ///
529    /// # Errors
530    ///
531    /// Returns [`RegistrationError::Duplicate`] when the operation kind is already registered.
532    pub fn register<O, H>(&mut self, handler: H) -> Result<&mut Self, RegistrationError>
533    where
534        O: DomainOperation,
535        H: OperationHandler<O> + 'static,
536    {
537        self.register_inner::<O, H>(handler, O::CURRENT_SCHEMA, None)
538    }
539
540    /// Registers a handler and direct migration adapter for a bounded historical schema window.
541    ///
542    /// # Errors
543    ///
544    /// Returns [`RegistrationError`] for duplicate kinds or an invalid compatibility window.
545    pub fn register_with_migration<O, H, M>(
546        &mut self,
547        minimum_schema: u16,
548        handler: H,
549        migrator: M,
550    ) -> Result<&mut Self, RegistrationError>
551    where
552        O: DomainOperation,
553        H: OperationHandler<O> + 'static,
554        M: PayloadMigrator + 'static,
555    {
556        self.register_inner::<O, H>(handler, minimum_schema, Some(Arc::new(migrator)))
557    }
558
559    fn register_inner<O, H>(
560        &mut self,
561        handler: H,
562        minimum_schema: u16,
563        migrator: Option<Arc<dyn PayloadMigrator>>,
564    ) -> Result<&mut Self, RegistrationError>
565    where
566        O: DomainOperation,
567        H: OperationHandler<O> + 'static,
568    {
569        if minimum_schema == 0 || minimum_schema > O::CURRENT_SCHEMA {
570            return Err(RegistrationError::SchemaWindow);
571        }
572        if self.handlers.contains_key(&O::KIND) {
573            return Err(RegistrationError::Duplicate(O::KIND));
574        }
575        self.handlers.insert(
576            O::KIND,
577            Arc::new(TypedHandler::<O, H> {
578                handler,
579                minimum_schema,
580                migrator,
581                marker: std::marker::PhantomData,
582            }),
583        );
584        Ok(self)
585    }
586
587    fn handler(
588        &self,
589        operation_kind: u16,
590    ) -> Result<&Arc<dyn ErasedOperationHandler>, ExecutionError> {
591        self.handlers.get(&operation_kind).ok_or_else(|| {
592            ExecutionError::invalid_operation(format!(
593                "operation kind {operation_kind} is not registered"
594            ))
595        })
596    }
597}
598
599#[async_trait]
600impl OperationExecutor for OperationRegistry {
601    async fn authorize_scope(
602        &self,
603        auth: &AuthContext,
604        session: &SessionMetadata,
605    ) -> Result<(), ExecutionError> {
606        self.scope_authorizer.authorize_scope(auth, session).await
607    }
608
609    async fn authorize<'a>(
610        &self,
611        auth: &AuthContext,
612        operation: AuthenticatedOperation<'a>,
613    ) -> Result<AuthorizedOperation<'a>, ExecutionError> {
614        let envelope = operation.envelope();
615        self.handler(envelope.operation_kind.0)?
616            .authorize(auth, envelope)
617            .await?;
618        Ok(operation.authorize())
619    }
620
621    async fn execute(
622        &self,
623        auth: &AuthContext,
624        operation: ExecutableOperation<'_>,
625        current: Option<&CurrentEntity>,
626    ) -> Result<AuthoritativeMutation, ExecutionError> {
627        let envelope = operation.envelope();
628        self.handler(envelope.operation_kind.0)?
629            .execute(auth, envelope, current)
630            .await
631    }
632}
633
634/// Invalid intra-batch operation dependency graph.
635#[derive(Clone, Debug, Error, Eq, PartialEq)]
636pub enum DependencyError {
637    /// An operation ID appeared more than once.
638    #[error("operation {0} appears more than once in the dependency graph")]
639    Duplicate(aequora_types::OperationId),
640    /// Intra-batch dependencies contain a cycle. No operation should execute.
641    #[error("operation dependency graph contains a cycle")]
642    Cycle,
643}
644
645/// Deterministic topological execution plan.
646#[derive(Clone, Debug, Eq, PartialEq)]
647pub struct DependencyPlan {
648    ordered_indices: Vec<usize>,
649    groups: Vec<Vec<usize>>,
650}
651
652impl DependencyPlan {
653    /// Operation indexes in stable topological order.
654    #[must_use]
655    pub fn ordered_indices(&self) -> &[usize] {
656        &self.ordered_indices
657    }
658
659    /// Independent topological levels. CPU-only validation within one level may run in parallel.
660    #[must_use]
661    pub fn groups(&self) -> &[Vec<usize>] {
662        &self.groups
663    }
664}
665
666/// Builds a stable `O(V + E)` dependency plan for operations contained in one batch.
667/// Dependencies outside the batch remain server-ledger prerequisites and are not graph edges.
668///
669/// # Errors
670///
671/// Returns [`DependencyError::Duplicate`] for duplicate operation IDs and
672/// [`DependencyError::Cycle`] when no topological execution order exists.
673pub fn plan_dependencies(
674    operations: &[OperationEnvelope],
675) -> Result<DependencyPlan, DependencyError> {
676    let mut positions = HashMap::with_capacity(operations.len());
677    for (index, operation) in operations.iter().enumerate() {
678        if positions.insert(operation.operation_id, index).is_some() {
679            return Err(DependencyError::Duplicate(operation.operation_id));
680        }
681    }
682
683    let mut indegree = vec![0_usize; operations.len()];
684    let mut outgoing = vec![Vec::new(); operations.len()];
685    for (index, operation) in operations.iter().enumerate() {
686        for dependency in &operation.metadata.dependencies {
687            if let Some(&dependency_index) = positions.get(dependency) {
688                indegree[index] = indegree[index].saturating_add(1);
689                outgoing[dependency_index].push(index);
690            }
691        }
692    }
693
694    let mut ready: BinaryHeap<Reverse<usize>> = indegree
695        .iter()
696        .enumerate()
697        .filter_map(|(index, degree)| (*degree == 0).then_some(Reverse(index)))
698        .collect();
699    let mut ordered_indices = Vec::with_capacity(operations.len());
700    let mut groups = Vec::new();
701    while !ready.is_empty() {
702        let mut group = Vec::with_capacity(ready.len());
703        while let Some(Reverse(index)) = ready.pop() {
704            group.push(index);
705        }
706        for &index in &group {
707            ordered_indices.push(index);
708            for &dependent in &outgoing[index] {
709                indegree[dependent] = indegree[dependent].saturating_sub(1);
710                if indegree[dependent] == 0 {
711                    ready.push(Reverse(dependent));
712                }
713            }
714        }
715        groups.push(group);
716    }
717    if ordered_indices.len() != operations.len() {
718        return Err(DependencyError::Cycle);
719    }
720    Ok(DependencyPlan {
721        ordered_indices,
722        groups,
723    })
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use aequora_protocol::{OperationKind, OperationMetadata};
730    use aequora_types::{
731        EntityId, EntityRef, EntityType, HybridTimestamp, NodeId, OperationId, ProtocolVersion,
732    };
733    use serde::{Deserialize, Serialize};
734
735    #[derive(Deserialize, Serialize)]
736    struct CurrentCommand {
737        value: u16,
738    }
739
740    impl DomainOperation for CurrentCommand {
741        const KIND: u16 = 9;
742        const CURRENT_SCHEMA: u16 = 2;
743    }
744
745    #[derive(Deserialize, Serialize)]
746    struct LegacyCommand {
747        value: u8,
748    }
749
750    struct CommandMigration;
751
752    impl PayloadMigrator for CommandMigration {
753        fn migrate(&self, from: SchemaVersion, payload: &[u8]) -> Result<Vec<u8>, ExecutionError> {
754            if from != SchemaVersion(1) {
755                return Err(ExecutionError::schema_incompatible(
756                    "only schema one can be migrated",
757                ));
758            }
759            let legacy: LegacyCommand = postcard::from_bytes(payload)
760                .map_err(|_| ExecutionError::invalid_operation("legacy payload is malformed"))?;
761            postcard::to_stdvec(&CurrentCommand {
762                value: u16::from(legacy.value),
763            })
764            .map_err(|_| ExecutionError::invalid_operation("migration encoding failed"))
765        }
766    }
767
768    struct AllowScope;
769
770    #[async_trait]
771    impl ScopeAuthorizer for AllowScope {
772        async fn authorize_scope(
773            &self,
774            _auth: &AuthContext,
775            _session: &SessionMetadata,
776        ) -> Result<(), ExecutionError> {
777            Ok(())
778        }
779    }
780
781    struct CurrentHandler;
782
783    #[async_trait]
784    impl OperationHandler<CurrentCommand> for CurrentHandler {
785        async fn authorize(
786            &self,
787            _auth: &AuthContext,
788            _operation: &CurrentCommand,
789            _envelope: &OperationEnvelope,
790        ) -> Result<(), ExecutionError> {
791            Ok(())
792        }
793
794        async fn execute(
795            &self,
796            _auth: &AuthContext,
797            operation: &CurrentCommand,
798            _envelope: &OperationEnvelope,
799            _current: Option<&CurrentEntity>,
800        ) -> Result<AuthoritativeMutation, ExecutionError> {
801            Ok(AuthoritativeMutation {
802                payload: operation.value.to_be_bytes().to_vec(),
803                change_kind: ChangeKind::Upsert,
804            })
805        }
806    }
807
808    fn envelope(payload: Vec<u8>, schema_version: SchemaVersion) -> OperationEnvelope {
809        OperationEnvelope {
810            protocol_version: ProtocolVersion::V1,
811            operation_id: OperationId::new(),
812            tenant_id: TenantId::new(),
813            actor_id: ActorId::new(),
814            device_id: DeviceId::new(),
815            entity: EntityRef {
816                entity_type: EntityType::new(1).unwrap_or_else(|error| panic!("{error}")),
817                entity_id: EntityId::new(),
818            },
819            base_version: None,
820            created_at: HybridTimestamp {
821                physical_ms: 1,
822                logical: 0,
823                node: NodeId::new(),
824            },
825            schema_version,
826            operation_kind: OperationKind(CurrentCommand::KIND),
827            payload,
828            metadata: OperationMetadata::default(),
829        }
830    }
831
832    #[tokio::test]
833    async fn registry_migrates_only_the_explicit_schema_window() {
834        let mut registry = OperationRegistry::new(AllowScope);
835        registry
836            .register_with_migration::<CurrentCommand, _, _>(1, CurrentHandler, CommandMigration)
837            .unwrap_or_else(|error| panic!("{error}"));
838        let legacy = LegacyCommand { value: 42 };
839        let operation = envelope(
840            postcard::to_stdvec(&legacy).unwrap_or_else(|error| panic!("{error}")),
841            SchemaVersion(1),
842        );
843        let auth = AuthContext {
844            actor_id: operation.actor_id,
845            tenant_id: operation.tenant_id,
846            device_id: operation.device_id,
847        };
848
849        let authenticated = IncomingOperation::new(&operation)
850            .authenticate(&auth)
851            .unwrap_or_else(|error| panic!("{error}"));
852        let authorized = registry
853            .authorize(&auth, authenticated)
854            .await
855            .unwrap_or_else(|error| panic!("{error}"));
856        let mutation = registry
857            .execute(&auth, authorized.validate().executable(), None)
858            .await
859            .unwrap_or_else(|error| panic!("{error}"));
860        assert_eq!(mutation.payload, 42_u16.to_be_bytes());
861
862        let unsupported = envelope(Vec::new(), SchemaVersion(3));
863        let unsupported_auth = AuthContext {
864            actor_id: unsupported.actor_id,
865            tenant_id: unsupported.tenant_id,
866            device_id: unsupported.device_id,
867        };
868        let unsupported = IncomingOperation::new(&unsupported)
869            .authenticate(&unsupported_auth)
870            .unwrap_or_else(|error| panic!("{error}"));
871        assert!(matches!(
872            registry.authorize(&unsupported_auth, unsupported).await,
873            Err(ExecutionError {
874                code: RejectionCode::SchemaIncompatible,
875                ..
876            })
877        ));
878    }
879
880    #[test]
881    fn authenticated_provenance_preserves_correlation_for_events_and_jobs() {
882        let operation = envelope(Vec::new(), SchemaVersion(1));
883        let auth = AuthContext {
884            actor_id: operation.actor_id,
885            tenant_id: operation.tenant_id,
886            device_id: operation.device_id,
887        };
888        let authenticated = IncomingOperation::new(&operation)
889            .authenticate(&auth)
890            .unwrap_or_else(|error| panic!("{error}"));
891        let provenance = authenticated.provenance(&auth);
892        let event = provenance.primary_event(EventId::new());
893        let derived = event.derive(EventId::new());
894        let job = provenance.job(JobId::new(), LineageRef::Event(derived.event_id));
895
896        assert_eq!(provenance.tenant_id(), auth.tenant_id);
897        assert_eq!(
898            event.lineage.correlation_id,
899            operation.metadata.lineage.correlation_id
900        );
901        assert_eq!(
902            event.lineage.caused_by,
903            Some(LineageRef::Operation(operation.operation_id))
904        );
905        assert_eq!(derived.lineage.correlation_id, event.lineage.correlation_id);
906        assert_eq!(
907            derived.lineage.caused_by,
908            Some(LineageRef::Event(event.event_id))
909        );
910        assert_eq!(job.lineage.correlation_id, event.lineage.correlation_id);
911        assert_eq!(
912            job.lineage.caused_by,
913            Some(LineageRef::Event(derived.event_id))
914        );
915    }
916}