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