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