iroh_db/
db.rs

1use std::{
2    cmp::Ordering,
3    collections::{BTreeMap, BTreeSet},
4    marker::PhantomData,
5    path::{Path, PathBuf},
6    sync::{Arc, RwLock},
7    time::Instant,
8};
9
10use iroh_db_blobs::{BlobEngine, BlobError, BlobRef, BlobStream, FetchScheduler, IrohBlobStore};
11use iroh_db_core::{
12    AuthorId, BlobHash, CapabilityId, CollectionId, CommitBody, CommitCodecError, CommitEnvelope,
13    CommitHeader, CommitId, Dot, EqualityFilter, IrohRecord, MigrationId, Operation, OperationKind,
14    RangeFilter, RecordError, RecordKey, SchemaActivation, SchemaId, SchemaStage, TransactionId,
15    TypedField, VersionVector, staging_digest,
16};
17use iroh_db_security::{
18    AuthorityError, CapabilityCertificate, ConsistencyMode, ControlTransition, DomainDescriptor,
19    DomainSeed, Invitation, InvitationError, InvitationSecret, InvitationTicket, KeyProvider,
20    LocalCredentials, Permission, PermissionSet, SecurityError, open_commit, seal_commit,
21};
22use iroh_db_store::{
23    ApplyOutcome, BlobFuture, BlobStore, BlobStoreError, IndexValue, MaterializedError,
24    MaterializedRecord, RecordHead, RecordHeadSet, SnapshotBaseline, Store, StoreError,
25    StoredRecord,
26};
27use tokio::io::AsyncRead;
28use tokio::sync::{Mutex, RwLock as AsyncRwLock, broadcast};
29
30use crate::telemetry::Telemetry;
31
32const CHANGE_CAPACITY: usize = 1_024;
33const MAX_RECONCILE_COMMITS: usize = 65_536;
34
35/// An embedded database operation failure.
36#[derive(Debug, thiserror::Error)]
37pub enum DbError {
38    /// Durable metadata or blob storage failed.
39    #[error(transparent)]
40    Store(#[from] StoreError),
41    /// A typed record failed schema or codec validation.
42    #[error(transparent)]
43    Record(#[from] RecordError),
44    /// A registered schema migration was invalid or its deterministic transform failed.
45    #[error(transparent)]
46    Migration(#[from] MigrationError),
47    /// A prepared materialization violated storage bounds.
48    #[error(transparent)]
49    Materialized(#[from] MaterializedError),
50    /// Commit encryption, signing or key-provider access failed.
51    #[error(transparent)]
52    Security(#[from] SecurityError),
53    /// A capability, invitation, or domain-control object failed validation.
54    #[error(transparent)]
55    Authority(#[from] AuthorityError),
56    /// A targeted online or offline invitation failed validation or decryption.
57    #[error(transparent)]
58    Invitation(#[from] InvitationError),
59    /// A bounded canonical commit object could not be constructed.
60    #[error(transparent)]
61    Commit(#[from] CommitCodecError),
62    /// A canonical migration stage or activation was invalid.
63    #[error(transparent)]
64    MigrationCodec(#[from] iroh_db_core::MigrationCodecError),
65    /// The operating-system cryptographic random source failed.
66    #[error("cryptographic randomness is unavailable")]
67    RandomnessUnavailable,
68    /// The local author sequence exhausted its durable range.
69    #[error("local author sequence is exhausted")]
70    SequenceExhausted,
71    /// A transaction without operations cannot create a commit.
72    #[error("transaction contains no operations")]
73    EmptyTransaction,
74    /// Collection or transaction handles still reference the database during close.
75    #[error("database cannot close while collection or transaction handles remain")]
76    HandlesOpen,
77    /// A query predicate could not encode its value.
78    #[error("query predicate is invalid: {0}")]
79    InvalidQuery(RecordError),
80    /// An opaque pagination cursor was malformed, modified, stale, or used with another query.
81    #[error("invalid pagination cursor: {0}")]
82    InvalidCursor(String),
83    /// Encrypted blob import, manifest validation or streaming setup failed.
84    #[error(transparent)]
85    Blob(#[from] BlobError),
86    /// A snapshot failed canonical, signature, domain or state-root validation.
87    #[error("snapshot validation failed: {0}")]
88    InvalidSnapshot(String),
89    /// A network-authenticated device is not authorized for this domain operation.
90    #[error("device is not authorized for this domain")]
91    UnauthorizedDevice,
92    /// A commit authored behind a signed epoch cut must be explicitly rebased by the application.
93    #[error("commit {0} was authored before the active epoch cut and requires rebase")]
94    RebaseRequired(CommitId),
95    /// A domain handle predates the durable control epoch and must be reopened.
96    #[error("domain handle epoch {handle} is stale; active epoch is {active}")]
97    StaleEpoch { handle: u64, active: u64 },
98    /// A one-time offline invitation ticket was already imported by this device.
99    #[error("offline invitation ticket was already consumed")]
100    InvitationAlreadyUsed,
101    /// A remote commit targets a typed collection not registered by this process.
102    #[error("record schema is not registered for collection {0}")]
103    UnknownRecordSchema(CollectionId),
104    /// A newer schema was used without traversing the registered migration chain.
105    #[error(
106        "collection {collection_id} requires a schema migration from version {stored_version} to {requested_version}"
107    )]
108    SchemaMigrationRequired {
109        /// Stable collection requiring migration.
110        collection_id: CollectionId,
111        /// Highest durable schema version.
112        stored_version: u32,
113        /// Schema version requested by the write.
114        requested_version: u32,
115    },
116    /// Incompatible activation siblings froze a collection pending explicit recovery.
117    #[error("collection {0} is frozen by incompatible schema activations")]
118    SchemaMigrationFrozen(CollectionId),
119    /// A remote commit header, operation, or materialization disagreed about its target.
120    #[error("remote commit operation is inconsistent: {0}")]
121    InvalidRemoteCommit(String),
122    /// Endpoint, QUIC, control-frame, or immutable transfer failed.
123    #[error("synchronization failed: {0}")]
124    Network(String),
125    /// Verification, repair, backup, or restore failed without overwriting a good copy.
126    #[error("operation failed: {0}")]
127    Operation(String),
128}
129
130/// A deterministic application schema migration failure.
131#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
132pub enum MigrationError {
133    /// The registered descriptors cannot form one adjacent migration step.
134    #[error("invalid migration registration: {0}")]
135    InvalidRegistration(String),
136    /// A stored source record or transformed target record violated its type contract.
137    #[error("migration record is invalid: {0}")]
138    InvalidRecord(String),
139    /// The application rejected a source record without exposing its contents.
140    #[error("application migration failed: {0}")]
141    Application(String),
142}
143
144/// Progress from one complete pass over every registered local-domain migration.
145#[derive(Debug, Clone, PartialEq, Eq, Default)]
146pub struct MigrationReport {
147    staged: usize,
148    already_staged: usize,
149    activated: usize,
150    already_activated: usize,
151    activation_commits: Vec<CommitId>,
152}
153
154impl MigrationReport {
155    /// Number of source records newly staged by this pass.
156    pub const fn staged(&self) -> usize {
157        self.staged
158    }
159
160    /// Number of matching durable stages reused during resumption.
161    pub const fn already_staged(&self) -> usize {
162        self.already_staged
163    }
164
165    /// Number of collection transitions activated by this pass.
166    pub const fn activated(&self) -> usize {
167        self.activated
168    }
169
170    /// Number of transitions already active before this pass.
171    pub const fn already_activated(&self) -> usize {
172        self.already_activated
173    }
174
175    /// Activation commits created by this pass in domain/step order.
176    pub fn activation_commits(&self) -> &[CommitId] {
177        &self.activation_commits
178    }
179
180    fn absorb(&mut self, step: MigrationReport) {
181        self.staged += step.staged;
182        self.already_staged += step.already_staged;
183        self.activated += step.activated;
184        self.already_activated += step.already_activated;
185        self.activation_commits.extend(step.activation_commits);
186    }
187}
188
189impl MigrationError {
190    /// Creates a redacted application-level transform failure.
191    pub fn application(message: impl Into<String>) -> Self {
192        Self::Application(message.into())
193    }
194}
195
196/// A committed materialized-record change.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct Change {
199    collection_id: CollectionId,
200    record_id: Vec<u8>,
201    kind: ChangeKind,
202}
203
204impl Change {
205    /// Returns the affected collection.
206    pub const fn collection_id(&self) -> CollectionId {
207        self.collection_id
208    }
209
210    /// Returns the canonical application record ID.
211    pub fn record_id(&self) -> &[u8] {
212        &self.record_id
213    }
214
215    /// Returns whether the record was replaced or removed.
216    pub const fn kind(&self) -> ChangeKind {
217        self.kind
218    }
219}
220
221/// The visible effect of a committed record mutation.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum ChangeKind {
224    /// A record became present or received new materialized state.
225    Upserted,
226    /// A record was removed.
227    Deleted,
228}
229
230/// One atomic post-commit subscription event.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct ChangeBatch {
233    commit_id: CommitId,
234    sequence: u64,
235    frontier: Vec<CommitId>,
236    changes: Vec<Change>,
237}
238
239impl ChangeBatch {
240    /// Returns the immutable source commit.
241    pub const fn commit_id(&self) -> CommitId {
242        self.commit_id
243    }
244
245    /// Returns the monotonically increasing local-author sequence.
246    pub const fn sequence(&self) -> u64 {
247        self.sequence
248    }
249
250    /// Returns the complete local domain frontier after this batch was applied.
251    pub fn frontier(&self) -> &[CommitId] {
252        &self.frontier
253    }
254
255    /// Returns all record effects published atomically by the commit.
256    pub fn changes(&self) -> &[Change] {
257        &self.changes
258    }
259}
260
261/// Subscription receive failure.
262#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
263pub enum SubscriptionError {
264    /// The database closed the subscription channel.
265    #[error("subscription is closed")]
266    Closed,
267    /// The bounded channel dropped events; the caller must re-query.
268    #[error("subscription lagged by {0} commit batches; re-query required")]
269    Lagged(u64),
270}
271
272/// A local-first embedded database with one default private domain.
273#[derive(Clone)]
274pub struct IrohDb {
275    pub(crate) inner: Arc<DbInner>,
276    pub(crate) domain: Arc<RwLock<DomainSeed>>,
277}
278
279/// Opens a database with the exact deterministic schema migrations understood
280/// by this application version.
281pub struct IrohDbBuilder {
282    root: PathBuf,
283    migrations: Vec<MigrationStep>,
284    run_migrations_on_open: bool,
285    key_provider: Option<Arc<dyn KeyProvider>>,
286}
287
288#[derive(Clone)]
289struct MigrationStep {
290    from: iroh_db_core::SchemaDescriptor,
291    to: iroh_db_core::SchemaDescriptor,
292    register_from: fn(&IrohDb),
293    register_to: fn(&IrohDb),
294    transform: Arc<MigrationTransform>,
295}
296
297type MigrationTransform =
298    dyn Fn(&[u8]) -> Result<PreparedRecord, MigrationError> + Send + Sync + 'static;
299
300pub(crate) struct DbInner {
301    pub(crate) store: Store,
302    pub(crate) blobs: Arc<IrohBlobStore>,
303    pub(crate) blob_scheduler: FetchScheduler,
304    pub(crate) credentials: Arc<dyn KeyProvider>,
305    pub(crate) writer: Mutex<()>,
306    pub(crate) control_gate: AsyncRwLock<()>,
307    domains: RwLock<BTreeMap<iroh_db_core::DomainId, Arc<RwLock<DomainSeed>>>>,
308    changes: RwLock<BTreeMap<iroh_db_core::DomainId, broadcast::Sender<ChangeBatch>>>,
309    adapters: RwLock<BTreeMap<(CollectionId, SchemaId), RecordAdapter>>,
310    migrations: RwLock<Vec<MigrationStep>>,
311    pub(crate) telemetry: Arc<Telemetry>,
312}
313
314#[derive(Clone)]
315struct DomainBlobStore {
316    blobs: Arc<IrohBlobStore>,
317    metadata: Store,
318    domain_id: iroh_db_core::DomainId,
319}
320
321impl BlobStore for DomainBlobStore {
322    fn put(&self, bytes: Vec<u8>) -> BlobFuture<'_, BlobHash> {
323        Box::pin(async move {
324            let hash = self.blobs.put(bytes).await?;
325            self.metadata
326                .claim_blob(self.domain_id, hash)
327                .map_err(blob_metadata_error)?;
328            Ok(hash)
329        })
330    }
331
332    fn get(&self, hash: BlobHash) -> BlobFuture<'_, Option<Vec<u8>>> {
333        self.blobs.get(hash)
334    }
335
336    fn list(&self) -> BlobFuture<'_, Vec<BlobHash>> {
337        Box::pin(async move {
338            let mut owned = Vec::new();
339            for hash in self.blobs.list().await? {
340                if self
341                    .metadata
342                    .owns_blob(self.domain_id, hash)
343                    .map_err(blob_metadata_error)?
344                {
345                    owned.push(hash);
346                }
347            }
348            Ok(owned)
349        })
350    }
351
352    fn claim(&self, hash: BlobHash) -> BlobFuture<'_, ()> {
353        Box::pin(async move {
354            self.metadata
355                .claim_blob(self.domain_id, hash)
356                .map_err(blob_metadata_error)
357        })
358    }
359}
360
361#[derive(Clone, Copy)]
362struct RecordAdapter {
363    merge: fn(Option<&[u8]>, &MaterializedRecord) -> Result<MaterializedRecord, DbError>,
364    canonicalize: fn(
365        &MaterializedRecord,
366        AuthorId,
367        u64,
368        &VersionVector,
369        &mut u32,
370    ) -> Result<MaterializedRecord, DbError>,
371}
372
373struct QuarantineMetadata {
374    frontier: Vec<CommitId>,
375    author_sequences: Vec<(AuthorId, u64)>,
376    author_heads: Vec<(AuthorId, u64, CommitId)>,
377}
378
379impl DbInner {
380    pub(crate) fn has_record_adapters(&self) -> bool {
381        !self
382            .adapters
383            .read()
384            .expect("record adapter lock is not poisoned")
385            .is_empty()
386    }
387}
388
389/// Result of feeding one authenticated immutable commit into the local apply kernel.
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum RemoteApply {
392    /// The commit and its merged materialization became durable.
393    Applied(CommitId),
394    /// The exact commit was already accepted.
395    Duplicate(CommitId),
396    /// The commit is durable history but targets a schema outside the active cut.
397    SchemaRebaseRequired {
398        /// Immutable commit retained for explicit application-level transformation.
399        commit: CommitId,
400    },
401    /// Conflicting author-sequence branches were removed from accepted history.
402    Quarantined {
403        /// The author whose signing key produced conflicting commits.
404        author: AuthorId,
405        /// The first equivocated author-local sequence.
406        sequence: u64,
407    },
408}
409
410/// Application-facing label that groups device endpoints without changing protocol identity.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct DeviceGroup {
413    label: String,
414    members: Vec<AuthorId>,
415}
416
417impl DeviceGroup {
418    pub fn label(&self) -> &str {
419        &self.label
420    }
421
422    pub fn members(&self) -> &[AuthorId] {
423        &self.members
424    }
425}
426
427/// Result of copying an explicit current-state projection into independent domain history.
428#[derive(Debug, Clone, Copy, PartialEq, Eq)]
429pub struct ProjectionReport {
430    selected: usize,
431    commit: Option<CommitId>,
432}
433
434impl ProjectionReport {
435    pub const fn selected(&self) -> usize {
436        self.selected
437    }
438
439    pub const fn commit(&self) -> Option<CommitId> {
440        self.commit
441    }
442}
443
444impl IrohDbBuilder {
445    /// Uses an application-managed identity and domain-key provider instead of
446    /// the default file vault beneath the database directory.
447    #[must_use]
448    pub fn key_provider(mut self, provider: Arc<dyn KeyProvider>) -> Self {
449        self.key_provider = Some(provider);
450        self
451    }
452
453    /// Defers migration work until [`IrohDb::run_migrations`] is called.
454    #[must_use]
455    pub const fn defer_migrations(mut self) -> Self {
456        self.run_migrations_on_open = false;
457        self
458    }
459
460    /// Registers one exact adjacent schema migration. The transform is a
461    /// function pointer so durable behavior cannot depend on captured runtime
462    /// state.
463    pub fn migration<From, To>(
464        mut self,
465        transform: fn(From) -> Result<To, MigrationError>,
466    ) -> Result<Self, MigrationError>
467    where
468        From: IrohRecord + 'static,
469        To: IrohRecord<Id = From::Id> + 'static,
470    {
471        let from = From::schema().map_err(|error| {
472            MigrationError::InvalidRegistration(format!("source schema: {error}"))
473        })?;
474        let to = To::schema().map_err(|error| {
475            MigrationError::InvalidRegistration(format!("target schema: {error}"))
476        })?;
477        if from.collection_id() != to.collection_id() {
478            return Err(MigrationError::InvalidRegistration(
479                "source and target collection IDs differ".into(),
480            ));
481        }
482        if from.version().checked_add(1) != Some(to.version()) {
483            return Err(MigrationError::InvalidRegistration(
484                "schema versions must be adjacent and increasing".into(),
485            ));
486        }
487        if self
488            .migrations
489            .iter()
490            .any(|step| step.from.schema_id() == from.schema_id())
491        {
492            return Err(MigrationError::InvalidRegistration(
493                "source schema already has a registered successor".into(),
494            ));
495        }
496        let migration = move |state: &[u8]| {
497            let source = From::decode_record(state)
498                .map_err(|error| MigrationError::InvalidRecord(error.to_string()))?;
499            let source_id = source
500                .record_id()
501                .map_err(|error| MigrationError::InvalidRecord(error.to_string()))?;
502            let target = transform(source)?;
503            let target_id = target
504                .record_id()
505                .map_err(|error| MigrationError::InvalidRecord(error.to_string()))?;
506            if source_id != target_id {
507                return Err(MigrationError::InvalidRecord(
508                    "migration changed the canonical primary key".into(),
509                ));
510            }
511            prepare_upsert(&target)
512                .map_err(|error| MigrationError::InvalidRecord(error.to_string()))
513        };
514        self.migrations.push(MigrationStep {
515            from,
516            to,
517            register_from: register_record_adapter::<From>,
518            register_to: register_record_adapter::<To>,
519            transform: Arc::new(migration),
520        });
521        Ok(self)
522    }
523
524    /// Opens the local database, registers historical adapters, and resumes
525    /// every applicable migration before returning a readable handle.
526    pub async fn open(self) -> Result<IrohDb, DbError> {
527        self.open_with_report().await.map(|(database, _)| database)
528    }
529
530    /// Opens the database and returns exact migration work performed while opening.
531    pub async fn open_with_report(mut self) -> Result<(IrohDb, MigrationReport), DbError> {
532        self.migrations
533            .sort_by_key(|step| (step.from.collection_id(), step.from.version()));
534        let database = if let Some(provider) = self.key_provider {
535            IrohDb::open_with_key_provider(&self.root, provider).await?
536        } else {
537            IrohDb::open(&self.root).await?
538        };
539        for step in &self.migrations {
540            (step.register_from)(&database);
541            (step.register_to)(&database);
542        }
543        *database
544            .inner
545            .migrations
546            .write()
547            .expect("migration registry lock is not poisoned") = self.migrations;
548        let report = if self.run_migrations_on_open {
549            database.run_migrations().await?
550        } else {
551            MigrationReport::default()
552        };
553        Ok((database, report))
554    }
555}
556
557impl IrohDb {
558    /// Begins opening a database with an application migration registry.
559    pub fn builder(root: impl AsRef<Path>) -> IrohDbBuilder {
560        IrohDbBuilder {
561            root: root.as_ref().to_path_buf(),
562            migrations: Vec::new(),
563            run_migrations_on_open: true,
564            key_provider: None,
565        }
566    }
567
568    /// Opens or creates an offline embedded database. No endpoint is started.
569    pub async fn open(root: impl AsRef<Path>) -> Result<Self, DbError> {
570        let root = root.as_ref();
571        std::fs::create_dir_all(root).map_err(|error| StoreError::Io(error.to_string()))?;
572        let credentials = Arc::new(LocalCredentials::load_or_create(root.join("keys"))?);
573        Self::open_with_key_provider(root, credentials).await
574    }
575
576    /// Opens a database with an application-managed durable key provider.
577    pub async fn open_with_key_provider(
578        root: impl AsRef<Path>,
579        credentials: Arc<dyn KeyProvider>,
580    ) -> Result<Self, DbError> {
581        let root = root.as_ref();
582        std::fs::create_dir_all(root).map_err(|error| StoreError::Io(error.to_string()))?;
583        let blobs = Arc::new(
584            IrohBlobStore::load(root.join("blobs"))
585                .await
586                .map_err(StoreError::from)?,
587        );
588        let store = Store::open(root, blobs.clone())?;
589        reconcile_domain_epochs(credentials.as_ref(), &store)?;
590        store.trust_author(credentials.domain_id(), credentials.signer().author())?;
591        let domain = credentials.domain_seed();
592        credentials.store_domain(&domain)?;
593        let domain = Arc::new(RwLock::new(domain));
594        let (changes, _) = broadcast::channel(CHANGE_CAPACITY);
595        let mut domain_changes = BTreeMap::new();
596        domain_changes.insert(
597            domain
598                .read()
599                .expect("domain seed lock is not poisoned")
600                .domain_id(),
601            changes,
602        );
603        let mut domains = BTreeMap::new();
604        domains.insert(
605            domain
606                .read()
607                .expect("domain seed lock is not poisoned")
608                .domain_id(),
609            domain.clone(),
610        );
611        Ok(Self {
612            inner: Arc::new(DbInner {
613                store,
614                blobs,
615                blob_scheduler: FetchScheduler::default(),
616                credentials,
617                writer: Mutex::new(()),
618                control_gate: AsyncRwLock::new(()),
619                domains: RwLock::new(domains),
620                changes: RwLock::new(domain_changes),
621                adapters: RwLock::new(BTreeMap::new()),
622                migrations: RwLock::new(Vec::new()),
623                telemetry: Arc::new(Telemetry::default()),
624            }),
625            domain,
626        })
627    }
628
629    /// Creates a new per-device identity inside an existing encrypted domain.
630    pub async fn create_in_domain(
631        root: impl AsRef<Path>,
632        seed: &DomainSeed,
633    ) -> Result<Self, DbError> {
634        let root = root.as_ref();
635        std::fs::create_dir_all(root).map_err(|error| StoreError::Io(error.to_string()))?;
636        LocalCredentials::create_for_domain(root.join("keys"), seed)?;
637        Self::open(root).await
638    }
639
640    /// Opens a strongly typed collection in the default private domain.
641    pub fn collection<Record: IrohRecord>(&self) -> Collection<Record> {
642        register_record_adapter::<Record>(self);
643        Collection {
644            db: self.clone(),
645            marker: PhantomData,
646        }
647    }
648
649    /// Starts an atomic transaction in the default private domain.
650    pub fn transaction(&self) -> Transaction {
651        Transaction {
652            db: self.clone(),
653            prepared: Vec::new(),
654        }
655    }
656
657    /// Returns the encrypted first-class blob API for the default domain.
658    pub fn blobs(&self) -> Blobs {
659        Blobs { db: self.clone() }
660    }
661
662    /// Returns this device's authenticated endpoint identity.
663    pub fn author_id(&self) -> AuthorId {
664        self.inner.credentials.signer().author()
665    }
666
667    /// Exports sensitive domain bootstrap material for a trusted device-add workflow.
668    pub fn domain_seed(&self) -> DomainSeed {
669        self.domain
670            .read()
671            .expect("domain seed lock is not poisoned")
672            .clone()
673    }
674
675    /// Returns this handle's isolated security and replication domain.
676    pub fn domain_id(&self) -> iroh_db_core::DomainId {
677        self.domain
678            .read()
679            .expect("domain seed lock is not poisoned")
680            .domain_id()
681    }
682
683    /// Attaches an independently encrypted domain to this device and returns its handle.
684    pub fn attach_domain(&self, seed: &DomainSeed) -> Result<Self, DbError> {
685        self.inner.credentials.store_domain(seed)?;
686        self.inner
687            .store
688            .trust_author(seed.domain_id(), self.author_id())?;
689        self.ensure_change_channel(seed.domain_id());
690        self.domain(seed.domain_id())
691    }
692
693    /// Creates a capability-controlled domain owned by this device.
694    pub fn create_domain(&self, mode: ConsistencyMode) -> Result<Self, DbError> {
695        let seed = DomainSeed::generate()?;
696        let domain = self.attach_domain(&seed)?;
697        let mut descriptor_nonce = [0_u8; 32];
698        let mut capability_nonce = [0_u8; 32];
699        getrandom::fill(&mut descriptor_nonce).map_err(|_| DbError::RandomnessUnavailable)?;
700        getrandom::fill(&mut capability_nonce).map_err(|_| DbError::RandomnessUnavailable)?;
701        let signer = self.inner.credentials.signer();
702        let descriptor =
703            DomainDescriptor::issue(seed.domain_id(), mode, &signer, descriptor_nonce)?;
704        let root = CapabilityCertificate::issue_root(
705            seed.domain_id(),
706            PermissionSet::all(),
707            &signer,
708            capability_nonce,
709        )?;
710        self.inner.store.bootstrap_domain(&descriptor, &root)?;
711        Ok(domain)
712    }
713
714    /// Returns the signed genesis descriptor for a controlled domain.
715    pub fn domain_descriptor(&self) -> Result<Option<DomainDescriptor>, DbError> {
716        self.inner
717            .store
718            .domain_descriptor(self.domain_id())
719            .map_err(DbError::from)
720    }
721
722    /// Advances the domain epoch at its current causal frontier and revokes endpoints.
723    pub fn rotate_epoch(
724        &self,
725        revoked_subjects: &[AuthorId],
726    ) -> Result<(Self, ControlTransition), DbError> {
727        let controller = self.inner.store.current_controller(self.domain_id())?;
728        self.rotate_epoch_with_controller(revoked_subjects, controller)
729    }
730
731    /// Advances the epoch and designates the endpoint that must sign the following transition.
732    pub fn rotate_epoch_with_controller(
733        &self,
734        revoked_subjects: &[AuthorId],
735        next_controller: AuthorId,
736    ) -> Result<(Self, ControlTransition), DbError> {
737        let _control_writer = self
738            .inner
739            .control_gate
740            .try_write()
741            .map_err(|_| DbError::Operation("control plane is busy".into()))?;
742        let active_epoch = self.inner.store.current_epoch(self.domain_id())?;
743        if self.epoch() != active_epoch {
744            return Err(DbError::StaleEpoch {
745                handle: self.epoch(),
746                active: active_epoch,
747            });
748        }
749        let author_capability = self
750            .inner
751            .store
752            .active_capability(self.domain_id(), self.author_id())?
753            .ok_or(DbError::UnauthorizedDevice)?;
754        if self.inner.store.current_controller(self.domain_id())? != self.author_id() {
755            return Err(DbError::UnauthorizedDevice);
756        }
757        let head = self.inner.store.control_head(self.domain_id())?;
758        let sequence = head.as_ref().map_or(Ok(1), |value| {
759            value
760                .sequence()
761                .checked_add(1)
762                .ok_or(DbError::SequenceExhausted)
763        })?;
764        let predecessor = head.as_ref().map(ControlTransition::id).transpose()?;
765        let next_seed = self.domain_seed().next_epoch()?;
766        let writer = self
767            .inner
768            .store
769            .current_writer(self.domain_id())?
770            .ok_or(DbError::UnauthorizedDevice)?;
771        let next_controller_capability = self
772            .inner
773            .store
774            .active_capability(self.domain_id(), next_controller)?
775            .ok_or(DbError::UnauthorizedDevice)?;
776        let mut nonce = [0_u8; 32];
777        getrandom::fill(&mut nonce).map_err(|_| DbError::RandomnessUnavailable)?;
778        let control = ControlTransition::issue(
779            self.domain_id(),
780            sequence,
781            predecessor,
782            active_epoch,
783            self.inner.store.frontier(self.domain_id())?,
784            revoked_subjects.to_vec(),
785            writer,
786            next_controller,
787            next_controller_capability,
788            next_seed.distribution_digest(),
789            author_capability,
790            nonce,
791            &self.inner.credentials.signer(),
792        )?;
793        self.inner.store.observe_control(&control)?;
794        self.inner.credentials.stage_domain(&next_seed)?;
795        if let Err(error) = self.inner.store.apply_control(&control) {
796            return Err(DbError::Store(error));
797        }
798        self.inner.credentials.activate_domain(&next_seed)?;
799        self.set_domain_seed(&next_seed);
800        Ok((self.domain(self.domain_id())?, control))
801    }
802
803    /// Applies signed control and its separately delivered endpoint-targeted epoch key.
804    pub fn apply_epoch_rotation(
805        &self,
806        control: &ControlTransition,
807        next_seed: &DomainSeed,
808    ) -> Result<Self, DbError> {
809        self.apply_epoch_rotation_with_capabilities(control, next_seed, &[])
810    }
811
812    pub(crate) fn apply_epoch_rotation_with_capabilities(
813        &self,
814        control: &ControlTransition,
815        next_seed: &DomainSeed,
816        successor_chain: &[CapabilityCertificate],
817    ) -> Result<Self, DbError> {
818        let _control_writer = self
819            .inner
820            .control_gate
821            .try_write()
822            .map_err(|_| DbError::Operation("control plane is busy".into()))?;
823        if control.domain_id() != self.domain_id()
824            || next_seed.domain_id() != self.domain_id()
825            || next_seed.epoch() != control.new_epoch()
826            || next_seed.distribution_digest() != control.key_distribution_digest()
827            || control.revoked_subjects().contains(&self.author_id())
828        {
829            return Err(DbError::UnauthorizedDevice);
830        }
831        if successor_chain.is_empty() {
832            self.inner.store.observe_control(control)?;
833        }
834        self.inner.credentials.stage_domain(next_seed)?;
835        let apply_result = if successor_chain.is_empty() {
836            self.inner.store.apply_control(control)
837        } else {
838            self.inner
839                .store
840                .apply_control_bundle(control, successor_chain)
841        };
842        if let Err(error) = apply_result {
843            return Err(DbError::Store(error));
844        }
845        self.inner.credentials.activate_domain(next_seed)?;
846        self.set_domain_seed(next_seed);
847        self.domain(self.domain_id())
848    }
849
850    /// Issues and installs a narrowed endpoint capability from this device's active grant.
851    #[allow(clippy::too_many_arguments)]
852    pub fn issue_capability(
853        &self,
854        subject: AuthorId,
855        permissions: PermissionSet,
856        delegation_permissions: PermissionSet,
857        valid_from_epoch: u64,
858        valid_through_epoch: Option<u64>,
859    ) -> Result<CapabilityCertificate, DbError> {
860        let _control_writer = self
861            .inner
862            .control_gate
863            .try_write()
864            .map_err(|_| DbError::Operation("control plane is busy".into()))?;
865        self.issue_capability_guarded(
866            subject,
867            permissions,
868            delegation_permissions,
869            valid_from_epoch,
870            valid_through_epoch,
871        )
872    }
873
874    fn issue_capability_guarded(
875        &self,
876        subject: AuthorId,
877        permissions: PermissionSet,
878        delegation_permissions: PermissionSet,
879        valid_from_epoch: u64,
880        valid_through_epoch: Option<u64>,
881    ) -> Result<CapabilityCertificate, DbError> {
882        let active_epoch = self.inner.store.current_epoch(self.domain_id())?;
883        if self.epoch() != active_epoch {
884            return Err(DbError::StaleEpoch {
885                handle: self.epoch(),
886                active: active_epoch,
887            });
888        }
889        let issuer_id = self
890            .inner
891            .store
892            .active_capability(self.domain_id(), self.author_id())?
893            .ok_or(DbError::UnauthorizedDevice)?;
894        if !self.inner.store.authorize_capability(
895            self.domain_id(),
896            self.author_id(),
897            issuer_id,
898            Permission::Invite,
899            active_epoch,
900        )? {
901            return Err(DbError::UnauthorizedDevice);
902        }
903        let issuer = self
904            .inner
905            .store
906            .capability(issuer_id)?
907            .ok_or(DbError::UnauthorizedDevice)?;
908        let control_head = self.inner.store.control_head(self.domain_id())?;
909        let control_sequence = control_head.as_ref().map_or(0, ControlTransition::sequence);
910        let control_head_id = control_head
911            .as_ref()
912            .map(ControlTransition::id)
913            .transpose()?;
914        let mut nonce = [0_u8; 32];
915        getrandom::fill(&mut nonce).map_err(|_| DbError::RandomnessUnavailable)?;
916        let capability = issuer.delegate_at_control(
917            &self.inner.credentials.signer(),
918            subject,
919            permissions,
920            delegation_permissions,
921            control_sequence,
922            control_head_id,
923            valid_from_epoch,
924            valid_through_epoch,
925            nonce,
926        )?;
927        self.inner.store.install_capability(&capability)?;
928        self.inner.store.trust_author(self.domain_id(), subject)?;
929        Ok(capability)
930    }
931
932    /// Returns this device's active capability in the selected domain.
933    pub fn active_capability(&self) -> Result<Option<CapabilityCertificate>, DbError> {
934        self.inner
935            .store
936            .active_capability(self.domain_id(), self.author_id())?
937            .map(|id| self.inner.store.capability(id))
938            .transpose()
939            .map(Option::flatten)
940            .map_err(DbError::from)
941    }
942
943    pub(crate) fn capability_for_permission(
944        &self,
945        subject: AuthorId,
946        permission: Permission,
947        epoch: u64,
948    ) -> Result<CapabilityId, DbError> {
949        if !self
950            .inner
951            .store
952            .is_author_trusted(self.domain_id(), subject)?
953        {
954            return Err(DbError::UnauthorizedDevice);
955        }
956        if self
957            .inner
958            .store
959            .domain_descriptor(self.domain_id())?
960            .is_none()
961        {
962            return Ok(private_capability(self.domain_id()));
963        }
964        let capability_id = self
965            .inner
966            .store
967            .active_capability(self.domain_id(), subject)?
968            .ok_or(DbError::UnauthorizedDevice)?;
969        if !self.inner.store.authorize_capability(
970            self.domain_id(),
971            subject,
972            capability_id,
973            permission,
974            epoch,
975        )? {
976            return Err(DbError::UnauthorizedDevice);
977        }
978        Ok(capability_id)
979    }
980
981    pub(crate) fn blob_engine(&self) -> BlobEngine {
982        BlobEngine::new_at_epoch(
983            Arc::new(DomainBlobStore {
984                blobs: self.inner.blobs.clone(),
985                metadata: self.inner.store.clone(),
986                domain_id: self.domain_id(),
987            }),
988            self.domain_id(),
989            self.epoch(),
990            self.domain_key(),
991        )
992        .with_fetch_scheduler(self.inner.blob_scheduler.clone())
993    }
994
995    pub(crate) fn blob_engine_for_epoch(&self, epoch: u64) -> Result<BlobEngine, DbError> {
996        Ok(BlobEngine::new_at_epoch(
997            Arc::new(DomainBlobStore {
998                blobs: self.inner.blobs.clone(),
999                metadata: self.inner.store.clone(),
1000                domain_id: self.domain_id(),
1001            }),
1002            self.domain_id(),
1003            epoch,
1004            self.domain_key_for(epoch)?,
1005        )
1006        .with_fetch_scheduler(self.inner.blob_scheduler.clone()))
1007    }
1008
1009    /// Issues a targeted signed invitation carrying the current epoch key and capability chain.
1010    pub fn invite(
1011        &self,
1012        subject: AuthorId,
1013        permissions: PermissionSet,
1014        delegation_permissions: PermissionSet,
1015        valid_through_epoch: Option<u64>,
1016    ) -> Result<Invitation, DbError> {
1017        let _control_writer = self
1018            .inner
1019            .control_gate
1020            .try_write()
1021            .map_err(|_| DbError::Operation("control plane is busy".into()))?;
1022        let capability = self.issue_capability_guarded(
1023            subject,
1024            permissions,
1025            delegation_permissions,
1026            self.epoch(),
1027            valid_through_epoch,
1028        )?;
1029        let descriptor = self
1030            .domain_descriptor()?
1031            .ok_or(DbError::UnauthorizedDevice)?;
1032        let chain = self.inner.store.capability_chain(capability.id()?)?;
1033        let head = self.inner.store.control_head(self.domain_id())?;
1034        let controller_capability = head.as_ref().map_or_else(
1035            || {
1036                self.inner
1037                    .store
1038                    .active_capability(self.domain_id(), descriptor.owner())?
1039                    .ok_or(DbError::UnauthorizedDevice)
1040            },
1041            |control| Ok(control.controller_capability()),
1042        )?;
1043        let controller_chain = self.inner.store.capability_chain(controller_capability)?;
1044        let issuer_capability = capability
1045            .issuer_capability()
1046            .ok_or(DbError::UnauthorizedDevice)?;
1047        let active_chains = self
1048            .inner
1049            .store
1050            .active_capabilities(self.domain_id())?
1051            .into_iter()
1052            .map(|id| self.inner.store.capability_chain(id).map_err(DbError::from))
1053            .collect::<Result<Vec<_>, _>>()?;
1054        Invitation::issue_at_checkpoint_with_evidence(
1055            descriptor,
1056            chain,
1057            controller_chain,
1058            &active_chains,
1059            self.domain_seed(),
1060            head,
1061            self.inner.store.subject_revocations(self.domain_id())?,
1062            issuer_capability,
1063            subject,
1064            &self.inner.credentials.signer(),
1065        )
1066        .map_err(DbError::from)
1067    }
1068
1069    /// Imports a validated invitation only when it names this device endpoint.
1070    pub fn import_invitation(&self, invitation: &Invitation) -> Result<Self, DbError> {
1071        let _control_writer = self
1072            .inner
1073            .control_gate
1074            .try_write()
1075            .map_err(|_| DbError::Operation("control plane is busy".into()))?;
1076        self.import_invitation_guarded(invitation)
1077    }
1078
1079    fn import_invitation_guarded(&self, invitation: &Invitation) -> Result<Self, DbError> {
1080        if invitation.subject() != self.author_id() {
1081            return Err(DbError::UnauthorizedDevice);
1082        }
1083        let authority_exists = self
1084            .inner
1085            .store
1086            .domain_descriptor(invitation.descriptor().domain_id())?
1087            .is_some();
1088        if authority_exists {
1089            let domain_id = invitation.descriptor().domain_id();
1090            let current_head = self.inner.store.control_head(domain_id)?;
1091            let current_head_id = current_head
1092                .as_ref()
1093                .map(ControlTransition::id)
1094                .transpose()?;
1095            let checkpoint_head_id = invitation
1096                .authority_checkpoint()
1097                .head()
1098                .map(ControlTransition::id)
1099                .transpose()?;
1100            if self.inner.store.current_epoch(domain_id)? != invitation.domain_seed().epoch()
1101                || current_head_id != checkpoint_head_id
1102            {
1103                return Err(DbError::UnauthorizedDevice);
1104            }
1105            self.inner
1106                .store
1107                .install_capability_chain_atomic(invitation.capability_chain())?;
1108            self.inner
1109                .store
1110                .trust_author(domain_id, invitation.issuer())?;
1111        } else {
1112            self.inner
1113                .store
1114                .import_authority_checkpoint_atomic(invitation)?;
1115        }
1116        self.inner
1117            .credentials
1118            .store_domain(&invitation.domain_seed())?;
1119        self.domain(invitation.descriptor().domain_id())
1120    }
1121
1122    /// Decrypts an offline ticket for this endpoint and imports its domain authority.
1123    pub fn import_ticket(
1124        &self,
1125        ticket: &InvitationTicket,
1126        secret: &InvitationSecret,
1127    ) -> Result<Self, DbError> {
1128        let invitation = ticket.open(secret, self.author_id())?;
1129        let _control_writer = self
1130            .inner
1131            .control_gate
1132            .try_write()
1133            .map_err(|_| DbError::Operation("control plane is busy".into()))?;
1134        if !self.inner.store.consume_invitation(ticket.id()?)? {
1135            return Err(DbError::InvitationAlreadyUsed);
1136        }
1137        self.import_invitation_guarded(&invitation)
1138    }
1139
1140    /// Opens a previously attached domain under the same device identity and local store.
1141    pub fn domain(&self, domain_id: iroh_db_core::DomainId) -> Result<Self, DbError> {
1142        let seed = self.inner.credentials.load_domain(domain_id)?;
1143        self.ensure_change_channel(domain_id);
1144        let domain = self
1145            .inner
1146            .domains
1147            .write()
1148            .expect("domain context lock is not poisoned")
1149            .entry(domain_id)
1150            .or_insert_with(|| Arc::new(RwLock::new(seed)))
1151            .clone();
1152        Ok(Self {
1153            inner: self.inner.clone(),
1154            domain,
1155        })
1156    }
1157
1158    /// Lists domains whose encrypted keys are available to this device.
1159    pub fn domains(&self) -> Result<Vec<iroh_db_core::DomainId>, DbError> {
1160        self.inner.credentials.domains().map_err(DbError::from)
1161    }
1162
1163    /// Replaces a bounded local UX group; authorization remains endpoint-specific.
1164    pub fn set_device_group(&self, label: &str, members: &[AuthorId]) -> Result<(), DbError> {
1165        self.inner
1166            .store
1167            .set_device_group(self.domain_id(), label, members)
1168            .map_err(DbError::from)
1169    }
1170
1171    /// Lists durable application-facing device groups for this domain.
1172    pub fn device_groups(&self) -> Result<Vec<DeviceGroup>, DbError> {
1173        Ok(self
1174            .inner
1175            .store
1176            .device_groups(self.domain_id())?
1177            .into_iter()
1178            .map(|(label, members)| DeviceGroup { label, members })
1179            .collect())
1180    }
1181
1182    /// Copies selected current typed records into one independent destination commit.
1183    pub async fn project<Record, PredicateFn>(
1184        &self,
1185        destination: &IrohDb,
1186        mut include: PredicateFn,
1187    ) -> Result<ProjectionReport, DbError>
1188    where
1189        Record: IrohRecord,
1190        PredicateFn: FnMut(&Record) -> bool,
1191    {
1192        if self.domain_id() == destination.domain_id() {
1193            return Err(DbError::InvalidRemoteCommit(
1194                "projection destination must be another domain".into(),
1195            ));
1196        }
1197        let records = self.collection::<Record>().query().fetch().await?;
1198        let _destination_collection = destination.collection::<Record>();
1199        let selected: Vec<_> = records
1200            .into_iter()
1201            .filter(|record| include(record))
1202            .collect();
1203        if selected.is_empty() {
1204            return Ok(ProjectionReport {
1205                selected: 0,
1206                commit: None,
1207            });
1208        }
1209        let selected_count = selected.len();
1210        let mut transaction = destination.transaction();
1211        for record in selected {
1212            transaction.put(record)?;
1213        }
1214        let batch = transaction.commit().await?;
1215        Ok(ProjectionReport {
1216            selected: selected_count,
1217            commit: Some(batch.commit_id()),
1218        })
1219    }
1220
1221    /// Adds a device identity to the local authorization set.
1222    pub fn trust_device(&self, author: AuthorId) -> Result<(), DbError> {
1223        self.inner.store.trust_author(self.domain_id(), author)?;
1224        Ok(())
1225    }
1226
1227    /// Loads accepted immutable commit envelopes in deterministic content-hash order.
1228    pub async fn commit_envelopes(&self) -> Result<Vec<CommitEnvelope>, DbError> {
1229        let domain = self.domain_id();
1230        let mut commits = Vec::new();
1231        for commit_id in self.inner.store.list_stored_commit_ids(domain)? {
1232            commits.push(self.inner.store.load_commit(commit_id).await?);
1233        }
1234        Ok(commits)
1235    }
1236
1237    /// Validates, CRDT-merges, and atomically accepts one commit from an authenticated endpoint.
1238    pub async fn accept_remote_commit(
1239        &self,
1240        authenticated_peer: AuthorId,
1241        envelope: CommitEnvelope,
1242    ) -> Result<RemoteApply, DbError> {
1243        let _control_reader = self.inner.control_gate.read().await;
1244        let _writer = self.inner.writer.lock().await;
1245        let header = envelope.header();
1246        let domain_id = self.domain_id();
1247        let commit_id = envelope.commit_id();
1248        if header.domain_id() != domain_id {
1249            return Err(DbError::UnauthorizedDevice);
1250        }
1251        if let Some(outcome) = self.quarantined_remote_apply(header, commit_id)? {
1252            return Ok(outcome);
1253        }
1254        if self
1255            .inner
1256            .store
1257            .contains_commit(self.domain_id(), commit_id)?
1258        {
1259            return Ok(RemoteApply::Duplicate(commit_id));
1260        }
1261        self.validate_remote_commit_authority(authenticated_peer, header, commit_id)?;
1262        let body = open_commit(&envelope, &self.domain_key_for(header.epoch())?)?;
1263        if let Some(existing) = self.inner.store.conflicting_author_commit(&envelope)? {
1264            return self.quarantine_equivocation(envelope, existing).await;
1265        }
1266        let adapters = self
1267            .inner
1268            .adapters
1269            .read()
1270            .expect("record adapter lock is not poisoned")
1271            .clone();
1272        let migration = decode_migration_body(&envelope, &body)?;
1273        if let Some(outcome) = self
1274            .accept_remote_migration(&envelope, migration, &adapters)
1275            .await?
1276        {
1277            return Ok(outcome);
1278        }
1279        self.validate_consistency(header.author(), &body)?;
1280        if self.commit_requires_schema_rebase(&body)? {
1281            let context = self.inclusive_commit_context(&envelope).await?;
1282            return match self
1283                .inner
1284                .store
1285                .persist_commit_with_reconciliation(&envelope, &[], &context, &[])
1286                .await?
1287            {
1288                ApplyOutcome::Duplicate(id) => Ok(RemoteApply::Duplicate(id)),
1289                ApplyOutcome::Applied(id) => {
1290                    self.inner.telemetry.record_schema_rebase();
1291                    Ok(RemoteApply::SchemaRebaseRequired { commit: id })
1292                }
1293            };
1294        }
1295        let converged = self
1296            .converged_mutations(&envelope, &body, &adapters)
1297            .await?;
1298        let changes = converged
1299            .mutations
1300            .iter()
1301            .map(|mutation| Change {
1302                collection_id: mutation.schema().collection_id(),
1303                record_id: mutation.record_id().to_vec(),
1304                kind: if mutation.state().is_some() {
1305                    ChangeKind::Upserted
1306                } else {
1307                    ChangeKind::Deleted
1308                },
1309            })
1310            .collect();
1311        match self
1312            .inner
1313            .store
1314            .persist_commit_with_reconciliation(
1315                &envelope,
1316                &converged.mutations,
1317                &converged.context,
1318                &converged.record_heads,
1319            )
1320            .await?
1321        {
1322            ApplyOutcome::Duplicate(id) => Ok(RemoteApply::Duplicate(id)),
1323            ApplyOutcome::Applied(id) => {
1324                let batch = ChangeBatch {
1325                    commit_id: id,
1326                    sequence: header.author_sequence(),
1327                    frontier: self.inner.store.frontier(self.domain_id())?,
1328                    changes,
1329                };
1330                let _no_subscribers = self.changes().send(batch);
1331                Ok(RemoteApply::Applied(id))
1332            }
1333        }
1334    }
1335
1336    fn validate_remote_commit_authority(
1337        &self,
1338        authenticated_peer: AuthorId,
1339        header: &CommitHeader,
1340        commit_id: CommitId,
1341    ) -> Result<(), DbError> {
1342        let domain_id = self.domain_id();
1343        let active_epoch = self.inner.store.current_epoch(domain_id)?;
1344        if header.epoch() < active_epoch {
1345            return Err(DbError::RebaseRequired(commit_id));
1346        }
1347        if header.epoch() != active_epoch
1348            || !self
1349                .inner
1350                .store
1351                .is_author_trusted(domain_id, authenticated_peer)?
1352            || !self
1353                .inner
1354                .store
1355                .is_author_trusted(domain_id, header.author())?
1356        {
1357            return Err(DbError::UnauthorizedDevice);
1358        }
1359        if self.inner.store.domain_descriptor(domain_id)?.is_some()
1360            && !self.inner.store.authorize_capability(
1361                domain_id,
1362                header.author(),
1363                header.capability_id(),
1364                Permission::Write,
1365                header.epoch(),
1366            )?
1367        {
1368            return Err(DbError::UnauthorizedDevice);
1369        }
1370        Ok(())
1371    }
1372
1373    async fn accept_remote_migration(
1374        &self,
1375        envelope: &CommitEnvelope,
1376        migration: MigrationBody,
1377        adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
1378    ) -> Result<Option<RemoteApply>, DbError> {
1379        let header = envelope.header();
1380        match migration {
1381            MigrationBody::Ordinary => Ok(None),
1382            MigrationBody::Stages(stage_batch) => {
1383                self.validate_remote_migration_authority(header)?;
1384                validate_staged_records(&stage_batch, adapters)?;
1385                let context = self.inclusive_commit_context(envelope).await?;
1386                let outcome = self
1387                    .inner
1388                    .store
1389                    .persist_schema_stages_with_context(envelope, &stage_batch, &context)
1390                    .await?;
1391                Ok(Some(match outcome {
1392                    ApplyOutcome::Duplicate(id) => RemoteApply::Duplicate(id),
1393                    ApplyOutcome::Applied(id) => RemoteApply::Applied(id),
1394                }))
1395            }
1396            MigrationBody::Activation(activation) => {
1397                self.validate_remote_migration_authority(header)?;
1398                self.validate_activation_cut(envelope, &activation).await?;
1399                let before = self.inner.store.export_materialized(self.domain_id())?;
1400                let context = self.inclusive_commit_context(envelope).await?;
1401                let outcome = self
1402                    .inner
1403                    .store
1404                    .persist_schema_activation_with_context(envelope, &activation, &context)
1405                    .await?;
1406                Ok(Some(match outcome {
1407                    ApplyOutcome::Duplicate(id) => RemoteApply::Duplicate(id),
1408                    ApplyOutcome::Applied(id) => {
1409                        let after = self.inner.store.export_materialized(self.domain_id())?;
1410                        let changes = materialization_changes(&before, &after);
1411                        if !changes.is_empty() {
1412                            let _no_subscribers = self.changes().send(ChangeBatch {
1413                                commit_id: id,
1414                                sequence: header.author_sequence(),
1415                                frontier: self.inner.store.frontier(self.domain_id())?,
1416                                changes,
1417                            });
1418                        }
1419                        RemoteApply::Applied(id)
1420                    }
1421                }))
1422            }
1423        }
1424    }
1425
1426    fn validate_remote_migration_authority(&self, header: &CommitHeader) -> Result<(), DbError> {
1427        if self
1428            .inner
1429            .store
1430            .domain_descriptor(self.domain_id())?
1431            .is_some()
1432            && (self.inner.store.current_controller(self.domain_id())? != header.author()
1433                || !self.inner.store.authorize_capability(
1434                    self.domain_id(),
1435                    header.author(),
1436                    header.capability_id(),
1437                    Permission::Admin,
1438                    header.epoch(),
1439                )?)
1440        {
1441            return Err(DbError::UnauthorizedDevice);
1442        }
1443        Ok(())
1444    }
1445
1446    fn commit_requires_schema_rebase(&self, body: &CommitBody) -> Result<bool, DbError> {
1447        let mut older = false;
1448        let mut current = false;
1449        for operation in body.operations() {
1450            let mutation = MaterializedRecord::decode_canonical(operation.payload())?;
1451            let Some(activation) = self
1452                .inner
1453                .store
1454                .active_schema_activation(self.domain_id(), operation.collection_id())?
1455            else {
1456                current = true;
1457                continue;
1458            };
1459            if mutation.schema().version() < activation.to().version() {
1460                older = true;
1461            } else {
1462                current = true;
1463            }
1464        }
1465        if older && current {
1466            return Err(DbError::InvalidRemoteCommit(
1467                "one commit crosses an active schema boundary".into(),
1468            ));
1469        }
1470        Ok(older)
1471    }
1472
1473    async fn validate_activation_cut(
1474        &self,
1475        incoming: &CommitEnvelope,
1476        activation: &SchemaActivation,
1477    ) -> Result<(), DbError> {
1478        let mut graph = BTreeMap::<CommitId, Vec<CommitId>>::new();
1479        let mut evidence = BTreeMap::<Vec<u8>, Vec<(CommitId, SchemaStage)>>::new();
1480        for commit_id in self.inner.store.list_stored_commit_ids(self.domain_id())? {
1481            let envelope = self.inner.store.load_commit(commit_id).await?;
1482            graph.insert(commit_id, envelope.header().dependencies().to_vec());
1483            let body = open_commit(&envelope, &self.domain_key_for(envelope.header().epoch())?)?;
1484            if let MigrationBody::Stages(stages) = decode_migration_body(&envelope, &body)? {
1485                for stage in stages {
1486                    if stage.migration_id() == activation.migration_id() {
1487                        evidence
1488                            .entry(stage.source_record_id().to_vec())
1489                            .or_default()
1490                            .push((commit_id, stage));
1491                    }
1492                }
1493            }
1494        }
1495        graph.insert(
1496            incoming.commit_id(),
1497            incoming.header().dependencies().to_vec(),
1498        );
1499        let durable = self
1500            .inner
1501            .store
1502            .schema_stages(self.domain_id(), activation.migration_id())?;
1503        for stage in durable {
1504            let causally_selected =
1505                evidence
1506                    .get(stage.source_record_id())
1507                    .is_some_and(|candidates| {
1508                        candidates.iter().any(|(commit_id, candidate)| {
1509                            candidate == &stage
1510                                && is_ancestor(*commit_id, incoming.commit_id(), &graph)
1511                        })
1512                    });
1513            if !causally_selected {
1514                return Err(DbError::InvalidRemoteCommit(
1515                    "activation does not causally depend on its complete staging set".into(),
1516                ));
1517            }
1518        }
1519        let selected_ids: Vec<_> = graph
1520            .keys()
1521            .copied()
1522            .filter(|commit_id| {
1523                *commit_id != incoming.commit_id()
1524                    && is_ancestor(*commit_id, incoming.commit_id(), &graph)
1525            })
1526            .collect();
1527        let adapters = self
1528            .inner
1529            .adapters
1530            .read()
1531            .expect("record adapter lock is not poisoned")
1532            .clone();
1533        let source_records = self.rebuild_commit_ids(&selected_ids, &adapters).await?;
1534        let sources: BTreeMap<_, _> = source_records
1535            .iter()
1536            .filter(|record| {
1537                record.schema().collection_id() == activation.from().collection_id()
1538                    && record.schema().schema_id() == activation.from().schema_id()
1539                    && record.state().is_some()
1540            })
1541            .map(|record| (record.record_id().to_vec(), record))
1542            .collect();
1543        let durable = self
1544            .inner
1545            .store
1546            .schema_stages(self.domain_id(), activation.migration_id())?;
1547        if sources.len() != durable.len()
1548            || durable.iter().any(|stage| {
1549                sources
1550                    .get(stage.source_record_id())
1551                    .and_then(|record| record.state())
1552                    .is_none_or(|state| {
1553                        BlobHash::from_bytes(*blake3::hash(state).as_bytes())
1554                            != stage.source_digest()
1555                    })
1556            })
1557        {
1558            return Err(DbError::InvalidRemoteCommit(
1559                "activation staging does not exactly cover its source-state cut".into(),
1560            ));
1561        }
1562        Ok(())
1563    }
1564
1565    fn quarantined_remote_apply(
1566        &self,
1567        header: &CommitHeader,
1568        commit_id: CommitId,
1569    ) -> Result<Option<RemoteApply>, DbError> {
1570        let commit_quarantined = self.inner.store.is_commit_quarantined(commit_id)?;
1571        let author_quarantined = self
1572            .inner
1573            .store
1574            .author_quarantine(header.domain_id(), header.author())?
1575            .is_some_and(|from_sequence| header.author_sequence() >= from_sequence);
1576        Ok(
1577            (commit_quarantined || author_quarantined).then_some(RemoteApply::Quarantined {
1578                author: header.author(),
1579                sequence: header.author_sequence(),
1580            }),
1581        )
1582    }
1583
1584    async fn quarantine_equivocation(
1585        &self,
1586        evidence: CommitEnvelope,
1587        existing: CommitId,
1588    ) -> Result<RemoteApply, DbError> {
1589        let author = evidence.header().author();
1590        let sequence = evidence.header().author_sequence();
1591        let commit_ids = self.inner.store.list_stored_commit_ids(self.domain_id())?;
1592        let mut graph = BTreeMap::<CommitId, Vec<CommitId>>::new();
1593        let mut headers = BTreeMap::<CommitId, (AuthorId, u64)>::new();
1594        for commit_id in &commit_ids {
1595            let envelope = self.inner.store.load_commit(*commit_id).await?;
1596            graph.insert(*commit_id, envelope.header().dependencies().to_vec());
1597            headers.insert(
1598                *commit_id,
1599                (
1600                    envelope.header().author(),
1601                    envelope.header().author_sequence(),
1602                ),
1603            );
1604        }
1605        let mut invalid = BTreeSet::from([existing]);
1606        loop {
1607            let mut changed = false;
1608            for (commit_id, dependencies) in &graph {
1609                if !invalid.contains(commit_id)
1610                    && dependencies
1611                        .iter()
1612                        .any(|dependency| invalid.contains(dependency))
1613                {
1614                    changed |= invalid.insert(*commit_id);
1615                }
1616            }
1617            if !changed {
1618                break;
1619            }
1620        }
1621        let accepted: Vec<_> = commit_ids
1622            .into_iter()
1623            .filter(|commit_id| !invalid.contains(commit_id))
1624            .collect();
1625        let adapters = self
1626            .inner
1627            .adapters
1628            .read()
1629            .expect("record adapter lock is not poisoned")
1630            .clone();
1631        let mutations = self.rebuild_commit_ids(&accepted, &adapters).await?;
1632        let baseline = self.inner.store.snapshot_baseline(self.domain_id())?;
1633        let metadata = quarantine_metadata(baseline.as_ref(), &accepted, &graph, &headers);
1634        let invalid: Vec<_> = invalid.into_iter().collect();
1635        let previous_records = self.inner.store.export_materialized(self.domain_id())?;
1636        self.inner
1637            .store
1638            .quarantine_equivocation(
1639                &evidence,
1640                &invalid,
1641                &metadata.frontier,
1642                &metadata.author_sequences,
1643                &metadata.author_heads,
1644                &mutations,
1645            )
1646            .await?;
1647        self.inner.telemetry.record_equivocation_quarantine();
1648        let changes = materialization_changes(&previous_records, &mutations);
1649        if !changes.is_empty() {
1650            let _no_subscribers = self.changes().send(ChangeBatch {
1651                commit_id: evidence.commit_id(),
1652                sequence,
1653                frontier: metadata.frontier,
1654                changes,
1655            });
1656        }
1657        Ok(RemoteApply::Quarantined { author, sequence })
1658    }
1659
1660    async fn converged_mutations(
1661        &self,
1662        incoming: &CommitEnvelope,
1663        incoming_body: &CommitBody,
1664        adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
1665    ) -> Result<ConvergedCommit, DbError> {
1666        let domain = self.domain_id();
1667        for dependency in incoming.header().dependencies() {
1668            if !self.inner.store.contains_commit(domain, *dependency)? {
1669                return Err(DbError::InvalidRemoteCommit(
1670                    "commit dependency is missing".into(),
1671                ));
1672            }
1673        }
1674
1675        let dependency_context = self
1676            .dependency_context(incoming.header().dependencies())
1677            .await?;
1678        let affected =
1679            validate_incoming_operations(incoming, incoming_body, adapters, &dependency_context)?;
1680        let incoming_id = incoming.commit_id();
1681        let mut mutations = Vec::with_capacity(affected.len());
1682        let mut replacements = Vec::with_capacity(affected.len());
1683        for (key, _) in affected {
1684            let incoming_mutation = incoming_body
1685                .operations()
1686                .iter()
1687                .find(|operation| {
1688                    operation.collection_id() == key.0 && operation.record_id() == key.1
1689                })
1690                .ok_or_else(|| DbError::InvalidRemoteCommit("missing record operation".into()))
1691                .and_then(|operation| {
1692                    MaterializedRecord::decode_canonical(operation.payload()).map_err(DbError::from)
1693                })?;
1694            let mut heads = self.inner.store.record_heads(domain, key.0, &key.1)?;
1695            heads.retain(|head| {
1696                !dependency_context.covers(&Dot::new(head.author(), head.sequence(), 0))
1697            });
1698            heads.push(RecordHead::new(
1699                incoming_id,
1700                incoming.header().author(),
1701                incoming.header().author_sequence(),
1702                incoming_mutation,
1703            ));
1704            heads.sort_unstable_by_key(RecordHead::commit_id);
1705            let entries: Vec<_> = heads
1706                .iter()
1707                .map(|head| (head.commit_id(), head.mutation().clone()))
1708                .collect();
1709            mutations.push(resolve_causal_record_heads(&key, &entries, adapters)?);
1710            replacements.push(RecordHeadSet::new(key.0, key.1, heads)?);
1711        }
1712        let mut context = dependency_context;
1713        context.observe(Dot::new(
1714            incoming.header().author(),
1715            incoming.header().author_sequence(),
1716            0,
1717        ));
1718        Ok(ConvergedCommit {
1719            mutations,
1720            context,
1721            record_heads: replacements,
1722        })
1723    }
1724
1725    async fn dependency_context(
1726        &self,
1727        dependencies: &[CommitId],
1728    ) -> Result<VersionVector, DbError> {
1729        self.ensure_reconciliation_index().await?;
1730        let mut context = VersionVector::new();
1731        let baseline = self.inner.store.snapshot_baseline(self.domain_id())?;
1732        for &commit_id in dependencies {
1733            if let Some(dependency) = self
1734                .inner
1735                .store
1736                .commit_context(self.domain_id(), commit_id)?
1737            {
1738                context.merge(&dependency);
1739            } else if self
1740                .inner
1741                .store
1742                .contains_commit(self.domain_id(), commit_id)?
1743            {
1744                let baseline = baseline.as_ref().ok_or_else(|| {
1745                    DbError::InvalidSnapshot(
1746                        "accepted dependency has no indexed or snapshot causal context".into(),
1747                    )
1748                })?;
1749                for &(author, sequence) in baseline.author_sequences() {
1750                    context.observe(Dot::new(author, sequence, 0));
1751                }
1752            } else {
1753                return Err(DbError::InvalidRemoteCommit(
1754                    "commit dependency is missing".into(),
1755                ));
1756            }
1757        }
1758        Ok(context)
1759    }
1760
1761    async fn inclusive_commit_context(
1762        &self,
1763        envelope: &CommitEnvelope,
1764    ) -> Result<VersionVector, DbError> {
1765        let mut context = self
1766            .dependency_context(envelope.header().dependencies())
1767            .await?;
1768        context.observe(Dot::new(
1769            envelope.header().author(),
1770            envelope.header().author_sequence(),
1771            0,
1772        ));
1773        Ok(context)
1774    }
1775
1776    #[allow(clippy::too_many_lines)]
1777    async fn ensure_reconciliation_index(&self) -> Result<(), DbError> {
1778        let domain_id = self.domain_id();
1779        if self.inner.store.reconciliation_index_ready(domain_id)? {
1780            return Ok(());
1781        }
1782        let commit_ids = self.inner.store.list_stored_commit_ids(domain_id)?;
1783        if commit_ids.len() > MAX_RECONCILE_COMMITS {
1784            return Err(DbError::InvalidRemoteCommit(
1785                "reconciliation index rebuild budget exceeded".into(),
1786            ));
1787        }
1788        self.inner
1789            .telemetry
1790            .add_reconciliation_commits_scanned(commit_ids.len());
1791
1792        let mut commits = BTreeMap::<CommitId, (CommitEnvelope, CommitBody)>::new();
1793        for commit_id in commit_ids {
1794            let envelope = self.inner.store.load_commit(commit_id).await?;
1795            if envelope.header().domain_id() != domain_id {
1796                return Err(DbError::InvalidRemoteCommit(
1797                    "indexed commit belongs to another domain".into(),
1798                ));
1799            }
1800            let body = open_commit(&envelope, &self.domain_key_for(envelope.header().epoch())?)?;
1801            commits.insert(commit_id, (envelope, body));
1802        }
1803
1804        let baseline = self.inner.store.snapshot_baseline(domain_id)?;
1805        let mut baseline_context = VersionVector::new();
1806        if let Some(baseline) = &baseline {
1807            for &(author, sequence) in baseline.author_sequences() {
1808                baseline_context.observe(Dot::new(author, sequence, 0));
1809            }
1810        }
1811        let mut pending: BTreeSet<_> = commits.keys().copied().collect();
1812        let mut contexts = BTreeMap::<CommitId, VersionVector>::new();
1813        let mut record_heads = BTreeMap::<RecordTarget, Vec<RecordHead>>::new();
1814        while !pending.is_empty() {
1815            let next = pending.iter().copied().find(|commit_id| {
1816                commits[commit_id]
1817                    .0
1818                    .header()
1819                    .dependencies()
1820                    .iter()
1821                    .all(|dependency| {
1822                        contexts.contains_key(dependency) || !commits.contains_key(dependency)
1823                    })
1824            });
1825            let Some(commit_id) = next else {
1826                return Err(DbError::InvalidRemoteCommit(
1827                    "commit history contains a dependency cycle".into(),
1828                ));
1829            };
1830            let (envelope, body) = &commits[&commit_id];
1831            let mut dependency_context = VersionVector::new();
1832            for dependency in envelope.header().dependencies() {
1833                if let Some(context) = contexts.get(dependency) {
1834                    dependency_context.merge(context);
1835                } else if self.inner.store.contains_commit(domain_id, *dependency)?
1836                    && baseline.is_some()
1837                {
1838                    dependency_context.merge(&baseline_context);
1839                } else {
1840                    return Err(DbError::InvalidRemoteCommit(
1841                        "commit history has an unindexed dependency".into(),
1842                    ));
1843                }
1844            }
1845
1846            if matches!(
1847                decode_migration_body(envelope, body)?,
1848                MigrationBody::Ordinary
1849            ) {
1850                for operation in body.operations() {
1851                    if !matches!(
1852                        operation.kind(),
1853                        OperationKind::FieldStateMerge | OperationKind::RecordDelete
1854                    ) {
1855                        return Err(DbError::InvalidRemoteCommit(
1856                            "accepted record commit contains an unsupported operation".into(),
1857                        ));
1858                    }
1859                    let mutation = MaterializedRecord::decode_canonical(operation.payload())?;
1860                    let key = (operation.collection_id(), operation.record_id().to_vec());
1861                    let heads = record_heads.entry(key).or_default();
1862                    heads.retain(|head| {
1863                        !dependency_context.covers(&Dot::new(head.author(), head.sequence(), 0))
1864                    });
1865                    heads.push(RecordHead::new(
1866                        commit_id,
1867                        envelope.header().author(),
1868                        envelope.header().author_sequence(),
1869                        mutation,
1870                    ));
1871                }
1872            }
1873            let mut context = dependency_context;
1874            context.observe(Dot::new(
1875                envelope.header().author(),
1876                envelope.header().author_sequence(),
1877                0,
1878            ));
1879            contexts.insert(commit_id, context);
1880            pending.remove(&commit_id);
1881        }
1882
1883        let contexts: Vec<_> = contexts.into_iter().collect();
1884        let heads: Vec<_> = record_heads.into_values().flatten().collect();
1885        self.inner
1886            .store
1887            .replace_reconciliation_index(domain_id, &contexts, &heads)?;
1888        Ok(())
1889    }
1890
1891    pub(crate) async fn rebuild_mutations(&self) -> Result<Vec<MaterializedRecord>, DbError> {
1892        let domain = self.domain_id();
1893        let commit_ids = self.inner.store.list_stored_commit_ids(domain)?;
1894        if commit_ids.len() > MAX_RECONCILE_COMMITS {
1895            return Err(DbError::InvalidSnapshot(
1896                "rebuild commit budget exceeded".into(),
1897            ));
1898        }
1899        let adapters = self
1900            .inner
1901            .adapters
1902            .read()
1903            .expect("record adapter lock is not poisoned")
1904            .clone();
1905        self.rebuild_commit_ids(&commit_ids, &adapters).await
1906    }
1907
1908    async fn rebuild_commit_ids(
1909        &self,
1910        commit_ids: &[CommitId],
1911        adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
1912    ) -> Result<Vec<MaterializedRecord>, DbError> {
1913        let Some(base_records) = self.verified_snapshot_baseline_records().await? else {
1914            return self.materialize_commit_ids(commit_ids, adapters).await;
1915        };
1916        let tail = self.materialize_commit_ids(commit_ids, adapters).await?;
1917        let mut rebuilt = BTreeMap::<(CollectionId, Vec<u8>), MaterializedRecord>::new();
1918        for record in base_records {
1919            let collection_id = record.schema().collection_id();
1920            let adapter = *adapters
1921                .get(&(collection_id, record.schema().schema_id()))
1922                .ok_or(DbError::UnknownRecordSchema(collection_id))?;
1923            if record.state().is_none() {
1924                return Err(DbError::InvalidSnapshot(
1925                    "snapshot contains a deleted materialized record".into(),
1926                ));
1927            }
1928            let canonical = (adapter.merge)(None, &record)?;
1929            rebuilt.insert((collection_id, canonical.record_id().to_vec()), canonical);
1930        }
1931        for mutation in tail {
1932            let collection_id = mutation.schema().collection_id();
1933            if self
1934                .inner
1935                .store
1936                .active_schema_activation(self.domain_id(), collection_id)?
1937                .is_some_and(|activation| mutation.schema().version() < activation.to().version())
1938            {
1939                continue;
1940            }
1941            let key = (collection_id, mutation.record_id().to_vec());
1942            if mutation.state().is_none() {
1943                rebuilt.remove(&key);
1944                continue;
1945            }
1946            let existing = rebuilt.get(&key);
1947            if existing.is_some_and(|value| value.schema().version() > mutation.schema().version())
1948            {
1949                continue;
1950            }
1951            let adapter = *adapters
1952                .get(&(collection_id, mutation.schema().schema_id()))
1953                .ok_or(DbError::UnknownRecordSchema(collection_id))?;
1954            let existing_state = existing
1955                .filter(|value| value.schema().schema_id() == mutation.schema().schema_id())
1956                .and_then(MaterializedRecord::state);
1957            let merged = (adapter.merge)(existing_state, &mutation)?;
1958            rebuilt.insert(key, merged);
1959        }
1960        Ok(rebuilt.into_values().collect())
1961    }
1962
1963    async fn materialize_commit_ids(
1964        &self,
1965        commit_ids: &[CommitId],
1966        adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
1967    ) -> Result<Vec<MaterializedRecord>, DbError> {
1968        let mut graph = BTreeMap::<CommitId, Vec<CommitId>>::new();
1969        let mut candidates = RecordCandidates::new();
1970        let mut stages_by_record = BTreeMap::<(MigrationId, Vec<u8>), SchemaStage>::new();
1971        let mut activations = Vec::<(CommitId, SchemaActivation)>::new();
1972        for &commit_id in commit_ids {
1973            let envelope = self.inner.store.load_commit(commit_id).await?;
1974            graph.insert(commit_id, envelope.header().dependencies().to_vec());
1975            let body = open_commit(&envelope, &self.domain_key_for(envelope.header().epoch())?)?;
1976            match decode_migration_body(&envelope, &body)? {
1977                MigrationBody::Ordinary => {
1978                    let dependency_context = self
1979                        .dependency_context(envelope.header().dependencies())
1980                        .await?;
1981                    let affected = validate_incoming_operations(
1982                        &envelope,
1983                        &body,
1984                        adapters,
1985                        &dependency_context,
1986                    )?;
1987                    collect_affected_operations(commit_id, &body, &affected, &mut candidates)?;
1988                }
1989                MigrationBody::Stages(stage_batch) => {
1990                    for stage in stage_batch {
1991                        let key = (stage.migration_id(), stage.source_record_id().to_vec());
1992                        if let Some(existing) = stages_by_record.insert(key, stage.clone())
1993                            && existing != stage
1994                        {
1995                            return Err(DbError::InvalidRemoteCommit(
1996                                "migration history contains conflicting stages".into(),
1997                            ));
1998                        }
1999                    }
2000                }
2001                MigrationBody::Activation(activation) => {
2002                    activations.push((commit_id, *activation));
2003                }
2004            }
2005        }
2006
2007        let mut active = BTreeMap::<CollectionId, (CommitId, SchemaActivation)>::new();
2008        for (commit_id, activation) in activations {
2009            let collection = activation.to().collection_id();
2010            if let Some((_, existing)) = active.get(&collection) {
2011                if existing.from().schema_id() == activation.from().schema_id()
2012                    && existing != &activation
2013                {
2014                    return Err(DbError::InvalidRemoteCommit(
2015                        "collection has incompatible schema activation siblings".into(),
2016                    ));
2017                }
2018                if existing.to().version() >= activation.to().version() {
2019                    continue;
2020                }
2021            }
2022            active.insert(collection, (commit_id, activation));
2023        }
2024
2025        for (&collection, (activation_commit, activation)) in &active {
2026            let activation_stages: Vec<_> = stages_by_record
2027                .values()
2028                .filter(|stage| stage.migration_id() == activation.migration_id())
2029                .cloned()
2030                .collect();
2031            if u64::try_from(activation_stages.len()).ok() != Some(activation.staged_records())
2032                || staging_digest(&activation_stages)? != activation.staging_digest()
2033            {
2034                return Err(DbError::InvalidRemoteCommit(
2035                    "activation staging set is incomplete during rebuild".into(),
2036                ));
2037            }
2038            for stage in activation_stages {
2039                let target = MaterializedRecord::decode_canonical(stage.target_record())?;
2040                candidates
2041                    .entry((collection, target.record_id().to_vec()))
2042                    .or_default()
2043                    .push((*activation_commit, target));
2044            }
2045        }
2046
2047        for (key, entries) in &mut candidates {
2048            let Some((activation_commit, activation)) = active.get(&key.0) else {
2049                continue;
2050            };
2051            entries.retain(|(commit_id, mutation)| {
2052                mutation.schema().version() >= activation.to().version()
2053                    && (*commit_id == *activation_commit
2054                        || is_ancestor(*activation_commit, *commit_id, &graph))
2055            });
2056        }
2057        let mut mutations = Vec::with_capacity(candidates.len());
2058        for (key, entries) in candidates {
2059            if entries.is_empty() {
2060                continue;
2061            }
2062            mutations.push(resolve_record_heads(&key, &entries, &graph, adapters)?);
2063        }
2064        Ok(mutations)
2065    }
2066
2067    /// Flushes and shuts down the local blob actor for a clean same-process reopen.
2068    pub async fn close(self) -> Result<(), DbError> {
2069        let inner = Arc::try_unwrap(self.inner).map_err(|_| DbError::HandlesOpen)?;
2070        let DbInner { store, blobs, .. } = inner;
2071        drop(store);
2072        blobs
2073            .shutdown()
2074            .await
2075            .map_err(StoreError::from)
2076            .map_err(DbError::from)
2077    }
2078
2079    pub(crate) fn domain_key(&self) -> iroh_db_security::DomainKey {
2080        self.domain_seed().domain_key()
2081    }
2082
2083    fn domain_key_for(&self, epoch: u64) -> Result<iroh_db_security::DomainKey, DbError> {
2084        self.inner
2085            .credentials
2086            .load_domain_epoch(self.domain_id(), epoch)
2087            .map(|seed| seed.domain_key())
2088            .map_err(DbError::from)
2089    }
2090
2091    pub(crate) fn epoch(&self) -> u64 {
2092        self.domain
2093            .read()
2094            .expect("domain seed lock is not poisoned")
2095            .epoch()
2096    }
2097
2098    fn set_domain_seed(&self, seed: &DomainSeed) {
2099        *self
2100            .domain
2101            .write()
2102            .expect("domain seed lock is not poisoned") = seed.clone();
2103    }
2104
2105    fn ensure_change_channel(&self, domain_id: iroh_db_core::DomainId) {
2106        let mut changes = self
2107            .inner
2108            .changes
2109            .write()
2110            .expect("change channel lock is not poisoned");
2111        changes.entry(domain_id).or_insert_with(|| {
2112            let (sender, _) = broadcast::channel(CHANGE_CAPACITY);
2113            sender
2114        });
2115    }
2116
2117    fn changes(&self) -> broadcast::Sender<ChangeBatch> {
2118        self.ensure_change_channel(self.domain_id());
2119        self.inner
2120            .changes
2121            .read()
2122            .expect("change channel lock is not poisoned")
2123            .get(&self.domain_id())
2124            .expect("domain change channel exists")
2125            .clone()
2126    }
2127
2128    /// Resumes every registered transition across all locally attached domains.
2129    pub async fn run_migrations(&self) -> Result<MigrationReport, DbError> {
2130        let steps = self
2131            .inner
2132            .migrations
2133            .read()
2134            .expect("migration registry lock is not poisoned")
2135            .clone();
2136        let mut report = MigrationReport::default();
2137        for domain_id in self.domains()? {
2138            let domain = self.domain(domain_id)?;
2139            for step in &steps {
2140                report.absorb(domain.run_migration_step(step).await?);
2141            }
2142        }
2143        Ok(report)
2144    }
2145
2146    async fn run_migration_step(&self, step: &MigrationStep) -> Result<MigrationReport, DbError> {
2147        let _control_reader = self.inner.control_gate.read().await;
2148        let _writer = self.inner.writer.lock().await;
2149        let domain_id = self.domain_id();
2150        let collection_id = step.from.collection_id();
2151        if let Some(active) = self
2152            .inner
2153            .store
2154            .active_schema_activation(domain_id, collection_id)?
2155            && active.to().version() >= step.to.version()
2156        {
2157            let registered =
2158                self.inner
2159                    .store
2160                    .registered_schema(domain_id, collection_id, step.to.version())?;
2161            if registered.is_some_and(|schema| schema.schema_id() == step.to.schema_id()) {
2162                return Ok(MigrationReport {
2163                    already_activated: 1,
2164                    ..MigrationReport::default()
2165                });
2166            }
2167            return Err(MigrationError::InvalidRegistration(
2168                "active migration chain contains an incompatible historical descriptor".into(),
2169            )
2170            .into());
2171        }
2172        let Some(latest) = self
2173            .inner
2174            .store
2175            .latest_registered_schema(domain_id, collection_id)?
2176        else {
2177            return Ok(MigrationReport::default());
2178        };
2179        if latest.schema_id() != step.from.schema_id() {
2180            return Err(MigrationError::InvalidRegistration(format!(
2181                "durable schema version {} does not match registered source version {}",
2182                latest.version(),
2183                step.from.version()
2184            ))
2185            .into());
2186        }
2187        self.require_migration_coordinator()?;
2188        let records = self.inner.store.list_records(domain_id, collection_id)?;
2189        let migration_id =
2190            migration_id(domain_id, &step.from, &step.to, source_cut_digest(&records));
2191        let mut stages = Vec::with_capacity(records.len());
2192        for record in records {
2193            let target = (step.transform)(record.state())?;
2194            let source_digest = BlobHash::from_bytes(*blake3::hash(record.state()).as_bytes());
2195            stages.push(SchemaStage::new(
2196                migration_id,
2197                step.from.clone(),
2198                step.to.clone(),
2199                record.record_id().to_vec(),
2200                source_digest,
2201                target.materialized.encode_canonical()?,
2202            )?);
2203        }
2204        let existing: BTreeSet<_> = self
2205            .inner
2206            .store
2207            .schema_stages(domain_id, migration_id)?
2208            .into_iter()
2209            .map(|stage| stage.source_record_id().to_vec())
2210            .collect();
2211        let missing: Vec<_> = stages
2212            .iter()
2213            .filter(|stage| !existing.contains(stage.source_record_id()))
2214            .cloned()
2215            .collect();
2216        for batch in missing.chunks(256) {
2217            self.commit_schema_stages_locked(batch).await?;
2218        }
2219        let activation = SchemaActivation::new(
2220            migration_id,
2221            step.from.clone(),
2222            step.to.clone(),
2223            u64::try_from(stages.len()).map_err(|_| {
2224                MigrationError::InvalidRegistration("migration record count overflow".into())
2225            })?,
2226            staging_digest(&stages)?,
2227        )?;
2228        let activation_commit = self.commit_schema_activation_locked(&activation).await?;
2229        Ok(MigrationReport {
2230            staged: missing.len(),
2231            already_staged: stages.len().saturating_sub(missing.len()),
2232            activated: 1,
2233            already_activated: 0,
2234            activation_commits: vec![activation_commit],
2235        })
2236    }
2237
2238    fn require_migration_coordinator(&self) -> Result<(), DbError> {
2239        if self
2240            .inner
2241            .store
2242            .domain_descriptor(self.domain_id())?
2243            .is_some()
2244            && self.inner.store.current_controller(self.domain_id())? != self.author_id()
2245        {
2246            return Err(DbError::UnauthorizedDevice);
2247        }
2248        Ok(())
2249    }
2250
2251    async fn commit_schema_stages_locked(
2252        &self,
2253        stages: &[SchemaStage],
2254    ) -> Result<CommitId, DbError> {
2255        if stages.is_empty() {
2256            return Err(DbError::EmptyTransaction);
2257        }
2258        let operations = stages
2259            .iter()
2260            .map(|stage| {
2261                Operation::new(
2262                    stage.to().collection_id(),
2263                    stage.source_record_id().to_vec(),
2264                    0,
2265                    OperationKind::SchemaStage,
2266                    stage.encode_canonical(),
2267                )
2268            })
2269            .collect::<Result<Vec<_>, _>>()?;
2270        let schemas = vec![stages[0].from().schema_id(), stages[0].to().schema_id()];
2271        let envelope = self.seal_migration_commit_locked(operations, schemas, Permission::Admin)?;
2272        let context = self.inclusive_commit_context(&envelope).await?;
2273        self.inner
2274            .store
2275            .persist_schema_stages_with_context(&envelope, stages, &context)
2276            .await?;
2277        Ok(envelope.commit_id())
2278    }
2279
2280    async fn commit_schema_activation_locked(
2281        &self,
2282        activation: &SchemaActivation,
2283    ) -> Result<CommitId, DbError> {
2284        let operation = Operation::new(
2285            activation.to().collection_id(),
2286            Vec::new(),
2287            0,
2288            OperationKind::SchemaActivate,
2289            activation.encode_canonical(),
2290        )?;
2291        let envelope = self.seal_migration_commit_locked(
2292            vec![operation],
2293            vec![activation.from().schema_id(), activation.to().schema_id()],
2294            Permission::Admin,
2295        )?;
2296        self.validate_activation_cut(&envelope, activation).await?;
2297        let before = self.inner.store.export_materialized(self.domain_id())?;
2298        let context = self.inclusive_commit_context(&envelope).await?;
2299        self.inner
2300            .store
2301            .persist_schema_activation_with_context(&envelope, activation, &context)
2302            .await?;
2303        let after = self.inner.store.export_materialized(self.domain_id())?;
2304        let changes = materialization_changes(&before, &after);
2305        if !changes.is_empty() {
2306            let _no_subscribers = self.changes().send(ChangeBatch {
2307                commit_id: envelope.commit_id(),
2308                sequence: envelope.header().author_sequence(),
2309                frontier: self.inner.store.frontier(self.domain_id())?,
2310                changes,
2311            });
2312        }
2313        Ok(envelope.commit_id())
2314    }
2315
2316    fn seal_migration_commit_locked(
2317        &self,
2318        operations: Vec<Operation>,
2319        schemas: Vec<SchemaId>,
2320        permission: Permission,
2321    ) -> Result<CommitEnvelope, DbError> {
2322        self.require_migration_coordinator()?;
2323        let domain_id = self.domain_id();
2324        let active_epoch = self.inner.store.current_epoch(domain_id)?;
2325        if self.epoch() != active_epoch {
2326            return Err(DbError::StaleEpoch {
2327                handle: self.epoch(),
2328                active: active_epoch,
2329            });
2330        }
2331        let signer = self.inner.credentials.signer();
2332        let sequence = self
2333            .inner
2334            .store
2335            .last_sequence(domain_id, signer.author())?
2336            .map_or(Ok(0), |value| {
2337                value.checked_add(1).ok_or(DbError::SequenceExhausted)
2338            })?;
2339        let dependencies = self.inner.store.frontier(domain_id)?;
2340        let capability_id = match self
2341            .inner
2342            .store
2343            .active_capability(domain_id, signer.author())?
2344        {
2345            Some(capability_id)
2346                if self.inner.store.authorize_capability(
2347                    domain_id,
2348                    signer.author(),
2349                    capability_id,
2350                    permission,
2351                    self.epoch(),
2352                )? =>
2353            {
2354                capability_id
2355            }
2356            Some(_) | None if self.inner.store.domain_descriptor(domain_id)?.is_some() => {
2357                return Err(DbError::UnauthorizedDevice);
2358            }
2359            None => private_capability(domain_id),
2360            Some(_) => return Err(DbError::UnauthorizedDevice),
2361        };
2362        let mut nonce = [0_u8; 24];
2363        let mut transaction_id = [0_u8; 32];
2364        getrandom::fill(&mut nonce).map_err(|_| DbError::RandomnessUnavailable)?;
2365        getrandom::fill(&mut transaction_id).map_err(|_| DbError::RandomnessUnavailable)?;
2366        let body = CommitBody::new(TransactionId::from_bytes(transaction_id), operations)?;
2367        let header = CommitHeader::new(
2368            domain_id,
2369            self.epoch(),
2370            signer.author(),
2371            sequence,
2372            dependencies,
2373            capability_id,
2374            schemas,
2375            nonce,
2376        )?;
2377        seal_commit(header, &body, &self.domain_key(), &signer).map_err(DbError::from)
2378    }
2379
2380    #[tracing::instrument(name = "iroh_db.apply", skip_all)]
2381    async fn apply(&self, prepared: Vec<PreparedRecord>) -> Result<ChangeBatch, DbError> {
2382        let started = Instant::now();
2383        let result = self.apply_inner(prepared).await;
2384        self.inner
2385            .telemetry
2386            .observe_local_apply(started.elapsed(), result.is_err());
2387        result
2388    }
2389
2390    async fn apply_inner(&self, prepared: Vec<PreparedRecord>) -> Result<ChangeBatch, DbError> {
2391        if prepared.is_empty() {
2392            return Err(DbError::EmptyTransaction);
2393        }
2394        validate_prepared_targets(&prepared)?;
2395        let _control_reader = self.inner.control_gate.read().await;
2396        let _writer = self.inner.writer.lock().await;
2397        let domain_id = self.domain_id();
2398        let active_epoch = self.inner.store.current_epoch(domain_id)?;
2399        if self.epoch() != active_epoch {
2400            return Err(DbError::StaleEpoch {
2401                handle: self.epoch(),
2402                active: active_epoch,
2403            });
2404        }
2405        self.validate_schema_writes(&prepared)?;
2406        let signer = self.inner.credentials.signer();
2407        let sequence = self
2408            .inner
2409            .store
2410            .last_sequence(domain_id, signer.author())?
2411            .map_or(Ok(0), |value| {
2412                value.checked_add(1).ok_or(DbError::SequenceExhausted)
2413            })?;
2414        let dependencies = self.inner.store.frontier(domain_id)?;
2415        let dependency_context = self.dependency_context(&dependencies).await?;
2416        let mut nonce = [0_u8; 24];
2417        let mut transaction_id = [0_u8; 32];
2418        getrandom::fill(&mut nonce).map_err(|_| DbError::RandomnessUnavailable)?;
2419        getrandom::fill(&mut transaction_id).map_err(|_| DbError::RandomnessUnavailable)?;
2420
2421        let adapters = self
2422            .inner
2423            .adapters
2424            .read()
2425            .expect("record adapter lock is not poisoned")
2426            .clone();
2427        let operations = canonical_operations(
2428            &prepared,
2429            &adapters,
2430            signer.author(),
2431            sequence,
2432            &dependency_context,
2433        )?;
2434        let schemas: Vec<SchemaId> = prepared.iter().map(|entry| entry.schema_id).collect();
2435        let body = CommitBody::new(TransactionId::from_bytes(transaction_id), operations)?;
2436        self.validate_consistency(signer.author(), &body)?;
2437        let capability_id = match self
2438            .inner
2439            .store
2440            .active_capability(domain_id, signer.author())?
2441        {
2442            Some(capability_id)
2443                if self.inner.store.authorize_capability(
2444                    domain_id,
2445                    signer.author(),
2446                    capability_id,
2447                    Permission::Write,
2448                    self.epoch(),
2449                )? =>
2450            {
2451                capability_id
2452            }
2453            Some(_) | None if self.inner.store.domain_descriptor(domain_id)?.is_some() => {
2454                return Err(DbError::UnauthorizedDevice);
2455            }
2456            None => private_capability(domain_id),
2457            Some(_) => return Err(DbError::UnauthorizedDevice),
2458        };
2459        let header = CommitHeader::new(
2460            domain_id,
2461            self.epoch(),
2462            signer.author(),
2463            sequence,
2464            dependencies,
2465            capability_id,
2466            schemas,
2467            nonce,
2468        )?;
2469        let envelope = seal_commit(header, &body, &self.domain_key(), &signer)?;
2470        let converged = self
2471            .converged_mutations(&envelope, &body, &adapters)
2472            .await?;
2473        self.inner
2474            .store
2475            .persist_commit_with_reconciliation(
2476                &envelope,
2477                &converged.mutations,
2478                &converged.context,
2479                &converged.record_heads,
2480            )
2481            .await?;
2482        let batch = ChangeBatch {
2483            commit_id: envelope.commit_id(),
2484            sequence,
2485            frontier: self.inner.store.frontier(domain_id)?,
2486            changes: prepared.into_iter().map(|entry| entry.change).collect(),
2487        };
2488        let _no_subscribers = self.changes().send(batch.clone());
2489        Ok(batch)
2490    }
2491
2492    fn validate_schema_writes(&self, prepared: &[PreparedRecord]) -> Result<(), DbError> {
2493        for entry in prepared {
2494            let schema = entry.materialized.schema();
2495            self.ensure_collection_available(schema.collection_id())?;
2496            let Some(stored) = self
2497                .inner
2498                .store
2499                .latest_registered_schema(self.domain_id(), schema.collection_id())?
2500            else {
2501                continue;
2502            };
2503            if stored.schema_id() == schema.schema_id() {
2504                continue;
2505            }
2506            return Err(DbError::SchemaMigrationRequired {
2507                collection_id: schema.collection_id(),
2508                stored_version: stored.version(),
2509                requested_version: schema.version(),
2510            });
2511        }
2512        Ok(())
2513    }
2514
2515    fn ensure_collection_available(&self, collection_id: CollectionId) -> Result<(), DbError> {
2516        if self
2517            .inner
2518            .store
2519            .migration_frozen(self.domain_id(), collection_id)?
2520        {
2521            return Err(DbError::SchemaMigrationFrozen(collection_id));
2522        }
2523        Ok(())
2524    }
2525
2526    fn validate_consistency(&self, author: AuthorId, body: &CommitBody) -> Result<(), DbError> {
2527        let Some(descriptor) = self.inner.store.domain_descriptor(self.domain_id())? else {
2528            return Ok(());
2529        };
2530        match descriptor.mode() {
2531            ConsistencyMode::MultiWriter => Ok(()),
2532            ConsistencyMode::SingleWriter
2533                if self.inner.store.current_writer(self.domain_id())? == Some(author) =>
2534            {
2535                Ok(())
2536            }
2537            ConsistencyMode::SingleWriter => Err(DbError::UnauthorizedDevice),
2538            ConsistencyMode::AppendOnly => {
2539                for operation in body.operations() {
2540                    if operation.kind() != OperationKind::FieldStateMerge
2541                        || self
2542                            .inner
2543                            .store
2544                            .get_record(
2545                                self.domain_id(),
2546                                operation.collection_id(),
2547                                operation.record_id(),
2548                            )?
2549                            .is_some()
2550                    {
2551                        return Err(DbError::InvalidRemoteCommit(
2552                            "append-only domains reject updates and deletes".into(),
2553                        ));
2554                    }
2555                    let mutation = MaterializedRecord::decode_canonical(operation.payload())?;
2556                    if mutation
2557                        .schema()
2558                        .fields()
2559                        .iter()
2560                        .filter(|field| !field.is_record_id())
2561                        .any(|field| field.crdt() != iroh_db_core::CrdtKind::Immutable)
2562                    {
2563                        return Err(DbError::InvalidRemoteCommit(
2564                            "append-only records require immutable fields".into(),
2565                        ));
2566                    }
2567                }
2568                Ok(())
2569            }
2570        }
2571    }
2572}
2573
2574/// Encrypted blob import and lazy streaming handle.
2575pub struct Blobs {
2576    db: IrohDb,
2577}
2578
2579impl Blobs {
2580    /// Encrypts and persists bytes as independently verified chunks plus a manifest.
2581    pub async fn import_bytes(
2582        &self,
2583        bytes: Vec<u8>,
2584        media_type: Option<String>,
2585    ) -> Result<BlobRef, DbError> {
2586        self.engine()
2587            .import_bytes(bytes, media_type)
2588            .await
2589            .map_err(DbError::from)
2590    }
2591
2592    /// Incrementally encrypts and persists an asynchronous plaintext stream.
2593    pub async fn import_reader(
2594        &self,
2595        reader: impl AsyncRead + Unpin,
2596        media_type: Option<String>,
2597    ) -> Result<BlobRef, DbError> {
2598        self.engine()
2599            .import_reader(reader, media_type)
2600            .await
2601            .map_err(DbError::from)
2602    }
2603
2604    /// Authenticates a manifest and opens a lazy seekable plaintext stream.
2605    pub async fn open(&self, reference: &BlobRef) -> Result<BlobStream, DbError> {
2606        self.engine().open(reference).await.map_err(DbError::from)
2607    }
2608
2609    fn engine(&self) -> BlobEngine {
2610        self.db.blob_engine()
2611    }
2612}
2613
2614/// A handle to one strongly typed collection.
2615pub struct Collection<Record> {
2616    db: IrohDb,
2617    marker: PhantomData<fn() -> Record>,
2618}
2619
2620impl<Record: IrohRecord> Collection<Record> {
2621    /// Atomically creates or replaces one materialized CRDT record.
2622    pub async fn put(&self, record: Record) -> Result<ChangeBatch, DbError> {
2623        self.db.apply(vec![prepare_upsert(&record)?]).await
2624    }
2625
2626    /// Reads one record entirely from local materialized state.
2627    #[allow(clippy::unused_async)]
2628    pub async fn get(&self, id: &Record::Id) -> Result<Option<Record>, DbError> {
2629        let schema = Record::schema()?;
2630        self.db
2631            .ensure_collection_available(schema.collection_id())?;
2632        self.db
2633            .inner
2634            .store
2635            .get_record(
2636                self.db.domain_id(),
2637                schema.collection_id(),
2638                &id.encode_key()?,
2639            )?
2640            .map(|bytes| Record::decode_record(&bytes).map_err(DbError::from))
2641            .transpose()
2642    }
2643
2644    /// Atomically deletes one record if present.
2645    pub async fn delete(&self, id: &Record::Id) -> Result<ChangeBatch, DbError> {
2646        self.db.apply(vec![prepare_delete::<Record>(id)?]).await
2647    }
2648
2649    /// Starts a typed local query.
2650    pub fn query(&self) -> Query<Record> {
2651        Query {
2652            db: self.db.clone(),
2653            filter: None,
2654            order: Vec::new(),
2655            limit: None,
2656            after: None,
2657            marker: PhantomData,
2658        }
2659    }
2660
2661    /// Subscribes to future atomic changes for this collection.
2662    pub fn subscribe(&self) -> Result<Subscription<Record>, DbError> {
2663        let schema = Record::schema()?;
2664        self.db
2665            .ensure_collection_available(schema.collection_id())?;
2666        let receiver = self.db.changes().subscribe();
2667        let initial = self
2668            .db
2669            .inner
2670            .store
2671            .list_records(self.db.domain_id(), schema.collection_id())?
2672            .into_iter()
2673            .map(|entry| Record::decode_record(entry.state()).map_err(DbError::from))
2674            .collect::<Result<Vec<_>, _>>()?;
2675        Ok(Subscription {
2676            collection_id: schema.collection_id(),
2677            initial: Some(initial),
2678            receiver,
2679        })
2680    }
2681}
2682
2683/// A type-checked local collection query.
2684pub struct Query<Record> {
2685    db: IrohDb,
2686    filter: Option<Predicate<Record>>,
2687    order: Vec<OrderClause<Record>>,
2688    limit: Option<usize>,
2689    after: Option<Cursor>,
2690    marker: PhantomData<fn() -> Record>,
2691}
2692
2693/// A typed query predicate.
2694#[derive(Debug)]
2695pub enum Predicate<Record> {
2696    /// Exact visible-value equality.
2697    Equal(EqualityFilter<Record>),
2698    /// Ordered visible-value comparison.
2699    Range(RangeFilter<Record>),
2700}
2701
2702impl<Record> From<EqualityFilter<Record>> for Predicate<Record> {
2703    fn from(value: EqualityFilter<Record>) -> Self {
2704        Self::Equal(value)
2705    }
2706}
2707
2708impl<Record> From<RangeFilter<Record>> for Predicate<Record> {
2709    fn from(value: RangeFilter<Record>) -> Self {
2710        Self::Range(value)
2711    }
2712}
2713
2714/// Query result order for a typed field.
2715#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2716pub enum OrderDirection {
2717    /// Smallest values first.
2718    Ascending,
2719    /// Largest values first.
2720    Descending,
2721}
2722
2723type ValueComparator =
2724    dyn Fn(&[Vec<u8>], &[Vec<u8>]) -> Result<Ordering, RecordError> + Send + Sync;
2725type EncodedCandidate = (Vec<u8>, Vec<u8>);
2726
2727struct OrderClause<Record> {
2728    field_id: u32,
2729    direction: OrderDirection,
2730    compare: Arc<ValueComparator>,
2731    marker: PhantomData<fn() -> Record>,
2732}
2733
2734/// An opaque, authenticated continuation position.
2735#[derive(Clone, PartialEq, Eq)]
2736pub struct Cursor(Vec<u8>);
2737
2738impl Cursor {
2739    /// Reconstructs a cursor received from durable application state or a client.
2740    pub fn from_bytes(bytes: Vec<u8>) -> Self {
2741        Self(bytes)
2742    }
2743
2744    /// Returns the opaque encoded cursor.
2745    pub fn as_bytes(&self) -> &[u8] {
2746        &self.0
2747    }
2748}
2749
2750impl std::fmt::Debug for Cursor {
2751    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2752        formatter.write_str("Cursor([opaque])")
2753    }
2754}
2755
2756/// One stable page and an optional continuation cursor.
2757#[derive(Debug)]
2758pub struct Page<Record> {
2759    items: Vec<Record>,
2760    next_cursor: Option<Cursor>,
2761}
2762
2763impl<Record> Page<Record> {
2764    /// Returns the page records in requested order.
2765    pub fn items(&self) -> &[Record] {
2766        &self.items
2767    }
2768
2769    /// Consumes this page and returns its records.
2770    pub fn into_items(self) -> Vec<Record> {
2771        self.items
2772    }
2773
2774    /// Returns a cursor when more matching records existed at execution time.
2775    pub const fn next_cursor(&self) -> Option<&Cursor> {
2776        self.next_cursor.as_ref()
2777    }
2778}
2779
2780impl<Record: IrohRecord> Query<Record> {
2781    /// Adds an equality predicate. V1 accepts one predicate per query.
2782    #[must_use]
2783    pub fn filter(mut self, filter: impl Into<Predicate<Record>>) -> Self {
2784        self.filter = Some(filter.into());
2785        self
2786    }
2787
2788    /// Sets the primary typed ordering clause, replacing any earlier ordering.
2789    #[must_use]
2790    pub fn order_by<Value>(
2791        mut self,
2792        field: TypedField<Record, Value>,
2793        direction: OrderDirection,
2794    ) -> Self
2795    where
2796        Value: RecordKey + Ord + Send + Sync + 'static,
2797    {
2798        self.order.clear();
2799        self.order.push(OrderClause {
2800            field_id: field.field_id(),
2801            direction,
2802            compare: Arc::new(compare_visible::<Value>),
2803            marker: PhantomData,
2804        });
2805        self
2806    }
2807
2808    /// Adds a typed tie-breaking clause after all existing ordering clauses.
2809    #[must_use]
2810    pub fn then_by<Value>(
2811        mut self,
2812        field: TypedField<Record, Value>,
2813        direction: OrderDirection,
2814    ) -> Self
2815    where
2816        Value: RecordKey + Ord + Send + Sync + 'static,
2817    {
2818        self.order.push(OrderClause {
2819            field_id: field.field_id(),
2820            direction,
2821            compare: Arc::new(compare_visible::<Value>),
2822            marker: PhantomData,
2823        });
2824        self
2825    }
2826
2827    /// Bounds the number of records returned.
2828    #[must_use]
2829    pub const fn limit(mut self, limit: usize) -> Self {
2830        self.limit = Some(limit);
2831        self
2832    }
2833
2834    /// Continues after a cursor produced by the exact same query shape.
2835    #[must_use]
2836    pub fn after(mut self, cursor: Cursor) -> Self {
2837        self.after = Some(cursor);
2838        self
2839    }
2840
2841    /// Executes against one local redb read snapshot.
2842    #[allow(clippy::unused_async)]
2843    pub async fn fetch(self) -> Result<Vec<Record>, DbError> {
2844        Ok(self.fetch_page().await?.into_items())
2845    }
2846
2847    /// Executes a stable page against one local redb read snapshot.
2848    #[allow(clippy::unused_async)]
2849    #[tracing::instrument(name = "iroh_db.query", skip_all)]
2850    pub async fn fetch_page(self) -> Result<Page<Record>, DbError> {
2851        let telemetry = self.db.inner.telemetry.clone();
2852        let started = Instant::now();
2853        let result = self.fetch_page_inner();
2854        telemetry.observe_query(started.elapsed(), result.is_err());
2855        result
2856    }
2857
2858    fn fetch_page_inner(self) -> Result<Page<Record>, DbError> {
2859        let schema = Record::schema()?;
2860        self.db
2861            .ensure_collection_available(schema.collection_id())?;
2862        let domain = self.db.domain_id();
2863        let fingerprint = self.fingerprint(&schema)?;
2864        let candidates = self.candidates(domain, schema.collection_id(), &schema)?;
2865        self.db
2866            .inner
2867            .telemetry
2868            .add_index_records_scanned(candidates.len());
2869        let mut records = Vec::new();
2870        for (record_id, bytes) in candidates {
2871            let record = Record::decode_record(&bytes)?;
2872            if self.matches(&record)? {
2873                records.push(QueryRecord { record_id, record });
2874            }
2875        }
2876        if !self.order.is_empty() {
2877            let mut sort_error = None;
2878            records.sort_by(|left, right| {
2879                let result = compare_records(left, right, &self.order);
2880                match result {
2881                    Ok(value) => value,
2882                    Err(error) => {
2883                        sort_error.get_or_insert(error);
2884                        Ordering::Equal
2885                    }
2886                }
2887            });
2888            if let Some(error) = sort_error {
2889                return Err(error);
2890            }
2891        }
2892
2893        let start = match &self.after {
2894            Some(cursor) => {
2895                let last_id = self.decode_cursor(cursor, schema.collection_id(), fingerprint)?;
2896                records
2897                    .iter()
2898                    .position(|entry| entry.record_id == last_id)
2899                    .map(|position| position + 1)
2900                    .ok_or_else(|| DbError::InvalidCursor("cursor position is stale".into()))?
2901            }
2902            None => 0,
2903        };
2904        let limit = self.limit.unwrap_or(usize::MAX);
2905        let end = start.saturating_add(limit).min(records.len());
2906        let has_more = end < records.len();
2907        let selected = &records[start..end];
2908        let next_cursor = if has_more {
2909            selected
2910                .last()
2911                .map(|entry| {
2912                    self.encode_cursor(schema.collection_id(), fingerprint, &entry.record_id)
2913                })
2914                .transpose()?
2915        } else {
2916            None
2917        };
2918        let items = records
2919            .drain(start..end)
2920            .map(|entry| entry.record)
2921            .collect();
2922        Ok(Page { items, next_cursor })
2923    }
2924
2925    fn candidates(
2926        &self,
2927        domain: iroh_db_core::DomainId,
2928        collection: CollectionId,
2929        schema: &iroh_db_core::SchemaDescriptor,
2930    ) -> Result<Vec<EncodedCandidate>, DbError> {
2931        if let Some(Predicate::Equal(filter)) = &self.filter {
2932            let value = filter
2933                .encoded_value()
2934                .map_err(|error| DbError::InvalidQuery(error.clone()))?;
2935            let indexed = schema
2936                .fields()
2937                .iter()
2938                .any(|field| field.id() == filter.field_id() && field.is_indexed());
2939            if indexed {
2940                return self
2941                    .db
2942                    .inner
2943                    .store
2944                    .lookup_equal(domain, collection, filter.field_id(), value)?
2945                    .into_iter()
2946                    .filter_map(|id| {
2947                        self.db
2948                            .inner
2949                            .store
2950                            .get_record(domain, collection, &id)
2951                            .transpose()
2952                            .map(|result| result.map(|state| (id, state)))
2953                    })
2954                    .collect::<Result<Vec<_>, _>>()
2955                    .map_err(DbError::from);
2956            }
2957        }
2958        Ok(self
2959            .db
2960            .inner
2961            .store
2962            .list_records(domain, collection)?
2963            .into_iter()
2964            .map(|entry| (entry.record_id().to_vec(), entry.state().to_vec()))
2965            .collect())
2966    }
2967
2968    fn matches(&self, record: &Record) -> Result<bool, DbError> {
2969        match &self.filter {
2970            Some(Predicate::Equal(filter)) => {
2971                let expected = filter
2972                    .encoded_value()
2973                    .map_err(|error| DbError::InvalidQuery(error.clone()))?;
2974                Ok(record
2975                    .field_values(filter.field_id())?
2976                    .iter()
2977                    .any(|value| value == expected))
2978            }
2979            Some(Predicate::Range(filter)) => filter
2980                .matches(&record.field_values(filter.field_id())?)
2981                .map_err(DbError::InvalidQuery),
2982            None => Ok(true),
2983        }
2984    }
2985
2986    fn fingerprint(&self, schema: &iroh_db_core::SchemaDescriptor) -> Result<[u8; 32], DbError> {
2987        let mut hasher = blake3::Hasher::new();
2988        hasher.update(b"iroh-db/query/v1");
2989        hasher.update(schema.collection_id().as_bytes());
2990        hasher.update(schema.schema_id().as_bytes());
2991        match &self.filter {
2992            None => {
2993                hasher.update(&[0]);
2994            }
2995            Some(Predicate::Equal(filter)) => {
2996                hasher.update(&[1]);
2997                hasher.update(&filter.field_id().to_be_bytes());
2998                hasher.update(
2999                    filter
3000                        .encoded_value()
3001                        .map_err(|error| DbError::InvalidQuery(error.clone()))?,
3002                );
3003            }
3004            Some(Predicate::Range(filter)) => {
3005                hasher.update(&[2, filter.operator() as u8]);
3006                hasher.update(&filter.field_id().to_be_bytes());
3007                hasher.update(
3008                    filter
3009                        .encoded_bound()
3010                        .map_err(|error| DbError::InvalidQuery(error.clone()))?,
3011                );
3012            }
3013        }
3014        hasher.update(
3015            &u64::try_from(self.order.len())
3016                .unwrap_or(u64::MAX)
3017                .to_be_bytes(),
3018        );
3019        for order in &self.order {
3020            hasher.update(&[order.direction as u8]);
3021            hasher.update(&order.field_id.to_be_bytes());
3022        }
3023        Ok(*hasher.finalize().as_bytes())
3024    }
3025
3026    fn encode_cursor(
3027        &self,
3028        collection: CollectionId,
3029        fingerprint: [u8; 32],
3030        record_id: &[u8],
3031    ) -> Result<Cursor, DbError> {
3032        let domain = self.db.domain_id();
3033        let length = u32::try_from(record_id.len())
3034            .map_err(|_| DbError::InvalidCursor("record ID is too large".into()))?;
3035        let mut bytes = Vec::with_capacity(108 + record_id.len());
3036        bytes.extend_from_slice(b"IDBCUR01");
3037        bytes.extend_from_slice(domain.as_bytes());
3038        bytes.extend_from_slice(collection.as_bytes());
3039        bytes.extend_from_slice(&fingerprint);
3040        bytes.extend_from_slice(&length.to_be_bytes());
3041        bytes.extend_from_slice(record_id);
3042        let key = self
3043            .db
3044            .inner
3045            .credentials
3046            .domain_key()
3047            .derive_subkey(domain.as_bytes(), b"iroh-db/query-cursor/v1")?;
3048        bytes.extend_from_slice(blake3::keyed_hash(&key, &bytes).as_bytes());
3049        Ok(Cursor(bytes))
3050    }
3051
3052    fn decode_cursor(
3053        &self,
3054        cursor: &Cursor,
3055        collection: CollectionId,
3056        fingerprint: [u8; 32],
3057    ) -> Result<Vec<u8>, DbError> {
3058        let bytes = cursor.as_bytes();
3059        if bytes.len() < 140 || &bytes[..8] != b"IDBCUR01" {
3060            return Err(DbError::InvalidCursor("malformed cursor".into()));
3061        }
3062        let length = u32::from_be_bytes(
3063            bytes[104..108]
3064                .try_into()
3065                .map_err(|_| DbError::InvalidCursor("malformed cursor".into()))?,
3066        ) as usize;
3067        if bytes.len() != 140 + length {
3068            return Err(DbError::InvalidCursor("malformed cursor".into()));
3069        }
3070        let domain = self.db.domain_id();
3071        if &bytes[8..40] != domain.as_bytes()
3072            || &bytes[40..72] != collection.as_bytes()
3073            || bytes[72..104] != fingerprint
3074        {
3075            return Err(DbError::InvalidCursor(
3076                "cursor belongs to another query".into(),
3077            ));
3078        }
3079        let signed_len = bytes.len() - 32;
3080        let key = self
3081            .db
3082            .inner
3083            .credentials
3084            .domain_key()
3085            .derive_subkey(domain.as_bytes(), b"iroh-db/query-cursor/v1")?;
3086        let expected = blake3::keyed_hash(&key, &bytes[..signed_len]);
3087        if !constant_time_equal(expected.as_bytes(), &bytes[signed_len..]) {
3088            return Err(DbError::InvalidCursor(
3089                "cursor authentication failed".into(),
3090            ));
3091        }
3092        Ok(bytes[108..signed_len].to_vec())
3093    }
3094}
3095
3096struct QueryRecord<Record> {
3097    record_id: Vec<u8>,
3098    record: Record,
3099}
3100
3101fn compare_records<Record: IrohRecord>(
3102    left: &QueryRecord<Record>,
3103    right: &QueryRecord<Record>,
3104    orders: &[OrderClause<Record>],
3105) -> Result<Ordering, DbError> {
3106    for order in orders {
3107        let left_values = left.record.field_values(order.field_id)?;
3108        let right_values = right.record.field_values(order.field_id)?;
3109        let value_order = (order.compare)(&left_values, &right_values)?;
3110        let value_order = match order.direction {
3111            OrderDirection::Ascending => value_order,
3112            OrderDirection::Descending => value_order.reverse(),
3113        };
3114        if value_order != Ordering::Equal {
3115            return Ok(value_order);
3116        }
3117    }
3118    Ok(left.record_id.cmp(&right.record_id))
3119}
3120
3121fn compare_visible<Value: RecordKey + Ord>(
3122    left: &[Vec<u8>],
3123    right: &[Vec<u8>],
3124) -> Result<Ordering, RecordError> {
3125    let left = left
3126        .iter()
3127        .map(|value| Value::decode_key(value))
3128        .collect::<Result<Vec<_>, _>>()?
3129        .into_iter()
3130        .min();
3131    let right = right
3132        .iter()
3133        .map(|value| Value::decode_key(value))
3134        .collect::<Result<Vec<_>, _>>()?
3135        .into_iter()
3136        .min();
3137    Ok(left.cmp(&right))
3138}
3139
3140fn constant_time_equal(left: &[u8], right: &[u8]) -> bool {
3141    if left.len() != right.len() {
3142        return false;
3143    }
3144    left.iter()
3145        .zip(right)
3146        .fold(0_u8, |difference, (left, right)| {
3147            difference | (left ^ right)
3148        })
3149        == 0
3150}
3151
3152/// One ordered subscription event.
3153#[derive(Debug)]
3154pub enum SubscriptionEvent<Record> {
3155    /// A local record snapshot captured when the receiver was registered.
3156    Initial(Vec<Record>),
3157    /// One later atomic commit batch affecting the collection.
3158    Changes(ChangeBatch),
3159}
3160
3161/// A bounded typed collection change stream.
3162pub struct Subscription<Record> {
3163    collection_id: CollectionId,
3164    initial: Option<Vec<Record>>,
3165    receiver: broadcast::Receiver<ChangeBatch>,
3166}
3167
3168impl<Record> Subscription<Record> {
3169    /// Receives the initial local snapshot, then later batches affecting this collection.
3170    pub async fn recv(&mut self) -> Result<SubscriptionEvent<Record>, SubscriptionError> {
3171        if let Some(initial) = self.initial.take() {
3172            return Ok(SubscriptionEvent::Initial(initial));
3173        }
3174        loop {
3175            let batch = self.receiver.recv().await.map_err(|error| match error {
3176                broadcast::error::RecvError::Closed => SubscriptionError::Closed,
3177                broadcast::error::RecvError::Lagged(count) => SubscriptionError::Lagged(count),
3178            })?;
3179            let changes: Vec<_> = batch
3180                .changes
3181                .iter()
3182                .filter(|change| change.collection_id == self.collection_id)
3183                .cloned()
3184                .collect();
3185            if !changes.is_empty() {
3186                return Ok(SubscriptionEvent::Changes(ChangeBatch { changes, ..batch }));
3187            }
3188        }
3189    }
3190}
3191
3192/// An atomic builder restricted to the database's default domain.
3193pub struct Transaction {
3194    db: IrohDb,
3195    prepared: Vec<PreparedRecord>,
3196}
3197
3198impl Transaction {
3199    /// Adds a typed record replacement to this transaction.
3200    #[allow(clippy::needless_pass_by_value)]
3201    pub fn put<Record: IrohRecord>(&mut self, record: Record) -> Result<(), DbError> {
3202        register_record_adapter::<Record>(&self.db);
3203        self.prepared.push(prepare_upsert(&record)?);
3204        Ok(())
3205    }
3206
3207    /// Adds a typed record deletion to this transaction.
3208    pub fn delete<Record: IrohRecord>(&mut self, id: &Record::Id) -> Result<(), DbError> {
3209        register_record_adapter::<Record>(&self.db);
3210        self.prepared.push(prepare_delete::<Record>(id)?);
3211        Ok(())
3212    }
3213
3214    /// Creates one encrypted commit and publishes every prepared record atomically.
3215    pub async fn commit(self) -> Result<ChangeBatch, DbError> {
3216        self.db.apply(self.prepared).await
3217    }
3218}
3219
3220struct PreparedRecord {
3221    materialized: MaterializedRecord,
3222    schema_id: SchemaId,
3223    change: Change,
3224}
3225
3226fn register_record_adapter<Record: IrohRecord>(database: &IrohDb) {
3227    let schema = Record::schema().expect("a record schema is statically valid");
3228    database
3229        .inner
3230        .adapters
3231        .write()
3232        .expect("record adapter lock is not poisoned")
3233        .insert(
3234            (schema.collection_id(), schema.schema_id()),
3235            RecordAdapter {
3236                merge: merge_materialized::<Record>,
3237                canonicalize: canonicalize_materialized::<Record>,
3238            },
3239        );
3240}
3241
3242fn migration_id(
3243    domain_id: iroh_db_core::DomainId,
3244    from: &iroh_db_core::SchemaDescriptor,
3245    to: &iroh_db_core::SchemaDescriptor,
3246    source_cut_digest: BlobHash,
3247) -> MigrationId {
3248    let mut hasher = blake3::Hasher::new();
3249    hasher.update(b"iroh-db/schema-migration/v1");
3250    hasher.update(domain_id.as_bytes());
3251    hasher.update(from.schema_id().as_bytes());
3252    hasher.update(to.schema_id().as_bytes());
3253    hasher.update(source_cut_digest.as_bytes());
3254    MigrationId::from_bytes(*hasher.finalize().as_bytes())
3255}
3256
3257fn source_cut_digest(records: &[StoredRecord]) -> BlobHash {
3258    let mut hasher = blake3::Hasher::new();
3259    hasher.update(b"iroh-db/schema-source-cut/v1");
3260    hasher.update(&(records.len() as u64).to_be_bytes());
3261    for record in records {
3262        hasher.update(&(record.record_id().len() as u64).to_be_bytes());
3263        hasher.update(record.record_id());
3264        hasher.update(&(record.state().len() as u64).to_be_bytes());
3265        hasher.update(record.state());
3266    }
3267    BlobHash::from_bytes(*hasher.finalize().as_bytes())
3268}
3269
3270fn validate_prepared_targets(prepared: &[PreparedRecord]) -> Result<(), DbError> {
3271    let mut targets = BTreeSet::new();
3272    for entry in prepared {
3273        let target = (entry.change.collection_id, entry.change.record_id.clone());
3274        if !targets.insert(target) {
3275            return Err(DbError::InvalidRemoteCommit(
3276                "a commit mutates one record more than once".into(),
3277            ));
3278        }
3279    }
3280    Ok(())
3281}
3282
3283fn canonical_operations(
3284    prepared: &[PreparedRecord],
3285    adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
3286    author: AuthorId,
3287    sequence: u64,
3288    dependency_context: &VersionVector,
3289) -> Result<Vec<Operation>, DbError> {
3290    let mut next_operation = 0;
3291    let mut operations = Vec::with_capacity(prepared.len());
3292    for entry in prepared {
3293        let mutation = if entry.materialized.state().is_some() {
3294            let adapter = adapters
3295                .get(&(entry.change.collection_id, entry.schema_id))
3296                .ok_or(DbError::UnknownRecordSchema(entry.change.collection_id))?;
3297            (adapter.canonicalize)(
3298                &entry.materialized,
3299                author,
3300                sequence,
3301                dependency_context,
3302                &mut next_operation,
3303            )?
3304        } else {
3305            entry.materialized.clone()
3306        };
3307        operations.push(Operation::new(
3308            mutation.schema().collection_id(),
3309            mutation.record_id().to_vec(),
3310            0,
3311            if mutation.state().is_some() {
3312                OperationKind::FieldStateMerge
3313            } else {
3314                OperationKind::RecordDelete
3315            },
3316            mutation.encode_canonical()?,
3317        )?);
3318    }
3319    Ok(operations)
3320}
3321
3322fn prepare_upsert<Record: IrohRecord>(record: &Record) -> Result<PreparedRecord, DbError> {
3323    let schema = Record::schema()?;
3324    let record_id = record.record_id()?;
3325    let state = record.encode_record()?;
3326    let mut indexes = Vec::new();
3327    for field in schema.fields().iter().filter(|field| field.is_indexed()) {
3328        for value in record.field_values(field.id())? {
3329            indexes.push(IndexValue::new(field.id(), value));
3330        }
3331    }
3332    let materialized =
3333        MaterializedRecord::upsert(schema.clone(), record_id.clone(), state, indexes)?;
3334    Ok(PreparedRecord {
3335        materialized,
3336        schema_id: schema.schema_id(),
3337        change: Change {
3338            collection_id: schema.collection_id(),
3339            record_id,
3340            kind: ChangeKind::Upserted,
3341        },
3342    })
3343}
3344
3345fn prepare_delete<Record: IrohRecord>(id: &Record::Id) -> Result<PreparedRecord, DbError> {
3346    let schema = Record::schema()?;
3347    let record_id = id.encode_key()?;
3348    let materialized = MaterializedRecord::delete(schema.clone(), record_id.clone())?;
3349    Ok(PreparedRecord {
3350        materialized,
3351        schema_id: schema.schema_id(),
3352        change: Change {
3353            collection_id: schema.collection_id(),
3354            record_id,
3355            kind: ChangeKind::Deleted,
3356        },
3357    })
3358}
3359
3360type RecordTarget = (CollectionId, Vec<u8>);
3361type RecordCandidates = BTreeMap<RecordTarget, Vec<(CommitId, MaterializedRecord)>>;
3362
3363struct ConvergedCommit {
3364    mutations: Vec<MaterializedRecord>,
3365    context: VersionVector,
3366    record_heads: Vec<RecordHeadSet>,
3367}
3368
3369enum MigrationBody {
3370    Ordinary,
3371    Stages(Vec<SchemaStage>),
3372    Activation(Box<SchemaActivation>),
3373}
3374
3375fn decode_migration_body(
3376    envelope: &CommitEnvelope,
3377    body: &CommitBody,
3378) -> Result<MigrationBody, DbError> {
3379    let has_migration = body.operations().iter().any(|operation| {
3380        matches!(
3381            operation.kind(),
3382            OperationKind::SchemaStage | OperationKind::SchemaActivate
3383        )
3384    });
3385    if !has_migration {
3386        return Ok(MigrationBody::Ordinary);
3387    }
3388    if body
3389        .operations()
3390        .iter()
3391        .all(|operation| operation.kind() == OperationKind::SchemaStage)
3392    {
3393        let mut stages = Vec::with_capacity(body.operations().len());
3394        for operation in body.operations() {
3395            let stage = SchemaStage::decode_canonical(operation.payload())?;
3396            if operation.collection_id() != stage.to().collection_id()
3397                || operation.record_id() != stage.source_record_id()
3398                || operation.field_id() != 0
3399                || !envelope
3400                    .header()
3401                    .schema_ids()
3402                    .contains(&stage.from().schema_id())
3403                || !envelope
3404                    .header()
3405                    .schema_ids()
3406                    .contains(&stage.to().schema_id())
3407            {
3408                return Err(DbError::InvalidRemoteCommit(
3409                    "migration stage target or schema header is inconsistent".into(),
3410                ));
3411            }
3412
3413            stages.push(stage);
3414        }
3415        staging_digest(&stages)?;
3416        return Ok(MigrationBody::Stages(stages));
3417    }
3418    if body.operations().len() == 1 && body.operations()[0].kind() == OperationKind::SchemaActivate
3419    {
3420        let operation = &body.operations()[0];
3421        let activation = SchemaActivation::decode_canonical(operation.payload())?;
3422        if operation.collection_id() != activation.to().collection_id()
3423            || !operation.record_id().is_empty()
3424            || operation.field_id() != 0
3425            || !envelope
3426                .header()
3427                .schema_ids()
3428                .contains(&activation.from().schema_id())
3429            || !envelope
3430                .header()
3431                .schema_ids()
3432                .contains(&activation.to().schema_id())
3433        {
3434            return Err(DbError::InvalidRemoteCommit(
3435                "schema activation target or schema header is inconsistent".into(),
3436            ));
3437        }
3438        return Ok(MigrationBody::Activation(Box::new(activation)));
3439    }
3440    Err(DbError::InvalidRemoteCommit(
3441        "migration and record operations cannot share one commit".into(),
3442    ))
3443}
3444
3445fn validate_staged_records(
3446    stages: &[SchemaStage],
3447    adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
3448) -> Result<(), DbError> {
3449    for stage in stages {
3450        if !adapters.contains_key(&(stage.from().collection_id(), stage.from().schema_id())) {
3451            return Err(DbError::UnknownRecordSchema(stage.from().collection_id()));
3452        }
3453        let adapter = adapters
3454            .get(&(stage.to().collection_id(), stage.to().schema_id()))
3455            .ok_or(DbError::UnknownRecordSchema(stage.to().collection_id()))?;
3456        let target = MaterializedRecord::decode_canonical(stage.target_record())?;
3457        if (adapter.merge)(None, &target)? != target {
3458            return Err(DbError::InvalidRemoteCommit(
3459                "staged target is not canonical for its registered record type".into(),
3460            ));
3461        }
3462    }
3463    Ok(())
3464}
3465
3466fn materialization_changes(
3467    before: &[MaterializedRecord],
3468    after: &[MaterializedRecord],
3469) -> Vec<Change> {
3470    let before = present_records(before);
3471    let after = present_records(after);
3472    before
3473        .keys()
3474        .chain(after.keys())
3475        .cloned()
3476        .collect::<BTreeSet<_>>()
3477        .into_iter()
3478        .filter(|target| before.get(target) != after.get(target))
3479        .map(|(collection_id, record_id)| Change {
3480            collection_id,
3481            kind: if after.contains_key(&(collection_id, record_id.clone())) {
3482                ChangeKind::Upserted
3483            } else {
3484                ChangeKind::Deleted
3485            },
3486            record_id,
3487        })
3488        .collect()
3489}
3490
3491fn present_records(values: &[MaterializedRecord]) -> BTreeMap<RecordTarget, &MaterializedRecord> {
3492    values
3493        .iter()
3494        .filter(|record| record.state().is_some())
3495        .map(|record| {
3496            (
3497                (record.schema().collection_id(), record.record_id().to_vec()),
3498                record,
3499            )
3500        })
3501        .collect()
3502}
3503
3504fn validate_incoming_operations(
3505    envelope: &CommitEnvelope,
3506    body: &CommitBody,
3507    adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
3508    dependency_context: &VersionVector,
3509) -> Result<BTreeMap<RecordTarget, RecordAdapter>, DbError> {
3510    let mut affected = BTreeMap::new();
3511    let mut next_operation = 0;
3512    for operation in body.operations() {
3513        if !matches!(
3514            operation.kind(),
3515            OperationKind::FieldStateMerge | OperationKind::RecordDelete
3516        ) {
3517            return Err(DbError::InvalidRemoteCommit(
3518                "unsupported operation kind".into(),
3519            ));
3520        }
3521        let mutation = MaterializedRecord::decode_canonical(operation.payload())?;
3522        if mutation.schema().collection_id() != operation.collection_id()
3523            || mutation.record_id() != operation.record_id()
3524            || !envelope
3525                .header()
3526                .schema_ids()
3527                .contains(&mutation.schema().schema_id())
3528        {
3529            return Err(DbError::InvalidRemoteCommit(
3530                "schema or record target mismatch".into(),
3531            ));
3532        }
3533        let adapter = *adapters
3534            .get(&(operation.collection_id(), mutation.schema().schema_id()))
3535            .ok_or(DbError::UnknownRecordSchema(operation.collection_id()))?;
3536        if mutation.state().is_some() {
3537            let canonical = (adapter.canonicalize)(
3538                &mutation,
3539                envelope.header().author(),
3540                envelope.header().author_sequence(),
3541                dependency_context,
3542                &mut next_operation,
3543            )?;
3544            if canonical != mutation {
3545                return Err(DbError::InvalidRemoteCommit(
3546                    "record contains causal metadata not issued by its commit".into(),
3547                ));
3548            }
3549        }
3550        let key = (operation.collection_id(), operation.record_id().to_vec());
3551        if affected.insert(key, adapter).is_some() {
3552            return Err(DbError::InvalidRemoteCommit(
3553                "a commit mutates one record more than once".into(),
3554            ));
3555        }
3556    }
3557    Ok(affected)
3558}
3559
3560fn collect_affected_operations(
3561    commit_id: CommitId,
3562    body: &CommitBody,
3563    affected: &BTreeMap<RecordTarget, RecordAdapter>,
3564    candidates: &mut RecordCandidates,
3565) -> Result<(), DbError> {
3566    for operation in body.operations() {
3567        let key = (operation.collection_id(), operation.record_id().to_vec());
3568        if !affected.contains_key(&key) {
3569            continue;
3570        }
3571        let mutation = MaterializedRecord::decode_canonical(operation.payload())?;
3572        if mutation.schema().collection_id() != operation.collection_id()
3573            || mutation.record_id() != operation.record_id()
3574        {
3575            return Err(DbError::InvalidRemoteCommit(
3576                "accepted operation target is inconsistent".into(),
3577            ));
3578        }
3579        candidates
3580            .entry(key)
3581            .or_default()
3582            .push((commit_id, mutation));
3583    }
3584    Ok(())
3585}
3586
3587fn resolve_record_heads(
3588    key: &RecordTarget,
3589    entries: &[(CommitId, MaterializedRecord)],
3590    graph: &BTreeMap<CommitId, Vec<CommitId>>,
3591    adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
3592) -> Result<MaterializedRecord, DbError> {
3593    let mut heads: Vec<_> = entries
3594        .iter()
3595        .filter(|(candidate, _)| {
3596            !entries
3597                .iter()
3598                .any(|(other, _)| candidate != other && is_ancestor(*candidate, *other, graph))
3599        })
3600        .cloned()
3601        .collect();
3602    heads.sort_unstable_by_key(|(commit_id, _)| *commit_id);
3603    resolve_causal_record_heads(key, &heads, adapters)
3604}
3605
3606fn resolve_causal_record_heads(
3607    key: &RecordTarget,
3608    entries: &[(CommitId, MaterializedRecord)],
3609    adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
3610) -> Result<MaterializedRecord, DbError> {
3611    let mut heads: Vec<_> = entries.iter().collect();
3612    heads.sort_unstable_by_key(|(commit_id, _)| *commit_id);
3613    let active_version = heads
3614        .iter()
3615        .map(|(_, mutation)| mutation.schema().version())
3616        .max()
3617        .ok_or_else(|| DbError::InvalidRemoteCommit("record has no causal head".into()))?;
3618    heads.retain(|(_, mutation)| mutation.schema().version() == active_version);
3619    let active_schema = heads[0].1.schema().schema_id();
3620    if heads
3621        .iter()
3622        .any(|(_, mutation)| mutation.schema().schema_id() != active_schema)
3623    {
3624        return Err(DbError::InvalidRemoteCommit(
3625            "concurrent schema descriptors share one version".into(),
3626        ));
3627    }
3628    let adapter = *adapters
3629        .get(&(key.0, active_schema))
3630        .ok_or(DbError::UnknownRecordSchema(key.0))?;
3631    if heads.iter().any(|(_, mutation)| mutation.state().is_some()) {
3632        let mut merged: Option<MaterializedRecord> = None;
3633        for (_, mutation) in heads
3634            .into_iter()
3635            .filter(|(_, mutation)| mutation.state().is_some())
3636        {
3637            merged = Some((adapter.merge)(
3638                merged.as_ref().and_then(MaterializedRecord::state),
3639                mutation,
3640            )?);
3641        }
3642        return merged
3643            .ok_or_else(|| DbError::InvalidRemoteCommit("record head resolution failed".into()));
3644    }
3645    let (_, deletion) = heads
3646        .first()
3647        .ok_or_else(|| DbError::InvalidRemoteCommit("record has no causal head".into()))?;
3648    MaterializedRecord::delete(deletion.schema().clone(), key.1.clone()).map_err(DbError::from)
3649}
3650
3651fn is_ancestor(
3652    ancestor: CommitId,
3653    descendant: CommitId,
3654    graph: &BTreeMap<CommitId, Vec<CommitId>>,
3655) -> bool {
3656    let mut pending = vec![descendant];
3657    let mut visited = BTreeSet::new();
3658    while let Some(commit_id) = pending.pop() {
3659        if !visited.insert(commit_id) {
3660            continue;
3661        }
3662        let Some(dependencies) = graph.get(&commit_id) else {
3663            continue;
3664        };
3665        if dependencies.contains(&ancestor) {
3666            return true;
3667        }
3668        pending.extend(dependencies);
3669    }
3670    false
3671}
3672
3673fn quarantine_metadata(
3674    baseline: Option<&SnapshotBaseline>,
3675    accepted: &[CommitId],
3676    graph: &BTreeMap<CommitId, Vec<CommitId>>,
3677    headers: &BTreeMap<CommitId, (AuthorId, u64)>,
3678) -> QuarantineMetadata {
3679    let mut frontier_candidates: BTreeSet<_> = baseline.map_or_else(BTreeSet::new, |baseline| {
3680        baseline.frontier().iter().copied().collect()
3681    });
3682    frontier_candidates.extend(accepted.iter().copied());
3683    let referenced: BTreeSet<_> = accepted
3684        .iter()
3685        .flat_map(|commit_id| graph[commit_id].iter().copied())
3686        .filter(|dependency| frontier_candidates.contains(dependency))
3687        .collect();
3688    let frontier = frontier_candidates
3689        .into_iter()
3690        .filter(|commit_id| !referenced.contains(commit_id))
3691        .collect();
3692    let mut author_sequences: BTreeMap<AuthorId, u64> = baseline
3693        .map_or_else(BTreeMap::new, |baseline| {
3694            baseline.author_sequences().iter().copied().collect()
3695        });
3696    let mut author_heads = BTreeMap::<AuthorId, (u64, CommitId)>::new();
3697    for commit_id in accepted {
3698        let (author, sequence) = headers[commit_id];
3699        author_sequences
3700            .entry(author)
3701            .and_modify(|current| *current = (*current).max(sequence))
3702            .or_insert(sequence);
3703        author_heads
3704            .entry(author)
3705            .and_modify(|current| {
3706                if sequence > current.0 {
3707                    *current = (sequence, *commit_id);
3708                }
3709            })
3710            .or_insert((sequence, *commit_id));
3711    }
3712    QuarantineMetadata {
3713        frontier,
3714        author_sequences: author_sequences.into_iter().collect(),
3715        author_heads: author_heads
3716            .into_iter()
3717            .map(|(author, (sequence, commit_id))| (author, sequence, commit_id))
3718            .collect(),
3719    }
3720}
3721
3722fn merge_materialized<Record: IrohRecord>(
3723    existing: Option<&[u8]>,
3724    incoming: &MaterializedRecord,
3725) -> Result<MaterializedRecord, DbError> {
3726    let schema = Record::schema()?;
3727    if schema.schema_id() != incoming.schema().schema_id() {
3728        return Err(DbError::InvalidRemoteCommit(
3729            "registered schema mismatch".into(),
3730        ));
3731    }
3732    let Some(incoming_state) = incoming.state() else {
3733        return MaterializedRecord::delete(schema, incoming.record_id().to_vec())
3734            .map_err(DbError::from);
3735    };
3736    let mut record = Record::decode_record(incoming_state)?;
3737    if record.record_id()? != incoming.record_id() {
3738        return Err(DbError::InvalidRemoteCommit(
3739            "encoded record ID mismatch".into(),
3740        ));
3741    }
3742    if let Some(existing) = existing {
3743        let existing = Record::decode_record(existing)?;
3744        record.merge_record(&existing)?;
3745    }
3746    let mut indexes = Vec::new();
3747    for field in schema.fields().iter().filter(|field| field.is_indexed()) {
3748        for value in record.field_values(field.id())? {
3749            indexes.push(IndexValue::new(field.id(), value));
3750        }
3751    }
3752    MaterializedRecord::upsert(
3753        schema,
3754        incoming.record_id().to_vec(),
3755        record.encode_record()?,
3756        indexes,
3757    )
3758    .map_err(DbError::from)
3759}
3760
3761fn canonicalize_materialized<Record: IrohRecord>(
3762    incoming: &MaterializedRecord,
3763    author: AuthorId,
3764    sequence: u64,
3765    context: &VersionVector,
3766    next_operation: &mut u32,
3767) -> Result<MaterializedRecord, DbError> {
3768    let schema = Record::schema()?;
3769    if schema.schema_id() != incoming.schema().schema_id() {
3770        return Err(DbError::InvalidRemoteCommit(
3771            "registered schema mismatch".into(),
3772        ));
3773    }
3774    let Some(state) = incoming.state() else {
3775        return MaterializedRecord::delete(schema, incoming.record_id().to_vec())
3776            .map_err(DbError::from);
3777    };
3778    let mut record = Record::decode_record(state)?;
3779    if record.record_id()? != incoming.record_id() {
3780        return Err(DbError::InvalidRemoteCommit(
3781            "encoded record ID mismatch".into(),
3782        ));
3783    }
3784    record.canonicalize_causality(author, sequence, context, next_operation)?;
3785    let mut indexes = Vec::new();
3786    for field in schema.fields().iter().filter(|field| field.is_indexed()) {
3787        for value in record.field_values(field.id())? {
3788            indexes.push(IndexValue::new(field.id(), value));
3789        }
3790    }
3791    MaterializedRecord::upsert(
3792        schema,
3793        incoming.record_id().to_vec(),
3794        record.encode_record()?,
3795        indexes,
3796    )
3797    .map_err(DbError::from)
3798}
3799
3800fn reconcile_domain_epochs(credentials: &dyn KeyProvider, store: &Store) -> Result<(), DbError> {
3801    for domain_id in credentials.domains()? {
3802        if store.domain_descriptor(domain_id)?.is_none() {
3803            continue;
3804        }
3805        let active_epoch = match store.current_epoch(domain_id) {
3806            Ok(epoch) => epoch,
3807            Err(StoreError::ControlFork { .. }) => continue,
3808            Err(error) => return Err(DbError::Store(error)),
3809        };
3810        let current = credentials.load_domain(domain_id)?;
3811        if current.epoch() == active_epoch {
3812            continue;
3813        }
3814        let distance = current.epoch().abs_diff(active_epoch);
3815        if distance != 1 {
3816            return Err(DbError::Operation(format!(
3817                "domain {domain_id} key epoch differs from control by {distance}"
3818            )));
3819        }
3820        let authoritative = credentials.load_domain_epoch(domain_id, active_epoch)?;
3821        if active_epoch > 0 {
3822            let head = store.control_head(domain_id)?.ok_or_else(|| {
3823                DbError::Operation("active epoch has no control transition".into())
3824            })?;
3825            if head.new_epoch() != active_epoch
3826                || head.key_distribution_digest() != authoritative.distribution_digest()
3827            {
3828                return Err(DbError::Operation(
3829                    "staged epoch key does not match the active control transition".into(),
3830                ));
3831            }
3832        }
3833        credentials.activate_domain(&authoritative)?;
3834    }
3835    Ok(())
3836}
3837
3838pub(crate) fn private_capability(domain_id: iroh_db_core::DomainId) -> CapabilityId {
3839    let mut hasher = blake3::Hasher::new();
3840    hasher.update(b"iroh-db/private-capability/v1");
3841    hasher.update(domain_id.as_bytes());
3842    CapabilityId::from_bytes(*hasher.finalize().as_bytes())
3843}
3844
3845#[allow(clippy::needless_pass_by_value)]
3846fn blob_metadata_error(error: impl std::fmt::Display) -> BlobStoreError {
3847    BlobStoreError::new(error.to_string())
3848}
3849
3850#[cfg(test)]
3851mod control_recovery_tests {
3852    use super::*;
3853
3854    #[tokio::test]
3855    async fn open_activates_the_staged_key_selected_by_durable_control() {
3856        let directory = tempfile::tempdir().unwrap();
3857        let device = IrohDb::open(directory.path()).await.unwrap();
3858        let domain = device.create_domain(ConsistencyMode::MultiWriter).unwrap();
3859        let domain_id = domain.domain_id();
3860        let old_seed = domain.domain_seed();
3861        let (next, _) = domain.rotate_epoch(&[]).unwrap();
3862        assert_eq!(next.domain_seed().epoch(), 1);
3863
3864        device.inner.credentials.activate_domain(&old_seed).unwrap();
3865        assert_eq!(
3866            device
3867                .inner
3868                .credentials
3869                .load_domain(domain_id)
3870                .unwrap()
3871                .epoch(),
3872            0
3873        );
3874        drop(domain);
3875        drop(next);
3876        device.close().await.unwrap();
3877
3878        let reopened = IrohDb::open(directory.path()).await.unwrap();
3879        let recovered = reopened.domain(domain_id).unwrap();
3880        assert_eq!(recovered.domain_seed().epoch(), 1);
3881        drop(recovered);
3882        reopened.close().await.unwrap();
3883    }
3884
3885    #[tokio::test]
3886    async fn control_mutation_fails_while_a_disclosure_read_permit_is_held() {
3887        let directory = tempfile::tempdir().unwrap();
3888        let device = IrohDb::open(directory.path()).await.unwrap();
3889        let domain = device.create_domain(ConsistencyMode::MultiWriter).unwrap();
3890        let disclosure = device.inner.control_gate.read().await;
3891        assert!(matches!(
3892            domain.rotate_epoch(&[]),
3893            Err(DbError::Operation(message)) if message == "control plane is busy"
3894        ));
3895        assert!(matches!(
3896            domain.invite(
3897                AuthorId::from_bytes([91; 32]),
3898                PermissionSet::from_permissions(&[Permission::Read]),
3899                PermissionSet::empty(),
3900                None,
3901            ),
3902            Err(DbError::Operation(message)) if message == "control plane is busy"
3903        ));
3904        assert!(domain.domain_descriptor().unwrap().is_some());
3905        drop(disclosure);
3906        let (rotated, _) = domain.rotate_epoch(&[]).unwrap();
3907        assert_eq!(rotated.domain_seed().epoch(), 1);
3908        drop(domain);
3909        drop(rotated);
3910        device.close().await.unwrap();
3911    }
3912
3913    #[tokio::test]
3914    async fn snapshot_operations_wait_for_a_stable_authority_position() {
3915        let directory = tempfile::tempdir().unwrap();
3916        let device = IrohDb::open(directory.path()).await.unwrap();
3917        let domain = device.create_domain(ConsistencyMode::MultiWriter).unwrap();
3918        let snapshot = domain.snapshot().await.unwrap();
3919
3920        let authority_mutation = device.inner.control_gate.write().await;
3921        let capture_domain = domain.clone();
3922        let mut capture = tokio::spawn(async move { capture_domain.snapshot().await });
3923        assert!(
3924            tokio::time::timeout(std::time::Duration::from_millis(50), &mut capture)
3925                .await
3926                .is_err()
3927        );
3928        drop(authority_mutation);
3929        capture.await.unwrap().unwrap();
3930
3931        let authority_mutation = device.inner.control_gate.write().await;
3932        let install_domain = domain.clone();
3933        let mut install =
3934            tokio::spawn(async move { install_domain.install_snapshot(snapshot).await });
3935        assert!(
3936            tokio::time::timeout(std::time::Duration::from_millis(50), &mut install)
3937                .await
3938                .is_err()
3939        );
3940        drop(authority_mutation);
3941        install.await.unwrap().unwrap();
3942
3943        drop(domain);
3944        device.close().await.unwrap();
3945    }
3946
3947    #[tokio::test]
3948    async fn rotation_updates_every_live_handle_for_the_domain() {
3949        let directory = tempfile::tempdir().unwrap();
3950        let device = IrohDb::open(directory.path()).await.unwrap();
3951        let domain = device.create_domain(ConsistencyMode::MultiWriter).unwrap();
3952        let clone = domain.clone();
3953        let (rotated, _) = domain.rotate_epoch(&[]).unwrap();
3954
3955        assert_eq!(clone.epoch(), 1);
3956        assert_eq!(clone.domain_seed().epoch(), 1);
3957        assert_eq!(rotated.epoch(), 1);
3958
3959        drop(domain);
3960        drop(clone);
3961        drop(rotated);
3962        device.close().await.unwrap();
3963    }
3964}