Skip to main content

appcore_sync/sync/
revision.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: revision.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/10/12 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/10/12 00:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Bounded structural causal evidence for opaque object revisions.
12//!
13//! Deployment must authenticate each event and bind it to one tenant before
14//! constructing a chain. Structural validation does not authenticate evidence.
15//! Content equality, timestamps and business schemas never decide ancestry.
16
17use super::{SyncError, SyncResult};
18use serde::{Deserialize, Serialize};
19use std::collections::BTreeSet;
20
21/// Maximum revisions inspected in one causal decision.
22pub const MAX_REVISION_CHAIN_V1: usize = 1_024;
23
24/// Opaque revision identity, independent of document schema or content digest.
25#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct ObjectRevisionV1 {
28    /// Stable tenant-scoped opaque object identity, at most 128 bytes.
29    pub object_id: String,
30    /// Unique immutable revision identity, at most 128 bytes.
31    pub revision_id: String,
32    /// Direct parent; absent only for the initial revision.
33    pub base_revision_id: Option<String>,
34    /// Unique authenticated event identity, at most 128 bytes.
35    pub event_id: String,
36}
37
38impl std::fmt::Debug for ObjectRevisionV1 {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct("ObjectRevisionV1").finish_non_exhaustive()
41    }
42}
43
44impl ObjectRevisionV1 {
45    /// Validates identity bounds without revealing their contents in errors.
46    pub fn validate(&self) -> SyncResult<()> {
47        for id in [&self.object_id, &self.revision_id, &self.event_id] {
48            validate_id(id)?;
49        }
50        if let Some(base) = &self.base_revision_id {
51            validate_id(base)?;
52            if base == &self.revision_id {
53                return invalid();
54            }
55        }
56        Ok(())
57    }
58}
59
60/// Structural chain supplied oldest-first after envelope/tenant authentication.
61/// Missing earlier history is permitted; missing links inside the chain are not.
62#[derive(Clone)]
63pub struct RevisionChainV1 {
64    revisions: Vec<ObjectRevisionV1>,
65}
66
67impl RevisionChainV1 {
68    /// Checks the bounded chain, object binding, unique events and parent links.
69    pub fn new(revisions: Vec<ObjectRevisionV1>) -> SyncResult<Self> {
70        if revisions.is_empty() || revisions.len() > MAX_REVISION_CHAIN_V1 {
71            return invalid();
72        }
73        let mut ids = BTreeSet::new();
74        let mut events = BTreeSet::new();
75        for (index, revision) in revisions.iter().enumerate() {
76            revision.validate()?;
77            if revision.object_id != revisions[0].object_id
78                || !ids.insert(&revision.revision_id)
79                || !events.insert(&revision.event_id)
80                || revisions[..index].iter().any(|previous| {
81                    previous.base_revision_id.as_ref() == Some(&revision.revision_id)
82                })
83            {
84                return invalid();
85            }
86            if index > 0
87                && revision.base_revision_id.as_ref() != Some(&revisions[index - 1].revision_id)
88            {
89                return invalid();
90            }
91        }
92        Ok(Self { revisions })
93    }
94
95    fn position(&self, revision: &ObjectRevisionV1) -> SyncResult<Option<usize>> {
96        let position = self.revisions.iter().position(|entry| {
97            entry.revision_id == revision.revision_id || entry.event_id == revision.event_id
98        });
99        if position.is_some_and(|index| self.revisions[index] != *revision) {
100            return invalid();
101        }
102        Ok(position)
103    }
104}
105
106/// Payload-free causal conclusion. It never performs materialization or merging.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109#[non_exhaustive]
110pub enum RevisionDecisionV1 {
111    /// The authenticated immutable event is already applied.
112    AlreadyApplied,
113    /// Remote is a proven ancestor; retain the newer local revision.
114    AncestorEcho,
115    /// Remote descends from local through explicit evidence.
116    Applicable,
117    /// Both revisions directly descend from the same known parent.
118    ConcurrentBranch,
119    /// Evidence does not establish a relation; request bounded history/snapshot.
120    SnapshotRequired,
121}
122
123/// Classifies two authenticated object revisions using explicit causal evidence.
124/// Advance the transport checkpoint independently when an ancestor echo is accepted.
125/// An intentional revert must have a fresh revision/event descended from local.
126pub fn decide_object_revision_v1(
127    local: &ObjectRevisionV1,
128    remote: &ObjectRevisionV1,
129    chain: &RevisionChainV1,
130) -> SyncResult<RevisionDecisionV1> {
131    local.validate()?;
132    remote.validate()?;
133    if local.object_id != remote.object_id || local.object_id != chain.revisions[0].object_id {
134        return invalid();
135    }
136    let left = chain.position(local)?;
137    let right = chain.position(remote)?;
138    if local.revision_id == remote.revision_id || local.event_id == remote.event_id {
139        return if local == remote {
140            Ok(RevisionDecisionV1::AlreadyApplied)
141        } else {
142            invalid()
143        };
144    }
145    if let (Some(left), Some(right)) = (left, right) {
146        return Ok(if right < left {
147            RevisionDecisionV1::AncestorEcho
148        } else {
149            RevisionDecisionV1::Applicable
150        });
151    }
152    if remote.base_revision_id.as_ref() == Some(&local.revision_id) {
153        return Ok(RevisionDecisionV1::Applicable);
154    }
155    if local.base_revision_id.is_some() && local.base_revision_id == remote.base_revision_id {
156        return Ok(RevisionDecisionV1::ConcurrentBranch);
157    }
158    Ok(RevisionDecisionV1::SnapshotRequired)
159}
160
161/// Rejects a checkpoint beyond the authenticated observed head before any mutation.
162pub fn validate_checkpoint_head_v1(checkpoint: u64, observed_head: u64) -> SyncResult<()> {
163    if checkpoint > observed_head {
164        return Err(SyncError::InvalidSequence(checkpoint));
165    }
166    Ok(())
167}
168
169fn validate_id(id: &str) -> SyncResult<()> {
170    if id.is_empty() || id.len() > 128 || id.chars().any(char::is_control) {
171        return invalid();
172    }
173    Ok(())
174}
175
176fn invalid<T>() -> SyncResult<T> {
177    Err(SyncError::InvalidSyncMessage(
178        "invalid object revision evidence",
179    ))
180}