Skip to main content

icydb_core/db/
integrity.rs

1//! Module: db::integrity
2//! Responsibility: bounded integrity-inspection result vocabulary and lifecycle identity.
3//! Does not own: accepted schema meaning, physical traversal, or inspection progress persistence.
4//! Boundary: database control + accepted inspection plan -> typed Quick inspection result.
5
6mod deep;
7mod derived;
8mod job;
9mod progress_codec;
10mod progress_store;
11mod proof;
12mod row;
13
14use crate::{
15    db::{
16        commit::{database_control_proof_identity, database_incarnation_id, ensure_recovered},
17        registry::{
18            StoreAllocationIdentities, StoreHandle, StoreRuntimeStorageCapabilities,
19            StoreRuntimeStorageMode,
20        },
21        schema::{
22            AcceptedInspectionPlan, IdentityStateLifecycle, MAX_IDENTITY_STATE_RECORDS_PER_DATABASE,
23        },
24    },
25    error::{ConstraintValuePath, ErrorClass, ErrorOrigin, InternalError},
26    traits::CanisterKind,
27};
28use candid::CandidType;
29use serde::Deserialize;
30use std::{
31    collections::BTreeMap,
32    sync::atomic::{AtomicU64, Ordering},
33};
34
35pub(in crate::db) use deep::{
36    abort_deep_integrity_job, continue_deep_integrity_job, run_next_integrity_retention_page,
37    start_deep_integrity_job,
38};
39pub(in crate::db) use derived::{
40    DerivedInspectionLimits, execute_index_integrity_page, execute_reverse_integrity_page,
41};
42pub use job::{
43    DeepIntegrityPage, DeepIntegrityPageStatus, IntegrityAbortReceipt, IntegrityAbortStatus,
44    IntegrityDeepError, IntegrityJobError, IntegrityJobId, IntegrityJobOwner, IntegrityJobReceipt,
45    IntegrityPendingTerminal, IntegritySubmissionKey, IntegrityTerminalOutcome,
46};
47pub(in crate::db) use job::{
48    IntegrityCheckpoint, IntegrityJob, IntegrityJobState, IntegrityReceiptEnvelope,
49    IntegrityReceiptReplayKey, MAX_INTEGRITY_IN_PROGRESS_PAGES,
50};
51pub(in crate::db) use proof::{IntegrityProofVector, capture_integrity_proof_vector};
52pub(in crate::db) use row::{
53    PhysicalUnitCheckpoint, RowInspectionLimits, execute_row_integrity_page,
54};
55
56pub(in crate::db) const MAX_INTEGRITY_PATH_BYTES: usize = 4 * 1024;
57
58/// One authorization-bound typed integrity operation.
59///
60/// Entity-bearing variants pin the generated selector identity that the
61/// session must match against current accepted authority. Continuation and
62/// abort carry only the opaque job identity; private checkpoints never cross
63/// this boundary.
64
65#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
66pub enum IntegrityCheckRequest {
67    /// Execute one bounded metadata/control inspection.
68    Quick {
69        /// Accepted entity selector to resolve and verify.
70        entity: IntegrityEntityIdentity,
71    },
72    /// Create or replay one idempotent Deep job.
73    DeepStart {
74        /// Accepted entity selector to resolve and verify.
75        entity: IntegrityEntityIdentity,
76        /// Owner-scoped idempotency key.
77        submission_key: IntegritySubmissionKey,
78    },
79    /// Advance or replay one retained Deep job.
80    DeepContinue {
81        /// Opaque engine-issued job identity.
82        job_id: IntegrityJobId,
83        /// Sequence of the outstanding receipt being acknowledged.
84        acknowledged_sequence: u64,
85    },
86    /// Freeze one retained Deep job for replayable abort.
87    DeepAbort {
88        /// Opaque engine-issued job identity.
89        job_id: IntegrityJobId,
90    },
91}
92
93impl IntegrityCheckRequest {
94    /// Build one Deep continuation or exact replay request.
95    #[must_use]
96    pub const fn deep_continue(job_id: IntegrityJobId, acknowledged_sequence: u64) -> Self {
97        Self::DeepContinue {
98            job_id,
99            acknowledged_sequence,
100        }
101    }
102
103    /// Build one replayable Deep-abort request.
104    #[must_use]
105    pub const fn deep_abort(job_id: IntegrityJobId) -> Self {
106        Self::DeepAbort { job_id }
107    }
108}
109
110/// Typed result shared by trusted Rust and SQL integrity frontends.
111
112#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
113pub enum IntegrityCheckResult {
114    /// Bounded one-call Quick result.
115    Quick(QuickIntegrityResult),
116    /// Start, continuation, terminal, or abort Deep receipt.
117    Deep(IntegrityJobReceipt),
118}
119
120fn validate_quick_integrity_control<C: CanisterKind>(
121    db: &crate::db::Db<C>,
122    plan: &AcceptedInspectionPlan,
123    incarnation: DatabaseIncarnationId,
124) -> Result<Vec<IntegrityFinding>, InternalError> {
125    let identity = plan.identity();
126    let source_store = db.store_handle(identity.store_path())?;
127    let relations = plan.relation_inspection();
128    let mut participating_stores =
129        BTreeMap::from([(identity.store_path().to_string(), source_store)]);
130    for relation in relations {
131        participating_stores
132            .entry(relation.target_store_path().to_string())
133            .or_insert_with(|| relation.target_store());
134    }
135
136    let _database_control = database_control_proof_identity()?;
137    proof::validate_integrity_allocation_registry()?;
138    validate_quick_identity_control(db, incarnation)?;
139    let mut findings = Vec::new();
140    for (store_path, store) in &participating_stores {
141        if let Some(finding) = validate_quick_store_control(plan, store_path, *store)? {
142            findings.push(finding);
143        }
144    }
145    for ordinal in 0..plan.index_inspection().len() {
146        let _domain = plan
147            .index_inspection()
148            .domain(ordinal, identity.entity_tag())?;
149    }
150
151    Ok(findings)
152}
153
154fn validate_quick_identity_control<C: CanisterKind>(
155    db: &crate::db::Db<C>,
156    incarnation: DatabaseIncarnationId,
157) -> Result<(), InternalError> {
158    let mut stores = db.with_store_registry(|registry| registry.iter().collect::<Vec<_>>());
159    stores.sort_unstable_by_key(|(store_path, _)| *store_path);
160
161    let mut owners = BTreeMap::new();
162    let mut state_count = 0usize;
163    for (store_path, store) in stores {
164        let states = store.with_schema(|schema_store| {
165            schema_store.identity_state_inventory_for_integrity(incarnation)
166        })?;
167        state_count = state_count
168            .checked_add(states.len())
169            .ok_or_else(InternalError::identity_state_corruption)?;
170        if state_count > MAX_IDENTITY_STATE_RECORDS_PER_DATABASE {
171            return Err(InternalError::identity_state_corruption());
172        }
173
174        for state in states {
175            let owner = state.owner();
176            record_quick_identity_owner(&mut owners, store_path, &state)?;
177            if state.lifecycle() == IdentityStateLifecycle::Active {
178                let runtime_entity = db
179                    .accepted_runtime_entity_for_tag(owner.entity_tag())
180                    .map_err(|_| InternalError::identity_state_corruption())?;
181                if runtime_entity.store_path() != store_path {
182                    return Err(InternalError::identity_state_corruption());
183                }
184            }
185        }
186    }
187
188    Ok(())
189}
190
191fn record_quick_identity_owner<'a>(
192    owners: &mut BTreeMap<(crate::types::EntityTag, crate::db::schema::FieldId), &'a str>,
193    store_path: &'a str,
194    state: &crate::db::schema::IdentityState,
195) -> Result<(), InternalError> {
196    let owner = state.owner();
197    let key = (owner.entity_tag(), owner.field_id());
198    if owners.insert(key, store_path).is_some() {
199        return Err(InternalError::identity_state_corruption());
200    }
201    Ok(())
202}
203
204fn validate_quick_store_control(
205    plan: &AcceptedInspectionPlan,
206    store_path: &str,
207    store: StoreHandle,
208) -> Result<Option<IntegrityFinding>, InternalError> {
209    let capabilities = store.storage_capabilities();
210    let allocations = store.allocation_identities();
211    match capabilities.storage_mode() {
212        StoreRuntimeStorageMode::Heap => {
213            if capabilities != StoreRuntimeStorageCapabilities::heap()
214                || allocations != StoreAllocationIdentities::absent()
215                || store.journal_tail_store().is_some()
216            {
217                return Err(InternalError::store_invariant());
218            }
219            Ok(None)
220        }
221        StoreRuntimeStorageMode::Journaled => {
222            if capabilities != StoreRuntimeStorageCapabilities::journaled()
223                || !allocations.matches_storage_capabilities(capabilities)
224            {
225                return Err(InternalError::store_invariant());
226            }
227            let journal = store
228                .journal_tail_store()
229                .ok_or_else(InternalError::store_invariant)?
230                .with_borrow(crate::db::journal::JournalTailStore::proof_identity)?;
231            if !journal.is_well_formed() {
232                return Ok(Some(quick_journal_control_finding(plan, store_path)));
233            }
234            Ok(None)
235        }
236    }
237}
238
239fn quick_journal_control_finding(
240    plan: &AcceptedInspectionPlan,
241    store_path: &str,
242) -> IntegrityFinding {
243    let error = InternalError::store_corruption();
244    IntegrityFinding {
245        diagnostic_code: error.diagnostic_code().error_code().raw(),
246        class: IntegrityFindingClass::Corruption,
247        severity: IntegritySeverity::Error,
248        kind: IntegrityFindingKind::JournalControlMismatch,
249        entity: IntegrityEntityIdentity::from_plan(plan),
250        store_path: store_path.to_string(),
251        phase: IntegrityPhase::QuickMetadata,
252        verifier_family: IntegrityVerifierFamily::JournalEnvelope,
253        physical_key: Vec::new(),
254        primary_key: None,
255        field_paths: Vec::new(),
256        value_path: None,
257        constraint_id: None,
258        constraint_name: None,
259        schema_index_id: None,
260        relation_id: None,
261        expected: Some("well-formed-journal-control".to_string()),
262        observed: Some("inconsistent-journal-control".to_string()),
263    }
264}
265
266fn relation_field_paths(plan: &AcceptedInspectionPlan, relation_id: u32) -> Vec<String> {
267    let snapshot = plan.snapshot().persisted_snapshot();
268    let Some(relation) = snapshot
269        .relations()
270        .iter()
271        .find(|relation| relation.id().get() == relation_id)
272    else {
273        return Vec::new();
274    };
275
276    relation
277        .local_field_ids()
278        .iter()
279        .filter_map(|field_id| {
280            snapshot
281                .fields()
282                .iter()
283                .find(|field| field.id() == *field_id)
284                .map(|field| field.name().to_string())
285        })
286        .collect()
287}
288
289const MAX_QUICK_RETURNED_FINDINGS: usize = 64;
290#[cfg(target_arch = "wasm32")]
291const DATABASE_INCARNATION_DOMAIN: &[u8] = b"icydb.database-incarnation.v1";
292static DATABASE_INCARNATION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
293
294/// Durable identity of one database lifecycle.
295///
296/// The identity is independent of accepted schema, row, index, relation, and
297/// journal revisions. Ordinary reopen preserves it. Any future restore,
298/// replacement, or import lane that can reuse those revisions must mint and
299/// publish a fresh identity before the restored database becomes available.
300#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq)]
301pub struct DatabaseIncarnationId([u8; 16]);
302
303impl DatabaseIncarnationId {
304    /// Decode one current-form nonzero incarnation identity.
305    pub(crate) fn try_from_bytes(bytes: [u8; 16]) -> Result<Self, InternalError> {
306        if bytes == [0; 16] {
307            return Err(InternalError::database_incarnation_invalid());
308        }
309
310        Ok(Self(bytes))
311    }
312
313    /// Return the canonical persisted identity bytes.
314    #[must_use]
315    pub const fn to_bytes(self) -> [u8; 16] {
316        self.0
317    }
318
319    fn generate() -> Result<Self, InternalError> {
320        let sequence = DATABASE_INCARNATION_SEQUENCE
321            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
322                current.checked_add(1)
323            })
324            .map_err(|_| InternalError::database_incarnation_generation_failed())?
325            .checked_add(1)
326            .ok_or_else(InternalError::database_incarnation_generation_failed)?;
327
328        #[cfg(not(target_arch = "wasm32"))]
329        let bytes = {
330            let mut bytes = [0_u8; 16];
331            getrandom::fill(&mut bytes)
332                .map_err(|_| InternalError::database_incarnation_generation_failed())?;
333            bytes
334        };
335
336        #[cfg(target_arch = "wasm32")]
337        let bytes = {
338            use sha2::{Digest, Sha256};
339
340            let mut hasher = Sha256::new();
341            hasher.update(DATABASE_INCARNATION_DOMAIN);
342            hasher.update(ic_cdk::api::canister_self().as_slice());
343            hasher.update(ic_cdk::api::time().to_be_bytes());
344            hasher.update(sequence.to_be_bytes());
345            let digest = hasher.finalize();
346            let mut bytes = [0_u8; 16];
347            bytes.copy_from_slice(&digest[..16]);
348            bytes
349        };
350
351        let _ = sequence;
352        Self::try_from_bytes(bytes)
353    }
354
355    #[cfg(test)]
356    pub(crate) const fn for_tests(fill: u8) -> Self {
357        let mut bytes = [fill; 16];
358        if fill == 0 {
359            bytes[15] = 1;
360        }
361        Self(bytes)
362    }
363}
364
365/// Stable entity identity projected into integrity responses.
366#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
367pub struct IntegrityEntityIdentity {
368    entity_tag: u64,
369    entity_path: String,
370    store_path: String,
371}
372
373impl IntegrityEntityIdentity {
374    fn from_plan(plan: &AcceptedInspectionPlan) -> Self {
375        Self::from_accepted_identity(plan.identity_ref())
376    }
377
378    pub(in crate::db) fn from_accepted_identity(
379        identity: &crate::db::schema::AcceptedCatalogIdentity,
380    ) -> Self {
381        Self {
382            entity_tag: identity.entity_tag().value(),
383            entity_path: identity.entity_path().to_string(),
384            store_path: identity.store_path().to_string(),
385        }
386    }
387
388    pub(in crate::db) const fn validate(&self) -> Result<(), IntegrityJobError> {
389        if self.entity_tag == 0
390            || self.entity_path.is_empty()
391            || self.entity_path.len() > MAX_INTEGRITY_PATH_BYTES
392            || self.store_path.is_empty()
393            || self.store_path.len() > MAX_INTEGRITY_PATH_BYTES
394        {
395            return Err(IntegrityJobError::InvalidEntityIdentity);
396        }
397        Ok(())
398    }
399
400    /// Return the stable accepted entity tag.
401    #[must_use]
402    pub const fn entity_tag(&self) -> u64 {
403        self.entity_tag
404    }
405
406    /// Borrow the accepted entity path.
407    #[must_use]
408    pub const fn entity_path(&self) -> &str {
409        self.entity_path.as_str()
410    }
411
412    /// Borrow the accepted store path.
413    #[must_use]
414    pub const fn store_path(&self) -> &str {
415        self.store_path.as_str()
416    }
417}
418
419/// Broad machine-readable accepted-authority failure class.
420#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
421pub enum IntegrityAuthorityClass {
422    /// Accepted authority bytes or closure are corrupt.
423    Corruption,
424    /// Accepted authority uses an unsupported persisted form.
425    IncompatiblePersistedFormat,
426    /// Accepted authority violates an internal invariant.
427    InvariantViolation,
428    /// The selected entity or storage contract is unsupported.
429    Unsupported,
430    /// The engine could not complete accepted-authority inspection.
431    Internal,
432}
433
434/// Broad machine-readable integrity finding class.
435#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
436pub enum IntegrityFindingClass {
437    /// Accepted or physical bytes are corrupt.
438    Corruption,
439    /// Current-form persisted bytes cannot be decoded by this build.
440    IncompatiblePersistedFormat,
441    /// A required bounded proof could not be completed.
442    ResourceLimited,
443}
444
445/// Stable semantic family of one integrity finding.
446
447#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
448pub enum IntegrityFindingKind {
449    /// The physical data key is not a valid current key for its entity interval.
450    MalformedDataKey,
451
452    /// The maintained row envelope or slot table is malformed.
453    MalformedRow,
454
455    /// The row exceeds the maintained current raw-byte bound.
456    OversizedRow,
457
458    /// One active accepted field payload violates its exact field contract.
459    InvalidFieldValue,
460
461    /// The physical key and decoded primary-key field values disagree.
462    PrimaryKeyMismatch,
463
464    /// An Identity primary key is zero or has the wrong exact unsigned shape.
465    InvalidIdentityValue,
466
467    /// A live Identity primary key is above committed high-water.
468    IdentityHighWaterExceeded,
469
470    /// One validated accepted row-local constraint is violated.
471    ConstraintViolation,
472
473    /// One row-derived active forward-index witness is absent.
474    MissingIndexEntry,
475
476    /// One row-derived active forward-index witness has invalid value bytes.
477    DivergentIndexEntry,
478
479    /// One active forward-index entry has malformed key, identity, or value framing.
480    MalformedIndexEntry,
481
482    /// One active forward-index entry points at no authoritative source row.
483    OrphanIndexEntry,
484
485    /// One unique logical key has more than one physical row witness.
486    DuplicateUniqueIndexKey,
487
488    /// One accepted relation points to an absent target row.
489    MissingRelationTarget,
490
491    /// One expected active reverse-relation witness is absent.
492    MissingReverseRelationEntry,
493
494    /// One expected active reverse-relation witness has invalid value bytes.
495    DivergentReverseRelationEntry,
496
497    /// One active reverse-relation entry has malformed key, identity, or value framing.
498    MalformedReverseRelationEntry,
499
500    /// One active reverse-relation entry points at no authoritative source row.
501    OrphanReverseRelationEntry,
502
503    /// One durable journal batch is not a valid current-form envelope.
504    MalformedJournalBatch,
505
506    /// The durable journal tail omits one or more expected sequence values.
507    JournalSequenceGap,
508
509    /// Two durable journal batches carry the same logical batch identity.
510    DuplicateJournalBatchIdentity,
511
512    /// Bounded journal control records disagree without requiring tail traversal.
513    JournalControlMismatch,
514}
515
516/// Canonical Deep inspection phase.
517
518#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
519pub enum IntegrityPhase {
520    /// Bounded accepted metadata and control closure.
521    QuickMetadata,
522
523    /// Canonical physical row storage.
524    Rows,
525
526    /// Active forward-index storage.
527    IndexEntries,
528
529    /// Active source-owned reverse-relation storage.
530    ReverseRelations,
531
532    /// Durable journal tails.
533    JournalTails,
534
535    /// Final unchanged-proof-vector comparison.
536    FinalProofVectorCheck,
537}
538
539/// Deterministic verifier family within one physical inspection unit.
540
541#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd)]
542pub enum IntegrityVerifierFamily {
543    /// Current physical data-key framing and identity.
544    DataKey,
545
546    /// Current row envelope, layout stamp, slot count, and table framing.
547    RowEnvelope,
548
549    /// One accepted field payload or frozen historical fill.
550    FieldValue,
551
552    /// Physical key versus accepted row primary-key fields.
553    PrimaryKey,
554
555    /// Accepted Identity owner and committed high-water.
556    IdentityState,
557
558    /// Validated accepted row-local constraints.
559    ValidatedConstraints,
560
561    /// One expected active forward-index witness.
562    ForwardIndex,
563
564    /// One physical active forward-index entry.
565    IndexEntry,
566
567    /// One unique-key multiplicity proof.
568    UniqueIndex,
569
570    /// One accepted relation's target and reverse witness projection.
571    Relation,
572
573    /// One physical active source-owned reverse-relation entry.
574    ReverseRelationEntry,
575
576    /// Current durable journal batch framing and sequence continuity.
577    JournalEnvelope,
578
579    /// Durable journal batch identity uniqueness.
580    JournalBatchIdentity,
581}
582
583/// Severity of one definite integrity finding.
584#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
585pub enum IntegritySeverity {
586    /// The finding identifies invalid maintained state.
587    Error,
588    /// The finding is an operator advisory and does not invalidate a clean proof.
589    Advisory,
590}
591
592/// One bounded machine-readable integrity finding.
593///
594/// Raw row payloads and unbounded application values are deliberately absent.
595#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
596pub struct IntegrityFinding {
597    diagnostic_code: u16,
598    class: IntegrityFindingClass,
599    severity: IntegritySeverity,
600    kind: IntegrityFindingKind,
601    entity: IntegrityEntityIdentity,
602    store_path: String,
603    phase: IntegrityPhase,
604    verifier_family: IntegrityVerifierFamily,
605    physical_key: Vec<u8>,
606    primary_key: Option<Vec<u8>>,
607    field_paths: Vec<String>,
608    value_path: Option<Box<ConstraintValuePath>>,
609    constraint_id: Option<u32>,
610    constraint_name: Option<String>,
611    schema_index_id: Option<u32>,
612    relation_id: Option<u32>,
613    expected: Option<String>,
614    observed: Option<String>,
615}
616
617impl IntegrityFinding {
618    /// Return the stable compact diagnostic code.
619    #[must_use]
620    pub const fn diagnostic_code(&self) -> u16 {
621        self.diagnostic_code
622    }
623
624    /// Return the broad finding class.
625    #[must_use]
626    pub const fn class(&self) -> IntegrityFindingClass {
627        self.class
628    }
629
630    /// Return the finding severity.
631    #[must_use]
632    pub const fn severity(&self) -> IntegritySeverity {
633        self.severity
634    }
635
636    /// Return the stable semantic finding family.
637    #[must_use]
638    pub const fn kind(&self) -> IntegrityFindingKind {
639        self.kind
640    }
641
642    /// Borrow the accepted entity identity.
643    #[must_use]
644    pub const fn entity(&self) -> &IntegrityEntityIdentity {
645        &self.entity
646    }
647
648    /// Borrow the affected store path.
649    #[must_use]
650    pub const fn store_path(&self) -> &str {
651        self.store_path.as_str()
652    }
653
654    /// Return the Deep phase that observed this finding.
655    #[must_use]
656    pub const fn phase(&self) -> IntegrityPhase {
657        self.phase
658    }
659
660    /// Return the deterministic verifier family that observed this finding.
661    #[must_use]
662    pub const fn verifier_family(&self) -> IntegrityVerifierFamily {
663        self.verifier_family
664    }
665
666    /// Borrow the bounded exact physical key.
667    #[must_use]
668    pub const fn physical_key(&self) -> &[u8] {
669        self.physical_key.as_slice()
670    }
671
672    /// Borrow the canonical primary-key suffix after successful key decoding.
673    #[must_use]
674    pub fn primary_key(&self) -> Option<&[u8]> {
675        self.primary_key.as_deref()
676    }
677
678    /// Borrow bounded accepted field paths relevant to the finding.
679    #[must_use]
680    pub const fn field_paths(&self) -> &[String] {
681        self.field_paths.as_slice()
682    }
683
684    /// Borrow the concrete accepted value path for targeted-rule findings.
685    #[must_use]
686    pub fn value_path(&self) -> Option<&ConstraintValuePath> {
687        self.value_path.as_deref()
688    }
689
690    /// Return the accepted constraint identity when applicable.
691    #[must_use]
692    pub const fn constraint_id(&self) -> Option<u32> {
693        self.constraint_id
694    }
695
696    /// Borrow the accepted constraint name when applicable.
697    #[must_use]
698    pub fn constraint_name(&self) -> Option<&str> {
699        self.constraint_name.as_deref()
700    }
701
702    /// Return the accepted logical index identity when applicable.
703    #[must_use]
704    pub const fn schema_index_id(&self) -> Option<u32> {
705        self.schema_index_id
706    }
707
708    /// Return the accepted relation identity when applicable.
709    #[must_use]
710    pub const fn relation_id(&self) -> Option<u32> {
711        self.relation_id
712    }
713
714    /// Borrow the bounded expected-state label, when applicable.
715    #[must_use]
716    pub fn expected(&self) -> Option<&str> {
717        self.expected.as_deref()
718    }
719
720    /// Borrow the bounded observed-state label, when applicable.
721    #[must_use]
722    pub fn observed(&self) -> Option<&str> {
723        self.observed.as_deref()
724    }
725}
726
727/// Typed reason that accepted authority could not be inspected.
728#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
729pub struct IntegrityAuthorityDiagnostic {
730    diagnostic_code: u16,
731    class: IntegrityAuthorityClass,
732}
733
734impl IntegrityAuthorityDiagnostic {
735    pub(in crate::db) fn from_internal(error: &InternalError) -> Self {
736        let class = match error.class {
737            ErrorClass::Corruption => IntegrityAuthorityClass::Corruption,
738            ErrorClass::IncompatiblePersistedFormat => {
739                IntegrityAuthorityClass::IncompatiblePersistedFormat
740            }
741            ErrorClass::InvariantViolation => IntegrityAuthorityClass::InvariantViolation,
742            ErrorClass::Unsupported | ErrorClass::NotFound | ErrorClass::Conflict => {
743                IntegrityAuthorityClass::Unsupported
744            }
745            ErrorClass::Internal => IntegrityAuthorityClass::Internal,
746        };
747        Self {
748            diagnostic_code: error.diagnostic_code().error_code().raw(),
749            class,
750        }
751    }
752
753    /// Return the stable compact diagnostic code.
754    #[must_use]
755    pub const fn diagnostic_code(&self) -> u16 {
756        self.diagnostic_code
757    }
758
759    /// Return the broad failure class.
760    #[must_use]
761    pub const fn class(&self) -> IntegrityAuthorityClass {
762        self.class
763    }
764}
765
766/// Typed bounded-resource failure.
767#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
768pub struct IntegrityResourceDiagnostic {
769    diagnostic_code: u16,
770}
771
772impl IntegrityResourceDiagnostic {
773    /// Return the stable compact diagnostic code.
774    #[must_use]
775    pub const fn diagnostic_code(&self) -> u16 {
776        self.diagnostic_code
777    }
778}
779
780/// Outcome of one bounded Quick integrity inspection.
781#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
782pub enum QuickIntegrityStatus {
783    /// Every bounded Quick family was inspected without findings.
784    CompleteClean,
785    /// Every bounded Quick family was inspected and definite findings exist.
786    CompleteWithFindings,
787    /// Load-bearing accepted authority could not be inspected.
788    Uninspectable(IntegrityAuthorityDiagnostic),
789    /// The minimum bounded inspection atom could not be completed.
790    ResourceLimited(IntegrityResourceDiagnostic),
791}
792
793/// Complete result of one bounded accepted-native Quick inspection.
794#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
795pub struct QuickIntegrityResult {
796    entity: IntegrityEntityIdentity,
797    database_incarnation_id: DatabaseIncarnationId,
798    accepted_schema_version: u32,
799    accepted_schema_fingerprint: [u8; 16],
800    status: QuickIntegrityStatus,
801    total_findings: u64,
802    omitted_findings: u64,
803    findings: Vec<IntegrityFinding>,
804}
805
806impl QuickIntegrityResult {
807    /// Borrow the accepted entity identity.
808    #[must_use]
809    pub const fn entity(&self) -> &IntegrityEntityIdentity {
810        &self.entity
811    }
812
813    /// Return the durable database incarnation inspected by this call.
814    #[must_use]
815    pub const fn database_incarnation_id(&self) -> DatabaseIncarnationId {
816        self.database_incarnation_id
817    }
818
819    /// Return the accepted entity schema version.
820    #[must_use]
821    pub const fn accepted_schema_version(&self) -> u32 {
822        self.accepted_schema_version
823    }
824
825    /// Return the accepted entity schema fingerprint.
826    #[must_use]
827    pub const fn accepted_schema_fingerprint(&self) -> [u8; 16] {
828        self.accepted_schema_fingerprint
829    }
830
831    /// Borrow the Quick completion status.
832    #[must_use]
833    pub const fn status(&self) -> &QuickIntegrityStatus {
834        &self.status
835    }
836
837    /// Return the exact number of findings observed.
838    #[must_use]
839    pub const fn total_findings(&self) -> u64 {
840        self.total_findings
841    }
842
843    /// Return the number of findings omitted from the bounded response prefix.
844    #[must_use]
845    pub const fn omitted_findings(&self) -> u64 {
846        self.omitted_findings
847    }
848
849    /// Borrow the bounded canonical finding prefix.
850    #[must_use]
851    pub const fn findings(&self) -> &[IntegrityFinding] {
852        self.findings.as_slice()
853    }
854}
855
856struct QuickIntegrityAccumulator {
857    total_findings: u64,
858    findings: Vec<IntegrityFinding>,
859}
860
861impl QuickIntegrityAccumulator {
862    const fn new() -> Self {
863        Self {
864            total_findings: 0,
865            findings: Vec::new(),
866        }
867    }
868
869    fn record(&mut self, finding: IntegrityFinding) -> Result<(), IntegrityResourceDiagnostic> {
870        self.total_findings =
871            self.total_findings
872                .checked_add(1)
873                .ok_or(IntegrityResourceDiagnostic {
874                    diagnostic_code: icydb_diagnostic_code::ErrorCode::RUNTIME_INTERNAL.raw(),
875                })?;
876        if self.findings.len() < MAX_QUICK_RETURNED_FINDINGS {
877            self.findings.push(finding);
878        }
879        Ok(())
880    }
881
882    fn complete(
883        self,
884        plan: &AcceptedInspectionPlan,
885        incarnation: DatabaseIncarnationId,
886    ) -> Result<QuickIntegrityResult, InternalError> {
887        let status = if self.total_findings == 0 {
888            QuickIntegrityStatus::CompleteClean
889        } else {
890            QuickIntegrityStatus::CompleteWithFindings
891        };
892        let omitted_findings = self.omitted_findings()?;
893        let identity = plan.identity();
894
895        Ok(QuickIntegrityResult {
896            entity: IntegrityEntityIdentity::from_plan(plan),
897            database_incarnation_id: incarnation,
898            accepted_schema_version: identity.accepted_schema_version().get(),
899            accepted_schema_fingerprint: identity.accepted_schema_fingerprint(),
900            status,
901            total_findings: self.total_findings,
902            omitted_findings,
903            findings: self.findings,
904        })
905    }
906
907    fn resource_limited(
908        self,
909        plan: &AcceptedInspectionPlan,
910        incarnation: DatabaseIncarnationId,
911        diagnostic: IntegrityResourceDiagnostic,
912    ) -> Result<QuickIntegrityResult, InternalError> {
913        let omitted_findings = self.omitted_findings()?;
914        let identity = plan.identity();
915
916        Ok(QuickIntegrityResult {
917            entity: IntegrityEntityIdentity::from_plan(plan),
918            database_incarnation_id: incarnation,
919            accepted_schema_version: identity.accepted_schema_version().get(),
920            accepted_schema_fingerprint: identity.accepted_schema_fingerprint(),
921            status: QuickIntegrityStatus::ResourceLimited(diagnostic),
922            total_findings: self.total_findings,
923            omitted_findings,
924            findings: self.findings,
925        })
926    }
927
928    fn omitted_findings(&self) -> Result<u64, InternalError> {
929        let returned = u64::try_from(self.findings.len()).map_err(|_| {
930            InternalError::classified(ErrorClass::InvariantViolation, ErrorOrigin::Response)
931        })?;
932        self.total_findings.checked_sub(returned).ok_or_else(|| {
933            InternalError::classified(ErrorClass::InvariantViolation, ErrorOrigin::Response)
934        })
935    }
936}
937
938pub(in crate::db) fn uninspectable_quick_integrity(
939    identity: crate::db::schema::AcceptedCatalogIdentity,
940    incarnation: DatabaseIncarnationId,
941    error: &InternalError,
942) -> QuickIntegrityResult {
943    QuickIntegrityResult {
944        entity: IntegrityEntityIdentity::from_accepted_identity(&identity),
945        database_incarnation_id: incarnation,
946        accepted_schema_version: identity.accepted_schema_version().get(),
947        accepted_schema_fingerprint: identity.accepted_schema_fingerprint(),
948        status: QuickIntegrityStatus::Uninspectable(IntegrityAuthorityDiagnostic::from_internal(
949            error,
950        )),
951        total_findings: 0,
952        omitted_findings: 0,
953        findings: Vec::new(),
954    }
955}
956
957pub(in crate::db) fn execute_quick_integrity<C: CanisterKind>(
958    db: &crate::db::Db<C>,
959    plan: &AcceptedInspectionPlan,
960) -> Result<QuickIntegrityResult, InternalError> {
961    ensure_recovered(db)?;
962    let incarnation = database_incarnation_id()?;
963    let findings = match validate_quick_integrity_control(db, plan, incarnation) {
964        Ok(findings) => findings,
965        Err(error) => {
966            return Ok(uninspectable_quick_integrity(
967                plan.identity(),
968                incarnation,
969                &error,
970            ));
971        }
972    };
973    let mut accumulator = QuickIntegrityAccumulator::new();
974    for finding in findings {
975        if let Err(diagnostic) = accumulator.record(finding) {
976            return accumulator.resource_limited(plan, incarnation, diagnostic);
977        }
978    }
979
980    accumulator.complete(plan, incarnation)
981}
982
983pub(crate) fn generate_database_incarnation_id() -> Result<DatabaseIncarnationId, InternalError> {
984    DatabaseIncarnationId::generate()
985}
986
987/// Generate the database-lifecycle secret used to authenticate scalar cursors.
988///
989/// The key is persisted beside the database incarnation before runtime
990/// authority becomes visible. It uses the same process-local generation
991/// authority as generated ULID keys and is not exposed through an outward API.
992pub(crate) fn generate_cursor_authentication_key() -> Result<[u8; 32], InternalError> {
993    let first = <crate::types::Ulid as crate::types::GenerateKey>::generate()?;
994    let second = <crate::types::Ulid as crate::types::GenerateKey>::generate()?;
995    let mut bytes = [0_u8; 32];
996    bytes[..16].copy_from_slice(&first.to_bytes());
997    bytes[16..].copy_from_slice(&second.to_bytes());
998    if bytes == [0; 32] {
999        return Err(InternalError::database_incarnation_generation_failed());
1000    }
1001
1002    Ok(bytes)
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008    use crate::{
1009        db::schema::{FieldStorageDecode, LeafCodec, ScalarCodec},
1010        db::{
1011            commit::CommitSchemaFingerprint,
1012            schema::{
1013                AcceptedCatalogIdentity, AcceptedCompositeCatalog, AcceptedFieldKind,
1014                AcceptedSchemaRevision, AcceptedSchemaSnapshot, AcceptedValueCatalogHandle,
1015                FieldId, IdentityState, IdentityStateOwner, PersistedFieldSnapshot,
1016                PersistedSchemaSnapshot, SchemaFieldSlot, SchemaInsertDefault, SchemaRowLayout,
1017                SchemaVersion, empty_accepted_enum_catalog_for_tests,
1018            },
1019        },
1020        types::EntityTag,
1021    };
1022
1023    fn plan() -> AcceptedInspectionPlan {
1024        let revision = AcceptedSchemaRevision::INITIAL;
1025        let identity = AcceptedCatalogIdentity::new(
1026            EntityTag::new(23),
1027            "tests::QuickEntity",
1028            "tests::QuickStore",
1029            revision,
1030            SchemaVersion::initial(),
1031            CommitSchemaFingerprint::from([0x44; 16]),
1032        );
1033        let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1034            SchemaVersion::initial(),
1035            "tests::QuickEntity".to_string(),
1036            "QuickEntity".to_string(),
1037            FieldId::new(1),
1038            SchemaRowLayout::initial(vec![(FieldId::new(1), SchemaFieldSlot::new(0))]),
1039            vec![PersistedFieldSnapshot::new_initial(
1040                FieldId::new(1),
1041                "id".to_string(),
1042                SchemaFieldSlot::new(0),
1043                AcceptedFieldKind::Nat64,
1044                Vec::new(),
1045                false,
1046                SchemaInsertDefault::None,
1047                FieldStorageDecode::ByKind,
1048                LeafCodec::Scalar(ScalarCodec::Nat64),
1049            )],
1050        ));
1051        let value_catalog = AcceptedValueCatalogHandle::new_for_tests(
1052            empty_accepted_enum_catalog_for_tests(),
1053            AcceptedCompositeCatalog::empty(),
1054            revision,
1055        );
1056
1057        AcceptedInspectionPlan::compile_relation_free_for_tests(identity, snapshot, value_catalog)
1058            .expect("accepted Quick plan should compile")
1059    }
1060
1061    fn finding(plan: &AcceptedInspectionPlan) -> IntegrityFinding {
1062        IntegrityFinding {
1063            diagnostic_code: icydb_diagnostic_code::ErrorCode::STORE_CORRUPTION.raw(),
1064            class: IntegrityFindingClass::Corruption,
1065            severity: IntegritySeverity::Error,
1066            kind: IntegrityFindingKind::MalformedRow,
1067            entity: IntegrityEntityIdentity::from_plan(plan),
1068            store_path: plan.identity().store_path().to_string(),
1069            phase: IntegrityPhase::Rows,
1070            verifier_family: IntegrityVerifierFamily::RowEnvelope,
1071            physical_key: vec![1],
1072            primary_key: None,
1073            field_paths: Vec::new(),
1074            value_path: None,
1075            constraint_id: None,
1076            constraint_name: None,
1077            schema_index_id: None,
1078            relation_id: None,
1079            expected: None,
1080            observed: None,
1081        }
1082    }
1083
1084    #[test]
1085    fn database_incarnation_rejects_zero_and_round_trips_current_bytes() {
1086        assert!(DatabaseIncarnationId::try_from_bytes([0; 16]).is_err());
1087
1088        let identity = DatabaseIncarnationId::for_tests(7);
1089        assert_eq!(
1090            DatabaseIncarnationId::try_from_bytes(identity.to_bytes())
1091                .expect("nonzero incarnation should decode"),
1092            identity,
1093        );
1094    }
1095
1096    #[test]
1097    fn integrity_finding_candid_preserves_targeted_constraint_path() {
1098        let plan = plan();
1099        let mut finding = finding(&plan);
1100        let path = ConstraintValuePath::new(vec![
1101            crate::error::ConstraintValuePathComponent::RootField { field_id: 1 },
1102            crate::error::ConstraintValuePathComponent::ListElement { index: 2 },
1103        ]);
1104        finding.kind = IntegrityFindingKind::ConstraintViolation;
1105        finding.value_path = Some(Box::new(path.clone()));
1106        finding.constraint_id = Some(7);
1107        finding.constraint_name = Some("nested_limit".to_string());
1108
1109        let bytes = candid::encode_one(&finding).expect("integrity finding should encode");
1110        let decoded: IntegrityFinding =
1111            candid::decode_one(&bytes).expect("integrity finding should decode");
1112        assert_eq!(decoded.value_path(), Some(&path));
1113        assert_eq!(decoded.constraint_id(), Some(7));
1114        assert_eq!(decoded.constraint_name(), Some("nested_limit"));
1115    }
1116
1117    #[test]
1118    fn quick_clean_result_binds_incarnation_and_accepted_plan_identity() {
1119        let plan = plan();
1120        let incarnation = DatabaseIncarnationId::for_tests(8);
1121        let result = QuickIntegrityAccumulator::new()
1122            .complete(&plan, incarnation)
1123            .expect("clean Quick accounting should remain valid");
1124
1125        assert_eq!(result.status(), &QuickIntegrityStatus::CompleteClean);
1126        assert_eq!(result.database_incarnation_id(), incarnation);
1127        assert_eq!(result.accepted_schema_version(), 1);
1128        assert_eq!(result.accepted_schema_fingerprint(), [0x44; 16]);
1129        assert_eq!(result.total_findings(), 0);
1130        assert_eq!(result.omitted_findings(), 0);
1131    }
1132
1133    #[test]
1134    fn quick_findings_keep_a_bounded_prefix_and_exact_omitted_count() {
1135        let plan = plan();
1136        let mut accumulator = QuickIntegrityAccumulator::new();
1137        for _ in 0..=MAX_QUICK_RETURNED_FINDINGS {
1138            accumulator
1139                .record(finding(&plan))
1140                .expect("bounded test finding count should fit");
1141        }
1142        let result = accumulator
1143            .complete(&plan, DatabaseIncarnationId::for_tests(9))
1144            .expect("one-over-cap Quick accounting should remain valid");
1145
1146        assert_eq!(result.status(), &QuickIntegrityStatus::CompleteWithFindings,);
1147        assert_eq!(result.total_findings(), 65);
1148        assert_eq!(result.findings().len(), MAX_QUICK_RETURNED_FINDINGS);
1149        assert_eq!(result.omitted_findings(), 1);
1150        assert_eq!(
1151            result.total_findings(),
1152            result.findings().len() as u64 + result.omitted_findings(),
1153        );
1154    }
1155
1156    #[test]
1157    fn quick_findings_at_the_exact_returned_cap_have_no_omissions() {
1158        let plan = plan();
1159        let mut accumulator = QuickIntegrityAccumulator::new();
1160        for _ in 0..MAX_QUICK_RETURNED_FINDINGS {
1161            accumulator
1162                .record(finding(&plan))
1163                .expect("exact-cap finding count should fit");
1164        }
1165        let result = accumulator
1166            .complete(&plan, DatabaseIncarnationId::for_tests(10))
1167            .expect("exact-cap Quick accounting should remain valid");
1168
1169        assert_eq!(result.total_findings(), 64);
1170        assert_eq!(result.findings().len(), MAX_QUICK_RETURNED_FINDINGS);
1171        assert_eq!(result.omitted_findings(), 0);
1172    }
1173
1174    #[test]
1175    fn quick_selected_authority_failure_is_not_a_clean_completion() {
1176        let plan = plan();
1177        let error = InternalError::accepted_row_constraint_program_corrupt();
1178        let result = uninspectable_quick_integrity(
1179            plan.identity(),
1180            DatabaseIncarnationId::for_tests(11),
1181            &error,
1182        );
1183
1184        assert!(matches!(
1185            result.status(),
1186            QuickIntegrityStatus::Uninspectable(IntegrityAuthorityDiagnostic {
1187                class: IntegrityAuthorityClass::Corruption,
1188                ..
1189            }),
1190        ));
1191        assert_eq!(result.total_findings(), 0);
1192        assert_eq!(result.omitted_findings(), 0);
1193    }
1194
1195    #[test]
1196    fn quick_identity_inventory_rejects_active_retired_owner_collision_first() {
1197        let incarnation = DatabaseIncarnationId::for_tests(12);
1198        let owner = IdentityStateOwner::try_new(incarnation, EntityTag::new(31), FieldId::new(1))
1199            .expect("identity owner should admit");
1200        let active = IdentityState::new_active(owner, AcceptedFieldKind::Nat64)
1201            .expect("active identity state should admit");
1202        let retired = active.retire().expect("active state should retire");
1203        let mut owners = BTreeMap::new();
1204
1205        record_quick_identity_owner(&mut owners, "tests::first", &active)
1206            .expect("the first owner should admit");
1207        let error = record_quick_identity_owner(&mut owners, "tests::second", &retired)
1208            .expect_err("an active/retired owner collision must reject");
1209
1210        assert_eq!(error.class(), ErrorClass::Corruption);
1211        assert_eq!(error.origin(), ErrorOrigin::Identity);
1212    }
1213}