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