Skip to main content

aequora_bootstrap/
lib.rs

1//! Durable, bounded, and transport-neutral large snapshot bootstrap contracts.
2//!
3//! This crate defines portable manifests, deterministic chunks, restart guards, snapshot leases,
4//! staging generations, and atomic activation evidence. Database transactions, object storage,
5//! authorization, and application payload interpretation stay in adapters.
6
7use aequora_protocol::SnapshotEntity;
8use aequora_scope::{ScopeGeneration, ScopeVersion};
9pub use aequora_types::{AuthorityEpoch, AuthorityId};
10use aequora_types::{OperationId, Sequence, SnapshotId, SyncScopeId};
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use std::collections::{BTreeMap, BTreeSet};
14use thiserror::Error;
15use uuid::Uuid;
16
17/// Current portable large-bootstrap manifest format.
18pub const MANIFEST_FORMAT_VERSION: u32 = 1;
19
20/// Durable identity for one local bootstrap attempt.
21#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
22#[serde(transparent)]
23pub struct BootstrapJobId(Uuid);
24
25impl BootstrapJobId {
26    /// Creates an approximately time-ordered job identifier.
27    #[must_use]
28    pub fn new() -> Self {
29        Self(Uuid::now_v7())
30    }
31}
32
33impl Default for BootstrapJobId {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39macro_rules! nonzero_u64 {
40    ($(#[$meta:meta])* $name:ident) => {
41        $(#[$meta])*
42        #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
43        #[serde(transparent)]
44        pub struct $name(u64);
45
46        impl $name {
47            /// Creates a non-zero value.
48            ///
49            /// # Errors
50            ///
51            /// Returns [`BootstrapError::ZeroValue`] when `value` is zero.
52            pub const fn new(value: u64) -> Result<Self, BootstrapError> {
53                if value == 0 {
54                    Err(BootstrapError::ZeroValue(stringify!($name)))
55                } else {
56                    Ok(Self(value))
57                }
58            }
59
60            /// Returns the wire value.
61            #[must_use]
62            pub const fn get(self) -> u64 {
63                self.0
64            }
65        }
66    };
67}
68
69nonzero_u64!(
70    /// Application projection schema understood by the snapshot payload codec.
71    ProjectionSchemaVersion
72);
73nonzero_u64!(
74    /// Monotonic local replica generation; staging and active data never share a generation.
75    ReplicaGeneration
76);
77/// One immutable authoritative state boundary.
78#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
79pub struct SnapshotBoundary {
80    pub authority_id: AuthorityId,
81    pub scope_id: SyncScopeId,
82    pub scope_version: ScopeVersion,
83    pub scope_generation: ScopeGeneration,
84    pub sequence: Sequence,
85    pub authority_epoch: AuthorityEpoch,
86}
87
88/// Encoding applied to one chunk object.
89#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
90pub enum CompressionKind {
91    None,
92    Zstd,
93}
94
95/// Stable content-bound chunk identity, independent from a temporary URL.
96#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
97#[serde(transparent)]
98pub struct ChunkId(pub [u8; 32]);
99
100/// Inclusive deterministic entity range represented by one chunk.
101#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
102pub struct EntityRange {
103    pub first: aequora_types::EntityRef,
104    pub last: aequora_types::EntityRef,
105}
106
107/// Opaque, refreshable location selected by a transport adapter.
108#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
109pub enum ChunkLocation {
110    Service,
111    HttpRange { object_ref: String },
112    ObjectStore { object_ref: String, via_cdn: bool },
113}
114
115/// One ordered, independently verifiable manifest entry.
116#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
117pub struct ChunkDescriptor {
118    pub chunk_id: ChunkId,
119    pub ordinal: u32,
120    pub entity_range: EntityRange,
121    pub record_count: u64,
122    pub compressed_bytes: u64,
123    pub uncompressed_bytes: u64,
124    pub digest: [u8; 32],
125    pub compression: CompressionKind,
126    pub location: ChunkLocation,
127}
128
129/// Immutable, versioned description of a complete snapshot.
130#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
131pub struct SnapshotManifest {
132    pub format_version: u32,
133    pub snapshot_id: SnapshotId,
134    pub boundary: SnapshotBoundary,
135    pub schema_version: ProjectionSchemaVersion,
136    pub record_count: u64,
137    pub total_uncompressed_bytes: u64,
138    pub total_compressed_bytes: u64,
139    pub root_digest: [u8; 32],
140    pub chunks: Vec<ChunkDescriptor>,
141}
142
143/// Bounded encoded chunk produced alongside a manifest.
144#[derive(Clone, Debug, Eq, PartialEq)]
145pub struct SnapshotChunk {
146    pub descriptor: ChunkDescriptor,
147    pub bytes: Vec<u8>,
148}
149
150/// Verified build result; adapters publish the manifest only after every chunk is durable.
151#[derive(Clone, Debug, Eq, PartialEq)]
152pub struct BuiltSnapshot {
153    pub manifest: SnapshotManifest,
154    pub chunks: Vec<SnapshotChunk>,
155}
156
157/// Deterministic chunking and hard resource limits.
158#[derive(Clone, Copy, Debug, Eq, PartialEq)]
159pub struct ChunkingConfig {
160    pub target_records: usize,
161    pub target_uncompressed_bytes: usize,
162    pub max_chunk_uncompressed_bytes: usize,
163    pub max_chunks: usize,
164    pub max_records: usize,
165    pub max_total_uncompressed_bytes: usize,
166}
167
168impl Default for ChunkingConfig {
169    fn default() -> Self {
170        Self {
171            target_records: 1_000,
172            target_uncompressed_bytes: 8 * 1024 * 1024,
173            max_chunk_uncompressed_bytes: 32 * 1024 * 1024,
174            max_chunks: 65_536,
175            max_records: 10_000_000,
176            max_total_uncompressed_bytes: 64 * 1024 * 1024 * 1024,
177        }
178    }
179}
180
181impl SnapshotManifest {
182    /// Deterministically sorts, chunks, encodes, and hashes one consistent record set.
183    ///
184    /// # Errors
185    ///
186    /// Rejects invalid bounds, duplicate entities, serialization failures, and size overflow.
187    pub fn build(
188        snapshot_id: SnapshotId,
189        boundary: SnapshotBoundary,
190        schema_version: ProjectionSchemaVersion,
191        records: &[SnapshotEntity],
192        config: ChunkingConfig,
193    ) -> Result<BuiltSnapshot, BootstrapError> {
194        config.validate()?;
195        if records.len() > config.max_records {
196            return Err(BootstrapError::RecordLimit);
197        }
198        let mut ordered = records.to_vec();
199        ordered.sort_by_key(|record| record.entity);
200        let mut chunks = Vec::new();
201        let manifest = Self::build_streaming(
202            snapshot_id,
203            boundary,
204            schema_version,
205            ordered,
206            config,
207            |chunk| {
208                chunks.push(chunk);
209                Ok(())
210            },
211        )?;
212        Ok(BuiltSnapshot { manifest, chunks })
213    }
214
215    /// Builds a snapshot from records already ordered by entity and emits each bounded encoded
216    /// chunk to `sink` immediately. Only the current chunk and bounded descriptor manifest remain
217    /// in memory; adapters can durably upload each chunk before the manifest is published.
218    ///
219    /// # Errors
220    ///
221    /// Rejects unordered or duplicate input, invalid bounds, excessive records/chunks/bytes,
222    /// serialization errors, and sink failures.
223    pub fn build_streaming<I, F>(
224        snapshot_id: SnapshotId,
225        boundary: SnapshotBoundary,
226        schema_version: ProjectionSchemaVersion,
227        records: I,
228        config: ChunkingConfig,
229        mut sink: F,
230    ) -> Result<Self, BootstrapError>
231    where
232        I: IntoIterator<Item = SnapshotEntity>,
233        F: FnMut(SnapshotChunk) -> Result<(), BootstrapError>,
234    {
235        config.validate()?;
236        let mut descriptors = Vec::with_capacity(config.max_chunks.min(1_024));
237        let mut current = Vec::with_capacity(config.target_records.min(1_024));
238        let mut previous_entity = None;
239        let mut record_count = 0_usize;
240        let mut total_uncompressed_bytes = 0_u64;
241
242        for record in records {
243            if previous_entity.is_some_and(|previous| previous >= record.entity) {
244                return Err(if previous_entity == Some(record.entity) {
245                    BootstrapError::DuplicateEntity
246                } else {
247                    BootstrapError::EntityOrder
248                });
249            }
250            previous_entity = Some(record.entity);
251            record_count = record_count
252                .checked_add(1)
253                .ok_or(BootstrapError::Overflow)?;
254            if record_count > config.max_records {
255                return Err(BootstrapError::RecordLimit);
256            }
257            current.push(record);
258            let encoded_len = postcard::to_stdvec(&current)?.len();
259            let reached_target = current.len() >= config.target_records
260                || encoded_len >= config.target_uncompressed_bytes;
261            if encoded_len > config.max_chunk_uncompressed_bytes {
262                if current.len() == 1 {
263                    return Err(BootstrapError::ChunkByteLimit);
264                }
265                let last = current.pop().ok_or(BootstrapError::InvalidState)?;
266                emit_stream_chunk(
267                    snapshot_id,
268                    &current,
269                    config,
270                    &mut descriptors,
271                    &mut total_uncompressed_bytes,
272                    &mut sink,
273                )?;
274                current.clear();
275                current.push(last);
276                if postcard::to_stdvec(&current)?.len() > config.max_chunk_uncompressed_bytes {
277                    return Err(BootstrapError::ChunkByteLimit);
278                }
279            } else if reached_target {
280                emit_stream_chunk(
281                    snapshot_id,
282                    &current,
283                    config,
284                    &mut descriptors,
285                    &mut total_uncompressed_bytes,
286                    &mut sink,
287                )?;
288                current.clear();
289            }
290        }
291        if !current.is_empty() {
292            emit_stream_chunk(
293                snapshot_id,
294                &current,
295                config,
296                &mut descriptors,
297                &mut total_uncompressed_bytes,
298                &mut sink,
299            )?;
300        }
301
302        let mut manifest = Self {
303            format_version: MANIFEST_FORMAT_VERSION,
304            snapshot_id,
305            boundary,
306            schema_version,
307            record_count: u64::try_from(record_count).map_err(|_| BootstrapError::Overflow)?,
308            total_uncompressed_bytes,
309            total_compressed_bytes: total_uncompressed_bytes,
310            root_digest: [0; 32],
311            chunks: descriptors,
312        };
313        manifest.root_digest = manifest.calculate_root()?;
314        Ok(manifest)
315    }
316
317    /// Verifies format, boundary identity, deterministic ordinals, totals, ranges, and root.
318    ///
319    /// # Errors
320    ///
321    /// Returns a typed failure for any manifest inconsistency.
322    pub fn verify(&self) -> Result<(), BootstrapError> {
323        if self.format_version != MANIFEST_FORMAT_VERSION {
324            return Err(BootstrapError::UnsupportedManifestVersion);
325        }
326        let mut records = 0_u64;
327        let mut compressed = 0_u64;
328        let mut uncompressed = 0_u64;
329        let mut previous_last = None;
330        for (index, chunk) in self.chunks.iter().enumerate() {
331            let ordinal = u32::try_from(index).map_err(|_| BootstrapError::Overflow)?;
332            if chunk.ordinal != ordinal || chunk.record_count == 0 {
333                return Err(BootstrapError::ChunkOrder);
334            }
335            if chunk.entity_range.first > chunk.entity_range.last
336                || previous_last.is_some_and(|last| last >= chunk.entity_range.first)
337            {
338                return Err(BootstrapError::EntityOrder);
339            }
340            validate_location(&chunk.location)?;
341            records = records
342                .checked_add(chunk.record_count)
343                .ok_or(BootstrapError::Overflow)?;
344            compressed = compressed
345                .checked_add(chunk.compressed_bytes)
346                .ok_or(BootstrapError::Overflow)?;
347            uncompressed = uncompressed
348                .checked_add(chunk.uncompressed_bytes)
349                .ok_or(BootstrapError::Overflow)?;
350            previous_last = Some(chunk.entity_range.last);
351        }
352        if records != self.record_count
353            || compressed != self.total_compressed_bytes
354            || uncompressed != self.total_uncompressed_bytes
355        {
356            return Err(BootstrapError::ManifestTotals);
357        }
358        if self.calculate_root()? != self.root_digest {
359            return Err(BootstrapError::RootMismatch);
360        }
361        Ok(())
362    }
363
364    fn calculate_root(&self) -> Result<[u8; 32], BootstrapError> {
365        let encoded = postcard::to_stdvec(&(
366            self.format_version,
367            self.snapshot_id,
368            self.boundary,
369            self.schema_version,
370            self.record_count,
371            self.total_uncompressed_bytes,
372            self.total_compressed_bytes,
373            &self.chunks,
374        ))?;
375        Ok(*blake3::hash(&encoded).as_bytes())
376    }
377}
378
379impl SnapshotChunk {
380    /// Verifies exact identity, size, hash, ordering, and record count before installation.
381    ///
382    /// # Errors
383    ///
384    /// Returns a typed error without exposing record payloads.
385    pub fn decode_verified(
386        &self,
387        expected: &ChunkDescriptor,
388        max_uncompressed_bytes: usize,
389    ) -> Result<Vec<SnapshotEntity>, BootstrapError> {
390        if &self.descriptor != expected {
391            return Err(BootstrapError::ChunkIdentityMismatch);
392        }
393        let expected_bytes =
394            usize::try_from(expected.uncompressed_bytes).map_err(|_| BootstrapError::Overflow)?;
395        if self.bytes.len() != expected_bytes || self.bytes.len() > max_uncompressed_bytes {
396            return Err(BootstrapError::ChunkByteLimit);
397        }
398        if *blake3::hash(&self.bytes).as_bytes() != expected.digest {
399            return Err(BootstrapError::ChunkDigestMismatch);
400        }
401        let records: Vec<SnapshotEntity> = postcard::from_bytes(&self.bytes)?;
402        let count = u64::try_from(records.len()).map_err(|_| BootstrapError::Overflow)?;
403        if count != expected.record_count {
404            return Err(BootstrapError::ChunkRecordCount);
405        }
406        let first = records.first().ok_or(BootstrapError::ChunkRecordCount)?;
407        let last = records.last().ok_or(BootstrapError::ChunkRecordCount)?;
408        if first.entity != expected.entity_range.first || last.entity != expected.entity_range.last
409        {
410            return Err(BootstrapError::EntityOrder);
411        }
412        if records
413            .windows(2)
414            .any(|pair| pair[0].entity >= pair[1].entity)
415        {
416            return Err(BootstrapError::EntityOrder);
417        }
418        Ok(records)
419    }
420}
421
422fn build_chunk(
423    snapshot_id: SnapshotId,
424    ordinal: usize,
425    records: &[SnapshotEntity],
426) -> Result<SnapshotChunk, BootstrapError> {
427    let bytes = postcard::to_stdvec(records)?;
428    let digest = *blake3::hash(&bytes).as_bytes();
429    let ordinal = u32::try_from(ordinal).map_err(|_| BootstrapError::Overflow)?;
430    let first = records.first().ok_or(BootstrapError::ChunkRecordCount)?;
431    let last = records.last().ok_or(BootstrapError::ChunkRecordCount)?;
432    let mut identity = blake3::Hasher::new();
433    identity.update(snapshot_id.as_uuid().as_bytes());
434    identity.update(&ordinal.to_le_bytes());
435    identity.update(&digest);
436    let byte_count = u64::try_from(bytes.len()).map_err(|_| BootstrapError::Overflow)?;
437    let record_count = u64::try_from(records.len()).map_err(|_| BootstrapError::Overflow)?;
438    let descriptor = ChunkDescriptor {
439        chunk_id: ChunkId(*identity.finalize().as_bytes()),
440        ordinal,
441        entity_range: EntityRange {
442            first: first.entity,
443            last: last.entity,
444        },
445        record_count,
446        compressed_bytes: byte_count,
447        uncompressed_bytes: byte_count,
448        digest,
449        compression: CompressionKind::None,
450        location: ChunkLocation::Service,
451    };
452    Ok(SnapshotChunk { descriptor, bytes })
453}
454
455fn emit_stream_chunk<F>(
456    snapshot_id: SnapshotId,
457    records: &[SnapshotEntity],
458    config: ChunkingConfig,
459    descriptors: &mut Vec<ChunkDescriptor>,
460    total_uncompressed_bytes: &mut u64,
461    sink: &mut F,
462) -> Result<(), BootstrapError>
463where
464    F: FnMut(SnapshotChunk) -> Result<(), BootstrapError>,
465{
466    if descriptors.len() == config.max_chunks {
467        return Err(BootstrapError::ChunkLimit);
468    }
469    let chunk = build_chunk(snapshot_id, descriptors.len(), records)?;
470    *total_uncompressed_bytes = total_uncompressed_bytes
471        .checked_add(chunk.descriptor.uncompressed_bytes)
472        .ok_or(BootstrapError::Overflow)?;
473    let max_total =
474        u64::try_from(config.max_total_uncompressed_bytes).map_err(|_| BootstrapError::Overflow)?;
475    if *total_uncompressed_bytes > max_total {
476        return Err(BootstrapError::TotalByteLimit);
477    }
478    descriptors.push(chunk.descriptor.clone());
479    sink(chunk)
480}
481
482impl ChunkingConfig {
483    fn validate(self) -> Result<(), BootstrapError> {
484        if self.target_records == 0
485            || self.target_uncompressed_bytes == 0
486            || self.max_chunk_uncompressed_bytes == 0
487            || self.max_chunks == 0
488            || self.max_records == 0
489            || self.max_total_uncompressed_bytes == 0
490            || self.target_uncompressed_bytes > self.max_chunk_uncompressed_bytes
491        {
492            return Err(BootstrapError::InvalidLimits);
493        }
494        Ok(())
495    }
496}
497
498fn validate_location(location: &ChunkLocation) -> Result<(), BootstrapError> {
499    match location {
500        ChunkLocation::Service => Ok(()),
501        ChunkLocation::HttpRange { object_ref } | ChunkLocation::ObjectStore { object_ref, .. }
502            if !object_ref.trim().is_empty() =>
503        {
504            Ok(())
505        }
506        ChunkLocation::HttpRange { .. } | ChunkLocation::ObjectStore { .. } => {
507            Err(BootstrapError::BlankObjectReference)
508        }
509    }
510}
511
512/// Durable workflow states. Only the declared transition graph is accepted.
513#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
514pub enum BootstrapState {
515    Requested,
516    Planning,
517    Downloading,
518    Installing,
519    Verifying,
520    ReadyToActivate,
521    Activating,
522    CatchingUp,
523    Complete,
524    Failed,
525    Quarantined,
526    Cancelled,
527}
528
529impl BootstrapState {
530    fn may_transition(self, next: Self) -> bool {
531        matches!(
532            (self, next),
533            (
534                Self::Requested | Self::Failed | Self::Cancelled,
535                Self::Planning
536            ) | (Self::Planning, Self::Downloading)
537                | (Self::Downloading, Self::Installing | Self::Cancelled)
538                | (
539                    Self::Installing,
540                    Self::Downloading | Self::Verifying | Self::Cancelled
541                )
542                | (Self::Verifying, Self::ReadyToActivate | Self::Quarantined)
543                | (Self::ReadyToActivate, Self::Activating | Self::Cancelled)
544                | (Self::Activating, Self::CatchingUp)
545                | (Self::CatchingUp, Self::Complete)
546                | (
547                    Self::Requested
548                        | Self::Planning
549                        | Self::Downloading
550                        | Self::Installing
551                        | Self::Verifying
552                        | Self::ReadyToActivate
553                        | Self::Activating
554                        | Self::CatchingUp,
555                    Self::Failed
556                )
557        )
558    }
559}
560
561/// Durable state for one independently retryable chunk.
562#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
563pub enum ChunkState {
564    NotStarted,
565    Downloading,
566    Downloaded,
567    Verified,
568    Installed,
569}
570
571/// Persisted resume metadata; object identity prevents cross-object range append.
572#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
573pub struct ChunkProgress {
574    pub chunk_id: ChunkId,
575    pub ordinal: u32,
576    pub state: ChunkState,
577    pub downloaded_bytes: u64,
578    pub object_identity: Option<String>,
579    pub attempts: u32,
580}
581
582/// Policy for local mutations while a replacement generation is staged.
583#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
584pub enum BootstrapMutationPolicy {
585    AllowQueue,
586    ReadOnly,
587    Custom,
588}
589
590/// Payload-free commitment to pending intent preserved across activation.
591#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
592pub struct PendingIntentPlan {
593    pub operation_ids: Vec<OperationId>,
594    pub immutable_sent: BTreeSet<OperationId>,
595    pub digest: [u8; 32],
596}
597
598impl PendingIntentPlan {
599    /// Sorts identities, rejects duplicate declarations, and calculates a restart-stable digest.
600    ///
601    /// # Errors
602    ///
603    /// Returns an error when a sent identity is absent or an identity is duplicated.
604    pub fn build(
605        mut operation_ids: Vec<OperationId>,
606        immutable_sent: BTreeSet<OperationId>,
607    ) -> Result<Self, BootstrapError> {
608        operation_ids.sort_unstable();
609        if operation_ids.windows(2).any(|pair| pair[0] == pair[1]) {
610            return Err(BootstrapError::DuplicatePendingIntent);
611        }
612        if !immutable_sent
613            .iter()
614            .all(|operation| operation_ids.binary_search(operation).is_ok())
615        {
616            return Err(BootstrapError::UnknownSentIntent);
617        }
618        let encoded = postcard::to_stdvec(&(&operation_ids, &immutable_sent))?;
619        Ok(Self {
620            operation_ids,
621            immutable_sent,
622            digest: *blake3::hash(&encoded).as_bytes(),
623        })
624    }
625}
626
627/// Complete durable job record; manifest identity and scope cannot drift on resume.
628#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
629pub struct BootstrapJob {
630    pub job_id: BootstrapJobId,
631    pub snapshot_id: SnapshotId,
632    pub manifest_root: [u8; 32],
633    pub boundary: SnapshotBoundary,
634    pub schema_version: ProjectionSchemaVersion,
635    pub staging_generation: ReplicaGeneration,
636    pub mutation_policy: BootstrapMutationPolicy,
637    pub pending_intent_digest: [u8; 32],
638    pub state: BootstrapState,
639    pub chunks: BTreeMap<u32, ChunkProgress>,
640}
641
642impl BootstrapJob {
643    /// Creates a job whose per-chunk progress exactly matches a verified manifest.
644    ///
645    /// # Errors
646    ///
647    /// Rejects an invalid manifest.
648    pub fn new(
649        manifest: &SnapshotManifest,
650        staging_generation: ReplicaGeneration,
651        mutation_policy: BootstrapMutationPolicy,
652        pending: &PendingIntentPlan,
653    ) -> Result<Self, BootstrapError> {
654        manifest.verify()?;
655        let chunks = manifest
656            .chunks
657            .iter()
658            .map(|chunk| {
659                (
660                    chunk.ordinal,
661                    ChunkProgress {
662                        chunk_id: chunk.chunk_id,
663                        ordinal: chunk.ordinal,
664                        state: ChunkState::NotStarted,
665                        downloaded_bytes: 0,
666                        object_identity: None,
667                        attempts: 0,
668                    },
669                )
670            })
671            .collect();
672        Ok(Self {
673            job_id: BootstrapJobId::new(),
674            snapshot_id: manifest.snapshot_id,
675            manifest_root: manifest.root_digest,
676            boundary: manifest.boundary,
677            schema_version: manifest.schema_version,
678            staging_generation,
679            mutation_policy,
680            pending_intent_digest: pending.digest,
681            state: BootstrapState::Requested,
682            chunks,
683        })
684    }
685
686    /// Fails closed when the durable state machine is asked to skip a phase.
687    ///
688    /// # Errors
689    ///
690    /// Returns [`BootstrapError::IllegalTransition`] for an undeclared edge.
691    pub fn transition(&mut self, next: BootstrapState) -> Result<(), BootstrapError> {
692        if !self.state.may_transition(next) {
693            return Err(BootstrapError::IllegalTransition {
694                from: self.state,
695                to: next,
696            });
697        }
698        self.state = next;
699        Ok(())
700    }
701
702    /// Verifies immutable resume identity and exact manifest chunk membership.
703    ///
704    /// # Errors
705    ///
706    /// Rejects a changed snapshot, boundary, schema, root, or chunk map.
707    pub fn validate_resume(&self, manifest: &SnapshotManifest) -> Result<(), BootstrapError> {
708        manifest.verify()?;
709        if self.snapshot_id != manifest.snapshot_id
710            || self.manifest_root != manifest.root_digest
711            || self.boundary != manifest.boundary
712            || self.schema_version != manifest.schema_version
713            || self.chunks.len() != manifest.chunks.len()
714            || manifest.chunks.iter().any(|chunk| {
715                self.chunks
716                    .get(&chunk.ordinal)
717                    .is_none_or(|progress| progress.chunk_id != chunk.chunk_id)
718            })
719        {
720            return Err(BootstrapError::ResumeIdentityMismatch);
721        }
722        Ok(())
723    }
724
725    /// Records one bounded range response while preserving immutable object identity.
726    ///
727    /// The returned progress must be persisted before requesting the next range.
728    ///
729    /// # Errors
730    ///
731    /// Rejects an unknown chunk, identity drift, non-contiguous offset, overflow, or premature
732    /// completion.
733    pub fn record_chunk_read(
734        &mut self,
735        descriptor: &ChunkDescriptor,
736        read: &ChunkRead,
737    ) -> Result<ChunkProgress, BootstrapError> {
738        if read.object_identity.trim().is_empty() {
739            return Err(BootstrapError::BlankObjectIdentity);
740        }
741        let progress = self
742            .chunks
743            .get_mut(&descriptor.ordinal)
744            .ok_or(BootstrapError::UnknownChunk)?;
745        if progress.chunk_id != descriptor.chunk_id
746            || progress
747                .object_identity
748                .as_ref()
749                .is_some_and(|identity| identity != &read.object_identity)
750        {
751            return Err(BootstrapError::RangeResumeMismatch);
752        }
753        if !matches!(
754            progress.state,
755            ChunkState::NotStarted | ChunkState::Downloading
756        ) {
757            return Err(BootstrapError::InvalidChunkState);
758        }
759        let bytes = u64::try_from(read.bytes.len()).map_err(|_| BootstrapError::Overflow)?;
760        let expected_next = progress
761            .downloaded_bytes
762            .checked_add(bytes)
763            .ok_or(BootstrapError::Overflow)?;
764        if read.next_offset != expected_next || read.next_offset > descriptor.compressed_bytes {
765            return Err(BootstrapError::RangeResumeMismatch);
766        }
767        if read.complete != (read.next_offset == descriptor.compressed_bytes) {
768            return Err(BootstrapError::RangeCompletionMismatch);
769        }
770        progress.downloaded_bytes = read.next_offset;
771        progress.object_identity = Some(read.object_identity.clone());
772        progress.attempts = progress.attempts.saturating_add(1);
773        progress.state = if read.complete {
774            ChunkState::Downloaded
775        } else {
776            ChunkState::Downloading
777        };
778        Ok(progress.clone())
779    }
780
781    /// Advances an exactly downloaded chunk after independent content verification.
782    ///
783    /// # Errors
784    ///
785    /// Rejects unknown chunks and phase skipping.
786    pub fn mark_chunk_verified(
787        &mut self,
788        descriptor: &ChunkDescriptor,
789    ) -> Result<(), BootstrapError> {
790        self.advance_chunk(descriptor, ChunkState::Downloaded, ChunkState::Verified)
791    }
792
793    /// Advances a verified chunk only after the adapter atomically installs records and progress.
794    ///
795    /// # Errors
796    ///
797    /// Rejects unknown chunks and phase skipping.
798    pub fn mark_chunk_installed(
799        &mut self,
800        descriptor: &ChunkDescriptor,
801    ) -> Result<(), BootstrapError> {
802        self.advance_chunk(descriptor, ChunkState::Verified, ChunkState::Installed)
803    }
804
805    fn advance_chunk(
806        &mut self,
807        descriptor: &ChunkDescriptor,
808        expected: ChunkState,
809        next: ChunkState,
810    ) -> Result<(), BootstrapError> {
811        let progress = self
812            .chunks
813            .get_mut(&descriptor.ordinal)
814            .ok_or(BootstrapError::UnknownChunk)?;
815        if progress.chunk_id != descriptor.chunk_id || progress.state != expected {
816            return Err(BootstrapError::InvalidChunkState);
817        }
818        progress.state = next;
819        Ok(())
820    }
821
822    /// Returns payload-free progress for status UIs and telemetry.
823    #[must_use]
824    pub fn status(&self) -> BootstrapStatus {
825        let chunks_complete = self
826            .chunks
827            .values()
828            .filter(|progress| progress.state == ChunkState::Installed)
829            .count();
830        BootstrapStatus {
831            state: self.state,
832            scope_id: self.boundary.scope_id,
833            bytes_downloaded: self
834                .chunks
835                .values()
836                .map(|progress| progress.downloaded_bytes)
837                .sum(),
838            chunks_complete,
839            chunks_total: self.chunks.len(),
840        }
841    }
842}
843
844/// Payload-free job status.
845#[derive(Clone, Copy, Debug, Eq, PartialEq)]
846pub struct BootstrapStatus {
847    pub state: BootstrapState,
848    pub scope_id: SyncScopeId,
849    pub bytes_downloaded: u64,
850    pub chunks_complete: usize,
851    pub chunks_total: usize,
852}
853
854/// Stable identity required before appending bytes to a partial chunk.
855#[derive(Clone, Debug, Eq, PartialEq)]
856pub struct RangeResumeGuard {
857    pub snapshot_id: SnapshotId,
858    pub chunk_id: ChunkId,
859    pub object_identity: String,
860    pub downloaded_bytes: u64,
861}
862
863impl RangeResumeGuard {
864    /// Verifies a range response still belongs to the same immutable object and stays in bounds.
865    ///
866    /// # Errors
867    ///
868    /// Rejects object drift, wrong chunks, blank identities, or offsets past the object.
869    pub fn verify(
870        &self,
871        snapshot_id: SnapshotId,
872        descriptor: &ChunkDescriptor,
873        object_identity: &str,
874    ) -> Result<(), BootstrapError> {
875        if object_identity.trim().is_empty() {
876            return Err(BootstrapError::BlankObjectIdentity);
877        }
878        if self.snapshot_id != snapshot_id
879            || self.chunk_id != descriptor.chunk_id
880            || self.object_identity != object_identity
881            || self.downloaded_bytes > descriptor.compressed_bytes
882        {
883            return Err(BootstrapError::RangeResumeMismatch);
884        }
885        Ok(())
886    }
887}
888
889/// Server guarantee that the journal delta after a snapshot boundary remains readable.
890#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
891pub struct SnapshotLease {
892    pub snapshot_id: SnapshotId,
893    pub boundary_sequence: Sequence,
894    pub retained_from: Sequence,
895    pub expires_at_unix_ms: u64,
896}
897
898impl SnapshotLease {
899    /// Verifies identity, time, and retained journal coverage.
900    ///
901    /// # Errors
902    ///
903    /// Rejects expired leases or a journal floor beyond the first required delta.
904    pub fn verify(
905        self,
906        snapshot_id: SnapshotId,
907        boundary: SnapshotBoundary,
908        now_unix_ms: u64,
909    ) -> Result<(), BootstrapError> {
910        let first_delta = boundary
911            .sequence
912            .0
913            .checked_add(1)
914            .ok_or(BootstrapError::Overflow)?;
915        if self.snapshot_id != snapshot_id || self.boundary_sequence != boundary.sequence {
916            return Err(BootstrapError::LeaseIdentityMismatch);
917        }
918        if now_unix_ms >= self.expires_at_unix_ms {
919            return Err(BootstrapError::LeaseExpired);
920        }
921        if self.retained_from.0 > first_delta {
922            return Err(BootstrapError::JournalGap);
923        }
924        Ok(())
925    }
926}
927
928/// Disk and memory budgets checked before transfer begins.
929#[derive(Clone, Copy, Debug, Eq, PartialEq)]
930pub struct BootstrapPreflight {
931    pub available_disk_bytes: u64,
932    pub reserved_disk_bytes: u64,
933    pub max_memory_bytes: u64,
934    pub ready_queue_bytes: u64,
935    pub max_chunk_uncompressed_bytes: u64,
936}
937
938impl BootstrapPreflight {
939    /// Proves the manifest and bounded pipeline fit declared resources without overflow.
940    ///
941    /// # Errors
942    ///
943    /// Returns a disk or memory budget failure.
944    pub fn verify(self, manifest: &SnapshotManifest) -> Result<(), BootstrapError> {
945        let required_disk = manifest
946            .total_compressed_bytes
947            .checked_add(manifest.total_uncompressed_bytes)
948            .and_then(|value| value.checked_add(self.reserved_disk_bytes))
949            .ok_or(BootstrapError::Overflow)?;
950        if required_disk > self.available_disk_bytes {
951            return Err(BootstrapError::InsufficientDisk);
952        }
953        let required_memory = self
954            .ready_queue_bytes
955            .checked_add(self.max_chunk_uncompressed_bytes)
956            .ok_or(BootstrapError::Overflow)?;
957        if required_memory > self.max_memory_bytes {
958            return Err(BootstrapError::InsufficientMemory);
959        }
960        if manifest
961            .chunks
962            .iter()
963            .any(|chunk| chunk.uncompressed_bytes > self.max_chunk_uncompressed_bytes)
964        {
965            return Err(BootstrapError::ChunkByteLimit);
966        }
967        Ok(())
968    }
969}
970
971/// Complete fail-closed evidence required by the atomic activation transaction.
972#[derive(Clone, Debug, Eq, PartialEq)]
973pub struct ActivationEvidence {
974    pub manifest_root: [u8; 32],
975    pub installed_chunks: BTreeSet<ChunkId>,
976    pub current_scope_version: ScopeVersion,
977    pub current_scope_generation: ScopeGeneration,
978    pub current_authority_epoch: AuthorityEpoch,
979    pub authorization_current: bool,
980    pub staging_verified: bool,
981    pub pending_intent_digest: [u8; 32],
982    pub lease: SnapshotLease,
983}
984
985/// Validated activation token passed to an adapter transaction.
986#[derive(Clone, Copy, Debug, Eq, PartialEq)]
987pub struct VerifiedActivation {
988    pub job_id: BootstrapJobId,
989    pub snapshot_id: SnapshotId,
990    pub staging_generation: ReplicaGeneration,
991    pub cursor_sequence: Sequence,
992    pub pending_intent_digest: [u8; 32],
993}
994
995/// Checks every activation guard without mutating local state.
996///
997/// # Errors
998///
999/// Returns a typed blocker when authorization, scope, epoch, lease, manifest, chunks, staging, or
1000/// pending-intent identity changed.
1001pub fn verify_activation(
1002    job: &BootstrapJob,
1003    manifest: &SnapshotManifest,
1004    evidence: &ActivationEvidence,
1005    now_unix_ms: u64,
1006) -> Result<VerifiedActivation, BootstrapError> {
1007    job.validate_resume(manifest)?;
1008    if job.state != BootstrapState::ReadyToActivate {
1009        return Err(BootstrapError::NotReadyToActivate);
1010    }
1011    evidence
1012        .lease
1013        .verify(job.snapshot_id, job.boundary, now_unix_ms)?;
1014    let expected_chunks = manifest
1015        .chunks
1016        .iter()
1017        .map(|chunk| chunk.chunk_id)
1018        .collect::<BTreeSet<_>>();
1019    if evidence.manifest_root != job.manifest_root {
1020        return Err(BootstrapError::RootMismatch);
1021    }
1022    if evidence.installed_chunks != expected_chunks
1023        || job
1024            .chunks
1025            .values()
1026            .any(|progress| progress.state != ChunkState::Installed)
1027    {
1028        return Err(BootstrapError::IncompleteStaging);
1029    }
1030    if !evidence.authorization_current {
1031        return Err(BootstrapError::AuthorizationRevoked);
1032    }
1033    if evidence.current_scope_version != job.boundary.scope_version
1034        || evidence.current_scope_generation != job.boundary.scope_generation
1035    {
1036        return Err(BootstrapError::ScopeChanged);
1037    }
1038    if evidence.current_authority_epoch != job.boundary.authority_epoch {
1039        return Err(BootstrapError::AuthorityEpochChanged);
1040    }
1041    if !evidence.staging_verified {
1042        return Err(BootstrapError::StagingNotVerified);
1043    }
1044    if evidence.pending_intent_digest != job.pending_intent_digest {
1045        return Err(BootstrapError::PendingIntentChanged);
1046    }
1047    Ok(VerifiedActivation {
1048        job_id: job.job_id,
1049        snapshot_id: job.snapshot_id,
1050        staging_generation: job.staging_generation,
1051        cursor_sequence: job.boundary.sequence,
1052        pending_intent_digest: job.pending_intent_digest,
1053    })
1054}
1055
1056/// One bounded read from a service, HTTP range endpoint, object store, or CDN.
1057#[derive(Clone, Debug, Eq, PartialEq)]
1058pub struct ChunkRead {
1059    pub bytes: Vec<u8>,
1060    pub next_offset: u64,
1061    pub complete: bool,
1062    pub object_identity: String,
1063}
1064
1065/// Request used to capture one database-neutral consistent read view.
1066#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1067pub struct SnapshotRequest {
1068    pub scope_id: SyncScopeId,
1069    pub scope_version: ScopeVersion,
1070    pub scope_generation: ScopeGeneration,
1071    pub schema_version: ProjectionSchemaVersion,
1072}
1073
1074/// Open consistent database view from which bounded canonical snapshot entities are streamed.
1075#[async_trait]
1076pub trait SnapshotReadView: Send {
1077    fn snapshot_id(&self) -> SnapshotId;
1078    fn boundary(&self) -> SnapshotBoundary;
1079    async fn next_records(
1080        &mut self,
1081        max_records: usize,
1082        max_uncompressed_bytes: usize,
1083    ) -> Result<Vec<SnapshotEntity>, BootstrapStoreError>;
1084}
1085
1086/// Authority adapter capable of opening a consistent, scoped snapshot read view.
1087#[async_trait]
1088pub trait SnapshotSource: Send + Sync {
1089    async fn open_snapshot(
1090        &self,
1091        request: SnapshotRequest,
1092    ) -> Result<Box<dyn SnapshotReadView>, BootstrapStoreError>;
1093}
1094
1095/// Transfer adapter for manifests and bounded/ranged chunk reads.
1096#[async_trait]
1097pub trait SnapshotChunkSource: Send + Sync {
1098    async fn manifest(
1099        &self,
1100        snapshot_id: SnapshotId,
1101    ) -> Result<SnapshotManifest, BootstrapStoreError>;
1102
1103    async fn read_chunk(
1104        &self,
1105        snapshot_id: SnapshotId,
1106        chunk: &ChunkDescriptor,
1107        offset: u64,
1108        max_bytes: usize,
1109    ) -> Result<ChunkRead, BootstrapStoreError>;
1110}
1111
1112/// Local adapter contract for bounded staging and one atomic logical generation swap.
1113#[async_trait]
1114pub trait SnapshotSink: Send + Sync {
1115    async fn begin_staging(
1116        &self,
1117        job: &BootstrapJob,
1118        manifest: &SnapshotManifest,
1119        pending: &PendingIntentPlan,
1120    ) -> Result<(), BootstrapStoreError>;
1121
1122    /// Installs one verified chunk and its durable progress in the same bounded transaction.
1123    async fn install_chunk(
1124        &self,
1125        job: &BootstrapJob,
1126        descriptor: &ChunkDescriptor,
1127        records: &[SnapshotEntity],
1128    ) -> Result<(), BootstrapStoreError>;
1129
1130    async fn verify_staging(
1131        &self,
1132        job: &BootstrapJob,
1133        manifest: &SnapshotManifest,
1134    ) -> Result<(), BootstrapStoreError>;
1135
1136    /// Atomically activates the complete staging generation, boundary cursor, and pending intent.
1137    async fn activate(
1138        &self,
1139        activation: VerifiedActivation,
1140    ) -> Result<ActivationOutcome, BootstrapStoreError>;
1141
1142    /// Removes or seals staged unauthorized data after revocation. Active data is unaffected.
1143    async fn quarantine_revoked(&self, job: &BootstrapJob) -> Result<(), BootstrapStoreError>;
1144}
1145
1146/// Authority adapter contract for journal-retention lease acquisition and renewal.
1147#[async_trait]
1148pub trait SnapshotLeaseStore: Send + Sync {
1149    async fn acquire(
1150        &self,
1151        snapshot_id: SnapshotId,
1152        boundary: SnapshotBoundary,
1153    ) -> Result<SnapshotLease, BootstrapStoreError>;
1154
1155    async fn renew(&self, lease: SnapshotLease) -> Result<SnapshotLease, BootstrapStoreError>;
1156}
1157
1158/// Result of the one atomic activation transaction.
1159#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1160pub struct ActivationOutcome {
1161    pub active_generation: ReplicaGeneration,
1162    pub cursor_sequence: Sequence,
1163    pub preserved_pending_operations: usize,
1164}
1165
1166/// Adapter capability statement used by deployment certification.
1167#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
1168pub enum SnapshotInstallFeature {
1169    GenerationSwap,
1170    ResumableChunkInstall,
1171    ConsistentRead,
1172    PreservesPendingIntent,
1173}
1174
1175/// Explicit local adapter snapshot capabilities.
1176#[derive(Clone, Debug, Default, Eq, PartialEq)]
1177pub struct SnapshotInstallCapability {
1178    pub features: BTreeSet<SnapshotInstallFeature>,
1179}
1180
1181impl SnapshotInstallCapability {
1182    /// Tier-A requires every crash-safety capability.
1183    #[must_use]
1184    pub fn is_tier_a(&self) -> bool {
1185        [
1186            SnapshotInstallFeature::GenerationSwap,
1187            SnapshotInstallFeature::ResumableChunkInstall,
1188            SnapshotInstallFeature::ConsistentRead,
1189            SnapshotInstallFeature::PreservesPendingIntent,
1190        ]
1191        .iter()
1192        .all(|feature| self.features.contains(feature))
1193    }
1194}
1195
1196/// Portable workflow validation failure.
1197#[derive(Clone, Debug, Error, Eq, PartialEq)]
1198pub enum BootstrapError {
1199    #[error("{0} must be non-zero")]
1200    ZeroValue(&'static str),
1201    #[error("bootstrap limits are invalid")]
1202    InvalidLimits,
1203    #[error("snapshot record limit exceeded")]
1204    RecordLimit,
1205    #[error("snapshot chunk limit exceeded")]
1206    ChunkLimit,
1207    #[error("snapshot total-byte limit exceeded")]
1208    TotalByteLimit,
1209    #[error("snapshot chunk byte limit exceeded")]
1210    ChunkByteLimit,
1211    #[error("snapshot contains a duplicate entity")]
1212    DuplicateEntity,
1213    #[error("snapshot manifest version is unsupported")]
1214    UnsupportedManifestVersion,
1215    #[error("snapshot chunk ordering is invalid")]
1216    ChunkOrder,
1217    #[error("snapshot entity ordering is invalid")]
1218    EntityOrder,
1219    #[error("snapshot manifest totals do not match descriptors")]
1220    ManifestTotals,
1221    #[error("snapshot root digest does not match")]
1222    RootMismatch,
1223    #[error("snapshot chunk identity changed")]
1224    ChunkIdentityMismatch,
1225    #[error("snapshot chunk digest does not match")]
1226    ChunkDigestMismatch,
1227    #[error("snapshot chunk record count does not match")]
1228    ChunkRecordCount,
1229    #[error("bootstrap state is invalid")]
1230    InvalidState,
1231    #[error("bootstrap chunk is unknown")]
1232    UnknownChunk,
1233    #[error("bootstrap chunk phase is invalid")]
1234    InvalidChunkState,
1235    #[error("illegal bootstrap transition from {from:?} to {to:?}")]
1236    IllegalTransition {
1237        from: BootstrapState,
1238        to: BootstrapState,
1239    },
1240    #[error("bootstrap resume identity changed")]
1241    ResumeIdentityMismatch,
1242    #[error("range resume object identity changed")]
1243    RangeResumeMismatch,
1244    #[error("range response completion marker does not match its offset")]
1245    RangeCompletionMismatch,
1246    #[error("range resume object identity is blank")]
1247    BlankObjectIdentity,
1248    #[error("chunk object reference is blank")]
1249    BlankObjectReference,
1250    #[error("pending operation identity is duplicated")]
1251    DuplicatePendingIntent,
1252    #[error("sent operation is absent from the pending plan")]
1253    UnknownSentIntent,
1254    #[error("snapshot lease identity changed")]
1255    LeaseIdentityMismatch,
1256    #[error("snapshot lease expired")]
1257    LeaseExpired,
1258    #[error("required post-snapshot journal history is unavailable")]
1259    JournalGap,
1260    #[error("insufficient disk for bounded bootstrap")]
1261    InsufficientDisk,
1262    #[error("insufficient memory for bounded bootstrap")]
1263    InsufficientMemory,
1264    #[error("bootstrap is not ready to activate")]
1265    NotReadyToActivate,
1266    #[error("staging does not contain every required chunk")]
1267    IncompleteStaging,
1268    #[error("scope authorization was revoked")]
1269    AuthorizationRevoked,
1270    #[error("scope version or generation changed")]
1271    ScopeChanged,
1272    #[error("authority epoch changed")]
1273    AuthorityEpochChanged,
1274    #[error("staging verification is incomplete")]
1275    StagingNotVerified,
1276    #[error("pending intent changed during bootstrap")]
1277    PendingIntentChanged,
1278    #[error("integer or size overflow")]
1279    Overflow,
1280    #[error("snapshot serialization failed")]
1281    Codec,
1282}
1283
1284impl From<postcard::Error> for BootstrapError {
1285    fn from(_: postcard::Error) -> Self {
1286        Self::Codec
1287    }
1288}
1289
1290/// Payload-free adapter failure classification.
1291#[derive(Clone, Debug, Error, Eq, PartialEq)]
1292#[error("bootstrap store {kind:?}: {message}")]
1293pub struct BootstrapStoreError {
1294    pub kind: BootstrapStoreErrorKind,
1295    pub message: String,
1296}
1297
1298/// Retry semantics for adapter failures.
1299#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1300pub enum BootstrapStoreErrorKind {
1301    Transient,
1302    Permanent,
1303    Conflict,
1304    Unauthorized,
1305    Capacity,
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310    use super::*;
1311    use aequora_types::{EntityId, EntityRef, EntityType, EntityVersion};
1312    use proptest::prelude::*;
1313
1314    fn boundary() -> SnapshotBoundary {
1315        SnapshotBoundary {
1316            authority_id: AuthorityId::from_uuid(Uuid::from_u128(1)),
1317            scope_id: SyncScopeId::from_uuid(Uuid::from_u128(11)),
1318            scope_version: ScopeVersion::INITIAL,
1319            scope_generation: ScopeGeneration::INITIAL,
1320            sequence: Sequence(41),
1321            authority_epoch: AuthorityEpoch::new(1).unwrap_or_else(|error| panic!("{error}")),
1322        }
1323    }
1324
1325    fn record(id: u128, payload_size: usize) -> SnapshotEntity {
1326        SnapshotEntity {
1327            entity: EntityRef {
1328                entity_type: EntityType::new(1).unwrap_or_else(|error| panic!("{error}")),
1329                entity_id: EntityId::from_uuid(Uuid::from_u128(id)),
1330            },
1331            version: EntityVersion::INITIAL,
1332            payload: vec![7; payload_size],
1333            tombstone: false,
1334        }
1335    }
1336
1337    fn built(records: &[SnapshotEntity]) -> BuiltSnapshot {
1338        SnapshotManifest::build(
1339            SnapshotId::from_uuid(Uuid::from_u128(77)),
1340            boundary(),
1341            ProjectionSchemaVersion::new(1).unwrap_or_else(|error| panic!("{error}")),
1342            records,
1343            ChunkingConfig {
1344                target_records: 2,
1345                target_uncompressed_bytes: 1_024,
1346                max_chunk_uncompressed_bytes: 4_096,
1347                max_chunks: 32,
1348                max_records: 100,
1349                max_total_uncompressed_bytes: 65_536,
1350            },
1351        )
1352        .unwrap_or_else(|error| panic!("{error}"))
1353    }
1354
1355    #[test]
1356    fn deterministic_manifest_is_order_independent_and_chunks_verify() {
1357        let one = built(&[record(3, 8), record(1, 8), record(2, 8)]);
1358        let two = built(&[record(2, 8), record(3, 8), record(1, 8)]);
1359        assert_eq!(one.manifest, two.manifest);
1360        assert_eq!(one.chunks, two.chunks);
1361        one.manifest
1362            .verify()
1363            .unwrap_or_else(|error| panic!("{error}"));
1364        for chunk in &one.chunks {
1365            let decoded = chunk
1366                .decode_verified(&chunk.descriptor, 4_096)
1367                .unwrap_or_else(|error| panic!("{error}"));
1368            assert!(!decoded.is_empty());
1369        }
1370    }
1371
1372    #[test]
1373    fn streaming_builder_emits_one_bounded_chunk_at_a_time() {
1374        let records = vec![record(1, 8), record(2, 8), record(3, 8)];
1375        let config = ChunkingConfig {
1376            target_records: 2,
1377            target_uncompressed_bytes: 1_024,
1378            max_chunk_uncompressed_bytes: 4_096,
1379            max_chunks: 32,
1380            max_records: 100,
1381            max_total_uncompressed_bytes: 65_536,
1382        };
1383        let mut emitted = Vec::new();
1384        let manifest = SnapshotManifest::build_streaming(
1385            SnapshotId::from_uuid(Uuid::from_u128(77)),
1386            boundary(),
1387            ProjectionSchemaVersion::new(1).unwrap_or_else(|error| panic!("{error}")),
1388            records,
1389            config,
1390            |chunk| {
1391                assert!(chunk.bytes.len() <= config.max_chunk_uncompressed_bytes);
1392                emitted.push(chunk.descriptor.clone());
1393                Ok(())
1394            },
1395        )
1396        .unwrap_or_else(|error| panic!("{error}"));
1397        assert_eq!(manifest.chunks, emitted);
1398        assert_eq!(manifest.record_count, 3);
1399        manifest.verify().unwrap_or_else(|error| panic!("{error}"));
1400    }
1401
1402    #[test]
1403    fn manifest_and_chunk_tampering_fail_closed() {
1404        let mut snapshot = built(&[record(1, 8), record(2, 8)]);
1405        snapshot.manifest.record_count += 1;
1406        assert_eq!(
1407            snapshot.manifest.verify(),
1408            Err(BootstrapError::ManifestTotals)
1409        );
1410        let mut chunk = snapshot.chunks.remove(0);
1411        chunk.bytes[0] ^= 1;
1412        assert_eq!(
1413            chunk.decode_verified(&chunk.descriptor, 4_096),
1414            Err(BootstrapError::ChunkDigestMismatch)
1415        );
1416    }
1417
1418    #[test]
1419    fn state_machine_and_resume_identity_are_fail_closed() {
1420        let snapshot = built(&[record(1, 8)]);
1421        let pending = PendingIntentPlan::build(Vec::new(), BTreeSet::new())
1422            .unwrap_or_else(|error| panic!("{error}"));
1423        let mut job = BootstrapJob::new(
1424            &snapshot.manifest,
1425            ReplicaGeneration::new(2).unwrap_or_else(|error| panic!("{error}")),
1426            BootstrapMutationPolicy::AllowQueue,
1427            &pending,
1428        )
1429        .unwrap_or_else(|error| panic!("{error}"));
1430        assert!(job.transition(BootstrapState::Activating).is_err());
1431        job.transition(BootstrapState::Planning)
1432            .unwrap_or_else(|error| panic!("{error}"));
1433        job.validate_resume(&snapshot.manifest)
1434            .unwrap_or_else(|error| panic!("{error}"));
1435        let mut changed = snapshot.manifest.clone();
1436        changed.root_digest[0] ^= 1;
1437        assert!(job.validate_resume(&changed).is_err());
1438    }
1439
1440    #[test]
1441    fn range_resume_requires_the_same_immutable_object() {
1442        let snapshot = built(&[record(1, 8)]);
1443        let descriptor = &snapshot.manifest.chunks[0];
1444        let guard = RangeResumeGuard {
1445            snapshot_id: snapshot.manifest.snapshot_id,
1446            chunk_id: descriptor.chunk_id,
1447            object_identity: "etag-v1".to_owned(),
1448            downloaded_bytes: 3,
1449        };
1450        guard
1451            .verify(snapshot.manifest.snapshot_id, descriptor, "etag-v1")
1452            .unwrap_or_else(|error| panic!("{error}"));
1453        assert_eq!(
1454            guard.verify(snapshot.manifest.snapshot_id, descriptor, "etag-v2"),
1455            Err(BootstrapError::RangeResumeMismatch)
1456        );
1457
1458        let pending = PendingIntentPlan::build(Vec::new(), BTreeSet::new())
1459            .unwrap_or_else(|error| panic!("{error}"));
1460        let mut job = BootstrapJob::new(
1461            &snapshot.manifest,
1462            ReplicaGeneration::new(2).unwrap_or_else(|error| panic!("{error}")),
1463            BootstrapMutationPolicy::AllowQueue,
1464            &pending,
1465        )
1466        .unwrap_or_else(|error| panic!("{error}"));
1467        let first = ChunkRead {
1468            bytes: snapshot.chunks[0].bytes[..3].to_vec(),
1469            next_offset: 3,
1470            complete: false,
1471            object_identity: "etag-v1".to_owned(),
1472        };
1473        assert_eq!(
1474            job.record_chunk_read(descriptor, &first)
1475                .unwrap_or_else(|error| panic!("{error}"))
1476                .state,
1477            ChunkState::Downloading
1478        );
1479        let rest = ChunkRead {
1480            bytes: snapshot.chunks[0].bytes[3..].to_vec(),
1481            next_offset: descriptor.compressed_bytes,
1482            complete: true,
1483            object_identity: "etag-v1".to_owned(),
1484        };
1485        assert_eq!(
1486            job.record_chunk_read(descriptor, &rest)
1487                .unwrap_or_else(|error| panic!("{error}"))
1488                .state,
1489            ChunkState::Downloaded
1490        );
1491        job.mark_chunk_verified(descriptor)
1492            .unwrap_or_else(|error| panic!("{error}"));
1493        job.mark_chunk_installed(descriptor)
1494            .unwrap_or_else(|error| panic!("{error}"));
1495    }
1496
1497    #[test]
1498    fn lease_preflight_and_activation_require_complete_current_evidence() {
1499        let snapshot = built(&[record(1, 8), record(2, 8)]);
1500        BootstrapPreflight {
1501            available_disk_bytes: 1_000_000,
1502            reserved_disk_bytes: 1_000,
1503            max_memory_bytes: 16_384,
1504            ready_queue_bytes: 4_096,
1505            max_chunk_uncompressed_bytes: 4_096,
1506        }
1507        .verify(&snapshot.manifest)
1508        .unwrap_or_else(|error| panic!("{error}"));
1509        let pending = PendingIntentPlan::build(Vec::new(), BTreeSet::new())
1510            .unwrap_or_else(|error| panic!("{error}"));
1511        let mut job = BootstrapJob::new(
1512            &snapshot.manifest,
1513            ReplicaGeneration::new(2).unwrap_or_else(|error| panic!("{error}")),
1514            BootstrapMutationPolicy::AllowQueue,
1515            &pending,
1516        )
1517        .unwrap_or_else(|error| panic!("{error}"));
1518        for progress in job.chunks.values_mut() {
1519            progress.state = ChunkState::Installed;
1520        }
1521        job.state = BootstrapState::ReadyToActivate;
1522        let installed_chunks = snapshot
1523            .manifest
1524            .chunks
1525            .iter()
1526            .map(|chunk| chunk.chunk_id)
1527            .collect();
1528        let evidence = ActivationEvidence {
1529            manifest_root: snapshot.manifest.root_digest,
1530            installed_chunks,
1531            current_scope_version: job.boundary.scope_version,
1532            current_scope_generation: job.boundary.scope_generation,
1533            current_authority_epoch: job.boundary.authority_epoch,
1534            authorization_current: true,
1535            staging_verified: true,
1536            pending_intent_digest: pending.digest,
1537            lease: SnapshotLease {
1538                snapshot_id: job.snapshot_id,
1539                boundary_sequence: job.boundary.sequence,
1540                retained_from: Sequence(0),
1541                expires_at_unix_ms: 10_000,
1542            },
1543        };
1544        let activation = verify_activation(&job, &snapshot.manifest, &evidence, 5_000)
1545            .unwrap_or_else(|error| panic!("{error}"));
1546        assert_eq!(activation.cursor_sequence, Sequence(41));
1547        let mut revoked = evidence;
1548        revoked.authorization_current = false;
1549        assert_eq!(
1550            verify_activation(&job, &snapshot.manifest, &revoked, 5_000),
1551            Err(BootstrapError::AuthorizationRevoked)
1552        );
1553    }
1554
1555    proptest! {
1556        #[test]
1557        fn generated_input_order_never_changes_manifest(mut ids in prop::collection::btree_set(1_u128..10_000, 1..40).prop_map(|values| values.into_iter().collect::<Vec<_>>())) {
1558            let records = ids.iter().map(|id| record(*id, 4)).collect::<Vec<_>>();
1559            let expected = built(&records);
1560            ids.reverse();
1561            let reversed = ids.iter().map(|id| record(*id, 4)).collect::<Vec<_>>();
1562            prop_assert_eq!(built(&reversed), expected);
1563        }
1564    }
1565}