From d12e5af6180bf4f122160e437817e1ff4d42301c Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 9 Sep 2026 11:36:20 -0700 Subject: [PATCH 01/59] Add: plan retention recovery from restart evidence Retention publication refuses every retained stage as recovery-required, and nothing yet decides what a retained stage means. This adds the storage-independent half of that decision. assess_root_stage, assess_manifest_stage, and assess_head_stage classify each fixed stage as absent, complete, truncated, or corrupt, using the decoders' own truncation variants so a crash mid-write and a complete-looking record that fails a checksum are told apart. RetentionRecoveryEvidence binds those assessments to the observed current state and to whether each pool already holds the entry a complete stage names. plan_retention_recovery is pure over that evidence and applies the documented classification: a truncated stage with no later-ordered effect is discarded; a complete root or manifest stage is linked into its pool and retained as a recovery-protected orphan; a complete head over linked stages is finalized and both stages removed; stages the published head already names are cleaned up; everything else is a typed RetentionRecoveryRefusal before any effect. Eleven laws over the golden version-two records cover every crash prefix the recovery page names, including the successor case against a published generation. No I/O happens here; the storage port and executor follow. Refs #19 --- CHANGELOG.md | 10 + docs/formats/segment-store-v2/requirements.md | 2 +- src/adapters/retention.rs | 18 + .../retention/filesystem_retention_current.rs | 17 + src/adapters/retention/recovery_evidence.rs | 96 +++++ src/adapters/retention/recovery_plan.rs | 70 ++++ src/adapters/retention/recovery_planner.rs | 283 ++++++++++++++ .../retention/recovery_planner_tests.rs | 356 ++++++++++++++++++ src/adapters/retention/recovery_refusal.rs | 174 +++++++++ .../retention/recovery_stage_assessment.rs | 92 +++++ src/lib.rs | 20 +- 11 files changed, 1130 insertions(+), 8 deletions(-) create mode 100644 src/adapters/retention/recovery_evidence.rs create mode 100644 src/adapters/retention/recovery_plan.rs create mode 100644 src/adapters/retention/recovery_planner.rs create mode 100644 src/adapters/retention/recovery_planner_tests.rs create mode 100644 src/adapters/retention/recovery_refusal.rs create mode 100644 src/adapters/retention/recovery_stage_assessment.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 080ae98b..3561f7a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ after its public API and format compatibility policies are established. ### Added +- Storage-independent retention recovery planning: `assess_root_stage`, + `assess_manifest_stage`, and `assess_head_stage` classify each fixed stage + as absent, complete, truncated, or corrupt through the decoders' own + truncation laws; `plan_retention_recovery` turns that evidence, the observed + current state, and pool-entry observations into an ordered + `RetentionRecoveryPlan` (discard a pre-effect truncated stage, link and + protect complete orphans, finalize a complete head over linked stages, clean + up stages the published head already names) or a typed + `RetentionRecoveryRefusal`. Planning performs no I/O; storage execution is + the next step. - `FilesystemRetentionPublicationAuthority` executes the 17 ordered retention publication phases against a completely migrated version-2 root. It stages `root.next`, `manifest.next`, and `head.next` exclusively, verifies device diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index ae8100b7..ff8ae2f4 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -15,7 +15,7 @@ case is not evidence. | `KEEP-RETENTION-004` | Retain and release compare expected and observed generations and publish exact successors only | unforgeable readiness and preflight proofs in `tests/retention_transition.rs` and `tests/retention_preflight.rs`; exact successor preparation and complete receipt evidence in `tests/retention_publication_preparation.rs` and `tests/retention_publication_execution.rs`; writer-locked initial filesystem publication in `filesystem_retention_storage_tests`; observed-head successor publication, exact predecessor binding, and absent-head refusal in `filesystem_retention_successor_tests`; the store's catalog head must name the closure's catalog generation and digest before any forward write in `filesystem_retention_catalog_tests`; a head whose predecessor disagrees with its manifest refuses in `filesystem_retention_current_tests`; a successor reopens and decodes the manifest-selected predecessor root and refuses an absent or changed one in `filesystem_retention_expectation_tests` | Implemented | | `KEEP-RETENTION-005` | Closure derivation is deterministic, bounded, cycle-safe, fail-closed, and verifies complete blob reconstruction | exact accounting, reconstruction, adversarial-catalog, and exhaustive model laws in `tests/retention_closure.rs`; corrupt members refuse through the inherited segment-record admission laws and seeded `segment_format` fuzz target routed by `closure-corruption.md` | Implemented | | `KEEP-RETENTION-006` | Publication follows the exact ordered durability protocol, including new namespace-directory admission and retention of fixed-stage evidence until head commit, and returns only after cleanup synchronization | typed vocabulary and blocking port in `tests/retention_publication_phase.rs` and `tests/retention_publication_storage.rs`; ordered execution, conditional namespace sync, and all 17 exact storage-fault boundaries in `tests/retention_publication_execution.rs`; production 17-phase forward filesystem execution, exclusive staging, byte-equal inode-substitution refusal, and retained-stage recovery refusal in `filesystem_retention_storage_tests`; orphan namespace directories count against the 4,096 ceiling and refuse a new namespace before any stage is written in `filesystem_retention_capacity_tests`; crash injection remains | In progress in #19 | -| `KEEP-RETENTION-007` | Restart resolves every fixed-stage crash prefix to one documented lawful state or typed ambiguity | recovery-required refusals before any mutation in `filesystem_retention_expectation_tests`: an absent head over populated pools, a non-initial head prepared against an absent head, an orphan directory for a namespace expected absent, and an absent directory for a namespace expected current; debug and release crash matrix remains; replaced protocol directories, an absent or changed head-selected catalog, an over-full census, zero-generation pool names, and a stage retained by a failed write refuse in `filesystem_retention_*_tests` | In progress in #19 | +| `KEEP-RETENTION-007` | Restart resolves every fixed-stage crash prefix to one documented lawful state or typed ambiguity | recovery-required refusals before any mutation in `filesystem_retention_expectation_tests`: an absent head over populated pools, a non-initial head prepared against an absent head, an orphan directory for a namespace expected absent, and an absent directory for a namespace expected current; debug and release crash matrix remains; replaced protocol directories, an absent or changed head-selected catalog, an over-full census, zero-generation pool names, and a stage retained by a failed write refuse in `filesystem_retention_*_tests`; storage-independent classification of every fixed-stage crash prefix (discard, link and protect, finalize, clean up, or typed refusal) in `recovery_planner_tests` | In progress in #19 | | `KEEP-RETENTION-008` | Readers double-collect catalog and retention heads and bind one complete catalog, manifest, and root-generation view under a `ReaderFence` | immutable snapshot and concurrency tests | Planned in #19 | | `KEEP-RETENTION-009` | Exact already-committed retry is idempotent only while its successor remains current | byte-identical planning in `tests/retention_transition.rs`; authority-revalidated zero-mutation retry receipt in `tests/retention_publication_execution.rs`; exact already-committed filesystem retry with a byte-identical retention witness in `filesystem_retention_storage_tests`; superseded-candidate filesystem refusal with zero mutation in `filesystem_retention_successor_tests`; committed retry reopens the head-selected manifest entry and root pool bytes, refusing absent, changed, or corrupt evidence in `filesystem_retention_current_tests`; every refusal is a typed `RetentionCurrentStateRefusal` source, with superseded, committed-root-absent, committed-root-changed, and head-absent-with-artifacts pinned by downcast | Implemented | | `KEEP-RETENTION-010` | Model operation sequences agree with a deterministic namespace-to-anchor-set map and never admit caller identity, paths, clocks, or application policy | model-based and source-architecture tests | Planned in #19 | diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 334f250f..1e6b3087 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -91,6 +91,13 @@ mod transition_preflight_error; mod transition_readiness; mod verified_closure; +mod recovery_evidence; +mod recovery_plan; +mod recovery_planner; +#[cfg(test)] +mod recovery_planner_tests; +mod recovery_refusal; +mod recovery_stage_assessment; pub use admitted_manifest::AdmittedRetentionManifest; pub use admitted_root::AdmittedRetentionRoot; pub use canonical_head::CanonicalRetentionHead; @@ -118,6 +125,17 @@ pub use publication_preparation::prepare_retention_publication; pub use publication_preparation_error::RetentionPublicationPreparationError; pub use publication_receipt::RetentionPublicationReceipt; pub use publication_storage::RetentionPublicationStorage; +pub use recovery_evidence::{ + RetentionPoolEntryObservation, RetentionPoolObservations, RetentionRecoveryEvidence, + RetentionStageAssessments, +}; +pub use recovery_plan::{RetentionRecoveryOutcome, RetentionRecoveryPlan, RetentionRecoveryStep}; +pub use recovery_planner::plan_retention_recovery; +pub use recovery_refusal::{RetentionFixedStage, RetentionPool, RetentionRecoveryRefusal}; +pub use recovery_stage_assessment::{ + RetentionHeadStageAssessment, RetentionManifestStageAssessment, RetentionRootStageAssessment, + RetentionStageAssessment, assess_head_stage, assess_manifest_stage, assess_root_stage, +}; pub use root_decode_error::RetentionRootDecodeError; pub use root_encode_error::RetentionRootEncodeError; pub use transition_disposition::RetentionTransitionDisposition; diff --git a/src/adapters/retention/filesystem_retention_current.rs b/src/adapters/retention/filesystem_retention_current.rs index fd22ae2a..11793206 100644 --- a/src/adapters/retention/filesystem_retention_current.rs +++ b/src/adapters/retention/filesystem_retention_current.rs @@ -57,6 +57,23 @@ impl ObservedRetentionState { } } +#[cfg(test)] +impl ObservedRetentionState { + /// Builds the observed state from exact head and manifest bytes. + pub(super) fn for_tests(head: &[u8], manifest: &[u8]) -> io::Result { + let decoded = ChecksummedRetentionHead::decode(head) + .map_err(|source| RetentionCurrentStateRefusal::HeadRefused { source }.into_io())?; + let admitted = AdmittedRetentionManifest::decode(manifest) + .map_err(|source| RetentionCurrentStateRefusal::ManifestRefused { source }.into_io())?; + Ok(Self { + head: Box::from(head), + manifest: Box::from(manifest), + decoded_head: *decoded.head(), + decoded_manifest: admitted.manifest().clone(), + }) + } +} + /// The verified relationship between one preparation and the observed state. #[derive(Clone, Copy)] pub(super) enum ObservedDisposition<'state> { diff --git a/src/adapters/retention/recovery_evidence.rs b/src/adapters/retention/recovery_evidence.rs new file mode 100644 index 00000000..de12663f --- /dev/null +++ b/src/adapters/retention/recovery_evidence.rs @@ -0,0 +1,96 @@ +//! This module owns the complete evidence retention recovery plans from. + +use super::{ + ObservedRetentionState, RetentionHeadStageAssessment, RetentionManifestStageAssessment, + RetentionRootStageAssessment, +}; + +/// Whether an immutable pool already holds the entry a complete stage names. +/// +/// The observation is meaningful only for a `Complete` stage: a truncated or +/// corrupt stage names no canonical entry, and the adapter reports `Absent`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RetentionPoolEntryObservation { + /// No entry exists under the canonical name. + Absent, + /// The entry exists with exactly the stage's bytes. + Identical, + /// The entry exists with other bytes or another kind. + Different, +} + +/// The three fixed-stage assessments read at restart. +#[derive(Debug)] +pub struct RetentionStageAssessments<'bytes> { + /// Assessment of `retention/root.next`. + pub root: RetentionRootStageAssessment<'bytes>, + /// Assessment of `retention/manifest.next`. + pub manifest: RetentionManifestStageAssessment<'bytes>, + /// Assessment of `retention/head.next`. + pub head: RetentionHeadStageAssessment<'bytes>, +} + +/// Whether each immutable pool holds the entry its complete stage names. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RetentionPoolObservations { + /// The root pool entry the complete root stage names. + pub root: RetentionPoolEntryObservation, + /// The manifest pool entry the complete manifest stage names. + pub manifest: RetentionPoolEntryObservation, +} + +/// Everything restart observed before planning retention recovery. +/// +/// The evidence is read under exclusive writer authority and performs no +/// mutation; planning over it is pure. +#[derive(Debug)] +pub struct RetentionRecoveryEvidence<'bytes, 'state> { + current: Option<&'state ObservedRetentionState>, + stages: RetentionStageAssessments<'bytes>, + pools: RetentionPoolObservations, +} + +impl<'bytes, 'state> RetentionRecoveryEvidence<'bytes, 'state> { + /// Binds the observed current state, the three stage assessments, and the + /// pool observations for the entries the complete stages name. + #[must_use] + pub const fn new( + current: Option<&'state ObservedRetentionState>, + stages: RetentionStageAssessments<'bytes>, + pools: RetentionPoolObservations, + ) -> Self { + Self { + current, + stages, + pools, + } + } + + pub(super) fn into_parts( + self, + ) -> ( + Option<&'state ObservedRetentionState>, + RetentionStageAssessments<'bytes>, + RetentionPoolObservations, + ) { + (self.current, self.stages, self.pools) + } + + /// The published head and manifest, or `None` when no head is published. + #[must_use] + pub const fn current(&self) -> Option<&'state ObservedRetentionState> { + self.current + } + + /// The three stage assessments. + #[must_use] + pub const fn stages(&self) -> &RetentionStageAssessments<'bytes> { + &self.stages + } + + /// The pool observations for the entries the complete stages name. + #[must_use] + pub const fn pools(&self) -> RetentionPoolObservations { + self.pools + } +} diff --git a/src/adapters/retention/recovery_plan.rs b/src/adapters/retention/recovery_plan.rs new file mode 100644 index 00000000..6de46707 --- /dev/null +++ b/src/adapters/retention/recovery_plan.rs @@ -0,0 +1,70 @@ +//! This module owns the typed retention recovery plan and its outcome. + +/// One ordered recovery effect. Each maps to exactly one storage capability. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RetentionRecoveryStep { + /// Remove a truncated `head.next` whose replacement never happened. + DiscardHeadStage, + /// Remove a truncated `manifest.next` that was never linked. + DiscardManifestStage, + /// Remove a truncated `root.next` that was never linked. + DiscardRootStage, + /// Admit the namespace directory and link the complete root stage into it. + LinkRoot, + /// Link the complete manifest stage into the manifest pool. + LinkManifest, + /// Replace `retention/HEAD` with the complete head stage and synchronize. + FinalizeHead, + /// Remove the retained root stage after its pool link is proven. + RemoveRootStage, + /// Remove the retained manifest stage after its pool link is proven. + RemoveManifestStage, +} + +/// The state recovery leaves once every step has executed. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RetentionRecoveryOutcome { + /// No stage remains; forward publication may proceed. + Clean, + /// The staged generation is (or was already) the published head and its + /// stages are removed; forward publication may proceed. + Committed, + /// Complete stages remain linked and retained as valid orphans. They are + /// recovery-protected until explicit disposition; forward publication + /// refuses meanwhile. + Protected { + /// `root.next` remains retained. + root_stage: bool, + /// `manifest.next` remains retained. + manifest_stage: bool, + }, +} + +/// The ordered effects recovery must execute and the state they produce. +#[derive(Clone, Debug, Eq, PartialEq)] +#[must_use] +pub struct RetentionRecoveryPlan { + steps: Vec, + outcome: RetentionRecoveryOutcome, +} + +impl RetentionRecoveryPlan { + pub(super) const fn new( + steps: Vec, + outcome: RetentionRecoveryOutcome, + ) -> Self { + Self { steps, outcome } + } + + /// The effects in execution order; empty when nothing must change. + #[must_use] + pub fn steps(&self) -> &[RetentionRecoveryStep] { + &self.steps + } + + /// The state the store is in after every step executes. + #[must_use] + pub const fn outcome(&self) -> RetentionRecoveryOutcome { + self.outcome + } +} diff --git a/src/adapters/retention/recovery_planner.rs b/src/adapters/retention/recovery_planner.rs new file mode 100644 index 00000000..63e0d784 --- /dev/null +++ b/src/adapters/retention/recovery_planner.rs @@ -0,0 +1,283 @@ +//! This module owns pure planning of retention recovery from restart evidence. + +use super::{ + AdmittedRetentionManifest, AdmittedRetentionRoot, ChecksummedRetentionHead, + ObservedRetentionState, RetentionFixedStage, RetentionPool, + RetentionPoolEntryObservation as Pool, RetentionPoolObservations, RetentionRecoveryEvidence, + RetentionRecoveryOutcome, RetentionRecoveryPlan, RetentionRecoveryRefusal as Refusal, + RetentionRecoveryStep as Step, RetentionStageAssessment as Stage, +}; +use crate::{LivenessGeneration, RootGeneration}; + +type Current<'state> = Option<&'state ObservedRetentionState>; + +/// Plans retention recovery from complete restart evidence. +/// +/// The call performs no I/O. It applies the documented classification: a +/// truncated stage with no later-ordered effect is discarded; a complete +/// root or manifest stage is linked into its pool and retained as a +/// recovery-protected orphan; a complete head stage naming the staged +/// manifest is finalized and both retained stages are removed; a staged +/// generation the published head already names is cleaned up. Any other +/// combination is unrecoverable ambiguity and refuses before any effect. +/// +/// # Errors +/// +/// Returns [`RetentionRecoveryRefusal`](super::RetentionRecoveryRefusal) naming +/// the exact ambiguity. +pub fn plan_retention_recovery( + evidence: RetentionRecoveryEvidence<'_, '_>, +) -> Result { + let head_present = evidence.stages().head.is_present(); + let manifest_present = evidence.stages().manifest.is_present(); + let (current, stages, pools) = evidence.into_parts(); + let mut steps = Vec::new(); + let head = match stages.head { + Stage::Absent => None, + Stage::Truncated { .. } => { + steps.push(Step::DiscardHeadStage); + None + } + Stage::Corrupt(source) => return Err(Refusal::corrupt_head(source)), + Stage::Complete(head) => Some(head), + }; + let manifest = match stages.manifest { + Stage::Absent => None, + Stage::Truncated { .. } => { + if head_present || pools.manifest != Pool::Absent { + return Err(Refusal::TruncatedStageWithLaterEffect { + stage: RetentionFixedStage::Manifest, + }); + } + steps.push(Step::DiscardManifestStage); + None + } + Stage::Corrupt(source) => return Err(Refusal::corrupt_manifest(source)), + Stage::Complete(manifest) => Some(manifest), + }; + let root = match stages.root { + Stage::Absent => None, + Stage::Truncated { .. } => { + if head_present || manifest_present || pools.root != Pool::Absent { + return Err(Refusal::TruncatedStageWithLaterEffect { + stage: RetentionFixedStage::Root, + }); + } + steps.push(Step::DiscardRootStage); + None + } + Stage::Corrupt(source) => return Err(Refusal::corrupt_root(source)), + Stage::Complete(root) => Some(root), + }; + if root.is_some() && pools.root == Pool::Different { + return Err(Refusal::PoolEntryDiffers { + pool: RetentionPool::Roots, + }); + } + if manifest.is_some() && pools.manifest == Pool::Different { + return Err(Refusal::PoolEntryDiffers { + pool: RetentionPool::Manifests, + }); + } + match (head, manifest, root) { + (Some(head), Some(manifest), Some(root)) => finalize_head( + current, + CompleteStages { + head: &head, + manifest: &manifest, + root: &root, + }, + pools, + steps, + ), + (Some(_), _, _) => Err(Refusal::HeadStageWithoutManifestStage), + (None, Some(manifest), root) => { + plan_manifest(current, &manifest, root.as_ref(), pools, steps) + } + (None, None, Some(root)) => plan_root(current, &root, pools.root, steps), + (None, None, None) => Ok(RetentionRecoveryPlan::new( + steps, + RetentionRecoveryOutcome::Clean, + )), + } +} + +/// The three complete stages a head finalization is planned from. +#[derive(Clone, Copy)] +struct CompleteStages<'a, 'bytes> { + head: &'a ChecksummedRetentionHead<'bytes>, + manifest: &'a AdmittedRetentionManifest<'bytes>, + root: &'a AdmittedRetentionRoot<'bytes>, +} + +fn finalize_head( + current: Current<'_>, + stages: CompleteStages<'_, '_>, + pools: RetentionPoolObservations, + mut steps: Vec, +) -> Result { + let CompleteStages { + head, + manifest, + root, + } = stages; + let head = head.head(); + if head.manifest_digest() != manifest.digest() + || head.generation() != manifest.manifest().generation() + { + return Err(Refusal::HeadStageNamesOtherManifest); + } + if !manifest_names_root(manifest, root) { + return Err(Refusal::ManifestStageNamesOtherRoot); + } + if pools.root != Pool::Identical { + return Err(Refusal::RootNotLinkedBeforeHead); + } + if pools.manifest != Pool::Identical { + return Err(Refusal::ManifestNotLinkedBeforeHead); + } + if !is_committed(current, manifest) && !manifest_succeeds(current, manifest) { + return Err(Refusal::HeadPredecessorMismatch); + } + steps.extend([ + Step::FinalizeHead, + Step::RemoveRootStage, + Step::RemoveManifestStage, + ]); + Ok(RetentionRecoveryPlan::new( + steps, + RetentionRecoveryOutcome::Committed, + )) +} + +fn plan_manifest( + current: Current<'_>, + manifest: &AdmittedRetentionManifest<'_>, + root: Option<&AdmittedRetentionRoot<'_>>, + pools: RetentionPoolObservations, + mut steps: Vec, +) -> Result { + if is_committed(current, manifest) { + if let Some(root) = root { + if !manifest_names_root(manifest, root) { + return Err(Refusal::ManifestStageNamesOtherRoot); + } + if pools.root != Pool::Identical { + return Err(Refusal::RootNotLinkedBeforeHead); + } + steps.push(Step::RemoveRootStage); + } + steps.push(Step::RemoveManifestStage); + return Ok(RetentionRecoveryPlan::new( + steps, + RetentionRecoveryOutcome::Committed, + )); + } + let root = root.ok_or(Refusal::ManifestStageWithoutRootStage)?; + if !manifest_names_root(manifest, root) { + return Err(Refusal::ManifestStageNamesOtherRoot); + } + if !manifest_succeeds(current, manifest) { + return Err(Refusal::ManifestNotSuccessor); + } + if !root_succeeds(current, root) { + return Err(Refusal::RootNotSuccessor); + } + if pools.root == Pool::Absent { + steps.push(Step::LinkRoot); + } + if pools.manifest == Pool::Absent { + steps.push(Step::LinkManifest); + } + Ok(RetentionRecoveryPlan::new( + steps, + RetentionRecoveryOutcome::Protected { + root_stage: true, + manifest_stage: true, + }, + )) +} + +fn plan_root( + current: Current<'_>, + root: &AdmittedRetentionRoot<'_>, + root_pool: Pool, + mut steps: Vec, +) -> Result { + if !root_succeeds(current, root) { + return Err(Refusal::RootNotSuccessor); + } + if root_pool == Pool::Absent { + steps.push(Step::LinkRoot); + } + Ok(RetentionRecoveryPlan::new( + steps, + RetentionRecoveryOutcome::Protected { + root_stage: true, + manifest_stage: false, + }, + )) +} + +/// Whether the published head already names the staged manifest. +fn is_committed(current: Current<'_>, manifest: &AdmittedRetentionManifest<'_>) -> bool { + current.is_some_and(|current| { + current.head().manifest_digest() == manifest.digest() + && current.head().generation() == manifest.manifest().generation() + }) +} + +fn manifest_names_root( + manifest: &AdmittedRetentionManifest<'_>, + root: &AdmittedRetentionRoot<'_>, +) -> bool { + let namespace = root.root().namespace().digest(); + let entries = manifest.manifest().entries(); + entries + .binary_search_by_key(&namespace, |entry| entry.namespace()) + .ok() + .and_then(|index| entries.get(index)) + .is_some_and(|entry| { + entry.root_generation() == root.root().generation() + && entry.root_digest() == root.digest() + }) +} + +fn manifest_succeeds(current: Current<'_>, manifest: &AdmittedRetentionManifest<'_>) -> bool { + let manifest = manifest.manifest(); + current.map_or_else( + || manifest.predecessor().is_none() && manifest.generation() == LivenessGeneration::INITIAL, + |current| { + manifest.predecessor() == Some(current.head().manifest_digest()) + && current + .head() + .generation() + .successor() + .is_ok_and(|successor| successor == manifest.generation()) + }, + ) +} + +fn root_succeeds(current: Current<'_>, root: &AdmittedRetentionRoot<'_>) -> bool { + let namespace = root.root().namespace().digest(); + let entry = current.and_then(|current| { + let entries = current.manifest().entries(); + entries + .binary_search_by_key(&namespace, |entry| entry.namespace()) + .ok() + .and_then(|index| entries.get(index).copied()) + }); + entry.map_or_else( + || { + root.root().predecessor().is_none() + && root.root().generation() == RootGeneration::INITIAL + }, + |entry| { + root.root().predecessor() == Some(entry.root_digest()) + && entry + .root_generation() + .successor() + .is_ok_and(|successor| successor == root.root().generation()) + }, + ) +} diff --git a/src/adapters/retention/recovery_planner_tests.rs b/src/adapters/retention/recovery_planner_tests.rs new file mode 100644 index 00000000..2dba6d27 --- /dev/null +++ b/src/adapters/retention/recovery_planner_tests.rs @@ -0,0 +1,356 @@ +//! Retention recovery planning laws over the golden version-two records. + +use std::error::Error; + +use super::filesystem_retention_test_fixture::{ + HEAD_HEX, MANIFEST_HEX, ROOT_HEX, fixture, initial_preparation, successor_preparation, +}; +use super::{ + AdmittedRetentionManifest, AdmittedRetentionRoot, ObservedRetentionState, RetentionFixedStage, + RetentionPool, RetentionPoolEntryObservation as Pool, RetentionPoolObservations, + RetentionRecoveryEvidence, RetentionRecoveryOutcome as Outcome, RetentionRecoveryRefusal, + RetentionRecoveryStep as Step, RetentionStageAssessments, assess_head_stage, + assess_manifest_stage, assess_root_stage, plan_retention_recovery, +}; + +struct Records { + root: Vec, + manifest: Vec, + head: Vec, +} + +fn generation_one() -> Result> { + Ok(Records { + root: fixture(ROOT_HEX)?, + manifest: fixture(MANIFEST_HEX)?, + head: fixture(HEAD_HEX)?, + }) +} + +type StageBytes<'b> = (Option<&'b [u8]>, Option<&'b [u8]>, Option<&'b [u8]>); + +fn evidence<'b, 's>( + current: Option<&'s ObservedRetentionState>, + (root, manifest, head): StageBytes<'b>, + (root_pool, manifest_pool): (Pool, Pool), +) -> RetentionRecoveryEvidence<'b, 's> { + RetentionRecoveryEvidence::new( + current, + RetentionStageAssessments { + root: assess_root_stage(root), + manifest: assess_manifest_stage(manifest), + head: assess_head_stage(head), + }, + RetentionPoolObservations { + root: root_pool, + manifest: manifest_pool, + }, + ) +} + +fn corrupt(bytes: &[u8]) -> Vec { + let mut bytes = bytes.to_vec(); + if let Some(last) = bytes.last_mut() { + *last ^= 0x01; + } + bytes +} + +#[test] +fn a_clean_store_needs_nothing() -> Result<(), Box> { + let plan = plan_retention_recovery(evidence( + None, + (None, None, None), + (Pool::Absent, Pool::Absent), + ))?; + assert!(plan.steps().is_empty()); + assert_eq!(plan.outcome(), Outcome::Clean); + Ok(()) +} + +#[test] +fn a_truncated_root_stage_with_no_later_effect_is_discarded() -> Result<(), Box> { + let records = generation_one()?; + let partial = records + .root + .get(..100) + .ok_or("root fixture shorter than 100 bytes")?; + let plan = plan_retention_recovery(evidence( + None, + (Some(partial), None, None), + (Pool::Absent, Pool::Absent), + ))?; + assert_eq!(plan.steps(), [Step::DiscardRootStage]); + assert_eq!(plan.outcome(), Outcome::Clean); + Ok(()) +} + +#[test] +fn a_truncated_stage_with_a_later_effect_refuses() -> Result<(), Box> { + let records = generation_one()?; + let partial = records + .manifest + .get(..100) + .ok_or("manifest fixture shorter than 100 bytes")?; + let error = plan_retention_recovery(evidence( + None, + (Some(&records.root), Some(partial), None), + (Pool::Identical, Pool::Identical), + )) + .err() + .ok_or("a truncated manifest with a pool link was discarded")?; + assert!(matches!( + error, + RetentionRecoveryRefusal::TruncatedStageWithLaterEffect { + stage: RetentionFixedStage::Manifest + } + )); + Ok(()) +} + +#[test] +fn a_corrupt_stage_refuses_with_its_decode_error() -> Result<(), Box> { + let records = generation_one()?; + let corrupt_root = corrupt(&records.root); + let error = plan_retention_recovery(evidence( + None, + (Some(&corrupt_root), None, None), + (Pool::Absent, Pool::Absent), + )) + .err() + .ok_or("a corrupt root stage was planned")?; + assert!(matches!( + error, + RetentionRecoveryRefusal::StageCorrupt { + stage: RetentionFixedStage::Root, + .. + } + )); + assert!(error.source().is_some()); + Ok(()) +} + +#[test] +fn a_complete_root_stage_is_linked_and_protected() -> Result<(), Box> { + let records = generation_one()?; + let unlinked = plan_retention_recovery(evidence( + None, + (Some(&records.root), None, None), + (Pool::Absent, Pool::Absent), + ))?; + let linked = plan_retention_recovery(evidence( + None, + (Some(&records.root), None, None), + (Pool::Identical, Pool::Absent), + ))?; + let differs = plan_retention_recovery(evidence( + None, + (Some(&records.root), None, None), + (Pool::Different, Pool::Absent), + )) + .err() + .ok_or("a conflicting root pool entry was planned over")?; + assert_eq!(unlinked.steps(), [Step::LinkRoot]); + assert!(linked.steps().is_empty()); + for plan in [&unlinked, &linked] { + assert_eq!( + plan.outcome(), + Outcome::Protected { + root_stage: true, + manifest_stage: false + } + ); + } + assert!(matches!( + differs, + RetentionRecoveryRefusal::PoolEntryDiffers { + pool: RetentionPool::Roots + } + )); + Ok(()) +} + +#[test] +fn complete_root_and_manifest_stages_are_linked_and_protected() -> Result<(), Box> { + let records = generation_one()?; + let plan = plan_retention_recovery(evidence( + None, + (Some(&records.root), Some(&records.manifest), None), + (Pool::Absent, Pool::Absent), + ))?; + assert_eq!(plan.steps(), [Step::LinkRoot, Step::LinkManifest]); + assert_eq!( + plan.outcome(), + Outcome::Protected { + root_stage: true, + manifest_stage: true + } + ); + Ok(()) +} + +#[test] +fn a_complete_head_stage_over_linked_stages_is_finalized() -> Result<(), Box> { + let records = generation_one()?; + let plan = plan_retention_recovery(evidence( + None, + ( + Some(&records.root), + Some(&records.manifest), + Some(&records.head), + ), + (Pool::Identical, Pool::Identical), + ))?; + assert_eq!( + plan.steps(), + [ + Step::FinalizeHead, + Step::RemoveRootStage, + Step::RemoveManifestStage + ] + ); + assert_eq!(plan.outcome(), Outcome::Committed); + let unlinked = plan_retention_recovery(evidence( + None, + ( + Some(&records.root), + Some(&records.manifest), + Some(&records.head), + ), + (Pool::Absent, Pool::Identical), + )) + .err() + .ok_or("a head stage over an unlinked root was finalized")?; + assert!(matches!( + unlinked, + RetentionRecoveryRefusal::RootNotLinkedBeforeHead + )); + Ok(()) +} + +#[test] +fn a_truncated_head_stage_is_discarded_and_the_orphans_stay_protected() -> Result<(), Box> +{ + let records = generation_one()?; + let partial = records + .head + .get(..40) + .ok_or("head fixture shorter than 40 bytes")?; + let plan = plan_retention_recovery(evidence( + None, + (Some(&records.root), Some(&records.manifest), Some(partial)), + (Pool::Identical, Pool::Identical), + ))?; + assert_eq!(plan.steps(), [Step::DiscardHeadStage]); + assert_eq!( + plan.outcome(), + Outcome::Protected { + root_stage: true, + manifest_stage: true + } + ); + Ok(()) +} + +#[test] +fn a_head_stage_without_the_staged_manifest_refuses() -> Result<(), Box> { + let records = generation_one()?; + let error = plan_retention_recovery(evidence( + None, + (Some(&records.root), None, Some(&records.head)), + (Pool::Identical, Pool::Absent), + )) + .err() + .ok_or("a head stage without a manifest stage was finalized")?; + assert!(matches!( + error, + RetentionRecoveryRefusal::HeadStageWithoutManifestStage + )); + Ok(()) +} + +#[test] +fn stages_the_published_head_already_names_are_cleaned_up() -> Result<(), Box> { + let records = generation_one()?; + let current = ObservedRetentionState::for_tests(&records.head, &records.manifest)?; + let both = plan_retention_recovery(evidence( + Some(¤t), + (Some(&records.root), Some(&records.manifest), None), + (Pool::Identical, Pool::Identical), + ))?; + let manifest_only = plan_retention_recovery(evidence( + Some(¤t), + (None, Some(&records.manifest), None), + (Pool::Absent, Pool::Identical), + ))?; + let head_too = plan_retention_recovery(evidence( + Some(¤t), + ( + Some(&records.root), + Some(&records.manifest), + Some(&records.head), + ), + (Pool::Identical, Pool::Identical), + ))?; + assert_eq!( + both.steps(), + [Step::RemoveRootStage, Step::RemoveManifestStage] + ); + assert_eq!(manifest_only.steps(), [Step::RemoveManifestStage]); + assert_eq!( + head_too.steps(), + [ + Step::FinalizeHead, + Step::RemoveRootStage, + Step::RemoveManifestStage + ] + ); + for plan in [&both, &manifest_only, &head_too] { + assert_eq!(plan.outcome(), Outcome::Committed); + } + Ok(()) +} + +#[test] +fn a_successor_generation_is_planned_against_the_published_state() -> Result<(), Box> { + let records = generation_one()?; + let current = ObservedRetentionState::for_tests(&records.head, &records.manifest)?; + let current_root = AdmittedRetentionRoot::decode(&records.root)?; + let current_manifest = AdmittedRetentionManifest::decode(&records.manifest)?; + let candidate = super::filesystem_retention_test_fixture::successor_root(¤t_root)?; + let preparation = successor_preparation(¤t_root, ¤t_manifest, candidate.encoded())?; + let publication = preparation + .publication() + .ok_or("successor preparation carries no publication")?; + let manifest = publication.manifest().encoded().to_vec(); + let head = publication.head().encoded().to_vec(); + + let finalize = plan_retention_recovery(evidence( + Some(¤t), + (Some(candidate.encoded()), Some(&manifest), Some(&head)), + (Pool::Identical, Pool::Identical), + ))?; + let stale = plan_retention_recovery(evidence( + None, + (Some(candidate.encoded()), Some(&manifest), None), + (Pool::Absent, Pool::Absent), + )) + .err() + .ok_or("a successor manifest over an absent head was planned")?; + + assert_eq!( + finalize.steps(), + [ + Step::FinalizeHead, + Step::RemoveRootStage, + Step::RemoveManifestStage + ] + ); + assert_eq!(finalize.outcome(), Outcome::Committed); + assert!(matches!( + stale, + RetentionRecoveryRefusal::ManifestNotSuccessor + )); + drop(initial_preparation(&records.root)?); + Ok(()) +} diff --git a/src/adapters/retention/recovery_refusal.rs b/src/adapters/retention/recovery_refusal.rs new file mode 100644 index 00000000..449b394b --- /dev/null +++ b/src/adapters/retention/recovery_refusal.rs @@ -0,0 +1,174 @@ +//! This module owns the typed refusals of retention recovery planning. + +use std::error::Error; +use std::fmt; + +use super::{RetentionHeadDecodeError, RetentionManifestDecodeError, RetentionRootDecodeError}; + +/// One of the three fixed retention stage names. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RetentionFixedStage { + /// `retention/root.next`. + Root, + /// `retention/manifest.next`. + Manifest, + /// `retention/head.next`. + Head, +} + +/// One of the two immutable retention pools. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RetentionPool { + /// `retention/roots/`. + Roots, + /// `retention/manifests`. + Manifests, +} + +/// Why the observed stages are unrecoverable ambiguity rather than a plan. +/// +/// Every variant leaves the store untouched: recovery refuses before any +/// effect, and the evidence stays for explicit disposition. +#[derive(Debug)] +#[non_exhaustive] +pub enum RetentionRecoveryRefusal { + /// A stage is complete enough to judge and fails a canonical law. + StageCorrupt { + /// The stage that failed. + stage: RetentionFixedStage, + /// The exact decode refusal. + source: Box, + }, + /// A truncated stage has a later-ordered effect, so it is not pre-effect. + TruncatedStageWithLaterEffect { + /// The truncated stage. + stage: RetentionFixedStage, + }, + /// A complete stage names a pool entry that exists with other bytes. + PoolEntryDiffers { + /// The pool holding the conflicting entry. + pool: RetentionPool, + }, + /// A complete head stage exists without a complete manifest stage. + HeadStageWithoutManifestStage, + /// The head stage names a manifest other than the staged one. + HeadStageNamesOtherManifest, + /// The head stage's predecessor is not the published manifest. + HeadPredecessorMismatch, + /// The head stage exists but the staged manifest was never linked. + ManifestNotLinkedBeforeHead, + /// The head stage exists but the staged root was never linked. + RootNotLinkedBeforeHead, + /// A complete manifest stage exists without the root stage it introduces + /// and is not the published manifest. + ManifestStageWithoutRootStage, + /// The manifest stage does not select the staged root. + ManifestStageNamesOtherRoot, + /// The manifest stage is not the exact successor of the published manifest. + ManifestNotSuccessor, + /// The root stage is not the exact successor of the namespace's current root. + RootNotSuccessor, +} + +impl RetentionRecoveryRefusal { + pub(super) fn corrupt_root(source: RetentionRootDecodeError) -> Self { + Self::StageCorrupt { + stage: RetentionFixedStage::Root, + source: Box::new(source), + } + } + + pub(super) fn corrupt_manifest(source: RetentionManifestDecodeError) -> Self { + Self::StageCorrupt { + stage: RetentionFixedStage::Manifest, + source: Box::new(source), + } + } + + pub(super) fn corrupt_head(source: RetentionHeadDecodeError) -> Self { + Self::StageCorrupt { + stage: RetentionFixedStage::Head, + source: Box::new(source), + } + } +} + +impl fmt::Display for RetentionFixedStage { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Root => "root.next", + Self::Manifest => "manifest.next", + Self::Head => "head.next", + }) + } +} + +impl fmt::Display for RetentionPool { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Roots => "retention/roots", + Self::Manifests => "retention/manifests", + }) + } +} + +impl fmt::Display for RetentionRecoveryRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::StageCorrupt { stage, .. } => { + write!(formatter, "retention stage {stage} is corrupt") + } + Self::TruncatedStageWithLaterEffect { stage } => write!( + formatter, + "truncated retention stage {stage} has a later-ordered effect" + ), + Self::PoolEntryDiffers { pool } => { + write!( + formatter, + "{pool} holds a different entry under the staged name" + ) + } + other => formatter.write_str(other.message()), + } + } +} + +impl RetentionRecoveryRefusal { + const fn message(&self) -> &'static str { + match self { + Self::HeadStageWithoutManifestStage => { + "head.next exists without a complete manifest.next" + } + Self::HeadStageNamesOtherManifest => { + "head.next names a manifest other than manifest.next" + } + Self::HeadPredecessorMismatch => "head.next does not succeed the published manifest", + Self::ManifestNotLinkedBeforeHead => { + "head.next exists but manifest.next was never linked" + } + Self::RootNotLinkedBeforeHead => "head.next exists but root.next was never linked", + Self::ManifestStageWithoutRootStage => { + "manifest.next exists without root.next and is not the published manifest" + } + Self::ManifestStageNamesOtherRoot => "manifest.next does not select root.next", + Self::ManifestNotSuccessor => { + "manifest.next is not the successor of the published manifest" + } + Self::RootNotSuccessor => { + "root.next is not the successor of its namespace's current root" + } + Self::StageCorrupt { .. } + | Self::TruncatedStageWithLaterEffect { .. } + | Self::PoolEntryDiffers { .. } => "retention recovery refused", + } + } +} + +impl Error for RetentionRecoveryRefusal { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::StageCorrupt { source, .. } => Some(source.as_ref()), + _ => None, + } + } +} diff --git a/src/adapters/retention/recovery_stage_assessment.rs b/src/adapters/retention/recovery_stage_assessment.rs new file mode 100644 index 00000000..3fb7d29e --- /dev/null +++ b/src/adapters/retention/recovery_stage_assessment.rs @@ -0,0 +1,92 @@ +//! This module owns restart assessment of the three fixed retention stages. + +use super::{ + AdmittedRetentionManifest, AdmittedRetentionRoot, ChecksummedRetentionHead, + RetentionHeadDecodeError, RetentionManifestDecodeError, RetentionRootDecodeError, +}; + +/// One fixed retention stage as assessed from its exact bytes at restart. +/// +/// `Truncated` means the bytes end before the boundary the record's own +/// framing declares, which is the shape a crash during the stage write leaves +/// behind. Every other decode failure is `Corrupt`: a complete-looking record +/// that fails a checksum, digest, or semantic law is unrecoverable ambiguity, +/// never an incomplete write. +#[derive(Debug)] +pub enum RetentionStageAssessment { + /// No entry exists under the stage name. + Absent, + /// The bytes decode as one canonical record. + Complete(Record), + /// The bytes end before the declared record boundary. + Truncated { + /// The length the framing declares. + expected: usize, + /// The length that was present. + observed: usize, + }, + /// The bytes are complete enough to judge and fail a canonical law. + Corrupt(Error), +} + +/// Assessment of `retention/root.next`. +pub type RetentionRootStageAssessment<'bytes> = + RetentionStageAssessment, RetentionRootDecodeError>; +/// Assessment of `retention/manifest.next`. +pub type RetentionManifestStageAssessment<'bytes> = + RetentionStageAssessment, RetentionManifestDecodeError>; +/// Assessment of `retention/head.next`. +pub type RetentionHeadStageAssessment<'bytes> = + RetentionStageAssessment, RetentionHeadDecodeError>; + +impl RetentionStageAssessment { + /// Returns whether an entry exists under the stage name. + #[must_use] + pub const fn is_present(&self) -> bool { + !matches!(self, Self::Absent) + } +} + +/// Assesses the bytes found under `retention/root.next`, if any. +#[must_use] +pub fn assess_root_stage(bytes: Option<&[u8]>) -> RetentionRootStageAssessment<'_> { + match bytes.map(AdmittedRetentionRoot::decode) { + None => RetentionStageAssessment::Absent, + Some(Ok(root)) => RetentionStageAssessment::Complete(root), + Some(Err(RetentionRootDecodeError::Truncated { expected, observed })) => { + RetentionStageAssessment::Truncated { expected, observed } + } + Some(Err(source)) => RetentionStageAssessment::Corrupt(source), + } +} + +/// Assesses the bytes found under `retention/manifest.next`, if any. +#[must_use] +pub fn assess_manifest_stage(bytes: Option<&[u8]>) -> RetentionManifestStageAssessment<'_> { + match bytes.map(AdmittedRetentionManifest::decode) { + None => RetentionStageAssessment::Absent, + Some(Ok(manifest)) => RetentionStageAssessment::Complete(manifest), + Some(Err(RetentionManifestDecodeError::Truncated { expected, observed })) => { + RetentionStageAssessment::Truncated { expected, observed } + } + Some(Err(source)) => RetentionStageAssessment::Corrupt(source), + } +} + +/// Assesses the bytes found under `retention/head.next`, if any. +/// +/// The head is one fixed 144-byte record, so fewer bytes are a truncation and +/// more bytes are corruption. +#[must_use] +pub fn assess_head_stage(bytes: Option<&[u8]>) -> RetentionHeadStageAssessment<'_> { + match bytes.map(ChecksummedRetentionHead::decode) { + None => RetentionStageAssessment::Absent, + Some(Ok(head)) => RetentionStageAssessment::Complete(head), + Some(Err(RetentionHeadDecodeError::WrongLength { expected, observed })) + if observed < expected => + { + RetentionStageAssessment::Truncated { expected, observed } + } + Some(Err(source)) => RetentionStageAssessment::Corrupt(source), + } +} diff --git a/src/lib.rs b/src/lib.rs index 6611c89b..f07545de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -137,15 +137,21 @@ pub use adapters::{ CanonicalRetentionManifest, CanonicalRetentionRoot, ChecksummedRetentionHead, FilesystemRetentionAuthorityError, FilesystemRetentionPublicationAuthority, ObservedRetentionState, PreparedRetentionPublication, RetentionAuthorityDirectory, - RetentionClosureVerificationError, RetentionCurrentStateRefusal, RetentionHeadDecodeError, - RetentionManifestDecodeError, RetentionManifestEncodeError, RetentionNamespaceAdmission, + RetentionClosureVerificationError, RetentionCurrentStateRefusal, RetentionFixedStage, + RetentionHeadDecodeError, RetentionHeadStageAssessment, RetentionManifestDecodeError, + RetentionManifestEncodeError, RetentionManifestStageAssessment, RetentionNamespaceAdmission, + RetentionPool, RetentionPoolEntryObservation, RetentionPoolObservations, RetentionPublicationError, RetentionPublicationOutcome, RetentionPublicationPhase, RetentionPublicationPreparation, RetentionPublicationPreparationError, - RetentionPublicationReceipt, RetentionPublicationStorage, RetentionRootDecodeError, - RetentionRootEncodeError, RetentionTransitionDisposition, RetentionTransitionError, - RetentionTransitionPreflight, RetentionTransitionPreflightError, RetentionTransitionReadiness, - VerifiedRetentionClosure, execute_retention_publication, plan_retention_transition, - preflight_retention_transition, prepare_retention_publication, verify_retention_closure, + RetentionPublicationReceipt, RetentionPublicationStorage, RetentionRecoveryEvidence, + RetentionRecoveryOutcome, RetentionRecoveryPlan, RetentionRecoveryRefusal, + RetentionRecoveryStep, RetentionRootDecodeError, RetentionRootEncodeError, + RetentionRootStageAssessment, RetentionStageAssessment, RetentionStageAssessments, + RetentionTransitionDisposition, RetentionTransitionError, RetentionTransitionPreflight, + RetentionTransitionPreflightError, RetentionTransitionReadiness, VerifiedRetentionClosure, + assess_head_stage, assess_manifest_stage, assess_root_stage, execute_retention_publication, + plan_retention_recovery, plan_retention_transition, preflight_retention_transition, + prepare_retention_publication, verify_retention_closure, }; pub use blob::{ BlobHashError, BlobHasher, BlobId, BlobLength, BlobReadError, ByteLength, ByteOffset, From 48c999880208775a6df50248cd7fe2e24aa6ddaf Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 9 Sep 2026 12:04:50 -0700 Subject: [PATCH 02/59] Add: execute a retention recovery plan through a blocking storage port RetentionRecoveryStorage names one durable capability per recovery step: discard a truncated stage, link a complete root or manifest stage into its pool, finalize the head, and remove a retained stage after its link is proven. Each capability owns its complete effect and the synchronization that makes it durable, so an implementation cannot report a step done before its evidence would survive process death. execute_retention_recovery runs a plan in order, calling exactly one capability per step, and stops at the first refusal with the refused step, the completed prefix, and the storage's error as source; the caller re-observes and re-plans rather than continuing from stale evidence. The receipt records the executed steps and the plan's outcome. Three laws against a recording fake storage pin the mapping, the empty plan, and the refusal prefix. Refs #19 --- CHANGELOG.md | 6 +- src/adapters/retention.rs | 8 ++ src/adapters/retention/recovery_execution.rs | 112 +++++++++++++++++ .../retention/recovery_execution_tests.rs | 113 ++++++++++++++++++ src/adapters/retention/recovery_storage.rs | 72 +++++++++++ src/lib.rs | 11 +- 6 files changed, 315 insertions(+), 7 deletions(-) create mode 100644 src/adapters/retention/recovery_execution.rs create mode 100644 src/adapters/retention/recovery_execution_tests.rs create mode 100644 src/adapters/retention/recovery_storage.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3561f7a7..3f8865c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,10 @@ after its public API and format compatibility policies are established. `RetentionRecoveryPlan` (discard a pre-effect truncated stage, link and protect complete orphans, finalize a complete head over linked stages, clean up stages the published head already names) or a typed - `RetentionRecoveryRefusal`. Planning performs no I/O; storage execution is - the next step. + `RetentionRecoveryRefusal`. `RetentionRecoveryStorage` names one blocking + capability per step and `execute_retention_recovery` runs a plan in order, + stopping at the first refused step with the completed prefix named in + `RetentionRecoveryError`; the filesystem implementation is the next step. - `FilesystemRetentionPublicationAuthority` executes the 17 ordered retention publication phases against a completely migrated version-2 root. It stages `root.next`, `manifest.next`, and `head.next` exclusively, verifies device diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 1e6b3087..5a75baf3 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -92,12 +92,16 @@ mod transition_readiness; mod verified_closure; mod recovery_evidence; +mod recovery_execution; +#[cfg(test)] +mod recovery_execution_tests; mod recovery_plan; mod recovery_planner; #[cfg(test)] mod recovery_planner_tests; mod recovery_refusal; mod recovery_stage_assessment; +mod recovery_storage; pub use admitted_manifest::AdmittedRetentionManifest; pub use admitted_root::AdmittedRetentionRoot; pub use canonical_head::CanonicalRetentionHead; @@ -129,6 +133,9 @@ pub use recovery_evidence::{ RetentionPoolEntryObservation, RetentionPoolObservations, RetentionRecoveryEvidence, RetentionStageAssessments, }; +pub use recovery_execution::{ + RetentionRecoveryError, RetentionRecoveryReceipt, execute_retention_recovery, +}; pub use recovery_plan::{RetentionRecoveryOutcome, RetentionRecoveryPlan, RetentionRecoveryStep}; pub use recovery_planner::plan_retention_recovery; pub use recovery_refusal::{RetentionFixedStage, RetentionPool, RetentionRecoveryRefusal}; @@ -136,6 +143,7 @@ pub use recovery_stage_assessment::{ RetentionHeadStageAssessment, RetentionManifestStageAssessment, RetentionRootStageAssessment, RetentionStageAssessment, assess_head_stage, assess_manifest_stage, assess_root_stage, }; +pub use recovery_storage::RetentionRecoveryStorage; pub use root_decode_error::RetentionRootDecodeError; pub use root_encode_error::RetentionRootEncodeError; pub use transition_disposition::RetentionTransitionDisposition; diff --git a/src/adapters/retention/recovery_execution.rs b/src/adapters/retention/recovery_execution.rs new file mode 100644 index 00000000..813217a5 --- /dev/null +++ b/src/adapters/retention/recovery_execution.rs @@ -0,0 +1,112 @@ +//! This module owns ordered execution of one retention recovery plan. + +use std::error::Error; +use std::fmt; +use std::io; + +use super::{ + RetentionRecoveryOutcome, RetentionRecoveryPlan, RetentionRecoveryStep, + RetentionRecoveryStorage, +}; + +/// The complete record of one executed retention recovery plan. +#[derive(Clone, Debug, Eq, PartialEq)] +#[must_use] +pub struct RetentionRecoveryReceipt { + executed: Vec, + outcome: RetentionRecoveryOutcome, +} + +impl RetentionRecoveryReceipt { + /// Every step that executed, in order. + #[must_use] + pub fn executed(&self) -> &[RetentionRecoveryStep] { + &self.executed + } + + /// The state the store is in now. + #[must_use] + pub const fn outcome(&self) -> RetentionRecoveryOutcome { + self.outcome + } +} + +/// One refused recovery step and the steps that completed before it. +#[derive(Debug)] +pub struct RetentionRecoveryError { + step: RetentionRecoveryStep, + executed: Vec, + source: io::Error, +} + +impl RetentionRecoveryError { + /// The step that refused. + #[must_use] + pub const fn step(&self) -> RetentionRecoveryStep { + self.step + } + + /// Every step that completed before the refusal, in order. + #[must_use] + pub fn executed(&self) -> &[RetentionRecoveryStep] { + &self.executed + } +} + +impl fmt::Display for RetentionRecoveryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "retention recovery step {:?} refused after {} completed step(s)", + self.step, + self.executed.len() + ) + } +} + +impl Error for RetentionRecoveryError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(&self.source) + } +} + +/// Executes `plan` against `storage` in order, stopping at the first refusal. +/// +/// Each step calls exactly one storage capability. A refused step leaves the +/// completed steps' effects in place, names the step, and returns; the caller +/// re-observes and re-plans rather than continuing from stale evidence. +/// +/// # Errors +/// +/// Returns [`RetentionRecoveryError`] with the refused step, the completed +/// steps, and the storage's own error as source. +pub fn execute_retention_recovery( + storage: &mut S, + plan: &RetentionRecoveryPlan, +) -> Result { + let mut executed = Vec::with_capacity(plan.steps().len()); + for &step in plan.steps() { + let result = match step { + RetentionRecoveryStep::DiscardHeadStage => storage.discard_head_stage(), + RetentionRecoveryStep::DiscardManifestStage => storage.discard_manifest_stage(), + RetentionRecoveryStep::DiscardRootStage => storage.discard_root_stage(), + RetentionRecoveryStep::LinkRoot => storage.link_root(), + RetentionRecoveryStep::LinkManifest => storage.link_manifest(), + RetentionRecoveryStep::FinalizeHead => storage.finalize_head(), + RetentionRecoveryStep::RemoveRootStage => storage.remove_root_stage(), + RetentionRecoveryStep::RemoveManifestStage => storage.remove_manifest_stage(), + }; + if let Err(source) = result { + return Err(RetentionRecoveryError { + step, + executed, + source, + }); + } + executed.push(step); + } + Ok(RetentionRecoveryReceipt { + executed, + outcome: plan.outcome(), + }) +} diff --git a/src/adapters/retention/recovery_execution_tests.rs b/src/adapters/retention/recovery_execution_tests.rs new file mode 100644 index 00000000..d68ac800 --- /dev/null +++ b/src/adapters/retention/recovery_execution_tests.rs @@ -0,0 +1,113 @@ +//! Retention recovery execution laws against a recording fake storage. + +use std::error::Error; +use std::io; + +use super::{ + RetentionRecoveryError, RetentionRecoveryOutcome, RetentionRecoveryPlan, + RetentionRecoveryStep as Step, RetentionRecoveryStorage, execute_retention_recovery, +}; + +#[derive(Default)] +struct Recording { + calls: Vec, + refuse_at: Option, +} + +impl Recording { + fn record(&mut self, step: Step) -> io::Result<()> { + if self.refuse_at == Some(step) { + return Err(io::Error::other("injected refusal")); + } + self.calls.push(step); + Ok(()) + } +} + +impl RetentionRecoveryStorage for Recording { + fn discard_head_stage(&mut self) -> io::Result<()> { + self.record(Step::DiscardHeadStage) + } + fn discard_manifest_stage(&mut self) -> io::Result<()> { + self.record(Step::DiscardManifestStage) + } + fn discard_root_stage(&mut self) -> io::Result<()> { + self.record(Step::DiscardRootStage) + } + fn link_root(&mut self) -> io::Result<()> { + self.record(Step::LinkRoot) + } + fn link_manifest(&mut self) -> io::Result<()> { + self.record(Step::LinkManifest) + } + fn finalize_head(&mut self) -> io::Result<()> { + self.record(Step::FinalizeHead) + } + fn remove_root_stage(&mut self) -> io::Result<()> { + self.record(Step::RemoveRootStage) + } + fn remove_manifest_stage(&mut self) -> io::Result<()> { + self.record(Step::RemoveManifestStage) + } +} + +const FINALIZE: [Step; 3] = [ + Step::FinalizeHead, + Step::RemoveRootStage, + Step::RemoveManifestStage, +]; + +#[test] +fn every_step_calls_exactly_its_capability_in_plan_order() -> Result<(), Box> { + let all = [ + Step::DiscardHeadStage, + Step::DiscardManifestStage, + Step::DiscardRootStage, + Step::LinkRoot, + Step::LinkManifest, + Step::FinalizeHead, + Step::RemoveRootStage, + Step::RemoveManifestStage, + ]; + let plan = RetentionRecoveryPlan::new(all.to_vec(), RetentionRecoveryOutcome::Committed); + let mut storage = Recording::default(); + + let receipt = execute_retention_recovery(&mut storage, &plan)?; + + assert_eq!(storage.calls, all); + assert_eq!(receipt.executed(), all); + assert_eq!(receipt.outcome(), RetentionRecoveryOutcome::Committed); + Ok(()) +} + +#[test] +fn an_empty_plan_touches_nothing_and_reports_its_outcome() -> Result<(), Box> { + let plan = RetentionRecoveryPlan::new(Vec::new(), RetentionRecoveryOutcome::Clean); + let mut storage = Recording::default(); + + let receipt = execute_retention_recovery(&mut storage, &plan)?; + + assert!(storage.calls.is_empty()); + assert!(receipt.executed().is_empty()); + assert_eq!(receipt.outcome(), RetentionRecoveryOutcome::Clean); + Ok(()) +} + +#[test] +fn a_refused_step_stops_execution_and_names_the_completed_prefix() -> Result<(), Box> { + let plan = RetentionRecoveryPlan::new(FINALIZE.to_vec(), RetentionRecoveryOutcome::Committed); + let mut storage = Recording { + calls: Vec::new(), + refuse_at: Some(Step::RemoveRootStage), + }; + + let error: RetentionRecoveryError = execute_retention_recovery(&mut storage, &plan) + .err() + .ok_or("an injected refusal was reported as success")?; + + assert_eq!(error.step(), Step::RemoveRootStage); + assert_eq!(error.executed(), [Step::FinalizeHead]); + assert_eq!(storage.calls, [Step::FinalizeHead]); + assert!(error.source().is_some()); + Ok(()) +} diff --git a/src/adapters/retention/recovery_storage.rs b/src/adapters/retention/recovery_storage.rs new file mode 100644 index 00000000..5404642b --- /dev/null +++ b/src/adapters/retention/recovery_storage.rs @@ -0,0 +1,72 @@ +//! This module owns the blocking storage capability port for retention recovery. + +use std::io; + +/// Durable capabilities retention recovery executes, one per plan step. +/// +/// Each capability owns its complete effect and the synchronization that makes +/// it durable, so an implementation cannot report a step as done before its +/// evidence would survive process death. Every capability is called at most +/// once per plan, in plan order, and never after a refused capability. +pub trait RetentionRecoveryStorage { + /// Removes a truncated `head.next` and synchronizes `retention`. + /// + /// # Errors + /// + /// Returns the exact filesystem failure; the stage must remain when it fails. + fn discard_head_stage(&mut self) -> io::Result<()>; + + /// Removes a truncated, never linked `manifest.next` and synchronizes `retention`. + /// + /// # Errors + /// + /// Returns the exact filesystem failure; the stage must remain when it fails. + fn discard_manifest_stage(&mut self) -> io::Result<()>; + + /// Removes a truncated, never linked `root.next` and synchronizes `retention`. + /// + /// # Errors + /// + /// Returns the exact filesystem failure; the stage must remain when it fails. + fn discard_root_stage(&mut self) -> io::Result<()>; + + /// Admits the staged root's namespace directory, links the complete root + /// stage into it without replacement, and synchronizes both directories. + /// + /// # Errors + /// + /// Returns the exact filesystem failure or a refusal of a conflicting entry. + fn link_root(&mut self) -> io::Result<()>; + + /// Links the complete manifest stage into the manifest pool without + /// replacement and synchronizes the pool. + /// + /// # Errors + /// + /// Returns the exact filesystem failure or a refusal of a conflicting entry. + fn link_manifest(&mut self) -> io::Result<()>; + + /// Replaces `retention/HEAD` with the complete head stage atomically and + /// synchronizes `retention`. + /// + /// # Errors + /// + /// Returns the exact filesystem failure. + fn finalize_head(&mut self) -> io::Result<()>; + + /// Removes the retained root stage after proving its pool link and + /// synchronizes `retention`. + /// + /// # Errors + /// + /// Returns the exact filesystem failure or a refusal when the link is not proven. + fn remove_root_stage(&mut self) -> io::Result<()>; + + /// Removes the retained manifest stage after proving its pool link and + /// synchronizes `retention`. + /// + /// # Errors + /// + /// Returns the exact filesystem failure or a refusal when the link is not proven. + fn remove_manifest_stage(&mut self) -> io::Result<()>; +} diff --git a/src/lib.rs b/src/lib.rs index f07545de..68e493de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -143,15 +143,16 @@ pub use adapters::{ RetentionPool, RetentionPoolEntryObservation, RetentionPoolObservations, RetentionPublicationError, RetentionPublicationOutcome, RetentionPublicationPhase, RetentionPublicationPreparation, RetentionPublicationPreparationError, - RetentionPublicationReceipt, RetentionPublicationStorage, RetentionRecoveryEvidence, - RetentionRecoveryOutcome, RetentionRecoveryPlan, RetentionRecoveryRefusal, - RetentionRecoveryStep, RetentionRootDecodeError, RetentionRootEncodeError, + RetentionPublicationReceipt, RetentionPublicationStorage, RetentionRecoveryError, + RetentionRecoveryEvidence, RetentionRecoveryOutcome, RetentionRecoveryPlan, + RetentionRecoveryReceipt, RetentionRecoveryRefusal, RetentionRecoveryStep, + RetentionRecoveryStorage, RetentionRootDecodeError, RetentionRootEncodeError, RetentionRootStageAssessment, RetentionStageAssessment, RetentionStageAssessments, RetentionTransitionDisposition, RetentionTransitionError, RetentionTransitionPreflight, RetentionTransitionPreflightError, RetentionTransitionReadiness, VerifiedRetentionClosure, assess_head_stage, assess_manifest_stage, assess_root_stage, execute_retention_publication, - plan_retention_recovery, plan_retention_transition, preflight_retention_transition, - prepare_retention_publication, verify_retention_closure, + execute_retention_recovery, plan_retention_recovery, plan_retention_transition, + preflight_retention_transition, prepare_retention_publication, verify_retention_closure, }; pub use blob::{ BlobHashError, BlobHasher, BlobId, BlobLength, BlobReadError, ByteLength, ByteOffset, From 16f22a92191a9fe4fe008d2c669fbfb08760679b Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 9 Sep 2026 12:34:24 -0700 Subject: [PATCH 03/59] Add: recover retained retention stages under filesystem authority FilesystemRetentionPublicationAuthority::recover observes the published state, reads root.next, manifest.next, and head.next within their format bounds (one byte past the bound so an oversized stage is corrupt rather than truncated), looks up the pool entries the complete stages name, plans through plan_retention_recovery, and executes the plan as the RetentionRecoveryStorage implementation under the retained writer lock. Complete stages are reopened through the new FilesystemRetentionStage::reopen, which binds the handle and the named entry to their identity exactly as a freshly created stage is, so link, replace, and remove refuse a substituted stage during recovery too. A truncated stage is discarded only after its kind, length, and identity match what was observed. Every step synchronizes the directory it changed before returning. Four laws build real crash prefixes by driving the publication phases directly and stopping: a clean store is clean; a root stage written and synchronized is linked and retained as a protected orphan; a head stage synchronized before the crash is finalized, the stages are removed, and the byte-identical retry reports AlreadyCommitted; a truncated root stage is discarded. Publication does not yet call recover itself; that wiring follows. Refs #19 --- CHANGELOG.md | 6 +- src/adapters/filesystem_exact_record.rs | 3 +- src/adapters/retention.rs | 6 + .../filesystem_retention_authority.rs | 3 + .../filesystem_retention_recovery.rs | 267 ++++++++++++++++++ .../filesystem_retention_recovery_error.rs | 48 ++++ ...lesystem_retention_recovery_observation.rs | 154 ++++++++++ .../filesystem_retention_recovery_tests.rs | 146 ++++++++++ .../retention/filesystem_retention_stage.rs | 17 ++ src/lib.rs | 35 +-- 10 files changed, 666 insertions(+), 19 deletions(-) create mode 100644 src/adapters/retention/filesystem_retention_recovery.rs create mode 100644 src/adapters/retention/filesystem_retention_recovery_error.rs create mode 100644 src/adapters/retention/filesystem_retention_recovery_observation.rs create mode 100644 src/adapters/retention/filesystem_retention_recovery_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f8865c1..6d949837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,11 @@ after its public API and format compatibility policies are established. `RetentionRecoveryRefusal`. `RetentionRecoveryStorage` names one blocking capability per step and `execute_retention_recovery` runs a plan in order, stopping at the first refused step with the completed prefix named in - `RetentionRecoveryError`; the filesystem implementation is the next step. + `RetentionRecoveryError`. `FilesystemRetentionPublicationAuthority::recover` + observes the stages within their format bounds, reopens complete stages + bound to their identity, and executes the plan under the retained writer + lock, so a crash after the head stage is synchronized finalizes on restart + and a byte-identical retry is already committed. - `FilesystemRetentionPublicationAuthority` executes the 17 ordered retention publication phases against a completely migrated version-2 root. It stages `root.next`, `manifest.next`, and `head.next` exclusively, verifies device diff --git a/src/adapters/filesystem_exact_record.rs b/src/adapters/filesystem_exact_record.rs index 14beda76..92590bd1 100644 --- a/src/adapters/filesystem_exact_record.rs +++ b/src/adapters/filesystem_exact_record.rs @@ -205,7 +205,8 @@ pub(super) fn link_without_replacement( } } -fn open_read(directory: &Dir, name: &str) -> io::Result { +/// Opens `name` read-only without following links or blocking. +pub(super) fn open_read(directory: &Dir, name: &str) -> io::Result { let mut options = OpenOptions::new(); options.read(true).follow(FollowSymlinks::No).nonblock(true); directory.open_with(name, &options) diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 5a75baf3..5c1061f7 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -36,6 +36,11 @@ mod filesystem_retention_namespace; #[cfg(test)] mod filesystem_retention_namespace_tests; mod filesystem_retention_pool_name; +mod filesystem_retention_recovery; +mod filesystem_retention_recovery_error; +mod filesystem_retention_recovery_observation; +#[cfg(test)] +mod filesystem_retention_recovery_tests; mod filesystem_retention_refusal; mod filesystem_retention_stage; mod filesystem_retention_storage; @@ -115,6 +120,7 @@ pub use filesystem_retention_authority_error::{ FilesystemRetentionAuthorityError, RetentionAuthorityDirectory, }; pub use filesystem_retention_current::ObservedRetentionState; +pub use filesystem_retention_recovery_error::FilesystemRetentionRecoveryError; pub use filesystem_retention_refusal::RetentionCurrentStateRefusal; pub use head_decode_error::RetentionHeadDecodeError; pub use manifest_decode_error::RetentionManifestDecodeError; diff --git a/src/adapters/retention/filesystem_retention_authority.rs b/src/adapters/retention/filesystem_retention_authority.rs index ada4ed58..fe0cf1ec 100644 --- a/src/adapters/retention/filesystem_retention_authority.rs +++ b/src/adapters/retention/filesystem_retention_authority.rs @@ -9,6 +9,7 @@ use super::filesystem_retention_authority_error::{ FilesystemRetentionAuthorityError as Error, RetentionAuthorityDirectory as Directory, }; use super::filesystem_retention_current::{self, ObservedRetentionState}; +use super::filesystem_retention_recovery::RetentionRecoveryContext; use crate::adapters::{FilesystemVersionTwoAdmission, FilesystemWriterLock}; /// Exclusive authority to publish retention transitions on one pinned root. @@ -32,6 +33,7 @@ pub struct FilesystemRetentionPublicationAuthority { pub(super) roots: Dir, pub(super) manifests: Dir, pub(super) attempt: Option, + pub(super) recovery: Option, _lock: FilesystemWriterLock, } @@ -68,6 +70,7 @@ impl FilesystemRetentionPublicationAuthority { roots, manifests, attempt: None, + recovery: None, _lock: lock, }) } diff --git a/src/adapters/retention/filesystem_retention_recovery.rs b/src/adapters/retention/filesystem_retention_recovery.rs new file mode 100644 index 00000000..1de0e93f --- /dev/null +++ b/src/adapters/retention/filesystem_retention_recovery.rs @@ -0,0 +1,267 @@ +//! This module owns filesystem execution of retention recovery under authority. + +use std::io; + +use cap_fs_ext::DirExt; +use cap_std::fs::Dir; + +use super::filesystem_retention_authority::FilesystemRetentionPublicationAuthority; +use super::filesystem_retention_pool_name as pool_name; +use super::filesystem_retention_recovery_observation::{RetentionRecoveryObservation, StageBytes}; +use super::filesystem_retention_stage::{FilesystemRetentionStage, invalid_data}; +use super::{ + FilesystemRetentionRecoveryError as Error, RetentionRecoveryReceipt, RetentionRecoveryStorage, + RetentionStageAssessment, assess_head_stage, assess_manifest_stage, assess_root_stage, + execute_retention_recovery, plan_retention_recovery, +}; +use crate::adapters::filesystem_catalog_artifact::synchronize_directory; +use crate::adapters::filesystem_exact_record::{self as exact_record, EntryIdentity}; + +/// One retained stage as recovery holds it between steps. +pub(super) enum RecoveredStage { + /// A complete stage reopened and bound to its identity. + Complete { + stage: FilesystemRetentionStage, + pool_name: String, + namespace: Option, + }, + /// A truncated stage identified for discard. + Truncated { + identity: EntryIdentity, + length: u64, + }, +} + +/// The retained stages one recovery run operates on. +pub(super) struct RetentionRecoveryContext { + root: Option, + manifest: Option, + head: Option, +} + +impl RetentionRecoveryContext { + fn reopen(retention: &Dir, observation: &RetentionRecoveryObservation) -> io::Result { + let root = observation + .root() + .map(|stage| -> io::Result { + match assess_root_stage(Some(&stage.bytes)) { + RetentionStageAssessment::Complete(admitted) => Ok(RecoveredStage::Complete { + stage: FilesystemRetentionStage::reopen( + retention, + pool_name::ROOT_STAGE, + &stage.bytes, + )?, + pool_name: pool_name::root(admitted.root().generation(), admitted.digest()), + namespace: Some(pool_name::namespace(admitted.root().namespace().digest())), + }), + _ => Ok(truncated(stage)), + } + }) + .transpose()?; + let manifest = observation + .manifest() + .map(|stage| -> io::Result { + match assess_manifest_stage(Some(&stage.bytes)) { + RetentionStageAssessment::Complete(admitted) => Ok(RecoveredStage::Complete { + stage: FilesystemRetentionStage::reopen( + retention, + pool_name::MANIFEST_STAGE, + &stage.bytes, + )?, + pool_name: pool_name::manifest( + admitted.manifest().generation(), + admitted.digest(), + ), + namespace: None, + }), + _ => Ok(truncated(stage)), + } + }) + .transpose()?; + let head = observation + .head() + .map(|stage| -> io::Result { + match assess_head_stage(Some(&stage.bytes)) { + RetentionStageAssessment::Complete(_) => Ok(RecoveredStage::Complete { + stage: FilesystemRetentionStage::reopen( + retention, + pool_name::HEAD_STAGE, + &stage.bytes, + )?, + pool_name: pool_name::HEAD.to_owned(), + namespace: None, + }), + _ => Ok(truncated(stage)), + } + }) + .transpose()?; + Ok(Self { + root, + manifest, + head, + }) + } +} + +fn truncated(stage: &StageBytes) -> RecoveredStage { + RecoveredStage::Truncated { + identity: stage.identity, + length: u64::try_from(stage.bytes.len()).unwrap_or(u64::MAX), + } +} + +impl FilesystemRetentionPublicationAuthority { + /// Observes, plans, and executes recovery of every fixed retention stage. + /// + /// The synchronous call runs under the retained writer lock. A clean store + /// returns an empty receipt; a truncated pre-effect stage is discarded; a + /// complete stage is linked and retained as a recovery-protected orphan; + /// a complete head over linked stages is finalized. Any pending + /// publication attempt is discarded first, and the caller re-verifies + /// current state afterwards. + /// + /// # Errors + /// + /// Returns [`FilesystemRetentionRecoveryError`](super::FilesystemRetentionRecoveryError) + /// at the exact observation failure, planning refusal, or refused step. + pub fn recover(&mut self) -> Result { + self.attempt = None; + self.recovery = None; + let observation = + RetentionRecoveryObservation::observe(&self.retention, &self.roots, &self.manifests) + .map_err(|source| Error::Observe { source })?; + let plan = plan_retention_recovery(observation.evidence()) + .map_err(|source| Error::Plan { source })?; + self.recovery = Some( + RetentionRecoveryContext::reopen(&self.retention, &observation) + .map_err(|source| Error::Observe { source })?, + ); + let result = + execute_retention_recovery(self, &plan).map_err(|source| Error::Execute { source }); + self.recovery = None; + result + } +} + +fn no_recovery() -> io::Error { + invalid_data("no retention recovery is in progress") +} + +fn take_complete( + slot: &mut Option, +) -> io::Result<(FilesystemRetentionStage, String, Option)> { + match slot.take() { + Some(RecoveredStage::Complete { + stage, + pool_name, + namespace, + }) => Ok((stage, pool_name, namespace)), + Some(other) => { + *slot = Some(other); + Err(invalid_data("recovery step expected a complete stage")) + } + None => Err(invalid_data("recovery step expected a retained stage")), + } +} + +fn discard_truncated( + retention: &Dir, + name: &str, + slot: &mut Option, +) -> io::Result<()> { + let Some(RecoveredStage::Truncated { identity, length }) = slot.take() else { + return Err(invalid_data("recovery step expected a truncated stage")); + }; + let metadata = retention.symlink_metadata(name)?; + if !metadata.is_file() || metadata.len() != length || EntryIdentity::from(&metadata) != identity + { + return Err(invalid_data( + "truncated retention stage changed before discard", + )); + } + retention.remove_file(name)?; + exact_record::require_absent(retention, name) + .map_err(|_source| invalid_data("discarded retention stage remained visible"))?; + synchronize_directory(retention) +} + +impl RetentionRecoveryStorage for FilesystemRetentionPublicationAuthority { + fn discard_head_stage(&mut self) -> io::Result<()> { + let context = self.recovery.as_mut().ok_or_else(no_recovery)?; + discard_truncated(&self.retention, pool_name::HEAD_STAGE, &mut context.head) + } + + fn discard_manifest_stage(&mut self) -> io::Result<()> { + let context = self.recovery.as_mut().ok_or_else(no_recovery)?; + discard_truncated( + &self.retention, + pool_name::MANIFEST_STAGE, + &mut context.manifest, + ) + } + + fn discard_root_stage(&mut self) -> io::Result<()> { + let context = self.recovery.as_mut().ok_or_else(no_recovery)?; + discard_truncated(&self.retention, pool_name::ROOT_STAGE, &mut context.root) + } + + fn link_root(&mut self) -> io::Result<()> { + let context = self.recovery.as_ref().ok_or_else(no_recovery)?; + let Some(RecoveredStage::Complete { + stage, + pool_name: name, + namespace: Some(namespace), + }) = context.root.as_ref() + else { + return Err(invalid_data("link_root expected a complete root stage")); + }; + match self.roots.create_dir(namespace) { + Ok(()) => {} + Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {} + Err(source) => return Err(source), + } + let directory = self.roots.open_dir_nofollow(namespace)?; + synchronize_directory(&self.roots)?; + stage.link(&self.retention, &directory, name)?; + synchronize_directory(&directory) + } + + fn link_manifest(&mut self) -> io::Result<()> { + let context = self.recovery.as_ref().ok_or_else(no_recovery)?; + let Some(RecoveredStage::Complete { + stage, + pool_name: name, + .. + }) = context.manifest.as_ref() + else { + return Err(invalid_data( + "link_manifest expected a complete manifest stage", + )); + }; + stage.link(&self.retention, &self.manifests, name)?; + synchronize_directory(&self.manifests) + } + + fn finalize_head(&mut self) -> io::Result<()> { + let context = self.recovery.as_mut().ok_or_else(no_recovery)?; + let (stage, _name, _namespace) = take_complete(&mut context.head)?; + stage.replace(&self.retention, pool_name::HEAD)?; + synchronize_directory(&self.retention) + } + + fn remove_root_stage(&mut self) -> io::Result<()> { + let context = self.recovery.as_mut().ok_or_else(no_recovery)?; + let (stage, name, namespace) = take_complete(&mut context.root)?; + let namespace = namespace.ok_or_else(|| invalid_data("root stage without a namespace"))?; + let directory = self.roots.open_dir_nofollow(&namespace)?; + stage.remove(&self.retention, &directory, &name)?; + synchronize_directory(&self.retention) + } + + fn remove_manifest_stage(&mut self) -> io::Result<()> { + let context = self.recovery.as_mut().ok_or_else(no_recovery)?; + let (stage, name, _namespace) = take_complete(&mut context.manifest)?; + stage.remove(&self.retention, &self.manifests, &name)?; + synchronize_directory(&self.retention) + } +} diff --git a/src/adapters/retention/filesystem_retention_recovery_error.rs b/src/adapters/retention/filesystem_retention_recovery_error.rs new file mode 100644 index 00000000..64c4513c --- /dev/null +++ b/src/adapters/retention/filesystem_retention_recovery_error.rs @@ -0,0 +1,48 @@ +//! This module owns the typed error of filesystem retention recovery. + +use std::error::Error; +use std::fmt; +use std::io; + +use super::{RetentionRecoveryError, RetentionRecoveryRefusal}; + +/// Why filesystem retention recovery did not reach a receipt. +#[derive(Debug)] +#[non_exhaustive] +pub enum FilesystemRetentionRecoveryError { + /// Reading the current state, a stage, or a pool entry failed. + Observe { + /// The exact filesystem or admission failure. + source: io::Error, + }, + /// The observed stages are unrecoverable ambiguity. + Plan { + /// The exact planning refusal. + source: RetentionRecoveryRefusal, + }, + /// A recovery step refused; earlier steps' effects remain. + Execute { + /// The refused step, the completed prefix, and the storage error. + source: RetentionRecoveryError, + }, +} + +impl fmt::Display for FilesystemRetentionRecoveryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Observe { .. } => "retention recovery could not observe the store", + Self::Plan { .. } => "retention recovery refused the observed stages", + Self::Execute { .. } => "a retention recovery step refused", + }) + } +} + +impl Error for FilesystemRetentionRecoveryError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Observe { source } => Some(source), + Self::Plan { source } => Some(source), + Self::Execute { source } => Some(source), + } + } +} diff --git a/src/adapters/retention/filesystem_retention_recovery_observation.rs b/src/adapters/retention/filesystem_retention_recovery_observation.rs new file mode 100644 index 00000000..9b21fc8e --- /dev/null +++ b/src/adapters/retention/filesystem_retention_recovery_observation.rs @@ -0,0 +1,154 @@ +//! This module owns restart observation of the retention stages and pools. + +use std::io::{self, Read}; + +use cap_fs_ext::DirExt; +use cap_std::fs::Dir; + +use super::filesystem_retention_current::{self, ObservedRetentionState}; +use super::filesystem_retention_pool_name as pool_name; +use super::{ + RetentionPoolEntryObservation as Pool, RetentionPoolObservations, RetentionRecoveryEvidence, + RetentionStageAssessment, RetentionStageAssessments, assess_head_stage, assess_manifest_stage, + assess_root_stage, head_decoder, root_header_decoder, +}; +use crate::adapters::filesystem_exact_record::{ + self as exact_record, EntryIdentity, ExactRecordError, +}; + +/// 160-byte header, 4,096 entries of 72 bytes, manifest digest, checksum. +const MANIFEST_MAXIMUM_ENCODED_LENGTH: usize = 295_136; + +/// The exact bytes and entry identity of one retained stage. +pub(super) struct StageBytes { + pub(super) bytes: Box<[u8]>, + pub(super) identity: EntryIdentity, +} + +/// Everything restart read under writer authority before planning recovery. +pub(super) struct RetentionRecoveryObservation { + current: Option, + root: Option, + manifest: Option, + head: Option, + pools: RetentionPoolObservations, +} + +impl RetentionRecoveryObservation { + /// Reads the current state, the three stages, and the pool entries the + /// complete stages name. Performs no mutation. + pub(super) fn observe(retention: &Dir, roots: &Dir, manifests: &Dir) -> io::Result { + let current = filesystem_retention_current::observe(retention, manifests)?; + let root = read_stage( + retention, + pool_name::ROOT_STAGE, + root_header_decoder::MAXIMUM_ENCODED_LENGTH, + )?; + let manifest = read_stage( + retention, + pool_name::MANIFEST_STAGE, + MANIFEST_MAXIMUM_ENCODED_LENGTH, + )?; + let head = read_stage( + retention, + pool_name::HEAD_STAGE, + head_decoder::ENCODED_LENGTH, + )?; + let root_pool = match assess_root_stage(root.as_ref().map(|stage| &*stage.bytes)) { + RetentionStageAssessment::Complete(admitted) => { + let namespace = pool_name::namespace(admitted.root().namespace().digest()); + let name = pool_name::root(admitted.root().generation(), admitted.digest()); + match roots.open_dir_nofollow(namespace) { + Ok(directory) => pool_entry(&directory, &name, admitted.encoded())?, + Err(source) if source.kind() == io::ErrorKind::NotFound => Pool::Absent, + Err(source) => return Err(source), + } + } + _ => Pool::Absent, + }; + let manifest_pool = + match assess_manifest_stage(manifest.as_ref().map(|stage| &*stage.bytes)) { + RetentionStageAssessment::Complete(admitted) => { + let name = + pool_name::manifest(admitted.manifest().generation(), admitted.digest()); + pool_entry(manifests, &name, admitted.encoded())? + } + _ => Pool::Absent, + }; + Ok(Self { + current, + root, + manifest, + head, + pools: RetentionPoolObservations { + root: root_pool, + manifest: manifest_pool, + }, + }) + } + + /// The pure evidence recovery plans from. + pub(super) fn evidence(&self) -> RetentionRecoveryEvidence<'_, '_> { + RetentionRecoveryEvidence::new( + self.current.as_ref(), + RetentionStageAssessments { + root: assess_root_stage(self.root.as_ref().map(|stage| &*stage.bytes)), + manifest: assess_manifest_stage(self.manifest.as_ref().map(|stage| &*stage.bytes)), + head: assess_head_stage(self.head.as_ref().map(|stage| &*stage.bytes)), + }, + self.pools, + ) + } + + pub(super) const fn root(&self) -> Option<&StageBytes> { + self.root.as_ref() + } + + pub(super) const fn manifest(&self) -> Option<&StageBytes> { + self.manifest.as_ref() + } + + pub(super) const fn head(&self) -> Option<&StageBytes> { + self.head.as_ref() + } +} + +/// Reads a stage's complete bytes up to one byte past `bound`. +/// +/// A stage longer than its format's maximum is returned in full up to that +/// point so assessment classifies it as corrupt rather than truncated. +fn read_stage(retention: &Dir, name: &str, bound: usize) -> io::Result> { + let mut file = match exact_record::open_read(retention, name) { + Ok(file) => file, + Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(source) => return Err(source), + }; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "retained retention stage is not a regular file", + )); + } + let identity = EntryIdentity::from(&metadata); + let limit = bound + .checked_add(1) + .and_then(|limit| u64::try_from(limit).ok()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "stage bound overflowed"))?; + let mut bytes = Vec::new(); + file.by_ref().take(limit).read_to_end(&mut bytes)?; + Ok(Some(StageBytes { + bytes: bytes.into_boxed_slice(), + identity, + })) +} + +/// Whether `directory` holds `name` with exactly `expected` bytes. +fn pool_entry(directory: &Dir, name: &str, expected: &[u8]) -> io::Result { + match exact_record::read_exact_optional(directory, name, expected.len()) { + Ok(None) => Ok(Pool::Absent), + Ok(Some(bytes)) if bytes == expected => Ok(Pool::Identical), + Ok(Some(_)) | Err(ExactRecordError::Refused(_)) => Ok(Pool::Different), + Err(ExactRecordError::Io(source)) => Err(source), + } +} diff --git a/src/adapters/retention/filesystem_retention_recovery_tests.rs b/src/adapters/retention/filesystem_retention_recovery_tests.rs new file mode 100644 index 00000000..0cb5d3ae --- /dev/null +++ b/src/adapters/retention/filesystem_retention_recovery_tests.rs @@ -0,0 +1,146 @@ +//! Filesystem retention recovery laws over real crash prefixes. + +use std::error::Error; +use std::fs; + +use super::filesystem_retention_test_fixture::{ + ROOT_HEX, fixture, head_path, initial_preparation, manifest_pool_path, open_authority, + root_pool_path, +}; +use super::{ + FilesystemRetentionPublicationAuthority, RetentionPublicationOutcome, + RetentionPublicationPreparation, RetentionPublicationStorage, + RetentionRecoveryOutcome as Outcome, RetentionRecoveryStep as Step, +}; +use crate::execute_retention_publication; + +type Phase<'a> = + &'a mut dyn FnMut(&mut FilesystemRetentionPublicationAuthority) -> std::io::Result<()>; + +/// Executes publication phases 1 through `count` and stops, like a crash there. +fn drive( + authority: &mut FilesystemRetentionPublicationAuthority, + preparation: &RetentionPublicationPreparation<'_>, + count: usize, +) -> Result<(), Box> { + let publication = preparation + .publication() + .ok_or("preparation carries no publication")?; + let root = preparation.candidate(); + let phases: [Phase<'_>; 13] = [ + &mut |a| a.verify_current(preparation).map(|_| ()), + &mut |a| a.write_root_stage(root), + &mut |a| a.synchronize_root_stage(), + &mut |a| a.admit_root_namespace(root).map(|_| ()), + &mut |a| a.synchronize_roots_after_namespace(), + &mut |a| a.link_root(root), + &mut |a| a.synchronize_root_namespace(root), + &mut |a| a.write_manifest_stage(publication.manifest()), + &mut |a| a.synchronize_manifest_stage(), + &mut |a| a.link_manifest(publication.manifest()), + &mut |a| a.synchronize_manifest_pool(), + &mut |a| a.write_head_stage(publication.head()), + &mut |a| a.synchronize_head_stage(), + ]; + for phase in phases.into_iter().take(count) { + phase(authority)?; + } + Ok(()) +} + +#[test] +fn a_clean_store_recovers_to_clean() -> Result<(), Box> { + let (_sandbox, mut authority) = open_authority("filesystem-retention-recovery-clean")?; + let receipt = authority.recover()?; + assert!(receipt.executed().is_empty()); + assert_eq!(receipt.outcome(), Outcome::Clean); + Ok(()) +} + +#[test] +fn a_written_root_stage_is_linked_and_protected() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("filesystem-retention-recovery-root")?; + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + drive(&mut authority, &preparation, 3)?; + + let receipt = authority.recover()?; + + assert_eq!(receipt.executed(), [Step::LinkRoot]); + assert_eq!( + receipt.outcome(), + Outcome::Protected { + root_stage: true, + manifest_stage: false + } + ); + assert_eq!( + fs::read(root_pool_path(sandbox.path(), preparation.candidate()))?, + root_bytes + ); + assert!(sandbox.path().join("retention").join("root.next").is_file()); + assert!(!head_path(sandbox.path()).exists()); + Ok(()) +} + +#[test] +fn a_synchronized_head_stage_is_finalized_and_the_retry_is_already_committed() +-> Result<(), Box> { + let (sandbox, mut authority) = open_authority("filesystem-retention-recovery-head")?; + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + drive(&mut authority, &preparation, 13)?; + let publication = preparation.publication().ok_or("no publication")?; + + let receipt = authority.recover()?; + + assert_eq!( + receipt.executed(), + [ + Step::FinalizeHead, + Step::RemoveRootStage, + Step::RemoveManifestStage + ] + ); + assert_eq!(receipt.outcome(), Outcome::Committed); + assert_eq!( + fs::read(head_path(sandbox.path()))?, + publication.head().encoded() + ); + assert_eq!( + fs::read(manifest_pool_path(sandbox.path(), &preparation))?, + publication.manifest().encoded() + ); + for stage in ["root.next", "manifest.next", "head.next"] { + assert!( + !sandbox.path().join("retention").join(stage).exists(), + "{stage} remained" + ); + } + let retry = execute_retention_publication(&mut authority, &preparation)?; + assert_eq!( + retry.outcome(), + RetentionPublicationOutcome::AlreadyCommitted + ); + Ok(()) +} + +#[test] +fn a_truncated_root_stage_is_discarded() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("filesystem-retention-recovery-truncated")?; + let root_bytes = fixture(ROOT_HEX)?; + let stage = sandbox.path().join("retention").join("root.next"); + fs::write( + &stage, + root_bytes + .get(..100) + .ok_or("root fixture shorter than 100 bytes")?, + )?; + + let receipt = authority.recover()?; + + assert_eq!(receipt.executed(), [Step::DiscardRootStage]); + assert_eq!(receipt.outcome(), Outcome::Clean); + assert!(!stage.exists()); + Ok(()) +} diff --git a/src/adapters/retention/filesystem_retention_stage.rs b/src/adapters/retention/filesystem_retention_stage.rs index 35a24598..0167b8ce 100644 --- a/src/adapters/retention/filesystem_retention_stage.rs +++ b/src/adapters/retention/filesystem_retention_stage.rs @@ -37,6 +37,23 @@ impl FilesystemRetentionStage { }) } + /// Reopens a retained stage whose exact bytes restart already read. + /// + /// The handle and the named entry are verified against `expected` and + /// bound to the entry's identity, so every later transition refuses a + /// substituted or replaced stage exactly as a freshly created one would. + pub(super) fn reopen(root: &Dir, name: &'static str, expected: &[u8]) -> io::Result { + let file = exact_record::open_read(root, name)?; + let identity = EntryIdentity::of_file(&file)?; + verify_named_record(root, name, expected, identity)?; + Ok(Self { + name, + expected: Box::from(expected), + identity, + file, + }) + } + /// Synchronizes the complete stage and reverifies its exact bytes. pub(super) fn synchronize(&self, root: &Dir) -> io::Result<()> { self.require_handle()?; diff --git a/src/lib.rs b/src/lib.rs index 68e493de..c6ff84eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -136,23 +136,24 @@ pub use adapters::{ AdmittedRetentionManifest, AdmittedRetentionRoot, CanonicalRetentionHead, CanonicalRetentionManifest, CanonicalRetentionRoot, ChecksummedRetentionHead, FilesystemRetentionAuthorityError, FilesystemRetentionPublicationAuthority, - ObservedRetentionState, PreparedRetentionPublication, RetentionAuthorityDirectory, - RetentionClosureVerificationError, RetentionCurrentStateRefusal, RetentionFixedStage, - RetentionHeadDecodeError, RetentionHeadStageAssessment, RetentionManifestDecodeError, - RetentionManifestEncodeError, RetentionManifestStageAssessment, RetentionNamespaceAdmission, - RetentionPool, RetentionPoolEntryObservation, RetentionPoolObservations, - RetentionPublicationError, RetentionPublicationOutcome, RetentionPublicationPhase, - RetentionPublicationPreparation, RetentionPublicationPreparationError, - RetentionPublicationReceipt, RetentionPublicationStorage, RetentionRecoveryError, - RetentionRecoveryEvidence, RetentionRecoveryOutcome, RetentionRecoveryPlan, - RetentionRecoveryReceipt, RetentionRecoveryRefusal, RetentionRecoveryStep, - RetentionRecoveryStorage, RetentionRootDecodeError, RetentionRootEncodeError, - RetentionRootStageAssessment, RetentionStageAssessment, RetentionStageAssessments, - RetentionTransitionDisposition, RetentionTransitionError, RetentionTransitionPreflight, - RetentionTransitionPreflightError, RetentionTransitionReadiness, VerifiedRetentionClosure, - assess_head_stage, assess_manifest_stage, assess_root_stage, execute_retention_publication, - execute_retention_recovery, plan_retention_recovery, plan_retention_transition, - preflight_retention_transition, prepare_retention_publication, verify_retention_closure, + FilesystemRetentionRecoveryError, ObservedRetentionState, PreparedRetentionPublication, + RetentionAuthorityDirectory, RetentionClosureVerificationError, RetentionCurrentStateRefusal, + RetentionFixedStage, RetentionHeadDecodeError, RetentionHeadStageAssessment, + RetentionManifestDecodeError, RetentionManifestEncodeError, RetentionManifestStageAssessment, + RetentionNamespaceAdmission, RetentionPool, RetentionPoolEntryObservation, + RetentionPoolObservations, RetentionPublicationError, RetentionPublicationOutcome, + RetentionPublicationPhase, RetentionPublicationPreparation, + RetentionPublicationPreparationError, RetentionPublicationReceipt, RetentionPublicationStorage, + RetentionRecoveryError, RetentionRecoveryEvidence, RetentionRecoveryOutcome, + RetentionRecoveryPlan, RetentionRecoveryReceipt, RetentionRecoveryRefusal, + RetentionRecoveryStep, RetentionRecoveryStorage, RetentionRootDecodeError, + RetentionRootEncodeError, RetentionRootStageAssessment, RetentionStageAssessment, + RetentionStageAssessments, RetentionTransitionDisposition, RetentionTransitionError, + RetentionTransitionPreflight, RetentionTransitionPreflightError, RetentionTransitionReadiness, + VerifiedRetentionClosure, assess_head_stage, assess_manifest_stage, assess_root_stage, + execute_retention_publication, execute_retention_recovery, plan_retention_recovery, + plan_retention_transition, preflight_retention_transition, prepare_retention_publication, + verify_retention_closure, }; pub use blob::{ BlobHashError, BlobHasher, BlobId, BlobLength, BlobReadError, ByteLength, ByteOffset, From 7e6cf879b63ab8cfd99ac5e9a3ad2feabbd4fc8a Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 9 Sep 2026 13:01:08 -0700 Subject: [PATCH 04/59] Test: recover every retention publication crash prefix to its documented state The retention fixture now drives all 18 storage-port phases in RetentionPublicationPhase::ALL order and stops after any prefix, which is the exact state a process death after that phase leaves behind. Three laws use it. The first walks every prefix from 0 through 18 in a fresh store and requires the documented recovery steps and outcome, the stages left behind, idempotent re-recovery, and the forward retry's result: published after a clean prefix, refused as recovery-required while protected orphans remain, already committed once the head is finalized. The second truncates each stage mid-write and requires only that stage discarded. The third replays successor prefixes over a published generation and requires the committed head to name the successor. recovery.md states that storage execution now exists and only process-death evidence remains; the RETENTION-007 ledger cell names the laws. Refs #19 --- CHANGELOG.md | 6 +- docs/formats/segment-store-v2/recovery.md | 4 +- docs/formats/segment-store-v2/requirements.md | 2 +- src/adapters/retention.rs | 2 + ...esystem_retention_recovery_prefix_tests.rs | 208 ++++++++++++++++++ .../filesystem_retention_recovery_tests.rs | 46 +--- .../filesystem_retention_test_fixture.rs | 47 ++++ 7 files changed, 271 insertions(+), 44 deletions(-) create mode 100644 src/adapters/retention/filesystem_retention_recovery_prefix_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d949837..2e7cb397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,11 @@ after its public API and format compatibility policies are established. observes the stages within their format bounds, reopens complete stages bound to their identity, and executes the plan under the retained writer lock, so a crash after the head stage is synchronized finalizes on restart - and a byte-identical retry is already committed. + and a byte-identical retry is already committed. Laws drive every + publication prefix from 0 through 18 phases, truncate each stage mid-write, + and replay successor prefixes over a published generation; each recovers to + its documented state, recovery is idempotent, and the forward retry reports + the predicted outcome. - `FilesystemRetentionPublicationAuthority` executes the 17 ordered retention publication phases against a completely migrated version-2 root. It stages `root.next`, `manifest.next`, and `head.next` exclusively, verifies device diff --git a/docs/formats/segment-store-v2/recovery.md b/docs/formats/segment-store-v2/recovery.md index efa539bc..d4449ad6 100644 --- a/docs/formats/segment-store-v2/recovery.md +++ b/docs/formats/segment-store-v2/recovery.md @@ -232,7 +232,9 @@ The retention crash points are: | `KEEP-CRASH-052` | retention cleanup synchronization | `RetentionPublicationPhase::ALL` freezes this exact order as a typed public -vocabulary. Storage execution and process-death evidence remain unimplemented. +vocabulary. `FilesystemRetentionPublicationAuthority::recover` implements the +classification above and its effects; process-death evidence remains +unimplemented. Each point requires before, during, and after process-death evidence. Restart must establish exact catalog visibility, retention head, namespace generation, diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index ff8ae2f4..1fbda2db 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -15,7 +15,7 @@ case is not evidence. | `KEEP-RETENTION-004` | Retain and release compare expected and observed generations and publish exact successors only | unforgeable readiness and preflight proofs in `tests/retention_transition.rs` and `tests/retention_preflight.rs`; exact successor preparation and complete receipt evidence in `tests/retention_publication_preparation.rs` and `tests/retention_publication_execution.rs`; writer-locked initial filesystem publication in `filesystem_retention_storage_tests`; observed-head successor publication, exact predecessor binding, and absent-head refusal in `filesystem_retention_successor_tests`; the store's catalog head must name the closure's catalog generation and digest before any forward write in `filesystem_retention_catalog_tests`; a head whose predecessor disagrees with its manifest refuses in `filesystem_retention_current_tests`; a successor reopens and decodes the manifest-selected predecessor root and refuses an absent or changed one in `filesystem_retention_expectation_tests` | Implemented | | `KEEP-RETENTION-005` | Closure derivation is deterministic, bounded, cycle-safe, fail-closed, and verifies complete blob reconstruction | exact accounting, reconstruction, adversarial-catalog, and exhaustive model laws in `tests/retention_closure.rs`; corrupt members refuse through the inherited segment-record admission laws and seeded `segment_format` fuzz target routed by `closure-corruption.md` | Implemented | | `KEEP-RETENTION-006` | Publication follows the exact ordered durability protocol, including new namespace-directory admission and retention of fixed-stage evidence until head commit, and returns only after cleanup synchronization | typed vocabulary and blocking port in `tests/retention_publication_phase.rs` and `tests/retention_publication_storage.rs`; ordered execution, conditional namespace sync, and all 17 exact storage-fault boundaries in `tests/retention_publication_execution.rs`; production 17-phase forward filesystem execution, exclusive staging, byte-equal inode-substitution refusal, and retained-stage recovery refusal in `filesystem_retention_storage_tests`; orphan namespace directories count against the 4,096 ceiling and refuse a new namespace before any stage is written in `filesystem_retention_capacity_tests`; crash injection remains | In progress in #19 | -| `KEEP-RETENTION-007` | Restart resolves every fixed-stage crash prefix to one documented lawful state or typed ambiguity | recovery-required refusals before any mutation in `filesystem_retention_expectation_tests`: an absent head over populated pools, a non-initial head prepared against an absent head, an orphan directory for a namespace expected absent, and an absent directory for a namespace expected current; debug and release crash matrix remains; replaced protocol directories, an absent or changed head-selected catalog, an over-full census, zero-generation pool names, and a stage retained by a failed write refuse in `filesystem_retention_*_tests`; storage-independent classification of every fixed-stage crash prefix (discard, link and protect, finalize, clean up, or typed refusal) in `recovery_planner_tests` | In progress in #19 | +| `KEEP-RETENTION-007` | Restart resolves every fixed-stage crash prefix to one documented lawful state or typed ambiguity | recovery-required refusals before any mutation in `filesystem_retention_expectation_tests`: an absent head over populated pools, a non-initial head prepared against an absent head, an orphan directory for a namespace expected absent, and an absent directory for a namespace expected current; debug and release crash matrix remains; replaced protocol directories, an absent or changed head-selected catalog, an over-full census, zero-generation pool names, and a stage retained by a failed write refuse in `filesystem_retention_*_tests`; storage-independent classification of every fixed-stage crash prefix (discard, link and protect, finalize, clean up, or typed refusal) in `recovery_planner_tests`; every publication prefix 0 through 18, each mid-write truncation, and successor prefixes recover in-process to the documented state, idempotently, with the forward retry reporting the predicted outcome, in `filesystem_retention_recovery_prefix_tests` | In progress in #19 | | `KEEP-RETENTION-008` | Readers double-collect catalog and retention heads and bind one complete catalog, manifest, and root-generation view under a `ReaderFence` | immutable snapshot and concurrency tests | Planned in #19 | | `KEEP-RETENTION-009` | Exact already-committed retry is idempotent only while its successor remains current | byte-identical planning in `tests/retention_transition.rs`; authority-revalidated zero-mutation retry receipt in `tests/retention_publication_execution.rs`; exact already-committed filesystem retry with a byte-identical retention witness in `filesystem_retention_storage_tests`; superseded-candidate filesystem refusal with zero mutation in `filesystem_retention_successor_tests`; committed retry reopens the head-selected manifest entry and root pool bytes, refusing absent, changed, or corrupt evidence in `filesystem_retention_current_tests`; every refusal is a typed `RetentionCurrentStateRefusal` source, with superseded, committed-root-absent, committed-root-changed, and head-absent-with-artifacts pinned by downcast | Implemented | | `KEEP-RETENTION-010` | Model operation sequences agree with a deterministic namespace-to-anchor-set map and never admit caller identity, paths, clocks, or application policy | model-based and source-architecture tests | Planned in #19 | diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 5c1061f7..3dabcda8 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -40,6 +40,8 @@ mod filesystem_retention_recovery; mod filesystem_retention_recovery_error; mod filesystem_retention_recovery_observation; #[cfg(test)] +mod filesystem_retention_recovery_prefix_tests; +#[cfg(test)] mod filesystem_retention_recovery_tests; mod filesystem_retention_refusal; mod filesystem_retention_stage; diff --git a/src/adapters/retention/filesystem_retention_recovery_prefix_tests.rs b/src/adapters/retention/filesystem_retention_recovery_prefix_tests.rs new file mode 100644 index 00000000..8b0526ff --- /dev/null +++ b/src/adapters/retention/filesystem_retention_recovery_prefix_tests.rs @@ -0,0 +1,208 @@ +//! Every publication crash prefix recovers to exactly one documented state. + +use std::error::Error; +use std::fs; +use std::path::Path; + +use super::filesystem_retention_test_fixture::{ + MANIFEST_HEX, PUBLICATION_PHASE_COUNT, ROOT_HEX, drive_publication, fixture, + initial_preparation, open_authority, refusal, successor_preparation, successor_root, +}; +use super::{ + AdmittedRetentionManifest, AdmittedRetentionRoot, RetentionCurrentStateRefusal, + RetentionPublicationError, RetentionPublicationOutcome, RetentionRecoveryOutcome as Outcome, + RetentionRecoveryStep as Step, +}; +use crate::execute_retention_publication; + +const PROTECTED_ROOT: Outcome = Outcome::Protected { + root_stage: true, + manifest_stage: false, +}; +const PROTECTED_BOTH: Outcome = Outcome::Protected { + root_stage: true, + manifest_stage: true, +}; + +/// The documented recovery of a crash after `count` phases, and the forward +/// retry's result afterwards. +fn expected(count: usize) -> (Vec, Outcome, Retry) { + match count { + 0 | 1 => (vec![], Outcome::Clean, Retry::Published), + 2..=5 => (vec![Step::LinkRoot], PROTECTED_ROOT, Retry::Refused), + 6 | 7 => (vec![], PROTECTED_ROOT, Retry::Refused), + 8 | 9 => (vec![Step::LinkManifest], PROTECTED_BOTH, Retry::Refused), + 10 | 11 => (vec![], PROTECTED_BOTH, Retry::Refused), + 12 | 13 => ( + vec![ + Step::FinalizeHead, + Step::RemoveRootStage, + Step::RemoveManifestStage, + ], + Outcome::Committed, + Retry::AlreadyCommitted, + ), + 14 | 15 => ( + vec![Step::RemoveRootStage, Step::RemoveManifestStage], + Outcome::Committed, + Retry::AlreadyCommitted, + ), + 16 => ( + vec![Step::RemoveManifestStage], + Outcome::Committed, + Retry::AlreadyCommitted, + ), + _ => (vec![], Outcome::Clean, Retry::AlreadyCommitted), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Retry { + Published, + AlreadyCommitted, + Refused, +} + +fn stages_present(root: &Path) -> [bool; 3] { + let retention = root.join("retention"); + ["root.next", "manifest.next", "head.next"].map(|stage| retention.join(stage).exists()) +} + +fn stages_for(outcome: Outcome) -> [bool; 3] { + match outcome { + Outcome::Clean | Outcome::Committed => [false, false, false], + Outcome::Protected { + root_stage, + manifest_stage, + } => [root_stage, manifest_stage, false], + } +} + +#[test] +fn every_initial_publication_prefix_recovers_to_its_documented_state() -> Result<(), Box> +{ + for count in 0..=PUBLICATION_PHASE_COUNT { + let name = format!("filesystem-retention-recovery-prefix-{count}"); + let (sandbox, mut authority) = open_authority(&name)?; + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + drive_publication(&mut authority, &preparation, count)?; + let (steps, outcome, retry) = expected(count); + + let receipt = authority + .recover() + .map_err(|error| format!("prefix {count}: {error}"))?; + + assert_eq!(receipt.executed(), steps, "prefix {count}: steps"); + assert_eq!(receipt.outcome(), outcome, "prefix {count}: outcome"); + assert_eq!( + stages_present(sandbox.path()), + stages_for(outcome), + "prefix {count}: stages" + ); + let second = authority + .recover() + .map_err(|error| format!("prefix {count} again: {error}"))?; + assert!( + second.executed().is_empty(), + "prefix {count}: recovery is idempotent" + ); + match ( + retry, + execute_retention_publication(&mut authority, &preparation), + ) { + (Retry::Published, Ok(receipt)) => { + assert_eq!(receipt.outcome(), RetentionPublicationOutcome::Published); + } + (Retry::AlreadyCommitted, Ok(receipt)) => { + assert_eq!( + receipt.outcome(), + RetentionPublicationOutcome::AlreadyCommitted + ); + } + (Retry::Refused, Err(RetentionPublicationError::CurrentVerification { source })) => { + assert!( + matches!( + refusal(&source), + Some(RetentionCurrentStateRefusal::RetainedStage) + ), + "prefix {count}: protected orphans refuse forward publication" + ); + } + (retry, result) => { + return Err(format!("prefix {count}: expected {retry:?}, got {result:?}").into()); + } + } + } + Ok(()) +} + +#[test] +fn a_crash_during_each_stage_write_discards_only_that_stage() -> Result<(), Box> { + for (phase, stage, discard) in [ + (2, "root.next", Step::DiscardRootStage), + (8, "manifest.next", Step::DiscardManifestStage), + (12, "head.next", Step::DiscardHeadStage), + ] { + let name = format!("filesystem-retention-recovery-during-{phase}"); + let (sandbox, mut authority) = open_authority(&name)?; + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + drive_publication(&mut authority, &preparation, phase - 1)?; + let publication = preparation.publication().ok_or("no publication")?; + let complete: &[u8] = match phase { + 2 => preparation.candidate().encoded(), + 8 => publication.manifest().encoded(), + _ => publication.head().encoded(), + }; + // 100 bytes is inside every record's framing: the head is 144 bytes, the + // manifest header 160, and the root header 192. + let partial = complete.get(..100).ok_or("record shorter than 100 bytes")?; + fs::write(sandbox.path().join("retention").join(stage), partial)?; + let (mut steps, outcome, _retry) = expected(phase - 1); + steps.insert(0, discard); + + let receipt = authority + .recover() + .map_err(|error| format!("during {phase}: {error}"))?; + + assert_eq!(receipt.executed(), steps, "during {phase}: steps"); + assert_eq!(receipt.outcome(), outcome, "during {phase}: outcome"); + assert!(!sandbox.path().join("retention").join(stage).exists()); + } + Ok(()) +} + +#[test] +fn successor_prefixes_recover_against_the_published_generation() -> Result<(), Box> { + for count in [2, 9, 13, 15] { + let name = format!("filesystem-retention-recovery-successor-{count}"); + let (_sandbox, mut authority) = open_authority(&name)?; + let root_bytes = fixture(ROOT_HEX)?; + let _published = + execute_retention_publication(&mut authority, &initial_preparation(&root_bytes)?)?; + let current_root = AdmittedRetentionRoot::decode(&root_bytes)?; + let manifest_bytes = fixture(MANIFEST_HEX)?; + let current_manifest = AdmittedRetentionManifest::decode(&manifest_bytes)?; + let candidate = successor_root(¤t_root)?; + let preparation = + successor_preparation(¤t_root, ¤t_manifest, candidate.encoded())?; + drive_publication(&mut authority, &preparation, count)?; + let (steps, outcome, _retry) = expected(count); + + let receipt = authority + .recover() + .map_err(|error| format!("successor {count}: {error}"))?; + + assert_eq!(receipt.executed(), steps, "successor {count}: steps"); + assert_eq!(receipt.outcome(), outcome, "successor {count}: outcome"); + if outcome == Outcome::Committed { + let observed = authority.observe_current()?.ok_or("no head after commit")?; + assert_eq!( + observed.head().generation(), + preparation.liveness_generation() + ); + } + } + Ok(()) +} diff --git a/src/adapters/retention/filesystem_retention_recovery_tests.rs b/src/adapters/retention/filesystem_retention_recovery_tests.rs index 0cb5d3ae..798676c7 100644 --- a/src/adapters/retention/filesystem_retention_recovery_tests.rs +++ b/src/adapters/retention/filesystem_retention_recovery_tests.rs @@ -4,50 +4,14 @@ use std::error::Error; use std::fs; use super::filesystem_retention_test_fixture::{ - ROOT_HEX, fixture, head_path, initial_preparation, manifest_pool_path, open_authority, - root_pool_path, + ROOT_HEX, drive_publication, fixture, head_path, initial_preparation, manifest_pool_path, + open_authority, root_pool_path, }; use super::{ - FilesystemRetentionPublicationAuthority, RetentionPublicationOutcome, - RetentionPublicationPreparation, RetentionPublicationStorage, - RetentionRecoveryOutcome as Outcome, RetentionRecoveryStep as Step, + RetentionPublicationOutcome, RetentionRecoveryOutcome as Outcome, RetentionRecoveryStep as Step, }; use crate::execute_retention_publication; -type Phase<'a> = - &'a mut dyn FnMut(&mut FilesystemRetentionPublicationAuthority) -> std::io::Result<()>; - -/// Executes publication phases 1 through `count` and stops, like a crash there. -fn drive( - authority: &mut FilesystemRetentionPublicationAuthority, - preparation: &RetentionPublicationPreparation<'_>, - count: usize, -) -> Result<(), Box> { - let publication = preparation - .publication() - .ok_or("preparation carries no publication")?; - let root = preparation.candidate(); - let phases: [Phase<'_>; 13] = [ - &mut |a| a.verify_current(preparation).map(|_| ()), - &mut |a| a.write_root_stage(root), - &mut |a| a.synchronize_root_stage(), - &mut |a| a.admit_root_namespace(root).map(|_| ()), - &mut |a| a.synchronize_roots_after_namespace(), - &mut |a| a.link_root(root), - &mut |a| a.synchronize_root_namespace(root), - &mut |a| a.write_manifest_stage(publication.manifest()), - &mut |a| a.synchronize_manifest_stage(), - &mut |a| a.link_manifest(publication.manifest()), - &mut |a| a.synchronize_manifest_pool(), - &mut |a| a.write_head_stage(publication.head()), - &mut |a| a.synchronize_head_stage(), - ]; - for phase in phases.into_iter().take(count) { - phase(authority)?; - } - Ok(()) -} - #[test] fn a_clean_store_recovers_to_clean() -> Result<(), Box> { let (_sandbox, mut authority) = open_authority("filesystem-retention-recovery-clean")?; @@ -62,7 +26,7 @@ fn a_written_root_stage_is_linked_and_protected() -> Result<(), Box> let (sandbox, mut authority) = open_authority("filesystem-retention-recovery-root")?; let root_bytes = fixture(ROOT_HEX)?; let preparation = initial_preparation(&root_bytes)?; - drive(&mut authority, &preparation, 3)?; + drive_publication(&mut authority, &preparation, 3)?; let receipt = authority.recover()?; @@ -89,7 +53,7 @@ fn a_synchronized_head_stage_is_finalized_and_the_retry_is_already_committed() let (sandbox, mut authority) = open_authority("filesystem-retention-recovery-head")?; let root_bytes = fixture(ROOT_HEX)?; let preparation = initial_preparation(&root_bytes)?; - drive(&mut authority, &preparation, 13)?; + drive_publication(&mut authority, &preparation, 13)?; let publication = preparation.publication().ok_or("no publication")?; let receipt = authority.recover()?; diff --git a/src/adapters/retention/filesystem_retention_test_fixture.rs b/src/adapters/retention/filesystem_retention_test_fixture.rs index 9672c10d..988e5675 100644 --- a/src/adapters/retention/filesystem_retention_test_fixture.rs +++ b/src/adapters/retention/filesystem_retention_test_fixture.rs @@ -8,6 +8,7 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +use super::RetentionPublicationStorage; use super::filesystem_retention_authority::FilesystemRetentionPublicationAuthority; use super::{ AdmittedRetentionManifest, AdmittedRetentionRoot, CanonicalRetentionRoot, @@ -261,3 +262,49 @@ fn write_version_one(sandbox: &TestDirectory) -> Result<(), Box> { const fn maximum_policy() -> SegmentReadPolicy { SegmentReadPolicy::new(SegmentRecordLimit::MAXIMUM, LayoutEntryLimit::MAXIMUM) } + +type PublicationPhase<'a> = + &'a mut dyn FnMut(&mut FilesystemRetentionPublicationAuthority) -> io::Result<()>; + +/// The number of storage-port phases one publication executes. +pub(super) const PUBLICATION_PHASE_COUNT: usize = 18; + +/// Executes publication phases 1 through `count` and stops, like a crash there. +/// +/// Phase 1 is current-state verification; 2 through 18 are the storage-port +/// phases in `RetentionPublicationPhase::ALL` order, so `count` selects the +/// exact prefix a process death after that phase would leave behind. +pub(super) fn drive_publication( + authority: &mut FilesystemRetentionPublicationAuthority, + preparation: &RetentionPublicationPreparation<'_>, + count: usize, +) -> Result<(), Box> { + let publication = preparation + .publication() + .ok_or("preparation carries no publication")?; + let root = preparation.candidate(); + let phases: [PublicationPhase<'_>; PUBLICATION_PHASE_COUNT] = [ + &mut |a| a.verify_current(preparation).map(|_| ()), + &mut |a| a.write_root_stage(root), + &mut |a| a.synchronize_root_stage(), + &mut |a| a.admit_root_namespace(root).map(|_| ()), + &mut |a| a.synchronize_roots_after_namespace(), + &mut |a| a.link_root(root), + &mut |a| a.synchronize_root_namespace(root), + &mut |a| a.write_manifest_stage(publication.manifest()), + &mut |a| a.synchronize_manifest_stage(), + &mut |a| a.link_manifest(publication.manifest()), + &mut |a| a.synchronize_manifest_pool(), + &mut |a| a.write_head_stage(publication.head()), + &mut |a| a.synchronize_head_stage(), + &mut |a| a.replace_head(), + &mut |a| a.synchronize_retention_namespace(), + &mut |a| a.remove_root_stage(), + &mut |a| a.remove_manifest_stage(), + &mut |a| a.synchronize_cleanup(), + ]; + for phase in phases.into_iter().take(count) { + phase(authority)?; + } + Ok(()) +} From 3d031b4b601bef0245bb8db88937cce91085242b Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 9 Sep 2026 13:30:19 -0700 Subject: [PATCH 05/59] Fix: run retention recovery as the first step of publication The retention publication page has always listed "completes recovery of every fixed retention stage" as publication's first step, and until now the filesystem writer refused every retained stage instead, which left an interrupted publication waiting for a human. verify_current now calls recover before anything else. A clean or committed outcome continues; a protected outcome (complete orphans awaiting explicit disposition) refuses RetainedStage as before; recovery's planning refusal and step failure travel as RecoveryRefused { source } and RecoveryStepRefused { source } through RetentionCurrentStateRefusal, so callers keep recovery's own reason. Three laws that pinned the refuse-everything doctrine now pin the recovered behaviour: a truncated manifest stage is discarded and publication publishes (this law failed before the change), a complete orphan root stage still refuses and stays retained and linked, and a complete head stage without its manifest refuses with recovery's ambiguity. README, the version-two overview, the retention page, and the ledger nonclaims describe the recovered behaviour and name the two remaining waits: complete orphans until disposition (#21) and process-death evidence (#19). Refs #19 #21 --- CHANGELOG.md | 5 +- README.md | 14 ++++-- docs/formats/segment-store-v2/README.md | 8 +-- docs/formats/segment-store-v2/requirements.md | 9 ++-- docs/formats/segment-store-v2/retention.md | 5 +- .../filesystem_retention_attempt_tests.rs | 6 +-- .../filesystem_retention_recovery.rs | 4 +- .../retention/filesystem_retention_refusal.rs | 17 +++++++ .../retention/filesystem_retention_storage.rs | 20 +++++++- .../filesystem_retention_storage_tests.rs | 49 +++++++++++++++---- 10 files changed, 104 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e7cb397..9bc32b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,10 @@ after its public API and format compatibility policies are established. publication prefix from 0 through 18 phases, truncate each stage mid-write, and replay successor prefixes over a published generation; each recovers to its documented state, recovery is idempotent, and the forward retry reports - the predicted outcome. + the predicted outcome. Publication runs that recovery as its first step, so + an interrupted publication no longer waits for a human unless it left a + complete orphan; `RecoveryRefused` and `RecoveryStepRefused` carry + recovery's own errors through `RetentionCurrentStateRefusal`. - `FilesystemRetentionPublicationAuthority` executes the 17 ordered retention publication phases against a completely migrated version-2 root. It stages `root.next`, `manifest.next`, and `head.next` exclusively, verifies device diff --git a/README.md b/README.md index 51b601ca..9453d5a2 100644 --- a/README.md +++ b/README.md @@ -69,11 +69,15 @@ Keep is required to refuse all three, before mutating anything. ## What it does not do yet -Version 2 writes correctly from a clean start and, if it finds the residue of -an interrupted publication, refuses rather than guesses. Nothing yet recovers -that residue, and readers have no fence, so **an interrupted version-2 -publication waits for a human until #19 lands.** A version-1 store stays -admitted until its owner migrates it; migrate only if you accept that wait. +Version 2 writes correctly from a clean start, and the next publication +recovers the residue of an interrupted one: a stage cut mid-write is +discarded, a head already synchronized is finalized, and a byte-identical +retry reports already committed. The one state that waits for a human is a +complete orphan, a crash between the root link and the head finalization, +which stays recovery-protected until explicit disposition lands with garbage +collection (#21). Readers have no fence yet, and process-death evidence for +the recovery itself is still to come (#19). A version-1 store stays admitted +until its owner migrates it. | Gap | Tracked | | --- | --- | diff --git a/docs/formats/segment-store-v2/README.md b/docs/formats/segment-store-v2/README.md index 1f27b80d..20b19f22 100644 --- a/docs/formats/segment-store-v2/README.md +++ b/docs/formats/segment-store-v2/README.md @@ -94,10 +94,10 @@ head and the catalog it selects, and refuses superseded candidates, retained stages, replaced protocol directories, and every namespace or capacity violation before mutation, each as a typed `RetentionCurrentStateRefusal`. -Not implemented: retention publication recovery and `KEEP-CRASH-036..052` -process-death evidence, partial-prefix migration recovery and -`KEEP-CRASH-053..073`, the reader fence, model-based transition evidence, and -garbage collection. Issue #19 owns the first four and issue #21 the last; +Retention publication recovery is implemented and proven in-process for every +crash prefix; its `KEEP-CRASH-036..052` process-death evidence is not. +Not implemented: partial-prefix migration recovery and `KEEP-CRASH-053..073`, +the reader fence, model-based transition evidence, and garbage collection. Issue #19 owns the first four and issue #21 the last; issue #97 owns the restart-stable root identity coordinate. A version-1 store remains admitted until its owner migrates it, and the [requirements ledger](requirements.md) is the authority on which requirements diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index 1fbda2db..b604f9eb 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -60,10 +60,11 @@ case is not evidence. - A fresh forward writer is not proof that version 2 is restart-safe or production-admitted; partial-prefix recovery and crash evidence remain mandatory. This applies to retention publication exactly as it applies to - migration: the filesystem publication writer refuses every retained stage - instead of continuing it. A stage left behind by a failed write is recovery - evidence like any crash residue; it is never unlinked, and the next - publication refuses until recovery classifies it. + migration: the filesystem publication writer never continues a retained + stage; it recovers it first, discarding a pre-effect truncated stage, + finalizing a complete head, and refusing a complete orphan until explicit + disposition. A stage left behind by a failed write is recovery evidence like + any crash residue and is classified the same way. - Publication binds this store's catalog `HEAD` to the verified closure and reopens the head-selected catalog pool entry under authority, but it does not re-read closure-member segments: every read authenticates them, and diff --git a/docs/formats/segment-store-v2/retention.md b/docs/formats/segment-store-v2/retention.md index 6f7402f2..44a8668c 100644 --- a/docs/formats/segment-store-v2/retention.md +++ b/docs/formats/segment-store-v2/retention.md @@ -165,8 +165,9 @@ implements root, manifest, and head codecs with a typed verified anchor-set digest, expected-state transition planning, deterministic closure verification, a blocking publication storage capability port, and ordered storage-port orchestration. `FilesystemRetentionPublicationAuthority` publishes initial and -successor generations against its observed head and refuses superseded -candidates and retained stages; recovery, fencing, and collection remain absent. +successor generations against its observed head, recovers retained stages +first, and refuses superseded candidates and protected orphans; fencing and +collection remain absent. ## Global retention manifest diff --git a/src/adapters/retention/filesystem_retention_attempt_tests.rs b/src/adapters/retention/filesystem_retention_attempt_tests.rs index 01c5cec5..c24aa308 100644 --- a/src/adapters/retention/filesystem_retention_attempt_tests.rs +++ b/src/adapters/retention/filesystem_retention_attempt_tests.rs @@ -20,17 +20,17 @@ fn refused_verification_admits_no_later_phase() -> Result<(), Box> { let preparation = initial_preparation(&root_bytes)?; fs::write( sandbox.path().join("retention").join("head.next"), - b"retained", + fixture(super::filesystem_retention_test_fixture::HEAD_HEX)?, )?; let before = retention_witness(sandbox.path())?; let error = authority .verify_current(&preparation) .err() - .ok_or("retained head stage was admitted")?; + .ok_or("an ambiguous head stage was admitted")?; assert!(matches!( refusal(&error), - Some(RetentionCurrentStateRefusal::RetainedStage) + Some(RetentionCurrentStateRefusal::RecoveryRefused { .. }) )); let error = authority .write_root_stage(preparation.candidate()) diff --git a/src/adapters/retention/filesystem_retention_recovery.rs b/src/adapters/retention/filesystem_retention_recovery.rs index 1de0e93f..d350d8e8 100644 --- a/src/adapters/retention/filesystem_retention_recovery.rs +++ b/src/adapters/retention/filesystem_retention_recovery.rs @@ -117,8 +117,8 @@ impl FilesystemRetentionPublicationAuthority { /// returns an empty receipt; a truncated pre-effect stage is discarded; a /// complete stage is linked and retained as a recovery-protected orphan; /// a complete head over linked stages is finalized. Any pending - /// publication attempt is discarded first, and the caller re-verifies - /// current state afterwards. + /// publication attempt is discarded first. Publication calls this itself + /// as its first step; callers may also run it explicitly at restart. /// /// # Errors /// diff --git a/src/adapters/retention/filesystem_retention_refusal.rs b/src/adapters/retention/filesystem_retention_refusal.rs index be4d20d7..598565c4 100644 --- a/src/adapters/retention/filesystem_retention_refusal.rs +++ b/src/adapters/retention/filesystem_retention_refusal.rs @@ -5,6 +5,7 @@ use std::fmt; use std::io; use super::{RetentionHeadDecodeError, RetentionManifestDecodeError}; +use super::{RetentionRecoveryError, RetentionRecoveryRefusal}; use crate::adapters::{CatalogDecodeError, PublicationHeadDecodeError}; use crate::{CatalogGeneration, LivenessGeneration, RetentionManifestDigest}; @@ -122,6 +123,16 @@ pub enum RetentionCurrentStateRefusal { /// A protocol directory named at admission (`retention`, `roots`, or /// `manifests`) no longer names the pinned directory that was admitted. ProtocolDirectoryReplaced, + /// Restart recovery refused the retained stages as unrecoverable ambiguity. + RecoveryRefused { + /// The exact planning refusal. + source: RetentionRecoveryRefusal, + }, + /// A restart recovery step refused; the completed prefix remains. + RecoveryStepRefused { + /// The refused step, the completed prefix, and the storage error. + source: RetentionRecoveryError, + }, /// A record's kind or length disagreed with its declaration. RecordKindOrLength, /// A record carried bytes beyond its declared length. @@ -228,6 +239,10 @@ impl RetentionCurrentStateRefusal { Self::RecordKindOrLength => "retention record kind or length disagreed", Self::RecordTrailingBytes => "retention record carried trailing bytes", Self::RecordLengthOverflow => "retention record length exceeded the addressable range", + Self::RecoveryRefused { .. } => { + "restart recovery refused the retained retention stages" + } + Self::RecoveryStepRefused { .. } => "a restart recovery step refused", Self::ProtocolDirectoryReplaced => { "a retention protocol directory was replaced after admission" } @@ -253,6 +268,8 @@ impl Error for RetentionCurrentStateRefusal { Self::ManifestRefused { source } => Some(source), Self::CatalogHeadRefused { source } => Some(source), Self::CatalogRefused { source } => Some(source.as_ref()), + Self::RecoveryRefused { source } => Some(source), + Self::RecoveryStepRefused { source } => Some(source), _ => None, } } diff --git a/src/adapters/retention/filesystem_retention_storage.rs b/src/adapters/retention/filesystem_retention_storage.rs index e5c897c9..ac943ec1 100644 --- a/src/adapters/retention/filesystem_retention_storage.rs +++ b/src/adapters/retention/filesystem_retention_storage.rs @@ -14,8 +14,9 @@ use super::filesystem_retention_pool_name as pool_name; use super::filesystem_retention_stage::FilesystemRetentionStage; use super::{ AdmittedRetentionRoot, CanonicalRetentionHead, CanonicalRetentionManifest, - RetentionCurrentStateRefusal, RetentionNamespaceAdmission, RetentionPublicationPreparation, - RetentionPublicationStorage, RetentionTransitionDisposition, + FilesystemRetentionRecoveryError, RetentionCurrentStateRefusal, RetentionNamespaceAdmission, + RetentionPublicationPreparation, RetentionPublicationStorage, RetentionRecoveryOutcome, + RetentionTransitionDisposition, }; use crate::RetentionGenerationExpectation; use crate::adapters::filesystem_catalog_artifact::synchronize_directory; @@ -28,6 +29,21 @@ impl RetentionPublicationStorage for FilesystemRetentionPublicationAuthority { ) -> io::Result { self.attempt = None; require_pinned_directories(&self.root, &self.retention, &self.roots, &self.manifests)?; + let recovery = self.recover().map_err(|error| match error { + FilesystemRetentionRecoveryError::Observe { source } => source, + FilesystemRetentionRecoveryError::Plan { source } => { + RetentionCurrentStateRefusal::RecoveryRefused { source }.into_io() + } + FilesystemRetentionRecoveryError::Execute { source } => { + RetentionCurrentStateRefusal::RecoveryStepRefused { source }.into_io() + } + })?; + if matches!( + recovery.outcome(), + RetentionRecoveryOutcome::Protected { .. } + ) { + return Err(RetentionCurrentStateRefusal::RetainedStage.into_io()); + } require_no_retained_stage(&self.retention)?; let census = filesystem_retention_namespace::admit(&self.retention, &self.roots, &self.manifests)?; diff --git a/src/adapters/retention/filesystem_retention_storage_tests.rs b/src/adapters/retention/filesystem_retention_storage_tests.rs index 3c132572..697fd551 100644 --- a/src/adapters/retention/filesystem_retention_storage_tests.rs +++ b/src/adapters/retention/filesystem_retention_storage_tests.rs @@ -77,6 +77,10 @@ fn retained_stage_refuses_publication_before_recovery() -> Result<(), Box io::Result)>> { } #[test] -fn retained_manifest_stage_refuses_publication_before_recovery() -> Result<(), Box> { - let (sandbox, mut authority) = - open_authority("filesystem-retention-recovery-required-manifest")?; +fn a_complete_orphan_root_stage_refuses_publication_until_disposition() -> Result<(), Box> +{ + let (sandbox, mut authority) = open_authority("filesystem-retention-recovery-required-orphan")?; let root_bytes = fixture(ROOT_HEX)?; let preparation = initial_preparation(&root_bytes)?; fs::write( - sandbox.path().join("retention").join("manifest.next"), - b"partial bytes left by a failed write", + sandbox.path().join("retention").join("root.next"), + &root_bytes, )?; - let before = retention_witness(sandbox.path())?; let error = execute_retention_publication(&mut authority, &preparation) .err() - .ok_or("retained manifest stage was unexpectedly published over")?; + .ok_or("a complete orphan root stage was unexpectedly published over")?; let RetentionPublicationError::CurrentVerification { source } = error else { - return Err("retained stage refused outside current-state verification".into()); + return Err("protected orphan refused outside current-state verification".into()); }; - assert_eq!(source.kind(), io::ErrorKind::InvalidData); - assert_eq!(retention_witness(sandbox.path())?, before); + assert!(matches!( + super::filesystem_retention_test_fixture::refusal(&source), + Some(super::RetentionCurrentStateRefusal::RetainedStage) + )); + assert!(sandbox.path().join("retention").join("root.next").is_file()); + assert_eq!( + fs::read(root_pool_path(sandbox.path(), preparation.candidate()))?, + root_bytes + ); + Ok(()) +} + +#[test] +fn a_truncated_stage_is_recovered_and_publication_proceeds() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("filesystem-retention-recovered-stage")?; + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + let stage = sandbox.path().join("retention").join("manifest.next"); + fs::write(&stage, b"partial bytes left by a failed write")?; + + let receipt = execute_retention_publication(&mut authority, &preparation)?; + + assert_eq!(receipt.outcome(), RetentionPublicationOutcome::Published); + assert!( + !stage.exists(), + "the truncated stage must be discarded by recovery" + ); + assert_eq!(fs::read(head_path(sandbox.path()))?, fixture(HEAD_HEX)?); Ok(()) } From 7a512c179d41a8deb4f486d601e25b91d51116f2 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 9 Sep 2026 14:04:12 -0700 Subject: [PATCH 06/59] Add: kill real writers at every retention publication coordinate The durability crash matrix now covers KEEP-CRASH-036 through 052. A child initializes a store, writes the golden bundle corpus, migrates it through all 21 phases, reopens it as version two, prepares retention generation one against the bundle catalog snapshot, and publishes through a decorator that dies before, during, or after the selected phase. During a stage write the decorator leaves a 100-byte prefix, inside every record's framing, so restart classifies it as truncated rather than corrupt. Restart reopens the store through the same admission a production caller would use, runs FilesystemRetentionPublicationAuthority::recover, and requires the documented steps and outcome for that exact prefix, then requires the forward retry to report what recovery predicts: published after a clean prefix, refused as recovery-required while protected orphans remain, already committed once the head is finalized. All 51 retention coordinates pass, and the complete 156-case matrix passes locally. FilesystemVersionTwoAdmission::reopen_unchecked_for_repository_tasks and FilesystemStoreMigrationAuthority::open_unchecked_for_repository_tasks give repository tools the bypass version one already had; every namespace, record, and identity law still applies through them. KEEP-RETENTION-007 is now Implemented in the ledger; the README, overview, and recovery page say that process-death evidence exists. Refs #19 --- CHANGELOG.md | 9 +- README.md | 14 +- docs/formats/segment-store-v2/README.md | 5 +- docs/formats/segment-store-v2/recovery.md | 5 +- docs/formats/segment-store-v2/requirements.md | 2 +- .../filesystem_version_two_admission.rs | 21 +- .../filesystem_migration_authority.rs | 20 ++ .../production_protocol.rs | 5 + .../production_protocol/fixture.rs | 34 +++ .../production_protocol/initialization.rs | 17 +- .../production_protocol/retention.rs | 114 +++++++++ .../production_protocol/retention_storage.rs | 229 ++++++++++++++++++ xtask/src/durability_crash_matrix/restart.rs | 4 + .../restart/expectation.rs | 5 + .../restart/retention.rs | 162 +++++++++++++ xtask/src/durability_crash_point.rs | 72 +++++- xtask/src/durability_crash_point_identity.rs | 17 ++ .../tests/durability_crash_point_contract.rs | 83 ++++++- 18 files changed, 797 insertions(+), 21 deletions(-) create mode 100644 xtask/src/durability_crash_matrix/production_protocol/retention.rs create mode 100644 xtask/src/durability_crash_matrix/production_protocol/retention_storage.rs create mode 100644 xtask/src/durability_crash_matrix/restart/retention.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bc32b57..93f740fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,14 @@ after its public API and format compatibility policies are established. the predicted outcome. Publication runs that recovery as its first step, so an interrupted publication no longer waits for a human unless it left a complete orphan; `RecoveryRefused` and `RecoveryStepRefused` carry - recovery's own errors through `RetentionCurrentStateRefusal`. + recovery's own errors through `RetentionCurrentStateRefusal`. The crash + matrix gains `KEEP-CRASH-036` through `052`: a child migrates a golden + bundle store, publishes retention generation one, and is killed before, + during, or after each of the seventeen phases; restart reopens the store, + runs recovery, and requires the documented steps, outcome, and forward + retry. `FilesystemVersionTwoAdmission::reopen_unchecked_for_repository_tasks` + and `FilesystemStoreMigrationAuthority::open_unchecked_for_repository_tasks` + give repository tools the same bypass version one already had. - `FilesystemRetentionPublicationAuthority` executes the 17 ordered retention publication phases against a completely migrated version-2 root. It stages `root.next`, `manifest.next`, and `head.next` exclusively, verifies device diff --git a/README.md b/README.md index 9453d5a2..cedc6fae 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,10 @@ Keep is required to refuse all three, before mutating anything. generation-versioned catalogs, and a fixed-width `HEAD` are published through an ordered protocol whose every step is a named crash point. Platform admission is Linux ext4, non-casefolded, one writer. -- **Proven restart recovery for version 1.** The crash matrix kills real - writer processes at 105 before/during/after coordinates - (`KEEP-CRASH-001`–`035`) and verifies the store lands in exactly one - documented lawful state each time. +- **Proven restart recovery.** The crash matrix kills real writer processes + at 156 before/during/after coordinates (`KEEP-CRASH-001`–`052`) and + verifies the store lands in exactly one documented lawful state each time, + for version-1 publication and for version-2 retention publication. - **Version-2 retention and migration, forward path.** Explicit retention roots, deterministic closure verification, a one-way 21-phase migration, and a 17-phase retention publication — all with production filesystem @@ -75,9 +75,9 @@ discarded, a head already synchronized is finalized, and a byte-identical retry reports already committed. The one state that waits for a human is a complete orphan, a crash between the root link and the head finalization, which stays recovery-protected until explicit disposition lands with garbage -collection (#21). Readers have no fence yet, and process-death evidence for -the recovery itself is still to come (#19). A version-1 store stays admitted -until its owner migrates it. +collection (#21). The crash matrix proves that recovery by killing real +writer processes at all 51 retention coordinates. Readers have no fence yet +(#19). A version-1 store stays admitted until its owner migrates it. | Gap | Tracked | | --- | --- | diff --git a/docs/formats/segment-store-v2/README.md b/docs/formats/segment-store-v2/README.md index 20b19f22..e3a82744 100644 --- a/docs/formats/segment-store-v2/README.md +++ b/docs/formats/segment-store-v2/README.md @@ -94,8 +94,9 @@ head and the catalog it selects, and refuses superseded candidates, retained stages, replaced protocol directories, and every namespace or capacity violation before mutation, each as a typed `RetentionCurrentStateRefusal`. -Retention publication recovery is implemented and proven in-process for every -crash prefix; its `KEEP-CRASH-036..052` process-death evidence is not. +Retention publication recovery is implemented and proven both in-process for +every crash prefix and by the crash matrix, which kills a real writer before, +during, and after `KEEP-CRASH-036` through `052`. Not implemented: partial-prefix migration recovery and `KEEP-CRASH-053..073`, the reader fence, model-based transition evidence, and garbage collection. Issue #19 owns the first four and issue #21 the last; issue #97 owns the restart-stable root identity coordinate. A version-1 store diff --git a/docs/formats/segment-store-v2/recovery.md b/docs/formats/segment-store-v2/recovery.md index d4449ad6..15049a1c 100644 --- a/docs/formats/segment-store-v2/recovery.md +++ b/docs/formats/segment-store-v2/recovery.md @@ -233,8 +233,9 @@ The retention crash points are: `RetentionPublicationPhase::ALL` freezes this exact order as a typed public vocabulary. `FilesystemRetentionPublicationAuthority::recover` implements the -classification above and its effects; process-death evidence remains -unimplemented. +classification above and its effects, and the crash matrix kills a real +writer before, during, and after every point and requires restart to recover +to the documented state. Each point requires before, during, and after process-death evidence. Restart must establish exact catalog visibility, retention head, namespace generation, diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index b604f9eb..6d1d3189 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -15,7 +15,7 @@ case is not evidence. | `KEEP-RETENTION-004` | Retain and release compare expected and observed generations and publish exact successors only | unforgeable readiness and preflight proofs in `tests/retention_transition.rs` and `tests/retention_preflight.rs`; exact successor preparation and complete receipt evidence in `tests/retention_publication_preparation.rs` and `tests/retention_publication_execution.rs`; writer-locked initial filesystem publication in `filesystem_retention_storage_tests`; observed-head successor publication, exact predecessor binding, and absent-head refusal in `filesystem_retention_successor_tests`; the store's catalog head must name the closure's catalog generation and digest before any forward write in `filesystem_retention_catalog_tests`; a head whose predecessor disagrees with its manifest refuses in `filesystem_retention_current_tests`; a successor reopens and decodes the manifest-selected predecessor root and refuses an absent or changed one in `filesystem_retention_expectation_tests` | Implemented | | `KEEP-RETENTION-005` | Closure derivation is deterministic, bounded, cycle-safe, fail-closed, and verifies complete blob reconstruction | exact accounting, reconstruction, adversarial-catalog, and exhaustive model laws in `tests/retention_closure.rs`; corrupt members refuse through the inherited segment-record admission laws and seeded `segment_format` fuzz target routed by `closure-corruption.md` | Implemented | | `KEEP-RETENTION-006` | Publication follows the exact ordered durability protocol, including new namespace-directory admission and retention of fixed-stage evidence until head commit, and returns only after cleanup synchronization | typed vocabulary and blocking port in `tests/retention_publication_phase.rs` and `tests/retention_publication_storage.rs`; ordered execution, conditional namespace sync, and all 17 exact storage-fault boundaries in `tests/retention_publication_execution.rs`; production 17-phase forward filesystem execution, exclusive staging, byte-equal inode-substitution refusal, and retained-stage recovery refusal in `filesystem_retention_storage_tests`; orphan namespace directories count against the 4,096 ceiling and refuse a new namespace before any stage is written in `filesystem_retention_capacity_tests`; crash injection remains | In progress in #19 | -| `KEEP-RETENTION-007` | Restart resolves every fixed-stage crash prefix to one documented lawful state or typed ambiguity | recovery-required refusals before any mutation in `filesystem_retention_expectation_tests`: an absent head over populated pools, a non-initial head prepared against an absent head, an orphan directory for a namespace expected absent, and an absent directory for a namespace expected current; debug and release crash matrix remains; replaced protocol directories, an absent or changed head-selected catalog, an over-full census, zero-generation pool names, and a stage retained by a failed write refuse in `filesystem_retention_*_tests`; storage-independent classification of every fixed-stage crash prefix (discard, link and protect, finalize, clean up, or typed refusal) in `recovery_planner_tests`; every publication prefix 0 through 18, each mid-write truncation, and successor prefixes recover in-process to the documented state, idempotently, with the forward retry reporting the predicted outcome, in `filesystem_retention_recovery_prefix_tests` | In progress in #19 | +| `KEEP-RETENTION-007` | Restart resolves every fixed-stage crash prefix to one documented lawful state or typed ambiguity | recovery-required refusals before any mutation in `filesystem_retention_expectation_tests`: an absent head over populated pools, a non-initial head prepared against an absent head, an orphan directory for a namespace expected absent, and an absent directory for a namespace expected current; replaced protocol directories, an absent or changed head-selected catalog, an over-full census, zero-generation pool names, and a stage retained by a failed write refuse in `filesystem_retention_*_tests`; storage-independent classification of every fixed-stage crash prefix (discard, link and protect, finalize, clean up, or typed refusal) in `recovery_planner_tests`; every publication prefix 0 through 18, each mid-write truncation, and successor prefixes recover in-process to the documented state, idempotently, with the forward retry reporting the predicted outcome, in `filesystem_retention_recovery_prefix_tests`; `cargo xtask durability-crash-matrix` kills a real writer before, during, and after `KEEP-CRASH-036` through `052` and requires restart recovery to reach the documented state and the forward retry to report the predicted outcome | Implemented | | `KEEP-RETENTION-008` | Readers double-collect catalog and retention heads and bind one complete catalog, manifest, and root-generation view under a `ReaderFence` | immutable snapshot and concurrency tests | Planned in #19 | | `KEEP-RETENTION-009` | Exact already-committed retry is idempotent only while its successor remains current | byte-identical planning in `tests/retention_transition.rs`; authority-revalidated zero-mutation retry receipt in `tests/retention_publication_execution.rs`; exact already-committed filesystem retry with a byte-identical retention witness in `filesystem_retention_storage_tests`; superseded-candidate filesystem refusal with zero mutation in `filesystem_retention_successor_tests`; committed retry reopens the head-selected manifest entry and root pool bytes, refusing absent, changed, or corrupt evidence in `filesystem_retention_current_tests`; every refusal is a typed `RetentionCurrentStateRefusal` source, with superseded, committed-root-absent, committed-root-changed, and head-absent-with-artifacts pinned by downcast | Implemented | | `KEEP-RETENTION-010` | Model operation sequences agree with a deterministic namespace-to-anchor-set map and never admit caller identity, paths, clocks, or application policy | model-based and source-architecture tests | Planned in #19 | diff --git a/src/adapters/filesystem_version_two_admission.rs b/src/adapters/filesystem_version_two_admission.rs index 2303c5a7..5d031991 100644 --- a/src/adapters/filesystem_version_two_admission.rs +++ b/src/adapters/filesystem_version_two_admission.rs @@ -3,7 +3,7 @@ use std::path::Path; use cap_fs_ext::DirExt; -#[cfg(test)] +#[cfg(any(test, feature = "repository-tasks"))] use cap_std::ambient_authority; use cap_std::fs::Dir; @@ -64,6 +64,25 @@ impl FilesystemVersionTwoAdmission { } /// Releases the writer lock and the three pinned retention capabilities. + /// Reopens a migrated root without platform admission for repository tasks. + /// + /// The crash matrix and other repository tools run on hosts outside the + /// admitted Linux profile; every namespace, record, and identity law still + /// applies. Production callers use [`Self::reopen`]. + /// + /// # Errors + /// + /// Returns [`FilesystemPlatformAdmissionError`] exactly as [`Self::reopen`] + /// does for every boundary after platform admission. + #[cfg(feature = "repository-tasks")] + pub fn reopen_unchecked_for_repository_tasks( + store_root: &Path, + ) -> Result { + let root = Dir::open_ambient_dir(store_root, ambient_authority()) + .map_err(|source| FilesystemPlatformAdmissionError::Platform { source })?; + Self::admit(root) + } + pub(super) fn into_parts(self) -> (FilesystemWriterLock, Dir, Dir, Dir) { (self.lock, self.retention, self.roots, self.manifests) } diff --git a/src/adapters/store_migration/filesystem_migration_authority.rs b/src/adapters/store_migration/filesystem_migration_authority.rs index e894bcb4..2277bfd0 100644 --- a/src/adapters/store_migration/filesystem_migration_authority.rs +++ b/src/adapters/store_migration/filesystem_migration_authority.rs @@ -70,6 +70,26 @@ impl FilesystemStoreMigrationAuthority { }) } + /// Pins a root for migration without platform admission for repository tasks. + /// + /// Repository tools such as the crash matrix run on hosts outside the + /// admitted Linux profile; namespace, head, catalog, inventory, and record + /// laws still apply in full. Production callers use [`Self::open`]. + /// + /// # Errors + /// + /// Returns [`FilesystemMigrationAuthorityError`](super::FilesystemMigrationAuthorityError) + /// when the root identity cannot be read or the pools cannot be pinned. + #[cfg(feature = "repository-tasks")] + pub fn open_unchecked_for_repository_tasks( + lock: crate::adapters::FilesystemWriterLock, + policy: SegmentReadPolicy, + ) -> Result { + let admission = FilesystemPlatformAdmission::unchecked_for_repository_tasks(lock) + .map_err(|source| Error::RootIdentity { source })?; + Self::open(admission, policy) + } + /// Observes one canonical intent from exact current version-1 authority. /// /// The synchronous call admits the exact published root namespace, physical diff --git a/xtask/src/durability_crash_matrix/production_protocol.rs b/xtask/src/durability_crash_matrix/production_protocol.rs index 6453a94c..fa842b26 100644 --- a/xtask/src/durability_crash_matrix/production_protocol.rs +++ b/xtask/src/durability_crash_matrix/production_protocol.rs @@ -8,6 +8,8 @@ mod publication; mod publication_storage; mod recovery; mod recovery_storage; +pub(super) mod retention; +mod retention_storage; mod segment_stage; use std::error::Error; @@ -41,6 +43,9 @@ pub(super) fn run( DurabilityCrashSequence::RecoveryDiscard => { recovery::run(&store_root, &mut control)?; } + DurabilityCrashSequence::Retention => { + retention::run(&store_root, &mut control)?; + } } Err(DurabilityCrashMatrixError::PointSequenceMismatch { point: case.point(), diff --git a/xtask/src/durability_crash_matrix/production_protocol/fixture.rs b/xtask/src/durability_crash_matrix/production_protocol/fixture.rs index 728f7dde..4f6f1b08 100644 --- a/xtask/src/durability_crash_matrix/production_protocol/fixture.rs +++ b/xtask/src/durability_crash_matrix/production_protocol/fixture.rs @@ -11,6 +11,20 @@ const SEGMENT_HEX: &str = const CATALOG_HEX: &str = include_str!("../../../../conformance/segment-store/v1/one-zero-catalog.hex"); const HEAD_HEX: &str = include_str!("../../../../conformance/segment-store/v1/one-zero-head.hex"); +const BUNDLE_SEGMENT_HEX: &str = + include_str!("../../../../conformance/segment-store/v1/one-zero-bundle-segment.hex"); +const BUNDLE_CATALOG_HEX: &str = + include_str!("../../../../conformance/segment-store/v1/one-zero-bundle-catalog.hex"); +const BUNDLE_HEAD_HEX: &str = + include_str!("../../../../conformance/segment-store/v1/one-zero-bundle-head.hex"); +const RETENTION_ROOT_HEX: &str = + include_str!("../../../../conformance/segment-store/v2/one-anchor-root.hex"); +/// Pool name of the bundle segment the retention root's closure references. +pub(in crate::durability_crash_matrix) const BUNDLE_SEGMENT_NAME: &str = + "221f6745cd8a5221c9a87c3707593608479282b54a4a74d0e753fd76f70e8db2.seg"; +/// Pool name of the generation-one bundle catalog. +pub(in crate::durability_crash_matrix) const BUNDLE_CATALOG_NAME: &str = + "0000000000000001-0b7cad1b6de663d34beacbc214db7497f2e36ab6b08dfbd5febbc8d06a418811.cat"; pub(in crate::durability_crash_matrix) const SEGMENT_POOL_PATH: &str = "segments/b7542dced2ab770894a14d1d04b066e3a899942602c5986d35ba6df6c1a35cfc.seg"; @@ -31,6 +45,26 @@ impl GoldenFixture { Self::decode("catalog", CATALOG_HEX, 352) } + pub(in crate::durability_crash_matrix) fn bundle_segment() + -> Result { + Self::decode("bundle segment", BUNDLE_SEGMENT_HEX, 701) + } + + pub(in crate::durability_crash_matrix) fn bundle_catalog() + -> Result { + Self::decode("bundle catalog", BUNDLE_CATALOG_HEX, 512) + } + + pub(in crate::durability_crash_matrix) fn bundle_head() + -> Result { + Self::decode("bundle head", BUNDLE_HEAD_HEX, 128) + } + + pub(in crate::durability_crash_matrix) fn retention_root() + -> Result { + Self::decode("retention root", RETENTION_ROOT_HEX, 378) + } + pub(in crate::durability_crash_matrix) fn head() -> Result { Self::decode("head", HEAD_HEX, 128) } diff --git a/xtask/src/durability_crash_matrix/production_protocol/initialization.rs b/xtask/src/durability_crash_matrix/production_protocol/initialization.rs index 6bd54667..97701738 100644 --- a/xtask/src/durability_crash_matrix/production_protocol/initialization.rs +++ b/xtask/src/durability_crash_matrix/production_protocol/initialization.rs @@ -25,16 +25,23 @@ pub(super) fn run( .map_err(|source| verification("execute production store initialization", source)) } -pub(super) fn publisher( +/// Initializes a fresh store and returns its retained writer lock. +pub(super) fn initialized_lock( store_root: &Path, -) -> Result { +) -> Result { let mut storage = RepositoryInitializationStorage::admit_unchecked(store_root) .map_err(|source| DurabilityCrashMatrixError::io("open initialization storage", source))?; let _receipt = initialize_store(&mut storage) .map_err(|source| verification("initialize production crash store", source))?; - let lock = storage.into_writer_lock().map_err(|source| { - DurabilityCrashMatrixError::io("retain initialized writer lock", source) - })?; + storage + .into_writer_lock() + .map_err(|source| DurabilityCrashMatrixError::io("retain initialized writer lock", source)) +} + +pub(super) fn publisher( + store_root: &Path, +) -> Result { + let lock = initialized_lock(store_root)?; FilesystemCatalogPublisher::open_unchecked_for_repository_tasks(lock, restart_policy()?) .map_err(|source| DurabilityCrashMatrixError::io("open crash catalog publisher", source)) } diff --git a/xtask/src/durability_crash_matrix/production_protocol/retention.rs b/xtask/src/durability_crash_matrix/production_protocol/retention.rs new file mode 100644 index 00000000..486f54ba --- /dev/null +++ b/xtask/src/durability_crash_matrix/production_protocol/retention.rs @@ -0,0 +1,114 @@ +//! This module owns execution of the production retention publication protocol. + +use std::fs; +use std::path::Path; + +use keep::{ + AdmittedCatalog, AdmittedRetentionRoot, AdmittedSegment, ChecksummedCatalog, + ChecksummedPublicationHead, FilesystemRetentionPublicationAuthority, + FilesystemStoreMigrationAuthority, FilesystemVersionTwoAdmission, + RetentionGenerationExpectation, RetentionPublicationPreparation, execute_retention_publication, + execute_store_migration, preflight_retention_transition, prepare_retention_publication, +}; + +use super::control::CrashControl; +use super::fixture::{BUNDLE_CATALOG_NAME, BUNDLE_SEGMENT_NAME, GoldenFixture}; +use super::initialization; +use super::retention_storage::CrashRetentionStorage; +use super::{DurabilityCrashMatrixError, verification}; + +/// Migrates a fresh bundle store and publishes retention generation one, +/// dying at the selected coordinate. +pub(super) fn run( + store_root: &Path, + control: &mut CrashControl, +) -> Result<(), DurabilityCrashMatrixError> { + let authority = migrated_authority(store_root)?; + let root = GoldenFixture::retention_root()?; + let preparation = preparation(root.bytes())?; + let mut storage = CrashRetentionStorage::new(authority, control, store_root); + execute_retention_publication(&mut storage, &preparation) + .map(|_receipt| ()) + .map_err(|source| verification("execute production retention publication", source)) +} + +/// Initializes, populates, and migrates the bundle store, then reopens it as +/// version two and returns retention authority over it. +fn migrated_authority( + store_root: &Path, +) -> Result { + let lock = initialization::initialized_lock(store_root)?; + write_bundle(store_root)?; + let mut migration = FilesystemStoreMigrationAuthority::open_unchecked_for_repository_tasks( + lock, + initialization::segment_policy(), + ) + .map_err(|source| verification("open crash migration authority", source))?; + let intent = migration + .observe_intent() + .map_err(|source| verification("observe crash migration intent", source))?; + let _receipt = execute_store_migration(&mut migration, &intent) + .map_err(|source| verification("execute crash store migration", source))?; + drop(migration); + reopened_authority(store_root) +} + +/// Reopens the migrated store and returns retention authority over it. +pub(in crate::durability_crash_matrix) fn reopened_authority( + store_root: &Path, +) -> Result { + let admission = + FilesystemVersionTwoAdmission::reopen_unchecked_for_repository_tasks(store_root) + .map_err(|source| verification("reopen crash store as version two", source))?; + FilesystemRetentionPublicationAuthority::open(admission) + .map_err(|source| verification("open crash retention authority", source)) +} + +fn write_bundle(store_root: &Path) -> Result<(), DurabilityCrashMatrixError> { + let segment = GoldenFixture::bundle_segment()?; + let catalog = GoldenFixture::bundle_catalog()?; + let head = GoldenFixture::bundle_head()?; + for (relative, bytes) in [ + (format!("segments/{BUNDLE_SEGMENT_NAME}"), segment.bytes()), + (format!("catalogs/{BUNDLE_CATALOG_NAME}"), catalog.bytes()), + ("HEAD".to_owned(), head.bytes()), + ] { + fs::write(store_root.join(&relative), bytes) + .map_err(|source| DurabilityCrashMatrixError::io("write bundle corpus", source))?; + } + Ok(()) +} + +/// Prepares the frozen generation-one root as an initial publication against +/// the bundle catalog snapshot. +pub(in crate::durability_crash_matrix) fn preparation( + root_bytes: &[u8], +) -> Result, DurabilityCrashMatrixError> { + let segment_fixture = GoldenFixture::bundle_segment()?; + let catalog_fixture = GoldenFixture::bundle_catalog()?; + let head_fixture = GoldenFixture::bundle_head()?; + let candidate = AdmittedRetentionRoot::decode(root_bytes) + .map_err(|source| verification("decode crash retention root", source))?; + let segment = + AdmittedSegment::decode(segment_fixture.bytes(), initialization::segment_policy()) + .map_err(|source| verification("admit bundle segment", source))?; + let segments = [segment]; + let catalog: AdmittedCatalog<'_, '_> = ChecksummedCatalog::decode(catalog_fixture.bytes()) + .map_err(|source| verification("decode bundle catalog", source))? + .admit(&segments) + .map_err(|source| verification("admit bundle catalog", source))?; + let head = ChecksummedPublicationHead::decode(head_fixture.bytes()) + .map_err(|source| verification("decode bundle head", source))?; + let snapshot = head + .admit(catalog) + .map_err(|source| verification("admit bundle snapshot", source))?; + let preflight = preflight_retention_transition( + RetentionGenerationExpectation::Absent, + None, + candidate, + &snapshot, + ) + .map_err(|source| verification("preflight crash retention transition", source))?; + prepare_retention_publication(preflight, None) + .map_err(|source| verification("prepare crash retention publication", source)) +} diff --git a/xtask/src/durability_crash_matrix/production_protocol/retention_storage.rs b/xtask/src/durability_crash_matrix/production_protocol/retention_storage.rs new file mode 100644 index 00000000..3d8bfa2a --- /dev/null +++ b/xtask/src/durability_crash_matrix/production_protocol/retention_storage.rs @@ -0,0 +1,229 @@ +//! This module owns crash injection around production retention publication. + +use std::fs::OpenOptions; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use keep::{ + AdmittedRetentionRoot, CanonicalRetentionHead, CanonicalRetentionManifest, + FilesystemRetentionPublicationAuthority, RetentionNamespaceAdmission, + RetentionPublicationPreparation, RetentionPublicationStorage, RetentionTransitionDisposition, +}; +use xtask::{DurabilityCrashPoint, DurabilityCrashPosition}; + +use super::control::{CrashControl, DuringTiming}; + +/// Bytes an interrupted stage write leaves behind: inside every record's +/// fixed framing, so restart classifies the stage as truncated. +const STAGE_INTERRUPTION: usize = 100; + +pub(super) struct CrashRetentionStorage<'control> { + inner: FilesystemRetentionPublicationAuthority, + control: &'control mut CrashControl, + retention: PathBuf, +} + +impl<'control> CrashRetentionStorage<'control> { + pub(super) fn new( + inner: FilesystemRetentionPublicationAuthority, + control: &'control mut CrashControl, + store_root: &Path, + ) -> Self { + Self { + inner, + control, + retention: store_root.join("retention"), + } + } + + fn execute( + &mut self, + point: DurabilityCrashPoint, + during: DuringTiming, + operation: impl FnOnce(&mut FilesystemRetentionPublicationAuthority) -> io::Result, + ) -> io::Result { + self.control.before(point, during)?; + let result = operation(&mut self.inner)?; + self.control.after(point, during)?; + Ok(result) + } + + fn execute_write( + &mut self, + point: DurabilityCrashPoint, + stage: &str, + bytes: &[u8], + complete: impl FnOnce(&mut FilesystemRetentionPublicationAuthority) -> io::Result<()>, + ) -> io::Result<()> { + match self.control.position(point) { + None => complete(&mut self.inner), + Some(DurabilityCrashPosition::Before) => self.control.await_process_death(), + Some(DurabilityCrashPosition::During) => { + let partial = bytes.get(..STAGE_INTERRUPTION).ok_or_else(|| { + io::Error::other("retention record shorter than the interruption prefix") + })?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(self.retention.join(stage))?; + file.write_all(partial)?; + self.control.await_process_death() + } + Some(DurabilityCrashPosition::After) => { + complete(&mut self.inner)?; + self.control.await_process_death() + } + } + } +} + +impl RetentionPublicationStorage for CrashRetentionStorage<'_> { + fn verify_current( + &mut self, + preparation: &RetentionPublicationPreparation<'_>, + ) -> io::Result { + self.inner.verify_current(preparation) + } + + fn write_root_stage(&mut self, root: &AdmittedRetentionRoot<'_>) -> io::Result<()> { + self.execute_write( + DurabilityCrashPoint::WriteRootStage, + "root.next", + root.encoded(), + |inner| inner.write_root_stage(root), + ) + } + + fn synchronize_root_stage(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::SynchronizeRootStage, + DuringTiming::Before, + FilesystemRetentionPublicationAuthority::synchronize_root_stage, + ) + } + + fn admit_root_namespace( + &mut self, + root: &AdmittedRetentionRoot<'_>, + ) -> io::Result { + self.execute( + DurabilityCrashPoint::AdmitRootNamespace, + DuringTiming::After, + |inner| inner.admit_root_namespace(root), + ) + } + + fn synchronize_roots_after_namespace(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::SynchronizeRootsAfterNamespace, + DuringTiming::Before, + FilesystemRetentionPublicationAuthority::synchronize_roots_after_namespace, + ) + } + + fn link_root(&mut self, root: &AdmittedRetentionRoot<'_>) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::LinkRoot, + DuringTiming::After, + |inner| inner.link_root(root), + ) + } + + fn synchronize_root_namespace(&mut self, root: &AdmittedRetentionRoot<'_>) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::SynchronizeRootNamespace, + DuringTiming::Before, + |inner| inner.synchronize_root_namespace(root), + ) + } + + fn write_manifest_stage(&mut self, manifest: &CanonicalRetentionManifest) -> io::Result<()> { + self.execute_write( + DurabilityCrashPoint::WriteManifestStage, + "manifest.next", + manifest.encoded(), + |inner| inner.write_manifest_stage(manifest), + ) + } + + fn synchronize_manifest_stage(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::SynchronizeManifestStage, + DuringTiming::Before, + FilesystemRetentionPublicationAuthority::synchronize_manifest_stage, + ) + } + + fn link_manifest(&mut self, manifest: &CanonicalRetentionManifest) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::LinkManifest, + DuringTiming::After, + |inner| inner.link_manifest(manifest), + ) + } + + fn synchronize_manifest_pool(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::SynchronizeManifestPool, + DuringTiming::Before, + FilesystemRetentionPublicationAuthority::synchronize_manifest_pool, + ) + } + + fn write_head_stage(&mut self, head: &CanonicalRetentionHead) -> io::Result<()> { + self.execute_write( + DurabilityCrashPoint::WriteHeadStage, + "head.next", + head.encoded(), + |inner| inner.write_head_stage(head), + ) + } + + fn synchronize_head_stage(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::SynchronizeHeadStage, + DuringTiming::Before, + FilesystemRetentionPublicationAuthority::synchronize_head_stage, + ) + } + + fn replace_head(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::ReplaceRetentionHead, + DuringTiming::After, + FilesystemRetentionPublicationAuthority::replace_head, + ) + } + + fn synchronize_retention_namespace(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::SynchronizeRetentionNamespace, + DuringTiming::Before, + FilesystemRetentionPublicationAuthority::synchronize_retention_namespace, + ) + } + + fn remove_root_stage(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::RemoveRootStage, + DuringTiming::After, + FilesystemRetentionPublicationAuthority::remove_root_stage, + ) + } + + fn remove_manifest_stage(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::RemoveManifestStage, + DuringTiming::After, + FilesystemRetentionPublicationAuthority::remove_manifest_stage, + ) + } + + fn synchronize_cleanup(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::SynchronizeRetentionCleanup, + DuringTiming::Before, + FilesystemRetentionPublicationAuthority::synchronize_cleanup, + ) + } +} diff --git a/xtask/src/durability_crash_matrix/restart.rs b/xtask/src/durability_crash_matrix/restart.rs index 423efc81..aedabe8f 100644 --- a/xtask/src/durability_crash_matrix/restart.rs +++ b/xtask/src/durability_crash_matrix/restart.rs @@ -1,6 +1,7 @@ //! This module owns independent post-process-death store verification. mod expectation; +mod retention; mod semantic; use std::collections::BTreeSet; @@ -17,6 +18,9 @@ pub(super) fn verify( store_root: &Path, case: DurabilityCrashCase, ) -> Result<(), DurabilityCrashMatrixError> { + if case.point().sequence() == xtask::DurabilityCrashSequence::Retention { + return retention::verify(store_root, case); + } let expected = ExpectedStoreState::for_case(case)?; let observed_paths = inventory(store_root)?; if observed_paths != expected.paths() { diff --git a/xtask/src/durability_crash_matrix/restart/expectation.rs b/xtask/src/durability_crash_matrix/restart/expectation.rs index 1b6be39c..0d000ca6 100644 --- a/xtask/src/durability_crash_matrix/restart/expectation.rs +++ b/xtask/src/durability_crash_matrix/restart/expectation.rs @@ -64,6 +64,11 @@ impl ExpectedStoreState { DurabilityCrashSequence::Head => sequence::head(case), DurabilityCrashSequence::RecoveryDiscard => sequence::recovery(case), DurabilityCrashSequence::Initialization => sequence::initialization(case), + DurabilityCrashSequence::Retention => { + Err(DurabilityCrashMatrixError::PointSequenceMismatch { + point: case.point(), + }) + } } } diff --git a/xtask/src/durability_crash_matrix/restart/retention.rs b/xtask/src/durability_crash_matrix/restart/retention.rs new file mode 100644 index 00000000..e210a18e --- /dev/null +++ b/xtask/src/durability_crash_matrix/restart/retention.rs @@ -0,0 +1,162 @@ +//! This module owns post-process-death verification of retention publication. +//! +//! After the child dies at its coordinate, restart reopens the migrated store +//! through the same admission a production caller would use, runs retention +//! recovery, and requires the documented steps and outcome for that exact +//! prefix; then it requires the forward retry to report the outcome recovery +//! predicts. + +use std::io; +use std::path::Path; + +use keep::{ + RetentionCurrentStateRefusal, RetentionPublicationError, RetentionPublicationOutcome, + RetentionRecoveryOutcome as Outcome, RetentionRecoveryStep as Step, + execute_retention_publication, +}; +use xtask::{DurabilityCrashCase, DurabilityCrashPoint, DurabilityCrashPosition}; + +use super::super::DurabilityCrashMatrixError; +use super::super::production_protocol::fixture::GoldenFixture; +use super::super::production_protocol::retention::{preparation, reopened_authority}; +use super::super::production_protocol::verification; + +const PROTECTED_ROOT: Outcome = Outcome::Protected { + root_stage: true, + manifest_stage: false, +}; +const PROTECTED_BOTH: Outcome = Outcome::Protected { + root_stage: true, + manifest_stage: true, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Retry { + Published, + AlreadyCommitted, + Refused, +} + +pub(super) fn verify( + store_root: &Path, + case: DurabilityCrashCase, +) -> Result<(), DurabilityCrashMatrixError> { + let (steps, outcome, retry) = expected(case); + let mut authority = reopened_authority(store_root)?; + let receipt = authority + .recover() + .map_err(|source| verification("recover crash retention stages", source))?; + if receipt.executed() != steps.as_slice() || receipt.outcome() != outcome { + return Err(mismatch(format!( + "{} {:?}: expected {steps:?} -> {outcome:?}, recovered {:?} -> {:?}", + case.point().identifier(), + case.position(), + receipt.executed(), + receipt.outcome() + ))); + } + let root = GoldenFixture::retention_root()?; + let preparation = preparation(root.bytes())?; + match ( + retry, + execute_retention_publication(&mut authority, &preparation), + ) { + (Retry::Published, Ok(receipt)) + if receipt.outcome() == RetentionPublicationOutcome::Published => {} + (Retry::AlreadyCommitted, Ok(receipt)) + if receipt.outcome() == RetentionPublicationOutcome::AlreadyCommitted => {} + (Retry::Refused, Err(RetentionPublicationError::CurrentVerification { source })) + if source + .get_ref() + .and_then(|refusal| refusal.downcast_ref::()) + .is_some_and(|refusal| { + matches!(refusal, RetentionCurrentStateRefusal::RetainedStage) + }) => {} + (retry, result) => { + return Err(mismatch(format!( + "{} {:?}: expected forward retry {retry:?}, got {result:?}", + case.point().identifier(), + case.position() + ))); + } + } + Ok(()) +} + +fn mismatch(message: String) -> DurabilityCrashMatrixError { + verification("verify crash retention recovery", io::Error::other(message)) +} + +/// The number of completed publication phases and any truncated stage the +/// coordinate leaves behind. +fn prefix(case: DurabilityCrashCase) -> (usize, Option) { + let index = DurabilityCrashPoint::ALL + .iter() + .position(|point| *point == case.point()) + .and_then(|index| index.checked_sub(35)) + .unwrap_or(0); + // Phase 1 is current-state verification; point n is phase n + 2. + let phase = index.saturating_add(2); + let write = match case.point() { + DurabilityCrashPoint::WriteRootStage => Some(Step::DiscardRootStage), + DurabilityCrashPoint::WriteManifestStage => Some(Step::DiscardManifestStage), + DurabilityCrashPoint::WriteHeadStage => Some(Step::DiscardHeadStage), + _ => None, + }; + match case.position() { + DurabilityCrashPosition::After => (phase, None), + DurabilityCrashPosition::During if write.is_some() => (phase.saturating_sub(1), write), + DurabilityCrashPosition::During if atomic(case.point()) => (phase, None), + DurabilityCrashPosition::Before | DurabilityCrashPosition::During => { + (phase.saturating_sub(1), None) + } + } +} + +const fn atomic(point: DurabilityCrashPoint) -> bool { + matches!( + point, + DurabilityCrashPoint::AdmitRootNamespace + | DurabilityCrashPoint::LinkRoot + | DurabilityCrashPoint::LinkManifest + | DurabilityCrashPoint::ReplaceRetentionHead + | DurabilityCrashPoint::RemoveRootStage + | DurabilityCrashPoint::RemoveManifestStage + ) +} + +/// The documented recovery for the prefix a coordinate leaves behind. +fn expected(case: DurabilityCrashCase) -> (Vec, Outcome, Retry) { + let (count, truncated) = prefix(case); + let (mut steps, outcome, retry) = match count { + 0 | 1 => (vec![], Outcome::Clean, Retry::Published), + 2..=5 => (vec![Step::LinkRoot], PROTECTED_ROOT, Retry::Refused), + 6 | 7 => (vec![], PROTECTED_ROOT, Retry::Refused), + 8 | 9 => (vec![Step::LinkManifest], PROTECTED_BOTH, Retry::Refused), + 10 | 11 => (vec![], PROTECTED_BOTH, Retry::Refused), + 12 | 13 => ( + vec![ + Step::FinalizeHead, + Step::RemoveRootStage, + Step::RemoveManifestStage, + ], + Outcome::Committed, + Retry::AlreadyCommitted, + ), + 14 | 15 => ( + vec![Step::RemoveRootStage, Step::RemoveManifestStage], + Outcome::Committed, + Retry::AlreadyCommitted, + ), + 16 => ( + vec![Step::RemoveManifestStage], + Outcome::Committed, + Retry::AlreadyCommitted, + ), + _ => (vec![], Outcome::Clean, Retry::AlreadyCommitted), + }; + if let Some(discard) = truncated { + steps.insert(0, discard); + } + (steps, outcome, retry) +} diff --git a/xtask/src/durability_crash_point.rs b/xtask/src/durability_crash_point.rs index 2ed8d6a5..bbb01a28 100644 --- a/xtask/src/durability_crash_point.rs +++ b/xtask/src/durability_crash_point.rs @@ -13,6 +13,8 @@ pub enum DurabilityCrashSequence { RecoveryDiscard, /// Writer-locked store initialization. Initialization, + /// Version-two retention publication, `KEEP-CRASH-036` through `052`. + Retention, } /// One stable process-death boundary in the durable segment-store protocol. @@ -88,11 +90,45 @@ pub enum DurabilityCrashPoint { CreateCatalogPoolDirectory, /// Synchronize the store root after initialization. SynchronizeRootAfterInitialization, + /// Retention root stage write. + WriteRootStage, + /// Retention root stage synchronization. + SynchronizeRootStage, + /// New namespace-directory creation or exact admission. + AdmitRootNamespace, + /// Namespace-pool synchronization after creation. + SynchronizeRootsAfterNamespace, + /// Immutable root link. + LinkRoot, + /// Root namespace-directory synchronization. + SynchronizeRootNamespace, + /// Retention manifest stage write. + WriteManifestStage, + /// Retention manifest stage synchronization. + SynchronizeManifestStage, + /// Immutable manifest link. + LinkManifest, + /// Manifest pool synchronization. + SynchronizeManifestPool, + /// Retention-head stage write. + WriteHeadStage, + /// Retention-head stage synchronization. + SynchronizeHeadStage, + /// Retention-head atomic replacement. + ReplaceRetentionHead, + /// Committed retention namespace synchronization. + SynchronizeRetentionNamespace, + /// Retained root-stage removal. + RemoveRootStage, + /// Retained manifest-stage removal. + RemoveManifestStage, + /// Retention cleanup synchronization. + SynchronizeRetentionCleanup, } impl DurabilityCrashPoint { /// Every crash boundary in stable protocol order. - pub const ALL: [Self; 35] = [ + pub const ALL: [Self; 52] = [ Self::CreateSegmentStage, Self::WriteSegmentHeader, Self::AppendSegmentRecord, @@ -128,6 +164,23 @@ impl DurabilityCrashPoint { Self::CreateSegmentPoolDirectory, Self::CreateCatalogPoolDirectory, Self::SynchronizeRootAfterInitialization, + Self::WriteRootStage, + Self::SynchronizeRootStage, + Self::AdmitRootNamespace, + Self::SynchronizeRootsAfterNamespace, + Self::LinkRoot, + Self::SynchronizeRootNamespace, + Self::WriteManifestStage, + Self::SynchronizeManifestStage, + Self::LinkManifest, + Self::SynchronizeManifestPool, + Self::WriteHeadStage, + Self::SynchronizeHeadStage, + Self::ReplaceRetentionHead, + Self::SynchronizeRetentionNamespace, + Self::RemoveRootStage, + Self::RemoveManifestStage, + Self::SynchronizeRetentionCleanup, ]; /// Parses one exact stable crash identifier. @@ -177,6 +230,23 @@ impl DurabilityCrashPoint { | Self::CreateSegmentPoolDirectory | Self::CreateCatalogPoolDirectory | Self::SynchronizeRootAfterInitialization => DurabilityCrashSequence::Initialization, + Self::WriteRootStage + | Self::SynchronizeRootStage + | Self::AdmitRootNamespace + | Self::SynchronizeRootsAfterNamespace + | Self::LinkRoot + | Self::SynchronizeRootNamespace + | Self::WriteManifestStage + | Self::SynchronizeManifestStage + | Self::LinkManifest + | Self::SynchronizeManifestPool + | Self::WriteHeadStage + | Self::SynchronizeHeadStage + | Self::ReplaceRetentionHead + | Self::SynchronizeRetentionNamespace + | Self::RemoveRootStage + | Self::RemoveManifestStage + | Self::SynchronizeRetentionCleanup => DurabilityCrashSequence::Retention, } } diff --git a/xtask/src/durability_crash_point_identity.rs b/xtask/src/durability_crash_point_identity.rs index 173afa74..4ed0bc50 100644 --- a/xtask/src/durability_crash_point_identity.rs +++ b/xtask/src/durability_crash_point_identity.rs @@ -42,6 +42,23 @@ impl DurabilityCrashPoint { Self::CreateSegmentPoolDirectory => "KEEP-CRASH-033", Self::CreateCatalogPoolDirectory => "KEEP-CRASH-034", Self::SynchronizeRootAfterInitialization => "KEEP-CRASH-035", + Self::WriteRootStage => "KEEP-CRASH-036", + Self::SynchronizeRootStage => "KEEP-CRASH-037", + Self::AdmitRootNamespace => "KEEP-CRASH-038", + Self::SynchronizeRootsAfterNamespace => "KEEP-CRASH-039", + Self::LinkRoot => "KEEP-CRASH-040", + Self::SynchronizeRootNamespace => "KEEP-CRASH-041", + Self::WriteManifestStage => "KEEP-CRASH-042", + Self::SynchronizeManifestStage => "KEEP-CRASH-043", + Self::LinkManifest => "KEEP-CRASH-044", + Self::SynchronizeManifestPool => "KEEP-CRASH-045", + Self::WriteHeadStage => "KEEP-CRASH-046", + Self::SynchronizeHeadStage => "KEEP-CRASH-047", + Self::ReplaceRetentionHead => "KEEP-CRASH-048", + Self::SynchronizeRetentionNamespace => "KEEP-CRASH-049", + Self::RemoveRootStage => "KEEP-CRASH-050", + Self::RemoveManifestStage => "KEEP-CRASH-051", + Self::SynchronizeRetentionCleanup => "KEEP-CRASH-052", } } } diff --git a/xtask/tests/durability_crash_point_contract.rs b/xtask/tests/durability_crash_point_contract.rs index 1945b010..61b35436 100644 --- a/xtask/tests/durability_crash_point_contract.rs +++ b/xtask/tests/durability_crash_point_contract.rs @@ -4,7 +4,7 @@ use xtask::{DurabilityCrashPoint, DurabilityCrashSequence}; -use DurabilityCrashSequence::{Catalog, Head, Initialization, RecoveryDiscard, Segment}; +use DurabilityCrashSequence::{Catalog, Head, Initialization, RecoveryDiscard, Retention, Segment}; const EXPECTED: &[(DurabilityCrashPoint, &str, DurabilityCrashSequence)] = &[ ( @@ -162,6 +162,87 @@ const EXPECTED: &[(DurabilityCrashPoint, &str, DurabilityCrashSequence)] = &[ "KEEP-CRASH-035", Initialization, ), + ( + DurabilityCrashPoint::WriteRootStage, + "KEEP-CRASH-036", + Retention, + ), + ( + DurabilityCrashPoint::SynchronizeRootStage, + "KEEP-CRASH-037", + Retention, + ), + ( + DurabilityCrashPoint::AdmitRootNamespace, + "KEEP-CRASH-038", + Retention, + ), + ( + DurabilityCrashPoint::SynchronizeRootsAfterNamespace, + "KEEP-CRASH-039", + Retention, + ), + (DurabilityCrashPoint::LinkRoot, "KEEP-CRASH-040", Retention), + ( + DurabilityCrashPoint::SynchronizeRootNamespace, + "KEEP-CRASH-041", + Retention, + ), + ( + DurabilityCrashPoint::WriteManifestStage, + "KEEP-CRASH-042", + Retention, + ), + ( + DurabilityCrashPoint::SynchronizeManifestStage, + "KEEP-CRASH-043", + Retention, + ), + ( + DurabilityCrashPoint::LinkManifest, + "KEEP-CRASH-044", + Retention, + ), + ( + DurabilityCrashPoint::SynchronizeManifestPool, + "KEEP-CRASH-045", + Retention, + ), + ( + DurabilityCrashPoint::WriteHeadStage, + "KEEP-CRASH-046", + Retention, + ), + ( + DurabilityCrashPoint::SynchronizeHeadStage, + "KEEP-CRASH-047", + Retention, + ), + ( + DurabilityCrashPoint::ReplaceRetentionHead, + "KEEP-CRASH-048", + Retention, + ), + ( + DurabilityCrashPoint::SynchronizeRetentionNamespace, + "KEEP-CRASH-049", + Retention, + ), + ( + DurabilityCrashPoint::RemoveRootStage, + "KEEP-CRASH-050", + Retention, + ), + ( + DurabilityCrashPoint::RemoveManifestStage, + "KEEP-CRASH-051", + Retention, + ), + ( + DurabilityCrashPoint::SynchronizeRetentionCleanup, + "KEEP-CRASH-052", + Retention, + ), ]; #[test] From 4aad28d13022a7c408e0be97e50fb2cdc2b8d736 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 9 Sep 2026 14:35:30 -0700 Subject: [PATCH 07/59] Add: fenced, double-collected reader views of a version-two store Readers had no way to observe a version-two store that could not straddle a publication: nothing held the reader fence the recovery page specifies, and nothing bound the catalog head and the retention head to one instant. ReaderFence acquires a shared kernel lock on reader.lock, verified as a regular zero-length file reached without following links and re-verified after locking, and holds it for the snapshot's lifetime; collection will take the same lock exclusively, so no published root, manifest, or segment can be deleted under a live view. collect_retention_view is storage-independent: it reads both head coordinates, loads the view, reads them again, and accepts only agreement, retrying within a ReaderAttemptLimit and refusing an exhausted limit or an absent catalog. FilesystemRetentionSnapshot admits the root as version two, acquires the fence, collects the catalog snapshot, the retention head, and its manifest through that loop, and verifies each selected root against the manifest on demand while the fence is held. Four scripted-source laws pin the loop (first-attempt acceptance, retry after a publication between the reads, exhaustion, absent catalog). Five filesystem laws pin the fence and the view: an unpublished store binds the catalog and no head; a published generation is read and its root verified byte for byte; a substituted root refuses; two readers share the fence while an exclusive lock waits; a replaced reader.lock refuses. KEEP-RETENTION-008 is Implemented in the ledger; the README's fence gap is closed. Refs #19 --- CHANGELOG.md | 7 + README.md | 6 +- docs/formats/segment-store-v2/README.md | 4 +- docs/formats/segment-store-v2/requirements.md | 2 +- src/adapters/retention.rs | 16 ++ .../filesystem_retention_snapshot.rs | 224 ++++++++++++++++++ .../filesystem_retention_snapshot_error.rs | 63 +++++ .../filesystem_retention_snapshot_tests.rs | 124 ++++++++++ .../retention/reader_attempt_limit.rs | 25 ++ src/adapters/retention/reader_fence.rs | 60 +++++ .../retention/retention_view_collector.rs | 113 +++++++++ .../retention_view_collector_tests.rs | 104 ++++++++ src/lib.rs | 33 +-- 13 files changed, 761 insertions(+), 20 deletions(-) create mode 100644 src/adapters/retention/filesystem_retention_snapshot.rs create mode 100644 src/adapters/retention/filesystem_retention_snapshot_error.rs create mode 100644 src/adapters/retention/filesystem_retention_snapshot_tests.rs create mode 100644 src/adapters/retention/reader_attempt_limit.rs create mode 100644 src/adapters/retention/reader_fence.rs create mode 100644 src/adapters/retention/retention_view_collector.rs create mode 100644 src/adapters/retention/retention_view_collector_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 93f740fd..da0a2f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ after its public API and format compatibility policies are established. ### Added +- `FilesystemRetentionSnapshot` is the version-two reader view: it admits the + root as version two, acquires a shared `ReaderFence` on `reader.lock`, + double-collects the catalog and retention heads around loading through + `collect_retention_view` (bounded by `ReaderAttemptLimit`, refusing an + exhausted limit or an absent catalog), binds the catalog snapshot, the + retention head, and its manifest, and verifies each selected root against + the manifest on demand while the fence is held. - Storage-independent retention recovery planning: `assess_root_stage`, `assess_manifest_stage`, and `assess_head_stage` classify each fixed stage as absent, complete, truncated, or corrupt through the decoders' own diff --git a/README.md b/README.md index cedc6fae..3f5dc4f2 100644 --- a/README.md +++ b/README.md @@ -76,14 +76,14 @@ retry reports already committed. The one state that waits for a human is a complete orphan, a crash between the root link and the head finalization, which stays recovery-protected until explicit disposition lands with garbage collection (#21). The crash matrix proves that recovery by killing real -writer processes at all 51 retention coordinates. Readers have no fence yet -(#19). A version-1 store stays admitted until its owner migrates it. +writer processes at all 51 retention coordinates. Readers hold a shared +fence and double-collect both heads, so a view never straddles a +publication. A version-1 store stays admitted until its owner migrates it. | Gap | Tracked | | --- | --- | | Restart recovery for retention publication and migration | [#19](https://github.com/flyingrobots/keep/issues/19) | | Restart-stable root identity coordinate in the migration intent | [#97](https://github.com/flyingrobots/keep/issues/97) | -| Reader fence binding one consistent catalog + retention snapshot | [#19](https://github.com/flyingrobots/keep/issues/19) | | Precise verification reports at explicit depths | [#20](https://github.com/flyingrobots/keep/issues/20) | | Garbage collection and identity-preserving compaction | [#21](https://github.com/flyingrobots/keep/issues/21) | | Bounded production ingestion through the durable store | [#82](https://github.com/flyingrobots/keep/issues/82) | diff --git a/docs/formats/segment-store-v2/README.md b/docs/formats/segment-store-v2/README.md index e3a82744..65b9e473 100644 --- a/docs/formats/segment-store-v2/README.md +++ b/docs/formats/segment-store-v2/README.md @@ -97,8 +97,10 @@ violation before mutation, each as a typed `RetentionCurrentStateRefusal`. Retention publication recovery is implemented and proven both in-process for every crash prefix and by the crash matrix, which kills a real writer before, during, and after `KEEP-CRASH-036` through `052`. +Readers bind one consistent catalog, retention head, and manifest view under a +shared `ReaderFence` and verify selected roots on demand. Not implemented: partial-prefix migration recovery and `KEEP-CRASH-053..073`, -the reader fence, model-based transition evidence, and garbage collection. Issue #19 owns the first four and issue #21 the last; +model-based transition evidence, and garbage collection. Issue #19 owns the first four and issue #21 the last; issue #97 owns the restart-stable root identity coordinate. A version-1 store remains admitted until its owner migrates it, and the [requirements ledger](requirements.md) is the authority on which requirements diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index 6d1d3189..23368873 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -16,7 +16,7 @@ case is not evidence. | `KEEP-RETENTION-005` | Closure derivation is deterministic, bounded, cycle-safe, fail-closed, and verifies complete blob reconstruction | exact accounting, reconstruction, adversarial-catalog, and exhaustive model laws in `tests/retention_closure.rs`; corrupt members refuse through the inherited segment-record admission laws and seeded `segment_format` fuzz target routed by `closure-corruption.md` | Implemented | | `KEEP-RETENTION-006` | Publication follows the exact ordered durability protocol, including new namespace-directory admission and retention of fixed-stage evidence until head commit, and returns only after cleanup synchronization | typed vocabulary and blocking port in `tests/retention_publication_phase.rs` and `tests/retention_publication_storage.rs`; ordered execution, conditional namespace sync, and all 17 exact storage-fault boundaries in `tests/retention_publication_execution.rs`; production 17-phase forward filesystem execution, exclusive staging, byte-equal inode-substitution refusal, and retained-stage recovery refusal in `filesystem_retention_storage_tests`; orphan namespace directories count against the 4,096 ceiling and refuse a new namespace before any stage is written in `filesystem_retention_capacity_tests`; crash injection remains | In progress in #19 | | `KEEP-RETENTION-007` | Restart resolves every fixed-stage crash prefix to one documented lawful state or typed ambiguity | recovery-required refusals before any mutation in `filesystem_retention_expectation_tests`: an absent head over populated pools, a non-initial head prepared against an absent head, an orphan directory for a namespace expected absent, and an absent directory for a namespace expected current; replaced protocol directories, an absent or changed head-selected catalog, an over-full census, zero-generation pool names, and a stage retained by a failed write refuse in `filesystem_retention_*_tests`; storage-independent classification of every fixed-stage crash prefix (discard, link and protect, finalize, clean up, or typed refusal) in `recovery_planner_tests`; every publication prefix 0 through 18, each mid-write truncation, and successor prefixes recover in-process to the documented state, idempotently, with the forward retry reporting the predicted outcome, in `filesystem_retention_recovery_prefix_tests`; `cargo xtask durability-crash-matrix` kills a real writer before, during, and after `KEEP-CRASH-036` through `052` and requires restart recovery to reach the documented state and the forward retry to report the predicted outcome | Implemented | -| `KEEP-RETENTION-008` | Readers double-collect catalog and retention heads and bind one complete catalog, manifest, and root-generation view under a `ReaderFence` | immutable snapshot and concurrency tests | Planned in #19 | +| `KEEP-RETENTION-008` | Readers double-collect catalog and retention heads and bind one complete catalog, manifest, and root-generation view under a `ReaderFence` | `ReaderFence` holds a shared kernel lock on a verified zero-length `reader.lock`; `collect_retention_view` accepts a view only when both head coordinates agree before and after loading and refuses an exhausted attempt limit (`retention_view_collector_tests`); `FilesystemRetentionSnapshot` binds the catalog snapshot, retention head, and manifest under the fence and verifies each selected root on demand while the fence is held, refusing a substituted root and a replaced fence, and two readers share the fence while an exclusive lock waits (`filesystem_retention_snapshot_tests`) | Implemented | | `KEEP-RETENTION-009` | Exact already-committed retry is idempotent only while its successor remains current | byte-identical planning in `tests/retention_transition.rs`; authority-revalidated zero-mutation retry receipt in `tests/retention_publication_execution.rs`; exact already-committed filesystem retry with a byte-identical retention witness in `filesystem_retention_storage_tests`; superseded-candidate filesystem refusal with zero mutation in `filesystem_retention_successor_tests`; committed retry reopens the head-selected manifest entry and root pool bytes, refusing absent, changed, or corrupt evidence in `filesystem_retention_current_tests`; every refusal is a typed `RetentionCurrentStateRefusal` source, with superseded, committed-root-absent, committed-root-changed, and head-absent-with-artifacts pinned by downcast | Implemented | | `KEEP-RETENTION-010` | Model operation sequences agree with a deterministic namespace-to-anchor-set map and never admit caller identity, paths, clocks, or application policy | model-based and source-architecture tests | Planned in #19 | diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 3dabcda8..84b6c292 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -44,6 +44,10 @@ mod filesystem_retention_recovery_prefix_tests; #[cfg(test)] mod filesystem_retention_recovery_tests; mod filesystem_retention_refusal; +mod filesystem_retention_snapshot; +mod filesystem_retention_snapshot_error; +#[cfg(test)] +mod filesystem_retention_snapshot_tests; mod filesystem_retention_stage; mod filesystem_retention_storage; #[cfg(test)] @@ -98,6 +102,8 @@ mod transition_preflight_error; mod transition_readiness; mod verified_closure; +mod reader_attempt_limit; +mod reader_fence; mod recovery_evidence; mod recovery_execution; #[cfg(test)] @@ -109,6 +115,9 @@ mod recovery_planner_tests; mod recovery_refusal; mod recovery_stage_assessment; mod recovery_storage; +mod retention_view_collector; +#[cfg(test)] +mod retention_view_collector_tests; pub use admitted_manifest::AdmittedRetentionManifest; pub use admitted_root::AdmittedRetentionRoot; pub use canonical_head::CanonicalRetentionHead; @@ -124,6 +133,8 @@ pub use filesystem_retention_authority_error::{ pub use filesystem_retention_current::ObservedRetentionState; pub use filesystem_retention_recovery_error::FilesystemRetentionRecoveryError; pub use filesystem_retention_refusal::RetentionCurrentStateRefusal; +pub use filesystem_retention_snapshot::FilesystemRetentionSnapshot; +pub use filesystem_retention_snapshot_error::FilesystemRetentionSnapshotError; pub use head_decode_error::RetentionHeadDecodeError; pub use manifest_decode_error::RetentionManifestDecodeError; pub use manifest_encode_error::RetentionManifestEncodeError; @@ -137,6 +148,8 @@ pub use publication_preparation::prepare_retention_publication; pub use publication_preparation_error::RetentionPublicationPreparationError; pub use publication_receipt::RetentionPublicationReceipt; pub use publication_storage::RetentionPublicationStorage; +pub use reader_attempt_limit::ReaderAttemptLimit; +pub use reader_fence::ReaderFence; pub use recovery_evidence::{ RetentionPoolEntryObservation, RetentionPoolObservations, RetentionRecoveryEvidence, RetentionStageAssessments, @@ -152,6 +165,9 @@ pub use recovery_stage_assessment::{ RetentionStageAssessment, assess_head_stage, assess_manifest_stage, assess_root_stage, }; pub use recovery_storage::RetentionRecoveryStorage; +pub use retention_view_collector::{ + RetentionViewCoordinates, RetentionViewError, RetentionViewSource, collect_retention_view, +}; pub use root_decode_error::RetentionRootDecodeError; pub use root_encode_error::RetentionRootEncodeError; pub use transition_disposition::RetentionTransitionDisposition; diff --git a/src/adapters/retention/filesystem_retention_snapshot.rs b/src/adapters/retention/filesystem_retention_snapshot.rs new file mode 100644 index 00000000..131947ec --- /dev/null +++ b/src/adapters/retention/filesystem_retention_snapshot.rs @@ -0,0 +1,224 @@ +//! This module owns one fenced, double-collected reader view of a version-two store. + +use std::io; +use std::path::{Path, PathBuf}; + +use cap_fs_ext::DirExt; +use cap_std::fs::Dir; + +use super::filesystem_retention_current::{self, ObservedRetentionState}; +use super::filesystem_retention_pool_name as pool_name; +use super::{ + AdmittedRetentionRoot, FilesystemRetentionSnapshotError as Error, ReaderAttemptLimit, + ReaderFence, RetentionViewCoordinates, RetentionViewSource, collect_retention_view, + root_header_decoder, +}; +use crate::adapters::filesystem_exact_record::{self as exact_record, ExactRecordError}; +use crate::adapters::{ + CatalogRestartPolicy, ChecksummedPublicationHead, FilesystemCatalogSnapshot, + filesystem_initialization_namespace, filesystem_version_two_records, publication_head_decoder, +}; +use crate::{RetentionHead, RetentionManifest, RetentionNamespaceDigest}; + +const HEAD_NAME: &str = "HEAD"; + +/// One consistent reader view: the catalog snapshot, the retention head, and +/// the manifest it selects, all observed under one shared reader fence. +/// +/// The view holds the fence for its lifetime, so collection cannot delete the +/// roots or segments it names while it lives. Selected roots are read on +/// demand and verified against the manifest's digest before they are returned. +#[must_use] +pub struct FilesystemRetentionSnapshot { + _fence: ReaderFence, + roots: Dir, + catalog: FilesystemCatalogSnapshot, + retention: Option, +} + +struct View { + catalog: FilesystemCatalogSnapshot, + retention: Option, +} + +struct Source { + root: Dir, + retention: Dir, + manifests: Dir, + store_root: PathBuf, + policy: CatalogRestartPolicy, +} + +impl RetentionViewSource for Source { + type View = View; + + fn coordinates(&mut self) -> io::Result { + let catalog = filesystem_retention_current::read_exact_optional( + &self.root, + HEAD_NAME, + publication_head_decoder::ENCODED_LENGTH, + )? + .map(|bytes| { + ChecksummedPublicationHead::decode(&bytes) + .map(|head| (head.generation(), head.catalog_digest())) + .map_err(|source| io::Error::new(io::ErrorKind::InvalidData, source)) + }) + .transpose()?; + let retention = filesystem_retention_current::observe(&self.retention, &self.manifests)? + .map(|state| (state.head().generation(), state.head().manifest_digest())); + Ok(RetentionViewCoordinates { catalog, retention }) + } + + fn load(&mut self) -> io::Result { + let catalog = FilesystemCatalogSnapshot::load(&self.store_root, self.policy) + .map_err(|source| io::Error::new(io::ErrorKind::InvalidData, source))?; + let retention = filesystem_retention_current::observe(&self.retention, &self.manifests)?; + Ok(View { catalog, retention }) + } +} + +impl FilesystemRetentionSnapshot { + /// Admits the root as version two, acquires the reader fence, and + /// double-collects one consistent view within `limit` attempts. + /// + /// The call takes no writer authority and mutates nothing. It may block + /// while collection holds the fence exclusively. + /// + /// # Errors + /// + /// Returns [`FilesystemRetentionSnapshotError`](super::FilesystemRetentionSnapshotError) + /// at the exact admission, fence, collection, or catalog refusal. + pub fn load( + store_root: &Path, + policy: CatalogRestartPolicy, + limit: ReaderAttemptLimit, + ) -> Result { + let root = Dir::open_ambient_dir(store_root, cap_std::ambient_authority()) + .map_err(|source| Error::Admission { source })?; + filesystem_initialization_namespace::admit_version_two(&root) + .map_err(|source| Error::Admission { source })?; + let _bound = filesystem_version_two_records::admit(&root) + .map_err(|source| Error::Admission { source })?; + let fence = ReaderFence::acquire(&root).map_err(|source| Error::Fence { source })?; + let retention = root + .open_dir_nofollow(pool_name::RETENTION) + .map_err(|source| Error::Admission { source })?; + let roots = retention + .open_dir_nofollow(pool_name::ROOTS) + .map_err(|source| Error::Admission { source })?; + let manifests = retention + .open_dir_nofollow(pool_name::MANIFESTS) + .map_err(|source| Error::Admission { source })?; + let mut source = Source { + root, + retention, + manifests, + store_root: store_root.to_path_buf(), + policy, + }; + let view = + collect_retention_view(&mut source, limit).map_err(|source| Error::View { source })?; + Ok(Self { + _fence: fence, + roots, + catalog: view.catalog, + retention: view.retention, + }) + } + + /// The catalog snapshot the view binds. + pub const fn catalog(&self) -> &FilesystemCatalogSnapshot { + &self.catalog + } + + /// The published retention head, or `None` when no generation is published. + #[must_use] + pub fn retention_head(&self) -> Option<&RetentionHead> { + self.retention.as_ref().map(ObservedRetentionState::head) + } + + /// The manifest the retention head selects, or `None` when none is published. + #[must_use] + pub fn manifest(&self) -> Option<&RetentionManifest> { + self.retention + .as_ref() + .map(ObservedRetentionState::manifest) + } + + /// Reads and verifies the root the manifest selects for `namespace`. + /// + /// Returns `None` when the manifest names no root for the namespace. The + /// pool entry is read without following links, bounded by the root + /// format's maximum length, decoded, and required to carry exactly the + /// generation and digest the manifest names. + /// + /// # Errors + /// + /// Returns [`FilesystemRetentionSnapshotError::Root`](super::FilesystemRetentionSnapshotError::Root) + /// when the entry is absent, unreadable, or not the selected root. + pub fn retained_root( + &self, + namespace: RetentionNamespaceDigest, + ) -> Result>, Error> { + let Some(manifest) = self.manifest() else { + return Ok(None); + }; + let entries = manifest.entries(); + let Some(entry) = entries + .binary_search_by_key(&namespace, |entry| entry.namespace()) + .ok() + .and_then(|index| entries.get(index).copied()) + else { + return Ok(None); + }; + let directory = self + .roots + .open_dir_nofollow(pool_name::namespace(namespace)) + .map_err(|source| Error::Root { source })?; + let name = pool_name::root(entry.root_generation(), entry.root_digest()); + let length = directory + .symlink_metadata(&name) + .and_then(|metadata| { + usize::try_from(metadata.len()).map_err(|_source| invalid("root length overflow")) + }) + .map_err(|source| Error::Root { source })?; + if length > root_header_decoder::MAXIMUM_ENCODED_LENGTH { + return Err(Error::Root { + source: invalid("selected root exceeds the format bound"), + }); + } + let bytes = match exact_record::read_exact_optional(&directory, &name, length) { + Ok(Some(bytes)) => bytes, + Ok(None) => { + return Err(Error::Root { + source: invalid("selected root is absent"), + }); + } + Err(ExactRecordError::Io(source)) => return Err(Error::Root { source }), + Err(ExactRecordError::Refused(refusal)) => { + return Err(Error::Root { + source: invalid_string(format!("selected root refused: {refusal}")), + }); + } + }; + let root = AdmittedRetentionRoot::decode(&bytes).map_err(|source| Error::Root { + source: io::Error::new(io::ErrorKind::InvalidData, source), + })?; + if root.digest() != entry.root_digest() + || root.root().generation() != entry.root_generation() + { + return Err(Error::Root { + source: invalid("selected root does not decode to the manifest's selection"), + }); + } + Ok(Some(bytes.into_boxed_slice())) + } +} + +fn invalid(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +fn invalid_string(message: String) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} diff --git a/src/adapters/retention/filesystem_retention_snapshot_error.rs b/src/adapters/retention/filesystem_retention_snapshot_error.rs new file mode 100644 index 00000000..876d76bf --- /dev/null +++ b/src/adapters/retention/filesystem_retention_snapshot_error.rs @@ -0,0 +1,63 @@ +//! This module owns the typed error of filesystem retention snapshot loading. + +use std::error::Error; +use std::fmt; +use std::io; + +use super::RetentionViewError; +use crate::adapters::CatalogRestartError; + +/// Why a reader could not bind one consistent version-two view. +#[derive(Debug)] +#[non_exhaustive] +pub enum FilesystemRetentionSnapshotError { + /// The root is not an exactly admitted version-two store. + Admission { + /// The exact namespace or record refusal. + source: io::Error, + }, + /// The reader fence could not be acquired. + Fence { + /// The exact filesystem failure. + source: io::Error, + }, + /// The heads never agreed, or a head read failed. + View { + /// The exact collection refusal. + source: RetentionViewError, + }, + /// The catalog `HEAD` selects a catalog that does not admit. + Catalog { + /// The exact restart refusal. + source: CatalogRestartError, + }, + /// A selected root pool entry is absent, unreadable, or not the manifest's. + Root { + /// The exact filesystem or decode refusal. + source: io::Error, + }, +} + +impl fmt::Display for FilesystemRetentionSnapshotError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Admission { .. } => "version-two reader admission refused", + Self::Fence { .. } => "reader fence acquisition failed", + Self::View { .. } => "reader view collection refused", + Self::Catalog { .. } => "catalog snapshot refused", + Self::Root { .. } => "selected retention root refused", + }) + } +} + +impl Error for FilesystemRetentionSnapshotError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Admission { source } | Self::Fence { source } | Self::Root { source } => { + Some(source) + } + Self::View { source } => Some(source), + Self::Catalog { source } => Some(source), + } + } +} diff --git a/src/adapters/retention/filesystem_retention_snapshot_tests.rs b/src/adapters/retention/filesystem_retention_snapshot_tests.rs new file mode 100644 index 00000000..8f45ae78 --- /dev/null +++ b/src/adapters/retention/filesystem_retention_snapshot_tests.rs @@ -0,0 +1,124 @@ +//! Reader fence and fenced snapshot laws over migrated stores. + +use std::error::Error; +use std::fs; + +use cap_std::fs::Dir; +use rustix::fs::{FlockOperation, flock}; + +use super::filesystem_retention_test_fixture::{ + ROOT_HEX, fixture, initial_preparation, migrated_store, open_authority, +}; +use super::{ + AdmittedRetentionRoot, FilesystemRetentionSnapshot, FilesystemRetentionSnapshotError, + ReaderAttemptLimit, ReaderFence, +}; +use crate::adapters::{ + CatalogRestartByteLimit, CatalogRestartPolicy, SegmentReadPolicy, SegmentRecordLimit, +}; +use crate::{LayoutEntryLimit, execute_retention_publication}; + +fn policy() -> Result> { + Ok(CatalogRestartPolicy::new( + SegmentReadPolicy::new(SegmentRecordLimit::MAXIMUM, LayoutEntryLimit::MAXIMUM), + CatalogRestartByteLimit::new(1_048_576)?, + )) +} + +#[test] +fn a_migrated_store_snapshot_binds_the_catalog_and_no_retention_head() -> Result<(), Box> +{ + let sandbox = migrated_store("filesystem-retention-snapshot-empty")?; + let snapshot = + FilesystemRetentionSnapshot::load(sandbox.path(), policy()?, ReaderAttemptLimit::DEFAULT)?; + assert_eq!(snapshot.catalog().generation().get(), 1); + assert!(snapshot.retention_head().is_none()); + assert!(snapshot.manifest().is_none()); + Ok(()) +} + +#[test] +fn a_published_generation_is_read_and_its_root_verified() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("filesystem-retention-snapshot-published")?; + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + let _published = execute_retention_publication(&mut authority, &preparation)?; + drop(authority); + let candidate = AdmittedRetentionRoot::decode(&root_bytes)?; + let namespace = candidate.root().namespace().digest(); + + let snapshot = + FilesystemRetentionSnapshot::load(sandbox.path(), policy()?, ReaderAttemptLimit::DEFAULT)?; + + let head = snapshot + .retention_head() + .ok_or("no retention head in the view")?; + assert_eq!(head.generation(), preparation.liveness_generation()); + let root = snapshot + .retained_root(namespace)? + .ok_or("the manifest does not select the published namespace")?; + assert_eq!(&*root, root_bytes.as_slice()); + Ok(()) +} + +#[test] +fn a_substituted_root_refuses_under_the_snapshot() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("filesystem-retention-snapshot-substituted")?; + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + let _published = execute_retention_publication(&mut authority, &preparation)?; + drop(authority); + let candidate = AdmittedRetentionRoot::decode(&root_bytes)?; + let path = super::filesystem_retention_test_fixture::root_pool_path(sandbox.path(), &candidate); + let mut corrupt = root_bytes.clone(); + if let Some(last) = corrupt.last_mut() { + *last ^= 0x01; + } + fs::write(&path, &corrupt)?; + + let snapshot = + FilesystemRetentionSnapshot::load(sandbox.path(), policy()?, ReaderAttemptLimit::DEFAULT)?; + let error = snapshot + .retained_root(candidate.root().namespace().digest()) + .err() + .ok_or("a corrupt root pool entry was returned")?; + + assert!(matches!( + error, + FilesystemRetentionSnapshotError::Root { .. } + )); + Ok(()) +} + +#[test] +fn readers_share_the_fence_and_collection_cannot_take_it_exclusively() -> Result<(), Box> +{ + let sandbox = migrated_store("filesystem-retention-snapshot-fence")?; + let root = Dir::open_ambient_dir(sandbox.path(), cap_std::ambient_authority())?; + let first = ReaderFence::acquire(&root)?; + let second = ReaderFence::acquire(&root)?; + let collector = std::fs::File::open(sandbox.path().join("reader.lock"))?; + + let refused = flock(&collector, FlockOperation::NonBlockingLockExclusive); + + assert!(refused.is_err(), "an exclusive fence must wait for readers"); + drop(second); + drop(first); + flock(&collector, FlockOperation::NonBlockingLockExclusive)?; + Ok(()) +} + +#[test] +fn a_replaced_reader_lock_refuses_the_fence() -> Result<(), Box> { + let sandbox = migrated_store("filesystem-retention-snapshot-bad-fence")?; + fs::write(sandbox.path().join("reader.lock"), b"not empty")?; + let error = + FilesystemRetentionSnapshot::load(sandbox.path(), policy()?, ReaderAttemptLimit::DEFAULT) + .err() + .ok_or("a non-empty reader.lock was accepted as the fence")?; + assert!(matches!( + error, + FilesystemRetentionSnapshotError::Fence { .. } + )); + Ok(()) +} diff --git a/src/adapters/retention/reader_attempt_limit.rs b/src/adapters/retention/reader_attempt_limit.rs new file mode 100644 index 00000000..67b6afdf --- /dev/null +++ b/src/adapters/retention/reader_attempt_limit.rs @@ -0,0 +1,25 @@ +//! This module owns the bounded retry limit of reader view collection. + +use std::num::NonZeroU32; + +/// How many times a reader may re-collect before refusing a moving store. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[must_use] +pub struct ReaderAttemptLimit(NonZeroU32); + +impl ReaderAttemptLimit { + /// Three attempts: one publication may land between any two reads, and a + /// store that moves faster than a reader can double-collect is refused. + pub const DEFAULT: Self = Self(NonZeroU32::MIN.saturating_add(2)); + + /// Admits an explicit positive attempt count. + pub const fn new(attempts: NonZeroU32) -> Self { + Self(attempts) + } + + /// The admitted attempt count. + #[must_use] + pub const fn get(self) -> u32 { + self.0.get() + } +} diff --git a/src/adapters/retention/reader_fence.rs b/src/adapters/retention/reader_fence.rs new file mode 100644 index 00000000..966d1c98 --- /dev/null +++ b/src/adapters/retention/reader_fence.rs @@ -0,0 +1,60 @@ +//! This module owns the shared reader fence over one version-two store root. + +use std::io; + +use cap_fs_ext::MetadataExt; +use cap_std::fs::{Dir, File, Metadata}; +use rustix::fs::{FlockOperation, flock}; + +use crate::adapters::filesystem_exact_record; + +const READER_LOCK: &str = "reader.lock"; + +/// A shared kernel lock on `reader.lock` held for one snapshot's lifetime. +/// +/// Collection acquires the store writer authority and then an exclusive lock +/// on the same file, so while any fence is held no published segment, root, +/// or manifest can be deleted. Publication proceeds beside fences because it +/// only adds immutable successors. Dropping the fence releases only the +/// kernel lock; the persistent file is never deleted. +#[must_use] +pub struct ReaderFence { + _file: File, +} + +impl ReaderFence { + /// Acquires the shared fence, waiting while collection holds it exclusively. + /// + /// `reader.lock` must be a regular zero-length file reached without + /// following links; its identity is verified after the open so a swapped + /// entry refuses. + pub(super) fn acquire(root: &Dir) -> io::Result { + let file = filesystem_exact_record::open_read(root, READER_LOCK)?; + verify(root, &file)?; + flock(&file, FlockOperation::LockShared)?; + verify(root, &file)?; + Ok(Self { _file: file }) + } +} + +fn verify(root: &Dir, file: &File) -> io::Result<()> { + let handle = file.metadata()?; + let entry = root.symlink_metadata(READER_LOCK)?; + if handle.is_file() + && entry.is_file() + && handle.len() == 0 + && entry.len() == 0 + && identity(&handle) == identity(&entry) + { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + "reader fence kind, length, or identity disagreed", + )) + } +} + +fn identity(metadata: &Metadata) -> (u64, u64) { + (metadata.dev(), metadata.ino()) +} diff --git a/src/adapters/retention/retention_view_collector.rs b/src/adapters/retention/retention_view_collector.rs new file mode 100644 index 00000000..0502e3a2 --- /dev/null +++ b/src/adapters/retention/retention_view_collector.rs @@ -0,0 +1,113 @@ +//! This module owns storage-independent double collection of one reader view. + +use std::error::Error; +use std::fmt; +use std::io; + +use super::ReaderAttemptLimit; +use crate::{CatalogDigest, CatalogGeneration, LivenessGeneration, RetentionManifestDigest}; + +/// The coordinates both heads name at one instant. +/// +/// A view is accepted only when the coordinates read before loading it equal +/// the coordinates read after, so the view belongs to one catalog generation +/// and one liveness generation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RetentionViewCoordinates { + /// The catalog `HEAD` coordinate, or `None` when no catalog is published. + pub catalog: Option<(CatalogGeneration, CatalogDigest)>, + /// The `retention/HEAD` coordinate, or `None` when no retention head is published. + pub retention: Option<(LivenessGeneration, RetentionManifestDigest)>, +} + +/// The reads one reader view needs, in the order the collector calls them. +pub trait RetentionViewSource { + /// The complete view loaded between two coordinate reads. + type View; + + /// Reads both head coordinates without loading anything they select. + /// + /// # Errors + /// + /// Returns the exact read or decode failure. + fn coordinates(&mut self) -> io::Result; + + /// Loads the complete view the current heads select. + /// + /// # Errors + /// + /// Returns the exact load failure. + fn load(&mut self) -> io::Result; +} + +/// Why a reader view could not be collected. +#[derive(Debug)] +#[non_exhaustive] +pub enum RetentionViewError { + /// No catalog `HEAD` is published, so no view exists to collect. + CatalogAbsent, + /// The heads moved between every collection within the attempt limit. + AttemptsExhausted { + /// The attempts that were made. + attempts: u32, + }, + /// A read or load failed. + Io { + /// The exact failure. + source: io::Error, + }, +} + +impl fmt::Display for RetentionViewError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CatalogAbsent => formatter.write_str("no catalog head is published"), + Self::AttemptsExhausted { attempts } => write!( + formatter, + "the store moved between every one of {attempts} view collections" + ), + Self::Io { .. } => formatter.write_str("reader view collection failed"), + } + } +} + +impl Error for RetentionViewError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Io { source } => Some(source), + Self::CatalogAbsent | Self::AttemptsExhausted { .. } => None, + } + } +} + +/// Collects one view whose head coordinates agree before and after loading. +/// +/// Each attempt reads the coordinates, loads the view, and reads the +/// coordinates again; a view is accepted only when both reads agree. A +/// generation, length, digest, or checksum change discards the view and +/// retries until `limit` is exhausted, which refuses. +/// +/// # Errors +/// +/// Returns [`RetentionViewError`] for an absent catalog, an exhausted limit, +/// or the source's own failure. +pub fn collect_retention_view( + source: &mut S, + limit: ReaderAttemptLimit, +) -> Result { + let io = |source| RetentionViewError::Io { source }; + for _attempt in 0..limit.get() { + let before = source.coordinates().map_err(io)?; + if before.catalog.is_none() { + return Err(RetentionViewError::CatalogAbsent); + } + let view = source.load().map_err(io)?; + let after = source.coordinates().map_err(io)?; + if before == after { + return Ok(view); + } + } + Err(RetentionViewError::AttemptsExhausted { + attempts: limit.get(), + }) +} diff --git a/src/adapters/retention/retention_view_collector_tests.rs b/src/adapters/retention/retention_view_collector_tests.rs new file mode 100644 index 00000000..a8a9c39d --- /dev/null +++ b/src/adapters/retention/retention_view_collector_tests.rs @@ -0,0 +1,104 @@ +//! Reader view collection laws against a scripted source. + +use std::error::Error; +use std::io; +use std::num::NonZeroU32; + +use super::{ + ReaderAttemptLimit, RetentionViewCoordinates, RetentionViewError, RetentionViewSource, + collect_retention_view, +}; +use crate::{CatalogDigest, CatalogGeneration}; + +struct Scripted { + coordinates: Vec, + loads: u32, +} + +impl RetentionViewSource for Scripted { + type View = u32; + + fn coordinates(&mut self) -> io::Result { + if self.coordinates.is_empty() { + return Err(io::Error::other("script exhausted")); + } + Ok(self.coordinates.remove(0)) + } + + fn load(&mut self) -> io::Result { + self.loads = self.loads.saturating_add(1); + Ok(self.loads) + } +} + +fn published(generation: u64) -> Result> { + Ok(RetentionViewCoordinates { + catalog: Some(( + CatalogGeneration::new(generation)?, + CatalogDigest::from_validated([0; 32]), + )), + retention: None, + }) +} + +const ABSENT: RetentionViewCoordinates = RetentionViewCoordinates { + catalog: None, + retention: None, +}; + +#[test] +fn a_stable_store_is_collected_on_the_first_attempt() -> Result<(), Box> { + let mut source = Scripted { + coordinates: vec![published(1)?, published(1)?], + loads: 0, + }; + let view = collect_retention_view(&mut source, ReaderAttemptLimit::DEFAULT)?; + assert_eq!(view, 1); + Ok(()) +} + +#[test] +fn a_publication_between_the_reads_discards_the_view_and_retries() -> Result<(), Box> { + let mut source = Scripted { + coordinates: vec![published(1)?, published(2)?, published(2)?, published(2)?], + loads: 0, + }; + let view = collect_retention_view(&mut source, ReaderAttemptLimit::DEFAULT)?; + assert_eq!( + view, 2, + "the first load was discarded and the second accepted" + ); + Ok(()) +} + +#[test] +fn a_store_that_never_settles_exhausts_the_limit() -> Result<(), Box> { + let mut source = Scripted { + coordinates: (1..=8).map(published).collect::>()?, + loads: 0, + }; + let limit = ReaderAttemptLimit::new(NonZeroU32::new(2).ok_or("zero")?); + let error = collect_retention_view(&mut source, limit) + .err() + .ok_or("a moving store was accepted")?; + assert!(matches!( + error, + RetentionViewError::AttemptsExhausted { attempts: 2 } + )); + assert_eq!(source.loads, 2); + Ok(()) +} + +#[test] +fn an_absent_catalog_refuses_before_loading() -> Result<(), Box> { + let mut source = Scripted { + coordinates: vec![ABSENT], + loads: 0, + }; + let error = collect_retention_view(&mut source, ReaderAttemptLimit::DEFAULT) + .err() + .ok_or("an absent catalog was collected")?; + assert!(matches!(error, RetentionViewError::CatalogAbsent)); + assert_eq!(source.loads, 0); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index c6ff84eb..425d62ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -136,21 +136,24 @@ pub use adapters::{ AdmittedRetentionManifest, AdmittedRetentionRoot, CanonicalRetentionHead, CanonicalRetentionManifest, CanonicalRetentionRoot, ChecksummedRetentionHead, FilesystemRetentionAuthorityError, FilesystemRetentionPublicationAuthority, - FilesystemRetentionRecoveryError, ObservedRetentionState, PreparedRetentionPublication, - RetentionAuthorityDirectory, RetentionClosureVerificationError, RetentionCurrentStateRefusal, - RetentionFixedStage, RetentionHeadDecodeError, RetentionHeadStageAssessment, - RetentionManifestDecodeError, RetentionManifestEncodeError, RetentionManifestStageAssessment, - RetentionNamespaceAdmission, RetentionPool, RetentionPoolEntryObservation, - RetentionPoolObservations, RetentionPublicationError, RetentionPublicationOutcome, - RetentionPublicationPhase, RetentionPublicationPreparation, - RetentionPublicationPreparationError, RetentionPublicationReceipt, RetentionPublicationStorage, - RetentionRecoveryError, RetentionRecoveryEvidence, RetentionRecoveryOutcome, - RetentionRecoveryPlan, RetentionRecoveryReceipt, RetentionRecoveryRefusal, - RetentionRecoveryStep, RetentionRecoveryStorage, RetentionRootDecodeError, - RetentionRootEncodeError, RetentionRootStageAssessment, RetentionStageAssessment, - RetentionStageAssessments, RetentionTransitionDisposition, RetentionTransitionError, - RetentionTransitionPreflight, RetentionTransitionPreflightError, RetentionTransitionReadiness, - VerifiedRetentionClosure, assess_head_stage, assess_manifest_stage, assess_root_stage, + FilesystemRetentionRecoveryError, FilesystemRetentionSnapshot, + FilesystemRetentionSnapshotError, ObservedRetentionState, PreparedRetentionPublication, + ReaderAttemptLimit, ReaderFence, RetentionAuthorityDirectory, + RetentionClosureVerificationError, RetentionCurrentStateRefusal, RetentionFixedStage, + RetentionHeadDecodeError, RetentionHeadStageAssessment, RetentionManifestDecodeError, + RetentionManifestEncodeError, RetentionManifestStageAssessment, RetentionNamespaceAdmission, + RetentionPool, RetentionPoolEntryObservation, RetentionPoolObservations, + RetentionPublicationError, RetentionPublicationOutcome, RetentionPublicationPhase, + RetentionPublicationPreparation, RetentionPublicationPreparationError, + RetentionPublicationReceipt, RetentionPublicationStorage, RetentionRecoveryError, + RetentionRecoveryEvidence, RetentionRecoveryOutcome, RetentionRecoveryPlan, + RetentionRecoveryReceipt, RetentionRecoveryRefusal, RetentionRecoveryStep, + RetentionRecoveryStorage, RetentionRootDecodeError, RetentionRootEncodeError, + RetentionRootStageAssessment, RetentionStageAssessment, RetentionStageAssessments, + RetentionTransitionDisposition, RetentionTransitionError, RetentionTransitionPreflight, + RetentionTransitionPreflightError, RetentionTransitionReadiness, RetentionViewCoordinates, + RetentionViewError, RetentionViewSource, VerifiedRetentionClosure, assess_head_stage, + assess_manifest_stage, assess_root_stage, collect_retention_view, execute_retention_publication, execute_retention_recovery, plan_retention_recovery, plan_retention_transition, preflight_retention_transition, prepare_retention_publication, verify_retention_closure, From c9277eadbcb22a5ba1330760a3b908178957d4e5 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 9 Sep 2026 15:10:19 -0700 Subject: [PATCH 08/59] Test: prove retention transitions against a namespace-to-anchor-set model KEEP-RETENTION-010 asks that model operation sequences agree with a deterministic namespace-to-anchor-set map and that no caller identity, path, clock, or application policy enters the core transition. Five laws now run every three-operation sequence over initial publications of two namespaces, a successor of the first, a byte-identical retry of the last accepted publication, and an initial publication from a stale view: 125 sequences, each in a fresh migrated store, driven through the real filesystem authority. After every step the fenced reader view must equal the model exactly: the manifest's namespace-to-generation map, the liveness generation, and each selected root's generation and anchor set, with a refused operation leaving the view unchanged. The model was corrected three times by the store during development, each time toward the rule the publication page states: a byte-identical retry is already committed only while that exact staged successor, head included, remains current; a stale initial is superseded by any later publication. A source contract walks src/retention and the storage-independent retention adapters and refuses any clock, path, filesystem, environment, or identity token. KEEP-RETENTION-010 is Implemented in the ledger. Refs #19 --- CHANGELOG.md | 6 + docs/formats/segment-store-v2/README.md | 5 +- docs/formats/segment-store-v2/requirements.md | 2 +- src/adapters/retention.rs | 2 + .../retention/retention_model_tests.rs | 363 ++++++++++++++++++ tests/retention_core_architecture_contract.rs | 61 +++ 6 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 src/adapters/retention/retention_model_tests.rs create mode 100644 tests/retention_core_architecture_contract.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index da0a2f2f..fa72581e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ after its public API and format compatibility policies are established. ### Added +- Model-based retention evidence: every three-operation sequence over initial + publications of two namespaces, a successor, a byte-identical retry, and a + stale initial (125 sequences, each in a fresh migrated store) agrees with a + deterministic namespace-to-(generation, anchor-set) map and liveness after + every step, observed through the fenced reader view; a source contract + keeps clocks, paths, environment, and identity out of the retention core. - `FilesystemRetentionSnapshot` is the version-two reader view: it admits the root as version two, acquires a shared `ReaderFence` on `reader.lock`, double-collects the catalog and retention heads around loading through diff --git a/docs/formats/segment-store-v2/README.md b/docs/formats/segment-store-v2/README.md index 65b9e473..fb15150a 100644 --- a/docs/formats/segment-store-v2/README.md +++ b/docs/formats/segment-store-v2/README.md @@ -98,9 +98,10 @@ Retention publication recovery is implemented and proven both in-process for every crash prefix and by the crash matrix, which kills a real writer before, during, and after `KEEP-CRASH-036` through `052`. Readers bind one consistent catalog, retention head, and manifest view under a -shared `ReaderFence` and verify selected roots on demand. +shared `ReaderFence` and verify selected roots on demand. Every three-operation +transition sequence agrees with a deterministic namespace-to-anchor-set model. Not implemented: partial-prefix migration recovery and `KEEP-CRASH-053..073`, -model-based transition evidence, and garbage collection. Issue #19 owns the first four and issue #21 the last; +and garbage collection. Issue #19 owns the first four and issue #21 the last; issue #97 owns the restart-stable root identity coordinate. A version-1 store remains admitted until its owner migrates it, and the [requirements ledger](requirements.md) is the authority on which requirements diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index 23368873..ea326d03 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -18,7 +18,7 @@ case is not evidence. | `KEEP-RETENTION-007` | Restart resolves every fixed-stage crash prefix to one documented lawful state or typed ambiguity | recovery-required refusals before any mutation in `filesystem_retention_expectation_tests`: an absent head over populated pools, a non-initial head prepared against an absent head, an orphan directory for a namespace expected absent, and an absent directory for a namespace expected current; replaced protocol directories, an absent or changed head-selected catalog, an over-full census, zero-generation pool names, and a stage retained by a failed write refuse in `filesystem_retention_*_tests`; storage-independent classification of every fixed-stage crash prefix (discard, link and protect, finalize, clean up, or typed refusal) in `recovery_planner_tests`; every publication prefix 0 through 18, each mid-write truncation, and successor prefixes recover in-process to the documented state, idempotently, with the forward retry reporting the predicted outcome, in `filesystem_retention_recovery_prefix_tests`; `cargo xtask durability-crash-matrix` kills a real writer before, during, and after `KEEP-CRASH-036` through `052` and requires restart recovery to reach the documented state and the forward retry to report the predicted outcome | Implemented | | `KEEP-RETENTION-008` | Readers double-collect catalog and retention heads and bind one complete catalog, manifest, and root-generation view under a `ReaderFence` | `ReaderFence` holds a shared kernel lock on a verified zero-length `reader.lock`; `collect_retention_view` accepts a view only when both head coordinates agree before and after loading and refuses an exhausted attempt limit (`retention_view_collector_tests`); `FilesystemRetentionSnapshot` binds the catalog snapshot, retention head, and manifest under the fence and verifies each selected root on demand while the fence is held, refusing a substituted root and a replaced fence, and two readers share the fence while an exclusive lock waits (`filesystem_retention_snapshot_tests`) | Implemented | | `KEEP-RETENTION-009` | Exact already-committed retry is idempotent only while its successor remains current | byte-identical planning in `tests/retention_transition.rs`; authority-revalidated zero-mutation retry receipt in `tests/retention_publication_execution.rs`; exact already-committed filesystem retry with a byte-identical retention witness in `filesystem_retention_storage_tests`; superseded-candidate filesystem refusal with zero mutation in `filesystem_retention_successor_tests`; committed retry reopens the head-selected manifest entry and root pool bytes, refusing absent, changed, or corrupt evidence in `filesystem_retention_current_tests`; every refusal is a typed `RetentionCurrentStateRefusal` source, with superseded, committed-root-absent, committed-root-changed, and head-absent-with-artifacts pinned by downcast | Implemented | -| `KEEP-RETENTION-010` | Model operation sequences agree with a deterministic namespace-to-anchor-set map and never admit caller identity, paths, clocks, or application policy | model-based and source-architecture tests | Planned in #19 | +| `KEEP-RETENTION-010` | Model operation sequences agree with a deterministic namespace-to-anchor-set map and never admit caller identity, paths, clocks, or application policy | every three-operation sequence over initial publications of two namespaces, a successor, a byte-identical retry, and a stale initial (125 sequences, each in a fresh migrated store) agrees with a deterministic namespace-to-(generation, anchor-set) map plus liveness after every step, observed through the fenced reader view, in `retention_model_tests`; `tests/retention_core_architecture_contract.rs` refuses any clock, path, environment, or identity token in the retention core | Implemented | diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 84b6c292..606ce05e 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -115,6 +115,8 @@ mod recovery_planner_tests; mod recovery_refusal; mod recovery_stage_assessment; mod recovery_storage; +#[cfg(test)] +mod retention_model_tests; mod retention_view_collector; #[cfg(test)] mod retention_view_collector_tests; diff --git a/src/adapters/retention/retention_model_tests.rs b/src/adapters/retention/retention_model_tests.rs new file mode 100644 index 00000000..55c7ef84 --- /dev/null +++ b/src/adapters/retention/retention_model_tests.rs @@ -0,0 +1,363 @@ +//! Model-based retention laws: every operation sequence agrees with a +//! deterministic namespace-to-anchor-set map, and a refused operation leaves +//! the fenced reader view exactly where it was. + +use std::collections::BTreeMap; +use std::error::Error; +use std::path::PathBuf; + +use super::filesystem_retention_test_fixture::{ + ROOT_HEX, fixture, initial_preparation, initial_root, new_namespace_preparation, + open_authority, successor_preparation, successor_root, +}; +use super::{ + AdmittedRetentionManifest, AdmittedRetentionRoot, FilesystemRetentionPublicationAuthority, + FilesystemRetentionSnapshot, ReaderAttemptLimit, RetentionPublicationOutcome, + RetentionPublicationPreparation, +}; +use crate::adapters::{ + CatalogRestartByteLimit, CatalogRestartPolicy, SegmentReadPolicy, SegmentRecordLimit, +}; +use crate::{ + LayoutEntryLimit, RetentionAnchor, RetentionNamespaceDigest, execute_retention_publication, +}; + +const NAMESPACE_B: &[u8] = b"model-namespace-b"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Namespace { + A, + B, +} + +#[derive(Clone, Copy, Debug)] +enum Operation { + /// Publish generation one of the namespace from a fresh view. + Initial(Namespace), + /// Publish the exact successor of namespace A from a fresh view. + Successor, + /// Replay the last accepted publication byte for byte. + RetryLast, + /// Publish generation one of namespace A from a view that predates it. + StaleInitial, +} + +const OPERATIONS: [Operation; 5] = [ + Operation::Initial(Namespace::A), + Operation::Initial(Namespace::B), + Operation::Successor, + Operation::RetryLast, + Operation::StaleInitial, +]; + +/// The byte ingredients of one preparation; rebuilding it is byte-identical. +#[derive(Clone)] +enum Recipe { + Initial { + candidate: Vec, + manifest: Option>, + }, + Successor { + current_root: Vec, + manifest: Vec, + candidate: Vec, + }, +} + +impl Recipe { + fn candidate(&self) -> &[u8] { + match self { + Self::Initial { candidate, .. } | Self::Successor { candidate, .. } => candidate, + } + } + + fn publish( + &self, + authority: &mut FilesystemRetentionPublicationAuthority, + ) -> Result> { + let preparation: RetentionPublicationPreparation<'_> = match self { + Self::Initial { + candidate, + manifest: None, + } => initial_preparation(candidate)?, + Self::Initial { + candidate, + manifest: Some(manifest), + } => { + new_namespace_preparation(&AdmittedRetentionManifest::decode(manifest)?, candidate)? + } + Self::Successor { + current_root, + manifest, + candidate, + } => successor_preparation( + &AdmittedRetentionRoot::decode(current_root)?, + &AdmittedRetentionManifest::decode(manifest)?, + candidate, + )?, + }; + Ok(execute_retention_publication(authority, &preparation)?.outcome()) + } +} + +/// The reference model: namespace digest to (generation, anchors), plus the +/// liveness generation, which counts accepted publications. +#[derive(Default)] +struct Model { + namespaces: BTreeMap)>, + liveness: u64, +} + +struct Store { + authority: FilesystemRetentionPublicationAuthority, + path: PathBuf, + template: Vec, + last_accepted: Option, +} + +fn policy() -> Result> { + Ok(CatalogRestartPolicy::new( + SegmentReadPolicy::new(SegmentRecordLimit::MAXIMUM, LayoutEntryLimit::MAXIMUM), + CatalogRestartByteLimit::new(1_048_576)?, + )) +} + +fn snapshot(store: &Store) -> Result> { + Ok(FilesystemRetentionSnapshot::load( + &store.path, + policy()?, + ReaderAttemptLimit::DEFAULT, + )?) +} + +fn digest_of(bytes: &[u8]) -> Result> { + Ok(AdmittedRetentionRoot::decode(bytes)? + .root() + .namespace() + .digest()) +} + +fn candidate_bytes(store: &Store, namespace: Namespace) -> Result, Box> { + match namespace { + Namespace::A => Ok(store.template.clone()), + Namespace::B => { + let template = AdmittedRetentionRoot::decode(&store.template)?; + Ok(initial_root(NAMESPACE_B, &template)?.encoded().to_vec()) + } + } +} + +/// A recipe and the answer the model expects for it. +type Planned = (Recipe, Expected); + +/// What the model says the store must answer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Expected { + Published, + AlreadyCommitted, + Refused, +} + +/// Builds the recipe an operation would publish and the model's expected +/// answer. `None` means the operation has no candidate. +fn recipe( + store: &Store, + model: &Model, + operation: Operation, +) -> Result, Box> { + let fresh_manifest = store + .authority + .observe_current()? + .map(|state| state.manifest_bytes().to_vec()); + Ok(match operation { + Operation::Initial(namespace) => { + let candidate = candidate_bytes(store, namespace)?; + let expected = if model.namespaces.contains_key(&digest_of(&candidate)?) { + Expected::Refused + } else { + Expected::Published + }; + Some(( + Recipe::Initial { + candidate, + manifest: fresh_manifest, + }, + expected, + )) + } + Operation::StaleInitial => { + // The stale caller's preparation stages liveness generation one, + // so it is byte-identical to the accepted publication only while + // that publication is still the whole history; any later + // publication supersedes it. + let candidate = candidate_bytes(store, Namespace::A)?; + let expected = match ( + model.namespaces.get(&digest_of(&candidate)?), + model.liveness, + ) { + (None, 0) => Expected::Published, + (Some(_), 1) => Expected::AlreadyCommitted, + _ => Expected::Refused, + }; + Some(( + Recipe::Initial { + candidate, + manifest: None, + }, + expected, + )) + } + Operation::Successor => { + let digest = digest_of(&store.template)?; + if !model.namespaces.contains_key(&digest) { + return Ok(None); + } + let current_root = snapshot(store)? + .retained_root(digest)? + .ok_or("model root absent on disk")? + .to_vec(); + let candidate = successor_root(&AdmittedRetentionRoot::decode(¤t_root)?)? + .encoded() + .to_vec(); + Some(( + Recipe::Successor { + current_root, + manifest: fresh_manifest.ok_or("successor over no manifest")?, + candidate, + }, + Expected::Published, + )) + } + Operation::RetryLast => store + .last_accepted + .clone() + .map(|recipe| (recipe, Expected::AlreadyCommitted)), + }) +} + +/// Applies one operation to the store and the model. +fn apply(store: &mut Store, model: &mut Model, operation: Operation) -> Result<(), Box> { + let Some((recipe, expected)) = recipe(store, model, operation)? else { + return Ok(()); + }; + match (expected, recipe.publish(&mut store.authority)) { + (Expected::AlreadyCommitted, Ok(RetentionPublicationOutcome::AlreadyCommitted)) + | (Expected::Refused, Err(_)) => {} + (Expected::Published, Ok(RetentionPublicationOutcome::Published)) => { + let candidate = AdmittedRetentionRoot::decode(recipe.candidate())?; + model.namespaces.insert( + candidate.root().namespace().digest(), + ( + candidate.root().generation().get(), + candidate.root().anchors().to_vec(), + ), + ); + model.liveness = model.liveness.saturating_add(1); + store.last_accepted = Some(recipe); + } + (expected, result) => { + return Err( + format!("{operation:?}: model expects {expected:?}, store {result:?}").into(), + ); + } + } + Ok(()) +} + +/// Requires the fenced reader view to agree with the model exactly. +fn verify(store: &Store, model: &Model) -> Result<(), Box> { + let snapshot = snapshot(store)?; + let observed: BTreeMap<_, _> = snapshot + .manifest() + .map(|manifest| { + manifest + .entries() + .iter() + .map(|entry| (entry.namespace(), entry.root_generation().get())) + .collect() + }) + .unwrap_or_default(); + let expected: BTreeMap<_, _> = model + .namespaces + .iter() + .map(|(namespace, (generation, _))| (*namespace, *generation)) + .collect(); + assert_eq!(observed, expected, "manifest disagrees with the model"); + let liveness = snapshot + .retention_head() + .map_or(0, |head| head.generation().get()); + assert_eq!( + liveness, model.liveness, + "liveness generation disagrees with the model" + ); + for (namespace, (generation, anchors)) in &model.namespaces { + let bytes = snapshot + .retained_root(*namespace)? + .ok_or("model namespace has no root on disk")?; + let root = AdmittedRetentionRoot::decode(&bytes)?; + assert_eq!(root.root().generation().get(), *generation); + assert_eq!( + root.root().anchors(), + anchors.as_slice(), + "anchor set disagrees with the model" + ); + } + Ok(()) +} + +/// Runs every three-operation sequence that starts with `first` in a fresh +/// migrated store each, checking the fenced view against the model after +/// every step. +fn run_sequences(first: Operation, label: &str) -> Result<(), Box> { + let template = fixture(ROOT_HEX)?; + let mut sequences = 0_u32; + for second in OPERATIONS { + for third in OPERATIONS { + let name = format!("filesystem-retention-model-{label}-{sequences}"); + let (sandbox, authority) = open_authority(&name)?; + let mut store = Store { + authority, + path: sandbox.path().to_path_buf(), + template: template.clone(), + last_accepted: None, + }; + let mut model = Model::default(); + for operation in [first, second, third] { + apply(&mut store, &mut model, operation) + .map_err(|error| format!("{first:?} {second:?} {third:?}: {error}"))?; + verify(&store, &model) + .map_err(|error| format!("{first:?} {second:?} {third:?}: {error}"))?; + } + sequences = sequences.saturating_add(1); + } + } + assert_eq!(sequences, 25); + Ok(()) +} + +#[test] +fn sequences_starting_with_an_initial_publication_of_a_agree_with_the_model() +-> Result<(), Box> { + run_sequences(Operation::Initial(Namespace::A), "initial-a") +} + +#[test] +fn sequences_starting_with_an_initial_publication_of_b_agree_with_the_model() +-> Result<(), Box> { + run_sequences(Operation::Initial(Namespace::B), "initial-b") +} + +#[test] +fn sequences_starting_with_a_successor_agree_with_the_model() -> Result<(), Box> { + run_sequences(Operation::Successor, "successor") +} + +#[test] +fn sequences_starting_with_a_retry_agree_with_the_model() -> Result<(), Box> { + run_sequences(Operation::RetryLast, "retry") +} + +#[test] +fn sequences_starting_with_a_stale_initial_agree_with_the_model() -> Result<(), Box> { + run_sequences(Operation::StaleInitial, "stale") +} diff --git a/tests/retention_core_architecture_contract.rs b/tests/retention_core_architecture_contract.rs new file mode 100644 index 00000000..3b75430d --- /dev/null +++ b/tests/retention_core_architecture_contract.rs @@ -0,0 +1,61 @@ +//! The retention core admits no caller identity, path, clock, or application policy. + +use std::error::Error; +use std::fs; +use std::path::Path; + +/// Tokens that would let identity, paths, clocks, or environment into a +/// transition decision. The filesystem adapters own paths; the core does not. +const FORBIDDEN: [&str; 8] = [ + "SystemTime", + "Instant", + "std::env", + "std::path", + "std::fs", + "getuid", + "hostname", + "username", +]; + +/// Storage-independent retention modules outside `src/retention/`. +const CORE_ADAPTERS: [&str; 8] = [ + "src/adapters/retention/transition_planner.rs", + "src/adapters/retention/transition_preflight.rs", + "src/adapters/retention/publication_preparation.rs", + "src/adapters/retention/publication_execution.rs", + "src/adapters/retention/publication_storage.rs", + "src/adapters/retention/recovery_planner.rs", + "src/adapters/retention/recovery_execution.rs", + "src/adapters/retention/retention_view_collector.rs", +]; + +#[test] +fn the_retention_core_admits_no_identity_path_clock_or_policy() -> Result<(), Box> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut sources = Vec::new(); + for entry in fs::read_dir(root.join("src/retention"))? { + let path = entry?.path(); + if path.extension().is_some_and(|extension| extension == "rs") { + sources.push(path); + } + } + for adapter in CORE_ADAPTERS { + let path = root.join(adapter); + assert!( + path.is_file(), + "{adapter} is missing; update the contract list" + ); + sources.push(path); + } + for path in sources { + let source = fs::read_to_string(&path)?; + for token in FORBIDDEN { + assert!( + !source.contains(token), + "{} names `{token}`; the retention core decides from evidence alone", + path.display() + ); + } + } + Ok(()) +} From 24c2d9337ea0bea9169d402f5447cde6d0214611 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 08:27:27 -0700 Subject: [PATCH 09/59] Docs: add the feature and task roadmap Inventory every feature Keep has, is building, or intends, from the issues, the documentation tree, the changelog, and the public API, with a checklist up front and a task breakdown per unfinished feature. The requirement ledgers stay authoritative; this page is a plan, not evidence. Co-Authored-By: Claude Fable 5.1 --- ROADMAP.md | 2404 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2404 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..40d08963 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,2404 @@ +# Keep Roadmap + +This page has one job: to list every feature Keep has, is building, or +intends, and to break the unfinished ones into tasks precise enough to +start. It is a plan. It is not evidence. The authoritative status of every +requirement, with the test that proves it, remains the requirement ledgers: +[`segment-store-v1/requirements.md`](docs/formats/segment-store-v1/requirements.md), +[`segment-store-v2/requirements.md`](docs/formats/segment-store-v2/requirements.md), +and +[`authenticated-reconstruction/requirements.md`](docs/invariants/authenticated-reconstruction/requirements.md). +Where this page and a ledger disagree, the ledger wins. + +Snapshot: `main` at `f49cff7`, 2026-09-30. Twenty issues open, thirty-two +closed, one non-dependency pull request open (#99). + +## How to read this page + +Every feature has a status word: + +| Status | Meaning | +| --- | --- | +| Done | Shipped on `main` with executable evidence named in a ledger, the changelog, or a test file | +| Partial | Some tasks shipped; the remaining tasks are listed with an owner | +| In review | An open pull request delivers it; not on `main` yet | +| Planned | Owned by a GitHub issue; no executable evidence | +| Proposed | Suggested here; no issue, no decision record, no owner | +| Out of scope | Refused or deferred by a decision record; listed so nobody re-proposes it by accident | + +A feature is a capability a user can name. A task is one unit of work that +delivers part of a feature. Each unfinished task carries the full field set: +requirements, acceptance criteria, scope, user stories, interface, contract +schema, test plan, definition of done, complexity, documentation, and +dependencies. Completed tasks carry a checkbox and a pointer to their +evidence instead, because the ledgers already own that detail. + +User stories take four perspectives: + +- **Human**: an operator or maintainer running Keep by hand. +- **API user**: a Rust program linking the `keep` crate. +- **MCP user**: a person driving Keep through a Model Context Protocol server + that wraps the crate. No such server exists today; the stories say what one + would need. See F-42. +- **Agent**: an autonomous coding or operations agent acting through the API + or an MCP server without a human in the loop. + +Keep's core exposes no command-line interface by design +(`docs/Rust Standards.md` §5.2). Where a task lists an interface, it names +the Rust API and the operation an out-of-core CLI or MCP adapter would +expose over it. + +Complexity uses four sizes: **S** (one pull request under the 400-line +target), **M** (two to four pull requests), **L** (a milestone slice of +several weeks with its own crash or corruption evidence), **XL** (spans +milestones or needs a new decision record first). + +Task identifiers are `T-.`. They exist only on this page. +Requirement identifiers (`KEEP-RETENTION-007`), crash points +(`KEEP-CRASH-036`), issues (`#21`), and ADRs (`ADR-0009`) are the durable +names; use those in code, tests, and commits. + +## Checklist + +### Foundations (M1 and M2) + +- [x] [F-01 Core law and fail-closed contract](#f-01-core-law-and-fail-closed-contract) — Done +- [x] [F-02 BlobId exact logical identity](#f-02-blobid-exact-logical-identity) — Done +- [x] [F-03 Identity layers and RepresentationId](#f-03-identity-layers-and-representationid) — Done as a model; representation codec reserved +- [x] [F-04 Deterministic chunking and ChunkId](#f-04-deterministic-chunking-and-chunkid) — Done +- [x] [F-05 Flat chunk layout v1 and LayoutId](#f-05-flat-chunk-layout-v1-and-layoutid) — Done +- [x] [F-06 Reference store](#f-06-reference-store) — Done; two open defects +- [x] [F-07 Authenticated reconstruction and exact range reads](#f-07-authenticated-reconstruction-and-exact-range-reads) — Done for the reference store +- [x] [F-08 Conformance corpora and the Golden File Worldline](#f-08-conformance-corpora-and-the-golden-file-worldline) — Done +- [x] [F-09 Streaming CAS benchmark baseline](#f-09-streaming-cas-benchmark-baseline) — Done; thresholds unconfigured +- [x] [F-10 Hexagonal boundary architecture](#f-10-hexagonal-boundary-architecture) — Done + +### Durable segment store version 1 (M3) + +- [x] [F-11 Immutable segment format and verified I/O](#f-11-immutable-segment-format-and-verified-io) — Done +- [x] [F-12 Catalog generations and writer-locked publication](#f-12-catalog-generations-and-writer-locked-publication) — Done +- [x] [F-13 Store initialization, recovery, and the crash matrix](#f-13-store-initialization-recovery-and-the-crash-matrix) — Done +- [ ] [F-14 Segment store v1 living documentation refresh](#f-14-segment-store-v1-living-documentation-refresh) — Planned (#69) + +### Retention and version 2 (M4) + +- [x] [F-15 Retention roots, release, and GC liveness model](#f-15-retention-roots-release-and-gc-liveness-model) — Done (ADR-0009) +- [x] [F-16 Version-2 format records and codecs](#f-16-version-2-format-records-and-codecs) — Done; mutation coverage in progress +- [ ] [F-17 One-way migration from version 1 to version 2](#f-17-one-way-migration-from-version-1-to-version-2) — Partial (#97 and residual #19) +- [ ] [F-18 Retention publication](#f-18-retention-publication) — Partial; recovery in review (PR #99) +- [ ] [F-19 Reader fence and immutable version-2 snapshots](#f-19-reader-fence-and-immutable-version-2-snapshots) — In review (PR #99) +- [ ] [F-20 Model-based retention transition evidence](#f-20-model-based-retention-transition-evidence) — In review (PR #99) +- [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Planned (#20) +- [ ] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Planned (#21) +- [ ] [F-23 Durable authenticated reads and refusal receipts](#f-23-durable-authenticated-reads-and-refusal-receipts) — Planned; no open Keep issue +- [ ] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Planned (#82, #74, #72) + +### Integration (M5) + +- [x] [F-25 Echo adapter and transaction boundary](#f-25-echo-adapter-and-transaction-boundary) — Done in the Echo repository +- [ ] [F-26 Graft Golden File Worldline end to end](#f-26-graft-golden-file-worldline-end-to-end) — Planned (#24) +- [ ] [F-27 git-cas import posture](#f-27-git-cas-import-posture) — Planned (#25) + +### Encrypted representations and lifecycle surfaces (M6) + +- [ ] [F-28 Authenticated encrypted representations](#f-28-authenticated-encrypted-representations) — Planned (#86, #83) +- [ ] [F-29 Retention-derived lifecycle surfaces](#f-29-retention-derived-lifecycle-surfaces) — Planned (#85) +- [ ] [F-30 Opaque asset, page, and bundle handles](#f-30-opaque-asset-page-and-bundle-handles) — Planned (#89) +- [ ] [F-31 Private named vault and mutable root sets](#f-31-private-named-vault-and-mutable-root-sets) — Planned (#92) +- [ ] [F-32 Managed cache sets](#f-32-managed-cache-sets) — Planned (#90) +- [ ] [F-33 Expiry-safe replay sets](#f-33-expiry-safe-replay-sets) — Planned (#87) +- [ ] [F-34 Portable bindings](#f-34-portable-bindings) — Planned (#91) + +### Exploration + +- [ ] [F-35 Read-only FUSE projection](#f-35-read-only-fuse-projection) — Planned (#66) +- [ ] [F-36 Transactional write-enabled projection](#f-36-transactional-write-enabled-projection) — Planned (#73) + +### Repository, process, and documentation + +- [x] [F-37 Repository verification tooling and CI gates](#f-37-repository-verification-tooling-and-ci-gates) — Done +- [ ] [F-38 Documentation status drift](#f-38-documentation-status-drift) — Proposed; small + +### Proposed, without an owner + +- [ ] [F-39 Compression representation codec](#f-39-compression-representation-codec) — Proposed +- [ ] [F-40 Additional chunking profiles and hierarchical layouts](#f-40-additional-chunking-profiles-and-hierarchical-layouts) — Proposed +- [ ] [F-41 Platform adapters beyond Linux ext4](#f-41-platform-adapters-beyond-linux-ext4) — Proposed +- [ ] [F-42 Operator surfaces](#f-42-operator-surfaces) — Proposed (CLI and MCP adapters) +- [ ] [F-43 Public release and API stability](#f-43-public-release-and-api-stability) — Proposed +- [ ] [F-44 Multi-writer, replication, and remote tiers](#f-44-multi-writer-replication-and-remote-tiers) — Out of scope + +### Assurance beyond empirical testing + +- [ ] [F-45 Formal verification of the durable protocols](#f-45-formal-verification-of-the-durable-protocols) — Proposed +- [ ] [F-46 Condition coverage and mutation analysis](#f-46-condition-coverage-and-mutation-analysis) — Proposed + +## Dependency map + +Edges are "needs", read left to right. Only edges between unfinished +features are listed; finished prerequisites are implied. + +- F-17 partial-prefix migration recovery needs F-17 T-17.1 (#97). +- F-18 recovery and F-19 and F-20 ship together in PR #99; F-18 orphan + disposition then needs F-22. +- F-21 (#20) is the most-cited blocker: F-22, F-23, F-24, F-27, F-28, F-29, + F-30, F-31, F-33, F-34 all name it. +- F-22 (#21) needs F-19, F-21, and the remainder of F-18; it is needed by + F-26, F-29, F-31, F-32, F-33. +- F-23 needs F-19 and F-22 (a pinned view must survive collection). +- F-24 (#82) needs F-18, F-21, and the F-06 defects #74 and #72. +- F-26 (#24) needs F-22 and F-25. +- F-28 implementation (#83) needs the F-28 ADR (#86), F-21, #74, #72. +- F-29 (#85) needs F-18, F-21, F-22; it is needed by F-30, F-31, F-32, F-33. +- F-30 (#89) needs F-24 and F-29; F-31 (#92) needs F-30; F-32 (#90) needs + F-31. +- F-34 (#91) needs F-21, F-26, and stable handles from F-30. +- F-35 (#66) needs F-19 and F-22 before any mount is exposed; F-36 needs + F-35. +- F-42 needs F-23 and F-24 before an adapter has a durable path to wrap. +- F-43 needs every Planned feature in M4, plus F-24 and F-28, before a + format-compatibility policy can be promised. + +## Features + +### F-01 Core law and fail-closed contract + +**Status:** Done. Governs every other feature. + +For a given content identity, Keep must return exactly the bytes named by +that identity, or refuse. Keep refuses, before mutating anything, a disk +that returns a corrupted block, a process killed mid-update that leaves +finished-looking state, and a byte-identical file substituted at the same +path. The core holds no clock, no caller identity, no paths, and no +application policy. + +- [x] T-01.1 State the law and its limits — `README.md`, + `docs/adr/0001-exact-logical-byte-identity.md`, + `docs/invariants/authenticated-reconstruction/README.md`. +- [x] T-01.2 Make every refusal a typed value, never a string — every + boundary error enum carries `expected` and `observed` fields and a + preserved `source`; `unwrap_used`, `expect_used`, and `panic` are denied + workspace-wide. +- [x] T-01.3 Keep application semantics out of the core — `KEEP-STORE-016`; + Echo, Git, Graft, WARP, and CLI types never enter `src/`. + +### F-02 BlobId exact logical identity + +**Status:** Done (ADR-0001, issues #2 and #6, M1). + +`BlobId` is BLAKE3-256 over a typed, domain-separated preimage +(`KEEP:BLOB:DATA`, version, algorithm, the bytes, and a trailing `u64` +length) with a strict 59-byte binary form and a strict text form +`keep:blob:v1:blake3-256::`. It never moves under +rechunking, repacking, compression, encryption, key rotation, migration, +compaction, or catalog rebuild. + +- [x] T-02.1 Decide the identity contract — ADR-0001. +- [x] T-02.2 Implement `BlobId`, `BlobHasher`, `BlobLength`, and both codecs + with typed parse failures — `src/blob/`, `src/adapters/`; corpus + `conformance/golden-file-worldline/v1/`. +- [x] T-02.3 One-pass unknown-length streaming identity — `BlobHasher` + keeps constant state; the length suffix makes a single pass sufficient. + +Deferred by the ADR without an owner: tree-hash parallelism "when a future +measured path warrants it". Any faster path must reproduce every +independent vector first. + +### F-03 Identity layers and RepresentationId + +**Status:** Done as a model (ADR-0002, issue #3). The `RepresentationId` +codec is reserved and unassigned. + +Five concepts stay distinct: `BlobId` (logical bytes), `LayoutId` +(reconstruction plan), `RepresentationId` (one stored encoding, including +compression and encryption), physical location (mutable catalog evidence), +and retention reference (liveness evidence). The transition-law table says +which identifiers may change under rechunk, repack, re-encrypt, tier copy, +compaction, and catalog rebuild. + +- [x] T-03.1 Decide the layers and their transition laws — ADR-0002. +- [x] T-03.2 Assign layout codec 1 — `keep.flat-chunks/v1`, see F-05. +- [ ] T-03.3 Assign a representation codec — reserved envelope + `KEEP:REPR:ID`; first assignment belongs to F-28 (encryption) or F-39 + (compression), whichever lands first. The catalog, retention, and GC + formats must already carry a representation coordinate before either + ships; see T-28.2. + +### F-04 Deterministic chunking and ChunkId + +**Status:** Done (ADR-0003, issues #7 and #8, M2). + +Boundary algorithm `keep.fastcdc-gear64/v1`; the only registered profile is +`fastcdc-64k-v1` (minimum 16 KiB, target 64 KiB, maximum 256 KiB, NC2 +normalization, seed 0). Boundaries are source-partition invariant; the +detector retains at most 4 KiB of state and allocates nothing on the heap. +`ChunkId` names one exact nonempty chunk under its own domain +`KEEP:CHUNK:DATA`. + +- [x] T-04.1 Decide the algorithm and profile record — ADR-0003; 96-byte + profile record; `StorageProfileId`. +- [x] T-04.2 Implement `FastCdc` and `ChunkId` — `src/chunk/`; + `tests/streaming_cdc.rs`, `tests/streaming_cdc_memory.rs`, + `fuzz/fuzz_targets/fast_cdc.rs`, `benches/streaming_cdc.rs`. +- [x] T-04.3 Language-neutral corpora — `conformance/cdc-profile/v1/`, + `conformance/chunk-id/v1/`; `cargo xtask conformance-check`. + +Open design note without an owner: the `FastCdc::feed` callback has no +fallible storage sink; F-24 supplies that boundary. + +### F-05 Flat chunk layout v1 and LayoutId + +**Status:** Done (issues #9, #10, #11, #13, M2). Two ledger rows are +specified by design only. + +`keep.flat-chunks/v1` maps one `BlobId` to an ordered, bounded, contiguous +sequence of `ChunkId` entries under one registered `StorageProfileId`. +Decoded, validated, admitted, and verified-reconstruction states are +distinct types. Depth 1, at most 1,048,576 entries, at most 256 GiB per +plan. + +- [x] T-05.1 Specify the format — `docs/formats/flat-chunk-layout-v1/`; + `KEEP-LAYOUT-001` to `-012`. +- [x] T-05.2 Implement the codec, admission, and fuzz target — + `AdmittedLayout`, `CanonicalLayoutRecord`, `LayoutDecodePolicy`; + `fuzz/fuzz_targets/layout_record.rs`; corpus `conformance/layout/v1/`. +- [x] T-05.3 Verified reconstruction replays the profile — `KEEP-LAYOUT-016`. +- [x] T-05.4 Exact range planning — `AdmittedLayout::plan_range`, + `KEEP-LAYOUT-017`. +- [ ] T-05.5 Name executable evidence for `KEEP-LAYOUT-013` and + `KEEP-LAYOUT-015`. Both rows read "Specified" with no test named. + Requirement: a test asserts that no physical coordinate participates in + `LayoutId` (change a catalog location, identity unchanged) and that a + codec-1 record with any hierarchical marker refuses with a typed error. + Acceptance: ledger rows change to Implemented with file names. Scope in: + two tests and a ledger edit. Scope out: any format change. Interface: + none. Test plan: golden (existing corpus), edges (reserved bytes, + unknown codec token), no fuzz needed. Definition of done: ledger and + tests merged. Complexity: S. Documentation: ledger rows only. + Dependencies: none. + +### F-06 Reference store + +**Status:** Done (issue #13). Two open defects, #71 and #74. + +`ReferenceStore` is the capacity-bounded, in-memory, non-durable adapter +that proves the stage, commit, and reconstruct laws: `stage` chunks and +hashes without visibility, `StagedBlob::commit` is the explicit +transition, `reconstruct` and `read_range` verify before emitting. Process +death loses everything in it; no API makes a durability claim. + +- [x] T-06.1 Bounded streaming ingestion and reconstruction — + `tests/streaming_cas/`, 216 exhaustive three-step model sequences. +- [x] T-06.2 Chunk deduplication keyed by `ChunkId` as a storage fact, not + retention — `docs/architecture/reference-store/README.md`. +- [ ] T-06.3 Single-pass authenticated emit (#71, P2). + - **Requirements:** each selected chunk is hashed exactly once per + `reconstruct` or `read_range` call; the complete `BlobId`, range + accounting, and profile-boundary verification stay intact; the failure + contract (untrusted prefix on failure) is unchanged. + - **Acceptance criteria:** a counting adapter proves one + `ChunkId::hash_bytes` call per selected chunk; all existing refusal laws + pass; no new public type. + - **Scope:** in — `src/reference/reconstruction.rs`, + `src/reference/range_read_execution.rs`. Out — a strict two-phase mode; + if a caller wants proof-before-emit, that is a new explicit API, not a + default. + - **User stories:** Human — a maintainer sees full-blob reads cost one + hash per chunk in the benchmark, not two. API user — `reconstruct` on a + large blob halves CPU with identical receipts. MCP user — a "read blob" + tool returns faster with the same guarantee. Agent — an agent reading + many blobs in a loop is not charged twice for verification. + - **Interface:** unchanged `ReferenceStore::reconstruct*` and + `read_*range`. + - **Contract schema:** none. + - **Test plan:** golden — existing `reconstruction_laws`; edges — + single-chunk blob, two-chunk blob, range touching one boundary chunk; + known failures — every `refusal_laws` case must still stop at the same + chunk; fuzz — none; stress — the benchmark scenario `whole-blob read` + must show read amplification at 1 instead of 2. + - **Definition of done:** regression test merged, benchmark baseline + regenerated, CHANGELOG entry. + - **Complexity:** S. + - **Documentation:** `docs/architecture/reference-store/rationale.md` + paragraph on two verification passes rewritten. + - **Dependencies:** none. Blocks nothing, but F-24 inherits the pattern. +- [ ] T-06.4 Bounded-memory staging (#74, P1). + - **Requirements:** staging holds a bounded window of missing chunks, not + every missing chunk for the whole blob; the bound is explicit, checked, + and reported; `StagedBlob` semantics (invisible until commit) hold. + - **Acceptance criteria:** an instrumented test proves peak staged bytes + stay under a configured ceiling for a source larger than the ceiling; or + a written rationale proves materialization is unavoidable for the + in-memory adapter and the ceiling is enforced as a refusal instead. + - **Scope:** in — `src/reference/chunk_staging.rs`, + `src/reference/staged_blob.rs`, `IngestionAllocation`. Out — durable + staging (F-24), asynchronous ingestion. + - **User stories:** Human — staging a 4 GiB file does not need 4 GiB of + RAM. API user — `stage` refuses with a typed capacity error before + exhausting memory. MCP user — an "ingest file" tool cannot take the + server down with one large input. Agent — an agent ingesting a corpus + can predict memory from the documented bound. + - **Interface:** `ReferenceStore::stage` gains no parameters; the bound + comes from `ReferenceStoreCapacity` or a new `StagingWindow` value. + - **Contract schema:** none. + - **Test plan:** golden — existing ingestion laws; edges — source exactly + at the window, one byte over, all chunks already present, none present; + known failures — interrupted source mid-window discards only the + window; fuzz — none; stress — `tests/streaming_cas_memory.rs` extended + with a memory ceiling law. + - **Definition of done:** memory law merged; README example unchanged. + - **Complexity:** M. + - **Documentation:** reference-store README "bounded memory" section. + - **Dependencies:** blocks F-24 (#82) and F-28 (#83). + +### F-07 Authenticated reconstruction and exact range reads + +**Status:** Done for `ReferenceStore` (`KEEP-RECONSTRUCT-001` to `-008`). +The durable form is F-23. + +Every read either establishes its proof scope, emits exactly the supported +bytes, and returns a receipt; or returns an evidenced refusal; or fails +operationally with no content claim. `ReconstructionReceipt` proves the +complete object. `RangeReadReceipt` proves only the requested bytes from +authenticated complete chunks and never satisfies an API that needs the +complete-object receipt. + +- [x] T-07.1 State the contract — + `docs/invariants/authenticated-reconstruction/`. +- [x] T-07.2 Complete-object and exact-layout reads — + `tests/streaming_cas/reconstruction_laws.rs`. +- [x] T-07.3 Exact range reads load only overlapping chunks — + `tests/range_read.rs`, `tests/range_read_properties.rs`, + `tests/range_read_entrypoints.rs`. +- [x] T-07.4 Success, evidenced refusal, and operational failure are + distinct — `tests/range_read_failures.rs`. + +### F-08 Conformance corpora and the Golden File Worldline + +**Status:** Done (issues #4, #5, #44, #57, #59). + +Language-neutral, checked-in corpora with independent oracles: the Golden +File Worldline (eight semantic laws over states A and B), CDC profile v1, +ChunkId v1, layout v1, segment store v1 (with the `KEEP-CRASH-001` to +`-035` transition table), and segment store v2. Every checker is Rust, +lives in `xtask`, and imports no production code. The external `b3sum` +witness runs under a bounded, deadline-guarded process boundary. + +- [x] T-08.1 Worldline scenario and reference model — + `docs/conformance/golden-file-worldline.md`; + `cargo xtask golden-file-worldline-check`. +- [x] T-08.2 CDC and ChunkId oracles in Rust — `cargo xtask conformance-check`. +- [x] T-08.3 Segment store v1 and v2 fixture oracles — + `xtask/tests/segment_store_protocol_contract/`, + `xtask/tests/retention_store_v2_format_oracle`. +- [x] T-08.4 Bounded external digest execution — ADR-0008. + +Rows in `capabilities.tsv` marked `declared-future` (chunk reuse, exact +range I/O, durability, restart recovery, corruption refusal, retention, +compaction, encryption) become executable only when the owning feature +below lands; each such feature's definition of done includes flipping its +row. + +### F-09 Streaming CAS benchmark baseline + +**Status:** Done (issue #12). Regression thresholds are deliberately +unconfigured. + +`cargo xtask benchmark-baseline` runs thirteen scenarios over a 16 MiB +generated corpus, compares the registered profile with benchmark-only +FastCDC sizes, fixed-size chunking, and git-cas Buzhash, and writes a TSV +with environment and commit identity. One baseline exists: +`benchmark/baselines/c529c07-aarch64-apple-darwin.tsv`. + +- [x] T-09.1 Corpus, scenarios, metrics, and one baseline — + `docs/benchmarks/streaming-cas-baseline-v1/README.md`. +- [ ] T-09.2 Regression thresholds from controlled history. + - **Requirements:** at least five clean optimized baselines on one + designated runner class before any tolerance is proposed; thresholds + are per scenario and per metric; a threshold breach is advisory until + the standard says otherwise. + - **Acceptance criteria:** a documented runner class, five committed + baselines, a proposal table with the tolerance and its derivation, and + an xtask comparison command that reports breaches without failing CI. + - **Scope:** in — baselines directory, comparison command, README table. + Out — CI gating, optimization work, new scenarios (see T-09.3). + - **User stories:** Human — a maintainer sees a p95 regression named in a + PR check before merging. API user — none directly. MCP user — none. + Agent — an agent proposing a chunker optimization can cite a threshold + instead of a feeling. + - **Interface:** `cargo xtask benchmark-compare `. + - **Contract schema:** the existing TSV columns; a `tolerance` column in + a new `thresholds.tsv`. + - **Test plan:** golden — comparison over two identical files reports no + breach; edges — missing scenario, missing column, different commit, + different runner; fuzz — none; soak — five consecutive runs on the + runner within the proposed tolerance. + - **Definition of done:** proposal accepted in a rationale note; command + merged. + - **Complexity:** M (mostly waiting for history). + - **Documentation:** benchmark README "Regression thresholds" section. + - **Dependencies:** a Linux runner class (F-41 T-41.3) if durable + scenarios are to count. +- [ ] T-09.3 Durable-store scenarios. + - **Requirements:** scenarios the Rust standard §18 requires and the + harness lacks: already-compressed data, recovery scan, root + publication, GC planning, compaction, post-compaction reads; metrics + the harness does not record: fsync count, store size on disk. + - **Acceptance criteria:** each scenario reproducible from a generated + corpus; results carry the same environment identity as the CAS + baseline; verification cannot be silently disabled. + - **Scope:** in — `benchmark/` scenarios and metrics. Out — thresholds. + - **User stories:** Human — an operator sees how long recovery takes for + a store of a given size. API user — none. MCP user — none. Agent — an + agent planning a GC run can estimate its cost. + - **Interface:** `cargo xtask benchmark-baseline --profile durable`. + - **Contract schema:** TSV columns extended; documented in the README. + - **Test plan:** golden — scenario list is pinned; edges — empty store, + single-segment store; stress — a store at the catalog entry ceiling. + - **Definition of done:** one committed durable baseline on Linux. + - **Complexity:** M. + - **Documentation:** benchmark README. + - **Dependencies:** F-13 (recovery scan), F-18 (root publication), F-22 + (GC planning, compaction); each scenario lands with its feature. + +### F-10 Hexagonal boundary architecture + +**Status:** Done (ADR-0004). + +The domain core owns laws, validated types, and policy-free orchestration. +Outbound ports name required capabilities (`RetentionPublicationStorage` +names seventeen). Adapters implement ports. Codecs live only at boundaries. +Core and ports import no adapter and no dependency wire type. + +- [x] T-10.1 Decide the architecture — ADR-0004. +- [x] T-10.2 Enforce it structurally — `cargo xtask source-structure-check` + (module size, forbidden filenames, no Python), `unreachable_pub = "deny"`. +- [x] T-10.3 Every durable protocol has a storage port and a fault-injecting + fake — `tests/*_storage.rs` across catalog, recovery, retention, and + migration. + +### F-11 Immutable segment format and verified I/O + +**Status:** Done (ADR-0005, issues #14 and #15, M3). + +`keep.segment-store/v1` segments: a 64-byte header, complete typed records +(chunk or flat layout) each with a 112-byte header and a 32-byte checksum, +and a 128-byte seal carrying the physical segment digest. `StagedSegment` +writes only content-admitted records; `SealedSegment` and `ClosedSegment` +are distinct consuming types; `AdmittedSegment` exposes payloads only after +complete framing, checksum, and identity verification. + +- [x] T-11.1 Specify the protocol as one inseparable triple of bytes, crash + states, and recovery — ADR-0005; `docs/formats/segment-store-v1/`. +- [x] T-11.2 Implement codecs, writer, and reader — `KEEP-SEGMENT-001` to + `-010`; `fuzz/fuzz_targets/segment_format.rs`. +- [x] T-11.3 Deterministic fault injection at every write phase — + `tests/segment_writer/`, `tests/segment_filesystem_stage.rs`. + +### F-12 Catalog generations and writer-locked publication + +**Status:** Done (issue #16, M3). + +Immutable, generation-numbered catalogs map logical record identity to +physical location without making location part of identity. A 128-byte +`HEAD` names exactly one catalog and is the only file version 1 replaces +in place. One writer holds kernel advisory locks on the store root and +`writer.lock`; readers retain one complete generation and never mix two. + +- [x] T-12.1 Catalog and head codecs, ordering, successor proofs — + `KEEP-CATALOG-001` to `-006`, `-011`. +- [x] T-12.2 Writer exclusion and platform-admitted publication — + `KEEP-CATALOG-007`, `-008`; `FilesystemWriterLock`, + `FilesystemCatalogPublisher`, `publish_catalog_generation`. +- [x] T-12.3 Restart snapshot and model agreement — `KEEP-CATALOG-009`, + `-010`; `FilesystemCatalogSnapshot`. + +### F-13 Store initialization, recovery, and the crash matrix + +**Status:** Done (issue #17, M3). + +Production initialization admits only a writable, non-casefolded Linux +ext4 root on a single local host. Opening is observational; recovery is +explicit and planned against storage ports: inventory, name classification, +stage fingerprint and assessment, then discard, completion, next-head +finalization, or segment resume. `cargo xtask durability-crash-matrix` +kills real writer processes before, during, and after `KEEP-CRASH-001` to +`-035` (105 cases) and asserts the store lands in exactly one documented +lawful state. + +- [x] T-13.1 Ordered, idempotent, writer-locked initialization — + `KEEP-RECOVERY-002` to `-004`; `initialize_store`. +- [x] T-13.2 Recovery inventory, classification, fingerprint, assessment — + `KEEP-RECOVERY-005` to `-012`. +- [x] T-13.3 Discard, completion, next-head finalization, segment resume — + `KEEP-RECOVERY-013` to `-020`. +- [x] T-13.4 Process-death crash matrix — `KEEP-RECOVERY-021`; + `xtask/src/durability_crash_matrix/`; ADR-0006 and ADR-0007 for the + child-process boundary. + +What the matrix does not prove: host power loss, torn media writes, or a +filesystem that violates the admitted atomicity contract. See F-41 T-41.2. + +### F-14 Segment store v1 living documentation refresh + +**Status:** Planned (#69, P2, M4). + +`docs/formats/segment-store-v1/README.md` and `publication.md` still say +initialization, platform admission, and explicit recovery are future work +owned by #17, and that #16 "does not implement admission/recovery". Both +issues are complete on `main`. The v1 pages understate shipped guarantees +and hand version-2 migration a stale source boundary. + +- [ ] T-14.1 Reconcile every v1 page with `main`. + - **Requirements:** every living v1 page describes current behaviour; + historical scope stays reachable through linked issues, ADRs, and Git + history; every existing requirement identifier and test name remains + an evidence anchor; `recovery.md` sentence "Transitive publication-view + admission and filesystem-streaming semantic classification remain + unimplemented" is either evidenced or moved to a gap with an owner. + - **Acceptance criteria:** no v1 page assigns implemented behaviour to a + future issue; `xtask` written-contract tests that pin protocol phrases + still pass or are updated in the same PR. + - **Scope:** in — `docs/formats/segment-store-v1/*`. Out — v1 bytes, v2 + pages, reorganizing unrelated docs. + - **User stories:** Human — a reader of the v1 format learns what + recovery does today. API user — a caller finds the recovery entry + points named on the page that describes their crash states. MCP user — + none. Agent — an agent implementing an adapter does not re-implement + recovery that already exists. + - **Interface:** none. + - **Contract schema:** none. + - **Test plan:** `cargo xtask documentation-integrity-check`, + `cargo xtask documentation-refusal-check`, the + `xtask/tests/*_contract.rs` phrase pins. + - **Definition of done:** #69 closed; CHANGELOG "Changed" entry. + - **Complexity:** S. + - **Documentation:** this task is documentation. + - **Dependencies:** none. Should close before the v2 pages become the + primary format route. + +### F-15 Retention roots, release, and GC liveness model + +**Status:** Done (ADR-0009, issue #18, M4). This is a decision, not an +implementation; F-16 through F-22 implement it. + +Caller-supplied opaque namespaces (1 to 255 bytes, at most 4,096 per store) +each hold a generation-checked root of reconstruction anchors +(`BlobId` plus `LayoutId`). A global manifest binds every namespace to its +root under one liveness generation. Release publishes a successor +generation that omits an anchor; it promises no erasure. Grace is an +explicit anchor in a dedicated namespace, never a clock. GC plans from an +immutable liveness snapshot, retires whole segments only, and holds writer +authority plus an exclusive reader fence. + +- [x] T-15.1 Decide namespaces, generations, anchors, closure, release, + grace, liveness snapshots, GC, dispositions — ADR-0009. +- [x] T-15.2 Reject Git refs, leases, reference counts, unversioned + tracing, caller-supplied physical closure, and clock-based grace — + ADR-0009 "Alternatives considered". + +Deferred by the ADR: a representation-aware retention policy ("retain every +representation") as a future extension; an ABA-safe successor protocol to +raise the 4,096 namespace ceiling or reclaim tombstones. + +### F-16 Version-2 format records and codecs + +**Status:** Done (issue #19, PR #78). `KEEP-RETENTION-003` mutation +coverage is in progress. + +`keep.segment-store/v2` adds a 96-byte `FORMAT` marker, 256-byte migration +intent and receipt, root-generation records (192-byte header, 119-byte +anchors, digest, checksum), a global manifest (160-byte header, 72-byte +entries), a 144-byte `retention/HEAD`, and reserved grammars for GC intent, +GC receipt, and recovery disposition receipts. Every record has a +domain-separated digest and checksum, a frozen golden fixture, and a +decoder that refuses every structural fault before admission. + +- [x] T-16.1 Freeze the definition and corpus — + `conformance/segment-store/v2/definition.tsv`; format-definition digest + `32381f1a…3427`. +- [x] T-16.2 Retention values and codecs — `KEEP-RETENTION-001`, `-002`; + `RetentionNamespace`, `RootGeneration`, `LivenessGeneration`, + `RetentionAnchor`, `CanonicalRetentionRoot`, `CanonicalRetentionManifest`, + `CanonicalRetentionHead`. +- [x] T-16.3 Marker, intent, and receipt codecs — `KEEP-MIGRATION-002`; + `fuzz/fuzz_targets/migration_format.rs`. +- [x] T-16.4 Seeded `retention_format` fuzz target. +- [ ] T-16.5 Complete the corruption matrix (`KEEP-RETENTION-003`). + - **Requirements:** every structural field of root, manifest, and head + has a permanent mutation case with the exact typed refusal it must + produce; no field is covered only by the fuzz target. + - **Acceptance criteria:** the ledger row moves from "In progress in + #19" to Implemented naming the mutation test modules; a deliberately + weakened decoder fails at least one case per field. + - **Scope:** in — `tests/retention_root_decoding.rs`, + `tests/retention_manifest_codec.rs`, `tests/retention_head_codec.rs` + and a mutation table. Out — format changes. + - **User stories:** Human — a maintainer can point at the test that + refuses a flipped bit in a manifest entry. API user — decode errors + name the field. MCP user — none. Agent — an agent adding a field knows + the matrix it must extend. + - **Interface:** none. + - **Contract schema:** none. + - **Test plan:** golden — v2 corpus; edges — every reserved byte, every + length field at bound and bound plus one, unsorted anchors, duplicate + namespace digests, generation zero and overflow; known failures — + substituted digest with valid checksum; fuzz — existing target seeded + from the corpus. + - **Definition of done:** ledger row Implemented. + - **Complexity:** S. + - **Documentation:** ledger row. + - **Dependencies:** none. + +### F-17 One-way migration from version 1 to version 2 + +**Status:** Partial. The fresh forward path is Done (issue #19, PR #78). +Partial-prefix recovery, the `KEEP-CRASH-053` to `-073` matrix, and +`KEEP-MIGRATION-001`, `-004`, `-005`, `-006`, `-007`, `-008` residue remain, +gated by #97. + +A complete version-2 store is entered only by migrating a version-1 store: +admit and recover v1, revalidate head, catalog, pools, root identity, and +writer authority, then execute twenty-one ordered phases that publish +`migration.intent`, create `reader.lock` and the retention, GC, and +recovery directories, publish `FORMAT`, reopen and verify the complete v2 +view, and publish `migration.receipt`. Every version-1 byte is preserved. +Direct version-2 initialization is undefined. There is no downgrade. + +- [x] T-17.0 Forward migration under writer authority — + `KEEP-MIGRATION-002`, `-003`; `execute_store_migration`, + `StoreMigrationPhase::ALL`, `FilesystemStoreMigrationAuthority`, + `FilesystemStoreMigrationInventoryReader`; + `FilesystemVersionTwoAdmission::reopen`. +- [ ] T-17.1 Restart-stable root identity coordinate (#97). + - **Requirements:** the migration intent stops depending on + `statx.stx_mnt_id`, which changes across unmount, remount, and reboot; + either the intent format drops the mount coordinate (a format revision + with a new golden record) or a restart-stable coordinate is defined + (device plus inode of the root and of `FORMAT`, or filesystem UUID) with + a specified remount re-admission rule. The `RootIdentityChanged` + refusal and both comparison sites (`verify_root_identity` before + mutation, `reopen` on reopen) stay. + - **Acceptance criteria:** a decision recorded in + `docs/formats/segment-store-v2/recovery.md` and `requirements.md`; a + law that reopens a migrated store after a simulated remount (different + mount id, same device and inode) and observes the decided behaviour; + `KEEP-MIGRATION-004` evidence no longer depends on a transient + coordinate; the v2 corpus updated if bytes change. + - **Scope:** in — intent codec, root identity types + (`StoreRootMountIdentity`, `StoreRootIdentityCoordinate`), reopen + admission, corpus. Out — partial-prefix recovery itself (T-17.2); + non-Linux identity. + - **User stories:** Human — an operator reboots the host and the store + reopens without refusing as "root identity changed". API user — + `FilesystemVersionTwoAdmission::reopen` succeeds after remount and still + refuses a store copied to another device. MCP user — a "reopen store" + tool distinguishes "moved" from "rebooted". Agent — an agent that + migrates a store and later resumes on a fresh boot is not locked out. + - **Interface:** none new; `FilesystemPlatformAdmissionError::RootIdentityChanged` + keeps its shape or gains a documented variant. + - **Contract schema:** `CanonicalStoreMigrationIntent` bytes 0..256 per + `definition.tsv`; a revision bumps the record version and adds a new + `migration-intent.hex`. + - **Test plan:** golden — new or unchanged intent fixture; edges — same + inode different device, same device different inode, `FORMAT` replaced + by a byte-identical file at a new inode; known failures — every current + `RootIdentityChanged` law; fuzz — `migration_format` reseeded; soak — + none. + - **Definition of done:** #97 closed; rationale note explains the + rejected alternative. + - **Complexity:** M (S if the coordinate is simply dropped). + - **Documentation:** `recovery.md`, `requirements.md`, `migration-crash.md`, + v2 corpus README, CHANGELOG. + - **Dependencies:** blocks T-17.2 and T-17.3. +- [ ] T-17.2 Partial-prefix migration recovery (`KEEP-MIGRATION-004`, + residual #19 item 7). + - **Requirements:** the seven-row recovery table in + `migration-recovery.md` becomes executable: no artifact admits v1; + intent stage only finalizes or discards the pre-effect stage; durable + intent continues; intent plus a canonical prefix of v2 names verifies + each and continues; complete v2 shape without marker writes the marker; + marker without receipt reopens and publishes the receipt; exact receipt + cleans any receipt stage and admits. Every ambiguity row (missing + predecessor, changed v1 coordinate, out-of-order name, wrong kind or + bytes, conflicting receipt, unknown entry, changed root identity) + refuses before mutation with a typed value. Continuation is idempotent. + - **Acceptance criteria:** a storage-independent planner over the golden + records with one law per table row; a filesystem adapter that reopens + each stage by device and inode identity; every prefix 0 through 21 and + each mid-write truncation recovers in-process to the documented state; + `KEEP-MIGRATION-001`, `-004`, `-005`, `-006` rows move to Implemented. + - **Scope:** in — `src/adapters/store_migration/` recovery planner, + executor, storage port, filesystem adapter. Out — process-death + evidence (T-17.3); GC artifacts (F-22). + - **User stories:** Human — an operator whose migration was interrupted + reruns it and it finishes instead of waiting for a human. API user — + `FilesystemStoreMigrationAuthority::recover` returns a receipt naming + which prefix it found and what it did. MCP user — a "migrate store" + tool is safe to retry. Agent — an agent can drive migration to + completion without reading logs. + - **Interface:** `recover_store_migration(...)`, trait + `StoreMigrationRecoveryStorage`, `StoreMigrationRecoveryReceipt`, + `StoreMigrationRecoveryError`; filesystem + `FilesystemStoreMigrationAuthority::recover`. + - **Contract schema:** no new durable bytes; receipt is in-memory and + binds the observed prefix, the intent digest, and every phase executed. + - **Test plan:** golden — each table row from the v2 corpus; edges — + prefix boundaries at each of the 21 phases, truncated `intent.next`, + `FORMAT.next` complete but unlinked, receipt stage with wrong intent + digest; known failures — every ambiguity row; fuzz — recovery planner + over mutated inventories; stress — a store with 2,097,152 inventory + entries recovers within the inventory ceiling. + - **Definition of done:** ledger rows Implemented; README gap table row + for migration recovery removed. + - **Complexity:** L. + - **Documentation:** `migration-recovery.md` Status, `recovery.md`, + `requirements.md`, CHANGELOG. + - **Dependencies:** needs T-17.1. Blocks T-17.3, F-43. +- [ ] T-17.3 Migration crash matrix `KEEP-CRASH-053` to `-073` + (`KEEP-MIGRATION-007`). + - **Requirements:** real writer processes killed before, during, and + after each of the 21 boundaries (`KEEP-CRASH-060` needs one case per + admitted directory-prefix length); restart runs T-17.2 and the forward + retry reports the predicted outcome; the matrix runs in debug and + optimized xtask profiles; no sleeps, wall-clock, or scheduler luck. + - **Acceptance criteria:** `cargo xtask durability-crash-matrix` covers + 001 to 073 plus retention (T-18.2); every case asserts catalog + visibility, `FORMAT` presence, intent and receipt state, directory + set, and recovery report; `KEEP-MIGRATION-007` and `-008` Implemented. + - **Scope:** in — `xtask/src/durability_crash_matrix/`, fault-injecting + port decorators behind `repository-tasks`. Out — power loss. + - **User stories:** Human — CI proves migration survives being killed at + every step. API user — none directly. MCP user — none. Agent — an agent + can trust the migration retry rule because it is machine-verified. + - **Interface:** `cargo xtask durability-crash-matrix --sequence migration`. + - **Contract schema:** `conformance/segment-store/v2/transitions.tsv` + listing 053 to 073 with pre-state, interrupted class, post-state, + recovery posture (mirrors the v1 table). + - **Test plan:** golden — transitions table; edges — kill during + directory sync, kill between link and stage removal; known failures — + none expected; stress — the full matrix under CI's ten-second deadline. + - **Definition of done:** CI green on the extended matrix; #19 residue + closed. + - **Complexity:** L. + - **Documentation:** `migration-crash.md` "does not yet claim crash + recovery" removed; README "Proven restart recovery" bullet updated. + - **Dependencies:** needs T-17.2. + +### F-18 Retention publication + +**Status:** Partial. Forward publication is Done (issue #19, PR #78). +Recovery and the `KEEP-CRASH-036` to `-052` matrix are In review (PR #99). +Explicit disposition of complete orphans waits for F-22. + +A retain or release names a namespace, an expected state (absent or an +exact `RootGeneration`), a complete anchor set, and the realization +profile. Publication recovers every fixed retention stage, admits the +current head, manifest, and root, compares generations, verifies the +closure against the pinned catalog, then executes seventeen ordered +durability phases: stage `root.next`, link the root into its pool, stage +`manifest.next`, link the manifest, stage `head.next`, atomically replace +`retention/HEAD`, and remove the stages only after the head commits. It +returns a receipt binding every coordinate. It refuses retained stages, +superseded candidates, substituted files, replaced protocol directories, +and every namespace or capacity violation before writing anything. + +- [x] T-18.0 Forward publication with filesystem authority — + `KEEP-RETENTION-004`, `-005`, `-009`; `execute_retention_publication`, + `RetentionPublicationPhase` (17), `FilesystemRetentionPublicationAuthority`, + `RetentionCurrentStateRefusal`. +- [ ] T-18.1 Retention publication recovery (`KEEP-RETENTION-007`; PR #99). + - **Requirements:** truncated stage with no later effect is discarded; + complete root or manifest stage is linked into its pool and retained + as a recovery-protected orphan, and publication refuses until + disposition; complete head over linked stages is finalized and both + stages removed; stages the published head already names are cleaned + up; anything else refuses with a typed value before any effect. + `verify_current` runs recovery first. + - **Acceptance criteria:** storage-independent planner with one law per + classification; `FilesystemRetentionPublicationAuthority::recover` + reopens stages by identity; every prefix 0 through 18 and each + mid-write truncation recovers in-process; `RecoveryRefused` and + `RecoveryStepRefused` are distinguishable. + - **Scope:** in — `src/adapters/retention/` recovery planner, executor, + storage port, filesystem adapter. Out — orphan disposition (F-22). + - **User stories:** Human — an operator whose retain was interrupted + reruns it and it either finishes or names the orphan that needs a + decision. API user — `recover` returns a receipt naming each stage and + its disposition. MCP user — a "retain" tool is idempotent across + crashes. Agent — an agent can retry retention without inspecting the + directory. + - **Interface:** `execute_retention_recovery(...)`, trait + `RetentionRecoveryStorage`, `FilesystemRetentionPublicationAuthority::recover`. + - **Contract schema:** none new; in-memory receipt. + - **Test plan:** golden — recovery planning laws over the v2 golden + records; edges — each of the three stage files complete, truncated, + absent, and byte-identical at a new inode; known failures — every + "anything else" refusal; fuzz — none new; stress — none. + - **Definition of done:** PR #99 merged; ledger row Implemented. + - **Complexity:** L (delivered in PR #99). + - **Documentation:** `recovery.md` "Retention publication recovery" + table marked implemented; CHANGELOG. + - **Dependencies:** none. Blocks F-22 orphan disposition, F-43. +- [ ] T-18.2 Retention crash matrix `KEEP-CRASH-036` to `-052` (PR #99). + - **Requirements:** real process death before, during, and after each + of the 17 phases (51 coordinates); restart recovers and the forward + retry reports the predicted outcome; full matrix green in debug and + release. + - **Acceptance criteria:** 156-case combined matrix green in CI; + `KEEP-RETENTION-006` crash-injection remainder closed. + - **Scope:** in — `xtask/src/durability_crash_matrix/`. Out — migration + coordinates (T-17.3). + - **User stories:** as T-17.3, for retention. + - **Interface:** `cargo xtask durability-crash-matrix`. + - **Contract schema:** `conformance/segment-store/v2/transitions.tsv` + rows 036 to 052. + - **Test plan:** as T-17.3. + - **Definition of done:** PR #99 merged; README "waits for a human" + paragraph rewritten to name only orphan disposition. + - **Complexity:** L (delivered in PR #99). + - **Documentation:** `recovery.md`, README gap table. + - **Dependencies:** T-18.1. +- [ ] T-18.3 Closure-member re-verification under filesystem authority + (`closure.md` Status; `KEEP-RETENTION-006` source-chain obligation). + - **Requirements:** when recovery or publication re-reads a + closure-member segment under authority, the original decode or + admission error travels as the `source` of the operation-level error; + no wrapping erases it. + - **Acceptance criteria:** a law downcasts through the publication error + to the exact `SegmentRecordAdmissionError` that caused it. + - **Scope:** in — error wrapping in the filesystem retention authority. + Out — verification reports (F-21). + - **User stories:** Human — a refused retain says which segment record + failed and why. API user — `source()` chains are complete. MCP user — + the tool error names the record. Agent — an agent can route the + failure to the right remediation. + - **Interface:** none new. + - **Contract schema:** none. + - **Test plan:** golden — none; edges — corrupt chunk record, corrupt + layout record, missing member; fuzz — none. + - **Definition of done:** `closure.md` Status updated. + - **Complexity:** S. + - **Documentation:** `closure.md`, `closure-corruption.md`. + - **Dependencies:** T-18.1. + +### F-19 Reader fence and immutable version-2 snapshots + +**Status:** In review (PR #99; `KEEP-RETENTION-008`). + +`reader.lock` is a persistent, zero-length regular file whose existence +and contents prove nothing. A version-2 reader acquires a kernel-managed +shared lock on it before opening the catalog `HEAD` or `retention/HEAD`; +the returned `ReaderFence` owns the lock for the snapshot's lifetime and +releases only the lock, never the file. Readers double-collect both heads +around complete transitive admission and accept only identical coordinates +before and after, retrying within a bounded attempt limit. GC takes writer +authority then the exclusive reader lock, in that order; publication never +waits on readers because it deletes nothing. + +- [ ] T-19.1 `ReaderFence`, `collect_retention_view`, + `FilesystemRetentionSnapshot` (PR #99). + - **Requirements:** shared lock acquired before either head is opened; + fence released on drop or process death without deleting `reader.lock`; + double-collect compares catalog generation and digest plus retention + liveness generation and manifest digest; bounded retries; exhaustion + refuses with a typed value; the snapshot binds one catalog, one + manifest, and every root generation the manifest names. + - **Acceptance criteria:** nine laws (per PR #99): fence before head, + identical-coordinates acceptance, changed-coordinates retry, retry + exhaustion refusal, fence survives publication, exclusive acquisition + blocks new readers, drop releases, process death releases, file never + deleted. + - **Scope:** in — `src/adapters/retention/` snapshot and fence. Out — + GC's exclusive acquisition (F-22), durable read receipts (F-23). + - **User stories:** Human — an operator can run a verification pass + while a writer publishes and see one consistent view. API user — + `FilesystemRetentionSnapshot::open(root)` returns a view whose + coordinates are named on the receipt. MCP user — a "snapshot store" + tool returns a handle that later reads bind to. Agent — an agent's + long-running audit is not invalidated by concurrent retains. + - **Interface:** `ReaderFence`, `collect_retention_view`, + `FilesystemRetentionSnapshot::{open, catalog, manifest, root}`. + - **Contract schema:** none new; `reader.lock` semantics in + `recovery.md`. + - **Test plan:** golden — none; edges — publication between the two + collections, `reader.lock` missing (refuse; it is created by + migration), `reader.lock` replaced by a directory or symlink; known + failures — retry exhaustion; concurrency — two readers and one writer + under `cfg(target_os = "linux")`; stress — a reader held across a full + crash-matrix run. + - **Definition of done:** PR #99 merged; `KEEP-RETENTION-008` + Implemented; README gap table row removed. + - **Complexity:** M (delivered in PR #99). + - **Documentation:** `recovery.md` "Reader fence" Status; ADR-0009 + consequence satisfied; CHANGELOG. + - **Dependencies:** none. Blocks F-22 and F-23. + +### F-20 Model-based retention transition evidence + +**Status:** In review (PR #99; `KEEP-RETENTION-010`). + +Every three-operation sequence of retain, release, and re-read across +namespaces agrees with a deterministic namespace-to-anchor-set map +observed through the fenced view, and a source-architecture contract keeps +clocks, paths, environment, and caller identity out of the core. + +- [ ] T-20.1 125 three-operation sequences against the model (PR #99). + - **Requirements:** the model is a `BTreeMap>` with generation counters; each sequence + compares the fenced view with the model after every step; the source + contract greps `src/retention/` and the core `src/adapters/retention/` + planners for `std::time`, `std::env`, `std::path`, and process + identity. + - **Acceptance criteria:** the ledger row names the test module; a + deliberately wrong transition planner fails at least one sequence. + - **Scope:** in — `tests/retention_model.rs` or equivalent. Out — + four-operation sequences (property tests may extend later). + - **User stories:** Human — a maintainer trusts that retain and release + compose. API user — none directly. MCP user — none. Agent — an agent + composing retention operations relies on documented sequence laws. + - **Interface:** none. + - **Contract schema:** none. + - **Test plan:** golden — none; edges — retain then release same anchor, + release absent anchor, retain at stale generation, empty anchor set; + property — random sequences up to length 8 as a follow-up. + - **Definition of done:** PR #99 merged; `KEEP-RETENTION-010` Implemented. + - **Complexity:** M (delivered in PR #99). + - **Documentation:** ledger row; v2 README Status. + - **Dependencies:** F-19. + +### F-21 Precise verification reports and corruption refusal + +**Status:** Planned (#20, P1, M4). The most-cited open blocker: F-22, +F-23, F-24, F-27, F-28, F-29, F-30, F-31, F-33, and F-34 all name it. + +Verify content and store structure at explicit, enumerated depths; report +exactly what was established and nothing more; refuse when evidence is +missing, conflicting, or corrupt. Verification never repairs, substitutes, +quarantines, or rewrites physical state. + +- [ ] T-21.1 Verification policy and report types. + - **Requirements:** policy is an enum of depths, never a set of boolean + flags: framing, checksum, chunk identity, layout identity, complete + blob identity, catalog reachability, retention-root closure, and (once + F-19 lands) snapshot binding; a report states the depth reached per + subject and can never present partial verification as complete; + typed failures retain expected and observed identities, lengths, + generations, and format versions; missing, corrupt, and ambiguous + (conflicting) are distinct; reports contain no plaintext, keys, or + unbounded paths. + - **Acceptance criteria:** `VerificationDepth` enum; `VerificationReport` + with one `VerifiedSubject` per subject naming the depth established; + `VerificationRefusal` with `Missing`, `Corrupt { expected, observed }`, + `Ambiguous { candidates }` variants; a compile-time law that a report + at depth N cannot be converted into one at depth N+1. + - **Scope:** in — a `verification` core module and its adapters over + segment, catalog, layout, and retention readers. Out — repair (never), + remote attestation, application trust decisions, GC (F-22). + - **User stories:** Human — an operator asks "is this store sound to + depth X?" and gets a report they can file, not a boolean. API user — + `verify(store, policy)` returns a typed report with a subject list. + MCP user — a "verify store" tool with a depth parameter returns a + structured report the client can render. Agent — an agent decides + whether to retain, migrate, or escalate from the report's typed + refusals, without parsing prose. + - **Interface:** `verify_blob(view, BlobId, VerificationDepth)`, + `verify_catalog(view, VerificationDepth)`, + `verify_retention(view, RetentionNamespace, VerificationDepth)`; an + adapter would expose `keep verify --depth [--blob ]` and an + MCP tool `keep.verify { depth, subject }`. + - **Contract schema:** in-memory types only in this task. A durable + report format is T-21.3. + - **Test plan:** golden — a report over the v1 and v2 corpora at every + depth; edges — empty store, depth beyond what the view supports + (refuse, not degrade), subject absent versus subject corrupt; known + failures — every existing corruption law must map to exactly one + refusal variant; fuzz — report decoder once T-21.3 exists; stress — + verifying a store at the catalog entry ceiling within the documented + memory bound. + - **Definition of done:** types merged with rustdoc stating memory, I/O, + and complexity per depth. + - **Complexity:** M. + - **Documentation:** new `docs/invariants/verification/` page; ADR-0009 + consequence ("report the exact verification depth") satisfied. + - **Dependencies:** none for the reference store; F-19 for + snapshot-bound depths. +- [ ] T-21.2 Permanent corruption matrix over every durable structural + field. + - **Requirements:** every field of segment header, record header, record + checksum, seal, catalog header, entry, trailer, publication head, + `FORMAT`, intent, receipt, root, manifest, and retention head has a + named mutation whose refusal variant and depth are asserted through + the report, not only through the decoder. + - **Acceptance criteria:** a mutation table per format under + `conformance/`; the Golden File Worldline `corruption refusal` + capability row flips from `declared-future`. + - **Scope:** in — tests and corpora. Out — new formats. + - **User stories:** Human — a maintainer sees which byte a refusal is + about. API user — none new. MCP user — none. Agent — an agent adding + a field extends a table, not a prose list. + - **Interface:** `cargo xtask conformance-check` extended. + - **Contract schema:** `mutations.tsv` per format. + - **Test plan:** golden — the tables; edges — multi-field mutations must + report the first refusal in the documented check order; fuzz — + existing decoder targets; stress — none. + - **Definition of done:** Worldline capability row executable. + - **Complexity:** M. + - **Documentation:** each format README "Mutation ledger". + - **Dependencies:** T-21.1. +- [ ] T-21.3 Durable refusal and verification receipts. + - **Requirements:** a canonical, versioned, checksummed report record + binding `BlobId`, admitted view (catalog generation and digest, + liveness generation and manifest digest), exact `LayoutId` if present, + refusal classification, proof stage, and contract version; no key + material or plaintext; bounded length. + - **Acceptance criteria:** golden fixture; decoder refuses every + structural fault; a receipt written by one process is admitted by + another. + - **Scope:** in — record format, codec, corpus. Out — where receipts are + stored (application choice; Keep does not persist them itself). + - **User stories:** Human — an operator attaches a receipt to an incident + ticket. API user — receipts serialize without Serde-defined bytes. MCP + user — the tool returns the receipt bytes and its text form. Agent — + an agent hands a receipt to another agent and both agree on what it + proves. + - **Interface:** `CanonicalVerificationReceipt::{encode, decode}`. + - **Contract schema:** `KEEP:VERIFY:RCPT` magic, version, depth, subject + kind, subject identity slot (60 bytes), view coordinates, refusal + variant, checksum; exact layout in a new + `docs/formats/verification-receipt-v1/`. + - **Test plan:** golden — fixture; edges — every reserved byte; known + failures — a range-read receipt must not decode as a complete-object + receipt; fuzz — new `verification_receipt` target. + - **Definition of done:** `KEEP-RECONSTRUCT-006` "durable refusal + receipts planned" resolved. + - **Complexity:** M. + - **Documentation:** new format page; `docs/formats/README.md` registry + row. + - **Dependencies:** T-21.1; F-19 for view coordinates. + +### F-22 Garbage collection, compaction, and recovery dispositions + +**Status:** Planned (#21, P1, M4). Grammars are frozen and their presence +refuses (`KEEP-GC-001`, `-002`). + +Plan GC from an immutable liveness snapshot; classify every segment as +live, unreachable, corrupt, ambiguous, recovery-protected, +reader-protected, or already retired; retire only whole immutable segments +that the current catalog no longer names; compact mixed segments by +copying and verifying live records into new segments and publishing a +catalog successor first; hold writer authority then the exclusive reader +lock; write and sync `gc/intent` before any unlink; unlink candidates in +canonical order with a directory sync after every one; publish +`gc/receipt` after all are absent. A verified orphan from an interrupted +publication stays recovery-protected until an explicit finalize-or-retire +disposition receipt exists. `BlobId`, `ChunkId`, and `LayoutId` never move. + +- [ ] T-22.1 GC record codecs and namespace admission (`KEEP-GC-001`). + - **Requirements:** `GcRetirementIntent` (320-byte header, 72-byte + candidates, at most 65,536, digest, checksum), `GcRetirementReceipt` + (320 bytes), `RecoveryDispositionReceipt` (320 bytes) encode and decode + exactly per `gc.md`; presence of any such artifact without the + implementing recovery still refuses until T-22.4. + - **Acceptance criteria:** golden `.hex` fixtures added to + `conformance/segment-store/v2/`; decoders refuse every structural + fault; `gc_format` fuzz target seeded. + - **Scope:** in — codecs, corpus, fuzz. Out — execution. + - **User stories:** Human — none yet. API user — the types exist so + tooling can inspect an intent left by a crash. MCP user — none. Agent — + none. + - **Interface:** `CanonicalGcRetirementIntent`, `AdmittedGcRetirementIntent`, + and receipt equivalents. + - **Contract schema:** as `gc.md`; domains `keep.gc-candidate-set/v2`, + `keep.gc-retirement-intent/v2`, `keep.gc-retirement-receipt-checksum/v2`, + `keep.recovery-disposition-receipt-checksum/v2`. + - **Test plan:** golden — fixtures; edges — candidate count 0 and + 65,537, unsorted candidates, reader-lock identity zero; fuzz — new + target. + - **Definition of done:** `KEEP-GC-001` Implemented. + - **Complexity:** M. + - **Documentation:** `gc.md` Status; corpus README. + - **Dependencies:** none. +- [ ] T-22.2 Deterministic `GcPlan` from a liveness snapshot. + - **Requirements:** input is one immutable snapshot (F-19): manifest + generation and digest, complete namespace map, every anchor and + closure, every profile coordinate, catalog generation and digest, + traversal limits; planning is pure and observational; output is an + immutable, inspectable, `#[must_use]` plan classifying every segment; + no segment is a candidate while the current catalog names any record + in it; ambiguous or corrupt state is refused, never collected. + - **Acceptance criteria:** the plan for the golden v2 store is a golden + fixture; a model test proves that live sets before and after planning + are identical; every classification has a law. + - **Scope:** in — `src/retention/gc/` planner. Out — execution + (T-22.4), compaction (T-22.3). + - **User stories:** Human — an operator runs a dry run and reads exactly + which segments would go and why. API user — `plan_gc(snapshot)` returns + a `GcPlan` they can inspect before executing. MCP user — a "plan gc" + tool returns the plan as data; nothing changes on disk. Agent — an + agent reviews the plan's ambiguity list and refuses to proceed if + nonempty. + - **Interface:** `plan_gc(&RetentionSnapshot, GcLimits) -> Result`; adapter `keep gc plan` and MCP `keep.gc.plan`. + - **Contract schema:** in-memory `GcPlan`; the intent record is derived + from it in T-22.4. + - **Test plan:** golden — plan fixture; edges — empty store, store with + only orphans, segment shared between live and released anchors; known + failures — a segment named by a retained stage; property — random + retain and release histories, then plan, then assert the live closure + is untouched; stress — planning at the closure node ceiling. + - **Definition of done:** planner merged with its model test. + - **Complexity:** L. + - **Documentation:** `gc.md` planning section; a warning per + Documentation Standards §5.4 on every page that describes execution. + - **Dependencies:** F-19, F-21 (planning consumes verification depth). +- [ ] T-22.3 Identity-preserving compaction. + - **Requirements:** copy live records into new immutable segments, + verify them, publish a catalog successor naming the new locations, + revalidate expected catalog and retention generations before acting, + keep old segments readable until the successor is durable; `BlobId`, + `ChunkId`, `LayoutId` stable; every step is a named crash point. + - **Acceptance criteria:** model test proves reads and retention sets are + equivalent before and after; crash injection covers copy, verify, + publication, retirement, deletion, and recovery of compaction; + benchmarks report amplification, sync count, reclaimed bytes, latency, + and peak temporary space. + - **Scope:** in — compaction planner, executor, storage port, filesystem + adapter, crash points `KEEP-CRASH-074` onward. Out — re-encoding + (representation change) which waits for F-28 or F-39. + - **User stories:** Human — an operator reclaims space from a store + whose segments are half released. API user — `compact(plan)` returns a + receipt naming the new catalog generation. MCP user — "compact" is + refused unless a dry-run plan id is supplied. Agent — an agent + schedules compaction only when the plan's reclaimable bytes exceed a + threshold it computes. + - **Interface:** `execute_compaction(...)`, trait `CompactionStorage`. + - **Contract schema:** none new beyond the catalog successor. + - **Test plan:** golden — a compacted golden store; edges — nothing to + compact, everything live, a segment at the 1 GiB ceiling; known + failures — crash after new segment sealed but before catalog + published leaves a valid orphan, not a loss; crash matrix — every new + crash point; soak — repeated compaction cycles preserve every identity. + - **Definition of done:** Worldline `compaction stability` row executable. + - **Complexity:** L. + - **Documentation:** `gc.md` compaction section; ADR-0002 compaction + example cross-linked. + - **Dependencies:** T-22.2. +- [ ] T-22.4 GC execution, retirement, and recovery (`KEEP-GC-002`). + - **Requirements:** writer authority then exclusive `reader.lock`; + intent written, flushed, synced before any unlink; canonical order; + per-unlink directory sync; receipt after all absent; a retained intent + makes admission recovery-required and excludes catalog publication, + retention transitions, and another GC until resolved; recovery + resolves idle, active, partial, completion-pending, receipt-transition, + and complete states and refuses everything else. + - **Acceptance criteria:** crash points for every GC boundary; the GC + state table in `gc.md` executable; a store cannot lose a live segment + under any crash prefix (model test over the matrix). + - **Scope:** in — executor, storage port, filesystem adapter, recovery, + crash matrix sequence. Out — background scheduling policy; secure + erasure. + - **User stories:** Human — an operator runs GC once and, if the host + dies, reruns it to completion. API user — `execute_gc(intent)` is + idempotent per intent. MCP user — "gc execute" requires the plan id + and reports the receipt. Agent — an agent never runs GC without a + fresh plan whose snapshot coordinates match the current heads. + - **Interface:** `execute_gc(...)`, `recover_gc(...)`, trait `GcStorage`. + - **Contract schema:** `gc/intent`, `gc/receipt` per T-22.1. + - **Test plan:** golden — intent and receipt for the golden store; + edges — zero candidates (refuse: nothing to do is not an intent), + candidate already absent before intent (ambiguity); crash matrix — + before, during, after each boundary; concurrency — a reader holding + the fence blocks GC until it drops; stress — 65,536 candidates. + - **Definition of done:** `KEEP-GC-002` Implemented; README gap table + row removed; a Documentation Standards §5.4 warning on every GC page. + - **Complexity:** XL across T-22.2 to T-22.5. + - **Documentation:** `gc.md`, `recovery.md`, `requirements.md`, CHANGELOG. + - **Dependencies:** T-22.1, T-22.2, F-19. +- [ ] T-22.5 Explicit orphan disposition. + - **Requirements:** a finalize-or-retire decision for a recovery-protected + orphan is durable as `recovery/dispositions/.receipt` via the + fixed-stage protocol; retirement proves the artifact is named by no + `HEAD`, no fixed stage, no pending publication, no retained closure, + and no active reader; at most 65,536 receipts. + - **Acceptance criteria:** publication that was refusing on an orphan + proceeds once a disposition exists; GC admits only the exact receipt. + - **Scope:** in — disposition planner, executor, adapter. Out — + automatic disposition (a human or an explicit policy decides). + - **User stories:** Human — the "waits for a human" case becomes one + command with a stated consequence. API user — `dispose(orphan, + Decision)` returns the receipt. MCP user — "dispose orphan" demands the + decision and the orphan digest. Agent — an agent may finalize (safe) + without escalation but must escalate retire. + - **Interface:** `plan_recovery_disposition`, `execute_recovery_disposition`. + - **Contract schema:** `RecoveryDispositionReceipt` per T-22.1. + - **Test plan:** golden — receipt fixture; edges — dispose an artifact + the head now names (refuse), dispose twice (idempotent), receipt + ceiling; crash matrix — stage, link, sync, remove boundaries. + - **Definition of done:** README "waits for a human" paragraph removed. + - **Complexity:** M. + - **Documentation:** `recovery.md` dispositions section with a §5.4 + warning. + - **Dependencies:** T-18.1, T-22.1. + +### F-23 Durable authenticated reads and refusal receipts + +**Status:** Planned. `KEEP-RECONSTRUCT-009` and `-010` cite #22 and #23, +which are closed; no open Keep issue owns this. Open one. + +The durable segment, catalog, publication, and recovery surfaces do not +yet form one high-level `BlobId`-to-writer contract. A durable read must +bind to one admitted immutable snapshot, prevent its evidence from being +collected during the read, verify the retained closure, resolve exact +immutable records, preserve the view while successors publish, and return +a receipt naming the view, with refusal distinct from operational failure +and no hidden whole-blob allocation. + +- [ ] T-23.1 `DurableStore` read surface over a fenced snapshot. + - **Requirements:** `reconstruct`, `reconstruct_layout`, `read_range` + with the same laws as `ReferenceStore` but bound to a + `FilesystemRetentionSnapshot`; receipts gain the view coordinates; + chunk loads stream from admitted segment records with bounded memory; + a snapshot dropped mid-read is impossible by construction (the read + borrows it). + - **Acceptance criteria:** the Worldline restart and range assertions run + against the durable backend; `KEEP-RECONSTRUCT-009`, `-010` + Implemented; every `ReferenceStore` read law has a durable twin. + - **Scope:** in — a `durable` adapter module composing snapshot, catalog + lookup, segment record reads, layout admission, and the existing + reconstruction core. Out — writes (F-24), verification depth beyond + what the read needs (F-21). + - **User stories:** Human — an operator reads a blob out of a store by + identity with one call and gets a receipt naming the generation it + came from. API user — `DurableStore::open(root)?.reconstruct(id, + &mut out)?` is the durable twin of the README example. MCP user — a + "read blob" tool returns bytes plus receipt. Agent — an agent reading + across a store restart sees identical receipts for identical views. + - **Interface:** `DurableStore::{open, snapshot, contains_blob, + reconstruct, reconstruct_layout, read_range}`; adapters expose + `keep cat ` and MCP `keep.read { blob, range? }`. + - **Contract schema:** `DurableReconstructionReceipt` extends + `ReconstructionReceipt` with catalog generation and digest and + liveness generation and manifest digest. + - **Test plan:** golden — Worldline over the durable backend; edges — + blob present in catalog but segment unreadable (operational failure, + not refusal), blob whose layout names a chunk the catalog lacks + (evidenced refusal), range across a segment boundary; known failures — + every reference refusal law; fuzz — none new; stress — reading at the + catalog ceiling with the documented memory bound; concurrency — reads + during publication and during a blocked GC. + - **Definition of done:** README "Try it" gains a durable example + (Linux-only, marked). + - **Complexity:** L. + - **Documentation:** `docs/invariants/authenticated-reconstruction/README.md` + "durable" section; `docs/architecture/durable-store/` new page. + - **Dependencies:** F-19; F-22 T-22.4 for the "evidence cannot be + collected" law (until then, it holds vacuously because nothing + collects). + +### F-24 Bounded production ingestion through the durable store + +**Status:** Planned (#82, P1, M6). Needs #74 and #72 (F-06) and F-21. + +One bounded production path from an unknown-length source through the +registered CDC profile, chunk verification and deduplication, immutable +segment publication, catalog admission, and an exact receipt. It preserves +`keep.fastcdc-gear64/v1`. Deduplication, batching, and backpressure must not +change `BlobId`, `ChunkId`, `LayoutId`, publication order, recovery, or +error precision. + +- [ ] T-24.1 Backend-neutral ingestion contract. + - **Requirements:** a trait or port that `ReferenceStore` and the durable + writer both satisfy where their durability claims overlap: stage, + commit, receipt; staging admits count-and-byte limits; the reference + adapter stays honest about being non-durable. + - **Acceptance criteria:** one integration test suite runs against both + backends; a compile-time law that a `ReferenceStore` receipt cannot be + passed where a durable receipt is required. + - **Scope:** in — a `store` port module. Out — asynchronous APIs. + - **User stories:** Human — none. API user — code written against the + port runs in tests on the reference store and in production on disk. + MCP user — the same tool schema regardless of backend. Agent — an + agent's test harness and production path share one contract. + - **Interface:** trait `ContentStore { stage, commit, reconstruct, + read_range }` or the smallest equivalent. + - **Contract schema:** none. + - **Test plan:** golden — Worldline through the port; edges — every + existing ingestion law on both backends. + - **Definition of done:** both adapters implement the port. + - **Complexity:** M. + - **Documentation:** `docs/architecture/` port page. + - **Dependencies:** T-06.4. +- [ ] T-24.2 Durable staged ingestion with deduplication. + - **Requirements:** single pass over an unknown-length source; existing + chunks reused only after exact identity and representation + verification against the pinned catalog; missing chunks stream into a + `StagedSegment` without materializing the blob; layout and complete + `BlobId` verified before visible admission; commit publishes the + segment and a catalog successor through the existing 26 v1 crash + points; receipt binds profile, blob, layout, segment digests, catalog + generation, and exact byte counts (logical, physical new, physical + reused). + - **Acceptance criteria:** Worldline `chunk reuse` and `production + ingest` capability rows executable; every write boundary has short + write, interruption, process death, and restart evidence (reusing the + v1 matrix); benchmark reports throughput, peak memory, allocations, + sync count, dedup ratio. + - **Scope:** in — durable staging adapter, segment rollover at the + 1 GiB and 1,048,576-record ceilings, catalog successor publication. + Out — new CDC algorithm; convergent encryption; retention (a caller + retains afterwards through F-18). + - **User stories:** Human — an operator ingests a directory of files and + sees dedup ratio in the receipt. API user — `DurableStore::stage(&mut + source, limits)?.commit()?` returns a receipt with byte counts. MCP + user — an "ingest" tool accepts a path or stream and returns the + `BlobId` and receipt. Agent — an agent ingests a build artifact and + retains it in one namespace in two calls, both idempotent. + - **Interface:** `DurableStore::{stage, commit}`; adapters + `keep put ` and MCP `keep.ingest`. + - **Contract schema:** `DurableIngestionReceipt`. + - **Test plan:** golden — ingest the Worldline inputs and compare + catalog bytes with the v1 corpus; edges — source shorter than one + chunk, exactly at the segment ceiling, every chunk already present, + rollover mid-blob; known failures — interrupted source discards the + stage and leaves a reusable staged segment; crash matrix — the + existing v1 points driven by ingestion instead of by fixtures; soak — + ingest until the catalog entry ceiling and confirm the typed refusal; + stress — memory ceiling law over a multi-GiB synthetic source. + - **Definition of done:** #82 closed; README gap table row removed; + README "Try it" durable example writes as well as reads. + - **Complexity:** L. + - **Documentation:** `docs/architecture/durable-store/` ingestion page + with the memory bound; CHANGELOG. + - **Dependencies:** T-24.1, T-06.3, T-06.4, F-18, F-21 (representation + verification depth), F-23. +- [ ] T-24.3 Bounded streaming write-through pipeline (#72, P3). + - **Requirements:** a source adapter emitting verified range segments, a + sink adapter applying an exactly-once write protocol, and a transfer + adapter coordinating a bounded chunk window with receipts and + cancellation; zero-copy handoff of immutable chunk bytes within the + window; cancellation never upgrades partial output into success. + - **Acceptance criteria:** benchmark shows lower CPU and allocation than + a caller-owned copy loop for large blobs; one read-to-write and one + copy-to-write integration test; full identity, range correctness, and + refusal laws preserved. + - **Scope:** in — `ChunkPipeline` in adapters. Out — multi-threaded + pipelines until a bounded-memory proof exists. + - **User stories:** Human — none. API user — copying a blob between two + stores does not buffer it. MCP user — a "copy blob" tool streams. Agent + — an agent mirroring a namespace to a second store does so in bounded + memory. + - **Interface:** `transfer(source, sink, window) -> TransferReceipt`. + - **Contract schema:** none. + - **Test plan:** golden — copy the Worldline inputs; edges — sink fails + mid-window, source dies mid-window, profile mismatch between stores; + stress — window of one chunk. + - **Definition of done:** #72 closed. + - **Complexity:** M. + - **Documentation:** architecture page. + - **Dependencies:** T-24.2, F-23. + +### F-25 Echo adapter and transaction boundary + +**Status:** Done in the Echo repository (issues #22 and #23 closed +2026-08-15; work tracked as flyingrobots/echo#721 and #722). Keep's side +is the authenticated reconstruction contract (F-07) and the no-Echo-types +law (`KEEP-STORE-016`). + +Echo owns causal meaning; Keep owns exact physical bytes. Echo never +commits a causal reference to content lacking verified physical retention. +No subprocess or Node sidecar sits in the storage path. + +- [x] T-25.1 Contract: success, evidenced refusal, operational failure — + PR #77; `docs/invariants/authenticated-reconstruction/`. +- [x] T-25.2 Adapter and cutover — echo#722 (outside this repository). + +Residual Keep obligations from #22 and #23 live in F-23 (durable refusal +receipts, pinned durable reads) because those issues closed before #20 +did. + +### F-26 Graft Golden File Worldline end to end + +**Status:** Planned (#24, P2, M5). Needs F-22 and F-25. + +Prove that Graft can continuously retain and recover nearby workspace +states through Echo and Keep with no Git or Node subprocess in the storage +hot path: observe state A, admit only after stage, verify, retain; produce +state B by an early insertion; recover both by identity; show chunk reuse +without reuse being identity; range reads load only overlapping chunks; a +stale-basis write refuses; termination and restart recover a lawful state; +corruption refuses precisely; compaction preserves every identity; Git +publication changes no Keep or Echo identity. + +- [ ] T-26.1 Cross-repository fixture and matrices. + - **Requirements:** pinned Keep, Echo, and Graft revisions; the twelve + scenario steps executable; kill-and-restart and corruption matrices + across every cross-repository durability boundary; POSIX and + agent-native Graft reads return the same coordinate and bytes; metrics + for logical bytes, physical bytes, reuse, amplification, sync count, + latency, peak memory, restart time. + - **Acceptance criteria:** the fixture is a reusable cross-language + conformance corpus; the demonstration distinguishes implemented + guarantees from watcher completeness and application policy. + - **Scope:** in — a fixture under `conformance/graft-worldline/v1/` or in + the Graft repository with Keep pinned. Out — replacing Git publication; + cross-organization transport; cryptographic settlement. + - **User stories:** Human — a Graft user edits `Foo.txt`, kills the + machine, and gets both states back byte-exact. API user — none + directly. MCP user — a Graft MCP tool reads a workspace state by + coordinate. Agent — an agent operating on a Graft workspace relies on + the stale-basis refusal to avoid clobbering. + - **Interface:** none in Keep. + - **Contract schema:** the fixture's `steps.tsv` and `capabilities.tsv` + in the Worldline style. + - **Test plan:** golden — the fixture; crash matrix — every + cross-repository boundary; corruption — every layer; benchmark — the + listed metrics. + - **Definition of done:** #24 closed with the fixture linked from + `docs/conformance/`. + - **Complexity:** XL (three repositories). + - **Documentation:** `docs/conformance/graft-worldline.md`. + - **Dependencies:** F-22, F-23, F-24, F-25. + +### F-27 git-cas import posture + +**Status:** Planned (#25, P2, M5). An ADR is the deliverable. + +Decide whether and how existing git-cas assets can be imported without +letting Git representation details or legacy validation weakness enter +Keep's native contracts: which manifest and encryption versions are +accepted; streaming, staged, independently verified import; re-identification +under Keep's canonical `BlobId`; handling of missing manifest hashes, +malformed sub-manifest topology, conflicting digest and OID pairs; one-way +only; provenance and weaker-evidence posture. + +- [ ] T-27.1 ADR: import now, import later, or never. + - **Requirements:** the decision names concrete migration evidence; + native formats stay independent of Git OIDs, trees, refs, and process + execution; imported bytes verify against both source evidence and the + Keep destination identity; weak legacy evidence is represented + explicitly, never silently upgraded; interruption and restart specified; + fixtures are license-safe with no real workspace material. + - **Acceptance criteria:** `docs/adr/0010-git-cas-import-posture.md` (or + the next free number) Accepted with alternatives evaluated. + - **Scope:** in — the decision. Out — the importer (a new feature if + chosen), bidirectional sync, treating git-cas handles as Keep identity. + - **User stories:** Human — a git-cas user learns whether their store can + move to Keep and what evidence they lose. API user — none until the + importer exists. MCP user — none. Agent — an agent migrating a + workspace knows whether to plan an import or a re-ingest. + - **Interface:** none. + - **Contract schema:** none. + - **Test plan:** none; a decision record. + - **Definition of done:** ADR merged; #25 closed. + - **Complexity:** S for the ADR; L if import is chosen. + - **Documentation:** the ADR; `docs/adr/README.md` index. + - **Dependencies:** F-21 (verification depth vocabulary for "weaker + evidence"); informed by F-28. + +### F-28 Authenticated encrypted representations + +**Status:** Planned (#86 ADR, P1; #83 implementation, P1; M6). + +A native representation contract for authenticated encryption that +preserves `BlobId` and `LayoutId` across encryption, re-encryption, +recipient changes, and key rotation: an audited AEAD suite and nonce +strategy, independently authenticated frames with canonical AAD binding +blob, layout, representation version, frame coordinate, and declared +context; a random per-representation data-encryption key wrapped for each +recipient key-encryption key; a semantic key capability crossing the port +without importing a keychain, KMS, CLI, or application key-reference type; +key material never in durable formats, diagnostics, receipts, or logs; no +plaintext released before the authenticated boundary succeeds. Convergent +and deterministic encryption are excluded from the private-content +profile. + +- [ ] T-28.1 ADR: encrypted representation contract (#86). + - **Requirements:** freeze identity, framing, AAD, nonce, recipient + envelope, and verification laws; define `RepresentationId` for the + encrypted codec (first assignment of the reserved envelope, T-03.3); + distinguish missing, unavailable, unauthorized, revoked, malformed, + and authentication-failed key paths; review dependency, side-channel, + zeroization, crash, backup, and recovery implications; require + independent golden and mutation corpora before codec admission. + - **Acceptance criteria:** ADR Accepted; a written dependency review for + the chosen crypto crate and every enabled feature per + `docs/dependencies/`. + - **Scope:** in — the decision and threat model. Out — external key + custody, purge authority, metadata-oblivious storage. + - **User stories:** Human — an operator learns what an attacker with the + disk sees (lengths, boundaries, identities) and does not see (bytes). + API user — the port names one `KeyCapability` trait to implement. MCP + user — none until T-28.2. Agent — an agent knows which errors mean + "get a key" versus "the data is corrupt". + - **Interface:** none. + - **Contract schema:** the ADR fixes the frame and envelope byte tables. + - **Test plan:** none; a decision record. + - **Definition of done:** #86 closed. + - **Complexity:** M (the threat model is the work). + - **Documentation:** the ADR; `docs/formats/README.md` reserves the + codec. + - **Dependencies:** F-21 for verification-depth vocabulary; ADR-0001 and + ADR-0002. +- [ ] T-28.2 Framed envelope encryption and key rotation (#83). + - **Requirements:** encrypt and decrypt stream within explicit frame, + count, and byte bounds; every frame binds AAD and refuses reordering, + duplication, omission, truncation, and cross-representation + substitution; recipient add and remove and KEK rotation do not + re-encrypt payload frames or move `BlobId`; rotation receipt binds old + and new representation and envelope coordinates without key material; + catalog entries carry a representation coordinate so one blob may have + plaintext and encrypted representations side by side. + - **Acceptance criteria:** golden, mutation, corruption, property, fuzz, + process-death, recovery, and public-contract tests; benchmarks for + throughput, peak memory, frame overhead, rotation work; the Worldline + `encryption` capability row executable. + - **Scope:** in — representation codec, catalog representation + coordinate (a v3 catalog entry or a v2 sidecar, decided in T-28.1), + key capability port, rotation protocol with crash points. Out — + external key stores; deterministic encryption; treating rotation as + deletion. + - **User stories:** Human — an operator rotates a key and no payload is + rewritten. API user — `DurableStore::stage_encrypted(source, + recipients)` returns the same `BlobId` as plaintext staging. MCP user — + "ingest" gains a `recipients` parameter; "read" fails with a typed + "key unavailable" the client can act on. Agent — an agent holding a + recipient key reads; one without is refused before any plaintext. + - **Interface:** `KeyCapability` trait; `stage_encrypted`, + `rotate_recipients`; adapters `keep put --encrypt-to `, + `keep keys rotate`. + - **Contract schema:** frame header (magic, version, frame index, AAD + digest, ciphertext length, tag), envelope record (recipient id digest, + wrapped DEK), representation record binding `LayoutId` and frame + count; exact tables in a new `docs/formats/encrypted-representation-v1/`. + - **Test plan:** golden — fixtures produced with a fixed test key (never + a real key); edges — zero frames (refuse), maximum frame count, one + recipient, maximum recipients, rotation with zero remaining recipients + (refuse); known failures — every frame-tamper case; fuzz — frame and + envelope decoders; crash matrix — rotation boundaries; soak — repeated + rotations preserve readability. + - **Definition of done:** #83 closed; README gap table row removed. + - **Complexity:** XL. + - **Documentation:** format page, `docs/dependencies/` entries, SECURITY.md + scope update, CHANGELOG. + - **Dependencies:** T-28.1, F-21, T-06.4, T-24.3, F-24. + +### F-29 Retention-derived lifecycle surfaces + +**Status:** Planned (#85 ADR, P1, M6). Needs F-18, F-21, F-22. + +Decide how named vaults, mutable root sets, managed cache sets, and +expiry-safe replay sets lower onto retention namespaces and generations +without importing application meaning, ambient clocks, or unsafe early +release into the core; which primitives live in core, an optional policy +crate, or the application; how named assets map to opaque digests without +leaking labels; how time enters only as caller-supplied observations. + +- [ ] T-29.1 ADR: lifecycle surfaces over retention. + - **Requirements:** a core, policy, and application boundary per + surface; no path, caller identity, wall clock, TTL, LRU score, or label + in content identity; all mutations compare exact expected and observed + generations and return immutable evidence; scoped acquisitions retain + a complete generation until explicit release; replay-set release is + expiry-only; private naming has an equality-leak and key-rotation + threat model; crash-state and concurrency tables per cross-generation + transition; the design names which surfaces wait for F-22 and which + ship over retention alone. + - **Acceptance criteria:** ADR Accepted; F-30 through F-33 each cite the + section that governs them. + - **Scope:** in — the decision. Out — any implementation. + - **User stories:** Human — an application author learns which of these + they get from Keep and which they build. API user — one policy crate + boundary to depend on. MCP user — the tool vocabulary for vault, set, + cache, and replay is fixed. Agent — an agent knows that "expire" is a + caller observation, not a Keep clock. + - **Interface:** none. + - **Contract schema:** none. + - **Test plan:** none. + - **Definition of done:** #85 closed. + - **Complexity:** M. + - **Documentation:** the ADR. + - **Dependencies:** F-18, F-21, F-22. + +### F-30 Opaque asset, page, and bundle handles + +**Status:** Planned (#89, P2, M6). Needs F-24 and F-29. + +Validated opaque handles for immutable assets, bounded pages, and +deterministic structured bundles so applications never manage segment +coordinates, catalog locations, or physical identifiers. A handle +identifies one completely validated immutable graph and implies no +durability, retention, publication, or application meaning its receipt +does not establish. + +- [ ] T-30.1 Handle grammar, codecs, traversal. + - **Requirements:** asset handles bind exact `BlobId`, `LayoutId`, and + admitted representation evidence; page handles enforce explicit + count-and-byte bounds; bundle handles use a canonical descriptor + grammar refusing duplicates, misorder, depth, fanout, and aggregate + byte violations; streaming bounded traversal; named-member reads do + not materialize unrelated members; handles contain no paths, offsets, + generations, keys, or labels; retain and publish return + generation-scoped evidence separate from the handle; re-encoding or + relocation preserves the handle when the governed graph is unchanged. + - **Acceptance criteria:** golden, mutation, corruption, graph-cycle, + property, fuzz, and public-contract tests; a bundle at maximum fanout + traverses within the documented bound. + - **Scope:** in — a `handle` module and a bundle descriptor format. Out — + mutable documents; application schemas; access-control tokens. + - **User stories:** Human — none directly. API user — an application + stores a directory as one bundle handle and reads one file from it + without loading the rest. MCP user — "read member" takes a handle and + a member name. Agent — an agent packages a build output as a bundle + and hands one handle to the next stage. + - **Interface:** `AssetHandle`, `PageHandle`, `BundleHandle`, + `BundleDescriptor`, `read_member`. + - **Contract schema:** bundle descriptor record: magic, version, member + count, sorted members (name digest, kind, handle), descriptor digest, + checksum; in `docs/formats/bundle-v1/`. + - **Test plan:** golden — descriptor fixtures; edges — empty bundle, + single member, duplicate name digest, cycle through a nested bundle; + fuzz — descriptor decoder; stress — maximum fanout and depth. + - **Definition of done:** #89 closed. + - **Complexity:** L. + - **Documentation:** format page; architecture page. + - **Dependencies:** F-18, F-21, F-24, F-29. + +### F-31 Private named vault and mutable root sets + +**Status:** Planned (#92, P1, M6). Needs F-22, F-29, F-30. + +Application-friendly private named vaults and mutable root sets over exact +retention namespaces and generations: opaque private-name digests map to +validated handles without storing plaintext names; root-set replacement is +an exact generation compare-and-swap that never merges stale candidates; +release publishes a successor and never claims deletion; read acquisitions +pin one complete generation; inspection reports verification, retention, +and generation posture separately. + +- [ ] T-31.1 Vault and root-set surfaces. + - **Requirements:** as above; private-name construction has an explicit + equality-leak and key-rotation contract; recovery covers every staged + generation, name-index, head, and cleanup crash prefix. + - **Acceptance criteria:** concurrency, corruption, property, model, + fuzz, and process-death tests; crash points allocated. + - **Scope:** in — a policy-layer crate or module per T-29.1. Out — Git + ref semantics; ambient identity or ACLs; metadata confidentiality + beyond the admitted private-name profile. + - **User stories:** Human — an operator names a release "v1.2" privately + and later asks what it points at. API user — `vault.put(name, handle, + expected_generation)` returns evidence. MCP user — "vault get" and + "vault put" tools with generation parameters. Agent — an agent updates + a root set optimistically and retries on the typed stale error. + - **Interface:** `Vault::{get, put, remove, inspect}`, + `RootSet::{replace, acquire, release}`. + - **Contract schema:** name-index record binding name digest, handle, + generation; format page. + - **Test plan:** golden — index fixture; edges — put at stale generation, + remove absent, acquire during replace; model — vault operations + against a `BTreeMap`; crash matrix — every stage boundary. + - **Definition of done:** #92 closed. + - **Complexity:** L. + - **Documentation:** format and architecture pages; a §5.4 warning on + release. + - **Dependencies:** F-18, F-21, F-22, F-29, F-30. + +### F-32 Managed cache sets + +**Status:** Planned (#90, P2, M6). Needs F-22, F-29, F-31. + +An optional managed cache policy over retention generations: explicit TTL +observations, entry and logical-byte limits, deterministic approximate-LRU +eviction, bounded inspection, and scoped acquisitions that keep a selected +generation retained during use. Cache policy may release retention but +cannot alter identity, bypass reader safety, infer time from an ambient +clock, or make an acquired generation collectible before scope release. + +- [ ] T-32.1 Cache-set policy surface. + - **Requirements:** time enters as a validated caller observation under + a named policy; checked arithmetic on entry and byte accounting; + deterministic eviction under identical observations; eviction + publishes an exact successor and returns evidence; scoped acquisition + retains until release or crash recovery disposes its durable scope; + inspection cannot shorten live windows. + - **Acceptance criteria:** model tests over admission, access, eviction, + acquisition, release, stale writers, restart, and GC interaction; + benchmarks for policy work, metadata growth, acquisition cost, + retained bytes without assigning deduplicated bytes to one owner. + - **Scope:** in — policy-layer surface. Out — wall-clock ownership in + core; exact global LRU; cache membership as application authority. + - **User stories:** Human — an operator caps a cache at 10 GiB and it + stays there. API user — `cache.acquire(handle, observation)` pins a + generation for the scope. MCP user — "cache touch" and "cache evict" + with an observation parameter. Agent — an agent supplies its own clock + reading and gets deterministic eviction. + - **Interface:** `CacheSet::{admit, touch, acquire, release, evict, inspect}`. + - **Contract schema:** cache-set policy record and per-entry observation + record. + - **Test plan:** model — the listed operations; property — identical + observation sequences produce identical eviction; crash matrix — + scope boundaries; benchmark — as listed. + - **Definition of done:** #90 closed. + - **Complexity:** L. + - **Documentation:** format and policy pages. + - **Dependencies:** F-22, F-29, F-31. + +### F-33 Expiry-safe replay sets + +**Status:** Planned (#87, P2, M6). Needs F-22, F-29. + +An expiry-safe replay-marker surface: atomic add-if-absent, digest-only +durable metadata, exact successor evidence, and expiry-only release. A +live marker cannot disappear before admitted expiry through remove, +repair, capacity, LRU, stale writer, recovery, or GC paths; duplicate +admission returns the exact existing evidence or a typed conflict. + +- [ ] T-33.1 Replay-set surface. + - **Requirements:** marker identity is a typed domain-separated digest; + plaintext tokens never in durable metadata or diagnostics; add-if-absent + is atomic against one exact generation and refuses stale writers; + expiry is validated policy evidence, not a Keep clock; no public + remove, repair, capacity, or LRU path releases a live marker; release + admits only an observation at or beyond expiry and publishes a + successor; restart preserves every unexpired marker through all crash + prefixes. + - **Acceptance criteria:** receipts distinguish newly admitted, already + present, expired-and-released, stale, unavailable, corrupt; model, + concurrency, corruption, property, fuzz, process-death tests cover + premature-release attempts. + - **Scope:** in — policy-layer surface. Out — authentication or token + validation; sharing cache eviction; wall-clock truth. + - **User stories:** Human — none directly. API user — an application + rejects a replayed token by one `add_if_absent` call. MCP user — + "replay check" tool. Agent — an agent deduplicates its own actions + across restarts. + - **Interface:** `ReplaySet::{add_if_absent, release_expired, inspect}`. + - **Contract schema:** marker record (digest, expiry observation, + generation). + - **Test plan:** model — add, re-add, release before and after expiry; + concurrency — two writers race on one marker; crash matrix — stage + boundaries. + - **Definition of done:** #87 closed. + - **Complexity:** M. + - **Documentation:** policy page. + - **Dependencies:** F-18, F-21, F-22, F-29. + +### F-34 Portable bindings + +**Status:** Planned (#91 ADR, P3, M6). Needs F-21, F-26, and stable +handles from F-30. + +One stable foreign binding contract for Node.js, Bun, Deno, and future +consumers that does not fork identity, formats, verification, durability, +or recovery per runtime. Identity and durable bytes come only from the Rust +core; typed failure distinctions survive every binding; large I/O is +bounded and streaming; cancellation cannot upgrade partial output into a +success receipt; secret-bearing buffers have explicit lifetime and logging +policy; one conformance suite runs against every runtime adapter. + +- [ ] T-34.1 ADR: binding architecture. + - **Requirements:** choose C ABI, N-API, WebAssembly, or a generated + boundary; decide which operations are synchronous, worker-blocking, or + streaming callbacks; specify cancellation, backpressure, process death, + and version negotiation; reject reimplementation in JS or TS and + runtime-specific identity. + - **Acceptance criteria:** ADR Accepted; a live executable witness per + claimed runtime before any runtime is named as supported. + - **Scope:** in — the decision. Out — shipping bindings before native + contracts stabilize (F-43). + - **User stories:** Human — none. API user (JS) — the same `BlobId` text + as Rust for the same bytes. MCP user — an MCP server written in + TypeScript can wrap Keep without a subprocess. Agent — an agent in a + JS runtime gets the same typed refusals. + - **Interface:** none until implemented. + - **Contract schema:** none. + - **Test plan:** the Worldline through each binding. + - **Definition of done:** #91 closed. + - **Complexity:** M for the ADR; XL for bindings. + - **Documentation:** the ADR. + - **Dependencies:** F-21, F-26, F-30, F-43. + +### F-35 Read-only FUSE projection + +**Status:** Planned (#66 ADR, P3). No mount is exposed until F-19 and +F-22 land. + +Expose an admitted durable snapshot as a read-only filesystem while +preserving the core law: a namespace of identity-addressed files or named +retained roots; random reads that never silently upgrade a range receipt +into whole-blob verification; stable inode, size, and ordering semantics +without clocks or host-order iteration; every open handle bound to one +catalog and retention snapshot; typed adapter errors mapped to FUSE codes; +no writable, rename, truncate, link, or repair operation. + +- [ ] T-35.1 ADR and fake-port proof. + - **Requirements:** the ADR decides viability and names authority and + verification boundaries; deterministic tests against a fake FUSE port + prove namespace ordering, inode stability, snapshot pinning, short and + random reads, verification refusal, and unmount cleanup; a Linux-only + proof mount skippable in CI; benchmarks for lookup latency, read + throughput, verification work, allocations, page-cache behaviour; + dependency, platform, privilege, and crash-surface audit. + - **Acceptance criteria:** ADR Accepted with the read-only invariant + stated; fake-port suite green. + - **Scope:** in — an out-of-core adapter crate. Out — writes (F-36); + treating paths or inodes as identity; network filesystems. + - **User stories:** Human — an operator mounts a snapshot and greps it. + API user — none. MCP user — none (the mount is the interface). Agent — + an agent points ordinary tools at a mount and gets refusals as I/O + errors, never plausible bytes. + - **Interface:** `keep mount --read-only ` in the + adapter crate. + - **Contract schema:** none. + - **Test plan:** fake-port laws; Linux proof mount; benchmarks. + - **Definition of done:** #66 closed. + - **Complexity:** L. + - **Documentation:** the ADR; adapter README. + - **Dependencies:** F-19, F-22, F-23; informed by F-21. + +### F-36 Transactional write-enabled projection + +**Status:** Planned (#73, P2). Extends F-35. + +A write-capable projection for copy and migration workflows that preserves +authenticated source identity, records mutation intents as first-class +causal events, and replays safely on reopen: writes are append-only at the +projection boundary unless an explicit transaction boundary is proven; no +silent patch application; no unbounded rehydration. + +- [ ] T-36.1 Design and end-to-end proof. + - **Requirements:** a tracked design on recovery and crash boundaries; + one end-to-end test (mount-style view, write path, remap to a verified + layout, unchanged identity of untouched bytes, deterministic replay on + reopen); negative tests for untrusted mutation and partial writes; + failure modes for partial visibility without a durable intent log, + replay reordering, and power-loss gaps between mount transaction and + durable publication. + - **Acceptance criteria:** as listed in #73. + - **Scope:** in — adapter crate. Out — a general mutable POSIX layer. + - **User stories:** Human — an operator copies a tree into a mount and + gets one committed bundle. API user — none. MCP user — none. Agent — + an agent writes through the mount and later reads the exact bytes by + identity. + - **Interface:** `keep mount --transactional`. + - **Contract schema:** mutation-intent journal record. + - **Test plan:** end-to-end; crash matrix over journal boundaries. + - **Definition of done:** #73 closed. + - **Complexity:** XL. + - **Documentation:** design page; adapter README. + - **Dependencies:** F-35, F-24. + +### F-37 Repository verification tooling and CI gates + +**Status:** Done (issues #32, #33, #34, #35, #40, #44, #47, #55, #57, +and #59; ADR-0006, ADR-0007, ADR-0008). + +`cargo xtask` is the single repository-owned automation boundary, in +Rust, with no tracked Python: `verify`, `documentation-integrity-check` +(markdownlint-cli2 0.23.2, lychee 0.21.0 offline with fragments, actionlint +1.7.12), `documentation-refusal-check`, `source-structure-check` (500-line +hard limit, forbidden filenames), `golden-file-worldline-check`, +`conformance-check`, `durability-crash-matrix`, `benchmark-baseline`, +`prepare-fuzz-corpus`, `fuzz run --profile smoke|scheduled`, +`fuzz describe`, `fuzz check-corpus`. CI pins every action, persists no +credentials on read-only jobs, runs a scheduled fuzz campaign, and +Dependabot covers every manifest. + +- [x] T-37.1 xtask crate and source-structure law — #44. +- [x] T-37.2 Documentation integrity in xtask — #32, #35, #47. +- [x] T-37.3 Fuzz orchestration in xtask; scheduled campaigns — #33, #55. +- [x] T-37.4 Conformance oracles in xtask; bounded `b3sum` — #57, #59. +- [x] T-37.5 CI hygiene — #34, #40. +- [x] T-37.6 Child-process boundary — ADR-0006, ADR-0007, ADR-0008. + +Gates the Rust standard §24 expects that are not yet automated, each a +candidate S task without an issue: Miri on a nightly subset; a +mutation-testing subset; a public API diff check; a format fixture diff +check; a coverage threshold; a forbidden-terms check. + +### F-38 Documentation status drift + +**Status:** Proposed. Small, and worth doing before the next release +note. + +Several living pages lag `main`. None changes behaviour; each misleads a +reader about ownership. + +- [ ] T-38.1 Fix the README gap table. + - **Requirements:** the rows "Restart recovery for retention publication + and migration" and "Reader fence" point at #19, which closed on + 2026-09-08 under a different title; retention recovery and the fence + are in PR #99, and migration recovery has no open issue. Open one issue + per remaining gap (migration recovery, durable reads F-23) and point + the table at them; rewrite the "waits for a human until #19 lands" + sentence. + - **Acceptance criteria:** every gap-table row names an open issue or an + open PR. + - **Scope:** README, one or two new issues. Out — everything else. + - **User stories:** Human — a reader following a gap lands on live work. + API user, MCP user, Agent — same. + - **Interface:** none. **Contract schema:** none. + - **Test plan:** `cargo xtask documentation-integrity-check`. + - **Definition of done:** merged. **Complexity:** S. + - **Documentation:** README. **Dependencies:** none. +- [ ] T-38.2 Correct the crate doc in `src/lib.rs`. + - **Requirements:** the sentence "Partial-prefix migration recovery, + filesystem retention execution, immutable reader snapshots, and + garbage collection remain intentionally absent" predates + `FilesystemRetentionPublicationAuthority`; rewrite to the true set (and + again when PR #99 merges). + - **Acceptance criteria:** `cargo doc` output matches the ledger. + - **Scope, stories, interface, schema:** as T-38.1. + - **Test plan:** `cargo test --doc`. **Definition of done:** merged. + - **Complexity:** S. **Documentation:** rustdoc. **Dependencies:** none. +- [ ] T-38.3 Reconcile v2 pages with each other. + - **Requirements:** `migration-inventory.md` says verification-first + storage "remains in progress" while `KEEP-MIGRATION-003` says + Implemented; `retention-publication.md` describes v2 catalog + publication proving retained closures but the ledger has no + requirement row or evidence for it. Add the row and its test, or mark + the paragraph as a gap. + - **Acceptance criteria:** no two v2 pages disagree on a status. + - **Scope, stories, interface, schema:** as T-38.1. + - **Test plan:** documentation gates plus the phrase-pin contract tests. + - **Definition of done:** merged. **Complexity:** S. + - **Documentation:** the pages named. **Dependencies:** none. F-14 covers + the v1 pages. + +### F-39 Compression representation codec + +**Status:** Proposed. Every decision record defers compression to a +representation codec without naming an owner. + +A representation codec that stores a chunk or layout compressed, changes +only `RepresentationId`, and is verified by decompressing to the exact +chunk bytes and re-hashing before any byte is emitted. + +- [ ] T-39.1 ADR and codec. + - **Requirements:** frame-per-chunk compression with the uncompressed + `ChunkId` and length in the frame header; decompression bounded by the + declared length and refused on overrun; the catalog carries the + representation coordinate (shared with T-28.2); already-compressed + input is detected by measurement, not by extension; the benchmark + scenario "already compressed data" (T-09.3) gates the default policy. + - **Acceptance criteria:** golden and mutation corpora; a compression + bomb (declared length small, actual large) refuses before allocation; + the benchmark shows the ratio and CPU cost per profile. + - **Scope:** in — one audited pure-Rust codec behind a feature flag with + a dependency review. Out — dictionary training; per-file heuristics + from paths. + - **User stories:** Human — an operator turns on compression and sees + physical bytes drop in the ingestion receipt. API user — reads are + unchanged; `BlobId` is unchanged. MCP user — "ingest" gains a + `representation` parameter. Agent — an agent chooses compression per + blob from measured entropy. + - **Interface:** `stage` with a `RepresentationPolicy`. + - **Contract schema:** compressed frame header; format page. + - **Test plan:** golden — fixtures; edges — incompressible input, empty + chunk (impossible: chunks are nonempty), maximum chunk; known failures + — declared-length lies; fuzz — frame decoder; benchmark — ratio and + CPU. + - **Definition of done:** ADR Accepted; codec shipped behind a flag. + - **Complexity:** L. + - **Documentation:** ADR; format page; dependency review. + - **Dependencies:** T-03.3 (shares the representation coordinate with + F-28), F-24, F-21. + +### F-40 Additional chunking profiles and hierarchical layouts + +**Status:** Proposed. ADR-0003 and the layout rationale both name the +evidence required and defer the decision. + +Register a second CDC profile (the benchmark measured 16 KiB and 256 KiB +targets) only when workload evidence justifies a durable coordinate, and +add a hierarchical layout codec only when a workload exceeds 256 GiB per +plan or needs bounded plan streaming. + +- [ ] T-40.1 Second registered profile. + - **Requirements:** a new 96-byte profile record and `StorageProfileId`; + the conformance corpus gains vectors for it; selection is explicit + per stage call, never inferred from paths; the closure verifier's + profile replay admits it. + - **Acceptance criteria:** benchmark evidence on a designated runner + shows a measurable win for a named workload; ADR-0003 amended or a + new ADR. + - **Scope:** in — profile registry, corpus, benchmark. Out — keyed or + privacy-oriented chunking (needs its own threat model). + - **User stories:** Human — an operator chooses a small-chunk profile for + a source-code store. API user — `stage(source, profile, limits)`. MCP + user — "ingest" gains a `profile` parameter. Agent — an agent selects a + profile by measured reuse. + - **Interface:** as above. + - **Contract schema:** profile record. + - **Test plan:** golden — new corpus; property — partition invariance; + benchmark — the win. + - **Definition of done:** profile registered. + - **Complexity:** M. + - **Documentation:** ADR; corpus README. + - **Dependencies:** T-09.2. +- [ ] T-40.2 Hierarchical layout codec. + - **Requirements:** a new codec (codec 1 has no extension point) with + its own depth, fanout, cycle, aggregate, and allocation laws; range + planning across levels; closure accounting extended. + - **Acceptance criteria:** golden and mutation corpora; the 256 GiB + ceiling lifted to a stated new bound. + - **Scope:** in — codec 2. Out — changing codec 1. + - **User stories:** Human — a store holds a 1 TiB image. API user — + reads are unchanged. MCP user — none. Agent — none. + - **Interface:** none new. + - **Contract schema:** layout codec 2 record. + - **Test plan:** golden; edges — depth exactly at bound; fuzz — decoder. + - **Definition of done:** codec registered in `docs/formats/README.md`. + - **Complexity:** L. + - **Documentation:** new format page. + - **Dependencies:** a workload that needs it. + +### F-41 Platform adapters beyond Linux ext4 + +**Status:** Proposed. `recovery.md` defers Windows "until an adapter and +crash harness prove equivalent semantics"; macOS is not admitted for +production; the crash matrix does not simulate power loss. + +- [ ] T-41.1 macOS APFS production admission. + - **Requirements:** a platform profile that probes APFS for the same + capabilities (atomic no-clobber link, atomic same-filesystem + replacement, durable `fsync` semantics including `F_FULLFSYNC`, + advisory locks, device and inode identity); refuses anything else; + the crash matrix runs on macOS in CI. + - **Acceptance criteria:** `KEEP-RECOVERY-003` gains a macOS row; the + 105-case matrix (and the v2 matrices) green on a macOS runner. + - **Scope:** in — `filesystem_platform_profile.rs` sibling. Out — + case-insensitive volumes (refuse), network volumes. + - **User stories:** Human — a developer on a Mac runs a durable store + locally. API user — same API. MCP user — same. Agent — same. + - **Interface:** none new. + - **Contract schema:** none. + - **Test plan:** the full crash matrix on macOS; platform refusal laws. + - **Definition of done:** README platform admission sentence updated. + - **Complexity:** L. + - **Documentation:** `recovery.md` platform contract. + - **Dependencies:** none; benefits from T-41.2. +- [ ] T-41.2 Host power-loss simulation. + - **Requirements:** a Linux harness at the block layer, not the process + layer: `dm-log-writes` to record every write and replay each prefix, + `dm-flakey` to drop and corrupt writes in a window, or a CrashMonkey + style checker that enumerates legal reorderings of unsynced writes + across barriers; at each `KEEP-CRASH` point, every legal on-disk + state the recorded writes admit is restarted and asserted with the + same restart assertions as the process-death matrix. Process death + proves only that the writer's own ordering is right; this proves the + store survives write reordering, torn records, and a lost volatile + drive cache before a barrier. + - **Acceptance criteria:** every `KEEP-CRASH` point has a power-loss + twin; torn-record cases (a record cut inside a sector) refuse as + truncation, never admit; README "Proven restart recovery" states what + is now covered and what is not. + - **Scope:** in — xtask harness, privileged CI job. Out — torn writes + below the sector size the device guarantees atomic (state as a + nonclaim); hardware that lies about flush completion. + - **User stories:** Human — an operator trusts the store after a real + power cut, not only after `kill -9`. Others — same. + - **Interface:** `cargo xtask durability-crash-matrix --power-loss`. + - **Contract schema:** none. + - **Test plan:** the matrix under simulated loss. + - **Definition of done:** v1 requirements "host-power-loss simulation + remains outside" sentence removed. + - **Complexity:** L. + - **Documentation:** `recovery.md`, README. + - **Dependencies:** a privileged Linux runner (T-41.3). +- [ ] T-41.3 Designated Linux runner class. + - **Requirements:** one named runner class for benchmarks, the crash + matrix, and power-loss simulation, with recorded kernel, filesystem, + and hardware identity. + - **Acceptance criteria:** baselines and matrices name the class. + - **Scope:** CI configuration. + - **User stories:** Human — reproducible numbers. Others — none. + - **Interface:** none. **Contract schema:** none. + - **Test plan:** none. **Definition of done:** in use by T-09.2. + - **Complexity:** S. **Documentation:** benchmark README. + - **Dependencies:** none. +- [ ] T-41.4 Windows adapter — deferred; opens only after T-41.1 proves + the second-platform pattern. Complexity XL. No task fields until then. +- [ ] T-41.5 Barrier and journal semantics per filesystem. + - **Requirements:** a written account, per candidate filesystem (XFS, + btrfs, ZFS, APFS, and ext4 in each journaling mode), of what `fsync` + on a file and on its directory guarantees, whether a rename or link is + durable without a parent sync, whether the journal can reorder data + against metadata, and whether copy-on-write can leave a stale block + visible after a crash; each claim tied to a T-41.2 run on that + filesystem, not to documentation alone. + - **Acceptance criteria:** the platform profile probe admits a + filesystem only when its row is proven; the v1 `recovery.md` platform + contract cites the table. + - **Scope:** in — the table and the probe. Out — writing to a raw block + device to sidestep filesystems (a separate decision; it forfeits the + hard-link and directory-sync protocol the formats depend on). + - **User stories:** Human — an operator learns why their filesystem is + refused, by row. Others — same. + - **Interface:** none. **Contract schema:** none. + - **Test plan:** T-41.2 on each filesystem. + - **Definition of done:** table merged and cited by the probe. + - **Complexity:** L. **Documentation:** `recovery.md` platform contract. + - **Dependencies:** T-41.2, T-41.3. + +### F-42 Operator surfaces + +**Status:** Proposed. Keep's core exposes no CLI or MCP surface by design; +both belong in separate adapter crates that depend on `keep` and import +nothing back. + +- [ ] T-42.1 `keep-cli` adapter crate. + - **Requirements:** every command maps one-to-one onto a public API call + and prints the receipt or the typed refusal; no command invents + policy; dangerous commands (`gc execute`, `compact`, `dispose`) demand + an explicit plan identifier and print the §5.4 warning; exit codes + distinguish success, evidenced refusal, and operational failure; + output has a stable machine form (a canonical JSON profile per + ADR-0004) and a human form. + - **Acceptance criteria:** a golden transcript per command over the + golden store; the crate is not a workspace default member and is not + published with `keep`. + - **Scope:** in — `keep-cli/`. Out — anything the core cannot do. + - **User stories:** Human — `keep verify --depth blob /example/store` + prints a report. API user — none. MCP user — the MCP server may shell + out to nothing; it links the crate. Agent — an agent uses the machine + form. + - **Interface:** `keep init`, `keep put`, `keep cat`, `keep verify`, + `keep retain`, `keep release`, `keep migrate`, `keep recover`, + `keep gc plan|execute`, `keep compact`, `keep dispose`, `keep mount`. + - **Contract schema:** the canonical JSON output profile. + - **Test plan:** golden transcripts; edges — every refusal variant has + an exit code; fuzz — argument parser. + - **Definition of done:** crate merged with transcripts. + - **Complexity:** M once F-23 and F-24 exist. + - **Documentation:** crate README; a how-to page per Documentation + Standards. + - **Dependencies:** F-23, F-24; F-21 for `verify`; F-22 for `gc`. +- [ ] T-42.2 `keep-mcp` adapter crate. + - **Requirements:** an MCP server exposing tools that mirror the CLI + commands with JSON schemas derived from the same canonical profile; + every tool result carries the receipt or the typed refusal; tools that + mutate require the same plan identifiers as the CLI; no tool exposes + physical paths, offsets, or key material; secret-bearing inputs + (recipient keys, F-28) have an explicit lifetime and are never logged. + - **Acceptance criteria:** a conformance run of the Worldline through the + MCP tools; schema snapshots as golden fixtures. + - **Scope:** in — `keep-mcp/`. Out — remote transport security beyond + what the MCP host provides. + - **User stories:** Human — a person in an MCP-capable client asks for a + blob by identity and gets exact bytes or a refusal. API user — none. + MCP user — the tool list is the whole surface. Agent — an agent + ingests, retains, verifies, and reads with typed outcomes it can branch + on. + - **Interface:** tools `keep.ingest`, `keep.read`, `keep.verify`, + `keep.retain`, `keep.release`, `keep.snapshot`, `keep.gc.plan`, + `keep.gc.execute`, `keep.recover`, `keep.dispose`. + - **Contract schema:** tool input and output JSON schemas; golden + snapshots. + - **Test plan:** Worldline through the tools; edges — every refusal + variant round-trips; fuzz — tool input decoder. + - **Definition of done:** crate merged; a how-to page. + - **Complexity:** M after T-42.1. + - **Documentation:** crate README; how-to. + - **Dependencies:** T-42.1 (shared canonical profile), F-34 if the + server is not Rust. + +### F-43 Public release and API stability + +**Status:** Proposed. Keep is `0.0.0`, `publish = false`, and states it +will follow SemVer "after its public API and format compatibility policies +are established". + +- [ ] T-43.1 Format compatibility policy. + - **Requirements:** a written policy stating which formats are frozen + (`keep.flat-chunks/v1`, `keep.segment-store/v1`, `/v2`, the profile + record), how a successor is introduced (new coordinate, one-way + migration, no downgrade), and what a reader of version N promises for + version N minus one. + - **Acceptance criteria:** a cross-version compatibility test that opens + every committed golden store with the current reader. + - **Scope:** in — policy page and test. Out — new formats. + - **User stories:** Human — an operator knows whether upgrading Keep can + strand a store. API user — same for the crate. MCP user, Agent — same. + - **Interface:** none. **Contract schema:** none. + - **Test plan:** the compatibility test. **Definition of done:** merged. + - **Complexity:** S. **Documentation:** `docs/formats/README.md` policy + section. **Dependencies:** F-17 complete. +- [ ] T-43.2 Public API surface review and `0.1.0`. + - **Requirements:** the flat re-export list in `src/lib.rs` (over two + hundred names) is reviewed for what a caller needs versus what the + crash matrix needs; internal-only names move behind + `repository-tasks` or `pub(crate)`; a public API diff gate (F-37 + candidate) runs in CI; rustdoc builds with no warnings and every + important workflow has a doctest; `publish = true`; CHANGELOG `0.1.0` + section. + - **Acceptance criteria:** `cargo semver-checks` or an equivalent passes; + crates.io dry run passes; README "Try it" runs from the published + crate. + - **Scope:** in — API surface, docs, manifest. Out — new features. + - **User stories:** Human — `cargo add keep` works. API user — a stable + surface with a documented deprecation policy. MCP user — none. Agent — + an agent reads the rustdoc and finds the durable entry point on the + front page. + - **Interface:** none. **Contract schema:** none. + - **Test plan:** API diff gate; doc tests. **Definition of done:** tag. + - **Complexity:** M. **Documentation:** README, CHANGELOG, rustdoc. + - **Dependencies:** F-17, F-18, F-19, F-21, F-22, F-23, F-24 (a + `0.1.0` without a durable write path is not worth promising). + +### F-44 Multi-writer, replication, and remote tiers + +**Status:** Out of scope by ADR-0005 ("Multi-writer, distributed locking, +and network filesystems require another decision") and the v1 rationale. +Listed so the boundary is visible. + +What would have to exist first, in order: a durable read path (F-23), a +durable write path (F-24), GC with reader fencing (F-22), a +representation coordinate so a remote tier is a location, not an identity +(T-03.3), and a decision record on cross-host authority that ADR-0005 +explicitly did not make. Until then: one writer, many readers, one local +host, Linux ext4. + +Also out of scope, by explicit nonclaim and not revisited here: secure +deletion or erasure (ADR-0009, README); application semantics for Echo, +Git, Graft, or WARP inside the core (`KEEP-STORE-016`); Git objects or refs +as a storage or retention protocol (ADR-0005, ADR-0009); clock-based grace +(ADR-0009); Serde output as a durable format (ADR-0004). + +### F-45 Formal verification of the durable protocols + +**Status:** Proposed. Every proof Keep has today is empirical: property +tests, model-based tests against a boring reference, golden corpora, +fuzzing, and process-death injection. None of it is a proof over all +interleavings. + +The publication, migration, retention, reader-fence, and GC protocols are +each a small state machine with explicit phases, named crash points, and a +recovery classifier. That is exactly the shape a model checker handles. +The Rust codecs and planners are pure functions over bounded byte arrays, +which is the shape a bounded model checker handles. + +- [ ] T-45.1 TLA+ model of publication, migration, and retention. + - **Requirements:** one PlusCal or TLA+ specification per protocol + (catalog publication `KEEP-CRASH-001` to `-035`, migration `-053` to + `-073`, retention publication `-036` to `-052`, then GC once F-22 + lands) with a crash action enabled at every phase boundary and a + recovery action that models the classifier; safety properties: one + verified head always selects one complete catalog; no live segment is + ever unlinked; a reader under the fence never observes two + generations; every crash prefix reaches exactly one lawful state; + liveness: recovery always terminates and a retry always completes or + refuses. The model is checked with TLC over a bounded number of + phases, writers (one), readers (at least two), and crash points. + - **Acceptance criteria:** TLC reports no counterexample for each + property; the phase order in the spec is generated from or checked + against `CatalogPublicationPhase::ALL`, `StoreMigrationPhase::ALL`, and + `RetentionPublicationPhase::ALL` so the model cannot drift from the + code; a deliberately reordered phase produces a counterexample. + - **Scope:** in — `formal/` directory, an xtask command that runs TLC in + CI with pinned versions. Out — proving the Rust implementation refines + the model (that is T-45.2 and beyond). + - **User stories:** Human — a reviewer reads a counterexample trace + instead of reasoning about interleavings by hand. API user — none. + MCP user — none. Agent — an agent changing a phase order must update + the spec and gets a machine-checked answer. + - **Interface:** `cargo xtask formal-check`. + - **Contract schema:** none. + - **Test plan:** TLC over the bounded model; a mutation set of known-bad + orderings that must each fail. + - **Definition of done:** three specs checked in CI; a rationale note + stating what the model does and does not cover. + - **Complexity:** L. + - **Documentation:** `formal/README.md`; each protocol page links its + spec. + - **Dependencies:** none; GC spec waits for F-22. +- [ ] T-45.2 Bounded model checking of codecs and planners. + - **Requirements:** Kani proofs (or an equivalent bounded checker) over + every decoder that the codec refuses, never panics, and never reads + past its input for all byte arrays up to the record's bounded length; + over the range planner that the selected interval is minimal and + covers the range for all admissible layouts up to a bounded entry + count; over every checked-arithmetic path that overflow is a typed + refusal, not a wrap. + - **Acceptance criteria:** proofs run in CI on a pinned toolchain; + every existing fuzz target has a proof harness for the same entry + point; a deliberately introduced unchecked add fails a proof. + - **Scope:** in — proof harnesses next to the fuzz targets. Out — + proving filesystem behaviour (T-45.1 and T-41.2 own that). + - **User stories:** Human — a reviewer trusts that no input bitstream + panics a decoder. API user — the "never panics" claim in rustdoc is + machine-backed. MCP user — none. Agent — an agent adding a field adds + a harness and gets a proof or a counterexample. + - **Interface:** `cargo xtask formal-check --bounded`. + - **Contract schema:** none. + - **Test plan:** the proofs; the mutation set. + - **Definition of done:** every decoder and planner has a harness; the + Rust standard's "no panic" gate cites it. + - **Complexity:** L. + - **Documentation:** `formal/README.md`; a `docs/dependencies/` review + for the checker. + - **Dependencies:** none. +- [ ] T-45.3 Refinement from model to code — deferred. Proving the Rust + executor refines the TLA+ model (Creusot, Verus, or a trace-checking + harness that replays TLC traces through the storage-port fakes) is the + step after T-45.1 and T-45.2. The trace-replay form is tractable early: + every TLC trace becomes a deterministic test over the existing fault- + injecting fakes. Complexity XL for deductive refinement; M for trace + replay. No task fields until T-45.1 lands. + +### F-46 Condition coverage and mutation analysis + +**Status:** Proposed. The Rust standard §24 lists a coverage threshold and +a mutation subset as expected gates; neither runs today. + +- [ ] T-46.1 Mutation analysis in CI. + - **Requirements:** `cargo-mutants` over `src/` on a schedule and over + changed files on every pull request; every surviving mutant is either + killed by a new law or listed in a reviewed exclusion file with a + reason; timeouts and unviable mutants are distinguished from + survivors. + - **Acceptance criteria:** zero unexplained survivors on `main`; the + exclusion file is short and each entry cites the invariant that makes + the mutant unobservable. + - **Scope:** in — xtask command, CI job, exclusion file. Out — mutating + tests, fuzz targets, or xtask itself. + - **User stories:** Human — a reviewer sees that deleting a checksum + comparison fails a named test. API user — none. MCP user — none. Agent + — an agent's new code is rejected if a mutant survives. + - **Interface:** `cargo xtask mutation-check [--changed]`. + - **Contract schema:** `mutants-exclusions.toml`. + - **Test plan:** the run itself; a seeded known-survivable mutant to + prove the exclusion mechanism. + - **Definition of done:** scheduled job green; PR job advisory for one + release then required. + - **Complexity:** M. + - **Documentation:** CONTRIBUTING; `docs/dependencies/` review. + - **Dependencies:** none. +- [ ] T-46.2 Branch and condition coverage gate. + - **Requirements:** instrumented coverage with branch granularity on the + core and adapter modules; a documented threshold per module family + (codecs and planners at 100 percent branch, orchestration at a stated + lower bound); a report of every uncovered condition; modified + condition/decision coverage for the recovery classifiers and the + publication readiness checks, where each boolean sub-condition is + shown to independently flip the outcome. + - **Acceptance criteria:** the gate runs in CI on a pinned toolchain; + every decoder, planner, and classifier reaches its threshold; the + MC/DC set for the classifiers is a checked-in table naming the test + that flips each condition. + - **Scope:** in — coverage tooling, thresholds, the MC/DC table. Out — + coverage of xtask, benchmarks, and tests themselves. + - **User stories:** Human — a reviewer sees which condition a PR left + unexercised. API user — none. MCP user — none. Agent — an agent adding + a condition adds the flipping test. + - **Interface:** `cargo xtask coverage-check`. + - **Contract schema:** `coverage-thresholds.toml`; `mcdc.tsv`. + - **Test plan:** the run; a seeded uncovered branch to prove the gate + fails. + - **Definition of done:** thresholds enforced; the MC/DC table covers + every recovery classifier. + - **Complexity:** M for branch coverage; L for MC/DC. + - **Documentation:** CONTRIBUTING; Rust standard §24 row updated from + "expected" to "enforced". + - **Dependencies:** none; T-46.1 first, since mutation results tell you + which uncovered conditions matter. From 29690fc30ac79345f51e7639ce057f28a5114764 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 08:34:04 -0700 Subject: [PATCH 10/59] Test: complete the retention record corruption matrices Problem: KEEP-RETENTION-003 promised a precise refusal for every structural field of the version-2 root, manifest, and head, but the executable evidence covered framing, checksums, digests, and a handful of semantic fields; version, header length, flags, declared length, anchor and entry width, both reserved regions, profile coordinates, closure limits, predecessor history, layout identity, canonical order, namespace bounds, and the count ceilings had no test. The ledger row read "mutation coverage remains". Approach: one table per record, in tests//mutation_laws.rs. Each row mutates one field of the frozen corpus fixture and then reseals every digest and checksum the mutation did not target, so the case proves the named field check and nothing upstream of it. Rows whose refusal needs a different record shape (empty or 256-byte namespace, duplicated anchor or entry, 65,537 anchors, 4,097 entries) reframe the fixture header around a new body. A shared tests/support/byte_patches.rs owns the patch, flip, read, and domain-hash helpers. Evidence: weakening the root flags check or the head reserved-byte check makes the matching matrix row fail with "mutated was admitted"; restoring the decoder makes all 21 tests in the three binaries pass. Ledger: KEEP-RETENTION-003 moves to Implemented. ROADMAP T-16.5 checked. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 10 + ROADMAP.md | 11 +- docs/formats/segment-store-v2/requirements.md | 2 +- tests/retention_head_codec.rs | 9 +- tests/retention_head_codec/mutation_laws.rs | 162 ++++++++ tests/retention_manifest_codec.rs | 2 + .../retention_manifest_codec/mutation_laws.rs | 302 ++++++++++++++ tests/retention_root_decoding.rs | 10 +- .../retention_root_decoding/mutation_laws.rs | 377 ++++++++++++++++++ tests/support/byte_patches.rs | 77 ++++ tests/support/mod.rs | 2 + 11 files changed, 952 insertions(+), 12 deletions(-) create mode 100644 tests/retention_head_codec/mutation_laws.rs create mode 100644 tests/retention_manifest_codec/mutation_laws.rs create mode 100644 tests/retention_root_decoding/mutation_laws.rs create mode 100644 tests/support/byte_patches.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 080ae98b..7d348bad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ after its public API and format compatibility policies are established. ### Added +- Field-by-field corruption matrices for the version-2 retention root, + manifest, and head decoders. Every header, body, and trailer field has one + sealed mutation whose digests and checksum are recomputed around it, so + each case pins the exact first refusal of that field alone; reframed + records prove the namespace length bounds, canonical anchor and entry + ordering, and the anchor and entry count ceilings after complete + integrity. `KEEP-RETENTION-003` is Implemented. +- `ROADMAP.md` inventories every feature Keep has, is building, or intends, + with a checklist and a task breakdown per unfinished feature. + - `FilesystemRetentionPublicationAuthority` executes the 17 ordered retention publication phases against a completely migrated version-2 root. It stages `root.next`, `manifest.next`, and `head.next` exclusively, verifies device diff --git a/ROADMAP.md b/ROADMAP.md index 40d08963..616202f3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -83,7 +83,7 @@ names; use those in code, tests, and commits. ### Retention and version 2 (M4) - [x] [F-15 Retention roots, release, and GC liveness model](#f-15-retention-roots-release-and-gc-liveness-model) — Done (ADR-0009) -- [x] [F-16 Version-2 format records and codecs](#f-16-version-2-format-records-and-codecs) — Done; mutation coverage in progress +- [x] [F-16 Version-2 format records and codecs](#f-16-version-2-format-records-and-codecs) — Done - [ ] [F-17 One-way migration from version 1 to version 2](#f-17-one-way-migration-from-version-1-to-version-2) — Partial (#97 and residual #19) - [ ] [F-18 Retention publication](#f-18-retention-publication) — Partial; recovery in review (PR #99) - [ ] [F-19 Reader fence and immutable version-2 snapshots](#f-19-reader-fence-and-immutable-version-2-snapshots) — In review (PR #99) @@ -603,8 +603,8 @@ raise the 4,096 namespace ceiling or reclaim tombstones. ### F-16 Version-2 format records and codecs -**Status:** Done (issue #19, PR #78). `KEEP-RETENTION-003` mutation -coverage is in progress. +**Status:** Done (issue #19, PR #78; `KEEP-RETENTION-003` completed on +this roadmap's branch). `keep.segment-store/v2` adds a 96-byte `FORMAT` marker, 256-byte migration intent and receipt, root-generation records (192-byte header, 119-byte @@ -624,7 +624,10 @@ decoder that refuses every structural fault before admission. - [x] T-16.3 Marker, intent, and receipt codecs — `KEEP-MIGRATION-002`; `fuzz/fuzz_targets/migration_format.rs`. - [x] T-16.4 Seeded `retention_format` fuzz target. -- [ ] T-16.5 Complete the corruption matrix (`KEEP-RETENTION-003`). +- [x] T-16.5 Complete the corruption matrix (`KEEP-RETENTION-003`) — + `tests/retention_root_decoding/mutation_laws.rs`, + `tests/retention_manifest_codec/mutation_laws.rs`, + `tests/retention_head_codec/mutation_laws.rs`. Original task fields: - **Requirements:** every structural field of root, manifest, and head has a permanent mutation case with the exact typed refusal it must produce; no field is covered only by the fuzz target. diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index ae8100b7..b780bf7b 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -11,7 +11,7 @@ case is not evidence. | --- | --- | --- | --- | | `KEEP-RETENTION-001` | `RetentionNamespace`, `RootGeneration`, `LivenessGeneration`, profile coordinates, limits, anchors, and digests are validated typed values | `tests/retention_values.rs`, `tests/retention_root_encoding.rs`, and typed verified anchor-set evidence in `tests/retention_root_decoding.rs` | Implemented | | `KEEP-RETENTION-002` | Root, manifest, and head codecs implement the exact canonical grammars and fixed bounds | independent golden corpus plus `tests/retention_root_encoding.rs`, `tests/retention_root_decoding.rs`, `tests/retention_manifest_codec.rs`, and `tests/retention_head_codec.rs` | Implemented | -| `KEEP-RETENTION-003` | Every structural field, truncation boundary, ordering law, duplicate, overflow, flag, reserved byte, digest, checksum, and trailing byte has a precise refusal | seeded `retention_format` fuzz target plus the root, manifest, and head corruption matrix; mutation coverage remains | In progress in #19 | +| `KEEP-RETENTION-003` | Every structural field, truncation boundary, ordering law, duplicate, overflow, flag, reserved byte, digest, checksum, and trailing byte has a precise refusal | one sealed mutation per header, body, and trailer field with its exact first refusal, plus reframed namespace-bound, ordering, and count-ceiling cases, in `tests/retention_root_decoding/mutation_laws.rs`, `tests/retention_manifest_codec/mutation_laws.rs`, and `tests/retention_head_codec/mutation_laws.rs`; framing and integrity precedence in `tests/retention_root_decoding.rs`, `tests/retention_manifest_codec/refusal_laws.rs`, and `tests/retention_head_codec.rs`; seeded `retention_format` fuzz target | Implemented | | `KEEP-RETENTION-004` | Retain and release compare expected and observed generations and publish exact successors only | unforgeable readiness and preflight proofs in `tests/retention_transition.rs` and `tests/retention_preflight.rs`; exact successor preparation and complete receipt evidence in `tests/retention_publication_preparation.rs` and `tests/retention_publication_execution.rs`; writer-locked initial filesystem publication in `filesystem_retention_storage_tests`; observed-head successor publication, exact predecessor binding, and absent-head refusal in `filesystem_retention_successor_tests`; the store's catalog head must name the closure's catalog generation and digest before any forward write in `filesystem_retention_catalog_tests`; a head whose predecessor disagrees with its manifest refuses in `filesystem_retention_current_tests`; a successor reopens and decodes the manifest-selected predecessor root and refuses an absent or changed one in `filesystem_retention_expectation_tests` | Implemented | | `KEEP-RETENTION-005` | Closure derivation is deterministic, bounded, cycle-safe, fail-closed, and verifies complete blob reconstruction | exact accounting, reconstruction, adversarial-catalog, and exhaustive model laws in `tests/retention_closure.rs`; corrupt members refuse through the inherited segment-record admission laws and seeded `segment_format` fuzz target routed by `closure-corruption.md` | Implemented | | `KEEP-RETENTION-006` | Publication follows the exact ordered durability protocol, including new namespace-directory admission and retention of fixed-stage evidence until head commit, and returns only after cleanup synchronization | typed vocabulary and blocking port in `tests/retention_publication_phase.rs` and `tests/retention_publication_storage.rs`; ordered execution, conditional namespace sync, and all 17 exact storage-fault boundaries in `tests/retention_publication_execution.rs`; production 17-phase forward filesystem execution, exclusive staging, byte-equal inode-substitution refusal, and retained-stage recovery refusal in `filesystem_retention_storage_tests`; orphan namespace directories count against the 4,096 ceiling and refuse a new namespace before any stage is written in `filesystem_retention_capacity_tests`; crash injection remains | In progress in #19 | diff --git a/tests/retention_head_codec.rs b/tests/retention_head_codec.rs index b3bd0ce2..d67c511a 100644 --- a/tests/retention_head_codec.rs +++ b/tests/retention_head_codec.rs @@ -1,5 +1,7 @@ //! Public semantic and canonical-codec laws for the retention head. +#[path = "retention_head_codec/mutation_laws.rs"] +mod mutation_laws; mod support; use std::io; @@ -12,8 +14,9 @@ use keep::{ const ONE_ROOT_MANIFEST: &str = include_str!("../conformance/segment-store/v2/one-root-manifest.hex"); -const ONE_ROOT_HEAD: &str = include_str!("../conformance/segment-store/v2/one-root-head.hex"); -const CHECKSUM_OFFSET: usize = 112; +pub(crate) const ONE_ROOT_HEAD: &str = + include_str!("../conformance/segment-store/v2/one-root-head.hex"); +pub(crate) const CHECKSUM_OFFSET: usize = 112; #[test] fn one_root_head_has_one_semantic_and_canonical_representation() @@ -165,7 +168,7 @@ fn complete_integrity_precedes_head_semantics() -> Result<(), Box Result, io::Error> { +pub(crate) fn fixture_bytes(fixture: &str) -> Result, io::Error> { let encoded = fixture .strip_suffix('\n') .ok_or_else(|| io::Error::other("retention fixture lacks final newline"))?; diff --git a/tests/retention_head_codec/mutation_laws.rs b/tests/retention_head_codec/mutation_laws.rs new file mode 100644 index 00000000..a586c3db --- /dev/null +++ b/tests/retention_head_codec/mutation_laws.rs @@ -0,0 +1,162 @@ +//! Field-by-field corruption matrix for the version-2 retention head. +//! +//! Every field of the fixed 144-byte head has one mutation and one exact +//! first refusal (`KEEP-RETENTION-003`). + +use std::io; + +use keep::{ChecksummedRetentionHead, RetentionHeadDecodeError as Refusal, RetentionHeadError}; + +use super::{CHECKSUM_OFFSET, ONE_ROOT_HEAD, fixture_bytes}; +use crate::support::{domain_hash, flip, patch}; + +struct Mutation { + field: &'static str, + reseal: bool, + mutate: fn(&mut Vec) -> io::Result<()>, + refuses: fn(&Refusal) -> bool, +} + +const MATRIX: &[Mutation] = &[ + Mutation { + field: "magic", + reseal: true, + mutate: |bytes| flip(bytes, 15), + refuses: |error| matches!(error, Refusal::InvalidMagic { .. }), + }, + Mutation { + field: "version", + reseal: true, + mutate: |bytes| patch(bytes, 16, &3_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::UnsupportedVersion { + expected: 2, + observed: 3 + } + ) + }, + }, + Mutation { + field: "record length", + reseal: true, + mutate: |bytes| patch(bytes, 18, &143_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::InvalidRecordLength { + expected: 144, + observed: 143 + } + ) + }, + }, + Mutation { + field: "flags", + reseal: true, + mutate: |bytes| patch(bytes, 20, &1_u32.to_be_bytes()), + refuses: |error| matches!(error, Refusal::UnsupportedFlags { observed: 1 }), + }, + Mutation { + field: "liveness generation zero", + reseal: true, + mutate: |bytes| patch(bytes, 24, &0_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::LivenessGeneration { .. }), + }, + Mutation { + field: "liveness generation two without predecessor", + reseal: true, + mutate: |bytes| patch(bytes, 24, &2_u64.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::Semantic { + source: RetentionHeadError::MissingPredecessor { .. } + } + ) + }, + }, + Mutation { + field: "manifest length below bound", + reseal: true, + mutate: |bytes| patch(bytes, 32, &223_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::ManifestLength { .. }), + }, + Mutation { + field: "manifest length not congruent", + reseal: true, + mutate: |bytes| patch(bytes, 32, &225_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::ManifestLength { .. }), + }, + Mutation { + field: "predecessor digest at generation one", + reseal: true, + mutate: |bytes| flip(bytes, 72), + refuses: |error| { + matches!( + error, + Refusal::Semantic { + source: RetentionHeadError::InitialGenerationHasPredecessor { .. }, + } + ) + }, + }, + Mutation { + field: "reserved bytes", + reseal: true, + mutate: |bytes| flip(bytes, 111), + refuses: |error| matches!(error, Refusal::NonZeroReserved { .. }), + }, + Mutation { + field: "checksum", + reseal: false, + mutate: |bytes| flip(bytes, 143), + refuses: |error| matches!(error, Refusal::ChecksumMismatch { .. }), + }, +]; + +#[test] +fn every_head_field_has_one_exact_first_refusal() -> Result<(), Box> { + for mutation in MATRIX { + let mut bytes = fixture_bytes(ONE_ROOT_HEAD)?; + (mutation.mutate)(&mut bytes)?; + if mutation.reseal { + reseal(&mut bytes)?; + } + let Err(error) = ChecksummedRetentionHead::decode(&bytes) else { + return Err(format!("mutated {} was admitted", mutation.field).into()); + }; + assert!( + (mutation.refuses)(&error), + "{} refused with {error:?}", + mutation.field + ); + } + Ok(()) +} + +#[test] +fn manifest_digest_is_carried_not_verified_by_the_head() -> Result<(), Box> { + let mut bytes = fixture_bytes(ONE_ROOT_HEAD)?; + flip(&mut bytes, 40)?; + reseal(&mut bytes)?; + let head = ChecksummedRetentionHead::decode(&bytes)?; + let mut expected = fixture_bytes(ONE_ROOT_HEAD)?; + flip(&mut expected, 40)?; + assert_eq!( + head.head().manifest_digest().as_bytes(), + expected + .get(40..72) + .ok_or_else(|| io::Error::other("frozen retention head lacks a digest"))? + ); + Ok(()) +} + +fn reseal(bytes: &mut [u8]) -> io::Result<()> { + let preimage = bytes + .get(..CHECKSUM_OFFSET) + .ok_or_else(|| io::Error::other("retention head lacks its checksum preimage"))?; + let checksum = domain_hash(b"keep.retention-head-checksum/v2\0", preimage); + patch(bytes, CHECKSUM_OFFSET, &checksum) +} diff --git a/tests/retention_manifest_codec.rs b/tests/retention_manifest_codec.rs index a17fb86e..416d8d06 100644 --- a/tests/retention_manifest_codec.rs +++ b/tests/retention_manifest_codec.rs @@ -1,5 +1,7 @@ //! Public semantic and canonical-codec laws for retention manifests. +#[path = "retention_manifest_codec/mutation_laws.rs"] +mod mutation_laws; #[path = "retention_manifest_codec/refusal_laws.rs"] mod refusal_laws; mod support; diff --git a/tests/retention_manifest_codec/mutation_laws.rs b/tests/retention_manifest_codec/mutation_laws.rs new file mode 100644 index 00000000..f2f72265 --- /dev/null +++ b/tests/retention_manifest_codec/mutation_laws.rs @@ -0,0 +1,302 @@ +//! Field-by-field corruption matrix for version-2 retention manifests. +//! +//! Every structural field of the manifest header, entry body, and trailer +//! has one mutation and one exact first refusal (`KEEP-RETENTION-003`). + +use std::io; + +use keep::{ + AdmittedRetentionManifest, RetentionManifestDecodeError as Refusal, RetentionManifestError, +}; + +use super::{ + CHECKSUM_OFFSET, ENTRY_BODY_OFFSET, ENTRY_SET_DIGEST_OFFSET, MANIFEST_DIGEST_OFFSET, + ONE_ROOT_MANIFEST, fixture_bytes, +}; +use crate::support::{counted_domain_hash, domain_hash, flip, patch, read_u32}; + +const HEADER_LENGTH: usize = 160; +const ENTRY_WIDTH: usize = 72; +const TRAILER_LENGTH: usize = 64; +const ENTRY_COUNT_OFFSET: usize = 44; +const MAXIMUM_ENTRY_COUNT: u32 = 4_096; + +#[derive(Clone, Copy)] +enum Seal { + Nothing, + Checksum, + Digests, + Everything, +} + +struct Mutation { + field: &'static str, + seal: Seal, + mutate: fn(&mut Vec) -> io::Result<()>, + refuses: fn(&Refusal) -> bool, +} + +const MATRIX: &[Mutation] = &[ + Mutation { + field: "magic", + seal: Seal::Everything, + mutate: |bytes| flip(bytes, 15), + refuses: |error| matches!(error, Refusal::InvalidMagic { .. }), + }, + Mutation { + field: "version", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 16, &3_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::UnsupportedVersion { + expected: 2, + observed: 3 + } + ) + }, + }, + Mutation { + field: "header length", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 18, &159_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::InvalidHeaderLength { + expected: 160, + observed: 159 + } + ) + }, + }, + Mutation { + field: "flags", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 20, &1_u32.to_be_bytes()), + refuses: |error| matches!(error, Refusal::UnsupportedFlags { observed: 1 }), + }, + Mutation { + field: "total record length", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 24, &295_u64.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::DeclaredLengthMismatch { + expected: 296, + observed: 295 + } + ) + }, + }, + Mutation { + field: "liveness generation zero", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 32, &0_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::LivenessGeneration { .. }), + }, + Mutation { + field: "liveness generation two without predecessor", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 32, &2_u64.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::Semantic { + source: RetentionManifestError::MissingPredecessor { .. } + } + ) + }, + }, + Mutation { + field: "entry width", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 40, &71_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::InvalidEntryWidth { + expected: 72, + observed: 71 + } + ) + }, + }, + Mutation { + field: "reserved entry bytes", + seal: Seal::Everything, + mutate: |bytes| flip(bytes, 42), + refuses: |error| matches!(error, Refusal::NonZeroReserved { field: "entry" }), + }, + Mutation { + field: "entry count participates in the declared length", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, ENTRY_COUNT_OFFSET, &2_u32.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::DeclaredLengthMismatch { + expected: 368, + observed: 296 + } + ) + }, + }, + Mutation { + field: "predecessor digest at generation one", + seal: Seal::Everything, + mutate: |bytes| flip(bytes, 48), + refuses: |error| { + matches!( + error, + Refusal::Semantic { + source: RetentionManifestError::InitialGenerationHasPredecessor { .. }, + } + ) + }, + }, + Mutation { + field: "entry-set digest", + seal: Seal::Digests, + mutate: |bytes| flip(bytes, ENTRY_SET_DIGEST_OFFSET), + refuses: |error| matches!(error, Refusal::EntrySetDigestMismatch { .. }), + }, + Mutation { + field: "reserved trailing header bytes", + seal: Seal::Everything, + mutate: |bytes| flip(bytes, 159), + refuses: |error| { + matches!( + error, + Refusal::NonZeroReserved { + field: "trailing header" + } + ) + }, + }, + Mutation { + field: "entry root generation zero", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, ENTRY_BODY_OFFSET + 32, &0_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::RootGeneration { index: 0, .. }), + }, + Mutation { + field: "manifest digest", + seal: Seal::Checksum, + mutate: |bytes| flip(bytes, MANIFEST_DIGEST_OFFSET), + refuses: |error| matches!(error, Refusal::ManifestDigestMismatch { .. }), + }, + Mutation { + field: "checksum", + seal: Seal::Nothing, + mutate: |bytes| flip(bytes, CHECKSUM_OFFSET), + refuses: |error| matches!(error, Refusal::ChecksumMismatch { .. }), + }, +]; + +#[test] +fn every_manifest_field_has_one_exact_first_refusal() -> Result<(), Box> { + for mutation in MATRIX { + let mut bytes = fixture_bytes(ONE_ROOT_MANIFEST)?; + (mutation.mutate)(&mut bytes)?; + seal(&mut bytes, mutation.seal)?; + let Err(error) = AdmittedRetentionManifest::decode(&bytes) else { + return Err(format!("mutated {} was admitted", mutation.field).into()); + }; + assert!( + (mutation.refuses)(&error), + "{} refused with {error:?}", + mutation.field + ); + } + Ok(()) +} + +#[test] +fn entry_order_and_count_ceilings_refuse_after_complete_integrity() +-> Result<(), Box> { + let entry = fixture_entry_bytes()?; + let mut duplicated = entry.clone(); + duplicated.extend_from_slice(&entry); + let repeated = reframe(&duplicated, 2)?; + assert!(matches!( + AdmittedRetentionManifest::decode(&repeated), + Err(Refusal::NonCanonicalEntryOrder { index: 1 }) + )); + + let count = MAXIMUM_ENTRY_COUNT + .checked_add(1) + .ok_or_else(|| io::Error::other("entry ceiling overflows"))?; + let body_length = usize::try_from(count)? + .checked_mul(ENTRY_WIDTH) + .ok_or_else(|| io::Error::other("entry body overflows"))?; + let oversized = reframe(&vec![0_u8; body_length], count)?; + assert!(matches!( + AdmittedRetentionManifest::decode(&oversized), + Err(Refusal::EntryCountExceeded { + maximum: MAXIMUM_ENTRY_COUNT, + observed, + }) if observed == count + )); + Ok(()) +} + +fn fixture_entry_bytes() -> io::Result> { + let bytes = fixture_bytes(ONE_ROOT_MANIFEST)?; + bytes + .get(ENTRY_BODY_OFFSET..MANIFEST_DIGEST_OFFSET) + .map(<[u8]>::to_vec) + .ok_or_else(|| io::Error::other("frozen manifest lacks its entry body")) +} + +/// Builds a manifest from the frozen header with a replaced entry body. +fn reframe(entries: &[u8], entry_count: u32) -> io::Result> { + let fixture = fixture_bytes(ONE_ROOT_MANIFEST)?; + let mut bytes = fixture + .get(..HEADER_LENGTH) + .map(<[u8]>::to_vec) + .ok_or_else(|| io::Error::other("frozen manifest lacks its header"))?; + bytes.extend_from_slice(entries); + bytes.extend_from_slice(&[0_u8; TRAILER_LENGTH]); + let total_length = u64::try_from(bytes.len()) + .map_err(|_| io::Error::other("record exceeds the u64 length field"))?; + patch(&mut bytes, 24, &total_length.to_be_bytes())?; + patch(&mut bytes, ENTRY_COUNT_OFFSET, &entry_count.to_be_bytes())?; + seal(&mut bytes, Seal::Everything)?; + Ok(bytes) +} + +fn seal(bytes: &mut [u8], seal: Seal) -> io::Result<()> { + let checksum_offset = bytes + .len() + .checked_sub(32) + .ok_or_else(|| io::Error::other("retention manifest lacks a checksum"))?; + let digest_offset = checksum_offset + .checked_sub(32) + .ok_or_else(|| io::Error::other("retention manifest lacks a digest"))?; + if matches!(seal, Seal::Everything) { + let count = read_u32(bytes, ENTRY_COUNT_OFFSET)?; + let body = bytes + .get(HEADER_LENGTH..digest_offset) + .ok_or_else(|| io::Error::other("retention manifest lacks its entry body"))?; + let digest = counted_domain_hash(b"keep.retention-manifest-entries/v2\0", count, body); + patch(bytes, ENTRY_SET_DIGEST_OFFSET, &digest)?; + } + if matches!(seal, Seal::Everything | Seal::Digests) { + let preimage = bytes + .get(..digest_offset) + .ok_or_else(|| io::Error::other("retention manifest lacks its digest preimage"))?; + let digest = domain_hash(b"keep.retention-manifest/v2\0", preimage); + patch(bytes, digest_offset, &digest)?; + } + if !matches!(seal, Seal::Nothing) { + let preimage = bytes + .get(..checksum_offset) + .ok_or_else(|| io::Error::other("retention manifest lacks its checksum preimage"))?; + let checksum = domain_hash(b"keep.retention-manifest-checksum/v2\0", preimage); + patch(bytes, checksum_offset, &checksum)?; + } + Ok(()) +} diff --git a/tests/retention_root_decoding.rs b/tests/retention_root_decoding.rs index be1a4d34..c28d7ee1 100644 --- a/tests/retention_root_decoding.rs +++ b/tests/retention_root_decoding.rs @@ -1,5 +1,7 @@ //! Public decoding and integrity laws for version-2 retention roots. +#[path = "retention_root_decoding/mutation_laws.rs"] +mod mutation_laws; mod support; use std::io; @@ -7,10 +9,10 @@ use std::io; use keep::{AdmittedRetentionRoot, RetentionRootDecodeError}; const ONE_ANCHOR_ROOT: &str = include_str!("../conformance/segment-store/v2/one-anchor-root.hex"); -const ANCHOR_SET_DIGEST_OFFSET: usize = 148; +pub(crate) const ANCHOR_SET_DIGEST_OFFSET: usize = 148; const ANCHOR_SET_DIGEST_END: usize = 180; -const ANCHOR_BODY_OFFSET: usize = 195; -const ROOT_DIGEST_OFFSET: usize = 314; +pub(crate) const ANCHOR_BODY_OFFSET: usize = 195; +pub(crate) const ROOT_DIGEST_OFFSET: usize = 314; const CHECKSUM_OFFSET: usize = 346; #[test] @@ -144,7 +146,7 @@ fn anchor_set_integrity_precedes_nested_identity_admission() Ok(()) } -fn fixture_bytes() -> Result, io::Error> { +pub(crate) fn fixture_bytes() -> Result, io::Error> { let encoded = ONE_ANCHOR_ROOT .strip_suffix('\n') .ok_or_else(|| io::Error::other("retention root fixture lacks final newline"))?; diff --git a/tests/retention_root_decoding/mutation_laws.rs b/tests/retention_root_decoding/mutation_laws.rs new file mode 100644 index 00000000..337dcaaa --- /dev/null +++ b/tests/retention_root_decoding/mutation_laws.rs @@ -0,0 +1,377 @@ +//! Field-by-field corruption matrix for version-2 retention roots. +//! +//! Every structural field of the root header, body, and trailer has one +//! mutation and one exact first refusal (`KEEP-RETENTION-003`). The sealed +//! matrix recomputes every digest and checksum that the mutation did not +//! target, so each case proves the named field check and nothing else. + +use std::io; + +use keep::{AdmittedRetentionRoot, RetentionRootDecodeError as Refusal, RetentionRootError}; + +use super::{ANCHOR_BODY_OFFSET, ANCHOR_SET_DIGEST_OFFSET, ROOT_DIGEST_OFFSET, fixture_bytes}; +use crate::support::{counted_domain_hash, domain_hash, flip, patch, read_u16, read_u32}; + +const HEADER_LENGTH: usize = 192; +const ANCHOR_WIDTH: usize = 119; +const TRAILER_LENGTH: usize = 64; +const NAMESPACE_LENGTH_OFFSET: usize = 40; +const ANCHOR_COUNT_OFFSET: usize = 44; +const MAXIMUM_ANCHOR_COUNT: u32 = 65_536; + +#[derive(Clone, Copy)] +enum Seal { + Nothing, + Checksum, + Digests, + Everything, +} + +struct Mutation { + field: &'static str, + seal: Seal, + mutate: fn(&mut Vec) -> io::Result<()>, + refuses: fn(&Refusal) -> bool, +} + +const MATRIX: &[Mutation] = &[ + Mutation { + field: "magic", + seal: Seal::Everything, + mutate: |bytes| flip(bytes, 15), + refuses: |error| matches!(error, Refusal::InvalidMagic { .. }), + }, + Mutation { + field: "version", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 16, &3_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::UnsupportedVersion { + expected: 2, + observed: 3 + } + ) + }, + }, + Mutation { + field: "header length", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 18, &191_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::InvalidHeaderLength { + expected: 192, + observed: 191 + } + ) + }, + }, + Mutation { + field: "flags", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 20, &1_u32.to_be_bytes()), + refuses: |error| matches!(error, Refusal::UnsupportedFlags { observed: 1 }), + }, + Mutation { + field: "total record length", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 24, &377_u64.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::DeclaredLengthMismatch { + expected: 378, + observed: 377 + } + ) + }, + }, + Mutation { + field: "root generation zero", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 32, &0_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::Generation { .. }), + }, + Mutation { + field: "root generation two without predecessor", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 32, &2_u64.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::Semantic { + source: RetentionRootError::MissingPredecessor { .. } + } + ) + }, + }, + Mutation { + field: "anchor width", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 42, &118_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::InvalidAnchorWidth { + expected: 119, + observed: 118 + } + ) + }, + }, + Mutation { + field: "anchor count participates in the declared length", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, ANCHOR_COUNT_OFFSET, &2_u32.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::DeclaredLengthMismatch { + expected: 497, + observed: 378 + } + ) + }, + }, + Mutation { + field: "profile identity", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 48, &2_u32.to_be_bytes()), + refuses: |error| matches!(error, Refusal::Profile { .. }), + }, + Mutation { + field: "profile version", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 52, &2_u32.to_be_bytes()), + refuses: |error| matches!(error, Refusal::Profile { .. }), + }, + Mutation { + field: "profile-definition digest", + seal: Seal::Everything, + mutate: |bytes| flip(bytes, 56), + refuses: |error| matches!(error, Refusal::Profile { .. }), + }, + Mutation { + field: "closure-node limit zero", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 88, &0_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::ClosureLimit { .. }), + }, + Mutation { + field: "closure-depth limit above ceiling", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 96, &9_u16.to_be_bytes()), + refuses: |error| matches!(error, Refusal::ClosureLimit { .. }), + }, + Mutation { + field: "reserved limit bytes", + seal: Seal::Everything, + mutate: |bytes| flip(bytes, 98), + refuses: |error| matches!(error, Refusal::NonZeroReserved { field: "limit" }), + }, + Mutation { + field: "encoded-byte limit zero", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 100, &0_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::ClosureLimit { .. }), + }, + Mutation { + field: "physical-byte limit zero", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 108, &0_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::ClosureLimit { .. }), + }, + Mutation { + field: "predecessor digest at generation one", + seal: Seal::Everything, + mutate: |bytes| flip(bytes, 116), + refuses: |error| { + matches!( + error, + Refusal::Semantic { + source: RetentionRootError::InitialGenerationHasPredecessor { .. }, + } + ) + }, + }, + Mutation { + field: "anchor-set digest", + seal: Seal::Digests, + mutate: |bytes| flip(bytes, ANCHOR_SET_DIGEST_OFFSET), + refuses: |error| matches!(error, Refusal::AnchorSetDigestMismatch { .. }), + }, + Mutation { + field: "reserved trailing header bytes", + seal: Seal::Everything, + mutate: |bytes| flip(bytes, 191), + refuses: |error| { + matches!( + error, + Refusal::NonZeroReserved { + field: "trailing header" + } + ) + }, + }, + Mutation { + field: "anchor blob identity", + seal: Seal::Everything, + mutate: |bytes| flip(bytes, ANCHOR_BODY_OFFSET), + refuses: |error| matches!(error, Refusal::BlobId { index: 0, .. }), + }, + Mutation { + field: "anchor layout identity", + seal: Seal::Everything, + mutate: |bytes| flip(bytes, ANCHOR_BODY_OFFSET + 59), + refuses: |error| matches!(error, Refusal::LayoutId { index: 0, .. }), + }, + Mutation { + field: "root digest", + seal: Seal::Checksum, + mutate: |bytes| flip(bytes, ROOT_DIGEST_OFFSET), + refuses: |error| matches!(error, Refusal::RootDigestMismatch { .. }), + }, + Mutation { + field: "checksum", + seal: Seal::Nothing, + mutate: |bytes| flip(bytes, 377), + refuses: |error| matches!(error, Refusal::ChecksumMismatch { .. }), + }, +]; + +#[test] +fn every_root_field_has_one_exact_first_refusal() -> Result<(), Box> { + for mutation in MATRIX { + let mut bytes = fixture_bytes()?; + (mutation.mutate)(&mut bytes)?; + seal(&mut bytes, mutation.seal)?; + let Err(error) = AdmittedRetentionRoot::decode(&bytes) else { + return Err(format!("mutated {} was admitted", mutation.field).into()); + }; + assert!( + (mutation.refuses)(&error), + "{} refused with {error:?}", + mutation.field + ); + } + Ok(()) +} + +#[test] +fn namespace_length_bounds_refuse_after_complete_integrity() +-> Result<(), Box> { + let anchors = fixture_anchors()?; + let empty = reframe(&[], &anchors, 1)?; + assert!(matches!( + AdmittedRetentionRoot::decode(&empty), + Err(Refusal::Namespace { .. }) + )); + + let too_long = reframe(&[0x2f; 256], &anchors, 1)?; + assert!(matches!( + AdmittedRetentionRoot::decode(&too_long), + Err(Refusal::Namespace { .. }) + )); + Ok(()) +} + +#[test] +fn anchor_order_and_count_ceilings_refuse_after_complete_integrity() +-> Result<(), Box> { + let anchor = fixture_anchors()?; + let mut duplicated = anchor.clone(); + duplicated.extend_from_slice(&anchor); + let repeated = reframe(&[0x00, 0x2f, 0xff], &duplicated, 2)?; + assert!(matches!( + AdmittedRetentionRoot::decode(&repeated), + Err(Refusal::NonCanonicalAnchorOrder { index: 1 }) + )); + + let count = MAXIMUM_ANCHOR_COUNT + .checked_add(1) + .ok_or_else(|| io::Error::other("anchor ceiling overflows"))?; + let body_length = usize::try_from(count)? + .checked_mul(ANCHOR_WIDTH) + .ok_or_else(|| io::Error::other("anchor body overflows"))?; + let oversized = reframe(&[0x00, 0x2f, 0xff], &vec![0_u8; body_length], count)?; + assert!(matches!( + AdmittedRetentionRoot::decode(&oversized), + Err(Refusal::AnchorCountExceeded { + maximum: MAXIMUM_ANCHOR_COUNT, + observed, + }) if observed == count + )); + Ok(()) +} + +fn fixture_anchors() -> io::Result> { + let bytes = fixture_bytes()?; + bytes + .get(ANCHOR_BODY_OFFSET..ROOT_DIGEST_OFFSET) + .map(<[u8]>::to_vec) + .ok_or_else(|| io::Error::other("frozen retention root lacks its anchor body")) +} + +/// Builds a root from the frozen header with a replaced namespace and body. +fn reframe(namespace: &[u8], anchors: &[u8], anchor_count: u32) -> io::Result> { + let fixture = fixture_bytes()?; + let mut bytes = fixture + .get(..HEADER_LENGTH) + .map(<[u8]>::to_vec) + .ok_or_else(|| io::Error::other("frozen retention root lacks its header"))?; + bytes.extend_from_slice(namespace); + bytes.extend_from_slice(anchors); + bytes.extend_from_slice(&[0_u8; TRAILER_LENGTH]); + let namespace_length = u16::try_from(namespace.len()) + .map_err(|_| io::Error::other("namespace exceeds the u16 length field"))?; + let total_length = u64::try_from(bytes.len()) + .map_err(|_| io::Error::other("record exceeds the u64 length field"))?; + patch(&mut bytes, 24, &total_length.to_be_bytes())?; + patch( + &mut bytes, + NAMESPACE_LENGTH_OFFSET, + &namespace_length.to_be_bytes(), + )?; + patch(&mut bytes, ANCHOR_COUNT_OFFSET, &anchor_count.to_be_bytes())?; + seal(&mut bytes, Seal::Everything)?; + Ok(bytes) +} + +fn seal(bytes: &mut [u8], seal: Seal) -> io::Result<()> { + let checksum_offset = bytes + .len() + .checked_sub(32) + .ok_or_else(|| io::Error::other("retention root lacks a checksum"))?; + let digest_offset = checksum_offset + .checked_sub(32) + .ok_or_else(|| io::Error::other("retention root lacks a digest"))?; + if matches!(seal, Seal::Everything) { + let body_offset = HEADER_LENGTH + .checked_add(usize::from(read_u16(bytes, NAMESPACE_LENGTH_OFFSET)?)) + .ok_or_else(|| io::Error::other("namespace length overflows"))?; + let count = read_u32(bytes, ANCHOR_COUNT_OFFSET)?; + let body = bytes + .get(body_offset..digest_offset) + .ok_or_else(|| io::Error::other("retention root lacks its anchor body"))?; + let digest = counted_domain_hash(b"keep.retention-anchor-set/v2\0", count, body); + patch(bytes, ANCHOR_SET_DIGEST_OFFSET, &digest)?; + } + if matches!(seal, Seal::Everything | Seal::Digests) { + let preimage = bytes + .get(..digest_offset) + .ok_or_else(|| io::Error::other("retention root lacks its digest preimage"))?; + let digest = domain_hash(b"keep.retention-root/v2\0", preimage); + patch(bytes, digest_offset, &digest)?; + } + if !matches!(seal, Seal::Nothing) { + let preimage = bytes + .get(..checksum_offset) + .ok_or_else(|| io::Error::other("retention root lacks its checksum preimage"))?; + let checksum = domain_hash(b"keep.retention-root-checksum/v2\0", preimage); + patch(bytes, checksum_offset, &checksum)?; + } + Ok(()) +} diff --git a/tests/support/byte_patches.rs b/tests/support/byte_patches.rs new file mode 100644 index 00000000..1e7e00fc --- /dev/null +++ b/tests/support/byte_patches.rs @@ -0,0 +1,77 @@ +//! Byte-level fixture patching for corruption matrices. + +use std::io; + +/// Overwrites the bytes at `offset` with `value`. +/// +/// # Errors +/// +/// Returns an error when the patch would fall outside the fixture. +pub(crate) fn patch(bytes: &mut [u8], offset: usize, value: &[u8]) -> io::Result<()> { + let end = offset + .checked_add(value.len()) + .ok_or_else(|| io::Error::other("patch offset overflows"))?; + bytes + .get_mut(offset..end) + .ok_or_else(|| io::Error::other("patch falls outside the fixture"))? + .copy_from_slice(value); + Ok(()) +} + +/// Flips the lowest bit of one byte. +/// +/// # Errors +/// +/// Returns an error when `offset` is outside the fixture. +pub(crate) fn flip(bytes: &mut [u8], offset: usize) -> io::Result<()> { + let byte = bytes + .get_mut(offset) + .ok_or_else(|| io::Error::other("flip offset falls outside the fixture"))?; + *byte ^= 1; + Ok(()) +} + +/// Reads one big-endian `u16`. +/// +/// # Errors +/// +/// Returns an error when the field is outside the fixture. +pub(crate) fn read_u16(bytes: &[u8], offset: usize) -> io::Result { + read_array(bytes, offset).map(u16::from_be_bytes) +} + +/// Reads one big-endian `u32`. +/// +/// # Errors +/// +/// Returns an error when the field is outside the fixture. +pub(crate) fn read_u32(bytes: &[u8], offset: usize) -> io::Result { + read_array(bytes, offset).map(u32::from_be_bytes) +} + +fn read_array(bytes: &[u8], offset: usize) -> io::Result<[u8; WIDTH]> { + let end = offset + .checked_add(WIDTH) + .ok_or_else(|| io::Error::other("read offset overflows"))?; + let field = bytes + .get(offset..end) + .ok_or_else(|| io::Error::other("read falls outside the fixture"))?; + <[u8; WIDTH]>::try_from(field).map_err(|_| io::Error::other("read width mismatch")) +} + +/// BLAKE3-256 over a domain string followed by the preimage. +pub(crate) fn domain_hash(domain: &[u8], preimage: &[u8]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(preimage); + *hasher.finalize().as_bytes() +} + +/// BLAKE3-256 over a domain string, a big-endian `u32` count, and a body. +pub(crate) fn counted_domain_hash(domain: &[u8], count: u32, body: &[u8]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(&count.to_be_bytes()); + hasher.update(body); + *hasher.finalize().as_bytes() +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 9f143055..e0d1b7ed 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -6,6 +6,7 @@ reason = "shared fixtures are crate-visible across binary integration-test modules" )] +pub(crate) mod byte_patches; pub(crate) mod byte_readers; pub(crate) mod byte_writers; @@ -13,6 +14,7 @@ use std::io; use keep::{ChunkSpan, FastCdc}; +pub(crate) use byte_patches::{counted_domain_hash, domain_hash, flip, patch, read_u16, read_u32}; pub(crate) use byte_readers::{FailingReader, LyingReader, PartitionReader}; pub(crate) use byte_writers::{ FailingWriter, LyingWriter, PartitionWriter, PrefixThenFailWriter, ZeroWriter, From a5c6701a086f001876cdfc4865895c57680a618a Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 08:37:11 -0700 Subject: [PATCH 11/59] Docs: describe the shipped v1 initialization and recovery on the living pages Problem: the living keep.segment-store/v1 pages still assigned store initialization, platform admission, explicit recovery, and the crash matrix to issue #17 as future work, said issue #16 "does not implement" admission or recovery, promised "a future admission producer", and called transitive publication-view admission unimplemented. Issues #16 and #17 are complete on main, so the reference understated shipped guarantees and handed the version-2 migration work a stale source boundary (#69). Approach: every stale sentence now states current behaviour and names its evidence. The publication page names FilesystemPlatformAdmission::initialize and ::reopen as the only production admission producers and routes the crash matrix's unchecked value to the repository-tasks feature. The recovery page states whole-byte classification as the ledger's design (KEEP-RECOVERY-010, -011) instead of a gap. The requirements prose routes retention, collection, and power-loss simulation to their current owners. Evidence: a new contract law in xtask/tests/segment_store_implementation_documentation.rs refuses each stale phrase; it fails against the previous pages ("stale issue-era claim survives") and passes against these. The existing implementation and crash-matrix documentation laws still pass. markdownlint, the roadmap link check, and git diff --check are clean. Closes #69. ROADMAP T-14.1 checked. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 8 ++++++ ROADMAP.md | 8 ++++-- docs/formats/segment-store-v1/README.md | 6 ++-- docs/formats/segment-store-v1/publication.md | 28 ++++++++++++------- docs/formats/segment-store-v1/recovery.md | 8 ++++-- docs/formats/segment-store-v1/requirements.md | 14 +++++++--- ...ment_store_implementation_documentation.rs | 23 +++++++++++++++ 7 files changed, 73 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d348bad..8fab820e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -252,6 +252,14 @@ after its public API and format compatibility policies are established. ### Changed +- The living `keep.segment-store/v1` pages describe `main`: initialization, + platform admission, explicit recovery, and the crash matrix are stated as + implemented in issue #17 instead of owned by it; the publication page names + `FilesystemPlatformAdmission::initialize` and `::reopen` as the production + admission producers; the recovery page states whole-byte classification as + the ledger's design rather than a gap; and the requirements prose routes + retention, collection, and power-loss simulation to their current owners. + A contract law refuses the stale phrases. Closes #69. - Documentation refreshed after the version-two merge: the README, the version-two overview status, the closure and recovery status lines, the reconstruction contract's retention note, and the requirements ledger state diff --git a/ROADMAP.md b/ROADMAP.md index 616202f3..dce624f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -78,7 +78,7 @@ names; use those in code, tests, and commits. - [x] [F-11 Immutable segment format and verified I/O](#f-11-immutable-segment-format-and-verified-io) — Done - [x] [F-12 Catalog generations and writer-locked publication](#f-12-catalog-generations-and-writer-locked-publication) — Done - [x] [F-13 Store initialization, recovery, and the crash matrix](#f-13-store-initialization-recovery-and-the-crash-matrix) — Done -- [ ] [F-14 Segment store v1 living documentation refresh](#f-14-segment-store-v1-living-documentation-refresh) — Planned (#69) +- [x] [F-14 Segment store v1 living documentation refresh](#f-14-segment-store-v1-living-documentation-refresh) — Done on this branch (#69) ### Retention and version 2 (M4) @@ -541,7 +541,9 @@ filesystem that violates the admitted atomicity contract. See F-41 T-41.2. ### F-14 Segment store v1 living documentation refresh -**Status:** Planned (#69, P2, M4). +**Status:** Done on this branch (#69, P2, M4); an absence law in +`xtask/tests/segment_store_implementation_documentation.rs` keeps the stale +phrases from returning. `docs/formats/segment-store-v1/README.md` and `publication.md` still say initialization, platform admission, and explicit recovery are future work @@ -549,7 +551,7 @@ owned by #17, and that #16 "does not implement admission/recovery". Both issues are complete on `main`. The v1 pages understate shipped guarantees and hand version-2 migration a stale source boundary. -- [ ] T-14.1 Reconcile every v1 page with `main`. +- [x] T-14.1 Reconcile every v1 page with `main`. Original task fields: - **Requirements:** every living v1 page describes current behaviour; historical scope stays reachable through linked issues, ADRs, and Git history; every existing requirement identifier and test name remains diff --git a/docs/formats/segment-store-v1/README.md b/docs/formats/segment-store-v1/README.md index 06feafe4..0194600e 100644 --- a/docs/formats/segment-store-v1/README.md +++ b/docs/formats/segment-store-v1/README.md @@ -7,8 +7,10 @@ visibility, and recovery as one contract. ADR-0005 records the cross-cutting decision. These pages are a protocol commitment. Segment writing and verified reading are implemented in issue #15. Catalog generation, writer-locked publication, and immutable restart snapshots -are implemented in issue #16. Store initialization and complete executable -crash and recovery evidence remain owned by issue #17. +are implemented in issue #16. Store initialization, platform admission, +explicit recovery, and the 105-case process-death crash matrix are implemented +in issue #17. Every page below describes the behaviour of `main`; the +[requirements ledger](requirements.md) names the test behind each claim. ## Core law diff --git a/docs/formats/segment-store-v1/publication.md b/docs/formats/segment-store-v1/publication.md index 3783be79..925a3c97 100644 --- a/docs/formats/segment-store-v1/publication.md +++ b/docs/formats/segment-store-v1/publication.md @@ -127,10 +127,17 @@ existing store root plus `staging`, `segments`, and `catalogs`. Both operations perform blocking filesystem I/O. Neither operation repairs, enumerates, or removes protocol state. -Issue #16 defines the proof type but deliberately exposes no public producer. -The filesystem transition suite uses a crate-private, test-only unchecked proof -to exercise publication mechanics. Issue #17 must implement initialization and -the platform contract before production callers can obtain admission. +`FilesystemPlatformAdmission` has private fields, so only Keep's own +initialization and platform-admission boundary can produce a production value. +A new store obtains one through `FilesystemPlatformAdmission::initialize`, +which runs the ordered initialization protocol (`KEEP-RECOVERY-002`); a +published store reacquires one through `FilesystemPlatformAdmission::reopen`, +which mutates nothing and admits the production platform +(`KEEP-RECOVERY-003`). Both are described in +[Recovery and platform contract](recovery.md). The crash matrix's +fault-injecting decorators obtain an unchecked value only behind the +`repository-tasks` Cargo feature, which is never enabled for production +builds. `publish_catalog_generation` performs complete semantic preflight before the first storage transition. With `FilesystemCatalogPublisher`, it then executes @@ -161,12 +168,13 @@ head-selected coordinates, refuses symbolic links and nonregular artifacts, checks every length before allocation, and reconstructs logical bindings only after all canonical bytes and physical coordinates verify. -Issue #16 does not implement store-root initialization, platform admission, or -explicit recovery. A future admission producer must prove the exact canonical -directories and persistent lock file before opening a publisher. Any retained -`head.next` or `current.cat`, and any `current.seg` not owned by the selected -staged segment, causes publication to refuse before mutation and requires issue -recovery under #17. When `HEAD` is absent, the publisher probes both immutable pools +Publication never initializes, admits, or recovers on its own. Admission +proves the exact canonical directories and the persistent lock file before a +publisher opens. Any retained `head.next` or `current.cat`, and any +`current.seg` not owned by the selected staged segment, causes publication to +refuse before mutation; the explicit recovery boundaries in +[Recovery and platform contract](recovery.md) classify and resolve that +residue. When `HEAD` is absent, the publisher probes both immutable pools and admits first publication only when both are empty; any entry is preserved as recovery evidence and refuses the operation. An already-current retry refuses every fixed-name stage. diff --git a/docs/formats/segment-store-v1/recovery.md b/docs/formats/segment-store-v1/recovery.md index 20c3402e..3c03da4e 100644 --- a/docs/formats/segment-store-v1/recovery.md +++ b/docs/formats/segment-store-v1/recovery.md @@ -55,9 +55,11 @@ exact truncation only when every available segment- or record-header framing byte remains canonical. It preserves proven partial-framing and complete-looking corruption as typed refusals. Catalog- and next-head-stage classifiers apply the same available-fixed-framing rule before -distinguishing exact truncation from complete canonical bytes. Transitive -publication-view admission and filesystem-streaming semantic classification -remain unimplemented. +distinguishing exact truncation from complete canonical bytes. Every +classifier consumes complete, protocol-bounded stage bytes that the inventory +reader materializes after fingerprinting; the ledger's classification rows +(`KEEP-RECOVERY-010`, `KEEP-RECOVERY-011`) are whole-byte by design, and no +classifier streams from a filesystem handle. `admit_recovery_stage_bytes` first requires the canonical-name stage, exact length, and recomputed stage fingerprint to match prior observation evidence; only `assess_recovery_stage` may dispatch those admitted bytes to a semantic diff --git a/docs/formats/segment-store-v1/requirements.md b/docs/formats/segment-store-v1/requirements.md index fe544a28..a82faaef 100644 --- a/docs/formats/segment-store-v1/requirements.md +++ b/docs/formats/segment-store-v1/requirements.md @@ -62,7 +62,8 @@ Issue #16 implements catalog-generation admission, writer-locked filesystem publication mechanics, and immutable reader snapshots. Production publisher construction requires `FilesystemPlatformAdmission`, whose platform-checked producer is implemented as the initialization slice of issue #17. Explicit -recovery remains separate work. +recovery is implemented in issue #17; its evidence is the recovery table +below. @@ -103,7 +104,11 @@ may now authorize a transition-checked finalization through a semantic storage port, and the filesystem finalizer binds that transition to pinned writer-authorized storage. These slices now include reusable-stage continuation and the complete process-death crash matrix. Retention, compaction, garbage -collection, and host-power-loss simulation remain outside issue #17. +collection, and host-power-loss simulation remain outside version 1: +retention belongs to +[`keep.segment-store/v2`](../segment-store-v2/README.md), compaction and +garbage collection are planned in issue #21, and host-power-loss simulation +has no owner and is listed in the repository [roadmap](../../../ROADMAP.md). @@ -185,8 +190,9 @@ segment corpus and adds parser fuzzing and corruption evidence. Issue #16 matches the catalog and publication-head corpus, executes the documented publication order through a real filesystem adapter, reconstructs exact immutable restart snapshots, and adds deterministic transition-model and -seeded parser-fuzz evidence. Crash-injection and explicit recovery remain -owned by issue #17. +seeded parser-fuzz evidence. Issue #17 adds initialization, platform +admission, explicit recovery, and the 105-case process-death crash matrix +(`KEEP-RECOVERY-001`–`KEEP-RECOVERY-021`). The format-local tradeoffs are recorded in the [colocated rationale](rationale.md). diff --git a/xtask/tests/segment_store_implementation_documentation.rs b/xtask/tests/segment_store_implementation_documentation.rs index cb616aff..5345c5b9 100644 --- a/xtask/tests/segment_store_implementation_documentation.rs +++ b/xtask/tests/segment_store_implementation_documentation.rs @@ -4,6 +4,8 @@ const ROOT_README: &str = include_str!("../../README.md"); const FORMAT_REGISTRY: &str = include_str!("../../docs/formats/README.md"); const FORMAT_README: &str = include_str!("../../docs/formats/segment-store-v1/README.md"); const REQUIREMENTS: &str = include_str!("../../docs/formats/segment-store-v1/requirements.md"); +const PUBLICATION: &str = include_str!("../../docs/formats/segment-store-v1/publication.md"); +const RECOVERY: &str = include_str!("../../docs/formats/segment-store-v1/recovery.md"); const CORPUS_README: &str = include_str!("../../conformance/segment-store/v1/README.md"); #[test] @@ -32,3 +34,24 @@ fn living_documentation_names_the_implemented_segment_boundary() { } assert!(!ROOT_README.contains("Durable segment storage, retention")); } + +#[test] +fn living_v1_pages_no_longer_assign_shipped_recovery_to_a_future_issue() { + for (document, stale_claim) in [ + (FORMAT_README, "remain owned by issue #17"), + (PUBLICATION, "Issue #17 must implement initialization"), + ( + PUBLICATION, + "Issue #16 does not implement store-root initialization", + ), + (PUBLICATION, "A future admission producer"), + (RECOVERY, "remain unimplemented"), + (REQUIREMENTS, "Explicit\nrecovery remains separate work"), + (REQUIREMENTS, "remain\nowned by issue #17"), + ] { + assert!( + !document.contains(stale_claim), + "stale issue-era claim survives: {stale_claim:?}" + ); + } +} From cec0559388244fc713ad9731de19c04d247fb530 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 08:40:35 -0700 Subject: [PATCH 12/59] Docs: fold the second architecture assessment into the roadmap Name Echo, Graft, and warp-drive as the sibling projects above Keep and say which roadmap features are really membrane concerns. Strengthen T-40.2 with the flat-plan cost that motivates a hierarchical layout, route the FUSE time-machine namespace and the agent copy-on-write sandbox to F-35 and F-36 with warp-drive as the likely home, and add five proposed features: hardware-accelerated identity and chunking behind a feature flag with identity equivalence proven against the corpora (F-47), a layout-level structural diff that never reads a payload (F-48), compact retention inclusion proofs, which need a Merkle anchor set and so a format successor (F-49), untrusted chunk transport gated on the multi-writer decision (F-50), and a kernel-bypass ingestion adapter gated on the unsafe boundary decision (F-51). Co-Authored-By: Claude Fable 5.1 --- ROADMAP.md | 268 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 262 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index dce624f9..3e34967d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,6 +43,17 @@ User stories take four perspectives: - **Agent**: an autonomous coding or operations agent acting through the API or an MCP server without a human in the loop. +Three sibling projects sit above Keep and shape what it must provide: +[Echo](https://github.com/flyingrobots/echo) owns causal history and +consumes Keep through the authenticated reconstruction contract (F-25); +[Graft](https://github.com/flyingrobots/graft) owns workspace policy +(F-26); and [warp-drive](https://github.com/flyingrobots/warp-drive) is a +POSIX-shaped FUSE membrane over Echo where every read is a projection from a +coordinate and every write is an intent against an explicit basis. Keep +stays the physical ground truth beneath all three: exact bytes named by +identity, or a refusal. Where a feature below is really a membrane concern, +it says so and names warp-drive as the likely home. + Keep's core exposes no command-line interface by design (`docs/Rust Standards.md` §5.2). Where a task lists an interface, it names the Rust API and the operation an out-of-core CLI or MCP adapter would @@ -127,12 +138,20 @@ names; use those in code, tests, and commits. - [ ] [F-42 Operator surfaces](#f-42-operator-surfaces) — Proposed (CLI and MCP adapters) - [ ] [F-43 Public release and API stability](#f-43-public-release-and-api-stability) — Proposed - [ ] [F-44 Multi-writer, replication, and remote tiers](#f-44-multi-writer-replication-and-remote-tiers) — Out of scope +- [ ] [F-47 Hardware-accelerated identity and chunking](#f-47-hardware-accelerated-identity-and-chunking) — Proposed +- [ ] [F-48 Layout-level structural diff](#f-48-layout-level-structural-diff) — Proposed +- [ ] [F-49 Compact retention inclusion proofs](#f-49-compact-retention-inclusion-proofs) — Proposed; needs a format successor ### Assurance beyond empirical testing - [ ] [F-45 Formal verification of the durable protocols](#f-45-formal-verification-of-the-durable-protocols) — Proposed - [ ] [F-46 Condition coverage and mutation analysis](#f-46-condition-coverage-and-mutation-analysis) — Proposed +### Moonshots + +- [ ] [F-50 Untrusted chunk transport](#f-50-untrusted-chunk-transport) — Proposed; needs the F-44 decision +- [ ] [F-51 Kernel-bypass ingestion adapter](#f-51-kernel-bypass-ingestion-adapter) — Proposed; Linux only + ## Dependency map Edges are "needs", read left to right. Only edges between unfinished @@ -158,6 +177,14 @@ features are listed; finished prerequisites are implied. - F-42 needs F-23 and F-24 before an adapter has a durable path to wrap. - F-43 needs every Planned feature in M4, plus F-24 and F-28, before a format-compatibility policy can be promised. +- F-47 needs the conformance corpora (F-08) as its oracle and T-09.2 for the + before-and-after numbers; it changes no identity. +- F-48 needs only F-05 today; its durable form needs F-23. +- F-49 needs a retention format successor (a Merkle anchor set) and F-19. +- F-50 needs F-24, F-44's decision record, and F-49 for cross-store + retention claims. +- F-51 needs T-24.2 and a decision on the unsafe boundary (ADR-0006 is the + precedent). ## Features @@ -1826,7 +1853,13 @@ policy; one conformance suite runs against every runtime adapter. ### F-35 Read-only FUSE projection **Status:** Planned (#66 ADR, P3). No mount is exposed until F-19 and -F-22 land. +F-22 land. The POSIX membrane above Keep is warp-drive's job; the ADR here +should decide whether Keep ships any mount at all or only the snapshot and +read surfaces (F-19, F-23) that warp-drive projects. One namespace the ADR +must weigh: a time machine, `generations//...`, where +every historical retention generation is a read-only directory backed by +authenticated on-the-fly reconstruction. Keep can supply the fenced +snapshot per generation; the directory shape is the membrane's. Expose an admitted durable snapshot as a read-only filesystem while preserving the core law: a namespace of identity-addressed files or named @@ -1863,7 +1896,13 @@ no writable, rename, truncate, link, or repair operation. ### F-36 Transactional write-enabled projection -**Status:** Planned (#73, P2). Extends F-35. +**Status:** Planned (#73, P2). Extends F-35. warp-drive is the likely +home: its writes are already intents against an explicit basis, which is +this feature's transaction boundary. The Keep-side contract it needs is +an ephemeral staging port: an agent stages hundreds of file changes into a +scratch segment, runs its tests against the projected view, and either +commits to a successor generation with one atomic head replacement or +discards the stage without touching the published store. A write-capable projection for copy and migration workflows that preserves authenticated source identity, records mutation intents as first-class @@ -2044,12 +2083,21 @@ plan or needs bounded plan streaming. - [ ] T-40.2 Hierarchical layout codec. - **Requirements:** a new codec (codec 1 has no extension point) with its own depth, fanout, cycle, aggregate, and allocation laws; range - planning across levels; closure accounting extended. + planning across levels; closure accounting extended. The motivation is + concrete: a 100 GiB blob under `fastcdc-64k-v1` is about 1.6 million + chunk identities in one flat record of about 70 MiB, so every range + read admits the whole plan before it can select one chunk. A + fixed-arity tree of layout records gives logarithmic seeks, lets a + range read admit only the subtree it touches, and makes the + layout-level diff in F-48 skip identical subtrees by digest. - **Acceptance criteria:** golden and mutation corpora; the 256 GiB - ceiling lifted to a stated new bound. + ceiling lifted to a stated new bound; a range read of one chunk in a + maximal blob admits at most depth-many layout records. - **Scope:** in — codec 2. Out — changing codec 1. - - **User stories:** Human — a store holds a 1 TiB image. API user — - reads are unchanged. MCP user — none. Agent — none. + - **User stories:** Human — a store holds a 1 TiB model checkpoint. API + user — reads are unchanged; range reads on huge blobs stop paying for + the whole plan. MCP user — none. Agent — an agent diffing two + checkpoints touches only the subtrees that changed. - **Interface:** none new. - **Contract schema:** layout codec 2 record. - **Test plan:** golden; edges — depth exactly at bound; fuzz — decoder. @@ -2407,3 +2455,211 @@ a mutation subset as expected gates; neither runs today. "expected" to "enforced". - **Dependencies:** none; T-46.1 first, since mutation results tell you which uncovered conditions matter. + +### F-47 Hardware-accelerated identity and chunking + +**Status:** Proposed. `blake3` is admitted with only the `pure` and `std` +features so that no unsafe code sits under Keep's own +`#![forbid(unsafe_code)]`; the gear-table FastCDC detector is scalar by +design (ADR-0003 admits vectorized implementations only if they reproduce +every scalar vector). + +- [ ] T-47.1 Optional accelerated adapter behind a feature flag. + - **Requirements:** an `accelerated` Cargo feature that enables BLAKE3's + SIMD backends (NEON, AVX2, AVX-512) and, separately, a vectorized + boundary detector; the canonical output is byte-identical: every + `BlobId`, `ChunkId`, and boundary in every conformance corpus + reproduces exactly under both builds; the unsafe surface lives in the + dependency, never in Keep, and the dependency review in + `docs/dependencies/` names every enabled feature and its audited + version; the pure build stays the default and the one CI proves the + corpora against first. + - **Acceptance criteria:** `cargo xtask conformance-check` and + `golden-file-worldline-check` pass under both feature sets on the + designated runner; the benchmark reports ingest throughput for both, + with the accelerated build measured, not assumed; a mismatch between + builds on any vector is a refusal to admit the feature. + - **Scope:** in — feature flag, dependency review, corpus runs under both + builds, benchmark rows. Out — changing any identity or profile; a + Keep-owned SIMD implementation. + - **User stories:** Human — an operator enables the flag and ingest + runs several times faster with identical identities. API user — no + API change. MCP user — none. Agent — an agent ingesting large corpora + picks the accelerated build once its receipts match the pure build. + - **Interface:** Cargo feature `accelerated`. + - **Contract schema:** none. + - **Test plan:** golden — every corpus under both builds; property — + partition invariance under the vectorized detector; benchmark — both + builds, same corpus, same runner. + - **Definition of done:** feature documented in the README engineering + standard with the identity-equivalence law named. + - **Complexity:** M. + - **Documentation:** dependency review; ADR-0003 consequence satisfied. + - **Dependencies:** F-08, T-09.2. + +### F-48 Layout-level structural diff + +**Status:** Proposed. Everything it needs already exists: two admitted +flat layouts are ordered `ChunkId` sequences with logical offsets, so the +difference between two blobs is a sequence alignment over identities that +never reads a payload byte. + +- [ ] T-48.1 `diff_layouts` over admitted layouts. + - **Requirements:** input is two `AdmittedLayout` values; output is an + ordered list of hunks (`Same { offset_a, offset_b, length }`, + `Insert`, `Delete`, `Replace`) in logical bytes, derived from a + longest-common-subsequence or Myers alignment over `ChunkId` with + checked arithmetic and a documented bound on entry count; identical + layouts produce one `Same` hunk; the result proves only chunk-identity + equality, never byte equality of unequal chunks (a `Replace` says the + chunks differ, not how). + - **Acceptance criteria:** the Golden File Worldline's state A and B + diff to exactly the early-insertion hunk the CDC profile predicts; + property tests over random edits agree with a byte-level diff of the + reconstructed blobs at chunk granularity; no payload read occurs + (instrumented). + - **Scope:** in — a `layout::diff` core module and the reference-store + entry point. Out — byte-level diffs inside a chunk; rendering; a + durable form until F-23 lands. + - **User stories:** Human — a maintainer sees which regions of a 2 GiB + file changed in under a millisecond. API user — + `store.diff(blob_a, blob_b)` returns hunks from the catalog alone. MCP + user — a "diff blobs" tool returns hunks without streaming either + blob. Agent — an agent inspects a repository's edit footprint across + generations without reading payloads. + - **Interface:** `diff_layouts(&AdmittedLayout, &AdmittedLayout) -> + Result`; adapters `keep diff ` and + MCP `keep.diff`. + - **Contract schema:** in-memory `LayoutDiff`; a canonical text form for + the CLI. + - **Test plan:** golden — Worldline A and B; edges — empty against + nonempty, identical, fully disjoint, one-chunk blobs; property — + random insert, delete, replace edits; stress — two maximal plans + within the documented bound. + - **Definition of done:** merged with the Worldline law. + - **Complexity:** S for flat layouts; M once F-40 T-40.2 adds subtree + skipping. + - **Documentation:** reference-store README section. + - **Dependencies:** F-05; F-23 for the durable entry point. + +### F-49 Compact retention inclusion proofs + +**Status:** Proposed. Needs a retention format successor, so it is not a +version-2 feature. + +Today a root's anchor set is committed by one flat digest over up to +65,536 anchors, so proving that one `BlobId` is retained under liveness +generation N means shipping the whole anchor set (up to 7.8 MiB) plus the +manifest and head. A Merkle anchor set would make the proof logarithmic: +a few hundred bytes of sibling digests, the root record header, the +manifest entry, and the head. + +- [ ] T-49.1 Format successor with a Merkle anchor-set digest. + - **Requirements:** the anchor-set digest becomes the root of a + fixed-arity Merkle tree over the sorted anchors, with domain-separated + leaf and node hashing; the root record, manifest, and head bytes stay + otherwise identical; a proof is a canonical record binding the anchor, + its sibling path, the root generation and digest, the manifest + generation and digest, and the head checksum; verification needs no + store access. + - **Acceptance criteria:** golden proof fixtures; a verifier under + `conformance/` that imports no production code; a tampered sibling, + a proof against the wrong generation, and a proof for an absent + anchor each refuse with a typed value; the closure verifier still + admits the new root bytes. + - **Scope:** in — the successor specification, the new anchor-set digest, + proof record, verifier, migration from the flat digest. Out — proving + anything about application meaning; proving bytes exist on disk (the + proof says "retained under this generation", exactly what the head + says). + - **User stories:** Human — an auditor verifies that a release artifact + was retained at generation N from a 1 KiB receipt. API user — + `retain` returns the proof alongside the receipt. MCP user — a + "prove retention" tool returns the proof bytes. Agent — one agent + hands another a proof instead of a store path. + - **Interface:** `prove_retention(snapshot, namespace, anchor)`, + `verify_retention_proof(bytes)`. + - **Contract schema:** proof record in a new format page; the successor + root record in a `segment-store-v3` or a v2 revision, per the ADR. + - **Test plan:** golden; edges — one-anchor set, maximal set, first and + last leaf; known failures — every tamper case; fuzz — proof decoder. + - **Definition of done:** ADR Accepted; proof verifier in conformance. + - **Complexity:** L. + - **Documentation:** ADR; format page; ADR-0009 consequence. + - **Dependencies:** F-19 (the proof names a fenced view), F-43 T-43.1 + (successor policy). + +### F-50 Untrusted chunk transport + +**Status:** Proposed; a moonshot. Blocked on the F-44 decision record. + +Because every chunk is self-authenticating (`ChunkId` is a hash of the +bytes), a chunk can be fetched from any peer, cache, or neighbouring agent +and admitted only after Keep re-hashes it. A corrupt or malicious peer +cannot get bytes into the store; at worst it wastes bandwidth. That makes a +zero-trust chunk swarm for build farms and multi-agent clusters a +transport problem, not a trust problem. + +- [ ] T-50.1 Inbound chunk admission port and one transport adapter. + - **Requirements:** a port that accepts `(ChunkId, bytes)` from an + untrusted source and admits only on exact identity, with a bounded + in-flight window and a per-source refusal budget; a "want list" + derived from a layout minus the local catalog; one reference + transport (HTTP or gRPC over a LAN) in an adapter crate; no peer ever + influences identity, layout, retention, or publication order. + - **Acceptance criteria:** a peer returning wrong bytes for a `ChunkId` + is refused before staging and counted against its budget; a full + blob assembled from two peers reconstructs to its `BlobId`; the + transport adapter imports no Keep internals beyond the port. + - **Scope:** in — the port, the want-list derivation, one adapter. Out + — peer discovery, incentives, encryption on the wire (use the + transport's), and any multi-writer semantics (F-44). + - **User stories:** Human — a build farm warms every worker from its + neighbours. API user — `fetch_missing(layout, peers)`. MCP user — + none. Agent — an agent pulls dependencies from a sibling agent's + cache without trusting it. + - **Interface:** trait `ChunkSource`; `fetch_missing`. + - **Contract schema:** wire format for want lists and chunk frames, + versioned. + - **Test plan:** golden — wire fixtures; adversarial — wrong bytes, + truncated bytes, oversized bytes, duplicate delivery; concurrency — + two sources racing on one chunk; stress — the in-flight window at its + bound. + - **Definition of done:** adapter crate merged behind the port. + - **Complexity:** XL. + - **Documentation:** ADR for the trust model; adapter README. + - **Dependencies:** F-24, F-44 decision; F-49 for retention claims that + cross stores. + +### F-51 Kernel-bypass ingestion adapter + +**Status:** Proposed; a moonshot. Linux only. + +A specialized adapter that streams bytes from a socket or block device +through the chunk detector into an immutable segment with as few user-space +copies as the identity computation allows: `io_uring` for submission, +`splice(2)` or `copy_file_range(2)` for chunk bodies that are already on +disk. Hashing still has to see every byte, so "zero-copy" means one pass +for identity and no second copy for storage. + +- [ ] T-51.1 Feasibility and unsafe-boundary decision. + - **Requirements:** measure the copies the T-24.2 path makes today; + decide whether an `io_uring` adapter can live in a separate crate with + an explained unsafe allowance (ADR-0006 is the precedent for a single + admitted unsafe boundary) or through a safe wrapper crate with its own + dependency review; state what the adapter cannot change: identity, + chunk boundaries, publication order, crash points. + - **Acceptance criteria:** a rationale note with the measured baseline + and the decision; if pursued, the adapter passes the same crash + matrix as the ordinary writer. + - **Scope:** in — the decision and measurement. Out — any adapter + before T-24.2 exists. + - **User stories:** Human — an operator ingests from a 100 GbE socket + at line rate. Others — no API change. + - **Interface:** none until decided. + - **Contract schema:** none. + - **Test plan:** benchmark before and after; crash matrix. + - **Definition of done:** rationale merged. + - **Complexity:** M for the decision; XL for the adapter. + - **Documentation:** rationale; dependency review. + - **Dependencies:** T-24.2, T-41.3. From 68b81eedfea6c30ad54ab8d1f459a741e6ff82b8 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 08:44:49 -0700 Subject: [PATCH 13/59] Fix: compare only restart-stable root coordinates on version-two reopen Problem: the migration intent persists three root coordinates, and reopen compared all three against the reopened root. One of them, statx.stx_mnt_id, names a mount instance and changes on every unmount, remount, and reboot, so a correctly remounted store refused with RootIdentityChanged { coordinate: Mount, .. }, and partial-prefix migration recovery (KEEP-MIGRATION-004) would have rejected the store's own intent after a reboot (#97). Decision: the restart-stable root identity is the (device, file) pair. FilesystemVersionTwoAdmission::reopen compares device and inode only. FilesystemStoreMigrationAuthority still compares all three, but only against the observation it made itself in the same process, which is the check that catches a root swapped under a running migration. The intent bytes are unchanged, so no fixture, digest, or marker moves. BoundRootIdentity no longer carries the mount value because nothing may read it. Rejected: dropping the field (the definition digest covers the layout, so every version-2 fixture would re-derive for no restart-safety gain); the filesystem UUID (stronger under device-mapper renumbering but needs FS_IOC_GETFSUUID on Linux 6.5 or superblock parsing; recorded as the successor coordinate). recovery.md states the rule and the dev_t limit; rationale.md records the alternatives. Evidence: reopen_admits_a_remounted_root_and_refuses_a_moved_one asserts that a changed mount id with the same device and inode admits, and that a changed inode or device refuses with the exact coordinate. Against the previous comparison the remount assertion fails ("a remounted root ... must reopen"); against this change the version-two admission, migration, and complete keep suites pass. Closes #97. ROADMAP T-17.1 checked; README gap-table row removed. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 10 +++++++ README.md | 7 ++--- ROADMAP.md | 8 +++-- docs/formats/segment-store-v2/README.md | 7 +++-- docs/formats/segment-store-v2/rationale.md | 20 +++++++++++++ docs/formats/segment-store-v2/recovery.md | 30 +++++++++++++++++++ docs/formats/segment-store-v2/requirements.md | 4 +-- .../filesystem_platform_admission_error.rs | 3 ++ .../filesystem_version_two_admission.rs | 15 ++++------ .../filesystem_version_two_records.rs | 20 +++++-------- .../filesystem_version_two_admission_tests.rs | 21 ++++++------- .../filesystem_migration_authority_error.rs | 7 ++++- 12 files changed, 110 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fab820e..90db55fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -252,6 +252,16 @@ after its public API and format compatibility policies are established. ### Changed +- Version-two reopen compares only the restart-stable root coordinates, the + device and the root inode, against the migration intent. The mount identity + the intent records is `statx.stx_mnt_id`, a mount instance that changes on + every unmount, remount, and reboot; comparing it on reopen made a correctly + remounted store refuse with `RootIdentityChanged { coordinate: Mount, .. }` + and would have made partial-prefix migration recovery reject the store's + own intent after a reboot. The migrating process still compares all three + coordinates against its own observation. The intent bytes are unchanged; + `recovery.md` states the rule and its `dev_t` limit, and `rationale.md` + records the rejected alternatives. Closes #97. - The living `keep.segment-store/v1` pages describe `main`: initialization, platform admission, explicit recovery, and the crash matrix are stated as implemented in issue #17 instead of owned by it; the publication page names diff --git a/README.md b/README.md index 51b601ca..97f69915 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,9 @@ Keep is required to refuse all three, before mutating anything. roots, deterministic closure verification, a one-way 21-phase migration, and a 17-phase retention publication — all with production filesystem writers, all preserving every version-1 byte. Reopening a migrated store - jointly admits its marker, intent, and receipt, binds the root's device, - mount, and inode identity to the intent, and pins the directories it - admitted. Publication binds this store's own catalog head and the catalog + jointly admits its marker, intent, and receipt, binds the root's + restart-stable device and inode identity to the intent (a remounted store + admits; a moved one refuses), and pins the directories it admitted. Publication binds this store's own catalog head and the catalog it selects, and refuses retained stages, superseded candidates, substituted files, replaced protocol directories, and every namespace or capacity violation before it writes anything. Each refusal is a typed value, not a @@ -78,7 +78,6 @@ admitted until its owner migrates it; migrate only if you accept that wait. | Gap | Tracked | | --- | --- | | Restart recovery for retention publication and migration | [#19](https://github.com/flyingrobots/keep/issues/19) | -| Restart-stable root identity coordinate in the migration intent | [#97](https://github.com/flyingrobots/keep/issues/97) | | Reader fence binding one consistent catalog + retention snapshot | [#19](https://github.com/flyingrobots/keep/issues/19) | | Precise verification reports at explicit depths | [#20](https://github.com/flyingrobots/keep/issues/20) | | Garbage collection and identity-preserving compaction | [#21](https://github.com/flyingrobots/keep/issues/21) | diff --git a/ROADMAP.md b/ROADMAP.md index 3e34967d..c3206a1e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -95,7 +95,7 @@ names; use those in code, tests, and commits. - [x] [F-15 Retention roots, release, and GC liveness model](#f-15-retention-roots-release-and-gc-liveness-model) — Done (ADR-0009) - [x] [F-16 Version-2 format records and codecs](#f-16-version-2-format-records-and-codecs) — Done -- [ ] [F-17 One-way migration from version 1 to version 2](#f-17-one-way-migration-from-version-1-to-version-2) — Partial (#97 and residual #19) +- [ ] [F-17 One-way migration from version 1 to version 2](#f-17-one-way-migration-from-version-1-to-version-2) — Partial (#97 done on this branch; residual #19) - [ ] [F-18 Retention publication](#f-18-retention-publication) — Partial; recovery in review (PR #99) - [ ] [F-19 Reader fence and immutable version-2 snapshots](#f-19-reader-fence-and-immutable-version-2-snapshots) — In review (PR #99) - [ ] [F-20 Model-based retention transition evidence](#f-20-model-based-retention-transition-evidence) — In review (PR #99) @@ -702,7 +702,11 @@ Direct version-2 initialization is undefined. There is no downgrade. `StoreMigrationPhase::ALL`, `FilesystemStoreMigrationAuthority`, `FilesystemStoreMigrationInventoryReader`; `FilesystemVersionTwoAdmission::reopen`. -- [ ] T-17.1 Restart-stable root identity coordinate (#97). +- [x] T-17.1 Restart-stable root identity coordinate (#97) — decided as + the `(device, file)` pair with the mount id as same-process evidence; + bytes unchanged; `recovery.md` "Root identity across restart", + `rationale.md`, and the remount law in + `filesystem_version_two_admission_tests`. Original task fields: - **Requirements:** the migration intent stops depending on `statx.stx_mnt_id`, which changes across unmount, remount, and reboot; either the intent format drops the mount coordinate (a format revision diff --git a/docs/formats/segment-store-v2/README.md b/docs/formats/segment-store-v2/README.md index 1f27b80d..0f70d682 100644 --- a/docs/formats/segment-store-v2/README.md +++ b/docs/formats/segment-store-v2/README.md @@ -97,8 +97,11 @@ violation before mutation, each as a typed `RetentionCurrentStateRefusal`. Not implemented: retention publication recovery and `KEEP-CRASH-036..052` process-death evidence, partial-prefix migration recovery and `KEEP-CRASH-053..073`, the reader fence, model-based transition evidence, and -garbage collection. Issue #19 owns the first four and issue #21 the last; -issue #97 owns the restart-stable root identity coordinate. A version-1 store +garbage collection. Issue #19 owns the first four and issue #21 the last. +Reopen compares only the restart-stable root coordinates, device and inode, +against the intent; see +[root identity across restart](recovery.md#root-identity-across-restart). A +version-1 store remains admitted until its owner migrates it, and the [requirements ledger](requirements.md) is the authority on which requirements are proven. diff --git a/docs/formats/segment-store-v2/rationale.md b/docs/formats/segment-store-v2/rationale.md index 48213b12..aab8a1b6 100644 --- a/docs/formats/segment-store-v2/rationale.md +++ b/docs/formats/segment-store-v2/rationale.md @@ -96,3 +96,23 @@ would mutate the exact version-2 root grammar. Accepting placeholder bytes was also rejected. Version 2 reserves the names, while their presence remains an unsupported mandatory state until issue #21 supplies complete byte, parser, crash, recovery, corruption, and fuzz evidence. + +## Compare restart-stable root coordinates on reopen + +The migration intent records the root's device, mount, and inode identity. +Only the first and last survive a remount: `statx.stx_mnt_id` is a mount +instance, not a volume. Comparing it on reopen made a correctly remounted +store refuse and would have made partial-prefix migration recovery reject +the store's own intent after a reboot. + +Reopen and every restart path therefore compare device and inode only, while +the migrating process still compares all three against its own observation. +The intent bytes are unchanged. + +Rejected: removing the mount field. The definition digest covers the intent +layout, so the change would re-derive the format marker, the receipt, and +every version-2 fixture for no gain in restart safety. Rejected: the +filesystem UUID as the device coordinate. It is stronger than `dev_t` under +device-mapper renumbering, but reading it needs `FS_IOC_GETFSUUID` (Linux +6.5) or superblock parsing, and no admitted platform has shown `dev_t` to be +unstable; it is the recorded successor coordinate if one does. diff --git a/docs/formats/segment-store-v2/recovery.md b/docs/formats/segment-store-v2/recovery.md index efa539bc..263c79b1 100644 --- a/docs/formats/segment-store-v2/recovery.md +++ b/docs/formats/segment-store-v2/recovery.md @@ -139,6 +139,36 @@ identity, caller identity, path, and time do not enter the identifier. The migration intent separately binds the physical root coordinates so in-place recovery refuses a substituted store. +### Root identity across restart + +The three root coordinates the intent records do not have the same lifetime. +`statx.stx_mnt_id` names a mount instance: it changes on every unmount, +remount, and reboot, so a store that is merely remounted would refuse if any +restart path compared it. The device and inode coordinates name the volume +and the root directory and survive remounts on the admitted platform. + +The restart-stable root identity is therefore the pair `(device, file)`: + +- `FilesystemStoreMigrationAuthority` compares all three coordinates, but + only against the observation it made itself when it opened the root in the + same process; that comparison catches a root swapped underneath a running + migration and never crosses a restart. +- `FilesystemVersionTwoAdmission::reopen`, and every recovery path that + compares a persisted intent against a reopened root, compare device and + file only and refuse with `RootIdentityChanged { coordinate: Device | File, + .. }`. A remounted store admits; a store copied to another device or + restored into a different directory refuses. + +The mount coordinate stays in the record as the migration-time observation. +Its bytes are not authority for any later decision. + +Limit: `dev_t` is stable across reboots only while the block device keeps its +major and minor numbers. A device-mapper or hot-plug renumbering makes a +correct store refuse with `RootIdentityChanged { coordinate: Device, .. }`; +version 2 defines no re-admission for that case, and a successor coordinate +(the filesystem UUID) is the rationale's recorded alternative if it proves +necessary. + `migration.receipt` is exactly 256 bytes: diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index b780bf7b..912134fb 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -30,8 +30,8 @@ case is not evidence. | --- | --- | --- | --- | | `KEEP-MIGRATION-001` | Exact version-1 stores remain admitted until a durable migration artifact exists | compatibility fixtures | Planned in #19 | | `KEEP-MIGRATION-002` | Format marker, intent, and receipt have complete fixed byte tables, named domains, bounds, checksums, deterministic store identity, and exact initial-state digests | exact admission in `tests/store_format_marker.rs`, `tests/store_migration_intent.rs`, and `tests/store_migration_receipt.rs`; canonical construction in `tests/store_migration_intent_encoding.rs` and `tests/store_migration_receipt_encoding.rs`; seeded `migration_format` fuzz target | Implemented | -| `KEEP-MIGRATION-003` | Migration revalidates version-1 head, catalog, pools, root identity, and writer authority before mutation | bounded canonical pool inventory in `tests/store_migration_inventory.rs`; writer-locked filesystem pool admission in `filesystem_inventory_*_tests`; exact authority observation and drift refusal in `filesystem_migration_authority_tests`; verification-first execution in `tests/store_migration_execution.rs`; fresh filesystem integration and post-publication drift refusal in `filesystem_migration_storage_tests`; a version-one store still holding a retained stage refuses before the intent is observed in `filesystem_migration_storage_tests` | Implemented | -| `KEEP-MIGRATION-004` | Every partial migration prefix continues idempotently under writer authority | state-machine and recovery tests | Planned in #19 | +| `KEEP-MIGRATION-003` | Migration revalidates version-1 head, catalog, pools, root identity (all three coordinates within the migrating process; device and file across restart), and writer authority before mutation | bounded canonical pool inventory in `tests/store_migration_inventory.rs`; writer-locked filesystem pool admission in `filesystem_inventory_*_tests`; exact authority observation and drift refusal in `filesystem_migration_authority_tests`; verification-first execution in `tests/store_migration_execution.rs`; fresh filesystem integration and post-publication drift refusal in `filesystem_migration_storage_tests`; a version-one store still holding a retained stage refuses before the intent is observed in `filesystem_migration_storage_tests` | Implemented | +| `KEEP-MIGRATION-004` | Every partial migration prefix continues idempotently under writer authority, comparing only the restart-stable root coordinates (device and file) against the persisted intent | restart-stable reopen law in `filesystem_version_two_admission_tests`; state-machine and recovery tests remain | Planned in #19 | | `KEEP-MIGRATION-005` | Unknown, out-of-order, substituted, corrupt, conflicting, or changed evidence is unrecoverable ambiguity | forward-execution stage preservation, byte-equal inode-substitution, out-of-order-prefix, and post-publication drift laws in `filesystem_migration_storage_tests`; unknown `retention` entries, non-digest namespace directories, and noncanonical pool names refuse before any retention stage is written in `filesystem_retention_namespace_tests`; restart corruption and mutation matrix remains | In progress in #19 | | `KEEP-MIGRATION-006` | Migration never rewrites or deletes admitted version-1 immutable bytes | exact segment, catalog, and head before/after witness in `filesystem_migration_storage_tests`; restart-path evidence remains | In progress in #19 | | `KEEP-MIGRATION-007` | Process death around every intent stage, canonical link, namespace prefix, marker stage, receipt stage, cleanup, and synchronization boundary reaches a documented lawful state | ordered phases and capabilities in `tests/store_migration_phase.rs` and `tests/store_migration_storage.rs`; exact phase-failure execution in `tests/store_migration_execution.rs`; production 21-phase forward execution in `filesystem_migration_storage_tests`; `KEEP-CRASH-053..=073` process-death matrix remains | In progress in #19 | diff --git a/src/adapters/filesystem_platform_admission_error.rs b/src/adapters/filesystem_platform_admission_error.rs index d824d82e..62793eb1 100644 --- a/src/adapters/filesystem_platform_admission_error.rs +++ b/src/adapters/filesystem_platform_admission_error.rs @@ -31,6 +31,9 @@ pub enum FilesystemPlatformAdmissionError { source: io::Error, }, /// The reopened root's physical identity is not the one the migration intent bound. + /// + /// Reopen compares the restart-stable device and file coordinates only; a + /// remounted store admits, so this variant never names `Mount`. RootIdentityChanged { /// The coordinate that disagreed. coordinate: StoreRootIdentityCoordinate, diff --git a/src/adapters/filesystem_version_two_admission.rs b/src/adapters/filesystem_version_two_admission.rs index 2303c5a7..7260ea32 100644 --- a/src/adapters/filesystem_version_two_admission.rs +++ b/src/adapters/filesystem_version_two_admission.rs @@ -102,10 +102,12 @@ fn pin(parent: &Dir, name: &str) -> Result Self { - Self { - device, - mount, - file, - } + /// Binds the two restart-stable root coordinates the intent persists. + /// + /// The intent also records the mount identity observed during migration, + /// but `statx.stx_mnt_id` changes across unmount, remount, and reboot, so + /// no reopen path reads it; the migration authority compares it only + /// inside the process that observed it. + pub(super) const fn new(device: u64, file: u64) -> Self { + Self { device, file } } pub(super) const fn device(self) -> u64 { self.device } - pub(super) const fn mount(self) -> u64 { - self.mount - } - pub(super) const fn file(self) -> u64 { self.file } @@ -69,7 +66,6 @@ pub(super) fn admit(root: &Dir) -> io::Result { .map_err(|source| Refusal::Receipt { source }.into_io())?; Ok(BoundRootIdentity::new( intent.root_device_identity().get(), - intent.root_mount_identity().get(), intent.root_file_identity().get(), )) } diff --git a/src/adapters/retention/filesystem_version_two_admission_tests.rs b/src/adapters/retention/filesystem_version_two_admission_tests.rs index a5e86c67..9649bef5 100644 --- a/src/adapters/retention/filesystem_version_two_admission_tests.rs +++ b/src/adapters/retention/filesystem_version_two_admission_tests.rs @@ -123,11 +123,18 @@ fn production_version_two_reopen_admits_an_exact_migrated_store() -> Result<(), Ok(()) } +/// A remount changes `statx.stx_mnt_id` but neither the device nor the root +/// inode; reopen must admit that store. A different inode on the same device +/// or a different device is a relocated or restored store and refuses. #[test] -fn reopened_root_identity_must_match_the_intent_coordinates() { - let bound = BoundRootIdentity::new(1, 2, 3); +fn reopen_admits_a_remounted_root_and_refuses_a_moved_one() { + let bound = BoundRootIdentity::new(1, 3); assert!(require_root_identity(bound, FilesystemRootIdentity::new(1, 2, 3)).is_ok()); + assert!( + require_root_identity(bound, FilesystemRootIdentity::new(1, 7, 3)).is_ok(), + "a remounted root (same device and inode, new mount id) must reopen" + ); assert!(matches!( require_root_identity(bound, FilesystemRootIdentity::new(1, 2, 4)), Err(FilesystemPlatformAdmissionError::RootIdentityChanged { @@ -140,14 +147,8 @@ fn reopened_root_identity_must_match_the_intent_coordinates() { require_root_identity(bound, FilesystemRootIdentity::new(9, 2, 3)), Err(FilesystemPlatformAdmissionError::RootIdentityChanged { coordinate: StoreRootIdentityCoordinate::Device, - .. - }) - )); - assert!(matches!( - require_root_identity(bound, FilesystemRootIdentity::new(1, 7, 3)), - Err(FilesystemPlatformAdmissionError::RootIdentityChanged { - coordinate: StoreRootIdentityCoordinate::Mount, - .. + expected: 1, + observed: 9, }) )); } diff --git a/src/adapters/store_migration/filesystem_migration_authority_error.rs b/src/adapters/store_migration/filesystem_migration_authority_error.rs index 4e986016..d6628a6c 100644 --- a/src/adapters/store_migration/filesystem_migration_authority_error.rs +++ b/src/adapters/store_migration/filesystem_migration_authority_error.rs @@ -21,11 +21,16 @@ pub enum FilesystemMigrationAuthorityArtifact { } /// Physical store-root coordinate compared during migration admission. +/// +/// `Device` and `File` are restart-stable and are compared on every reopen of +/// a migrated store. `Mount` names a mount instance that changes across +/// unmount, remount, and reboot; only the migrating process compares it, and +/// only against the observation it made itself. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum StoreRootIdentityCoordinate { /// Platform device coordinate. Device, - /// Platform mount coordinate. + /// Platform mount coordinate; same-process evidence only. Mount, /// Platform file coordinate. File, From e4fb098a811bfe18d10edfaef154750a108ef9e8 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 09:04:11 -0700 Subject: [PATCH 14/59] Feat: canonical GC retirement intent and receipt codecs Problem: keep.segment-store/v2 froze the GcRetirementIntent and GcRetirementReceipt grammars (gc.md, definition.tsv) but nothing could encode, decode, or admit them; KEEP-GC-001 had no executable evidence and GC planning (#21) had no record to write before its first unlink. Approach: a core gc module owns the checked GcGeneration. An adapters::gc module owns the semantic GcRetirementIntent (a canonical, duplicate-free, digest-ordered candidate set of at most 65,536 segments over typed liveness, catalog, profile, pool, disposition, and reader-lock coordinates), its canonical encoder and admitting decoder with checksum-first, intent-digest-second, candidate-set-digest-third integrity, and the receipt codec that binds every revalidated coordinate to the admitted intent. The receipt's synchronization count is defined as one per candidate, the pool-directory sync after each unlink; gc.md states this. The reader-lock mount coordinate is documented as same-process evidence, consistent with the root identity decision in #97. Not in this change: the RecoveryDispositionReceipt codec. Its artifact kind, decision, and classification fields are "registered enumerations" with no registered values; registering them adds definition.tsv rows and therefore re-derives the format-definition digest, the FORMAT marker, the migration receipt, and every version-2 fixture. That is an owner decision; ROADMAP T-22.1a records it. Namespace admission still refuses every GC record on disk. Evidence: the independent xtask oracle constructs one-candidate-gc-intent.hex and one-candidate-gc-receipt.hex from accepted version-1 and version-2 fixtures at fixed offsets, without touching the definition digest (ORIGIN.md records the inputs). Production tests decode both fixtures, re-encode them byte for byte, and pin one exact first refusal per header, body, and trailer field plus reframed empty, duplicate, descending, and 65,537-candidate cases. Disabling the candidate-set digest check fails "mutated candidate-set digest was admitted"; disabling the synchronization-count binding fails "mutated synchronization count was admitted". The gc_format fuzz target is seeded from both fixtures with the receipt framed behind its intent; the xtask seed, campaign, oracle, conformance-shape, and parser-fuzz laws pass. The complete keep suite passes. Ledger: KEEP-GC-001 moves to In progress in #21. ROADMAP T-22.1 checked. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 17 + ROADMAP.md | 36 +- conformance/segment-store/v2/ORIGIN.md | 21 ++ conformance/segment-store/v2/README.md | 17 +- conformance/segment-store/v2/artifacts.tsv | 2 + .../v2/one-candidate-gc-intent.hex | 1 + .../v2/one-candidate-gc-receipt.hex | 1 + docs/formats/segment-store-v2/gc.md | 21 +- docs/formats/segment-store-v2/requirements.md | 2 +- fuzz/Cargo.toml | 7 + fuzz/README.md | 5 + fuzz/fuzz_targets/gc_format.rs | 46 +++ src/adapters/exports.rs | 1 + src/adapters/gc/admitted_intent.rs | 67 ++++ src/adapters/gc/admitted_receipt.rs | 48 +++ src/adapters/gc/candidate.rs | 48 +++ src/adapters/gc/canonical_intent.rs | 67 ++++ src/adapters/gc/canonical_receipt.rs | 54 +++ src/adapters/gc/evidence_digests.rs | 56 +++ src/adapters/gc/intent.rs | 93 +++++ src/adapters/gc/intent_candidate_decoder.rs | 29 ++ src/adapters/gc/intent_coordinates.rs | 40 ++ src/adapters/gc/intent_decode_error.rs | 144 ++++++++ .../gc/intent_decode_error_display.rs | 96 +++++ src/adapters/gc/intent_decoder.rs | 34 ++ src/adapters/gc/intent_encoder.rs | 89 +++++ src/adapters/gc/intent_error.rs | 51 +++ src/adapters/gc/intent_field_decoder.rs | 93 +++++ src/adapters/gc/intent_format.rs | 47 +++ src/adapters/gc/intent_header_decoder.rs | 98 +++++ src/adapters/gc/intent_integrity.rs | 54 +++ src/adapters/gc/intent_semantic_header.rs | 54 +++ src/adapters/gc/mod.rs | 53 +++ src/adapters/gc/reader_lock_identity.rs | 56 +++ src/adapters/gc/receipt.rs | 104 ++++++ src/adapters/gc/receipt_bytes.rs | 44 +++ src/adapters/gc/receipt_decode_error.rs | 174 +++++++++ src/adapters/gc/receipt_decoder.rs | 171 +++++++++ src/adapters/gc/receipt_encoder.rs | 47 +++ src/adapters/gc/receipt_format.rs | 17 + src/adapters/gc/record_digests.rs | 35 ++ src/adapters/mod.rs | 1 + src/gc/generation.rs | 49 +++ src/gc/generation_error.rs | 30 ++ src/gc/mod.rs | 10 + src/lib.rs | 11 + tests/gc_retirement_intent.rs | 102 ++++++ tests/gc_retirement_intent/mutation_laws.rs | 341 ++++++++++++++++++ tests/gc_retirement_receipt.rs | 317 ++++++++++++++++ xtask/src/fuzz_campaign/target/tests.rs | 1 + xtask/src/fuzz_seed_corpus.rs | 2 + xtask/src/fuzz_seed_corpus/gc_seeds.rs | 80 ++++ .../fuzz_seed_corpus/tests/materialization.rs | 10 +- ...retention_store_v2_conformance_contract.rs | 2 + .../tests/retention_store_v2_format_oracle.rs | 5 + .../artifacts.rs | 5 + .../artifacts/gc.rs | 135 +++++++ .../parser_fuzz_laws.rs | 23 ++ 58 files changed, 3252 insertions(+), 12 deletions(-) create mode 100644 conformance/segment-store/v2/one-candidate-gc-intent.hex create mode 100644 conformance/segment-store/v2/one-candidate-gc-receipt.hex create mode 100644 fuzz/fuzz_targets/gc_format.rs create mode 100644 src/adapters/gc/admitted_intent.rs create mode 100644 src/adapters/gc/admitted_receipt.rs create mode 100644 src/adapters/gc/candidate.rs create mode 100644 src/adapters/gc/canonical_intent.rs create mode 100644 src/adapters/gc/canonical_receipt.rs create mode 100644 src/adapters/gc/evidence_digests.rs create mode 100644 src/adapters/gc/intent.rs create mode 100644 src/adapters/gc/intent_candidate_decoder.rs create mode 100644 src/adapters/gc/intent_coordinates.rs create mode 100644 src/adapters/gc/intent_decode_error.rs create mode 100644 src/adapters/gc/intent_decode_error_display.rs create mode 100644 src/adapters/gc/intent_decoder.rs create mode 100644 src/adapters/gc/intent_encoder.rs create mode 100644 src/adapters/gc/intent_error.rs create mode 100644 src/adapters/gc/intent_field_decoder.rs create mode 100644 src/adapters/gc/intent_format.rs create mode 100644 src/adapters/gc/intent_header_decoder.rs create mode 100644 src/adapters/gc/intent_integrity.rs create mode 100644 src/adapters/gc/intent_semantic_header.rs create mode 100644 src/adapters/gc/mod.rs create mode 100644 src/adapters/gc/reader_lock_identity.rs create mode 100644 src/adapters/gc/receipt.rs create mode 100644 src/adapters/gc/receipt_bytes.rs create mode 100644 src/adapters/gc/receipt_decode_error.rs create mode 100644 src/adapters/gc/receipt_decoder.rs create mode 100644 src/adapters/gc/receipt_encoder.rs create mode 100644 src/adapters/gc/receipt_format.rs create mode 100644 src/adapters/gc/record_digests.rs create mode 100644 src/gc/generation.rs create mode 100644 src/gc/generation_error.rs create mode 100644 src/gc/mod.rs create mode 100644 tests/gc_retirement_intent.rs create mode 100644 tests/gc_retirement_intent/mutation_laws.rs create mode 100644 tests/gc_retirement_receipt.rs create mode 100644 xtask/src/fuzz_seed_corpus/gc_seeds.rs create mode 100644 xtask/tests/retention_store_v2_format_oracle/artifacts/gc.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 90db55fd..55f9aff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,23 @@ after its public API and format compatibility policies are established. ### Added +- Canonical codecs for the version-2 `GcRetirementIntent` and + `GcRetirementReceipt` records. `GcRetirementIntent` admits a canonical, + duplicate-free, digest-ordered candidate set of at most 65,536 segments + over its liveness, catalog, profile, pool, disposition, and reader-lock + coordinates; `CanonicalGcRetirementIntent` and `AdmittedGcRetirementIntent` + encode and admit it with checksum-first, digest-second, candidate-set + third integrity; `CanonicalGcRetirementReceipt` and + `AdmittedGcRetirementReceipt` bind a receipt to its admitted intent + coordinate by coordinate, with a synchronization count of exactly one per + candidate. `GcGeneration` is the checked retirement generation. The + independent oracle constructs `one-candidate-gc-intent.hex` and + `one-candidate-gc-receipt.hex` from accepted version-1 and version-2 + fixtures without touching the definition digest; the `gc_format` fuzz + target is seeded from them. Namespace admission still refuses every GC + record on disk. `KEEP-GC-001` moves to In progress; the + `RecoveryDispositionReceipt` codec waits for its enumerations to be + registered in `definition.tsv`. - Field-by-field corruption matrices for the version-2 retention root, manifest, and head decoders. Every header, body, and trailer field has one sealed mutation whose digests and checksum are recomputed around it, so diff --git a/ROADMAP.md b/ROADMAP.md index c3206a1e..d1d38d3f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1116,7 +1116,9 @@ quarantines, or rewrites physical state. ### F-22 Garbage collection, compaction, and recovery dispositions **Status:** Planned (#21, P1, M4). Grammars are frozen and their presence -refuses (`KEEP-GC-001`, `-002`). +refuses (`KEEP-GC-001`, `-002`). The intent and receipt codecs landed on +this branch (T-22.1); the disposition codec waits on an enumeration +decision (T-22.1a). Plan GC from an immutable liveness snapshot; classify every segment as live, unreachable, corrupt, ambiguous, recovery-protected, @@ -1130,7 +1132,37 @@ canonical order with a directory sync after every one; publish publication stays recovery-protected until an explicit finalize-or-retire disposition receipt exists. `BlobId`, `ChunkId`, and `LayoutId` never move. -- [ ] T-22.1 GC record codecs and namespace admission (`KEEP-GC-001`). +- [x] T-22.1 GC intent and receipt codecs (`KEEP-GC-001`, in progress) — + `src/adapters/gc/`, `tests/gc_retirement_intent.rs`, + `tests/gc_retirement_receipt.rs`, `fuzz/fuzz_targets/gc_format.rs`, + fixtures `one-candidate-gc-intent.hex` and `one-candidate-gc-receipt.hex`. + Original task fields: +- [ ] T-22.1a Register the disposition enumerations and ship its codec. + - **Requirements:** `gc.md` says the artifact kind, decision, and + classification fields are "registered" enumerations but names no + values; freezing them means adding rows to `definition.tsv`, which + changes the format-definition digest, the `FORMAT` marker bytes, the + migration receipt, and every version-2 fixture. Decide the values + (kinds: segment, catalog, root, manifest, head stage; decisions: + finalize, retire; classifications: the recovery classes of + `recovery.md`), regenerate the corpus through the oracle, and add the + codec with the same golden, mutation, and fuzz evidence as the intent. + - **Acceptance criteria:** `definition.tsv` rows for each enumeration; a + new definition digest recorded in `README.md` and `ORIGIN.md`; + `RecoveryDispositionReceipt` codec with a fixture; `KEEP-GC-001` + Implemented. + - **Scope:** in — the decision, the corpus regeneration, the codec. + Out — disposition execution (T-22.5). + - **User stories:** as T-22.1. + - **Interface:** `CanonicalRecoveryDispositionReceipt`, + `AdmittedRecoveryDispositionReceipt`. + - **Contract schema:** per `gc.md` with the enumerations filled in. + - **Test plan:** golden; mutation per field; unknown enumeration values + refuse; fuzz through `gc_format`. + - **Definition of done:** ledger row Implemented. + - **Complexity:** M (the corpus ripple is the work). + - **Documentation:** `gc.md`, corpus README and ORIGIN, CHANGELOG. + - **Dependencies:** an owner decision on the enumeration values. - **Requirements:** `GcRetirementIntent` (320-byte header, 72-byte candidates, at most 65,536, digest, checksum), `GcRetirementReceipt` (320 bytes), `RecoveryDispositionReceipt` (320 bytes) encode and decode diff --git a/conformance/segment-store/v2/ORIGIN.md b/conformance/segment-store/v2/ORIGIN.md index 305c4d23..8c8b9291 100644 --- a/conformance/segment-store/v2/ORIGIN.md +++ b/conformance/segment-store/v2/ORIGIN.md @@ -65,3 +65,24 @@ Changing any fixture requires a deliberate specification change, an updated definition or profile digest when affected, fresh independent construction, and review of every dependent migration and retention coordinate. A fixture is never regenerated to make a production implementation pass. + +## GC record addition + +The GC retirement intent and receipt fixtures were added on 2026-09-30 with +`rustc 1.98.1 (48a229cea 2026-09-01)` and `cargo 1.98.1`. They import exact +bytes only from these previously accepted fixtures, at fixed offsets: + +- `conformance/segment-store/v1/one-zero-segment.hex` (segment digest at + 273, record checksum at 177); +- `conformance/segment-store/v1/one-zero-catalog-generation-two.hex` + (catalog digest at 320); +- `conformance/segment-store/v1/one-zero-head-generation-two.hex` (head + checksum at 96); +- `conformance/segment-store/v1/empty-segment.hex` (segment digest at 128); +- the version-2 manifest digest, inventory digest, and profile digest the + oracle already constructs. + +The definition digest is unchanged: no definition row was added, because +both grammars were already frozen in `definition.tsv`. The same temporary, +removed write path materialized the two `.hex` files and the `artifacts.tsv` +rows; the committed oracle is read-only and rejects drift. diff --git a/conformance/segment-store/v2/README.md b/conformance/segment-store/v2/README.md index 7417eb21..b668d30d 100644 --- a/conformance/segment-store/v2/README.md +++ b/conformance/segment-store/v2/README.md @@ -20,6 +20,8 @@ migration, retention transition, or garbage collector exists. | `one-anchor-root.hex` | Generation-1 root with one nontext namespace | | `one-root-manifest.hex` | Generation-1 one-namespace manifest | | `one-root-head.hex` | Generation-1 retention head | +| `one-candidate-gc-intent.hex` | Generation-1 GC retirement intent naming one candidate | +| `one-candidate-gc-receipt.hex` | Generation-1 GC retirement receipt completing that intent | | `ORIGIN.md` | Construction provenance and verification boundary | Every text file uses UTF-8 or ASCII, LF line endings, and one final newline. @@ -27,8 +29,9 @@ Every hex fixture is one lowercase hexadecimal line with one final newline. In `artifacts.tsv`, `bound_digest_hex` is the marker content digest for `format-marker`, the intent digest for `migration-intent`, the referenced intent digest for `migration-receipt`, the canonical record digest for -`retention-root` and `retention-manifest`, and the referenced manifest digest -for `retention-head`. +`retention-root` and `retention-manifest`, the referenced manifest digest +for `retention-head`, the intent digest for `gc-intent`, and the referenced +intent digest for `gc-receipt`. ## Frozen identities @@ -55,6 +58,16 @@ The retention fixture uses namespace bytes `00 2f ff`, proving the namespace is opaque and not a path or Unicode string. Its one anchor combines the canonical one-zero `BlobId` and `LayoutId` values from the existing layout corpus. +The GC fixtures name the version-1 one-zero segment as their one candidate, +with that segment's record checksum standing in for its verification-evidence +digest; the generation-two catalog digest and head checksum stand in for the +catalog-successor coordinates; the migration inventory digest is the +segment-pool identity; the empty-segment digest is the post-retirement pool +state; and the reader-lock coordinates are the fixture-only values `4`, `5`, +and `6`. These are format evidence for the record grammars, not a consistent +store: the catalog they name still lists the candidate, which a real planner +would refuse. + ## Verification Run: diff --git a/conformance/segment-store/v2/artifacts.tsv b/conformance/segment-store/v2/artifacts.tsv index dace87c5..fc741711 100644 --- a/conformance/segment-store/v2/artifacts.tsv +++ b/conformance/segment-store/v2/artifacts.tsv @@ -6,3 +6,5 @@ migration-receipt migration-receipt 256 1 2 a15a00000219df20979da36419046eae9a0b one-anchor-root retention-root 378 1 1 ca4c11f265c3bed07073bdc3b6aef003e964ac8cb36fcfcc92f20fa6f0b60085 28c52ff0f8d6533234be083f425e921d699639e204e2c66dec0cae2ff0a2dc34 one-anchor-root.hex one-root-manifest retention-manifest 296 1 1 f46b96a2bf3379320cf59e8af15b9d108de06025c415307b28953714bd7a80eb 10597643c3fc9485c7ecd3bb511d6726e726fd92f0f769a204b899c5fdc77d2c one-root-manifest.hex one-root-head retention-head 144 1 1 f46b96a2bf3379320cf59e8af15b9d108de06025c415307b28953714bd7a80eb ac049edb33af7e957c6ff11ead7e1bcf9c40fa9793cc84979215ffbba5f630b7 one-root-head.hex +one-candidate-gc-intent gc-intent 456 1 1 a9dd523326a686b89ac8f0c410421766afc221f2963bdafd430dce7f59edc701 cefd325fbf7b1900e1a208c9c66ec5cfd03e565ea04a3bf9011338e9dabae749 one-candidate-gc-intent.hex +one-candidate-gc-receipt gc-receipt 320 1 1 a9dd523326a686b89ac8f0c410421766afc221f2963bdafd430dce7f59edc701 d3dd8ddeea9ce78a278b39a3bdf61b957fff8f6cfee647f138fc720091389147 one-candidate-gc-receipt.hex diff --git a/conformance/segment-store/v2/one-candidate-gc-intent.hex b/conformance/segment-store/v2/one-candidate-gc-intent.hex new file mode 100644 index 00000000..8f5e120b --- /dev/null +++ b/conformance/segment-store/v2/one-candidate-gc-intent.hex @@ -0,0 +1 @@ +4b4545503a47433a494e54454e543200000201400000000000000000000001c8000000000000000100480000000000010000000000000001f46b96a2bf3379320cf59e8af15b9d108de06025c415307b28953714bd7a80eb0000000000000002ea7d0055fd21f00ed94809ef4e671d72fa2e6a4a5d9ecefb23f3a320a2dad9930000000100000001db1c1c1a50613ef11f7c0ee0882e37b6d24e2db2ca57783d01197ba51b61ce59f67c0b68572fcb0b38a077048d72f7a419a0c0a7ae5c2629c3dd76253fcbeba640bf5d49c34847ac9cf46a256f343cee80cd980d1405d2dd02ceff8f58d674f9a80259fcd1237203ea6c6cc5065514abdeb01da603c3194b096a045cf694c95a0000000000000004000000000000000500000000000000066676bc9d70134a74cc9dfe397fb8a033fc45ebd903290d29b93018a097ee2b50b7542dced2ab770894a14d1d04b066e3a899942602c5986d35ba6df6c1a35cfc0000000000000151becb46b35120723210798a47e26144b8214d5ea65d28806e0ba941d2aa66bbfaa9dd523326a686b89ac8f0c410421766afc221f2963bdafd430dce7f59edc701cefd325fbf7b1900e1a208c9c66ec5cfd03e565ea04a3bf9011338e9dabae749 diff --git a/conformance/segment-store/v2/one-candidate-gc-receipt.hex b/conformance/segment-store/v2/one-candidate-gc-receipt.hex new file mode 100644 index 00000000..381ee13e --- /dev/null +++ b/conformance/segment-store/v2/one-candidate-gc-receipt.hex @@ -0,0 +1 @@ +4b4545503a47433a524543454950543200020140000000000000000000000001a9dd523326a686b89ac8f0c410421766afc221f2963bdafd430dce7f59edc7016676bc9d70134a74cc9dfe397fb8a033fc45ebd903290d29b93018a097ee2b50fef0cccd7bb6f75a3322d5457219ce4875e5f5ae75193e3c66d4856bca3801700000000000000001f46b96a2bf3379320cf59e8af15b9d108de06025c415307b28953714bd7a80eb0000000000000002ea7d0055fd21f00ed94809ef4e671d72fa2e6a4a5d9ecefb23f3a320a2dad9930000000000000004000000000000000500000000000000060000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d3dd8ddeea9ce78a278b39a3bdf61b957fff8f6cfee647f138fc720091389147 diff --git a/docs/formats/segment-store-v2/gc.md b/docs/formats/segment-store-v2/gc.md index 708afabc..9b46696c 100644 --- a/docs/formats/segment-store-v2/gc.md +++ b/docs/formats/segment-store-v2/gc.md @@ -8,6 +8,19 @@ Issue #21 owns their implementation. They are specified now so version 2 has one exact root grammar, but their presence remains unsupported mandatory state until every **Planned in #21** requirement becomes executable evidence. +Implemented: the intent and receipt codecs. `GcRetirementIntent` admits a +canonical candidate set over its coordinates; `CanonicalGcRetirementIntent` +and `AdmittedGcRetirementIntent` reproduce and admit the frozen +`one-candidate-gc-intent.hex`; `CanonicalGcRetirementReceipt` and +`AdmittedGcRetirementReceipt` bind a receipt to its admitted intent. The +receipt's synchronization count is exactly one per candidate: the +pool-directory synchronization that follows each unlink. The +`RecoveryDispositionReceipt` codec waits for its artifact-kind, decision, and +classification enumerations to be registered in `definition.tsv`, which +changes the definition digest and therefore every version-2 fixture; that is +a specification decision, not an implementation gap. Namespace admission +still refuses every one of these records on disk. + ## Common rules All integers are unsigned and big-endian. Flags and reserved bytes are zero. @@ -188,6 +201,8 @@ replacement, synchronizes `recovery/dispositions`, removes the stage, and synchronizes `recovery`. Until that completes, the artifact remains recovery-protected. -These grammars, their golden fixtures, parsers, corruption matrices, crash -points, model, benchmarks, and fuzz targets are **Planned in #21**. Issue #19 -must refuse their physical presence without mutating it. +The intent and receipt grammars have golden fixtures, parsers, corruption +matrices, and a seeded fuzz target. The disposition grammar's fixture and +parser, and every crash point, model, benchmark, execution, and recovery +law, are **Planned in #21**. Issue #19 must refuse their physical presence +without mutating it. diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index 912134fb..b6ceb152 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -45,7 +45,7 @@ case is not evidence. | ID | Requirement | Evidence | Status | | --- | --- | --- | --- | -| `KEEP-GC-001` | Version 2 specifies exact bounded GC intent, receipt, and recovery-disposition grammars but refuses their presence until their parser and recovery protocol are implemented | namespace admission tests | Planned in #21 | +| `KEEP-GC-001` | Version 2 specifies exact bounded GC intent, receipt, and recovery-disposition grammars but refuses their presence until their parser and recovery protocol are implemented | intent and receipt golden, canonical re-encoding, cross-record binding, and field-by-field corruption laws in `tests/gc_retirement_intent.rs`, `tests/gc_retirement_intent/mutation_laws.rs`, and `tests/gc_retirement_receipt.rs`; seeded `gc_format` fuzz target; the disposition-receipt codec waits for its registered enumerations to be frozen in `definition.tsv`; presence refusal in namespace admission tests | In progress in #21 | | `KEEP-GC-002` | GC intent, receipt, disposition, reader-fence, retirement, compaction, and recovery laws implement ADR-0009 without changing logical identity | golden-format, model-based, corruption, crash-injection, benchmark, and fuzz evidence | Planned in #21 | diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 88feb486..c527bfd0 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -103,6 +103,13 @@ test = false doc = false bench = false +[[bin]] +name = "gc_format" +path = "fuzz_targets/gc_format.rs" +test = false +doc = false +bench = false + [[bin]] name = "golden_protocol" path = "fuzz_targets/golden_protocol.rs" diff --git a/fuzz/README.md b/fuzz/README.md index 115dc98f..6931f305 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -75,6 +75,11 @@ and completion-receipt decoders. The receipt seed carries its exact marker and intent dependencies so mutations exercise integrity and cross-record binding; every admitted value must retain its exact input bytes. +The `gc_format` seeds select the public GC retirement-intent and +retirement-receipt decoders. The receipt seed carries its exact intent +dependency behind a length frame so mutations exercise cross-record binding +as well as framing; every admitted value must retain its exact input bytes. + The `segment_format` seeds select the public segment-header, record-header, complete-record, seal, and complete-segment boundaries. Canonical empty, one-record, and bundled segments keep mutations inside the nested parsers; diff --git a/fuzz/fuzz_targets/gc_format.rs b/fuzz/fuzz_targets/gc_format.rs new file mode 100644 index 00000000..8570c4ac --- /dev/null +++ b/fuzz/fuzz_targets/gc_format.rs @@ -0,0 +1,46 @@ +#![no_main] + +//! This target owns canonical GC retirement record parser fuzzing. + +use keep::{AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt}; +use libfuzzer_sys::fuzz_target; + +// Receipt inputs carry their exact intent dependency first, framed by a +// big-endian `u32` intent length, so mutations exercise cross-record +// binding and not only framing. +fuzz_target!(|bytes: &[u8]| { + let Some((&selector, input)) = bytes.split_first() else { + return; + }; + match selector { + 0 => intent(input), + _ => receipt(input), + } +}); + +fn intent(input: &[u8]) { + if let Ok(intent) = AdmittedGcRetirementIntent::decode(input) { + assert_eq!(intent.encoded(), input); + } +} + +fn receipt(input: &[u8]) { + let Some((length, remainder)) = input.split_at_checked(4) else { + return; + }; + let Ok(length) = <[u8; 4]>::try_from(length).map(u32::from_be_bytes) else { + return; + }; + let Ok(length) = usize::try_from(length) else { + return; + }; + let Some((intent_bytes, receipt_bytes)) = remainder.split_at_checked(length) else { + return; + }; + let Ok(intent) = AdmittedGcRetirementIntent::decode(intent_bytes) else { + return; + }; + if let Ok(receipt) = AdmittedGcRetirementReceipt::decode(receipt_bytes, &intent) { + assert_eq!(receipt.encoded(), receipt_bytes); + } +} diff --git a/src/adapters/exports.rs b/src/adapters/exports.rs index e2c8c51f..b625156d 100644 --- a/src/adapters/exports.rs +++ b/src/adapters/exports.rs @@ -58,6 +58,7 @@ pub use super::filesystem_segment_stage::FilesystemSegmentStage; pub use super::filesystem_version_two_admission::FilesystemVersionTwoAdmission; pub use super::filesystem_version_two_record_refusal::VersionTwoRecordRefusal; pub use super::filesystem_writer_lock::FilesystemWriterLock; +pub use super::gc::*; pub use super::layout_decode_error::LayoutDecodeError; pub use super::layout_decode_policy::LayoutDecodePolicy; pub use super::layout_encode_error::LayoutEncodeError; diff --git a/src/adapters/gc/admitted_intent.rs b/src/adapters/gc/admitted_intent.rs new file mode 100644 index 00000000..31738af6 --- /dev/null +++ b/src/adapters/gc/admitted_intent.rs @@ -0,0 +1,67 @@ +//! This boundary module owns admitted borrowed GC retirement intent bytes. + +use super::{ + GcCandidateSetDigest, GcRetirementIntent, GcRetirementIntentDecodeError, + GcRetirementIntentDigest, intent_decoder, +}; + +/// Borrowed canonical GC retirement intent record. +/// +/// Admission proves framing, checksum, intent digest, candidate-set digest, +/// and every semantic coordinate. It does not prove that any candidate is +/// unreachable or that any retirement occurred. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdmittedGcRetirementIntent<'encoded> { + encoded: &'encoded [u8], + intent: GcRetirementIntent, + candidate_set_digest: GcCandidateSetDigest, + digest: GcRetirementIntentDigest, +} + +impl<'encoded> AdmittedGcRetirementIntent<'encoded> { + /// Decodes and admits one exact retirement intent. + /// + /// # Errors + /// + /// Returns [`GcRetirementIntentDecodeError`] for invalid framing, + /// integrity, ordering, bounds, or semantic coordinates. + pub fn decode(encoded: &'encoded [u8]) -> Result { + intent_decoder::decode(encoded) + } + + /// Returns the exact borrowed canonical bytes. + #[must_use] + pub const fn encoded(&self) -> &'encoded [u8] { + self.encoded + } + + /// Returns the semantic intent. + pub const fn intent(&self) -> &GcRetirementIntent { + &self.intent + } + + /// Returns the verified candidate-set digest. + pub const fn candidate_set_digest(&self) -> GcCandidateSetDigest { + self.candidate_set_digest + } + + /// Returns the verified identity of the header and candidate bytes. + pub const fn digest(&self) -> GcRetirementIntentDigest { + self.digest + } + + pub(super) const fn admitted( + encoded: &'encoded [u8], + intent: GcRetirementIntent, + candidate_set_digest: GcCandidateSetDigest, + digest: GcRetirementIntentDigest, + ) -> Self { + Self { + encoded, + intent, + candidate_set_digest, + digest, + } + } +} diff --git a/src/adapters/gc/admitted_receipt.rs b/src/adapters/gc/admitted_receipt.rs new file mode 100644 index 00000000..4a093d5f --- /dev/null +++ b/src/adapters/gc/admitted_receipt.rs @@ -0,0 +1,48 @@ +//! This boundary module owns admitted borrowed GC retirement receipt bytes. + +use super::{ + AdmittedGcRetirementIntent, GcRetirementReceipt, GcRetirementReceiptDecodeError, + receipt_decoder, +}; + +/// Borrowed canonical GC retirement receipt record. +/// +/// Admission proves framing, checksum, and exact binding to the supplied +/// admitted intent. It does not prove that the named retirement occurred; +/// GC recovery must establish that from the pool. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdmittedGcRetirementReceipt<'encoded> { + encoded: &'encoded [u8], + receipt: GcRetirementReceipt, +} + +impl<'encoded> AdmittedGcRetirementReceipt<'encoded> { + /// Decodes and admits one exact receipt bound to `intent`. + /// + /// # Errors + /// + /// Returns [`GcRetirementReceiptDecodeError`] for invalid framing, + /// integrity, or any coordinate that disagrees with the intent. + pub fn decode( + encoded: &'encoded [u8], + intent: &AdmittedGcRetirementIntent<'_>, + ) -> Result { + receipt_decoder::decode(encoded, intent) + } + + /// Returns the exact borrowed canonical bytes. + #[must_use] + pub const fn encoded(&self) -> &'encoded [u8] { + self.encoded + } + + /// Returns the semantic receipt. + pub const fn receipt(&self) -> &GcRetirementReceipt { + &self.receipt + } + + pub(super) const fn admitted(encoded: &'encoded [u8], receipt: GcRetirementReceipt) -> Self { + Self { encoded, receipt } + } +} diff --git a/src/adapters/gc/candidate.rs b/src/adapters/gc/candidate.rs new file mode 100644 index 00000000..962ea58c --- /dev/null +++ b/src/adapters/gc/candidate.rs @@ -0,0 +1,48 @@ +//! This boundary module owns one GC retirement candidate. + +use super::VerificationEvidenceDigest; +use crate::SegmentDigest; + +/// One immutable segment proposed for retirement. +/// +/// The value names the segment by its physical digest and exact length and +/// carries the digest of the evidence that verified it. It makes no claim +/// that the segment is unreachable; the intent that lists it does. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct GcCandidate { + segment_digest: SegmentDigest, + segment_length: u64, + evidence_digest: VerificationEvidenceDigest, +} + +impl GcCandidate { + /// Binds one candidate segment to its verification evidence. + pub const fn new( + segment_digest: SegmentDigest, + segment_length: u64, + evidence_digest: VerificationEvidenceDigest, + ) -> Self { + Self { + segment_digest, + segment_length, + evidence_digest, + } + } + + /// Returns the physical segment digest. + pub const fn segment_digest(&self) -> SegmentDigest { + self.segment_digest + } + + /// Returns the exact segment byte length. + #[must_use] + pub const fn segment_length(&self) -> u64 { + self.segment_length + } + + /// Returns the verification-evidence digest. + pub const fn evidence_digest(&self) -> VerificationEvidenceDigest { + self.evidence_digest + } +} diff --git a/src/adapters/gc/canonical_intent.rs b/src/adapters/gc/canonical_intent.rs new file mode 100644 index 00000000..1f923e08 --- /dev/null +++ b/src/adapters/gc/canonical_intent.rs @@ -0,0 +1,67 @@ +//! This boundary module owns canonical owned GC retirement intent bytes. + +use super::{ + GcCandidateSetDigest, GcRetirementIntent, GcRetirementIntentDigest, + GcRetirementIntentEncodeError, intent_encoder, +}; + +/// Owned canonical GC retirement intent record. +/// +/// Construction proves only that the bytes are the one canonical encoding of +/// the semantic intent. It does not prove that the intent was written, +/// synchronized, or acted on. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CanonicalGcRetirementIntent { + encoded: Vec, + intent: GcRetirementIntent, + candidate_set_digest: GcCandidateSetDigest, + digest: GcRetirementIntentDigest, +} + +impl CanonicalGcRetirementIntent { + /// Encodes one semantic intent into its canonical bytes. + /// + /// # Errors + /// + /// Returns [`GcRetirementIntentEncodeError`] when the bounded allocation + /// is refused. + pub fn from_intent(intent: &GcRetirementIntent) -> Result { + intent_encoder::encode(intent) + } + + /// Returns the exact canonical bytes. + #[must_use] + pub fn encoded(&self) -> &[u8] { + &self.encoded + } + + /// Returns the semantic intent. + pub const fn intent(&self) -> &GcRetirementIntent { + &self.intent + } + + /// Returns the verified candidate-set digest. + pub const fn candidate_set_digest(&self) -> GcCandidateSetDigest { + self.candidate_set_digest + } + + /// Returns the identity of the header and candidate bytes. + pub const fn digest(&self) -> GcRetirementIntentDigest { + self.digest + } + + pub(super) const fn admitted( + encoded: Vec, + intent: GcRetirementIntent, + candidate_set_digest: GcCandidateSetDigest, + digest: GcRetirementIntentDigest, + ) -> Self { + Self { + encoded, + intent, + candidate_set_digest, + digest, + } + } +} diff --git a/src/adapters/gc/canonical_receipt.rs b/src/adapters/gc/canonical_receipt.rs new file mode 100644 index 00000000..471cfc46 --- /dev/null +++ b/src/adapters/gc/canonical_receipt.rs @@ -0,0 +1,54 @@ +//! This boundary module owns canonical owned GC retirement receipt bytes. + +use super::{ + CanonicalGcRetirementIntent, GcRetirementReceipt, PoolStateDigest, receipt_encoder, + receipt_format, +}; + +/// Owned canonical GC retirement receipt record. +/// +/// Construction binds the completed intent and the caller's post-retirement +/// pool-state digest. It does not prove that any candidate was unlinked or +/// that any directory was synchronized. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CanonicalGcRetirementReceipt { + encoded: [u8; receipt_format::ENCODED_LENGTH], + receipt: GcRetirementReceipt, +} + +impl CanonicalGcRetirementReceipt { + /// Constructs the one receipt that completes `intent`. + pub fn from_intent( + intent: &CanonicalGcRetirementIntent, + pool_state_digest: PoolStateDigest, + ) -> Self { + receipt_encoder::encode(GcRetirementReceipt::for_intent( + intent.digest(), + intent.candidate_set_digest(), + intent.intent(), + pool_state_digest, + )) + } + + /// Returns the exact canonical receipt bytes. + #[must_use] + pub const fn encoded(&self) -> &[u8] { + &self.encoded + } + + /// Returns the semantic receipt. + pub const fn receipt(&self) -> &GcRetirementReceipt { + &self.receipt + } + + pub(super) const fn admitted( + encoded: &[u8; receipt_format::ENCODED_LENGTH], + receipt: GcRetirementReceipt, + ) -> Self { + Self { + encoded: *encoded, + receipt, + } + } +} diff --git a/src/adapters/gc/evidence_digests.rs b/src/adapters/gc/evidence_digests.rs new file mode 100644 index 00000000..be95c244 --- /dev/null +++ b/src/adapters/gc/evidence_digests.rs @@ -0,0 +1,56 @@ +//! This boundary module owns the caller-supplied evidence digests a GC +//! record carries. +//! +//! Each value names one exact 32-byte BLAKE3-256 coordinate that a later GC +//! planner or executor derives. This slice admits and transports the bytes; +//! it does not recompute them, so every constructor is public and every type +//! is a distinct newtype so the coordinates cannot be swapped. + +macro_rules! evidence_digest { + ($(#[$doc:meta])* $name:ident) => { + $(#[$doc])* + #[must_use] + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] + pub struct $name([u8; 32]); + + impl $name { + /// Wraps exact caller-supplied digest bytes. + pub const fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Returns the exact 32 digest bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + } + }; +} + +evidence_digest! { + /// Digest of the complete verification evidence for one retirement + /// candidate segment. + VerificationEvidenceDigest +} + +evidence_digest! { + /// Digest of the complete verified proof that the catalog successor names + /// no retirement candidate. + CatalogSuccessorProofDigest +} + +evidence_digest! { + /// Identity digest of the exact admitted immutable segment pool. + SegmentPoolIdentityDigest +} + +evidence_digest! { + /// Digest of the exact admitted recovery-disposition receipt set. + DispositionSetDigest +} + +evidence_digest! { + /// Digest of the verified, synchronized segment pool after retirement. + PoolStateDigest +} diff --git a/src/adapters/gc/intent.rs b/src/adapters/gc/intent.rs new file mode 100644 index 00000000..3d0c6cac --- /dev/null +++ b/src/adapters/gc/intent.rs @@ -0,0 +1,93 @@ +//! This boundary module owns the semantic GC retirement intent. + +use super::{GcCandidate, GcRetirementIntentCoordinates, GcRetirementIntentError}; + +/// One validated retirement intent: its coordinates and its canonical, +/// duplicate-free, digest-ordered candidate set. +/// +/// The value proves nothing about the store. It is the exact statement a +/// GC executor writes to `gc/intent` before the first unlink, so that +/// recovery can classify every later state against it. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GcRetirementIntent { + coordinates: GcRetirementIntentCoordinates, + candidates: Vec, +} + +impl GcRetirementIntent { + /// Maximum candidate count in one intent. + pub const MAXIMUM_CANDIDATE_COUNT: u32 = 65_536; + + /// Admits a retirement intent over a canonical candidate set. + /// + /// # Errors + /// + /// Returns [`GcRetirementIntentError`] when the set is empty, exceeds + /// the maximum, repeats a segment, or is not in canonical + /// segment-digest order. + pub fn new( + coordinates: GcRetirementIntentCoordinates, + candidates: Vec, + ) -> Result { + if candidates.is_empty() { + return Err(GcRetirementIntentError::NoCandidates); + } + let observed = u32::try_from(candidates.len()).map_err(|_| { + GcRetirementIntentError::CandidateCountExceeded { + maximum: Self::MAXIMUM_CANDIDATE_COUNT, + observed: u32::MAX, + } + })?; + if observed > Self::MAXIMUM_CANDIDATE_COUNT { + return Err(GcRetirementIntentError::CandidateCountExceeded { + maximum: Self::MAXIMUM_CANDIDATE_COUNT, + observed, + }); + } + require_canonical_order(&candidates)?; + Ok(Self { + coordinates, + candidates, + }) + } + + /// Returns every bound coordinate besides the candidates. + pub const fn coordinates(&self) -> &GcRetirementIntentCoordinates { + &self.coordinates + } + + /// Returns the canonical candidate set. + pub fn candidates(&self) -> &[GcCandidate] { + &self.candidates + } + + /// Returns the exact candidate count. + #[must_use] + pub fn candidate_count(&self) -> u32 { + // The constructor bounded the length by `MAXIMUM_CANDIDATE_COUNT`. + u32::try_from(self.candidates.len()).unwrap_or(Self::MAXIMUM_CANDIDATE_COUNT) + } +} + +fn require_canonical_order(candidates: &[GcCandidate]) -> Result<(), GcRetirementIntentError> { + for (position, pair) in candidates.windows(2).enumerate() { + let (Some(prior), Some(observed)) = (pair.first(), pair.get(1)) else { + continue; + }; + let index = u32::try_from(position) + .ok() + .and_then(|position| position.checked_add(1)) + .unwrap_or(u32::MAX); + match observed.segment_digest().cmp(&prior.segment_digest()) { + std::cmp::Ordering::Greater => {} + std::cmp::Ordering::Equal => { + return Err(GcRetirementIntentError::DuplicateCandidate { index }); + } + std::cmp::Ordering::Less => { + return Err(GcRetirementIntentError::NonCanonicalCandidateOrder { index }); + } + } + } + Ok(()) +} diff --git a/src/adapters/gc/intent_candidate_decoder.rs b/src/adapters/gc/intent_candidate_decoder.rs new file mode 100644 index 00000000..ede78a8f --- /dev/null +++ b/src/adapters/gc/intent_candidate_decoder.rs @@ -0,0 +1,29 @@ +//! This boundary module owns canonical GC candidate body decoding. + +use super::GcRetirementIntentDecodeError as Error; +use super::intent_field_decoder::{read_array, read_u64, require_exact}; +use super::{GcCandidate, VerificationEvidenceDigest, intent_format as format}; +use crate::adapters::SegmentDigest; + +pub(super) fn decode(encoded: &[u8], candidate_count: u32) -> Result, Error> { + let capacity = usize::try_from(candidate_count).map_err(|_| Error::LengthOverflow)?; + let expected_length = capacity + .checked_mul(format::CANDIDATE_WIDTH) + .ok_or(Error::LengthOverflow)?; + require_exact(encoded, expected_length)?; + let mut candidates = Vec::new(); + candidates + .try_reserve_exact(capacity) + .map_err(|source| Error::Allocation { source })?; + for bytes in encoded.chunks_exact(format::CANDIDATE_WIDTH) { + let segment_digest = SegmentDigest::from_validated(read_array(bytes, 0)?); + let segment_length = read_u64(bytes, 32)?; + let evidence_digest = VerificationEvidenceDigest::new(read_array(bytes, 40)?); + candidates.push(GcCandidate::new( + segment_digest, + segment_length, + evidence_digest, + )); + } + Ok(candidates) +} diff --git a/src/adapters/gc/intent_coordinates.rs b/src/adapters/gc/intent_coordinates.rs new file mode 100644 index 00000000..babe1064 --- /dev/null +++ b/src/adapters/gc/intent_coordinates.rs @@ -0,0 +1,40 @@ +//! This boundary module owns the liveness, catalog, profile, pool, and +//! reader-lock coordinates one GC retirement intent binds. + +use super::{ + CatalogSuccessorProofDigest, DispositionSetDigest, ReaderLockIdentity, + SegmentPoolIdentityDigest, +}; +use crate::{ + CatalogDigest, CatalogGeneration, GcGeneration, LivenessGeneration, RegisteredRetentionProfile, + RetentionManifestDigest, +}; + +/// Every coordinate a retirement intent binds besides its candidates. +/// +/// Fields are public because each is an already-validated typed value; the +/// struct only groups them so an intent is constructed from one value. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GcRetirementIntentCoordinates { + /// Generation of this retirement. + pub generation: GcGeneration, + /// Exact current global liveness generation. + pub liveness_generation: LivenessGeneration, + /// Exact current retention-manifest digest. + pub manifest_digest: RetentionManifestDigest, + /// Exact catalog successor generation that names no candidate. + pub catalog_generation: CatalogGeneration, + /// Exact catalog successor digest. + pub catalog_digest: CatalogDigest, + /// Exact retained realization profile. + pub profile: RegisteredRetentionProfile, + /// Digest of the complete catalog-successor proof. + pub catalog_successor_proof_digest: CatalogSuccessorProofDigest, + /// Identity digest of the exact admitted segment pool. + pub segment_pool_identity_digest: SegmentPoolIdentityDigest, + /// Digest of the exact admitted disposition receipts. + pub disposition_set_digest: DispositionSetDigest, + /// Identity of the exclusively locked `reader.lock`. + pub reader_lock: ReaderLockIdentity, +} diff --git a/src/adapters/gc/intent_decode_error.rs b/src/adapters/gc/intent_decode_error.rs new file mode 100644 index 00000000..8b420fa3 --- /dev/null +++ b/src/adapters/gc/intent_decode_error.rs @@ -0,0 +1,144 @@ +//! This boundary module owns typed GC retirement intent decoding and +//! encoding failures. + +use std::collections::TryReserveError; + +use super::GcRetirementIntentError; +use crate::{ + CatalogGenerationError, GcGenerationError, LivenessGenerationError, + RetentionProfileAdmissionError, +}; + +/// Failure to decode and admit one GC retirement intent. +#[derive(Debug)] +pub enum GcRetirementIntentDecodeError { + /// The byte string ended before its required exact length. + Truncated { + /// Required byte length. + expected: usize, + /// Observed byte length. + observed: usize, + }, + /// Bytes followed the required exact record. + TrailingData { + /// Required byte length. + expected: usize, + /// Observed byte length. + observed: usize, + }, + /// The fixed record magic was not canonical. + InvalidMagic { + /// Observed 16 magic bytes. + observed: [u8; 16], + }, + /// The format version is unsupported. + UnsupportedVersion { + /// Supported version. + expected: u16, + /// Observed version. + observed: u16, + }, + /// The fixed header width was not canonical. + InvalidHeaderLength { + /// Required header width. + expected: u16, + /// Observed width. + observed: u16, + }, + /// The record carried unsupported flags. + UnsupportedFlags { + /// Observed flag bits. + observed: u32, + }, + /// The declared total length disagreed with canonical field arithmetic. + DeclaredLengthMismatch { + /// Canonical computed length. + expected: u64, + /// Declared length. + observed: u64, + }, + /// Checked record-length arithmetic overflowed. + LengthOverflow, + /// The fixed candidate width was not canonical. + InvalidCandidateWidth { + /// Required candidate width. + expected: u16, + /// Observed candidate width. + observed: u16, + }, + /// A reserved field was nonzero. + NonZeroReserved { + /// Protocol field name. + field: &'static str, + }, + /// The declared candidate count exceeded the fixed bound. + CandidateCountExceeded { + /// Fixed maximum count. + maximum: u32, + /// Observed count. + observed: u32, + }, + /// Garbage-collection generation admission failed. + Generation { + /// Preserved generation failure. + source: GcGenerationError, + }, + /// Liveness-generation admission failed. + LivenessGeneration { + /// Preserved generation failure. + source: LivenessGenerationError, + }, + /// Catalog-generation admission failed. + CatalogGeneration { + /// Preserved generation failure. + source: CatalogGenerationError, + }, + /// Realization-profile admission failed. + Profile { + /// Preserved profile failure. + source: RetentionProfileAdmissionError, + }, + /// Candidate allocation was refused. + Allocation { + /// Preserved allocation failure. + source: TryReserveError, + }, + /// The candidate-set digest did not match the exact body. + CandidateSetDigestMismatch { + /// Computed canonical digest. + expected: [u8; 32], + /// Digest stored in the header. + observed: [u8; 32], + }, + /// The intent digest did not match the exact header and body. + IntentDigestMismatch { + /// Computed canonical digest. + expected: [u8; 32], + /// Digest stored in the record. + observed: [u8; 32], + }, + /// The checksum did not match the complete digest-bearing prefix. + ChecksumMismatch { + /// Computed canonical checksum. + expected: [u8; 32], + /// Checksum stored in the record. + observed: [u8; 32], + }, + /// Final semantic intent admission failed. + Semantic { + /// Preserved semantic failure. + source: GcRetirementIntentError, + }, +} + +/// Failure to encode one GC retirement intent. +#[derive(Debug)] +pub enum GcRetirementIntentEncodeError { + /// Checked record-length arithmetic overflowed. + LengthOverflow, + /// Bounded output allocation was refused. + Allocation { + /// Preserved allocation failure. + source: TryReserveError, + }, +} diff --git a/src/adapters/gc/intent_decode_error_display.rs b/src/adapters/gc/intent_decode_error_display.rs new file mode 100644 index 00000000..bcaa737b --- /dev/null +++ b/src/adapters/gc/intent_decode_error_display.rs @@ -0,0 +1,96 @@ +//! This boundary module owns GC retirement intent error formatting. + +use std::error::Error; +use std::fmt; + +use super::{GcRetirementIntentDecodeError, GcRetirementIntentEncodeError}; + +impl fmt::Display for GcRetirementIntentDecodeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Truncated { expected, observed } => write!( + formatter, + "GC intent requires {expected} bytes, observed {observed}" + ), + Self::TrailingData { expected, observed } => write!( + formatter, + "GC intent carries {observed} bytes after its exact {expected}" + ), + Self::InvalidMagic { .. } => formatter.write_str("invalid GC intent magic"), + Self::UnsupportedVersion { expected, observed } => write!( + formatter, + "unsupported GC intent version {observed}; expected {expected}" + ), + Self::InvalidHeaderLength { expected, observed } => write!( + formatter, + "GC intent header length {observed}; expected {expected}" + ), + Self::UnsupportedFlags { observed } => { + write!(formatter, "unsupported GC intent flags {observed:#010x}") + } + Self::DeclaredLengthMismatch { expected, observed } => write!( + formatter, + "GC intent declares {observed} bytes; canonical length is {expected}" + ), + Self::LengthOverflow => formatter.write_str("GC intent length arithmetic overflowed"), + Self::InvalidCandidateWidth { expected, observed } => write!( + formatter, + "GC intent candidate width {observed}; expected {expected}" + ), + Self::NonZeroReserved { field } => { + write!(formatter, "GC intent reserved {field} bytes are nonzero") + } + Self::CandidateCountExceeded { maximum, observed } => write!( + formatter, + "GC intent declares {observed} candidates; maximum {maximum}" + ), + Self::Generation { .. } => formatter.write_str("GC intent generation refused"), + Self::LivenessGeneration { .. } => { + formatter.write_str("GC intent liveness generation refused") + } + Self::CatalogGeneration { .. } => { + formatter.write_str("GC intent catalog generation refused") + } + Self::Profile { .. } => formatter.write_str("GC intent realization profile refused"), + Self::Allocation { .. } => formatter.write_str("GC intent allocation refused"), + Self::CandidateSetDigestMismatch { .. } => { + formatter.write_str("GC intent candidate-set digest mismatch") + } + Self::IntentDigestMismatch { .. } => formatter.write_str("GC intent digest mismatch"), + Self::ChecksumMismatch { .. } => formatter.write_str("GC intent checksum mismatch"), + Self::Semantic { .. } => formatter.write_str("GC intent semantic admission refused"), + } + } +} + +impl Error for GcRetirementIntentDecodeError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Generation { source } => Some(source), + Self::LivenessGeneration { source } => Some(source), + Self::CatalogGeneration { source } => Some(source), + Self::Profile { source } => Some(source), + Self::Allocation { source } => Some(source), + Self::Semantic { source } => Some(source), + _ => None, + } + } +} + +impl fmt::Display for GcRetirementIntentEncodeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LengthOverflow => formatter.write_str("GC intent length arithmetic overflowed"), + Self::Allocation { .. } => formatter.write_str("GC intent allocation refused"), + } + } +} + +impl Error for GcRetirementIntentEncodeError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Allocation { source } => Some(source), + Self::LengthOverflow => None, + } + } +} diff --git a/src/adapters/gc/intent_decoder.rs b/src/adapters/gc/intent_decoder.rs new file mode 100644 index 00000000..61833139 --- /dev/null +++ b/src/adapters/gc/intent_decoder.rs @@ -0,0 +1,34 @@ +//! This boundary module owns canonical GC retirement intent decoding order. + +use super::GcRetirementIntentDecodeError as Error; +use super::{ + AdmittedGcRetirementIntent, GcRetirementIntent, GcRetirementIntentDigest, + intent_candidate_decoder, intent_format as format, intent_header_decoder, intent_integrity, + intent_semantic_header, +}; + +pub(super) fn decode(encoded: &[u8]) -> Result, Error> { + let header = intent_header_decoder::decode(encoded)?; + let digest = intent_integrity::verify(encoded, header.digest_offset, header.checksum_offset)?; + let candidate_bytes = encoded + .get(format::HEADER_LENGTH..header.digest_offset) + .ok_or(Error::Truncated { + expected: header.digest_offset, + observed: encoded.len(), + })?; + let candidate_set_digest = intent_integrity::verify_candidate_set( + header.candidate_count, + candidate_bytes, + header.candidate_set_digest, + )?; + let coordinates = intent_semantic_header::admit(&header)?; + let candidates = intent_candidate_decoder::decode(candidate_bytes, header.candidate_count)?; + let intent = GcRetirementIntent::new(coordinates, candidates) + .map_err(|source| Error::Semantic { source })?; + Ok(AdmittedGcRetirementIntent::admitted( + encoded, + intent, + candidate_set_digest, + GcRetirementIntentDigest::from_hash(digest), + )) +} diff --git a/src/adapters/gc/intent_encoder.rs b/src/adapters/gc/intent_encoder.rs new file mode 100644 index 00000000..be28d666 --- /dev/null +++ b/src/adapters/gc/intent_encoder.rs @@ -0,0 +1,89 @@ +//! This boundary module owns canonical GC retirement intent encoding. + +use super::{ + CanonicalGcRetirementIntent, GcCandidate, GcCandidateSetDigest, GcRetirementIntent, + GcRetirementIntentCoordinates, GcRetirementIntentDigest, GcRetirementIntentEncodeError, + intent_format as format, +}; + +pub(super) fn encode( + intent: &GcRetirementIntent, +) -> Result { + let candidate_count = intent.candidate_count(); + let total_length = format::canonical_length(candidate_count) + .ok_or(GcRetirementIntentEncodeError::LengthOverflow)?; + let declared_length = + u64::try_from(total_length).map_err(|_| GcRetirementIntentEncodeError::LengthOverflow)?; + let mut candidates = Vec::new(); + candidates + .try_reserve_exact( + total_length + .checked_sub(format::HEADER_LENGTH) + .and_then(|length| length.checked_sub(format::TRAILER_LENGTH)) + .ok_or(GcRetirementIntentEncodeError::LengthOverflow)?, + ) + .map_err(|source| GcRetirementIntentEncodeError::Allocation { source })?; + for candidate in intent.candidates() { + write_candidate(&mut candidates, candidate); + } + let candidate_set_digest = format::candidate_set_digest(candidate_count, &candidates); + + let mut encoded = Vec::new(); + encoded + .try_reserve_exact(total_length) + .map_err(|source| GcRetirementIntentEncodeError::Allocation { source })?; + write_header(&mut encoded, intent, declared_length, candidate_set_digest); + encoded.extend_from_slice(&candidates); + let digest = format::intent_digest(&encoded); + encoded.extend_from_slice(&digest); + let checksum = format::checksum(&encoded); + encoded.extend_from_slice(&checksum); + Ok(CanonicalGcRetirementIntent::admitted( + encoded, + intent.clone(), + GcCandidateSetDigest::from_verified(candidate_set_digest), + GcRetirementIntentDigest::from_hash(digest), + )) +} + +fn write_candidate(output: &mut Vec, candidate: &GcCandidate) { + output.extend_from_slice(candidate.segment_digest().as_bytes()); + output.extend_from_slice(&candidate.segment_length().to_be_bytes()); + output.extend_from_slice(candidate.evidence_digest().as_bytes()); +} + +fn write_header( + output: &mut Vec, + intent: &GcRetirementIntent, + declared_length: u64, + candidate_set_digest: [u8; 32], +) { + let coordinates = intent.coordinates(); + output.extend_from_slice(&format::MAGIC); + output.extend_from_slice(&format::VERSION.to_be_bytes()); + output.extend_from_slice(&format::RECORD_HEADER_LENGTH.to_be_bytes()); + output.extend_from_slice(&0_u32.to_be_bytes()); + output.extend_from_slice(&declared_length.to_be_bytes()); + output.extend_from_slice(&coordinates.generation.get().to_be_bytes()); + output.extend_from_slice(&format::RECORD_CANDIDATE_WIDTH.to_be_bytes()); + output.extend_from_slice(&0_u16.to_be_bytes()); + output.extend_from_slice(&intent.candidate_count().to_be_bytes()); + write_coordinates(output, coordinates); + output.extend_from_slice(&candidate_set_digest); +} + +fn write_coordinates(output: &mut Vec, coordinates: &GcRetirementIntentCoordinates) { + output.extend_from_slice(&coordinates.liveness_generation.get().to_be_bytes()); + output.extend_from_slice(coordinates.manifest_digest.as_bytes()); + output.extend_from_slice(&coordinates.catalog_generation.get().to_be_bytes()); + output.extend_from_slice(coordinates.catalog_digest.as_bytes()); + output.extend_from_slice(&coordinates.profile.identity().to_be_bytes()); + output.extend_from_slice(&coordinates.profile.version().to_be_bytes()); + output.extend_from_slice(coordinates.profile.digest()); + output.extend_from_slice(coordinates.catalog_successor_proof_digest.as_bytes()); + output.extend_from_slice(coordinates.segment_pool_identity_digest.as_bytes()); + output.extend_from_slice(coordinates.disposition_set_digest.as_bytes()); + output.extend_from_slice(&coordinates.reader_lock.device().to_be_bytes()); + output.extend_from_slice(&coordinates.reader_lock.mount().to_be_bytes()); + output.extend_from_slice(&coordinates.reader_lock.file().to_be_bytes()); +} diff --git a/src/adapters/gc/intent_error.rs b/src/adapters/gc/intent_error.rs new file mode 100644 index 00000000..818a756c --- /dev/null +++ b/src/adapters/gc/intent_error.rs @@ -0,0 +1,51 @@ +//! This boundary module owns semantic GC retirement intent failures. + +use std::error::Error; +use std::fmt; + +/// Failure to admit one semantic GC retirement intent. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GcRetirementIntentError { + /// An intent must name at least one candidate. + NoCandidates, + /// The candidate set exceeded the fixed maximum. + CandidateCountExceeded { + /// Fixed maximum count. + maximum: u32, + /// Observed count. + observed: u32, + }, + /// Two candidates named one segment. + DuplicateCandidate { + /// Zero-based index of the repeated candidate. + index: u32, + }, + /// Candidates were not in ascending segment-digest order. + NonCanonicalCandidateOrder { + /// Zero-based index of the out-of-order candidate. + index: u32, + }, +} + +impl fmt::Display for GcRetirementIntentError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoCandidates => formatter.write_str("GC retirement intent names no candidate"), + Self::CandidateCountExceeded { maximum, observed } => write!( + formatter, + "GC retirement intent names {observed} candidates; maximum {maximum}" + ), + Self::DuplicateCandidate { index } => { + write!(formatter, "GC candidate {index} repeats a segment") + } + Self::NonCanonicalCandidateOrder { index } => { + write!( + formatter, + "GC candidate {index} breaks segment-digest order" + ) + } + } + } +} + +impl Error for GcRetirementIntentError {} diff --git a/src/adapters/gc/intent_field_decoder.rs b/src/adapters/gc/intent_field_decoder.rs new file mode 100644 index 00000000..a0e92a4b --- /dev/null +++ b/src/adapters/gc/intent_field_decoder.rs @@ -0,0 +1,93 @@ +//! This boundary module owns fixed-width GC retirement intent field +//! extraction. + +use std::cmp::Ordering; + +use super::GcRetirementIntentDecodeError as Error; + +pub(super) fn require_exact(encoded: &[u8], expected: usize) -> Result<(), Error> { + match encoded.len().cmp(&expected) { + Ordering::Less => Err(Error::Truncated { + expected, + observed: encoded.len(), + }), + Ordering::Equal => Ok(()), + Ordering::Greater => Err(Error::TrailingData { + expected, + observed: encoded.len(), + }), + } +} + +pub(super) const fn require_minimum(encoded: &[u8], expected: usize) -> Result<(), Error> { + if encoded.len() < expected { + Err(Error::Truncated { + expected, + observed: encoded.len(), + }) + } else { + Ok(()) + } +} + +pub(super) fn require_zero( + encoded: &[u8], + offset: usize, + width: usize, + field: &'static str, +) -> Result<(), Error> { + let end = offset.checked_add(width).ok_or(Error::LengthOverflow)?; + let bytes = encoded.get(offset..end).ok_or(Error::Truncated { + expected: end, + observed: encoded.len(), + })?; + if bytes.iter().all(|byte| *byte == 0) { + Ok(()) + } else { + Err(Error::NonZeroReserved { field }) + } +} + +pub(super) fn require_u16( + encoded: &[u8], + offset: usize, + expected: u16, + error: F, +) -> Result<(), Error> +where + F: FnOnce(u16, u16) -> Error, +{ + let observed = read_u16(encoded, offset)?; + if observed == expected { + Ok(()) + } else { + Err(error(expected, observed)) + } +} + +pub(super) fn read_u16(encoded: &[u8], offset: usize) -> Result { + read_array(encoded, offset).map(u16::from_be_bytes) +} + +pub(super) fn read_u32(encoded: &[u8], offset: usize) -> Result { + read_array(encoded, offset).map(u32::from_be_bytes) +} + +pub(super) fn read_u64(encoded: &[u8], offset: usize) -> Result { + read_array(encoded, offset).map(u64::from_be_bytes) +} + +pub(super) fn read_array( + encoded: &[u8], + offset: usize, +) -> Result<[u8; WIDTH], Error> { + let end = offset.checked_add(WIDTH).ok_or(Error::LengthOverflow)?; + let bytes = encoded.get(offset..end).ok_or(Error::Truncated { + expected: end, + observed: encoded.len(), + })?; + <[u8; WIDTH]>::try_from(bytes).map_err(|_| Error::Truncated { + expected: end, + observed: encoded.len(), + }) +} diff --git a/src/adapters/gc/intent_format.rs b/src/adapters/gc/intent_format.rs new file mode 100644 index 00000000..5fd12672 --- /dev/null +++ b/src/adapters/gc/intent_format.rs @@ -0,0 +1,47 @@ +//! This boundary module owns GC retirement intent framing constants and +//! integrity domains. + +pub(super) const HEADER_LENGTH: usize = 320; +pub(super) const CANDIDATE_WIDTH: usize = 72; +pub(super) const TRAILER_LENGTH: usize = 64; +pub(super) const CANDIDATE_SET_DIGEST_OFFSET: usize = 288; +pub(super) const MAGIC: [u8; 16] = *b"KEEP:GC:INTENT2\0"; +pub(super) const VERSION: u16 = 2; +pub(super) const RECORD_HEADER_LENGTH: u16 = 320; +pub(super) const RECORD_CANDIDATE_WIDTH: u16 = 72; +const CANDIDATE_SET_DOMAIN: &[u8] = b"keep.gc-candidate-set/v2\0"; +const INTENT_DOMAIN: &[u8] = b"keep.gc-retirement-intent/v2\0"; +const CHECKSUM_DOMAIN: &[u8] = b"keep.gc-retirement-intent-checksum/v2\0"; + +/// Canonical encoded length for `candidate_count` candidates, or `None` on +/// arithmetic overflow. +pub(super) fn canonical_length(candidate_count: u32) -> Option { + usize::try_from(candidate_count) + .ok()? + .checked_mul(CANDIDATE_WIDTH)? + .checked_add(HEADER_LENGTH)? + .checked_add(TRAILER_LENGTH) +} + +pub(super) fn candidate_set_digest(candidate_count: u32, candidates: &[u8]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(CANDIDATE_SET_DOMAIN); + hasher.update(&candidate_count.to_be_bytes()); + hasher.update(candidates); + *hasher.finalize().as_bytes() +} + +pub(super) fn intent_digest(preimage: &[u8]) -> [u8; 32] { + hash(INTENT_DOMAIN, preimage) +} + +pub(super) fn checksum(preimage: &[u8]) -> [u8; 32] { + hash(CHECKSUM_DOMAIN, preimage) +} + +fn hash(domain: &[u8], bytes: &[u8]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(bytes); + *hasher.finalize().as_bytes() +} diff --git a/src/adapters/gc/intent_header_decoder.rs b/src/adapters/gc/intent_header_decoder.rs new file mode 100644 index 00000000..9734b8e7 --- /dev/null +++ b/src/adapters/gc/intent_header_decoder.rs @@ -0,0 +1,98 @@ +//! This boundary module owns GC retirement intent header framing admission. + +use super::GcRetirementIntentDecodeError as Error; +use super::intent_field_decoder::{ + read_array, read_u32, read_u64, require_exact, require_minimum, require_u16, require_zero, +}; +use super::intent_format as format; + +pub(super) struct DecodedIntentHeader { + pub(super) generation: u64, + pub(super) candidate_count: u32, + pub(super) liveness_generation: u64, + pub(super) manifest_digest: [u8; 32], + pub(super) catalog_generation: u64, + pub(super) catalog_digest: [u8; 32], + pub(super) profile_identity: u32, + pub(super) profile_version: u32, + pub(super) profile_digest: [u8; 32], + pub(super) catalog_successor_proof_digest: [u8; 32], + pub(super) segment_pool_identity_digest: [u8; 32], + pub(super) disposition_set_digest: [u8; 32], + pub(super) reader_device: u64, + pub(super) reader_mount: u64, + pub(super) reader_file: u64, + pub(super) candidate_set_digest: [u8; 32], + pub(super) digest_offset: usize, + pub(super) checksum_offset: usize, +} + +pub(super) fn decode(encoded: &[u8]) -> Result { + require_minimum(encoded, format::HEADER_LENGTH)?; + validate_fixed_fields(encoded)?; + let candidate_count = read_u32(encoded, 44)?; + let total_length = format::canonical_length(candidate_count).ok_or(Error::LengthOverflow)?; + require_declared_length(encoded, total_length)?; + require_exact(encoded, total_length)?; + let checksum_offset = total_length.checked_sub(32).ok_or(Error::LengthOverflow)?; + let digest_offset = checksum_offset + .checked_sub(32) + .ok_or(Error::LengthOverflow)?; + Ok(DecodedIntentHeader { + generation: read_u64(encoded, 32)?, + candidate_count, + liveness_generation: read_u64(encoded, 48)?, + manifest_digest: read_array(encoded, 56)?, + catalog_generation: read_u64(encoded, 88)?, + catalog_digest: read_array(encoded, 96)?, + profile_identity: read_u32(encoded, 128)?, + profile_version: read_u32(encoded, 132)?, + profile_digest: read_array(encoded, 136)?, + catalog_successor_proof_digest: read_array(encoded, 168)?, + segment_pool_identity_digest: read_array(encoded, 200)?, + disposition_set_digest: read_array(encoded, 232)?, + reader_device: read_u64(encoded, 264)?, + reader_mount: read_u64(encoded, 272)?, + reader_file: read_u64(encoded, 280)?, + candidate_set_digest: read_array(encoded, format::CANDIDATE_SET_DIGEST_OFFSET)?, + digest_offset, + checksum_offset, + }) +} + +fn validate_fixed_fields(encoded: &[u8]) -> Result<(), Error> { + let magic = read_array(encoded, 0)?; + if magic != format::MAGIC { + return Err(Error::InvalidMagic { observed: magic }); + } + require_u16(encoded, 16, format::VERSION, |expected, observed| { + Error::UnsupportedVersion { expected, observed } + })?; + require_u16( + encoded, + 18, + format::RECORD_HEADER_LENGTH, + |expected, observed| Error::InvalidHeaderLength { expected, observed }, + )?; + let flags = read_u32(encoded, 20)?; + if flags != 0 { + return Err(Error::UnsupportedFlags { observed: flags }); + } + require_u16( + encoded, + 40, + format::RECORD_CANDIDATE_WIDTH, + |expected, observed| Error::InvalidCandidateWidth { expected, observed }, + )?; + require_zero(encoded, 42, 2, "candidate") +} + +fn require_declared_length(encoded: &[u8], total_length: usize) -> Result<(), Error> { + let observed = read_u64(encoded, 24)?; + let expected = u64::try_from(total_length).map_err(|_| Error::LengthOverflow)?; + if observed == expected { + Ok(()) + } else { + Err(Error::DeclaredLengthMismatch { expected, observed }) + } +} diff --git a/src/adapters/gc/intent_integrity.rs b/src/adapters/gc/intent_integrity.rs new file mode 100644 index 00000000..28f5d369 --- /dev/null +++ b/src/adapters/gc/intent_integrity.rs @@ -0,0 +1,54 @@ +//! This boundary module owns GC retirement intent digest and checksum +//! verification. + +use super::GcRetirementIntentDecodeError as Error; +use super::intent_field_decoder::read_array; +use super::{GcCandidateSetDigest, intent_format as format}; + +/// Verifies the trailing checksum, then the intent digest, and returns the +/// verified intent digest bytes. +pub(super) fn verify( + encoded: &[u8], + digest_offset: usize, + checksum_offset: usize, +) -> Result<[u8; 32], Error> { + let observed_checksum: [u8; 32] = read_array(encoded, checksum_offset)?; + let checksum_preimage = encoded.get(..checksum_offset).ok_or(Error::Truncated { + expected: checksum_offset, + observed: encoded.len(), + })?; + let expected_checksum = format::checksum(checksum_preimage); + if observed_checksum != expected_checksum { + return Err(Error::ChecksumMismatch { + expected: expected_checksum, + observed: observed_checksum, + }); + } + + let observed_digest: [u8; 32] = read_array(encoded, digest_offset)?; + let digest_preimage = encoded.get(..digest_offset).ok_or(Error::Truncated { + expected: digest_offset, + observed: encoded.len(), + })?; + let expected_digest = format::intent_digest(digest_preimage); + if observed_digest != expected_digest { + return Err(Error::IntentDigestMismatch { + expected: expected_digest, + observed: observed_digest, + }); + } + Ok(expected_digest) +} + +pub(super) fn verify_candidate_set( + candidate_count: u32, + candidates: &[u8], + observed: [u8; 32], +) -> Result { + let expected = format::candidate_set_digest(candidate_count, candidates); + if observed == expected { + Ok(GcCandidateSetDigest::from_verified(expected)) + } else { + Err(Error::CandidateSetDigestMismatch { expected, observed }) + } +} diff --git a/src/adapters/gc/intent_semantic_header.rs b/src/adapters/gc/intent_semantic_header.rs new file mode 100644 index 00000000..e18ca7cb --- /dev/null +++ b/src/adapters/gc/intent_semantic_header.rs @@ -0,0 +1,54 @@ +//! This boundary module owns post-integrity GC retirement intent header +//! admission. + +use super::GcRetirementIntentDecodeError as Error; +use super::intent_header_decoder::DecodedIntentHeader; +use super::{ + CatalogSuccessorProofDigest, DispositionSetDigest, GcRetirementIntent, + GcRetirementIntentCoordinates, ReaderLockIdentity, SegmentPoolIdentityDigest, +}; +use crate::{ + CatalogDigest, CatalogGeneration, GcGeneration, LivenessGeneration, RegisteredRetentionProfile, + RetentionManifestDigest, +}; + +pub(super) fn admit(header: &DecodedIntentHeader) -> Result { + if header.candidate_count > GcRetirementIntent::MAXIMUM_CANDIDATE_COUNT { + return Err(Error::CandidateCountExceeded { + maximum: GcRetirementIntent::MAXIMUM_CANDIDATE_COUNT, + observed: header.candidate_count, + }); + } + let generation = + GcGeneration::new(header.generation).map_err(|source| Error::Generation { source })?; + let liveness_generation = LivenessGeneration::new(header.liveness_generation) + .map_err(|source| Error::LivenessGeneration { source })?; + let catalog_generation = CatalogGeneration::new(header.catalog_generation) + .map_err(|source| Error::CatalogGeneration { source })?; + let profile = RegisteredRetentionProfile::admit( + header.profile_identity, + header.profile_version, + header.profile_digest, + ) + .map_err(|source| Error::Profile { source })?; + Ok(GcRetirementIntentCoordinates { + generation, + liveness_generation, + manifest_digest: RetentionManifestDigest::from_hash(header.manifest_digest), + catalog_generation, + catalog_digest: CatalogDigest::from_validated(header.catalog_digest), + profile, + catalog_successor_proof_digest: CatalogSuccessorProofDigest::new( + header.catalog_successor_proof_digest, + ), + segment_pool_identity_digest: SegmentPoolIdentityDigest::new( + header.segment_pool_identity_digest, + ), + disposition_set_digest: DispositionSetDigest::new(header.disposition_set_digest), + reader_lock: ReaderLockIdentity::new( + header.reader_device, + header.reader_mount, + header.reader_file, + ), + }) +} diff --git a/src/adapters/gc/mod.rs b/src/adapters/gc/mod.rs new file mode 100644 index 00000000..bb06063a --- /dev/null +++ b/src/adapters/gc/mod.rs @@ -0,0 +1,53 @@ +//! Canonical garbage-collection record adapters for `keep.segment-store/v2`. +//! +//! This module owns the semantic GC retirement intent and receipt, their +//! canonical encoders, and their admitting decoders. It does not own GC +//! planning, execution, reader fencing, recovery, or the recovery-disposition +//! receipt, whose registered enumerations are not yet frozen in the format +//! definition. + +mod admitted_intent; +mod admitted_receipt; +mod candidate; +mod canonical_intent; +mod canonical_receipt; +mod evidence_digests; +mod intent; +mod intent_candidate_decoder; +mod intent_coordinates; +mod intent_decode_error; +mod intent_decode_error_display; +mod intent_decoder; +mod intent_encoder; +mod intent_error; +mod intent_field_decoder; +mod intent_format; +mod intent_header_decoder; +mod intent_integrity; +mod intent_semantic_header; +mod reader_lock_identity; +mod receipt; +mod receipt_bytes; +mod receipt_decode_error; +mod receipt_decoder; +mod receipt_encoder; +mod receipt_format; +mod record_digests; + +pub use admitted_intent::AdmittedGcRetirementIntent; +pub use admitted_receipt::AdmittedGcRetirementReceipt; +pub use candidate::GcCandidate; +pub use canonical_intent::CanonicalGcRetirementIntent; +pub use canonical_receipt::CanonicalGcRetirementReceipt; +pub use evidence_digests::{ + CatalogSuccessorProofDigest, DispositionSetDigest, PoolStateDigest, SegmentPoolIdentityDigest, + VerificationEvidenceDigest, +}; +pub use intent::GcRetirementIntent; +pub use intent_coordinates::GcRetirementIntentCoordinates; +pub use intent_decode_error::{GcRetirementIntentDecodeError, GcRetirementIntentEncodeError}; +pub use intent_error::GcRetirementIntentError; +pub use reader_lock_identity::{ReaderLockCoordinate, ReaderLockIdentity}; +pub use receipt::GcRetirementReceipt; +pub use receipt_decode_error::GcRetirementReceiptDecodeError; +pub use record_digests::{GcCandidateSetDigest, GcRetirementIntentDigest}; diff --git a/src/adapters/gc/reader_lock_identity.rs b/src/adapters/gc/reader_lock_identity.rs new file mode 100644 index 00000000..9e2a700f --- /dev/null +++ b/src/adapters/gc/reader_lock_identity.rs @@ -0,0 +1,56 @@ +//! This boundary module owns the physical identity of the exclusively held +//! `reader.lock` that authorized one retirement. + +/// Device, mount, and file coordinates of the locked `reader.lock`. +/// +/// The mount coordinate is `statx.stx_mnt_id`, a mount instance that changes +/// across unmount, remount, and reboot; like the migration intent's root +/// mount identity it is same-process evidence, and a restart comparison uses +/// the device and file coordinates only. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ReaderLockIdentity { + device: u64, + mount: u64, + file: u64, +} + +impl ReaderLockIdentity { + /// Binds the three observed coordinates. + pub const fn new(device: u64, mount: u64, file: u64) -> Self { + Self { + device, + mount, + file, + } + } + + /// Returns the platform device coordinate. + #[must_use] + pub const fn device(self) -> u64 { + self.device + } + + /// Returns the platform mount coordinate. + #[must_use] + pub const fn mount(self) -> u64 { + self.mount + } + + /// Returns the platform file coordinate. + #[must_use] + pub const fn file(self) -> u64 { + self.file + } +} + +/// One `reader.lock` coordinate a receipt failed to bind to its intent. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReaderLockCoordinate { + /// Platform device coordinate. + Device, + /// Platform mount coordinate. + Mount, + /// Platform file coordinate. + File, +} diff --git a/src/adapters/gc/receipt.rs b/src/adapters/gc/receipt.rs new file mode 100644 index 00000000..aca18d0d --- /dev/null +++ b/src/adapters/gc/receipt.rs @@ -0,0 +1,104 @@ +//! This boundary module owns the semantic GC retirement receipt. + +use super::{ + GcCandidateSetDigest, GcRetirementIntent, GcRetirementIntentDigest, PoolStateDigest, + ReaderLockIdentity, +}; +use crate::{ + CatalogDigest, CatalogGeneration, GcGeneration, LivenessGeneration, RetentionManifestDigest, +}; + +/// The completion statement of one retirement. +/// +/// Every coordinate except the pool-state digest and the synchronization +/// count is bound from the intent the receipt completes; the executor +/// revalidates them and the decoder refuses a receipt that disagrees with +/// its intent. The synchronization count is one per unlinked candidate. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GcRetirementReceipt { + generation: GcGeneration, + intent_digest: GcRetirementIntentDigest, + retired_candidate_set_digest: GcCandidateSetDigest, + pool_state_digest: PoolStateDigest, + liveness_generation: LivenessGeneration, + manifest_digest: RetentionManifestDigest, + catalog_generation: CatalogGeneration, + catalog_digest: CatalogDigest, + reader_lock: ReaderLockIdentity, + synchronization_count: u64, +} + +impl GcRetirementReceipt { + pub(super) fn for_intent( + intent_digest: GcRetirementIntentDigest, + retired_candidate_set_digest: GcCandidateSetDigest, + intent: &GcRetirementIntent, + pool_state_digest: PoolStateDigest, + ) -> Self { + let coordinates = intent.coordinates(); + Self { + generation: coordinates.generation, + intent_digest, + retired_candidate_set_digest, + pool_state_digest, + liveness_generation: coordinates.liveness_generation, + manifest_digest: coordinates.manifest_digest, + catalog_generation: coordinates.catalog_generation, + catalog_digest: coordinates.catalog_digest, + reader_lock: coordinates.reader_lock, + synchronization_count: u64::from(intent.candidate_count()), + } + } + + /// Returns the completed retirement generation. + pub const fn generation(&self) -> GcGeneration { + self.generation + } + + /// Returns the digest of the completed intent. + pub const fn intent_digest(&self) -> GcRetirementIntentDigest { + self.intent_digest + } + + /// Returns the digest of the retired candidate set. + pub const fn retired_candidate_set_digest(&self) -> GcCandidateSetDigest { + self.retired_candidate_set_digest + } + + /// Returns the digest of the synchronized pool after retirement. + pub const fn pool_state_digest(&self) -> PoolStateDigest { + self.pool_state_digest + } + + /// Returns the revalidated liveness generation. + pub const fn liveness_generation(&self) -> LivenessGeneration { + self.liveness_generation + } + + /// Returns the revalidated retention-manifest digest. + pub const fn manifest_digest(&self) -> RetentionManifestDigest { + self.manifest_digest + } + + /// Returns the revalidated catalog generation. + pub const fn catalog_generation(&self) -> CatalogGeneration { + self.catalog_generation + } + + /// Returns the revalidated catalog digest. + pub const fn catalog_digest(&self) -> CatalogDigest { + self.catalog_digest + } + + /// Returns the identity of the exclusively locked `reader.lock`. + pub const fn reader_lock(&self) -> ReaderLockIdentity { + self.reader_lock + } + + /// Returns the number of completed pool-directory synchronizations. + #[must_use] + pub const fn synchronization_count(&self) -> u64 { + self.synchronization_count + } +} diff --git a/src/adapters/gc/receipt_bytes.rs b/src/adapters/gc/receipt_bytes.rs new file mode 100644 index 00000000..b229596e --- /dev/null +++ b/src/adapters/gc/receipt_bytes.rs @@ -0,0 +1,44 @@ +//! This boundary module owns fixed-width GC retirement receipt field access. + +use super::GcRetirementReceiptDecodeError as Error; +use super::receipt_format::ENCODED_LENGTH; + +pub(super) const fn require_length(encoded: &[u8]) -> Result<(), Error> { + if encoded.len() == ENCODED_LENGTH { + Ok(()) + } else { + Err(wrong_length(encoded)) + } +} + +pub(super) const fn wrong_length(encoded: &[u8]) -> Error { + Error::WrongLength { + expected: ENCODED_LENGTH, + observed: encoded.len(), + } +} + +pub(super) fn read_u16(encoded: &[u8], offset: usize) -> Result { + read_array(encoded, offset).map(u16::from_be_bytes) +} + +pub(super) fn read_u32(encoded: &[u8], offset: usize) -> Result { + read_array(encoded, offset).map(u32::from_be_bytes) +} + +pub(super) fn read_u64(encoded: &[u8], offset: usize) -> Result { + read_array(encoded, offset).map(u64::from_be_bytes) +} + +pub(super) fn read_array( + encoded: &[u8], + offset: usize, +) -> Result<[u8; WIDTH], Error> { + let Some(end) = offset.checked_add(WIDTH) else { + return Err(wrong_length(encoded)); + }; + let bytes = encoded + .get(offset..end) + .ok_or_else(|| wrong_length(encoded))?; + <[u8; WIDTH]>::try_from(bytes).map_err(|_| wrong_length(encoded)) +} diff --git a/src/adapters/gc/receipt_decode_error.rs b/src/adapters/gc/receipt_decode_error.rs new file mode 100644 index 00000000..bfef43f8 --- /dev/null +++ b/src/adapters/gc/receipt_decode_error.rs @@ -0,0 +1,174 @@ +//! This boundary module owns typed GC retirement receipt decoding failures. + +use std::error::Error; +use std::fmt; + +use super::ReaderLockCoordinate; + +/// Failure to decode and admit one GC retirement receipt. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GcRetirementReceiptDecodeError { + /// The input was not exactly one complete fixed-width receipt. + WrongLength { + /// Required fixed width. + expected: usize, + /// Observed input width. + observed: usize, + }, + /// The fixed record magic was not canonical. + InvalidMagic { + /// Observed 16 magic bytes. + observed: [u8; 16], + }, + /// The format version is unsupported. + UnsupportedVersion { + /// Supported version. + expected: u16, + /// Observed version. + observed: u16, + }, + /// The record-length field was noncanonical. + InvalidRecordLength { + /// Required record length. + expected: u16, + /// Observed record length. + observed: u16, + }, + /// The receipt carried unsupported flags. + UnsupportedFlags { + /// Observed flag bits. + observed: u32, + }, + /// The reserved bytes were nonzero. + NonZeroReserved, + /// The checksum did not match the exact prefix. + ChecksumMismatch { + /// Computed canonical checksum. + expected: [u8; 32], + /// Checksum stored in the record. + observed: [u8; 32], + }, + /// The receipt named a generation other than its intent's. + GenerationMismatch { + /// Intent generation. + expected: u64, + /// Receipt generation. + observed: u64, + }, + /// The receipt did not bind the supplied admitted intent. + IntentDigestMismatch { + /// Supplied intent digest. + expected: [u8; 32], + /// Receipt intent digest. + observed: [u8; 32], + }, + /// The retired candidate set was not the intent's candidate set. + RetiredSetDigestMismatch { + /// Intent candidate-set digest. + expected: [u8; 32], + /// Receipt retired-set digest. + observed: [u8; 32], + }, + /// The revalidated liveness generation disagreed with the intent. + LivenessGenerationMismatch { + /// Intent liveness generation. + expected: u64, + /// Receipt liveness generation. + observed: u64, + }, + /// The revalidated retention-manifest digest disagreed with the intent. + ManifestDigestMismatch { + /// Intent manifest digest. + expected: [u8; 32], + /// Receipt manifest digest. + observed: [u8; 32], + }, + /// The revalidated catalog generation disagreed with the intent. + CatalogGenerationMismatch { + /// Intent catalog generation. + expected: u64, + /// Receipt catalog generation. + observed: u64, + }, + /// The revalidated catalog digest disagreed with the intent. + CatalogDigestMismatch { + /// Intent catalog digest. + expected: [u8; 32], + /// Receipt catalog digest. + observed: [u8; 32], + }, + /// One `reader.lock` coordinate disagreed with the intent. + ReaderLockMismatch { + /// The coordinate that disagreed. + coordinate: ReaderLockCoordinate, + /// Intent value. + expected: u64, + /// Receipt value. + observed: u64, + }, + /// The synchronization count was not one per candidate. + SynchronizationCountMismatch { + /// Intent-derived count. + expected: u64, + /// Receipt count. + observed: u64, + }, +} + +impl fmt::Display for GcRetirementReceiptDecodeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongLength { expected, observed } => write!( + formatter, + "GC receipt requires {expected} bytes, observed {observed}" + ), + Self::InvalidMagic { .. } => formatter.write_str("invalid GC receipt magic"), + Self::UnsupportedVersion { expected, observed } => write!( + formatter, + "unsupported GC receipt version {observed}; expected {expected}" + ), + Self::InvalidRecordLength { expected, observed } => write!( + formatter, + "GC receipt record length {observed}; expected {expected}" + ), + Self::UnsupportedFlags { observed } => { + write!(formatter, "unsupported GC receipt flags {observed:#010x}") + } + Self::NonZeroReserved => formatter.write_str("GC receipt reserved bytes are nonzero"), + Self::ChecksumMismatch { .. } => formatter.write_str("GC receipt checksum mismatch"), + Self::GenerationMismatch { expected, observed } => write!( + formatter, + "GC receipt generation {observed} does not complete intent generation {expected}" + ), + Self::IntentDigestMismatch { .. } => { + formatter.write_str("GC receipt intent digest mismatch") + } + Self::RetiredSetDigestMismatch { .. } => { + formatter.write_str("GC receipt retired candidate-set digest mismatch") + } + Self::LivenessGenerationMismatch { expected, observed } => write!( + formatter, + "GC receipt liveness generation {observed}; intent bound {expected}" + ), + Self::ManifestDigestMismatch { .. } => { + formatter.write_str("GC receipt retention-manifest digest mismatch") + } + Self::CatalogGenerationMismatch { expected, observed } => write!( + formatter, + "GC receipt catalog generation {observed}; intent bound {expected}" + ), + Self::CatalogDigestMismatch { .. } => { + formatter.write_str("GC receipt catalog digest mismatch") + } + Self::ReaderLockMismatch { coordinate, .. } => { + write!(formatter, "GC receipt reader-lock {coordinate:?} mismatch") + } + Self::SynchronizationCountMismatch { expected, observed } => write!( + formatter, + "GC receipt synchronization count {observed}; intent derives {expected}" + ), + } + } +} + +impl Error for GcRetirementReceiptDecodeError {} diff --git a/src/adapters/gc/receipt_decoder.rs b/src/adapters/gc/receipt_decoder.rs new file mode 100644 index 00000000..a445f945 --- /dev/null +++ b/src/adapters/gc/receipt_decoder.rs @@ -0,0 +1,171 @@ +//! This boundary module owns GC retirement receipt decoding order. + +use super::GcRetirementReceiptDecodeError as Error; +use super::receipt_bytes::{ + read_array, read_u16, read_u32, read_u64, require_length, wrong_length, +}; +use super::{ + AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, GcRetirementReceipt, PoolStateDigest, + ReaderLockCoordinate, receipt_format as format, +}; + +pub(super) fn decode<'encoded>( + encoded: &'encoded [u8], + intent: &AdmittedGcRetirementIntent<'_>, +) -> Result, Error> { + require_length(encoded)?; + validate_fixed_fields(encoded)?; + verify_checksum(encoded)?; + let coordinates = intent.intent().coordinates(); + require_u64( + encoded, + 24, + coordinates.generation.get(), + |expected, observed| Error::GenerationMismatch { expected, observed }, + )?; + require_digest( + encoded, + 32, + intent.digest().as_bytes(), + |expected, observed| Error::IntentDigestMismatch { expected, observed }, + )?; + require_digest( + encoded, + 64, + intent.candidate_set_digest().as_bytes(), + |expected, observed| Error::RetiredSetDigestMismatch { expected, observed }, + )?; + let pool_state_digest = PoolStateDigest::new(read_array(encoded, 96)?); + require_u64( + encoded, + 128, + coordinates.liveness_generation.get(), + |expected, observed| Error::LivenessGenerationMismatch { expected, observed }, + )?; + require_digest( + encoded, + 136, + coordinates.manifest_digest.as_bytes(), + |expected, observed| Error::ManifestDigestMismatch { expected, observed }, + )?; + require_u64( + encoded, + 168, + coordinates.catalog_generation.get(), + |expected, observed| Error::CatalogGenerationMismatch { expected, observed }, + )?; + require_digest( + encoded, + 176, + coordinates.catalog_digest.as_bytes(), + |expected, observed| Error::CatalogDigestMismatch { expected, observed }, + )?; + require_reader_lock(encoded, intent)?; + require_u64( + encoded, + 232, + u64::from(intent.intent().candidate_count()), + |expected, observed| Error::SynchronizationCountMismatch { expected, observed }, + )?; + let receipt = GcRetirementReceipt::for_intent( + intent.digest(), + intent.candidate_set_digest(), + intent.intent(), + pool_state_digest, + ); + Ok(AdmittedGcRetirementReceipt::admitted(encoded, receipt)) +} + +fn validate_fixed_fields(encoded: &[u8]) -> Result<(), Error> { + let magic = read_array(encoded, 0)?; + if magic != format::MAGIC { + return Err(Error::InvalidMagic { observed: magic }); + } + let version = read_u16(encoded, 16)?; + if version != format::VERSION { + return Err(Error::UnsupportedVersion { + expected: format::VERSION, + observed: version, + }); + } + let record_length = read_u16(encoded, 18)?; + if record_length != format::RECORD_LENGTH { + return Err(Error::InvalidRecordLength { + expected: format::RECORD_LENGTH, + observed: record_length, + }); + } + let flags = read_u32(encoded, 20)?; + if flags != 0 { + return Err(Error::UnsupportedFlags { observed: flags }); + } + let reserved: [u8; format::RESERVED_LENGTH] = read_array(encoded, format::RESERVED_OFFSET)?; + if reserved != [0_u8; format::RESERVED_LENGTH] { + return Err(Error::NonZeroReserved); + } + Ok(()) +} + +fn verify_checksum(encoded: &[u8]) -> Result<(), Error> { + let preimage = encoded + .get(..format::CHECKSUM_OFFSET) + .ok_or_else(|| wrong_length(encoded))?; + let observed = read_array(encoded, format::CHECKSUM_OFFSET)?; + let expected = format::checksum(preimage); + if observed == expected { + Ok(()) + } else { + Err(Error::ChecksumMismatch { expected, observed }) + } +} + +fn require_reader_lock( + encoded: &[u8], + intent: &AdmittedGcRetirementIntent<'_>, +) -> Result<(), Error> { + let bound = intent.intent().coordinates().reader_lock; + for (coordinate, offset, expected) in [ + (ReaderLockCoordinate::Device, 208, bound.device()), + (ReaderLockCoordinate::Mount, 216, bound.mount()), + (ReaderLockCoordinate::File, 224, bound.file()), + ] { + let observed = read_u64(encoded, offset)?; + if observed != expected { + return Err(Error::ReaderLockMismatch { + coordinate, + expected, + observed, + }); + } + } + Ok(()) +} + +fn require_u64(encoded: &[u8], offset: usize, expected: u64, error: F) -> Result<(), Error> +where + F: FnOnce(u64, u64) -> Error, +{ + let observed = read_u64(encoded, offset)?; + if observed == expected { + Ok(()) + } else { + Err(error(expected, observed)) + } +} + +fn require_digest( + encoded: &[u8], + offset: usize, + expected: &[u8; 32], + error: F, +) -> Result<(), Error> +where + F: FnOnce([u8; 32], [u8; 32]) -> Error, +{ + let observed: [u8; 32] = read_array(encoded, offset)?; + if observed == *expected { + Ok(()) + } else { + Err(error(*expected, observed)) + } +} diff --git a/src/adapters/gc/receipt_encoder.rs b/src/adapters/gc/receipt_encoder.rs new file mode 100644 index 00000000..f8d7e3e3 --- /dev/null +++ b/src/adapters/gc/receipt_encoder.rs @@ -0,0 +1,47 @@ +//! This boundary module owns canonical GC retirement receipt encoding. + +use super::{CanonicalGcRetirementReceipt, GcRetirementReceipt, receipt_format as format}; + +pub(super) fn encode(receipt: GcRetirementReceipt) -> CanonicalGcRetirementReceipt { + let mut encoded = [0_u8; format::ENCODED_LENGTH]; + let (preimage, checksum_slot) = encoded.split_at_mut(format::CHECKSUM_OFFSET); + write_preimage(preimage, &receipt); + checksum_slot.copy_from_slice(&format::checksum(preimage)); + CanonicalGcRetirementReceipt::admitted(&encoded, receipt) +} + +fn write_preimage(output: &mut [u8], receipt: &GcRetirementReceipt) { + let (magic, output) = output.split_at_mut(16); + magic.copy_from_slice(&format::MAGIC); + let (version, output) = output.split_at_mut(2); + version.copy_from_slice(&format::VERSION.to_be_bytes()); + let (record_length, output) = output.split_at_mut(2); + record_length.copy_from_slice(&format::RECORD_LENGTH.to_be_bytes()); + let (flags, output) = output.split_at_mut(4); + flags.copy_from_slice(&0_u32.to_be_bytes()); + let (generation, output) = output.split_at_mut(8); + generation.copy_from_slice(&receipt.generation().get().to_be_bytes()); + let (intent_digest, output) = output.split_at_mut(32); + intent_digest.copy_from_slice(receipt.intent_digest().as_bytes()); + let (retired_set, output) = output.split_at_mut(32); + retired_set.copy_from_slice(receipt.retired_candidate_set_digest().as_bytes()); + let (pool_state, output) = output.split_at_mut(32); + pool_state.copy_from_slice(receipt.pool_state_digest().as_bytes()); + let (liveness, output) = output.split_at_mut(8); + liveness.copy_from_slice(&receipt.liveness_generation().get().to_be_bytes()); + let (manifest_digest, output) = output.split_at_mut(32); + manifest_digest.copy_from_slice(receipt.manifest_digest().as_bytes()); + let (catalog_generation, output) = output.split_at_mut(8); + catalog_generation.copy_from_slice(&receipt.catalog_generation().get().to_be_bytes()); + let (catalog_digest, output) = output.split_at_mut(32); + catalog_digest.copy_from_slice(receipt.catalog_digest().as_bytes()); + let (device, output) = output.split_at_mut(8); + device.copy_from_slice(&receipt.reader_lock().device().to_be_bytes()); + let (mount, output) = output.split_at_mut(8); + mount.copy_from_slice(&receipt.reader_lock().mount().to_be_bytes()); + let (file, output) = output.split_at_mut(8); + file.copy_from_slice(&receipt.reader_lock().file().to_be_bytes()); + let (synchronization_count, reserved) = output.split_at_mut(8); + synchronization_count.copy_from_slice(&receipt.synchronization_count().to_be_bytes()); + reserved.fill(0); +} diff --git a/src/adapters/gc/receipt_format.rs b/src/adapters/gc/receipt_format.rs new file mode 100644 index 00000000..71575cb6 --- /dev/null +++ b/src/adapters/gc/receipt_format.rs @@ -0,0 +1,17 @@ +//! This boundary module owns GC retirement receipt framing and integrity. + +pub(super) const CHECKSUM_OFFSET: usize = 288; +pub(super) const ENCODED_LENGTH: usize = 320; +pub(super) const MAGIC: [u8; 16] = *b"KEEP:GC:RECEIPT2"; +pub(super) const RECORD_LENGTH: u16 = 320; +pub(super) const VERSION: u16 = 2; +pub(super) const RESERVED_OFFSET: usize = 240; +pub(super) const RESERVED_LENGTH: usize = 48; +const CHECKSUM_DOMAIN: &[u8] = b"keep.gc-retirement-receipt-checksum/v2\0"; + +pub(super) fn checksum(preimage: &[u8]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(CHECKSUM_DOMAIN); + hasher.update(preimage); + *hasher.finalize().as_bytes() +} diff --git a/src/adapters/gc/record_digests.rs b/src/adapters/gc/record_digests.rs new file mode 100644 index 00000000..872c244f --- /dev/null +++ b/src/adapters/gc/record_digests.rs @@ -0,0 +1,35 @@ +//! This boundary module owns the digests a GC record computes over itself. + +/// Canonical BLAKE3-256 digest of one exact candidate entry set. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct GcCandidateSetDigest([u8; 32]); + +impl GcCandidateSetDigest { + pub(super) const fn from_verified(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Returns the exact 32 digest bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +/// Canonical BLAKE3-256 identity of one complete GC retirement intent. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct GcRetirementIntentDigest([u8; 32]); + +impl GcRetirementIntentDigest { + pub(super) const fn from_hash(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Returns the exact 32 digest bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 39627605..dd6eb15d 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -135,6 +135,7 @@ mod filesystem_version_two_record_refusal; mod filesystem_version_two_records; mod filesystem_writer_lock; mod framed_blake3; +mod gc; mod layout_decode_error; mod layout_decode_error_display; mod layout_decode_policy; diff --git a/src/gc/generation.rs b/src/gc/generation.rs new file mode 100644 index 00000000..7f87cec2 --- /dev/null +++ b/src/gc/generation.rs @@ -0,0 +1,49 @@ +//! This module owns checked garbage-collection generations. + +use std::num::NonZeroU64; + +use super::GcGenerationError; + +/// Positive generation of one garbage-collection retirement. +/// +/// This coordinate is deliberately distinct from every catalog, root, and +/// liveness generation: it counts retirements, not publications. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct GcGeneration(NonZeroU64); + +impl GcGeneration { + /// First garbage-collection generation. + pub const INITIAL: Self = Self(NonZeroU64::MIN); + + /// Admits one positive garbage-collection generation. + /// + /// # Errors + /// + /// Returns [`GcGenerationError::Zero`] when `value` is zero. + pub const fn new(value: u64) -> Result { + match NonZeroU64::new(value) { + Some(value) => Ok(Self(value)), + None => Err(GcGenerationError::Zero), + } + } + + /// Returns the exact positive generation. + #[must_use] + pub const fn get(self) -> u64 { + self.0.get() + } + + /// Derives the exact successor through checked addition. + /// + /// # Errors + /// + /// Returns [`GcGenerationError::Exhausted`] at `u64::MAX`. + pub const fn successor(self) -> Result { + let current = self.get(); + let Some(next) = current.checked_add(1) else { + return Err(GcGenerationError::Exhausted { current }); + }; + Self::new(next) + } +} diff --git a/src/gc/generation_error.rs b/src/gc/generation_error.rs new file mode 100644 index 00000000..1565ffda --- /dev/null +++ b/src/gc/generation_error.rs @@ -0,0 +1,30 @@ +//! This module owns garbage-collection generation admission failures. + +use std::error::Error; +use std::fmt; + +/// Failure to admit or advance one garbage-collection generation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GcGenerationError { + /// Generation zero is never a retirement. + Zero, + /// The generation space is exhausted. + Exhausted { + /// Current maximum generation. + current: u64, + }, +} + +impl fmt::Display for GcGenerationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Zero => formatter.write_str("garbage-collection generation must be positive"), + Self::Exhausted { current } => write!( + formatter, + "garbage-collection generation {current} has no successor" + ), + } + } +} + +impl Error for GcGenerationError {} diff --git a/src/gc/mod.rs b/src/gc/mod.rs new file mode 100644 index 00000000..6378b5e1 --- /dev/null +++ b/src/gc/mod.rs @@ -0,0 +1,10 @@ +//! Semantic garbage-collection coordinates. +//! +//! This module owns the checked garbage-collection generation. It does not +//! own record encoding, retirement planning, execution, or recovery. + +mod generation; +mod generation_error; + +pub use generation::GcGeneration; +pub use generation_error::GcGenerationError; diff --git a/src/lib.rs b/src/lib.rs index 6611c89b..b8f39382 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,6 +49,7 @@ mod adapters; mod blob; mod catalog; mod chunk; +mod gc; mod layout; mod profile; mod reference; @@ -132,6 +133,15 @@ pub use adapters::{ plan_recovery_segment_resume, plan_recovery_stage_completion, plan_recovery_stage_discard, publish_catalog_generation, read_recovery_inventory, }; +pub use adapters::{ + AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, CanonicalGcRetirementIntent, + CanonicalGcRetirementReceipt, CatalogSuccessorProofDigest, DispositionSetDigest, GcCandidate, + GcCandidateSetDigest, GcRetirementIntent, GcRetirementIntentCoordinates, + GcRetirementIntentDecodeError, GcRetirementIntentDigest, GcRetirementIntentEncodeError, + GcRetirementIntentError, GcRetirementReceipt, GcRetirementReceiptDecodeError, PoolStateDigest, + ReaderLockCoordinate, ReaderLockIdentity, SegmentPoolIdentityDigest, + VerificationEvidenceDigest, +}; pub use adapters::{ AdmittedRetentionManifest, AdmittedRetentionRoot, CanonicalRetentionHead, CanonicalRetentionManifest, CanonicalRetentionRoot, ChecksummedRetentionHead, @@ -157,6 +167,7 @@ pub use catalog::{ pub use chunk::{ ChunkHashError, ChunkId, ChunkLength, ChunkOffset, ChunkSpan, ChunkingError, FastCdc, }; +pub use gc::{GcGeneration, GcGenerationError}; pub use layout::{ AdmittedLayout, LayoutEntry, LayoutEntryLimit, LayoutEntryLimitError, LayoutId, LayoutIdMismatch, LayoutRecordLength, LayoutValidationError, RangePlan, RangePlanError, diff --git a/tests/gc_retirement_intent.rs b/tests/gc_retirement_intent.rs new file mode 100644 index 00000000..60e90927 --- /dev/null +++ b/tests/gc_retirement_intent.rs @@ -0,0 +1,102 @@ +//! Canonical GC retirement intent laws. + +#[path = "gc_retirement_intent/mutation_laws.rs"] +mod mutation_laws; +mod support; + +use std::io; + +use keep::{ + AdmittedGcRetirementIntent, CanonicalGcRetirementIntent, GcRetirementIntent, + GcRetirementIntentError, +}; + +pub(crate) const GC_INTENT: &str = + include_str!("../conformance/segment-store/v2/one-candidate-gc-intent.hex"); +pub(crate) const INTENT_DIGEST: [u8; 32] = [ + 0xa9, 0xdd, 0x52, 0x33, 0x26, 0xa6, 0x86, 0xb8, 0x9a, 0xc8, 0xf0, 0xc4, 0x10, 0x42, 0x17, 0x66, + 0xaf, 0xc2, 0x21, 0xf2, 0x96, 0x3b, 0xda, 0xfd, 0x43, 0x0d, 0xce, 0x7f, 0x59, 0xed, 0xc7, 0x01, +]; +pub(crate) const HEADER_LENGTH: usize = 320; +pub(crate) const CANDIDATE_WIDTH: usize = 72; +pub(crate) const CANDIDATE_SET_DIGEST_OFFSET: usize = 288; +pub(crate) const INTENT_DIGEST_OFFSET: usize = 392; +pub(crate) const CHECKSUM_OFFSET: usize = 424; + +#[test] +fn frozen_intent_decodes_and_reencodes_canonically() -> Result<(), Box> { + let bytes = fixture_bytes()?; + let admitted = AdmittedGcRetirementIntent::decode(&bytes)?; + assert_eq!(admitted.encoded(), bytes); + assert_eq!(admitted.digest().as_bytes(), &INTENT_DIGEST); + assert_eq!( + admitted.candidate_set_digest().as_bytes(), + bytes + .get(CANDIDATE_SET_DIGEST_OFFSET..HEADER_LENGTH) + .ok_or_else(|| io::Error::other("frozen intent lacks its candidate-set digest"))? + ); + + let intent = admitted.intent(); + let coordinates = intent.coordinates(); + assert_eq!(coordinates.generation.get(), 1); + assert_eq!(coordinates.liveness_generation.get(), 1); + assert_eq!(coordinates.catalog_generation.get(), 2); + assert_eq!(coordinates.profile.identity(), 1); + assert_eq!(coordinates.reader_lock.device(), 4); + assert_eq!(coordinates.reader_lock.mount(), 5); + assert_eq!(coordinates.reader_lock.file(), 6); + assert_eq!(intent.candidate_count(), 1); + let candidate = intent + .candidates() + .first() + .ok_or_else(|| io::Error::other("frozen intent names no candidate"))?; + assert_eq!(candidate.segment_length(), 337); + assert_eq!( + candidate.segment_digest().as_bytes(), + bytes + .get(HEADER_LENGTH..HEADER_LENGTH + 32) + .ok_or_else(|| io::Error::other("frozen intent lacks its candidate digest"))? + ); + + let canonical = CanonicalGcRetirementIntent::from_intent(intent)?; + assert_eq!(canonical.encoded(), bytes); + assert_eq!(canonical.digest(), admitted.digest()); + assert_eq!( + canonical.candidate_set_digest(), + admitted.candidate_set_digest() + ); + assert_eq!(canonical.intent(), intent); + Ok(()) +} + +#[test] +fn semantic_intent_refuses_empty_and_repeated_candidate_sets() +-> Result<(), Box> { + let bytes = fixture_bytes()?; + let admitted = AdmittedGcRetirementIntent::decode(&bytes)?; + let coordinates = *admitted.intent().coordinates(); + let candidate = *admitted + .intent() + .candidates() + .first() + .ok_or_else(|| io::Error::other("frozen intent names no candidate"))?; + + assert!(matches!( + GcRetirementIntent::new(coordinates, Vec::new()), + Err(GcRetirementIntentError::NoCandidates) + )); + assert!(matches!( + GcRetirementIntent::new(coordinates, vec![candidate, candidate]), + Err(GcRetirementIntentError::DuplicateCandidate { index: 1 }) + )); + let one = GcRetirementIntent::new(coordinates, vec![candidate])?; + assert_eq!(one, *admitted.intent()); + Ok(()) +} + +pub(crate) fn fixture_bytes() -> Result, io::Error> { + let encoded = GC_INTENT + .strip_suffix('\n') + .ok_or_else(|| io::Error::other("GC intent fixture lacks final newline"))?; + support::decode_hex(encoded) +} diff --git a/tests/gc_retirement_intent/mutation_laws.rs b/tests/gc_retirement_intent/mutation_laws.rs new file mode 100644 index 00000000..ebc13ea3 --- /dev/null +++ b/tests/gc_retirement_intent/mutation_laws.rs @@ -0,0 +1,341 @@ +//! Field-by-field corruption matrix for GC retirement intents. +//! +//! Every structural field of the intent header, candidate body, and trailer +//! has one mutation and one exact first refusal (`KEEP-GC-001`). The sealed +//! matrix recomputes every digest and checksum the mutation did not target. + +use std::io; + +use keep::{ + AdmittedGcRetirementIntent, GcRetirementIntentDecodeError as Refusal, GcRetirementIntentError, +}; + +use super::{ + CANDIDATE_SET_DIGEST_OFFSET, CANDIDATE_WIDTH, CHECKSUM_OFFSET, HEADER_LENGTH, + INTENT_DIGEST_OFFSET, fixture_bytes, +}; +use crate::support::{counted_domain_hash, domain_hash, flip, patch, read_u32}; + +const CANDIDATE_COUNT_OFFSET: usize = 44; +const TRAILER_LENGTH: usize = 64; +const MAXIMUM_CANDIDATE_COUNT: u32 = 65_536; + +#[derive(Clone, Copy)] +enum Seal { + Nothing, + Checksum, + Digests, + Everything, +} + +struct Mutation { + field: &'static str, + seal: Seal, + mutate: fn(&mut Vec) -> io::Result<()>, + refuses: fn(&Refusal) -> bool, +} + +const MATRIX: &[Mutation] = &[ + Mutation { + field: "magic", + seal: Seal::Everything, + mutate: |bytes| flip(bytes, 15), + refuses: |error| matches!(error, Refusal::InvalidMagic { .. }), + }, + Mutation { + field: "version", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 16, &3_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::UnsupportedVersion { + expected: 2, + observed: 3 + } + ) + }, + }, + Mutation { + field: "header length", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 18, &319_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::InvalidHeaderLength { + expected: 320, + observed: 319 + } + ) + }, + }, + Mutation { + field: "flags", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 20, &1_u32.to_be_bytes()), + refuses: |error| matches!(error, Refusal::UnsupportedFlags { observed: 1 }), + }, + Mutation { + field: "total record length", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 24, &455_u64.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::DeclaredLengthMismatch { + expected: 456, + observed: 455 + } + ) + }, + }, + Mutation { + field: "GC generation zero", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 32, &0_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::Generation { .. }), + }, + Mutation { + field: "candidate width", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 40, &71_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::InvalidCandidateWidth { + expected: 72, + observed: 71 + } + ) + }, + }, + Mutation { + field: "reserved candidate bytes", + seal: Seal::Everything, + mutate: |bytes| flip(bytes, 42), + refuses: |error| matches!(error, Refusal::NonZeroReserved { field: "candidate" }), + }, + Mutation { + field: "candidate count participates in the declared length", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, CANDIDATE_COUNT_OFFSET, &2_u32.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::DeclaredLengthMismatch { + expected: 528, + observed: 456 + } + ) + }, + }, + Mutation { + field: "liveness generation zero", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 48, &0_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::LivenessGeneration { .. }), + }, + Mutation { + field: "catalog generation zero", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 88, &0_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::CatalogGeneration { .. }), + }, + Mutation { + field: "profile identity", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 128, &2_u32.to_be_bytes()), + refuses: |error| matches!(error, Refusal::Profile { .. }), + }, + Mutation { + field: "profile version", + seal: Seal::Everything, + mutate: |bytes| patch(bytes, 132, &2_u32.to_be_bytes()), + refuses: |error| matches!(error, Refusal::Profile { .. }), + }, + Mutation { + field: "profile-definition digest", + seal: Seal::Everything, + mutate: |bytes| flip(bytes, 136), + refuses: |error| matches!(error, Refusal::Profile { .. }), + }, + Mutation { + field: "candidate-set digest", + seal: Seal::Digests, + mutate: |bytes| flip(bytes, CANDIDATE_SET_DIGEST_OFFSET), + refuses: |error| matches!(error, Refusal::CandidateSetDigestMismatch { .. }), + }, + Mutation { + field: "intent digest", + seal: Seal::Checksum, + mutate: |bytes| flip(bytes, INTENT_DIGEST_OFFSET), + refuses: |error| matches!(error, Refusal::IntentDigestMismatch { .. }), + }, + Mutation { + field: "checksum", + seal: Seal::Nothing, + mutate: |bytes| flip(bytes, CHECKSUM_OFFSET), + refuses: |error| matches!(error, Refusal::ChecksumMismatch { .. }), + }, +]; + +#[test] +fn every_intent_field_has_one_exact_first_refusal() -> Result<(), Box> { + for mutation in MATRIX { + let mut bytes = fixture_bytes()?; + (mutation.mutate)(&mut bytes)?; + seal(&mut bytes, mutation.seal)?; + let Err(error) = AdmittedGcRetirementIntent::decode(&bytes) else { + return Err(format!("mutated {} was admitted", mutation.field).into()); + }; + assert!( + (mutation.refuses)(&error), + "{} refused with {error:?}", + mutation.field + ); + } + Ok(()) +} + +#[test] +fn intent_framing_refuses_truncation_and_trailing_data() -> Result<(), Box> { + let bytes = fixture_bytes()?; + let mut truncated = bytes.clone(); + assert!(truncated.pop().is_some()); + assert!(matches!( + AdmittedGcRetirementIntent::decode(&truncated), + Err(Refusal::Truncated { + expected: 456, + observed: 455, + }) + )); + let mut trailing = bytes; + trailing.push(0); + assert!(matches!( + AdmittedGcRetirementIntent::decode(&trailing), + Err(Refusal::TrailingData { + expected: 456, + observed: 457, + }) + )); + Ok(()) +} + +#[test] +fn candidate_order_and_count_bounds_refuse_after_complete_integrity() +-> Result<(), Box> { + let candidate = fixture_candidate()?; + let empty = reframe(&[], 0)?; + assert!(matches!( + AdmittedGcRetirementIntent::decode(&empty), + Err(Refusal::Semantic { + source: GcRetirementIntentError::NoCandidates, + }) + )); + + let mut repeated = candidate.clone(); + repeated.extend_from_slice(&candidate); + let repeated = reframe(&repeated, 2)?; + assert!(matches!( + AdmittedGcRetirementIntent::decode(&repeated), + Err(Refusal::Semantic { + source: GcRetirementIntentError::DuplicateCandidate { index: 1 }, + }) + )); + + let mut smaller = candidate.clone(); + patch(&mut smaller, 0, &[0x00])?; + let mut descending = candidate.clone(); + descending.extend_from_slice(&smaller); + let descending = reframe(&descending, 2)?; + assert!(matches!( + AdmittedGcRetirementIntent::decode(&descending), + Err(Refusal::Semantic { + source: GcRetirementIntentError::NonCanonicalCandidateOrder { index: 1 }, + }) + )); + let mut ascending = smaller; + ascending.extend_from_slice(&candidate); + let ascending = reframe(&ascending, 2)?; + let two = AdmittedGcRetirementIntent::decode(&ascending)?; + assert_eq!(two.intent().candidate_count(), 2); + + let count = MAXIMUM_CANDIDATE_COUNT + .checked_add(1) + .ok_or_else(|| io::Error::other("candidate ceiling overflows"))?; + let body_length = usize::try_from(count)? + .checked_mul(CANDIDATE_WIDTH) + .ok_or_else(|| io::Error::other("candidate body overflows"))?; + let oversized = reframe(&vec![0_u8; body_length], count)?; + assert!(matches!( + AdmittedGcRetirementIntent::decode(&oversized), + Err(Refusal::CandidateCountExceeded { + maximum: MAXIMUM_CANDIDATE_COUNT, + observed, + }) if observed == count + )); + Ok(()) +} + +fn fixture_candidate() -> io::Result> { + let bytes = fixture_bytes()?; + bytes + .get(HEADER_LENGTH..INTENT_DIGEST_OFFSET) + .map(<[u8]>::to_vec) + .ok_or_else(|| io::Error::other("frozen intent lacks its candidate body")) +} + +/// Builds an intent from the frozen header with a replaced candidate body. +fn reframe(candidates: &[u8], candidate_count: u32) -> io::Result> { + let fixture = fixture_bytes()?; + let mut bytes = fixture + .get(..HEADER_LENGTH) + .map(<[u8]>::to_vec) + .ok_or_else(|| io::Error::other("frozen intent lacks its header"))?; + bytes.extend_from_slice(candidates); + bytes.extend_from_slice(&[0_u8; TRAILER_LENGTH]); + let total_length = u64::try_from(bytes.len()) + .map_err(|_| io::Error::other("record exceeds the u64 length field"))?; + patch(&mut bytes, 24, &total_length.to_be_bytes())?; + patch( + &mut bytes, + CANDIDATE_COUNT_OFFSET, + &candidate_count.to_be_bytes(), + )?; + seal(&mut bytes, Seal::Everything)?; + Ok(bytes) +} + +fn seal(bytes: &mut [u8], seal: Seal) -> io::Result<()> { + let checksum_offset = bytes + .len() + .checked_sub(32) + .ok_or_else(|| io::Error::other("GC intent lacks a checksum"))?; + let digest_offset = checksum_offset + .checked_sub(32) + .ok_or_else(|| io::Error::other("GC intent lacks a digest"))?; + if matches!(seal, Seal::Everything) { + let count = read_u32(bytes, CANDIDATE_COUNT_OFFSET)?; + let body = bytes + .get(HEADER_LENGTH..digest_offset) + .ok_or_else(|| io::Error::other("GC intent lacks its candidate body"))?; + let digest = counted_domain_hash(b"keep.gc-candidate-set/v2\0", count, body); + patch(bytes, CANDIDATE_SET_DIGEST_OFFSET, &digest)?; + } + if matches!(seal, Seal::Everything | Seal::Digests) { + let preimage = bytes + .get(..digest_offset) + .ok_or_else(|| io::Error::other("GC intent lacks its digest preimage"))?; + let digest = domain_hash(b"keep.gc-retirement-intent/v2\0", preimage); + patch(bytes, digest_offset, &digest)?; + } + if !matches!(seal, Seal::Nothing) { + let preimage = bytes + .get(..checksum_offset) + .ok_or_else(|| io::Error::other("GC intent lacks its checksum preimage"))?; + let checksum = domain_hash(b"keep.gc-retirement-intent-checksum/v2\0", preimage); + patch(bytes, checksum_offset, &checksum)?; + } + Ok(()) +} diff --git a/tests/gc_retirement_receipt.rs b/tests/gc_retirement_receipt.rs new file mode 100644 index 00000000..0c66c044 --- /dev/null +++ b/tests/gc_retirement_receipt.rs @@ -0,0 +1,317 @@ +//! Canonical GC retirement receipt laws. + +mod support; + +use std::io; + +use keep::{ + AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, CanonicalGcRetirementIntent, + CanonicalGcRetirementReceipt, GcRetirementReceiptDecodeError as Refusal, PoolStateDigest, + ReaderLockCoordinate, +}; + +use crate::support::{domain_hash, flip, patch}; + +const GC_INTENT: &str = include_str!("../conformance/segment-store/v2/one-candidate-gc-intent.hex"); +const GC_RECEIPT: &str = + include_str!("../conformance/segment-store/v2/one-candidate-gc-receipt.hex"); +const POOL_STATE_OFFSET: usize = 96; +const CHECKSUM_OFFSET: usize = 288; + +struct Mutation { + field: &'static str, + reseal: bool, + mutate: fn(&mut Vec) -> io::Result<()>, + refuses: fn(&Refusal) -> bool, +} + +const MATRIX: &[Mutation] = &[ + Mutation { + field: "magic", + reseal: true, + mutate: |bytes| flip(bytes, 15), + refuses: |error| matches!(error, Refusal::InvalidMagic { .. }), + }, + Mutation { + field: "version", + reseal: true, + mutate: |bytes| patch(bytes, 16, &3_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::UnsupportedVersion { + expected: 2, + observed: 3 + } + ) + }, + }, + Mutation { + field: "record length", + reseal: true, + mutate: |bytes| patch(bytes, 18, &319_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::InvalidRecordLength { + expected: 320, + observed: 319 + } + ) + }, + }, + Mutation { + field: "flags", + reseal: true, + mutate: |bytes| patch(bytes, 20, &1_u32.to_be_bytes()), + refuses: |error| matches!(error, Refusal::UnsupportedFlags { observed: 1 }), + }, + Mutation { + field: "GC generation", + reseal: true, + mutate: |bytes| patch(bytes, 24, &2_u64.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::GenerationMismatch { + expected: 1, + observed: 2 + } + ) + }, + }, + Mutation { + field: "intent digest", + reseal: true, + mutate: |bytes| flip(bytes, 32), + refuses: |error| matches!(error, Refusal::IntentDigestMismatch { .. }), + }, + Mutation { + field: "retired candidate-set digest", + reseal: true, + mutate: |bytes| flip(bytes, 64), + refuses: |error| matches!(error, Refusal::RetiredSetDigestMismatch { .. }), + }, + Mutation { + field: "liveness generation", + reseal: true, + mutate: |bytes| patch(bytes, 128, &2_u64.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::LivenessGenerationMismatch { + expected: 1, + observed: 2 + } + ) + }, + }, + Mutation { + field: "retention-manifest digest", + reseal: true, + mutate: |bytes| flip(bytes, 136), + refuses: |error| matches!(error, Refusal::ManifestDigestMismatch { .. }), + }, + Mutation { + field: "catalog generation", + reseal: true, + mutate: |bytes| patch(bytes, 168, &3_u64.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::CatalogGenerationMismatch { + expected: 2, + observed: 3 + } + ) + }, + }, + Mutation { + field: "catalog digest", + reseal: true, + mutate: |bytes| flip(bytes, 176), + refuses: |error| matches!(error, Refusal::CatalogDigestMismatch { .. }), + }, + Mutation { + field: "reader-lock device", + reseal: true, + mutate: |bytes| patch(bytes, 208, &9_u64.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::ReaderLockMismatch { + coordinate: ReaderLockCoordinate::Device, + expected: 4, + observed: 9, + } + ) + }, + }, + Mutation { + field: "reader-lock mount", + reseal: true, + mutate: |bytes| patch(bytes, 216, &9_u64.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::ReaderLockMismatch { + coordinate: ReaderLockCoordinate::Mount, + .. + } + ) + }, + }, + Mutation { + field: "reader-lock file", + reseal: true, + mutate: |bytes| patch(bytes, 224, &9_u64.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::ReaderLockMismatch { + coordinate: ReaderLockCoordinate::File, + .. + } + ) + }, + }, + Mutation { + field: "synchronization count", + reseal: true, + mutate: |bytes| patch(bytes, 232, &2_u64.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::SynchronizationCountMismatch { + expected: 1, + observed: 2 + } + ) + }, + }, + Mutation { + field: "reserved bytes", + reseal: true, + mutate: |bytes| flip(bytes, 287), + refuses: |error| matches!(error, Refusal::NonZeroReserved), + }, + Mutation { + field: "checksum", + reseal: false, + mutate: |bytes| flip(bytes, 319), + refuses: |error| matches!(error, Refusal::ChecksumMismatch { .. }), + }, +]; + +#[test] +fn frozen_receipt_completes_the_frozen_intent_and_reencodes_canonically() +-> Result<(), Box> { + let intent_bytes = fixture_bytes(GC_INTENT)?; + let receipt_bytes = fixture_bytes(GC_RECEIPT)?; + let intent = AdmittedGcRetirementIntent::decode(&intent_bytes)?; + let admitted = AdmittedGcRetirementReceipt::decode(&receipt_bytes, &intent)?; + assert_eq!(admitted.encoded(), receipt_bytes); + let receipt = admitted.receipt(); + assert_eq!( + receipt.generation(), + intent.intent().coordinates().generation + ); + assert_eq!(receipt.intent_digest(), intent.digest()); + assert_eq!( + receipt.retired_candidate_set_digest(), + intent.candidate_set_digest() + ); + assert_eq!(receipt.synchronization_count(), 1); + assert_eq!(receipt.reader_lock().file(), 6); + + let canonical_intent = CanonicalGcRetirementIntent::from_intent(intent.intent())?; + let canonical = + CanonicalGcRetirementReceipt::from_intent(&canonical_intent, receipt.pool_state_digest()); + assert_eq!(canonical.encoded(), receipt_bytes); + assert_eq!(canonical.receipt(), receipt); + Ok(()) +} + +#[test] +fn every_receipt_field_has_one_exact_first_refusal() -> Result<(), Box> { + let intent_bytes = fixture_bytes(GC_INTENT)?; + let intent = AdmittedGcRetirementIntent::decode(&intent_bytes)?; + for mutation in MATRIX { + let mut bytes = fixture_bytes(GC_RECEIPT)?; + (mutation.mutate)(&mut bytes)?; + if mutation.reseal { + reseal(&mut bytes)?; + } + let Err(error) = AdmittedGcRetirementReceipt::decode(&bytes, &intent) else { + return Err(format!("mutated {} was admitted", mutation.field).into()); + }; + assert!( + (mutation.refuses)(&error), + "{} refused with {error:?}", + mutation.field + ); + } + Ok(()) +} + +#[test] +fn receipt_framing_refuses_any_length_but_the_fixed_width() -> Result<(), Box> +{ + let intent_bytes = fixture_bytes(GC_INTENT)?; + let intent = AdmittedGcRetirementIntent::decode(&intent_bytes)?; + let bytes = fixture_bytes(GC_RECEIPT)?; + let mut truncated = bytes.clone(); + assert!(truncated.pop().is_some()); + assert!(matches!( + AdmittedGcRetirementReceipt::decode(&truncated, &intent), + Err(Refusal::WrongLength { + expected: 320, + observed: 319, + }) + )); + let mut trailing = bytes; + trailing.push(0); + assert!(matches!( + AdmittedGcRetirementReceipt::decode(&trailing, &intent), + Err(Refusal::WrongLength { + expected: 320, + observed: 321, + }) + )); + Ok(()) +} + +#[test] +fn pool_state_digest_is_carried_not_bound_to_the_intent() -> Result<(), Box> +{ + let intent_bytes = fixture_bytes(GC_INTENT)?; + let intent = AdmittedGcRetirementIntent::decode(&intent_bytes)?; + let mut bytes = fixture_bytes(GC_RECEIPT)?; + flip(&mut bytes, POOL_STATE_OFFSET)?; + reseal(&mut bytes)?; + let admitted = AdmittedGcRetirementReceipt::decode(&bytes, &intent)?; + let expected: [u8; 32] = bytes + .get(POOL_STATE_OFFSET..POOL_STATE_OFFSET + 32) + .ok_or_else(|| io::Error::other("receipt lacks its pool-state digest"))? + .try_into()?; + assert_eq!( + admitted.receipt().pool_state_digest(), + PoolStateDigest::new(expected) + ); + Ok(()) +} + +fn fixture_bytes(fixture: &str) -> Result, io::Error> { + let encoded = fixture + .strip_suffix('\n') + .ok_or_else(|| io::Error::other("GC fixture lacks final newline"))?; + support::decode_hex(encoded) +} + +fn reseal(bytes: &mut [u8]) -> io::Result<()> { + let preimage = bytes + .get(..CHECKSUM_OFFSET) + .ok_or_else(|| io::Error::other("GC receipt lacks its checksum preimage"))?; + let checksum = domain_hash(b"keep.gc-retirement-receipt-checksum/v2\0", preimage); + patch(bytes, CHECKSUM_OFFSET, &checksum) +} diff --git a/xtask/src/fuzz_campaign/target/tests.rs b/xtask/src/fuzz_campaign/target/tests.rs index b43744d5..ac517a42 100644 --- a/xtask/src/fuzz_campaign/target/tests.rs +++ b/xtask/src/fuzz_campaign/target/tests.rs @@ -28,6 +28,7 @@ fn checked_in_harness_set_is_exact_and_sorted() -> Result<(), Box> { "blob_id_text", "catalog_format", "fast_cdc", + "gc_format", "golden_protocol", "layout_record", "migration_format", diff --git a/xtask/src/fuzz_seed_corpus.rs b/xtask/src/fuzz_seed_corpus.rs index 3635322a..7fca8dfe 100644 --- a/xtask/src/fuzz_seed_corpus.rs +++ b/xtask/src/fuzz_seed_corpus.rs @@ -3,6 +3,7 @@ mod catalog_seeds; mod cdc_seeds; mod filesystem; +mod gc_seeds; mod identity_seeds; mod layout_seeds; mod migration_seeds; @@ -70,6 +71,7 @@ pub(super) fn prepare(repository_root: &Path) -> Result<(), FuzzSeedError> { let mut seeds = identity_seeds::seeds(&files)?; seeds.extend(catalog_seeds::seeds(&files)?); seeds.extend(cdc_seeds::seeds()?); + seeds.extend(gc_seeds::seeds(&files)?); seeds.extend(golden_protocol_seeds_from(&files)?); seeds.extend(layout_seeds::seeds(&files)?); seeds.extend(migration_seeds::seeds(&files)?); diff --git a/xtask/src/fuzz_seed_corpus/gc_seeds.rs b/xtask/src/fuzz_seed_corpus/gc_seeds.rs new file mode 100644 index 00000000..49914d9a --- /dev/null +++ b/xtask/src/fuzz_seed_corpus/gc_seeds.rs @@ -0,0 +1,80 @@ +//! This module owns canonical GC retirement record fuzz seeds. + +use super::filesystem::RepositoryFiles; +use super::segment_store_v2_fixture; +use super::{FuzzSeedError, MAX_SEED_BYTES, Seed, prefixed}; + +const GC_INTENT_FIXTURE: &str = "one-candidate-gc-intent.hex"; +const GC_RECEIPT_FIXTURE: &str = "one-candidate-gc-receipt.hex"; + +pub(super) const FIXTURES: [(u8, &str); 2] = [(0, GC_INTENT_FIXTURE), (1, GC_RECEIPT_FIXTURE)]; + +pub(super) fn seeds(files: &RepositoryFiles) -> Result, FuzzSeedError> { + let [ + (intent_selector, intent_fixture), + (receipt_selector, receipt_fixture), + ] = FIXTURES; + let intent = segment_store_v2_fixture::read_hex(files, intent_fixture)?; + let receipt = segment_store_v2_fixture::read_hex(files, receipt_fixture)?; + Ok(vec![ + Seed::new( + "gc_format", + "one-candidate-gc-intent", + prefixed(intent_selector, &intent)?, + )?, + Seed::new( + "gc_format", + "one-candidate-gc-receipt", + receipt_seed(receipt_selector, &intent, &receipt)?, + )?, + ]) +} + +/// Frames the receipt seed as selector, big-endian `u32` intent length, the +/// exact intent, then the receipt, matching the target's decoding order. +fn receipt_seed(selector: u8, intent: &[u8], receipt: &[u8]) -> Result, FuzzSeedError> { + let intent_length = u32::try_from(intent.len()) + .map_err(|_| FuzzSeedError::violation("GC intent seed exceeds the u32 length frame"))?; + let payload_bytes = intent + .len() + .checked_add(4) + .and_then(|length| length.checked_add(receipt.len())) + .ok_or_else(|| FuzzSeedError::violation("GC receipt seed length overflow"))?; + let framed_bytes = payload_bytes + .checked_add(1) + .ok_or_else(|| FuzzSeedError::violation("GC receipt seed length overflow"))?; + if framed_bytes > MAX_SEED_BYTES { + return Err(FuzzSeedError::violation( + "GC receipt seed exceeds the input bound", + )); + } + let mut payload = Vec::with_capacity(payload_bytes); + payload.extend_from_slice(&intent_length.to_be_bytes()); + payload.extend_from_slice(intent); + payload.extend_from_slice(receipt); + prefixed(selector, &payload) +} + +#[cfg(test)] +mod tests { + use super::{FuzzSeedError, MAX_SEED_BYTES, receipt_seed}; + + #[test] + fn receipt_seed_frames_the_intent_length_before_both_records() -> Result<(), FuzzSeedError> { + let seed = receipt_seed(1, b"intent", b"receipt")?; + assert_eq!(seed, b"\x01\x00\x00\x00\x06intentreceipt"); + Ok(()) + } + + #[test] + fn receipt_seed_refuses_before_allocating_above_the_seed_bound() -> Result<(), FuzzSeedError> { + let oversized_intent = vec![0; MAX_SEED_BYTES]; + let Err(FuzzSeedError::Violation(message)) = receipt_seed(1, &oversized_intent, &[]) else { + return Err(FuzzSeedError::violation( + "oversized GC receipt seed was admitted", + )); + }; + assert_eq!(message, "GC receipt seed exceeds the input bound"); + Ok(()) + } +} diff --git a/xtask/src/fuzz_seed_corpus/tests/materialization.rs b/xtask/src/fuzz_seed_corpus/tests/materialization.rs index 37dd51dc..fb9f9c7b 100644 --- a/xtask/src/fuzz_seed_corpus/tests/materialization.rs +++ b/xtask/src/fuzz_seed_corpus/tests/materialization.rs @@ -4,8 +4,8 @@ use std::collections::BTreeMap; use std::path::Path; use super::super::{ - FuzzSeedError, catalog_seeds, layout_seeds, migration_seeds, prepare, retention_seeds, - segment_seeds, + FuzzSeedError, catalog_seeds, gc_seeds, layout_seeds, migration_seeds, prepare, + retention_seeds, segment_seeds, }; use crate::test_directory::TestDirectory; @@ -47,8 +47,9 @@ fn seed_preparation_materializes_the_complete_deterministic_set() prepare(root)?; let corpus = root.join("fuzz/corpus"); let first = seed_contents(&corpus)?; - assert_eq!(first.len(), 46); + assert_eq!(first.len(), 48); assert_eq!(target_seed_count(&first, "catalog_format/"), 6); + assert_eq!(target_seed_count(&first, "gc_format/"), 2); assert_eq!(target_seed_count(&first, "golden_protocol/"), 9); assert_eq!(target_seed_count(&first, "layout_record/"), 4); assert_eq!(target_seed_count(&first, "migration_format/"), 3); @@ -131,7 +132,8 @@ fn copy_version_two_fixtures(source_root: &Path, root: &Path) -> Result<(), Fuzz })?; let fixtures = retention_seeds::FIXTURES .into_iter() - .chain(migration_seeds::FIXTURES); + .chain(migration_seeds::FIXTURES) + .chain(gc_seeds::FIXTURES); for (_selector, fixture) in fixtures { let source_path = source_root .join("conformance/segment-store/v2") diff --git a/xtask/tests/retention_store_v2_conformance_contract.rs b/xtask/tests/retention_store_v2_conformance_contract.rs index 38f7a601..d5a99bf1 100644 --- a/xtask/tests/retention_store_v2_conformance_contract.rs +++ b/xtask/tests/retention_store_v2_conformance_contract.rs @@ -23,6 +23,8 @@ const REQUIRED_PATHS: &[&str] = &[ "one-anchor-root.hex", "one-root-manifest.hex", "one-root-head.hex", + "one-candidate-gc-intent.hex", + "one-candidate-gc-receipt.hex", ]; fn repository_root() -> Result { diff --git a/xtask/tests/retention_store_v2_format_oracle.rs b/xtask/tests/retention_store_v2_format_oracle.rs index 86081ecc..d7c1bbe4 100644 --- a/xtask/tests/retention_store_v2_format_oracle.rs +++ b/xtask/tests/retention_store_v2_format_oracle.rs @@ -10,6 +10,11 @@ const LAYOUTS: &str = include_str!("../../conformance/layout/v1/layouts.tsv"); const V1_SEGMENT: &str = include_str!("../../conformance/segment-store/v1/one-zero-segment.hex"); const V1_CATALOG: &str = include_str!("../../conformance/segment-store/v1/one-zero-catalog.hex"); const V1_HEAD: &str = include_str!("../../conformance/segment-store/v1/one-zero-head.hex"); +const V1_CATALOG_TWO: &str = + include_str!("../../conformance/segment-store/v1/one-zero-catalog-generation-two.hex"); +const V1_HEAD_TWO: &str = + include_str!("../../conformance/segment-store/v1/one-zero-head-generation-two.hex"); +const V1_EMPTY_SEGMENT: &str = include_str!("../../conformance/segment-store/v1/empty-segment.hex"); struct Artifact { case_name: &'static str, diff --git a/xtask/tests/retention_store_v2_format_oracle/artifacts.rs b/xtask/tests/retention_store_v2_format_oracle/artifacts.rs index 06b2f504..d04cd46e 100644 --- a/xtask/tests/retention_store_v2_format_oracle/artifacts.rs +++ b/xtask/tests/retention_store_v2_format_oracle/artifacts.rs @@ -16,6 +16,8 @@ fn build_corpus() -> Result { let root = build_retention_root(profile_digest)?; let manifest = build_retention_manifest(&root)?; let head = build_retention_head(&manifest)?; + let gc_intent = build_gc_intent(profile_digest, inventory.digest, &manifest)?; + let gc_receipt = build_gc_receipt(&gc_intent)?; let artifacts = vec![ format, intent, @@ -41,6 +43,8 @@ fn build_corpus() -> Result { bytes: manifest.bytes, }, head, + gc_intent, + gc_receipt, ]; Ok(Corpus { profile_digest, @@ -141,5 +145,6 @@ fn migration_source( }) } +include!("artifacts/gc.rs"); include!("artifacts/migration.rs"); include!("artifacts/retention.rs"); diff --git a/xtask/tests/retention_store_v2_format_oracle/artifacts/gc.rs b/xtask/tests/retention_store_v2_format_oracle/artifacts/gc.rs new file mode 100644 index 00000000..75a0e1c9 --- /dev/null +++ b/xtask/tests/retention_store_v2_format_oracle/artifacts/gc.rs @@ -0,0 +1,135 @@ +// This included source owns construction of the GC retirement intent and +// receipt records over the accepted version-1 and version-2 fixtures. + +const GC_CANDIDATE_SET_DOMAIN: &[u8] = b"keep.gc-candidate-set/v2\0"; +const GC_INTENT_DOMAIN: &[u8] = b"keep.gc-retirement-intent/v2\0"; +const GC_INTENT_CHECKSUM_DOMAIN: &[u8] = b"keep.gc-retirement-intent-checksum/v2\0"; +const GC_RECEIPT_CHECKSUM_DOMAIN: &[u8] = b"keep.gc-retirement-receipt-checksum/v2\0"; +const EMPTY_DISPOSITION_SET_DOMAIN: &[u8] = b"keep.empty-disposition-set/v2\0"; + +struct GcSource { + candidate_segment_digest: [u8; 32], + candidate_segment_length: u64, + candidate_evidence_digest: [u8; 32], + catalog_digest: [u8; 32], + catalog_proof_digest: [u8; 32], + pool_state_digest: [u8; 32], +} + +/// Reads every GC coordinate from accepted fixtures at fixed offsets: the +/// one-zero segment supplies the candidate (its digest, length, and record +/// checksum as fixture-only verification evidence); the generation-two +/// catalog and head supply the successor coordinates; the empty segment's +/// digest stands in for the post-retirement pool state. +fn gc_source() -> Result { + let segment = decode_hex(V1_SEGMENT)?; + let catalog_two = decode_hex(V1_CATALOG_TWO)?; + let head_two = decode_hex(V1_HEAD_TWO)?; + let empty_segment = decode_hex(V1_EMPTY_SEGMENT)?; + require_length(&segment, 337, "version-1 source segment")?; + require_length(&catalog_two, 352, "version-1 generation-two catalog")?; + require_length(&head_two, 128, "version-1 generation-two head")?; + require_length(&empty_segment, 192, "version-1 empty segment")?; + Ok(GcSource { + candidate_segment_digest: array_32(&segment, 273)?, + candidate_segment_length: 337, + candidate_evidence_digest: array_32(&segment, 177)?, + catalog_digest: array_32(&catalog_two, 320)?, + catalog_proof_digest: array_32(&head_two, 96)?, + pool_state_digest: array_32(&empty_segment, 128)?, + }) +} + +fn build_gc_intent( + profile_digest: [u8; 32], + inventory_digest: [u8; 32], + manifest: &ManifestArtifact, +) -> Result { + let source = gc_source()?; + let mut candidates = Vec::with_capacity(72); + candidates.extend_from_slice(&source.candidate_segment_digest); + push_u64(&mut candidates, source.candidate_segment_length); + candidates.extend_from_slice(&source.candidate_evidence_digest); + require_length(&candidates, 72, "GC candidate entry")?; + let candidate_set_digest = + hash(GC_CANDIDATE_SET_DOMAIN, &[&1_u32.to_be_bytes(), &candidates]); + let empty_dispositions = hash(EMPTY_DISPOSITION_SET_DOMAIN, &[]); + + let mut bytes = Vec::with_capacity(320 + 72 + 64); + bytes.extend_from_slice(b"KEEP:GC:INTENT2\0"); + push_u16(&mut bytes, 2); + push_u16(&mut bytes, 320); + push_u32(&mut bytes, 0); + push_u64(&mut bytes, 320 + 72 + 64); + push_u64(&mut bytes, 1); + push_u16(&mut bytes, 72); + push_u16(&mut bytes, 0); + push_u32(&mut bytes, 1); + push_u64(&mut bytes, 1); + bytes.extend_from_slice(&manifest.digest); + push_u64(&mut bytes, 2); + bytes.extend_from_slice(&source.catalog_digest); + push_u32(&mut bytes, 1); + push_u32(&mut bytes, 1); + bytes.extend_from_slice(&profile_digest); + bytes.extend_from_slice(&source.catalog_proof_digest); + bytes.extend_from_slice(&inventory_digest); + bytes.extend_from_slice(&empty_dispositions); + push_u64(&mut bytes, 4); + push_u64(&mut bytes, 5); + push_u64(&mut bytes, 6); + bytes.extend_from_slice(&candidate_set_digest); + require_length(&bytes, 320, "GC intent header")?; + bytes.extend_from_slice(&candidates); + let intent_digest = hash(GC_INTENT_DOMAIN, &[&bytes]); + bytes.extend_from_slice(&intent_digest); + let checksum = hash(GC_INTENT_CHECKSUM_DOMAIN, &[&bytes]); + bytes.extend_from_slice(&checksum); + require_length(&bytes, 456, "GC intent")?; + Ok(Artifact { + case_name: "one-candidate-gc-intent", + kind: "gc-intent", + generation: "1", + entry_count: "1", + bound_digest: intent_digest, + final_checksum: checksum, + fixture: "one-candidate-gc-intent.hex", + bytes, + }) +} + +fn build_gc_receipt(intent: &Artifact) -> Result { + let source = gc_source()?; + let mut bytes = Vec::with_capacity(320); + bytes.extend_from_slice(b"KEEP:GC:RECEIPT2"); + push_u16(&mut bytes, 2); + push_u16(&mut bytes, 320); + push_u32(&mut bytes, 0); + push_u64(&mut bytes, u64_at(&intent.bytes, 32)?); + bytes.extend_from_slice(&intent.bound_digest); + bytes.extend_from_slice(&array_32(&intent.bytes, 288)?); + bytes.extend_from_slice(&source.pool_state_digest); + push_u64(&mut bytes, u64_at(&intent.bytes, 48)?); + bytes.extend_from_slice(&array_32(&intent.bytes, 56)?); + push_u64(&mut bytes, u64_at(&intent.bytes, 88)?); + bytes.extend_from_slice(&array_32(&intent.bytes, 96)?); + push_u64(&mut bytes, u64_at(&intent.bytes, 264)?); + push_u64(&mut bytes, u64_at(&intent.bytes, 272)?); + push_u64(&mut bytes, u64_at(&intent.bytes, 280)?); + push_u64(&mut bytes, 1); + bytes.extend_from_slice(&[0; 48]); + require_length(&bytes, 288, "GC receipt checksum preimage")?; + let checksum = hash(GC_RECEIPT_CHECKSUM_DOMAIN, &[&bytes]); + bytes.extend_from_slice(&checksum); + require_length(&bytes, 320, "GC receipt")?; + Ok(Artifact { + case_name: "one-candidate-gc-receipt", + kind: "gc-receipt", + generation: "1", + entry_count: "1", + bound_digest: intent.bound_digest, + final_checksum: checksum, + fixture: "one-candidate-gc-receipt.hex", + bytes, + }) +} diff --git a/xtask/tests/retention_store_v2_protocol_contract/parser_fuzz_laws.rs b/xtask/tests/retention_store_v2_protocol_contract/parser_fuzz_laws.rs index 763d4db0..0a5fff8d 100644 --- a/xtask/tests/retention_store_v2_protocol_contract/parser_fuzz_laws.rs +++ b/xtask/tests/retention_store_v2_protocol_contract/parser_fuzz_laws.rs @@ -30,6 +30,29 @@ fn retention_decoders_have_registered_seeded_fuzz_evidence() -> Result<(), Box Result<(), Box> { + let repository_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .ok_or("xtask manifest must have a repository parent")?; + + assert!( + repository_root + .join("fuzz/fuzz_targets/gc_format.rs") + .is_file() + ); + assert!( + repository_root + .join("xtask/src/fuzz_seed_corpus/gc_seeds.rs") + .is_file() + ); + assert!(FUZZ_MANIFEST.contains("name = \"gc_format\"")); + assert!(FUZZ_MANIFEST.contains("path = \"fuzz_targets/gc_format.rs\"")); + assert!(FUZZ_GUIDE.contains("The `gc_format` seeds")); + assert!(REQUIREMENTS.contains("`gc_format`")); + Ok(()) +} + #[test] fn migration_decoders_have_registered_seeded_fuzz_evidence() -> Result<(), Box> { let repository_root = Path::new(env!("CARGO_MANIFEST_DIR")) From 5a23238fc3241a19c2f936c4d923a86337f474e3 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 09:17:55 -0700 Subject: [PATCH 15/59] Feat: explicit-depth verification reports for the reference view Problem: issue #20 asks for verification at explicit depths that reports exactly what was established and refuses when evidence is missing, conflicting, or corrupt. Keep had reconstruction errors but no verification vocabulary: no way to ask "is this blob sound to depth X" and get a typed answer that cannot be read as more than it says. Approach: a core verification module owns VerificationDepth (one ordered enumeration, Framing through RetentionClosure, never boolean flags), VerificationSubject, VerificationReport (private fields, crate-only construction, no method that deepens it), VerificationRefusal (Missing, Corrupt, Ambiguous, and Unsupported, each with exact expected and observed coordinates), and VerificationError, which keeps an evidenced refusal apart from an operational failure. ReferenceStore::verify and verify_admitted_layout establish ChunkIdentity through CompleteBlobIdentity in one chunk pass, folding blob hashing and profile replay into it; a profile contradiction found mid-pass is held until every chunk is authenticated so the lower stage is always the one reported. Every other depth is refused with the supported range instead of degraded. Nothing is repaired; verify takes &self. docs/invariants/verification/ states the contract, records the decisions (ordered depth over flags, refuse over degrade, lowest stage first, three distinct refusals), and opens the KEEP-VERIFY ledger. Durable depths, Ambiguous producers, and a replayable receipt remain planned in #20. Evidence: public laws prove a report's depth equals the request at every supported depth for both subjects, that four unsupported depths refuse with the exact range, that an absent blob, absent layout, and absent chunk are Missing with their coordinates, and that a wrong target and false profile boundaries pass ChunkIdentity yet refuse only at CompleteBlobIdentity with the expected and observed values. An internal law tampers a stored chunk and gets Corrupt at the ChunkIdentity stage from both depths. Disabling the unsupported-depth refusal fails "report instead of a refusal". The complete keep suite passes. ROADMAP T-21.1 checked; F-21 Partial. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 13 + ROADMAP.md | 8 +- docs/invariants/verification/README.md | 99 +++++ docs/invariants/verification/rationale.md | 46 +++ docs/invariants/verification/requirements.md | 18 + src/lib.rs | 5 + src/reference/mod.rs | 1 + src/reference/verification.rs | 359 +++++++++++++++++++ src/reference/verification_tests.rs | 54 +++ src/verification/depth.rs | 41 +++ src/verification/error.rs | 69 ++++ src/verification/error_display.rs | 93 +++++ src/verification/evidence.rs | 63 ++++ src/verification/mod.rs | 21 ++ src/verification/refusal.rs | 52 +++ src/verification/report.rs | 66 ++++ src/verification/subject.rs | 13 + tests/verification_report.rs | 243 +++++++++++++ 18 files changed, 1262 insertions(+), 2 deletions(-) create mode 100644 docs/invariants/verification/README.md create mode 100644 docs/invariants/verification/rationale.md create mode 100644 docs/invariants/verification/requirements.md create mode 100644 src/reference/verification.rs create mode 100644 src/reference/verification_tests.rs create mode 100644 src/verification/depth.rs create mode 100644 src/verification/error.rs create mode 100644 src/verification/error_display.rs create mode 100644 src/verification/evidence.rs create mode 100644 src/verification/mod.rs create mode 100644 src/verification/refusal.rs create mode 100644 src/verification/report.rs create mode 100644 src/verification/subject.rs create mode 100644 tests/verification_report.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 55f9aff2..6f0cfd2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ after its public API and format compatibility policies are established. ### Added +- Explicit-depth verification. `VerificationDepth` is one ordered + enumeration from `Framing` to `RetentionClosure`; `ReferenceStore::verify` + and `verify_admitted_layout` establish exactly the requested depth and + return a `VerificationReport` with private fields and no way to deepen it, + or a `VerificationRefusal` that keeps `Missing`, `Corrupt`, `Ambiguous`, + and `Unsupported` distinct with exact expected and observed coordinates, + or an operational `VerificationFailure` that supports no content + conclusion. The reference view supports `ChunkIdentity` through + `CompleteBlobIdentity` in one chunk pass and refuses every other depth + instead of degrading; a lower-stage refusal is always reported first. + `docs/invariants/verification/` states the contract, the rationale, and + the `KEEP-VERIFY` ledger; durable depths and a replayable receipt remain + planned in #20. - Canonical codecs for the version-2 `GcRetirementIntent` and `GcRetirementReceipt` records. `GcRetirementIntent` admits a canonical, duplicate-free, digest-ordered candidate set of at most 65,536 segments diff --git a/ROADMAP.md b/ROADMAP.md index d1d38d3f..522175e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1005,7 +1005,8 @@ clocks, paths, environment, and caller identity out of the core. ### F-21 Precise verification reports and corruption refusal -**Status:** Planned (#20, P1, M4). The most-cited open blocker: F-22, +**Status:** Partial (#20, P1, M4); the vocabulary and the reference-store +form landed on this branch (T-21.1). The most-cited open blocker: F-22, F-23, F-24, F-27, F-28, F-29, F-30, F-31, F-33, and F-34 all name it. Verify content and store structure at explicit, enumerated depths; report @@ -1013,7 +1014,10 @@ exactly what was established and nothing more; refuse when evidence is missing, conflicting, or corrupt. Verification never repairs, substitutes, quarantines, or rewrites physical state. -- [ ] T-21.1 Verification policy and report types. +- [x] T-21.1 Verification policy and report types — `src/verification/`, + `ReferenceStore::verify`, `docs/invariants/verification/`, + `tests/verification_report.rs`; durable depths stay with T-21.3 and #20. + Original task fields: - **Requirements:** policy is an enum of depths, never a set of boolean flags: framing, checksum, chunk identity, layout identity, complete blob identity, catalog reachability, retention-root closure, and (once diff --git a/docs/invariants/verification/README.md b/docs/invariants/verification/README.md new file mode 100644 index 00000000..dd73976a --- /dev/null +++ b/docs/invariants/verification/README.md @@ -0,0 +1,99 @@ +# Verification Reports + +This page defines what a Keep verification report proves and what a +verification refusal evidences. The public non-durable `ReferenceStore` +implements the reference form. Durable views implement the same vocabulary +as their surfaces land. + +The [rationale](rationale.md) records the governed decisions. The +[requirement ledger](requirements.md) maps each law to its evidence. + +## Invariant + +A verification names one subject and one requested depth. It returns exactly +one of: + +1. a `VerificationReport` that establishes exactly the requested depth; +2. a `VerificationRefusal` that evidences, from a complete view, why the + requested depth cannot hold; +3. an operational failure from which no content conclusion follows. + +A report never states a deeper depth than was requested, and a view that +cannot establish a depth refuses it instead of reporting a shallower one. +Verification never repairs, substitutes, quarantines, or rewrites physical +state. + +## Depths + +`VerificationDepth` is an ordered enumeration, never a set of boolean flags: + +| Depth | Establishes | +| --- | --- | +| `Framing` | every durable record the subject depends on has canonical framing | +| `Checksum` | every such record has a matching checksum | +| `ChunkIdentity` | every chunk the subject names is present and hashes to its `ChunkId` | +| `LayoutIdentity` | the subject's layout produces its canonical `LayoutId` | +| `CompleteBlobIdentity` | the authenticated chunks reproduce the target `BlobId` and replay the registered storage profile | +| `CatalogReachability` | one admitted catalog generation names every record the subject needs | +| `RetentionClosure` | one retained root's closure reaches the subject under one fenced view | + +Establishing a depth requires every shallower depth the view supports. A +refusal names the `stage` Keep was establishing when it stopped, and a +lower-stage refusal is always reported before a deeper one. + +## Subjects and reports + +`VerificationSubject::Blob` verifies through the view's deterministic layout +choice; `VerificationSubject::Layout` verifies one exact committed layout. +`ReferenceStore::verify_admitted_layout` verifies a caller-supplied layout +whose canonical identity becomes the subject. + +A `VerificationReport` binds the subject, the depth established, the exact +`LayoutId` it was established through, that layout's target `BlobId`, and +the number of chunks authenticated. Its fields are private and it has no +method that raises its depth. + +A report proves nothing beyond its depth: not durability, not retention, not +application meaning, and not that a later verification will agree. + +## Refusals + +`VerificationRefusal` keeps three kinds of evidence distinct: + +- `Missing`: required evidence is absent from a complete view, with + `MissingEvidence` naming the blob, layout, or exact chunk; +- `Corrupt`: present evidence contradicts the identity it must reproduce, + with `CorruptionEvidence` carrying the expected and observed `ChunkId`, + `LayoutId`, or `BlobId`, or the boundary index at which profile replay + diverged; +- `Ambiguous`: two pieces of admitted evidence conflict, so neither a + positive nor a negative conclusion follows. + +`Unsupported` is the fourth variant: the view cannot establish the requested +depth at all, and says which depths it can. + +Absence is evidence only against a complete view. The reference store's +in-memory indexes are complete by construction. A durable view must bind a +complete admitted catalog before it may report `Missing`; until then an +unreadable index is an operational failure, not a refusal. + +## Reference store + +`ReferenceStore::verify` supports `ChunkIdentity` through +`CompleteBlobIdentity`. It holds no durable framing or checksums, no catalog, +and no retention, so every other depth is refused as `Unsupported`. + +Work and memory are bounded by the layout: every chunk is read and hashed +once in a single pass; `LayoutIdentity` and deeper materialize one canonical +layout record bounded by the layout's entry limit; `CompleteBlobIdentity` +replays the registered storage profile with the detector's fixed state. The +view allocates no other adapter-owned memory. + +No reference-store path produces `Ambiguous`. + +## Nonclaims + +A report contains no plaintext, key material, or path. It is an ephemeral +statement about one operation against one view; a durable, replayable form +is the refusal-receipt work in +[the reconstruction ledger](../authenticated-reconstruction/requirements.md). diff --git a/docs/invariants/verification/rationale.md b/docs/invariants/verification/rationale.md new file mode 100644 index 00000000..0adfecd0 --- /dev/null +++ b/docs/invariants/verification/rationale.md @@ -0,0 +1,46 @@ +# Verification Rationale + +This note records the governed decisions behind the verification vocabulary. + +## An ordered enumeration, not flags + +A policy of boolean flags (`check_chunks`, `check_blob`, ...) lets a caller +ask for a combination nothing establishes and lets a report be read as more +than it says. One ordered depth makes both impossible: the request is one +value, the report is one value, and the deeper value implies the shallower +ones the view supports. + +Rejected: a bit set of independent checks. Rejected: a boolean `deep` +parameter, which collapses five distinct propositions into two. + +## Refuse an unsupported depth instead of degrading + +A view that silently verified to the deepest depth it could would return a +report whose depth the caller did not ask for, and a caller comparing +`report.depth() >= requested` would be the only defense. Refusing with the +supported range keeps the report's depth equal to the request by law. + +## Report the lowest stage first + +The reference store folds complete-blob hashing and profile replay into the +single chunk pass so that no chunk is hashed twice. A profile-boundary +contradiction can therefore surface before the last chunk has been +authenticated. It is held until the pass completes, so a chunk-identity +contradiction (a lower stage) is the one reported when both exist. Callers +can rely on the stage order without knowing the pass structure. + +Rejected: two passes, one per stage, which doubles the work the read path +already avoids. + +## Missing, corrupt, and ambiguous stay distinct + +The authenticated reconstruction contract separates evidenced refusal from +operational failure. Verification refines the refusal into absence, +contradiction, and conflict because each authorizes a different next step: +absence can be resolved by ingestion, contradiction by restoring from another +copy, and conflict only by a human reading both sides. Collapsing them into +one `Invalid` would push that distinction into prose. + +`Ambiguous` is defined now with no reference-store producer so that durable +views, which can hold conflicting catalog and retention evidence, do not +introduce a fourth vocabulary later. diff --git a/docs/invariants/verification/requirements.md b/docs/invariants/verification/requirements.md new file mode 100644 index 00000000..8567ad17 --- /dev/null +++ b/docs/invariants/verification/requirements.md @@ -0,0 +1,18 @@ +# Verification Requirements + +This ledger maps the verification vocabulary to stable laws and executable +evidence. A planned case is not evidence. + + + +| ID | Exact law | Evidence | Status | +| --- | --- | --- | --- | +| `KEEP-VERIFY-001` | Verification depth is one ordered enumeration; a report establishes exactly the requested depth and exposes no way to deepen it | `tests/verification_report.rs`; `VerificationReport` has private fields and crate-only construction | Implemented | +| `KEEP-VERIFY-002` | A view refuses a depth it cannot establish as `Unsupported`, naming its supported range, instead of reporting a shallower depth | `tests/verification_report.rs` | Implemented for `ReferenceStore` | +| `KEEP-VERIFY-003` | Missing, corrupt, and ambiguous evidence are distinct refusals; each carries the exact expected and observed coordinates it can | `tests/verification_report.rs`, `src/reference/verification_tests.rs` | Implemented for `ReferenceStore`; `Ambiguous` has no producer yet | +| `KEEP-VERIFY-004` | A lower-stage refusal is reported before a deeper one, and the single chunk pass hashes every chunk once | `tests/verification_report.rs` (profile-boundary and target contradictions succeed at `ChunkIdentity` and refuse only at `CompleteBlobIdentity`) | Implemented for `ReferenceStore` | +| `KEEP-VERIFY-005` | Verification never repairs, substitutes, quarantines, or rewrites physical state | `ReferenceStore::verify` takes `&self`; `src/reference/verification_tests.rs` observes the tampered chunk unchanged | Implemented for `ReferenceStore` | +| `KEEP-VERIFY-006` | Durable views establish `Framing`, `Checksum`, `CatalogReachability`, and `RetentionClosure` against one fenced snapshot and may report `Ambiguous` for conflicting evidence | durable read surface and snapshot laws | Planned in [#20](https://github.com/flyingrobots/keep/issues/20) | +| `KEEP-VERIFY-007` | A durable, replayable verification receipt binds the subject, view coordinates, depth, and refusal classification | canonical receipt format and corpus | Planned in [#20](https://github.com/flyingrobots/keep/issues/20) | + + diff --git a/src/lib.rs b/src/lib.rs index b8f39382..1551b8bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,6 +54,7 @@ mod layout; mod profile; mod reference; mod retention; +mod verification; #[cfg(feature = "repository-tasks")] #[doc(hidden)] @@ -189,3 +190,7 @@ pub use retention::{ RetentionProfileAdmissionError, RetentionRoot, RetentionRootDigest, RetentionRootError, RootGeneration, RootGenerationError, }; +pub use verification::{ + CorruptionEvidence, MissingEvidence, VerificationDepth, VerificationError, VerificationFailure, + VerificationRefusal, VerificationReport, VerificationSubject, +}; diff --git a/src/reference/mod.rs b/src/reference/mod.rs index d612492a..9c3bc07d 100644 --- a/src/reference/mod.rs +++ b/src/reference/mod.rs @@ -25,6 +25,7 @@ mod reconstruction_error_display; mod reconstruction_receipt; mod staged_blob; mod store; +mod verification; pub use crate::profile::ProfileBoundary; pub use capacity::ReferenceStoreCapacity; diff --git a/src/reference/verification.rs b/src/reference/verification.rs new file mode 100644 index 00000000..4a00aa02 --- /dev/null +++ b/src/reference/verification.rs @@ -0,0 +1,359 @@ +//! Explicit-depth verification over the non-durable reference view. + +use crate::profile::{StorageProfileVerificationError, StorageProfileVerifier}; +use crate::{ + AdmittedLayout, BlobHasher, BlobId, CorruptionEvidence, LayoutId, MissingEvidence, + ReferenceStore, VerificationDepth, VerificationError, VerificationFailure, VerificationRefusal, + VerificationReport, VerificationSubject, +}; + +use super::chunk_verification::{ChunkVerificationError, verified_chunk}; + +/// Shallowest depth the in-memory view establishes: it holds no durable +/// framing or checksums to verify. +const SUPPORTED_MINIMUM: VerificationDepth = VerificationDepth::ChunkIdentity; +/// Deepest depth the in-memory view establishes: it has no catalog and no +/// retention. +const SUPPORTED_MAXIMUM: VerificationDepth = VerificationDepth::CompleteBlobIdentity; + +impl ReferenceStore { + /// Verifies one committed subject to exactly `depth` and reports it. + /// + /// The reference view supports `ChunkIdentity` through + /// `CompleteBlobIdentity`; any other depth is refused as unsupported, + /// never degraded. Every chunk the layout names is read and hashed once. + /// `LayoutIdentity` and deeper additionally materialize one canonical + /// layout record bounded by the layout's entry limit; `CompleteBlobIdentity` + /// additionally replays the registered storage profile. A lower-stage + /// refusal is always reported before a deeper one. Nothing is repaired. + /// + /// # Errors + /// + /// Returns [`VerificationError::Refused`] with exact missing, corrupt, or + /// unsupported evidence, or [`VerificationError::Operational`] when the + /// operation could not run to a conclusion. + pub fn verify( + &self, + subject: VerificationSubject, + depth: VerificationDepth, + ) -> Result { + require_supported(subject, depth)?; + let layout_id = match subject { + VerificationSubject::Blob(target) => self + .first_layout_id(target) + .ok_or_else(|| missing(subject, MissingEvidence::Blob(target)))?, + VerificationSubject::Layout(layout_id) => layout_id, + }; + let layout = self + .layout(layout_id) + .ok_or_else(|| missing(subject, MissingEvidence::Layout(layout_id)))?; + let binding = LayoutBinding { + id: layout_id, + layout, + committed: true, + }; + verify_layout(self, subject, depth, &binding) + } + + /// Verifies a caller-supplied admitted layout against this view. + /// + /// The layout need not be committed here; its canonical identity is + /// calculated first and becomes the report's subject. Every other law is + /// identical to [`ReferenceStore::verify`]. + /// + /// # Errors + /// + /// As [`ReferenceStore::verify`], plus an operational failure when the + /// layout cannot produce its canonical record. + pub fn verify_admitted_layout( + &self, + layout: &AdmittedLayout, + depth: VerificationDepth, + ) -> Result { + let layout_id = canonical_identity(layout)?; + let subject = VerificationSubject::Layout(layout_id); + require_supported(subject, depth)?; + let binding = LayoutBinding { + id: layout_id, + layout, + committed: false, + }; + verify_layout(self, subject, depth, &binding) + } +} + +/// One layout under verification and whether the view committed it under +/// its identity (a supplied layout has nothing to compare its identity to). +struct LayoutBinding<'a> { + id: LayoutId, + layout: &'a AdmittedLayout, + committed: bool, +} + +fn verify_layout( + store: &ReferenceStore, + subject: VerificationSubject, + depth: VerificationDepth, + binding: &LayoutBinding<'_>, +) -> Result { + let layout_id = binding.id; + let layout = binding.layout; + let committed = binding.committed; + let mut pass = DeepPass::begin(depth, subject, layout_id, layout)?; + let mut chunks_verified = 0_u64; + for (index, entry) in layout.entries().iter().copied().enumerate() { + let bytes = verified_chunk(store, layout_id, index, entry) + .map_err(|error| chunk_outcome(subject, error))?; + pass.feed(bytes)?; + chunks_verified = chunks_verified.saturating_add(1); + } + if committed && depth >= VerificationDepth::LayoutIdentity { + require_layout_identity(subject, layout_id, layout)?; + } + pass.conclude(layout.target())?; + Ok(VerificationReport::established( + subject, + depth, + layout_id, + layout.target(), + chunks_verified, + )) +} + +/// The complete-blob work folded into the single chunk pass. +/// +/// A profile-boundary contradiction found mid-pass is held until every chunk +/// has been authenticated, so a chunk-identity refusal (a lower stage) is +/// always the one reported. +struct DeepPass<'a> { + subject: VerificationSubject, + layout_id: LayoutId, + hasher: Option, + profile: Option>, + pending: Option, +} + +impl<'a> DeepPass<'a> { + fn begin( + depth: VerificationDepth, + subject: VerificationSubject, + layout_id: LayoutId, + layout: &'a AdmittedLayout, + ) -> Result { + if depth < VerificationDepth::CompleteBlobIdentity { + return Ok(Self { + subject, + layout_id, + hasher: None, + profile: None, + pending: None, + }); + } + let profile = StorageProfileVerifier::new(layout) + .map_err(|error| profile_failure(subject, layout_id, error))?; + Ok(Self { + subject, + layout_id, + hasher: Some(BlobHasher::new()), + profile: Some(profile), + pending: None, + }) + } + + fn feed(&mut self, bytes: &[u8]) -> Result<(), VerificationError> { + if let Some(hasher) = self.hasher.as_mut() { + hasher.update(bytes).map_err(|source| { + VerificationError::Operational(VerificationFailure::BlobHash { source }) + })?; + } + if let Some(profile) = self.profile.as_mut() + && let Err(error) = profile.feed(bytes) + { + self.profile = None; + self.pending = Some(profile_outcome(self.subject, self.layout_id, error)?); + } + Ok(()) + } + + fn conclude(self, target: BlobId) -> Result<(), VerificationError> { + let Self { + subject, + layout_id, + hasher, + profile, + pending, + } = self; + if let Some(pending) = pending { + return Err(VerificationError::refused(pending)); + } + if let Some(profile) = profile + && let Err(error) = profile.finish() + { + return Err(VerificationError::refused(profile_outcome( + subject, layout_id, error, + )?)); + } + let Some(hasher) = hasher else { + return Ok(()); + }; + let observed = hasher.finish(); + if observed == target { + return Ok(()); + } + Err(VerificationError::refused(VerificationRefusal::Corrupt { + subject, + stage: VerificationDepth::CompleteBlobIdentity, + evidence: CorruptionEvidence::BlobIdentity { + layout: layout_id, + expected: target, + observed, + }, + })) + } +} + +/// Maps a replay error to the refusal it evidences, or to the operational +/// failure it is. +fn profile_outcome( + subject: VerificationSubject, + layout_id: LayoutId, + error: StorageProfileVerificationError, +) -> Result { + match error { + StorageProfileVerificationError::BoundaryMismatch { index, .. } => { + Ok(VerificationRefusal::Corrupt { + subject, + stage: VerificationDepth::CompleteBlobIdentity, + evidence: CorruptionEvidence::ProfileBoundary { + layout: layout_id, + index, + }, + }) + } + other => Err(profile_failure(subject, layout_id, other)), + } +} + +fn require_supported( + subject: VerificationSubject, + depth: VerificationDepth, +) -> Result<(), VerificationError> { + if (SUPPORTED_MINIMUM..=SUPPORTED_MAXIMUM).contains(&depth) { + Ok(()) + } else { + Err(VerificationError::refused( + VerificationRefusal::Unsupported { + subject, + requested: depth, + supported_minimum: SUPPORTED_MINIMUM, + supported_maximum: SUPPORTED_MAXIMUM, + }, + )) + } +} + +fn require_layout_identity( + subject: VerificationSubject, + expected: LayoutId, + layout: &AdmittedLayout, +) -> Result<(), VerificationError> { + let observed = canonical_identity(layout)?; + if observed == expected { + Ok(()) + } else { + Err(VerificationError::refused(VerificationRefusal::Corrupt { + subject, + stage: VerificationDepth::LayoutIdentity, + evidence: CorruptionEvidence::LayoutIdentity { expected, observed }, + })) + } +} + +fn canonical_identity(layout: &AdmittedLayout) -> Result { + layout + .encode_record() + .map(|record| record.id()) + .map_err(|source| { + VerificationError::Operational(VerificationFailure::LayoutEncoding { source }) + }) +} + +fn missing(subject: VerificationSubject, evidence: MissingEvidence) -> VerificationError { + VerificationError::refused(VerificationRefusal::Missing { + subject, + stage: SUPPORTED_MINIMUM, + evidence, + }) +} + +fn chunk_outcome(subject: VerificationSubject, error: ChunkVerificationError) -> VerificationError { + match error { + ChunkVerificationError::Missing { + layout, + index, + requested, + } => VerificationError::refused(VerificationRefusal::Missing { + subject, + stage: VerificationDepth::ChunkIdentity, + evidence: MissingEvidence::Chunk { + layout, + index, + chunk: requested, + }, + }), + ChunkVerificationError::IdentityMismatch { + layout, + index, + expected, + observed, + } => VerificationError::refused(VerificationRefusal::Corrupt { + subject, + stage: VerificationDepth::ChunkIdentity, + evidence: CorruptionEvidence::ChunkIdentity { + layout, + index, + expected, + observed, + }, + }), + ChunkVerificationError::Hash { + layout, + index, + source, + .. + } => VerificationError::Operational(VerificationFailure::ChunkHash { + layout, + index, + source, + }), + } +} + +fn profile_failure( + subject: VerificationSubject, + layout: LayoutId, + error: StorageProfileVerificationError, +) -> VerificationError { + match error { + StorageProfileVerificationError::Unsupported { profile } => { + VerificationError::Operational(VerificationFailure::ProfileVerifierUnavailable { + layout, + profile, + }) + } + StorageProfileVerificationError::Chunking { source } => { + VerificationError::Operational(VerificationFailure::ProfileChunking { layout, source }) + } + StorageProfileVerificationError::BoundaryMismatch { index, .. } => { + VerificationError::refused(VerificationRefusal::Corrupt { + subject, + stage: VerificationDepth::CompleteBlobIdentity, + evidence: CorruptionEvidence::ProfileBoundary { layout, index }, + }) + } + } +} + +#[cfg(test)] +#[path = "verification_tests.rs"] +mod tests; diff --git a/src/reference/verification_tests.rs b/src/reference/verification_tests.rs new file mode 100644 index 00000000..24eeee2e --- /dev/null +++ b/src/reference/verification_tests.rs @@ -0,0 +1,54 @@ +//! Verification laws that need to tamper with the reference view's bytes. + +use std::io::Cursor; + +use crate::{ + CorruptionEvidence, LayoutEntryLimit, ReferenceStore, ReferenceStoreCapacity, + VerificationDepth, VerificationError, VerificationRefusal, VerificationSubject, +}; + +#[test] +fn tampered_stored_chunk_is_corrupt_at_the_chunk_identity_stage() +-> Result<(), Box> { + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(1_048_576)); + let mut source = Cursor::new(b"bytes the store will silently change"); + let published = store + .stage(&mut source, LayoutEntryLimit::MAXIMUM)? + .commit(&mut store)?; + let layout = store + .layout(published.layout_id()) + .ok_or("published layout is absent")?; + let entry = layout + .entries() + .first() + .copied() + .ok_or("published layout names no chunk")?; + let stored = store + .chunks + .get_mut(&entry.chunk_id()) + .ok_or("published chunk is absent")?; + let first = stored.first_mut().ok_or("published chunk is empty")?; + *first ^= 1; + + for depth in [ + VerificationDepth::ChunkIdentity, + VerificationDepth::CompleteBlobIdentity, + ] { + let outcome = store.verify(VerificationSubject::Blob(published.target()), depth); + assert!( + matches!( + &outcome, + Err(VerificationError::Refused(refusal)) if matches!( + **refusal, + VerificationRefusal::Corrupt { + stage: VerificationDepth::ChunkIdentity, + evidence: CorruptionEvidence::ChunkIdentity { index: 0, .. }, + .. + } + ) + ), + "depth {depth:?} did not report the chunk-identity contradiction: {outcome:?}" + ); + } + Ok(()) +} diff --git a/src/verification/depth.rs b/src/verification/depth.rs new file mode 100644 index 00000000..485dfa8c --- /dev/null +++ b/src/verification/depth.rs @@ -0,0 +1,41 @@ +//! This module owns the ordered verification depths. + +/// One explicit depth to which a verification establishes its subject. +/// +/// Depths are ordered: establishing a deeper depth requires every shallower +/// depth the view supports. A report names the one depth it established and +/// can never be read as a deeper one. Not every view supports every depth; +/// a view refuses a depth it cannot establish instead of degrading to a +/// shallower one. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum VerificationDepth { + /// Every durable record the subject depends on has canonical framing. + Framing, + /// Every durable record the subject depends on has a matching checksum. + Checksum, + /// Every chunk the subject names is present and hashes to its `ChunkId`. + ChunkIdentity, + /// The subject's layout produces its canonical `LayoutId`. + LayoutIdentity, + /// The complete reconstructed sequence hashes to the target `BlobId` and + /// replays the registered storage profile. + CompleteBlobIdentity, + /// One admitted catalog generation names every record the subject needs. + CatalogReachability, + /// One retained root's closure reaches the subject under one fenced view. + RetentionClosure, +} + +impl VerificationDepth { + /// Every depth in ascending order. + pub const ALL: [Self; 7] = [ + Self::Framing, + Self::Checksum, + Self::ChunkIdentity, + Self::LayoutIdentity, + Self::CompleteBlobIdentity, + Self::CatalogReachability, + Self::RetentionClosure, + ]; +} diff --git a/src/verification/error.rs b/src/verification/error.rs new file mode 100644 index 00000000..96e5ce25 --- /dev/null +++ b/src/verification/error.rs @@ -0,0 +1,69 @@ +//! This module owns the two outcomes a verification can return besides a +//! report. + +use crate::{ + BlobHashError, ChunkHashError, ChunkingError, LayoutEncodeError, LayoutId, StorageProfileId, +}; + +use super::VerificationRefusal; + +/// Why a verification returned no report. +/// +/// A refusal is evidence about content. An operational failure is not: it +/// supports no conclusion about presence, absence, or integrity. +#[derive(Debug)] +pub enum VerificationError { + /// The view established that the requested depth cannot hold. + /// + /// The refusal is boxed because it carries full expected and observed + /// identities; the success path stays small. + Refused(Box), + /// The operation could not run to a conclusion. + Operational(VerificationFailure), +} + +impl VerificationError { + /// Wraps one evidenced refusal. + #[must_use] + pub fn refused(refusal: VerificationRefusal) -> Self { + Self::Refused(Box::new(refusal)) + } +} + +/// An operational failure during verification. +#[derive(Debug)] +pub enum VerificationFailure { + /// Stored chunk bytes could not form a lawful chunk identity. + ChunkHash { + /// Layout naming the chunk. + layout: LayoutId, + /// Zero-based entry index. + index: usize, + /// Exact hashing failure. + source: ChunkHashError, + }, + /// Logical identity calculation failed. + BlobHash { + /// Exact hashing failure. + source: BlobHashError, + }, + /// A supplied layout could not produce its canonical record. + LayoutEncoding { + /// Exact encoding failure. + source: LayoutEncodeError, + }, + /// No verifier is implemented for the layout's registered profile. + ProfileVerifierUnavailable { + /// Layout whose profile could not be replayed. + layout: LayoutId, + /// Registered profile without a verifier. + profile: StorageProfileId, + }, + /// Replaying the registered storage profile failed to run. + ProfileChunking { + /// Layout whose profile was replayed. + layout: LayoutId, + /// Exact detector failure. + source: ChunkingError, + }, +} diff --git a/src/verification/error_display.rs b/src/verification/error_display.rs new file mode 100644 index 00000000..c992c77c --- /dev/null +++ b/src/verification/error_display.rs @@ -0,0 +1,93 @@ +//! This module owns verification outcome formatting. + +use std::error::Error; +use std::fmt; + +use super::{VerificationError, VerificationFailure, VerificationRefusal}; + +impl fmt::Display for VerificationRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing { stage, .. } => { + write!( + formatter, + "verification at {stage:?} found required evidence absent" + ) + } + Self::Corrupt { stage, .. } => { + write!( + formatter, + "verification at {stage:?} found contradicting evidence" + ) + } + Self::Ambiguous { stage, .. } => { + write!( + formatter, + "verification at {stage:?} found conflicting evidence" + ) + } + Self::Unsupported { + requested, + supported_minimum, + supported_maximum, + .. + } => write!( + formatter, + "verification depth {requested:?} is outside this view's \ + {supported_minimum:?}..={supported_maximum:?}" + ), + } + } +} + +impl Error for VerificationRefusal {} + +impl fmt::Display for VerificationFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ChunkHash { index, .. } => { + write!(formatter, "chunk {index} could not be hashed") + } + Self::BlobHash { .. } => formatter.write_str("blob identity could not be calculated"), + Self::LayoutEncoding { .. } => { + formatter.write_str("layout could not produce its canonical record") + } + Self::ProfileVerifierUnavailable { .. } => { + formatter.write_str("no verifier implements the registered storage profile") + } + Self::ProfileChunking { .. } => { + formatter.write_str("storage profile replay failed to run") + } + } + } +} + +impl Error for VerificationFailure { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::ChunkHash { source, .. } => Some(source), + Self::BlobHash { source } => Some(source), + Self::LayoutEncoding { source } => Some(source), + Self::ProfileChunking { source, .. } => Some(source), + Self::ProfileVerifierUnavailable { .. } => None, + } + } +} + +impl fmt::Display for VerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Refused(refusal) => write!(formatter, "verification refused: {refusal}"), + Self::Operational(failure) => write!(formatter, "verification failed: {failure}"), + } + } +} + +impl Error for VerificationError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Refused(refusal) => Some(refusal.as_ref()), + Self::Operational(failure) => Some(failure), + } + } +} diff --git a/src/verification/evidence.rs b/src/verification/evidence.rs new file mode 100644 index 00000000..24fe853b --- /dev/null +++ b/src/verification/evidence.rs @@ -0,0 +1,63 @@ +//! This module owns the exact evidence a verification refusal carries. + +use crate::{BlobId, ChunkId, LayoutId}; + +/// What was absent from the admitted view. +/// +/// Absence is evidenced only against a complete view; the reference store's +/// in-memory indexes are complete by construction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MissingEvidence { + /// No committed layout names the blob. + Blob(BlobId), + /// The exact layout is not committed. + Layout(LayoutId), + /// A committed or supplied layout names a chunk the view lacks. + Chunk { + /// Layout naming the chunk. + layout: LayoutId, + /// Zero-based entry index. + index: usize, + /// Absent exact chunk identity. + chunk: ChunkId, + }, +} + +/// Which integrity proposition failed, with the expected and observed values. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CorruptionEvidence { + /// Stored chunk bytes do not hash to the identity the layout names. + ChunkIdentity { + /// Layout naming the chunk. + layout: LayoutId, + /// Zero-based entry index. + index: usize, + /// Identity named by the layout. + expected: ChunkId, + /// Identity calculated from the stored bytes. + observed: ChunkId, + }, + /// The committed layout does not produce the identity it is keyed by. + LayoutIdentity { + /// Identity the view committed the layout under. + expected: LayoutId, + /// Identity the canonical record produces. + observed: LayoutId, + }, + /// The authenticated chunks do not reproduce the target blob identity. + BlobIdentity { + /// Layout reconstructed. + layout: LayoutId, + /// Target named by the layout. + expected: BlobId, + /// Identity calculated from every authenticated chunk. + observed: BlobId, + }, + /// Replaying the registered storage profile did not reproduce the layout. + ProfileBoundary { + /// Layout reconstructed. + layout: LayoutId, + /// Zero-based boundary index at which replay diverged. + index: usize, + }, +} diff --git a/src/verification/mod.rs b/src/verification/mod.rs new file mode 100644 index 00000000..7cf0c852 --- /dev/null +++ b/src/verification/mod.rs @@ -0,0 +1,21 @@ +//! Explicit verification depths, reports, and refusals. +//! +//! This module owns the vocabulary every Keep verification operation uses to +//! say exactly what it established and exactly why it stopped. It does not +//! own any store, any read path, or any repair: a report is a statement, a +//! refusal is evidence, and neither mutates physical state. + +mod depth; +mod error; +mod error_display; +mod evidence; +mod refusal; +mod report; +mod subject; + +pub use depth::VerificationDepth; +pub use error::{VerificationError, VerificationFailure}; +pub use evidence::{CorruptionEvidence, MissingEvidence}; +pub use refusal::VerificationRefusal; +pub use report::VerificationReport; +pub use subject::VerificationSubject; diff --git a/src/verification/refusal.rs b/src/verification/refusal.rs new file mode 100644 index 00000000..a8b2cbd9 --- /dev/null +++ b/src/verification/refusal.rs @@ -0,0 +1,52 @@ +//! This module owns evidenced verification refusals. + +use super::{CorruptionEvidence, MissingEvidence, VerificationDepth, VerificationSubject}; + +/// An evidenced refusal: the view is complete enough to say exactly why the +/// requested depth cannot be established. +/// +/// `stage` names the depth Keep was establishing when it stopped. Missing, +/// corrupt, and ambiguous evidence are distinct, and none of them is ever +/// repaired, substituted, or quarantined by verification. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VerificationRefusal { + /// Required evidence is absent from a complete view. + Missing { + /// Subject being verified. + subject: VerificationSubject, + /// Depth being established when the absence was found. + stage: VerificationDepth, + /// What was absent. + evidence: MissingEvidence, + }, + /// Present evidence contradicts the identity it must reproduce. + Corrupt { + /// Subject being verified. + subject: VerificationSubject, + /// Depth being established when the contradiction was found. + stage: VerificationDepth, + /// The exact contradiction. + evidence: CorruptionEvidence, + }, + /// Two pieces of admitted evidence conflict, so neither a positive nor a + /// negative conclusion follows. No reference-store path produces this; + /// durable views reserve it for conflicting catalog or retention state. + Ambiguous { + /// Subject being verified. + subject: VerificationSubject, + /// Depth being established when the conflict was found. + stage: VerificationDepth, + }, + /// The view cannot establish the requested depth at all, and refuses + /// rather than reporting a shallower one. + Unsupported { + /// Subject requested. + subject: VerificationSubject, + /// Depth requested. + requested: VerificationDepth, + /// Shallowest depth this view establishes. + supported_minimum: VerificationDepth, + /// Deepest depth this view establishes. + supported_maximum: VerificationDepth, + }, +} diff --git a/src/verification/report.rs b/src/verification/report.rs new file mode 100644 index 00000000..383eb6ad --- /dev/null +++ b/src/verification/report.rs @@ -0,0 +1,66 @@ +//! This module owns the statement one completed verification makes. + +use super::{VerificationDepth, VerificationSubject}; +use crate::{BlobId, LayoutId}; + +/// Exactly what one verification established. +/// +/// The report names the subject, the one depth established, and the exact +/// layout and target coordinates that depth was established against. It has +/// no constructor outside Keep and no method that raises its depth, so a +/// report at one depth cannot be presented as a report at a deeper one. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[must_use = "the verification report records the depth that was established"] +pub struct VerificationReport { + subject: VerificationSubject, + depth: VerificationDepth, + layout: LayoutId, + target: BlobId, + chunks_verified: u64, +} + +impl VerificationReport { + pub(crate) const fn established( + subject: VerificationSubject, + depth: VerificationDepth, + layout: LayoutId, + target: BlobId, + chunks_verified: u64, + ) -> Self { + Self { + subject, + depth, + layout, + target, + chunks_verified, + } + } + + /// Returns the subject that was verified. + pub const fn subject(self) -> VerificationSubject { + self.subject + } + + /// Returns the one depth this report establishes; never deeper. + pub const fn depth(self) -> VerificationDepth { + self.depth + } + + /// Returns the exact layout the depth was established through. + #[must_use] + pub const fn layout(self) -> LayoutId { + self.layout + } + + /// Returns the target blob that layout binds. + #[must_use] + pub const fn target(self) -> BlobId { + self.target + } + + /// Returns how many chunks were authenticated for this report. + #[must_use] + pub const fn chunks_verified(self) -> u64 { + self.chunks_verified + } +} diff --git a/src/verification/subject.rs b/src/verification/subject.rs new file mode 100644 index 00000000..649bd22f --- /dev/null +++ b/src/verification/subject.rs @@ -0,0 +1,13 @@ +//! This module owns the coordinate a verification is about. + +use crate::{BlobId, LayoutId}; + +/// The exact content coordinate a verification establishes or refuses. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum VerificationSubject { + /// One logical blob through the view's deterministic layout choice. + Blob(BlobId), + /// One exact committed layout. + Layout(LayoutId), +} diff --git a/tests/verification_report.rs b/tests/verification_report.rs new file mode 100644 index 00000000..3a30515b --- /dev/null +++ b/tests/verification_report.rs @@ -0,0 +1,243 @@ +//! Public verification-report laws over the reference view. + +#[path = "layout_mutations/support.rs"] +mod layout_mutation_support; +mod support; + +use std::error::Error; +use std::io::Cursor; + +use keep::{ + AdmittedLayout, BlobHasher, BlobId, ChunkSpan, CorruptionEvidence, FastCdc, LayoutDecodePolicy, + LayoutEntryLimit, MissingEvidence, PublishedBlob, ReferenceStore, ReferenceStoreCapacity, + RegisteredStorageProfile, VerificationDepth, VerificationError, VerificationRefusal, + VerificationReport, VerificationSubject, +}; + +use layout_mutation_support::mutation_cases; + +/// A blob identity together with the spans the registered profile cuts it into. +type Identified = (BlobId, Vec); + +const SOURCE: &[u8] = b"exact bytes whose every depth is reported, never inflated"; + +#[test] +fn a_report_establishes_exactly_the_requested_depth() -> Result<(), Box> { + let (store, published) = published_store(SOURCE)?; + for depth in [ + VerificationDepth::ChunkIdentity, + VerificationDepth::LayoutIdentity, + VerificationDepth::CompleteBlobIdentity, + ] { + for subject in [ + VerificationSubject::Blob(published.target()), + VerificationSubject::Layout(published.layout_id()), + ] { + let report = store.verify(subject, depth)?; + assert_eq!(report.depth(), depth, "{subject:?} at {depth:?}"); + assert_eq!(report.subject(), subject); + assert_eq!(report.layout(), published.layout_id()); + assert_eq!(report.target(), published.target()); + assert_eq!(report.chunks_verified(), 1); + } + } + assert!( + VerificationDepth::ALL + .windows(2) + .all(|pair| matches!(pair, [shallower, deeper] if shallower < deeper)) + ); + Ok(()) +} + +#[test] +fn unsupported_depths_refuse_with_the_supported_range() -> Result<(), Box> { + let (store, published) = published_store(SOURCE)?; + let subject = VerificationSubject::Blob(published.target()); + for depth in [ + VerificationDepth::Framing, + VerificationDepth::Checksum, + VerificationDepth::CatalogReachability, + VerificationDepth::RetentionClosure, + ] { + let refusal = refusal_of(store.verify(subject, depth))?; + assert!( + matches!( + refusal, + VerificationRefusal::Unsupported { + requested, + supported_minimum: VerificationDepth::ChunkIdentity, + supported_maximum: VerificationDepth::CompleteBlobIdentity, + .. + } if requested == depth + ), + "{depth:?} refused with {refusal:?}" + ); + } + Ok(()) +} + +#[test] +fn absent_subjects_are_missing_evidence_against_the_complete_view() -> Result<(), Box> { + let (store, _published) = published_store(SOURCE)?; + let mut other = Cursor::new(b"staged but never committed"); + let staged = store.stage(&mut other, LayoutEntryLimit::MAXIMUM)?; + let absent_blob = staged.target(); + let absent_layout = staged.layout_id(); + drop(staged); + + let refusal = refusal_of(store.verify( + VerificationSubject::Blob(absent_blob), + VerificationDepth::ChunkIdentity, + ))?; + assert!(matches!( + refusal, + VerificationRefusal::Missing { + stage: VerificationDepth::ChunkIdentity, + evidence: MissingEvidence::Blob(blob), + .. + } if blob == absent_blob + )); + + let refusal = refusal_of(store.verify( + VerificationSubject::Layout(absent_layout), + VerificationDepth::CompleteBlobIdentity, + ))?; + assert!(matches!( + refusal, + VerificationRefusal::Missing { + evidence: MissingEvidence::Layout(layout), + .. + } if layout == absent_layout + )); + + let (never_staged, spans) = identify(b"chunks this store never held")?; + let layout = AdmittedLayout::from_spans( + never_staged, + registered_profile()?, + spans, + LayoutEntryLimit::MAXIMUM, + )?; + let refusal = + refusal_of(store.verify_admitted_layout(&layout, VerificationDepth::ChunkIdentity))?; + assert!(matches!( + refusal, + VerificationRefusal::Missing { + stage: VerificationDepth::ChunkIdentity, + evidence: MissingEvidence::Chunk { index: 0, .. }, + .. + } + )); + Ok(()) +} + +#[test] +fn a_wrong_target_passes_chunk_identity_and_fails_only_the_complete_blob() +-> Result<(), Box> { + let (store, _published) = published_store(SOURCE)?; + let (real_target, spans) = identify(SOURCE)?; + // Same length, different bytes: layout admission checks the length, and + // only complete-blob verification can tell the two targets apart. + let mut altered = SOURCE.to_vec(); + support::patch(&mut altered, 0, b"E")?; + let (wrong_target, _) = identify(&altered)?; + assert_ne!(wrong_target, real_target); + let layout = AdmittedLayout::from_spans( + wrong_target, + registered_profile()?, + spans, + LayoutEntryLimit::MAXIMUM, + )?; + + let shallow = store.verify_admitted_layout(&layout, VerificationDepth::ChunkIdentity)?; + assert_eq!(shallow.depth(), VerificationDepth::ChunkIdentity); + assert_eq!(shallow.target(), wrong_target); + + let refusal = + refusal_of(store.verify_admitted_layout(&layout, VerificationDepth::CompleteBlobIdentity))?; + assert!(matches!( + refusal, + VerificationRefusal::Corrupt { + stage: VerificationDepth::CompleteBlobIdentity, + evidence: CorruptionEvidence::BlobIdentity { expected, observed, .. }, + .. + } if expected == wrong_target && observed == real_target + )); + Ok(()) +} + +#[test] +fn false_profile_boundaries_pass_chunk_identity_and_fail_only_the_complete_blob() +-> Result<(), Box> { + let mutation = mutation_cases()? + .into_iter() + .find(|candidate| candidate.case() == "profile-boundary-mismatch") + .ok_or("profile-boundary mismatch fixture is absent")?; + let encoded = mutation.mutated_record()?; + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(1_048_576)); + for bytes in [vec![0_u8; 262_143], vec![0_u8; 2]] { + let mut source = Cursor::new(bytes); + let _published = store + .stage(&mut source, LayoutEntryLimit::MAXIMUM)? + .commit(&mut store)?; + } + let policy = LayoutDecodePolicy::new(LayoutEntryLimit::MAXIMUM); + let layout = AdmittedLayout::decode_record(&encoded, policy)?; + + let shallow = store.verify_admitted_layout(&layout, VerificationDepth::ChunkIdentity)?; + assert_eq!(shallow.depth(), VerificationDepth::ChunkIdentity); + assert_eq!(shallow.chunks_verified(), 2); + + let refusal = + refusal_of(store.verify_admitted_layout(&layout, VerificationDepth::CompleteBlobIdentity))?; + assert!(matches!( + refusal, + VerificationRefusal::Corrupt { + stage: VerificationDepth::CompleteBlobIdentity, + evidence: CorruptionEvidence::ProfileBoundary { index: 0, .. }, + .. + } + )); + Ok(()) +} + +fn refusal_of( + outcome: Result, +) -> Result> { + match outcome { + Err(VerificationError::Refused(refusal)) => Ok(*refusal), + Err(VerificationError::Operational(failure)) => { + Err(format!("operational failure instead of a refusal: {failure:?}").into()) + } + Ok(report) => Err(format!("report instead of a refusal: {report:?}").into()), + } +} + +fn published_store(bytes: &[u8]) -> Result<(ReferenceStore, PublishedBlob), Box> { + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(1_048_576)); + let mut source = Cursor::new(bytes); + let published = store + .stage(&mut source, LayoutEntryLimit::MAXIMUM)? + .commit(&mut store)?; + Ok((store, published)) +} + +/// The one registered profile, taken from a staged layout so the test +/// depends on no profile constant. +fn registered_profile() -> Result> { + let store = ReferenceStore::new(ReferenceStoreCapacity::new(1_048_576)); + let mut source = Cursor::new(b"profile witness"); + let staged = store.stage(&mut source, LayoutEntryLimit::MAXIMUM)?; + Ok(staged.layout().profile()) +} + +fn identify(bytes: &[u8]) -> Result> { + let mut hasher = BlobHasher::new(); + hasher.update(bytes)?; + let mut detector = FastCdc::new(); + let mut spans = Vec::new(); + detector.feed(bytes, |span| spans.push(span))?; + if let Some(span) = detector.finish()? { + spans.push(span); + } + Ok((hasher.finish(), spans)) +} From fed314e8774c5c3e57361c119c624cbf0fc7a789 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 09:20:12 -0700 Subject: [PATCH 16/59] Docs: route every status page to live work Problem: the README gap table sent retention recovery, the reader fence, and migration recovery to #19, which closed on 2026-09-08 under another title; durable reads had no issue at all; the crate doc said filesystem retention execution was absent while FilesystemRetentionPublicationAuthority is exported; migration-inventory.md called verification-first migration storage "in progress" while KEEP-MIGRATION-003 is Implemented; and retention-publication.md described version-2 catalog publication as behaviour when no version-2 catalog publisher exists. Approach: open #108 (partial-prefix migration recovery and KEEP-CRASH-053..073) and #109 (durable authenticated reads, KEEP-RECONSTRUCT-009 and -010) and point the README rows at them and at PR #99; rewrite the crate doc to name what is present and absent; state the migration-inventory verification as implemented; and label version-2 catalog publication as a gap with its consequence: a migrated store admits no catalog publication until the durable write path (#82) lands. Evidence: the documentation contract tests, the version-2 protocol contract, the doctests, markdownlint, and the roadmap link check pass. ROADMAP T-38.1, T-38.2, T-38.3 checked; F-17 and F-23 routed to #108 and #109. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 8 +++++++ README.md | 20 +++++++++++------ ROADMAP.md | 22 +++++++++---------- .../segment-store-v2/migration-inventory.md | 4 ++-- .../segment-store-v2/retention-publication.md | 11 +++++++--- src/lib.rs | 11 ++++++---- 6 files changed, 49 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f0cfd2d..22921b0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -282,6 +282,14 @@ after its public API and format compatibility policies are established. ### Changed +- Status pages describe `main` again: the README gap table routes retention + recovery and the reader fence to PR #99, migration recovery to #108, and + durable reads to #109 instead of the closed #19; the crate doc names what + is present and absent; `migration-inventory.md` no longer calls + verification-first migration storage in progress; and + `retention-publication.md` labels version-2 catalog publication as a gap + (a migrated store admits no catalog publisher until #82) instead of + describing it as behaviour. - Version-two reopen compares only the restart-stable root coordinates, the device and the root inode, against the migration intent. The mount identity the intent records is `statx.stx_mnt_id`, a mount instance that changes on diff --git a/README.md b/README.md index 97f69915..1412db99 100644 --- a/README.md +++ b/README.md @@ -70,16 +70,22 @@ Keep is required to refuse all three, before mutating anything. ## What it does not do yet Version 2 writes correctly from a clean start and, if it finds the residue of -an interrupted publication, refuses rather than guesses. Nothing yet recovers -that residue, and readers have no fence, so **an interrupted version-2 -publication waits for a human until #19 lands.** A version-1 store stays -admitted until its owner migrates it; migrate only if you accept that wait. +an interrupted publication or migration, refuses rather than guesses. On +`main`, nothing yet recovers that residue and readers have no fence, so +**an interrupted version-2 publication or migration waits for a human.** +Retention recovery and the reader fence are in review in +[PR #99](https://github.com/flyingrobots/keep/pull/99); migration recovery +is [#108](https://github.com/flyingrobots/keep/issues/108). A version-1 +store stays admitted until its owner migrates it; migrate only if you accept +that wait. | Gap | Tracked | | --- | --- | -| Restart recovery for retention publication and migration | [#19](https://github.com/flyingrobots/keep/issues/19) | -| Reader fence binding one consistent catalog + retention snapshot | [#19](https://github.com/flyingrobots/keep/issues/19) | -| Precise verification reports at explicit depths | [#20](https://github.com/flyingrobots/keep/issues/20) | +| Restart recovery for retention publication | [PR #99](https://github.com/flyingrobots/keep/pull/99) | +| Restart recovery for migration | [#108](https://github.com/flyingrobots/keep/issues/108) | +| Reader fence binding one consistent catalog + retention snapshot | [PR #99](https://github.com/flyingrobots/keep/pull/99) | +| Durable authenticated reads bound to a fenced snapshot | [#109](https://github.com/flyingrobots/keep/issues/109) | +| Verification reports at durable depths and a replayable receipt | [#20](https://github.com/flyingrobots/keep/issues/20) | | Garbage collection and identity-preserving compaction | [#21](https://github.com/flyingrobots/keep/issues/21) | | Bounded production ingestion through the durable store | [#82](https://github.com/flyingrobots/keep/issues/82) | | Encrypted representations | [#86](https://github.com/flyingrobots/keep/issues/86) | diff --git a/ROADMAP.md b/ROADMAP.md index 522175e7..f43f8c96 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -95,13 +95,13 @@ names; use those in code, tests, and commits. - [x] [F-15 Retention roots, release, and GC liveness model](#f-15-retention-roots-release-and-gc-liveness-model) — Done (ADR-0009) - [x] [F-16 Version-2 format records and codecs](#f-16-version-2-format-records-and-codecs) — Done -- [ ] [F-17 One-way migration from version 1 to version 2](#f-17-one-way-migration-from-version-1-to-version-2) — Partial (#97 done on this branch; residual #19) +- [ ] [F-17 One-way migration from version 1 to version 2](#f-17-one-way-migration-from-version-1-to-version-2) — Partial (#97 done on this branch; recovery is #108) - [ ] [F-18 Retention publication](#f-18-retention-publication) — Partial; recovery in review (PR #99) - [ ] [F-19 Reader fence and immutable version-2 snapshots](#f-19-reader-fence-and-immutable-version-2-snapshots) — In review (PR #99) - [ ] [F-20 Model-based retention transition evidence](#f-20-model-based-retention-transition-evidence) — In review (PR #99) - [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Planned (#20) - [ ] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Planned (#21) -- [ ] [F-23 Durable authenticated reads and refusal receipts](#f-23-durable-authenticated-reads-and-refusal-receipts) — Planned; no open Keep issue +- [ ] [F-23 Durable authenticated reads and refusal receipts](#f-23-durable-authenticated-reads-and-refusal-receipts) — Planned (#109) - [ ] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Planned (#82, #74, #72) ### Integration (M5) @@ -128,7 +128,7 @@ names; use those in code, tests, and commits. ### Repository, process, and documentation - [x] [F-37 Repository verification tooling and CI gates](#f-37-repository-verification-tooling-and-ci-gates) — Done -- [ ] [F-38 Documentation status drift](#f-38-documentation-status-drift) — Proposed; small +- [ ] [F-38 Documentation status drift](#f-38-documentation-status-drift) — Done on this branch ### Proposed, without an owner @@ -748,7 +748,7 @@ Direct version-2 initialization is undefined. There is no downgrade. v2 corpus README, CHANGELOG. - **Dependencies:** blocks T-17.2 and T-17.3. - [ ] T-17.2 Partial-prefix migration recovery (`KEEP-MIGRATION-004`, - residual #19 item 7). + #108, the residual #19 item 7). - **Requirements:** the seven-row recovery table in `migration-recovery.md` becomes executable: no artifact admits v1; intent stage only finalizes or discards the pre-effect stage; durable @@ -1315,8 +1315,8 @@ disposition receipt exists. `BlobId`, `ChunkId`, and `LayoutId` never move. ### F-23 Durable authenticated reads and refusal receipts -**Status:** Planned. `KEEP-RECONSTRUCT-009` and `-010` cite #22 and #23, -which are closed; no open Keep issue owns this. Open one. +**Status:** Planned (#109). `KEEP-RECONSTRUCT-009` and `-010` cited the +closed #22 and #23; #109 now owns them. The durable segment, catalog, publication, and recovery surfaces do not yet form one high-level `BlobId`-to-writer contract. A durable read must @@ -2002,13 +2002,13 @@ check; a coverage threshold; a forbidden-terms check. ### F-38 Documentation status drift -**Status:** Proposed. Small, and worth doing before the next release -note. +**Status:** Done on this branch (#108 and #109 opened for the gaps the README +rows needed). Several living pages lag `main`. None changes behaviour; each misleads a reader about ownership. -- [ ] T-38.1 Fix the README gap table. +- [x] T-38.1 Fix the README gap table. Original task fields: - **Requirements:** the rows "Restart recovery for retention publication and migration" and "Reader fence" point at #19, which closed on 2026-09-08 under a different title; retention recovery and the fence @@ -2025,7 +2025,7 @@ reader about ownership. - **Test plan:** `cargo xtask documentation-integrity-check`. - **Definition of done:** merged. **Complexity:** S. - **Documentation:** README. **Dependencies:** none. -- [ ] T-38.2 Correct the crate doc in `src/lib.rs`. +- [x] T-38.2 Correct the crate doc in `src/lib.rs`. Original task fields: - **Requirements:** the sentence "Partial-prefix migration recovery, filesystem retention execution, immutable reader snapshots, and garbage collection remain intentionally absent" predates @@ -2035,7 +2035,7 @@ reader about ownership. - **Scope, stories, interface, schema:** as T-38.1. - **Test plan:** `cargo test --doc`. **Definition of done:** merged. - **Complexity:** S. **Documentation:** rustdoc. **Dependencies:** none. -- [ ] T-38.3 Reconcile v2 pages with each other. +- [x] T-38.3 Reconcile v2 pages with each other. Original task fields: - **Requirements:** `migration-inventory.md` says verification-first storage "remains in progress" while `KEEP-MIGRATION-003` says Implemented; `retention-publication.md` describes v2 catalog diff --git a/docs/formats/segment-store-v2/migration-inventory.md b/docs/formats/segment-store-v2/migration-inventory.md index 2eed8dcf..ff58c179 100644 --- a/docs/formats/segment-store-v2/migration-inventory.md +++ b/docs/formats/segment-store-v2/migration-inventory.md @@ -61,6 +61,6 @@ once. `FilesystemStoreMigrationAuthority` combines that digest with an identity-stable fixed-width `HEAD`, its selected admitted catalog, the exact version-1 root namespace, and the admitted physical root coordinates. Its `verify_current` operation repeats the complete observation and refuses any -different canonical intent before mutation. Filesystem migration storage that +different canonical intent before mutation. Filesystem migration storage invokes this verification immediately before its first namespace mutation -remains in progress. +(`KEEP-MIGRATION-003`). diff --git a/docs/formats/segment-store-v2/retention-publication.md b/docs/formats/segment-store-v2/retention-publication.md index d432c0fd..17053c80 100644 --- a/docs/formats/segment-store-v2/retention-publication.md +++ b/docs/formats/segment-store-v2/retention-publication.md @@ -20,9 +20,14 @@ the current manifest and derives exact canonical successors. ordered durability phases, and returns the complete receipt only after cleanup; exact already-committed retry revalidates authority and performs no mutation. -Version-2 catalog publication holds the same writer authority and proves every -current retained closure against its candidate catalog before replacing the -catalog `HEAD`. +Version-2 catalog publication must hold the same writer authority and prove +every current retained closure against its candidate catalog before replacing +the catalog `HEAD`. No version-2 catalog publisher exists yet: the version-1 +publisher cannot consume `FilesystemVersionTwoAdmission`, and version-1 +admission refuses a migrated root (`KEEP-MIGRATION-008`), so a migrated store +admits no catalog publication until the durable write path lands +([#82](https://github.com/flyingrobots/keep/issues/82)). This is a labeled +gap, not current behaviour. ## Generation transition diff --git a/src/lib.rs b/src/lib.rs index 1551b8bb..49ddec8b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,10 +37,13 @@ //! receipts bind an admitted intent and marker, registered empty-state digests, //! and the complete synchronization mask. Writer-locked filesystem authority //! now executes one fresh forward migration through exact fixed-record and -//! namespace transitions while retaining version-1 immutable bytes. -//! Partial-prefix migration recovery, filesystem retention execution, -//! immutable reader snapshots, and garbage collection remain intentionally -//! absent. +//! namespace transitions while retaining version-1 immutable bytes, and +//! forward retention publication executes under filesystem authority. The +//! GC retirement intent and receipt codecs, and explicit-depth verification +//! reports over the reference view, are available. Partial-prefix migration +//! recovery, retention publication recovery, immutable reader snapshots, +//! catalog publication on a migrated store, and garbage collection +//! execution remain intentionally absent. #[cfg(test)] extern crate self as keep; From 2bce29dc4cf7fe0cd11968bbd50c45b89ab41188 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 09:27:24 -0700 Subject: [PATCH 17/59] Feat: storage-independent migration recovery planning Problem: migration-recovery.md specifies a seven-row recovery table and its ambiguity rules for an interrupted version-1 to version-2 migration, but nothing executes it; KEEP-MIGRATION-004 had no evidence and an interrupted migration waits for a human (#108, the residual #19 item 7). Approach: the first slice is the planner, kept free of storage so every row is a law over the golden records. StoreMigrationResidue is the observed presence and exact bytes of every fixed migration name (the observer refuses wrong kinds and unknown entries before producing it). plan_store_migration_recovery maps a residue and the intent the version-1 store derives today onto the one StoreMigrationRecoveryPlan the table prescribes: admit version 1; discard one incomplete pre-effect stage and resume; resume at the earliest forward phase the residue cannot prove complete (a synchronization or an idempotent admission); or complete. Every other residue is a typed StoreMigrationRecoveryAmbiguity: an effect before a durable intent, an overlong, undecodable, or differing stage, a differing or undecodable intent, a namespace gap, a marker before the prefix, a receipt before the marker, or a receipt that does not bind the observed intent and marker. The persisted intent is compared on every coordinate but the mount identity, so a rebooted root does not reject its own intent (#97). Not in this change: the filesystem residue observer, the resuming storage that reopens each stage by device and inode identity, and the KEEP-CRASH-053..073 matrix. migration-recovery.md says so. Evidence: seven laws in tests/store_migration_recovery.rs cover every table row and every ambiguity rule over the frozen intent, marker, and receipt: exact, truncated, overlong, and corrupt intent stages; a durable intent with and without its stage; a remounted root admitted and a moved root refused; each effect before intent; contiguous, gapped, and fence-less prefixes; marker stage and marker resume points; receipt stage, linked, complete, and conflicting receipts. Removing the device comparison fails the moved-root law. The complete keep suite passes. Ledger: KEEP-MIGRATION-004 moves to In progress in #108. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 11 + ROADMAP.md | 6 +- .../segment-store-v2/migration-recovery.md | 18 + docs/formats/segment-store-v2/requirements.md | 2 +- src/adapters/store_migration.rs | 9 + .../migration_recovery_ambiguity.rs | 77 ++++ .../migration_recovery_ambiguity_display.rs | 64 +++ .../migration_recovery_plan.rs | 40 ++ .../migration_recovery_planner.rs | 243 +++++++++++ .../migration_recovery_residue.rs | 96 ++++ src/lib.rs | 5 + tests/store_migration_recovery.rs | 413 ++++++++++++++++++ 12 files changed, 981 insertions(+), 3 deletions(-) create mode 100644 src/adapters/store_migration/migration_recovery_ambiguity.rs create mode 100644 src/adapters/store_migration/migration_recovery_ambiguity_display.rs create mode 100644 src/adapters/store_migration/migration_recovery_plan.rs create mode 100644 src/adapters/store_migration/migration_recovery_planner.rs create mode 100644 src/adapters/store_migration/migration_recovery_residue.rs create mode 100644 tests/store_migration_recovery.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 22921b0c..b3c2017d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ after its public API and format compatibility policies are established. ### Added +- Storage-independent migration recovery planning. + `plan_store_migration_recovery` maps one observed `StoreMigrationResidue` + (the presence and exact bytes of every fixed migration name) and the + intent the version-1 store derives today onto the one lawful + `StoreMigrationRecoveryPlan` from the recovery table: admit version 1, + discard one incomplete pre-effect stage and resume, resume at the earliest + forward phase the residue cannot prove complete, or complete. Every other + residue is a typed `StoreMigrationRecoveryAmbiguity`. The persisted intent + is compared on every coordinate but the mount identity. The residue + observer, the resuming filesystem storage, and `KEEP-CRASH-053..073` + remain open in #108; `KEEP-MIGRATION-004` moves to In progress. - Explicit-depth verification. `VerificationDepth` is one ordered enumeration from `Framing` to `RetentionClosure`; `ReferenceStore::verify` and `verify_admitted_layout` establish exactly the requested depth and diff --git a/ROADMAP.md b/ROADMAP.md index f43f8c96..acc7db3b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -747,8 +747,10 @@ Direct version-2 initialization is undefined. There is no downgrade. - **Documentation:** `recovery.md`, `requirements.md`, `migration-crash.md`, v2 corpus README, CHANGELOG. - **Dependencies:** blocks T-17.2 and T-17.3. -- [ ] T-17.2 Partial-prefix migration recovery (`KEEP-MIGRATION-004`, - #108, the residual #19 item 7). +- [ ] T-17.2 Partial-prefix migration recovery (`KEEP-MIGRATION-004`; the + storage-independent planner and its laws landed on this branch; the + residue observer, resuming storage, and crash matrix remain in #108, the + residual #19 item 7). - **Requirements:** the seven-row recovery table in `migration-recovery.md` becomes executable: no artifact admits v1; intent stage only finalizes or discards the pre-effect stage; durable diff --git a/docs/formats/segment-store-v2/migration-recovery.md b/docs/formats/segment-store-v2/migration-recovery.md index 63f1c19a..e241ef1f 100644 --- a/docs/formats/segment-store-v2/migration-recovery.md +++ b/docs/formats/segment-store-v2/migration-recovery.md @@ -65,3 +65,21 @@ receipt, unknown entry, or changed root identity is unrecoverable ambiguity. Death before durable intent leaves v1 plus at most its non-authoritative stage. Death after durable intent leaves recovery-required v2 migration state. + +### Recovery planning + +`plan_store_migration_recovery` is the storage-independent form of the table. +It takes the intent the version-1 store derives today and one +`StoreMigrationResidue`, the observed presence and exact bytes of every fixed +name, and returns the one `StoreMigrationRecoveryPlan` the row prescribes: +admit version 1, discard one incomplete pre-effect stage and resume, resume +at the earliest forward phase the residue cannot prove complete, or complete. +Every other residue is a typed `StoreMigrationRecoveryAmbiguity`. The +persisted intent is compared on every coordinate but the mount identity, so +a rebooted root does not reject its own intent. + +The planner reads no storage. An observer that produces the residue and a +storage that executes the plan by reopening each stage by device and inode +identity are not implemented; until they are, an interrupted migration still +waits for a human +([#108](https://github.com/flyingrobots/keep/issues/108)). diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index b6ceb152..5ffaaa88 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -31,7 +31,7 @@ case is not evidence. | `KEEP-MIGRATION-001` | Exact version-1 stores remain admitted until a durable migration artifact exists | compatibility fixtures | Planned in #19 | | `KEEP-MIGRATION-002` | Format marker, intent, and receipt have complete fixed byte tables, named domains, bounds, checksums, deterministic store identity, and exact initial-state digests | exact admission in `tests/store_format_marker.rs`, `tests/store_migration_intent.rs`, and `tests/store_migration_receipt.rs`; canonical construction in `tests/store_migration_intent_encoding.rs` and `tests/store_migration_receipt_encoding.rs`; seeded `migration_format` fuzz target | Implemented | | `KEEP-MIGRATION-003` | Migration revalidates version-1 head, catalog, pools, root identity (all three coordinates within the migrating process; device and file across restart), and writer authority before mutation | bounded canonical pool inventory in `tests/store_migration_inventory.rs`; writer-locked filesystem pool admission in `filesystem_inventory_*_tests`; exact authority observation and drift refusal in `filesystem_migration_authority_tests`; verification-first execution in `tests/store_migration_execution.rs`; fresh filesystem integration and post-publication drift refusal in `filesystem_migration_storage_tests`; a version-one store still holding a retained stage refuses before the intent is observed in `filesystem_migration_storage_tests` | Implemented | -| `KEEP-MIGRATION-004` | Every partial migration prefix continues idempotently under writer authority, comparing only the restart-stable root coordinates (device and file) against the persisted intent | restart-stable reopen law in `filesystem_version_two_admission_tests`; state-machine and recovery tests remain | Planned in #19 | +| `KEEP-MIGRATION-004` | Every partial migration prefix continues idempotently under writer authority, comparing only the restart-stable root coordinates (device and file) against the persisted intent | storage-independent planner laws, one per recovery-table row and one per ambiguity rule, in `tests/store_migration_recovery.rs`; restart-stable reopen law in `filesystem_version_two_admission_tests`; the residue observer, the resuming filesystem storage, and the crash matrix remain | In progress in #108 | | `KEEP-MIGRATION-005` | Unknown, out-of-order, substituted, corrupt, conflicting, or changed evidence is unrecoverable ambiguity | forward-execution stage preservation, byte-equal inode-substitution, out-of-order-prefix, and post-publication drift laws in `filesystem_migration_storage_tests`; unknown `retention` entries, non-digest namespace directories, and noncanonical pool names refuse before any retention stage is written in `filesystem_retention_namespace_tests`; restart corruption and mutation matrix remains | In progress in #19 | | `KEEP-MIGRATION-006` | Migration never rewrites or deletes admitted version-1 immutable bytes | exact segment, catalog, and head before/after witness in `filesystem_migration_storage_tests`; restart-path evidence remains | In progress in #19 | | `KEEP-MIGRATION-007` | Process death around every intent stage, canonical link, namespace prefix, marker stage, receipt stage, cleanup, and synchronization boundary reaches a documented lawful state | ordered phases and capabilities in `tests/store_migration_phase.rs` and `tests/store_migration_storage.rs`; exact phase-failure execution in `tests/store_migration_execution.rs`; production 21-phase forward execution in `filesystem_migration_storage_tests`; `KEEP-CRASH-053..=073` process-death matrix remains | In progress in #19 | diff --git a/src/adapters/store_migration.rs b/src/adapters/store_migration.rs index b5fa2009..983f1b7c 100644 --- a/src/adapters/store_migration.rs +++ b/src/adapters/store_migration.rs @@ -83,6 +83,11 @@ mod migration_receipt_encoder; mod migration_receipt_format; mod migration_receipt_initial_state; mod migration_record_bytes; +mod migration_recovery_ambiguity; +mod migration_recovery_ambiguity_display; +mod migration_recovery_plan; +mod migration_recovery_planner; +mod migration_recovery_residue; mod migration_storage; mod migration_synchronization_mask; mod store_identifier; @@ -125,6 +130,10 @@ pub use migration_inventory_hasher::StoreMigrationInventoryHasher; pub use migration_phase::StoreMigrationPhase; pub use migration_receipt_decode_error::StoreMigrationReceiptDecodeError; pub(super) use migration_receipt_format::ENCODED_LENGTH as MIGRATION_RECEIPT_LENGTH; +pub use migration_recovery_ambiguity::{StoreMigrationEffect, StoreMigrationRecoveryAmbiguity}; +pub use migration_recovery_plan::{StoreMigrationFixedStage, StoreMigrationRecoveryPlan}; +pub use migration_recovery_planner::plan_store_migration_recovery; +pub use migration_recovery_residue::{MIGRATION_NAMESPACE_PREFIX, StoreMigrationResidue}; pub use migration_storage::StoreMigrationStorage; pub use migration_synchronization_mask::MigrationSynchronizationMask; pub use store_identifier::StoreIdentifier; diff --git a/src/adapters/store_migration/migration_recovery_ambiguity.rs b/src/adapters/store_migration/migration_recovery_ambiguity.rs new file mode 100644 index 00000000..e4ced3f8 --- /dev/null +++ b/src/adapters/store_migration/migration_recovery_ambiguity.rs @@ -0,0 +1,77 @@ +//! This boundary module owns migration residue that no lawful plan resolves. + +use super::{ + StoreFormatMarkerDecodeError, StoreMigrationFixedStage, StoreMigrationIntentDecodeError, + StoreMigrationReceiptDecodeError, +}; + +/// A migration effect that can exist only after a durable intent. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StoreMigrationEffect { + /// `reader.lock` or a prefix directory. + Namespace, + /// `FORMAT` or `FORMAT.next`. + Marker, + /// `migration.receipt` or `migration.receipt.next`. + Receipt, +} + +/// Residue that recovery refuses to interpret. +/// +/// Recovery never guesses which step produced conflicting evidence; every +/// variant names the exact conflict so a human can resolve it. +#[derive(Debug)] +pub enum StoreMigrationRecoveryAmbiguity { + /// A later effect exists although no durable intent does. + EffectBeforeIntent { + /// The earliest effect found. + effect: StoreMigrationEffect, + }, + /// A complete-length stage does not decode as a canonical record. + StageUndecodable { + /// The stage. + stage: StoreMigrationFixedStage, + }, + /// A stage is longer than its canonical record. + StageOverlong { + /// The stage. + stage: StoreMigrationFixedStage, + /// Observed byte length. + observed: usize, + }, + /// A complete stage and its canonical target, or a complete pre-effect + /// stage and the expected record, carry different bytes. + StageDiffers { + /// The stage. + stage: StoreMigrationFixedStage, + }, + /// `migration.intent` does not decode. + IntentUndecodable { + /// Preserved decode failure. + source: StoreMigrationIntentDecodeError, + }, + /// `migration.intent` binds a different version-1 store or root + /// (any coordinate but the mount identity). + IntentDiffers, + /// `reader.lock` and the directory prefix are not one contiguous prefix. + NamespaceOutOfOrder { + /// Index of the first absent slot (0 is `reader.lock`). + absent: usize, + /// Index of a later present slot. + present: usize, + }, + /// A marker artifact exists before the namespace prefix is complete. + MarkerBeforeNamespace, + /// `FORMAT` does not decode as the registered version-2 marker. + MarkerUndecodable { + /// Preserved decode failure. + source: StoreFormatMarkerDecodeError, + }, + /// A receipt artifact exists before `FORMAT`. + ReceiptBeforeMarker, + /// `migration.receipt` does not bind the observed intent and marker. + ReceiptUndecodable { + /// Preserved decode failure. + source: StoreMigrationReceiptDecodeError, + }, +} diff --git a/src/adapters/store_migration/migration_recovery_ambiguity_display.rs b/src/adapters/store_migration/migration_recovery_ambiguity_display.rs new file mode 100644 index 00000000..e18716a1 --- /dev/null +++ b/src/adapters/store_migration/migration_recovery_ambiguity_display.rs @@ -0,0 +1,64 @@ +//! This boundary module owns migration-recovery ambiguity formatting. + +use std::error::Error; +use std::fmt; + +use super::StoreMigrationRecoveryAmbiguity; + +impl fmt::Display for StoreMigrationRecoveryAmbiguity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EffectBeforeIntent { effect } => write!( + formatter, + "migration {effect:?} effect exists without a durable intent" + ), + Self::StageUndecodable { stage } => { + write!( + formatter, + "complete migration {stage:?} stage does not decode" + ) + } + Self::StageOverlong { stage, observed } => write!( + formatter, + "migration {stage:?} stage has {observed} bytes, more than its record" + ), + Self::StageDiffers { stage } => { + write!( + formatter, + "migration {stage:?} stage bytes differ from their record" + ) + } + Self::IntentUndecodable { .. } => { + formatter.write_str("migration intent does not decode") + } + Self::IntentDiffers => { + formatter.write_str("migration intent binds a different store or root") + } + Self::NamespaceOutOfOrder { absent, present } => write!( + formatter, + "migration namespace slot {present} exists before slot {absent}" + ), + Self::MarkerBeforeNamespace => { + formatter.write_str("format marker exists before the namespace prefix") + } + Self::MarkerUndecodable { .. } => formatter.write_str("format marker does not decode"), + Self::ReceiptBeforeMarker => { + formatter.write_str("migration receipt exists before the format marker") + } + Self::ReceiptUndecodable { .. } => { + formatter.write_str("migration receipt does not bind the observed records") + } + } + } +} + +impl Error for StoreMigrationRecoveryAmbiguity { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::IntentUndecodable { source } => Some(source), + Self::MarkerUndecodable { source } => Some(source), + Self::ReceiptUndecodable { source } => Some(source), + _ => None, + } + } +} diff --git a/src/adapters/store_migration/migration_recovery_plan.rs b/src/adapters/store_migration/migration_recovery_plan.rs new file mode 100644 index 00000000..92da9c15 --- /dev/null +++ b/src/adapters/store_migration/migration_recovery_plan.rs @@ -0,0 +1,40 @@ +//! This boundary module owns the lawful responses to migration residue. + +use super::StoreMigrationPhase; + +/// One fixed-name migration stage. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StoreMigrationFixedStage { + /// `migration.intent.next`. + Intent, + /// `FORMAT.next`. + Marker, + /// `migration.receipt.next`. + Receipt, +} + +/// The one lawful response to an observed migration residue. +/// +/// A plan is a statement about the residue; executing it is the recovery +/// storage's job. Every resume point is the earliest phase whose effect the +/// residue cannot prove, so re-running from it is idempotent. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StoreMigrationRecoveryPlan { + /// No migration artifact exists: the exact version-1 store admits. + VersionOne, + /// Remove one incomplete pre-effect stage, then resume at `resume`. + DiscardStage { + /// The incomplete stage to remove. + stage: StoreMigrationFixedStage, + /// The forward phase to resume at after removal. + resume: StoreMigrationPhase, + }, + /// Resume the forward protocol at `resume`. + Resume { + /// The earliest phase the residue cannot prove complete. + resume: StoreMigrationPhase, + }, + /// The exact receipt is canonical and no stage remains. + Complete, +} diff --git a/src/adapters/store_migration/migration_recovery_planner.rs b/src/adapters/store_migration/migration_recovery_planner.rs new file mode 100644 index 00000000..57572b27 --- /dev/null +++ b/src/adapters/store_migration/migration_recovery_planner.rs @@ -0,0 +1,243 @@ +//! This boundary module owns storage-independent migration recovery planning. +//! +//! The planner turns one observed residue into the one lawful response the +//! recovery table in `migration-recovery.md` prescribes, or into the exact +//! ambiguity that refuses it. It reads no storage and mutates nothing. + +use super::migration_recovery_ambiguity::StoreMigrationEffect as Effect; +use super::{ + AdmittedStoreFormatMarker, AdmittedStoreMigrationIntent, AdmittedStoreMigrationReceipt, + FORMAT_MARKER_LENGTH, MIGRATION_INTENT_LENGTH, MIGRATION_RECEIPT_LENGTH, + StoreMigrationFixedStage as Stage, StoreMigrationPhase as Phase, + StoreMigrationRecoveryAmbiguity as Ambiguity, StoreMigrationRecoveryPlan as Plan, + StoreMigrationResidue, +}; + +/// Plans the one lawful recovery of `residue` against the intent the +/// version-1 store derives today. +/// +/// The expected intent is compared on every coordinate but the mount +/// identity, which is same-process evidence (see `recovery.md`, "Root +/// identity across restart"). +/// +/// # Errors +/// +/// Returns [`StoreMigrationRecoveryAmbiguity`](super::StoreMigrationRecoveryAmbiguity) +/// when the residue matches no table row. +pub fn plan_store_migration_recovery( + expected: &AdmittedStoreMigrationIntent<'_>, + residue: &StoreMigrationResidue, +) -> Result { + let Some(intent_bytes) = residue.intent.as_deref() else { + return plan_before_durable_intent(expected, residue); + }; + let intent = AdmittedStoreMigrationIntent::decode(intent_bytes) + .map_err(|source| Ambiguity::IntentUndecodable { source })?; + if !restart_stable_match(expected, &intent) { + return Err(Ambiguity::IntentDiffers); + } + if let Some(stage) = residue.intent_stage.as_deref() { + return if stage == intent_bytes { + Ok(Plan::Resume { + resume: Phase::SynchronizeRootAfterIntent, + }) + } else { + Err(Ambiguity::StageDiffers { + stage: Stage::Intent, + }) + }; + } + plan_namespace(&intent, residue) +} + +/// Rows one and two: nothing, or an intent stage alone. +fn plan_before_durable_intent( + expected: &AdmittedStoreMigrationIntent<'_>, + residue: &StoreMigrationResidue, +) -> Result { + if residue.has_namespace_effect() { + return Err(Ambiguity::EffectBeforeIntent { + effect: Effect::Namespace, + }); + } + if residue.marker_stage.is_some() || residue.marker.is_some() { + return Err(Ambiguity::EffectBeforeIntent { + effect: Effect::Marker, + }); + } + if residue.has_receipt_effect() { + return Err(Ambiguity::EffectBeforeIntent { + effect: Effect::Receipt, + }); + } + let Some(stage) = residue.intent_stage.as_deref() else { + return Ok(Plan::VersionOne); + }; + match classify_stage(Stage::Intent, stage, MIGRATION_INTENT_LENGTH)? { + StageShape::Incomplete => Ok(Plan::DiscardStage { + stage: Stage::Intent, + resume: Phase::WriteIntentStage, + }), + StageShape::Complete => { + let staged = AdmittedStoreMigrationIntent::decode(stage).map_err(|_| { + Ambiguity::StageUndecodable { + stage: Stage::Intent, + } + })?; + if restart_stable_match(expected, &staged) { + Ok(Plan::Resume { + resume: Phase::SynchronizeIntentStage, + }) + } else { + Err(Ambiguity::StageDiffers { + stage: Stage::Intent, + }) + } + } + } +} + +/// Rows three and four: a durable intent, then `reader.lock` and the prefix. +fn plan_namespace( + intent: &AdmittedStoreMigrationIntent<'_>, + residue: &StoreMigrationResidue, +) -> Result { + let extent = residue + .namespace_extent() + .map_err(|(absent, present)| Ambiguity::NamespaceOutOfOrder { absent, present })?; + if extent < 7 { + if residue.has_marker_or_receipt_effect() { + return Err(Ambiguity::MarkerBeforeNamespace); + } + return Ok(Plan::Resume { + resume: if extent == 0 { + Phase::SynchronizeRootAfterIntentCleanup + } else { + Phase::AdmitNamespacePrefix + }, + }); + } + plan_marker(intent, residue) +} + +/// Rows five and six: the marker stage and the canonical marker. +fn plan_marker( + intent: &AdmittedStoreMigrationIntent<'_>, + residue: &StoreMigrationResidue, +) -> Result { + let Some(marker_bytes) = residue.marker.as_deref() else { + if residue.has_receipt_effect() { + return Err(Ambiguity::ReceiptBeforeMarker); + } + let Some(stage) = residue.marker_stage.as_deref() else { + return Ok(Plan::Resume { + resume: Phase::SynchronizeRootAfterNamespace, + }); + }; + return match classify_stage(Stage::Marker, stage, FORMAT_MARKER_LENGTH)? { + StageShape::Incomplete => Ok(Plan::DiscardStage { + stage: Stage::Marker, + resume: Phase::WriteMarkerStage, + }), + StageShape::Complete => { + AdmittedStoreFormatMarker::decode(stage) + .map(|_| ()) + .map_err(|_| Ambiguity::StageUndecodable { + stage: Stage::Marker, + })?; + Ok(Plan::Resume { + resume: Phase::SynchronizeMarkerStage, + }) + } + }; + }; + let marker = AdmittedStoreFormatMarker::decode(marker_bytes) + .map_err(|source| Ambiguity::MarkerUndecodable { source })?; + if let Some(stage) = residue.marker_stage.as_deref() { + return if stage == marker_bytes { + Ok(Plan::Resume { + resume: Phase::SynchronizeRootAfterMarker, + }) + } else { + Err(Ambiguity::StageDiffers { + stage: Stage::Marker, + }) + }; + } + plan_receipt(intent, &marker, residue) +} + +/// Row seven: the receipt stage and the canonical receipt. +fn plan_receipt( + intent: &AdmittedStoreMigrationIntent<'_>, + marker: &AdmittedStoreFormatMarker<'_>, + residue: &StoreMigrationResidue, +) -> Result { + let Some(receipt_bytes) = residue.receipt.as_deref() else { + let Some(stage) = residue.receipt_stage.as_deref() else { + return Ok(Plan::Resume { + resume: Phase::SynchronizeRootAfterMarkerCleanup, + }); + }; + return match classify_stage(Stage::Receipt, stage, MIGRATION_RECEIPT_LENGTH)? { + StageShape::Incomplete => Ok(Plan::DiscardStage { + stage: Stage::Receipt, + resume: Phase::WriteReceiptStage, + }), + StageShape::Complete => { + AdmittedStoreMigrationReceipt::decode(stage, intent, marker) + .map(|_| ()) + .map_err(|_| Ambiguity::StageUndecodable { + stage: Stage::Receipt, + })?; + Ok(Plan::Resume { + resume: Phase::SynchronizeReceiptStage, + }) + } + }; + }; + AdmittedStoreMigrationReceipt::decode(receipt_bytes, intent, marker) + .map(|_| ()) + .map_err(|source| Ambiguity::ReceiptUndecodable { source })?; + match residue.receipt_stage.as_deref() { + None => Ok(Plan::Complete), + Some(stage) if stage == receipt_bytes => Ok(Plan::Resume { + resume: Phase::SynchronizeRootAfterReceipt, + }), + Some(_) => Err(Ambiguity::StageDiffers { + stage: Stage::Receipt, + }), + } +} + +enum StageShape { + Incomplete, + Complete, +} + +fn classify_stage(stage: Stage, bytes: &[u8], length: usize) -> Result { + match bytes.len().cmp(&length) { + std::cmp::Ordering::Less => Ok(StageShape::Incomplete), + std::cmp::Ordering::Equal => Ok(StageShape::Complete), + std::cmp::Ordering::Greater => Err(Ambiguity::StageOverlong { + stage, + observed: bytes.len(), + }), + } +} + +/// Every intent coordinate but the mount identity. +fn restart_stable_match( + expected: &AdmittedStoreMigrationIntent<'_>, + observed: &AdmittedStoreMigrationIntent<'_>, +) -> bool { + expected.catalog_generation() == observed.catalog_generation() + && expected.catalog_length() == observed.catalog_length() + && expected.catalog_digest() == observed.catalog_digest() + && expected.predecessor_catalog_digest() == observed.predecessor_catalog_digest() + && expected.inventory_digest() == observed.inventory_digest() + && expected.root_device_identity() == observed.root_device_identity() + && expected.root_file_identity() == observed.root_file_identity() + && expected.target_definition_digest() == observed.target_definition_digest() + && expected.store_identifier() == observed.store_identifier() +} diff --git a/src/adapters/store_migration/migration_recovery_residue.rs b/src/adapters/store_migration/migration_recovery_residue.rs new file mode 100644 index 00000000..93e6e0e7 --- /dev/null +++ b/src/adapters/store_migration/migration_recovery_residue.rs @@ -0,0 +1,96 @@ +//! This boundary module owns the observed residue of one interrupted +//! migration. + +/// Names of the version-2 directory prefix in creation order, after +/// `reader.lock`. +pub const MIGRATION_NAMESPACE_PREFIX: [&str; 6] = [ + "retention", + "retention/roots", + "retention/manifests", + "gc", + "recovery", + "recovery/dispositions", +]; + +/// Everything a migration may have left behind, as one observation. +/// +/// An observer reads each fixed name without following links and refuses a +/// wrong file kind, a link, or an unknown entry before producing this value, +/// so the planner sees only bytes and presence. Byte fields carry exactly the +/// bytes observed, bounded by the observer to one byte more than the record's +/// canonical length so overlong records stay distinguishable. +#[must_use] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct StoreMigrationResidue { + /// Bytes of `migration.intent.next`, when present. + pub intent_stage: Option>, + /// Bytes of `migration.intent`, when present. + pub intent: Option>, + /// Whether the persistent `reader.lock` exists. + pub reader_fence: bool, + /// Presence of each directory in [`MIGRATION_NAMESPACE_PREFIX`] order. + pub namespace_prefix: [bool; 6], + /// Bytes of `FORMAT.next`, when present. + pub marker_stage: Option>, + /// Bytes of `FORMAT`, when present. + pub marker: Option>, + /// Bytes of `migration.receipt.next`, when present. + pub receipt_stage: Option>, + /// Bytes of `migration.receipt`, when present. + pub receipt: Option>, +} + +impl StoreMigrationResidue { + /// The observation of a store with no migration artifact at all. + pub const VERSION_ONE: Self = Self { + intent_stage: None, + intent: None, + reader_fence: false, + namespace_prefix: [false; 6], + marker_stage: None, + marker: None, + receipt_stage: None, + receipt: None, + }; + + /// Whether `reader.lock` or any prefix directory exists. + #[must_use] + pub fn has_namespace_effect(&self) -> bool { + self.reader_fence || self.namespace_prefix.iter().any(|present| *present) + } + + /// Whether any marker or receipt artifact or stage exists. + #[must_use] + pub const fn has_marker_or_receipt_effect(&self) -> bool { + self.marker_stage.is_some() + || self.marker.is_some() + || self.receipt_stage.is_some() + || self.receipt.is_some() + } + + /// Whether any receipt artifact or stage exists. + #[must_use] + pub const fn has_receipt_effect(&self) -> bool { + self.receipt_stage.is_some() || self.receipt.is_some() + } + + /// Length of the contiguous present prefix of `reader.lock` followed by + /// the six directories. + /// + /// # Errors + /// + /// Returns the index of the first absent slot and the index of a later + /// present slot when the prefix is not contiguous. + pub fn namespace_extent(&self) -> Result { + let slots = std::iter::once(self.reader_fence).chain(self.namespace_prefix); + let mut first_absent = None; + for (index, present) in slots.enumerate() { + match (first_absent, present) { + (None, false) => first_absent = Some(index), + (Some(absent), true) => return Err((absent, index)), + _ => {} + } + } + Ok(first_absent.unwrap_or(7)) + } +} diff --git a/src/lib.rs b/src/lib.rs index 49ddec8b..ce979390 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -161,6 +161,11 @@ pub use adapters::{ VerifiedRetentionClosure, execute_retention_publication, plan_retention_transition, preflight_retention_transition, prepare_retention_publication, verify_retention_closure, }; +pub use adapters::{ + MIGRATION_NAMESPACE_PREFIX, StoreMigrationEffect, StoreMigrationFixedStage, + StoreMigrationRecoveryAmbiguity, StoreMigrationRecoveryPlan, StoreMigrationResidue, + plan_store_migration_recovery, +}; pub use blob::{ BlobHashError, BlobHasher, BlobId, BlobLength, BlobReadError, ByteLength, ByteOffset, ByteRange, ByteRangeError, diff --git a/tests/store_migration_recovery.rs b/tests/store_migration_recovery.rs new file mode 100644 index 00000000..1609da78 --- /dev/null +++ b/tests/store_migration_recovery.rs @@ -0,0 +1,413 @@ +//! Storage-independent migration recovery planning laws. +//! +//! One law per row of the recovery table in `migration-recovery.md`, plus +//! one per ambiguity rule, each over the frozen version-2 records. + +mod support; + +use std::error::Error; + +use keep::{ + AdmittedStoreMigrationIntent, StoreMigrationEffect, StoreMigrationFixedStage as Stage, + StoreMigrationPhase as Phase, StoreMigrationRecoveryAmbiguity as Ambiguity, + StoreMigrationRecoveryPlan as Plan, StoreMigrationResidue, plan_store_migration_recovery, +}; + +use crate::support::{domain_hash, patch}; + +const INTENT: &str = include_str!("../conformance/segment-store/v2/migration-intent.hex"); +const MARKER: &str = include_str!("../conformance/segment-store/v2/format-marker.hex"); +const RECEIPT: &str = include_str!("../conformance/segment-store/v2/migration-receipt.hex"); +const INTENT_CHECKSUM_OFFSET: usize = 224; +const ROOT_DEVICE_OFFSET: usize = 136; +const ROOT_MOUNT_OFFSET: usize = 144; + +struct Records { + intent: Vec, + marker: Vec, + receipt: Vec, +} + +#[test] +fn no_artifact_admits_version_one() -> Result<(), Box> { + let records = records()?; + let expected = AdmittedStoreMigrationIntent::decode(&records.intent)?; + assert_eq!( + plan_store_migration_recovery(&expected, &StoreMigrationResidue::VERSION_ONE)?, + Plan::VersionOne + ); + Ok(()) +} + +#[test] +fn an_intent_stage_alone_resumes_when_exact_and_is_discarded_when_incomplete() +-> Result<(), Box> { + let records = records()?; + let expected = AdmittedStoreMigrationIntent::decode(&records.intent)?; + + let exact = StoreMigrationResidue { + intent_stage: Some(records.intent.clone()), + ..StoreMigrationResidue::VERSION_ONE + }; + assert_eq!( + plan_store_migration_recovery(&expected, &exact)?, + Plan::Resume { + resume: Phase::SynchronizeIntentStage + } + ); + + let mut truncated = records.intent.clone(); + truncated.truncate(100); + let incomplete = StoreMigrationResidue { + intent_stage: Some(truncated), + ..StoreMigrationResidue::VERSION_ONE + }; + assert_eq!( + plan_store_migration_recovery(&expected, &incomplete)?, + Plan::DiscardStage { + stage: Stage::Intent, + resume: Phase::WriteIntentStage, + } + ); + + let mut overlong = records.intent.clone(); + overlong.push(0); + let overlong = StoreMigrationResidue { + intent_stage: Some(overlong), + ..StoreMigrationResidue::VERSION_ONE + }; + assert!(matches!( + plan_store_migration_recovery(&expected, &overlong), + Err(Ambiguity::StageOverlong { + stage: Stage::Intent, + observed: 257, + }) + )); + + let mut corrupt = records.intent.clone(); + let last = corrupt.last_mut().ok_or("intent is empty")?; + *last ^= 1; + let corrupt = StoreMigrationResidue { + intent_stage: Some(corrupt), + ..StoreMigrationResidue::VERSION_ONE + }; + assert!(matches!( + plan_store_migration_recovery(&expected, &corrupt), + Err(Ambiguity::StageUndecodable { + stage: Stage::Intent + }) + )); + Ok(()) +} + +#[test] +fn a_durable_intent_resumes_after_its_cleanup_and_binds_restart_stable_coordinates() +-> Result<(), Box> { + let records = records()?; + let expected = AdmittedStoreMigrationIntent::decode(&records.intent)?; + + let durable = StoreMigrationResidue { + intent: Some(records.intent.clone()), + ..StoreMigrationResidue::VERSION_ONE + }; + assert_eq!( + plan_store_migration_recovery(&expected, &durable)?, + Plan::Resume { + resume: Phase::SynchronizeRootAfterIntentCleanup + } + ); + + let with_stage = StoreMigrationResidue { + intent_stage: Some(records.intent.clone()), + ..durable.clone() + }; + assert_eq!( + plan_store_migration_recovery(&expected, &with_stage)?, + Plan::Resume { + resume: Phase::SynchronizeRootAfterIntent + } + ); + + let mut remounted = records.intent.clone(); + patch(&mut remounted, ROOT_MOUNT_OFFSET, &9_u64.to_be_bytes())?; + reseal_intent(&mut remounted)?; + let remounted = AdmittedStoreMigrationIntent::decode(&remounted)?; + assert_eq!( + plan_store_migration_recovery(&remounted, &durable)?, + Plan::Resume { + resume: Phase::SynchronizeRootAfterIntentCleanup + }, + "a rebooted root's new mount id must not reject the store's own intent" + ); + + let mut moved = records.intent.clone(); + patch(&mut moved, ROOT_DEVICE_OFFSET, &9_u64.to_be_bytes())?; + reseal_intent(&mut moved)?; + let moved = AdmittedStoreMigrationIntent::decode(&moved)?; + assert!(matches!( + plan_store_migration_recovery(&moved, &durable), + Err(Ambiguity::IntentDiffers) + )); + + let mut corrupt = durable; + if let Some(bytes) = corrupt.intent.as_mut() + && let Some(last) = bytes.last_mut() + { + *last ^= 1; + } + assert!(matches!( + plan_store_migration_recovery(&expected, &corrupt), + Err(Ambiguity::IntentUndecodable { .. }) + )); + Ok(()) +} + +#[test] +fn every_effect_needs_a_durable_intent_first() -> Result<(), Box> { + let records = records()?; + let expected = AdmittedStoreMigrationIntent::decode(&records.intent)?; + for (residue, effect) in [ + ( + StoreMigrationResidue { + reader_fence: true, + ..StoreMigrationResidue::VERSION_ONE + }, + StoreMigrationEffect::Namespace, + ), + ( + StoreMigrationResidue { + marker: Some(records.marker.clone()), + ..StoreMigrationResidue::VERSION_ONE + }, + StoreMigrationEffect::Marker, + ), + ( + StoreMigrationResidue { + receipt_stage: Some(records.receipt.clone()), + ..StoreMigrationResidue::VERSION_ONE + }, + StoreMigrationEffect::Receipt, + ), + ] { + assert!(matches!( + plan_store_migration_recovery(&expected, &residue), + Err(Ambiguity::EffectBeforeIntent { effect: observed }) if observed == effect + )); + } + Ok(()) +} + +#[test] +fn the_namespace_prefix_resumes_in_order_and_refuses_gaps() -> Result<(), Box> { + let records = records()?; + let expected = AdmittedStoreMigrationIntent::decode(&records.intent)?; + let durable = StoreMigrationResidue { + intent: Some(records.intent.clone()), + ..StoreMigrationResidue::VERSION_ONE + }; + + let partial = StoreMigrationResidue { + reader_fence: true, + namespace_prefix: [true, true, false, false, false, false], + ..durable.clone() + }; + assert_eq!( + plan_store_migration_recovery(&expected, &partial)?, + Plan::Resume { + resume: Phase::AdmitNamespacePrefix + } + ); + + let complete = StoreMigrationResidue { + reader_fence: true, + namespace_prefix: [true; 6], + ..durable.clone() + }; + assert_eq!( + plan_store_migration_recovery(&expected, &complete)?, + Plan::Resume { + resume: Phase::SynchronizeRootAfterNamespace + } + ); + + let gap = StoreMigrationResidue { + reader_fence: true, + namespace_prefix: [true, false, true, false, false, false], + ..durable.clone() + }; + assert!(matches!( + plan_store_migration_recovery(&expected, &gap), + Err(Ambiguity::NamespaceOutOfOrder { + absent: 2, + present: 3, + }) + )); + + let no_fence = StoreMigrationResidue { + namespace_prefix: [true, false, false, false, false, false], + ..durable + }; + assert!(matches!( + plan_store_migration_recovery(&expected, &no_fence), + Err(Ambiguity::NamespaceOutOfOrder { + absent: 0, + present: 1, + }) + )); + + let early_marker = StoreMigrationResidue { + marker_stage: Some(records.marker.clone()), + ..partial + }; + assert!(matches!( + plan_store_migration_recovery(&expected, &early_marker), + Err(Ambiguity::MarkerBeforeNamespace) + )); + Ok(()) +} + +#[test] +fn the_marker_stage_and_marker_resume_at_their_first_unproven_phase() -> Result<(), Box> +{ + let records = records()?; + let expected = AdmittedStoreMigrationIntent::decode(&records.intent)?; + let namespace = StoreMigrationResidue { + intent: Some(records.intent.clone()), + reader_fence: true, + namespace_prefix: [true; 6], + ..StoreMigrationResidue::VERSION_ONE + }; + + let staged = StoreMigrationResidue { + marker_stage: Some(records.marker.clone()), + ..namespace.clone() + }; + assert_eq!( + plan_store_migration_recovery(&expected, &staged)?, + Plan::Resume { + resume: Phase::SynchronizeMarkerStage + } + ); + + let mut short = records.marker.clone(); + short.truncate(10); + let incomplete = StoreMigrationResidue { + marker_stage: Some(short), + ..namespace.clone() + }; + assert_eq!( + plan_store_migration_recovery(&expected, &incomplete)?, + Plan::DiscardStage { + stage: Stage::Marker, + resume: Phase::WriteMarkerStage, + } + ); + + let linked = StoreMigrationResidue { + marker: Some(records.marker.clone()), + marker_stage: Some(records.marker.clone()), + ..namespace.clone() + }; + assert_eq!( + plan_store_migration_recovery(&expected, &linked)?, + Plan::Resume { + resume: Phase::SynchronizeRootAfterMarker + } + ); + + let published = StoreMigrationResidue { + marker: Some(records.marker.clone()), + ..namespace.clone() + }; + assert_eq!( + plan_store_migration_recovery(&expected, &published)?, + Plan::Resume { + resume: Phase::SynchronizeRootAfterMarkerCleanup + } + ); + + let early_receipt = StoreMigrationResidue { + receipt: Some(records.receipt.clone()), + ..namespace + }; + assert!(matches!( + plan_store_migration_recovery(&expected, &early_receipt), + Err(Ambiguity::ReceiptBeforeMarker) + )); + Ok(()) +} + +#[test] +fn the_receipt_completes_the_migration_only_when_exact_and_alone() -> Result<(), Box> { + let records = records()?; + let expected = AdmittedStoreMigrationIntent::decode(&records.intent)?; + let marked = StoreMigrationResidue { + intent: Some(records.intent.clone()), + reader_fence: true, + namespace_prefix: [true; 6], + marker: Some(records.marker.clone()), + ..StoreMigrationResidue::VERSION_ONE + }; + + let staged = StoreMigrationResidue { + receipt_stage: Some(records.receipt.clone()), + ..marked.clone() + }; + assert_eq!( + plan_store_migration_recovery(&expected, &staged)?, + Plan::Resume { + resume: Phase::SynchronizeReceiptStage + } + ); + + let linked = StoreMigrationResidue { + receipt: Some(records.receipt.clone()), + receipt_stage: Some(records.receipt.clone()), + ..marked.clone() + }; + assert_eq!( + plan_store_migration_recovery(&expected, &linked)?, + Plan::Resume { + resume: Phase::SynchronizeRootAfterReceipt + } + ); + + let complete = StoreMigrationResidue { + receipt: Some(records.receipt.clone()), + ..marked.clone() + }; + assert_eq!( + plan_store_migration_recovery(&expected, &complete)?, + Plan::Complete + ); + + let mut conflicting = records.receipt.clone(); + let last = conflicting.last_mut().ok_or("receipt is empty")?; + *last ^= 1; + let conflicting = StoreMigrationResidue { + receipt: Some(conflicting), + ..marked + }; + assert!(matches!( + plan_store_migration_recovery(&expected, &conflicting), + Err(Ambiguity::ReceiptUndecodable { .. }) + )); + Ok(()) +} + +fn records() -> Result> { + Ok(Records { + intent: support::decode_hex(INTENT.trim_end())?, + marker: support::decode_hex(MARKER.trim_end())?, + receipt: support::decode_hex(RECEIPT.trim_end())?, + }) +} + +fn reseal_intent(bytes: &mut [u8]) -> Result<(), Box> { + let preimage = bytes + .get(..INTENT_CHECKSUM_OFFSET) + .ok_or("intent lacks its checksum preimage")?; + let checksum = domain_hash(b"keep.store-migration-intent-checksum/v2\0", preimage); + patch(bytes, INTENT_CHECKSUM_OFFSET, &checksum)?; + Ok(()) +} From ce3bc67b72910ebe5c8157980fea4bf050e1eb1a Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 09:40:02 -0700 Subject: [PATCH 18/59] Feat: recover an interrupted migration from any prefix Problem: a version-1 to version-2 migration that died between its first intent byte and its final root synchronization left residue that nothing could continue; version-1 admission refused the root because migration names existed, and the fresh forward writer refused because they did. An interrupted migration waited for a human (#108, residual #19 item 7). Approach: recovery is one ordered operation over a storage port, recover_store_migration: observe the residue (every fixed migration name without following links, bounded to one byte more than its record), plan it with the storage-independent planner against the intent the version-1 store derives today, adopt the exact stage and canonical handles the resume point needs, remove an incomplete pre-effect stage only when the plan says so, and resume_store_migration through every later phase with the persisted intent, never the freshly derived one, so a rebooted root's new mount id changes nothing. StoreMigrationRecoveryStorage is the port; FilesystemStoreMigrationAuthority::reopen_for_recovery is the filesystem form. It admits the published version-1 names plus any subset of migration residue under the writer lock without ever minting a version-1 platform admission, so the version-1 publisher can never run against a partly migrated root; adoption reopens each stage or canonical record by device and inode identity through the shared exact-record primitives, and the forward phases then verify against those handles exactly as the fresh writer would. Two primitives join filesystem_exact_record so no consumer opens records itself. Evidence: every forward prefix of zero through twenty-one phases, run in-process and then abandoned, recovers under a fresh authority to one complete migration whose intent, marker, and receipt admit, with no stage left and every version-1 byte unchanged; a stage truncated to 100 bytes is discarded and the migration completes; a corrupt durable intent refuses as ambiguity before any mutation. With canonical-record adoption disabled, prefix 5 fails "migration fixed record was not published". The complete keep suite, the architecture contract laws, the doctests, and the documentation gates pass. Ledger: KEEP-MIGRATION-001, -004, and -006 move to Implemented; -005 and -007 move to In progress in #108 with the process-death matrix remaining. ROADMAP T-17.2 checked. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 30 +-- README.md | 20 +- ROADMAP.md | 11 +- docs/formats/segment-store-v2/README.md | 8 +- .../segment-store-v2/migration-crash.md | 12 +- .../segment-store-v2/migration-recovery.md | 21 +- docs/formats/segment-store-v2/recovery.md | 8 +- docs/formats/segment-store-v2/requirements.md | 10 +- src/adapters/filesystem_exact_record.rs | 28 +++ .../filesystem_initialization_namespace.rs | 32 +++ src/adapters/store_migration.rs | 12 ++ .../filesystem_inventory_reader.rs | 13 ++ .../filesystem_migration_authority.rs | 36 +++- .../filesystem_migration_authority_error.rs | 14 +- ...ystem_migration_authority_error_display.rs | 11 +- .../filesystem_migration_fixed_artifact.rs | 45 ++++- .../filesystem_migration_recovery.rs | 177 +++++++++++++++++ .../filesystem_migration_recovery_tests.rs | 186 ++++++++++++++++++ .../filesystem_migration_residue.rs | 85 ++++++++ .../migration_recovery_execution.rs | 177 +++++++++++++++++ .../migration_recovery_storage.rs | 47 +++++ .../store_migration/migration_resumption.rs | 162 +++++++++++++++ src/lib.rs | 5 +- 23 files changed, 1094 insertions(+), 56 deletions(-) create mode 100644 src/adapters/store_migration/filesystem_migration_recovery.rs create mode 100644 src/adapters/store_migration/filesystem_migration_recovery_tests.rs create mode 100644 src/adapters/store_migration/filesystem_migration_residue.rs create mode 100644 src/adapters/store_migration/migration_recovery_execution.rs create mode 100644 src/adapters/store_migration/migration_recovery_storage.rs create mode 100644 src/adapters/store_migration/migration_resumption.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c2017d..00ce8713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,17 +10,25 @@ after its public API and format compatibility policies are established. ### Added -- Storage-independent migration recovery planning. - `plan_store_migration_recovery` maps one observed `StoreMigrationResidue` - (the presence and exact bytes of every fixed migration name) and the - intent the version-1 store derives today onto the one lawful - `StoreMigrationRecoveryPlan` from the recovery table: admit version 1, - discard one incomplete pre-effect stage and resume, resume at the earliest - forward phase the residue cannot prove complete, or complete. Every other - residue is a typed `StoreMigrationRecoveryAmbiguity`. The persisted intent - is compared on every coordinate but the mount identity. The residue - observer, the resuming filesystem storage, and `KEEP-CRASH-053..073` - remain open in #108; `KEEP-MIGRATION-004` moves to In progress. +- Partial-prefix migration recovery. `plan_store_migration_recovery` maps + one observed `StoreMigrationResidue` (the presence and exact bytes of every + fixed migration name) and the intent the version-1 store derives today + onto the one lawful `StoreMigrationRecoveryPlan` from the recovery table: + admit version 1, discard one incomplete pre-effect stage and resume, + resume at the earliest forward phase the residue cannot prove complete, or + complete; every other residue is a typed `StoreMigrationRecoveryAmbiguity`. + `recover_store_migration` drives a `StoreMigrationRecoveryStorage` through + observe, plan, adopt, discard, and `resume_store_migration`, publishing + the persisted intent rather than the freshly derived one. + `FilesystemStoreMigrationAuthority::reopen_for_recovery` acquires the + writer lock over a root carrying any lawful residue without minting a + version-1 platform admission, adopts exact stages and canonical records by + device and inode identity, and removes only an incomplete pre-effect + stage. Every prefix of zero through twenty-one phases and a truncated stage + recover in-process to one complete migration with every version-1 byte + intact; a corrupt durable intent refuses before any mutation. + `KEEP-MIGRATION-001` and `-004` move to Implemented; the + `KEEP-CRASH-053..073` process-death matrix remains open in #108. - Explicit-depth verification. `VerificationDepth` is one ordered enumeration from `Framing` to `RetentionClosure`; `ReferenceStore::verify` and `verify_admitted_layout` establish exactly the requested depth and diff --git a/README.md b/README.md index 1412db99..fcc04f4e 100644 --- a/README.md +++ b/README.md @@ -69,20 +69,20 @@ Keep is required to refuse all three, before mutating anything. ## What it does not do yet -Version 2 writes correctly from a clean start and, if it finds the residue of -an interrupted publication or migration, refuses rather than guesses. On -`main`, nothing yet recovers that residue and readers have no fence, so -**an interrupted version-2 publication or migration waits for a human.** -Retention recovery and the reader fence are in review in -[PR #99](https://github.com/flyingrobots/keep/pull/99); migration recovery -is [#108](https://github.com/flyingrobots/keep/issues/108). A version-1 -store stays admitted until its owner migrates it; migrate only if you accept -that wait. +Version 2 writes correctly from a clean start. An interrupted migration +recovers: `FilesystemStoreMigrationAuthority::reopen_for_recovery` and +`recover_store_migration` resume any prefix of the twenty-one phases, proven +in-process for every prefix; the process-death matrix for those phases is +still open in [#108](https://github.com/flyingrobots/keep/issues/108). An +interrupted retention publication is refused rather than guessed at, and +readers have no fence, so **an interrupted version-2 publication waits for a +human** until [PR #99](https://github.com/flyingrobots/keep/pull/99) merges. +A version-1 store stays admitted until its owner migrates it. | Gap | Tracked | | --- | --- | | Restart recovery for retention publication | [PR #99](https://github.com/flyingrobots/keep/pull/99) | -| Restart recovery for migration | [#108](https://github.com/flyingrobots/keep/issues/108) | +| Process-death evidence for migration recovery | [#108](https://github.com/flyingrobots/keep/issues/108) | | Reader fence binding one consistent catalog + retention snapshot | [PR #99](https://github.com/flyingrobots/keep/pull/99) | | Durable authenticated reads bound to a fenced snapshot | [#109](https://github.com/flyingrobots/keep/issues/109) | | Verification reports at durable depths and a replayable receipt | [#20](https://github.com/flyingrobots/keep/issues/20) | diff --git a/ROADMAP.md b/ROADMAP.md index acc7db3b..c4f950e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -95,7 +95,7 @@ names; use those in code, tests, and commits. - [x] [F-15 Retention roots, release, and GC liveness model](#f-15-retention-roots-release-and-gc-liveness-model) — Done (ADR-0009) - [x] [F-16 Version-2 format records and codecs](#f-16-version-2-format-records-and-codecs) — Done -- [ ] [F-17 One-way migration from version 1 to version 2](#f-17-one-way-migration-from-version-1-to-version-2) — Partial (#97 done on this branch; recovery is #108) +- [ ] [F-17 One-way migration from version 1 to version 2](#f-17-one-way-migration-from-version-1-to-version-2) — Partial (#97 and in-process recovery done on this branch; the crash matrix is #108) - [ ] [F-18 Retention publication](#f-18-retention-publication) — Partial; recovery in review (PR #99) - [ ] [F-19 Reader fence and immutable version-2 snapshots](#f-19-reader-fence-and-immutable-version-2-snapshots) — In review (PR #99) - [ ] [F-20 Model-based retention transition evidence](#f-20-model-based-retention-transition-evidence) — In review (PR #99) @@ -747,10 +747,11 @@ Direct version-2 initialization is undefined. There is no downgrade. - **Documentation:** `recovery.md`, `requirements.md`, `migration-crash.md`, v2 corpus README, CHANGELOG. - **Dependencies:** blocks T-17.2 and T-17.3. -- [ ] T-17.2 Partial-prefix migration recovery (`KEEP-MIGRATION-004`; the - storage-independent planner and its laws landed on this branch; the - residue observer, resuming storage, and crash matrix remain in #108, the - residual #19 item 7). +- [x] T-17.2 Partial-prefix migration recovery (`KEEP-MIGRATION-004`) — + planner, residue observer, resuming storage, and + `FilesystemStoreMigrationAuthority::reopen_for_recovery`, proven + in-process for every prefix; the process-death matrix is T-17.3 (#108). + Original task fields: - **Requirements:** the seven-row recovery table in `migration-recovery.md` becomes executable: no artifact admits v1; intent stage only finalizes or discards the pre-effect stage; durable diff --git a/docs/formats/segment-store-v2/README.md b/docs/formats/segment-store-v2/README.md index 0f70d682..764aa255 100644 --- a/docs/formats/segment-store-v2/README.md +++ b/docs/formats/segment-store-v2/README.md @@ -95,9 +95,11 @@ stages, replaced protocol directories, and every namespace or capacity violation before mutation, each as a typed `RetentionCurrentStateRefusal`. Not implemented: retention publication recovery and `KEEP-CRASH-036..052` -process-death evidence, partial-prefix migration recovery and -`KEEP-CRASH-053..073`, the reader fence, model-based transition evidence, and -garbage collection. Issue #19 owns the first four and issue #21 the last. +process-death evidence, the `KEEP-CRASH-053..073` process-death matrix for +migration recovery (in-process recovery of every prefix is implemented), the +reader fence, model-based transition evidence, and garbage collection. The +retention items are issue #19, the migration matrix is #108, and collection +is #21. Reopen compares only the restart-stable root coordinates, device and inode, against the intent; see [root identity across restart](recovery.md#root-identity-across-restart). A diff --git a/docs/formats/segment-store-v2/migration-crash.md b/docs/formats/segment-store-v2/migration-crash.md index d81d5656..5ff02462 100644 --- a/docs/formats/segment-store-v2/migration-crash.md +++ b/docs/formats/segment-store-v2/migration-crash.md @@ -104,7 +104,11 @@ prefix, marker, receipt, and cleanup state without depending on a clock, filesystem iteration order, or file existence alone. `StoreMigrationPhase::ALL` freezes the 21 boundaries above in exact order. -Fresh writer-locked filesystem execution now implements that exact order and -has deterministic in-process storage-fault and corruption laws. The -before/during/after process-death matrix and restart classifier remain -unimplemented; this page does not yet claim crash recovery. +Fresh writer-locked filesystem execution implements that exact order and has +deterministic in-process storage-fault and corruption laws. The restart +classifier and resuming storage are implemented and proven in-process for +every prefix of the 21 phases (see +[partial migration recovery](migration-recovery.md)). The before, during, and +after process-death matrix remains +([#108](https://github.com/flyingrobots/keep/issues/108)); until it runs, +this page claims in-process recovery, not process-death recovery. diff --git a/docs/formats/segment-store-v2/migration-recovery.md b/docs/formats/segment-store-v2/migration-recovery.md index e241ef1f..5d327a82 100644 --- a/docs/formats/segment-store-v2/migration-recovery.md +++ b/docs/formats/segment-store-v2/migration-recovery.md @@ -78,8 +78,21 @@ Every other residue is a typed `StoreMigrationRecoveryAmbiguity`. The persisted intent is compared on every coordinate but the mount identity, so a rebooted root does not reject its own intent. -The planner reads no storage. An observer that produces the residue and a -storage that executes the plan by reopening each stage by device and inode -identity are not implemented; until they are, an interrupted migration still -waits for a human +The planner reads no storage. `recover_store_migration` drives it: a +`StoreMigrationRecoveryStorage` observes the residue, the plan is taken +against the intent the version-1 store derives today, the exact stage and +canonical handles the resume point needs are adopted by reopening them by +device and inode identity, an incomplete pre-effect stage is removed if the +plan says so, and `resume_store_migration` runs every later phase with the +persisted intent, never the freshly derived one. +`FilesystemStoreMigrationAuthority::reopen_for_recovery` is the filesystem +form. It acquires the writer lock over a root carrying any lawful residue +without minting a version-1 platform admission, so the version-1 publisher +can never run against a partly migrated root. + +Every forward prefix of zero through twenty-one phases, and each incomplete +pre-effect stage, recovers in-process to exactly one complete migration with +every version-1 byte intact; a corrupt durable intent refuses before any +mutation. The before, during, and after process-death matrix for +`KEEP-CRASH-053..073` remains ([#108](https://github.com/flyingrobots/keep/issues/108)). diff --git a/docs/formats/segment-store-v2/recovery.md b/docs/formats/segment-store-v2/recovery.md index 263c79b1..a08c9559 100644 --- a/docs/formats/segment-store-v2/recovery.md +++ b/docs/formats/segment-store-v2/recovery.md @@ -204,9 +204,11 @@ recovery instead. Direct version-2 initialization is undefined. The exact offsets and fixtures are requirement `KEEP-MIGRATION-002`. The fresh writer emits only those canonical records; success is not restart evidence. -A migrated store is admitted for forward publication, but partial-prefix -recovery and `KEEP-MIGRATION-007` process-death evidence remain absent, so an -interrupted migration waits for recovery instead of continuing. +A migrated store is admitted for forward publication, and an interrupted +migration resumes from any prefix through +[partial migration recovery](migration-recovery.md), proven in-process; the +`KEEP-MIGRATION-007` process-death evidence remains +([#108](https://github.com/flyingrobots/keep/issues/108)). ## Retention publication recovery diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index 5ffaaa88..85f5aad4 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -28,13 +28,13 @@ case is not evidence. | ID | Requirement | Evidence | Status | | --- | --- | --- | --- | -| `KEEP-MIGRATION-001` | Exact version-1 stores remain admitted until a durable migration artifact exists | compatibility fixtures | Planned in #19 | +| `KEEP-MIGRATION-001` | Exact version-1 stores remain admitted until a durable migration artifact exists | the zero-phase prefix admits version one and an intent stage alone is never a durable artifact in `filesystem_migration_recovery_tests` and `tests/store_migration_recovery.rs` | Implemented | | `KEEP-MIGRATION-002` | Format marker, intent, and receipt have complete fixed byte tables, named domains, bounds, checksums, deterministic store identity, and exact initial-state digests | exact admission in `tests/store_format_marker.rs`, `tests/store_migration_intent.rs`, and `tests/store_migration_receipt.rs`; canonical construction in `tests/store_migration_intent_encoding.rs` and `tests/store_migration_receipt_encoding.rs`; seeded `migration_format` fuzz target | Implemented | | `KEEP-MIGRATION-003` | Migration revalidates version-1 head, catalog, pools, root identity (all three coordinates within the migrating process; device and file across restart), and writer authority before mutation | bounded canonical pool inventory in `tests/store_migration_inventory.rs`; writer-locked filesystem pool admission in `filesystem_inventory_*_tests`; exact authority observation and drift refusal in `filesystem_migration_authority_tests`; verification-first execution in `tests/store_migration_execution.rs`; fresh filesystem integration and post-publication drift refusal in `filesystem_migration_storage_tests`; a version-one store still holding a retained stage refuses before the intent is observed in `filesystem_migration_storage_tests` | Implemented | -| `KEEP-MIGRATION-004` | Every partial migration prefix continues idempotently under writer authority, comparing only the restart-stable root coordinates (device and file) against the persisted intent | storage-independent planner laws, one per recovery-table row and one per ambiguity rule, in `tests/store_migration_recovery.rs`; restart-stable reopen law in `filesystem_version_two_admission_tests`; the residue observer, the resuming filesystem storage, and the crash matrix remain | In progress in #108 | -| `KEEP-MIGRATION-005` | Unknown, out-of-order, substituted, corrupt, conflicting, or changed evidence is unrecoverable ambiguity | forward-execution stage preservation, byte-equal inode-substitution, out-of-order-prefix, and post-publication drift laws in `filesystem_migration_storage_tests`; unknown `retention` entries, non-digest namespace directories, and noncanonical pool names refuse before any retention stage is written in `filesystem_retention_namespace_tests`; restart corruption and mutation matrix remains | In progress in #19 | -| `KEEP-MIGRATION-006` | Migration never rewrites or deletes admitted version-1 immutable bytes | exact segment, catalog, and head before/after witness in `filesystem_migration_storage_tests`; restart-path evidence remains | In progress in #19 | -| `KEEP-MIGRATION-007` | Process death around every intent stage, canonical link, namespace prefix, marker stage, receipt stage, cleanup, and synchronization boundary reaches a documented lawful state | ordered phases and capabilities in `tests/store_migration_phase.rs` and `tests/store_migration_storage.rs`; exact phase-failure execution in `tests/store_migration_execution.rs`; production 21-phase forward execution in `filesystem_migration_storage_tests`; `KEEP-CRASH-053..=073` process-death matrix remains | In progress in #19 | +| `KEEP-MIGRATION-004` | Every partial migration prefix continues idempotently under writer authority, comparing only the restart-stable root coordinates (device and file) against the persisted intent | storage-independent planner laws, one per recovery-table row and one per ambiguity rule, in `tests/store_migration_recovery.rs`; every prefix of zero through twenty-one phases and a truncated pre-effect stage recover in-process to one complete migration in `filesystem_migration_recovery_tests`; restart-stable reopen law in `filesystem_version_two_admission_tests`; the process-death matrix is `KEEP-MIGRATION-007` | Implemented | +| `KEEP-MIGRATION-005` | Unknown, out-of-order, substituted, corrupt, conflicting, or changed evidence is unrecoverable ambiguity | forward-execution stage preservation, byte-equal inode-substitution, out-of-order-prefix, and post-publication drift laws in `filesystem_migration_storage_tests`; unknown `retention` entries, non-digest namespace directories, and noncanonical pool names refuse before any retention stage is written in `filesystem_retention_namespace_tests`; every planner ambiguity rule in `tests/store_migration_recovery.rs` and a corrupt durable intent refusing recovery before any mutation in `filesystem_migration_recovery_tests`; restart corruption and mutation matrix remains | In progress in #108 | +| `KEEP-MIGRATION-006` | Migration never rewrites or deletes admitted version-1 immutable bytes | exact segment, catalog, and head before/after witness in `filesystem_migration_storage_tests`; the same witness across recovery of every prefix in `filesystem_migration_recovery_tests` | Implemented | +| `KEEP-MIGRATION-007` | Process death around every intent stage, canonical link, namespace prefix, marker stage, receipt stage, cleanup, and synchronization boundary reaches a documented lawful state | ordered phases and capabilities in `tests/store_migration_phase.rs` and `tests/store_migration_storage.rs`; exact phase-failure execution in `tests/store_migration_execution.rs`; production 21-phase forward execution in `filesystem_migration_storage_tests`; in-process resumption from every prefix in `filesystem_migration_recovery_tests`; `KEEP-CRASH-053..=073` process-death matrix remains | In progress in #108 | | `KEEP-MIGRATION-008` | Version-1 admission refuses every version-2 or partial-migration artifact after migration begins | `FORMAT` refusal before mutation in `filesystem_migration_authority_tests`; exact version-1 reopen refusal of a migrated root and separate version-2 namespace admission in `filesystem_initialization_namespace`; version-2 reopen returns a distinct `FilesystemVersionTwoAdmission` that no version-1 publisher can consume (pinned by `tests/version_two_admission_contract.rs`), admits every version-2 protocol directory under the Linux profile, and jointly admits the exact marker, intent, and receipt before returning writer authority, with aliased-directory, corrupt, oversized, and mutually inconsistent record refusals in `filesystem_version_two_admission_tests` and `filesystem_platform_profile_tests`; remaining compatibility and fuzz matrix | In progress in #19 | diff --git a/src/adapters/filesystem_exact_record.rs b/src/adapters/filesystem_exact_record.rs index 14beda76..4b486584 100644 --- a/src/adapters/filesystem_exact_record.rs +++ b/src/adapters/filesystem_exact_record.rs @@ -205,6 +205,34 @@ pub(super) fn link_without_replacement( } } +/// Opens the regular record `name` read-only, following no links and never +/// blocking, for callers that retain the handle and verify it by identity. +pub(super) fn open_regular(directory: &Dir, name: &str) -> io::Result { + open_read(directory, name) +} + +/// Reads at most `bound` bytes of the regular file `name`, or `None` if absent. +/// +/// Residue observers use this to see an incomplete, exact, or overlong record +/// as it is; a present entry that is not a regular file refuses by kind. +pub(super) fn read_bounded_optional( + directory: &Dir, + name: &str, + bound: usize, +) -> Result>, ExactRecordError> { + let file = match open_read(directory, name) { + Ok(file) => file, + Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(source) => return Err(source.into()), + }; + if !file.metadata()?.is_file() { + return Err(ExactRecordRefusal::KindOrLength.into()); + } + let mut bytes = Vec::new(); + file.take(exact_length(bound)?).read_to_end(&mut bytes)?; + Ok(Some(bytes)) +} + fn open_read(directory: &Dir, name: &str) -> io::Result { let mut options = OpenOptions::new(); options.read(true).follow(FollowSymlinks::No).nonblock(true); diff --git a/src/adapters/filesystem_initialization_namespace.rs b/src/adapters/filesystem_initialization_namespace.rs index d1be904a..6b71be03 100644 --- a/src/adapters/filesystem_initialization_namespace.rs +++ b/src/adapters/filesystem_initialization_namespace.rs @@ -202,3 +202,35 @@ fn ambiguous_namespace() -> io::Error { "store root is not an empty or partial canonical initialization namespace", ) } + +/// Admits a published version-1 root carrying any subset of migration +/// residue, for migration recovery only. +/// +/// The five published names are required with their kinds; every migration +/// record, stage, the reader fence, and the three protocol directories are +/// optional but must have their kinds; anything else refuses. Which subsets +/// are lawful is the recovery planner's decision, not this admission's. +pub(super) fn admit_migrating(directory: &Dir) -> io::Result<()> { + admit_required_file(directory, LOCK_NAME)?; + admit_required_directory(directory, STAGING_NAME)?; + admit_required_directory(directory, SEGMENTS_NAME)?; + admit_required_directory(directory, CATALOGS_NAME)?; + admit_required_file(directory, HEAD_NAME)?; + for name in [ + READER_LOCK_NAME, + MARKER_NAME, + "FORMAT.next", + INTENT_NAME, + "migration.intent.next", + RECEIPT_NAME, + "migration.receipt.next", + ] { + admit_optional_file(directory, name)?; + } + for name in [RETENTION_NAME, GC_NAME, RECOVERY_NAME] { + admit_optional_directory(directory, name)?; + } + let mut allowed: Vec<&str> = PUBLISHED_NAMES.to_vec(); + allowed.extend_from_slice(&VERSION_TWO_MARKERS); + admit_membership(directory, &allowed) +} diff --git a/src/adapters/store_migration.rs b/src/adapters/store_migration.rs index 983f1b7c..3fc2eaeb 100644 --- a/src/adapters/store_migration.rs +++ b/src/adapters/store_migration.rs @@ -44,6 +44,10 @@ mod filesystem_migration_fixed_artifact; mod filesystem_migration_namespace; mod filesystem_migration_namespace_directory; mod filesystem_migration_reader_fence; +mod filesystem_migration_recovery; +#[cfg(test)] +mod filesystem_migration_recovery_tests; +mod filesystem_migration_residue; mod filesystem_migration_storage; #[cfg(test)] mod filesystem_migration_storage_tests; @@ -85,9 +89,12 @@ mod migration_receipt_initial_state; mod migration_record_bytes; mod migration_recovery_ambiguity; mod migration_recovery_ambiguity_display; +mod migration_recovery_execution; mod migration_recovery_plan; mod migration_recovery_planner; mod migration_recovery_residue; +mod migration_recovery_storage; +mod migration_resumption; mod migration_storage; mod migration_synchronization_mask; mod store_identifier; @@ -131,9 +138,14 @@ pub use migration_phase::StoreMigrationPhase; pub use migration_receipt_decode_error::StoreMigrationReceiptDecodeError; pub(super) use migration_receipt_format::ENCODED_LENGTH as MIGRATION_RECEIPT_LENGTH; pub use migration_recovery_ambiguity::{StoreMigrationEffect, StoreMigrationRecoveryAmbiguity}; +pub use migration_recovery_execution::{ + StoreMigrationRecoveryError, StoreMigrationRecoveryReceipt, recover_store_migration, +}; pub use migration_recovery_plan::{StoreMigrationFixedStage, StoreMigrationRecoveryPlan}; pub use migration_recovery_planner::plan_store_migration_recovery; pub use migration_recovery_residue::{MIGRATION_NAMESPACE_PREFIX, StoreMigrationResidue}; +pub use migration_recovery_storage::StoreMigrationRecoveryStorage; +pub use migration_resumption::resume_store_migration; pub use migration_storage::StoreMigrationStorage; pub use migration_synchronization_mask::MigrationSynchronizationMask; pub use store_identifier::StoreIdentifier; diff --git a/src/adapters/store_migration/filesystem_inventory_reader.rs b/src/adapters/store_migration/filesystem_inventory_reader.rs index 7d989ab6..2519a9ce 100644 --- a/src/adapters/store_migration/filesystem_inventory_reader.rs +++ b/src/adapters/store_migration/filesystem_inventory_reader.rs @@ -50,6 +50,19 @@ impl FilesystemStoreMigrationInventoryReader { policy: SegmentReadPolicy, ) -> Result { let (lock, root_identity) = admission.into_parts(); + Self::open_locked(lock, root_identity, policy) + } + + /// Pins both immutable pools under an already-held writer lock. + /// + /// Migration recovery uses this so a root carrying migration residue never + /// mints a version-1 platform admission the version-1 publisher could + /// consume. + pub(super) fn open_locked( + lock: FilesystemWriterLock, + root_identity: FilesystemRootIdentity, + policy: SegmentReadPolicy, + ) -> Result { let root = lock.clone_directory() .map_err(|source| FilesystemMigrationInventoryError::Io { diff --git a/src/adapters/store_migration/filesystem_migration_authority.rs b/src/adapters/store_migration/filesystem_migration_authority.rs index e894bcb4..44221757 100644 --- a/src/adapters/store_migration/filesystem_migration_authority.rs +++ b/src/adapters/store_migration/filesystem_migration_authority.rs @@ -38,6 +38,7 @@ const HEAD_LENGTH: u64 = 128; #[must_use] pub struct FilesystemStoreMigrationAuthority { inventory: FilesystemStoreMigrationInventoryReader, + pub(super) namespace: MigrationNamespacePolicy, pub(super) fixed_stage: Option, pub(super) published_intent: Option, pub(super) published_marker: Option, @@ -61,13 +62,24 @@ impl FilesystemStoreMigrationAuthority { ) -> Result { let inventory = FilesystemStoreMigrationInventoryReader::open(admission, policy) .map_err(|source| Error::Inventory { source })?; - Ok(Self { + Ok(Self::with_policy( inventory, + MigrationNamespacePolicy::Published, + )) + } + + pub(super) const fn with_policy( + inventory: FilesystemStoreMigrationInventoryReader, + namespace: MigrationNamespacePolicy, + ) -> Self { + Self { + inventory, + namespace, fixed_stage: None, published_intent: None, published_marker: None, published_receipt: None, - }) + } } /// Observes one canonical intent from exact current version-1 authority. @@ -136,8 +148,15 @@ impl FilesystemStoreMigrationAuthority { /// Recovery must complete before the intent is observed. fn verify_namespace(&self) -> Result<(), Error> { let root = self.inventory.root(); - filesystem_initialization_namespace::admit_published(root) - .map_err(|source| Error::Namespace { source })?; + match self.namespace { + MigrationNamespacePolicy::Published => { + filesystem_initialization_namespace::admit_published(root) + } + MigrationNamespacePolicy::Migrating => { + filesystem_initialization_namespace::admit_migrating(root) + } + } + .map_err(|source| Error::Namespace { source })?; let staging = root .open_dir_nofollow(STAGING_NAME) .map_err(|source| Error::Namespace { source })?; @@ -206,3 +225,12 @@ impl FilesystemStoreMigrationAuthority { self.inventory.root() } } + +/// Which root namespaces an authority admits when it observes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum MigrationNamespacePolicy { + /// Exactly the published version-1 namespace: a fresh migration. + Published, + /// The published version-1 namespace plus any migration residue: recovery. + Migrating, +} diff --git a/src/adapters/store_migration/filesystem_migration_authority_error.rs b/src/adapters/store_migration/filesystem_migration_authority_error.rs index d6628a6c..f8702fc0 100644 --- a/src/adapters/store_migration/filesystem_migration_authority_error.rs +++ b/src/adapters/store_migration/filesystem_migration_authority_error.rs @@ -3,7 +3,9 @@ use std::io; use super::{FilesystemMigrationInventoryError, StoreMigrationIntentDigest}; -use crate::adapters::{CatalogDecodeError, CatalogRestartError, PublicationHeadDecodeError}; +use crate::adapters::{ + CatalogDecodeError, CatalogRestartError, PublicationHeadDecodeError, WriterLockAcquireError, +}; use crate::{CatalogDigest, CatalogGeneration, CatalogLength}; /// Published version-1 artifact observed while establishing migration authority. @@ -39,6 +41,16 @@ pub enum StoreRootIdentityCoordinate { /// Failure to observe or revalidate exact filesystem migration authority. #[derive(Debug)] pub enum FilesystemMigrationAuthorityError { + /// The production platform refused the store root. + Platform { + /// Preserved platform source. + source: io::Error, + }, + /// The existing writer lock could not be acquired. + WriterLock { + /// Preserved lock refusal. + source: WriterLockAcquireError, + }, /// Complete immutable-pool inventory could not be admitted. Inventory { /// Preserved inventory refusal. diff --git a/src/adapters/store_migration/filesystem_migration_authority_error_display.rs b/src/adapters/store_migration/filesystem_migration_authority_error_display.rs index 9fc8c14d..f794346b 100644 --- a/src/adapters/store_migration/filesystem_migration_authority_error_display.rs +++ b/src/adapters/store_migration/filesystem_migration_authority_error_display.rs @@ -32,6 +32,12 @@ impl fmt::Display for StoreRootIdentityCoordinate { impl fmt::Display for FilesystemMigrationAuthorityError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Platform { .. } => { + formatter.write_str("filesystem migration platform admission was refused") + } + Self::WriterLock { .. } => { + formatter.write_str("filesystem migration writer lock was refused") + } Self::Inventory { .. } => { formatter.write_str("filesystem migration inventory was refused") } @@ -96,7 +102,10 @@ impl Error for FilesystemMigrationAuthorityError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { Self::Inventory { source } => Some(source), - Self::Namespace { source } | Self::RootIdentity { source } => Some(source), + Self::WriterLock { source } => Some(source), + Self::Platform { source } + | Self::Namespace { source } + | Self::RootIdentity { source } => Some(source), Self::Artifact { source, .. } => Some(source), Self::Head { source } => Some(source), Self::Catalog { source, .. } => Some(source), diff --git a/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs b/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs index 50bca1e5..6a26f15d 100644 --- a/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs +++ b/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs @@ -18,7 +18,7 @@ pub(super) enum FilesystemMigrationFixedArtifact { } impl FilesystemMigrationFixedArtifact { - const fn stage_name(self) -> &'static str { + pub(super) const fn stage_name(self) -> &'static str { match self { Self::Intent => "migration.intent.next", Self::Marker => "FORMAT.next", @@ -26,7 +26,7 @@ impl FilesystemMigrationFixedArtifact { } } - const fn canonical_name(self) -> &'static str { + pub(super) const fn canonical_name(self) -> &'static str { match self { Self::Intent => "migration.intent", Self::Marker => "FORMAT", @@ -34,7 +34,7 @@ impl FilesystemMigrationFixedArtifact { } } - const fn encoded_length(self) -> usize { + pub(super) const fn encoded_length(self) -> usize { match self { Self::Intent => migration_intent_format::ENCODED_LENGTH, Self::Marker => format_marker_decoder::ENCODED_LENGTH, @@ -196,3 +196,42 @@ fn require_length(artifact: FilesystemMigrationFixedArtifact, expected: &[u8]) - fn invalid_data(message: &'static str) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message) } + +impl FilesystemMigrationFixedStage { + /// Reopens an existing exact stage by identity for a resumed migration. + pub(super) fn reopen_stage( + root: &Dir, + artifact: FilesystemMigrationFixedArtifact, + expected: &[u8], + ) -> io::Result { + Self::reopen(root, artifact, artifact.stage_name(), expected) + } + + /// Reopens an existing exact canonical record by identity, as the + /// published handle a resumed migration verifies against. + pub(super) fn reopen_canonical( + root: &Dir, + artifact: FilesystemMigrationFixedArtifact, + expected: &[u8], + ) -> io::Result { + Self::reopen(root, artifact, artifact.canonical_name(), expected) + } + + fn reopen( + root: &Dir, + artifact: FilesystemMigrationFixedArtifact, + name: &str, + expected: &[u8], + ) -> io::Result { + require_length(artifact, expected)?; + let file = exact_record::open_regular(root, name)?; + let identity = EntryIdentity::of_file(&file)?; + verify_named_record(root, name, expected, identity)?; + Ok(Self { + artifact, + expected: Box::from(expected), + identity, + file, + }) + } +} diff --git a/src/adapters/store_migration/filesystem_migration_recovery.rs b/src/adapters/store_migration/filesystem_migration_recovery.rs new file mode 100644 index 00000000..1fd475a8 --- /dev/null +++ b/src/adapters/store_migration/filesystem_migration_recovery.rs @@ -0,0 +1,177 @@ +//! This module binds filesystem migration authority to the recovery port. + +use std::io; +use std::path::Path; + +use cap_std::fs::Dir; + +use super::filesystem_migration_authority::MigrationNamespacePolicy; +use super::filesystem_migration_authority_error::FilesystemMigrationAuthorityError as Error; +use super::filesystem_migration_fixed_artifact::{ + FilesystemMigrationFixedArtifact as FixedArtifact, FilesystemMigrationFixedStage, +}; +use super::migration_resumption::MigrationRecords; +use super::{ + CanonicalStoreMigrationIntent, FilesystemStoreMigrationAuthority, + FilesystemStoreMigrationInventoryReader, StoreMigrationFixedStage, + StoreMigrationRecoveryStorage, StoreMigrationResidue, filesystem_migration_residue, +}; +use crate::adapters::filesystem_exact_record::{self as exact_record, ExactRecordError}; +use crate::adapters::{ + FilesystemWriterLock, SegmentReadPolicy, filesystem_catalog_artifact, + filesystem_initialization_namespace, filesystem_platform_profile, +}; + +impl FilesystemStoreMigrationAuthority { + /// Reacquires writer authority over a root that may carry migration residue. + /// + /// The call admits the production platform, acquires the existing writer + /// lock, and admits the published version-1 namespace plus any subset of + /// migration records, stages, the reader fence, and protocol directories. + /// It never produces a version-1 platform admission, so the version-1 + /// publisher can never run against a partly migrated root. The returned + /// authority observes with the migrating namespace policy and implements + /// [`StoreMigrationRecoveryStorage`]. It mutates nothing. + /// + /// # Errors + /// + /// Returns [`FilesystemMigrationAuthorityError`](Error) at the exact + /// platform, writer-lock, namespace, or pool refusal. + pub fn reopen_for_recovery( + store_root: &Path, + policy: SegmentReadPolicy, + ) -> Result { + let root = filesystem_platform_profile::open(store_root) + .map_err(|source| Error::Platform { source })?; + Self::recover_root(root, policy) + } + + #[cfg(test)] + pub(super) fn reopen_for_recovery_unchecked_for_tests( + store_root: &Path, + policy: SegmentReadPolicy, + ) -> Result { + let root = Dir::open_ambient_dir(store_root, cap_std::ambient_authority()) + .map_err(|source| Error::Platform { source })?; + Self::recover_root(root, policy) + } + + fn recover_root(root: Dir, policy: SegmentReadPolicy) -> Result { + let lock = FilesystemWriterLock::try_acquire_in(root) + .map_err(|source| Error::WriterLock { source })?; + let directory = lock + .clone_directory() + .map_err(|source| Error::Namespace { source })?; + filesystem_initialization_namespace::admit_migrating(&directory) + .map_err(|source| Error::Namespace { source })?; + let root_identity = filesystem_platform_profile::root_identity(&directory) + .map_err(|source| Error::RootIdentity { source })?; + let inventory = + FilesystemStoreMigrationInventoryReader::open_locked(lock, root_identity, policy) + .map_err(|source| Error::Inventory { source })?; + Ok(Self::with_policy( + inventory, + MigrationNamespacePolicy::Migrating, + )) + } +} + +impl StoreMigrationRecoveryStorage for FilesystemStoreMigrationAuthority { + fn observe_residue(&mut self) -> io::Result { + filesystem_migration_residue::observe(self.root()) + } + + fn adopt_residue( + &mut self, + residue: &StoreMigrationResidue, + intent: &CanonicalStoreMigrationIntent, + ) -> io::Result<()> { + let records = MigrationRecords::for_intent(intent); + adopt_artifact( + self, + FixedArtifact::Intent, + residue.intent_stage.as_deref(), + residue.intent.as_deref(), + intent.encoded(), + )?; + adopt_artifact( + self, + FixedArtifact::Marker, + residue.marker_stage.as_deref(), + residue.marker.as_deref(), + records.marker.encoded(), + )?; + adopt_artifact( + self, + FixedArtifact::Receipt, + residue.receipt_stage.as_deref(), + residue.receipt.as_deref(), + records.receipt.encoded(), + ) + } + + fn discard_stage(&mut self, stage: StoreMigrationFixedStage) -> io::Result<()> { + let artifact = match stage { + StoreMigrationFixedStage::Intent => FixedArtifact::Intent, + StoreMigrationFixedStage::Marker => FixedArtifact::Marker, + StoreMigrationFixedStage::Receipt => FixedArtifact::Receipt, + }; + let root = self.root(); + exact_record::require_absent(root, artifact.canonical_name()).map_err(refusal)?; + let metadata = root.symlink_metadata(artifact.stage_name())?; + let complete = u64::try_from(artifact.encoded_length()) + .map_err(|_| invalid("migration stage length exceeded u64"))?; + if !metadata.is_file() || metadata.len() >= complete { + return Err(invalid( + "only an incomplete regular pre-effect stage may be discarded", + )); + } + root.remove_file(artifact.stage_name())?; + exact_record::require_absent(root, artifact.stage_name()).map_err(refusal)?; + filesystem_catalog_artifact::synchronize_directory(root) + } +} + +/// Reopens the handles one artifact's residue implies: an exact stage becomes +/// the active stage (shared with its canonical target when both exist), a +/// canonical record alone becomes the published handle, and an incomplete +/// stage is left for the planner's discard. +fn adopt_artifact( + authority: &mut FilesystemStoreMigrationAuthority, + artifact: FixedArtifact, + stage: Option<&[u8]>, + canonical: Option<&[u8]>, + expected: &[u8], +) -> io::Result<()> { + let root = authority.root(); + if stage.is_some_and(|bytes| bytes == expected) { + if authority.fixed_stage.is_some() { + return Err(invalid("migration residue holds more than one exact stage")); + } + let reopened = FilesystemMigrationFixedStage::reopen_stage(root, artifact, expected)?; + authority.fixed_stage = Some(reopened); + return Ok(()); + } + if canonical.is_some() { + let reopened = FilesystemMigrationFixedStage::reopen_canonical(root, artifact, expected)?; + match artifact { + FixedArtifact::Intent => authority.published_intent = Some(reopened), + FixedArtifact::Marker => authority.published_marker = Some(reopened), + FixedArtifact::Receipt => authority.published_receipt = Some(reopened), + } + } + Ok(()) +} + +fn refusal(error: ExactRecordError) -> io::Error { + match error { + ExactRecordError::Io(source) => source, + ExactRecordError::Refused(refusal) => { + io::Error::new(io::ErrorKind::InvalidData, refusal.to_string()) + } + } +} + +fn invalid(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} diff --git a/src/adapters/store_migration/filesystem_migration_recovery_tests.rs b/src/adapters/store_migration/filesystem_migration_recovery_tests.rs new file mode 100644 index 00000000..1a70e62e --- /dev/null +++ b/src/adapters/store_migration/filesystem_migration_recovery_tests.rs @@ -0,0 +1,186 @@ +//! Filesystem migration recovery laws: every forward prefix recovers. + +use std::error::Error; +use std::fs; +use std::io; +use std::path::Path; + +use super::filesystem_migration_test_fixture::{maximum_policy, open_authority}; +use super::migration_resumption::{MigrationRecords, execute_phase}; +use super::{ + AdmittedStoreFormatMarker, AdmittedStoreMigrationIntent, AdmittedStoreMigrationReceipt, + FilesystemStoreMigrationAuthority, StoreMigrationFixedStage, StoreMigrationPhase, + StoreMigrationRecoveryError, StoreMigrationRecoveryPlan, StoreMigrationStorage, + recover_store_migration, +}; + +/// Runs the first `count` forward phases in-process, drops the writer, and +/// recovers under a fresh authority. Every prefix must end in exactly one +/// complete migration whose records admit, with every version-1 byte intact. +#[test] +fn every_forward_prefix_recovers_to_one_complete_migration() -> Result<(), Box> { + for count in 0..=StoreMigrationPhase::ALL.len() { + let name = format!("filesystem-migration-recovery-prefix-{count}"); + let (sandbox, mut authority) = open_authority(&name)?; + let intent = authority.observe_intent()?; + let witness = version_one_witness(sandbox.path())?; + StoreMigrationStorage::verify_current(&mut authority, &intent)?; + let records = MigrationRecords::for_intent(&intent); + for phase in StoreMigrationPhase::ALL.iter().take(count) { + execute_phase(&mut authority, *phase, &records)?; + } + drop(authority); + + let mut recovered = + FilesystemStoreMigrationAuthority::reopen_for_recovery_unchecked_for_tests( + sandbox.path(), + maximum_policy(), + )?; + let expected = recovered.observe_intent()?; + let receipt = recover_store_migration(&mut recovered, &expected) + .map_err(|error| format!("prefix {count}: {error}: {:?}", error.source()))?; + drop(recovered); + + match receipt.plan() { + StoreMigrationRecoveryPlan::VersionOne => { + assert_eq!(count, 0, "only an untouched store admits version one"); + assert!(!sandbox.path().join("migration.intent").exists()); + } + StoreMigrationRecoveryPlan::Complete => { + // The receipt is canonical from phase 20 on; the final root + // synchronization leaves nothing a residue can observe. + assert!(count >= 20, "prefix {count} reported complete"); + } + StoreMigrationRecoveryPlan::Resume { .. } => { + assert!(receipt.published().is_some(), "prefix {count} resumed"); + assert_complete_migration(sandbox.path(), &intent)?; + } + StoreMigrationRecoveryPlan::DiscardStage { .. } => { + return Err(format!("prefix {count} needed a discard for an exact stage").into()); + } + } + if count > 0 { + assert_complete_migration(sandbox.path(), &intent)?; + } + assert_eq!( + version_one_witness(sandbox.path())?, + witness, + "prefix {count}" + ); + sandbox.remove()?; + } + Ok(()) +} + +#[test] +fn a_truncated_intent_stage_is_discarded_and_the_migration_completes() -> Result<(), Box> +{ + let (sandbox, mut authority) = open_authority("filesystem-migration-recovery-truncated")?; + let intent = authority.observe_intent()?; + StoreMigrationStorage::verify_current(&mut authority, &intent)?; + StoreMigrationStorage::write_intent_stage(&mut authority, &intent)?; + drop(authority); + let stage = sandbox.path().join("migration.intent.next"); + let mut bytes = fs::read(&stage)?; + bytes.truncate(100); + fs::write(&stage, &bytes)?; + + let mut recovered = FilesystemStoreMigrationAuthority::reopen_for_recovery_unchecked_for_tests( + sandbox.path(), + maximum_policy(), + )?; + let expected = recovered.observe_intent()?; + let receipt = recover_store_migration(&mut recovered, &expected)?; + drop(recovered); + + assert_eq!( + receipt.plan(), + StoreMigrationRecoveryPlan::DiscardStage { + stage: StoreMigrationFixedStage::Intent, + resume: StoreMigrationPhase::WriteIntentStage, + } + ); + assert_complete_migration(sandbox.path(), &intent)?; + sandbox.remove()?; + Ok(()) +} + +#[test] +fn a_corrupt_durable_intent_refuses_recovery_before_any_mutation() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("filesystem-migration-recovery-corrupt")?; + let intent = authority.observe_intent()?; + StoreMigrationStorage::verify_current(&mut authority, &intent)?; + let records = MigrationRecords::for_intent(&intent); + for phase in StoreMigrationPhase::ALL.iter().take(6) { + execute_phase(&mut authority, *phase, &records)?; + } + drop(authority); + let canonical = sandbox.path().join("migration.intent"); + let mut bytes = fs::read(&canonical)?; + let last = bytes.last_mut().ok_or("intent is empty")?; + *last ^= 1; + fs::write(&canonical, &bytes)?; + let before = fs::read_dir(sandbox.path())?.count(); + + let mut recovered = FilesystemStoreMigrationAuthority::reopen_for_recovery_unchecked_for_tests( + sandbox.path(), + maximum_policy(), + )?; + let expected = recovered.observe_intent()?; + let error = recover_store_migration(&mut recovered, &expected) + .err() + .ok_or("a corrupt durable intent was recovered")?; + drop(recovered); + + assert!(matches!( + error, + StoreMigrationRecoveryError::Ambiguity { .. } + )); + assert_eq!(fs::read_dir(sandbox.path())?.count(), before); + assert!(!sandbox.path().join("reader.lock").exists()); + sandbox.remove()?; + Ok(()) +} + +fn assert_complete_migration( + root: &Path, + intent: &super::CanonicalStoreMigrationIntent, +) -> Result<(), Box> { + let intent_bytes = fs::read(root.join("migration.intent"))?; + let marker_bytes = fs::read(root.join("FORMAT"))?; + let receipt_bytes = fs::read(root.join("migration.receipt"))?; + let admitted = AdmittedStoreMigrationIntent::decode(&intent_bytes)?; + let marker = AdmittedStoreFormatMarker::decode(&marker_bytes)?; + let _receipt = AdmittedStoreMigrationReceipt::decode(&receipt_bytes, &admitted, &marker)?; + assert_eq!(admitted.encoded(), intent.encoded()); + for stage in [ + "migration.intent.next", + "FORMAT.next", + "migration.receipt.next", + ] { + assert!(!root.join(stage).exists(), "{stage} survived recovery"); + } + for directory in [ + "retention/roots", + "retention/manifests", + "gc", + "recovery/dispositions", + ] { + assert!(root.join(directory).is_dir(), "{directory} is absent"); + } + assert_eq!(fs::metadata(root.join("reader.lock"))?.len(), 0); + Ok(()) +} + +fn version_one_witness(root: &Path) -> io::Result)>> { + let mut witness = vec![("HEAD".to_owned(), fs::read(root.join("HEAD"))?)]; + for pool in ["segments", "catalogs"] { + for entry in fs::read_dir(root.join(pool))? { + let entry = entry?; + let name = format!("{pool}/{}", entry.file_name().to_string_lossy()); + witness.push((name, fs::read(entry.path())?)); + } + } + witness.sort(); + Ok(witness) +} diff --git a/src/adapters/store_migration/filesystem_migration_residue.rs b/src/adapters/store_migration/filesystem_migration_residue.rs new file mode 100644 index 00000000..3914b806 --- /dev/null +++ b/src/adapters/store_migration/filesystem_migration_residue.rs @@ -0,0 +1,85 @@ +//! This module owns observing migration residue on one pinned root. + +use std::io; + +use cap_fs_ext::DirExt; +use cap_std::fs::Dir; + +use super::filesystem_migration_namespace_directory::ambiguous; +use super::{ + FORMAT_MARKER_LENGTH, MIGRATION_INTENT_LENGTH, MIGRATION_RECEIPT_LENGTH, StoreMigrationResidue, +}; +use crate::adapters::filesystem_exact_record::{self as exact_record, ExactRecordError}; + +const READER_LOCK: &str = "reader.lock"; + +/// Observes every fixed migration name without mutation. +/// +/// Records and stages are read without following links and bounded to one +/// byte more than their canonical length, so an overlong file stays +/// distinguishable from an exact one. A wrong kind refuses. +pub(super) fn observe(root: &Dir) -> io::Result { + Ok(StoreMigrationResidue { + intent_stage: bounded_file(root, "migration.intent.next", MIGRATION_INTENT_LENGTH)?, + intent: bounded_file(root, "migration.intent", MIGRATION_INTENT_LENGTH)?, + reader_fence: reader_fence(root)?, + namespace_prefix: namespace_prefix(root)?, + marker_stage: bounded_file(root, "FORMAT.next", FORMAT_MARKER_LENGTH)?, + marker: bounded_file(root, "FORMAT", FORMAT_MARKER_LENGTH)?, + receipt_stage: bounded_file(root, "migration.receipt.next", MIGRATION_RECEIPT_LENGTH)?, + receipt: bounded_file(root, "migration.receipt", MIGRATION_RECEIPT_LENGTH)?, + }) +} + +fn bounded_file(root: &Dir, name: &str, length: usize) -> io::Result>> { + let bound = length + .checked_add(1) + .ok_or_else(|| ambiguous("migration residue bound overflowed"))?; + exact_record::read_bounded_optional(root, name, bound).map_err(|error| match error { + ExactRecordError::Io(source) => source, + ExactRecordError::Refused(_) => ambiguous("migration residue entry has the wrong kind"), + }) +} + +fn reader_fence(root: &Dir) -> io::Result { + match root.symlink_metadata(READER_LOCK) { + Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(source), + Ok(metadata) if metadata.is_file() && metadata.len() == 0 => Ok(true), + Ok(_) => Err(ambiguous("reader fence has the wrong kind or length")), + } +} + +fn namespace_prefix(root: &Dir) -> io::Result<[bool; 6]> { + let retention = optional_directory(root, "retention")?; + let (roots, manifests) = match retention.as_ref() { + Some(retention) => ( + optional_directory(retention, "roots")?.is_some(), + optional_directory(retention, "manifests")?.is_some(), + ), + None => (false, false), + }; + let gc = optional_directory(root, "gc")?.is_some(); + let recovery = optional_directory(root, "recovery")?; + let dispositions = match recovery.as_ref() { + Some(recovery) => optional_directory(recovery, "dispositions")?.is_some(), + None => false, + }; + Ok([ + retention.is_some(), + roots, + manifests, + gc, + recovery.is_some(), + dispositions, + ]) +} + +fn optional_directory(parent: &Dir, name: &str) -> io::Result> { + match parent.symlink_metadata(name) { + Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None), + Err(source) => Err(source), + Ok(metadata) if metadata.is_dir() => parent.open_dir_nofollow(name).map(Some), + Ok(_) => Err(ambiguous("migration namespace entry has the wrong kind")), + } +} diff --git a/src/adapters/store_migration/migration_recovery_execution.rs b/src/adapters/store_migration/migration_recovery_execution.rs new file mode 100644 index 00000000..70550b91 --- /dev/null +++ b/src/adapters/store_migration/migration_recovery_execution.rs @@ -0,0 +1,177 @@ +//! This boundary module owns ordered migration recovery: observe, plan, +//! adopt, discard, resume. + +use std::error::Error; +use std::fmt; +use std::io; + +use super::{ + AdmittedStoreMigrationIntent, CanonicalStoreMigrationIntent, CanonicalStoreMigrationReceipt, + StoreMigrationError, StoreMigrationFixedStage, StoreMigrationRecoveryAmbiguity, + StoreMigrationRecoveryPlan, StoreMigrationRecoveryStorage, StoreMigrationResidue, + plan_store_migration_recovery, resume_store_migration, +}; + +/// What one recovery found and did. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StoreMigrationRecoveryReceipt { + plan: StoreMigrationRecoveryPlan, + published: Option, +} + +impl StoreMigrationRecoveryReceipt { + /// Returns the plan the residue admitted. + pub const fn plan(&self) -> StoreMigrationRecoveryPlan { + self.plan + } + + /// Returns the migration receipt this recovery published, when it ran + /// the forward protocol to completion. + pub const fn published(&self) -> Option<&CanonicalStoreMigrationReceipt> { + self.published.as_ref() + } +} + +/// Failure to recover one interrupted migration. +#[derive(Debug)] +pub enum StoreMigrationRecoveryError { + /// The residue could not be observed. + Observation { + /// Preserved storage failure. + source: io::Error, + }, + /// The residue matches no lawful recovery row. + Ambiguity { + /// The exact conflict. + source: StoreMigrationRecoveryAmbiguity, + }, + /// The exact stage and canonical handles could not be adopted. + Adoption { + /// Preserved storage failure. + source: io::Error, + }, + /// The incomplete pre-effect stage could not be removed. + Discard { + /// The stage. + stage: StoreMigrationFixedStage, + /// Preserved storage failure. + source: io::Error, + }, + /// A resumed forward phase refused. + Resumption { + /// Preserved phase failure. + source: StoreMigrationError, + }, +} + +/// Recovers one interrupted migration under writer authority. +/// +/// The residue is observed once, planned against `expected` (the intent the +/// version-1 store derives today, compared on every restart-stable +/// coordinate), and either admitted as version 1, reported complete, or +/// driven through the remaining forward phases with the persisted intent, +/// never the freshly derived one. Nothing is truncated, replaced, or +/// repaired; an incomplete pre-effect stage is the only artifact removed. +/// +/// # Errors +/// +/// Returns [`StoreMigrationRecoveryError`] at the exact boundary that refused. +pub fn recover_store_migration( + storage: &mut impl StoreMigrationRecoveryStorage, + expected: &CanonicalStoreMigrationIntent, +) -> Result { + let residue = storage + .observe_residue() + .map_err(|source| StoreMigrationRecoveryError::Observation { source })?; + let expected_admitted = + AdmittedStoreMigrationIntent::decode(expected.encoded()).map_err(|source| { + StoreMigrationRecoveryError::Observation { + source: io::Error::new(io::ErrorKind::InvalidData, source), + } + })?; + let plan = plan_store_migration_recovery(&expected_admitted, &residue) + .map_err(|source| StoreMigrationRecoveryError::Ambiguity { source })?; + let resume = match plan { + StoreMigrationRecoveryPlan::VersionOne | StoreMigrationRecoveryPlan::Complete => { + return Ok(StoreMigrationRecoveryReceipt { + plan, + published: None, + }); + } + StoreMigrationRecoveryPlan::DiscardStage { stage, resume } => { + let persisted = persisted_intent(&residue, expected)?; + storage + .adopt_residue(&residue, &persisted) + .map_err(|source| StoreMigrationRecoveryError::Adoption { source })?; + storage + .discard_stage(stage) + .map_err(|source| StoreMigrationRecoveryError::Discard { stage, source })?; + resume + } + StoreMigrationRecoveryPlan::Resume { resume } => { + let persisted = persisted_intent(&residue, expected)?; + storage + .adopt_residue(&residue, &persisted) + .map_err(|source| StoreMigrationRecoveryError::Adoption { source })?; + resume + } + }; + let persisted = persisted_intent(&residue, expected)?; + let published = resume_store_migration(storage, &persisted, resume) + .map_err(|source| StoreMigrationRecoveryError::Resumption { source })?; + Ok(StoreMigrationRecoveryReceipt { + plan, + published: Some(published), + }) +} + +/// The intent the resumed phases must publish: the durable one when it +/// exists, else the exact staged one, else the freshly derived one. +fn persisted_intent( + residue: &StoreMigrationResidue, + expected: &CanonicalStoreMigrationIntent, +) -> Result { + let staged = residue + .intent + .as_deref() + .or(residue.intent_stage.as_deref()); + let Some(bytes) = staged else { + return Ok(expected.clone()); + }; + match AdmittedStoreMigrationIntent::decode(bytes) { + Ok(admitted) => Ok(CanonicalStoreMigrationIntent::from_admitted(&admitted)), + Err(_) if residue.intent.is_none() => Ok(expected.clone()), + Err(source) => Err(StoreMigrationRecoveryError::Observation { + source: io::Error::new(io::ErrorKind::InvalidData, source), + }), + } +} + +impl fmt::Display for StoreMigrationRecoveryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Observation { .. } => formatter.write_str("migration residue observation failed"), + Self::Ambiguity { source } => { + write!(formatter, "migration residue is ambiguous: {source}") + } + Self::Adoption { .. } => formatter.write_str("migration residue adoption failed"), + Self::Discard { stage, .. } => { + write!(formatter, "migration {stage:?} stage discard failed") + } + Self::Resumption { source } => write!(formatter, "resumed migration failed: {source}"), + } + } +} + +impl Error for StoreMigrationRecoveryError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Observation { source } + | Self::Adoption { source } + | Self::Discard { source, .. } => Some(source), + Self::Ambiguity { source } => Some(source), + Self::Resumption { source } => Some(source), + } + } +} diff --git a/src/adapters/store_migration/migration_recovery_storage.rs b/src/adapters/store_migration/migration_recovery_storage.rs new file mode 100644 index 00000000..11b6133b --- /dev/null +++ b/src/adapters/store_migration/migration_recovery_storage.rs @@ -0,0 +1,47 @@ +//! This boundary module owns the blocking capabilities migration recovery +//! needs beyond the forward protocol. + +use std::io; + +use super::{ + CanonicalStoreMigrationIntent, StoreMigrationFixedStage, StoreMigrationResidue, + StoreMigrationStorage, +}; + +/// Blocking storage capabilities for recovering one interrupted migration. +/// +/// An implementation holds exclusive writer authority over a root that may +/// carry any lawful migration residue. It observes without mutation, adopts +/// the exact stage and canonical handles a resume point needs by reopening +/// them by device and inode identity, and removes only an incomplete +/// pre-effect stage the planner named. +pub trait StoreMigrationRecoveryStorage: StoreMigrationStorage { + /// Observes every fixed migration name without mutation. + /// + /// # Errors + /// + /// Returns the exact I/O failure, or a typed refusal for a wrong file + /// kind, a link, or an unknown entry. + fn observe_residue(&mut self) -> io::Result; + + /// Reopens, verifies, and retains every exact stage and canonical record + /// the residue holds, so later phases find the handles the forward + /// protocol would have retained. + /// + /// # Errors + /// + /// Returns the exact reopen, identity, or byte verification failure. + fn adopt_residue( + &mut self, + residue: &StoreMigrationResidue, + intent: &CanonicalStoreMigrationIntent, + ) -> io::Result<()>; + + /// Removes one incomplete pre-effect stage and synchronizes the root. + /// + /// # Errors + /// + /// Returns the exact removal or synchronization failure, or a typed + /// refusal when the stage's canonical target already exists. + fn discard_stage(&mut self, stage: StoreMigrationFixedStage) -> io::Result<()>; +} diff --git a/src/adapters/store_migration/migration_resumption.rs b/src/adapters/store_migration/migration_resumption.rs new file mode 100644 index 00000000..8e103a53 --- /dev/null +++ b/src/adapters/store_migration/migration_resumption.rs @@ -0,0 +1,162 @@ +//! This boundary module owns resuming an interrupted migration at one exact +//! phase. + +use std::io; + +use super::{ + CanonicalStoreFormatMarker, CanonicalStoreMigrationIntent, CanonicalStoreMigrationReceipt, + StoreMigrationError, StoreMigrationPhase, StoreMigrationStorage, +}; + +/// The three canonical records one migration publishes. +pub(super) struct MigrationRecords<'a> { + pub(super) intent: &'a CanonicalStoreMigrationIntent, + pub(super) marker: CanonicalStoreFormatMarker, + pub(super) receipt: CanonicalStoreMigrationReceipt, +} + +impl<'a> MigrationRecords<'a> { + pub(super) fn for_intent(intent: &'a CanonicalStoreMigrationIntent) -> Self { + let marker = CanonicalStoreFormatMarker::version_two(); + let receipt = CanonicalStoreMigrationReceipt::from_canonical(intent, &marker); + Self { + intent, + marker, + receipt, + } + } +} + +/// Resumes one migration at `from` and runs every later phase in order. +/// +/// The storage must already hold the handles the phases before `from` +/// established; a recovery storage adopts them from the observed residue. +/// No current-state verification runs here, because recovery verified the +/// persisted intent before planning. The returned receipt exists only after +/// the final store-root synchronization. +/// +/// # Errors +/// +/// Returns [`StoreMigrationError::Storage`] naming the exact phase that +/// refused. Failure returns no receipt. +pub fn resume_store_migration( + storage: &mut impl StoreMigrationStorage, + intent: &CanonicalStoreMigrationIntent, + from: StoreMigrationPhase, +) -> Result { + let records = MigrationRecords::for_intent(intent); + let start = StoreMigrationPhase::ALL + .iter() + .position(|phase| *phase == from) + .unwrap_or(StoreMigrationPhase::ALL.len()); + for phase in StoreMigrationPhase::ALL.iter().skip(start) { + execute_phase(storage, *phase, &records)?; + } + Ok(records.receipt) +} + +/// Runs exactly one phase against the storage. +pub(super) fn execute_phase( + storage: &mut impl StoreMigrationStorage, + phase: StoreMigrationPhase, + records: &MigrationRecords<'_>, +) -> Result<(), StoreMigrationError> { + let result = match phase { + StoreMigrationPhase::WriteIntentStage + | StoreMigrationPhase::SynchronizeIntentStage + | StoreMigrationPhase::LinkIntent + | StoreMigrationPhase::SynchronizeRootAfterIntent + | StoreMigrationPhase::RemoveIntentStage + | StoreMigrationPhase::SynchronizeRootAfterIntentCleanup + | StoreMigrationPhase::AdmitReaderFence + | StoreMigrationPhase::AdmitNamespacePrefix + | StoreMigrationPhase::SynchronizeRootAfterNamespace => { + intent_and_namespace_phase(storage, phase, records.intent) + } + StoreMigrationPhase::WriteMarkerStage + | StoreMigrationPhase::SynchronizeMarkerStage + | StoreMigrationPhase::LinkMarker + | StoreMigrationPhase::SynchronizeRootAfterMarker + | StoreMigrationPhase::RemoveMarkerStage + | StoreMigrationPhase::SynchronizeRootAfterMarkerCleanup => { + marker_phase(storage, phase, &records.marker) + } + StoreMigrationPhase::WriteReceiptStage + | StoreMigrationPhase::SynchronizeReceiptStage + | StoreMigrationPhase::LinkReceipt + | StoreMigrationPhase::SynchronizeRootAfterReceipt + | StoreMigrationPhase::RemoveReceiptStage + | StoreMigrationPhase::SynchronizeRootAfterReceiptCleanup => { + receipt_phase(storage, phase, &records.receipt) + } + }; + result.map_err(|source| StoreMigrationError::Storage { phase, source }) +} + +fn intent_and_namespace_phase( + storage: &mut impl StoreMigrationStorage, + phase: StoreMigrationPhase, + intent: &CanonicalStoreMigrationIntent, +) -> io::Result<()> { + match phase { + StoreMigrationPhase::WriteIntentStage => storage.write_intent_stage(intent), + StoreMigrationPhase::SynchronizeIntentStage => storage.synchronize_intent_stage(), + StoreMigrationPhase::LinkIntent => storage.link_intent(intent), + StoreMigrationPhase::SynchronizeRootAfterIntent => storage.synchronize_root_after_intent(), + StoreMigrationPhase::RemoveIntentStage => storage.remove_intent_stage(), + StoreMigrationPhase::SynchronizeRootAfterIntentCleanup => { + storage.synchronize_root_after_intent_cleanup() + } + StoreMigrationPhase::AdmitReaderFence => storage.admit_reader_fence(), + StoreMigrationPhase::AdmitNamespacePrefix => storage.admit_namespace_prefix(), + StoreMigrationPhase::SynchronizeRootAfterNamespace => { + storage.synchronize_root_after_namespace() + } + _ => Err(wrong_section(phase)), + } +} + +fn marker_phase( + storage: &mut impl StoreMigrationStorage, + phase: StoreMigrationPhase, + marker: &CanonicalStoreFormatMarker, +) -> io::Result<()> { + match phase { + StoreMigrationPhase::WriteMarkerStage => storage.write_marker_stage(marker), + StoreMigrationPhase::SynchronizeMarkerStage => storage.synchronize_marker_stage(), + StoreMigrationPhase::LinkMarker => storage.link_marker(marker), + StoreMigrationPhase::SynchronizeRootAfterMarker => storage.synchronize_root_after_marker(), + StoreMigrationPhase::RemoveMarkerStage => storage.remove_marker_stage(), + StoreMigrationPhase::SynchronizeRootAfterMarkerCleanup => { + storage.synchronize_root_after_marker_cleanup() + } + _ => Err(wrong_section(phase)), + } +} + +fn receipt_phase( + storage: &mut impl StoreMigrationStorage, + phase: StoreMigrationPhase, + receipt: &CanonicalStoreMigrationReceipt, +) -> io::Result<()> { + match phase { + StoreMigrationPhase::WriteReceiptStage => storage.write_receipt_stage(receipt), + StoreMigrationPhase::SynchronizeReceiptStage => storage.synchronize_receipt_stage(), + StoreMigrationPhase::LinkReceipt => storage.link_receipt(receipt), + StoreMigrationPhase::SynchronizeRootAfterReceipt => { + storage.synchronize_root_after_receipt() + } + StoreMigrationPhase::RemoveReceiptStage => storage.remove_receipt_stage(), + StoreMigrationPhase::SynchronizeRootAfterReceiptCleanup => { + storage.synchronize_root_after_receipt_cleanup() + } + _ => Err(wrong_section(phase)), + } +} + +fn wrong_section(phase: StoreMigrationPhase) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("migration phase {phase} dispatched to the wrong section"), + ) +} diff --git a/src/lib.rs b/src/lib.rs index ce979390..dc493b23 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -163,8 +163,9 @@ pub use adapters::{ }; pub use adapters::{ MIGRATION_NAMESPACE_PREFIX, StoreMigrationEffect, StoreMigrationFixedStage, - StoreMigrationRecoveryAmbiguity, StoreMigrationRecoveryPlan, StoreMigrationResidue, - plan_store_migration_recovery, + StoreMigrationRecoveryAmbiguity, StoreMigrationRecoveryError, StoreMigrationRecoveryPlan, + StoreMigrationRecoveryReceipt, StoreMigrationRecoveryStorage, StoreMigrationResidue, + plan_store_migration_recovery, recover_store_migration, resume_store_migration, }; pub use blob::{ BlobHashError, BlobHasher, BlobId, BlobLength, BlobReadError, ByteLength, ByteOffset, From 4b32c02043f601b2fe5d6fa9ed80479df7d27581 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 09:43:40 -0700 Subject: [PATCH 19/59] Fix: hash each chunk once per reference-store read Problem: ReferenceStore::reconstruct* hashed every chunk in the verification pass and then hashed it again in the emission pass; range reads did the same over the selected chunks. Every full or large-range read paid double CPU for an adapter whose chunks cannot change during the call (#71). Approach: keep the verification pass exactly as it was, so every "refuses before output" law holds unchanged, and make the emission pass fetch each already-verified immutable chunk by identity through a new emitted_chunk that hashes nothing. The in-memory view is immutable under &self, so a second hash proved nothing the first did not. The reference-store rationale records the rejected alternative and the obligation a durable adapter keeps: bytes that can change between passes must be reverified or pinned. Evidence: a test-only hash counter on the store pins one hash per chunk for a two-chunk reconstruction and one hash for a one-chunk range read. With emission routed back through verified_chunk, the reconstruction law fails with every chunk listed twice. The reference, streaming CAS, range read, and verification suites pass. Closes #71. ROADMAP T-06.3 checked. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 7 +++++ ROADMAP.md | 8 ++++-- docs/architecture/reference-store/README.md | 4 +-- .../architecture/reference-store/rationale.md | 18 ++++++++----- .../authenticated-reconstruction/README.md | 8 +++--- src/reference/chunk_verification.rs | 25 +++++++++++++++++ src/reference/range_read_execution.rs | 4 +-- src/reference/range_read_tests.rs | 16 +++++++++++ src/reference/reconstruction.rs | 11 ++++---- src/reference/reconstruction_tests.rs | 27 +++++++++++++++++++ src/reference/store.rs | 4 +++ 11 files changed, 111 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ce8713..2c501b06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -691,6 +691,13 @@ after its public API and format compatibility policies are established. ### Fixed +- `ReferenceStore` reconstruction and range reads hash each selected chunk + exactly once. The verification pass still runs to completion before the + first output write; the emission pass now fetches each verified immutable + chunk by identity instead of hashing it again, because the in-memory view + cannot change under `&self`. Every "refuses before output" law is + unchanged; a test-only hash counter pins one hash per chunk. Closes #71. + Review corrections to the unreleased retention and migration work above; none of these shipped in a release. diff --git a/ROADMAP.md b/ROADMAP.md index c4f950e6..cb5002cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -78,7 +78,7 @@ names; use those in code, tests, and commits. - [x] [F-03 Identity layers and RepresentationId](#f-03-identity-layers-and-representationid) — Done as a model; representation codec reserved - [x] [F-04 Deterministic chunking and ChunkId](#f-04-deterministic-chunking-and-chunkid) — Done - [x] [F-05 Flat chunk layout v1 and LayoutId](#f-05-flat-chunk-layout-v1-and-layoutid) — Done -- [x] [F-06 Reference store](#f-06-reference-store) — Done; two open defects +- [x] [F-06 Reference store](#f-06-reference-store) — Done; one open defect (#74) - [x] [F-07 Authenticated reconstruction and exact range reads](#f-07-authenticated-reconstruction-and-exact-range-reads) — Done for the reference store - [x] [F-08 Conformance corpora and the Golden File Worldline](#f-08-conformance-corpora-and-the-golden-file-worldline) — Done - [x] [F-09 Streaming CAS benchmark baseline](#f-09-streaming-cas-benchmark-baseline) — Done; thresholds unconfigured @@ -318,7 +318,11 @@ death loses everything in it; no API makes a durability claim. `tests/streaming_cas/`, 216 exhaustive three-step model sequences. - [x] T-06.2 Chunk deduplication keyed by `ChunkId` as a storage fact, not retention — `docs/architecture/reference-store/README.md`. -- [ ] T-06.3 Single-pass authenticated emit (#71, P2). +- [x] T-06.3 Single-pass authenticated emit (#71) — the verification pass + hashes each chunk once; emission fetches verified chunks by identity; + `every_chunk_is_hashed_exactly_once_per_reconstruction` and + `a_range_read_hashes_each_selected_chunk_exactly_once`. Original task + fields: - **Requirements:** each selected chunk is hashed exactly once per `reconstruct` or `read_range` call; the complete `BlobId`, range accounting, and profile-boundary verification stay intact; the failure diff --git a/docs/architecture/reference-store/README.md b/docs/architecture/reference-store/README.md index 2c05d291..d985e251 100644 --- a/docs/architecture/reference-store/README.md +++ b/docs/architecture/reference-store/README.md @@ -71,8 +71,8 @@ backend must define a separate explicit recovery protocol. ## Reconstruction -Whole-blob reconstruction performs two passes over immutable in-memory chunks. -Before output it: +Whole-blob reconstruction hashes each immutable in-memory chunk exactly once, +then emits the verified chunks by identity. Before output it: 1. verifies every stored chunk against its named `ChunkId`; 2. replays `fastcdc-64k-v1` and compares every boundary with the layout; and diff --git a/docs/architecture/reference-store/rationale.md b/docs/architecture/reference-store/rationale.md index 68ba4c30..13433bbc 100644 --- a/docs/architecture/reference-store/rationale.md +++ b/docs/architecture/reference-store/rationale.md @@ -43,11 +43,14 @@ Repair belongs to a future explicit recovery protocol with its own evidence. Writing a verified prefix before discovering a later missing chunk, false profile boundary, or full-blob mismatch would expose bytes from an unauthenticated claim. Reconstruction first verifies the entire plan without -output. It then reverifies each chunk immediately before writing because the -output pass is a separate traversal. +output, hashing each chunk exactly once. It then emits each verified chunk by +identity without hashing it again: the in-memory view cannot change under +`&self`, so a second hash would prove nothing the first did not. -This costs two chunk-verification passes. Correct refusal and a simple audit -story outweigh throughput until measured evidence justifies another design. +Rejected: reverifying on emission (issue #71). It doubled the CPU of every +full and large-range read for an adapter whose chunks are immutable for the +duration of the call. A durable adapter, whose bytes can change between +passes, must reverify on emission or pin what it verified. ## Why range reads authenticate selected chunks only @@ -106,8 +109,9 @@ association refuses before output. for durable application data. - Staging memory can grow with unique content only up to explicit capacity. - Layout metadata remains bounded but can be large at the protocol maximum. -- Reconstruction performs two verification passes before reporting success. -- Exact range reads perform two verification passes over only the selected - chunks and deliberately make no complete-blob verification claim. +- Reconstruction hashes each chunk once, before its first output write, and + emits verified chunks by identity. +- Exact range reads hash only the selected chunks, once each, and + deliberately make no complete-blob verification claim. - Durable storage must implement a different adapter with documented publication order, crash states, recovery behavior, and synchronization. diff --git a/docs/invariants/authenticated-reconstruction/README.md b/docs/invariants/authenticated-reconstruction/README.md index 98430be1..4c3dea09 100644 --- a/docs/invariants/authenticated-reconstruction/README.md +++ b/docs/invariants/authenticated-reconstruction/README.md @@ -150,9 +150,11 @@ complete success receipt. Keep does not claim that an unsuccessful call left an arbitrary `Write` untouched. The current `ReferenceStore` verifies the complete realization before its -first output write, then reverifies each immutable chunk immediately before -emission. This prevents known unauthenticated content from being emitted; it -does not make the caller's sink atomic. +first output write, hashing each chunk once, then emits each verified +immutable chunk by identity; its view cannot change under `&self`. This +prevents known unauthenticated content from being emitted; it does not make +the caller's sink atomic. A durable view whose bytes can change between +verification and emission must reverify or pin what it verified. ## Decisions, refusals, and operation failures diff --git a/src/reference/chunk_verification.rs b/src/reference/chunk_verification.rs index eea07ed4..07f700e0 100644 --- a/src/reference/chunk_verification.rs +++ b/src/reference/chunk_verification.rs @@ -16,6 +16,8 @@ pub(super) fn verified_chunk( index, requested: expected, })?; + #[cfg(test)] + store.observed_chunk_hashes.borrow_mut().push(expected); let observed = ChunkId::hash_bytes(bytes).map_err(|source| ChunkVerificationError::Hash { layout: layout_id, index, @@ -53,3 +55,26 @@ pub(super) enum ChunkVerificationError { observed: ChunkId, }, } + +/// Fetches an already-authenticated immutable chunk for emission. +/// +/// The verification pass hashed every selected chunk before the first byte +/// was written, and the in-memory view cannot change under `&self`, so the +/// emission pass looks the chunk up by identity and hashes nothing. A chunk +/// that vanished between the passes is impossible here; the arm exists so a +/// durable adapter that reuses this shape cannot forget it. +pub(super) fn emitted_chunk( + store: &ReferenceStore, + layout_id: LayoutId, + index: usize, + entry: LayoutEntry, +) -> Result<&[u8], ChunkVerificationError> { + let expected = entry.chunk_id(); + store + .chunk(expected) + .ok_or(ChunkVerificationError::Missing { + layout: layout_id, + index, + requested: expected, + }) +} diff --git a/src/reference/range_read_execution.rs b/src/reference/range_read_execution.rs index ee74f569..feceb2b2 100644 --- a/src/reference/range_read_execution.rs +++ b/src/reference/range_read_execution.rs @@ -7,7 +7,7 @@ use crate::{ ReferenceStore, }; -use super::chunk_verification::verified_chunk; +use super::chunk_verification::{emitted_chunk, verified_chunk}; use super::output_write::write_all; use super::range_read_error_mapping::{range_chunk_error, range_output_error}; use super::{RangeReadError, RangeReadReceipt}; @@ -68,7 +68,7 @@ where let (first, entries) = selected_entries(layout, plan)?; let mut written = 0_u64; for (index, entry) in (first..plan.end_entry()).zip(entries.iter().copied()) { - let bytes = verified_chunk(store, layout_id, index, entry).map_err(range_chunk_error)?; + let bytes = emitted_chunk(store, layout_id, index, entry).map_err(range_chunk_error)?; let selected = selected_chunk_slice(layout_id, index, entry, plan.requested(), bytes)?; write_all(output, selected, &mut written) .map_err(|error| range_output_error(layout_id, error))?; diff --git a/src/reference/range_read_tests.rs b/src/reference/range_read_tests.rs index 72c871dd..cb5d2310 100644 --- a/src/reference/range_read_tests.rs +++ b/src/reference/range_read_tests.rs @@ -157,3 +157,19 @@ fn source_slice(source: &[u8], requested: ByteRange) -> Result<&[u8], Box Result<(), Box> { + let (store, source, layout_id, _index, entry) = range_fixture()?; + let selected = entry.chunk_id(); + store.observed_chunk_hashes.borrow_mut().clear(); + let requested = one_byte_inside(entry)?; + let mut output = Vec::new(); + + let _receipt = store.read_layout_range(layout_id, requested, &mut output)?; + + assert_eq!(output, source_slice(&source, requested)?); + assert_eq!(store.observed_chunk_hashes.borrow().as_slice(), [selected]); + Ok(()) +} diff --git a/src/reference/reconstruction.rs b/src/reference/reconstruction.rs index 5021ecdf..a2b7d86e 100644 --- a/src/reference/reconstruction.rs +++ b/src/reference/reconstruction.rs @@ -6,7 +6,7 @@ use crate::{ AdmittedLayout, BlobHasher, BlobId, BlobLength, LayoutDecodePolicy, LayoutId, ReferenceStore, }; -use super::chunk_verification::{ChunkVerificationError, verified_chunk}; +use super::chunk_verification::{ChunkVerificationError, emitted_chunk, verified_chunk}; use super::output_write::{OutputWriteError, write_all}; use super::profile_verification::ProfileVerifier; use super::{ReconstructionError, ReconstructionReceipt}; @@ -17,9 +17,10 @@ impl ReferenceStore { /// The lowest canonical committed [`LayoutId`] is chosen deterministically /// when more than one layout names the blob. Reconstruction first verifies /// every chunk, the registered storage-profile boundaries, and the complete - /// logical [`BlobId`] without writing. It then reverifies each immutable - /// reference-store chunk immediately before emitting it, so no - /// unauthenticated byte reaches `output`. + /// logical [`BlobId`] without writing, hashing each chunk exactly once. It + /// then emits each verified immutable chunk by identity without hashing it + /// again: the in-memory view cannot change under `&self`, so no + /// unauthenticated byte reaches `output` and no chunk pays for two hashes. /// /// Short writes are completed and interrupted writes are retried. This /// synchronous blocking operation allocates no adapter-owned heap memory, @@ -192,7 +193,7 @@ where let mut written = 0_u64; for (index, entry) in layout.entries().iter().copied().enumerate() { let bytes = - verified_chunk(store, layout_id, index, entry).map_err(reconstruction_chunk_error)?; + emitted_chunk(store, layout_id, index, entry).map_err(reconstruction_chunk_error)?; write_chunk(output, layout_id, bytes, &mut written)?; } Ok(BlobLength::new(written)) diff --git a/src/reference/reconstruction_tests.rs b/src/reference/reconstruction_tests.rs index 995cac37..972417c4 100644 --- a/src/reference/reconstruction_tests.rs +++ b/src/reference/reconstruction_tests.rs @@ -42,3 +42,30 @@ fn corrupted_stored_chunk_refuses_before_output() -> Result<(), Box> assert!(output.is_empty()); Ok(()) } + +/// Issue #71: every chunk is hashed exactly once per reconstruction. The +/// verification pass hashes; the emission pass fetches by identity. +#[test] +fn every_chunk_is_hashed_exactly_once_per_reconstruction() -> Result<(), Box> { + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(1_048_576)); + let mut reader = Cursor::new(vec![0_u8; 300_000]); + let published = store + .stage(&mut reader, LayoutEntryLimit::MAXIMUM)? + .commit(&mut store)?; + let entries: Vec<_> = store + .layout(published.layout_id()) + .ok_or("published layout is absent")? + .entries() + .iter() + .map(|entry| entry.chunk_id()) + .collect(); + assert!(entries.len() >= 2, "the law needs a multi-chunk blob"); + store.observed_chunk_hashes.borrow_mut().clear(); + let mut output = Vec::new(); + + let receipt = store.reconstruct(published.target(), &mut output)?; + + assert_eq!(receipt.bytes_written().get(), 300_000); + assert_eq!(store.observed_chunk_hashes.borrow().as_slice(), entries); + Ok(()) +} diff --git a/src/reference/store.rs b/src/reference/store.rs index cd235726..1bf3ca0a 100644 --- a/src/reference/store.rs +++ b/src/reference/store.rs @@ -46,6 +46,8 @@ pub struct ReferenceStore { pub(super) materialized_bytes: usize, #[cfg(test)] pub(super) observed_chunk_reads: RefCell>, + #[cfg(test)] + pub(super) observed_chunk_hashes: RefCell>, } impl ReferenceStore { @@ -60,6 +62,8 @@ impl ReferenceStore { materialized_bytes: 0, #[cfg(test)] observed_chunk_reads: RefCell::new(Vec::new()), + #[cfg(test)] + observed_chunk_hashes: RefCell::new(Vec::new()), } } From 132b1c7a3b46607d2c6a6c4348b8fcf1eb9a6dee Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 09:52:41 -0700 Subject: [PATCH 20/59] Feat: measured memory ceiling and floor for reference-store staging ROADMAP T-06.4 (#74). The in-memory adapter cannot stage a bounded window of a larger blob: a staged chunk lives either in the invisible StagedBlob or the visible store map, so a window would publish a prefix, and spilling to disk is the durable ingestion adapter (#82). The bound is therefore the store capacity, enforced as a refusal before any chunk copy crosses it. - `ReferenceStore::STAGING_SCRATCH_LIMIT_BYTES` names the fixed scratch one `stage` call holds beyond new unique chunk bytes (8 KiB read buffer, one maximum-length chunk buffer, detector retained state), const-asserted against the registered profile. - `tests/streaming_cas_memory.rs` measures the ceiling: a source five times the capacity refuses with `CapacityExceeded` whose `attempted` is at most one maximum chunk past the capacity, peak heap stays under scratch plus capacity plus layout metadata, and the refusal retains nothing. A second law proves fully deduplicated staging stays at the scratch floor with zero pending bytes. - `tests/streaming_cas/ingestion_laws.rs` admits a source exactly at capacity and refuses the next byte. - Reference-store README gains a "Bounded memory" section; the rationale records the rejected staging window and spill alternatives. Red: weakening the capacity check and the store-side dedup fails both memory laws. Green: restored. Closes #74 Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 13 +++ ROADMAP.md | 19 ++-- docs/architecture/reference-store/README.md | 28 +++++- .../architecture/reference-store/rationale.md | 36 ++++++- src/reference/ingestion.rs | 25 +++++ src/reference/staged_blob.rs | 5 +- tests/streaming_cas/ingestion_laws.rs | 19 ++++ tests/streaming_cas_memory.rs | 96 ++++++++++++++++++- 8 files changed, 228 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c501b06..4e5ee3cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ after its public API and format compatibility policies are established. ### Added +- Reference-store staging memory contract. `ReferenceStore::STAGING_SCRATCH_LIMIT_BYTES` + names the fixed scratch one `stage` call holds beyond the new unique chunk + bytes it copies (the 8 KiB read buffer, one maximum-length chunk buffer, + and the detector's retained state). Peak staging memory is that scratch + plus the capacity the store has not yet materialized plus layout metadata + bounded by the entry limit, because the adapter refuses with + `CapacityExceeded` before copying a chunk that would cross the capacity. + `tests/streaming_cas_memory.rs` measures that ceiling for a source five + times the capacity, proves the refusal retains nothing, and proves fully + deduplicated staging stays at the scratch floor; the reference-store + rationale records why the in-memory adapter cannot stage a bounded window + of a larger blob without publishing a prefix or becoming the durable + ingestion adapter (#74). - Partial-prefix migration recovery. `plan_store_migration_recovery` maps one observed `StoreMigrationResidue` (the presence and exact bytes of every fixed migration name) and the intent the version-1 store derives today diff --git a/ROADMAP.md b/ROADMAP.md index cb5002cb..a5545f06 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -78,7 +78,7 @@ names; use those in code, tests, and commits. - [x] [F-03 Identity layers and RepresentationId](#f-03-identity-layers-and-representationid) — Done as a model; representation codec reserved - [x] [F-04 Deterministic chunking and ChunkId](#f-04-deterministic-chunking-and-chunkid) — Done - [x] [F-05 Flat chunk layout v1 and LayoutId](#f-05-flat-chunk-layout-v1-and-layoutid) — Done -- [x] [F-06 Reference store](#f-06-reference-store) — Done; one open defect (#74) +- [x] [F-06 Reference store](#f-06-reference-store) — Done - [x] [F-07 Authenticated reconstruction and exact range reads](#f-07-authenticated-reconstruction-and-exact-range-reads) — Done for the reference store - [x] [F-08 Conformance corpora and the Golden File Worldline](#f-08-conformance-corpora-and-the-golden-file-worldline) — Done - [x] [F-09 Streaming CAS benchmark baseline](#f-09-streaming-cas-benchmark-baseline) — Done; thresholds unconfigured @@ -102,7 +102,7 @@ names; use those in code, tests, and commits. - [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Planned (#20) - [ ] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Planned (#21) - [ ] [F-23 Durable authenticated reads and refusal receipts](#f-23-durable-authenticated-reads-and-refusal-receipts) — Planned (#109) -- [ ] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Planned (#82, #74, #72) +- [ ] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Planned (#82, #72) ### Integration (M5) @@ -165,9 +165,9 @@ features are listed; finished prerequisites are implied. - F-22 (#21) needs F-19, F-21, and the remainder of F-18; it is needed by F-26, F-29, F-31, F-32, F-33. - F-23 needs F-19 and F-22 (a pinned view must survive collection). -- F-24 (#82) needs F-18, F-21, and the F-06 defects #74 and #72. +- F-24 (#82) needs F-18, F-21, and the F-06 defect #72. - F-26 (#24) needs F-22 and F-25. -- F-28 implementation (#83) needs the F-28 ADR (#86), F-21, #74, #72. +- F-28 implementation (#83) needs the F-28 ADR (#86), F-21, #72. - F-29 (#85) needs F-18, F-21, F-22; it is needed by F-30, F-31, F-32, F-33. - F-30 (#89) needs F-24 and F-29; F-31 (#92) needs F-30; F-32 (#90) needs F-31. @@ -306,7 +306,7 @@ plan. ### F-06 Reference store -**Status:** Done (issue #13). Two open defects, #71 and #74. +**Status:** Done (issue #13). Defects #71 and #74 closed on this branch. `ReferenceStore` is the capacity-bounded, in-memory, non-durable adapter that proves the stage, commit, and reconstruct laws: `stage` chunks and @@ -353,7 +353,12 @@ death loses everything in it; no API makes a durability claim. - **Documentation:** `docs/architecture/reference-store/rationale.md` paragraph on two verification passes rewritten. - **Dependencies:** none. Blocks nothing, but F-24 inherits the pattern. -- [ ] T-06.4 Bounded-memory staging (#74, P1). +- [x] T-06.4 Bounded-memory staging (#74, P1) — resolved by rationale plus + measured laws: `ReferenceStore::STAGING_SCRATCH_LIMIT_BYTES` names the + fixed scratch, `tests/streaming_cas_memory.rs` measures the ceiling and + the deduplicated floor, and the reference-store rationale records why a + staging window is unavailable to an in-memory adapter. Original task + fields: - **Requirements:** staging holds a bounded window of missing chunks, not every missing chunk for the whole blob; the bound is explicit, checked, and reported; `StagedBlob` semantics (invisible until commit) hold. @@ -1377,7 +1382,7 @@ and no hidden whole-blob allocation. ### F-24 Bounded production ingestion through the durable store -**Status:** Planned (#82, P1, M6). Needs #74 and #72 (F-06) and F-21. +**Status:** Planned (#82, P1, M6). Needs #72 (F-06) and F-21. One bounded production path from an unknown-length source through the registered CDC profile, chunk verification and deduplication, immutable diff --git a/docs/architecture/reference-store/README.md b/docs/architecture/reference-store/README.md index d985e251..c3ef2a9d 100644 --- a/docs/architecture/reference-store/README.md +++ b/docs/architecture/reference-store/README.md @@ -51,6 +51,27 @@ store used during staging. Those bytes may grow with blob length up to `ReferenceStoreCapacity`. The API and type documentation expose that materialization; input beyond the configured capacity refuses. +### Bounded memory + +Staging memory has an explicit, checked ceiling and floor: + +- **Ceiling.** Peak adapter-owned memory during one `stage` call never + exceeds `ReferenceStore::STAGING_SCRATCH_LIMIT_BYTES` (the 8 KiB read + buffer, one maximum-length chunk buffer, and the detector's retained state) + plus the capacity the store has not yet materialized plus layout metadata + proportional to the entry limit. The adapter checks committed bytes plus + pending bytes plus the incoming chunk against the capacity *before* copying + the chunk, so a refusal reports `CapacityExceeded { capacity, attempted }` + with `attempted` at most one maximum chunk beyond the capacity, and pending + bytes never cross it. A refusal retains nothing. +- **Floor.** A source whose every chunk the store already owns stages with + zero pending bytes and allocates only the scratch and layout metadata; no + chunk is copied to be compared. + +The ceiling is a refusal, not a window. The [rationale](rationale.md#why-staging-materializes-up-to-capacity) +records why this in-memory adapter cannot stage a bounded window of a larger +blob without either publishing a prefix or becoming a durable adapter. + ## Publication Staged work is invisible and `#[must_use]`. `StagedBlob::commit` is the only @@ -123,7 +144,8 @@ remain non-durable before, during, and after the operation. ## Evidence - `tests/streaming_cas/ingestion_laws.rs` covers staging, deduplication, - capacity, short reads, interruptions, and streaming entry-cap refusal. + capacity (exactly at, and one byte over), short reads, interruptions, and + streaming entry-cap refusal. - `tests/streaming_cas/reconstruction_laws.rs` covers exact authenticated output, full-blob mismatch, and missing chunks. - `tests/streaming_cas/refusal_laws.rs` covers malformed records, frozen false @@ -138,7 +160,9 @@ remain non-durable before, during, and after the operation. - `src/reference/range_read_tests.rs` proves prefix and suffix chunks are not loaded and selected-chunk corruption refuses before output. - `tests/streaming_cas_memory.rs` proves committed-layout reconstruction and - range reads allocate no adapter-owned heap memory. + range reads allocate no adapter-owned heap memory, that staging a source + five times the capacity refuses under the memory ceiling and retains + nothing, and that fully deduplicated staging stays at the scratch floor. - `tests/golden_file_worldline/storage_assertions.rs` executes the Golden File Worldline through the public API. diff --git a/docs/architecture/reference-store/rationale.md b/docs/architecture/reference-store/rationale.md index 13433bbc..f360eaf1 100644 --- a/docs/architecture/reference-store/rationale.md +++ b/docs/architecture/reference-store/rationale.md @@ -27,6 +27,38 @@ reports the number and bytes of chunks absent from the store used during staging. Commit rechecks that another destination already owns any required chunks omitted by that deduplication. +## Why staging materializes up to capacity + +Issue #74 asked for a bounded-memory staging path that does not retain the +complete missing-chunk set of a blob, or an explicit rationale for why that +materialization is unavoidable. For this adapter it is unavoidable, and the +bound is enforced as a refusal. + +The store is process memory. A staged chunk has exactly two possible homes: +the invisible `StagedBlob` or the visible store map. Moving a chunk from the +first to the second before the complete `BlobId` and admitted layout exist +would publish a prefix, which the stage-before-commit rule below forbids. +Holding it anywhere else, a spill file or a durable segment, is the durable +staged-ingestion adapter (issue #82), not this one. So every new unique chunk +of a blob must be owned by its `StagedBlob` until the one synchronous commit, +and the only honest bound is the store's own capacity. + +That bound is explicit and checked. The adapter compares committed bytes plus +pending bytes plus the incoming chunk against `ReferenceStoreCapacity` before +it copies the chunk, so pending bytes never cross the capacity and a refusal +retains nothing. `ReferenceStore::STAGING_SCRATCH_LIMIT_BYTES` names the fixed +scratch the streaming engine holds beyond those bytes. Peak staging memory is +therefore capacity not yet materialized, plus that scratch, plus layout +metadata bounded by the entry limit; `tests/streaming_cas_memory.rs` measures +both the ceiling and the deduplicated floor. + +Rejected: a staging window that commits full chunks as it fills. It would +make a chunk visible under no admitted layout and no verified `BlobId`, and a +later source failure would leave orphaned chunks that only a garbage +collector could reclaim. Rejected: spilling the window to disk. That adapter +needs publication order, crash states, and recovery evidence, which is the +durable ingestion feature, not a defect fix in the reference adapter. + ## Why stage before commit Reading, chunking, hashing, allocation, and canonical layout calculation can @@ -107,7 +139,9 @@ association refuses before output. - The adapter is deterministic and straightforward to model, but unsuitable for durable application data. -- Staging memory can grow with unique content only up to explicit capacity. +- Staging memory can grow with unique content only up to explicit capacity; + the peak is measured against `STAGING_SCRATCH_LIMIT_BYTES` plus that + capacity, and a refusal retains nothing. - Layout metadata remains bounded but can be large at the protocol maximum. - Reconstruction hashes each chunk once, before its first output write, and emits verified chunks by identity. diff --git a/src/reference/ingestion.rs b/src/reference/ingestion.rs index 81258daa..784fca6f 100644 --- a/src/reference/ingestion.rs +++ b/src/reference/ingestion.rs @@ -15,11 +15,36 @@ macro_rules! read_buffer_bytes { }; } +macro_rules! maximum_chunk_bytes { + () => { + 262_144 + }; +} + const READ_BUFFER_BYTES: usize = read_buffer_bytes!(); // This bound makes more than one detector boundary per read impossible. const _: () = assert!(read_buffer_bytes!() <= FastCdc::MINIMUM_CHUNK_LENGTH.get()); +const MAXIMUM_CHUNK_BYTES: usize = maximum_chunk_bytes!(); +const _: () = assert!(maximum_chunk_bytes!() == FastCdc::MAXIMUM_CHUNK_LENGTH.get()); impl ReferenceStore { + /// Fixed scratch memory that one [`ReferenceStore::stage`] call may hold + /// beyond the new unique chunk bytes it stages. + /// + /// The scratch is the 8 KiB read buffer, one buffer sized to + /// [`FastCdc::MAXIMUM_CHUNK_LENGTH`], and the detector's retained state. + /// It does not grow with the source length. Layout metadata proportional + /// to the caller's [`LayoutEntryLimit`] is accounted separately. + /// + /// Together with the capacity check this gives the staging memory + /// ceiling: peak adapter-owned memory never exceeds this scratch plus the + /// capacity not yet materialized by the store plus the layout metadata, + /// because the store refuses with [`IngestionError::CapacityExceeded`] + /// before copying a chunk that would cross the capacity. + pub const STAGING_SCRATCH_LIMIT_BYTES: usize = READ_BUFFER_BYTES + .saturating_add(MAXIMUM_CHUNK_BYTES) + .saturating_add(FastCdc::RETAINED_STATE_LIMIT_BYTES); + /// Reads one logical stream into invisible, validated staged work. /// /// The streaming engine retains one fixed 8 KiB read buffer, one buffer diff --git a/src/reference/staged_blob.rs b/src/reference/staged_blob.rs index f25f4510..a8efd924 100644 --- a/src/reference/staged_blob.rs +++ b/src/reference/staged_blob.rs @@ -10,7 +10,10 @@ use super::{PublishError, PublishedBlob, ReferenceStore}; /// /// This value explicitly owns every target chunk that was absent from the /// store used during staging. Its memory may therefore grow with logical blob -/// length, bounded by the store capacity checked during staging. Committing to +/// length, bounded by the store capacity checked during staging: staging +/// refuses before copying a chunk that would cross the capacity, so the +/// pending bytes reported by [`StagedBlob::pending_materialized_bytes`] never +/// exceed the capacity the store had not yet materialized. Committing to /// another store succeeds only when that destination already owns every /// deduplicated chunk not carried by this value. This materialization belongs /// to the deliberately in-memory reference adapter, not to the streaming diff --git a/tests/streaming_cas/ingestion_laws.rs b/tests/streaming_cas/ingestion_laws.rs index ecae5c9d..94f9e4cc 100644 --- a/tests/streaming_cas/ingestion_laws.rs +++ b/tests/streaming_cas/ingestion_laws.rs @@ -65,6 +65,25 @@ fn identical_chunks_are_deduplicated_without_a_retention_claim() -> Result<(), B Ok(()) } +#[test] +fn staging_admits_a_source_exactly_at_capacity() -> Result<(), Box> { + let source = b"every byte of the configured capacity"; + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(source.len())); + let mut reader = Cursor::new(source); + + let staged = store.stage(&mut reader, LayoutEntryLimit::MAXIMUM)?; + + assert_eq!(staged.pending_materialized_bytes(), source.len()); + let published = staged.commit(&mut store)?; + assert!(store.contains_blob(published.target())); + assert!(matches!( + store.stage(&mut Cursor::new(b"x"), LayoutEntryLimit::MAXIMUM), + Err(IngestionError::CapacityExceeded { capacity, attempted }) + if capacity == source.len() && attempted == source.len().saturating_add(1) + )); + Ok(()) +} + #[test] fn capacity_refusal_publishes_nothing() -> Result<(), Box> { let source = b"one byte beyond the configured capacity"; diff --git a/tests/streaming_cas_memory.rs b/tests/streaming_cas_memory.rs index e9aa3797..1113b069 100644 --- a/tests/streaming_cas_memory.rs +++ b/tests/streaming_cas_memory.rs @@ -1,11 +1,12 @@ -//! Isolated heap-allocation evidence for committed CAS reconstruction. +//! Isolated heap-allocation evidence for reference-store staging and reconstruction. use std::error::Error; use std::io::{Cursor, sink}; use allocation_counter::{AllocationInfo, measure}; use keep::{ - ByteLength, ByteOffset, ByteRange, LayoutEntryLimit, ReferenceStore, ReferenceStoreCapacity, + ByteLength, ByteOffset, ByteRange, FastCdc, IngestionError, LayoutEntryLimit, ReferenceStore, + ReferenceStoreCapacity, }; #[test] @@ -51,3 +52,94 @@ fn committed_range_reads_allocate_no_adapter_owned_heap_memory() -> Result<(), B assert_eq!(observed, AllocationInfo::default()); Ok(()) } + +const CEILING: usize = 200_000; + +/// Layout metadata a staging call may hold per emitted chunk: one span, one +/// admitted entry, one encoded record entry, and map-node slack. +const METADATA_ALLOWANCE_PER_CHUNK: usize = 1_024; + +#[test] +fn staging_ceiling_refuses_before_pending_bytes_exceed_capacity() -> Result<(), Box> { + let source = deterministic_bytes(1_000_000); + let store = ReferenceStore::new(ReferenceStoreCapacity::new(CEILING)); + let mut reader = Cursor::new(&source); + let mut result = None; + + let observed = measure(|| { + result = Some(store.stage(&mut reader, LayoutEntryLimit::MAXIMUM)); + }); + + let error = result + .ok_or("allocation measurement did not run staging")? + .err() + .ok_or("a source five times the capacity unexpectedly staged")?; + let maximum_chunk = usize::try_from(FastCdc::MAXIMUM_CHUNK_LENGTH.get())?; + assert!(matches!( + error, + IngestionError::CapacityExceeded { capacity, attempted } + if capacity == CEILING + && attempted > CEILING + && attempted <= CEILING.saturating_add(maximum_chunk) + )); + let ceiling = CEILING + .saturating_add(ReferenceStore::STAGING_SCRATCH_LIMIT_BYTES) + .saturating_add(metadata_allowance(source.len())); + assert!( + observed.bytes_max <= u64::try_from(ceiling)?, + "staging peaked at {} bytes above the {ceiling}-byte ceiling", + observed.bytes_max + ); + assert_eq!(observed.bytes_current, 0, "a refusal retained heap memory"); + Ok(()) +} + +#[test] +fn deduplicated_staging_allocates_only_bounded_scratch() -> Result<(), Box> { + let source = deterministic_bytes(300_000); + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(1_048_576)); + let mut reader = Cursor::new(&source); + let published = store + .stage(&mut reader, LayoutEntryLimit::MAXIMUM)? + .commit(&mut store)?; + let mut reader = Cursor::new(&source); + let mut result = None; + + let observed = measure(|| { + result = Some(store.stage(&mut reader, LayoutEntryLimit::MAXIMUM)); + }); + + let staged = result.ok_or("allocation measurement did not run staging")??; + assert_eq!(staged.target(), published.target()); + assert_eq!(staged.pending_chunk_count(), 0); + assert_eq!(staged.pending_materialized_bytes(), 0); + let floor = ReferenceStore::STAGING_SCRATCH_LIMIT_BYTES + .saturating_add(metadata_allowance(source.len())); + assert!( + observed.bytes_max <= u64::try_from(floor)?, + "deduplicated staging peaked at {} bytes above the {floor}-byte floor", + observed.bytes_max + ); + Ok(()) +} + +fn metadata_allowance(source_length: usize) -> usize { + let minimum_chunk = usize::try_from(FastCdc::MINIMUM_CHUNK_LENGTH.get()).unwrap_or(1); + source_length + .div_ceil(minimum_chunk) + .saturating_add(1) + .saturating_mul(METADATA_ALLOWANCE_PER_CHUNK) +} + +fn deterministic_bytes(length: usize) -> Vec { + let mut state = 0x0123_4567_89ab_cdef_u64; + let mut bytes = Vec::with_capacity(length); + for _ in 0..length { + state ^= state.wrapping_shl(13); + state ^= state.wrapping_shr(7); + state ^= state.wrapping_shl(17); + let [byte, ..] = state.to_le_bytes(); + bytes.push(byte); + } + bytes +} From 65ed69e4d73987686e1f7cf593c44474c944b93b Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 10:13:48 -0700 Subject: [PATCH 21/59] Feat: process-death crash matrix for the 21 migration phases ROADMAP T-17.3 (KEEP-MIGRATION-007). `cargo xtask durability-crash-matrix` now covers KEEP-CRASH-053 through -073: 68 cases (21 boundaries at three positions, plus one `during` case per admitted directory-prefix length for KEEP-CRASH-060). Each case publishes the Golden File Worldline version-1 store in an isolated child, runs the production migration with the selected boundary gated, and kills the child's process group. The parent compares the restarted root against an independent expected-state model, reopens it for recovery as a restarted writer would, requires `recover_store_migration` to report the plan the recovery table predicts, runs it (or the forward retry after an untouched version-1 store admits), and requires one complete migration with every version-1 byte intact and a second recovery that reports `Complete`. keep: - `FilesystemStoreMigrationAuthority` gains repository-task hooks behind `repository-tasks`: open and reopen-for-recovery without platform admission, a strict-prefix fixed-stage write, and a partial namespace prefix admission; `admit_namespace_prefix` now admits its six directories through one ordered loop the partial form shares. xtask: - `DurabilityCrashPoint` gains the 21 `Migration*` boundaries, the `Migration` sequence, `MIGRATION` order, and `during_occurrences`; `DurabilityCrashCase::all` yields one `during` case per occurrence (173 cases total) and `in_sequence` filters; `--sequence ` and an optional `--case` occurrence argument. - `CrashMigrationStorage` gates every `StoreMigrationStorage` phase; `restart/migration.rs` and `migration_expectation.rs` hold the independent inventory and recovery-plan model. - `conformance/segment-store/v2/transitions.tsv` records each boundary's states and posture; `transition_laws.rs` pins it to `StoreMigrationPhase::ALL`. Docs: migration-crash.md no longer claims only in-process recovery; KEEP-MIGRATION-007 Implemented; KEEP-MIGRATION-005 residue re-pointed to #20; README, v2 README, corpus README and ORIGIN, CHANGELOG, ROADMAP. Red: a wrong expected plan row (061) and a wrong production planner row (055 during) each fail their case with the exact plan mismatch. Green: all 173 cases pass in under ten seconds; every crash contract test passes. Closes #108 Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 17 +- README.md | 14 +- ROADMAP.md | 17 +- conformance/segment-store/v2/ORIGIN.md | 5 + conformance/segment-store/v2/README.md | 19 ++ conformance/segment-store/v2/transitions.tsv | 23 ++ docs/formats/segment-store-v2/README.md | 12 +- .../segment-store-v2/migration-crash.md | 24 +- .../segment-store-v2/migration-recovery.md | 5 +- docs/formats/segment-store-v2/recovery.md | 5 +- docs/formats/segment-store-v2/requirements.md | 4 +- src/adapters/store_migration.rs | 2 + .../filesystem_migration_fixed_artifact.rs | 19 ++ .../filesystem_migration_namespace.rs | 83 ++++- .../filesystem_migration_recovery.rs | 2 +- .../filesystem_migration_repository_tasks.rs | 99 ++++++ xtask/src/durability_crash_case.rs | 31 +- xtask/src/durability_crash_matrix.rs | 45 ++- xtask/src/durability_crash_matrix/child.rs | 13 +- xtask/src/durability_crash_matrix/error.rs | 19 +- .../durability_crash_matrix/error/display.rs | 43 ++- xtask/src/durability_crash_matrix/process.rs | 8 +- .../production_protocol.rs | 9 +- .../production_protocol/control.rs | 8 +- .../production_protocol/initialization.rs | 2 +- .../production_protocol/migration.rs | 39 +++ .../production_protocol/migration_storage.rs | 309 ++++++++++++++++++ xtask/src/durability_crash_matrix/restart.rs | 9 +- .../restart/expectation.rs | 5 + .../restart/migration.rs | 122 +++++++ .../restart/migration_expectation.rs | 226 +++++++++++++ xtask/src/durability_crash_point.rs | 170 +++++++++- xtask/src/durability_crash_point_identity.rs | 21 ++ xtask/tests/durability_crash_case_contract.rs | 53 +-- xtask/tests/durability_crash_documentation.rs | 15 + .../tests/durability_crash_point_contract.rs | 163 ++++++++- .../durability_crash_production_contract.rs | 2 + ...retention_store_v2_conformance_contract.rs | 1 + .../retention_store_v2_protocol_contract.rs | 2 + .../transition_laws.rs | 79 +++++ 40 files changed, 1652 insertions(+), 92 deletions(-) create mode 100644 conformance/segment-store/v2/transitions.tsv create mode 100644 src/adapters/store_migration/filesystem_migration_repository_tasks.rs create mode 100644 xtask/src/durability_crash_matrix/production_protocol/migration.rs create mode 100644 xtask/src/durability_crash_matrix/production_protocol/migration_storage.rs create mode 100644 xtask/src/durability_crash_matrix/restart/migration.rs create mode 100644 xtask/src/durability_crash_matrix/restart/migration_expectation.rs create mode 100644 xtask/tests/retention_store_v2_protocol_contract/transition_laws.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e5ee3cb..42887430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ after its public API and format compatibility policies are established. ### Added +- Migration process-death matrix. `cargo xtask durability-crash-matrix` + now runs `KEEP-CRASH-053` through `-073`: 68 cases that publish the Golden + File Worldline version-1 store in an isolated child, execute the production + 21-phase migration with one boundary gated, kill the child's process group + before, during, or after it (one `during` case per admitted + directory-prefix length for `KEEP-CRASH-060`), then compare the restarted + root against an independent expected-state model, require + `recover_store_migration` to report the recovery plan the table predicts, + run it (or the forward retry after an untouched version-1 store admits), and + require one complete migration with every version-1 byte intact and a + second recovery that reports `Complete`. `--sequence ` selects one + protocol sequence and `--case` accepts an occurrence. The ledger is + `conformance/segment-store/v2/transitions.tsv`; `KEEP-MIGRATION-007` moves + to Implemented (#108). - Reference-store staging memory contract. `ReferenceStore::STAGING_SCRATCH_LIMIT_BYTES` names the fixed scratch one `stage` call holds beyond the new unique chunk bytes it copies (the 8 KiB read buffer, one maximum-length chunk buffer, @@ -40,8 +54,7 @@ after its public API and format compatibility policies are established. stage. Every prefix of zero through twenty-one phases and a truncated stage recover in-process to one complete migration with every version-1 byte intact; a corrupt durable intent refuses before any mutation. - `KEEP-MIGRATION-001` and `-004` move to Implemented; the - `KEEP-CRASH-053..073` process-death matrix remains open in #108. + `KEEP-MIGRATION-001` and `-004` move to Implemented. - Explicit-depth verification. `VerificationDepth` is one ordered enumeration from `Framing` to `RetentionClosure`; `ReferenceStore::verify` and `verify_admitted_layout` establish exactly the requested depth and diff --git a/README.md b/README.md index fcc04f4e..f9be78c0 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,12 @@ Keep is required to refuse all three, before mutating anything. generation-versioned catalogs, and a fixed-width `HEAD` are published through an ordered protocol whose every step is a named crash point. Platform admission is Linux ext4, non-casefolded, one writer. -- **Proven restart recovery for version 1.** The crash matrix kills real - writer processes at 105 before/during/after coordinates - (`KEEP-CRASH-001`–`035`) and verifies the store lands in exactly one - documented lawful state each time. +- **Proven restart recovery for version 1 and for migration.** The crash + matrix kills real writer processes at 173 before/during/after coordinates + (`KEEP-CRASH-001`–`035` for version 1, `053`–`073` for the one-way + migration) and verifies the store lands in exactly one documented lawful + state each time; every interrupted migration recovers to one complete + migration with every version-1 byte intact. - **Version-2 retention and migration, forward path.** Explicit retention roots, deterministic closure verification, a one-way 21-phase migration, and a 17-phase retention publication — all with production filesystem @@ -72,8 +74,7 @@ Keep is required to refuse all three, before mutating anything. Version 2 writes correctly from a clean start. An interrupted migration recovers: `FilesystemStoreMigrationAuthority::reopen_for_recovery` and `recover_store_migration` resume any prefix of the twenty-one phases, proven -in-process for every prefix; the process-death matrix for those phases is -still open in [#108](https://github.com/flyingrobots/keep/issues/108). An +in-process for every prefix and by killing a real writer at every boundary. An interrupted retention publication is refused rather than guessed at, and readers have no fence, so **an interrupted version-2 publication waits for a human** until [PR #99](https://github.com/flyingrobots/keep/pull/99) merges. @@ -82,7 +83,6 @@ A version-1 store stays admitted until its owner migrates it. | Gap | Tracked | | --- | --- | | Restart recovery for retention publication | [PR #99](https://github.com/flyingrobots/keep/pull/99) | -| Process-death evidence for migration recovery | [#108](https://github.com/flyingrobots/keep/issues/108) | | Reader fence binding one consistent catalog + retention snapshot | [PR #99](https://github.com/flyingrobots/keep/pull/99) | | Durable authenticated reads bound to a fenced snapshot | [#109](https://github.com/flyingrobots/keep/issues/109) | | Verification reports at durable depths and a replayable receipt | [#20](https://github.com/flyingrobots/keep/issues/20) | diff --git a/ROADMAP.md b/ROADMAP.md index a5545f06..1e57933e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -95,7 +95,7 @@ names; use those in code, tests, and commits. - [x] [F-15 Retention roots, release, and GC liveness model](#f-15-retention-roots-release-and-gc-liveness-model) — Done (ADR-0009) - [x] [F-16 Version-2 format records and codecs](#f-16-version-2-format-records-and-codecs) — Done -- [ ] [F-17 One-way migration from version 1 to version 2](#f-17-one-way-migration-from-version-1-to-version-2) — Partial (#97 and in-process recovery done on this branch; the crash matrix is #108) +- [ ] [F-17 One-way migration from version 1 to version 2](#f-17-one-way-migration-from-version-1-to-version-2) — Partial (#97, in-process recovery, and the `KEEP-CRASH-053`–`073` matrix done on this branch; `KEEP-MIGRATION-005` and `-008` residue remains) - [ ] [F-18 Retention publication](#f-18-retention-publication) — Partial; recovery in review (PR #99) - [ ] [F-19 Reader fence and immutable version-2 snapshots](#f-19-reader-fence-and-immutable-version-2-snapshots) — In review (PR #99) - [ ] [F-20 Model-based retention transition evidence](#f-20-model-based-retention-transition-evidence) — In review (PR #99) @@ -694,9 +694,10 @@ decoder that refuses every structural fault before admission. ### F-17 One-way migration from version 1 to version 2 **Status:** Partial. The fresh forward path is Done (issue #19, PR #78). -Partial-prefix recovery, the `KEEP-CRASH-053` to `-073` matrix, and -`KEEP-MIGRATION-001`, `-004`, `-005`, `-006`, `-007`, `-008` residue remain, -gated by #97. +Partial-prefix recovery and the `KEEP-CRASH-053` to `-073` process-death +matrix are Done on this branch (`KEEP-MIGRATION-001`, `-004`, `-006`, `-007` +Implemented); the `KEEP-MIGRATION-005` corruption matrix (F-21) and `-008` +compatibility residue remain. A complete version-2 store is entered only by migrating a version-1 store: admit and recover v1, revalidate head, catalog, pools, root identity, and @@ -803,8 +804,12 @@ Direct version-2 initialization is undefined. There is no downgrade. - **Documentation:** `migration-recovery.md` Status, `recovery.md`, `requirements.md`, CHANGELOG. - **Dependencies:** needs T-17.1. Blocks T-17.3, F-43. -- [ ] T-17.3 Migration crash matrix `KEEP-CRASH-053` to `-073` - (`KEEP-MIGRATION-007`). +- [x] T-17.3 Migration crash matrix `KEEP-CRASH-053` to `-073` + (`KEEP-MIGRATION-007`) — `cargo xtask durability-crash-matrix --sequence + migration` runs 68 killed-writer cases against an independent + expected-state model and the predicted recovery plan; + `conformance/segment-store/v2/transitions.tsv` is the ledger. Original + task fields: - **Requirements:** real writer processes killed before, during, and after each of the 21 boundaries (`KEEP-CRASH-060` needs one case per admitted directory-prefix length); restart runs T-17.2 and the forward diff --git a/conformance/segment-store/v2/ORIGIN.md b/conformance/segment-store/v2/ORIGIN.md index 8c8b9291..dcbfe206 100644 --- a/conformance/segment-store/v2/ORIGIN.md +++ b/conformance/segment-store/v2/ORIGIN.md @@ -6,6 +6,11 @@ The corpus was constructed on 2026-07-29 with: - `cargo 1.96.0 (30a34c682 2026-05-25)`; and - `b3sum 1.8.5`. +`transitions.tsv` was added on 2026-09-30 by transcribing the 21 boundaries of +`StoreMigrationPhase::ALL` and the recovery table in +`docs/formats/segment-store-v2/migration-recovery.md`; the crash matrix in +`xtask` is its executable check. + ## Independent inputs The oracle imports exact bytes only from these previously accepted fixtures: diff --git a/conformance/segment-store/v2/README.md b/conformance/segment-store/v2/README.md index b668d30d..cc844fe8 100644 --- a/conformance/segment-store/v2/README.md +++ b/conformance/segment-store/v2/README.md @@ -14,6 +14,7 @@ migration, retention transition, or garbage collector exists. | `inventory.tsv` | Canonical one-segment, one-catalog migration inventory | | `migration-source.tsv` | Exact version-1 and derived migration coordinates | | `artifacts.tsv` | Golden artifact lengths, digests, checksums, and filenames | +| `transitions.tsv` | One stable crash identifier per migration boundary, `KEEP-CRASH-053` to `-073` | | `format-marker.hex` | Canonical 96-byte `FORMAT` record | | `migration-intent.hex` | Canonical 256-byte migration intent | | `migration-receipt.hex` | Canonical 256-byte migration receipt | @@ -58,6 +59,24 @@ The retention fixture uses namespace bytes `00 2f ff`, proving the namespace is opaque and not a path or Unicode string. Its one anchor combines the canonical one-zero `BlobId` and `LayoutId` values from the existing layout corpus. +## Transition protocol + +`transitions.tsv` mirrors the version-1 table: one row per migration +durability operation with its pre-state, interrupted-state classification, +post-state, and recovery posture. `KEEP-CRASH-053`, `-062`, and `-068` are +the only rows whose interruption may leave an incomplete pre-effect stage and +therefore the only rows that plan a discard; `-072` and `-073` admit the +complete migration. + +The `cargo xtask durability-crash-matrix --sequence migration` harness +executes 68 canonical process-death cases from this table: 21 boundaries at +three positions plus one `during` case per admitted directory-prefix length +for `KEEP-CRASH-060`. It kills an isolated writer process group, compares the +restarted root against an independent expected-state model, requires the +production planner to report the predicted recovery plan, and requires the +recovered store to be one complete migration with every version-1 byte +intact. Host power loss remains outside its claim. + The GC fixtures name the version-1 one-zero segment as their one candidate, with that segment's record checksum standing in for its verification-evidence digest; the generation-two catalog digest and head checksum stand in for the diff --git a/conformance/segment-store/v2/transitions.tsv b/conformance/segment-store/v2/transitions.tsv new file mode 100644 index 00000000..cce218d3 --- /dev/null +++ b/conformance/segment-store/v2/transitions.tsv @@ -0,0 +1,23 @@ +keep.segment-store.transitions/v2 +crash_id phase operation pre_state interrupted_class post_state recovery_posture +KEEP-CRASH-053 migration write-intent-stage admitted-version-one-store absent-or-incomplete-intent-stage complete-intent-stage discard-incomplete-stage-or-resume-stage-sync +KEEP-CRASH-054 migration sync-intent-stage complete-intent-stage complete-intent-stage durable-intent-stage resume-stage-sync +KEEP-CRASH-055 migration link-intent durable-intent-stage durable-intent-stage-or-linked-intent linked-intent verify-no-clobber-link-and-resume-root-sync +KEEP-CRASH-056 migration sync-root-after-intent linked-intent linked-intent durable-intent resume-root-sync +KEEP-CRASH-057 migration remove-intent-stage durable-intent durable-intent-with-or-without-stage durable-intent-alone resume-cleanup-sync +KEEP-CRASH-058 migration sync-root-after-intent-cleanup durable-intent-alone durable-intent-alone intent-cleanup-synchronized resume-cleanup-sync +KEEP-CRASH-059 migration admit-reader-fence intent-cleanup-synchronized intent-with-or-without-reader-fence reader-fence-admitted resume-namespace-prefix +KEEP-CRASH-060 migration admit-namespace-prefix reader-fence-admitted directory-prefix-length-zero-to-six namespace-prefix-admitted resume-namespace-prefix-or-root-sync +KEEP-CRASH-061 migration sync-root-after-namespace namespace-prefix-admitted namespace-prefix-admitted durable-namespace-prefix resume-root-sync +KEEP-CRASH-062 migration write-marker-stage durable-namespace-prefix absent-or-incomplete-marker-stage complete-marker-stage discard-incomplete-stage-or-resume-stage-sync +KEEP-CRASH-063 migration sync-marker-stage complete-marker-stage complete-marker-stage durable-marker-stage resume-stage-sync +KEEP-CRASH-064 migration link-marker durable-marker-stage durable-marker-stage-or-linked-marker linked-marker verify-no-clobber-link-and-resume-root-sync +KEEP-CRASH-065 migration sync-root-after-marker linked-marker linked-marker durable-marker resume-root-sync +KEEP-CRASH-066 migration remove-marker-stage durable-marker durable-marker-with-or-without-stage durable-marker-alone resume-cleanup-sync +KEEP-CRASH-067 migration sync-root-after-marker-cleanup durable-marker-alone durable-marker-alone marker-cleanup-synchronized resume-cleanup-sync +KEEP-CRASH-068 migration write-receipt-stage marker-cleanup-synchronized absent-or-incomplete-receipt-stage complete-receipt-stage discard-incomplete-stage-or-resume-stage-sync +KEEP-CRASH-069 migration sync-receipt-stage complete-receipt-stage complete-receipt-stage durable-receipt-stage resume-stage-sync +KEEP-CRASH-070 migration link-receipt durable-receipt-stage durable-receipt-stage-or-linked-receipt linked-receipt verify-no-clobber-link-and-resume-root-sync +KEEP-CRASH-071 migration sync-root-after-receipt linked-receipt linked-receipt durable-receipt resume-root-sync +KEEP-CRASH-072 migration remove-receipt-stage durable-receipt durable-receipt-with-or-without-stage complete-migration admit-complete-migration +KEEP-CRASH-073 migration sync-root-after-receipt-cleanup complete-migration complete-migration durable-complete-migration admit-complete-migration diff --git a/docs/formats/segment-store-v2/README.md b/docs/formats/segment-store-v2/README.md index 764aa255..864c356e 100644 --- a/docs/formats/segment-store-v2/README.md +++ b/docs/formats/segment-store-v2/README.md @@ -94,12 +94,14 @@ head and the catalog it selects, and refuses superseded candidates, retained stages, replaced protocol directories, and every namespace or capacity violation before mutation, each as a typed `RetentionCurrentStateRefusal`. +Migration recovery is proven in-process for every prefix and by the +`KEEP-CRASH-053..073` process-death matrix, which kills a real writer at each +of its 68 boundary coordinates and recovers the restarted root. + Not implemented: retention publication recovery and `KEEP-CRASH-036..052` -process-death evidence, the `KEEP-CRASH-053..073` process-death matrix for -migration recovery (in-process recovery of every prefix is implemented), the -reader fence, model-based transition evidence, and garbage collection. The -retention items are issue #19, the migration matrix is #108, and collection -is #21. +process-death evidence, the reader fence, model-based transition evidence, +and garbage collection. The retention items are issue #19; collection is +issue #21. Reopen compares only the restart-stable root coordinates, device and inode, against the intent; see [root identity across restart](recovery.md#root-identity-across-restart). A diff --git a/docs/formats/segment-store-v2/migration-crash.md b/docs/formats/segment-store-v2/migration-crash.md index 5ff02462..3833ba49 100644 --- a/docs/formats/segment-store-v2/migration-crash.md +++ b/docs/formats/segment-store-v2/migration-crash.md @@ -108,7 +108,23 @@ Fresh writer-locked filesystem execution implements that exact order and has deterministic in-process storage-fault and corruption laws. The restart classifier and resuming storage are implemented and proven in-process for every prefix of the 21 phases (see -[partial migration recovery](migration-recovery.md)). The before, during, and -after process-death matrix remains -([#108](https://github.com/flyingrobots/keep/issues/108)); until it runs, -this page claims in-process recovery, not process-death recovery. +[partial migration recovery](migration-recovery.md)). + +The before, during, and after process-death matrix runs as +`cargo xtask durability-crash-matrix --sequence migration`. For each of the +68 cases (21 boundaries at three positions, plus one `during` case per +admitted directory-prefix length for `KEEP-CRASH-060`) an isolated child +process publishes the Golden File Worldline version-1 store, executes the +production migration protocol with the selected boundary gated, and is killed +by its process group. The parent then compares the exact root inventory +against an independent expected-state model, reopens the root for recovery +the way a restarted writer would, requires the production planner to report +the plan the +[recovery table](migration-recovery.md#partial-migration-recovery) predicts, +runs that recovery (or the forward retry after an untouched version-1 store +admits), and requires one complete migration with every version-1 byte intact +and a second recovery that reports `Complete`. The +[transitions ledger](../../../conformance/segment-store/v2/transitions.tsv) +records each boundary's pre-state, interrupted class, post-state, and recovery +posture. The matrix proves application process death; host power loss +remains outside its claim. diff --git a/docs/formats/segment-store-v2/migration-recovery.md b/docs/formats/segment-store-v2/migration-recovery.md index 5d327a82..ffa3a867 100644 --- a/docs/formats/segment-store-v2/migration-recovery.md +++ b/docs/formats/segment-store-v2/migration-recovery.md @@ -94,5 +94,6 @@ Every forward prefix of zero through twenty-one phases, and each incomplete pre-effect stage, recovers in-process to exactly one complete migration with every version-1 byte intact; a corrupt durable intent refuses before any mutation. The before, during, and after process-death matrix for -`KEEP-CRASH-053..073` remains -([#108](https://github.com/flyingrobots/keep/issues/108)). +`KEEP-CRASH-053..073` kills real writer processes at every boundary and +verifies the same outcome from the restarted root; see +[migration crash points](migration-crash.md#process-death-matrix). diff --git a/docs/formats/segment-store-v2/recovery.md b/docs/formats/segment-store-v2/recovery.md index a08c9559..4294b28e 100644 --- a/docs/formats/segment-store-v2/recovery.md +++ b/docs/formats/segment-store-v2/recovery.md @@ -206,9 +206,8 @@ The exact offsets and fixtures are requirement `KEEP-MIGRATION-002`. The fresh writer emits only those canonical records; success is not restart evidence. A migrated store is admitted for forward publication, and an interrupted migration resumes from any prefix through -[partial migration recovery](migration-recovery.md), proven in-process; the -`KEEP-MIGRATION-007` process-death evidence remains -([#108](https://github.com/flyingrobots/keep/issues/108)). +[partial migration recovery](migration-recovery.md), proven both in-process +and by the `KEEP-MIGRATION-007` process-death matrix. ## Retention publication recovery diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index 85f5aad4..644da1e3 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -32,9 +32,9 @@ case is not evidence. | `KEEP-MIGRATION-002` | Format marker, intent, and receipt have complete fixed byte tables, named domains, bounds, checksums, deterministic store identity, and exact initial-state digests | exact admission in `tests/store_format_marker.rs`, `tests/store_migration_intent.rs`, and `tests/store_migration_receipt.rs`; canonical construction in `tests/store_migration_intent_encoding.rs` and `tests/store_migration_receipt_encoding.rs`; seeded `migration_format` fuzz target | Implemented | | `KEEP-MIGRATION-003` | Migration revalidates version-1 head, catalog, pools, root identity (all three coordinates within the migrating process; device and file across restart), and writer authority before mutation | bounded canonical pool inventory in `tests/store_migration_inventory.rs`; writer-locked filesystem pool admission in `filesystem_inventory_*_tests`; exact authority observation and drift refusal in `filesystem_migration_authority_tests`; verification-first execution in `tests/store_migration_execution.rs`; fresh filesystem integration and post-publication drift refusal in `filesystem_migration_storage_tests`; a version-one store still holding a retained stage refuses before the intent is observed in `filesystem_migration_storage_tests` | Implemented | | `KEEP-MIGRATION-004` | Every partial migration prefix continues idempotently under writer authority, comparing only the restart-stable root coordinates (device and file) against the persisted intent | storage-independent planner laws, one per recovery-table row and one per ambiguity rule, in `tests/store_migration_recovery.rs`; every prefix of zero through twenty-one phases and a truncated pre-effect stage recover in-process to one complete migration in `filesystem_migration_recovery_tests`; restart-stable reopen law in `filesystem_version_two_admission_tests`; the process-death matrix is `KEEP-MIGRATION-007` | Implemented | -| `KEEP-MIGRATION-005` | Unknown, out-of-order, substituted, corrupt, conflicting, or changed evidence is unrecoverable ambiguity | forward-execution stage preservation, byte-equal inode-substitution, out-of-order-prefix, and post-publication drift laws in `filesystem_migration_storage_tests`; unknown `retention` entries, non-digest namespace directories, and noncanonical pool names refuse before any retention stage is written in `filesystem_retention_namespace_tests`; every planner ambiguity rule in `tests/store_migration_recovery.rs` and a corrupt durable intent refusing recovery before any mutation in `filesystem_migration_recovery_tests`; restart corruption and mutation matrix remains | In progress in #108 | +| `KEEP-MIGRATION-005` | Unknown, out-of-order, substituted, corrupt, conflicting, or changed evidence is unrecoverable ambiguity | forward-execution stage preservation, byte-equal inode-substitution, out-of-order-prefix, and post-publication drift laws in `filesystem_migration_storage_tests`; unknown `retention` entries, non-digest namespace directories, and noncanonical pool names refuse before any retention stage is written in `filesystem_retention_namespace_tests`; every planner ambiguity rule in `tests/store_migration_recovery.rs` and a corrupt durable intent refusing recovery before any mutation in `filesystem_migration_recovery_tests`; restart corruption and mutation matrix remains | In progress in #20 | | `KEEP-MIGRATION-006` | Migration never rewrites or deletes admitted version-1 immutable bytes | exact segment, catalog, and head before/after witness in `filesystem_migration_storage_tests`; the same witness across recovery of every prefix in `filesystem_migration_recovery_tests` | Implemented | -| `KEEP-MIGRATION-007` | Process death around every intent stage, canonical link, namespace prefix, marker stage, receipt stage, cleanup, and synchronization boundary reaches a documented lawful state | ordered phases and capabilities in `tests/store_migration_phase.rs` and `tests/store_migration_storage.rs`; exact phase-failure execution in `tests/store_migration_execution.rs`; production 21-phase forward execution in `filesystem_migration_storage_tests`; in-process resumption from every prefix in `filesystem_migration_recovery_tests`; `KEEP-CRASH-053..=073` process-death matrix remains | In progress in #108 | +| `KEEP-MIGRATION-007` | Process death around every intent stage, canonical link, namespace prefix, marker stage, receipt stage, cleanup, and synchronization boundary reaches a documented lawful state | ordered phases and capabilities in `tests/store_migration_phase.rs` and `tests/store_migration_storage.rs`; exact phase-failure execution in `tests/store_migration_execution.rs`; production 21-phase forward execution in `filesystem_migration_storage_tests`; in-process resumption from every prefix in `filesystem_migration_recovery_tests`; `KEEP-CRASH-053..=073` process-death matrix of 68 killed-writer cases with an independent expected-state model, predicted recovery plan, and complete-migration witness in `cargo xtask durability-crash-matrix --sequence migration`, with the ledger in `conformance/segment-store/v2/transitions.tsv` | Implemented | | `KEEP-MIGRATION-008` | Version-1 admission refuses every version-2 or partial-migration artifact after migration begins | `FORMAT` refusal before mutation in `filesystem_migration_authority_tests`; exact version-1 reopen refusal of a migrated root and separate version-2 namespace admission in `filesystem_initialization_namespace`; version-2 reopen returns a distinct `FilesystemVersionTwoAdmission` that no version-1 publisher can consume (pinned by `tests/version_two_admission_contract.rs`), admits every version-2 protocol directory under the Linux profile, and jointly admits the exact marker, intent, and receipt before returning writer authority, with aliased-directory, corrupt, oversized, and mutually inconsistent record refusals in `filesystem_version_two_admission_tests` and `filesystem_platform_profile_tests`; remaining compatibility and fuzz matrix | In progress in #19 | diff --git a/src/adapters/store_migration.rs b/src/adapters/store_migration.rs index 3fc2eaeb..01a489d6 100644 --- a/src/adapters/store_migration.rs +++ b/src/adapters/store_migration.rs @@ -47,6 +47,8 @@ mod filesystem_migration_reader_fence; mod filesystem_migration_recovery; #[cfg(test)] mod filesystem_migration_recovery_tests; +#[cfg(feature = "repository-tasks")] +mod filesystem_migration_repository_tasks; mod filesystem_migration_residue; mod filesystem_migration_storage; #[cfg(test)] diff --git a/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs b/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs index 6a26f15d..bafc714d 100644 --- a/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs +++ b/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs @@ -69,6 +69,25 @@ impl FilesystemMigrationFixedStage { }) } + /// Creates the stage exclusively and writes only `expected[..end]`, + /// leaving an unsynchronized incomplete pre-effect stage behind. The + /// handle is dropped: repository crash tasks kill the process next. + pub(super) fn create_prefix( + root: &Dir, + artifact: FilesystemMigrationFixedArtifact, + expected: &[u8], + end: usize, + ) -> io::Result<()> { + require_length(artifact, expected)?; + let prefix = expected + .get(..end) + .filter(|prefix| prefix.len() < expected.len()) + .ok_or_else(|| invalid_data("migration stage prefix is not strict"))?; + let mut file = filesystem_catalog_artifact::create_exclusive(root, artifact.stage_name())?; + file.write_all(prefix)?; + file.flush() + } + pub(super) fn synchronize(&self, root: &Dir) -> io::Result<()> { self.require_handle()?; self.file.sync_all()?; diff --git a/src/adapters/store_migration/filesystem_migration_namespace.rs b/src/adapters/store_migration/filesystem_migration_namespace.rs index 9b294eb8..ca50f828 100644 --- a/src/adapters/store_migration/filesystem_migration_namespace.rs +++ b/src/adapters/store_migration/filesystem_migration_namespace.rs @@ -94,14 +94,15 @@ pub(super) fn admit_reader_fence(root: &Dir) -> io::Result<()> { verify_reader_root(root) } +/// The number of directories the namespace prefix admits, in order. +pub(super) const PREFIX_DIRECTORY_COUNT: usize = 6; + pub(super) fn admit_namespace_prefix(root: &Dir) -> io::Result<()> { preflight_prefix(root)?; - let retention = PinnedMigrationDirectory::admit(root, RETENTION)?; - let roots = PinnedMigrationDirectory::admit(retention.directory(), ROOTS)?; - let manifests = PinnedMigrationDirectory::admit(retention.directory(), MANIFESTS)?; - let gc = PinnedMigrationDirectory::admit(root, GC)?; - let recovery = PinnedMigrationDirectory::admit(root, RECOVERY)?; - let dispositions = PinnedMigrationDirectory::admit(recovery.directory(), DISPOSITIONS)?; + let prefix = admit_directories(root, PREFIX_DIRECTORY_COUNT)?; + let (retention, roots, manifests, gc, recovery, dispositions) = prefix + .complete() + .ok_or_else(|| ambiguous("namespace prefix admission stopped short"))?; roots.verify(retention.directory())?; manifests.verify(retention.directory())?; dispositions.verify(recovery.directory())?; @@ -111,6 +112,76 @@ pub(super) fn admit_namespace_prefix(root: &Dir) -> io::Result<()> { verify_namespace_prefix(root) } +/// Admits only the first `count` prefix directories, in protocol order, and +/// stops without the final verification. Repository crash tasks use this to +/// leave a store at an exact directory-prefix length. +pub(super) fn admit_namespace_prefix_partially(root: &Dir, count: usize) -> io::Result<()> { + if count > PREFIX_DIRECTORY_COUNT { + return Err(ambiguous("namespace prefix count exceeds the protocol")); + } + preflight_prefix(root)?; + admit_directories(root, count).map(|_prefix| ()) +} + +/// The prefix directories admitted so far, each pinned by its handle. +#[derive(Default)] +struct AdmittedPrefix { + retention: Option, + roots: Option, + manifests: Option, + gc: Option, + recovery: Option, + dispositions: Option, +} + +type CompletePrefix<'a> = ( + &'a PinnedMigrationDirectory, + &'a PinnedMigrationDirectory, + &'a PinnedMigrationDirectory, + &'a PinnedMigrationDirectory, + &'a PinnedMigrationDirectory, + &'a PinnedMigrationDirectory, +); + +impl AdmittedPrefix { + fn complete(&self) -> Option> { + Some(( + self.retention.as_ref()?, + self.roots.as_ref()?, + self.manifests.as_ref()?, + self.gc.as_ref()?, + self.recovery.as_ref()?, + self.dispositions.as_ref()?, + )) + } +} + +/// Admits the first `count` prefix directories in the normative order: +/// `retention`, `retention/roots`, `retention/manifests`, `gc`, `recovery`, +/// `recovery/dispositions`. Each admission synchronizes its parent. +fn admit_directories(root: &Dir, count: usize) -> io::Result { + let mut prefix = AdmittedPrefix::default(); + for ordinal in 0..count { + match ordinal { + 0 => prefix.retention = Some(PinnedMigrationDirectory::admit(root, RETENTION)?), + 1 => prefix.roots = Some(admit_child(prefix.retention.as_ref(), ROOTS)?), + 2 => prefix.manifests = Some(admit_child(prefix.retention.as_ref(), MANIFESTS)?), + 3 => prefix.gc = Some(PinnedMigrationDirectory::admit(root, GC)?), + 4 => prefix.recovery = Some(PinnedMigrationDirectory::admit(root, RECOVERY)?), + _ => prefix.dispositions = Some(admit_child(prefix.recovery.as_ref(), DISPOSITIONS)?), + } + } + Ok(prefix) +} + +fn admit_child( + parent: Option<&PinnedMigrationDirectory>, + name: &'static str, +) -> io::Result { + let parent = parent.ok_or_else(|| ambiguous("namespace prefix parent was not admitted"))?; + PinnedMigrationDirectory::admit(parent.directory(), name) +} + pub(super) fn verify_intent_root(root: &Dir) -> io::Result<()> { require_v1_and_intent(root)?; require_exact_membership(root, &BEFORE_READER) diff --git a/src/adapters/store_migration/filesystem_migration_recovery.rs b/src/adapters/store_migration/filesystem_migration_recovery.rs index 1fd475a8..801b606d 100644 --- a/src/adapters/store_migration/filesystem_migration_recovery.rs +++ b/src/adapters/store_migration/filesystem_migration_recovery.rs @@ -56,7 +56,7 @@ impl FilesystemStoreMigrationAuthority { Self::recover_root(root, policy) } - fn recover_root(root: Dir, policy: SegmentReadPolicy) -> Result { + pub(super) fn recover_root(root: Dir, policy: SegmentReadPolicy) -> Result { let lock = FilesystemWriterLock::try_acquire_in(root) .map_err(|source| Error::WriterLock { source })?; let directory = lock diff --git a/src/adapters/store_migration/filesystem_migration_repository_tasks.rs b/src/adapters/store_migration/filesystem_migration_repository_tasks.rs new file mode 100644 index 00000000..3d91ff05 --- /dev/null +++ b/src/adapters/store_migration/filesystem_migration_repository_tasks.rs @@ -0,0 +1,99 @@ +//! This module owns the migration authority's repository crash-task hooks. +//! +//! Repository process-death tasks drive the production migration protocol +//! from a store they built without platform admission, stop it at an exact +//! byte or directory prefix, and reopen it for recovery. Nothing here is a +//! separate protocol: each hook runs one production step short, or opens the +//! same authority without the Linux platform check. + +use std::io; +use std::path::Path; + +use cap_std::fs::Dir; + +use super::filesystem_migration_authority::MigrationNamespacePolicy; +use super::filesystem_migration_authority_error::FilesystemMigrationAuthorityError as Error; +use super::filesystem_migration_fixed_artifact::{ + FilesystemMigrationFixedArtifact as FixedArtifact, FilesystemMigrationFixedStage, +}; +use super::{ + FilesystemStoreMigrationAuthority, FilesystemStoreMigrationInventoryReader, + StoreMigrationFixedStage, filesystem_migration_namespace, +}; +use crate::adapters::{FilesystemPlatformAdmission, FilesystemWriterLock, SegmentReadPolicy}; + +impl FilesystemStoreMigrationAuthority { + /// Opens fresh migration authority over a store built without platform + /// admission, for repository process-death tasks. + /// + /// # Errors + /// + /// Returns the same admission and pool failures as [`Self::open`]. + #[doc(hidden)] + pub fn open_unchecked_for_repository_tasks( + lock: FilesystemWriterLock, + policy: SegmentReadPolicy, + ) -> Result { + let admission = FilesystemPlatformAdmission::unchecked_for_repository_tasks(lock) + .map_err(|source| Error::Platform { source })?; + let inventory = FilesystemStoreMigrationInventoryReader::open(admission, policy) + .map_err(|source| Error::Inventory { source })?; + Ok(Self::with_policy( + inventory, + MigrationNamespacePolicy::Published, + )) + } + + /// Reacquires recovery authority without platform admission, for + /// repository process-death tasks; otherwise exactly + /// [`Self::reopen_for_recovery`]. + /// + /// # Errors + /// + /// Returns the same writer-lock, namespace, and pool failures as + /// [`Self::reopen_for_recovery`]. + #[doc(hidden)] + pub fn reopen_for_recovery_unchecked_for_repository_tasks( + store_root: &Path, + policy: SegmentReadPolicy, + ) -> Result { + let root = Dir::open_ambient_dir(store_root, cap_std::ambient_authority()) + .map_err(|source| Error::Platform { source })?; + Self::recover_root(root, policy) + } + + /// Creates `stage` and writes a strict prefix of `record`, leaving an + /// incomplete pre-effect stage exactly as process death during the write + /// would. + /// + /// # Errors + /// + /// Returns the exact length, prefix-bound, creation, or write failure. + #[doc(hidden)] + pub fn write_fixed_stage_prefix_for_repository_tasks( + &mut self, + stage: StoreMigrationFixedStage, + record: &[u8], + prefix: usize, + ) -> io::Result<()> { + let artifact = match stage { + StoreMigrationFixedStage::Intent => FixedArtifact::Intent, + StoreMigrationFixedStage::Marker => FixedArtifact::Marker, + StoreMigrationFixedStage::Receipt => FixedArtifact::Receipt, + }; + FilesystemMigrationFixedStage::create_prefix(self.root(), artifact, record, prefix) + } + + /// Admits only the first `count` namespace-prefix directories in protocol + /// order, each with its parent synchronized, and stops before the final + /// prefix verification. + /// + /// # Errors + /// + /// Returns the exact preflight or admission failure, or refuses a count + /// beyond the six-directory prefix. + #[doc(hidden)] + pub fn admit_namespace_prefix_for_repository_tasks(&mut self, count: usize) -> io::Result<()> { + filesystem_migration_namespace::admit_namespace_prefix_partially(self.root(), count) + } +} diff --git a/xtask/src/durability_crash_case.rs b/xtask/src/durability_crash_case.rs index 3b267d47..29e26529 100644 --- a/xtask/src/durability_crash_case.rs +++ b/xtask/src/durability_crash_case.rs @@ -2,7 +2,7 @@ use crate::{ DurabilityCrashCaseError, DurabilityCrashOccurrence, DurabilityCrashPoint, - DurabilityCrashPosition, + DurabilityCrashPosition, DurabilityCrashSequence, }; /// One validated process-death coordinate in the durability crash matrix. @@ -41,11 +41,38 @@ impl DurabilityCrashCase { } /// Returns every canonical case in point-major, position-minor order. + /// + /// A boundary with more than one `during` occurrence contributes one + /// `during` case per occurrence, in occurrence order, between its + /// `before` and `after` cases. pub fn all() -> impl Iterator { DurabilityCrashPoint::ALL.into_iter().flat_map(|point| { DurabilityCrashPosition::ALL .into_iter() - .map(move |position| Self::canonical(point, position)) + .flat_map(move |position| Self::canonical_at(point, position)) + }) + } + + /// Returns every canonical case whose boundary belongs to `sequence`. + pub fn in_sequence(sequence: DurabilityCrashSequence) -> impl Iterator { + Self::all().filter(move |case| case.point().sequence() == sequence) + } + + fn canonical_at( + point: DurabilityCrashPoint, + position: DurabilityCrashPosition, + ) -> impl Iterator { + let occurrences = if position == DurabilityCrashPosition::During { + point.during_occurrences() + } else { + 1 + }; + (0..occurrences).map(move |ordinal| { + let mut case = Self::canonical(point, position); + if point.occurrence_counted() { + case.occurrence = Some(DurabilityCrashOccurrence::new(ordinal)); + } + case }) } diff --git a/xtask/src/durability_crash_matrix.rs b/xtask/src/durability_crash_matrix.rs index aca8546e..c762b0e5 100644 --- a/xtask/src/durability_crash_matrix.rs +++ b/xtask/src/durability_crash_matrix.rs @@ -12,9 +12,11 @@ use std::path::Path; pub(crate) use error::DurabilityCrashMatrixError; use xtask::{ DurabilityCrashCase, DurabilityCrashOccurrence, DurabilityCrashPoint, DurabilityCrashPosition, + DurabilityCrashSequence, }; const CASE_ARGUMENT: &str = "--case"; +const SEQUENCE_ARGUMENT: &str = "--sequence"; pub(crate) fn run( repository_root: &Path, @@ -26,6 +28,14 @@ pub(crate) fn run( } return Ok(()); }; + if flag == OsStr::new(SEQUENCE_ARGUMENT) { + let sequence = parse_sequence(&mut arguments)?; + refuse_extra(&mut arguments)?; + for case in DurabilityCrashCase::in_sequence(sequence) { + run_case(repository_root, case)?; + } + return Ok(()); + } if flag != OsStr::new(CASE_ARGUMENT) { return Err(DurabilityCrashMatrixError::Usage); } @@ -44,6 +54,8 @@ pub(crate) fn run_child( child::run(case, Path::new(&case_root), Path::new(&readiness_socket)) } +/// Parses `POINT POSITION [OCCURRENCE]`; the occurrence is read only for an +/// occurrence-counted boundary and defaults to the first occurrence. fn parse_case( arguments: &mut impl Iterator, ) -> Result { @@ -59,13 +71,40 @@ fn parse_case( .ok_or(DurabilityCrashMatrixError::InvalidPositionEncoding)?; let position = DurabilityCrashPosition::from_identifier(position_text) .ok_or_else(|| DurabilityCrashMatrixError::UnknownPosition(position_text.into()))?; - let occurrence = point - .occurrence_counted() - .then_some(DurabilityCrashOccurrence::FIRST); + let occurrence = if point.occurrence_counted() { + Some(parse_occurrence(arguments)?) + } else { + None + }; DurabilityCrashCase::new(point, position, occurrence) .map_err(DurabilityCrashMatrixError::InvalidCase) } +fn parse_occurrence( + arguments: &mut impl Iterator, +) -> Result { + let Some(argument) = arguments.next() else { + return Ok(DurabilityCrashOccurrence::FIRST); + }; + let text = argument + .to_str() + .ok_or(DurabilityCrashMatrixError::InvalidOccurrenceEncoding)?; + text.parse() + .map(DurabilityCrashOccurrence::new) + .map_err(|_| DurabilityCrashMatrixError::UnknownOccurrence(text.into())) +} + +fn parse_sequence( + arguments: &mut impl Iterator, +) -> Result { + let argument = arguments.next().ok_or(DurabilityCrashMatrixError::Usage)?; + let text = argument + .to_str() + .ok_or(DurabilityCrashMatrixError::InvalidSequenceEncoding)?; + DurabilityCrashSequence::from_identifier(text) + .ok_or_else(|| DurabilityCrashMatrixError::UnknownSequence(text.into())) +} + fn refuse_extra( arguments: &mut impl Iterator, ) -> Result<(), DurabilityCrashMatrixError> { diff --git a/xtask/src/durability_crash_matrix/child.rs b/xtask/src/durability_crash_matrix/child.rs index 4c664853..fa474fa9 100644 --- a/xtask/src/durability_crash_matrix/child.rs +++ b/xtask/src/durability_crash_matrix/child.rs @@ -37,10 +37,15 @@ fn write_marker( } pub(super) fn marker(case: DurabilityCrashCase) -> Vec { - format!( - "{}\t{}\n", + let mut marker = format!( + "{}\t{}", case.point().identifier(), case.position().identifier() - ) - .into_bytes() + ); + if let Some(occurrence) = case.occurrence() { + marker.push('\t'); + marker.push_str(&occurrence.get().to_string()); + } + marker.push('\n'); + marker.into_bytes() } diff --git a/xtask/src/durability_crash_matrix/error.rs b/xtask/src/durability_crash_matrix/error.rs index d0a7ec46..4c76aaac 100644 --- a/xtask/src/durability_crash_matrix/error.rs +++ b/xtask/src/durability_crash_matrix/error.rs @@ -7,15 +7,18 @@ use std::error::Error; use std::io; use std::time::Duration; +use keep::StoreMigrationRecoveryPlan; use xtask::protocol_admission::HexError; use xtask::{ - DurabilityCrashCase, DurabilityCrashCaseError, DurabilityCrashPoint, DurabilityCrashPosition, + DurabilityCrashCase, DurabilityCrashCaseError, DurabilityCrashOccurrence, DurabilityCrashPoint, + DurabilityCrashPosition, }; pub(crate) enum DurabilityCrashMatrixError { Case { point: DurabilityCrashPoint, position: DurabilityCrashPosition, + occurrence: Option, source: Box, }, ArtifactBytesMismatch { @@ -50,8 +53,10 @@ pub(crate) enum DurabilityCrashMatrixError { artifact: &'static str, }, InvalidCase(DurabilityCrashCaseError), + InvalidOccurrenceEncoding, InvalidPointEncoding, InvalidPositionEncoding, + InvalidSequenceEncoding, InvalidReadinessSignal { observed: u8, }, @@ -78,6 +83,10 @@ pub(crate) enum DurabilityCrashMatrixError { PointSequenceMismatch { point: DurabilityCrashPoint, }, + RecoveryPlanMismatch { + expected: StoreMigrationRecoveryPlan, + observed: StoreMigrationRecoveryPlan, + }, RepeatedInventoryPath { path: String, }, @@ -93,8 +102,10 @@ pub(crate) enum DurabilityCrashMatrixError { expected: &'static str, observed: &'static str, }, + UnknownOccurrence(String), UnknownPoint(String), UnknownPosition(String), + UnknownSequence(String), Usage, Verification { phase: &'static str, @@ -127,6 +138,7 @@ impl DurabilityCrashMatrixError { Self::Case { point: case.point(), position: case.position(), + occurrence: case.occurrence(), source: Box::new(self), } } @@ -147,20 +159,25 @@ impl Error for DurabilityCrashMatrixError { | Self::FixtureLength { .. } | Self::FixtureRange | Self::FixtureTerminator { .. } + | Self::InvalidOccurrenceEncoding | Self::InvalidPointEncoding | Self::InvalidPositionEncoding + | Self::InvalidSequenceEncoding | Self::InvalidReadinessSignal { .. } | Self::InventoryMismatch { .. } | Self::HardLinkIdentityMismatch { .. } | Self::MissingVisibleRecord { .. } | Self::NonUnicodeStatePath | Self::PointSequenceMismatch { .. } + | Self::RecoveryPlanMismatch { .. } | Self::RepeatedInventoryPath { .. } | Self::SnapshotGenerationMismatch { .. } | Self::Timeout { .. } | Self::UnexpectedArtifactKind { .. } + | Self::UnknownOccurrence(_) | Self::UnknownPoint(_) | Self::UnknownPosition(_) + | Self::UnknownSequence(_) | Self::Usage => None, } } diff --git a/xtask/src/durability_crash_matrix/error/display.rs b/xtask/src/durability_crash_matrix/error/display.rs index 79f24e82..f269fa17 100644 --- a/xtask/src/durability_crash_matrix/error/display.rs +++ b/xtask/src/durability_crash_matrix/error/display.rs @@ -18,6 +18,7 @@ impl fmt::Display for DurabilityCrashMatrixError { | Self::HardLinkIdentityMismatch { .. } | Self::InventoryMismatch { .. } | Self::MissingVisibleRecord { .. } + | Self::RecoveryPlanMismatch { .. } | Self::RepeatedInventoryPath { .. } | Self::SnapshotGenerationMismatch { .. } | Self::UnexpectedArtifactKind { .. } => format_state(self, formatter), @@ -31,10 +32,14 @@ impl fmt::Display for DurabilityCrashMatrixError { | Self::FixtureTerminator { .. } => format_fixture(self, formatter), Self::Case { .. } | Self::InvalidCase(_) + | Self::InvalidOccurrenceEncoding | Self::InvalidPointEncoding | Self::InvalidPositionEncoding + | Self::InvalidSequenceEncoding + | Self::UnknownOccurrence(_) | Self::UnknownPoint(_) | Self::UnknownPosition(_) + | Self::UnknownSequence(_) | Self::Usage => format_command(self, formatter), Self::Io { .. } | Self::NonUnicodeStatePath @@ -82,6 +87,10 @@ fn format_state( "post-crash snapshot lacks visible record `{record}`" ) } + DurabilityCrashMatrixError::RecoveryPlanMismatch { expected, observed } => write!( + formatter, + "post-crash migration recovery planned {observed:?}, expected {expected:?}" + ), DurabilityCrashMatrixError::RepeatedInventoryPath { path } => { write!(formatter, "post-crash inventory repeated path `{path}`") } @@ -182,31 +191,51 @@ fn format_command( DurabilityCrashMatrixError::Case { point, position, + occurrence, source, - } => write!( - formatter, - "{} {}: {source}", - point.identifier(), - position.identifier() - ), + } => { + write!( + formatter, + "{} {}", + point.identifier(), + position.identifier() + )?; + if let Some(occurrence) = occurrence { + write!(formatter, " occurrence {}", occurrence.get())?; + } + write!(formatter, ": {source}") + } DurabilityCrashMatrixError::InvalidCase(error) => { write!(formatter, "invalid crash case: {error}") } + DurabilityCrashMatrixError::InvalidOccurrenceEncoding => { + formatter.write_str("crash occurrence is not valid Unicode") + } DurabilityCrashMatrixError::InvalidPointEncoding => { formatter.write_str("crash point is not valid Unicode") } DurabilityCrashMatrixError::InvalidPositionEncoding => { formatter.write_str("crash position is not valid Unicode") } + DurabilityCrashMatrixError::InvalidSequenceEncoding => { + formatter.write_str("crash sequence is not valid Unicode") + } + DurabilityCrashMatrixError::UnknownOccurrence(occurrence) => { + write!(formatter, "unknown crash occurrence `{occurrence}`") + } DurabilityCrashMatrixError::UnknownPoint(point) => { write!(formatter, "unknown crash point `{point}`") } DurabilityCrashMatrixError::UnknownPosition(position) => { write!(formatter, "unknown crash position `{position}`") } + DurabilityCrashMatrixError::UnknownSequence(sequence) => { + write!(formatter, "unknown crash sequence `{sequence}`") + } DurabilityCrashMatrixError::Usage => formatter.write_str( "usage: cargo xtask durability-crash-matrix \ - --case ", + [--case [] \ + | --sequence ]", ), _ => Err(fmt::Error), } diff --git a/xtask/src/durability_crash_matrix/process.rs b/xtask/src/durability_crash_matrix/process.rs index 8375ad6f..c3b4540c 100644 --- a/xtask/src/durability_crash_matrix/process.rs +++ b/xtask/src/durability_crash_matrix/process.rs @@ -67,9 +67,11 @@ fn spawn( .current_dir(repository_root) .arg("__durability-crash-child") .arg(case.point().identifier()) - .arg(case.position().identifier()) - .arg(case_root) - .arg(socket_path); + .arg(case.position().identifier()); + if let Some(occurrence) = case.occurrence() { + command.arg(occurrence.get().to_string()); + } + command.arg(case_root).arg(socket_path); let mut child = crate::bounded_process::spawn_in_process_group(&mut command) .map_err(|source| DurabilityCrashMatrixError::io("spawn crash child", source))?; match ProcessGroup::for_child(&child) { diff --git a/xtask/src/durability_crash_matrix/production_protocol.rs b/xtask/src/durability_crash_matrix/production_protocol.rs index 6453a94c..45fe2600 100644 --- a/xtask/src/durability_crash_matrix/production_protocol.rs +++ b/xtask/src/durability_crash_matrix/production_protocol.rs @@ -2,8 +2,10 @@ mod control; pub(super) mod fixture; -mod initialization; +pub(super) mod initialization; mod initialization_storage; +mod migration; +mod migration_storage; mod publication; mod publication_storage; mod recovery; @@ -41,6 +43,9 @@ pub(super) fn run( DurabilityCrashSequence::RecoveryDiscard => { recovery::run(&store_root, &mut control)?; } + DurabilityCrashSequence::Migration => { + migration::run(&store_root, &mut control)?; + } } Err(DurabilityCrashMatrixError::PointSequenceMismatch { point: case.point(), @@ -54,7 +59,7 @@ fn create_store_root(case_root: &Path) -> Result DurabilityCrashMatrixError { diff --git a/xtask/src/durability_crash_matrix/production_protocol/control.rs b/xtask/src/durability_crash_matrix/production_protocol/control.rs index 9d96a91e..72fdeb5b 100644 --- a/xtask/src/durability_crash_matrix/production_protocol/control.rs +++ b/xtask/src/durability_crash_matrix/production_protocol/control.rs @@ -3,7 +3,9 @@ use std::io::{self, Read, Write}; use std::os::unix::net::UnixStream; -use xtask::{DurabilityCrashCase, DurabilityCrashPoint, DurabilityCrashPosition}; +use xtask::{ + DurabilityCrashCase, DurabilityCrashOccurrence, DurabilityCrashPoint, DurabilityCrashPosition, +}; const READY: u8 = b'r'; @@ -55,6 +57,10 @@ impl CrashControl { (self.case.point() == point).then(|| self.case.position()) } + pub(super) const fn occurrence(&self) -> Option { + self.case.occurrence() + } + pub(super) fn await_process_death(&mut self) -> io::Result<()> { self.readiness.write_all(&[READY])?; let mut unexpected = [0_u8; 1]; diff --git a/xtask/src/durability_crash_matrix/production_protocol/initialization.rs b/xtask/src/durability_crash_matrix/production_protocol/initialization.rs index 6bd54667..d75d1e17 100644 --- a/xtask/src/durability_crash_matrix/production_protocol/initialization.rs +++ b/xtask/src/durability_crash_matrix/production_protocol/initialization.rs @@ -45,6 +45,6 @@ pub(super) fn restart_policy() -> Result SegmentReadPolicy { +pub(in crate::durability_crash_matrix) const fn segment_policy() -> SegmentReadPolicy { SegmentReadPolicy::new(SegmentRecordLimit::MAXIMUM, LayoutEntryLimit::MAXIMUM) } diff --git a/xtask/src/durability_crash_matrix/production_protocol/migration.rs b/xtask/src/durability_crash_matrix/production_protocol/migration.rs new file mode 100644 index 00000000..6b932783 --- /dev/null +++ b/xtask/src/durability_crash_matrix/production_protocol/migration.rs @@ -0,0 +1,39 @@ +//! This module owns execution of the production store-migration protocol. + +use std::path::Path; + +use keep::{FilesystemStoreMigrationAuthority, FilesystemWriterLock, execute_store_migration}; + +use super::control::CrashControl; +use super::initialization; +use super::migration_storage::CrashMigrationStorage; +use super::publication; +use super::{DurabilityCrashMatrixError, verification}; + +/// Publishes the Golden File Worldline version-1 store, then migrates it +/// through the production 21-phase protocol with the selected boundary gated +/// for process death. +/// +/// No version-1 gate fires for a migration case, so the publication +/// precondition runs to completion and releases its writer lock before the +/// migration authority reacquires it. +pub(super) fn run( + store_root: &Path, + control: &mut CrashControl, +) -> Result<(), DurabilityCrashMatrixError> { + publication::run(store_root, control)?; + let lock = FilesystemWriterLock::try_acquire(store_root) + .map_err(|source| verification("reacquire writer lock for migration", source))?; + let authority = FilesystemStoreMigrationAuthority::open_unchecked_for_repository_tasks( + lock, + initialization::segment_policy(), + ) + .map_err(|source| verification("open production migration authority", source))?; + let intent = authority + .observe_intent() + .map_err(|source| verification("observe production migration intent", source))?; + let mut storage = CrashMigrationStorage::new(authority, control); + execute_store_migration(&mut storage, &intent) + .map(|_receipt| ()) + .map_err(|source| verification("execute production store migration", source)) +} diff --git a/xtask/src/durability_crash_matrix/production_protocol/migration_storage.rs b/xtask/src/durability_crash_matrix/production_protocol/migration_storage.rs new file mode 100644 index 00000000..da5be82c --- /dev/null +++ b/xtask/src/durability_crash_matrix/production_protocol/migration_storage.rs @@ -0,0 +1,309 @@ +//! This module owns crash injection around production store migration. + +use std::io; + +use keep::{ + CanonicalStoreFormatMarker, CanonicalStoreMigrationIntent, CanonicalStoreMigrationReceipt, + FilesystemStoreMigrationAuthority, StoreMigrationFixedStage, StoreMigrationStorage, +}; +use xtask::{DurabilityCrashPoint, DurabilityCrashPosition}; + +use super::control::{CrashControl, DuringTiming}; + +const INTENT_INTERRUPTION: usize = 128; +const MARKER_INTERRUPTION: usize = 48; +const RECEIPT_INTERRUPTION: usize = 128; + +pub(super) struct CrashMigrationStorage<'control> { + inner: FilesystemStoreMigrationAuthority, + control: &'control mut CrashControl, +} + +impl<'control> CrashMigrationStorage<'control> { + pub(super) const fn new( + inner: FilesystemStoreMigrationAuthority, + control: &'control mut CrashControl, + ) -> Self { + Self { inner, control } + } +} + +impl StoreMigrationStorage for CrashMigrationStorage<'_> { + fn verify_current(&mut self, intent: &CanonicalStoreMigrationIntent) -> io::Result<()> { + StoreMigrationStorage::verify_current(&mut self.inner, intent) + } + + fn write_intent_stage(&mut self, intent: &CanonicalStoreMigrationIntent) -> io::Result<()> { + execute_write( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationWriteIntentStage, + |inner| inner.write_intent_stage(intent), + |inner| { + inner.write_fixed_stage_prefix_for_repository_tasks( + StoreMigrationFixedStage::Intent, + intent.encoded(), + INTENT_INTERRUPTION, + ) + }, + ) + } + + fn synchronize_intent_stage(&mut self) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationSynchronizeIntentStage, + DuringTiming::Before, + FilesystemStoreMigrationAuthority::synchronize_intent_stage, + ) + } + + fn link_intent(&mut self, intent: &CanonicalStoreMigrationIntent) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationLinkIntent, + DuringTiming::After, + |inner| inner.link_intent(intent), + ) + } + + fn synchronize_root_after_intent(&mut self) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationSynchronizeRootAfterIntent, + DuringTiming::Before, + FilesystemStoreMigrationAuthority::synchronize_root_after_intent, + ) + } + + fn remove_intent_stage(&mut self) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationRemoveIntentStage, + DuringTiming::After, + FilesystemStoreMigrationAuthority::remove_intent_stage, + ) + } + + fn synchronize_root_after_intent_cleanup(&mut self) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationSynchronizeRootAfterIntentCleanup, + DuringTiming::Before, + FilesystemStoreMigrationAuthority::synchronize_root_after_intent_cleanup, + ) + } + + fn admit_reader_fence(&mut self) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationAdmitReaderFence, + DuringTiming::After, + FilesystemStoreMigrationAuthority::admit_reader_fence, + ) + } + + fn admit_namespace_prefix(&mut self) -> io::Result<()> { + let point = DurabilityCrashPoint::MigrationAdmitNamespacePrefix; + match self.control.position(point) { + None => self.inner.admit_namespace_prefix(), + Some(DurabilityCrashPosition::Before) => self.control.await_process_death(), + Some(DurabilityCrashPosition::During) => { + let reached = self + .control + .occurrence() + .map_or(1, |occurrence| occurrence.get().saturating_add(1)); + let reached = usize::try_from(reached).map_err(io::Error::other)?; + self.inner + .admit_namespace_prefix_for_repository_tasks(reached)?; + self.control.await_process_death() + } + Some(DurabilityCrashPosition::After) => { + self.inner.admit_namespace_prefix()?; + self.control.await_process_death() + } + } + } + + fn synchronize_root_after_namespace(&mut self) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationSynchronizeRootAfterNamespace, + DuringTiming::Before, + FilesystemStoreMigrationAuthority::synchronize_root_after_namespace, + ) + } + + fn write_marker_stage(&mut self, marker: &CanonicalStoreFormatMarker) -> io::Result<()> { + execute_write( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationWriteMarkerStage, + |inner| inner.write_marker_stage(marker), + |inner| { + inner.write_fixed_stage_prefix_for_repository_tasks( + StoreMigrationFixedStage::Marker, + marker.encoded(), + MARKER_INTERRUPTION, + ) + }, + ) + } + + fn synchronize_marker_stage(&mut self) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationSynchronizeMarkerStage, + DuringTiming::Before, + FilesystemStoreMigrationAuthority::synchronize_marker_stage, + ) + } + + fn link_marker(&mut self, marker: &CanonicalStoreFormatMarker) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationLinkMarker, + DuringTiming::After, + |inner| inner.link_marker(marker), + ) + } + + fn synchronize_root_after_marker(&mut self) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationSynchronizeRootAfterMarker, + DuringTiming::Before, + FilesystemStoreMigrationAuthority::synchronize_root_after_marker, + ) + } + + fn remove_marker_stage(&mut self) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationRemoveMarkerStage, + DuringTiming::After, + FilesystemStoreMigrationAuthority::remove_marker_stage, + ) + } + + fn synchronize_root_after_marker_cleanup(&mut self) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationSynchronizeRootAfterMarkerCleanup, + DuringTiming::Before, + FilesystemStoreMigrationAuthority::synchronize_root_after_marker_cleanup, + ) + } + + fn write_receipt_stage(&mut self, receipt: &CanonicalStoreMigrationReceipt) -> io::Result<()> { + execute_write( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationWriteReceiptStage, + |inner| inner.write_receipt_stage(receipt), + |inner| { + inner.write_fixed_stage_prefix_for_repository_tasks( + StoreMigrationFixedStage::Receipt, + receipt.encoded(), + RECEIPT_INTERRUPTION, + ) + }, + ) + } + + fn synchronize_receipt_stage(&mut self) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationSynchronizeReceiptStage, + DuringTiming::Before, + FilesystemStoreMigrationAuthority::synchronize_receipt_stage, + ) + } + + fn link_receipt(&mut self, receipt: &CanonicalStoreMigrationReceipt) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationLinkReceipt, + DuringTiming::After, + |inner| inner.link_receipt(receipt), + ) + } + + fn synchronize_root_after_receipt(&mut self) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationSynchronizeRootAfterReceipt, + DuringTiming::Before, + FilesystemStoreMigrationAuthority::synchronize_root_after_receipt, + ) + } + + fn remove_receipt_stage(&mut self) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationRemoveReceiptStage, + DuringTiming::After, + FilesystemStoreMigrationAuthority::remove_receipt_stage, + ) + } + + fn synchronize_root_after_receipt_cleanup(&mut self) -> io::Result<()> { + execute( + &mut self.inner, + self.control, + DurabilityCrashPoint::MigrationSynchronizeRootAfterReceiptCleanup, + DuringTiming::Before, + FilesystemStoreMigrationAuthority::synchronize_root_after_receipt_cleanup, + ) + } +} + +fn execute( + inner: &mut FilesystemStoreMigrationAuthority, + control: &mut CrashControl, + point: DurabilityCrashPoint, + during: DuringTiming, + operation: impl FnOnce(&mut FilesystemStoreMigrationAuthority) -> io::Result, +) -> io::Result { + control.before(point, during)?; + let result = operation(inner)?; + control.after(point, during)?; + Ok(result) +} + +fn execute_write( + inner: &mut FilesystemStoreMigrationAuthority, + control: &mut CrashControl, + point: DurabilityCrashPoint, + complete: impl FnOnce(&mut FilesystemStoreMigrationAuthority) -> io::Result<()>, + interrupted: impl FnOnce(&mut FilesystemStoreMigrationAuthority) -> io::Result<()>, +) -> io::Result<()> { + match control.position(point) { + None => complete(inner), + Some(DurabilityCrashPosition::Before) => control.await_process_death(), + Some(DurabilityCrashPosition::During) => { + interrupted(inner)?; + control.await_process_death() + } + Some(DurabilityCrashPosition::After) => { + complete(inner)?; + control.await_process_death() + } + } +} diff --git a/xtask/src/durability_crash_matrix/restart.rs b/xtask/src/durability_crash_matrix/restart.rs index 423efc81..8ee7c51a 100644 --- a/xtask/src/durability_crash_matrix/restart.rs +++ b/xtask/src/durability_crash_matrix/restart.rs @@ -1,6 +1,8 @@ //! This module owns independent post-process-death store verification. mod expectation; +mod migration; +mod migration_expectation; mod semantic; use std::collections::BTreeSet; @@ -11,12 +13,15 @@ use std::path::{Path, PathBuf}; use super::DurabilityCrashMatrixError; use super::production_protocol::fixture::GoldenFixture; use expectation::ExpectedStoreState; -use xtask::DurabilityCrashCase; +use xtask::{DurabilityCrashCase, DurabilityCrashSequence}; pub(super) fn verify( store_root: &Path, case: DurabilityCrashCase, ) -> Result<(), DurabilityCrashMatrixError> { + if case.point().sequence() == DurabilityCrashSequence::Migration { + return migration::verify(store_root, case); + } let expected = ExpectedStoreState::for_case(case)?; let observed_paths = inventory(store_root)?; if observed_paths != expected.paths() { @@ -47,7 +52,7 @@ pub(super) fn verify( Ok(()) } -fn inventory(store_root: &Path) -> Result, DurabilityCrashMatrixError> { +pub(super) fn inventory(store_root: &Path) -> Result, DurabilityCrashMatrixError> { let mut paths = BTreeSet::new(); let mut pending = vec![PathBuf::new()]; while let Some(relative_parent) = pending.pop() { diff --git a/xtask/src/durability_crash_matrix/restart/expectation.rs b/xtask/src/durability_crash_matrix/restart/expectation.rs index 1b6be39c..c4a779a0 100644 --- a/xtask/src/durability_crash_matrix/restart/expectation.rs +++ b/xtask/src/durability_crash_matrix/restart/expectation.rs @@ -64,6 +64,11 @@ impl ExpectedStoreState { DurabilityCrashSequence::Head => sequence::head(case), DurabilityCrashSequence::RecoveryDiscard => sequence::recovery(case), DurabilityCrashSequence::Initialization => sequence::initialization(case), + DurabilityCrashSequence::Migration => { + Err(DurabilityCrashMatrixError::PointSequenceMismatch { + point: case.point(), + }) + } } } diff --git a/xtask/src/durability_crash_matrix/restart/migration.rs b/xtask/src/durability_crash_matrix/restart/migration.rs new file mode 100644 index 00000000..ce219195 --- /dev/null +++ b/xtask/src/durability_crash_matrix/restart/migration.rs @@ -0,0 +1,122 @@ +//! This module owns independent post-process-death migration verification: +//! the exact residue, the predicted recovery plan, the production recovery, +//! and the complete migration it must leave behind. + +use std::fs; +use std::path::Path; + +use keep::{ + FilesystemStoreMigrationAuthority, FilesystemWriterLock, StoreMigrationRecoveryPlan as Plan, + execute_store_migration, recover_store_migration, +}; +use xtask::DurabilityCrashCase; + +use super::migration_expectation::{MigrationExpectation, complete_paths}; +use crate::durability_crash_matrix::DurabilityCrashMatrixError; +use crate::durability_crash_matrix::production_protocol::fixture::{ + CATALOG_POOL_PATH, GoldenFixture, SEGMENT_POOL_PATH, +}; +use crate::durability_crash_matrix::production_protocol::initialization::segment_policy; +use crate::durability_crash_matrix::production_protocol::verification; + +pub(super) fn verify( + store_root: &Path, + case: DurabilityCrashCase, +) -> Result<(), DurabilityCrashMatrixError> { + let expected = MigrationExpectation::for_case(case)?; + require_inventory(store_root, expected.paths())?; + verify_version_one_bytes(store_root)?; + + let plan = recover(store_root)?; + if plan != expected.plan() { + return Err(DurabilityCrashMatrixError::RecoveryPlanMismatch { + expected: expected.plan(), + observed: plan, + }); + } + if plan == Plan::VersionOne { + migrate_forward(store_root)?; + } + + require_inventory(store_root, &complete_paths())?; + verify_version_one_bytes(store_root)?; + let settled = recover(store_root)?; + if settled == Plan::Complete { + Ok(()) + } else { + Err(DurabilityCrashMatrixError::RecoveryPlanMismatch { + expected: Plan::Complete, + observed: settled, + }) + } +} + +fn require_inventory( + store_root: &Path, + expected: &std::collections::BTreeSet, +) -> Result<(), DurabilityCrashMatrixError> { + let observed = super::inventory(store_root)?; + if &observed == expected { + Ok(()) + } else { + Err(DurabilityCrashMatrixError::InventoryMismatch { + expected: expected.clone(), + observed, + }) + } +} + +/// Reacquires writer authority the way a restarted process would and runs +/// the production recovery once, reporting the plan the residue admitted. +fn recover(store_root: &Path) -> Result { + let mut authority = + FilesystemStoreMigrationAuthority::reopen_for_recovery_unchecked_for_repository_tasks( + store_root, + segment_policy(), + ) + .map_err(|source| verification("reopen migration for recovery", source))?; + let expected = authority + .observe_intent() + .map_err(|source| verification("observe recovery migration intent", source))?; + let receipt = recover_store_migration(&mut authority, &expected) + .map_err(|source| verification("recover production migration", source))?; + Ok(receipt.plan()) +} + +/// The forward retry after an untouched version-1 store admits. +fn migrate_forward(store_root: &Path) -> Result<(), DurabilityCrashMatrixError> { + let lock = FilesystemWriterLock::try_acquire(store_root) + .map_err(|source| verification("reacquire writer lock for forward retry", source))?; + let mut authority = FilesystemStoreMigrationAuthority::open_unchecked_for_repository_tasks( + lock, + segment_policy(), + ) + .map_err(|source| verification("open forward-retry migration authority", source))?; + let intent = authority + .observe_intent() + .map_err(|source| verification("observe forward-retry migration intent", source))?; + execute_store_migration(&mut authority, &intent) + .map(|_receipt| ()) + .map_err(|source| verification("execute forward-retry migration", source)) +} + +/// Migration never rewrites a version-1 byte: the pools and `HEAD` remain +/// the Golden File Worldline fixtures before and after recovery. +fn verify_version_one_bytes(store_root: &Path) -> Result<(), DurabilityCrashMatrixError> { + for (relative, fixture) in [ + (SEGMENT_POOL_PATH, GoldenFixture::segment()?), + (CATALOG_POOL_PATH, GoldenFixture::catalog()?), + ("HEAD", GoldenFixture::head()?), + ] { + let observed = fs::read(store_root.join(relative)) + .map_err(|source| DurabilityCrashMatrixError::io("read version-1 artifact", source))?; + if observed != fixture.bytes() { + return Err(DurabilityCrashMatrixError::artifact_bytes( + relative, + fixture.bytes(), + &observed, + )); + } + } + Ok(()) +} diff --git a/xtask/src/durability_crash_matrix/restart/migration_expectation.rs b/xtask/src/durability_crash_matrix/restart/migration_expectation.rs new file mode 100644 index 00000000..e60cf70b --- /dev/null +++ b/xtask/src/durability_crash_matrix/restart/migration_expectation.rs @@ -0,0 +1,226 @@ +//! This module owns the independent expected state after migration process +//! death: the exact root inventory and the one lawful recovery plan. +//! +//! The rules here restate the recovery table in +//! `docs/formats/segment-store-v2/migration-recovery.md` without reading any +//! production classifier, so a planner defect cannot hide behind itself. + +use std::collections::BTreeSet; + +use keep::{StoreMigrationFixedStage, StoreMigrationPhase, StoreMigrationRecoveryPlan as Plan}; +use xtask::{DurabilityCrashCase, DurabilityCrashPoint, DurabilityCrashPosition}; + +use crate::durability_crash_matrix::DurabilityCrashMatrixError; +use crate::durability_crash_matrix::production_protocol::fixture::{ + CATALOG_POOL_PATH, SEGMENT_POOL_PATH, +}; + +pub(super) const INTENT_STAGE: &str = "migration.intent.next"; +pub(super) const INTENT: &str = "migration.intent"; +pub(super) const READER_LOCK: &str = "reader.lock"; +pub(super) const MARKER_STAGE: &str = "FORMAT.next"; +pub(super) const MARKER: &str = "FORMAT"; +pub(super) const RECEIPT_STAGE: &str = "migration.receipt.next"; +pub(super) const RECEIPT: &str = "migration.receipt"; + +/// The namespace prefix in admission order; each entry is one `during` +/// occurrence of `KEEP-CRASH-060`. +const PREFIX_DIRECTORIES: [&str; 6] = [ + "retention", + "retention/roots", + "retention/manifests", + "gc", + "recovery", + "recovery/dispositions", +]; + +pub(super) struct MigrationExpectation { + paths: BTreeSet, + plan: Plan, +} + +impl MigrationExpectation { + pub(super) fn for_case(case: DurabilityCrashCase) -> Result { + let step = migration_step(case.point())?; + let partial = partial_stage(case); + let done = if case.position() == DurabilityCrashPosition::After + || (case.position() == DurabilityCrashPosition::During && atomic(case.point())) + { + step.saturating_add(1) + } else { + step + }; + let prefix_reached = prefix_reached(case); + let mut paths = version_one_paths(); + if (1..5).contains(&done) { + paths.insert(INTENT_STAGE.into()); + } + if done >= 3 { + paths.insert(INTENT.into()); + } + if done >= 7 { + paths.insert(READER_LOCK.into()); + } + let directories = if done >= 8 { + PREFIX_DIRECTORIES.len() + } else { + prefix_reached.unwrap_or(0) + }; + paths.extend( + PREFIX_DIRECTORIES + .iter() + .take(directories) + .map(|path| (*path).into()), + ); + if (10..14).contains(&done) { + paths.insert(MARKER_STAGE.into()); + } + if done >= 12 { + paths.insert(MARKER.into()); + } + if (16..20).contains(&done) { + paths.insert(RECEIPT_STAGE.into()); + } + if done >= 18 { + paths.insert(RECEIPT.into()); + } + if let Some(stage) = partial { + paths.insert(stage_name(stage).into()); + } + let plan = partial.map_or_else( + || plan_after(done, prefix_reached), + |stage| Plan::DiscardStage { + stage, + resume: write_phase(stage), + }, + ); + Ok(Self { paths, plan }) + } + + pub(super) const fn paths(&self) -> &BTreeSet { + &self.paths + } + + pub(super) const fn plan(&self) -> Plan { + self.plan + } +} + +/// The exact version-1 store the migration starts from. +pub(super) fn version_one_paths() -> BTreeSet { + [ + "writer.lock", + "staging", + "segments", + "catalogs", + "HEAD", + SEGMENT_POOL_PATH, + CATALOG_POOL_PATH, + ] + .into_iter() + .map(Into::into) + .collect() +} + +/// The exact root after one complete migration: no stage remains. +pub(super) fn complete_paths() -> BTreeSet { + let mut paths = version_one_paths(); + paths.extend( + [INTENT, READER_LOCK, MARKER, RECEIPT] + .into_iter() + .chain(PREFIX_DIRECTORIES) + .map(Into::into), + ); + paths +} + +fn migration_step(point: DurabilityCrashPoint) -> Result { + DurabilityCrashPoint::MIGRATION + .into_iter() + .position(|candidate| candidate == point) + .ok_or(DurabilityCrashMatrixError::PointSequenceMismatch { point }) +} + +/// Boundaries whose effect is one link, unlink, or exclusive creation, so +/// `during` cannot leave a partial effect. +const fn atomic(point: DurabilityCrashPoint) -> bool { + matches!( + point, + DurabilityCrashPoint::MigrationLinkIntent + | DurabilityCrashPoint::MigrationRemoveIntentStage + | DurabilityCrashPoint::MigrationAdmitReaderFence + | DurabilityCrashPoint::MigrationLinkMarker + | DurabilityCrashPoint::MigrationRemoveMarkerStage + | DurabilityCrashPoint::MigrationLinkReceipt + | DurabilityCrashPoint::MigrationRemoveReceiptStage + ) +} + +/// Process death during a stage write leaves an incomplete pre-effect stage. +fn partial_stage(case: DurabilityCrashCase) -> Option { + if case.position() != DurabilityCrashPosition::During { + return None; + } + match case.point() { + DurabilityCrashPoint::MigrationWriteIntentStage => Some(StoreMigrationFixedStage::Intent), + DurabilityCrashPoint::MigrationWriteMarkerStage => Some(StoreMigrationFixedStage::Marker), + DurabilityCrashPoint::MigrationWriteReceiptStage => Some(StoreMigrationFixedStage::Receipt), + _ => None, + } +} + +/// Process death during namespace admission leaves the first `occurrence + 1` +/// prefix directories, each with its parent synchronized. +fn prefix_reached(case: DurabilityCrashCase) -> Option { + if case.point() != DurabilityCrashPoint::MigrationAdmitNamespacePrefix + || case.position() != DurabilityCrashPosition::During + { + return None; + } + let ordinal = case + .occurrence() + .map_or(0, xtask::DurabilityCrashOccurrence::get); + usize::try_from(ordinal.saturating_add(1)).ok() +} + +const fn stage_name(stage: StoreMigrationFixedStage) -> &'static str { + match stage { + StoreMigrationFixedStage::Intent => INTENT_STAGE, + StoreMigrationFixedStage::Marker => MARKER_STAGE, + StoreMigrationFixedStage::Receipt => RECEIPT_STAGE, + } +} + +const fn write_phase(stage: StoreMigrationFixedStage) -> StoreMigrationPhase { + match stage { + StoreMigrationFixedStage::Intent => StoreMigrationPhase::WriteIntentStage, + StoreMigrationFixedStage::Marker => StoreMigrationPhase::WriteMarkerStage, + StoreMigrationFixedStage::Receipt => StoreMigrationPhase::WriteReceiptStage, + } +} + +/// The recovery-table row for a residue in which the first `done` phases +/// completed and nothing else happened: resume at the earliest phase the +/// residue cannot prove. +const fn plan_after(done: usize, prefix_reached: Option) -> Plan { + let resume = match done { + 0 => return Plan::VersionOne, + 1 | 2 => StoreMigrationPhase::SynchronizeIntentStage, + 3 | 4 => StoreMigrationPhase::SynchronizeRootAfterIntent, + 5 | 6 => StoreMigrationPhase::SynchronizeRootAfterIntentCleanup, + 7 => match prefix_reached { + Some(reached) if reached == PREFIX_DIRECTORIES.len() => { + StoreMigrationPhase::SynchronizeRootAfterNamespace + } + _ => StoreMigrationPhase::AdmitNamespacePrefix, + }, + 8 | 9 => StoreMigrationPhase::SynchronizeRootAfterNamespace, + 10 | 11 => StoreMigrationPhase::SynchronizeMarkerStage, + 12 | 13 => StoreMigrationPhase::SynchronizeRootAfterMarker, + 14 | 15 => StoreMigrationPhase::SynchronizeRootAfterMarkerCleanup, + 16 | 17 => StoreMigrationPhase::SynchronizeReceiptStage, + 18 | 19 => StoreMigrationPhase::SynchronizeRootAfterReceipt, + _ => return Plan::Complete, + }; + Plan::Resume { resume } +} diff --git a/xtask/src/durability_crash_point.rs b/xtask/src/durability_crash_point.rs index 2ed8d6a5..555ed7ec 100644 --- a/xtask/src/durability_crash_point.rs +++ b/xtask/src/durability_crash_point.rs @@ -13,6 +13,41 @@ pub enum DurabilityCrashSequence { RecoveryDiscard, /// Writer-locked store initialization. Initialization, + /// One-way version-1 to version-2 store migration. + Migration, +} + +impl DurabilityCrashSequence { + /// Every sequence in stable protocol order. + pub const ALL: [Self; 6] = [ + Self::Segment, + Self::Catalog, + Self::Head, + Self::RecoveryDiscard, + Self::Initialization, + Self::Migration, + ]; + + /// Returns the stable identifier used by the crash-matrix command line. + #[must_use] + pub const fn identifier(self) -> &'static str { + match self { + Self::Segment => "segment", + Self::Catalog => "catalog", + Self::Head => "head", + Self::RecoveryDiscard => "recovery-discard", + Self::Initialization => "initialization", + Self::Migration => "migration", + } + } + + /// Parses one exact sequence identifier. + #[must_use] + pub fn from_identifier(identifier: &str) -> Option { + Self::ALL + .into_iter() + .find(|sequence| sequence.identifier() == identifier) + } } /// One stable process-death boundary in the durable segment-store protocol. @@ -88,11 +123,54 @@ pub enum DurabilityCrashPoint { CreateCatalogPoolDirectory, /// Synchronize the store root after initialization. SynchronizeRootAfterInitialization, + /// Write the complete canonical `migration.intent.next`. + MigrationWriteIntentStage, + /// Synchronize `migration.intent.next`. + MigrationSynchronizeIntentStage, + /// Link the synchronized intent stage to `migration.intent`. + MigrationLinkIntent, + /// Synchronize the store root after the intent link. + MigrationSynchronizeRootAfterIntent, + /// Remove the retained `migration.intent.next`. + MigrationRemoveIntentStage, + /// Synchronize the store root after intent-stage cleanup. + MigrationSynchronizeRootAfterIntentCleanup, + /// Create or exactly admit the persistent reader fence. + MigrationAdmitReaderFence, + /// Create or exactly admit the canonical version-2 directory prefix; the + /// occurrence names the directory-prefix length reached. + MigrationAdmitNamespacePrefix, + /// Synchronize the store root after namespace admission. + MigrationSynchronizeRootAfterNamespace, + /// Write the complete canonical `FORMAT.next`. + MigrationWriteMarkerStage, + /// Synchronize `FORMAT.next`. + MigrationSynchronizeMarkerStage, + /// Link the synchronized marker stage to `FORMAT`. + MigrationLinkMarker, + /// Synchronize the store root after the marker link. + MigrationSynchronizeRootAfterMarker, + /// Remove the retained `FORMAT.next`. + MigrationRemoveMarkerStage, + /// Synchronize the store root after marker-stage cleanup. + MigrationSynchronizeRootAfterMarkerCleanup, + /// Write the complete canonical `migration.receipt.next`. + MigrationWriteReceiptStage, + /// Synchronize `migration.receipt.next`. + MigrationSynchronizeReceiptStage, + /// Link the synchronized receipt stage to `migration.receipt`. + MigrationLinkReceipt, + /// Synchronize the store root after the receipt link. + MigrationSynchronizeRootAfterReceipt, + /// Remove the retained `migration.receipt.next`. + MigrationRemoveReceiptStage, + /// Synchronize the store root after receipt-stage cleanup. + MigrationSynchronizeRootAfterReceiptCleanup, } impl DurabilityCrashPoint { /// Every crash boundary in stable protocol order. - pub const ALL: [Self; 35] = [ + pub const ALL: [Self; 56] = [ Self::CreateSegmentStage, Self::WriteSegmentHeader, Self::AppendSegmentRecord, @@ -128,8 +206,59 @@ impl DurabilityCrashPoint { Self::CreateSegmentPoolDirectory, Self::CreateCatalogPoolDirectory, Self::SynchronizeRootAfterInitialization, + Self::MigrationWriteIntentStage, + Self::MigrationSynchronizeIntentStage, + Self::MigrationLinkIntent, + Self::MigrationSynchronizeRootAfterIntent, + Self::MigrationRemoveIntentStage, + Self::MigrationSynchronizeRootAfterIntentCleanup, + Self::MigrationAdmitReaderFence, + Self::MigrationAdmitNamespacePrefix, + Self::MigrationSynchronizeRootAfterNamespace, + Self::MigrationWriteMarkerStage, + Self::MigrationSynchronizeMarkerStage, + Self::MigrationLinkMarker, + Self::MigrationSynchronizeRootAfterMarker, + Self::MigrationRemoveMarkerStage, + Self::MigrationSynchronizeRootAfterMarkerCleanup, + Self::MigrationWriteReceiptStage, + Self::MigrationSynchronizeReceiptStage, + Self::MigrationLinkReceipt, + Self::MigrationSynchronizeRootAfterReceipt, + Self::MigrationRemoveReceiptStage, + Self::MigrationSynchronizeRootAfterReceiptCleanup, ]; + /// The migration boundaries in `StoreMigrationPhase::ALL` order. + pub const MIGRATION: [Self; 21] = [ + Self::MigrationWriteIntentStage, + Self::MigrationSynchronizeIntentStage, + Self::MigrationLinkIntent, + Self::MigrationSynchronizeRootAfterIntent, + Self::MigrationRemoveIntentStage, + Self::MigrationSynchronizeRootAfterIntentCleanup, + Self::MigrationAdmitReaderFence, + Self::MigrationAdmitNamespacePrefix, + Self::MigrationSynchronizeRootAfterNamespace, + Self::MigrationWriteMarkerStage, + Self::MigrationSynchronizeMarkerStage, + Self::MigrationLinkMarker, + Self::MigrationSynchronizeRootAfterMarker, + Self::MigrationRemoveMarkerStage, + Self::MigrationSynchronizeRootAfterMarkerCleanup, + Self::MigrationWriteReceiptStage, + Self::MigrationSynchronizeReceiptStage, + Self::MigrationLinkReceipt, + Self::MigrationSynchronizeRootAfterReceipt, + Self::MigrationRemoveReceiptStage, + Self::MigrationSynchronizeRootAfterReceiptCleanup, + ]; + + /// The number of directories the migration namespace prefix admits; each + /// is one `during` occurrence of + /// [`Self::MigrationAdmitNamespacePrefix`]. + pub const NAMESPACE_PREFIX_DIRECTORIES: u32 = 6; + /// Parses one exact stable crash identifier. #[must_use] pub fn from_identifier(identifier: &str) -> Option { @@ -177,12 +306,49 @@ impl DurabilityCrashPoint { | Self::CreateSegmentPoolDirectory | Self::CreateCatalogPoolDirectory | Self::SynchronizeRootAfterInitialization => DurabilityCrashSequence::Initialization, + Self::MigrationWriteIntentStage + | Self::MigrationSynchronizeIntentStage + | Self::MigrationLinkIntent + | Self::MigrationSynchronizeRootAfterIntent + | Self::MigrationRemoveIntentStage + | Self::MigrationSynchronizeRootAfterIntentCleanup + | Self::MigrationAdmitReaderFence + | Self::MigrationAdmitNamespacePrefix + | Self::MigrationSynchronizeRootAfterNamespace + | Self::MigrationWriteMarkerStage + | Self::MigrationSynchronizeMarkerStage + | Self::MigrationLinkMarker + | Self::MigrationSynchronizeRootAfterMarker + | Self::MigrationRemoveMarkerStage + | Self::MigrationSynchronizeRootAfterMarkerCleanup + | Self::MigrationWriteReceiptStage + | Self::MigrationSynchronizeReceiptStage + | Self::MigrationLinkReceipt + | Self::MigrationSynchronizeRootAfterReceipt + | Self::MigrationRemoveReceiptStage + | Self::MigrationSynchronizeRootAfterReceiptCleanup => { + DurabilityCrashSequence::Migration + } } } /// Reports whether tests may select a repeated occurrence. #[must_use] pub const fn occurrence_counted(self) -> bool { - matches!(self, Self::AppendSegmentRecord) + matches!( + self, + Self::AppendSegmentRecord | Self::MigrationAdmitNamespacePrefix + ) + } + + /// Returns how many distinct `during` occurrences the canonical matrix + /// runs for this boundary: one directory-prefix length per occurrence + /// for the migration namespace prefix, otherwise exactly one. + #[must_use] + pub const fn during_occurrences(self) -> u32 { + match self { + Self::MigrationAdmitNamespacePrefix => Self::NAMESPACE_PREFIX_DIRECTORIES, + _ => 1, + } } } diff --git a/xtask/src/durability_crash_point_identity.rs b/xtask/src/durability_crash_point_identity.rs index 173afa74..7959b43b 100644 --- a/xtask/src/durability_crash_point_identity.rs +++ b/xtask/src/durability_crash_point_identity.rs @@ -42,6 +42,27 @@ impl DurabilityCrashPoint { Self::CreateSegmentPoolDirectory => "KEEP-CRASH-033", Self::CreateCatalogPoolDirectory => "KEEP-CRASH-034", Self::SynchronizeRootAfterInitialization => "KEEP-CRASH-035", + Self::MigrationWriteIntentStage => "KEEP-CRASH-053", + Self::MigrationSynchronizeIntentStage => "KEEP-CRASH-054", + Self::MigrationLinkIntent => "KEEP-CRASH-055", + Self::MigrationSynchronizeRootAfterIntent => "KEEP-CRASH-056", + Self::MigrationRemoveIntentStage => "KEEP-CRASH-057", + Self::MigrationSynchronizeRootAfterIntentCleanup => "KEEP-CRASH-058", + Self::MigrationAdmitReaderFence => "KEEP-CRASH-059", + Self::MigrationAdmitNamespacePrefix => "KEEP-CRASH-060", + Self::MigrationSynchronizeRootAfterNamespace => "KEEP-CRASH-061", + Self::MigrationWriteMarkerStage => "KEEP-CRASH-062", + Self::MigrationSynchronizeMarkerStage => "KEEP-CRASH-063", + Self::MigrationLinkMarker => "KEEP-CRASH-064", + Self::MigrationSynchronizeRootAfterMarker => "KEEP-CRASH-065", + Self::MigrationRemoveMarkerStage => "KEEP-CRASH-066", + Self::MigrationSynchronizeRootAfterMarkerCleanup => "KEEP-CRASH-067", + Self::MigrationWriteReceiptStage => "KEEP-CRASH-068", + Self::MigrationSynchronizeReceiptStage => "KEEP-CRASH-069", + Self::MigrationLinkReceipt => "KEEP-CRASH-070", + Self::MigrationSynchronizeRootAfterReceipt => "KEEP-CRASH-071", + Self::MigrationRemoveReceiptStage => "KEEP-CRASH-072", + Self::MigrationSynchronizeRootAfterReceiptCleanup => "KEEP-CRASH-073", } } } diff --git a/xtask/tests/durability_crash_case_contract.rs b/xtask/tests/durability_crash_case_contract.rs index 87b671e4..1136e2dc 100644 --- a/xtask/tests/durability_crash_case_contract.rs +++ b/xtask/tests/durability_crash_case_contract.rs @@ -6,40 +6,47 @@ use std::error::Error; use xtask::{ DurabilityCrashCase, DurabilityCrashCaseError, DurabilityCrashOccurrence, DurabilityCrashPoint, - DurabilityCrashPosition, + DurabilityCrashPosition, DurabilityCrashSequence, }; #[test] -fn every_crash_point_has_exactly_three_ordered_process_death_cases() -> Result<(), Box> { +fn every_crash_point_has_three_ordered_positions_and_one_during_case_per_occurrence() +-> Result<(), Box> { let cases: Vec<_> = DurabilityCrashCase::all().collect(); - let expected = DurabilityCrashPoint::ALL - .len() - .checked_mul(DurabilityCrashPosition::ALL.len()) - .ok_or("crash-matrix case count overflow")?; - - assert_eq!(cases.len(), expected); - for (point_index, point) in DurabilityCrashPoint::ALL.into_iter().enumerate() { - for (position_index, position) in DurabilityCrashPosition::ALL.into_iter().enumerate() { - let index = point_index - .checked_mul(DurabilityCrashPosition::ALL.len()) - .and_then(|base| base.checked_add(position_index)) - .ok_or("crash-matrix index overflow")?; - let case = cases.get(index).ok_or("missing canonical crash case")?; - assert_eq!(case.point(), point); - assert_eq!(case.position(), position); - assert_eq!( - case.occurrence(), - point + let mut expected = Vec::new(); + for point in DurabilityCrashPoint::ALL { + for position in DurabilityCrashPosition::ALL { + let occurrences = if position == DurabilityCrashPosition::During { + point.during_occurrences() + } else { + 1 + }; + for ordinal in 0..occurrences { + let occurrence = point .occurrence_counted() - .then_some(DurabilityCrashOccurrence::FIRST) - ); + .then_some(DurabilityCrashOccurrence::new(ordinal)); + expected.push(DurabilityCrashCase::new(point, position, occurrence)?); + } } } + + assert_eq!(cases, expected); + // 56 boundaries at three positions, plus five extra namespace-prefix + // lengths for `KEEP-CRASH-060`. + assert_eq!(cases.len(), 173); + let migration: Vec<_> = + DurabilityCrashCase::in_sequence(DurabilityCrashSequence::Migration).collect(); + assert_eq!(migration.len(), 68); + assert!( + migration + .iter() + .all(|case| case.point().sequence() == DurabilityCrashSequence::Migration) + ); Ok(()) } #[test] -fn occurrence_coordinates_exist_only_for_record_append() -> Result<(), Box> { +fn occurrence_coordinates_exist_only_for_counted_boundaries() -> Result<(), Box> { let occurrence = DurabilityCrashOccurrence::new(7); let counted = DurabilityCrashCase::new( diff --git a/xtask/tests/durability_crash_documentation.rs b/xtask/tests/durability_crash_documentation.rs index 8949f2d3..78193699 100644 --- a/xtask/tests/durability_crash_documentation.rs +++ b/xtask/tests/durability_crash_documentation.rs @@ -6,6 +6,10 @@ const ROOT_README: &str = include_str!("../../README.md"); const RECOVERY: &str = include_str!("../../docs/formats/segment-store-v1/recovery.md"); const REQUIREMENTS: &str = include_str!("../../docs/formats/segment-store-v1/requirements.md"); const CORPUS_README: &str = include_str!("../../conformance/segment-store/v1/README.md"); +const V2_CORPUS_README: &str = include_str!("../../conformance/segment-store/v2/README.md"); +const MIGRATION_CRASH: &str = + include_str!("../../docs/formats/segment-store-v2/migration-crash.md"); +const V2_REQUIREMENTS: &str = include_str!("../../docs/formats/segment-store-v2/requirements.md"); #[test] fn living_documentation_routes_the_complete_crash_matrix_and_its_limits() { @@ -14,6 +18,12 @@ fn living_documentation_routes_the_complete_crash_matrix_and_its_limits() { (RECOVERY, "## Process-death crash matrix"), (REQUIREMENTS, "`KEEP-RECOVERY-021`"), (CORPUS_README, "105 canonical process-death cases"), + (V2_CORPUS_README, "68 canonical process-death cases"), + ( + MIGRATION_CRASH, + "cargo xtask durability-crash-matrix --sequence migration", + ), + (V2_REQUIREMENTS, "`KEEP-MIGRATION-007`"), ] { assert!( document.contains(claim), @@ -24,4 +34,9 @@ fn living_documentation_routes_the_complete_crash_matrix_and_its_limits() { "Process-death injection, retention, compaction, and garbage collection remain planned." )); assert!(RECOVERY.contains("does not simulate host power loss")); + assert!( + !MIGRATION_CRASH + .contains("this page claims in-process recovery, not process-death recovery") + ); + assert!(!ROOT_README.contains("Process-death evidence for migration recovery")); } diff --git a/xtask/tests/durability_crash_point_contract.rs b/xtask/tests/durability_crash_point_contract.rs index 1945b010..06c21cea 100644 --- a/xtask/tests/durability_crash_point_contract.rs +++ b/xtask/tests/durability_crash_point_contract.rs @@ -4,7 +4,7 @@ use xtask::{DurabilityCrashPoint, DurabilityCrashSequence}; -use DurabilityCrashSequence::{Catalog, Head, Initialization, RecoveryDiscard, Segment}; +use DurabilityCrashSequence::{Catalog, Head, Initialization, Migration, RecoveryDiscard, Segment}; const EXPECTED: &[(DurabilityCrashPoint, &str, DurabilityCrashSequence)] = &[ ( @@ -162,6 +162,111 @@ const EXPECTED: &[(DurabilityCrashPoint, &str, DurabilityCrashSequence)] = &[ "KEEP-CRASH-035", Initialization, ), + ( + DurabilityCrashPoint::MigrationWriteIntentStage, + "KEEP-CRASH-053", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeIntentStage, + "KEEP-CRASH-054", + Migration, + ), + ( + DurabilityCrashPoint::MigrationLinkIntent, + "KEEP-CRASH-055", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeRootAfterIntent, + "KEEP-CRASH-056", + Migration, + ), + ( + DurabilityCrashPoint::MigrationRemoveIntentStage, + "KEEP-CRASH-057", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeRootAfterIntentCleanup, + "KEEP-CRASH-058", + Migration, + ), + ( + DurabilityCrashPoint::MigrationAdmitReaderFence, + "KEEP-CRASH-059", + Migration, + ), + ( + DurabilityCrashPoint::MigrationAdmitNamespacePrefix, + "KEEP-CRASH-060", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeRootAfterNamespace, + "KEEP-CRASH-061", + Migration, + ), + ( + DurabilityCrashPoint::MigrationWriteMarkerStage, + "KEEP-CRASH-062", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeMarkerStage, + "KEEP-CRASH-063", + Migration, + ), + ( + DurabilityCrashPoint::MigrationLinkMarker, + "KEEP-CRASH-064", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeRootAfterMarker, + "KEEP-CRASH-065", + Migration, + ), + ( + DurabilityCrashPoint::MigrationRemoveMarkerStage, + "KEEP-CRASH-066", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeRootAfterMarkerCleanup, + "KEEP-CRASH-067", + Migration, + ), + ( + DurabilityCrashPoint::MigrationWriteReceiptStage, + "KEEP-CRASH-068", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeReceiptStage, + "KEEP-CRASH-069", + Migration, + ), + ( + DurabilityCrashPoint::MigrationLinkReceipt, + "KEEP-CRASH-070", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeRootAfterReceipt, + "KEEP-CRASH-071", + Migration, + ), + ( + DurabilityCrashPoint::MigrationRemoveReceiptStage, + "KEEP-CRASH-072", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeRootAfterReceiptCleanup, + "KEEP-CRASH-073", + Migration, + ), ]; #[test] @@ -173,7 +278,7 @@ fn crash_boundaries_have_one_contiguous_stable_vocabulary() { } #[test] -fn only_record_append_selects_an_occurrence() { +fn only_record_append_and_namespace_prefix_select_an_occurrence() { let occurrence_counted: Vec<_> = DurabilityCrashPoint::ALL .into_iter() .filter(|point| point.occurrence_counted()) @@ -181,6 +286,58 @@ fn only_record_append_selects_an_occurrence() { assert_eq!( occurrence_counted, - [DurabilityCrashPoint::AppendSegmentRecord] + [ + DurabilityCrashPoint::AppendSegmentRecord, + DurabilityCrashPoint::MigrationAdmitNamespacePrefix + ] ); } + +#[test] +fn the_namespace_prefix_runs_one_during_case_per_directory() { + for point in DurabilityCrashPoint::ALL { + let expected = if point == DurabilityCrashPoint::MigrationAdmitNamespacePrefix { + DurabilityCrashPoint::NAMESPACE_PREFIX_DIRECTORIES + } else { + 1 + }; + assert_eq!( + point.during_occurrences(), + expected, + "{}", + point.identifier() + ); + } + assert_eq!(DurabilityCrashPoint::NAMESPACE_PREFIX_DIRECTORIES, 6); +} + +#[test] +fn migration_boundaries_follow_the_twenty_one_phases_in_order() { + let migration: Vec<_> = DurabilityCrashPoint::ALL + .into_iter() + .filter(|point| point.sequence() == Migration) + .collect(); + + assert_eq!(migration, DurabilityCrashPoint::MIGRATION); + assert_eq!(migration.len(), keep::StoreMigrationPhase::ALL.len()); + assert_eq!( + DurabilityCrashPoint::MIGRATION.map(DurabilityCrashPoint::identifier), + std::array::from_fn::<_, 21, _>(|index| { + let ordinal = 53 + index; + let identifier = format!("KEEP-CRASH-{ordinal:03}"); + DurabilityCrashPoint::from_identifier(&identifier) + .map_or("missing", DurabilityCrashPoint::identifier) + }) + ); +} + +#[test] +fn sequences_round_trip_their_command_line_identifiers() { + for sequence in DurabilityCrashSequence::ALL { + assert_eq!( + DurabilityCrashSequence::from_identifier(sequence.identifier()), + Some(sequence) + ); + } + assert_eq!(DurabilityCrashSequence::from_identifier("retention"), None); +} diff --git a/xtask/tests/durability_crash_production_contract.rs b/xtask/tests/durability_crash_production_contract.rs index 7c5cbf6a..eba7adcc 100644 --- a/xtask/tests/durability_crash_production_contract.rs +++ b/xtask/tests/durability_crash_production_contract.rs @@ -15,6 +15,7 @@ fn crash_children_execute_every_claimed_production_protocol() -> Result<(), Box< "durability_crash_matrix/production_protocol/initialization.rs", "durability_crash_matrix/production_protocol/publication.rs", "durability_crash_matrix/production_protocol/recovery.rs", + "durability_crash_matrix/production_protocol/migration.rs", ] .into_iter() .map(|path| fs::read_to_string(source_root.join(path))) @@ -27,6 +28,7 @@ fn crash_children_execute_every_claimed_production_protocol() -> Result<(), Box< "publish_catalog_generation(", "initialize_store(", "execute_recovery_stage_discard(", + "execute_store_migration(", ] { assert!( protocol.contains(required), diff --git a/xtask/tests/retention_store_v2_conformance_contract.rs b/xtask/tests/retention_store_v2_conformance_contract.rs index d5a99bf1..6cd5b6b9 100644 --- a/xtask/tests/retention_store_v2_conformance_contract.rs +++ b/xtask/tests/retention_store_v2_conformance_contract.rs @@ -17,6 +17,7 @@ const REQUIRED_PATHS: &[&str] = &[ "inventory.tsv", "migration-source.tsv", "artifacts.tsv", + "transitions.tsv", "format-marker.hex", "migration-intent.hex", "migration-receipt.hex", diff --git a/xtask/tests/retention_store_v2_protocol_contract.rs b/xtask/tests/retention_store_v2_protocol_contract.rs index 0847610f..d3e7fbe9 100644 --- a/xtask/tests/retention_store_v2_protocol_contract.rs +++ b/xtask/tests/retention_store_v2_protocol_contract.rs @@ -8,6 +8,8 @@ mod closure_contract_laws; mod migration_contract_laws; #[path = "retention_store_v2_protocol_contract/parser_fuzz_laws.rs"] mod parser_fuzz_laws; +#[path = "retention_store_v2_protocol_contract/transition_laws.rs"] +mod transition_laws; use std::fs; use std::io; diff --git a/xtask/tests/retention_store_v2_protocol_contract/transition_laws.rs b/xtask/tests/retention_store_v2_protocol_contract/transition_laws.rs new file mode 100644 index 00000000..64c39a98 --- /dev/null +++ b/xtask/tests/retention_store_v2_protocol_contract/transition_laws.rs @@ -0,0 +1,79 @@ +//! Stable migration crash-transition ledger laws. + +use keep::StoreMigrationPhase; + +const TRANSITIONS: &str = include_str!("../../../conformance/segment-store/v2/transitions.tsv"); + +/// The `operation` column in `StoreMigrationPhase::ALL` order. +const OPERATIONS: [&str; 21] = [ + "write-intent-stage", + "sync-intent-stage", + "link-intent", + "sync-root-after-intent", + "remove-intent-stage", + "sync-root-after-intent-cleanup", + "admit-reader-fence", + "admit-namespace-prefix", + "sync-root-after-namespace", + "write-marker-stage", + "sync-marker-stage", + "link-marker", + "sync-root-after-marker", + "remove-marker-stage", + "sync-root-after-marker-cleanup", + "write-receipt-stage", + "sync-receipt-stage", + "link-receipt", + "sync-root-after-receipt", + "remove-receipt-stage", + "sync-root-after-receipt-cleanup", +]; + +#[test] +fn migration_transition_ledger_is_complete_and_stable() -> Result<(), String> { + assert!(TRANSITIONS.starts_with( + "keep.segment-store.transitions/v2\n\ + crash_id\tphase\toperation\tpre_state\tinterrupted_class\t\ + post_state\trecovery_posture\n" + )); + assert_eq!(OPERATIONS.len(), StoreMigrationPhase::ALL.len()); + + let mut row_count = 0usize; + for (offset, row) in TRANSITIONS.lines().skip(2).enumerate() { + let ordinal = offset + .checked_add(53) + .ok_or("transition ordinal overflow")?; + let expected_id = format!("KEEP-CRASH-{ordinal:03}"); + let fields: Vec<_> = row.split('\t').collect(); + assert_eq!(fields.first(), Some(&expected_id.as_str())); + assert_eq!(fields.get(1), Some(&"migration"), "{expected_id}"); + assert_eq!(fields.get(2), OPERATIONS.get(offset), "{expected_id}"); + assert_eq!( + fields.len(), + 7, + "transition {expected_id} must have seven fields" + ); + assert!( + fields.iter().all(|field| !field.is_empty()), + "transition {expected_id} has an empty field" + ); + row_count = row_count + .checked_add(1) + .ok_or("transition count overflow")?; + } + assert_eq!(row_count, 21); + + // Every stage write may leave an incomplete pre-effect stage, and only + // those rows may plan a discard; the last two rows admit completion. + for (ordinal, row) in TRANSITIONS.lines().skip(2).enumerate() { + let discards = row.contains("discard-incomplete-stage"); + assert_eq!(discards, matches!(ordinal, 0 | 9 | 15), "{row}"); + let completes = row.ends_with("admit-complete-migration"); + assert_eq!(completes, ordinal >= 19, "{row}"); + } + assert!( + TRANSITIONS.contains("directory-prefix-length-zero-to-six"), + "KEEP-CRASH-060 must name one case per admitted prefix length" + ); + Ok(()) +} From 925d5924b00f5a50f52bc81033faac3eb837743e Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 10:18:26 -0700 Subject: [PATCH 22/59] Fix: gate the migration crash-task helpers behind repository-tasks `FilesystemMigrationFixedStage::create_prefix` and `admit_namespace_prefix_partially` exist only for the process-death matrix, which builds keep with `repository-tasks`. A minimal-feature build refused them as dead code under `#![deny(warnings)]` (CI "Check minimal features" and the fuzz-target build). Both now carry the same feature gate as their only consumer. Co-Authored-By: Claude Fable 5.1 --- .../store_migration/filesystem_migration_fixed_artifact.rs | 1 + src/adapters/store_migration/filesystem_migration_namespace.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs b/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs index bafc714d..3a1ff7fb 100644 --- a/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs +++ b/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs @@ -72,6 +72,7 @@ impl FilesystemMigrationFixedStage { /// Creates the stage exclusively and writes only `expected[..end]`, /// leaving an unsynchronized incomplete pre-effect stage behind. The /// handle is dropped: repository crash tasks kill the process next. + #[cfg(feature = "repository-tasks")] pub(super) fn create_prefix( root: &Dir, artifact: FilesystemMigrationFixedArtifact, diff --git a/src/adapters/store_migration/filesystem_migration_namespace.rs b/src/adapters/store_migration/filesystem_migration_namespace.rs index ca50f828..ca557280 100644 --- a/src/adapters/store_migration/filesystem_migration_namespace.rs +++ b/src/adapters/store_migration/filesystem_migration_namespace.rs @@ -115,6 +115,7 @@ pub(super) fn admit_namespace_prefix(root: &Dir) -> io::Result<()> { /// Admits only the first `count` prefix directories, in protocol order, and /// stops without the final verification. Repository crash tasks use this to /// leave a store at an exact directory-prefix length. +#[cfg(feature = "repository-tasks")] pub(super) fn admit_namespace_prefix_partially(root: &Dir, count: usize) -> io::Result<()> { if count > PREFIX_DIRECTORY_COUNT { return Err(ambiguous("namespace prefix count exceeds the protocol")); From 5bba27ea09433b372ac4108d1bb3aea6daa34793 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 10:35:48 -0700 Subject: [PATCH 23/59] Feat: re-verify closure members under filesystem retention authority ROADMAP T-18.3. A preparation's closure was verified against a snapshot the caller supplied, possibly from another process, another store, or before a pool entry changed. Publication no longer trusts it: after binding this store's catalog head and the catalog it selects, current-state verification loads that catalog and every segment it names within the authority's new `CatalogRestartPolicy`, admits each record through the inherited segment laws, re-runs `verify_retention_closure`, and requires the same closure digest before any retention stage is written. - `FilesystemRetentionPublicationAuthority::open(admission, catalog_policy)` makes the member-read bound explicit; the fixture, the version-two admission law, and the crash-matrix retention child pass one. - `RetentionCurrentStateRefusal` gains `ClosureMemberRefused { source: Box }`, `ClosureReverificationRefused { source: RetentionClosureVerificationError }`, and `ClosureDigestChanged`; both sources are returned by `source()`, so the chain from the publication error reaches the exact `SegmentRecordAdmissionError`. - `filesystem_retention_member_tests`: an intact store re-verifies and admits; a chunk payload flipped under a resealed record checksum refuses with `ChunkIdentityMismatch` reachable through `source()`; a layout payload likewise with `Layout`; a removed member segment with the `CatalogRestartError`; each leaves the retention namespace untouched. - `closure.md` Status and `closure-corruption.md` describe the re-verification and its refusal ownership; `KEEP-RETENTION-005` evidence names the laws. Red: skipping the re-verification call admits all three damaged stores ("a damaged closure member was unexpectedly admitted"). Green: restored; the retention crash sequence still passes with re-verification in the publication child. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 10 + ROADMAP.md | 14 +- .../segment-store-v2/closure-corruption.md | 30 +++ docs/formats/segment-store-v2/closure.md | 5 +- docs/formats/segment-store-v2/requirements.md | 2 +- src/adapters/retention.rs | 2 + .../filesystem_retention_authority.rs | 17 +- .../retention/filesystem_retention_catalog.rs | 54 +++++- .../filesystem_retention_member_tests.rs | 171 ++++++++++++++++++ .../retention/filesystem_retention_refusal.rs | 27 ++- .../retention/filesystem_retention_storage.rs | 5 + .../filesystem_retention_test_fixture.rs | 31 +++- .../filesystem_version_two_admission_tests.rs | 5 +- .../production_protocol/retention.rs | 2 +- 14 files changed, 347 insertions(+), 28 deletions(-) create mode 100644 src/adapters/retention/filesystem_retention_member_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d06ae012..4e350b51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ after its public API and format compatibility policies are established. ### Added +- Closure-member re-verification under filesystem authority. + `FilesystemRetentionPublicationAuthority::open` now takes a + `CatalogRestartPolicy`, and current-state verification loads this store's + catalog and every segment it names within that bound, admits each record, + re-runs `verify_retention_closure`, and requires the preparation's closure + digest before any retention stage is written. A corrupt chunk or layout + member refuses as `RetentionCurrentStateRefusal::ClosureMemberRefused` + whose `source()` chain reaches the exact `SegmentRecordAdmissionError`; a + closure that no longer verifies is `ClosureReverificationRefused`; a + different digest is `ClosureDigestChanged`. - Migration process-death matrix. `cargo xtask durability-crash-matrix` now runs `KEEP-CRASH-053` through `-073`: 68 cases that publish the Golden File Worldline version-1 store in an isolated child, execute the production diff --git a/ROADMAP.md b/ROADMAP.md index 18bfc7d3..6ec34e7b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,7 +96,7 @@ names; use those in code, tests, and commits. - [x] [F-15 Retention roots, release, and GC liveness model](#f-15-retention-roots-release-and-gc-liveness-model) — Done (ADR-0009) - [x] [F-16 Version-2 format records and codecs](#f-16-version-2-format-records-and-codecs) — Done - [ ] [F-17 One-way migration from version 1 to version 2](#f-17-one-way-migration-from-version-1-to-version-2) — Partial (#97, in-process recovery, and the `KEEP-CRASH-053`–`073` matrix done on this branch; `KEEP-MIGRATION-005` and `-008` residue remains) -- [ ] [F-18 Retention publication](#f-18-retention-publication) — Partial; recovery and the `KEEP-CRASH-036`–`052` matrix merged from PR #99; T-18.3 and orphan disposition (F-22) remain +- [ ] [F-18 Retention publication](#f-18-retention-publication) — Partial; recovery, the `KEEP-CRASH-036`–`052` matrix, and member re-verification done on this branch; orphan disposition (F-22) remains - [x] [F-19 Reader fence and immutable version-2 snapshots](#f-19-reader-fence-and-immutable-version-2-snapshots) — Done on this branch (merged from PR #99) - [x] [F-20 Model-based retention transition evidence](#f-20-model-based-retention-transition-evidence) — Done on this branch (merged from PR #99) - [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Planned (#20) @@ -842,8 +842,8 @@ Direct version-2 initialization is undefined. There is no downgrade. **Status:** Partial. Forward publication is Done (issue #19, PR #78). Recovery and the `KEEP-CRASH-036` to `-052` matrix are Done on this branch -(merged from PR #99). Explicit disposition of complete orphans waits for -F-22. +(merged from PR #99), as is closure-member re-verification under authority +(T-18.3). Explicit disposition of complete orphans waits for F-22. A retain or release names a namespace, an expected state (absent or an exact `RootGeneration`), a complete anchor set, and the realization @@ -915,8 +915,12 @@ and every namespace or capacity violation before writing anything. - **Complexity:** L (delivered in PR #99). - **Documentation:** `recovery.md`, README gap table. - **Dependencies:** T-18.1. -- [ ] T-18.3 Closure-member re-verification under filesystem authority - (`closure.md` Status; `KEEP-RETENTION-006` source-chain obligation). +- [x] T-18.3 Closure-member re-verification under filesystem authority — + `reverify_closure_members` in `filesystem_retention_catalog.rs`, the + `ClosureMemberRefused`, `ClosureReverificationRefused`, and + `ClosureDigestChanged` refusals, and `filesystem_retention_member_tests` + downcasting through `source()` to the exact + `SegmentRecordAdmissionError`. Original task fields: - **Requirements:** when recovery or publication re-reads a closure-member segment under authority, the original decode or admission error travels as the `source` of the operation-level error; diff --git a/docs/formats/segment-store-v2/closure-corruption.md b/docs/formats/segment-store-v2/closure-corruption.md index 8e319249..7dccf75b 100644 --- a/docs/formats/segment-store-v2/closure-corruption.md +++ b/docs/formats/segment-store-v2/closure-corruption.md @@ -46,8 +46,38 @@ The inherited version-1 boundaries retain their typed errors: fully admitted catalog has no binding for the scheduled logical identity. It does not mean bytes were present but corrupt. +## Re-verification under authority + +A preparation carries a closure verified against a `CatalogSnapshot` the +caller supplied, which may have been read by another process, from another +store, or before a pool entry changed. Filesystem publication therefore does +not trust it. After binding this store's catalog head and the catalog it +selects, current-state verification loads that catalog together with every +segment it names, bounded by the authority's `CatalogRestartPolicy`, admits +each record through the proof chain above, re-runs `verify_retention_closure` +against the freshly admitted snapshot, and requires the same closure digest. + +Refusal keeps the original error. A member whose record framing, checksum, +chunk identity, or layout payload refuses surfaces as +`RetentionCurrentStateRefusal::ClosureMemberRefused` whose `source` is the +`CatalogRestartError` that carries the `SegmentReadError` and, beneath it, the +exact `SegmentRecordDecodeError` or `SegmentRecordAdmissionError`; a closure +that no longer verifies surfaces as `ClosureReverificationRefused` with the +`RetentionClosureVerificationError`; a closure that verifies to a different +digest surfaces as `ClosureDigestChanged`. No layer replaces a typed error +with a string, so a caller can walk `source()` from the publication error to +the record that failed. Every refusal happens before any retention stage is +written. + ## Executable evidence +- The [closure-member re-verification + laws](../../../src/adapters/retention/filesystem_retention_member_tests.rs) + corrupt a chunk payload, corrupt a layout payload, and remove a member + segment in a migrated store, then prove publication refuses with the exact + admission or restart error reachable through `source()` and leaves the + retention namespace untouched. + - The [segment-record framing laws](../../../tests/segment_record/framing_laws.rs) cover checksum and framing corruption. diff --git a/docs/formats/segment-store-v2/closure.md b/docs/formats/segment-store-v2/closure.md index 3e0e79ba..dcc4c508 100644 --- a/docs/formats/segment-store-v2/closure.md +++ b/docs/formats/segment-store-v2/closure.md @@ -2,9 +2,8 @@ - Status: Normative version-2 protocol; storage-independent verifier implemented; publication binds this store's catalog head and the catalog it - selects to the verified closure; member re-verification under filesystem - authority is planned in issue - [#19](https://github.com/flyingrobots/keep/issues/19) + selects to the verified closure and re-verifies every closure member from + this store's own pools under filesystem authority before writing anything - Format coordinate: `keep.segment-store/v2` - Requirement: [`KEEP-RETENTION-005`](requirements.md#retention-transitions) - Decision record: diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index a4998457..d72e6033 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -13,7 +13,7 @@ case is not evidence. | `KEEP-RETENTION-002` | Root, manifest, and head codecs implement the exact canonical grammars and fixed bounds | independent golden corpus plus `tests/retention_root_encoding.rs`, `tests/retention_root_decoding.rs`, `tests/retention_manifest_codec.rs`, and `tests/retention_head_codec.rs` | Implemented | | `KEEP-RETENTION-003` | Every structural field, truncation boundary, ordering law, duplicate, overflow, flag, reserved byte, digest, checksum, and trailing byte has a precise refusal | one sealed mutation per header, body, and trailer field with its exact first refusal, plus reframed namespace-bound, ordering, and count-ceiling cases, in `tests/retention_root_decoding/mutation_laws.rs`, `tests/retention_manifest_codec/mutation_laws.rs`, and `tests/retention_head_codec/mutation_laws.rs`; framing and integrity precedence in `tests/retention_root_decoding.rs`, `tests/retention_manifest_codec/refusal_laws.rs`, and `tests/retention_head_codec.rs`; seeded `retention_format` fuzz target | Implemented | | `KEEP-RETENTION-004` | Retain and release compare expected and observed generations and publish exact successors only | unforgeable readiness and preflight proofs in `tests/retention_transition.rs` and `tests/retention_preflight.rs`; exact successor preparation and complete receipt evidence in `tests/retention_publication_preparation.rs` and `tests/retention_publication_execution.rs`; writer-locked initial filesystem publication in `filesystem_retention_storage_tests`; observed-head successor publication, exact predecessor binding, and absent-head refusal in `filesystem_retention_successor_tests`; the store's catalog head must name the closure's catalog generation and digest before any forward write in `filesystem_retention_catalog_tests`; a head whose predecessor disagrees with its manifest refuses in `filesystem_retention_current_tests`; a successor reopens and decodes the manifest-selected predecessor root and refuses an absent or changed one in `filesystem_retention_expectation_tests` | Implemented | -| `KEEP-RETENTION-005` | Closure derivation is deterministic, bounded, cycle-safe, fail-closed, and verifies complete blob reconstruction | exact accounting, reconstruction, adversarial-catalog, and exhaustive model laws in `tests/retention_closure.rs`; corrupt members refuse through the inherited segment-record admission laws and seeded `segment_format` fuzz target routed by `closure-corruption.md` | Implemented | +| `KEEP-RETENTION-005` | Closure derivation is deterministic, bounded, cycle-safe, fail-closed, and verifies complete blob reconstruction | exact accounting, reconstruction, adversarial-catalog, and exhaustive model laws in `tests/retention_closure.rs`; corrupt members refuse through the inherited segment-record admission laws and seeded `segment_format` fuzz target routed by `closure-corruption.md`; publication re-reads every member under filesystem authority and surfaces the exact admission error as the refusal's `source` in `filesystem_retention_member_tests` | Implemented | | `KEEP-RETENTION-006` | Publication follows the exact ordered durability protocol, including new namespace-directory admission and retention of fixed-stage evidence until head commit, and returns only after cleanup synchronization | typed vocabulary and blocking port in `tests/retention_publication_phase.rs` and `tests/retention_publication_storage.rs`; ordered execution, conditional namespace sync, and all 17 exact storage-fault boundaries in `tests/retention_publication_execution.rs`; production 17-phase forward filesystem execution, exclusive staging, byte-equal inode-substitution refusal, and retained-stage recovery refusal in `filesystem_retention_storage_tests`; orphan namespace directories count against the 4,096 ceiling and refuse a new namespace before any stage is written in `filesystem_retention_capacity_tests`; crash injection remains | In progress in #19 | | `KEEP-RETENTION-007` | Restart resolves every fixed-stage crash prefix to one documented lawful state or typed ambiguity | recovery-required refusals before any mutation in `filesystem_retention_expectation_tests`: an absent head over populated pools, a non-initial head prepared against an absent head, an orphan directory for a namespace expected absent, and an absent directory for a namespace expected current; replaced protocol directories, an absent or changed head-selected catalog, an over-full census, zero-generation pool names, and a stage retained by a failed write refuse in `filesystem_retention_*_tests`; storage-independent classification of every fixed-stage crash prefix (discard, link and protect, finalize, clean up, or typed refusal) in `recovery_planner_tests`; every publication prefix 0 through 18, each mid-write truncation, and successor prefixes recover in-process to the documented state, idempotently, with the forward retry reporting the predicted outcome, in `filesystem_retention_recovery_prefix_tests`; `cargo xtask durability-crash-matrix` kills a real writer before, during, and after `KEEP-CRASH-036` through `052` and requires restart recovery to reach the documented state and the forward retry to report the predicted outcome | Implemented | | `KEEP-RETENTION-008` | Readers double-collect catalog and retention heads and bind one complete catalog, manifest, and root-generation view under a `ReaderFence` | `ReaderFence` holds a shared kernel lock on a verified zero-length `reader.lock`; `collect_retention_view` accepts a view only when both head coordinates agree before and after loading and refuses an exhausted attempt limit (`retention_view_collector_tests`); `FilesystemRetentionSnapshot` binds the catalog snapshot, retention head, and manifest under the fence and verifies each selected root on demand while the fence is held, refusing a substituted root and a replaced fence, and two readers share the fence while an exclusive lock waits (`filesystem_retention_snapshot_tests`) | Implemented | diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 606ce05e..6ab724bb 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -32,6 +32,8 @@ mod filesystem_retention_current_tests; mod filesystem_retention_expectation_tests; #[cfg(all(test, target_os = "linux"))] mod filesystem_retention_fifo_tests; +#[cfg(test)] +mod filesystem_retention_member_tests; mod filesystem_retention_namespace; #[cfg(test)] mod filesystem_retention_namespace_tests; diff --git a/src/adapters/retention/filesystem_retention_authority.rs b/src/adapters/retention/filesystem_retention_authority.rs index fe0cf1ec..0f3bcda0 100644 --- a/src/adapters/retention/filesystem_retention_authority.rs +++ b/src/adapters/retention/filesystem_retention_authority.rs @@ -10,7 +10,7 @@ use super::filesystem_retention_authority_error::{ }; use super::filesystem_retention_current::{self, ObservedRetentionState}; use super::filesystem_retention_recovery::RetentionRecoveryContext; -use crate::adapters::{FilesystemVersionTwoAdmission, FilesystemWriterLock}; +use crate::adapters::{CatalogRestartPolicy, FilesystemVersionTwoAdmission, FilesystemWriterLock}; /// Exclusive authority to publish retention transitions on one pinned root. /// @@ -32,6 +32,7 @@ pub struct FilesystemRetentionPublicationAuthority { pub(super) retention: Dir, pub(super) roots: Dir, pub(super) manifests: Dir, + pub(super) catalog_policy: CatalogRestartPolicy, pub(super) attempt: Option, pub(super) recovery: Option, _lock: FilesystemWriterLock, @@ -45,11 +46,15 @@ impl FilesystemRetentionPublicationAuthority { /// system refuses it: /// /// ```compile_fail - /// fn publish(admission: keep::FilesystemPlatformAdmission) { - /// let _ = keep::FilesystemRetentionPublicationAuthority::open(admission); + /// fn publish(admission: keep::FilesystemPlatformAdmission, policy: keep::CatalogRestartPolicy) { + /// let _ = keep::FilesystemRetentionPublicationAuthority::open(admission, policy); /// } /// ``` /// + /// `catalog_policy` bounds the one read of this store's catalog and every + /// segment it names that current-state verification performs to re-verify + /// the candidate's closure members under this authority. + /// /// This synchronous constructor opens pinned directory capabilities but /// materializes no record bodies and performs no protocol mutation. /// @@ -58,7 +63,10 @@ impl FilesystemRetentionPublicationAuthority { /// Returns [`FilesystemRetentionAuthorityError`](super::FilesystemRetentionAuthorityError) /// when the root capability cannot be cloned. The retention namespace and /// both immutable pools arrive already pinned by admission. - pub fn open(admission: FilesystemVersionTwoAdmission) -> Result { + pub fn open( + admission: FilesystemVersionTwoAdmission, + catalog_policy: CatalogRestartPolicy, + ) -> Result { let (lock, retention, roots, manifests) = admission.into_parts(); let root = lock.clone_directory().map_err(|source| Error::Directory { directory: Directory::Root, @@ -69,6 +77,7 @@ impl FilesystemRetentionPublicationAuthority { retention, roots, manifests, + catalog_policy, attempt: None, recovery: None, _lock: lock, diff --git a/src/adapters/retention/filesystem_retention_catalog.rs b/src/adapters/retention/filesystem_retention_catalog.rs index 446f8534..aee78c27 100644 --- a/src/adapters/retention/filesystem_retention_catalog.rs +++ b/src/adapters/retention/filesystem_retention_catalog.rs @@ -7,8 +7,13 @@ use cap_std::fs::Dir; use cap_fs_ext::DirExt; use super::filesystem_retention_current::read_exact_optional; -use super::{RetentionCurrentStateRefusal, RetentionPublicationPreparation}; -use crate::adapters::{ChecksummedCatalog, ChecksummedPublicationHead, physical_pool_name}; +use super::{ + RetentionCurrentStateRefusal, RetentionPublicationPreparation, verify_retention_closure, +}; +use crate::adapters::{ + CatalogRestartPolicy, ChecksummedCatalog, ChecksummedPublicationHead, catalog_restart_loader, + physical_pool_name, +}; const HEAD_NAME: &str = "HEAD"; const CATALOGS_NAME: &str = "catalogs"; @@ -22,8 +27,7 @@ const HEAD_LENGTH: usize = crate::adapters::publication_head_decoder::ENCODED_LE /// `HEAD` names exactly that catalog generation and digest and the selected /// catalog pool entry reopens under this authority, bounded by the head's /// declared length, and decodes to that generation and digest. Closure-member -/// segments are not re-read here: every read authenticates them, and their -/// re-verification under authority belongs to retention recovery. +/// segments are re-read afterwards by [`reverify_closure_members`]. pub(super) fn require_current_catalog( root: &Dir, preparation: &RetentionPublicationPreparation<'_>, @@ -70,3 +74,45 @@ fn require_selected_catalog(root: &Dir, head: ChecksummedPublicationHead<'_>) -> Err(RetentionCurrentStateRefusal::CatalogChanged.into_io()) } } + +/// Re-reads every closure member from this store's own pools and re-verifies +/// the candidate's closure against them under this authority. +/// +/// The preparation's closure was verified against a `CatalogSnapshot` the +/// caller supplied, which may have been read by another process, from +/// another store, or before a pool entry changed. This loads the catalog +/// `HEAD` selects together with every segment it names, bounded by `policy`, +/// admits each record through the inherited segment laws, and re-runs +/// closure verification. The exact decode or admission error a corrupt member +/// produces travels as the `source` of the refusal; nothing rewraps it into +/// a string. +pub(super) fn reverify_closure_members( + root: &Dir, + policy: CatalogRestartPolicy, + preparation: &RetentionPublicationPreparation<'_>, +) -> io::Result<()> { + let member_refused = |source| { + RetentionCurrentStateRefusal::ClosureMemberRefused { + source: Box::new(source), + } + .into_io() + }; + let loaded = catalog_restart_loader::load_from_directory(root, HEAD_NAME, policy) + .map_err(member_refused)?; + let snapshot = loaded.snapshot().map_err(member_refused)?; + let expected = preparation.closure(); + if (loaded.generation(), loaded.catalog_digest()) + != (expected.catalog_generation(), expected.catalog_digest()) + { + return Err(RetentionCurrentStateRefusal::CatalogChanged.into_io()); + } + let observed = + verify_retention_closure(preparation.candidate().root(), &snapshot).map_err(|source| { + RetentionCurrentStateRefusal::ClosureReverificationRefused { source }.into_io() + })?; + if observed.digest() == expected.digest() { + Ok(()) + } else { + Err(RetentionCurrentStateRefusal::ClosureDigestChanged.into_io()) + } +} diff --git a/src/adapters/retention/filesystem_retention_member_tests.rs b/src/adapters/retention/filesystem_retention_member_tests.rs new file mode 100644 index 00000000..ec7c57eb --- /dev/null +++ b/src/adapters/retention/filesystem_retention_member_tests.rs @@ -0,0 +1,171 @@ +//! Closure-member re-verification laws: publication under filesystem +//! authority re-reads every closure member from this store's own pools, and +//! the exact admission error a corrupt member produces travels as the +//! `source` of the publication refusal. + +use std::error::Error; +use std::fs; +use std::path::Path; + +use super::filesystem_retention_test_fixture::{ + ROOT_HEX, SEGMENT_NAME, fixture, initial_preparation, migrated_store, refusal, + reopen_authority, retention_witness, +}; +use super::{ + RetentionCurrentStateRefusal, RetentionPublicationStorage, RetentionTransitionDisposition, +}; +use crate::adapters::{CatalogRestartError, SegmentRecordAdmissionError}; + +/// The one-zero bundle segment: a chunk record at 64 whose payload byte is at +/// 176 and checksum at 177, and a layout record at 209 whose payload starts +/// at 321 and checksum at 541. +const CHUNK_PAYLOAD_OFFSET: usize = 176; +const CHUNK_CHECKSUM_OFFSET: usize = 177; +const CHUNK_RECORD_OFFSET: usize = 64; +const LAYOUT_PAYLOAD_OFFSET: usize = 321; +const LAYOUT_CHECKSUM_OFFSET: usize = 541; +const LAYOUT_RECORD_OFFSET: usize = 209; + +#[test] +fn an_intact_store_reverifies_every_member_and_admits_publication() -> Result<(), Box> { + let sandbox = migrated_store("retention-member-intact")?; + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + let mut authority = reopen_authority(sandbox.path())?; + + let disposition = authority.verify_current(&preparation)?; + + assert_eq!(disposition, RetentionTransitionDisposition::Publish); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn a_corrupt_chunk_member_refuses_with_its_admission_error_as_source() -> Result<(), Box> +{ + let sandbox = migrated_store("retention-member-corrupt-chunk")?; + corrupt_record( + sandbox.path(), + CHUNK_RECORD_OFFSET, + CHUNK_PAYLOAD_OFFSET, + CHUNK_CHECKSUM_OFFSET, + )?; + + let error = refuse_publication(sandbox.path())?; + + assert!(matches!( + refusal(&error), + Some(RetentionCurrentStateRefusal::ClosureMemberRefused { .. }) + )); + let admission = find_source::(&error) + .ok_or("the chunk admission error was erased from the source chain")?; + assert!(matches!( + admission, + SegmentRecordAdmissionError::ChunkIdentityMismatch { .. } + )); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn a_corrupt_layout_member_refuses_with_its_layout_admission_error_as_source() +-> Result<(), Box> { + let sandbox = migrated_store("retention-member-corrupt-layout")?; + corrupt_record( + sandbox.path(), + LAYOUT_RECORD_OFFSET, + LAYOUT_PAYLOAD_OFFSET, + LAYOUT_CHECKSUM_OFFSET, + )?; + + let error = refuse_publication(sandbox.path())?; + + assert!(matches!( + refusal(&error), + Some(RetentionCurrentStateRefusal::ClosureMemberRefused { .. }) + )); + let admission = find_source::(&error) + .ok_or("the layout admission error was erased from the source chain")?; + assert!(matches!( + admission, + SegmentRecordAdmissionError::Layout { .. } + )); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn a_missing_member_segment_refuses_with_the_restart_error_as_source() -> Result<(), Box> +{ + let sandbox = migrated_store("retention-member-missing")?; + fs::remove_file(sandbox.path().join("segments").join(SEGMENT_NAME))?; + + let error = refuse_publication(sandbox.path())?; + + assert!(matches!( + refusal(&error), + Some(RetentionCurrentStateRefusal::ClosureMemberRefused { .. }) + )); + assert!(find_source::(&error).is_some()); + sandbox.remove()?; + Ok(()) +} + +/// Runs current-state verification against a store whose pool is damaged +/// and proves it refuses before touching the retention namespace. +fn refuse_publication(root: &Path) -> Result> { + let before = retention_witness(root)?; + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + let mut authority = reopen_authority(root)?; + let error = authority + .verify_current(&preparation) + .err() + .ok_or("a damaged closure member was unexpectedly admitted")?; + drop(authority); + assert_eq!(retention_witness(root)?, before); + Ok(error) +} + +/// Flips one payload byte of the record at `record` and reseals that record's +/// checksum, so the record is content-valid framing whose payload no longer +/// hashes to its declared identity. The segment seal is left stale: record +/// admission refuses before the outer digest is compared. +fn corrupt_record( + root: &Path, + record: usize, + payload: usize, + checksum: usize, +) -> Result<(), Box> { + let path = root.join("segments").join(SEGMENT_NAME); + let mut bytes = fs::read(&path)?; + let byte = bytes.get_mut(payload).ok_or("payload offset")?; + *byte ^= 1; + let covered = bytes.get(record..checksum).ok_or("record coverage")?; + let covered_length = u64::try_from(covered.len())?; + let mut hasher = blake3::Hasher::new(); + hasher.update(b"KEEP:SEG:RECORD:SUM\0"); + hasher.update(&1_u16.to_be_bytes()); + hasher.update(&[1]); + hasher.update(covered); + hasher.update(&covered_length.to_be_bytes()); + let resealed = *hasher.finalize().as_bytes(); + let end = checksum.checked_add(32).ok_or("checksum end")?; + bytes + .get_mut(checksum..end) + .ok_or("checksum slot")? + .copy_from_slice(&resealed); + fs::write(path, bytes)?; + Ok(()) +} + +/// Walks the `source` chain from an `io::Error`'s payload downward. +fn find_source(error: &std::io::Error) -> Option<&T> { + let mut cursor: &(dyn Error + 'static) = error.get_ref()?; + loop { + if let Some(found) = cursor.downcast_ref::() { + return Some(found); + } + cursor = cursor.source()?; + } +} diff --git a/src/adapters/retention/filesystem_retention_refusal.rs b/src/adapters/retention/filesystem_retention_refusal.rs index 598565c4..56fd633d 100644 --- a/src/adapters/retention/filesystem_retention_refusal.rs +++ b/src/adapters/retention/filesystem_retention_refusal.rs @@ -4,9 +4,9 @@ use std::error::Error; use std::fmt; use std::io; +use super::{RetentionClosureVerificationError, RetentionRecoveryError, RetentionRecoveryRefusal}; use super::{RetentionHeadDecodeError, RetentionManifestDecodeError}; -use super::{RetentionRecoveryError, RetentionRecoveryRefusal}; -use crate::adapters::{CatalogDecodeError, PublicationHeadDecodeError}; +use crate::adapters::{CatalogDecodeError, CatalogRestartError, PublicationHeadDecodeError}; use crate::{CatalogGeneration, LivenessGeneration, RetentionManifestDigest}; /// Exact reason filesystem current-state verification refused a transition. @@ -71,6 +71,18 @@ pub enum RetentionCurrentStateRefusal { /// The catalog pool entry decodes to a generation or digest other than the /// one `HEAD` names. CatalogChanged, + /// A closure member re-read from this store's pools refused admission. + ClosureMemberRefused { + /// The exact load, decode, or admission refusal. + source: Box, + }, + /// The closure re-verified under this authority refused. + ClosureReverificationRefused { + /// The exact closure refusal. + source: RetentionClosureVerificationError, + }, + /// The closure re-verified under this authority has a different digest. + ClosureDigestChanged, /// The current liveness generation has no successor. LivenessExhausted, /// A byte-identical retry found that another successor is current. @@ -243,6 +255,15 @@ impl RetentionCurrentStateRefusal { "restart recovery refused the retained retention stages" } Self::RecoveryStepRefused { .. } => "a restart recovery step refused", + Self::ClosureMemberRefused { .. } => { + "a closure member re-read from this store's pools refused admission" + } + Self::ClosureReverificationRefused { .. } => { + "the closure re-verified under this authority refused" + } + Self::ClosureDigestChanged => { + "the closure re-verified under this authority has a different digest" + } Self::ProtocolDirectoryReplaced => { "a retention protocol directory was replaced after admission" } @@ -270,6 +291,8 @@ impl Error for RetentionCurrentStateRefusal { Self::CatalogRefused { source } => Some(source.as_ref()), Self::RecoveryRefused { source } => Some(source), Self::RecoveryStepRefused { source } => Some(source), + Self::ClosureMemberRefused { source } => Some(source.as_ref()), + Self::ClosureReverificationRefused { source } => Some(source), _ => None, } } diff --git a/src/adapters/retention/filesystem_retention_storage.rs b/src/adapters/retention/filesystem_retention_storage.rs index ac943ec1..95cbff8d 100644 --- a/src/adapters/retention/filesystem_retention_storage.rs +++ b/src/adapters/retention/filesystem_retention_storage.rs @@ -53,6 +53,11 @@ impl RetentionPublicationStorage for FilesystemRetentionPublicationAuthority { } let observed = filesystem_retention_current::disposition(preparation, current.as_ref())?; filesystem_retention_catalog::require_current_catalog(&self.root, preparation)?; + filesystem_retention_catalog::reverify_closure_members( + &self.root, + self.catalog_policy, + preparation, + )?; match observed { ObservedDisposition::Publish => { filesystem_retention_namespace::admit_expectation( diff --git a/src/adapters/retention/filesystem_retention_test_fixture.rs b/src/adapters/retention/filesystem_retention_test_fixture.rs index 988e5675..7c1d95b1 100644 --- a/src/adapters/retention/filesystem_retention_test_fixture.rs +++ b/src/adapters/retention/filesystem_retention_test_fixture.rs @@ -18,9 +18,10 @@ use crate::LayoutEntryLimit; use crate::adapters::filesystem_test_sandbox::TestDirectory; use crate::adapters::test_support::decode_hex; use crate::adapters::{ - AdmittedCatalog, AdmittedSegment, CatalogSnapshot, ChecksummedCatalog, - ChecksummedPublicationHead, FilesystemPlatformAdmission, FilesystemStoreMigrationAuthority, - FilesystemVersionTwoAdmission, SegmentReadPolicy, SegmentRecordLimit, + AdmittedCatalog, AdmittedSegment, CatalogRestartByteLimit, CatalogRestartPolicy, + CatalogSnapshot, ChecksummedCatalog, ChecksummedPublicationHead, FilesystemPlatformAdmission, + FilesystemStoreMigrationAuthority, FilesystemVersionTwoAdmission, SegmentReadPolicy, + SegmentRecordLimit, }; use crate::{ RetentionGenerationExpectation, RetentionNamespace, RetentionPolicy, RetentionRoot, @@ -45,7 +46,8 @@ const CATALOG_HEX: &str = const CATALOG_HEAD_HEX: &str = include_str!("../../../conformance/segment-store/v1/one-zero-bundle-head.hex"); -const SEGMENT_NAME: &str = "221f6745cd8a5221c9a87c3707593608479282b54a4a74d0e753fd76f70e8db2.seg"; +pub(super) const SEGMENT_NAME: &str = + "221f6745cd8a5221c9a87c3707593608479282b54a4a74d0e753fd76f70e8db2.seg"; pub(super) const CATALOG_NAME: &str = "0000000000000001-0b7cad1b6de663d34beacbc214db7497f2e36ab6b08dfbd5febbc8d06a418811.cat"; @@ -58,11 +60,28 @@ pub(super) fn open_authority( name: &str, ) -> Result<(TestDirectory, FilesystemRetentionPublicationAuthority), Box> { let sandbox = migrated_store(name)?; - let admission = FilesystemVersionTwoAdmission::reopen_unchecked_for_tests(sandbox.path())?; - let authority = FilesystemRetentionPublicationAuthority::open(admission)?; + let authority = reopen_authority(sandbox.path())?; Ok((sandbox, authority)) } +/// Reopens a migrated store for retention publication under the test +/// catalog policy. +pub(super) fn reopen_authority( + root: &Path, +) -> Result> { + let admission = FilesystemVersionTwoAdmission::reopen_unchecked_for_tests(root)?; + FilesystemRetentionPublicationAuthority::open(admission, catalog_policy()?).map_err(Into::into) +} + +/// The catalog policy tests re-verify closure members under: maximum +/// grammar limits and one mebibyte of retained segment bytes. +pub(super) fn catalog_policy() -> Result> { + Ok(CatalogRestartPolicy::new( + maximum_policy(), + CatalogRestartByteLimit::new(1_048_576)?, + )) +} + /// Decodes one LF-terminated lowercase hexadecimal conformance fixture. pub(super) fn fixture(hex: &str) -> Result, Box> { decode_hex(hex.strip_suffix('\n').ok_or("fixture must end in one LF")?).map_err(Into::into) diff --git a/src/adapters/retention/filesystem_version_two_admission_tests.rs b/src/adapters/retention/filesystem_version_two_admission_tests.rs index 9649bef5..c0368f2c 100644 --- a/src/adapters/retention/filesystem_version_two_admission_tests.rs +++ b/src/adapters/retention/filesystem_version_two_admission_tests.rs @@ -4,7 +4,7 @@ use std::error::Error; use std::fs; use super::filesystem_retention_test_fixture::{ - ROOT_HEX, fixture, initial_preparation, migrated_store, open_authority, refusal, + ROOT_HEX, catalog_policy, fixture, initial_preparation, migrated_store, open_authority, refusal, }; use super::{ FilesystemRetentionPublicationAuthority, RetentionCurrentStateRefusal, @@ -219,7 +219,8 @@ fn a_protocol_directory_replaced_after_reopen_is_neither_opened_nor_published_in fs::create_dir_all(sandbox.path().join("retention").join("roots"))?; fs::create_dir(sandbox.path().join("retention").join("manifests"))?; - let mut authority = FilesystemRetentionPublicationAuthority::open(admission)?; + let mut authority = + FilesystemRetentionPublicationAuthority::open(admission, catalog_policy()?)?; let observed = authority .observe_current()? .ok_or("the admitted retention directory lost its published head")?; diff --git a/xtask/src/durability_crash_matrix/production_protocol/retention.rs b/xtask/src/durability_crash_matrix/production_protocol/retention.rs index 486f54ba..f0c5f5d4 100644 --- a/xtask/src/durability_crash_matrix/production_protocol/retention.rs +++ b/xtask/src/durability_crash_matrix/production_protocol/retention.rs @@ -60,7 +60,7 @@ pub(in crate::durability_crash_matrix) fn reopened_authority( let admission = FilesystemVersionTwoAdmission::reopen_unchecked_for_repository_tasks(store_root) .map_err(|source| verification("reopen crash store as version two", source))?; - FilesystemRetentionPublicationAuthority::open(admission) + FilesystemRetentionPublicationAuthority::open(admission, initialization::restart_policy()?) .map_err(|source| verification("open crash retention authority", source)) } From 668fd5f03268006def8318b339cb89c85dbe206d Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 11:00:39 -0700 Subject: [PATCH 24/59] Feat: deterministic GC planning from a fenced liveness snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROADMAP T-22.2 (KEEP-GC-002, planning). `plan_gc` is the pure comparison ADR-0009 requires between one immutable liveness snapshot and one bounded physical inventory. It reads nothing, writes nothing, and never guesses. - `GcLivenessSnapshot` binds the fenced catalog's generation and digest and the retention state (`GcRetentionState::{Empty, Published}`), every segment the catalog names, every retained root's verified closure projected onto segments (`GcRetainedClosure`), every pool segment with its length, the segments a predecessor catalog named that the current catalog omits, and the segments a durable disposition retired. Duplicate inventory or namespaces refuse at assembly. - `GcSegmentClassification` classifies every inventoried segment exactly once: `live` (named and reached by a retained closure, with the root count), `named-unreachable` (named, unreached; only compaction can release it), `recovery-protected` (unnamed with no release evidence), `unreachable-superseded`, `unreachable-disposed`. Only the last two are candidates. Superseded or disposed segments absent from the inventory are reported already retired. - Every contradiction refuses the whole plan as a typed `GcPlanAmbiguity` (named segment absent, closure member unnamed or absent, superseded or disposed segment still named); more candidates than `GcLimits` admit refuse rather than truncate. `GcPlan` is `#[must_use]`, immutable, and inspectable; candidates come out in canonical digest order. - `observe_gc_liveness` assembles the snapshot from a `FilesystemRetentionSnapshot`: it re-admits the fenced catalog, projects each retained closure through a crate-private record-to-segment map on the admitted catalog, reads and admits every `segments/` entry within the `CatalogRestartPolicy` byte bound (a stray name, wrong kind, corrupt segment, or digest mismatch refuses), and walks the catalog predecessor chain for superseded segments. No disposition codec exists, so nothing is disposed. - `verify_retention_closure_members` reports the identities a closure resolved beside the verified closure; `verify_retention_closure` now delegates to it. - Golden: `conformance/segment-store/v2/gc-plan.tsv` is the plan for the frozen store, recomputed from the fixtures by the golden law. Model: 512 generated universes prove the live set is exactly the union of retained closures, no live or named segment is ever a candidate, every orphan without evidence stays protected, and planning is a pure function of its snapshot. Filesystem laws over the migrated fixture store cover the published, empty-retention, orphan, corrupt, and stray-entry cases. - `gc.md` gains a Planning section and the §5.4 warning ahead of execution; `KEEP-GC-002` records the planning evidence and keeps execution, compaction, disposition, and recovery Planned in #21. Red: classifying an unevidenced orphan as collectible fails the recovery-protected law, the orphan filesystem law, and the model law. Green: restored. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 18 + ROADMAP.md | 17 +- conformance/segment-store/v2/ORIGIN.md | 6 + conformance/segment-store/v2/README.md | 1 + conformance/segment-store/v2/gc-plan.tsv | 3 + docs/formats/segment-store-v2/gc.md | 60 ++- docs/formats/segment-store-v2/requirements.md | 2 +- src/adapters/admitted_catalog.rs | 22 +- src/adapters/catalog_snapshot.rs | 12 +- src/adapters/checksummed_catalog.rs | 15 +- src/adapters/gc/liveness_coordinates.rs | 62 +++ src/adapters/gc/liveness_observation.rs | 217 ++++++++++ src/adapters/gc/liveness_observation_error.rs | 180 ++++++++ src/adapters/gc/liveness_observation_tests.rs | 148 +++++++ src/adapters/gc/liveness_snapshot.rs | 156 +++++++ src/adapters/gc/mod.rs | 34 +- src/adapters/gc/plan.rs | 147 +++++++ src/adapters/gc/plan_error.rs | 108 +++++ src/adapters/gc/plan_limits.rs | 72 ++++ src/adapters/gc/plan_model_tests.rs | 142 +++++++ src/adapters/gc/planner.rs | 146 +++++++ src/adapters/gc/planner_tests.rs | 397 ++++++++++++++++++ src/adapters/gc/retained_closure.rs | 68 +++ src/adapters/gc/segment_classification.rs | 64 +++ src/adapters/gc/segment_pool_inventory.rs | 104 +++++ src/adapters/retention.rs | 3 +- src/adapters/retention/closure_verifier.rs | 26 +- .../filesystem_retention_test_fixture.rs | 14 +- src/lib.rs | 12 +- ...retention_store_v2_conformance_contract.rs | 1 + 30 files changed, 2224 insertions(+), 33 deletions(-) create mode 100644 conformance/segment-store/v2/gc-plan.tsv create mode 100644 src/adapters/gc/liveness_coordinates.rs create mode 100644 src/adapters/gc/liveness_observation.rs create mode 100644 src/adapters/gc/liveness_observation_error.rs create mode 100644 src/adapters/gc/liveness_observation_tests.rs create mode 100644 src/adapters/gc/liveness_snapshot.rs create mode 100644 src/adapters/gc/plan.rs create mode 100644 src/adapters/gc/plan_error.rs create mode 100644 src/adapters/gc/plan_limits.rs create mode 100644 src/adapters/gc/plan_model_tests.rs create mode 100644 src/adapters/gc/planner.rs create mode 100644 src/adapters/gc/planner_tests.rs create mode 100644 src/adapters/gc/retained_closure.rs create mode 100644 src/adapters/gc/segment_classification.rs create mode 100644 src/adapters/gc/segment_pool_inventory.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e350b51..c89bb43d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,24 @@ after its public API and format compatibility policies are established. ### Added +- Deterministic GC planning. `GcLivenessSnapshot` is one immutable liveness + snapshot plus one bounded physical inventory: the fenced catalog's + generation and digest, the retention state, every segment the catalog + names, every retained root's verified closure projected onto segments, + every pool segment with its length, and the segments a predecessor + catalog named that the current catalog omits. `plan_gc` classifies every + inventoried segment exactly once (`live`, `named-unreachable`, + `recovery-protected`, `unreachable-superseded`, `unreachable-disposed`), + reports already-retired segments, refuses every contradiction as a typed + `GcPlanAmbiguity`, and refuses rather than truncates above `GcLimits`. + `observe_gc_liveness` assembles the snapshot from a + `FilesystemRetentionSnapshot`, re-admitting every pool segment and walking + the catalog chain; a corrupt or stray pool entry refuses observation. The + golden plan for the frozen version-2 store is + `conformance/segment-store/v2/gc-plan.tsv`; a 512-universe model proves the + live set is exactly the union of retained closures and no live or named + segment is ever a candidate. Execution is not implemented; nothing is + unlinked. - Closure-member re-verification under filesystem authority. `FilesystemRetentionPublicationAuthority::open` now takes a `CatalogRestartPolicy`, and current-state verification loads this store's diff --git a/ROADMAP.md b/ROADMAP.md index 6ec34e7b..8fcf83d4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -100,7 +100,7 @@ names; use those in code, tests, and commits. - [x] [F-19 Reader fence and immutable version-2 snapshots](#f-19-reader-fence-and-immutable-version-2-snapshots) — Done on this branch (merged from PR #99) - [x] [F-20 Model-based retention transition evidence](#f-20-model-based-retention-transition-evidence) — Done on this branch (merged from PR #99) - [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Planned (#20) -- [ ] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Planned (#21) +- [ ] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Partial (#21; codecs and the deterministic planner done on this branch) - [ ] [F-23 Durable authenticated reads and refusal receipts](#f-23-durable-authenticated-reads-and-refusal-receipts) — Planned (#109) - [ ] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Planned (#82, #72) @@ -1144,10 +1144,11 @@ quarantines, or rewrites physical state. ### F-22 Garbage collection, compaction, and recovery dispositions -**Status:** Planned (#21, P1, M4). Grammars are frozen and their presence -refuses (`KEEP-GC-001`, `-002`). The intent and receipt codecs landed on -this branch (T-22.1); the disposition codec waits on an enumeration -decision (T-22.1a). +**Status:** Partial (#21, P1, M4). Grammars are frozen and their presence +refuses (`KEEP-GC-001`, `-002`). The intent and receipt codecs (T-22.1) and +the deterministic planner (T-22.2) landed on this branch; the disposition +codec waits on an enumeration decision (T-22.1a); compaction, execution, +and orphan disposition remain. Plan GC from an immutable liveness snapshot; classify every segment as live, unreachable, corrupt, ambiguous, recovery-protected, @@ -1216,7 +1217,11 @@ disposition receipt exists. `BlobId`, `ChunkId`, and `LayoutId` never move. - **Complexity:** M. - **Documentation:** `gc.md` Status; corpus README. - **Dependencies:** none. -- [ ] T-22.2 Deterministic `GcPlan` from a liveness snapshot. +- [x] T-22.2 Deterministic `GcPlan` from a liveness snapshot — + `plan_gc`, `GcLivenessSnapshot`, `observe_gc_liveness`, and + `GcSegmentClassification` in `src/adapters/gc/`; golden + `conformance/segment-store/v2/gc-plan.tsv`; the 512-universe model law. + Original task fields: - **Requirements:** input is one immutable snapshot (F-19): manifest generation and digest, complete namespace map, every anchor and closure, every profile coordinate, catalog generation and digest, diff --git a/conformance/segment-store/v2/ORIGIN.md b/conformance/segment-store/v2/ORIGIN.md index dcbfe206..5b6785f4 100644 --- a/conformance/segment-store/v2/ORIGIN.md +++ b/conformance/segment-store/v2/ORIGIN.md @@ -11,6 +11,12 @@ The corpus was constructed on 2026-07-29 with: `docs/formats/segment-store-v2/migration-recovery.md`; the crash matrix in `xtask` is its executable check. +`gc-plan.tsv` was added on 2026-09-30 by running `plan_gc` over the frozen +store (the one-zero bundle catalog at generation one, retained by the +one-anchor root under the generation-one manifest) and transcribing the +classification of its one segment; `src/adapters/gc/planner_tests.rs` +recomputes it from the fixtures on every run. + ## Independent inputs The oracle imports exact bytes only from these previously accepted fixtures: diff --git a/conformance/segment-store/v2/README.md b/conformance/segment-store/v2/README.md index cc844fe8..1bdeecde 100644 --- a/conformance/segment-store/v2/README.md +++ b/conformance/segment-store/v2/README.md @@ -15,6 +15,7 @@ migration, retention transition, or garbage collector exists. | `migration-source.tsv` | Exact version-1 and derived migration coordinates | | `artifacts.tsv` | Golden artifact lengths, digests, checksums, and filenames | | `transitions.tsv` | One stable crash identifier per migration boundary, `KEEP-CRASH-053` to `-073` | +| `gc-plan.tsv` | The deterministic GC plan for the frozen version-2 store: every segment's classification | | `format-marker.hex` | Canonical 96-byte `FORMAT` record | | `migration-intent.hex` | Canonical 256-byte migration intent | | `migration-receipt.hex` | Canonical 256-byte migration receipt | diff --git a/conformance/segment-store/v2/gc-plan.tsv b/conformance/segment-store/v2/gc-plan.tsv new file mode 100644 index 00000000..a247a296 --- /dev/null +++ b/conformance/segment-store/v2/gc-plan.tsv @@ -0,0 +1,3 @@ +keep.segment-store-v2.gc-plan/v1 +segment_digest_hex segment_length classification retained_roots +221f6745cd8a5221c9a87c3707593608479282b54a4a74d0e753fd76f70e8db2 701 live 1 diff --git a/docs/formats/segment-store-v2/gc.md b/docs/formats/segment-store-v2/gc.md index 9b46696c..4330bc2c 100644 --- a/docs/formats/segment-store-v2/gc.md +++ b/docs/formats/segment-store-v2/gc.md @@ -173,6 +173,58 @@ recovery/dispositions/.receipt The version-2 maximum is 65,536 disposition receipts. A future successor must migrate the namespace before raising the ceiling. +## Planning + +`plan_gc(&GcLivenessSnapshot, GcLimits)` is the pure, deterministic +comparison ADR-0009 requires between one immutable liveness snapshot and one +bounded physical inventory. It reads nothing and writes nothing. +`observe_gc_liveness` assembles the snapshot from a fenced +`FilesystemRetentionSnapshot`: it re-admits the fenced catalog, projects every +retained root's verified closure onto the segments that hold its records, +reads and admits every entry of the segment pool within the +`CatalogRestartPolicy` byte bound, and walks the catalog predecessor chain to +find segments a durably published successor superseded. No +`RecoveryDispositionReceipt` codec exists yet, so nothing is reported +disposed. + +The plan classifies every inventoried segment exactly once, in this order: + + + +| Classification | Meaning | Candidate | +| --- | --- | --- | +| `live` | the current catalog names it and at least one retained closure reaches it | no | +| `named-unreachable` | the current catalog names it and no retained closure reaches it; only a compaction successor can release it | no | +| `recovery-protected` | no catalog in the chain names it and no disposition retires it: an orphan of an interrupted publication | no | +| `unreachable-superseded` | a predecessor catalog named it and the current catalog omits it | yes | +| `unreachable-disposed` | a durable disposition receipt retired it | yes | + + + +A superseded or disposed segment absent from the inventory is reported as +already retired. Any contradiction refuses the whole plan as a typed +`GcPlanAmbiguity`: a named segment absent from the inventory, a closure +member the catalog does not name or the inventory lacks, or a superseded or +disposed segment the current catalog still names. More candidates than +`GcLimits` admit refuse rather than truncate. Reader protection is not a +planning classification: execution takes writer authority and the exclusive +reader lock and re-proves every coordinate the plan names before acting. + +The plan for the frozen version-2 store is +[`gc-plan.tsv`](../../../conformance/segment-store/v2/gc-plan.tsv); the +planner laws in `src/adapters/gc/planner_tests.rs` cover every +classification, every ambiguity, the limit, the golden plan, and a +512-universe model in which the live set is always exactly the union of the +retained closures and no live or named segment is ever a candidate. + +> **Warning.** Everything below this line describes execution, which +> unlinks immutable segments. Execution is not implemented. When it is, it +> must hold writer authority and the exclusive reader lock, write and +> synchronize `gc/intent` before the first unlink, act only on a plan whose +> coordinates it has re-proven against the reopened store, and be verified +> afterwards by a recovery report. `plan_gc` is the dry run: it changes +> nothing on disk. + ## State and recovery GC admits these states: @@ -202,7 +254,7 @@ synchronizes `recovery`. Until that completes, the artifact remains recovery-protected. The intent and receipt grammars have golden fixtures, parsers, corruption -matrices, and a seeded fuzz target. The disposition grammar's fixture and -parser, and every crash point, model, benchmark, execution, and recovery -law, are **Planned in #21**. Issue #19 must refuse their physical presence -without mutating it. +matrices, and a seeded fuzz target; the planner has its golden plan and model +law. The disposition grammar's fixture and parser, and every crash point, +benchmark, execution, and recovery law, are **Planned in #21**. Namespace +admission must refuse their physical presence without mutating it. diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index d72e6033..412aee5c 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -46,7 +46,7 @@ case is not evidence. | ID | Requirement | Evidence | Status | | --- | --- | --- | --- | | `KEEP-GC-001` | Version 2 specifies exact bounded GC intent, receipt, and recovery-disposition grammars but refuses their presence until their parser and recovery protocol are implemented | intent and receipt golden, canonical re-encoding, cross-record binding, and field-by-field corruption laws in `tests/gc_retirement_intent.rs`, `tests/gc_retirement_intent/mutation_laws.rs`, and `tests/gc_retirement_receipt.rs`; seeded `gc_format` fuzz target; the disposition-receipt codec waits for its registered enumerations to be frozen in `definition.tsv`; presence refusal in namespace admission tests | In progress in #21 | -| `KEEP-GC-002` | GC intent, receipt, disposition, reader-fence, retirement, compaction, and recovery laws implement ADR-0009 without changing logical identity | golden-format, model-based, corruption, crash-injection, benchmark, and fuzz evidence | Planned in #21 | +| `KEEP-GC-002` | GC intent, receipt, disposition, reader-fence, retirement, compaction, and recovery laws implement ADR-0009 without changing logical identity | deterministic planning: `plan_gc` classifies every inventoried segment against one fenced liveness snapshot, refuses every contradiction, and never names a live or catalog-named segment, proven by one law per classification and ambiguity, the golden `gc-plan.tsv`, a 512-universe model, and filesystem laws over the migrated fixture store in `src/adapters/gc/`; execution, compaction, disposition, and recovery remain golden-format, model-based, corruption, crash-injection, benchmark, and fuzz evidence Planned in #21 | In progress in #21 | diff --git a/src/adapters/admitted_catalog.rs b/src/adapters/admitted_catalog.rs index 51af0f55..4b41cca8 100644 --- a/src/adapters/admitted_catalog.rs +++ b/src/adapters/admitted_catalog.rs @@ -1,8 +1,11 @@ //! Catalog whose logical records are bound to admitted segment bytes. +use std::collections::BTreeMap; + use super::{ - AdmittedSegmentRecord, CatalogRecordBinding, CatalogSuccessor, CatalogTransitionError, - ChecksummedCatalog, SegmentRecordIdentity, catalog_transition, + AdmittedSegmentRecord, CatalogDecodeError, CatalogRecordBinding, CatalogSuccessor, + CatalogTransitionError, ChecksummedCatalog, SegmentDigest, SegmentRecordIdentity, + catalog_transition, }; use crate::{CatalogDigest, CatalogGeneration, CatalogLength}; @@ -60,6 +63,21 @@ impl<'catalog, 'records> AdmittedCatalog<'catalog, 'records> { .map(CatalogRecordBinding::record) } + /// Maps every logical identity to the physical segment that holds it. + /// + /// Physical GC planning needs the projection from records to their + /// containers; no other reader does, so it stays crate-private. + pub(crate) fn record_segments( + &self, + ) -> Result, CatalogDecodeError> { + let mut segments = BTreeMap::new(); + for entry in self.catalog.entries()? { + let entry = entry?; + segments.insert(entry.identity(), entry.segment_digest()); + } + Ok(segments) + } + /// Admits a fully verified candidate as this snapshot's exact successor. /// /// # Errors diff --git a/src/adapters/catalog_snapshot.rs b/src/adapters/catalog_snapshot.rs index 98991fe1..aa05c827 100644 --- a/src/adapters/catalog_snapshot.rs +++ b/src/adapters/catalog_snapshot.rs @@ -1,7 +1,10 @@ //! Immutable reader snapshot pinned by one head and admitted catalog. +use std::collections::BTreeMap; + use super::{ - AdmittedCatalog, AdmittedSegmentRecord, ChecksummedPublicationHead, SegmentRecordIdentity, + AdmittedCatalog, AdmittedSegmentRecord, CatalogDecodeError, ChecksummedPublicationHead, + SegmentDigest, SegmentRecordIdentity, }; use crate::{CatalogDigest, CatalogGeneration, CatalogLength}; @@ -52,6 +55,13 @@ impl<'head, 'catalog, 'records> CatalogSnapshot<'head, 'catalog, 'records> { self.catalog.record(identity) } + /// Maps every logical identity to the physical segment that holds it. + pub(crate) fn record_segments( + &self, + ) -> Result, CatalogDecodeError> { + self.catalog.record_segments() + } + pub(super) const fn new( head: ChecksummedPublicationHead<'head>, catalog: AdmittedCatalog<'catalog, 'records>, diff --git a/src/adapters/checksummed_catalog.rs b/src/adapters/checksummed_catalog.rs index c86e675f..efe44030 100644 --- a/src/adapters/checksummed_catalog.rs +++ b/src/adapters/checksummed_catalog.rs @@ -1,8 +1,10 @@ //! Canonically framed, checksum- and digest-verified borrowed catalog. +use std::collections::BTreeSet; + use super::{ AdmittedCatalog, AdmittedSegment, CatalogAdmissionError, CatalogDecodeError, CatalogEntries, - catalog_admission, catalog_decoder, + SegmentDigest, catalog_admission, catalog_decoder, }; use crate::{CatalogDigest, CatalogGeneration, CatalogLength}; @@ -122,6 +124,17 @@ impl<'a> ChecksummedCatalog<'a> { CatalogEntries::new(self.encoded, self.metadata.entry_count) } + /// Collects every physical segment this catalog names, without admitting + /// the segments themselves. GC planning uses it to walk the predecessor + /// chain; nothing else needs a catalog's containers without its records. + pub(crate) fn segment_digests(self) -> Result, CatalogDecodeError> { + let mut digests = BTreeSet::new(); + for entry in self.entries()? { + digests.insert(entry?.segment_digest()); + } + Ok(digests) + } + pub(super) const fn from_verified_parts( encoded: &'a [u8], metadata: CatalogMetadata, diff --git a/src/adapters/gc/liveness_coordinates.rs b/src/adapters/gc/liveness_coordinates.rs new file mode 100644 index 00000000..12abaa8f --- /dev/null +++ b/src/adapters/gc/liveness_coordinates.rs @@ -0,0 +1,62 @@ +//! This boundary module owns the immutable coordinates a GC plan binds. + +use crate::{CatalogDigest, CatalogGeneration, LivenessGeneration, RetentionManifestDigest}; + +/// The retention state a liveness snapshot was taken under. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GcRetentionState { + /// No retention head has been published: the canonical empty retention + /// state, in which nothing is retained and every anchor-less segment is + /// unreachable from retention. + Empty, + /// One published retention head selects one manifest. + Published { + /// The manifest's liveness generation. + generation: LivenessGeneration, + /// The exact manifest digest. + manifest_digest: RetentionManifestDigest, + }, +} + +/// The exact catalog and retention view one GC plan was computed against. +/// +/// A plan is a statement about exactly these coordinates. Execution must +/// reopen the store and prove the same coordinates before acting on it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GcLivenessCoordinates { + catalog_generation: CatalogGeneration, + catalog_digest: CatalogDigest, + retention: GcRetentionState, +} + +impl GcLivenessCoordinates { + /// Binds one catalog generation and digest to one retention state. + #[must_use] + pub const fn new( + catalog_generation: CatalogGeneration, + catalog_digest: CatalogDigest, + retention: GcRetentionState, + ) -> Self { + Self { + catalog_generation, + catalog_digest, + retention, + } + } + + /// Returns the catalog generation records were resolved against. + pub const fn catalog_generation(self) -> CatalogGeneration { + self.catalog_generation + } + + /// Returns the exact catalog digest records were resolved against. + pub const fn catalog_digest(self) -> CatalogDigest { + self.catalog_digest + } + + /// Returns the retention state the snapshot was taken under. + #[must_use] + pub const fn retention(self) -> GcRetentionState { + self.retention + } +} diff --git a/src/adapters/gc/liveness_observation.rs b/src/adapters/gc/liveness_observation.rs new file mode 100644 index 00000000..b0e837c3 --- /dev/null +++ b/src/adapters/gc/liveness_observation.rs @@ -0,0 +1,217 @@ +//! This boundary module assembles one liveness snapshot from a fenced view. +//! +//! Observation reads; it never writes. It re-admits the fenced catalog, +//! projects every retained root's verified closure onto segments, reads and +//! admits the whole segment pool, walks the catalog predecessor chain for +//! superseded segments, and hands the result to the pure planner. No +//! recovery-disposition receipt exists yet, so nothing is reported disposed. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use cap_fs_ext::DirExt; +use cap_std::fs::Dir; + +use super::{ + GcLivenessCoordinates, GcLivenessObservationError as Error, GcLivenessSnapshot, + GcRetainedClosure, GcRetentionState, +}; +use crate::adapters::retention::{AdmittedRetentionRoot, verify_retention_closure_members}; +use crate::adapters::{ + CatalogRestartArtifact, CatalogRestartPhase, CatalogRestartPolicy, CatalogSnapshot, + ChecksummedCatalog, FilesystemRetentionSnapshot, SegmentDigest, SegmentRecordIdentity, + catalog_restart_io, physical_pool_name, +}; +use crate::{CatalogDigest, CatalogGeneration, CatalogLength}; + +use super::segment_pool_inventory; + +const SEGMENTS: &str = "segments"; +const CATALOGS: &str = "catalogs"; + +/// Assembles the liveness snapshot the fenced `view` of `store_root` admits. +/// +/// `policy` bounds the segment grammar and the total bytes read from the +/// segment pool. The returned snapshot binds the view's catalog generation +/// and digest and its retention state, so a plan computed from it can only +/// be executed against exactly that view. +/// +/// # Errors +/// +/// Returns [`GcLivenessObservationError`](Error) at the exact catalog, root, +/// closure, pool, or chain refusal. No partial snapshot is returned. +pub fn observe_gc_liveness( + store_root: &Path, + view: &FilesystemRetentionSnapshot, + policy: CatalogRestartPolicy, +) -> Result { + let catalog = view + .catalog() + .snapshot() + .map_err(|source| Error::Catalog { source })?; + let record_segments = catalog + .record_segments() + .map_err(|source| Error::CatalogEntries { source })?; + let mut snapshot = GcLivenessSnapshot::new(GcLivenessCoordinates::new( + catalog.generation(), + catalog.catalog_digest(), + retention_state(view), + )); + let named: BTreeSet = record_segments.values().copied().collect(); + for segment in &named { + snapshot.name_segment(*segment); + } + retain_closures(view, &catalog, &record_segments, &mut snapshot)?; + let root = Dir::open_ambient_dir(store_root, cap_std::ambient_authority()) + .map_err(|source| Error::pool("open store root", source))?; + let segments = root + .open_dir_nofollow(SEGMENTS) + .map_err(|source| Error::pool("open segment pool", source))?; + let inventory = segment_pool_inventory::read( + &segments, + policy.segment_read(), + policy.retained_segment_bytes().get(), + )?; + for (segment, length) in inventory { + snapshot + .inventory_segment(segment, length) + .map_err(|source| Error::Snapshot { source })?; + } + let catalogs = root + .open_dir_nofollow(CATALOGS) + .map_err(|source| Error::pool("open catalog pool", source))?; + for segment in superseded_segments(&catalogs, &catalog, &named)? { + snapshot.supersede_segment(segment); + } + Ok(snapshot) +} + +fn retention_state(view: &FilesystemRetentionSnapshot) -> GcRetentionState { + view.retention_head() + .map_or(GcRetentionState::Empty, |head| { + GcRetentionState::Published { + generation: head.generation(), + manifest_digest: head.manifest_digest(), + } + }) +} + +fn retain_closures( + view: &FilesystemRetentionSnapshot, + catalog: &CatalogSnapshot<'_, '_, '_>, + record_segments: &BTreeMap, + snapshot: &mut GcLivenessSnapshot, +) -> Result<(), Error> { + let Some(manifest) = view.manifest() else { + return Ok(()); + }; + for entry in manifest.entries() { + let namespace = entry.namespace(); + let bytes = view + .retained_root(namespace) + .map_err(|source| Error::RetainedRoot { + namespace, + source: Box::new(source), + })? + .ok_or(Error::RetainedRootAbsent { namespace })?; + let root = AdmittedRetentionRoot::decode(&bytes).map_err(|source| Error::RetainedRoot { + namespace, + source: Box::new(source), + })?; + let members = verify_retention_closure_members(root.root(), catalog).map_err(|source| { + Error::Closure { + namespace, + source: Box::new(source), + } + })?; + let mut segments = BTreeSet::new(); + for identity in &members.identities { + let segment = record_segments + .get(identity) + .ok_or(Error::ClosureMemberUnindexed { namespace })?; + segments.insert(*segment); + } + snapshot + .retain(GcRetainedClosure::new( + namespace, + root.root().generation(), + root.digest(), + members.closure.digest(), + segments, + )) + .map_err(|source| Error::Snapshot { source })?; + } + Ok(()) +} + +/// Walks the catalog chain from the current catalog's predecessor to +/// generation one and returns every segment a predecessor names that the +/// current catalog does not. +fn superseded_segments( + catalogs: &Dir, + current: &CatalogSnapshot<'_, '_, '_>, + named: &BTreeSet, +) -> Result, Error> { + let mut superseded = BTreeSet::new(); + let mut generation = current.generation(); + let mut next = current.previous_catalog_digest(); + while let Some(digest) = next { + generation = predecessor_generation(generation)?; + let bytes = read_catalog(catalogs, generation, digest)?; + let catalog = + ChecksummedCatalog::decode(&bytes).map_err(|source| Error::PredecessorCatalog { + generation, + source: Box::new(source), + })?; + if catalog.generation() != generation || catalog.digest() != digest { + return Err(Error::PredecessorCatalogMismatch { generation }); + } + let segments = catalog + .segment_digests() + .map_err(|source| Error::PredecessorCatalog { + generation, + source: Box::new(source), + })?; + superseded.extend(segments.difference(named).copied()); + next = catalog.previous_catalog_digest(); + } + Ok(superseded) +} + +fn predecessor_generation(generation: CatalogGeneration) -> Result { + let previous = generation + .get() + .checked_sub(1) + .ok_or(Error::PredecessorCatalogMismatch { generation })?; + CatalogGeneration::new(previous) + .map_err(|_source| Error::PredecessorCatalogMismatch { generation }) +} + +fn read_catalog( + catalogs: &Dir, + generation: CatalogGeneration, + digest: CatalogDigest, +) -> Result, Error> { + let name = physical_pool_name::catalog(generation, digest); + let restart = |source| Error::PredecessorCatalog { + generation, + source: Box::new(source), + }; + let (file, length) = catalog_restart_io::open_regular( + catalogs, + &name, + CatalogRestartArtifact::Catalog, + CatalogRestartPhase::OpenCatalog, + ) + .map_err(restart)?; + if length > CatalogLength::MAXIMUM.get() { + return Err(Error::PredecessorCatalogMismatch { generation }); + } + catalog_restart_io::read_exact( + file, + CatalogRestartArtifact::Catalog, + CatalogRestartPhase::ReadCatalog, + length, + ) + .map_err(restart) +} diff --git a/src/adapters/gc/liveness_observation_error.rs b/src/adapters/gc/liveness_observation_error.rs new file mode 100644 index 00000000..652c61fd --- /dev/null +++ b/src/adapters/gc/liveness_observation_error.rs @@ -0,0 +1,180 @@ +//! This boundary module owns GC liveness observation refusals. + +use std::error::Error; +use std::fmt; +use std::io; + +use super::GcLivenessSnapshotError; +use crate::adapters::{CatalogDecodeError, CatalogRestartError, SegmentDigest, SegmentReadError}; +use crate::{CatalogGeneration, RetentionClosureVerificationError, RetentionNamespaceDigest}; + +/// Failure to assemble one liveness snapshot from a fenced store view. +/// +/// Every variant is a refusal: nothing is planned from a store whose evidence +/// could not be admitted in full. +#[derive(Debug)] +pub enum GcLivenessObservationError { + /// The fenced catalog could not be re-admitted. + Catalog { + /// The exact restart refusal. + source: CatalogRestartError, + }, + /// The catalog's record-to-segment projection could not be decoded. + CatalogEntries { + /// The exact decode refusal. + source: CatalogDecodeError, + }, + /// The manifest names a root the roots pool does not hold. + RetainedRootAbsent { + /// The namespace whose root is missing. + namespace: RetentionNamespaceDigest, + }, + /// A retained root could not be read or decoded. + RetainedRoot { + /// The namespace. + namespace: RetentionNamespaceDigest, + /// The exact snapshot or decode refusal. + source: Box, + }, + /// A retained root's closure no longer verifies against the catalog. + Closure { + /// The namespace. + namespace: RetentionNamespaceDigest, + /// The exact closure refusal. + source: Box, + }, + /// A closure member resolved through the catalog has no catalog entry. + ClosureMemberUnindexed { + /// The namespace. + namespace: RetentionNamespaceDigest, + }, + /// The segment or catalog pool refused an I/O action. + Pool { + /// The action. + action: &'static str, + /// The original error. + source: io::Error, + }, + /// A pool entry is not named by a lowercase segment digest. + PoolEntryName, + /// A pool entry is not a regular file. + PoolEntryKind { + /// The entry's declared segment. + segment: SegmentDigest, + }, + /// Reading the pool would exceed the byte limit. + PoolByteLimit { + /// The limit. + limit: u64, + }, + /// A pool segment did not admit. + SegmentAdmission { + /// The segment named by the entry. + segment: SegmentDigest, + /// The exact segment refusal. + source: Box, + }, + /// A pool segment admits but hashes to a different digest than its name. + SegmentDigestMismatch { + /// The name's digest. + expected: SegmentDigest, + /// The bytes' digest. + observed: SegmentDigest, + }, + /// A predecessor catalog in the chain could not be read or decoded. + PredecessorCatalog { + /// The predecessor generation. + generation: CatalogGeneration, + /// The exact refusal. + source: Box, + }, + /// A predecessor catalog does not carry the generation and digest the + /// chain names. + PredecessorCatalogMismatch { + /// The expected generation. + generation: CatalogGeneration, + }, + /// The snapshot could not be assembled without contradiction. + Snapshot { + /// The exact contradiction. + source: GcLivenessSnapshotError, + }, +} + +impl GcLivenessObservationError { + pub(super) const fn pool(action: &'static str, source: io::Error) -> Self { + Self::Pool { action, source } + } +} + +impl fmt::Display for GcLivenessObservationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Catalog { .. } => formatter.write_str("fenced catalog could not be re-admitted"), + Self::CatalogEntries { .. } => { + formatter.write_str("catalog record-to-segment projection could not be decoded") + } + Self::RetainedRootAbsent { .. } => { + formatter.write_str("manifest names a root the roots pool does not hold") + } + Self::RetainedRoot { .. } => formatter.write_str("retained root refused"), + Self::Closure { .. } => { + formatter.write_str("retained closure no longer verifies against the catalog") + } + Self::ClosureMemberUnindexed { .. } => { + formatter.write_str("closure member has no catalog entry") + } + Self::Pool { action, .. } => write!(formatter, "segment pool refused to {action}"), + Self::PoolEntryName => { + formatter.write_str("segment pool entry is not named by a lowercase digest") + } + Self::PoolEntryKind { .. } => { + formatter.write_str("segment pool entry is not a regular file") + } + Self::PoolByteLimit { limit } => { + write!( + formatter, + "segment pool exceeds the {limit}-byte read limit" + ) + } + Self::SegmentAdmission { .. } => formatter.write_str("pool segment did not admit"), + Self::SegmentDigestMismatch { .. } => { + formatter.write_str("pool segment hashes to a digest other than its name") + } + Self::PredecessorCatalog { generation, .. } => write!( + formatter, + "predecessor catalog generation {} refused", + generation.get() + ), + Self::PredecessorCatalogMismatch { generation } => write!( + formatter, + "predecessor catalog generation {} carries other coordinates", + generation.get() + ), + Self::Snapshot { .. } => formatter.write_str("liveness snapshot contradicts itself"), + } + } +} + +impl Error for GcLivenessObservationError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Catalog { source } => Some(source), + Self::CatalogEntries { source } => Some(source), + Self::RetainedRoot { source, .. } | Self::PredecessorCatalog { source, .. } => { + Some(source.as_ref()) + } + Self::Closure { source, .. } => Some(source.as_ref()), + Self::Pool { source, .. } => Some(source), + Self::SegmentAdmission { source, .. } => Some(source.as_ref()), + Self::Snapshot { source } => Some(source), + Self::RetainedRootAbsent { .. } + | Self::ClosureMemberUnindexed { .. } + | Self::PoolEntryName + | Self::PoolEntryKind { .. } + | Self::PoolByteLimit { .. } + | Self::SegmentDigestMismatch { .. } + | Self::PredecessorCatalogMismatch { .. } => None, + } + } +} diff --git a/src/adapters/gc/liveness_observation_tests.rs b/src/adapters/gc/liveness_observation_tests.rs new file mode 100644 index 00000000..14c08650 --- /dev/null +++ b/src/adapters/gc/liveness_observation_tests.rs @@ -0,0 +1,148 @@ +//! Filesystem GC liveness laws over the migrated fixture store. + +use std::error::Error; +use std::fs; +use std::path::Path; + +use super::{ + GcLimits, GcLivenessObservationError, GcRetentionState, GcSegmentClassification, + observe_gc_liveness, plan_gc, +}; +use crate::adapters::retention::filesystem_retention_test_fixture::{ + ROOT_HEX, catalog_policy, fixture, initial_preparation, migrated_store, reopen_authority, +}; +use crate::adapters::test_support::decode_hex; +use crate::adapters::{FilesystemRetentionSnapshot, ReaderAttemptLimit}; +use crate::execute_retention_publication; + +const ORPHAN_SEGMENT_HEX: &str = + include_str!("../../../conformance/segment-store/v1/one-zero-segment.hex"); +const ORPHAN_SEGMENT_NAME: &str = + "b7542dced2ab770894a14d1d04b066e3a899942602c5986d35ba6df6c1a35cfc.seg"; + +fn published_store( + name: &str, +) -> Result> { + let sandbox = migrated_store(name)?; + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + let mut authority = reopen_authority(sandbox.path())?; + let _published = execute_retention_publication(&mut authority, &preparation)?; + drop(authority); + Ok(sandbox) +} + +fn observe(root: &Path) -> Result> { + let view = + FilesystemRetentionSnapshot::load(root, catalog_policy()?, ReaderAttemptLimit::DEFAULT)?; + observe_gc_liveness(root, &view, catalog_policy()?).map_err(Into::into) +} + +#[test] +fn the_published_fixture_store_plans_its_one_segment_live() -> Result<(), Box> { + let sandbox = published_store("gc-liveness-published")?; + + let snapshot = observe(sandbox.path())?; + let plan = plan_gc(&snapshot, GcLimits::MAXIMUM)?; + + assert!(matches!( + snapshot.coordinates().retention(), + GcRetentionState::Published { .. } + )); + assert_eq!(snapshot.retained().len(), 1); + assert_eq!(plan.segments().len(), 1); + assert!(plan.segments().values().all(|planned| { + planned.classification() == GcSegmentClassification::Live { retained_roots: 1 } + })); + assert_eq!(plan.candidate_count(), 0); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn an_unpublished_store_plans_its_named_segment_unreachable_but_not_collectible() +-> Result<(), Box> { + let sandbox = migrated_store("gc-liveness-empty-retention")?; + + let snapshot = observe(sandbox.path())?; + let plan = plan_gc(&snapshot, GcLimits::MAXIMUM)?; + + assert_eq!(snapshot.coordinates().retention(), GcRetentionState::Empty); + assert!(snapshot.retained().is_empty()); + assert!( + plan.segments().values().all(|planned| { + planned.classification() == GcSegmentClassification::NamedUnreachable + }) + ); + assert_eq!(plan.candidate_count(), 0); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn an_orphan_pool_segment_is_recovery_protected_and_never_a_candidate() -> Result<(), Box> +{ + let sandbox = published_store("gc-liveness-orphan")?; + let orphan = decode_hex(ORPHAN_SEGMENT_HEX.trim())?; + fs::write( + sandbox.path().join("segments").join(ORPHAN_SEGMENT_NAME), + &orphan, + )?; + + let snapshot = observe(sandbox.path())?; + let plan = plan_gc(&snapshot, GcLimits::MAXIMUM)?; + + assert_eq!(plan.segments().len(), 2); + let protected = plan + .segments() + .values() + .filter(|planned| planned.classification() == GcSegmentClassification::RecoveryProtected) + .count(); + assert_eq!(protected, 1); + assert_eq!(plan.candidate_count(), 0); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn a_corrupt_pool_segment_refuses_observation_before_any_plan() -> Result<(), Box> { + let sandbox = published_store("gc-liveness-corrupt")?; + let mut orphan = decode_hex(ORPHAN_SEGMENT_HEX.trim())?; + let last = orphan.last_mut().ok_or("orphan is empty")?; + *last ^= 1; + fs::write( + sandbox.path().join("segments").join(ORPHAN_SEGMENT_NAME), + &orphan, + )?; + + let error = observe(sandbox.path()) + .err() + .ok_or("a corrupt pool segment was unexpectedly observed")?; + + let error = error + .downcast::() + .map_err(|_error| "observation refused with the wrong error type")?; + assert!(matches!( + *error, + GcLivenessObservationError::SegmentAdmission { .. } + )); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn a_pool_entry_not_named_by_a_digest_refuses_observation() -> Result<(), Box> { + let sandbox = published_store("gc-liveness-stray")?; + fs::write(sandbox.path().join("segments").join("stray.seg"), b"x")?; + + let error = observe(sandbox.path()) + .err() + .ok_or("a stray pool entry was unexpectedly observed")?; + + let error = error + .downcast::() + .map_err(|_error| "observation refused with the wrong error type")?; + assert!(matches!(*error, GcLivenessObservationError::PoolEntryName)); + sandbox.remove()?; + Ok(()) +} diff --git a/src/adapters/gc/liveness_snapshot.rs b/src/adapters/gc/liveness_snapshot.rs new file mode 100644 index 00000000..dd2aa32d --- /dev/null +++ b/src/adapters/gc/liveness_snapshot.rs @@ -0,0 +1,156 @@ +//! This boundary module owns the immutable liveness snapshot GC plans from. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use super::{GcLivenessCoordinates, GcRetainedClosure}; +use crate::RetentionNamespaceDigest; +use crate::adapters::SegmentDigest; + +/// One immutable liveness snapshot plus one bounded physical inventory. +/// +/// The snapshot is assembled by an observer that has already admitted every +/// byte it names; the planner reads it and nothing else. Every collection is +/// canonically ordered so planning is deterministic. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GcLivenessSnapshot { + coordinates: GcLivenessCoordinates, + inventory: BTreeMap, + named: BTreeSet, + retained: Vec, + superseded: BTreeSet, + disposed: BTreeSet, +} + +/// A snapshot that could not be assembled without contradiction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GcLivenessSnapshotError { + /// One segment was inventoried twice. + DuplicateInventory { + /// The repeated segment. + segment: SegmentDigest, + }, + /// One namespace was retained twice. + DuplicateNamespace { + /// The repeated namespace. + namespace: RetentionNamespaceDigest, + }, +} + +impl fmt::Display for GcLivenessSnapshotError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateInventory { .. } => { + formatter.write_str("segment inventoried twice in one liveness snapshot") + } + Self::DuplicateNamespace { .. } => { + formatter.write_str("namespace retained twice in one liveness snapshot") + } + } + } +} + +impl std::error::Error for GcLivenessSnapshotError {} + +impl GcLivenessSnapshot { + /// Starts an empty snapshot under `coordinates`. + pub const fn new(coordinates: GcLivenessCoordinates) -> Self { + Self { + coordinates, + inventory: BTreeMap::new(), + named: BTreeSet::new(), + retained: Vec::new(), + superseded: BTreeSet::new(), + disposed: BTreeSet::new(), + } + } + + /// Records one physical segment present in the pool with its length. + /// + /// # Errors + /// + /// Returns [`GcLivenessSnapshotError::DuplicateInventory`] when the + /// segment was already inventoried. + pub fn inventory_segment( + &mut self, + segment: SegmentDigest, + length: u64, + ) -> Result<(), GcLivenessSnapshotError> { + if self.inventory.insert(segment, length).is_some() { + return Err(GcLivenessSnapshotError::DuplicateInventory { segment }); + } + Ok(()) + } + + /// Records that the current catalog names at least one record in `segment`. + pub fn name_segment(&mut self, segment: SegmentDigest) { + self.named.insert(segment); + } + + /// Records one retained root's verified closure. + /// + /// # Errors + /// + /// Returns [`GcLivenessSnapshotError::DuplicateNamespace`] when the + /// namespace already has a retained closure. + pub fn retain(&mut self, closure: GcRetainedClosure) -> Result<(), GcLivenessSnapshotError> { + let namespace = closure.namespace(); + if self + .retained + .iter() + .any(|retained| retained.namespace() == namespace) + { + return Err(GcLivenessSnapshotError::DuplicateNamespace { namespace }); + } + self.retained.push(closure); + Ok(()) + } + + /// Records that a predecessor catalog in the pool's chain named `segment` + /// and the current catalog no longer does: the segment was superseded by + /// a durably published catalog successor. + pub fn supersede_segment(&mut self, segment: SegmentDigest) { + self.superseded.insert(segment); + } + + /// Records that a durable `RecoveryDispositionReceipt` retired `segment`. + pub fn dispose_segment(&mut self, segment: SegmentDigest) { + self.disposed.insert(segment); + } + + /// Returns the coordinates the snapshot binds. + #[must_use] + pub const fn coordinates(&self) -> GcLivenessCoordinates { + self.coordinates + } + + /// Returns every inventoried segment with its length. + #[must_use] + pub const fn inventory(&self) -> &BTreeMap { + &self.inventory + } + + /// Returns every segment the current catalog names. + #[must_use] + pub const fn named(&self) -> &BTreeSet { + &self.named + } + + /// Returns every retained closure in retention order. + pub fn retained(&self) -> &[GcRetainedClosure] { + &self.retained + } + + /// Returns every superseded segment. + #[must_use] + pub const fn superseded(&self) -> &BTreeSet { + &self.superseded + } + + /// Returns every segment with a durable retirement disposition. + #[must_use] + pub const fn disposed(&self) -> &BTreeSet { + &self.disposed + } +} diff --git a/src/adapters/gc/mod.rs b/src/adapters/gc/mod.rs index bb06063a..64809349 100644 --- a/src/adapters/gc/mod.rs +++ b/src/adapters/gc/mod.rs @@ -1,10 +1,11 @@ //! Canonical garbage-collection record adapters for `keep.segment-store/v2`. //! //! This module owns the semantic GC retirement intent and receipt, their -//! canonical encoders, and their admitting decoders. It does not own GC -//! planning, execution, reader fencing, recovery, or the recovery-disposition -//! receipt, whose registered enumerations are not yet frozen in the format -//! definition. +//! canonical encoders, and their admitting decoders, and the deterministic +//! planner that classifies one physical inventory against one immutable +//! liveness snapshot. It does not own GC execution, reader fencing, +//! recovery, or the recovery-disposition receipt, whose registered +//! enumerations are not yet frozen in the format definition. mod admitted_intent; mod admitted_receipt; @@ -25,6 +26,18 @@ mod intent_format; mod intent_header_decoder; mod intent_integrity; mod intent_semantic_header; +mod liveness_coordinates; +mod liveness_observation; +mod liveness_observation_error; +#[cfg(test)] +mod liveness_observation_tests; +mod liveness_snapshot; +mod plan; +mod plan_error; +mod plan_limits; +mod planner; +#[cfg(test)] +mod planner_tests; mod reader_lock_identity; mod receipt; mod receipt_bytes; @@ -33,6 +46,9 @@ mod receipt_decoder; mod receipt_encoder; mod receipt_format; mod record_digests; +mod retained_closure; +mod segment_classification; +mod segment_pool_inventory; pub use admitted_intent::AdmittedGcRetirementIntent; pub use admitted_receipt::AdmittedGcRetirementReceipt; @@ -47,7 +63,17 @@ pub use intent::GcRetirementIntent; pub use intent_coordinates::GcRetirementIntentCoordinates; pub use intent_decode_error::{GcRetirementIntentDecodeError, GcRetirementIntentEncodeError}; pub use intent_error::GcRetirementIntentError; +pub use liveness_coordinates::{GcLivenessCoordinates, GcRetentionState}; +pub use liveness_observation::observe_gc_liveness; +pub use liveness_observation_error::GcLivenessObservationError; +pub use liveness_snapshot::{GcLivenessSnapshot, GcLivenessSnapshotError}; +pub use plan::{GcPlan, GcPlannedCandidate, GcPlannedSegment}; +pub use plan_error::{GcPlanAmbiguity, GcPlanError}; +pub use plan_limits::{GcLimits, GcLimitsError}; +pub use planner::plan_gc; pub use reader_lock_identity::{ReaderLockCoordinate, ReaderLockIdentity}; pub use receipt::GcRetirementReceipt; pub use receipt_decode_error::GcRetirementReceiptDecodeError; pub use record_digests::{GcCandidateSetDigest, GcRetirementIntentDigest}; +pub use retained_closure::GcRetainedClosure; +pub use segment_classification::{GcSegmentClassification, GcUnreachableEvidence}; diff --git a/src/adapters/gc/plan.rs b/src/adapters/gc/plan.rs new file mode 100644 index 00000000..5387965a --- /dev/null +++ b/src/adapters/gc/plan.rs @@ -0,0 +1,147 @@ +//! This boundary module owns the immutable, inspectable GC plan. + +use std::collections::{BTreeMap, BTreeSet}; + +use super::{GcLivenessCoordinates, GcSegmentClassification}; +use crate::adapters::SegmentDigest; + +/// One inventoried segment's length and classification. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GcPlannedSegment { + length: u64, + classification: GcSegmentClassification, +} + +impl GcPlannedSegment { + pub(super) const fn new(length: u64, classification: GcSegmentClassification) -> Self { + Self { + length, + classification, + } + } + + /// Returns the inventoried segment length. + #[must_use] + pub const fn length(self) -> u64 { + self.length + } + + /// Returns the classification. + #[must_use] + pub const fn classification(self) -> GcSegmentClassification { + self.classification + } +} + +/// One collectible segment, in canonical digest order within its plan. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GcPlannedCandidate { + segment: SegmentDigest, + length: u64, +} + +impl GcPlannedCandidate { + /// Returns the candidate segment digest. + #[must_use] + pub const fn segment(self) -> SegmentDigest { + self.segment + } + + /// Returns the candidate segment length. + #[must_use] + pub const fn length(self) -> u64 { + self.length + } +} + +/// The deterministic classification of one physical inventory against one +/// liveness snapshot. +/// +/// A plan is a statement, not an action. It names the coordinates it was +/// computed against, classifies every inventoried segment, and lists the +/// collectible candidates in canonical order. Nothing about it touches +/// storage; the retirement intent is derived from it under writer authority +/// and the exclusive reader lock, after execution has re-proven every +/// coordinate. +#[must_use = "a GC plan is evidence; discarding it collects nothing"] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GcPlan { + coordinates: GcLivenessCoordinates, + segments: BTreeMap, + retired: BTreeSet, + candidate_count: u32, +} + +impl GcPlan { + pub(super) const fn new( + coordinates: GcLivenessCoordinates, + segments: BTreeMap, + retired: BTreeSet, + candidate_count: u32, + ) -> Self { + Self { + coordinates, + segments, + retired, + candidate_count, + } + } + + /// Returns the coordinates the plan was computed against. + #[must_use] + pub const fn coordinates(&self) -> GcLivenessCoordinates { + self.coordinates + } + + /// Returns every inventoried segment with its classification. + #[must_use] + pub const fn segments(&self) -> &BTreeMap { + &self.segments + } + + /// Returns one segment's classification, or `None` if it was not + /// inventoried. + #[must_use] + pub fn classification(&self, segment: SegmentDigest) -> Option { + self.segments + .get(&segment) + .map(|planned| planned.classification()) + } + + /// Returns the segments that were superseded or disposed and are already + /// absent from the inventory: retired by an earlier collection. + #[must_use] + pub const fn already_retired(&self) -> &BTreeSet { + &self.retired + } + + /// Returns the collectible candidates in canonical digest order. + pub fn candidates(&self) -> impl Iterator + '_ { + self.segments + .iter() + .filter(|(_, planned)| planned.classification().is_candidate()) + .map(|(segment, planned)| GcPlannedCandidate { + segment: *segment, + length: planned.length(), + }) + } + + /// Returns the number of collectible candidates. + #[must_use] + pub const fn candidate_count(&self) -> u32 { + self.candidate_count + } + + /// Returns every segment at least one retained closure reaches. + pub fn live_segments(&self) -> impl Iterator + '_ { + self.segments + .iter() + .filter(|(_, planned)| { + matches!( + planned.classification(), + GcSegmentClassification::Live { .. } + ) + }) + .map(|(segment, _)| *segment) + } +} diff --git a/src/adapters/gc/plan_error.rs b/src/adapters/gc/plan_error.rs new file mode 100644 index 00000000..4b200a7e --- /dev/null +++ b/src/adapters/gc/plan_error.rs @@ -0,0 +1,108 @@ +//! This boundary module owns GC planning refusals. + +use std::fmt; + +use crate::RetentionNamespaceDigest; +use crate::adapters::SegmentDigest; + +/// A snapshot whose evidence contradicts itself: planning refuses rather than +/// guessing, and nothing is collected. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GcPlanAmbiguity { + /// The current catalog names a segment the inventory lacks. + NamedSegmentAbsent { + /// The missing segment. + segment: SegmentDigest, + }, + /// A retained closure reaches a segment the current catalog does not name. + ClosureMemberUnnamed { + /// The retaining namespace. + namespace: RetentionNamespaceDigest, + /// The unnamed segment. + segment: SegmentDigest, + }, + /// A retained closure reaches a segment the inventory lacks. + ClosureMemberAbsent { + /// The retaining namespace. + namespace: RetentionNamespaceDigest, + /// The missing segment. + segment: SegmentDigest, + }, + /// A segment is both superseded and named by the current catalog. + SupersededSegmentNamed { + /// The contradictory segment. + segment: SegmentDigest, + }, + /// A segment has a retirement disposition yet the current catalog names it. + DisposedSegmentNamed { + /// The contradictory segment. + segment: SegmentDigest, + }, +} + +/// Failure to plan one collection. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GcPlanError { + /// The snapshot contradicts itself. + Ambiguous(GcPlanAmbiguity), + /// More segments are collectible than the limit admits. + CandidateLimit { + /// The admitted ceiling. + limit: u32, + /// The collectible count. + observed: u32, + }, + /// More retained roots reach one segment than the classification counts. + RetainedRootOverflow { + /// The over-retained segment. + segment: SegmentDigest, + }, +} + +impl fmt::Display for GcPlanAmbiguity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::NamedSegmentAbsent { .. } => { + "the current catalog names a segment absent from the inventory" + } + Self::ClosureMemberUnnamed { .. } => { + "a retained closure reaches a segment the current catalog does not name" + } + Self::ClosureMemberAbsent { .. } => { + "a retained closure reaches a segment absent from the inventory" + } + Self::SupersededSegmentNamed { .. } => { + "a superseded segment is still named by the current catalog" + } + Self::DisposedSegmentNamed { .. } => { + "a disposed segment is still named by the current catalog" + } + }) + } +} + +impl fmt::Display for GcPlanError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Ambiguous(ambiguity) => write!(formatter, "GC plan refused: {ambiguity}"), + Self::CandidateLimit { limit, observed } => write!( + formatter, + "GC plan has {observed} candidates, above the limit of {limit}" + ), + Self::RetainedRootOverflow { .. } => { + formatter.write_str("retained-root count overflowed for one segment") + } + } + } +} + +impl std::error::Error for GcPlanAmbiguity {} + +impl std::error::Error for GcPlanError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Ambiguous(ambiguity) => Some(ambiguity), + Self::CandidateLimit { .. } | Self::RetainedRootOverflow { .. } => None, + } + } +} diff --git a/src/adapters/gc/plan_limits.rs b/src/adapters/gc/plan_limits.rs new file mode 100644 index 00000000..953fcd7e --- /dev/null +++ b/src/adapters/gc/plan_limits.rs @@ -0,0 +1,72 @@ +//! This boundary module owns the explicit bounds one GC plan admits. + +use std::fmt; +use std::num::NonZeroU32; + +/// Explicit ceilings a GC plan must stay within. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GcLimits { + candidates: NonZeroU32, +} + +/// A limit outside the protocol's range. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GcLimitsError { + /// A zero limit admits no plan. + Zero, + /// The limit exceeds the retirement intent's candidate ceiling. + AboveMaximum { + /// The requested limit. + requested: u32, + }, +} + +impl fmt::Display for GcLimitsError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Zero => formatter.write_str("GC candidate limit must be positive"), + Self::AboveMaximum { requested } => write!( + formatter, + "GC candidate limit {requested} exceeds the intent ceiling {}", + GcLimits::MAXIMUM_CANDIDATES + ), + } + } +} + +impl std::error::Error for GcLimitsError {} + +impl GcLimits { + /// The most candidates one retirement intent can name. + pub const MAXIMUM_CANDIDATES: u32 = 65_536; + /// The protocol ceiling as a limit. + pub const MAXIMUM: Self = Self { + candidates: match NonZeroU32::new(Self::MAXIMUM_CANDIDATES) { + Some(candidates) => candidates, + None => NonZeroU32::MIN, + }, + }; + + /// Admits one candidate ceiling. + /// + /// # Errors + /// + /// Returns [`GcLimitsError`] for zero or a value above the intent ceiling. + pub const fn new(candidates: u32) -> Result { + if candidates > Self::MAXIMUM_CANDIDATES { + return Err(GcLimitsError::AboveMaximum { + requested: candidates, + }); + } + match NonZeroU32::new(candidates) { + Some(candidates) => Ok(Self { candidates }), + None => Err(GcLimitsError::Zero), + } + } + + /// Returns the candidate ceiling. + #[must_use] + pub const fn candidates(self) -> u32 { + self.candidates.get() + } +} diff --git a/src/adapters/gc/plan_model_tests.rs b/src/adapters/gc/plan_model_tests.rs new file mode 100644 index 00000000..8948c278 --- /dev/null +++ b/src/adapters/gc/plan_model_tests.rs @@ -0,0 +1,142 @@ +//! Model law: over generated liveness universes, planning never collects a +//! live or named segment, classifies every inventoried segment exactly once, +//! and is a pure function of its snapshot. + +use std::collections::BTreeSet; +use std::error::Error; + +use super::{closure, coordinates, segment}; +use crate::adapters::SegmentDigest; +use crate::adapters::gc::{ + GcLimits, GcLivenessSnapshot, GcPlannedCandidate, GcSegmentClassification, plan_gc, +}; + +const UNIVERSES: u32 = 512; + +struct XorShift(u64); + +impl XorShift { + fn next(&mut self) -> u64 { + self.0 ^= self.0.wrapping_shl(13); + self.0 ^= self.0.wrapping_shr(7); + self.0 ^= self.0.wrapping_shl(17); + self.0 + } + + fn below(&mut self, bound: u64) -> u64 { + self.next().checked_rem(bound.max(1)).unwrap_or(0) + } +} + +struct Universe { + snapshot: GcLivenessSnapshot, + named: BTreeSet, + live: BTreeSet, + released: BTreeSet, + inventory: BTreeSet, +} + +fn universe(random: &mut XorShift) -> Result> { + let mut snapshot = GcLivenessSnapshot::new(coordinates()?); + let mut named = BTreeSet::new(); + let mut released = BTreeSet::new(); + let mut inventory = BTreeSet::new(); + let count = random.below(8).saturating_add(1); + for seed in 1..=count { + let seed = u8::try_from(seed)?; + let digest = segment(seed); + let present = random.below(8) != 0; + if present { + snapshot.inventory_segment(digest, u64::from(seed))?; + inventory.insert(digest); + } + // Named only when present; superseded, disposed, or bare orphan + // otherwise, so every classification appears across the universes. + match random.below(5) { + 0 | 1 if present => { + snapshot.name_segment(digest); + named.insert(digest); + } + 2 => { + snapshot.supersede_segment(digest); + released.insert(digest); + } + 3 => { + snapshot.dispose_segment(digest); + released.insert(digest); + } + _ => {} + } + } + let mut live = BTreeSet::new(); + let named_list: Vec<_> = named.iter().copied().collect(); + for root in 0..random.below(4) { + let members: Vec<_> = named_list + .iter() + .copied() + .filter(|_| random.below(2) == 0) + .collect(); + live.extend(members.iter().copied()); + snapshot.retain(closure(u8::try_from(root.saturating_add(100))?, &members)?)?; + } + Ok(Universe { + snapshot, + named, + live, + released, + inventory, + }) +} + +#[test] +fn planning_never_collects_live_or_named_material_and_is_pure() -> Result<(), Box> { + let mut random = XorShift(0x9e37_79b9_7f4a_7c15); + for _ in 0..UNIVERSES { + let universe = universe(&mut random)?; + let plan = plan_gc(&universe.snapshot, GcLimits::MAXIMUM)?; + let again = plan_gc(&universe.snapshot, GcLimits::MAXIMUM)?; + assert_eq!(plan, again, "planning is a pure function of its snapshot"); + + let classified: BTreeSet<_> = plan.segments().keys().copied().collect(); + assert_eq!(classified, universe.inventory); + assert_eq!( + plan.live_segments().collect::>(), + universe.live, + "the live set is exactly the union of retained closures" + ); + let candidates: Vec<_> = plan.candidates().map(GcPlannedCandidate::segment).collect(); + assert!(candidates.windows(2).all(|pair| pair.first() < pair.last())); + assert_eq!(usize::try_from(plan.candidate_count())?, candidates.len()); + for candidate in &candidates { + assert!(!universe.named.contains(candidate)); + assert!(!universe.live.contains(candidate)); + assert!(universe.released.contains(candidate)); + assert!(universe.inventory.contains(candidate)); + } + for (digest, planned) in plan.segments() { + let expected_candidate = + universe.released.contains(digest) && !universe.named.contains(digest); + assert_eq!(planned.classification().is_candidate(), expected_candidate); + if universe.live.contains(digest) { + assert!(matches!( + planned.classification(), + GcSegmentClassification::Live { .. } + )); + } + if !universe.named.contains(digest) && !universe.released.contains(digest) { + assert_eq!( + planned.classification(), + GcSegmentClassification::RecoveryProtected, + "an orphan without release evidence stays protected" + ); + } + } + let retired: BTreeSet<_> = universe + .released + .difference(&universe.inventory) + .copied() + .collect(); + assert_eq!(plan.already_retired(), &retired); + } + Ok(()) +} diff --git a/src/adapters/gc/planner.rs b/src/adapters/gc/planner.rs new file mode 100644 index 00000000..71938429 --- /dev/null +++ b/src/adapters/gc/planner.rs @@ -0,0 +1,146 @@ +//! This boundary module owns deterministic GC planning. +//! +//! Planning is a pure comparison between one liveness snapshot and its +//! bounded inventory. It classifies every inventoried segment, refuses any +//! contradiction, and never guesses. + +use std::collections::{BTreeMap, BTreeSet}; + +use super::{ + GcLimits, GcLivenessSnapshot, GcPlan, GcPlanAmbiguity, GcPlanError, GcPlannedSegment, + GcSegmentClassification, GcUnreachableEvidence, +}; +use crate::adapters::SegmentDigest; + +/// Plans one collection from `snapshot` within `limits`. +/// +/// A segment the current catalog names is `Live` when a retained closure +/// reaches it and `NamedUnreachable` otherwise; neither is a candidate. An +/// unnamed segment is `Unreachable` only with superseding or disposition +/// evidence and `RecoveryProtected` without it. A superseded or disposed +/// segment absent from the inventory is reported as already retired. +/// +/// # Errors +/// +/// Returns [`GcPlanError::Ambiguous`] when the snapshot contradicts itself, +/// [`GcPlanError::CandidateLimit`] when more segments are collectible than +/// `limits` admit, or [`GcPlanError::RetainedRootOverflow`] when a segment's +/// retaining-root count exceeds `u32`. +pub fn plan_gc(snapshot: &GcLivenessSnapshot, limits: GcLimits) -> Result { + require_consistent(snapshot)?; + let reach = retained_reach(snapshot)?; + let mut segments = BTreeMap::new(); + let mut candidate_count = 0_u32; + for (segment, length) in snapshot.inventory() { + let classification = classify(snapshot, &reach, *segment); + if classification.is_candidate() { + candidate_count = + candidate_count + .checked_add(1) + .ok_or_else(|| GcPlanError::CandidateLimit { + limit: limits.candidates(), + observed: u32::MAX, + })?; + } + segments.insert(*segment, GcPlannedSegment::new(*length, classification)); + } + if candidate_count > limits.candidates() { + return Err(GcPlanError::CandidateLimit { + limit: limits.candidates(), + observed: candidate_count, + }); + } + let retired = snapshot + .superseded() + .union(snapshot.disposed()) + .filter(|segment| !snapshot.inventory().contains_key(segment)) + .copied() + .collect::>(); + Ok(GcPlan::new( + snapshot.coordinates(), + segments, + retired, + candidate_count, + )) +} + +fn require_consistent(snapshot: &GcLivenessSnapshot) -> Result<(), GcPlanError> { + if let Some(segment) = snapshot + .named() + .iter() + .find(|segment| !snapshot.inventory().contains_key(segment)) + { + return Err(GcPlanError::Ambiguous( + GcPlanAmbiguity::NamedSegmentAbsent { segment: *segment }, + )); + } + for closure in snapshot.retained() { + for segment in closure.segments() { + if !snapshot.inventory().contains_key(segment) { + return Err(GcPlanError::Ambiguous( + GcPlanAmbiguity::ClosureMemberAbsent { + namespace: closure.namespace(), + segment: *segment, + }, + )); + } + if !snapshot.named().contains(segment) { + return Err(GcPlanError::Ambiguous( + GcPlanAmbiguity::ClosureMemberUnnamed { + namespace: closure.namespace(), + segment: *segment, + }, + )); + } + } + } + if let Some(segment) = snapshot.superseded().intersection(snapshot.named()).next() { + return Err(GcPlanError::Ambiguous( + GcPlanAmbiguity::SupersededSegmentNamed { segment: *segment }, + )); + } + if let Some(segment) = snapshot.disposed().intersection(snapshot.named()).next() { + return Err(GcPlanError::Ambiguous( + GcPlanAmbiguity::DisposedSegmentNamed { segment: *segment }, + )); + } + Ok(()) +} + +/// Counts, per segment, the retained roots whose closure reaches it. +fn retained_reach( + snapshot: &GcLivenessSnapshot, +) -> Result, GcPlanError> { + let mut reach = BTreeMap::new(); + for closure in snapshot.retained() { + for segment in closure.segments() { + let count: &mut u32 = reach.entry(*segment).or_default(); + *count = count + .checked_add(1) + .ok_or(GcPlanError::RetainedRootOverflow { segment: *segment })?; + } + } + Ok(reach) +} + +fn classify( + snapshot: &GcLivenessSnapshot, + reach: &BTreeMap, + segment: SegmentDigest, +) -> GcSegmentClassification { + if snapshot.named().contains(&segment) { + return match reach.get(&segment).copied() { + Some(retained_roots) if retained_roots > 0 => { + GcSegmentClassification::Live { retained_roots } + } + _ => GcSegmentClassification::NamedUnreachable, + }; + } + if snapshot.superseded().contains(&segment) { + GcSegmentClassification::Unreachable(GcUnreachableEvidence::Superseded) + } else if snapshot.disposed().contains(&segment) { + GcSegmentClassification::Unreachable(GcUnreachableEvidence::Disposed) + } else { + GcSegmentClassification::RecoveryProtected + } +} diff --git a/src/adapters/gc/planner_tests.rs b/src/adapters/gc/planner_tests.rs new file mode 100644 index 00000000..ed35cee3 --- /dev/null +++ b/src/adapters/gc/planner_tests.rs @@ -0,0 +1,397 @@ +//! GC planning laws: one law per classification, one per ambiguity, the +//! candidate limit, and the golden plan for the frozen version-2 store. + +#[path = "plan_model_tests.rs"] +mod plan_model_tests; + +use std::collections::BTreeSet; +use std::error::Error; + +use super::{ + GcLimits, GcLimitsError, GcLivenessCoordinates, GcLivenessSnapshot, GcLivenessSnapshotError, + GcPlanAmbiguity, GcPlanError, GcRetainedClosure, GcRetentionState, GcSegmentClassification, + GcUnreachableEvidence, plan_gc, +}; +use crate::adapters::retention::AdmittedRetentionRoot; +use crate::adapters::test_support::decode_hex; +use crate::adapters::{ + AdmittedSegment, ChecksummedCatalog, ChecksummedPublicationHead, SegmentDigest, + SegmentReadPolicy, SegmentRecordLimit, +}; +use crate::{ + CatalogDigest, CatalogGeneration, LayoutEntryLimit, LivenessGeneration, RetentionClosureDigest, + RetentionManifestDigest, RetentionNamespaceDigest, RetentionRootDigest, RootGeneration, + verify_retention_closure, +}; + +const GOLDEN_PLAN: &str = include_str!("../../../conformance/segment-store/v2/gc-plan.tsv"); +const BUNDLE_SEGMENT_HEX: &str = + include_str!("../../../conformance/segment-store/v1/one-zero-bundle-segment.hex"); +const BUNDLE_CATALOG_HEX: &str = + include_str!("../../../conformance/segment-store/v1/one-zero-bundle-catalog.hex"); +const BUNDLE_HEAD_HEX: &str = + include_str!("../../../conformance/segment-store/v1/one-zero-bundle-head.hex"); +const ROOT_HEX: &str = include_str!("../../../conformance/segment-store/v2/one-anchor-root.hex"); +const MANIFEST_DIGEST_HEX: &str = + "f46b96a2bf3379320cf59e8af15b9d108de06025c415307b28953714bd7a80eb"; + +pub(super) fn segment(seed: u8) -> SegmentDigest { + SegmentDigest::from_validated([seed; 32]) +} + +pub(super) fn namespace(seed: u8) -> RetentionNamespaceDigest { + RetentionNamespaceDigest::from_hash([seed; 32]) +} + +pub(super) fn coordinates() -> Result> { + Ok(GcLivenessCoordinates::new( + CatalogGeneration::new(3)?, + CatalogDigest::from_validated([0x33; 32]), + GcRetentionState::Published { + generation: LivenessGeneration::new(2)?, + manifest_digest: RetentionManifestDigest::from_hash([0x44; 32]), + }, + )) +} + +pub(super) fn closure( + seed: u8, + segments: &[SegmentDigest], +) -> Result> { + Ok(GcRetainedClosure::new( + namespace(seed), + RootGeneration::new(1)?, + RetentionRootDigest::from_hash([seed; 32]), + RetentionClosureDigest::from_verified([seed; 32]), + segments.iter().copied().collect(), + )) +} + +fn snapshot(named: &[u8], unnamed: &[u8]) -> Result> { + let mut snapshot = GcLivenessSnapshot::new(coordinates()?); + for seed in named { + snapshot.inventory_segment(segment(*seed), u64::from(*seed))?; + snapshot.name_segment(segment(*seed)); + } + for seed in unnamed { + snapshot.inventory_segment(segment(*seed), u64::from(*seed))?; + } + Ok(snapshot) +} + +#[test] +fn named_segments_reached_by_retained_closures_are_live_with_their_root_count() +-> Result<(), Box> { + let mut snapshot = snapshot(&[1, 2], &[])?; + snapshot.retain(closure(10, &[segment(1)])?)?; + snapshot.retain(closure(11, &[segment(1), segment(2)])?)?; + + let plan = plan_gc(&snapshot, GcLimits::MAXIMUM)?; + + assert_eq!( + plan.classification(segment(1)), + Some(GcSegmentClassification::Live { retained_roots: 2 }) + ); + assert_eq!( + plan.classification(segment(2)), + Some(GcSegmentClassification::Live { retained_roots: 1 }) + ); + assert_eq!(plan.candidate_count(), 0); + assert_eq!( + plan.live_segments().collect::>(), + [segment(1), segment(2)] + ); + Ok(()) +} + +#[test] +fn a_named_segment_no_root_reaches_is_named_unreachable_and_never_a_candidate() +-> Result<(), Box> { + let snapshot = snapshot(&[1], &[])?; + + let plan = plan_gc(&snapshot, GcLimits::MAXIMUM)?; + + assert_eq!( + plan.classification(segment(1)), + Some(GcSegmentClassification::NamedUnreachable) + ); + assert_eq!(plan.candidates().count(), 0); + Ok(()) +} + +#[test] +fn an_unnamed_segment_without_release_evidence_is_recovery_protected() -> Result<(), Box> +{ + let snapshot = snapshot(&[], &[5])?; + + let plan = plan_gc(&snapshot, GcLimits::MAXIMUM)?; + + assert_eq!( + plan.classification(segment(5)), + Some(GcSegmentClassification::RecoveryProtected) + ); + assert_eq!(plan.candidate_count(), 0); + Ok(()) +} + +#[test] +fn superseded_and_disposed_unnamed_segments_are_the_only_candidates() -> Result<(), Box> +{ + let mut snapshot = snapshot(&[1], &[5, 6, 7])?; + snapshot.supersede_segment(segment(5)); + snapshot.dispose_segment(segment(6)); + snapshot.supersede_segment(segment(7)); + snapshot.dispose_segment(segment(7)); + + let plan = plan_gc(&snapshot, GcLimits::MAXIMUM)?; + + assert_eq!( + plan.classification(segment(5)), + Some(GcSegmentClassification::Unreachable( + GcUnreachableEvidence::Superseded + )) + ); + assert_eq!( + plan.classification(segment(6)), + Some(GcSegmentClassification::Unreachable( + GcUnreachableEvidence::Disposed + )) + ); + assert_eq!( + plan.classification(segment(7)), + Some(GcSegmentClassification::Unreachable( + GcUnreachableEvidence::Superseded + )), + "superseding evidence is reported before a disposition" + ); + let candidates: Vec<_> = plan + .candidates() + .map(|c| (c.segment(), c.length())) + .collect(); + assert_eq!( + candidates, + [(segment(5), 5), (segment(6), 6), (segment(7), 7)] + ); + assert_eq!(plan.candidate_count(), 3); + Ok(()) +} + +#[test] +fn superseded_or_disposed_segments_absent_from_the_inventory_are_already_retired() +-> Result<(), Box> { + let mut snapshot = snapshot(&[1], &[])?; + snapshot.supersede_segment(segment(8)); + snapshot.dispose_segment(segment(9)); + + let plan = plan_gc(&snapshot, GcLimits::MAXIMUM)?; + + assert_eq!( + plan.already_retired(), + &[segment(8), segment(9)] + .into_iter() + .collect::>() + ); + assert_eq!(plan.classification(segment(8)), None); + assert_eq!(plan.candidate_count(), 0); + Ok(()) +} + +#[test] +fn every_contradiction_refuses_the_plan() -> Result<(), Box> { + let mut named_absent = GcLivenessSnapshot::new(coordinates()?); + named_absent.name_segment(segment(1)); + assert_eq!( + plan_gc(&named_absent, GcLimits::MAXIMUM), + Err(GcPlanError::Ambiguous( + GcPlanAmbiguity::NamedSegmentAbsent { + segment: segment(1) + } + )) + ); + + let mut member_unnamed = snapshot(&[1], &[2])?; + member_unnamed.retain(closure(10, &[segment(2)])?)?; + assert_eq!( + plan_gc(&member_unnamed, GcLimits::MAXIMUM), + Err(GcPlanError::Ambiguous( + GcPlanAmbiguity::ClosureMemberUnnamed { + namespace: namespace(10), + segment: segment(2) + } + )) + ); + + let mut member_absent = snapshot(&[1], &[])?; + member_absent.retain(closure(10, &[segment(3)])?)?; + assert_eq!( + plan_gc(&member_absent, GcLimits::MAXIMUM), + Err(GcPlanError::Ambiguous( + GcPlanAmbiguity::ClosureMemberAbsent { + namespace: namespace(10), + segment: segment(3) + } + )) + ); + + let mut superseded_named = snapshot(&[1], &[])?; + superseded_named.supersede_segment(segment(1)); + assert_eq!( + plan_gc(&superseded_named, GcLimits::MAXIMUM), + Err(GcPlanError::Ambiguous( + GcPlanAmbiguity::SupersededSegmentNamed { + segment: segment(1) + } + )) + ); + + let mut disposed_named = snapshot(&[1], &[])?; + disposed_named.dispose_segment(segment(1)); + assert_eq!( + plan_gc(&disposed_named, GcLimits::MAXIMUM), + Err(GcPlanError::Ambiguous( + GcPlanAmbiguity::DisposedSegmentNamed { + segment: segment(1) + } + )) + ); + Ok(()) +} + +#[test] +fn the_candidate_limit_refuses_rather_than_truncates() -> Result<(), Box> { + let mut snapshot = snapshot(&[], &[5, 6])?; + snapshot.supersede_segment(segment(5)); + snapshot.supersede_segment(segment(6)); + + assert_eq!( + plan_gc(&snapshot, GcLimits::new(1)?), + Err(GcPlanError::CandidateLimit { + limit: 1, + observed: 2 + }) + ); + assert_eq!(GcLimits::new(0), Err(GcLimitsError::Zero)); + assert_eq!( + GcLimits::new(65_537), + Err(GcLimitsError::AboveMaximum { requested: 65_537 }) + ); + assert_eq!(GcLimits::MAXIMUM.candidates(), 65_536); + Ok(()) +} + +#[test] +fn an_empty_inventory_plans_nothing() -> Result<(), Box> { + let snapshot = GcLivenessSnapshot::new(coordinates()?); + + let plan = plan_gc(&snapshot, GcLimits::MAXIMUM)?; + + assert!(plan.segments().is_empty()); + assert_eq!(plan.candidate_count(), 0); + assert_eq!(plan.coordinates(), coordinates()?); + Ok(()) +} + +#[test] +fn a_snapshot_refuses_duplicate_inventory_and_namespaces() -> Result<(), Box> { + let mut snapshot = snapshot(&[1], &[])?; + assert_eq!( + snapshot.inventory_segment(segment(1), 1), + Err(GcLivenessSnapshotError::DuplicateInventory { + segment: segment(1) + }) + ); + snapshot.retain(closure(10, &[segment(1)])?)?; + assert_eq!( + snapshot.retain(closure(10, &[])?), + Err(GcLivenessSnapshotError::DuplicateNamespace { + namespace: namespace(10) + }) + ); + Ok(()) +} + +/// The frozen version-two store: the one-zero bundle catalog at generation +/// one, retained by the frozen one-anchor root under the frozen generation-one +/// manifest. Its plan is the golden ledger row for row. +#[test] +fn the_golden_version_two_store_plans_one_live_segment() -> Result<(), Box> { + let segment_bytes = fixture(BUNDLE_SEGMENT_HEX)?; + let catalog_bytes = fixture(BUNDLE_CATALOG_HEX)?; + let head_bytes = fixture(BUNDLE_HEAD_HEX)?; + let admitted_segment = AdmittedSegment::decode(&segment_bytes, maximum_policy())?; + let segment_digest = admitted_segment.digest(); + let segment_length = admitted_segment.segment_length(); + let segments = [admitted_segment]; + let catalog = ChecksummedCatalog::decode(&catalog_bytes)?.admit(&segments)?; + let catalog_snapshot = ChecksummedPublicationHead::decode(&head_bytes)?.admit(catalog)?; + let root_bytes = fixture(ROOT_HEX)?; + let root = AdmittedRetentionRoot::decode(&root_bytes)?; + let verified = verify_retention_closure(root.root(), &catalog_snapshot)?; + let manifest_digest = RetentionManifestDigest::from_hash(array(MANIFEST_DIGEST_HEX)?); + let mut snapshot = GcLivenessSnapshot::new(GcLivenessCoordinates::new( + catalog_snapshot.generation(), + catalog_snapshot.catalog_digest(), + GcRetentionState::Published { + generation: LivenessGeneration::new(1)?, + manifest_digest, + }, + )); + snapshot.inventory_segment(segment_digest, segment_length)?; + snapshot.name_segment(segment_digest); + snapshot.retain(GcRetainedClosure::new( + root.root().namespace().digest(), + root.root().generation(), + root.digest(), + verified.digest(), + BTreeSet::from([segment_digest]), + ))?; + + let plan = plan_gc(&snapshot, GcLimits::MAXIMUM)?; + + let rows: Vec<_> = GOLDEN_PLAN.lines().skip(2).collect(); + assert_eq!(rows.len(), plan.segments().len()); + for (row, (digest, planned)) in rows.iter().zip(plan.segments()) { + let fields: Vec<_> = row.split('\t').collect(); + assert_eq!( + fields.first().copied(), + Some(hex(digest.as_bytes()).as_str()) + ); + assert_eq!( + fields.get(1).copied(), + Some(planned.length().to_string().as_str()) + ); + assert_eq!( + fields.get(2).copied(), + Some(planned.classification().identifier()) + ); + let GcSegmentClassification::Live { retained_roots } = planned.classification() else { + return Err("the golden store's only segment must be live".into()); + }; + assert_eq!( + fields.get(3).copied(), + Some(retained_roots.to_string().as_str()) + ); + } + assert_eq!(plan.candidate_count(), 0); + Ok(()) +} + +fn fixture(hex: &str) -> Result, Box> { + decode_hex(hex.strip_suffix('\n').ok_or("fixture must end in one LF")?).map_err(Into::into) +} + +fn array(hex: &str) -> Result<[u8; 32], Box> { + <[u8; 32]>::try_from(decode_hex(hex)?).map_err(|_| "digest must be 32 bytes".into()) +} + +fn hex(bytes: &[u8; 32]) -> String { + use std::fmt::Write as _; + bytes.iter().fold(String::new(), |mut text, byte| { + let _ = write!(text, "{byte:02x}"); + text + }) +} + +const fn maximum_policy() -> SegmentReadPolicy { + SegmentReadPolicy::new(SegmentRecordLimit::MAXIMUM, LayoutEntryLimit::MAXIMUM) +} diff --git a/src/adapters/gc/retained_closure.rs b/src/adapters/gc/retained_closure.rs new file mode 100644 index 00000000..f40ab466 --- /dev/null +++ b/src/adapters/gc/retained_closure.rs @@ -0,0 +1,68 @@ +//! This boundary module owns one retained root's physical closure. + +use std::collections::BTreeSet; + +use crate::adapters::SegmentDigest; +use crate::{ + RetentionClosureDigest, RetentionNamespaceDigest, RetentionRootDigest, RootGeneration, +}; + +/// The segments one retained root's verified closure reaches. +/// +/// The closure digest is the verifier's own evidence; the segment set is the +/// physical projection of every record that closure resolved. A segment in +/// this set is live while this root is retained. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GcRetainedClosure { + namespace: RetentionNamespaceDigest, + generation: RootGeneration, + root_digest: RetentionRootDigest, + closure_digest: RetentionClosureDigest, + segments: BTreeSet, +} + +impl GcRetainedClosure { + /// Binds one retained root to the segments its closure reaches. + pub const fn new( + namespace: RetentionNamespaceDigest, + generation: RootGeneration, + root_digest: RetentionRootDigest, + closure_digest: RetentionClosureDigest, + segments: BTreeSet, + ) -> Self { + Self { + namespace, + generation, + root_digest, + closure_digest, + segments, + } + } + + /// Returns the namespace the root retains. + pub const fn namespace(&self) -> RetentionNamespaceDigest { + self.namespace + } + + /// Returns the retained root generation. + pub const fn generation(&self) -> RootGeneration { + self.generation + } + + /// Returns the exact retained root digest. + pub const fn root_digest(&self) -> RetentionRootDigest { + self.root_digest + } + + /// Returns the verifier's closure digest. + pub const fn closure_digest(&self) -> RetentionClosureDigest { + self.closure_digest + } + + /// Returns every segment the closure reaches, in canonical order. + #[must_use] + pub const fn segments(&self) -> &BTreeSet { + &self.segments + } +} diff --git a/src/adapters/gc/segment_classification.rs b/src/adapters/gc/segment_classification.rs new file mode 100644 index 00000000..78d15406 --- /dev/null +++ b/src/adapters/gc/segment_classification.rs @@ -0,0 +1,64 @@ +//! This boundary module owns the physical GC classification vocabulary. + +use std::fmt; + +/// Why an unreachable segment may enter a retirement plan. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GcUnreachableEvidence { + /// A predecessor catalog in the pool's chain named the segment and the + /// durably published current catalog omits it. + Superseded, + /// A durable `RecoveryDispositionReceipt` retired the segment. + Disposed, +} + +/// The one classification a plan assigns to each inventoried segment. +/// +/// The order of the variants is the order the planner decides them in: +/// catalog naming first, then retained reachability, then the evidence that +/// releases an unnamed segment. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GcSegmentClassification { + /// The current catalog names the segment and at least one retained + /// closure reaches a record in it. + Live { + /// How many retained roots reach the segment. + retained_roots: u32, + }, + /// The current catalog names the segment but no retained closure reaches + /// it. It is not a candidate: only a catalog successor published through + /// compaction can release a named segment. + NamedUnreachable, + /// No catalog in the pool's chain names the segment and no disposition + /// receipt retires it: a verified orphan of an interrupted publication, + /// protected until an explicit finalize-or-retire disposition. + RecoveryProtected, + /// The segment is unreachable and evidenced as collectible. + Unreachable(GcUnreachableEvidence), +} + +impl GcSegmentClassification { + /// Returns the stable identifier used by the golden plan ledger. + #[must_use] + pub const fn identifier(self) -> &'static str { + match self { + Self::Live { .. } => "live", + Self::NamedUnreachable => "named-unreachable", + Self::RecoveryProtected => "recovery-protected", + Self::Unreachable(GcUnreachableEvidence::Superseded) => "unreachable-superseded", + Self::Unreachable(GcUnreachableEvidence::Disposed) => "unreachable-disposed", + } + } + + /// Reports whether the segment may enter a retirement plan. + #[must_use] + pub const fn is_candidate(self) -> bool { + matches!(self, Self::Unreachable(_)) + } +} + +impl fmt::Display for GcSegmentClassification { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.identifier()) + } +} diff --git a/src/adapters/gc/segment_pool_inventory.rs b/src/adapters/gc/segment_pool_inventory.rs new file mode 100644 index 00000000..4159edd3 --- /dev/null +++ b/src/adapters/gc/segment_pool_inventory.rs @@ -0,0 +1,104 @@ +//! This boundary module owns one bounded, admitting read of the segment pool. + +use std::io::Read; + +use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt, OpenOptionsSyncExt}; +use cap_std::fs::{Dir, OpenOptions}; + +use super::GcLivenessObservationError as Error; +use crate::adapters::{AdmittedSegment, SegmentDigest, SegmentReadPolicy}; + +const SUFFIX: &str = ".seg"; +const DIGEST_HEX_LENGTH: usize = 64; + +/// Reads every `segments/` entry, requires each to be a regular file named +/// by the lowercase digest it hashes to, admits it under `policy`, and +/// returns the sorted `(digest, length)` inventory. Total bytes read stay +/// within `byte_limit`; an unknown entry, a wrong kind, a name that is not a +/// digest, or a segment whose bytes do not admit refuses the whole read. +pub(super) fn read( + segments: &Dir, + policy: SegmentReadPolicy, + byte_limit: u64, +) -> Result, Error> { + let mut inventory = Vec::new(); + let mut read_bytes = 0_u64; + for entry in segments + .entries() + .map_err(|source| Error::pool("list", source))? + { + let entry = entry.map_err(|source| Error::pool("read entry", source))?; + let name = entry + .file_name() + .into_string() + .map_err(|_name| Error::PoolEntryName)?; + let expected = parse_name(&name)?; + let metadata = segments + .symlink_metadata(&name) + .map_err(|source| Error::pool("inspect entry", source))?; + if !metadata.is_file() { + return Err(Error::PoolEntryKind { segment: expected }); + } + read_bytes = read_bytes + .checked_add(metadata.len()) + .filter(|total| *total <= byte_limit) + .ok_or(Error::PoolByteLimit { limit: byte_limit })?; + admit(segments, &name, expected, metadata.len(), policy)?; + inventory.push((expected, metadata.len())); + } + inventory.sort_unstable(); + Ok(inventory) +} + +fn parse_name(name: &str) -> Result { + let hex = name.strip_suffix(SUFFIX).ok_or(Error::PoolEntryName)?; + if hex.len() != DIGEST_HEX_LENGTH { + return Err(Error::PoolEntryName); + } + let mut bytes = [0_u8; 32]; + for (index, byte) in bytes.iter_mut().enumerate() { + let start = index.checked_mul(2).ok_or(Error::PoolEntryName)?; + let end = start.checked_add(2).ok_or(Error::PoolEntryName)?; + let pair = hex.get(start..end).ok_or(Error::PoolEntryName)?; + if pair.bytes().any(|c| c.is_ascii_uppercase()) { + return Err(Error::PoolEntryName); + } + *byte = u8::from_str_radix(pair, 16).map_err(|_source| Error::PoolEntryName)?; + } + Ok(SegmentDigest::from_validated(bytes)) +} + +fn admit( + segments: &Dir, + name: &str, + expected: SegmentDigest, + length: u64, + policy: SegmentReadPolicy, +) -> Result<(), Error> { + let mut options = OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No).nonblock(true); + let mut file = segments + .open_with(name, &options) + .map_err(|source| Error::pool("open segment", source))?; + let capacity = + usize::try_from(length).map_err(|_source| Error::PoolByteLimit { limit: u64::MAX })?; + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(capacity) + .map_err(|_source| Error::PoolByteLimit { limit: length })?; + file.read_to_end(&mut bytes) + .map_err(|source| Error::pool("read segment", source))?; + let admitted = + AdmittedSegment::decode(&bytes, policy).map_err(|source| Error::SegmentAdmission { + segment: expected, + source: Box::new(source), + })?; + if admitted.digest() == expected { + Ok(()) + } else { + Err(Error::SegmentDigestMismatch { + expected, + observed: admitted.digest(), + }) + } +} diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 6ab724bb..31b64673 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -57,7 +57,7 @@ mod filesystem_retention_storage_tests; #[cfg(test)] mod filesystem_retention_successor_tests; #[cfg(test)] -mod filesystem_retention_test_fixture; +pub(super) mod filesystem_retention_test_fixture; #[cfg(test)] mod filesystem_version_two_admission_tests; mod head_decode_error; @@ -130,6 +130,7 @@ pub use canonical_root::CanonicalRetentionRoot; pub use checksummed_head::ChecksummedRetentionHead; pub use closure_error::RetentionClosureVerificationError; pub use closure_verifier::verify_retention_closure; +pub(in crate::adapters) use closure_verifier::verify_retention_closure_members; pub use filesystem_retention_authority::FilesystemRetentionPublicationAuthority; pub use filesystem_retention_authority_error::{ FilesystemRetentionAuthorityError, RetentionAuthorityDirectory, diff --git a/src/adapters/retention/closure_verifier.rs b/src/adapters/retention/closure_verifier.rs index 31e93f77..7dac3c41 100644 --- a/src/adapters/retention/closure_verifier.rs +++ b/src/adapters/retention/closure_verifier.rs @@ -1,6 +1,6 @@ //! This module owns deterministic verification of one retained-root closure. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use crate::profile::StorageProfileVerifier; use crate::{ @@ -31,11 +31,33 @@ pub fn verify_retention_closure( root: &RetentionRoot, catalog: &CatalogSnapshot<'_, '_, '_>, ) -> Result { + verify_retention_closure_members(root, catalog).map(|members| members.closure) +} + +/// One verified closure together with every record identity it resolved. +/// +/// The identity set is the closure's logical membership; physical GC planning +/// projects it onto segments through the catalog. +pub(in crate::adapters) struct RetentionClosureMembers { + pub(in crate::adapters) closure: VerifiedRetentionClosure, + pub(in crate::adapters) identities: BTreeSet, +} + +/// Verifies every anchor exactly as [`verify_retention_closure`] does and +/// also reports the resolved member identities. +pub(in crate::adapters) fn verify_retention_closure_members( + root: &RetentionRoot, + catalog: &CatalogSnapshot<'_, '_, '_>, +) -> Result { let mut verifier = ClosureVerifier::new(root, catalog); for anchor in root.anchors().iter().copied() { verifier.verify_anchor(anchor)?; } - Ok(verifier.finish()) + let identities = verifier.records.keys().copied().collect(); + Ok(RetentionClosureMembers { + closure: verifier.finish(), + identities, + }) } struct ClosureVerifier<'snapshot, 'head, 'catalog, 'records> { diff --git a/src/adapters/retention/filesystem_retention_test_fixture.rs b/src/adapters/retention/filesystem_retention_test_fixture.rs index 7c1d95b1..66b18039 100644 --- a/src/adapters/retention/filesystem_retention_test_fixture.rs +++ b/src/adapters/retention/filesystem_retention_test_fixture.rs @@ -30,7 +30,7 @@ use crate::{ }; /// Frozen canonical generation-one root. -pub(super) const ROOT_HEX: &str = +pub(in crate::adapters) const ROOT_HEX: &str = include_str!("../../../conformance/segment-store/v2/one-anchor-root.hex"); /// Frozen canonical generation-one manifest. pub(super) const MANIFEST_HEX: &str = @@ -56,7 +56,7 @@ pub(super) const CATALOG_NAME: &str = /// The fixture publishes the exact bundle version-1 corpus, executes the /// complete forward migration, releases writer authority, then reopens the /// admitted root for retention publication. -pub(super) fn open_authority( +pub(in crate::adapters) fn open_authority( name: &str, ) -> Result<(TestDirectory, FilesystemRetentionPublicationAuthority), Box> { let sandbox = migrated_store(name)?; @@ -66,7 +66,7 @@ pub(super) fn open_authority( /// Reopens a migrated store for retention publication under the test /// catalog policy. -pub(super) fn reopen_authority( +pub(in crate::adapters) fn reopen_authority( root: &Path, ) -> Result> { let admission = FilesystemVersionTwoAdmission::reopen_unchecked_for_tests(root)?; @@ -75,7 +75,7 @@ pub(super) fn reopen_authority( /// The catalog policy tests re-verify closure members under: maximum /// grammar limits and one mebibyte of retained segment bytes. -pub(super) fn catalog_policy() -> Result> { +pub(in crate::adapters) fn catalog_policy() -> Result> { Ok(CatalogRestartPolicy::new( maximum_policy(), CatalogRestartByteLimit::new(1_048_576)?, @@ -83,7 +83,7 @@ pub(super) fn catalog_policy() -> Result> { } /// Decodes one LF-terminated lowercase hexadecimal conformance fixture. -pub(super) fn fixture(hex: &str) -> Result, Box> { +pub(in crate::adapters) fn fixture(hex: &str) -> Result, Box> { decode_hex(hex.strip_suffix('\n').ok_or("fixture must end in one LF")?).map_err(Into::into) } @@ -104,7 +104,7 @@ pub(super) fn with_snapshot( } /// Prepares the frozen generation-one root as an initial `Publish` transition. -pub(super) fn initial_preparation( +pub(in crate::adapters) fn initial_preparation( root_bytes: &[u8], ) -> Result, Box> { let candidate = AdmittedRetentionRoot::decode(root_bytes)?; @@ -254,7 +254,7 @@ fn hex(bytes: &[u8; 32]) -> String { } /// Builds one completely migrated version-2 store with writer authority released. -pub(super) fn migrated_store(name: &str) -> Result> { +pub(in crate::adapters) fn migrated_store(name: &str) -> Result> { let sandbox = TestDirectory::create(name)?; let admission = FilesystemPlatformAdmission::initialize_unchecked_for_tests(sandbox.path())?; write_version_one(&sandbox)?; diff --git a/src/lib.rs b/src/lib.rs index ca77a90f..c950e663 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -140,11 +140,15 @@ pub use adapters::{ pub use adapters::{ AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, CanonicalGcRetirementIntent, CanonicalGcRetirementReceipt, CatalogSuccessorProofDigest, DispositionSetDigest, GcCandidate, - GcCandidateSetDigest, GcRetirementIntent, GcRetirementIntentCoordinates, + GcCandidateSetDigest, GcLimits, GcLimitsError, GcLivenessCoordinates, + GcLivenessObservationError, GcLivenessSnapshot, GcLivenessSnapshotError, GcPlan, + GcPlanAmbiguity, GcPlanError, GcPlannedCandidate, GcPlannedSegment, GcRetainedClosure, + GcRetentionState, GcRetirementIntent, GcRetirementIntentCoordinates, GcRetirementIntentDecodeError, GcRetirementIntentDigest, GcRetirementIntentEncodeError, - GcRetirementIntentError, GcRetirementReceipt, GcRetirementReceiptDecodeError, PoolStateDigest, - ReaderLockCoordinate, ReaderLockIdentity, SegmentPoolIdentityDigest, - VerificationEvidenceDigest, + GcRetirementIntentError, GcRetirementReceipt, GcRetirementReceiptDecodeError, + GcSegmentClassification, GcUnreachableEvidence, PoolStateDigest, ReaderLockCoordinate, + ReaderLockIdentity, SegmentPoolIdentityDigest, VerificationEvidenceDigest, observe_gc_liveness, + plan_gc, }; pub use adapters::{ AdmittedRetentionManifest, AdmittedRetentionRoot, CanonicalRetentionHead, diff --git a/xtask/tests/retention_store_v2_conformance_contract.rs b/xtask/tests/retention_store_v2_conformance_contract.rs index 6cd5b6b9..6d57876a 100644 --- a/xtask/tests/retention_store_v2_conformance_contract.rs +++ b/xtask/tests/retention_store_v2_conformance_contract.rs @@ -18,6 +18,7 @@ const REQUIRED_PATHS: &[&str] = &[ "migration-source.tsv", "artifacts.tsv", "transitions.tsv", + "gc-plan.tsv", "format-marker.hex", "migration-intent.hex", "migration-receipt.hex", From 2859f681ae8fac5e6a1cf958b54821fa5e657bb1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 11:17:43 -0700 Subject: [PATCH 25/59] Feat: register the disposition enumerations and ship the receipt codec ROADMAP T-22.1a (KEEP-GC-001). `gc.md` had left the recovery-disposition receipt's artifact-kind, decision, and classification fields as unnamed "registered enumerations". They are now registered in `definition.tsv`, together with the `keep.recovery-disposition-artifact/v2` content-digest domain: - artifact kinds: segment:1, catalog:2, retention-root:3, retention-manifest:4, retention-head:5 - decisions: finalize:1, retire:2 - classifications: complete-orphan:1, complete-stage:2, stale-generation:3 Registering them changes the format-definition digest, so the format marker, the migration intent and receipt, the derived store identifier, and their `artifacts.tsv` and `migration-source.tsv` rows were rematerialized through the handwritten corpus oracle by the same temporary, removed write path the corpus was born from; every other fixture is byte-identical. `StoreFormatDefinitionDigest::VERSION_TWO` and the marker, intent, and store-identifier pins follow. The oracle also constructs `one-orphan-retire-disposition.hex`: the one-zero segment retired as a complete orphan under the generation-two catalog and head, the generation-one manifest, and the fixture-only reader-lock coordinates. `src/adapters/gc/`: `RecoveryArtifactKind`, `RecoveryDispositionDecision`, and `RecoveryClassification` carry their registered codes and identifiers; `RecoveryDispositionReceipt` binds the artifact, decision, coordinates, and decision-evidence digest; `CanonicalRecoveryDispositionReceipt` encodes it and `AdmittedRecoveryDispositionReceipt` admits framing, checksum, every enumeration, and positive generations. `tests/recovery_disposition_receipt.rs` proves the golden round trip, that every enumeration matches `definition.tsv` row for row, that every unregistered code refuses, and one exact first refusal per structural field; the `gc_format` fuzz target and seed corpus cover the third record. Namespace admission still refuses every GC record on disk. Red: admitting any enumeration code fails the artifact-kind mutation law ("mutated artifact kind was admitted"). Green: restored. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 14 + ROADMAP.md | 15 +- conformance/segment-store/v2/ORIGIN.md | 21 +- conformance/segment-store/v2/README.md | 13 +- conformance/segment-store/v2/artifacts.tsv | 7 +- conformance/segment-store/v2/definition.tsv | 4 + .../segment-store/v2/format-marker.hex | 2 +- .../segment-store/v2/migration-intent.hex | 2 +- .../segment-store/v2/migration-receipt.hex | 2 +- .../segment-store/v2/migration-source.tsv | 2 +- .../v2/one-orphan-retire-disposition.hex | 1 + docs/formats/segment-store-v2/gc.md | 40 ++- docs/formats/segment-store-v2/recovery.md | 5 +- docs/formats/segment-store-v2/requirements.md | 2 +- fuzz/fuzz_targets/gc_format.rs | 13 +- src/adapters/gc/admitted_disposition.rs | 49 +++ src/adapters/gc/canonical_disposition.rs | 48 +++ src/adapters/gc/disposition.rs | 100 ++++++ src/adapters/gc/disposition_decode_error.rs | 112 ++++++ src/adapters/gc/disposition_decoder.rs | 161 +++++++++ src/adapters/gc/disposition_encoder.rs | 59 ++++ src/adapters/gc/disposition_enums.rs | 84 +++++ src/adapters/gc/disposition_format.rs | 29 ++ src/adapters/gc/evidence_digests.rs | 22 ++ src/adapters/gc/mod.rs | 25 +- .../format_definition_digest.rs | 6 +- src/lib.rs | 26 +- tests/recovery_disposition_receipt.rs | 330 ++++++++++++++++++ tests/store_format_marker.rs | 4 +- tests/store_migration_intent/fixture.rs | 8 +- xtask/src/fuzz_seed_corpus/gc_seeds.rs | 14 +- .../fuzz_seed_corpus/tests/materialization.rs | 4 +- ...retention_store_v2_conformance_contract.rs | 1 + .../artifacts.rs | 2 + .../artifacts/gc.rs | 49 +++ 35 files changed, 1215 insertions(+), 61 deletions(-) create mode 100644 conformance/segment-store/v2/one-orphan-retire-disposition.hex create mode 100644 src/adapters/gc/admitted_disposition.rs create mode 100644 src/adapters/gc/canonical_disposition.rs create mode 100644 src/adapters/gc/disposition.rs create mode 100644 src/adapters/gc/disposition_decode_error.rs create mode 100644 src/adapters/gc/disposition_decoder.rs create mode 100644 src/adapters/gc/disposition_encoder.rs create mode 100644 src/adapters/gc/disposition_enums.rs create mode 100644 src/adapters/gc/disposition_format.rs create mode 100644 tests/recovery_disposition_receipt.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c89bb43d..a5b21712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ after its public API and format compatibility policies are established. ### Added +- `RecoveryDispositionReceipt` codec and its registered enumerations. + `definition.tsv` now registers the artifact kinds (`segment`, `catalog`, + `retention-root`, `retention-manifest`, `retention-head`), decisions + (`finalize`, `retire`), classifications (`complete-orphan`, + `complete-stage`, `stale-generation`), and the + `keep.recovery-disposition-artifact/v2` domain; the format-definition + digest, the format marker, the migration intent and receipt, and the + derived store identifier were rematerialized through the corpus oracle. + `CanonicalRecoveryDispositionReceipt` and + `AdmittedRecoveryDispositionReceipt` encode and admit the frozen + `one-orphan-retire-disposition.hex`; every unregistered code and zero + generation refuses; the `gc_format` fuzz target covers all three GC + records. `KEEP-GC-001` moves to Implemented. Namespace admission still + refuses every GC record on disk. - Deterministic GC planning. `GcLivenessSnapshot` is one immutable liveness snapshot plus one bounded physical inventory: the fenced catalog's generation and digest, the retention state, every segment the catalog diff --git a/ROADMAP.md b/ROADMAP.md index 8fcf83d4..bb34523e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -100,7 +100,7 @@ names; use those in code, tests, and commits. - [x] [F-19 Reader fence and immutable version-2 snapshots](#f-19-reader-fence-and-immutable-version-2-snapshots) — Done on this branch (merged from PR #99) - [x] [F-20 Model-based retention transition evidence](#f-20-model-based-retention-transition-evidence) — Done on this branch (merged from PR #99) - [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Planned (#20) -- [ ] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Partial (#21; codecs and the deterministic planner done on this branch) +- [ ] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Partial (#21; all three codecs and the deterministic planner done on this branch) - [ ] [F-23 Durable authenticated reads and refusal receipts](#f-23-durable-authenticated-reads-and-refusal-receipts) — Planned (#109) - [ ] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Planned (#82, #72) @@ -1145,10 +1145,9 @@ quarantines, or rewrites physical state. ### F-22 Garbage collection, compaction, and recovery dispositions **Status:** Partial (#21, P1, M4). Grammars are frozen and their presence -refuses (`KEEP-GC-001`, `-002`). The intent and receipt codecs (T-22.1) and -the deterministic planner (T-22.2) landed on this branch; the disposition -codec waits on an enumeration decision (T-22.1a); compaction, execution, -and orphan disposition remain. +refuses (`KEEP-GC-002`). All three codecs (T-22.1, T-22.1a; +`KEEP-GC-001` Implemented) and the deterministic planner (T-22.2) landed on +this branch; compaction, execution, and orphan disposition remain. Plan GC from an immutable liveness snapshot; classify every segment as live, unreachable, corrupt, ambiguous, recovery-protected, @@ -1167,7 +1166,11 @@ disposition receipt exists. `BlobId`, `ChunkId`, and `LayoutId` never move. `tests/gc_retirement_receipt.rs`, `fuzz/fuzz_targets/gc_format.rs`, fixtures `one-candidate-gc-intent.hex` and `one-candidate-gc-receipt.hex`. Original task fields: -- [ ] T-22.1a Register the disposition enumerations and ship its codec. +- [x] T-22.1a Register the disposition enumerations and ship its codec — + four `definition.tsv` rows, the corpus rematerialized through the oracle + (new definition digest `6cbc1c75…`), `src/adapters/gc/disposition*.rs`, + `tests/recovery_disposition_receipt.rs`, `gc_format` selector 2. + Original task fields: - **Requirements:** `gc.md` says the artifact kind, decision, and classification fields are "registered" enumerations but names no values; freezing them means adding rows to `definition.tsv`, which diff --git a/conformance/segment-store/v2/ORIGIN.md b/conformance/segment-store/v2/ORIGIN.md index 5b6785f4..751cdece 100644 --- a/conformance/segment-store/v2/ORIGIN.md +++ b/conformance/segment-store/v2/ORIGIN.md @@ -17,6 +17,25 @@ one-anchor root under the generation-one manifest) and transcribing the classification of its one segment; `src/adapters/gc/planner_tests.rs` recomputes it from the fixtures on every run. +## Disposition enumeration registration + +On 2026-09-30 `definition.tsv` gained four rows: the +`keep.recovery-disposition-artifact/v2\0` domain and the artifact-kind, +decision, and classification enumerations the `RecoveryDispositionReceipt` +grammar had left unregistered. That changed the format-definition digest +and therefore the format marker, the migration intent, the migration +receipt, the derived store identifier, and their `artifacts.tsv` and +`migration-source.tsv` rows. Every affected fixture was rematerialized +through the same temporary, removed write path from the handwritten oracle; +no other fixture changed. `one-orphan-retire-disposition.hex` was added in +the same pass: it retires the one-zero segment (identity digest at 273 of +`one-zero-segment.hex`, content digest of its exact bytes under the +artifact domain, length 337) as a complete orphan under the generation-two +catalog and head, the generation-one manifest, and the fixture-only +reader-lock coordinates `4`, `5`, `6`; its decision-evidence digest is the +segment's record checksum at 177, fixture-only evidence like the GC intent's +candidate evidence. + ## Independent inputs The oracle imports exact bytes only from these previously accepted fixtures: @@ -63,7 +82,7 @@ The format-definition digest was checked independently with: Exact output: ```text -32381f1ac332d1277a7e1faf8f11576993cb55b7e85d2a110b74dc9c3b873427 +6cbc1c75f6efab18c7c50ae281edef77a8b0c9ba19f618b28b18483d08462c92 ``` ## Materialization boundary diff --git a/conformance/segment-store/v2/README.md b/conformance/segment-store/v2/README.md index 1bdeecde..4b5b2ae1 100644 --- a/conformance/segment-store/v2/README.md +++ b/conformance/segment-store/v2/README.md @@ -24,6 +24,7 @@ migration, retention transition, or garbage collector exists. | `one-root-head.hex` | Generation-1 retention head | | `one-candidate-gc-intent.hex` | Generation-1 GC retirement intent naming one candidate | | `one-candidate-gc-receipt.hex` | Generation-1 GC retirement receipt completing that intent | +| `one-orphan-retire-disposition.hex` | Disposition retiring the one-zero segment as a complete orphan | | `ORIGIN.md` | Construction provenance and verification boundary | Every text file uses UTF-8 or ASCII, LF line endings, and one final newline. @@ -32,8 +33,9 @@ In `artifacts.tsv`, `bound_digest_hex` is the marker content digest for `format-marker`, the intent digest for `migration-intent`, the referenced intent digest for `migration-receipt`, the canonical record digest for `retention-root` and `retention-manifest`, the referenced manifest digest -for `retention-head`, the intent digest for `gc-intent`, and the referenced -intent digest for `gc-receipt`. +for `retention-head`, the intent digest for `gc-intent`, the referenced +intent digest for `gc-receipt`, and the artifact identity digest for +`recovery-disposition`. ## Frozen identities @@ -43,16 +45,17 @@ It hashes the exact `retention-profile.tsv` bytes under the registered profile domain. The format-definition digest is -`32381f1ac332d1277a7e1faf8f11576993cb55b7e85d2a110b74dc9c3b873427`. +`6cbc1c75f6efab18c7c50ae281edef77a8b0c9ba19f618b28b18483d08462c92`. It hashes the exact `definition.tsv` bytes under the registered format domain. The definition binds the profile digest, every named domain, magic, version, -field order, record width, format limit, and migration synchronization mask. +field order, record width, format limit, migration synchronization mask, and +the registered recovery-disposition enumerations. The migration fixture preserves the version-1 one-zero segment and generation-1 catalog. Its canonical two-entry inventory digest is `40bf5d49c34847ac9cf46a256f343cee80cd980d1405d2dd02ceff8f58d674f9`. The derived logical store identifier is -`0cd9d3dfbec9b349fe42d21475271b0e8de23c043440d6427a1c37898ad1dd79`. +`2b5ed4bcc926a6a5fa9fd5f749c134894de99d37bdf2def4e83bdf99a6539720`. Fixture-only root device, mount, and file coordinates are `1`, `2`, and `3`; they bind in-place recovery but do not enter the logical store identifier. diff --git a/conformance/segment-store/v2/artifacts.tsv b/conformance/segment-store/v2/artifacts.tsv index fc741711..c5d0415c 100644 --- a/conformance/segment-store/v2/artifacts.tsv +++ b/conformance/segment-store/v2/artifacts.tsv @@ -1,10 +1,11 @@ keep.segment-store-v2.artifacts/v1 case kind byte_length generation entry_count bound_digest_hex final_checksum_hex fixture -format-marker format-marker 96 - - 4b063c329085abdebe86b256d531b112c7ea33cb2f545caa40a7a869ff3337ce 06384cbaf2b69e0a12eeb2bf62df4c49e193d56f2bde940b3c5637320458abc1 format-marker.hex -migration-intent migration-intent 256 1 2 a15a00000219df20979da36419046eae9a0ba998645fbfe308ea4335a8326b44 7bec10cc8c1eef5ab0e8e8b6a33240bba291252d4263147df134062eb70d3f1f migration-intent.hex -migration-receipt migration-receipt 256 1 2 a15a00000219df20979da36419046eae9a0ba998645fbfe308ea4335a8326b44 3a6a5f29bfafeffb9401de5ba814c09c345adbad69e8ba0531e3eb1ebb0b681d migration-receipt.hex +format-marker format-marker 96 - - cbb0d60f9aaa896642e9e7cfa5e9575ad0782885d5221dfd7289cde779c63ea0 5ebc2cce9a69ac871e460cd1df2297b82ab37448503fd0ff4d996ff30f6a2e1e format-marker.hex +migration-intent migration-intent 256 1 2 f4914d8d9176710ebad4ff5c3f9b5ab8727b3eb4c463d1185f974798cca4a4c3 4b258bbaa2e93d182e697127af44492005c203ee70f4707f49db6f9468bd6a7e migration-intent.hex +migration-receipt migration-receipt 256 1 2 f4914d8d9176710ebad4ff5c3f9b5ab8727b3eb4c463d1185f974798cca4a4c3 65669e2483415cf5a65c6390e2eeb9cbb125a94c865d52d186348547db79bbf1 migration-receipt.hex one-anchor-root retention-root 378 1 1 ca4c11f265c3bed07073bdc3b6aef003e964ac8cb36fcfcc92f20fa6f0b60085 28c52ff0f8d6533234be083f425e921d699639e204e2c66dec0cae2ff0a2dc34 one-anchor-root.hex one-root-manifest retention-manifest 296 1 1 f46b96a2bf3379320cf59e8af15b9d108de06025c415307b28953714bd7a80eb 10597643c3fc9485c7ecd3bb511d6726e726fd92f0f769a204b899c5fdc77d2c one-root-manifest.hex one-root-head retention-head 144 1 1 f46b96a2bf3379320cf59e8af15b9d108de06025c415307b28953714bd7a80eb ac049edb33af7e957c6ff11ead7e1bcf9c40fa9793cc84979215ffbba5f630b7 one-root-head.hex one-candidate-gc-intent gc-intent 456 1 1 a9dd523326a686b89ac8f0c410421766afc221f2963bdafd430dce7f59edc701 cefd325fbf7b1900e1a208c9c66ec5cfd03e565ea04a3bf9011338e9dabae749 one-candidate-gc-intent.hex one-candidate-gc-receipt gc-receipt 320 1 1 a9dd523326a686b89ac8f0c410421766afc221f2963bdafd430dce7f59edc701 d3dd8ddeea9ce78a278b39a3bdf61b957fff8f6cfee647f138fc720091389147 one-candidate-gc-receipt.hex +one-orphan-retire-disposition recovery-disposition 320 2 - b7542dced2ab770894a14d1d04b066e3a899942602c5986d35ba6df6c1a35cfc e67d0d6d538aa2ccfdd263c6ab5686026de214157259caeb4b87d5c7e4392af7 one-orphan-retire-disposition.hex diff --git a/conformance/segment-store/v2/definition.tsv b/conformance/segment-store/v2/definition.tsv index cea06274..08a3daa0 100644 --- a/conformance/segment-store/v2/definition.tsv +++ b/conformance/segment-store/v2/definition.tsv @@ -14,6 +14,7 @@ domain.migration-intent keep.store-migration-intent/v2\0 domain.migration-intent-checksum keep.store-migration-intent-checksum/v2\0 domain.migration-inventory keep.store-v1-pool-inventory/v2\0 domain.migration-receipt-checksum keep.store-migration-receipt-checksum/v2\0 +domain.recovery-disposition-artifact keep.recovery-disposition-artifact/v2\0 domain.recovery-disposition-checksum keep.recovery-disposition-receipt-checksum/v2\0 domain.retention-anchor-set keep.retention-anchor-set/v2\0 domain.retention-head-checksum keep.retention-head-checksum/v2\0 @@ -53,6 +54,9 @@ migration.receipt.length 256 migration.receipt.magic KEEP:MIG:REC2\0\0\0 migration.receipt.synchronization-mask 0x00000000000003ff migration.receipt.version 2 +recovery.disposition.artifact-kinds segment:1,catalog:2,retention-root:3,retention-manifest:4,retention-head:5 +recovery.disposition.classifications complete-orphan:1,complete-stage:2,stale-generation:3 +recovery.disposition.decisions finalize:1,retire:2 recovery.disposition.fields magic:16,version:u16,record_length:u16,flags:u32,artifact_kind:u16,decision:u16,classification:u16,reserved:u16,artifact_length:u64,artifact_identity_digest:32,artifact_content_digest:32,publication_generation:u64,publication_checksum:32,catalog_generation:u64,catalog_digest:32,liveness_generation:u64,manifest_digest:32,reader_device:u64,reader_mount:u64,reader_file:u64,decision_evidence_digest:32,reserved:8,checksum:32 recovery.disposition.length 320 recovery.disposition.magic KEEP:REC:DISP2\0\0 diff --git a/conformance/segment-store/v2/format-marker.hex b/conformance/segment-store/v2/format-marker.hex index 30640a92..232cc368 100644 --- a/conformance/segment-store/v2/format-marker.hex +++ b/conformance/segment-store/v2/format-marker.hex @@ -1 +1 @@ -4b4545503a53544f52453a5632000000000200600000000032381f1ac332d1277a7e1faf8f11576993cb55b7e85d2a110b74dc9c3b873427000010000000000006384cbaf2b69e0a12eeb2bf62df4c49e193d56f2bde940b3c5637320458abc1 +4b4545503a53544f52453a563200000000020060000000006cbc1c75f6efab18c7c50ae281edef77a8b0c9ba19f618b28b18483d08462c9200001000000000005ebc2cce9a69ac871e460cd1df2297b82ab37448503fd0ff4d996ff30f6a2e1e diff --git a/conformance/segment-store/v2/migration-intent.hex b/conformance/segment-store/v2/migration-intent.hex index 5ce426b9..d61dfe1f 100644 --- a/conformance/segment-store/v2/migration-intent.hex +++ b/conformance/segment-store/v2/migration-intent.hex @@ -1 +1 @@ -4b4545503a4d49473a494e543200000000020100000000000000000000000001000000000000016004b82519b0399baefd0b9c0f32a871052e4c47e3a00226ab03b21661470f7320000000000000000000000000000000000000000000000000000000000000000040bf5d49c34847ac9cf46a256f343cee80cd980d1405d2dd02ceff8f58d674f900000000000000010000000000000002000000000000000332381f1ac332d1277a7e1faf8f11576993cb55b7e85d2a110b74dc9c3b8734270cd9d3dfbec9b349fe42d21475271b0e8de23c043440d6427a1c37898ad1dd797bec10cc8c1eef5ab0e8e8b6a33240bba291252d4263147df134062eb70d3f1f +4b4545503a4d49473a494e543200000000020100000000000000000000000001000000000000016004b82519b0399baefd0b9c0f32a871052e4c47e3a00226ab03b21661470f7320000000000000000000000000000000000000000000000000000000000000000040bf5d49c34847ac9cf46a256f343cee80cd980d1405d2dd02ceff8f58d674f90000000000000001000000000000000200000000000000036cbc1c75f6efab18c7c50ae281edef77a8b0c9ba19f618b28b18483d08462c922b5ed4bcc926a6a5fa9fd5f749c134894de99d37bdf2def4e83bdf99a65397204b258bbaa2e93d182e697127af44492005c203ee70f4707f49db6f9468bd6a7e diff --git a/conformance/segment-store/v2/migration-receipt.hex b/conformance/segment-store/v2/migration-receipt.hex index 66b524ea..bf4cf9b7 100644 --- a/conformance/segment-store/v2/migration-receipt.hex +++ b/conformance/segment-store/v2/migration-receipt.hex @@ -1 +1 @@ -4b4545503a4d49473a524543320000000002010000000000a15a00000219df20979da36419046eae9a0ba998645fbfe308ea4335a8326b440cd9d3dfbec9b349fe42d21475271b0e8de23c043440d6427a1c37898ad1dd794b063c329085abdebe86b256d531b112c7ea33cb2f545caa40a7a869ff3337ced52f1f022edb1de7b840c5bf8fb55de7932ca69370ae85e2bee4179143792bc3ba0ea200a5b06741564c43a79a91945bef0b0fac51c960ea4f8207094f3e1e31a80259fcd1237203ea6c6cc5065514abdeb01da603c3194b096a045cf694c95a00000000000003ff3a6a5f29bfafeffb9401de5ba814c09c345adbad69e8ba0531e3eb1ebb0b681d +4b4545503a4d49473a524543320000000002010000000000f4914d8d9176710ebad4ff5c3f9b5ab8727b3eb4c463d1185f974798cca4a4c32b5ed4bcc926a6a5fa9fd5f749c134894de99d37bdf2def4e83bdf99a6539720cbb0d60f9aaa896642e9e7cfa5e9575ad0782885d5221dfd7289cde779c63ea0d52f1f022edb1de7b840c5bf8fb55de7932ca69370ae85e2bee4179143792bc3ba0ea200a5b06741564c43a79a91945bef0b0fac51c960ea4f8207094f3e1e31a80259fcd1237203ea6c6cc5065514abdeb01da603c3194b096a045cf694c95a00000000000003ff65669e2483415cf5a65c6390e2eeb9cbb125a94c865d52d186348547db79bbf1 diff --git a/conformance/segment-store/v2/migration-source.tsv b/conformance/segment-store/v2/migration-source.tsv index 5d0204bf..2ba3be64 100644 --- a/conformance/segment-store/v2/migration-source.tsv +++ b/conformance/segment-store/v2/migration-source.tsv @@ -1,3 +1,3 @@ keep.segment-store-v2.migration-source/v1 case catalog_generation catalog_length catalog_digest_hex predecessor_digest_hex inventory_digest_hex definition_digest_hex store_id_hex root_device root_mount root_file -one-zero 1 352 04b82519b0399baefd0b9c0f32a871052e4c47e3a00226ab03b21661470f7320 0000000000000000000000000000000000000000000000000000000000000000 40bf5d49c34847ac9cf46a256f343cee80cd980d1405d2dd02ceff8f58d674f9 32381f1ac332d1277a7e1faf8f11576993cb55b7e85d2a110b74dc9c3b873427 0cd9d3dfbec9b349fe42d21475271b0e8de23c043440d6427a1c37898ad1dd79 1 2 3 +one-zero 1 352 04b82519b0399baefd0b9c0f32a871052e4c47e3a00226ab03b21661470f7320 0000000000000000000000000000000000000000000000000000000000000000 40bf5d49c34847ac9cf46a256f343cee80cd980d1405d2dd02ceff8f58d674f9 6cbc1c75f6efab18c7c50ae281edef77a8b0c9ba19f618b28b18483d08462c92 2b5ed4bcc926a6a5fa9fd5f749c134894de99d37bdf2def4e83bdf99a6539720 1 2 3 diff --git a/conformance/segment-store/v2/one-orphan-retire-disposition.hex b/conformance/segment-store/v2/one-orphan-retire-disposition.hex new file mode 100644 index 00000000..1ba8e7c1 --- /dev/null +++ b/conformance/segment-store/v2/one-orphan-retire-disposition.hex @@ -0,0 +1 @@ +4b4545503a5245433a44495350320000000201400000000000010002000100000000000000000151b7542dced2ab770894a14d1d04b066e3a899942602c5986d35ba6df6c1a35cfc61f451a4a4c195e49468576c7c804dae53b3f2d51cc58b9fe1f3b35bcf03a4850000000000000002f67c0b68572fcb0b38a077048d72f7a419a0c0a7ae5c2629c3dd76253fcbeba60000000000000002ea7d0055fd21f00ed94809ef4e671d72fa2e6a4a5d9ecefb23f3a320a2dad9930000000000000001f46b96a2bf3379320cf59e8af15b9d108de06025c415307b28953714bd7a80eb000000000000000400000000000000050000000000000006becb46b35120723210798a47e26144b8214d5ea65d28806e0ba941d2aa66bbfa0000000000000000e67d0d6d538aa2ccfdd263c6ab5686026de214157259caeb4b87d5c7e4392af7 diff --git a/docs/formats/segment-store-v2/gc.md b/docs/formats/segment-store-v2/gc.md index 4330bc2c..1cd5f482 100644 --- a/docs/formats/segment-store-v2/gc.md +++ b/docs/formats/segment-store-v2/gc.md @@ -8,18 +8,19 @@ Issue #21 owns their implementation. They are specified now so version 2 has one exact root grammar, but their presence remains unsupported mandatory state until every **Planned in #21** requirement becomes executable evidence. -Implemented: the intent and receipt codecs. `GcRetirementIntent` admits a -canonical candidate set over its coordinates; `CanonicalGcRetirementIntent` -and `AdmittedGcRetirementIntent` reproduce and admit the frozen +Implemented: all three codecs. `GcRetirementIntent` admits a canonical +candidate set over its coordinates; `CanonicalGcRetirementIntent` and +`AdmittedGcRetirementIntent` reproduce and admit the frozen `one-candidate-gc-intent.hex`; `CanonicalGcRetirementReceipt` and `AdmittedGcRetirementReceipt` bind a receipt to its admitted intent. The receipt's synchronization count is exactly one per candidate: the -pool-directory synchronization that follows each unlink. The -`RecoveryDispositionReceipt` codec waits for its artifact-kind, decision, and -classification enumerations to be registered in `definition.tsv`, which -changes the definition digest and therefore every version-2 fixture; that is -a specification decision, not an implementation gap. Namespace admission -still refuses every one of these records on disk. +pool-directory synchronization that follows each unlink. +`CanonicalRecoveryDispositionReceipt` and +`AdmittedRecoveryDispositionReceipt` reproduce and admit the frozen +`one-orphan-retire-disposition.hex` over the artifact-kind, decision, and +classification enumerations registered in `definition.tsv`. Namespace +admission still refuses every one of these records on disk: no execution, +retirement, or disposition protocol writes them yet. ## Common rules @@ -162,7 +163,26 @@ The checksum domain is `keep.gc-retirement-receipt-checksum/v2\0`. The checksum domain is `keep.recovery-disposition-receipt-checksum/v2\0`. -Unknown artifact kinds, decisions, or classifications refuse. +The artifact content digest is BLAKE3-256 of the artifact's exact bytes under +`keep.recovery-disposition-artifact/v2\0`; the artifact identity digest is +the artifact's pool-name digest. The three enumerations are registered in +`definition.tsv` and any other code refuses: + + + +| Field | Registered values | +| --- | --- | +| artifact kind | `segment:1`, `catalog:2`, `retention-root:3`, `retention-manifest:4`, `retention-head:5` | +| decision | `finalize:1`, `retire:2` | +| classification | `complete-orphan:1`, `complete-stage:2`, `stale-generation:3` | + + + +A `complete-orphan` is a complete, verified artifact linked into its pool +that no head, catalog, or manifest names; a `complete-stage` is a complete, +verified fixed stage not yet linked; a `stale-generation` is a complete +artifact whose generation a later publication superseded before it became +visible. Every generation field must be positive. The pool coordinate is: diff --git a/docs/formats/segment-store-v2/recovery.md b/docs/formats/segment-store-v2/recovery.md index 6b543476..82a44bb5 100644 --- a/docs/formats/segment-store-v2/recovery.md +++ b/docs/formats/segment-store-v2/recovery.md @@ -273,5 +273,6 @@ must establish exact catalog visibility, retention head, namespace generation, orphan classification, stage disposition, and recovery report. `GcRetirementIntent`, `GcRetirementReceipt`, and -`RecoveryDispositionReceipt` are owned by the [GC specification](gc.md). Until -issue #21 implements them, any such artifact is unsupported and refuses. +`RecoveryDispositionReceipt` are owned by the [GC specification](gc.md). +Their codecs exist; until issue #21 implements the protocols that write +them, any such artifact on disk is unsupported and refuses. diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index 412aee5c..9b0083e0 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -45,7 +45,7 @@ case is not evidence. | ID | Requirement | Evidence | Status | | --- | --- | --- | --- | -| `KEEP-GC-001` | Version 2 specifies exact bounded GC intent, receipt, and recovery-disposition grammars but refuses their presence until their parser and recovery protocol are implemented | intent and receipt golden, canonical re-encoding, cross-record binding, and field-by-field corruption laws in `tests/gc_retirement_intent.rs`, `tests/gc_retirement_intent/mutation_laws.rs`, and `tests/gc_retirement_receipt.rs`; seeded `gc_format` fuzz target; the disposition-receipt codec waits for its registered enumerations to be frozen in `definition.tsv`; presence refusal in namespace admission tests | In progress in #21 | +| `KEEP-GC-001` | Version 2 specifies exact bounded GC intent, receipt, and recovery-disposition grammars but refuses their presence until their parser and recovery protocol are implemented | intent and receipt golden, canonical re-encoding, cross-record binding, and field-by-field corruption laws in `tests/gc_retirement_intent.rs`, `tests/gc_retirement_intent/mutation_laws.rs`, and `tests/gc_retirement_receipt.rs`; disposition golden, canonical re-encoding, registered-enumeration agreement with `definition.tsv`, unregistered-code refusal, and field-by-field corruption laws in `tests/recovery_disposition_receipt.rs`; seeded `gc_format` fuzz target over all three records; presence refusal in namespace admission tests | Implemented | | `KEEP-GC-002` | GC intent, receipt, disposition, reader-fence, retirement, compaction, and recovery laws implement ADR-0009 without changing logical identity | deterministic planning: `plan_gc` classifies every inventoried segment against one fenced liveness snapshot, refuses every contradiction, and never names a live or catalog-named segment, proven by one law per classification and ambiguity, the golden `gc-plan.tsv`, a 512-universe model, and filesystem laws over the migrated fixture store in `src/adapters/gc/`; execution, compaction, disposition, and recovery remain golden-format, model-based, corruption, crash-injection, benchmark, and fuzz evidence Planned in #21 | In progress in #21 | diff --git a/fuzz/fuzz_targets/gc_format.rs b/fuzz/fuzz_targets/gc_format.rs index 8570c4ac..748dcfa9 100644 --- a/fuzz/fuzz_targets/gc_format.rs +++ b/fuzz/fuzz_targets/gc_format.rs @@ -2,7 +2,9 @@ //! This target owns canonical GC retirement record parser fuzzing. -use keep::{AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt}; +use keep::{ + AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, AdmittedRecoveryDispositionReceipt, +}; use libfuzzer_sys::fuzz_target; // Receipt inputs carry their exact intent dependency first, framed by a @@ -14,10 +16,17 @@ fuzz_target!(|bytes: &[u8]| { }; match selector { 0 => intent(input), - _ => receipt(input), + 1 => receipt(input), + _ => disposition(input), } }); +fn disposition(input: &[u8]) { + if let Ok(receipt) = AdmittedRecoveryDispositionReceipt::decode(input) { + assert_eq!(receipt.encoded(), input); + } +} + fn intent(input: &[u8]) { if let Ok(intent) = AdmittedGcRetirementIntent::decode(input) { assert_eq!(intent.encoded(), input); diff --git a/src/adapters/gc/admitted_disposition.rs b/src/adapters/gc/admitted_disposition.rs new file mode 100644 index 00000000..8846b34a --- /dev/null +++ b/src/adapters/gc/admitted_disposition.rs @@ -0,0 +1,49 @@ +//! This boundary module owns admitted borrowed recovery-disposition bytes. + +use super::{RecoveryDispositionDecodeError, RecoveryDispositionReceipt, disposition_decoder}; + +/// Borrowed canonical recovery-disposition receipt record. +/// +/// Admission proves framing, checksum, and that every enumeration carries a +/// registered code. It does not prove that the named artifact exists, that +/// its coordinates still hold, or that the decision was executed; the +/// disposition protocol and GC planning revalidate those. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdmittedRecoveryDispositionReceipt<'encoded> { + encoded: &'encoded [u8], + receipt: RecoveryDispositionReceipt, +} + +impl<'encoded> AdmittedRecoveryDispositionReceipt<'encoded> { + /// Decodes and admits one exact receipt. + /// + /// # Errors + /// + /// Returns [`RecoveryDispositionDecodeError`] for invalid framing, + /// integrity, an unregistered enumeration code, or a zero generation. + pub fn decode(encoded: &'encoded [u8]) -> Result { + disposition_decoder::decode(encoded) + } + + /// Returns the exact borrowed canonical bytes. + #[must_use] + pub const fn encoded(&self) -> &'encoded [u8] { + self.encoded + } + + /// Returns the semantic receipt. + pub const fn receipt(&self) -> &RecoveryDispositionReceipt { + &self.receipt + } + + pub(super) const fn admitted( + encoded: &'encoded [u8], + receipt: &RecoveryDispositionReceipt, + ) -> Self { + Self { + encoded, + receipt: *receipt, + } + } +} diff --git a/src/adapters/gc/canonical_disposition.rs b/src/adapters/gc/canonical_disposition.rs new file mode 100644 index 00000000..45b511b7 --- /dev/null +++ b/src/adapters/gc/canonical_disposition.rs @@ -0,0 +1,48 @@ +//! This boundary module owns canonical owned recovery-disposition bytes. + +use super::{RecoveryDispositionReceipt, disposition_encoder, disposition_format}; + +/// Owned canonical recovery-disposition receipt record. +/// +/// Construction encodes the decision exactly; it does not prove the +/// decision was executed or that the artifact still exists. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CanonicalRecoveryDispositionReceipt { + encoded: [u8; disposition_format::ENCODED_LENGTH], + receipt: RecoveryDispositionReceipt, +} + +impl CanonicalRecoveryDispositionReceipt { + /// Encodes `receipt` canonically. + pub fn from_receipt(receipt: &RecoveryDispositionReceipt) -> Self { + disposition_encoder::encode(receipt) + } + + /// Digests exact artifact bytes under the registered artifact domain, the + /// value the receipt's content digest must carry. + pub fn artifact_content_digest(bytes: &[u8]) -> super::ArtifactContentDigest { + super::ArtifactContentDigest::new(disposition_format::artifact_content_digest(bytes)) + } + + /// Returns the exact canonical receipt bytes. + #[must_use] + pub const fn encoded(&self) -> &[u8] { + &self.encoded + } + + /// Returns the semantic receipt. + pub const fn receipt(&self) -> &RecoveryDispositionReceipt { + &self.receipt + } + + pub(super) const fn admitted( + encoded: &[u8; disposition_format::ENCODED_LENGTH], + receipt: &RecoveryDispositionReceipt, + ) -> Self { + Self { + encoded: *encoded, + receipt: *receipt, + } + } +} diff --git a/src/adapters/gc/disposition.rs b/src/adapters/gc/disposition.rs new file mode 100644 index 00000000..517ae495 --- /dev/null +++ b/src/adapters/gc/disposition.rs @@ -0,0 +1,100 @@ +//! This boundary module owns the semantic recovery-disposition receipt. + +use super::{ + ArtifactContentDigest, ArtifactIdentityDigest, DecisionEvidenceDigest, ObservedHeadChecksum, + ReaderLockIdentity, RecoveryArtifactKind, RecoveryClassification, RecoveryDispositionDecision, +}; +use crate::{CatalogDigest, CatalogGeneration, LivenessGeneration, RetentionManifestDigest}; + +/// The coordinates one disposition was decided under. +/// +/// Every field is observed by the writer under writer authority and the +/// exclusive reader lock; the receipt transports them so a later planner +/// can refuse a disposition whose coordinates no longer hold. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RecoveryDispositionCoordinates { + /// The publication-head generation observed. + pub publication_generation: CatalogGeneration, + /// The publication-head checksum observed. + pub publication_checksum: ObservedHeadChecksum, + /// The catalog generation observed. + pub catalog_generation: CatalogGeneration, + /// The catalog digest observed. + pub catalog_digest: CatalogDigest, + /// The liveness generation observed. + pub liveness_generation: LivenessGeneration, + /// The retention-manifest digest observed. + pub manifest_digest: RetentionManifestDigest, + /// The exclusively locked `reader.lock`. + pub reader_lock: ReaderLockIdentity, +} + +/// The artifact one disposition names. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RecoveryDispositionArtifact { + /// The artifact kind. + pub kind: RecoveryArtifactKind, + /// The classification the artifact was admitted under. + pub classification: RecoveryClassification, + /// The exact observed length. + pub length: u64, + /// The physical evidence identity: the pool name digest. + pub identity_digest: ArtifactIdentityDigest, + /// The digest of the exact verified bytes under the artifact domain. + pub content_digest: ArtifactContentDigest, +} + +/// One explicit finalize-or-retire decision over one recovery-protected +/// artifact. +/// +/// A receipt is a statement about the decision and the coordinates it was +/// made under. It does not prove the artifact was finalized or unlinked; +/// the disposition protocol establishes that separately. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RecoveryDispositionReceipt { + artifact: RecoveryDispositionArtifact, + decision: RecoveryDispositionDecision, + coordinates: RecoveryDispositionCoordinates, + evidence_digest: DecisionEvidenceDigest, +} + +impl RecoveryDispositionReceipt { + /// Binds one decision to one artifact under one set of coordinates. + pub const fn new( + artifact: RecoveryDispositionArtifact, + decision: RecoveryDispositionDecision, + coordinates: RecoveryDispositionCoordinates, + evidence_digest: DecisionEvidenceDigest, + ) -> Self { + Self { + artifact, + decision, + coordinates, + evidence_digest, + } + } + + /// Returns the disposed artifact. + #[must_use] + pub const fn artifact(&self) -> RecoveryDispositionArtifact { + self.artifact + } + + /// Returns the decision. + #[must_use] + pub const fn decision(&self) -> RecoveryDispositionDecision { + self.decision + } + + /// Returns the coordinates the decision was made under. + #[must_use] + pub const fn coordinates(&self) -> RecoveryDispositionCoordinates { + self.coordinates + } + + /// Returns the digest of the complete decision evidence. + pub const fn evidence_digest(&self) -> DecisionEvidenceDigest { + self.evidence_digest + } +} diff --git a/src/adapters/gc/disposition_decode_error.rs b/src/adapters/gc/disposition_decode_error.rs new file mode 100644 index 00000000..05506961 --- /dev/null +++ b/src/adapters/gc/disposition_decode_error.rs @@ -0,0 +1,112 @@ +//! This boundary module owns typed recovery-disposition decoding failures. + +use std::error::Error; +use std::fmt; + +/// Which registered enumeration a disposition field belongs to. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RecoveryDispositionField { + /// The artifact kind at offset 24. + ArtifactKind, + /// The decision at offset 26. + Decision, + /// The recovery classification at offset 28. + Classification, +} + +/// Failure to decode and admit one recovery-disposition receipt. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RecoveryDispositionDecodeError { + /// The input was not exactly one complete fixed-width receipt. + WrongLength { + /// Required fixed width. + expected: usize, + /// Observed input width. + observed: usize, + }, + /// The fixed record magic was not canonical. + InvalidMagic { + /// Observed 16 magic bytes. + observed: [u8; 16], + }, + /// The format version is unsupported. + UnsupportedVersion { + /// Supported version. + expected: u16, + /// Observed version. + observed: u16, + }, + /// The record-length field was noncanonical. + InvalidRecordLength { + /// Required record length. + expected: u16, + /// Observed record length. + observed: u16, + }, + /// The receipt carried unsupported flags. + UnsupportedFlags { + /// Observed flag bits. + observed: u32, + }, + /// A reserved region was nonzero. + NonZeroReserved, + /// The checksum did not match the exact prefix. + ChecksumMismatch { + /// Computed canonical checksum. + expected: [u8; 32], + /// Checksum stored in the record. + observed: [u8; 32], + }, + /// An enumeration field carried an unregistered code. + UnregisteredCode { + /// The field. + field: RecoveryDispositionField, + /// The observed code. + observed: u16, + }, + /// A generation field was zero. + ZeroGeneration { + /// The record offset of the field. + offset: usize, + }, +} + +impl fmt::Display for RecoveryDispositionDecodeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongLength { expected, observed } => write!( + formatter, + "recovery disposition requires {expected} bytes, observed {observed}" + ), + Self::InvalidMagic { .. } => formatter.write_str("invalid recovery disposition magic"), + Self::UnsupportedVersion { expected, observed } => write!( + formatter, + "unsupported recovery disposition version {observed}; expected {expected}" + ), + Self::InvalidRecordLength { expected, observed } => write!( + formatter, + "recovery disposition record length {observed}; expected {expected}" + ), + Self::UnsupportedFlags { observed } => write!( + formatter, + "unsupported recovery disposition flags {observed:#010x}" + ), + Self::NonZeroReserved => { + formatter.write_str("recovery disposition reserved bytes are nonzero") + } + Self::ChecksumMismatch { .. } => { + formatter.write_str("recovery disposition checksum mismatch") + } + Self::UnregisteredCode { field, observed } => write!( + formatter, + "recovery disposition {field:?} code {observed} is not registered" + ), + Self::ZeroGeneration { offset } => write!( + formatter, + "recovery disposition generation at offset {offset} is zero" + ), + } + } +} + +impl Error for RecoveryDispositionDecodeError {} diff --git a/src/adapters/gc/disposition_decoder.rs b/src/adapters/gc/disposition_decoder.rs new file mode 100644 index 00000000..05a18a70 --- /dev/null +++ b/src/adapters/gc/disposition_decoder.rs @@ -0,0 +1,161 @@ +//! This boundary module owns recovery-disposition receipt decoding order: +//! framing, checksum, then every registered enumeration and coordinate. + +use super::RecoveryDispositionDecodeError as Error; +use super::{ + AdmittedRecoveryDispositionReceipt, ArtifactContentDigest, ArtifactIdentityDigest, + DecisionEvidenceDigest, ObservedHeadChecksum, ReaderLockIdentity, RecoveryArtifactKind, + RecoveryClassification, RecoveryDispositionArtifact, RecoveryDispositionCoordinates, + RecoveryDispositionDecision, RecoveryDispositionField, RecoveryDispositionReceipt, + disposition_format as format, +}; +use crate::{CatalogDigest, CatalogGeneration, LivenessGeneration, RetentionManifestDigest}; + +pub(super) fn decode(encoded: &[u8]) -> Result, Error> { + require_length(encoded)?; + validate_fixed_fields(encoded)?; + verify_checksum(encoded)?; + let kind = registered( + encoded, + 24, + RecoveryDispositionField::ArtifactKind, + RecoveryArtifactKind::from_code, + )?; + let decision = registered( + encoded, + 26, + RecoveryDispositionField::Decision, + RecoveryDispositionDecision::from_code, + )?; + let classification = registered( + encoded, + 28, + RecoveryDispositionField::Classification, + RecoveryClassification::from_code, + )?; + let artifact = RecoveryDispositionArtifact { + kind, + classification, + length: read_u64(encoded, 32)?, + identity_digest: ArtifactIdentityDigest::new(read_array(encoded, 40)?), + content_digest: ArtifactContentDigest::new(read_array(encoded, 72)?), + }; + let coordinates = RecoveryDispositionCoordinates { + publication_generation: catalog_generation(encoded, 104)?, + publication_checksum: ObservedHeadChecksum::new(read_array(encoded, 112)?), + catalog_generation: catalog_generation(encoded, 144)?, + catalog_digest: CatalogDigest::from_validated(read_array(encoded, 152)?), + liveness_generation: LivenessGeneration::new(read_u64(encoded, 184)?) + .map_err(|_source| Error::ZeroGeneration { offset: 184 })?, + manifest_digest: RetentionManifestDigest::from_hash(read_array(encoded, 192)?), + reader_lock: ReaderLockIdentity::new( + read_u64(encoded, 224)?, + read_u64(encoded, 232)?, + read_u64(encoded, 240)?, + ), + }; + let evidence_digest = DecisionEvidenceDigest::new(read_array(encoded, 248)?); + let receipt = RecoveryDispositionReceipt::new(artifact, decision, coordinates, evidence_digest); + Ok(AdmittedRecoveryDispositionReceipt::admitted( + encoded, &receipt, + )) +} + +fn validate_fixed_fields(encoded: &[u8]) -> Result<(), Error> { + let magic = read_array(encoded, 0)?; + if magic != format::MAGIC { + return Err(Error::InvalidMagic { observed: magic }); + } + let version = read_u16(encoded, 16)?; + if version != format::VERSION { + return Err(Error::UnsupportedVersion { + expected: format::VERSION, + observed: version, + }); + } + let record_length = read_u16(encoded, 18)?; + if record_length != format::RECORD_LENGTH { + return Err(Error::InvalidRecordLength { + expected: format::RECORD_LENGTH, + observed: record_length, + }); + } + let flags = read_u32(encoded, 20)?; + if flags != 0 { + return Err(Error::UnsupportedFlags { observed: flags }); + } + if read_u16(encoded, format::HEADER_RESERVED_OFFSET)? != 0 { + return Err(Error::NonZeroReserved); + } + let trailer: [u8; format::TRAILER_RESERVED_LENGTH] = + read_array(encoded, format::TRAILER_RESERVED_OFFSET)?; + if trailer != [0_u8; format::TRAILER_RESERVED_LENGTH] { + return Err(Error::NonZeroReserved); + } + Ok(()) +} + +fn verify_checksum(encoded: &[u8]) -> Result<(), Error> { + let preimage = encoded + .get(..format::CHECKSUM_OFFSET) + .ok_or_else(|| wrong_length(encoded))?; + let observed = read_array(encoded, format::CHECKSUM_OFFSET)?; + let expected = format::checksum(preimage); + if observed == expected { + Ok(()) + } else { + Err(Error::ChecksumMismatch { expected, observed }) + } +} + +fn registered( + encoded: &[u8], + offset: usize, + field: RecoveryDispositionField, + admit: fn(u16) -> Option, +) -> Result { + let observed = read_u16(encoded, offset)?; + admit(observed).ok_or(Error::UnregisteredCode { field, observed }) +} + +fn catalog_generation(encoded: &[u8], offset: usize) -> Result { + CatalogGeneration::new(read_u64(encoded, offset)?) + .map_err(|_source| Error::ZeroGeneration { offset }) +} + +const fn require_length(encoded: &[u8]) -> Result<(), Error> { + if encoded.len() == format::ENCODED_LENGTH { + Ok(()) + } else { + Err(wrong_length(encoded)) + } +} + +const fn wrong_length(encoded: &[u8]) -> Error { + Error::WrongLength { + expected: format::ENCODED_LENGTH, + observed: encoded.len(), + } +} + +fn read_u16(encoded: &[u8], offset: usize) -> Result { + read_array(encoded, offset).map(u16::from_be_bytes) +} + +fn read_u32(encoded: &[u8], offset: usize) -> Result { + read_array(encoded, offset).map(u32::from_be_bytes) +} + +fn read_u64(encoded: &[u8], offset: usize) -> Result { + read_array(encoded, offset).map(u64::from_be_bytes) +} + +fn read_array(encoded: &[u8], offset: usize) -> Result<[u8; WIDTH], Error> { + let Some(end) = offset.checked_add(WIDTH) else { + return Err(wrong_length(encoded)); + }; + let bytes = encoded + .get(offset..end) + .ok_or_else(|| wrong_length(encoded))?; + <[u8; WIDTH]>::try_from(bytes).map_err(|_| wrong_length(encoded)) +} diff --git a/src/adapters/gc/disposition_encoder.rs b/src/adapters/gc/disposition_encoder.rs new file mode 100644 index 00000000..086d4c12 --- /dev/null +++ b/src/adapters/gc/disposition_encoder.rs @@ -0,0 +1,59 @@ +//! This boundary module owns canonical recovery-disposition receipt encoding. + +use super::{CanonicalRecoveryDispositionReceipt, RecoveryDispositionReceipt, disposition_format}; + +pub(super) fn encode(receipt: &RecoveryDispositionReceipt) -> CanonicalRecoveryDispositionReceipt { + let mut encoded = [0_u8; disposition_format::ENCODED_LENGTH]; + let (preimage, checksum_slot) = encoded.split_at_mut(disposition_format::CHECKSUM_OFFSET); + write_preimage(preimage, receipt); + checksum_slot.copy_from_slice(&disposition_format::checksum(preimage)); + CanonicalRecoveryDispositionReceipt::admitted(&encoded, receipt) +} + +fn write_preimage(output: &mut [u8], receipt: &RecoveryDispositionReceipt) { + let artifact = receipt.artifact(); + let coordinates = receipt.coordinates(); + let (magic, output) = output.split_at_mut(16); + magic.copy_from_slice(&disposition_format::MAGIC); + let (version, output) = output.split_at_mut(2); + version.copy_from_slice(&disposition_format::VERSION.to_be_bytes()); + let (record_length, output) = output.split_at_mut(2); + record_length.copy_from_slice(&disposition_format::RECORD_LENGTH.to_be_bytes()); + let (flags, output) = output.split_at_mut(4); + flags.copy_from_slice(&0_u32.to_be_bytes()); + let (kind, output) = output.split_at_mut(2); + kind.copy_from_slice(&artifact.kind.code().to_be_bytes()); + let (decision, output) = output.split_at_mut(2); + decision.copy_from_slice(&receipt.decision().code().to_be_bytes()); + let (classification, output) = output.split_at_mut(2); + classification.copy_from_slice(&artifact.classification.code().to_be_bytes()); + let (reserved, output) = output.split_at_mut(2); + reserved.fill(0); + let (length, output) = output.split_at_mut(8); + length.copy_from_slice(&artifact.length.to_be_bytes()); + let (identity, output) = output.split_at_mut(32); + identity.copy_from_slice(artifact.identity_digest.as_bytes()); + let (content, output) = output.split_at_mut(32); + content.copy_from_slice(artifact.content_digest.as_bytes()); + let (head_generation, output) = output.split_at_mut(8); + head_generation.copy_from_slice(&coordinates.publication_generation.get().to_be_bytes()); + let (head_checksum, output) = output.split_at_mut(32); + head_checksum.copy_from_slice(coordinates.publication_checksum.as_bytes()); + let (catalog_generation, output) = output.split_at_mut(8); + catalog_generation.copy_from_slice(&coordinates.catalog_generation.get().to_be_bytes()); + let (catalog_digest, output) = output.split_at_mut(32); + catalog_digest.copy_from_slice(coordinates.catalog_digest.as_bytes()); + let (liveness, output) = output.split_at_mut(8); + liveness.copy_from_slice(&coordinates.liveness_generation.get().to_be_bytes()); + let (manifest, output) = output.split_at_mut(32); + manifest.copy_from_slice(coordinates.manifest_digest.as_bytes()); + let (device, output) = output.split_at_mut(8); + device.copy_from_slice(&coordinates.reader_lock.device().to_be_bytes()); + let (mount, output) = output.split_at_mut(8); + mount.copy_from_slice(&coordinates.reader_lock.mount().to_be_bytes()); + let (file, output) = output.split_at_mut(8); + file.copy_from_slice(&coordinates.reader_lock.file().to_be_bytes()); + let (evidence, reserved) = output.split_at_mut(32); + evidence.copy_from_slice(receipt.evidence_digest().as_bytes()); + reserved.fill(0); +} diff --git a/src/adapters/gc/disposition_enums.rs b/src/adapters/gc/disposition_enums.rs new file mode 100644 index 00000000..4343b99a --- /dev/null +++ b/src/adapters/gc/disposition_enums.rs @@ -0,0 +1,84 @@ +//! This boundary module owns the registered recovery-disposition +//! enumerations frozen in `definition.tsv`. + +macro_rules! registered_enum { + ( + $(#[$doc:meta])* $name:ident { $($(#[$variant_doc:meta])* $variant:ident = $code:literal / $identifier:literal),+ $(,)? } + ) => { + $(#[$doc])* + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] + pub enum $name { + $($(#[$variant_doc])* $variant,)+ + } + + impl $name { + /// Every registered value in code order. + pub const ALL: &'static [Self] = &[$(Self::$variant,)+]; + + /// Returns the registered wire code. + #[must_use] + pub const fn code(self) -> u16 { + match self { + $(Self::$variant => $code,)+ + } + } + + /// Returns the registered identifier from `definition.tsv`. + #[must_use] + pub const fn identifier(self) -> &'static str { + match self { + $(Self::$variant => $identifier,)+ + } + } + + /// Admits one registered wire code; every other code refuses. + #[must_use] + pub const fn from_code(code: u16) -> Option { + match code { + $($code => Some(Self::$variant),)+ + _ => None, + } + } + } + }; +} + +registered_enum! { + /// The kind of physical artifact one disposition names. + RecoveryArtifactKind { + /// An immutable segment in `segments/`. + Segment = 1 / "segment", + /// An immutable catalog generation in `catalogs/`. + Catalog = 2 / "catalog", + /// An immutable retention root in `retention/roots//`. + RetentionRoot = 3 / "retention-root", + /// An immutable retention manifest in `retention/manifests/`. + RetentionManifest = 4 / "retention-manifest", + /// A retention head stage, `retention/head.next`. + RetentionHead = 5 / "retention-head", + } +} + +registered_enum! { + /// The decision one disposition records. + RecoveryDispositionDecision { + /// Complete the interrupted publication and keep the artifact. + Finalize = 1 / "finalize", + /// Release the artifact from recovery protection so GC may plan it. + Retire = 2 / "retire", + } +} + +registered_enum! { + /// The recovery classification the artifact was admitted under. + RecoveryClassification { + /// A complete, verified artifact linked into its pool that no head, + /// catalog, or manifest names. + CompleteOrphan = 1 / "complete-orphan", + /// A complete, verified fixed stage not yet linked into its pool. + CompleteStage = 2 / "complete-stage", + /// A complete artifact whose generation a later publication + /// superseded before it became visible. + StaleGeneration = 3 / "stale-generation", + } +} diff --git a/src/adapters/gc/disposition_format.rs b/src/adapters/gc/disposition_format.rs new file mode 100644 index 00000000..889fc909 --- /dev/null +++ b/src/adapters/gc/disposition_format.rs @@ -0,0 +1,29 @@ +//! This boundary module owns recovery-disposition receipt framing and +//! integrity. + +pub(super) const CHECKSUM_OFFSET: usize = 288; +pub(super) const ENCODED_LENGTH: usize = 320; +pub(super) const MAGIC: [u8; 16] = *b"KEEP:REC:DISP2\0\0"; +pub(super) const RECORD_LENGTH: u16 = 320; +pub(super) const VERSION: u16 = 2; +pub(super) const HEADER_RESERVED_OFFSET: usize = 30; +pub(super) const TRAILER_RESERVED_OFFSET: usize = 280; +pub(super) const TRAILER_RESERVED_LENGTH: usize = 8; +const CHECKSUM_DOMAIN: &[u8] = b"keep.recovery-disposition-receipt-checksum/v2\0"; +const ARTIFACT_DOMAIN: &[u8] = b"keep.recovery-disposition-artifact/v2\0"; + +pub(super) fn checksum(preimage: &[u8]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(CHECKSUM_DOMAIN); + hasher.update(preimage); + *hasher.finalize().as_bytes() +} + +/// Digests the exact bytes of one disposed artifact under the registered +/// artifact domain. +pub(super) fn artifact_content_digest(bytes: &[u8]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(ARTIFACT_DOMAIN); + hasher.update(bytes); + *hasher.finalize().as_bytes() +} diff --git a/src/adapters/gc/evidence_digests.rs b/src/adapters/gc/evidence_digests.rs index be95c244..2ab0b402 100644 --- a/src/adapters/gc/evidence_digests.rs +++ b/src/adapters/gc/evidence_digests.rs @@ -54,3 +54,25 @@ evidence_digest! { /// Digest of the verified, synchronized segment pool after retirement. PoolStateDigest } + +evidence_digest! { + /// Physical evidence identity of one disposed artifact: its pool-name + /// digest. + ArtifactIdentityDigest +} + +evidence_digest! { + /// Digest of one disposed artifact's exact verified bytes under the + /// registered artifact domain. + ArtifactContentDigest +} + +evidence_digest! { + /// Digest of the complete canonical proof behind one disposition. + DecisionEvidenceDigest +} + +evidence_digest! { + /// The publication-head checksum observed when a disposition was decided. + ObservedHeadChecksum +} diff --git a/src/adapters/gc/mod.rs b/src/adapters/gc/mod.rs index 64809349..55ae5f6d 100644 --- a/src/adapters/gc/mod.rs +++ b/src/adapters/gc/mod.rs @@ -4,14 +4,21 @@ //! canonical encoders, and their admitting decoders, and the deterministic //! planner that classifies one physical inventory against one immutable //! liveness snapshot. It does not own GC execution, reader fencing, -//! recovery, or the recovery-disposition receipt, whose registered -//! enumerations are not yet frozen in the format definition. +//! or recovery. +mod admitted_disposition; mod admitted_intent; mod admitted_receipt; mod candidate; +mod canonical_disposition; mod canonical_intent; mod canonical_receipt; +mod disposition; +mod disposition_decode_error; +mod disposition_decoder; +mod disposition_encoder; +mod disposition_enums; +mod disposition_format; mod evidence_digests; mod intent; mod intent_candidate_decoder; @@ -50,14 +57,24 @@ mod retained_closure; mod segment_classification; mod segment_pool_inventory; +pub use admitted_disposition::AdmittedRecoveryDispositionReceipt; pub use admitted_intent::AdmittedGcRetirementIntent; pub use admitted_receipt::AdmittedGcRetirementReceipt; pub use candidate::GcCandidate; +pub use canonical_disposition::CanonicalRecoveryDispositionReceipt; pub use canonical_intent::CanonicalGcRetirementIntent; pub use canonical_receipt::CanonicalGcRetirementReceipt; +pub use disposition::{ + RecoveryDispositionArtifact, RecoveryDispositionCoordinates, RecoveryDispositionReceipt, +}; +pub use disposition_decode_error::{RecoveryDispositionDecodeError, RecoveryDispositionField}; +pub use disposition_enums::{ + RecoveryArtifactKind, RecoveryClassification, RecoveryDispositionDecision, +}; pub use evidence_digests::{ - CatalogSuccessorProofDigest, DispositionSetDigest, PoolStateDigest, SegmentPoolIdentityDigest, - VerificationEvidenceDigest, + ArtifactContentDigest, ArtifactIdentityDigest, CatalogSuccessorProofDigest, + DecisionEvidenceDigest, DispositionSetDigest, ObservedHeadChecksum, PoolStateDigest, + SegmentPoolIdentityDigest, VerificationEvidenceDigest, }; pub use intent::GcRetirementIntent; pub use intent_coordinates::GcRetirementIntentCoordinates; diff --git a/src/adapters/store_migration/format_definition_digest.rs b/src/adapters/store_migration/format_definition_digest.rs index f9b29bbf..2eedaf80 100644 --- a/src/adapters/store_migration/format_definition_digest.rs +++ b/src/adapters/store_migration/format_definition_digest.rs @@ -8,9 +8,9 @@ pub struct StoreFormatDefinitionDigest([u8; 32]); impl StoreFormatDefinitionDigest { /// Digest of the frozen `keep.segment-store/v2` definition. pub const VERSION_TWO: Self = Self([ - 0x32, 0x38, 0x1f, 0x1a, 0xc3, 0x32, 0xd1, 0x27, 0x7a, 0x7e, 0x1f, 0xaf, 0x8f, 0x11, 0x57, - 0x69, 0x93, 0xcb, 0x55, 0xb7, 0xe8, 0x5d, 0x2a, 0x11, 0x0b, 0x74, 0xdc, 0x9c, 0x3b, 0x87, - 0x34, 0x27, + 0x6c, 0xbc, 0x1c, 0x75, 0xf6, 0xef, 0xab, 0x18, 0xc7, 0xc5, 0x0a, 0xe2, 0x81, 0xed, 0xef, + 0x77, 0xa8, 0xb0, 0xc9, 0xba, 0x19, 0xf6, 0x18, 0xb2, 0x8b, 0x18, 0x48, 0x3d, 0x08, 0x46, + 0x2c, 0x92, ]); /// Returns the raw digest bytes. diff --git a/src/lib.rs b/src/lib.rs index c950e663..eccc02eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -138,17 +138,21 @@ pub use adapters::{ publish_catalog_generation, read_recovery_inventory, }; pub use adapters::{ - AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, CanonicalGcRetirementIntent, - CanonicalGcRetirementReceipt, CatalogSuccessorProofDigest, DispositionSetDigest, GcCandidate, - GcCandidateSetDigest, GcLimits, GcLimitsError, GcLivenessCoordinates, - GcLivenessObservationError, GcLivenessSnapshot, GcLivenessSnapshotError, GcPlan, - GcPlanAmbiguity, GcPlanError, GcPlannedCandidate, GcPlannedSegment, GcRetainedClosure, - GcRetentionState, GcRetirementIntent, GcRetirementIntentCoordinates, - GcRetirementIntentDecodeError, GcRetirementIntentDigest, GcRetirementIntentEncodeError, - GcRetirementIntentError, GcRetirementReceipt, GcRetirementReceiptDecodeError, - GcSegmentClassification, GcUnreachableEvidence, PoolStateDigest, ReaderLockCoordinate, - ReaderLockIdentity, SegmentPoolIdentityDigest, VerificationEvidenceDigest, observe_gc_liveness, - plan_gc, + AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, AdmittedRecoveryDispositionReceipt, + ArtifactContentDigest, ArtifactIdentityDigest, CanonicalGcRetirementIntent, + CanonicalGcRetirementReceipt, CanonicalRecoveryDispositionReceipt, CatalogSuccessorProofDigest, + DecisionEvidenceDigest, DispositionSetDigest, GcCandidate, GcCandidateSetDigest, GcLimits, + GcLimitsError, GcLivenessCoordinates, GcLivenessObservationError, GcLivenessSnapshot, + GcLivenessSnapshotError, GcPlan, GcPlanAmbiguity, GcPlanError, GcPlannedCandidate, + GcPlannedSegment, GcRetainedClosure, GcRetentionState, GcRetirementIntent, + GcRetirementIntentCoordinates, GcRetirementIntentDecodeError, GcRetirementIntentDigest, + GcRetirementIntentEncodeError, GcRetirementIntentError, GcRetirementReceipt, + GcRetirementReceiptDecodeError, GcSegmentClassification, GcUnreachableEvidence, + ObservedHeadChecksum, PoolStateDigest, ReaderLockCoordinate, ReaderLockIdentity, + RecoveryArtifactKind, RecoveryClassification, RecoveryDispositionArtifact, + RecoveryDispositionCoordinates, RecoveryDispositionDecision, RecoveryDispositionDecodeError, + RecoveryDispositionField, RecoveryDispositionReceipt, SegmentPoolIdentityDigest, + VerificationEvidenceDigest, observe_gc_liveness, plan_gc, }; pub use adapters::{ AdmittedRetentionManifest, AdmittedRetentionRoot, CanonicalRetentionHead, diff --git a/tests/recovery_disposition_receipt.rs b/tests/recovery_disposition_receipt.rs new file mode 100644 index 00000000..a6512fd0 --- /dev/null +++ b/tests/recovery_disposition_receipt.rs @@ -0,0 +1,330 @@ +//! Canonical recovery-disposition receipt laws. + +mod support; + +use std::io; + +use keep::{ + AdmittedRecoveryDispositionReceipt, CanonicalRecoveryDispositionReceipt, RecoveryArtifactKind, + RecoveryClassification, RecoveryDispositionDecision, RecoveryDispositionDecodeError as Refusal, + RecoveryDispositionField as Field, +}; + +use crate::support::{domain_hash, flip, patch}; + +const DISPOSITION: &str = + include_str!("../conformance/segment-store/v2/one-orphan-retire-disposition.hex"); +const SEGMENT: &str = include_str!("../conformance/segment-store/v1/one-zero-segment.hex"); +const DEFINITION: &str = include_str!("../conformance/segment-store/v2/definition.tsv"); +const CHECKSUM_DOMAIN: &[u8] = b"keep.recovery-disposition-receipt-checksum/v2\0"; +const CHECKSUM_OFFSET: usize = 288; +const CONTENT_DIGEST_OFFSET: usize = 72; +const SEGMENT_DIGEST_OFFSET: usize = 273; + +struct Mutation { + field: &'static str, + reseal: bool, + mutate: fn(&mut Vec) -> io::Result<()>, + refuses: fn(&Refusal) -> bool, +} + +const MATRIX: &[Mutation] = &[ + Mutation { + field: "magic", + reseal: true, + mutate: |bytes| flip(bytes, 15), + refuses: |error| matches!(error, Refusal::InvalidMagic { .. }), + }, + Mutation { + field: "version", + reseal: true, + mutate: |bytes| patch(bytes, 16, &3_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::UnsupportedVersion { + expected: 2, + observed: 3 + } + ) + }, + }, + Mutation { + field: "record length", + reseal: true, + mutate: |bytes| patch(bytes, 18, &319_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::InvalidRecordLength { + expected: 320, + observed: 319 + } + ) + }, + }, + Mutation { + field: "flags", + reseal: true, + mutate: |bytes| patch(bytes, 20, &1_u32.to_be_bytes()), + refuses: |error| matches!(error, Refusal::UnsupportedFlags { observed: 1 }), + }, + Mutation { + field: "artifact kind", + reseal: true, + mutate: |bytes| patch(bytes, 24, &6_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::UnregisteredCode { + field: Field::ArtifactKind, + observed: 6 + } + ) + }, + }, + Mutation { + field: "decision", + reseal: true, + mutate: |bytes| patch(bytes, 26, &0_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::UnregisteredCode { + field: Field::Decision, + observed: 0 + } + ) + }, + }, + Mutation { + field: "classification", + reseal: true, + mutate: |bytes| patch(bytes, 28, &4_u16.to_be_bytes()), + refuses: |error| { + matches!( + error, + Refusal::UnregisteredCode { + field: Field::Classification, + observed: 4 + } + ) + }, + }, + Mutation { + field: "header reserved", + reseal: true, + mutate: |bytes| flip(bytes, 31), + refuses: |error| matches!(error, Refusal::NonZeroReserved), + }, + Mutation { + field: "publication-head generation", + reseal: true, + mutate: |bytes| patch(bytes, 104, &0_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::ZeroGeneration { offset: 104 }), + }, + Mutation { + field: "catalog generation", + reseal: true, + mutate: |bytes| patch(bytes, 144, &0_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::ZeroGeneration { offset: 144 }), + }, + Mutation { + field: "liveness generation", + reseal: true, + mutate: |bytes| patch(bytes, 184, &0_u64.to_be_bytes()), + refuses: |error| matches!(error, Refusal::ZeroGeneration { offset: 184 }), + }, + Mutation { + field: "trailer reserved", + reseal: true, + mutate: |bytes| flip(bytes, 287), + refuses: |error| matches!(error, Refusal::NonZeroReserved), + }, + Mutation { + field: "checksum", + reseal: false, + mutate: |bytes| flip(bytes, 319), + refuses: |error| matches!(error, Refusal::ChecksumMismatch { .. }), + }, + Mutation { + field: "artifact length under a stale checksum", + reseal: false, + mutate: |bytes| flip(bytes, 39), + refuses: |error| matches!(error, Refusal::ChecksumMismatch { .. }), + }, +]; + +#[test] +fn frozen_disposition_decodes_and_reencodes_canonically() -> Result<(), Box> +{ + let bytes = fixture_bytes(DISPOSITION)?; + let admitted = AdmittedRecoveryDispositionReceipt::decode(&bytes)?; + assert_eq!(admitted.encoded(), bytes); + + let receipt = *admitted.receipt(); + let artifact = receipt.artifact(); + assert_eq!(artifact.kind, RecoveryArtifactKind::Segment); + assert_eq!(receipt.decision(), RecoveryDispositionDecision::Retire); + assert_eq!( + artifact.classification, + RecoveryClassification::CompleteOrphan + ); + assert_eq!(artifact.length, 337); + let segment = fixture_bytes(SEGMENT)?; + assert_eq!( + artifact.identity_digest.as_bytes().as_slice(), + segment + .get(SEGMENT_DIGEST_OFFSET..SEGMENT_DIGEST_OFFSET + 32) + .ok_or("segment digest")? + ); + assert_eq!( + artifact.content_digest, + CanonicalRecoveryDispositionReceipt::artifact_content_digest(&segment) + ); + assert_eq!( + bytes + .get(CONTENT_DIGEST_OFFSET..CONTENT_DIGEST_OFFSET + 32) + .ok_or("content digest")?, + artifact.content_digest.as_bytes().as_slice() + ); + let coordinates = receipt.coordinates(); + assert_eq!(coordinates.publication_generation.get(), 2); + assert_eq!(coordinates.catalog_generation.get(), 2); + assert_eq!(coordinates.liveness_generation.get(), 1); + assert_eq!( + ( + coordinates.reader_lock.device(), + coordinates.reader_lock.mount(), + coordinates.reader_lock.file() + ), + (4, 5, 6) + ); + + let canonical = CanonicalRecoveryDispositionReceipt::from_receipt(&receipt); + assert_eq!(canonical.encoded(), bytes); + assert_eq!(canonical.receipt(), &receipt); + Ok(()) +} + +#[test] +fn every_registered_code_round_trips_and_matches_the_definition() +-> Result<(), Box> { + for kind in RecoveryArtifactKind::ALL { + assert_eq!(RecoveryArtifactKind::from_code(kind.code()), Some(*kind)); + } + for decision in RecoveryDispositionDecision::ALL { + assert_eq!( + RecoveryDispositionDecision::from_code(decision.code()), + Some(*decision) + ); + } + for classification in RecoveryClassification::ALL { + assert_eq!( + RecoveryClassification::from_code(classification.code()), + Some(*classification) + ); + } + assert_eq!(RecoveryArtifactKind::from_code(0), None); + assert_eq!(RecoveryDispositionDecision::from_code(3), None); + assert_eq!(RecoveryClassification::from_code(u16::MAX), None); + + let registered = |key: &str| -> Result> { + DEFINITION + .lines() + .find_map(|row| { + row.strip_prefix(key) + .and_then(|rest| rest.strip_prefix('\t')) + }) + .map(str::to_owned) + .ok_or_else(|| format!("definition lacks {key}").into()) + }; + let render = |pairs: Vec<(&str, u16)>| { + pairs + .into_iter() + .map(|(name, code)| format!("{name}:{code}")) + .collect::>() + .join(",") + }; + assert_eq!( + registered("recovery.disposition.artifact-kinds")?, + render( + RecoveryArtifactKind::ALL + .iter() + .map(|kind| (kind.identifier(), kind.code())) + .collect() + ) + ); + assert_eq!( + registered("recovery.disposition.decisions")?, + render( + RecoveryDispositionDecision::ALL + .iter() + .map(|decision| (decision.identifier(), decision.code())) + .collect() + ) + ); + assert_eq!( + registered("recovery.disposition.classifications")?, + render( + RecoveryClassification::ALL + .iter() + .map(|class| (class.identifier(), class.code())) + .collect() + ) + ); + Ok(()) +} + +#[test] +fn every_structural_field_has_one_exact_first_refusal() -> Result<(), Box> { + let canonical = fixture_bytes(DISPOSITION)?; + for mutation in MATRIX { + let mut bytes = canonical.clone(); + (mutation.mutate)(&mut bytes)?; + if mutation.reseal { + let checksum = domain_hash( + CHECKSUM_DOMAIN, + bytes.get(..CHECKSUM_OFFSET).ok_or("checksum preimage")?, + ); + patch(&mut bytes, CHECKSUM_OFFSET, &checksum)?; + } + let error = AdmittedRecoveryDispositionReceipt::decode(&bytes) + .err() + .ok_or_else(|| format!("mutated {} was admitted", mutation.field))?; + assert!( + (mutation.refuses)(&error), + "{} reached the wrong refusal: {error:?}", + mutation.field + ); + } + Ok(()) +} + +#[test] +fn framing_refuses_truncation_and_trailing_bytes() -> Result<(), Box> { + let bytes = fixture_bytes(DISPOSITION)?; + let mut truncated = bytes.clone(); + assert!(truncated.pop().is_some()); + assert!(matches!( + AdmittedRecoveryDispositionReceipt::decode(&truncated), + Err(Refusal::WrongLength { + expected: 320, + observed: 319 + }) + )); + let mut trailing = bytes; + trailing.push(0); + assert!(matches!( + AdmittedRecoveryDispositionReceipt::decode(&trailing), + Err(Refusal::WrongLength { + expected: 320, + observed: 321 + }) + )); + Ok(()) +} + +fn fixture_bytes(hex: &str) -> Result, io::Error> { + support::decode_hex(hex.trim_end()) +} diff --git a/tests/store_format_marker.rs b/tests/store_format_marker.rs index bc23f609..03a569dd 100644 --- a/tests/store_format_marker.rs +++ b/tests/store_format_marker.rs @@ -11,8 +11,8 @@ use keep::{ const FORMAT_MARKER: &str = include_str!("../conformance/segment-store/v2/format-marker.hex"); const MARKER_DIGEST: [u8; 32] = [ - 0x4b, 0x06, 0x3c, 0x32, 0x90, 0x85, 0xab, 0xde, 0xbe, 0x86, 0xb2, 0x56, 0xd5, 0x31, 0xb1, 0x12, - 0xc7, 0xea, 0x33, 0xcb, 0x2f, 0x54, 0x5c, 0xaa, 0x40, 0xa7, 0xa8, 0x69, 0xff, 0x33, 0x37, 0xce, + 0xcb, 0xb0, 0xd6, 0x0f, 0x9a, 0xaa, 0x89, 0x66, 0x42, 0xe9, 0xe7, 0xcf, 0xa5, 0xe9, 0x57, 0x5a, + 0xd0, 0x78, 0x28, 0x85, 0xd5, 0x22, 0x1d, 0xfd, 0x72, 0x89, 0xcd, 0xe7, 0x79, 0xc6, 0x3e, 0xa0, ]; #[test] diff --git a/tests/store_migration_intent/fixture.rs b/tests/store_migration_intent/fixture.rs index 83da59d8..1fc154b3 100644 --- a/tests/store_migration_intent/fixture.rs +++ b/tests/store_migration_intent/fixture.rs @@ -20,12 +20,12 @@ pub(super) const INVENTORY_DIGEST: [u8; 32] = [ 0x80, 0xcd, 0x98, 0x0d, 0x14, 0x05, 0xd2, 0xdd, 0x02, 0xce, 0xff, 0x8f, 0x58, 0xd6, 0x74, 0xf9, ]; pub(super) const STORE_IDENTIFIER: [u8; 32] = [ - 0x0c, 0xd9, 0xd3, 0xdf, 0xbe, 0xc9, 0xb3, 0x49, 0xfe, 0x42, 0xd2, 0x14, 0x75, 0x27, 0x1b, 0x0e, - 0x8d, 0xe2, 0x3c, 0x04, 0x34, 0x40, 0xd6, 0x42, 0x7a, 0x1c, 0x37, 0x89, 0x8a, 0xd1, 0xdd, 0x79, + 0x2b, 0x5e, 0xd4, 0xbc, 0xc9, 0x26, 0xa6, 0xa5, 0xfa, 0x9f, 0xd5, 0xf7, 0x49, 0xc1, 0x34, 0x89, + 0x4d, 0xe9, 0x9d, 0x37, 0xbd, 0xf2, 0xde, 0xf4, 0xe8, 0x3b, 0xdf, 0x99, 0xa6, 0x53, 0x97, 0x20, ]; pub(super) const INTENT_DIGEST: [u8; 32] = [ - 0xa1, 0x5a, 0x00, 0x00, 0x02, 0x19, 0xdf, 0x20, 0x97, 0x9d, 0xa3, 0x64, 0x19, 0x04, 0x6e, 0xae, - 0x9a, 0x0b, 0xa9, 0x98, 0x64, 0x5f, 0xbf, 0xe3, 0x08, 0xea, 0x43, 0x35, 0xa8, 0x32, 0x6b, 0x44, + 0xf4, 0x91, 0x4d, 0x8d, 0x91, 0x76, 0x71, 0x0e, 0xba, 0xd4, 0xff, 0x5c, 0x3f, 0x9b, 0x5a, 0xb8, + 0x72, 0x7b, 0x3e, 0xb4, 0xc4, 0x63, 0xd1, 0x18, 0x5f, 0x97, 0x47, 0x98, 0xcc, 0xa4, 0xa4, 0xc3, ]; pub(super) fn fixture_bytes() -> Result, io::Error> { diff --git a/xtask/src/fuzz_seed_corpus/gc_seeds.rs b/xtask/src/fuzz_seed_corpus/gc_seeds.rs index 49914d9a..fd153e01 100644 --- a/xtask/src/fuzz_seed_corpus/gc_seeds.rs +++ b/xtask/src/fuzz_seed_corpus/gc_seeds.rs @@ -6,17 +6,29 @@ use super::{FuzzSeedError, MAX_SEED_BYTES, Seed, prefixed}; const GC_INTENT_FIXTURE: &str = "one-candidate-gc-intent.hex"; const GC_RECEIPT_FIXTURE: &str = "one-candidate-gc-receipt.hex"; +const DISPOSITION_FIXTURE: &str = "one-orphan-retire-disposition.hex"; -pub(super) const FIXTURES: [(u8, &str); 2] = [(0, GC_INTENT_FIXTURE), (1, GC_RECEIPT_FIXTURE)]; +pub(super) const FIXTURES: [(u8, &str); 3] = [ + (0, GC_INTENT_FIXTURE), + (1, GC_RECEIPT_FIXTURE), + (2, DISPOSITION_FIXTURE), +]; pub(super) fn seeds(files: &RepositoryFiles) -> Result, FuzzSeedError> { let [ (intent_selector, intent_fixture), (receipt_selector, receipt_fixture), + (disposition_selector, disposition_fixture), ] = FIXTURES; let intent = segment_store_v2_fixture::read_hex(files, intent_fixture)?; let receipt = segment_store_v2_fixture::read_hex(files, receipt_fixture)?; + let disposition = segment_store_v2_fixture::read_hex(files, disposition_fixture)?; Ok(vec![ + Seed::new( + "gc_format", + "one-orphan-retire-disposition", + prefixed(disposition_selector, &disposition)?, + )?, Seed::new( "gc_format", "one-candidate-gc-intent", diff --git a/xtask/src/fuzz_seed_corpus/tests/materialization.rs b/xtask/src/fuzz_seed_corpus/tests/materialization.rs index fb9f9c7b..ea728cd7 100644 --- a/xtask/src/fuzz_seed_corpus/tests/materialization.rs +++ b/xtask/src/fuzz_seed_corpus/tests/materialization.rs @@ -47,9 +47,9 @@ fn seed_preparation_materializes_the_complete_deterministic_set() prepare(root)?; let corpus = root.join("fuzz/corpus"); let first = seed_contents(&corpus)?; - assert_eq!(first.len(), 48); + assert_eq!(first.len(), 49); assert_eq!(target_seed_count(&first, "catalog_format/"), 6); - assert_eq!(target_seed_count(&first, "gc_format/"), 2); + assert_eq!(target_seed_count(&first, "gc_format/"), 3); assert_eq!(target_seed_count(&first, "golden_protocol/"), 9); assert_eq!(target_seed_count(&first, "layout_record/"), 4); assert_eq!(target_seed_count(&first, "migration_format/"), 3); diff --git a/xtask/tests/retention_store_v2_conformance_contract.rs b/xtask/tests/retention_store_v2_conformance_contract.rs index 6d57876a..cbed8fdf 100644 --- a/xtask/tests/retention_store_v2_conformance_contract.rs +++ b/xtask/tests/retention_store_v2_conformance_contract.rs @@ -27,6 +27,7 @@ const REQUIRED_PATHS: &[&str] = &[ "one-root-head.hex", "one-candidate-gc-intent.hex", "one-candidate-gc-receipt.hex", + "one-orphan-retire-disposition.hex", ]; fn repository_root() -> Result { diff --git a/xtask/tests/retention_store_v2_format_oracle/artifacts.rs b/xtask/tests/retention_store_v2_format_oracle/artifacts.rs index d04cd46e..83ab639f 100644 --- a/xtask/tests/retention_store_v2_format_oracle/artifacts.rs +++ b/xtask/tests/retention_store_v2_format_oracle/artifacts.rs @@ -18,6 +18,7 @@ fn build_corpus() -> Result { let head = build_retention_head(&manifest)?; let gc_intent = build_gc_intent(profile_digest, inventory.digest, &manifest)?; let gc_receipt = build_gc_receipt(&gc_intent)?; + let disposition = build_recovery_disposition(&manifest)?; let artifacts = vec![ format, intent, @@ -45,6 +46,7 @@ fn build_corpus() -> Result { head, gc_intent, gc_receipt, + disposition, ]; Ok(Corpus { profile_digest, diff --git a/xtask/tests/retention_store_v2_format_oracle/artifacts/gc.rs b/xtask/tests/retention_store_v2_format_oracle/artifacts/gc.rs index 75a0e1c9..645d4db6 100644 --- a/xtask/tests/retention_store_v2_format_oracle/artifacts/gc.rs +++ b/xtask/tests/retention_store_v2_format_oracle/artifacts/gc.rs @@ -6,6 +6,8 @@ const GC_INTENT_DOMAIN: &[u8] = b"keep.gc-retirement-intent/v2\0"; const GC_INTENT_CHECKSUM_DOMAIN: &[u8] = b"keep.gc-retirement-intent-checksum/v2\0"; const GC_RECEIPT_CHECKSUM_DOMAIN: &[u8] = b"keep.gc-retirement-receipt-checksum/v2\0"; const EMPTY_DISPOSITION_SET_DOMAIN: &[u8] = b"keep.empty-disposition-set/v2\0"; +const DISPOSITION_CHECKSUM_DOMAIN: &[u8] = b"keep.recovery-disposition-receipt-checksum/v2\0"; +const DISPOSITION_ARTIFACT_DOMAIN: &[u8] = b"keep.recovery-disposition-artifact/v2\0"; struct GcSource { candidate_segment_digest: [u8; 32], @@ -133,3 +135,50 @@ fn build_gc_receipt(intent: &Artifact) -> Result { bytes, }) } + +/// The disposition that retires the one-zero segment as a complete orphan +/// under the generation-two catalog and head, the generation-one manifest, +/// and the fixture-only reader-lock coordinates the GC intent uses. +fn build_recovery_disposition(manifest: &ManifestArtifact) -> Result { + let source = gc_source()?; + let segment = decode_hex(V1_SEGMENT)?; + let head_two = decode_hex(V1_HEAD_TWO)?; + let content_digest = hash(DISPOSITION_ARTIFACT_DOMAIN, &[&segment]); + let mut bytes = Vec::with_capacity(320); + bytes.extend_from_slice(b"KEEP:REC:DISP2\0\0"); + push_u16(&mut bytes, 2); + push_u16(&mut bytes, 320); + push_u32(&mut bytes, 0); + push_u16(&mut bytes, 1); + push_u16(&mut bytes, 2); + push_u16(&mut bytes, 1); + push_u16(&mut bytes, 0); + push_u64(&mut bytes, source.candidate_segment_length); + bytes.extend_from_slice(&source.candidate_segment_digest); + bytes.extend_from_slice(&content_digest); + push_u64(&mut bytes, u64_at(&head_two, 24)?); + bytes.extend_from_slice(&source.catalog_proof_digest); + push_u64(&mut bytes, 2); + bytes.extend_from_slice(&source.catalog_digest); + push_u64(&mut bytes, 1); + bytes.extend_from_slice(&manifest.digest); + push_u64(&mut bytes, 4); + push_u64(&mut bytes, 5); + push_u64(&mut bytes, 6); + bytes.extend_from_slice(&source.candidate_evidence_digest); + bytes.extend_from_slice(&[0; 8]); + require_length(&bytes, 288, "recovery disposition checksum preimage")?; + let checksum = hash(DISPOSITION_CHECKSUM_DOMAIN, &[&bytes]); + bytes.extend_from_slice(&checksum); + require_length(&bytes, 320, "recovery disposition receipt")?; + Ok(Artifact { + case_name: "one-orphan-retire-disposition", + kind: "recovery-disposition", + generation: "2", + entry_count: "-", + bound_digest: source.candidate_segment_digest, + final_checksum: checksum, + fixture: "one-orphan-retire-disposition.hex", + bytes, + }) +} From aa371ae455334aef156d71973c46ea73f0a5422e Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 11:44:43 -0700 Subject: [PATCH 26/59] Feat: explicit finalize-or-retire disposition of protected retention orphans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROADMAP T-22.5. A complete stage that recovery linked into its pool but no head ever committed was the one state that waited for a human. It still does, but the human now has one command with a stated consequence: `FilesystemRetentionPublicationAuthority::dispose(request)`. - Pure core: `plan_recovery_disposition` over the executed recovery plan and pool observations (refuses pending recovery, nothing protected, an unretained target, a root under a protected manifest, an unlinked pool entry, and `Finalize` without a published head); `RecoveryDispositionPhase` (ten ordered phases, eight for `Finalize`); `RecoveryDispositionStorage` port; `resume_recovery_disposition` executor that reports the refused phase and the completed prefix. - Filesystem adapter: recovery runs first; the exclusive reader fence is taken without waiting (`ReadersActive` otherwise); the receipt binds the artifact's kind, length, pool-name identity, content digest, and trailing checksum, the publication head and catalog, the retention state (liveness zero beside the initial retention-state digest when no head is published), and the locked `reader.lock`; it travels the fixed-stage protocol through `recovery/disposition.next` to `recovery/dispositions/.receipt` and is durable before the retained stage is removed. `Retire` then unlinks the pool entry and an emptied namespace directory, because an absent head admits no pool artifact and no collector exists for the retention pools; `Finalize` keeps the entry. Every residue an interrupted run leaves resumes on the next call, including a receipt whose pool entry is still present; a residue naming another decision is a typed ambiguity. - Admission: version-two roots admit canonical `.receipt` entries and a retained `disposition.next`; anything else in `recovery/dispositions` refuses. GC planning reads the receipts and admits only the exact `segment`/`retire` receipt decided under the snapshot's own coordinates; a stale one keeps its segment protected and a corrupt one refuses. - `ReaderFence::acquire_exclusive` (non-blocking) and `identity`. - Laws: retire under an absent head frees publication and empties the pools; finalize needs a published head; finalize of a successor orphan keeps its entry and frees a new-namespace publication; manifest before root; a second call reports nothing protected; a reader refuses; five reconstructed residues resume and two foreign residues refuse; readers admit receipts and refuse a stray entry; the exact, stale, and corrupt segment receipts plan as disposed, protected, and refused. - `recovery.md` gains the disposition protocol with its §5.4 warning; README's "waits for a human" paragraph names the command; `KEEP-GC-002` records the evidence. The process-death matrix for the disposition phases joins the GC sequence in T-22.4. Red: stopping execution after the receipt is durable fails five laws (stage and pool entry remain). Green: restored. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 21 + README.md | 9 +- ROADMAP.md | 28 +- docs/formats/segment-store-v2/gc.md | 6 +- docs/formats/segment-store-v2/recovery.md | 44 +- docs/formats/segment-store-v2/requirements.md | 2 +- .../filesystem_initialization_namespace.rs | 32 +- src/adapters/gc/disposition.rs | 14 +- src/adapters/gc/disposition_decode_error.rs | 10 + src/adapters/gc/disposition_decoder.rs | 27 +- src/adapters/gc/disposition_encoder.rs | 15 +- src/adapters/gc/disposition_format.rs | 5 + src/adapters/gc/liveness_observation.rs | 78 +- src/adapters/gc/liveness_observation_error.rs | 14 + src/adapters/gc/liveness_observation_tests.rs | 111 +++ src/adapters/physical_pool_name.rs | 5 + src/adapters/retention.rs | 19 + .../retention/disposition_execution.rs | 122 ++++ src/adapters/retention/disposition_plan.rs | 216 ++++++ src/adapters/retention/disposition_storage.rs | 87 +++ .../filesystem_retention_authority.rs | 3 + .../filesystem_retention_disposition.rs | 689 ++++++++++++++++++ .../filesystem_retention_disposition_tests.rs | 418 +++++++++++ .../filesystem_retention_pool_name.rs | 17 + .../filesystem_retention_recovery.rs | 5 +- src/adapters/retention/reader_fence.rs | 23 +- src/adapters/store_migration.rs | 1 + .../migration_receipt_initial_state.rs | 2 +- src/lib.rs | 19 +- tests/recovery_disposition_receipt.rs | 44 +- 30 files changed, 2022 insertions(+), 64 deletions(-) create mode 100644 src/adapters/retention/disposition_execution.rs create mode 100644 src/adapters/retention/disposition_plan.rs create mode 100644 src/adapters/retention/disposition_storage.rs create mode 100644 src/adapters/retention/filesystem_retention_disposition.rs create mode 100644 src/adapters/retention/filesystem_retention_disposition_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a5b21712..1db1039d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,27 @@ after its public API and format compatibility policies are established. ### Added +- Explicit disposition of recovery-protected retention orphans. + `FilesystemRetentionPublicationAuthority::dispose` records a + finalize-or-retire decision over a linked, retained `root.next` or + `manifest.next` under writer authority and the exclusive reader fence: + the `RecoveryDispositionReceipt` goes through the fixed-stage protocol + (`recovery/disposition.next`, link to + `recovery/dispositions/.receipt`, synchronize, remove, synchronize) + and is durable before the retained stage is removed, so publication that + refused `RetainedStage` proceeds. `Retire` also unlinks the pool entry and + an emptied namespace directory, because an absent head admits no pool + artifact; `Finalize` keeps the entry and needs a published head; a + manifest stage must be disposed before the root it names; a reader holding + the fence refuses without waiting; every residue an interrupted run leaves + resumes on the next call, and a residue naming another decision is a typed + ambiguity. `plan_recovery_disposition`, `RecoveryDispositionPhase`, + `RecoveryDispositionStorage`, and `resume_recovery_disposition` are the + pure planner, phases, port, and executor. Version-two admission now admits + canonical `.receipt` entries and a retained `disposition.next`; GC planning + admits only the exact `segment` receipt and treats a stale one as + protection. Liveness generation zero beside the initial retention-state + digest encodes a decision made under an absent retention head. - `RecoveryDispositionReceipt` codec and its registered enumerations. `definition.tsv` now registers the artifact kinds (`segment`, `catalog`, `retention-root`, `retention-manifest`, `retention-head`), decisions diff --git a/README.md b/README.md index 9cacc5b4..1078f09a 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,11 @@ Keep is required to refuse all three, before mutating anything. Version 2 writes correctly from a clean start, and the next publication recovers the residue of an interrupted one: a stage cut mid-write is discarded, a head already synchronized is finalized, and a byte-identical -retry reports already committed. The one state that waits for a human is a -complete orphan, a crash between the root link and the head finalization, -which stays recovery-protected until explicit disposition lands with garbage -collection (#21). The crash matrix proves that recovery by killing real +retry reports already committed. A complete orphan, a crash between the root +link and the head finalization, stays recovery-protected until a person or an +explicit policy calls `dispose` with a finalize-or-retire decision, which +records a durable receipt before it changes anything; nothing decides on +their behalf. The crash matrix proves publication recovery by killing real writer processes at all 51 retention coordinates, and an interrupted migration the same way at all 68 migration coordinates: `FilesystemStoreMigrationAuthority::reopen_for_recovery` and diff --git a/ROADMAP.md b/ROADMAP.md index bb34523e..8de07b27 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,11 +96,11 @@ names; use those in code, tests, and commits. - [x] [F-15 Retention roots, release, and GC liveness model](#f-15-retention-roots-release-and-gc-liveness-model) — Done (ADR-0009) - [x] [F-16 Version-2 format records and codecs](#f-16-version-2-format-records-and-codecs) — Done - [ ] [F-17 One-way migration from version 1 to version 2](#f-17-one-way-migration-from-version-1-to-version-2) — Partial (#97, in-process recovery, and the `KEEP-CRASH-053`–`073` matrix done on this branch; `KEEP-MIGRATION-005` and `-008` residue remains) -- [ ] [F-18 Retention publication](#f-18-retention-publication) — Partial; recovery, the `KEEP-CRASH-036`–`052` matrix, and member re-verification done on this branch; orphan disposition (F-22) remains +- [x] [F-18 Retention publication](#f-18-retention-publication) — Done on this branch (recovery, the `KEEP-CRASH-036`–`052` matrix, member re-verification, and orphan disposition) - [x] [F-19 Reader fence and immutable version-2 snapshots](#f-19-reader-fence-and-immutable-version-2-snapshots) — Done on this branch (merged from PR #99) - [x] [F-20 Model-based retention transition evidence](#f-20-model-based-retention-transition-evidence) — Done on this branch (merged from PR #99) - [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Planned (#20) -- [ ] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Partial (#21; all three codecs and the deterministic planner done on this branch) +- [ ] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Partial (#21; codecs, planner, and orphan disposition done on this branch; compaction and execution remain) - [ ] [F-23 Durable authenticated reads and refusal receipts](#f-23-durable-authenticated-reads-and-refusal-receipts) — Planned (#109) - [ ] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Planned (#82, #72) @@ -840,10 +840,10 @@ Direct version-2 initialization is undefined. There is no downgrade. ### F-18 Retention publication -**Status:** Partial. Forward publication is Done (issue #19, PR #78). -Recovery and the `KEEP-CRASH-036` to `-052` matrix are Done on this branch -(merged from PR #99), as is closure-member re-verification under authority -(T-18.3). Explicit disposition of complete orphans waits for F-22. +**Status:** Done on this branch. Forward publication (issue #19, PR #78), +recovery and the `KEEP-CRASH-036` to `-052` matrix (merged from PR #99), +closure-member re-verification (T-18.3), and explicit disposition of +complete orphans (T-22.5) are all in. A retain or release names a namespace, an expected state (absent or an exact `RootGeneration`), a complete anchor set, and the realization @@ -1144,10 +1144,10 @@ quarantines, or rewrites physical state. ### F-22 Garbage collection, compaction, and recovery dispositions -**Status:** Partial (#21, P1, M4). Grammars are frozen and their presence -refuses (`KEEP-GC-002`). All three codecs (T-22.1, T-22.1a; -`KEEP-GC-001` Implemented) and the deterministic planner (T-22.2) landed on -this branch; compaction, execution, and orphan disposition remain. +**Status:** Partial (#21, P1, M4). All three codecs (T-22.1, T-22.1a; +`KEEP-GC-001` Implemented), the deterministic planner (T-22.2), and explicit +orphan disposition (T-22.5) landed on this branch; GC intents and receipts +still refuse on disk; compaction and execution remain. Plan GC from an immutable liveness snapshot; classify every segment as live, unreachable, corrupt, ambiguous, recovery-protected, @@ -1320,7 +1320,13 @@ disposition receipt exists. `BlobId`, `ChunkId`, and `LayoutId` never move. - **Complexity:** XL across T-22.2 to T-22.5. - **Documentation:** `gc.md`, `recovery.md`, `requirements.md`, CHANGELOG. - **Dependencies:** T-22.1, T-22.2, F-19. -- [ ] T-22.5 Explicit orphan disposition. +- [x] T-22.5 Explicit orphan disposition — + `FilesystemRetentionPublicationAuthority::dispose`, the pure planner, + phases, port, and executor in `src/adapters/retention/disposition_*.rs`, + `filesystem_retention_disposition_tests` (retire, finalize, order, + readers, every residue, admission) and the exact-receipt GC law; the + process-death matrix for the disposition phases joins the GC sequence in + T-22.4. Original task fields: - **Requirements:** a finalize-or-retire decision for a recovery-protected orphan is durable as `recovery/dispositions/.receipt` via the fixed-stage protocol; retirement proves the artifact is named by no diff --git a/docs/formats/segment-store-v2/gc.md b/docs/formats/segment-store-v2/gc.md index 1cd5f482..f216d1e2 100644 --- a/docs/formats/segment-store-v2/gc.md +++ b/docs/formats/segment-store-v2/gc.md @@ -191,7 +191,11 @@ recovery/dispositions/.receipt ``` The version-2 maximum is 65,536 disposition receipts. A future successor must -migrate the namespace before raising the ceiling. +migrate the namespace before raising the ceiling. The protocol that writes a +receipt for a recovery-protected retention orphan is +[explicit disposition](recovery.md#explicit-disposition-of-protected-orphans); +liveness generation zero beside the initial retention-state digest records a +decision made while no retention head was published. ## Planning diff --git a/docs/formats/segment-store-v2/recovery.md b/docs/formats/segment-store-v2/recovery.md index 82a44bb5..bfc57970 100644 --- a/docs/formats/segment-store-v2/recovery.md +++ b/docs/formats/segment-store-v2/recovery.md @@ -238,7 +238,7 @@ regular file, removes it, synchronizes `retention`, and returns a typed discard report. Any later effect, stale generation, mismatched digest, missing transitive member, reappeared stage, conflicting pool entry, or other corruption is a typed refusal. A complete valid orphan remains -recovery-protected until explicit disposition. +recovery-protected until [explicit disposition](#explicit-disposition-of-protected-orphans). The retention crash points are: @@ -272,7 +272,41 @@ Each point requires before, during, and after process-death evidence. Restart must establish exact catalog visibility, retention head, namespace generation, orphan classification, stage disposition, and recovery report. -`GcRetirementIntent`, `GcRetirementReceipt`, and -`RecoveryDispositionReceipt` are owned by the [GC specification](gc.md). -Their codecs exist; until issue #21 implements the protocols that write -them, any such artifact on disk is unsupported and refuses. +## Explicit disposition of protected orphans + +A complete stage that recovery linked into its pool but that no head ever +committed is a recovery-protected orphan: publication refuses with +`RetainedStage` until a person or an explicit policy decides. The decision is +`FilesystemRetentionPublicationAuthority::dispose`, which takes writer +authority, runs recovery, refuses while any reader holds the fence, acquires +the fence exclusively, and records a `RecoveryDispositionReceipt` through the +fixed-stage protocol: write and synchronize `recovery/disposition.next`, link +it without replacement to `recovery/dispositions/.receipt`, +synchronize `recovery/dispositions`, remove the stage, and synchronize +`recovery`. Only then is the retained retention stage removed and `retention` +synchronized. Process death anywhere leaves either a recoverable stage or a +durable decision; the next `dispose` with the same request resumes from that +residue, and a residue naming another decision is a typed ambiguity. + +> **Warning.** Disposition changes what the store will keep. `Retire` unlinks +> the orphan's immutable pool entry after the receipt is durable, because an +> absent retention head admits no pool artifact and no collector exists for +> the retention pools; the bytes are gone and only the receipt records why. +> `Finalize` keeps the pool entry as a durable immutable artifact a +> byte-identical publication may reuse, and is refused while no retention head +> is published, since there is nothing to finalize into. Both require writer +> authority and the exclusive reader fence, both remove the retained stage so +> publication may proceed, and a manifest stage must be disposed before the +> root stage it names. The dry run is `plan_recovery_disposition` over the +> recovery plan; verify the result with `recover`, which reports `Clean`. + +Every receipt is admitted by namespace census as a regular file under its +canonical name. GC planning admits only the exact receipt: a `segment` +artifact retired under exactly the coordinates of the snapshot being planned +releases that segment; a receipt decided under other coordinates is stale and +keeps its material protected. + +`GcRetirementIntent` and `GcRetirementReceipt` are owned by the +[GC specification](gc.md). Their codecs exist; until issue #21 implements the +protocol that writes them, any such artifact on disk is unsupported and +refuses. diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index 9b0083e0..81d8a9db 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -46,7 +46,7 @@ case is not evidence. | ID | Requirement | Evidence | Status | | --- | --- | --- | --- | | `KEEP-GC-001` | Version 2 specifies exact bounded GC intent, receipt, and recovery-disposition grammars but refuses their presence until their parser and recovery protocol are implemented | intent and receipt golden, canonical re-encoding, cross-record binding, and field-by-field corruption laws in `tests/gc_retirement_intent.rs`, `tests/gc_retirement_intent/mutation_laws.rs`, and `tests/gc_retirement_receipt.rs`; disposition golden, canonical re-encoding, registered-enumeration agreement with `definition.tsv`, unregistered-code refusal, and field-by-field corruption laws in `tests/recovery_disposition_receipt.rs`; seeded `gc_format` fuzz target over all three records; presence refusal in namespace admission tests | Implemented | -| `KEEP-GC-002` | GC intent, receipt, disposition, reader-fence, retirement, compaction, and recovery laws implement ADR-0009 without changing logical identity | deterministic planning: `plan_gc` classifies every inventoried segment against one fenced liveness snapshot, refuses every contradiction, and never names a live or catalog-named segment, proven by one law per classification and ambiguity, the golden `gc-plan.tsv`, a 512-universe model, and filesystem laws over the migrated fixture store in `src/adapters/gc/`; execution, compaction, disposition, and recovery remain golden-format, model-based, corruption, crash-injection, benchmark, and fuzz evidence Planned in #21 | In progress in #21 | +| `KEEP-GC-002` | GC intent, receipt, disposition, reader-fence, retirement, compaction, and recovery laws implement ADR-0009 without changing logical identity | deterministic planning: `plan_gc` classifies every inventoried segment against one fenced liveness snapshot, refuses every contradiction, and never names a live or catalog-named segment, proven by one law per classification and ambiguity, the golden `gc-plan.tsv`, a 512-universe model, and filesystem laws over the migrated fixture store in `src/adapters/gc/`; explicit disposition of recovery-protected retention orphans in `filesystem_retention_disposition_tests` (retire unlinks under an absent head, finalize needs a published head, manifest before root, readers refuse, every residue resumes, receipts admitted by census) and exact-receipt admission by GC planning in `liveness_observation_tests`; execution, compaction, and recovery remain golden-format, model-based, corruption, crash-injection, benchmark, and fuzz evidence Planned in #21 | In progress in #21 | diff --git a/src/adapters/filesystem_initialization_namespace.rs b/src/adapters/filesystem_initialization_namespace.rs index 6b71be03..eb377be7 100644 --- a/src/adapters/filesystem_initialization_namespace.rs +++ b/src/adapters/filesystem_initialization_namespace.rs @@ -41,6 +41,7 @@ const RECOVERY_NAME: &str = "recovery"; const ROOTS_NAME: &str = "roots"; const MANIFESTS_NAME: &str = "manifests"; const DISPOSITIONS_NAME: &str = "dispositions"; +const DISPOSITION_STAGE_NAME: &str = "disposition.next"; const VERSION_TWO_NAMES: [&str; 12] = [ LOCK_NAME, STAGING_NAME, @@ -117,11 +118,12 @@ pub(super) fn admit_version_two(directory: &Dir) -> io::Result<()> { /// Admits the nested version-2 protocol directories the migration writer left. /// /// `retention` must carry both immutable pools (its head and stages belong to -/// retention publication); `gc` must be empty until `KEEP-GC-001` implements -/// its records; `recovery` must hold exactly an empty `dispositions`. This is -/// the same membership `verify_prefix_directories` requires at the end of -/// migration, so a root that drifted after migration refuses here rather than -/// as a later pinning failure. +/// retention publication); `gc` must be empty until GC execution writes its +/// records; `recovery` holds `dispositions` and at most a retained +/// `disposition.next` stage, and `dispositions` holds only regular files +/// named `.receipt`. Migration leaves the same shape with +/// empty pools, so a root that drifted after migration refuses here rather +/// than as a later pinning failure. fn admit_version_two_protocol_directories(directory: &Dir) -> io::Result<()> { let retention = directory.open_dir_nofollow(RETENTION_NAME)?; admit_required_directory(&retention, ROOTS_NAME)?; @@ -130,9 +132,25 @@ fn admit_version_two_protocol_directories(directory: &Dir) -> io::Result<()> { admit_membership(&gc, &[])?; let recovery = directory.open_dir_nofollow(RECOVERY_NAME)?; admit_required_directory(&recovery, DISPOSITIONS_NAME)?; - admit_membership(&recovery, &[DISPOSITIONS_NAME])?; + admit_optional_file(&recovery, DISPOSITION_STAGE_NAME)?; + admit_membership(&recovery, &[DISPOSITIONS_NAME, DISPOSITION_STAGE_NAME])?; let dispositions = recovery.open_dir_nofollow(DISPOSITIONS_NAME)?; - admit_membership(&dispositions, &[]) + admit_disposition_receipts(&dispositions) +} + +/// Every `recovery/dispositions` entry must be a regular file under a +/// canonical `.receipt` name; the receipt bytes are admitted +/// by whichever protocol consumes them. +fn admit_disposition_receipts(dispositions: &Dir) -> io::Result<()> { + for entry in dispositions.entries()? { + let entry = entry?; + if !entry.file_type()?.is_file() + || !crate::adapters::retention::is_disposition_name(&entry.file_name()) + { + return Err(ambiguous_namespace()); + } + } + Ok(()) } fn admit_optional_file(directory: &Dir, name: &str) -> io::Result<()> { diff --git a/src/adapters/gc/disposition.rs b/src/adapters/gc/disposition.rs index 517ae495..700140b2 100644 --- a/src/adapters/gc/disposition.rs +++ b/src/adapters/gc/disposition.rs @@ -1,10 +1,11 @@ //! This boundary module owns the semantic recovery-disposition receipt. use super::{ - ArtifactContentDigest, ArtifactIdentityDigest, DecisionEvidenceDigest, ObservedHeadChecksum, - ReaderLockIdentity, RecoveryArtifactKind, RecoveryClassification, RecoveryDispositionDecision, + ArtifactContentDigest, ArtifactIdentityDigest, DecisionEvidenceDigest, GcRetentionState, + ObservedHeadChecksum, ReaderLockIdentity, RecoveryArtifactKind, RecoveryClassification, + RecoveryDispositionDecision, }; -use crate::{CatalogDigest, CatalogGeneration, LivenessGeneration, RetentionManifestDigest}; +use crate::{CatalogDigest, CatalogGeneration}; /// The coordinates one disposition was decided under. /// @@ -21,10 +22,9 @@ pub struct RecoveryDispositionCoordinates { pub catalog_generation: CatalogGeneration, /// The catalog digest observed. pub catalog_digest: CatalogDigest, - /// The liveness generation observed. - pub liveness_generation: LivenessGeneration, - /// The retention-manifest digest observed. - pub manifest_digest: RetentionManifestDigest, + /// The retention state observed: the published head's liveness + /// generation and manifest digest, or the canonical empty state. + pub retention: GcRetentionState, /// The exclusively locked `reader.lock`. pub reader_lock: ReaderLockIdentity, } diff --git a/src/adapters/gc/disposition_decode_error.rs b/src/adapters/gc/disposition_decode_error.rs index 05506961..8d808be9 100644 --- a/src/adapters/gc/disposition_decode_error.rs +++ b/src/adapters/gc/disposition_decode_error.rs @@ -69,6 +69,12 @@ pub enum RecoveryDispositionDecodeError { /// The record offset of the field. offset: usize, }, + /// Liveness generation zero was paired with a digest other than the + /// canonical empty retention state. + EmptyRetentionDigestMismatch { + /// The observed manifest-digest slot. + observed: [u8; 32], + }, } impl fmt::Display for RecoveryDispositionDecodeError { @@ -105,6 +111,10 @@ impl fmt::Display for RecoveryDispositionDecodeError { formatter, "recovery disposition generation at offset {offset} is zero" ), + Self::EmptyRetentionDigestMismatch { .. } => formatter.write_str( + "recovery disposition names liveness generation zero without the empty \ + retention-state digest", + ), } } } diff --git a/src/adapters/gc/disposition_decoder.rs b/src/adapters/gc/disposition_decoder.rs index 05a18a70..a46bc3d6 100644 --- a/src/adapters/gc/disposition_decoder.rs +++ b/src/adapters/gc/disposition_decoder.rs @@ -4,10 +4,10 @@ use super::RecoveryDispositionDecodeError as Error; use super::{ AdmittedRecoveryDispositionReceipt, ArtifactContentDigest, ArtifactIdentityDigest, - DecisionEvidenceDigest, ObservedHeadChecksum, ReaderLockIdentity, RecoveryArtifactKind, - RecoveryClassification, RecoveryDispositionArtifact, RecoveryDispositionCoordinates, - RecoveryDispositionDecision, RecoveryDispositionField, RecoveryDispositionReceipt, - disposition_format as format, + DecisionEvidenceDigest, GcRetentionState, ObservedHeadChecksum, ReaderLockIdentity, + RecoveryArtifactKind, RecoveryClassification, RecoveryDispositionArtifact, + RecoveryDispositionCoordinates, RecoveryDispositionDecision, RecoveryDispositionField, + RecoveryDispositionReceipt, disposition_format as format, }; use crate::{CatalogDigest, CatalogGeneration, LivenessGeneration, RetentionManifestDigest}; @@ -45,9 +45,7 @@ pub(super) fn decode(encoded: &[u8]) -> Result( admit(observed).ok_or(Error::UnregisteredCode { field, observed }) } +/// Liveness generation zero names the canonical empty retention state and +/// must carry its digest; any positive generation names a published head. +fn retention_state(encoded: &[u8]) -> Result { + let generation = read_u64(encoded, 184)?; + let digest: [u8; 32] = read_array(encoded, 192)?; + match LivenessGeneration::new(generation) { + Ok(generation) => Ok(GcRetentionState::Published { + generation, + manifest_digest: RetentionManifestDigest::from_hash(digest), + }), + Err(_zero) if digest == format::empty_retention_digest() => Ok(GcRetentionState::Empty), + Err(_zero) => Err(Error::EmptyRetentionDigestMismatch { observed: digest }), + } +} + fn catalog_generation(encoded: &[u8], offset: usize) -> Result { CatalogGeneration::new(read_u64(encoded, offset)?) .map_err(|_source| Error::ZeroGeneration { offset }) diff --git a/src/adapters/gc/disposition_encoder.rs b/src/adapters/gc/disposition_encoder.rs index 086d4c12..fd2f90be 100644 --- a/src/adapters/gc/disposition_encoder.rs +++ b/src/adapters/gc/disposition_encoder.rs @@ -44,9 +44,20 @@ fn write_preimage(output: &mut [u8], receipt: &RecoveryDispositionReceipt) { let (catalog_digest, output) = output.split_at_mut(32); catalog_digest.copy_from_slice(coordinates.catalog_digest.as_bytes()); let (liveness, output) = output.split_at_mut(8); - liveness.copy_from_slice(&coordinates.liveness_generation.get().to_be_bytes()); let (manifest, output) = output.split_at_mut(32); - manifest.copy_from_slice(coordinates.manifest_digest.as_bytes()); + match coordinates.retention { + super::GcRetentionState::Empty => { + liveness.copy_from_slice(&0_u64.to_be_bytes()); + manifest.copy_from_slice(&super::disposition_format::empty_retention_digest()); + } + super::GcRetentionState::Published { + generation, + manifest_digest, + } => { + liveness.copy_from_slice(&generation.get().to_be_bytes()); + manifest.copy_from_slice(manifest_digest.as_bytes()); + } + } let (device, output) = output.split_at_mut(8); device.copy_from_slice(&coordinates.reader_lock.device().to_be_bytes()); let (mount, output) = output.split_at_mut(8); diff --git a/src/adapters/gc/disposition_format.rs b/src/adapters/gc/disposition_format.rs index 889fc909..fe8a4177 100644 --- a/src/adapters/gc/disposition_format.rs +++ b/src/adapters/gc/disposition_format.rs @@ -10,6 +10,11 @@ pub(super) const HEADER_RESERVED_OFFSET: usize = 30; pub(super) const TRAILER_RESERVED_OFFSET: usize = 280; pub(super) const TRAILER_RESERVED_LENGTH: usize = 8; const CHECKSUM_DOMAIN: &[u8] = b"keep.recovery-disposition-receipt-checksum/v2\0"; +/// The manifest-digest slot a receipt carries beside liveness generation +/// zero: the canonical empty retention state. +pub(super) fn empty_retention_digest() -> [u8; 32] { + *crate::adapters::store_migration::initial_retention_digest().as_bytes() +} const ARTIFACT_DOMAIN: &[u8] = b"keep.recovery-disposition-artifact/v2\0"; pub(super) fn checksum(preimage: &[u8]) -> [u8; 32] { diff --git a/src/adapters/gc/liveness_observation.rs b/src/adapters/gc/liveness_observation.rs index b0e837c3..94fead7e 100644 --- a/src/adapters/gc/liveness_observation.rs +++ b/src/adapters/gc/liveness_observation.rs @@ -12,11 +12,16 @@ use std::path::Path; use cap_fs_ext::DirExt; use cap_std::fs::Dir; +use super::{ + AdmittedRecoveryDispositionReceipt, RecoveryArtifactKind, RecoveryDispositionDecision, +}; use super::{ GcLivenessCoordinates, GcLivenessObservationError as Error, GcLivenessSnapshot, GcRetainedClosure, GcRetentionState, }; -use crate::adapters::retention::{AdmittedRetentionRoot, verify_retention_closure_members}; +use crate::adapters::retention::{ + AdmittedRetentionRoot, is_disposition_name, verify_retention_closure_members, +}; use crate::adapters::{ CatalogRestartArtifact, CatalogRestartPhase, CatalogRestartPolicy, CatalogSnapshot, ChecksummedCatalog, FilesystemRetentionSnapshot, SegmentDigest, SegmentRecordIdentity, @@ -28,6 +33,9 @@ use super::segment_pool_inventory; const SEGMENTS: &str = "segments"; const CATALOGS: &str = "catalogs"; +const RECOVERY: &str = "recovery"; +const DISPOSITIONS: &str = "dispositions"; +const RECEIPT_LENGTH: usize = 320; /// Assembles the liveness snapshot the fenced `view` of `store_root` admits. /// @@ -83,9 +91,77 @@ pub fn observe_gc_liveness( for segment in superseded_segments(&catalogs, &catalog, &named)? { snapshot.supersede_segment(segment); } + let recovery = root + .open_dir_nofollow(RECOVERY) + .map_err(|source| Error::pool("open recovery directory", source))?; + let dispositions = recovery + .open_dir_nofollow(DISPOSITIONS) + .map_err(|source| Error::pool("open disposition pool", source))?; + for segment in disposed_segments(&dispositions, snapshot.coordinates())? { + snapshot.dispose_segment(segment); + } Ok(snapshot) } +/// Reads every disposition receipt and admits only the exact ones: a +/// `segment` artifact, a `retire` decision, an entry named by its own +/// identity digest, and coordinates equal to this snapshot's. A receipt +/// decided under other coordinates is stale and keeps its segment +/// protected; a receipt that does not decode refuses the observation. +fn disposed_segments( + dispositions: &Dir, + coordinates: GcLivenessCoordinates, +) -> Result, Error> { + let mut disposed = BTreeSet::new(); + for entry in dispositions + .entries() + .map_err(|source| Error::pool("list dispositions", source))? + { + let entry = entry.map_err(|source| Error::pool("read disposition entry", source))?; + let name = entry.file_name(); + if !is_disposition_name(&name) { + return Err(Error::DispositionEntryName); + } + let name = name.to_string_lossy().into_owned(); + let bytes = catalog_restart_io::open_regular( + dispositions, + &name, + CatalogRestartArtifact::Catalog, + CatalogRestartPhase::OpenCatalog, + ) + .and_then(|(file, length)| { + catalog_restart_io::read_exact( + file, + CatalogRestartArtifact::Catalog, + CatalogRestartPhase::ReadCatalog, + length.min( + u64::try_from(RECEIPT_LENGTH) + .unwrap_or(u64::MAX) + .saturating_add(1), + ), + ) + }) + .map_err(|source| Error::Catalog { source })?; + let admitted = AdmittedRecoveryDispositionReceipt::decode(&bytes) + .map_err(|source| Error::Disposition { source })?; + let receipt = admitted.receipt(); + let artifact = receipt.artifact(); + let identity = SegmentDigest::from_validated(*artifact.identity_digest.as_bytes()); + if name != physical_pool_name::disposition(artifact.identity_digest.as_bytes()) { + return Err(Error::DispositionEntryName); + } + let exact = artifact.kind == RecoveryArtifactKind::Segment + && receipt.decision() == RecoveryDispositionDecision::Retire + && receipt.coordinates().catalog_generation == coordinates.catalog_generation() + && receipt.coordinates().catalog_digest == coordinates.catalog_digest() + && receipt.coordinates().retention == coordinates.retention(); + if exact { + disposed.insert(identity); + } + } + Ok(disposed) +} + fn retention_state(view: &FilesystemRetentionSnapshot) -> GcRetentionState { view.retention_head() .map_or(GcRetentionState::Empty, |head| { diff --git a/src/adapters/gc/liveness_observation_error.rs b/src/adapters/gc/liveness_observation_error.rs index 652c61fd..f1ab17bd 100644 --- a/src/adapters/gc/liveness_observation_error.rs +++ b/src/adapters/gc/liveness_observation_error.rs @@ -94,6 +94,14 @@ pub enum GcLivenessObservationError { /// The expected generation. generation: CatalogGeneration, }, + /// A `recovery/dispositions` entry is not named by a lowercase artifact + /// digest with the `.receipt` suffix, or its name is not its own identity. + DispositionEntryName, + /// A disposition receipt did not decode. + Disposition { + /// The exact decode refusal. + source: crate::adapters::RecoveryDispositionDecodeError, + }, /// The snapshot could not be assembled without contradiction. Snapshot { /// The exact contradiction. @@ -151,6 +159,10 @@ impl fmt::Display for GcLivenessObservationError { "predecessor catalog generation {} carries other coordinates", generation.get() ), + Self::DispositionEntryName => { + formatter.write_str("disposition entry is not named by its artifact digest") + } + Self::Disposition { .. } => formatter.write_str("disposition receipt did not decode"), Self::Snapshot { .. } => formatter.write_str("liveness snapshot contradicts itself"), } } @@ -168,7 +180,9 @@ impl Error for GcLivenessObservationError { Self::Pool { source, .. } => Some(source), Self::SegmentAdmission { source, .. } => Some(source.as_ref()), Self::Snapshot { source } => Some(source), + Self::Disposition { source } => Some(source), Self::RetainedRootAbsent { .. } + | Self::DispositionEntryName | Self::ClosureMemberUnindexed { .. } | Self::PoolEntryName | Self::PoolEntryKind { .. } diff --git a/src/adapters/gc/liveness_observation_tests.rs b/src/adapters/gc/liveness_observation_tests.rs index 14c08650..fde1024a 100644 --- a/src/adapters/gc/liveness_observation_tests.rs +++ b/src/adapters/gc/liveness_observation_tests.rs @@ -146,3 +146,114 @@ fn a_pool_entry_not_named_by_a_digest_refuses_observation() -> Result<(), Box Result, Box> { + use crate::adapters::{ + ArtifactIdentityDigest, CanonicalRecoveryDispositionReceipt, DecisionEvidenceDigest, + ObservedHeadChecksum, ReaderLockIdentity, RecoveryArtifactKind, RecoveryClassification, + RecoveryDispositionArtifact, RecoveryDispositionCoordinates, RecoveryDispositionDecision, + RecoveryDispositionReceipt, + }; + let orphan = decode_hex(ORPHAN_SEGMENT_HEX.trim())?; + let identity = <[u8; 32]>::try_from(decode_hex( + ORPHAN_SEGMENT_NAME + .strip_suffix(".seg") + .ok_or("orphan name")?, + )?) + .map_err(|_| "identity")?; + let receipt = RecoveryDispositionReceipt::new( + RecoveryDispositionArtifact { + kind: RecoveryArtifactKind::Segment, + classification: RecoveryClassification::CompleteOrphan, + length: u64::try_from(orphan.len())?, + identity_digest: ArtifactIdentityDigest::new(identity), + content_digest: CanonicalRecoveryDispositionReceipt::artifact_content_digest(&orphan), + }, + RecoveryDispositionDecision::Retire, + RecoveryDispositionCoordinates { + publication_generation: coordinates.catalog_generation(), + publication_checksum: ObservedHeadChecksum::new([0; 32]), + catalog_generation: coordinates.catalog_generation(), + catalog_digest, + retention: coordinates.retention(), + reader_lock: ReaderLockIdentity::new(1, 2, 3), + }, + DecisionEvidenceDigest::new([0; 32]), + ); + Ok(CanonicalRecoveryDispositionReceipt::from_receipt(&receipt) + .encoded() + .to_vec()) +} + +fn disposition_path(root: &Path) -> std::path::PathBuf { + root.join("recovery").join("dispositions").join(format!( + "{}.receipt", + ORPHAN_SEGMENT_NAME.trim_end_matches(".seg") + )) +} + +#[test] +fn an_exact_retire_receipt_makes_the_orphan_collectible_and_a_stale_one_does_not() +-> Result<(), Box> { + let sandbox = published_store("gc-liveness-disposed")?; + let orphan = decode_hex(ORPHAN_SEGMENT_HEX.trim())?; + fs::write( + sandbox.path().join("segments").join(ORPHAN_SEGMENT_NAME), + &orphan, + )?; + let coordinates = observe(sandbox.path())?.coordinates(); + + // A receipt decided under another catalog digest is stale: protected. + let stale = segment_receipt( + coordinates, + crate::CatalogDigest::from_validated([0x55; 32]), + )?; + fs::write(disposition_path(sandbox.path()), &stale)?; + let plan = plan_gc(&observe(sandbox.path())?, GcLimits::MAXIMUM)?; + assert_eq!(plan.candidate_count(), 0); + assert_eq!( + plan.segments() + .values() + .filter(|planned| { + planned.classification() == GcSegmentClassification::RecoveryProtected + }) + .count(), + 1 + ); + + // The exact receipt releases it. + let exact = segment_receipt(coordinates, coordinates.catalog_digest())?; + fs::write(disposition_path(sandbox.path()), &exact)?; + let plan = plan_gc(&observe(sandbox.path())?, GcLimits::MAXIMUM)?; + assert_eq!(plan.candidate_count(), 1); + let candidate = plan.candidates().next().ok_or("candidate")?; + assert_eq!( + plan.classification(candidate.segment()), + Some(GcSegmentClassification::Unreachable( + super::GcUnreachableEvidence::Disposed + )) + ); + + // A receipt that does not decode refuses the whole observation. + let mut corrupt = exact; + let last = corrupt.last_mut().ok_or("receipt")?; + *last ^= 1; + fs::write(disposition_path(sandbox.path()), &corrupt)?; + let error = observe(sandbox.path()) + .err() + .ok_or("a corrupt disposition receipt was observed")?; + let error = error + .downcast::() + .map_err(|_error| "observation refused with the wrong error type")?; + assert!(matches!( + *error, + GcLivenessObservationError::Disposition { .. } + )); + sandbox.remove()?; + Ok(()) +} diff --git a/src/adapters/physical_pool_name.rs b/src/adapters/physical_pool_name.rs index 72bab7cc..518a9ff2 100644 --- a/src/adapters/physical_pool_name.rs +++ b/src/adapters/physical_pool_name.rs @@ -8,6 +8,11 @@ pub(super) fn segment(digest: SegmentDigest) -> String { format!("{}.seg", DigestHex(digest.as_bytes())) } +/// The canonical `recovery/dispositions` entry name for one artifact. +pub(super) fn disposition(identity: &[u8; 32]) -> String { + format!("{}.receipt", DigestHex(identity)) +} + pub(super) fn catalog(generation: CatalogGeneration, digest: CatalogDigest) -> String { format!( "{:016x}-{}.cat", diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 31b64673..2d817793 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -13,6 +13,9 @@ mod closure_error_display; mod closure_member; mod closure_profile_error; mod closure_verifier; +mod disposition_execution; +mod disposition_plan; +mod disposition_storage; #[cfg(test)] mod filesystem_recovery_admission_tests; mod filesystem_retention_attempt; @@ -28,6 +31,9 @@ mod filesystem_retention_catalog_tests; mod filesystem_retention_current; #[cfg(test)] mod filesystem_retention_current_tests; +mod filesystem_retention_disposition; +#[cfg(test)] +mod filesystem_retention_disposition_tests; #[cfg(test)] mod filesystem_retention_expectation_tests; #[cfg(all(test, target_os = "linux"))] @@ -131,11 +137,24 @@ pub use checksummed_head::ChecksummedRetentionHead; pub use closure_error::RetentionClosureVerificationError; pub use closure_verifier::verify_retention_closure; pub(in crate::adapters) use closure_verifier::verify_retention_closure_members; +pub use disposition_execution::{ + RecoveryDispositionError, RecoveryDispositionExecutionReceipt, execute_recovery_disposition, + resume_recovery_disposition, +}; +pub use disposition_plan::{ + RecoveryDispositionPhase, RecoveryDispositionPlan, RecoveryDispositionRefusal, + RecoveryDispositionRequest, RecoveryDispositionTarget, plan_recovery_disposition, +}; +pub use disposition_storage::RecoveryDispositionStorage; pub use filesystem_retention_authority::FilesystemRetentionPublicationAuthority; pub use filesystem_retention_authority_error::{ FilesystemRetentionAuthorityError, RetentionAuthorityDirectory, }; pub use filesystem_retention_current::ObservedRetentionState; +pub use filesystem_retention_disposition::{ + FilesystemRetentionDispositionError, RecoveryDispositionAmbiguity, +}; +pub(in crate::adapters) use filesystem_retention_pool_name::is_disposition_name; pub use filesystem_retention_recovery_error::FilesystemRetentionRecoveryError; pub use filesystem_retention_refusal::RetentionCurrentStateRefusal; pub use filesystem_retention_snapshot::FilesystemRetentionSnapshot; diff --git a/src/adapters/retention/disposition_execution.rs b/src/adapters/retention/disposition_execution.rs new file mode 100644 index 00000000..5308a3c2 --- /dev/null +++ b/src/adapters/retention/disposition_execution.rs @@ -0,0 +1,122 @@ +//! This module owns ordered execution of one disposition's durable phases. + +use std::error::Error; +use std::fmt; +use std::io; + +use super::{RecoveryDispositionPhase, RecoveryDispositionStorage}; +use crate::adapters::RecoveryDispositionDecision; + +/// What one disposition run executed. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RecoveryDispositionExecutionReceipt { + executed: Vec, +} + +impl RecoveryDispositionExecutionReceipt { + /// The phases executed, in order; empty when the residue was complete. + #[must_use] + pub fn executed(&self) -> &[RecoveryDispositionPhase] { + &self.executed + } +} + +/// A disposition phase that refused, with the phases completed before it. +#[derive(Debug)] +pub struct RecoveryDispositionError { + phase: RecoveryDispositionPhase, + executed: Vec, + source: io::Error, +} + +impl RecoveryDispositionError { + /// The refused phase. + #[must_use] + pub const fn phase(&self) -> RecoveryDispositionPhase { + self.phase + } + + /// The phases completed before the refusal. + #[must_use] + pub fn executed(&self) -> &[RecoveryDispositionPhase] { + &self.executed + } +} + +impl fmt::Display for RecoveryDispositionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "disposition phase {:?} refused after {} completed phases", + self.phase, + self.executed.len() + ) + } +} + +impl Error for RecoveryDispositionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(&self.source) + } +} + +/// Executes every phase of `decision` from `from` onwards, in order. +/// +/// A refused phase leaves the completed phases' effects in place and names +/// itself; the caller re-observes the residue and resumes rather than +/// continuing from stale evidence. +/// +/// # Errors +/// +/// Returns [`RecoveryDispositionError`] with the refused phase, the phases +/// completed before it, and the storage's own error as source. +pub fn resume_recovery_disposition( + storage: &mut S, + decision: RecoveryDispositionDecision, + from: RecoveryDispositionPhase, +) -> Result { + let phases = RecoveryDispositionPhase::for_decision(decision); + let start = phases + .iter() + .position(|phase| *phase == from) + .unwrap_or(phases.len()); + let mut executed = Vec::new(); + for phase in phases.iter().copied().skip(start) { + let result = match phase { + RecoveryDispositionPhase::WriteStage => storage.write_disposition_stage(), + RecoveryDispositionPhase::SynchronizeStage => storage.synchronize_disposition_stage(), + RecoveryDispositionPhase::LinkReceipt => storage.link_disposition_receipt(), + RecoveryDispositionPhase::SynchronizeDispositions => storage.synchronize_dispositions(), + RecoveryDispositionPhase::RemoveStage => storage.remove_disposition_stage(), + RecoveryDispositionPhase::SynchronizeRecovery => storage.synchronize_recovery(), + RecoveryDispositionPhase::RemoveRetainedStage => storage.remove_retained_stage(), + RecoveryDispositionPhase::SynchronizeRetention => { + storage.synchronize_retention_after_disposition() + } + RecoveryDispositionPhase::RemovePoolEntry => storage.remove_pool_entry(), + RecoveryDispositionPhase::SynchronizePool => storage.synchronize_pool(), + }; + if let Err(source) = result { + return Err(RecoveryDispositionError { + phase, + executed, + source, + }); + } + executed.push(phase); + } + Ok(RecoveryDispositionExecutionReceipt { executed }) +} + +/// Executes every phase of a fresh disposition under `decision`. +/// +/// # Errors +/// +/// As [`resume_recovery_disposition`]. +pub fn execute_recovery_disposition( + storage: &mut S, + decision: RecoveryDispositionDecision, +) -> Result { + resume_recovery_disposition(storage, decision, RecoveryDispositionPhase::WriteStage) +} diff --git a/src/adapters/retention/disposition_plan.rs b/src/adapters/retention/disposition_plan.rs new file mode 100644 index 00000000..851a00fa --- /dev/null +++ b/src/adapters/retention/disposition_plan.rs @@ -0,0 +1,216 @@ +//! This module owns pure planning of one explicit finalize-or-retire +//! disposition over a recovery-protected retention orphan. + +use std::fmt; + +use super::{ + RetentionPoolEntryObservation as Pool, RetentionPoolObservations, RetentionRecoveryOutcome, + RetentionRecoveryPlan, +}; +use crate::adapters::RecoveryDispositionDecision; + +/// Which recovery-protected stage one disposition names. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RecoveryDispositionTarget { + /// The complete, linked, retained `retention/root.next`. + Root, + /// The complete, linked, retained `retention/manifest.next`. + Manifest, +} + +/// One explicit decision over one protected stage. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RecoveryDispositionRequest { + /// The protected stage. + pub target: RecoveryDispositionTarget, + /// The decision: `Finalize` keeps the linked pool entry as a durable + /// immutable artifact a byte-identical publication may reuse; `Retire` + /// additionally marks it collectible by a future retention-pool + /// collector. Both remove the retained stage so publication may proceed. + pub decision: RecoveryDispositionDecision, +} + +/// The ordered durable phases one disposition executes. +/// +/// The receipt is durable before the retained stage is removed, so process +/// death anywhere leaves either a recoverable stage or a durable decision. A +/// `Retire` decision additionally unlinks the immutable pool entry, because +/// an absent retention head admits no pool artifact and no collector exists +/// for retention pools; a `Finalize` decision ends at the retention +/// synchronization and keeps the entry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RecoveryDispositionPhase { + /// Write the complete canonical receipt to `recovery/disposition.next`. + WriteStage, + /// Synchronize `recovery/disposition.next`. + SynchronizeStage, + /// Link the stage to `recovery/dispositions/.receipt` + /// without replacement. + LinkReceipt, + /// Synchronize `recovery/dispositions`. + SynchronizeDispositions, + /// Remove the retained `recovery/disposition.next`. + RemoveStage, + /// Synchronize `recovery`. + SynchronizeRecovery, + /// Remove the retained retention stage after proving its pool link. + RemoveRetainedStage, + /// Synchronize `retention`. + SynchronizeRetention, + /// Unlink the retired artifact from its immutable pool after proving + /// its exact bytes (`Retire` only). + RemovePoolEntry, + /// Synchronize the pool the artifact left (`Retire` only). + SynchronizePool, +} + +impl RecoveryDispositionPhase { + /// Every phase in execution order. + pub const ALL: [Self; 10] = [ + Self::WriteStage, + Self::SynchronizeStage, + Self::LinkReceipt, + Self::SynchronizeDispositions, + Self::RemoveStage, + Self::SynchronizeRecovery, + Self::RemoveRetainedStage, + Self::SynchronizeRetention, + Self::RemovePoolEntry, + Self::SynchronizePool, + ]; + + /// The phases one decision executes, in order. + #[must_use] + pub fn for_decision(decision: RecoveryDispositionDecision) -> &'static [Self] { + match decision { + RecoveryDispositionDecision::Finalize => Self::ALL.get(..8).unwrap_or(&[]), + RecoveryDispositionDecision::Retire => &Self::ALL, + } + } +} + +/// The one lawful disposition of a request over observed evidence. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RecoveryDispositionPlan { + target: RecoveryDispositionTarget, + decision: RecoveryDispositionDecision, +} + +impl RecoveryDispositionPlan { + /// Returns the protected stage the plan disposes. + pub const fn target(self) -> RecoveryDispositionTarget { + self.target + } + + /// Returns the decision. + pub const fn decision(self) -> RecoveryDispositionDecision { + self.decision + } +} + +/// Why a request cannot be planned over the observed evidence. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RecoveryDispositionRefusal { + /// Recovery still has steps to execute; run it first. + RecoveryPending, + /// No stage is recovery-protected; there is nothing to dispose. + NothingProtected, + /// The requested stage is not retained. + TargetNotRetained { + /// The requested stage. + target: RecoveryDispositionTarget, + }, + /// The root stage cannot be disposed while the manifest stage that names + /// it is still protected; dispose the manifest first. + ManifestStageRemains, + /// The requested stage's pool entry is not the linked, identical entry + /// recovery proved. + TargetNotLinked { + /// The requested stage. + target: RecoveryDispositionTarget, + /// What the pool holds. + observed: Pool, + }, + /// No retention head is published, so there is no visible state to + /// finalize the artifact into; only `Retire` applies. + FinalizeRequiresPublishedHead, +} + +impl fmt::Display for RecoveryDispositionRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RecoveryPending => { + formatter.write_str("retention recovery has steps to run before any disposition") + } + Self::NothingProtected => formatter.write_str("no retention stage is protected"), + Self::TargetNotRetained { target } => { + write!(formatter, "the {target:?} stage is not retained") + } + Self::ManifestStageRemains => formatter + .write_str("the manifest stage names the root stage; dispose the manifest first"), + Self::TargetNotLinked { target, observed } => write!( + formatter, + "the {target:?} stage's pool entry is {observed:?}, not identical" + ), + Self::FinalizeRequiresPublishedHead => { + formatter.write_str("no retention head is published; an orphan can only be retired") + } + } + } +} + +impl std::error::Error for RecoveryDispositionRefusal {} + +/// Plans one disposition over the recovery plan and pool observations +/// restart established. +/// +/// The recovery plan must already be fully executed (no pending steps) and +/// must protect the requested stage; the stage's pool entry must be the +/// identical linked entry; `Finalize` needs a published retention head. +/// +/// # Errors +/// +/// Returns [`RecoveryDispositionRefusal`] naming the exact reason. +pub fn plan_recovery_disposition( + recovery: &RetentionRecoveryPlan, + pools: RetentionPoolObservations, + head_published: bool, + request: RecoveryDispositionRequest, +) -> Result { + if !recovery.steps().is_empty() { + return Err(RecoveryDispositionRefusal::RecoveryPending); + } + if request.decision == RecoveryDispositionDecision::Finalize && !head_published { + return Err(RecoveryDispositionRefusal::FinalizeRequiresPublishedHead); + } + let RetentionRecoveryOutcome::Protected { + root_stage, + manifest_stage, + } = recovery.outcome() + else { + return Err(RecoveryDispositionRefusal::NothingProtected); + }; + let (retained, observed) = match request.target { + RecoveryDispositionTarget::Root => (root_stage, pools.root), + RecoveryDispositionTarget::Manifest => (manifest_stage, pools.manifest), + }; + if !retained { + return Err(RecoveryDispositionRefusal::TargetNotRetained { + target: request.target, + }); + } + if request.target == RecoveryDispositionTarget::Root && manifest_stage { + return Err(RecoveryDispositionRefusal::ManifestStageRemains); + } + if observed != Pool::Identical { + return Err(RecoveryDispositionRefusal::TargetNotLinked { + target: request.target, + observed, + }); + } + Ok(RecoveryDispositionPlan { + target: request.target, + decision: request.decision, + }) +} diff --git a/src/adapters/retention/disposition_storage.rs b/src/adapters/retention/disposition_storage.rs new file mode 100644 index 00000000..e6d928ce --- /dev/null +++ b/src/adapters/retention/disposition_storage.rs @@ -0,0 +1,87 @@ +//! This module owns the blocking storage capability port for one disposition. + +use std::io; + +/// Durable capabilities a disposition executes, one per phase, in order. +/// +/// Each capability owns its complete effect and the synchronization that +/// makes it durable. The receipt bytes and the retained stage belong to the +/// implementation's context; the executor only sequences phases. +pub trait RecoveryDispositionStorage { + /// Exclusively creates `recovery/disposition.next` with the complete + /// canonical receipt. + /// + /// # Errors + /// + /// Returns the exact filesystem failure. + fn write_disposition_stage(&mut self) -> io::Result<()>; + + /// Synchronizes the stage and reverifies its bytes. + /// + /// # Errors + /// + /// Returns the exact filesystem failure. + fn synchronize_disposition_stage(&mut self) -> io::Result<()>; + + /// Links the stage into `recovery/dispositions` under the artifact's + /// canonical name without replacement. + /// + /// # Errors + /// + /// Returns the exact filesystem failure or a conflicting-entry refusal. + fn link_disposition_receipt(&mut self) -> io::Result<()>; + + /// Synchronizes `recovery/dispositions`. + /// + /// # Errors + /// + /// Returns the exact filesystem failure. + fn synchronize_dispositions(&mut self) -> io::Result<()>; + + /// Removes the retained stage after proving the linked receipt. + /// + /// # Errors + /// + /// Returns the exact filesystem failure or a refusal when the link is + /// not proven. + fn remove_disposition_stage(&mut self) -> io::Result<()>; + + /// Synchronizes `recovery`. + /// + /// # Errors + /// + /// Returns the exact filesystem failure. + fn synchronize_recovery(&mut self) -> io::Result<()>; + + /// Removes the disposed retention stage after proving its pool link. + /// + /// # Errors + /// + /// Returns the exact filesystem failure or a refusal when the link is + /// not proven. + fn remove_retained_stage(&mut self) -> io::Result<()>; + + /// Synchronizes `retention`. + /// + /// # Errors + /// + /// Returns the exact filesystem failure. + fn synchronize_retention_after_disposition(&mut self) -> io::Result<()>; + + /// Unlinks the retired artifact from its immutable pool after proving + /// its exact bytes, and removes its namespace directory when that leaves + /// it empty (`Retire` only). + /// + /// # Errors + /// + /// Returns the exact filesystem failure or a refusal when the entry's + /// bytes are not the disposed artifact's. + fn remove_pool_entry(&mut self) -> io::Result<()>; + + /// Synchronizes the pool the artifact left (`Retire` only). + /// + /// # Errors + /// + /// Returns the exact filesystem failure. + fn synchronize_pool(&mut self) -> io::Result<()>; +} diff --git a/src/adapters/retention/filesystem_retention_authority.rs b/src/adapters/retention/filesystem_retention_authority.rs index 0f3bcda0..3d118e3c 100644 --- a/src/adapters/retention/filesystem_retention_authority.rs +++ b/src/adapters/retention/filesystem_retention_authority.rs @@ -9,6 +9,7 @@ use super::filesystem_retention_authority_error::{ FilesystemRetentionAuthorityError as Error, RetentionAuthorityDirectory as Directory, }; use super::filesystem_retention_current::{self, ObservedRetentionState}; +use super::filesystem_retention_disposition::DispositionContext; use super::filesystem_retention_recovery::RetentionRecoveryContext; use crate::adapters::{CatalogRestartPolicy, FilesystemVersionTwoAdmission, FilesystemWriterLock}; @@ -35,6 +36,7 @@ pub struct FilesystemRetentionPublicationAuthority { pub(super) catalog_policy: CatalogRestartPolicy, pub(super) attempt: Option, pub(super) recovery: Option, + pub(super) disposition: Option, _lock: FilesystemWriterLock, } @@ -80,6 +82,7 @@ impl FilesystemRetentionPublicationAuthority { catalog_policy, attempt: None, recovery: None, + disposition: None, _lock: lock, }) } diff --git a/src/adapters/retention/filesystem_retention_disposition.rs b/src/adapters/retention/filesystem_retention_disposition.rs new file mode 100644 index 00000000..56fe8395 --- /dev/null +++ b/src/adapters/retention/filesystem_retention_disposition.rs @@ -0,0 +1,689 @@ +//! This module owns filesystem execution of one explicit disposition under +//! writer authority and the exclusive reader fence. + +use std::error::Error; +use std::fmt; +use std::io::{self, Read}; + +use cap_fs_ext::DirExt; +use cap_std::fs::Dir; + +use super::filesystem_retention_authority::FilesystemRetentionPublicationAuthority; +use super::filesystem_retention_current::{self, read_exact_optional}; +use super::filesystem_retention_pool_name as pool_name; +use super::filesystem_retention_recovery::RetentionRecoveryContext; +use super::filesystem_retention_recovery_observation::RetentionRecoveryObservation; +use super::filesystem_retention_stage::{FilesystemRetentionStage, invalid_data}; +use super::{ + AdmittedRetentionManifest, AdmittedRetentionRoot, FilesystemRetentionRecoveryError, + ReaderFence, RecoveryDispositionError, RecoveryDispositionPhase, RecoveryDispositionPlan, + RecoveryDispositionRefusal, RecoveryDispositionRequest, RecoveryDispositionStorage, + RecoveryDispositionTarget, RetentionRecoveryStorage, plan_recovery_disposition, + plan_retention_recovery, resume_recovery_disposition, +}; +use crate::adapters::filesystem_catalog_artifact::synchronize_directory; +use crate::adapters::filesystem_exact_record as exact_record; +use crate::adapters::{ + AdmittedRecoveryDispositionReceipt, ArtifactIdentityDigest, + CanonicalRecoveryDispositionReceipt, ChecksummedPublicationHead, DecisionEvidenceDigest, + GcRetentionState, ObservedHeadChecksum, ReaderLockIdentity, RecoveryArtifactKind, + RecoveryClassification, RecoveryDispositionArtifact, RecoveryDispositionCoordinates, + RecoveryDispositionDecision, RecoveryDispositionReceipt, filesystem_platform_profile, +}; + +const HEAD_NAME: &str = "HEAD"; +const HEAD_LENGTH: usize = crate::adapters::publication_head_decoder::ENCODED_LENGTH; +const HEAD_CHECKSUM_OFFSET: usize = 96; +const RECEIPT_LENGTH: usize = 320; + +/// Residue that admits no lawful resumption of a disposition. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RecoveryDispositionAmbiguity { + /// `recovery/disposition.next` holds bytes other than this disposition's + /// canonical receipt. + StageDiffers, + /// The canonical receipt entry holds other bytes. + ReceiptDiffers, + /// A retired artifact's pool entry no longer carries the bytes its + /// receipt names. + PoolEntryDiffers, +} + +/// Failure to dispose one protected stage. +#[derive(Debug)] +pub enum FilesystemRetentionDispositionError { + /// Recovery, which runs first, refused. + Recovery { + /// The exact recovery failure. + source: FilesystemRetentionRecoveryError, + }, + /// The request cannot be planned over the recovered evidence. + Plan { + /// The exact refusal. + source: RecoveryDispositionRefusal, + }, + /// A reader holds the shared fence; disposition never waits on readers. + ReadersActive, + /// Observing the store's coordinates or residue refused. + Observe { + /// The original failure. + source: io::Error, + }, + /// The residue admits no lawful resumption. + Ambiguity(RecoveryDispositionAmbiguity), + /// A durable phase refused. + Execute { + /// The refused phase and its cause. + source: RecoveryDispositionError, + }, +} + +impl fmt::Display for FilesystemRetentionDispositionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Recovery { .. } => formatter.write_str("retention recovery refused"), + Self::Plan { source } => write!(formatter, "disposition refused: {source}"), + Self::ReadersActive => { + formatter.write_str("a reader holds the fence; disposition does not wait") + } + Self::Observe { .. } => formatter.write_str("disposition observation refused"), + Self::Ambiguity(ambiguity) => { + write!(formatter, "disposition residue is ambiguous: {ambiguity:?}") + } + Self::Execute { source } => write!(formatter, "{source}"), + } + } +} + +impl Error for FilesystemRetentionDispositionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Recovery { source } => Some(source), + Self::Plan { source } => Some(source), + Self::Observe { source } => Some(source), + Self::Execute { source } => Some(source), + Self::ReadersActive | Self::Ambiguity(_) => None, + } + } +} + +/// Where a disposed artifact lives in its immutable pool. +enum PoolEntry { + Root { namespace: String, name: String }, + Manifest { name: String }, +} + +/// A located immutable pool entry with its complete bytes. +type LocatedEntry = (PoolEntry, Box<[u8]>); +/// A directory entry name with its complete bytes. +type NamedEntry = (String, Box<[u8]>); + +/// What a planned disposition binds before its context opens. +struct DispositionInputs { + plan: RecoveryDispositionPlan, + receipt: CanonicalRecoveryDispositionReceipt, + artifact: Box<[u8]>, + pool: PoolEntry, +} + +/// Everything one disposition run holds between phases. +pub(super) struct DispositionContext { + target: RecoveryDispositionTarget, + decision: RecoveryDispositionDecision, + receipt: CanonicalRecoveryDispositionReceipt, + artifact: Box<[u8]>, + pool: PoolEntry, + name: String, + recovery: Dir, + dispositions: Dir, + stage: Option, + _fence: ReaderFence, +} + +impl FilesystemRetentionPublicationAuthority { + /// Records one explicit finalize-or-retire decision over a + /// recovery-protected stage and removes the stage, so publication may + /// proceed. + /// + /// Recovery runs first, so a complete stage is linked before it can be + /// disposed. The receipt is written through the fixed-stage protocol + /// under the exclusive reader fence and is durable before the retained + /// stage is removed. `Finalize` keeps the linked pool entry and needs a + /// published retention head; `Retire` also unlinks the entry, because an + /// absent head admits no pool artifact and no collector exists for the + /// retention pools. An interrupted run resumes from its residue on the + /// next call with the same request. + /// + /// # Errors + /// + /// Returns [`FilesystemRetentionDispositionError`] at the exact recovery, + /// planning, fence, observation, residue, or phase refusal. + pub fn dispose( + &mut self, + request: RecoveryDispositionRequest, + ) -> Result { + self.attempt = None; + let _recovered = self + .recover() + .map_err(|source| FilesystemRetentionDispositionError::Recovery { source })?; + let observation = + RetentionRecoveryObservation::observe(&self.retention, &self.roots, &self.manifests) + .map_err(observe)?; + let evidence = observation.evidence(); + let pools = evidence.pools(); + let head_published = evidence.current().is_some(); + let recovery = plan_retention_recovery(evidence).map_err(|source| { + FilesystemRetentionDispositionError::Recovery { + source: FilesystemRetentionRecoveryError::Plan { source }, + } + })?; + let plan = match plan_recovery_disposition(&recovery, pools, head_published, request) { + Ok(plan) => plan, + Err( + refusal @ (RecoveryDispositionRefusal::NothingProtected + | RecoveryDispositionRefusal::TargetNotRetained { .. }), + ) => return self.finish_retired_pool_entry(request, refusal), + Err(source) => return Err(FilesystemRetentionDispositionError::Plan { source }), + }; + self.recovery = + Some(RetentionRecoveryContext::reopen(&self.retention, &observation).map_err(observe)?); + let fence = acquire_fence(&self.root)?; + let (artifact, pool) = disposed_artifact(&observation, plan.target()).map_err(observe)?; + let coordinates = self.disposition_coordinates(&fence).map_err(observe)?; + let receipt = receipt_for( + &artifact, + pool_identity(&pool, &artifact)?, + plan.decision(), + coordinates, + ) + .map_err(observe)?; + let inputs = DispositionInputs { + plan, + receipt, + artifact, + pool, + }; + let context = self.disposition_context(inputs, fence).map_err(observe)?; + let from = resume_phase(&context)?; + self.run_disposition(context, from) + } + + /// Completes a retirement whose receipt is durable but whose pool entry + /// still exists: the residue of process death after the retained stage + /// was removed. Any other residue returns the planner's refusal. + fn finish_retired_pool_entry( + &mut self, + request: RecoveryDispositionRequest, + refusal: RecoveryDispositionRefusal, + ) -> Result { + let recovery = self + .root + .open_dir_nofollow(pool_name::RECOVERY) + .map_err(observe)?; + let dispositions = recovery + .open_dir_nofollow(pool_name::DISPOSITIONS) + .map_err(observe)?; + let fence = acquire_fence(&self.root)?; + let coordinates = self.disposition_coordinates(&fence).map_err(observe)?; + let kind = match request.target { + RecoveryDispositionTarget::Root => RecoveryArtifactKind::RetentionRoot, + RecoveryDispositionTarget::Manifest => RecoveryArtifactKind::RetentionManifest, + }; + for entry in dispositions.entries().map_err(observe)? { + let name = entry + .map_err(observe)? + .file_name() + .to_string_lossy() + .into_owned(); + let Some(bytes) = + read_exact_optional(&dispositions, &name, RECEIPT_LENGTH).map_err(observe)? + else { + continue; + }; + let admitted = AdmittedRecoveryDispositionReceipt::decode(&bytes) + .map_err(|source| observe(invalid_data_from(source)))?; + let semantic = *admitted.receipt(); + let exact = semantic.artifact().kind == kind + && semantic.decision() == RecoveryDispositionDecision::Retire + && semantic.coordinates() == coordinates; + if !exact { + continue; + } + let Some((pool, artifact)) = self + .locate_pool_entry(request.target, semantic.artifact().identity_digest) + .map_err(observe)? + else { + continue; + }; + if CanonicalRecoveryDispositionReceipt::artifact_content_digest(&artifact) + != semantic.artifact().content_digest + { + return Err(FilesystemRetentionDispositionError::Ambiguity( + RecoveryDispositionAmbiguity::PoolEntryDiffers, + )); + } + let receipt = CanonicalRecoveryDispositionReceipt::from_receipt(&semantic); + let context = DispositionContext { + target: request.target, + decision: RecoveryDispositionDecision::Retire, + receipt, + artifact, + pool, + name, + recovery, + dispositions, + stage: None, + _fence: fence, + }; + return self.run_disposition(context, RecoveryDispositionPhase::RemovePoolEntry); + } + Err(FilesystemRetentionDispositionError::Plan { source: refusal }) + } + + fn run_disposition( + &mut self, + context: DispositionContext, + from: RecoveryDispositionPhase, + ) -> Result { + let receipt = context.receipt.clone(); + let decision = context.decision; + self.disposition = Some(context); + let result = resume_recovery_disposition(self, decision, from) + .map_err(|source| FilesystemRetentionDispositionError::Execute { source }); + self.disposition = None; + self.recovery = None; + result.map(|_executed| receipt) + } + + /// Finds the pool entry named by `identity` for `target`: a root under + /// any namespace directory, or a manifest. + fn locate_pool_entry( + &self, + target: RecoveryDispositionTarget, + identity: ArtifactIdentityDigest, + ) -> io::Result> { + let suffix = format!( + "-{}{}", + crate::adapters::digest_hex::DigestHex(identity.as_bytes()), + match target { + RecoveryDispositionTarget::Root => pool_name::ROOT_SUFFIX, + RecoveryDispositionTarget::Manifest => pool_name::MANIFEST_SUFFIX, + } + ); + match target { + RecoveryDispositionTarget::Root => { + for namespace in self.roots.entries()? { + let namespace = namespace?.file_name().to_string_lossy().into_owned(); + let directory = self.roots.open_dir_nofollow(&namespace)?; + if let Some((name, bytes)) = entry_with_suffix(&directory, &suffix)? { + return Ok(Some((PoolEntry::Root { namespace, name }, bytes))); + } + } + Ok(None) + } + RecoveryDispositionTarget::Manifest => Ok(entry_with_suffix(&self.manifests, &suffix)? + .map(|(name, bytes)| (PoolEntry::Manifest { name }, bytes))), + } + } + + /// Observes the publication head, catalog, retention state, and the + /// locked `reader.lock` identity the decision is made under. + fn disposition_coordinates( + &self, + fence: &ReaderFence, + ) -> io::Result { + let head_bytes = read_exact_optional(&self.root, HEAD_NAME, HEAD_LENGTH)? + .ok_or_else(|| invalid_data("publication head is absent"))?; + let head = ChecksummedPublicationHead::decode(&head_bytes).map_err(invalid_data_from)?; + let checksum: [u8; 32] = head_bytes + .get(HEAD_CHECKSUM_OFFSET..HEAD_LENGTH) + .and_then(|slice| slice.try_into().ok()) + .ok_or_else(|| invalid_data("publication head checksum slot"))?; + let retention = filesystem_retention_current::observe(&self.retention, &self.manifests)? + .map_or(GcRetentionState::Empty, |current| { + GcRetentionState::Published { + generation: current.head().generation(), + manifest_digest: current.head().manifest_digest(), + } + }); + let (device, file) = fence.identity()?; + let mount = filesystem_platform_profile::root_identity(&self.root)?.mount(); + Ok(RecoveryDispositionCoordinates { + publication_generation: head.generation(), + publication_checksum: ObservedHeadChecksum::new(checksum), + catalog_generation: head.generation(), + catalog_digest: head.catalog_digest(), + retention, + reader_lock: ReaderLockIdentity::new(device, mount, file), + }) + } + + fn disposition_context( + &self, + inputs: DispositionInputs, + fence: ReaderFence, + ) -> io::Result { + let recovery = self.root.open_dir_nofollow(pool_name::RECOVERY)?; + let dispositions = recovery.open_dir_nofollow(pool_name::DISPOSITIONS)?; + let identity = inputs.receipt.receipt().artifact().identity_digest; + Ok(DispositionContext { + target: inputs.plan.target(), + decision: inputs.plan.decision(), + receipt: inputs.receipt, + artifact: inputs.artifact, + pool: inputs.pool, + name: pool_name::disposition(identity.as_bytes()), + recovery, + dispositions, + stage: None, + _fence: fence, + }) + } +} + +/// Classifies the disposition residue and names the phase to resume at: +/// no stage and no receipt starts fresh; an exact stage resumes at its +/// synchronization; an exact stage beside the identical receipt resumes +/// at stage removal; the identical receipt alone resumes at the retained +/// retention stage. A truncated stage with no receipt is discarded first. +fn resume_phase( + context: &DispositionContext, +) -> Result { + let expected = context.receipt.encoded(); + let stage = read_bounded(&context.recovery, pool_name::DISPOSITION_STAGE).map_err(observe)?; + let receipt = read_exact_optional(&context.dispositions, &context.name, RECEIPT_LENGTH) + .map_err(|source| { + if source.kind() == io::ErrorKind::InvalidData { + FilesystemRetentionDispositionError::Ambiguity( + RecoveryDispositionAmbiguity::ReceiptDiffers, + ) + } else { + observe(source) + } + })?; + if receipt.as_deref().is_some_and(|bytes| bytes != expected) { + return Err(FilesystemRetentionDispositionError::Ambiguity( + RecoveryDispositionAmbiguity::ReceiptDiffers, + )); + } + match (stage.as_deref(), receipt.is_some()) { + (None, false) => Ok(RecoveryDispositionPhase::WriteStage), + (Some(bytes), false) if bytes == expected => Ok(RecoveryDispositionPhase::SynchronizeStage), + (Some(bytes), false) if expected.starts_with(bytes) => { + discard_stage(&context.recovery).map_err(observe)?; + Ok(RecoveryDispositionPhase::WriteStage) + } + (Some(bytes), true) if bytes == expected => Ok(RecoveryDispositionPhase::RemoveStage), + (None, true) => Ok(RecoveryDispositionPhase::RemoveRetainedStage), + (Some(_), _) => Err(FilesystemRetentionDispositionError::Ambiguity( + RecoveryDispositionAmbiguity::StageDiffers, + )), + } +} + +const fn observe(source: io::Error) -> FilesystemRetentionDispositionError { + FilesystemRetentionDispositionError::Observe { source } +} + +fn acquire_fence(root: &Dir) -> Result { + ReaderFence::acquire_exclusive(root).map_err(|source| { + if source.kind() == io::ErrorKind::WouldBlock { + FilesystemRetentionDispositionError::ReadersActive + } else { + observe(source) + } + }) +} + +/// The retained stage's exact bytes and the pool entry recovery linked them to. +fn disposed_artifact( + observation: &RetentionRecoveryObservation, + target: RecoveryDispositionTarget, +) -> io::Result<(Box<[u8]>, PoolEntry)> { + match target { + RecoveryDispositionTarget::Root => { + let bytes = observation + .root() + .ok_or_else(|| invalid_data("disposition target root stage vanished"))? + .bytes + .clone(); + let root = AdmittedRetentionRoot::decode(&bytes).map_err(invalid_data_from)?; + let pool = PoolEntry::Root { + namespace: pool_name::namespace(root.root().namespace().digest()), + name: pool_name::root(root.root().generation(), root.digest()), + }; + Ok((bytes, pool)) + } + RecoveryDispositionTarget::Manifest => { + let bytes = observation + .manifest() + .ok_or_else(|| invalid_data("disposition target manifest stage vanished"))? + .bytes + .clone(); + let manifest = AdmittedRetentionManifest::decode(&bytes).map_err(invalid_data_from)?; + let pool = PoolEntry::Manifest { + name: pool_name::manifest(manifest.manifest().generation(), manifest.digest()), + }; + Ok((bytes, pool)) + } + } +} + +/// The artifact's pool-name digest: the identity the receipt is filed under. +fn pool_identity( + pool: &PoolEntry, + artifact: &[u8], +) -> Result<(RecoveryArtifactKind, ArtifactIdentityDigest), FilesystemRetentionDispositionError> { + match pool { + PoolEntry::Root { .. } => { + let root = AdmittedRetentionRoot::decode(artifact) + .map_err(|source| observe(invalid_data_from(source)))?; + Ok(( + RecoveryArtifactKind::RetentionRoot, + ArtifactIdentityDigest::new(*root.digest().as_bytes()), + )) + } + PoolEntry::Manifest { .. } => { + let manifest = AdmittedRetentionManifest::decode(artifact) + .map_err(|source| observe(invalid_data_from(source)))?; + Ok(( + RecoveryArtifactKind::RetentionManifest, + ArtifactIdentityDigest::new(*manifest.digest().as_bytes()), + )) + } + } +} + +fn receipt_for( + artifact: &[u8], + (kind, identity): (RecoveryArtifactKind, ArtifactIdentityDigest), + decision: RecoveryDispositionDecision, + coordinates: RecoveryDispositionCoordinates, +) -> io::Result { + let disposed = RecoveryDispositionArtifact { + kind, + classification: RecoveryClassification::CompleteOrphan, + length: u64::try_from(artifact.len()).map_err(invalid_data_from)?, + identity_digest: identity, + content_digest: CanonicalRecoveryDispositionReceipt::artifact_content_digest(artifact), + }; + let evidence = DecisionEvidenceDigest::new(trailing_checksum(artifact)?); + Ok(CanonicalRecoveryDispositionReceipt::from_receipt( + &RecoveryDispositionReceipt::new(disposed, decision, coordinates, evidence), + )) +} + +/// The artifact record's trailing checksum: the evidence the decision was +/// made over. +fn trailing_checksum(bytes: &[u8]) -> io::Result<[u8; 32]> { + let start = bytes + .len() + .checked_sub(32) + .ok_or_else(|| invalid_data("artifact is shorter than its checksum"))?; + bytes + .get(start..) + .and_then(|slice| slice.try_into().ok()) + .ok_or_else(|| invalid_data("artifact checksum slot")) +} + +fn invalid_data_from(error: impl Error + Send + Sync + 'static) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, error) +} + +/// Reads `recovery/disposition.next` up to one byte past the receipt length. +fn read_bounded(recovery: &Dir, name: &str) -> io::Result>> { + let mut file = match exact_record::open_read(recovery, name) { + Ok(file) => file, + Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(source) => return Err(source), + }; + if !file.metadata()?.is_file() { + return Err(invalid_data("disposition stage is not a regular file")); + } + let mut bytes = Vec::new(); + let limit = u64::try_from(RECEIPT_LENGTH) + .map_err(invalid_data_from)? + .saturating_add(1); + file.by_ref().take(limit).read_to_end(&mut bytes)?; + Ok(Some(bytes)) +} + +/// The first regular entry of `directory` whose name ends with `suffix`, +/// with its complete bytes. +fn entry_with_suffix(directory: &Dir, suffix: &str) -> io::Result> { + for entry in directory.entries()? { + let name = entry?.file_name().to_string_lossy().into_owned(); + if !name.ends_with(suffix) { + continue; + } + let metadata = directory.symlink_metadata(&name)?; + if !metadata.is_file() { + return Err(invalid_data("retention pool entry is not a regular file")); + } + let length = usize::try_from(metadata.len()).map_err(invalid_data_from)?; + let bytes = read_exact_optional(directory, &name, length)? + .ok_or_else(|| invalid_data("retention pool entry vanished"))?; + return Ok(Some((name, bytes))); + } + Ok(None) +} + +fn discard_stage(recovery: &Dir) -> io::Result<()> { + recovery.remove_file(pool_name::DISPOSITION_STAGE)?; + exact_record::require_absent(recovery, pool_name::DISPOSITION_STAGE) + .map_err(|_source| invalid_data("discarded disposition stage remained visible"))?; + synchronize_directory(recovery) +} + +fn no_disposition() -> io::Error { + invalid_data("no disposition is in progress") +} + +/// Removes `name` from `directory` after proving it still holds `expected`. +fn unlink_verified(directory: &Dir, name: &str, expected: &[u8]) -> io::Result<()> { + let observed = read_exact_optional(directory, name, expected.len())? + .ok_or_else(|| invalid_data("retired pool entry is already absent"))?; + if observed.as_ref() != expected { + return Err(invalid_data( + "retired pool entry bytes disagree with the receipt", + )); + } + directory.remove_file(name)?; + exact_record::require_absent(directory, name) + .map_err(|_source| invalid_data("retired pool entry remained visible")) +} + +impl RecoveryDispositionStorage for FilesystemRetentionPublicationAuthority { + fn write_disposition_stage(&mut self) -> io::Result<()> { + let context = self.disposition.as_mut().ok_or_else(no_disposition)?; + context.stage = Some(FilesystemRetentionStage::create( + &context.recovery, + pool_name::DISPOSITION_STAGE, + context.receipt.encoded(), + )?); + Ok(()) + } + + fn synchronize_disposition_stage(&mut self) -> io::Result<()> { + let context = self.disposition.as_mut().ok_or_else(no_disposition)?; + if context.stage.is_none() { + context.stage = Some(FilesystemRetentionStage::reopen( + &context.recovery, + pool_name::DISPOSITION_STAGE, + context.receipt.encoded(), + )?); + } + let stage = context.stage.as_ref().ok_or_else(no_disposition)?; + stage.synchronize(&context.recovery) + } + + fn link_disposition_receipt(&mut self) -> io::Result<()> { + let context = self.disposition.as_ref().ok_or_else(no_disposition)?; + let stage = context.stage.as_ref().ok_or_else(no_disposition)?; + stage.link(&context.recovery, &context.dispositions, &context.name) + } + + fn synchronize_dispositions(&mut self) -> io::Result<()> { + let context = self.disposition.as_ref().ok_or_else(no_disposition)?; + synchronize_directory(&context.dispositions) + } + + fn remove_disposition_stage(&mut self) -> io::Result<()> { + let context = self.disposition.as_mut().ok_or_else(no_disposition)?; + if context.stage.is_none() { + context.stage = Some(FilesystemRetentionStage::reopen( + &context.recovery, + pool_name::DISPOSITION_STAGE, + context.receipt.encoded(), + )?); + } + let stage = context.stage.take().ok_or_else(no_disposition)?; + stage.remove(&context.recovery, &context.dispositions, &context.name) + } + + fn synchronize_recovery(&mut self) -> io::Result<()> { + let context = self.disposition.as_ref().ok_or_else(no_disposition)?; + synchronize_directory(&context.recovery) + } + + fn remove_retained_stage(&mut self) -> io::Result<()> { + let target = self.disposition.as_ref().ok_or_else(no_disposition)?.target; + match target { + RecoveryDispositionTarget::Root => RetentionRecoveryStorage::remove_root_stage(self), + RecoveryDispositionTarget::Manifest => { + RetentionRecoveryStorage::remove_manifest_stage(self) + } + } + } + + fn synchronize_retention_after_disposition(&mut self) -> io::Result<()> { + synchronize_directory(&self.retention) + } + + fn remove_pool_entry(&mut self) -> io::Result<()> { + let context = self.disposition.as_ref().ok_or_else(no_disposition)?; + match &context.pool { + PoolEntry::Root { namespace, name } => { + let directory = self.roots.open_dir_nofollow(namespace)?; + unlink_verified(&directory, name, &context.artifact)?; + synchronize_directory(&directory)?; + if directory.entries()?.next().is_none() { + drop(directory); + self.roots.remove_dir(namespace)?; + } + Ok(()) + } + PoolEntry::Manifest { name } => { + unlink_verified(&self.manifests, name, &context.artifact) + } + } + } + + fn synchronize_pool(&mut self) -> io::Result<()> { + let context = self.disposition.as_ref().ok_or_else(no_disposition)?; + match context.pool { + PoolEntry::Root { .. } => synchronize_directory(&self.roots), + PoolEntry::Manifest { .. } => synchronize_directory(&self.manifests), + } + } +} diff --git a/src/adapters/retention/filesystem_retention_disposition_tests.rs b/src/adapters/retention/filesystem_retention_disposition_tests.rs new file mode 100644 index 00000000..0f06872e --- /dev/null +++ b/src/adapters/retention/filesystem_retention_disposition_tests.rs @@ -0,0 +1,418 @@ +//! Explicit disposition laws over recovery-protected retention orphans. + +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; + +use cap_std::fs::Dir; + +use super::filesystem_retention_pool_name as pool_name; +use super::filesystem_retention_test_fixture::{ + MANIFEST_HEX, ROOT_HEX, catalog_policy, drive_publication, fixture, initial_preparation, + initial_root, new_namespace_preparation, open_authority, refusal, reopen_authority, + successor_preparation, successor_root, +}; +use super::{ + AdmittedRetentionManifest, AdmittedRetentionRoot, + FilesystemRetentionDispositionError as DispositionError, ReaderFence, + RecoveryDispositionAmbiguity, RecoveryDispositionRefusal, RecoveryDispositionRequest, + RecoveryDispositionTarget, RetentionCurrentStateRefusal, RetentionPublicationOutcome, + RetentionPublicationStorage, +}; +use crate::adapters::filesystem_test_sandbox::TestDirectory; +use crate::adapters::{ + AdmittedRecoveryDispositionReceipt, FilesystemRetentionSnapshot, GcRetentionState, + ReaderAttemptLimit, RecoveryArtifactKind, RecoveryClassification, RecoveryDispositionDecision, +}; +use crate::execute_retention_publication; + +const ROOT_DIGEST_HEX: &str = "ca4c11f265c3bed07073bdc3b6aef003e964ac8cb36fcfcc92f20fa6f0b60085"; + +/// Publishes the first `count` phases of the initial root publication and +/// releases the writer, leaving a recovery-protected orphan behind. +fn interrupted_store(name: &str, count: usize) -> Result> { + let (sandbox, mut authority) = open_authority(name)?; + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + drive_publication(&mut authority, &preparation, count)?; + drop(authority); + Ok(sandbox) +} + +fn request( + target: RecoveryDispositionTarget, + decision: RecoveryDispositionDecision, +) -> RecoveryDispositionRequest { + RecoveryDispositionRequest { target, decision } +} + +const fn retire(target: RecoveryDispositionTarget) -> RecoveryDispositionRequest { + RecoveryDispositionRequest { + target, + decision: RecoveryDispositionDecision::Retire, + } +} + +fn receipt_path(root: &Path) -> PathBuf { + root.join("recovery") + .join("dispositions") + .join(format!("{ROOT_DIGEST_HEX}.receipt")) +} + +fn root_pool_entry(root: &Path) -> Result> { + let root_bytes = fixture(ROOT_HEX)?; + let admitted = AdmittedRetentionRoot::decode(&root_bytes)?; + Ok(root + .join("retention") + .join("roots") + .join(pool_name::namespace(admitted.root().namespace().digest())) + .join(pool_name::root( + admitted.root().generation(), + admitted.digest(), + ))) +} + +fn publish_initial(root: &Path) -> Result> { + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + let mut authority = reopen_authority(root)?; + Ok(execute_retention_publication(&mut authority, &preparation)?.outcome()) +} + +#[test] +fn retiring_a_protected_root_under_an_absent_head_frees_publication() -> Result<(), Box> +{ + let sandbox = interrupted_store("disposition-retire-root", 7)?; + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + let mut authority = reopen_authority(sandbox.path())?; + let error = authority + .verify_current(&preparation) + .err() + .ok_or("a protected orphan was admitted for publication")?; + assert!(matches!( + refusal(&error), + Some(RetentionCurrentStateRefusal::RetainedStage) + )); + + let receipt = authority.dispose(retire(RecoveryDispositionTarget::Root))?; + drop(authority); + + let semantic = receipt.receipt(); + assert_eq!( + semantic.artifact().kind, + RecoveryArtifactKind::RetentionRoot + ); + assert_eq!( + semantic.artifact().classification, + RecoveryClassification::CompleteOrphan + ); + assert_eq!(semantic.decision(), RecoveryDispositionDecision::Retire); + assert_eq!(semantic.coordinates().retention, GcRetentionState::Empty); + assert_eq!(semantic.coordinates().publication_generation.get(), 1); + assert_eq!( + semantic.artifact().identity_digest.as_bytes().as_slice(), + crate::adapters::test_support::decode_hex(ROOT_DIGEST_HEX)?.as_slice() + ); + let stored = fs::read(receipt_path(sandbox.path()))?; + assert_eq!(stored, receipt.encoded()); + assert!(AdmittedRecoveryDispositionReceipt::decode(&stored).is_ok()); + assert!(!sandbox.path().join("retention").join("root.next").exists()); + assert!( + !sandbox + .path() + .join("recovery") + .join("disposition.next") + .exists() + ); + assert!(!root_pool_entry(sandbox.path())?.exists()); + assert_eq!( + fs::read_dir(sandbox.path().join("retention").join("roots"))?.count(), + 0, + "the emptied namespace directory is removed" + ); + + assert_eq!( + publish_initial(sandbox.path())?, + RetentionPublicationOutcome::Published + ); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn finalize_requires_a_published_head() -> Result<(), Box> { + let sandbox = interrupted_store("disposition-finalize-absent-head", 7)?; + let mut authority = reopen_authority(sandbox.path())?; + + let error = authority + .dispose(request( + RecoveryDispositionTarget::Root, + RecoveryDispositionDecision::Finalize, + )) + .err() + .ok_or("an orphan was finalized into no visible state")?; + + assert!(matches!( + error, + DispositionError::Plan { + source: RecoveryDispositionRefusal::FinalizeRequiresPublishedHead + } + )); + assert!(sandbox.path().join("retention").join("root.next").exists()); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn finalizing_a_successor_orphan_keeps_its_pool_entry_and_frees_publication() +-> Result<(), Box> { + let (sandbox, mut authority) = open_authority("disposition-finalize-successor")?; + let root_bytes = fixture(ROOT_HEX)?; + let _published = + execute_retention_publication(&mut authority, &initial_preparation(&root_bytes)?)?; + let current_root = AdmittedRetentionRoot::decode(&root_bytes)?; + let manifest_bytes = fixture(MANIFEST_HEX)?; + let current_manifest = AdmittedRetentionManifest::decode(&manifest_bytes)?; + let candidate = successor_root(¤t_root)?; + let preparation = successor_preparation(¤t_root, ¤t_manifest, candidate.encoded())?; + drive_publication(&mut authority, &preparation, 7)?; + + let receipt = authority.dispose(request( + RecoveryDispositionTarget::Root, + RecoveryDispositionDecision::Finalize, + ))?; + + assert_eq!( + receipt.receipt().decision(), + RecoveryDispositionDecision::Finalize + ); + assert!(matches!( + receipt.receipt().coordinates().retention, + GcRetentionState::Published { .. } + )); + assert!(!sandbox.path().join("retention").join("root.next").exists()); + let successor = AdmittedRetentionRoot::decode(candidate.encoded())?; + let entry = sandbox + .path() + .join("retention") + .join("roots") + .join(pool_name::namespace(successor.root().namespace().digest())) + .join(pool_name::root( + successor.root().generation(), + successor.digest(), + )); + assert!(entry.exists(), "finalize keeps the immutable pool entry"); + + let other = initial_root(b"other", ¤t_root)?; + let other_preparation = new_namespace_preparation(¤t_manifest, other.encoded())?; + let published = execute_retention_publication(&mut authority, &other_preparation)?; + assert_eq!(published.outcome(), RetentionPublicationOutcome::Published); + drop(authority); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn a_second_disposition_finds_nothing_protected_and_changes_nothing() -> Result<(), Box> +{ + let sandbox = interrupted_store("disposition-twice", 7)?; + let mut authority = reopen_authority(sandbox.path())?; + let receipt = authority.dispose(retire(RecoveryDispositionTarget::Root))?; + + let error = authority + .dispose(retire(RecoveryDispositionTarget::Root)) + .err() + .ok_or("a disposed store was disposed again")?; + + assert!(matches!( + error, + DispositionError::Plan { + source: RecoveryDispositionRefusal::NothingProtected + } + )); + assert_eq!(fs::read(receipt_path(sandbox.path()))?, receipt.encoded()); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn the_manifest_stage_must_be_disposed_before_the_root_it_names() -> Result<(), Box> { + let sandbox = interrupted_store("disposition-order", 11)?; + let mut authority = reopen_authority(sandbox.path())?; + + let error = authority + .dispose(retire(RecoveryDispositionTarget::Root)) + .err() + .ok_or("the root was disposed under a protected manifest")?; + assert!(matches!( + error, + DispositionError::Plan { + source: RecoveryDispositionRefusal::ManifestStageRemains + } + )); + + let manifest = authority.dispose(retire(RecoveryDispositionTarget::Manifest))?; + assert_eq!( + manifest.receipt().artifact().kind, + RecoveryArtifactKind::RetentionManifest + ); + let root = authority.dispose(retire(RecoveryDispositionTarget::Root))?; + assert_eq!( + root.receipt().artifact().kind, + RecoveryArtifactKind::RetentionRoot + ); + drop(authority); + assert_eq!( + fs::read_dir(sandbox.path().join("retention").join("manifests"))?.count(), + 0 + ); + assert_eq!( + publish_initial(sandbox.path())?, + RetentionPublicationOutcome::Published + ); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn a_reader_holding_the_fence_refuses_disposition_without_waiting() -> Result<(), Box> { + let sandbox = interrupted_store("disposition-readers", 7)?; + let directory = Dir::open_ambient_dir(sandbox.path(), cap_std::ambient_authority())?; + let fence = ReaderFence::acquire(&directory)?; + let mut authority = reopen_authority(sandbox.path())?; + + let error = authority + .dispose(retire(RecoveryDispositionTarget::Root)) + .err() + .ok_or("disposition ran beside an active reader")?; + + assert!(matches!(error, DispositionError::ReadersActive)); + assert!(sandbox.path().join("retention").join("root.next").exists()); + drop(fence); + let _receipt = authority.dispose(retire(RecoveryDispositionTarget::Root))?; + sandbox.remove()?; + Ok(()) +} + +/// Reconstructs each residue an interrupted retirement can leave and proves +/// the next call with the same request resumes to the same complete state, +/// or refuses a residue that names another decision. +#[test] +fn an_interrupted_retirement_resumes_from_every_residue() -> Result<(), Box> { + let sandbox = interrupted_store("disposition-resume", 7)?; + let mut authority = reopen_authority(sandbox.path())?; + let receipt = authority.dispose(retire(RecoveryDispositionTarget::Root))?; + drop(authority); + let root_stage = sandbox.path().join("retention").join("root.next"); + let stage = sandbox.path().join("recovery").join("disposition.next"); + let linked = receipt_path(sandbox.path()); + let pool_entry = root_pool_entry(sandbox.path())?; + let root_bytes = fixture(ROOT_HEX)?; + let expected = receipt.encoded().to_vec(); + let restore_orphan = || -> Result<(), Box> { + fs::create_dir_all(pool_entry.parent().ok_or("namespace")?)?; + fs::write(&pool_entry, &root_bytes)?; + fs::hard_link(&pool_entry, &root_stage)?; + Ok(()) + }; + let settled = |label: &str| -> Result<(), Box> { + assert!(!stage.exists(), "{label}: stage"); + assert!(!root_stage.exists(), "{label}: root stage"); + assert!(!pool_entry.exists(), "{label}: pool entry"); + assert_eq!(fs::read(&linked)?, expected, "{label}: receipt"); + Ok(()) + }; + + // Death after the receipt was linked, before stage removal. + restore_orphan()?; + fs::hard_link(&linked, &stage)?; + let again = + reopen_authority(sandbox.path())?.dispose(retire(RecoveryDispositionTarget::Root))?; + assert_eq!(again.encoded(), expected.as_slice()); + settled("after link")?; + + // Death after the disposition stage was removed, before the retained + // root stage was. + restore_orphan()?; + let _receipt = + reopen_authority(sandbox.path())?.dispose(retire(RecoveryDispositionTarget::Root))?; + settled("after stage removal")?; + + // Death after the retained stage was removed, before the pool entry was. + fs::create_dir_all(pool_entry.parent().ok_or("namespace")?)?; + fs::write(&pool_entry, &root_bytes)?; + let _receipt = + reopen_authority(sandbox.path())?.dispose(retire(RecoveryDispositionTarget::Root))?; + settled("after retained stage removal")?; + + // Death mid-write of the disposition stage before any receipt: the + // truncated stage is discarded and the disposition redone. + fs::remove_file(&linked)?; + restore_orphan()?; + fs::write(&stage, expected.get(..100).ok_or("prefix")?)?; + let _receipt = + reopen_authority(sandbox.path())?.dispose(retire(RecoveryDispositionTarget::Root))?; + settled("after truncated stage")?; + + // A stage or receipt naming another decision is ambiguity, not a retry. + restore_orphan()?; + let mut other = expected.clone(); + let byte = other.get_mut(27).ok_or("decision byte")?; + *byte = 1; + fs::write(&stage, &other)?; + let error = reopen_authority(sandbox.path())? + .dispose(retire(RecoveryDispositionTarget::Root)) + .err() + .ok_or("a foreign stage was resumed")?; + assert!(matches!( + error, + DispositionError::Ambiguity(RecoveryDispositionAmbiguity::StageDiffers) + )); + fs::remove_file(&stage)?; + fs::write(&linked, &other)?; + let error = reopen_authority(sandbox.path())? + .dispose(retire(RecoveryDispositionTarget::Root)) + .err() + .ok_or("a foreign receipt was resumed")?; + assert!(matches!( + error, + DispositionError::Ambiguity(RecoveryDispositionAmbiguity::ReceiptDiffers) + )); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn readers_admit_a_store_with_receipts_and_refuse_a_stray_disposition_entry() +-> Result<(), Box> { + let sandbox = interrupted_store("disposition-admission", 7)?; + let _receipt = + reopen_authority(sandbox.path())?.dispose(retire(RecoveryDispositionTarget::Root))?; + + let view = FilesystemRetentionSnapshot::load( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + )?; + drop(view); + fs::write( + sandbox + .path() + .join("recovery") + .join("dispositions") + .join("stray"), + b"x", + )?; + assert!( + FilesystemRetentionSnapshot::load( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT + ) + .is_err(), + "a stray disposition entry was admitted" + ); + sandbox.remove()?; + Ok(()) +} diff --git a/src/adapters/retention/filesystem_retention_pool_name.rs b/src/adapters/retention/filesystem_retention_pool_name.rs index ec5add05..cdff82d5 100644 --- a/src/adapters/retention/filesystem_retention_pool_name.rs +++ b/src/adapters/retention/filesystem_retention_pool_name.rs @@ -18,6 +18,10 @@ pub(super) const HEAD: &str = "HEAD"; pub(super) const ROOT_STAGE: &str = "root.next"; pub(super) const MANIFEST_STAGE: &str = "manifest.next"; pub(super) const HEAD_STAGE: &str = "head.next"; +pub(super) const RECOVERY: &str = "recovery"; +pub(super) const DISPOSITIONS: &str = "dispositions"; +pub(super) const DISPOSITION_STAGE: &str = "disposition.next"; +pub(super) const DISPOSITION_SUFFIX: &str = ".receipt"; pub(super) const ROOT_SUFFIX: &str = ".root"; pub(super) const MANIFEST_SUFFIX: &str = ".manifest"; const DIGEST_HEX: usize = 64; @@ -43,6 +47,19 @@ pub(super) fn manifest(generation: LivenessGeneration, digest: RetentionManifest ) } +/// The canonical `recovery/dispositions` entry name for one artifact. +pub(super) fn disposition(identity: &[u8; 32]) -> String { + format!("{}{DISPOSITION_SUFFIX}", DigestHex(identity)) +} + +/// Whether `name` is a canonical `.receipt` entry name. +pub(in crate::adapters) fn is_disposition_name(name: &OsStr) -> bool { + name.to_str().is_some_and(|text| { + text.strip_suffix(DISPOSITION_SUFFIX) + .is_some_and(|stem| is_lower_hex(OsStr::new(stem), DIGEST_HEX)) + }) +} + /// Whether `name` is a 64-lowercase-hex namespace directory name. pub(super) fn is_namespace_name(name: &OsStr) -> bool { is_lower_hex(name, DIGEST_HEX) diff --git a/src/adapters/retention/filesystem_retention_recovery.rs b/src/adapters/retention/filesystem_retention_recovery.rs index d350d8e8..5cbb1c41 100644 --- a/src/adapters/retention/filesystem_retention_recovery.rs +++ b/src/adapters/retention/filesystem_retention_recovery.rs @@ -40,7 +40,10 @@ pub(super) struct RetentionRecoveryContext { } impl RetentionRecoveryContext { - fn reopen(retention: &Dir, observation: &RetentionRecoveryObservation) -> io::Result { + pub(super) fn reopen( + retention: &Dir, + observation: &RetentionRecoveryObservation, + ) -> io::Result { let root = observation .root() .map(|stage| -> io::Result { diff --git a/src/adapters/retention/reader_fence.rs b/src/adapters/retention/reader_fence.rs index 966d1c98..8f44c991 100644 --- a/src/adapters/retention/reader_fence.rs +++ b/src/adapters/retention/reader_fence.rs @@ -19,7 +19,7 @@ const READER_LOCK: &str = "reader.lock"; /// kernel lock; the persistent file is never deleted. #[must_use] pub struct ReaderFence { - _file: File, + file: File, } impl ReaderFence { @@ -33,7 +33,26 @@ impl ReaderFence { verify(root, &file)?; flock(&file, FlockOperation::LockShared)?; verify(root, &file)?; - Ok(Self { _file: file }) + Ok(Self { file }) + } + + /// Acquires the fence exclusively without waiting, for collection and + /// disposition under writer authority. + /// + /// Any reader holding the shared fence refuses the acquisition with + /// [`io::ErrorKind::WouldBlock`]; the caller reports that readers are + /// active rather than waiting on them. + pub(super) fn acquire_exclusive(root: &Dir) -> io::Result { + let file = filesystem_exact_record::open_read(root, READER_LOCK)?; + verify(root, &file)?; + flock(&file, FlockOperation::NonBlockingLockExclusive)?; + verify(root, &file)?; + Ok(Self { file }) + } + + /// Returns the locked file's device and inode identity. + pub(super) fn identity(&self) -> io::Result<(u64, u64)> { + self.file.metadata().map(|metadata| identity(&metadata)) } } diff --git a/src/adapters/store_migration.rs b/src/adapters/store_migration.rs index 01a489d6..f5c546d1 100644 --- a/src/adapters/store_migration.rs +++ b/src/adapters/store_migration.rs @@ -88,6 +88,7 @@ mod migration_receipt_decoder; mod migration_receipt_encoder; mod migration_receipt_format; mod migration_receipt_initial_state; +pub(in crate::adapters) use migration_receipt_initial_state::initial_retention_digest; mod migration_record_bytes; mod migration_recovery_ambiguity; mod migration_recovery_ambiguity_display; diff --git a/src/adapters/store_migration/migration_receipt_initial_state.rs b/src/adapters/store_migration/migration_receipt_initial_state.rs index d89af3e9..8a55ecb7 100644 --- a/src/adapters/store_migration/migration_receipt_initial_state.rs +++ b/src/adapters/store_migration/migration_receipt_initial_state.rs @@ -59,7 +59,7 @@ pub(super) fn read_empty_disposition_digest( } } -pub(super) fn initial_retention_digest() -> InitialRetentionStateDigest { +pub(in crate::adapters) fn initial_retention_digest() -> InitialRetentionStateDigest { InitialRetentionStateDigest::from_hash(digest(INITIAL_RETENTION_DOMAIN)) } diff --git a/src/lib.rs b/src/lib.rs index eccc02eb..5c604d36 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -157,10 +157,13 @@ pub use adapters::{ pub use adapters::{ AdmittedRetentionManifest, AdmittedRetentionRoot, CanonicalRetentionHead, CanonicalRetentionManifest, CanonicalRetentionRoot, ChecksummedRetentionHead, - FilesystemRetentionAuthorityError, FilesystemRetentionPublicationAuthority, - FilesystemRetentionRecoveryError, FilesystemRetentionSnapshot, - FilesystemRetentionSnapshotError, ObservedRetentionState, PreparedRetentionPublication, - ReaderAttemptLimit, ReaderFence, RetentionAuthorityDirectory, + FilesystemRetentionAuthorityError, FilesystemRetentionDispositionError, + FilesystemRetentionPublicationAuthority, FilesystemRetentionRecoveryError, + FilesystemRetentionSnapshot, FilesystemRetentionSnapshotError, ObservedRetentionState, + PreparedRetentionPublication, ReaderAttemptLimit, ReaderFence, RecoveryDispositionAmbiguity, + RecoveryDispositionError, RecoveryDispositionExecutionReceipt, RecoveryDispositionPhase, + RecoveryDispositionPlan, RecoveryDispositionRefusal, RecoveryDispositionRequest, + RecoveryDispositionStorage, RecoveryDispositionTarget, RetentionAuthorityDirectory, RetentionClosureVerificationError, RetentionCurrentStateRefusal, RetentionFixedStage, RetentionHeadDecodeError, RetentionHeadStageAssessment, RetentionManifestDecodeError, RetentionManifestEncodeError, RetentionManifestStageAssessment, RetentionNamespaceAdmission, @@ -175,10 +178,10 @@ pub use adapters::{ RetentionTransitionDisposition, RetentionTransitionError, RetentionTransitionPreflight, RetentionTransitionPreflightError, RetentionTransitionReadiness, RetentionViewCoordinates, RetentionViewError, RetentionViewSource, VerifiedRetentionClosure, assess_head_stage, - assess_manifest_stage, assess_root_stage, collect_retention_view, - execute_retention_publication, execute_retention_recovery, plan_retention_recovery, - plan_retention_transition, preflight_retention_transition, prepare_retention_publication, - verify_retention_closure, + assess_manifest_stage, assess_root_stage, collect_retention_view, execute_recovery_disposition, + execute_retention_publication, execute_retention_recovery, plan_recovery_disposition, + plan_retention_recovery, plan_retention_transition, preflight_retention_transition, + prepare_retention_publication, resume_recovery_disposition, verify_retention_closure, }; pub use adapters::{ MIGRATION_NAMESPACE_PREFIX, StoreMigrationEffect, StoreMigrationFixedStage, diff --git a/tests/recovery_disposition_receipt.rs b/tests/recovery_disposition_receipt.rs index a6512fd0..02e2d830 100644 --- a/tests/recovery_disposition_receipt.rs +++ b/tests/recovery_disposition_receipt.rs @@ -5,9 +5,10 @@ mod support; use std::io; use keep::{ - AdmittedRecoveryDispositionReceipt, CanonicalRecoveryDispositionReceipt, RecoveryArtifactKind, - RecoveryClassification, RecoveryDispositionDecision, RecoveryDispositionDecodeError as Refusal, - RecoveryDispositionField as Field, + AdmittedRecoveryDispositionReceipt, CanonicalRecoveryDispositionReceipt, GcRetentionState, + RecoveryArtifactKind, RecoveryClassification, RecoveryDispositionDecision, + RecoveryDispositionDecodeError as Refusal, RecoveryDispositionField as Field, + RecoveryDispositionReceipt, }; use crate::support::{domain_hash, flip, patch}; @@ -130,10 +131,10 @@ const MATRIX: &[Mutation] = &[ refuses: |error| matches!(error, Refusal::ZeroGeneration { offset: 144 }), }, Mutation { - field: "liveness generation", + field: "liveness generation zero beside a published manifest digest", reseal: true, mutate: |bytes| patch(bytes, 184, &0_u64.to_be_bytes()), - refuses: |error| matches!(error, Refusal::ZeroGeneration { offset: 184 }), + refuses: |error| matches!(error, Refusal::EmptyRetentionDigestMismatch { .. }), }, Mutation { field: "trailer reserved", @@ -191,7 +192,10 @@ fn frozen_disposition_decodes_and_reencodes_canonically() -> Result<(), Box Result<(), Box Result<(), Box> { + let bytes = fixture_bytes(DISPOSITION)?; + let receipt = *AdmittedRecoveryDispositionReceipt::decode(&bytes)?.receipt(); + let mut coordinates = receipt.coordinates(); + coordinates.retention = GcRetentionState::Empty; + let empty = RecoveryDispositionReceipt::new( + receipt.artifact(), + receipt.decision(), + coordinates, + receipt.evidence_digest(), + ); + + let canonical = CanonicalRecoveryDispositionReceipt::from_receipt(&empty); + + assert_eq!( + canonical.encoded().get(184..192), + Some(0_u64.to_be_bytes().as_slice()) + ); + let admitted = AdmittedRecoveryDispositionReceipt::decode(canonical.encoded())?; + assert_eq!( + admitted.receipt().coordinates().retention, + GcRetentionState::Empty + ); + Ok(()) +} + #[test] fn framing_refuses_truncation_and_trailing_bytes() -> Result<(), Box> { let bytes = fixture_bytes(DISPOSITION)?; From 9a10b22753a5f797169356788e9c840f74a0db7e Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 12:29:48 -0700 Subject: [PATCH 27/59] Feat: GC execution, retirement, and recovery with its process-death matrix ROADMAP T-22.4 (KEEP-GC-002, still In progress until compaction). A plan is now something the store can act on. `FilesystemGcAuthority` pins one admitted version-two root under the writer lock. `prepare(&GcPlan)` reads `gc` and refuses over any residue but idle or complete (`RecoveryRequired`), refuses an empty plan (nothing to do is not an intent), acquires `reader.lock` exclusively without waiting (`ReadersActive`), re-observes liveness under that fence through an unfenced snapshot loader and requires the reopened store to plan identically (`PlanStale`), then derives the one canonical intent: generation succeeding the prior receipt's or one, each candidate bound to the digest of the record that released it (the predecessor catalog or the exact disposition receipt, now carried by `GcLivenessSnapshot`), and the proof, pool-identity, and disposition-set digests under the newly registered `keep.gc-catalog-successor-proof/v2`, `keep.gc-segment-pool/v2`, and `keep.gc-disposition-set/v2` domains. `GcExecutionPhase::ALL` fixes fourteen phases; `GcExecutionStorage` is the port and `execute_gc` / `resume_gc_execution` drive it: intent stage, sync, link without replacement, sync, stage removal, sync; per candidate a reopen without following links, kind/length/admitted-digest verification, unlink, and pool sync; receipt stage over the exact remaining inventory after every candidate is proven absent, sync, atomic rename onto `gc/receipt` (so the prior retirement's receipt is replaced like `retention/HEAD` and the next generation is its successor), sync; intent removal after proving the receipt completes it, sync. `GcResidue` is what restart reads; `plan_gc_recovery` classifies it into idle, complete, a discardable truncated stage, or the exact resumption point, treating a receipt whose generation the intent succeeds as the prior retirement's, and refuses everything else as a typed `GcRecoveryAmbiguity`. `recover` acts on the plan. Exclusion: while `gc/intent` is durable, retention publication refuses `GcIntentRetained` and another retirement refuses `RecoveryRequired`. Namespace admission admits exactly `intent.next`, `intent`, `receipt.next`, and `receipt` as regular files in `gc`. Evidence: `filesystem_gc_tests` (retire the disposed orphan, second generation, nothing-to-retire and stale-plan refusals before any intent, readers refuse, every interrupted prefix of the 14 points and both truncated stages recover to the same complete state without losing the live segment, intent exclusion, out-of-order absence is ambiguity, admission), and the `KEEP-CRASH-074..087` process-death matrix: `cargo xtask durability-crash-matrix --sequence gc`, 42 killed-writer cases over a migrated bundle store with one disposed orphan, each requiring the live segment intact, the predicted recovery row, one complete retirement, a fresh plan naming nothing, and a settled `Complete`. `transitions.tsv` gains rows 074-087 and `transition_laws` checks them against `GcExecutionPhase`. Registering the three derivation domains changed the format-definition digest, so the marker, migration intent and receipt, and store identifier fixtures were rematerialized through the corpus oracle's temporary, removed write path; every other fixture is byte-identical and ORIGIN.md records it. Structure: `filesystem_retention_disposition.rs` was over the 500-line maximum and is split into planning, evidence, and storage modules; the xtask crash-point sequence map and the contract table move to their own files. `docs/formats/segment-store-v2/gc-execution.md` owns the phases, state table, and matrix; `gc.md` and `recovery.md` are trimmed under the review threshold. Still owed under #21 and recorded in the ROADMAP: identity-preserving compaction (T-22.3), the 65,536-candidate stress run, and the disposition-phase process-death matrix T-22.5 deferred here. Red: with `candidates_present` taken from the residue instead of the decoded intent, resuming from a bare intent stage skipped every unlink and the receipt phase refused "a GC candidate is still present". Green: the count comes from the intent. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 28 ++ README.md | 11 +- ROADMAP.md | 22 +- conformance/segment-store/v2/ORIGIN.md | 17 +- conformance/segment-store/v2/README.md | 23 +- conformance/segment-store/v2/artifacts.tsv | 6 +- conformance/segment-store/v2/definition.tsv | 3 + .../segment-store/v2/format-marker.hex | 2 +- .../segment-store/v2/migration-intent.hex | 2 +- .../segment-store/v2/migration-receipt.hex | 2 +- .../segment-store/v2/migration-source.tsv | 2 +- conformance/segment-store/v2/transitions.tsv | 14 + docs/formats/README.md | 2 +- docs/formats/segment-store-v2/README.md | 6 +- docs/formats/segment-store-v2/gc-execution.md | 120 +++++ docs/formats/segment-store-v2/gc.md | 82 ++-- docs/formats/segment-store-v2/recovery.md | 68 ++- docs/formats/segment-store-v2/requirements.md | 2 +- .../filesystem_initialization_namespace.rs | 19 +- src/adapters/gc/admitted_receipt.rs | 17 + src/adapters/gc/execution.rs | 153 +++++++ src/adapters/gc/execution_phase.rs | 130 ++++++ src/adapters/gc/execution_storage.rs | 50 ++ src/adapters/gc/filesystem_gc_authority.rs | 308 +++++++++++++ src/adapters/gc/filesystem_gc_error.rs | 99 ++++ src/adapters/gc/filesystem_gc_residue.rs | 88 ++++ src/adapters/gc/filesystem_gc_storage.rs | 246 ++++++++++ src/adapters/gc/filesystem_gc_tests.rs | 431 ++++++++++++++++++ src/adapters/gc/intent_encoder.rs | 11 + src/adapters/gc/liveness_observation.rs | 29 +- src/adapters/gc/liveness_observation_tests.rs | 12 +- src/adapters/gc/liveness_snapshot.rs | 40 +- src/adapters/gc/mod.rs | 32 +- src/adapters/gc/plan_model_tests.rs | 6 +- src/adapters/gc/planner_tests.rs | 24 +- src/adapters/gc/receipt.rs | 30 ++ src/adapters/gc/receipt_decode_error.rs | 8 + src/adapters/gc/receipt_decoder.rs | 44 +- src/adapters/gc/recovery_plan.rs | 295 ++++++++++++ src/adapters/gc/recovery_residue.rs | 31 ++ src/adapters/gc/retirement_intent.rs | 206 +++++++++ src/adapters/gc/segment_pool_inventory.rs | 12 + src/adapters/retention.rs | 3 + .../filesystem_retention_disposition.rs | 303 ++---------- ...lesystem_retention_disposition_evidence.rs | 163 +++++++ ...ilesystem_retention_disposition_storage.rs | 128 ++++++ .../retention/filesystem_retention_refusal.rs | 4 + .../filesystem_retention_snapshot.rs | 35 +- .../retention/filesystem_retention_stage.rs | 27 +- .../retention/filesystem_retention_storage.rs | 12 + src/adapters/retention/reader_fence.rs | 4 +- src/adapters/store_migration.rs | 4 +- .../format_definition_digest.rs | 6 +- .../migration_receipt_initial_state.rs | 2 +- src/lib.rs | 19 +- .../production_protocol.rs | 5 + .../production_protocol/gc.rs | 140 ++++++ .../production_protocol/gc_storage.rs | 208 +++++++++ .../production_protocol/retention.rs | 2 +- xtask/src/durability_crash_matrix/restart.rs | 4 + .../restart/expectation.rs | 4 +- .../src/durability_crash_matrix/restart/gc.rs | 171 +++++++ xtask/src/durability_crash_point.rs | 150 +++--- xtask/src/durability_crash_point_identity.rs | 14 + xtask/src/durability_crash_point_sequence.rs | 101 ++++ xtask/src/lib.rs | 1 + xtask/tests/durability_crash_case_contract.rs | 10 +- .../tests/durability_crash_point_contract.rs | 371 +-------------- .../expected.rs | 409 +++++++++++++++++ .../transition_laws.rs | 49 +- 70 files changed, 4175 insertions(+), 907 deletions(-) create mode 100644 docs/formats/segment-store-v2/gc-execution.md create mode 100644 src/adapters/gc/execution.rs create mode 100644 src/adapters/gc/execution_phase.rs create mode 100644 src/adapters/gc/execution_storage.rs create mode 100644 src/adapters/gc/filesystem_gc_authority.rs create mode 100644 src/adapters/gc/filesystem_gc_error.rs create mode 100644 src/adapters/gc/filesystem_gc_residue.rs create mode 100644 src/adapters/gc/filesystem_gc_storage.rs create mode 100644 src/adapters/gc/filesystem_gc_tests.rs create mode 100644 src/adapters/gc/recovery_plan.rs create mode 100644 src/adapters/gc/recovery_residue.rs create mode 100644 src/adapters/gc/retirement_intent.rs create mode 100644 src/adapters/retention/filesystem_retention_disposition_evidence.rs create mode 100644 src/adapters/retention/filesystem_retention_disposition_storage.rs create mode 100644 xtask/src/durability_crash_matrix/production_protocol/gc.rs create mode 100644 xtask/src/durability_crash_matrix/production_protocol/gc_storage.rs create mode 100644 xtask/src/durability_crash_matrix/restart/gc.rs create mode 100644 xtask/src/durability_crash_point_sequence.rs create mode 100644 xtask/tests/durability_crash_point_contract/expected.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db1039d..ce89271d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,34 @@ after its public API and format compatibility policies are established. ### Added +- GC execution, retirement, and recovery. `FilesystemGcAuthority` retires + the released segments a `GcPlan` names: `prepare` refuses over any + residue but idle or complete, refuses an empty plan, acquires the reader + fence exclusively without waiting (`ReadersActive`), re-observes liveness + under it and requires the reopened store to plan identically + (`PlanStale`), and derives the one canonical intent (generation succeeding + the prior receipt's, per-candidate release evidence, and the newly + registered `keep.gc-catalog-successor-proof/v2`, `keep.gc-segment-pool/v2`, + and `keep.gc-disposition-set/v2` derivations). `execute_gc` then drives + the 14 fixed phases through `GcExecutionStorage`: intent stage, sync, + link, sync, cleanup, sync; per candidate a verified unlink and pool sync; + receipt stage over the exact remaining pool, sync, atomic replacement of + `gc/receipt`, sync; intent removal, sync. `GcResidue` and + `plan_gc_recovery` classify every residue into idle, complete, a + discardable truncated stage, or the exact resumption point, and refuse + everything else as `GcRecoveryAmbiguity`; `recover` acts on it. A durable + `gc/intent` makes retention publication refuse `GcIntentRetained` and + another retirement refuse `RecoveryRequired`. Namespace admission admits + exactly the four GC records as regular files. Proven by + `filesystem_gc_tests` (every interrupted prefix and both truncated stages + recover to the same complete state without losing the live segment) and + by the `KEEP-CRASH-074..087` process-death matrix, + `cargo xtask durability-crash-matrix --sequence gc`, 42 killed-writer + cases with the ledger rows in `transitions.tsv`. Registering the three + derivation domains changed the format-definition digest, so the marker, + migration intent and receipt, and store identifier fixtures were + rematerialized through the corpus oracle. Specified on + `docs/formats/segment-store-v2/gc-execution.md`. - Explicit disposition of recovery-protected retention orphans. `FilesystemRetentionPublicationAuthority::dispose` records a finalize-or-retire decision over a linked, retained `root.next` or diff --git a/README.md b/README.md index 1078f09a..f4ab8e24 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,12 @@ Keep is required to refuse all three, before mutating anything. through an ordered protocol whose every step is a named crash point. Platform admission is Linux ext4, non-casefolded, one writer. - **Proven restart recovery.** The crash matrix kills real writer processes - at 224 before/during/after coordinates (`KEEP-CRASH-001`–`073`) and + at 266 before/during/after coordinates (`KEEP-CRASH-001`–`087`) and verifies the store lands in exactly one documented lawful state each time, - for version-1 publication, version-2 retention publication, and the - one-way migration; every interrupted migration recovers to one complete - migration with every version-1 byte intact. + for version-1 publication, version-2 retention publication, the one-way + migration, and GC retirement; every interrupted migration recovers to one + complete migration with every version-1 byte intact, and no crash prefix + of a retirement loses a live segment. - **Version-2 retention and migration, forward path.** Explicit retention roots, deterministic closure verification, a one-way 21-phase migration, and a 17-phase retention publication — all with production filesystem @@ -90,7 +91,7 @@ a publication. A version-1 store stays admitted until its owner migrates it. | --- | --- | | Durable authenticated reads bound to a fenced snapshot | [#109](https://github.com/flyingrobots/keep/issues/109) | | Verification reports at durable depths and a replayable receipt | [#20](https://github.com/flyingrobots/keep/issues/20) | -| Garbage collection and identity-preserving compaction | [#21](https://github.com/flyingrobots/keep/issues/21) | +| Identity-preserving compaction | [#21](https://github.com/flyingrobots/keep/issues/21) | | Bounded production ingestion through the durable store | [#82](https://github.com/flyingrobots/keep/issues/82) | | Encrypted representations | [#86](https://github.com/flyingrobots/keep/issues/86) | diff --git a/ROADMAP.md b/ROADMAP.md index 8de07b27..6aae28fc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1145,9 +1145,10 @@ quarantines, or rewrites physical state. ### F-22 Garbage collection, compaction, and recovery dispositions **Status:** Partial (#21, P1, M4). All three codecs (T-22.1, T-22.1a; -`KEEP-GC-001` Implemented), the deterministic planner (T-22.2), and explicit -orphan disposition (T-22.5) landed on this branch; GC intents and receipts -still refuse on disk; compaction and execution remain. +`KEEP-GC-001` Implemented), the deterministic planner (T-22.2), explicit +orphan disposition (T-22.5), and retirement with recovery and its +process-death matrix (T-22.4) landed on this branch; identity-preserving +compaction (T-22.3) remains, so `KEEP-GC-002` stays In progress. Plan GC from an immutable liveness snapshot; classify every segment as live, unreachable, corrupt, ambiguous, recovery-protected, @@ -1289,7 +1290,16 @@ disposition receipt exists. `BlobId`, `ChunkId`, and `LayoutId` never move. - **Documentation:** `gc.md` compaction section; ADR-0002 compaction example cross-linked. - **Dependencies:** T-22.2. -- [ ] T-22.4 GC execution, retirement, and recovery (`KEEP-GC-002`). +- [x] T-22.4 GC execution, retirement, and recovery — + `FilesystemGcAuthority::{prepare, execute, recover}`, the 14-phase + `GcExecutionPhase` protocol over the `GcExecutionStorage` port, + `GcResidue` and `plan_gc_recovery`, the registered proof, pool, and + disposition-set derivations, `filesystem_gc_tests` (every prefix, both + truncated stages, readers, stale plan, exclusion), and the + `KEEP-CRASH-074..087` matrix (`--sequence gc`, 42 cases). `KEEP-GC-002` + stays In progress until compaction (T-22.3); the 65,536-candidate stress + run and the disposition-phase process-death matrix promised by T-22.5 are + still owed under #21. Original task fields: - **Requirements:** writer authority then exclusive `reader.lock`; intent written, flushed, synced before any unlink; canonical order; per-unlink directory sync; receipt after all absent; a retained intent @@ -1325,8 +1335,8 @@ disposition receipt exists. `BlobId`, `ChunkId`, and `LayoutId` never move. phases, port, and executor in `src/adapters/retention/disposition_*.rs`, `filesystem_retention_disposition_tests` (retire, finalize, order, readers, every residue, admission) and the exact-receipt GC law; the - process-death matrix for the disposition phases joins the GC sequence in - T-22.4. Original task fields: + process-death matrix for the disposition phases is still owed under #21 + (T-22.4 shipped the retirement matrix only). Original task fields: - **Requirements:** a finalize-or-retire decision for a recovery-protected orphan is durable as `recovery/dispositions/.receipt` via the fixed-stage protocol; retirement proves the artifact is named by no diff --git a/conformance/segment-store/v2/ORIGIN.md b/conformance/segment-store/v2/ORIGIN.md index 751cdece..ad5a252f 100644 --- a/conformance/segment-store/v2/ORIGIN.md +++ b/conformance/segment-store/v2/ORIGIN.md @@ -36,6 +36,21 @@ reader-lock coordinates `4`, `5`, `6`; its decision-evidence digest is the segment's record checksum at 177, fixture-only evidence like the GC intent's candidate evidence. +## GC derivation registration + +On 2026-09-30 `definition.tsv` gained three rows: the +`keep.gc-catalog-successor-proof/v2\0`, `keep.gc-segment-pool/v2\0`, and +`keep.gc-disposition-set/v2\0` domains under which GC execution derives the +intent's proof, pool-identity, and disposition-set digests. As with the +disposition registration, the format-definition digest changed and the +format marker, migration intent, migration receipt, store identifier, and +their `artifacts.tsv` and `migration-source.tsv` rows were rematerialized +through the same temporary, removed write path; every other fixture is +byte-identical. The GC intent fixture keeps its fixture-only proof, pool, +and disposition-set digests. `transitions.tsv` gained rows `KEEP-CRASH-074` +through `-087` by transcribing `GcExecutionPhase::ALL` and the state table in +`docs/formats/segment-store-v2/gc-execution.md`. + ## Independent inputs The oracle imports exact bytes only from these previously accepted fixtures: @@ -82,7 +97,7 @@ The format-definition digest was checked independently with: Exact output: ```text -6cbc1c75f6efab18c7c50ae281edef77a8b0c9ba19f618b28b18483d08462c92 +a4a010cee5da8aa3ba153c5034f436b92742c6c1f7cf6b43d890ad5fd5b5cf89 ``` ## Materialization boundary diff --git a/conformance/segment-store/v2/README.md b/conformance/segment-store/v2/README.md index 4b5b2ae1..3303eeb2 100644 --- a/conformance/segment-store/v2/README.md +++ b/conformance/segment-store/v2/README.md @@ -14,7 +14,7 @@ migration, retention transition, or garbage collector exists. | `inventory.tsv` | Canonical one-segment, one-catalog migration inventory | | `migration-source.tsv` | Exact version-1 and derived migration coordinates | | `artifacts.tsv` | Golden artifact lengths, digests, checksums, and filenames | -| `transitions.tsv` | One stable crash identifier per migration boundary, `KEEP-CRASH-053` to `-073` | +| `transitions.tsv` | One stable crash identifier per migration and GC boundary, `KEEP-CRASH-053` to `-087` | | `gc-plan.tsv` | The deterministic GC plan for the frozen version-2 store: every segment's classification | | `format-marker.hex` | Canonical 96-byte `FORMAT` record | | `migration-intent.hex` | Canonical 256-byte migration intent | @@ -45,11 +45,12 @@ It hashes the exact `retention-profile.tsv` bytes under the registered profile domain. The format-definition digest is -`6cbc1c75f6efab18c7c50ae281edef77a8b0c9ba19f618b28b18483d08462c92`. +`a4a010cee5da8aa3ba153c5034f436b92742c6c1f7cf6b43d890ad5fd5b5cf89`. It hashes the exact `definition.tsv` bytes under the registered format domain. -The definition binds the profile digest, every named domain, magic, version, -field order, record width, format limit, migration synchronization mask, and -the registered recovery-disposition enumerations. +The definition binds the profile digest, every named domain (including the +GC catalog-successor-proof, segment-pool, and disposition-set derivations), +magic, version, field order, record width, format limit, migration +synchronization mask, and the registered recovery-disposition enumerations. The migration fixture preserves the version-1 one-zero segment and generation-1 catalog. Its canonical two-entry inventory digest is @@ -65,12 +66,14 @@ one-zero `BlobId` and `LayoutId` values from the existing layout corpus. ## Transition protocol -`transitions.tsv` mirrors the version-1 table: one row per migration +`transitions.tsv` mirrors the version-1 table: one row per migration and GC durability operation with its pre-state, interrupted-state classification, -post-state, and recovery posture. `KEEP-CRASH-053`, `-062`, and `-068` are -the only rows whose interruption may leave an incomplete pre-effect stage and -therefore the only rows that plan a discard; `-072` and `-073` admit the -complete migration. +post-state, and recovery posture. `KEEP-CRASH-053`, `-062`, `-068`, `-074`, +and `-082` are the only rows whose interruption may leave an incomplete +pre-effect stage and therefore the only rows that plan a discard; `-072` and +`-073` admit the complete migration, `-086` and `-087` the complete +retirement. `cargo xtask durability-crash-matrix --sequence gc` executes the +42 GC cases the same way over one disposed orphan. The `cargo xtask durability-crash-matrix --sequence migration` harness executes 68 canonical process-death cases from this table: 21 boundaries at diff --git a/conformance/segment-store/v2/artifacts.tsv b/conformance/segment-store/v2/artifacts.tsv index c5d0415c..04d781ef 100644 --- a/conformance/segment-store/v2/artifacts.tsv +++ b/conformance/segment-store/v2/artifacts.tsv @@ -1,8 +1,8 @@ keep.segment-store-v2.artifacts/v1 case kind byte_length generation entry_count bound_digest_hex final_checksum_hex fixture -format-marker format-marker 96 - - cbb0d60f9aaa896642e9e7cfa5e9575ad0782885d5221dfd7289cde779c63ea0 5ebc2cce9a69ac871e460cd1df2297b82ab37448503fd0ff4d996ff30f6a2e1e format-marker.hex -migration-intent migration-intent 256 1 2 f4914d8d9176710ebad4ff5c3f9b5ab8727b3eb4c463d1185f974798cca4a4c3 4b258bbaa2e93d182e697127af44492005c203ee70f4707f49db6f9468bd6a7e migration-intent.hex -migration-receipt migration-receipt 256 1 2 f4914d8d9176710ebad4ff5c3f9b5ab8727b3eb4c463d1185f974798cca4a4c3 65669e2483415cf5a65c6390e2eeb9cbb125a94c865d52d186348547db79bbf1 migration-receipt.hex +format-marker format-marker 96 - - 447cf1e1ebce88af0661368525b7be6b94f7e2320b7366a45782172d30d25106 e99517465d34ae26aab7e91c75252ac1d30a2edd2a5f0f4d6eb7d0be6d955080 format-marker.hex +migration-intent migration-intent 256 1 2 6ed76546eb087a7872881f93da53eb8f9c941e525a4585b8f41e4542d9bfb503 dc34faf439c184cf8987c78f6c22a93a3d978c442358635a0e7317247c3d8aa0 migration-intent.hex +migration-receipt migration-receipt 256 1 2 6ed76546eb087a7872881f93da53eb8f9c941e525a4585b8f41e4542d9bfb503 bf50cccc83345d0d1b94eb12447de484865619e566dc034a8216f11a8b3ebfa1 migration-receipt.hex one-anchor-root retention-root 378 1 1 ca4c11f265c3bed07073bdc3b6aef003e964ac8cb36fcfcc92f20fa6f0b60085 28c52ff0f8d6533234be083f425e921d699639e204e2c66dec0cae2ff0a2dc34 one-anchor-root.hex one-root-manifest retention-manifest 296 1 1 f46b96a2bf3379320cf59e8af15b9d108de06025c415307b28953714bd7a80eb 10597643c3fc9485c7ecd3bb511d6726e726fd92f0f769a204b899c5fdc77d2c one-root-manifest.hex one-root-head retention-head 144 1 1 f46b96a2bf3379320cf59e8af15b9d108de06025c415307b28953714bd7a80eb ac049edb33af7e957c6ff11ead7e1bcf9c40fa9793cc84979215ffbba5f630b7 one-root-head.hex diff --git a/conformance/segment-store/v2/definition.tsv b/conformance/segment-store/v2/definition.tsv index 08a3daa0..2cfcc4d5 100644 --- a/conformance/segment-store/v2/definition.tsv +++ b/conformance/segment-store/v2/definition.tsv @@ -5,9 +5,12 @@ domain.format-definition keep.segment-store-definition/v2\0 domain.format-marker keep.store-format-marker/v2\0 domain.format-marker-checksum keep.segment-store-marker-checksum/v2\0 domain.gc-candidate-set keep.gc-candidate-set/v2\0 +domain.gc-catalog-successor-proof keep.gc-catalog-successor-proof/v2\0 +domain.gc-disposition-set keep.gc-disposition-set/v2\0 domain.gc-intent keep.gc-retirement-intent/v2\0 domain.gc-intent-checksum keep.gc-retirement-intent-checksum/v2\0 domain.gc-receipt-checksum keep.gc-retirement-receipt-checksum/v2\0 +domain.gc-segment-pool keep.gc-segment-pool/v2\0 domain.initial-gc-state keep.initial-gc-state/v2\0 domain.initial-retention-state keep.initial-retention-state/v2\0 domain.migration-intent keep.store-migration-intent/v2\0 diff --git a/conformance/segment-store/v2/format-marker.hex b/conformance/segment-store/v2/format-marker.hex index 232cc368..11e41865 100644 --- a/conformance/segment-store/v2/format-marker.hex +++ b/conformance/segment-store/v2/format-marker.hex @@ -1 +1 @@ -4b4545503a53544f52453a563200000000020060000000006cbc1c75f6efab18c7c50ae281edef77a8b0c9ba19f618b28b18483d08462c9200001000000000005ebc2cce9a69ac871e460cd1df2297b82ab37448503fd0ff4d996ff30f6a2e1e +4b4545503a53544f52453a56320000000002006000000000a4a010cee5da8aa3ba153c5034f436b92742c6c1f7cf6b43d890ad5fd5b5cf890000100000000000e99517465d34ae26aab7e91c75252ac1d30a2edd2a5f0f4d6eb7d0be6d955080 diff --git a/conformance/segment-store/v2/migration-intent.hex b/conformance/segment-store/v2/migration-intent.hex index d61dfe1f..0fd4d291 100644 --- a/conformance/segment-store/v2/migration-intent.hex +++ b/conformance/segment-store/v2/migration-intent.hex @@ -1 +1 @@ -4b4545503a4d49473a494e543200000000020100000000000000000000000001000000000000016004b82519b0399baefd0b9c0f32a871052e4c47e3a00226ab03b21661470f7320000000000000000000000000000000000000000000000000000000000000000040bf5d49c34847ac9cf46a256f343cee80cd980d1405d2dd02ceff8f58d674f90000000000000001000000000000000200000000000000036cbc1c75f6efab18c7c50ae281edef77a8b0c9ba19f618b28b18483d08462c922b5ed4bcc926a6a5fa9fd5f749c134894de99d37bdf2def4e83bdf99a65397204b258bbaa2e93d182e697127af44492005c203ee70f4707f49db6f9468bd6a7e +4b4545503a4d49473a494e543200000000020100000000000000000000000001000000000000016004b82519b0399baefd0b9c0f32a871052e4c47e3a00226ab03b21661470f7320000000000000000000000000000000000000000000000000000000000000000040bf5d49c34847ac9cf46a256f343cee80cd980d1405d2dd02ceff8f58d674f9000000000000000100000000000000020000000000000003a4a010cee5da8aa3ba153c5034f436b92742c6c1f7cf6b43d890ad5fd5b5cf89a046bebd6d1b05d33b56e26e131e5d44bc1872d875d339f837eecd21caa0c1e1dc34faf439c184cf8987c78f6c22a93a3d978c442358635a0e7317247c3d8aa0 diff --git a/conformance/segment-store/v2/migration-receipt.hex b/conformance/segment-store/v2/migration-receipt.hex index bf4cf9b7..71b3e9f5 100644 --- a/conformance/segment-store/v2/migration-receipt.hex +++ b/conformance/segment-store/v2/migration-receipt.hex @@ -1 +1 @@ -4b4545503a4d49473a524543320000000002010000000000f4914d8d9176710ebad4ff5c3f9b5ab8727b3eb4c463d1185f974798cca4a4c32b5ed4bcc926a6a5fa9fd5f749c134894de99d37bdf2def4e83bdf99a6539720cbb0d60f9aaa896642e9e7cfa5e9575ad0782885d5221dfd7289cde779c63ea0d52f1f022edb1de7b840c5bf8fb55de7932ca69370ae85e2bee4179143792bc3ba0ea200a5b06741564c43a79a91945bef0b0fac51c960ea4f8207094f3e1e31a80259fcd1237203ea6c6cc5065514abdeb01da603c3194b096a045cf694c95a00000000000003ff65669e2483415cf5a65c6390e2eeb9cbb125a94c865d52d186348547db79bbf1 +4b4545503a4d49473a5245433200000000020100000000006ed76546eb087a7872881f93da53eb8f9c941e525a4585b8f41e4542d9bfb503a046bebd6d1b05d33b56e26e131e5d44bc1872d875d339f837eecd21caa0c1e1447cf1e1ebce88af0661368525b7be6b94f7e2320b7366a45782172d30d25106d52f1f022edb1de7b840c5bf8fb55de7932ca69370ae85e2bee4179143792bc3ba0ea200a5b06741564c43a79a91945bef0b0fac51c960ea4f8207094f3e1e31a80259fcd1237203ea6c6cc5065514abdeb01da603c3194b096a045cf694c95a00000000000003ffbf50cccc83345d0d1b94eb12447de484865619e566dc034a8216f11a8b3ebfa1 diff --git a/conformance/segment-store/v2/migration-source.tsv b/conformance/segment-store/v2/migration-source.tsv index 2ba3be64..5eb07865 100644 --- a/conformance/segment-store/v2/migration-source.tsv +++ b/conformance/segment-store/v2/migration-source.tsv @@ -1,3 +1,3 @@ keep.segment-store-v2.migration-source/v1 case catalog_generation catalog_length catalog_digest_hex predecessor_digest_hex inventory_digest_hex definition_digest_hex store_id_hex root_device root_mount root_file -one-zero 1 352 04b82519b0399baefd0b9c0f32a871052e4c47e3a00226ab03b21661470f7320 0000000000000000000000000000000000000000000000000000000000000000 40bf5d49c34847ac9cf46a256f343cee80cd980d1405d2dd02ceff8f58d674f9 6cbc1c75f6efab18c7c50ae281edef77a8b0c9ba19f618b28b18483d08462c92 2b5ed4bcc926a6a5fa9fd5f749c134894de99d37bdf2def4e83bdf99a6539720 1 2 3 +one-zero 1 352 04b82519b0399baefd0b9c0f32a871052e4c47e3a00226ab03b21661470f7320 0000000000000000000000000000000000000000000000000000000000000000 40bf5d49c34847ac9cf46a256f343cee80cd980d1405d2dd02ceff8f58d674f9 a4a010cee5da8aa3ba153c5034f436b92742c6c1f7cf6b43d890ad5fd5b5cf89 a046bebd6d1b05d33b56e26e131e5d44bc1872d875d339f837eecd21caa0c1e1 1 2 3 diff --git a/conformance/segment-store/v2/transitions.tsv b/conformance/segment-store/v2/transitions.tsv index cce218d3..ccf0c8ab 100644 --- a/conformance/segment-store/v2/transitions.tsv +++ b/conformance/segment-store/v2/transitions.tsv @@ -21,3 +21,17 @@ KEEP-CRASH-070 migration link-receipt durable-receipt-stage durable-receipt-stag KEEP-CRASH-071 migration sync-root-after-receipt linked-receipt linked-receipt durable-receipt resume-root-sync KEEP-CRASH-072 migration remove-receipt-stage durable-receipt durable-receipt-with-or-without-stage complete-migration admit-complete-migration KEEP-CRASH-073 migration sync-root-after-receipt-cleanup complete-migration complete-migration durable-complete-migration admit-complete-migration +KEEP-CRASH-074 gc write-intent-stage complete-retirement-or-idle absent-or-incomplete-intent-stage complete-intent-stage discard-incomplete-stage-or-resume-stage-sync +KEEP-CRASH-075 gc sync-intent-stage complete-intent-stage complete-intent-stage durable-intent-stage resume-stage-sync +KEEP-CRASH-076 gc link-intent durable-intent-stage durable-intent-stage-or-linked-intent linked-intent verify-no-clobber-link-and-resume-gc-sync +KEEP-CRASH-077 gc sync-gc-after-intent linked-intent linked-intent durable-intent resume-gc-sync +KEEP-CRASH-078 gc remove-intent-stage durable-intent durable-intent-with-or-without-stage durable-intent-alone resume-cleanup-sync +KEEP-CRASH-079 gc sync-gc-after-intent-cleanup durable-intent-alone durable-intent-alone active-retirement resume-cleanup-sync +KEEP-CRASH-080 gc unlink-candidate active-or-partial-retirement candidate-present-or-absent canonical-absent-prefix-extended verify-prefix-and-resume-at-first-present-candidate +KEEP-CRASH-081 gc sync-segment-pool canonical-absent-prefix-extended canonical-absent-prefix-extended durable-absent-prefix resume-pool-sync +KEEP-CRASH-082 gc write-receipt-stage completion-pending absent-or-incomplete-receipt-stage complete-receipt-stage discard-incomplete-stage-or-resume-stage-sync +KEEP-CRASH-083 gc sync-receipt-stage complete-receipt-stage complete-receipt-stage durable-receipt-stage resume-stage-sync +KEEP-CRASH-084 gc replace-receipt durable-receipt-stage durable-receipt-stage-or-replaced-receipt receipt-transition verify-exact-receipt-and-resume-gc-sync +KEEP-CRASH-085 gc sync-gc-after-receipt receipt-transition receipt-transition durable-receipt-transition resume-gc-sync +KEEP-CRASH-086 gc remove-intent durable-receipt-transition receipt-with-or-without-intent complete-retirement admit-complete-retirement +KEEP-CRASH-087 gc sync-gc-after-intent-removal complete-retirement complete-retirement durable-complete-retirement admit-complete-retirement diff --git a/docs/formats/README.md b/docs/formats/README.md index 17cdb32c..035d58bd 100644 --- a/docs/formats/README.md +++ b/docs/formats/README.md @@ -9,7 +9,7 @@ admitted merely because one Rust type can serialize and deserialize it. | --- | --- | --- | --- | | [Flat Chunk Layout v1](flat-chunk-layout-v1/README.md) | `keep.flat-chunks/v1` | Implemented through verified reconstruction in issues #10 and #13 | [Golden corpus](../../conformance/layout/v1/README.md) | | [Durable Segment Store v1](segment-store-v1/README.md) | `keep.segment-store/v1` | Implemented through initialization, publication, restart, and recovery in issues #14–#17 | [Golden corpus](../../conformance/segment-store/v1/README.md) | -| [Durable Segment Store v2](segment-store-v2/README.md) | `keep.segment-store/v2` | One-way migration, version-two reopen, and forward retention publication implemented; retention recovery, reader fencing, and collection planned in issue #19 | [Golden corpus](../../conformance/segment-store/v2/README.md) | +| [Durable Segment Store v2](segment-store-v2/README.md) | `keep.segment-store/v2` | One-way migration, version-two reopen, retention publication and recovery, reader fencing, explicit disposition, and GC retirement implemented; compaction planned in issue #21 | [Golden corpus](../../conformance/segment-store/v2/README.md) | The registry records protocol specifications, including formats whose implementation is still planned. Each format page states its exact proof diff --git a/docs/formats/segment-store-v2/README.md b/docs/formats/segment-store-v2/README.md index 471b9fca..e4043cbf 100644 --- a/docs/formats/segment-store-v2/README.md +++ b/docs/formats/segment-store-v2/README.md @@ -50,6 +50,8 @@ The following pages form one protocol: ingress proof and its exact refusal evidence. - [GC and disposition records](gc.md) owns the canonical planned intent, completion, and recovery-disposition byte grammars. +- [GC execution and recovery](gc-execution.md) owns the retirement phases, + the residue state table, and `KEEP-CRASH-074` through `087`. - [Migration and recovery](recovery.md) owns the exact root namespace, version marker, reader fence, migration records, GC reservation, recovery-disposition reservation, and restart behavior. @@ -103,7 +105,9 @@ transition sequence agrees with a deterministic namespace-to-anchor-set model. Migration recovery is proven in-process for every prefix and by the `KEEP-CRASH-053..073` process-death matrix, which kills a real writer at each of its 68 boundary coordinates and recovers the restarted root. -Not implemented: garbage collection, issue #21. +GC retirement is proven in-process for every prefix and by the +`KEEP-CRASH-074..087` process-death matrix; identity-preserving compaction +is not implemented, issue #21. Reopen compares only the restart-stable root coordinates, device and inode, against the intent; see [root identity across restart](recovery.md#root-identity-across-restart). A diff --git a/docs/formats/segment-store-v2/gc-execution.md b/docs/formats/segment-store-v2/gc-execution.md new file mode 100644 index 00000000..bcd338f9 --- /dev/null +++ b/docs/formats/segment-store-v2/gc-execution.md @@ -0,0 +1,120 @@ +# GC Execution and Recovery + +This page owns the protocol that retires released segments from a +`keep.segment-store/v2` root: the fixed phases, the residue each phase leaves, +the one lawful recovery of each residue, and the process-death matrix that +proves it. The record grammars and the planner are owned by the +[GC specification](gc.md); the design contract is ADR-0009. + +> **Warning.** Execution unlinks immutable segments. It holds writer +> authority and the exclusive reader fence, writes and synchronizes +> `gc/intent` before the first unlink, acts only on a plan it has re-proven +> against the reopened store, and is verified afterwards by `recover`. +> `plan_gc` is the dry run: it changes nothing on disk. + +## Authority and re-proof + +`FilesystemGcAuthority::open` pins one admitted version-two root under the +writer lock. `prepare(&GcPlan)` reads `gc` and refuses unless the residue is +idle or complete; refuses a plan with no candidate (nothing to do is not an +intent); acquires `reader.lock` exclusively without waiting, refusing +`ReadersActive` while any reader holds the shared fence; re-observes liveness +under that fence and requires `plan_gc` over the reopened store to equal the +plan exactly, refusing `PlanStale` otherwise; then derives the one canonical +intent. Its generation succeeds the prior receipt's or is one; each candidate +carries the digest of the durable record that released it (the predecessor +catalog or the exact disposition receipt); the catalog-successor proof, pool +identity, and disposition-set digests are the derivations registered in +`definition.tsv` as `keep.gc-catalog-successor-proof/v2`, +`keep.gc-segment-pool/v2`, and `keep.gc-disposition-set/v2`. `execute` is +`prepare` followed by every phase. While `gc/intent` is durable, retention +publication refuses `GcIntentRetained` and another retirement refuses +`RecoveryRequired`. + +## Phases + +`GcExecutionPhase::ALL` fixes the order; `GcExecutionStorage` is the +blocking port, one capability per phase, and `execute_gc` and +`resume_gc_execution` drive it from the start or from any point. + + + +| Identifier | Phase | Effect | +| --- | --- | --- | +| `KEEP-CRASH-074` | `write-intent-stage` | exclusively create `gc/intent.next` with the complete intent | +| `KEEP-CRASH-075` | `sync-intent-stage` | synchronize and reverify the stage | +| `KEEP-CRASH-076` | `link-intent` | link the stage to `gc/intent` without replacement | +| `KEEP-CRASH-077` | `sync-gc-after-intent` | synchronize `gc`; the intent is now durable | +| `KEEP-CRASH-078` | `remove-intent-stage` | remove `gc/intent.next` after proving its link | +| `KEEP-CRASH-079` | `sync-gc-after-intent-cleanup` | synchronize `gc` | +| `KEEP-CRASH-080` | `unlink-candidate` | reopen one candidate without following links, verify kind, length, and admitted digest, unlink it (once per candidate, canonical order) | +| `KEEP-CRASH-081` | `sync-segment-pool` | synchronize `segments` (once per candidate) | +| `KEEP-CRASH-082` | `write-receipt-stage` | prove every candidate absent, read the pool, create `gc/receipt.next` with the receipt over the exact remaining inventory | +| `KEEP-CRASH-083` | `sync-receipt-stage` | synchronize and reverify the stage | +| `KEEP-CRASH-084` | `replace-receipt` | rename the stage onto `gc/receipt`, replacing the prior retirement's receipt atomically | +| `KEEP-CRASH-085` | `sync-gc-after-receipt` | synchronize `gc` | +| `KEEP-CRASH-086` | `remove-intent` | remove `gc/intent` after proving `gc/receipt` completes it | +| `KEEP-CRASH-087` | `sync-gc-after-intent-removal` | synchronize `gc`; the retirement is complete | + + + +The receipt replaces by rename, as `retention/HEAD` does, so `gc/receipt` +always holds the last completed retirement and the next intent's generation +is its successor. The receipt's synchronization count is one per candidate. + +## State and recovery + +`GcResidue` is what restart reads: every `gc` record as it is, bounded one +byte past its maximum length, and the presence of each candidate the durable +intent names. `plan_gc_recovery` is pure; `FilesystemGcAuthority::recover` +reads the residue, plans, and acts. + + + +| State | Evidence | Recovery | +| --- | --- | --- | +| idle | no `gc/intent`, `gc/receipt`, or stage | nothing; no retirement authority | +| complete | exact `gc/receipt` only | return the receipt | +| staged | complete `gc/intent.next`, no `gc/intent` | resume at `sync-intent-stage` | +| linked | `gc/intent` and byte-equal `gc/intent.next` | resume at `sync-gc-after-intent` | +| active | exact intent, every candidate present | resume at `sync-gc-after-intent-cleanup` | +| partial | exact intent, one canonical absent candidate prefix | resume at `unlink-candidate` for the first present candidate | +| completion pending | exact intent, every candidate absent, no receipt for it | resume at `write-receipt-stage` | +| receipt staged | completion pending beside a receipt stage that completes the intent | resume at `sync-receipt-stage` | +| receipt transition | exact intent and the receipt that completes it | resume at `sync-gc-after-receipt` | +| truncated stage | an intent stage before any authority, or a receipt stage after completion pending, shorter than its record | discard the stage, synchronize `gc`, replan | + + + +A `gc/receipt` whose generation the durable intent succeeds is the prior +retirement's and constrains nothing. Every other residue is a typed +`GcRecoveryAmbiguity` and nothing is touched: an undecodable intent, a +receipt that neither completes nor precedes the intent, a receipt stage +without an intent, a stage holding other bytes or longer than its record, an +absent candidate after a present one, or a receipt or receipt stage while a +candidate is still present. Recovery never guesses which deletion occurred. + +## Process-death matrix + +`cargo xtask durability-crash-matrix --sequence gc` runs 42 cases: the 14 +boundaries above at before, during, and after positions, over a migrated +bundle store with retention generation one published and the one-zero +segment added as an orphan pool entry released by its exact retire +disposition. An isolated child plans, prepares, and executes with the +selected boundary gated and is killed by its process group. The parent then +requires the live bundle segment byte-identical, reopens the root for +recovery the way a restarted writer would, requires the production planner to +report exactly the row above (a truncated stage first plans its discard), +runs the recovery (or the forward retirement after an idle residue), and +requires one complete retirement: `gc` holds only the receipt, the orphan is +absent, the live segment is intact, a fresh plan names nothing and reports +the orphan already retired, and a second recovery reports `Complete`. The +[transitions ledger](../../../conformance/segment-store/v2/transitions.tsv) +records rows `KEEP-CRASH-074` through `-087`. In-process, the same law runs +over every prefix of the 14 points and both truncated stages in +`src/adapters/gc/filesystem_gc_tests.rs`. The matrix proves application +process death; host power loss remains outside its claim. + +Not implemented: identity-preserving compaction (`named-unreachable` +material can only be released by a compaction successor), background +scheduling, and secure erasure; issue #21. diff --git a/docs/formats/segment-store-v2/gc.md b/docs/formats/segment-store-v2/gc.md index f216d1e2..d0839c35 100644 --- a/docs/formats/segment-store-v2/gc.md +++ b/docs/formats/segment-store-v2/gc.md @@ -4,23 +4,21 @@ This page owns the canonical planned `GcRetirementIntent`, `GcRetirementReceipt`, and `RecoveryDispositionReceipt` byte grammars for `keep.segment-store/v2`. -Issue #21 owns their implementation. They are specified now so version 2 has -one exact root grammar, but their presence remains unsupported mandatory state -until every **Planned in #21** requirement becomes executable evidence. - -Implemented: all three codecs. `GcRetirementIntent` admits a canonical -candidate set over its coordinates; `CanonicalGcRetirementIntent` and -`AdmittedGcRetirementIntent` reproduce and admit the frozen -`one-candidate-gc-intent.hex`; `CanonicalGcRetirementReceipt` and -`AdmittedGcRetirementReceipt` bind a receipt to its admitted intent. The -receipt's synchronization count is exactly one per candidate: the -pool-directory synchronization that follows each unlink. -`CanonicalRecoveryDispositionReceipt` and +Implemented: all three codecs, the planner, explicit disposition, and +retirement. `GcRetirementIntent` admits a canonical candidate set over its +coordinates; `CanonicalGcRetirementIntent` and `AdmittedGcRetirementIntent` +reproduce and admit the frozen `one-candidate-gc-intent.hex`; +`CanonicalGcRetirementReceipt` and `AdmittedGcRetirementReceipt` bind a +receipt to its admitted intent, and `decode_unbound` reads one without an +intent. `CanonicalRecoveryDispositionReceipt` and `AdmittedRecoveryDispositionReceipt` reproduce and admit the frozen -`one-orphan-retire-disposition.hex` over the artifact-kind, decision, and -classification enumerations registered in `definition.tsv`. Namespace -admission still refuses every one of these records on disk: no execution, -retirement, or disposition protocol writes them yet. +`one-orphan-retire-disposition.hex` over the enumerations registered in +`definition.tsv`. The protocol that writes `gc/intent` and `gc/receipt`, its +recovery, and its process-death matrix are owned by +[GC execution and recovery](gc-execution.md); explicit disposition by +[recovery](recovery.md#explicit-disposition-of-protected-orphans). Namespace +admission admits exactly those records as regular files and nothing else in +`gc`. Identity-preserving compaction remains **Planned in #21**. ## Common rules @@ -206,10 +204,10 @@ bounded physical inventory. It reads nothing and writes nothing. `FilesystemRetentionSnapshot`: it re-admits the fenced catalog, projects every retained root's verified closure onto the segments that hold its records, reads and admits every entry of the segment pool within the -`CatalogRestartPolicy` byte bound, and walks the catalog predecessor chain to -find segments a durably published successor superseded. No -`RecoveryDispositionReceipt` codec exists yet, so nothing is reported -disposed. +`CatalogRestartPolicy` byte bound, walks the catalog predecessor chain to +find segments a durably published successor superseded, and admits every +exact disposition receipt. Each released segment carries the digest of the +record that released it: the predecessor catalog or the receipt. The plan classifies every inventoried segment exactly once, in this order: @@ -241,35 +239,14 @@ classification, every ambiguity, the limit, the golden plan, and a 512-universe model in which the live set is always exactly the union of the retained closures and no live or named segment is ever a candidate. -> **Warning.** Everything below this line describes execution, which -> unlinks immutable segments. Execution is not implemented. When it is, it -> must hold writer authority and the exclusive reader lock, write and -> synchronize `gc/intent` before the first unlink, act only on a plan whose -> coordinates it has re-proven against the reopened store, and be verified -> afterwards by a recovery report. `plan_gc` is the dry run: it changes -> nothing on disk. +> **Warning.** Execution unlinks immutable segments. It is specified and +> proven on [GC execution and recovery](gc-execution.md): writer authority, +> the exclusive reader lock, a durable intent before the first unlink, a +> re-proven plan, and a recovery report afterwards. Recovery admits one +> canonical absent candidate prefix and treats every other residue as +> unrecoverable ambiguity. `plan_gc` is the dry run. -## State and recovery - -GC admits these states: - - - -| State | Evidence | Recovery | -| --- | --- | --- | -| idle | no `gc/intent` or `gc/receipt` | no retirement authority | -| active | exact intent, every candidate present | begin execution | -| partial | exact intent, one canonical absent candidate prefix | continue at first present candidate | -| completion pending | exact intent, every candidate absent | publish receipt | -| receipt transition | exact intent and exact receipt | synchronize receipt, remove intent, synchronize `gc` | -| complete | exact receipt only | return exact completion | - - - -An absent candidate outside the canonical absent candidate prefix, substituted -candidate, changed pool, stale coordinate, conflicting receipt, malformed -record, or unexplained absence is unrecoverable ambiguity. Recovery never -guesses which deletion occurred. +## Disposition transition A disposition transition writes and synchronizes `recovery/disposition.next`, verifies and links the immutable receipt without @@ -277,8 +254,7 @@ replacement, synchronizes `recovery/dispositions`, removes the stage, and synchronizes `recovery`. Until that completes, the artifact remains recovery-protected. -The intent and receipt grammars have golden fixtures, parsers, corruption -matrices, and a seeded fuzz target; the planner has its golden plan and model -law. The disposition grammar's fixture and parser, and every crash point, -benchmark, execution, and recovery law, are **Planned in #21**. Namespace -admission must refuse their physical presence without mutating it. +All three grammars have golden fixtures, parsers, corruption matrices, and a +seeded fuzz target; the planner has its golden plan and model law; retirement +and disposition have their in-process prefix laws and the process-death +matrix. Compaction and its benchmark evidence are **Planned in #21**. diff --git a/docs/formats/segment-store-v2/recovery.md b/docs/formats/segment-store-v2/recovery.md index bfc57970..d77fbf06 100644 --- a/docs/formats/segment-store-v2/recovery.md +++ b/docs/formats/segment-store-v2/recovery.md @@ -75,11 +75,9 @@ before opening catalog `HEAD` or `retention/HEAD`. The returned `ReaderFence` owns that lock for the complete snapshot lifetime. Close, drop, or process death releases only the kernel lock and never deletes the persistent file. -GC acquires the store writer authority and then an exclusive `reader.lock`, in -that fixed order. New readers wait and existing readers drain before GC -revalidation or physical deletion. Catalog and retention publication may -proceed beside readers because they publish immutable successors and delete no -published segment. +GC takes writer authority and then `reader.lock` exclusively, refusing +without waiting while readers hold it. Catalog and retention publication +proceed beside readers because they publish immutable successors. ## Migration records @@ -205,9 +203,8 @@ recovery instead. Direct version-2 initialization is undefined. The exact offsets and fixtures are requirement `KEEP-MIGRATION-002`. The fresh writer emits only those canonical records; success is not restart evidence. A migrated store is admitted for forward publication, and an interrupted -migration resumes from any prefix through -[partial migration recovery](migration-recovery.md), proven both in-process -and by the `KEEP-MIGRATION-007` process-death matrix. +migration resumes from any prefix through [partial migration +recovery](migration-recovery.md), proven in-process and by `KEEP-MIGRATION-007`. ## Retention publication recovery @@ -268,45 +265,36 @@ classification above and its effects, and the crash matrix kills a real writer before, during, and after every point and requires restart to recover to the documented state. -Each point requires before, during, and after process-death evidence. Restart -must establish exact catalog visibility, retention head, namespace generation, -orphan classification, stage disposition, and recovery report. +Restart must establish exact catalog visibility, retention head, namespace +generation, orphan classification, stage disposition, and recovery report. ## Explicit disposition of protected orphans A complete stage that recovery linked into its pool but that no head ever committed is a recovery-protected orphan: publication refuses with -`RetainedStage` until a person or an explicit policy decides. The decision is -`FilesystemRetentionPublicationAuthority::dispose`, which takes writer -authority, runs recovery, refuses while any reader holds the fence, acquires -the fence exclusively, and records a `RecoveryDispositionReceipt` through the -fixed-stage protocol: write and synchronize `recovery/disposition.next`, link -it without replacement to `recovery/dispositions/.receipt`, -synchronize `recovery/dispositions`, remove the stage, and synchronize -`recovery`. Only then is the retained retention stage removed and `retention` -synchronized. Process death anywhere leaves either a recoverable stage or a -durable decision; the next `dispose` with the same request resumes from that -residue, and a residue naming another decision is a typed ambiguity. +`RetainedStage` until a person or an explicit policy decides. +`FilesystemRetentionPublicationAuthority::dispose` takes writer authority, +runs recovery, refuses while any reader holds the fence, acquires the fence +exclusively, and records a `RecoveryDispositionReceipt` through the +fixed-stage protocol (`recovery/disposition.next`, link without replacement +to `recovery/dispositions/.receipt`, synchronize, remove the +stage, synchronize `recovery`); only then is the retained retention stage +removed and `retention` synchronized. Process death anywhere leaves a +recoverable stage or a durable decision; the next `dispose` with the same +request resumes from it, and a residue naming another decision is a typed +ambiguity. > **Warning.** Disposition changes what the store will keep. `Retire` unlinks -> the orphan's immutable pool entry after the receipt is durable, because an -> absent retention head admits no pool artifact and no collector exists for -> the retention pools; the bytes are gone and only the receipt records why. -> `Finalize` keeps the pool entry as a durable immutable artifact a -> byte-identical publication may reuse, and is refused while no retention head -> is published, since there is nothing to finalize into. Both require writer -> authority and the exclusive reader fence, both remove the retained stage so -> publication may proceed, and a manifest stage must be disposed before the -> root stage it names. The dry run is `plan_recovery_disposition` over the -> recovery plan; verify the result with `recover`, which reports `Clean`. +> the orphan's immutable pool entry after the receipt is durable; the bytes +> are gone and only the receipt records why. `Finalize` keeps the entry as a +> durable artifact a byte-identical publication may reuse and is refused +> while no retention head is published. A manifest stage must be disposed +> before the root it names. The dry run is `plan_recovery_disposition`; +> verify the result with `recover`, which reports `Clean`. Every receipt is admitted by namespace census as a regular file under its canonical name. GC planning admits only the exact receipt: a `segment` -artifact retired under exactly the coordinates of the snapshot being planned -releases that segment; a receipt decided under other coordinates is stale and -keeps its material protected. - -`GcRetirementIntent` and `GcRetirementReceipt` are owned by the -[GC specification](gc.md). Their codecs exist; until issue #21 implements the -protocol that writes them, any such artifact on disk is unsupported and -refuses. +artifact retired under exactly the planned snapshot's coordinates is +released; any other receipt is stale. `GcRetirementIntent` and +`GcRetirementReceipt` are owned by [GC](gc.md) and written by +[GC execution](gc-execution.md). diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index 81d8a9db..0801ae17 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -46,7 +46,7 @@ case is not evidence. | ID | Requirement | Evidence | Status | | --- | --- | --- | --- | | `KEEP-GC-001` | Version 2 specifies exact bounded GC intent, receipt, and recovery-disposition grammars but refuses their presence until their parser and recovery protocol are implemented | intent and receipt golden, canonical re-encoding, cross-record binding, and field-by-field corruption laws in `tests/gc_retirement_intent.rs`, `tests/gc_retirement_intent/mutation_laws.rs`, and `tests/gc_retirement_receipt.rs`; disposition golden, canonical re-encoding, registered-enumeration agreement with `definition.tsv`, unregistered-code refusal, and field-by-field corruption laws in `tests/recovery_disposition_receipt.rs`; seeded `gc_format` fuzz target over all three records; presence refusal in namespace admission tests | Implemented | -| `KEEP-GC-002` | GC intent, receipt, disposition, reader-fence, retirement, compaction, and recovery laws implement ADR-0009 without changing logical identity | deterministic planning: `plan_gc` classifies every inventoried segment against one fenced liveness snapshot, refuses every contradiction, and never names a live or catalog-named segment, proven by one law per classification and ambiguity, the golden `gc-plan.tsv`, a 512-universe model, and filesystem laws over the migrated fixture store in `src/adapters/gc/`; explicit disposition of recovery-protected retention orphans in `filesystem_retention_disposition_tests` (retire unlinks under an absent head, finalize needs a published head, manifest before root, readers refuse, every residue resumes, receipts admitted by census) and exact-receipt admission by GC planning in `liveness_observation_tests`; execution, compaction, and recovery remain golden-format, model-based, corruption, crash-injection, benchmark, and fuzz evidence Planned in #21 | In progress in #21 | +| `KEEP-GC-002` | GC intent, receipt, disposition, reader-fence, retirement, compaction, and recovery laws implement ADR-0009 without changing logical identity | deterministic planning: `plan_gc` classifies every inventoried segment against one fenced liveness snapshot, refuses every contradiction, and never names a live or catalog-named segment, proven by one law per classification and ambiguity, the golden `gc-plan.tsv`, a 512-universe model, and filesystem laws over the migrated fixture store in `src/adapters/gc/`; explicit disposition of recovery-protected retention orphans in `filesystem_retention_disposition_tests` (retire unlinks under an absent head, finalize needs a published head, manifest before root, readers refuse, every residue resumes, receipts admitted by census) and exact-receipt admission by GC planning in `liveness_observation_tests`; retirement and recovery: `FilesystemGcAuthority` re-proves the plan under writer authority and the exclusive fence, derives the intent with the registered proof, pool, and disposition-set digests, runs the 14 fixed phases through `GcExecutionStorage`, and `plan_gc_recovery` classifies every residue (retire, second generation, nothing-to-retire, stale plan, readers, every interrupted prefix, both truncated stages, intent exclusion of retention publication, ambiguity, admission) in `filesystem_gc_tests`, with the `KEEP-CRASH-074..=087` process-death matrix of 42 killed-writer cases in `cargo xtask durability-crash-matrix --sequence gc`; compaction and its golden-format, model-based, corruption, crash-injection, benchmark, and fuzz evidence remain Planned in #21 | In progress in #21 | diff --git a/src/adapters/filesystem_initialization_namespace.rs b/src/adapters/filesystem_initialization_namespace.rs index eb377be7..f7e9d19e 100644 --- a/src/adapters/filesystem_initialization_namespace.rs +++ b/src/adapters/filesystem_initialization_namespace.rs @@ -118,8 +118,9 @@ pub(super) fn admit_version_two(directory: &Dir) -> io::Result<()> { /// Admits the nested version-2 protocol directories the migration writer left. /// /// `retention` must carry both immutable pools (its head and stages belong to -/// retention publication); `gc` must be empty until GC execution writes its -/// records; `recovery` holds `dispositions` and at most a retained +/// retention publication); `gc` holds at most the regular files GC execution +/// writes (`intent.next`, `intent`, `receipt.next`, `receipt`), which GC +/// recovery classifies; `recovery` holds `dispositions` and at most a retained /// `disposition.next` stage, and `dispositions` holds only regular files /// named `.receipt`. Migration leaves the same shape with /// empty pools, so a root that drifted after migration refuses here rather @@ -129,7 +130,8 @@ fn admit_version_two_protocol_directories(directory: &Dir) -> io::Result<()> { admit_required_directory(&retention, ROOTS_NAME)?; admit_required_directory(&retention, MANIFESTS_NAME)?; let gc = directory.open_dir_nofollow(GC_NAME)?; - admit_membership(&gc, &[])?; + admit_membership(&gc, &crate::adapters::gc::GC_ENTRY_NAMES)?; + admit_regular_entries(&gc)?; let recovery = directory.open_dir_nofollow(RECOVERY_NAME)?; admit_required_directory(&recovery, DISPOSITIONS_NAME)?; admit_optional_file(&recovery, DISPOSITION_STAGE_NAME)?; @@ -153,6 +155,17 @@ fn admit_disposition_receipts(dispositions: &Dir) -> io::Result<()> { Ok(()) } +/// Every present entry must be a regular file; the names were admitted by +/// membership. +fn admit_regular_entries(directory: &Dir) -> io::Result<()> { + for entry in directory.entries()? { + if !entry?.file_type()?.is_file() { + return Err(ambiguous_namespace()); + } + } + Ok(()) +} + fn admit_optional_file(directory: &Dir, name: &str) -> io::Result<()> { admit_optional_kind(directory, name, cap_std::fs::FileType::is_file) } diff --git a/src/adapters/gc/admitted_receipt.rs b/src/adapters/gc/admitted_receipt.rs index 4a093d5f..b28a7395 100644 --- a/src/adapters/gc/admitted_receipt.rs +++ b/src/adapters/gc/admitted_receipt.rs @@ -31,6 +31,23 @@ impl<'encoded> AdmittedGcRetirementReceipt<'encoded> { receipt_decoder::decode(encoded, intent) } + /// Decodes one receipt's framing, checksum, and fields without an intent + /// to bind it to. + /// + /// The result proves nothing about which intent the receipt completed; + /// it is the prior retirement's receipt a new execution succeeds, or the + /// completion a restart reports over an idle `gc`. + /// + /// # Errors + /// + /// Returns [`GcRetirementReceiptDecodeError`] for invalid framing, + /// integrity, or a zero generation. + pub fn decode_unbound( + encoded: &[u8], + ) -> Result { + receipt_decoder::decode_unbound(encoded) + } + /// Returns the exact borrowed canonical bytes. #[must_use] pub const fn encoded(&self) -> &'encoded [u8] { diff --git a/src/adapters/gc/execution.rs b/src/adapters/gc/execution.rs new file mode 100644 index 00000000..d78d6ba8 --- /dev/null +++ b/src/adapters/gc/execution.rs @@ -0,0 +1,153 @@ +//! This boundary module owns ordered GC execution from any resumption point. + +use std::error::Error; +use std::fmt; +use std::io; + +use super::{GcExecutionPhase, GcExecutionPoint, GcExecutionStorage}; + +/// What one execution run executed. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GcExecutionReceipt { + executed: Vec, +} + +impl GcExecutionReceipt { + /// The points executed, in order; empty when the residue was complete. + #[must_use] + pub fn executed(&self) -> &[GcExecutionPoint] { + &self.executed + } +} + +/// An execution point that refused, with the points completed before it. +#[derive(Debug)] +pub struct GcExecutionError { + point: GcExecutionPoint, + executed: Vec, + source: io::Error, +} + +impl GcExecutionError { + /// The refused point. + #[must_use] + pub const fn point(&self) -> GcExecutionPoint { + self.point + } + + /// The points completed before the refusal. + #[must_use] + pub fn executed(&self) -> &[GcExecutionPoint] { + &self.executed + } +} + +impl fmt::Display for GcExecutionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "{} refused for candidate {} after {} completed points", + self.point.phase, + self.point.candidate, + self.executed.len() + ) + } +} + +impl Error for GcExecutionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(&self.source) + } +} + +/// Expands the phase order for `candidate_count` candidates into points. +fn points(candidate_count: usize) -> Vec { + let mut points = Vec::new(); + for phase in GcExecutionPhase::ALL { + if phase == GcExecutionPhase::UnlinkCandidate { + for candidate in 0..candidate_count { + points.push(GcExecutionPoint { + phase: GcExecutionPhase::UnlinkCandidate, + candidate, + }); + points.push(GcExecutionPoint { + phase: GcExecutionPhase::SynchronizeSegmentPool, + candidate, + }); + } + } else if phase != GcExecutionPhase::SynchronizeSegmentPool { + points.push(GcExecutionPoint::at(phase)); + } + } + points +} + +/// Executes every point from `from` onwards for `candidate_count` +/// candidates, in order. +/// +/// A refused point leaves the completed points' effects in place and names +/// itself; the caller re-observes the residue and resumes rather than +/// continuing from stale evidence. +/// +/// # Errors +/// +/// Returns [`GcExecutionError`] with the refused point, the points completed +/// before it, and the storage's own error as source. +pub fn resume_gc_execution( + storage: &mut S, + candidate_count: usize, + from: GcExecutionPoint, +) -> Result { + let all = points(candidate_count); + let start = all + .iter() + .position(|point| *point == from) + .unwrap_or(all.len()); + let mut executed = Vec::new(); + for point in all.into_iter().skip(start) { + let result = match point.phase { + GcExecutionPhase::WriteIntentStage => storage.write_intent_stage(), + GcExecutionPhase::SynchronizeIntentStage => storage.synchronize_intent_stage(), + GcExecutionPhase::LinkIntent => storage.link_intent(), + GcExecutionPhase::SynchronizeGcAfterIntent => storage.synchronize_gc_after_intent(), + GcExecutionPhase::RemoveIntentStage => storage.remove_intent_stage(), + GcExecutionPhase::SynchronizeGcAfterIntentCleanup => { + storage.synchronize_gc_after_intent_cleanup() + } + GcExecutionPhase::UnlinkCandidate => storage.unlink_candidate(point.candidate), + GcExecutionPhase::SynchronizeSegmentPool => { + storage.synchronize_segment_pool(point.candidate) + } + GcExecutionPhase::WriteReceiptStage => storage.write_receipt_stage(), + GcExecutionPhase::SynchronizeReceiptStage => storage.synchronize_receipt_stage(), + GcExecutionPhase::ReplaceReceipt => storage.replace_receipt(), + GcExecutionPhase::SynchronizeGcAfterReceipt => storage.synchronize_gc_after_receipt(), + GcExecutionPhase::RemoveIntent => storage.remove_intent(), + GcExecutionPhase::SynchronizeGcAfterIntentRemoval => { + storage.synchronize_gc_after_intent_removal() + } + }; + if let Err(source) = result { + return Err(GcExecutionError { + point, + executed, + source, + }); + } + executed.push(point); + } + Ok(GcExecutionReceipt { executed }) +} + +/// Executes a fresh retirement of `candidate_count` candidates. +/// +/// # Errors +/// +/// As [`resume_gc_execution`]. +pub fn execute_gc( + storage: &mut S, + candidate_count: usize, +) -> Result { + resume_gc_execution(storage, candidate_count, GcExecutionPoint::START) +} diff --git a/src/adapters/gc/execution_phase.rs b/src/adapters/gc/execution_phase.rs new file mode 100644 index 00000000..b23622ed --- /dev/null +++ b/src/adapters/gc/execution_phase.rs @@ -0,0 +1,130 @@ +//! This boundary module owns the ordered durable phases of one GC +//! retirement execution, `KEEP-CRASH-074` through `KEEP-CRASH-087`. + +use std::fmt; + +/// One durable transition of GC execution, in protocol order. +/// +/// The intent travels the fixed-stage protocol into `gc/intent`; every +/// candidate is then unlinked in canonical digest order with a segment-pool +/// synchronization after each unlink; the receipt is staged, synchronized, +/// and renamed onto `gc/receipt` (replacing the prior retirement's receipt +/// atomically, as `retention/HEAD` is replaced); finally the completed +/// intent is removed and `gc` synchronized. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GcExecutionPhase { + /// Write the complete canonical intent to `gc/intent.next`. + WriteIntentStage, + /// Synchronize `gc/intent.next`. + SynchronizeIntentStage, + /// Link the stage to `gc/intent` without replacement. + LinkIntent, + /// Synchronize `gc` after the intent link. + SynchronizeGcAfterIntent, + /// Remove the retained `gc/intent.next`. + RemoveIntentStage, + /// Synchronize `gc` after intent-stage cleanup. + SynchronizeGcAfterIntentCleanup, + /// Reopen, verify, and unlink one candidate (one occurrence per candidate). + UnlinkCandidate, + /// Synchronize `segments` after one unlink (one occurrence per candidate). + SynchronizeSegmentPool, + /// Write the complete canonical receipt to `gc/receipt.next`. + WriteReceiptStage, + /// Synchronize `gc/receipt.next`. + SynchronizeReceiptStage, + /// Rename the stage onto `gc/receipt`, replacing any prior receipt. + ReplaceReceipt, + /// Synchronize `gc` after the receipt replacement. + SynchronizeGcAfterReceipt, + /// Remove the completed `gc/intent`. + RemoveIntent, + /// Synchronize `gc` after the intent removal. + SynchronizeGcAfterIntentRemoval, +} + +impl GcExecutionPhase { + /// Every phase in execution order. + pub const ALL: [Self; 14] = [ + Self::WriteIntentStage, + Self::SynchronizeIntentStage, + Self::LinkIntent, + Self::SynchronizeGcAfterIntent, + Self::RemoveIntentStage, + Self::SynchronizeGcAfterIntentCleanup, + Self::UnlinkCandidate, + Self::SynchronizeSegmentPool, + Self::WriteReceiptStage, + Self::SynchronizeReceiptStage, + Self::ReplaceReceipt, + Self::SynchronizeGcAfterReceipt, + Self::RemoveIntent, + Self::SynchronizeGcAfterIntentRemoval, + ]; + + /// Whether the phase occurs once per candidate. + #[must_use] + pub const fn per_candidate(self) -> bool { + matches!(self, Self::UnlinkCandidate | Self::SynchronizeSegmentPool) + } + + /// The stable `transitions.tsv` operation name. + #[must_use] + pub const fn operation(self) -> &'static str { + match self { + Self::WriteIntentStage => "write-intent-stage", + Self::SynchronizeIntentStage => "sync-intent-stage", + Self::LinkIntent => "link-intent", + Self::SynchronizeGcAfterIntent => "sync-gc-after-intent", + Self::RemoveIntentStage => "remove-intent-stage", + Self::SynchronizeGcAfterIntentCleanup => "sync-gc-after-intent-cleanup", + Self::UnlinkCandidate => "unlink-candidate", + Self::SynchronizeSegmentPool => "sync-segment-pool", + Self::WriteReceiptStage => "write-receipt-stage", + Self::SynchronizeReceiptStage => "sync-receipt-stage", + Self::ReplaceReceipt => "replace-receipt", + Self::SynchronizeGcAfterReceipt => "sync-gc-after-receipt", + Self::RemoveIntent => "remove-intent", + Self::SynchronizeGcAfterIntentRemoval => "sync-gc-after-intent-removal", + } + } +} + +impl fmt::Display for GcExecutionPhase { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.operation()) + } +} + +/// One phase at one candidate index: the unit execution resumes from. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GcExecutionPoint { + /// The phase. + pub phase: GcExecutionPhase, + /// The candidate index for a per-candidate phase; zero otherwise. + pub candidate: usize, +} + +impl GcExecutionPoint { + /// The first point of a fresh execution. + pub const START: Self = Self::at(GcExecutionPhase::WriteIntentStage); + + /// The point at a phase that is not per candidate. + #[must_use] + pub const fn at(phase: GcExecutionPhase) -> Self { + Self { + phase, + candidate: 0, + } + } +} + +impl fmt::Display for GcExecutionPoint { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.phase.per_candidate() { + write!(formatter, "{}[{}]", self.phase, self.candidate) + } else { + fmt::Display::fmt(&self.phase, formatter) + } + } +} diff --git a/src/adapters/gc/execution_storage.rs b/src/adapters/gc/execution_storage.rs new file mode 100644 index 00000000..94dd12d4 --- /dev/null +++ b/src/adapters/gc/execution_storage.rs @@ -0,0 +1,50 @@ +//! This boundary module owns the blocking storage capability port GC +//! execution drives, one capability per phase. + +use std::io; + +/// Durable capabilities GC execution calls in [`GcExecutionPhase::ALL`] order. +/// +/// Each owns its complete effect and the synchronization that makes it +/// durable; the intent, receipt, and candidate list belong to the +/// implementation's context. Every method returns the exact filesystem +/// failure or the implementation's typed refusal as an [`io::Error`]. +/// +/// [`GcExecutionPhase::ALL`]: super::GcExecutionPhase::ALL +#[expect( + clippy::missing_errors_doc, + reason = "every capability returns the exact filesystem failure or refusal" +)] +pub trait GcExecutionStorage { + /// Exclusively creates `gc/intent.next` with the complete canonical intent. + fn write_intent_stage(&mut self) -> io::Result<()>; + /// Synchronizes the intent stage and reverifies its bytes. + fn synchronize_intent_stage(&mut self) -> io::Result<()>; + /// Links the intent stage to `gc/intent` without replacement. + fn link_intent(&mut self) -> io::Result<()>; + /// Synchronizes `gc` after the intent link. + fn synchronize_gc_after_intent(&mut self) -> io::Result<()>; + /// Removes the retained intent stage after proving its link. + fn remove_intent_stage(&mut self) -> io::Result<()>; + /// Synchronizes `gc` after intent-stage cleanup. + fn synchronize_gc_after_intent_cleanup(&mut self) -> io::Result<()>; + /// Reopens candidate `index` without following links, verifies its exact + /// identity and length against the intent, and unlinks only that entry. + fn unlink_candidate(&mut self, index: usize) -> io::Result<()>; + /// Synchronizes `segments` after candidate `index` was unlinked. + fn synchronize_segment_pool(&mut self, index: usize) -> io::Result<()>; + /// Exclusively creates `gc/receipt.next` with the complete canonical + /// receipt after proving every candidate absent from the pool. + fn write_receipt_stage(&mut self) -> io::Result<()>; + /// Synchronizes the receipt stage and reverifies its bytes. + fn synchronize_receipt_stage(&mut self) -> io::Result<()>; + /// Renames the receipt stage onto `gc/receipt`, replacing any prior + /// retirement's receipt atomically. + fn replace_receipt(&mut self) -> io::Result<()>; + /// Synchronizes `gc` after the receipt replacement. + fn synchronize_gc_after_receipt(&mut self) -> io::Result<()>; + /// Removes the completed `gc/intent` after proving the receipt completes it. + fn remove_intent(&mut self) -> io::Result<()>; + /// Synchronizes `gc` after the intent removal. + fn synchronize_gc_after_intent_removal(&mut self) -> io::Result<()>; +} diff --git a/src/adapters/gc/filesystem_gc_authority.rs b/src/adapters/gc/filesystem_gc_authority.rs new file mode 100644 index 00000000..e7a9a416 --- /dev/null +++ b/src/adapters/gc/filesystem_gc_authority.rs @@ -0,0 +1,308 @@ +//! This module owns exact writer-locked, reader-fenced filesystem GC +//! execution and recovery over one admitted version-two root. + +use std::path::{Path, PathBuf}; + +use cap_fs_ext::DirExt; +use cap_std::fs::Dir; + +use super::filesystem_gc_error::observe; +use super::filesystem_gc_residue::{self as residue, INTENT_STAGE, RECEIPT_STAGE}; +use super::{ + AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, CanonicalGcRetirementIntent, + CanonicalGcRetirementReceipt, FilesystemGcError as Error, GcExecutionPhase, GcExecutionPoint, + GcFixedStage, GcIntentEvidence, GcLimits, GcPlan, GcRecoveryPlan, GcRetirementReceipt, + ReaderLockIdentity, derive_gc_intent, observe_gc_liveness, plan_gc, plan_gc_recovery, + resume_gc_execution, +}; +use crate::adapters::retention::{FilesystemRetentionStage, ReaderFence}; +use crate::adapters::{ + CatalogRestartPolicy, FilesystemRetentionSnapshot, FilesystemVersionTwoAdmission, + FilesystemWriterLock, ReaderAttemptLimit, filesystem_platform_profile, +}; +use crate::{GcGeneration, RegisteredRetentionProfile}; + +const GC: &str = "gc"; +const SEGMENTS: &str = "segments"; + +/// Everything one retirement holds between phases. +pub(super) struct GcExecutionContext { + pub(super) intent: CanonicalGcRetirementIntent, + pub(super) receipt: Option, + pub(super) intent_stage: Option, + pub(super) receipt_stage: Option, + _fence: ReaderFence, +} + +/// Proof that [`FilesystemGcAuthority::prepare`] bound an intent and the +/// exclusive fence, with the count execution iterates over. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparedGcExecution { + candidate_count: usize, +} + +impl PreparedGcExecution { + /// The number of candidates the bound intent names. + #[must_use] + pub const fn candidate_count(self) -> usize { + self.candidate_count + } +} + +/// What one recovery run found and did. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GcRecoveryReport { + plan: GcRecoveryPlan, + receipt: Option, +} + +impl GcRecoveryReport { + /// The plan the residue admitted. + pub const fn plan(&self) -> GcRecoveryPlan { + self.plan + } + + /// The complete receipt, when the residue was or became complete. + pub const fn receipt(&self) -> Option<&GcRetirementReceipt> { + self.receipt.as_ref() + } +} + +/// Exclusive authority to retire released segments from one pinned root. +/// +/// The authority holds the writer lock for its lifetime and the exclusive +/// reader fence for each retirement. Passed to +/// [`execute_gc`](super::execute_gc) after [`Self::prepare`], its +/// [`GcExecutionStorage`](super::GcExecutionStorage) implementation runs +/// the fixed-stage protocol of `gc.md`; [`Self::execute`] does both. +/// [`Self::recover`] resolves whatever residue a prior run left. +#[must_use] +pub struct FilesystemGcAuthority { + pub(super) root: Dir, + pub(super) gc: Dir, + pub(super) segments: Dir, + pub(super) policy: CatalogRestartPolicy, + pub(super) context: Option, + store_root: PathBuf, + _lock: FilesystemWriterLock, +} + +impl FilesystemGcAuthority { + /// Pins one admitted version-two root for GC. + /// + /// `store_root` must be the path `admission` was reopened from; the + /// liveness view re-admits it on every retirement. The constructor + /// mutates nothing. + /// + /// # Errors + /// + /// Returns [`FilesystemGcError::Observe`](Error::Observe) when a pinned + /// directory cannot be opened. + pub fn open( + admission: FilesystemVersionTwoAdmission, + store_root: &Path, + policy: CatalogRestartPolicy, + ) -> Result { + let (lock, _retention, _roots, _manifests) = admission.into_parts(); + let root = lock.clone_directory().map_err(observe)?; + let gc = root.open_dir_nofollow(GC).map_err(observe)?; + let segments = root.open_dir_nofollow(SEGMENTS).map_err(observe)?; + Ok(Self { + root, + gc, + segments, + policy, + context: None, + store_root: store_root.to_path_buf(), + _lock: lock, + }) + } + + /// Resolves the residue of an interrupted retirement. + /// + /// Idle and complete residue change nothing; a truncated stage written + /// before any authority is discarded; every other lawful residue resumes + /// execution at its documented point under the exclusive fence. Any + /// other residue is a typed ambiguity and nothing is touched. + /// + /// # Errors + /// + /// Returns [`FilesystemGcError`] at the exact observation, ambiguity, + /// fence, or phase refusal. + pub fn recover(&mut self) -> Result { + self.context = None; + let residue = residue::read(&self.gc, &self.segments).map_err(observe)?; + let plan = plan_gc_recovery(&residue).map_err(Error::Ambiguity)?; + let receipt = match plan { + GcRecoveryPlan::Idle => None, + GcRecoveryPlan::Complete => residue + .receipt + .as_deref() + .map(AdmittedGcRetirementReceipt::decode_unbound) + .transpose() + .map_err(|source| observe(residue::invalid_from(source)))?, + GcRecoveryPlan::DiscardStage { stage } => { + let name = match stage { + GcFixedStage::Intent => INTENT_STAGE, + GcFixedStage::Receipt => RECEIPT_STAGE, + }; + residue::discard(&self.gc, name).map_err(observe)?; + None + } + GcRecoveryPlan::Resume { from } => { + let bytes = if from.phase == GcExecutionPhase::SynchronizeIntentStage { + residue.intent_stage.as_deref() + } else { + residue.intent.as_deref() + }; + let admitted = AdmittedGcRetirementIntent::decode( + bytes.ok_or_else(|| observe(residue::invalid("GC intent vanished")))?, + ) + .map_err(|source| observe(residue::invalid_from(source)))?; + let intent = CanonicalGcRetirementIntent::from_intent(admitted.intent()) + .map_err(Error::Encode)?; + let count = admitted.intent().candidates().len(); + Some(self.run(intent, count, from)?.receipt().to_owned()) + } + }; + Ok(GcRecoveryReport { plan, receipt }) + } + + /// Re-proves `plan` against the reopened store under writer authority + /// and the exclusive reader fence, derives its one canonical intent, and + /// binds both for execution. + /// + /// The intent's generation succeeds the prior receipt's, or is one. + /// + /// # Errors + /// + /// Returns [`FilesystemGcError`] when `gc` holds residue, readers hold + /// the fence, the plan names nothing, the re-observed store plans + /// differently, or the intent refuses. + pub fn prepare(&mut self, plan: &GcPlan) -> Result { + self.context = None; + let residue = residue::read(&self.gc, &self.segments).map_err(observe)?; + let generation = match plan_gc_recovery(&residue).map_err(Error::Ambiguity)? { + GcRecoveryPlan::Idle => GcGeneration::new(1).map_err(Error::Generation)?, + GcRecoveryPlan::Complete => prior_generation(residue.receipt.as_deref())? + .successor() + .map_err(Error::Generation)?, + plan => return Err(Error::RecoveryRequired { plan }), + }; + if plan.candidate_count() == 0 { + return Err(Error::NothingToRetire); + } + let fence = acquire_fence(&self.root)?; + let snapshot = self.reobserve()?; + if plan_gc(&snapshot, GcLimits::MAXIMUM).map_err(Error::Plan)? != *plan { + return Err(Error::PlanStale); + } + let intent = derive_gc_intent(plan, &snapshot, self.evidence(generation, &fence)?) + .map_err(Error::Intent)?; + let intent = CanonicalGcRetirementIntent::from_intent(&intent).map_err(Error::Encode)?; + let candidate_count = intent.intent().candidates().len(); + self.context = Some(GcExecutionContext { + intent, + receipt: None, + intent_stage: None, + receipt_stage: None, + _fence: fence, + }); + Ok(PreparedGcExecution { candidate_count }) + } + + /// Prepares and executes one complete retirement of `plan`. + /// + /// # Errors + /// + /// As [`Self::prepare`], then [`FilesystemGcError::Execute`](Error::Execute) + /// at the refused phase; the completed phases' effects remain for + /// [`Self::recover`]. + pub fn execute(&mut self, plan: &GcPlan) -> Result { + let prepared = self.prepare(plan)?; + let context = self.context.take().ok_or(Error::NothingToRetire)?; + let intent = context.intent.clone(); + self.context = Some(context); + self.run(intent, prepared.candidate_count, GcExecutionPoint::START) + } + + /// The intent [`Self::prepare`] bound, until execution completes or + /// the context is cleared. + #[must_use] + pub fn bound_intent(&self) -> Option<&CanonicalGcRetirementIntent> { + self.context.as_ref().map(|context| &context.intent) + } + + /// Returns the receipt of the retirement the authority last completed + /// through an external driver, clearing its context. + pub fn take_receipt(&mut self) -> Option { + self.context.take().and_then(|context| context.receipt) + } + + fn run( + &mut self, + intent: CanonicalGcRetirementIntent, + candidate_count: usize, + from: GcExecutionPoint, + ) -> Result { + if self.context.is_none() { + let fence = acquire_fence(&self.root)?; + self.context = Some(GcExecutionContext { + intent, + receipt: None, + intent_stage: None, + receipt_stage: None, + _fence: fence, + }); + } + let result = resume_gc_execution(self, candidate_count, from).map_err(Error::Execute); + let receipt = self.context.take().and_then(|context| context.receipt); + result.and_then(|_executed| receipt.ok_or(Error::NothingToRetire)) + } + + fn reobserve(&self) -> Result { + let view = FilesystemRetentionSnapshot::load_under_writer_authority( + &self.store_root, + self.policy, + ReaderAttemptLimit::DEFAULT, + ) + .map_err(|source| Error::Snapshot(Box::new(source)))?; + observe_gc_liveness(&self.store_root, &view, self.policy) + .map_err(|source| Error::Liveness(Box::new(source))) + } + + fn evidence( + &self, + generation: GcGeneration, + fence: &ReaderFence, + ) -> Result { + let (device, file) = fence.identity().map_err(observe)?; + let mount = filesystem_platform_profile::root_identity(&self.root) + .map_err(observe)? + .mount(); + Ok(GcIntentEvidence { + generation, + profile: RegisteredRetentionProfile::SINGLE_CANONICAL_WITNESS_V1, + reader_lock: ReaderLockIdentity::new(device, mount, file), + }) + } +} + +fn prior_generation(receipt: Option<&[u8]>) -> Result { + let bytes = receipt.ok_or_else(|| observe(residue::invalid("GC receipt vanished")))?; + AdmittedGcRetirementReceipt::decode_unbound(bytes) + .map(|receipt| receipt.generation()) + .map_err(|source| observe(residue::invalid_from(source))) +} + +fn acquire_fence(root: &Dir) -> Result { + ReaderFence::acquire_exclusive(root).map_err(|source| { + if source.kind() == std::io::ErrorKind::WouldBlock { + Error::ReadersActive + } else { + observe(source) + } + }) +} diff --git a/src/adapters/gc/filesystem_gc_error.rs b/src/adapters/gc/filesystem_gc_error.rs new file mode 100644 index 00000000..398ed48b --- /dev/null +++ b/src/adapters/gc/filesystem_gc_error.rs @@ -0,0 +1,99 @@ +//! This boundary module owns typed refusals of filesystem GC execution and +//! recovery. + +use std::error::Error; +use std::fmt; +use std::io; + +use super::{ + GcExecutionError, GcIntentDerivationError, GcLivenessObservationError, GcPlanError, + GcRecoveryAmbiguity, GcRecoveryPlan, GcRetirementIntentEncodeError, +}; +use crate::GcGenerationError; +use crate::adapters::FilesystemRetentionSnapshotError; + +/// Why filesystem GC refused to execute or recover. +#[derive(Debug)] +pub enum FilesystemGcError { + /// A read, open, or synchronization outside any phase failed. + Observe { + /// The exact failure. + source: io::Error, + }, + /// A reader holds the shared fence; retirement does not wait on it. + ReadersActive, + /// `gc` holds residue that recovery must resolve before a new intent. + RecoveryRequired { + /// What recovery would do with it. + plan: GcRecoveryPlan, + }, + /// The residue admits no lawful recovery. + Ambiguity(GcRecoveryAmbiguity), + /// The store no longer matches the plan: re-observed liveness planned + /// differently, so the plan's evidence is stale. + PlanStale, + /// The plan names no candidate; nothing to do is not an intent. + NothingToRetire, + /// The reopened view refused. + Snapshot(Box), + /// Re-observing liveness refused. + Liveness(Box), + /// Re-planning refused. + Plan(GcPlanError), + /// The plan yielded no intent. + Intent(GcIntentDerivationError), + /// The intent refused to encode. + Encode(GcRetirementIntentEncodeError), + /// The prior receipt's generation has no successor. + Generation(GcGenerationError), + /// A phase refused; the completed phases' effects remain for recovery. + Execute(GcExecutionError), +} + +impl fmt::Display for FilesystemGcError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Observe { .. } => formatter.write_str("GC observation failed"), + Self::ReadersActive => { + formatter.write_str("readers hold the fence; GC refuses to wait") + } + Self::RecoveryRequired { plan } => { + write!(formatter, "gc holds residue requiring recovery: {plan:?}") + } + Self::Ambiguity(source) => write!(formatter, "GC residue is ambiguous: {source}"), + Self::PlanStale => formatter.write_str("the store no longer matches the GC plan"), + Self::NothingToRetire => formatter.write_str("the GC plan names no candidate"), + Self::Snapshot(_) => formatter.write_str("GC could not reopen the store view"), + Self::Liveness(_) => formatter.write_str("GC could not re-observe liveness"), + Self::Plan(source) => write!(formatter, "GC re-planning refused: {source}"), + Self::Intent(source) => write!(formatter, "GC intent derivation refused: {source}"), + Self::Encode(source) => write!(formatter, "GC intent encoding refused: {source}"), + Self::Generation(source) => write!(formatter, "GC generation refused: {source}"), + Self::Execute(source) => write!(formatter, "GC execution refused: {source}"), + } + } +} + +impl Error for FilesystemGcError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Observe { source } => Some(source), + Self::Ambiguity(source) => Some(source), + Self::Snapshot(source) => Some(source.as_ref()), + Self::Liveness(source) => Some(source.as_ref()), + Self::Plan(source) => Some(source), + Self::Intent(source) => Some(source), + Self::Encode(source) => Some(source), + Self::Generation(source) => Some(source), + Self::Execute(source) => Some(source), + Self::ReadersActive + | Self::RecoveryRequired { .. } + | Self::PlanStale + | Self::NothingToRetire => None, + } + } +} + +pub(super) const fn observe(source: io::Error) -> FilesystemGcError { + FilesystemGcError::Observe { source } +} diff --git a/src/adapters/gc/filesystem_gc_residue.rs b/src/adapters/gc/filesystem_gc_residue.rs new file mode 100644 index 00000000..0bda44a9 --- /dev/null +++ b/src/adapters/gc/filesystem_gc_residue.rs @@ -0,0 +1,88 @@ +//! This boundary module owns the exact read of `gc` and the segment pool +//! that restart classifies, and the names GC execution writes. + +use std::io; + +use cap_std::fs::Dir; + +use super::{AdmittedGcRetirementIntent, GcResidue, GcRetirementIntent}; +use crate::adapters::filesystem_exact_record::{self as exact_record, ExactRecordError}; +use crate::adapters::physical_pool_name; + +/// `gc/intent.next`. +pub(super) const INTENT_STAGE: &str = "intent.next"; +/// `gc/intent`. +pub(super) const INTENT: &str = "intent"; +/// `gc/receipt.next`. +pub(super) const RECEIPT_STAGE: &str = "receipt.next"; +/// `gc/receipt`. +pub(super) const RECEIPT: &str = "receipt"; +/// Every entry `gc` may hold. +pub(in crate::adapters) const GC_ENTRY_NAMES: [&str; 4] = + [INTENT_STAGE, INTENT, RECEIPT_STAGE, RECEIPT]; + +const RECEIPT_LENGTH: usize = 320; + +/// Reads every `gc` record as it is, bounded one byte past its maximum +/// length, and the presence of every candidate the durable intent names. +pub(super) fn read(gc: &Dir, segments: &Dir) -> io::Result { + let intent_bound = + super::intent_format::canonical_length(GcRetirementIntent::MAXIMUM_CANDIDATE_COUNT) + .and_then(|length| length.checked_add(1)) + .ok_or_else(|| invalid("GC intent bound overflow"))?; + let receipt_bound = RECEIPT_LENGTH.saturating_add(1); + let intent = read_bounded(gc, INTENT, intent_bound)?; + let candidates_present = match intent.as_deref().map(AdmittedGcRetirementIntent::decode) { + Some(Ok(admitted)) => candidates_present(segments, admitted.intent())?, + Some(Err(_)) | None => Vec::new(), + }; + Ok(GcResidue { + intent_stage: read_bounded(gc, INTENT_STAGE, intent_bound)?, + intent, + receipt_stage: read_bounded(gc, RECEIPT_STAGE, receipt_bound)?, + receipt: read_bounded(gc, RECEIPT, receipt_bound)?, + candidates_present, + }) +} + +/// Whether each candidate's pool entry exists, by name, without following +/// links; execution verifies bytes, restart only counts presence. +fn candidates_present(segments: &Dir, intent: &GcRetirementIntent) -> io::Result> { + let mut present = Vec::new(); + for candidate in intent.candidates() { + let name = physical_pool_name::segment(candidate.segment_digest()); + match segments.symlink_metadata(&name) { + Ok(_metadata) => present.push(true), + Err(source) if source.kind() == io::ErrorKind::NotFound => present.push(false), + Err(source) => return Err(source), + } + } + Ok(present) +} + +fn read_bounded(gc: &Dir, name: &str, bound: usize) -> io::Result>> { + match exact_record::read_bounded_optional(gc, name, bound) { + Ok(bytes) => Ok(bytes.map(Vec::into_boxed_slice)), + Err(ExactRecordError::Io(source)) => Err(source), + Err(ExactRecordError::Refused(refusal)) => Err(io::Error::new( + io::ErrorKind::InvalidData, + refusal.to_string(), + )), + } +} + +/// Removes a discardable stage and proves it gone. +pub(super) fn discard(gc: &Dir, name: &str) -> io::Result<()> { + gc.remove_file(name)?; + exact_record::require_absent(gc, name) + .map_err(|_source| invalid("discarded GC stage remained visible"))?; + crate::adapters::filesystem_catalog_artifact::synchronize_directory(gc) +} + +pub(super) fn invalid(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +pub(super) fn invalid_from(error: impl std::error::Error + Send + Sync + 'static) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, error) +} diff --git a/src/adapters/gc/filesystem_gc_storage.rs b/src/adapters/gc/filesystem_gc_storage.rs new file mode 100644 index 00000000..7f67fa5b --- /dev/null +++ b/src/adapters/gc/filesystem_gc_storage.rs @@ -0,0 +1,246 @@ +//! This module owns the filesystem effects of every GC execution phase. + +use std::io; + +use super::filesystem_gc_authority::GcExecutionContext; +use super::filesystem_gc_residue::{INTENT, INTENT_STAGE, RECEIPT, RECEIPT_STAGE, invalid}; +use super::{ + AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, CanonicalGcRetirementReceipt, + FilesystemGcAuthority, GcExecutionStorage, PoolStateDigest, segment_pool_identity, + segment_pool_inventory, +}; +use crate::adapters::filesystem_catalog_artifact::synchronize_directory; +use crate::adapters::filesystem_exact_record::{self as exact_record, ExactRecordError}; +use crate::adapters::physical_pool_name; +use crate::adapters::retention::FilesystemRetentionStage; + +fn no_retirement() -> io::Error { + invalid("no GC retirement is in progress") +} + +impl FilesystemGcAuthority { + fn context(&mut self) -> io::Result<&mut GcExecutionContext> { + self.context.as_mut().ok_or_else(no_retirement) + } + + fn intent_stage(&self) -> io::Result<&FilesystemRetentionStage> { + self.context + .as_ref() + .and_then(|context| context.intent_stage.as_ref()) + .ok_or_else(no_retirement) + } + + fn receipt_stage(&self) -> io::Result<&FilesystemRetentionStage> { + self.context + .as_ref() + .and_then(|context| context.receipt_stage.as_ref()) + .ok_or_else(no_retirement) + } + + /// The receipt this retirement completes with: every candidate proven + /// absent, then the pool identity over the exact remaining inventory. + fn completing_receipt(&self) -> io::Result { + let context = self.context.as_ref().ok_or_else(no_retirement)?; + for candidate in context.intent.intent().candidates() { + let name = physical_pool_name::segment(candidate.segment_digest()); + match self.segments.symlink_metadata(&name) { + Err(source) if source.kind() == io::ErrorKind::NotFound => {} + Err(source) => return Err(source), + Ok(_present) => return Err(invalid("a GC candidate is still present")), + } + } + let inventory = segment_pool_inventory::read( + &self.segments, + self.policy.segment_read(), + self.policy.retained_segment_bytes().get(), + ) + .map_err(|source| io::Error::new(io::ErrorKind::InvalidData, source))? + .into_iter() + .collect(); + let pool_state = PoolStateDigest::new(segment_pool_identity(&inventory)); + Ok(CanonicalGcRetirementReceipt::from_intent( + &context.intent, + pool_state, + )) + } + + fn reopen_receipt_stage(&mut self) -> io::Result<()> { + if self.context()?.receipt.is_none() { + let receipt = self.completing_receipt()?; + self.context()?.receipt = Some(receipt); + } + let gc = self.gc.try_clone()?; + let context = self.context()?; + if context.receipt_stage.is_none() { + let receipt = context.receipt.as_ref().ok_or_else(no_retirement)?; + context.receipt_stage = Some(FilesystemRetentionStage::reopen( + &gc, + RECEIPT_STAGE, + receipt.encoded(), + )?); + } + Ok(()) + } + + fn reopen_intent_stage(&mut self) -> io::Result<()> { + let gc = self.gc.try_clone()?; + let context = self.context()?; + if context.intent_stage.is_none() { + context.intent_stage = Some(FilesystemRetentionStage::reopen( + &gc, + INTENT_STAGE, + context.intent.encoded(), + )?); + } + Ok(()) + } +} + +impl GcExecutionStorage for FilesystemGcAuthority { + fn write_intent_stage(&mut self) -> io::Result<()> { + let gc = self.gc.try_clone()?; + let context = self.context()?; + context.intent_stage = Some(FilesystemRetentionStage::create( + &gc, + INTENT_STAGE, + context.intent.encoded(), + )?); + Ok(()) + } + + fn synchronize_intent_stage(&mut self) -> io::Result<()> { + self.reopen_intent_stage()?; + self.intent_stage()?.synchronize(&self.gc) + } + + fn link_intent(&mut self) -> io::Result<()> { + self.reopen_intent_stage()?; + self.intent_stage()?.link(&self.gc, &self.gc, INTENT) + } + + fn synchronize_gc_after_intent(&mut self) -> io::Result<()> { + synchronize_directory(&self.gc) + } + + fn remove_intent_stage(&mut self) -> io::Result<()> { + self.reopen_intent_stage()?; + let stage = self + .context()? + .intent_stage + .take() + .ok_or_else(no_retirement)?; + stage.remove(&self.gc, &self.gc, INTENT) + } + + fn synchronize_gc_after_intent_cleanup(&mut self) -> io::Result<()> { + synchronize_directory(&self.gc) + } + + fn unlink_candidate(&mut self, index: usize) -> io::Result<()> { + let context = self.context.as_ref().ok_or_else(no_retirement)?; + let candidate = context + .intent + .intent() + .candidates() + .get(index) + .copied() + .ok_or_else(|| invalid("GC candidate index out of range"))?; + let name = physical_pool_name::segment(candidate.segment_digest()); + let metadata = self.segments.symlink_metadata(&name)?; + if !metadata.is_file() || metadata.len() != candidate.segment_length() { + return Err(invalid( + "GC candidate kind or length disagrees with the intent", + )); + } + segment_pool_inventory::admit_entry( + &self.segments, + &name, + candidate.segment_digest(), + metadata.len(), + self.policy.segment_read(), + ) + .map_err(|source| io::Error::new(io::ErrorKind::InvalidData, source))?; + self.segments.remove_file(&name)?; + exact_record::require_absent(&self.segments, &name) + .map_err(|_source| invalid("unlinked GC candidate remained visible")) + } + + fn synchronize_segment_pool(&mut self, _index: usize) -> io::Result<()> { + synchronize_directory(&self.segments) + } + + fn write_receipt_stage(&mut self) -> io::Result<()> { + let receipt = self.completing_receipt()?; + let gc = self.gc.try_clone()?; + let context = self.context()?; + context.receipt_stage = Some(FilesystemRetentionStage::create( + &gc, + RECEIPT_STAGE, + receipt.encoded(), + )?); + context.receipt = Some(receipt); + Ok(()) + } + + fn synchronize_receipt_stage(&mut self) -> io::Result<()> { + self.reopen_receipt_stage()?; + self.receipt_stage()?.synchronize(&self.gc) + } + + fn replace_receipt(&mut self) -> io::Result<()> { + self.reopen_receipt_stage()?; + let stage = self + .context()? + .receipt_stage + .take() + .ok_or_else(no_retirement)?; + stage.replace(&self.gc, RECEIPT) + } + + fn synchronize_gc_after_receipt(&mut self) -> io::Result<()> { + synchronize_directory(&self.gc) + } + + fn remove_intent(&mut self) -> io::Result<()> { + let context = self.context.as_ref().ok_or_else(no_retirement)?; + let bytes = match exact_record::read_exact_optional(&self.gc, RECEIPT, 320) { + Ok(Some(bytes)) => bytes, + Ok(None) => return Err(invalid("GC receipt is absent before intent removal")), + Err(ExactRecordError::Io(source)) => return Err(source), + Err(ExactRecordError::Refused(refusal)) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + refusal.to_string(), + )); + } + }; + let intent = AdmittedGcRetirementIntent::decode(context.intent.encoded()) + .map_err(|source| io::Error::new(io::ErrorKind::InvalidData, source))?; + let complete = AdmittedGcRetirementReceipt::decode(&bytes, &intent) + .map_err(|source| io::Error::new(io::ErrorKind::InvalidData, source))?; + if self.context()?.receipt.is_none() { + let receipt = CanonicalGcRetirementReceipt::from_intent( + &context_intent(self)?, + complete.receipt().pool_state_digest(), + ); + self.context()?.receipt = Some(receipt); + } + self.gc.remove_file(INTENT)?; + exact_record::require_absent(&self.gc, INTENT) + .map_err(|_source| invalid("removed GC intent remained visible")) + } + + fn synchronize_gc_after_intent_removal(&mut self) -> io::Result<()> { + synchronize_directory(&self.gc) + } +} + +fn context_intent( + authority: &FilesystemGcAuthority, +) -> io::Result { + authority + .context + .as_ref() + .map(|context| context.intent.clone()) + .ok_or_else(no_retirement) +} diff --git a/src/adapters/gc/filesystem_gc_tests.rs b/src/adapters/gc/filesystem_gc_tests.rs new file mode 100644 index 00000000..5ee76f59 --- /dev/null +++ b/src/adapters/gc/filesystem_gc_tests.rs @@ -0,0 +1,431 @@ +//! Filesystem GC execution and recovery laws over the migrated fixture +//! store: one disposed orphan retires, every refusal happens before any +//! intent, and every interrupted prefix recovers to the same complete +//! state without losing the live segment. + +use std::error::Error; +use std::fs; +use std::io; +use std::path::Path; + +use super::liveness_observation_tests::{ + ORPHAN_SEGMENT_HEX, ORPHAN_SEGMENT_NAME, disposition_path, observe, published_store, + segment_receipt, +}; +use super::{ + FilesystemGcAuthority, FilesystemGcError, GcExecutionPhase, GcExecutionPoint, + GcExecutionStorage, GcFixedStage, GcLimits, GcPlan, GcRecoveryPlan, plan_gc, + resume_gc_execution, +}; +use crate::adapters::filesystem_test_sandbox::TestDirectory; +use crate::adapters::retention::filesystem_retention_test_fixture::{ + ROOT_HEX, catalog_policy, fixture, initial_preparation, reopen_authority, +}; +use crate::adapters::test_support::decode_hex; +use crate::adapters::{ + FilesystemRetentionSnapshot, FilesystemVersionTwoAdmission, ReaderAttemptLimit, + RetentionCurrentStateRefusal, RetentionPublicationError, +}; +use crate::execute_retention_publication; + +const LIVE_SEGMENT_NAME: &str = + "221f6745cd8a5221c9a87c3707593608479282b54a4a74d0e753fd76f70e8db2.seg"; +/// Execution points for one candidate: the 14 phases with one unlink and +/// one pool synchronization. +const POINT_COUNT: usize = 14; + +/// A published store holding one orphan pool segment and the exact retire +/// receipt that releases it. +fn disposed_store(name: &str) -> Result> { + let sandbox = published_store(name)?; + let orphan = decode_hex(ORPHAN_SEGMENT_HEX.trim())?; + fs::write( + sandbox.path().join("segments").join(ORPHAN_SEGMENT_NAME), + &orphan, + )?; + let coordinates = observe(sandbox.path())?.coordinates(); + let exact = segment_receipt(coordinates, coordinates.catalog_digest())?; + fs::write(disposition_path(sandbox.path()), &exact)?; + Ok(sandbox) +} + +fn plan(root: &Path) -> Result> { + plan_gc(&observe(root)?, GcLimits::MAXIMUM).map_err(Into::into) +} + +fn authority(root: &Path) -> Result> { + let admission = FilesystemVersionTwoAdmission::reopen_unchecked_for_tests(root)?; + FilesystemGcAuthority::open(admission, root, catalog_policy()?).map_err(Into::into) +} + +fn gc_entries(root: &Path) -> Result, Box> { + let mut names: Vec = fs::read_dir(root.join("gc"))? + .map(|entry| Ok(entry?.file_name().to_string_lossy().into_owned())) + .collect::>()?; + names.sort(); + Ok(names) +} + +fn assert_complete(root: &Path) -> Result<(), Box> { + assert_eq!(gc_entries(root)?, ["receipt"]); + assert!(!root.join("segments").join(ORPHAN_SEGMENT_NAME).exists()); + assert!(root.join("segments").join(LIVE_SEGMENT_NAME).exists()); + let plan = plan(root)?; + assert_eq!(plan.candidate_count(), 0); + assert_eq!(plan.already_retired().len(), 1); + let report = authority(root)?.recover()?; + assert_eq!(report.plan(), GcRecoveryPlan::Complete); + assert!(report.receipt().is_some()); + Ok(()) +} + +/// Executes exactly `remaining` points, then refuses like a process death. +struct Prefix<'authority> { + inner: &'authority mut FilesystemGcAuthority, + remaining: usize, +} + +impl Prefix<'_> { + fn step(&mut self) -> io::Result<()> { + if self.remaining == 0 { + return Err(io::Error::other("prefix exhausted")); + } + self.remaining = self.remaining.saturating_sub(1); + Ok(()) + } +} + +macro_rules! forward { + ($($name:ident),* $(,)?) => { + $(fn $name(&mut self) -> io::Result<()> { + self.step()?; + self.inner.$name() + })* + }; +} + +impl GcExecutionStorage for Prefix<'_> { + forward!( + write_intent_stage, + synchronize_intent_stage, + link_intent, + synchronize_gc_after_intent, + remove_intent_stage, + synchronize_gc_after_intent_cleanup, + write_receipt_stage, + synchronize_receipt_stage, + replace_receipt, + synchronize_gc_after_receipt, + remove_intent, + synchronize_gc_after_intent_removal, + ); + + fn unlink_candidate(&mut self, index: usize) -> io::Result<()> { + self.step()?; + self.inner.unlink_candidate(index) + } + + fn synchronize_segment_pool(&mut self, index: usize) -> io::Result<()> { + self.step()?; + self.inner.synchronize_segment_pool(index) + } +} + +/// Runs the first `count` points of a fresh retirement and releases the +/// writer, like a process death there. +fn interrupt(root: &Path, count: usize) -> Result<(), Box> { + let plan = plan(root)?; + let mut authority = authority(root)?; + let prepared = authority.prepare(&plan)?; + let mut prefix = Prefix { + inner: &mut authority, + remaining: count, + }; + let result = resume_gc_execution( + &mut prefix, + prepared.candidate_count(), + GcExecutionPoint::START, + ); + if count < POINT_COUNT { + assert_eq!( + result.err().map(|error| error.executed().len()), + Some(count) + ); + } else { + assert_eq!(result?.executed().len(), POINT_COUNT); + } + Ok(()) +} + +/// The documented recovery for a process death after `count` points. +fn expected_plan(count: usize) -> GcRecoveryPlan { + let resume = |phase| GcRecoveryPlan::Resume { + from: GcExecutionPoint::at(phase), + }; + match count { + 0 => GcRecoveryPlan::Idle, + 1 | 2 => resume(GcExecutionPhase::SynchronizeIntentStage), + 3 | 4 => resume(GcExecutionPhase::SynchronizeGcAfterIntent), + 5 | 6 => resume(GcExecutionPhase::SynchronizeGcAfterIntentCleanup), + 7 | 8 => resume(GcExecutionPhase::WriteReceiptStage), + 9 | 10 => resume(GcExecutionPhase::SynchronizeReceiptStage), + 11 | 12 => resume(GcExecutionPhase::SynchronizeGcAfterReceipt), + _ => GcRecoveryPlan::Complete, + } +} + +#[test] +fn retiring_the_disposed_orphan_leaves_the_receipt_and_the_live_segment() +-> Result<(), Box> { + let sandbox = disposed_store("gc-execute-retire")?; + let plan = plan(sandbox.path())?; + assert_eq!(plan.candidate_count(), 1); + + let receipt = authority(sandbox.path())?.execute(&plan)?; + + assert_eq!(receipt.receipt().generation().get(), 1); + assert_eq!(receipt.receipt().synchronization_count(), 1); + assert_complete(sandbox.path())?; + let again = authority(sandbox.path())?.recover()?; + assert_eq!(again.receipt(), Some(receipt.receipt())); + // Nothing is left to retire, and the prior receipt stays in place. + let error = authority(sandbox.path())? + .execute(&plan) + .err() + .ok_or("a stale plan executed twice")?; + assert!(matches!(error, FilesystemGcError::PlanStale), "{error}"); + assert_eq!(gc_entries(sandbox.path())?, ["receipt"]); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn a_second_retirement_succeeds_the_first_receipts_generation() -> Result<(), Box> { + let sandbox = disposed_store("gc-execute-second")?; + let first = authority(sandbox.path())?.execute(&plan(sandbox.path())?)?; + // A second orphan disposed after the first retirement is generation two. + let orphan = decode_hex(ORPHAN_SEGMENT_HEX.trim())?; + fs::write( + sandbox.path().join("segments").join(ORPHAN_SEGMENT_NAME), + &orphan, + )?; + let plan = plan(sandbox.path())?; + assert_eq!(plan.candidate_count(), 1); + + let second = authority(sandbox.path())?.execute(&plan)?; + + assert_eq!(first.receipt().generation().get(), 1); + assert_eq!(second.receipt().generation().get(), 2); + assert_complete(sandbox.path())?; + sandbox.remove()?; + Ok(()) +} + +#[test] +fn nothing_to_retire_and_a_stale_plan_refuse_before_any_intent() -> Result<(), Box> { + let sandbox = published_store("gc-execute-refusals")?; + let empty = plan(sandbox.path())?; + let error = authority(sandbox.path())? + .execute(&empty) + .err() + .ok_or("an empty plan executed")?; + assert!( + matches!(error, FilesystemGcError::NothingToRetire), + "{error}" + ); + + let orphan = decode_hex(ORPHAN_SEGMENT_HEX.trim())?; + fs::write( + sandbox.path().join("segments").join(ORPHAN_SEGMENT_NAME), + &orphan, + )?; + let coordinates = observe(sandbox.path())?.coordinates(); + let exact = segment_receipt(coordinates, coordinates.catalog_digest())?; + fs::write(disposition_path(sandbox.path()), &exact)?; + let stale = plan(sandbox.path())?; + assert_eq!(stale.candidate_count(), 1); + fs::remove_file(disposition_path(sandbox.path()))?; + let error = authority(sandbox.path())? + .execute(&stale) + .err() + .ok_or("a stale plan executed")?; + assert!(matches!(error, FilesystemGcError::PlanStale), "{error}"); + assert!(gc_entries(sandbox.path())?.is_empty()); + assert!( + sandbox + .path() + .join("segments") + .join(ORPHAN_SEGMENT_NAME) + .exists() + ); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn a_reader_holding_the_fence_refuses_retirement_without_waiting() -> Result<(), Box> { + let sandbox = disposed_store("gc-execute-readers")?; + let plan = plan(sandbox.path())?; + let reader = FilesystemRetentionSnapshot::load( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + )?; + + let error = authority(sandbox.path())? + .execute(&plan) + .err() + .ok_or("retirement ran beside a reader")?; + + assert!(matches!(error, FilesystemGcError::ReadersActive), "{error}"); + assert!(gc_entries(sandbox.path())?.is_empty()); + drop(reader); + let _receipt = authority(sandbox.path())?.execute(&plan)?; + assert_complete(sandbox.path())?; + sandbox.remove()?; + Ok(()) +} + +#[test] +fn every_interrupted_prefix_recovers_to_the_same_complete_state() -> Result<(), Box> { + for count in 0..=POINT_COUNT { + let sandbox = disposed_store(&format!("gc-prefix-{count}"))?; + interrupt(sandbox.path(), count)?; + assert!( + sandbox + .path() + .join("segments") + .join(LIVE_SEGMENT_NAME) + .exists(), + "prefix {count} lost the live segment" + ); + let plan_before = plan(sandbox.path()); + if count < 5 { + let _plan = plan_before?; + } + + let report = authority(sandbox.path())?.recover()?; + + assert_eq!(report.plan(), expected_plan(count), "prefix {count}"); + if report.plan() == GcRecoveryPlan::Idle { + let _receipt = authority(sandbox.path())?.execute(&plan(sandbox.path())?)?; + } else { + assert!(report.receipt().is_some(), "prefix {count}"); + } + assert_complete(sandbox.path())?; + sandbox.remove()?; + } + Ok(()) +} + +#[test] +fn a_truncated_stage_is_discarded_and_the_retirement_then_completes() -> Result<(), Box> +{ + for (count, stage, name) in [ + (0, GcFixedStage::Intent, "intent.next"), + (8, GcFixedStage::Receipt, "receipt.next"), + ] { + let sandbox = disposed_store(&format!("gc-truncated-{count}"))?; + interrupt(sandbox.path(), count)?; + fs::write(sandbox.path().join("gc").join(name), [0xAB; 100])?; + + let report = authority(sandbox.path())?.recover()?; + assert_eq!(report.plan(), GcRecoveryPlan::DiscardStage { stage }); + assert!(!sandbox.path().join("gc").join(name).exists()); + + let report = authority(sandbox.path())?.recover()?; + assert_eq!(report.plan(), expected_plan(count)); + if report.plan() == GcRecoveryPlan::Idle { + let _receipt = authority(sandbox.path())?.execute(&plan(sandbox.path())?)?; + } + assert_complete(sandbox.path())?; + sandbox.remove()?; + } + Ok(()) +} + +#[test] +fn a_durable_intent_excludes_retention_publication_and_another_retirement() +-> Result<(), Box> { + let sandbox = disposed_store("gc-intent-excludes")?; + let plan = plan(sandbox.path())?; + interrupt(sandbox.path(), 6)?; + + let error = authority(sandbox.path())? + .execute(&plan) + .err() + .ok_or("a retirement started over a durable intent")?; + assert!( + matches!(error, FilesystemGcError::RecoveryRequired { .. }), + "{error}" + ); + + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + let mut retention = reopen_authority(sandbox.path())?; + let error = execute_retention_publication(&mut retention, &preparation) + .err() + .ok_or("retention published over a durable GC intent")?; + let refused = match &error { + RetentionPublicationError::CurrentVerification { source } => source + .get_ref() + .and_then(|refusal| refusal.downcast_ref::()), + _ => None, + }; + assert!( + matches!( + refused, + Some(RetentionCurrentStateRefusal::GcIntentRetained) + ), + "{error}" + ); + drop(retention); + + let report = authority(sandbox.path())?.recover()?; + assert_eq!(report.plan(), expected_plan(6)); + assert_complete(sandbox.path())?; + sandbox.remove()?; + Ok(()) +} + +#[test] +fn an_absent_candidate_after_a_present_one_is_ambiguous_and_touches_nothing() +-> Result<(), Box> { + let sandbox = disposed_store("gc-ambiguous")?; + interrupt(sandbox.path(), 6)?; + // The one candidate vanishes by other means, then a receipt stage + // appears while a foreign entry keeps the pool from being explained. + fs::write(sandbox.path().join("gc").join("receipt.next"), [0xAB; 320])?; + + let error = authority(sandbox.path())? + .recover() + .err() + .ok_or("ambiguous residue recovered")?; + + assert!(matches!(error, FilesystemGcError::Ambiguity(_)), "{error}"); + assert!(sandbox.path().join("gc").join("receipt.next").exists()); + assert!( + sandbox + .path() + .join("segments") + .join(ORPHAN_SEGMENT_NAME) + .exists() + ); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn admission_refuses_a_foreign_gc_entry_or_a_directory_in_place_of_a_record() +-> Result<(), Box> { + let sandbox = disposed_store("gc-admission")?; + fs::write(sandbox.path().join("gc").join("notes"), b"x")?; + assert!(authority(sandbox.path()).is_err()); + fs::remove_file(sandbox.path().join("gc").join("notes"))?; + fs::create_dir(sandbox.path().join("gc").join("intent"))?; + assert!(authority(sandbox.path()).is_err()); + fs::remove_dir(sandbox.path().join("gc").join("intent"))?; + let _authority = authority(sandbox.path())?; + sandbox.remove()?; + Ok(()) +} diff --git a/src/adapters/gc/intent_encoder.rs b/src/adapters/gc/intent_encoder.rs index be28d666..b36e19ff 100644 --- a/src/adapters/gc/intent_encoder.rs +++ b/src/adapters/gc/intent_encoder.rs @@ -46,6 +46,17 @@ pub(super) fn encode( )) } +/// The registered candidate-set digest over `candidates` in their given +/// order, without allocating the full record. +pub(super) fn candidate_set_digest(candidates: &[GcCandidate]) -> GcCandidateSetDigest { + let mut bytes = Vec::new(); + for candidate in candidates { + write_candidate(&mut bytes, candidate); + } + let count = u32::try_from(candidates.len()).unwrap_or(u32::MAX); + GcCandidateSetDigest::from_verified(format::candidate_set_digest(count, &bytes)) +} + fn write_candidate(output: &mut Vec, candidate: &GcCandidate) { output.extend_from_slice(candidate.segment_digest().as_bytes()); output.extend_from_slice(&candidate.segment_length().to_be_bytes()); diff --git a/src/adapters/gc/liveness_observation.rs b/src/adapters/gc/liveness_observation.rs index 94fead7e..4688aa74 100644 --- a/src/adapters/gc/liveness_observation.rs +++ b/src/adapters/gc/liveness_observation.rs @@ -14,6 +14,7 @@ use cap_std::fs::Dir; use super::{ AdmittedRecoveryDispositionReceipt, RecoveryArtifactKind, RecoveryDispositionDecision, + VerificationEvidenceDigest, }; use super::{ GcLivenessCoordinates, GcLivenessObservationError as Error, GcLivenessSnapshot, @@ -88,8 +89,8 @@ pub fn observe_gc_liveness( let catalogs = root .open_dir_nofollow(CATALOGS) .map_err(|source| Error::pool("open catalog pool", source))?; - for segment in superseded_segments(&catalogs, &catalog, &named)? { - snapshot.supersede_segment(segment); + for (segment, evidence) in superseded_segments(&catalogs, &catalog, &named)? { + snapshot.supersede_segment(segment, evidence); } let recovery = root .open_dir_nofollow(RECOVERY) @@ -97,8 +98,8 @@ pub fn observe_gc_liveness( let dispositions = recovery .open_dir_nofollow(DISPOSITIONS) .map_err(|source| Error::pool("open disposition pool", source))?; - for segment in disposed_segments(&dispositions, snapshot.coordinates())? { - snapshot.dispose_segment(segment); + for (segment, evidence) in disposed_segments(&dispositions, snapshot.coordinates())? { + snapshot.dispose_segment(segment, evidence); } Ok(snapshot) } @@ -111,8 +112,8 @@ pub fn observe_gc_liveness( fn disposed_segments( dispositions: &Dir, coordinates: GcLivenessCoordinates, -) -> Result, Error> { - let mut disposed = BTreeSet::new(); +) -> Result, Error> { + let mut disposed = BTreeMap::new(); for entry in dispositions .entries() .map_err(|source| Error::pool("list dispositions", source))? @@ -156,7 +157,11 @@ fn disposed_segments( && receipt.coordinates().catalog_digest == coordinates.catalog_digest() && receipt.coordinates().retention == coordinates.retention(); if exact { - disposed.insert(identity); + let checksum: [u8; 32] = bytes + .get(RECEIPT_LENGTH.saturating_sub(32)..RECEIPT_LENGTH) + .and_then(|slice| slice.try_into().ok()) + .ok_or(Error::DispositionEntryName)?; + disposed.insert(identity, VerificationEvidenceDigest::new(checksum)); } } Ok(disposed) @@ -227,8 +232,8 @@ fn superseded_segments( catalogs: &Dir, current: &CatalogSnapshot<'_, '_, '_>, named: &BTreeSet, -) -> Result, Error> { - let mut superseded = BTreeSet::new(); +) -> Result, Error> { + let mut superseded = BTreeMap::new(); let mut generation = current.generation(); let mut next = current.previous_catalog_digest(); while let Some(digest) = next { @@ -248,7 +253,11 @@ fn superseded_segments( generation, source: Box::new(source), })?; - superseded.extend(segments.difference(named).copied()); + for segment in segments.difference(named) { + superseded + .entry(*segment) + .or_insert_with(|| VerificationEvidenceDigest::new(*digest.as_bytes())); + } next = catalog.previous_catalog_digest(); } Ok(superseded) diff --git a/src/adapters/gc/liveness_observation_tests.rs b/src/adapters/gc/liveness_observation_tests.rs index fde1024a..ba0140cd 100644 --- a/src/adapters/gc/liveness_observation_tests.rs +++ b/src/adapters/gc/liveness_observation_tests.rs @@ -15,12 +15,12 @@ use crate::adapters::test_support::decode_hex; use crate::adapters::{FilesystemRetentionSnapshot, ReaderAttemptLimit}; use crate::execute_retention_publication; -const ORPHAN_SEGMENT_HEX: &str = +pub(super) const ORPHAN_SEGMENT_HEX: &str = include_str!("../../../conformance/segment-store/v1/one-zero-segment.hex"); -const ORPHAN_SEGMENT_NAME: &str = +pub(super) const ORPHAN_SEGMENT_NAME: &str = "b7542dced2ab770894a14d1d04b066e3a899942602c5986d35ba6df6c1a35cfc.seg"; -fn published_store( +pub(super) fn published_store( name: &str, ) -> Result> { let sandbox = migrated_store(name)?; @@ -32,7 +32,7 @@ fn published_store( Ok(sandbox) } -fn observe(root: &Path) -> Result> { +pub(super) fn observe(root: &Path) -> Result> { let view = FilesystemRetentionSnapshot::load(root, catalog_policy()?, ReaderAttemptLimit::DEFAULT)?; observe_gc_liveness(root, &view, catalog_policy()?).map_err(Into::into) @@ -149,7 +149,7 @@ fn a_pool_entry_not_named_by_a_digest_refuses_observation() -> Result<(), Box Result, Box> { @@ -190,7 +190,7 @@ fn segment_receipt( .to_vec()) } -fn disposition_path(root: &Path) -> std::path::PathBuf { +pub(super) fn disposition_path(root: &Path) -> std::path::PathBuf { root.join("recovery").join("dispositions").join(format!( "{}.receipt", ORPHAN_SEGMENT_NAME.trim_end_matches(".seg") diff --git a/src/adapters/gc/liveness_snapshot.rs b/src/adapters/gc/liveness_snapshot.rs index dd2aa32d..45200b65 100644 --- a/src/adapters/gc/liveness_snapshot.rs +++ b/src/adapters/gc/liveness_snapshot.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; -use super::{GcLivenessCoordinates, GcRetainedClosure}; +use super::{GcLivenessCoordinates, GcRetainedClosure, VerificationEvidenceDigest}; use crate::RetentionNamespaceDigest; use crate::adapters::SegmentDigest; @@ -21,6 +21,8 @@ pub struct GcLivenessSnapshot { retained: Vec, superseded: BTreeSet, disposed: BTreeSet, + evidence: BTreeMap, + disposition_checksums: BTreeSet<[u8; 32]>, } /// A snapshot that could not be assembled without contradiction. @@ -63,6 +65,8 @@ impl GcLivenessSnapshot { retained: Vec::new(), superseded: BTreeSet::new(), disposed: BTreeSet::new(), + evidence: BTreeMap::new(), + disposition_checksums: BTreeSet::new(), } } @@ -109,14 +113,42 @@ impl GcLivenessSnapshot { /// Records that a predecessor catalog in the pool's chain named `segment` /// and the current catalog no longer does: the segment was superseded by - /// a durably published catalog successor. - pub fn supersede_segment(&mut self, segment: SegmentDigest) { + /// a durably published catalog successor. `evidence` is the digest of + /// that predecessor catalog, the durable record that released it. + pub fn supersede_segment( + &mut self, + segment: SegmentDigest, + evidence: VerificationEvidenceDigest, + ) { self.superseded.insert(segment); + self.evidence.entry(segment).or_insert(evidence); } /// Records that a durable `RecoveryDispositionReceipt` retired `segment`. - pub fn dispose_segment(&mut self, segment: SegmentDigest) { + /// `evidence` is that receipt's checksum, the durable record that + /// released it; the checksum also enters the admitted disposition set. + pub fn dispose_segment( + &mut self, + segment: SegmentDigest, + evidence: VerificationEvidenceDigest, + ) { self.disposed.insert(segment); + self.evidence.entry(segment).or_insert(evidence); + self.disposition_checksums.insert(*evidence.as_bytes()); + } + + /// Returns the digest of the durable record that released `segment`, if + /// any. + #[must_use] + pub fn release_evidence(&self, segment: SegmentDigest) -> Option { + self.evidence.get(&segment).copied() + } + + /// Returns the checksums of every exact disposition receipt admitted, in + /// canonical order. + #[must_use] + pub const fn disposition_checksums(&self) -> &BTreeSet<[u8; 32]> { + &self.disposition_checksums } /// Returns the coordinates the snapshot binds. diff --git a/src/adapters/gc/mod.rs b/src/adapters/gc/mod.rs index 55ae5f6d..bd460f6e 100644 --- a/src/adapters/gc/mod.rs +++ b/src/adapters/gc/mod.rs @@ -3,8 +3,9 @@ //! This module owns the semantic GC retirement intent and receipt, their //! canonical encoders, and their admitting decoders, and the deterministic //! planner that classifies one physical inventory against one immutable -//! liveness snapshot. It does not own GC execution, reader fencing, -//! or recovery. +//! liveness snapshot, the fixed-phase execution protocol over a storage +//! port, and the recovery planner over the residue an interrupted execution +//! leaves. It does not own filesystem effects or reader fencing. mod admitted_disposition; mod admitted_intent; @@ -20,6 +21,15 @@ mod disposition_encoder; mod disposition_enums; mod disposition_format; mod evidence_digests; +mod execution; +mod execution_phase; +mod execution_storage; +mod filesystem_gc_authority; +mod filesystem_gc_error; +mod filesystem_gc_residue; +mod filesystem_gc_storage; +#[cfg(test)] +mod filesystem_gc_tests; mod intent; mod intent_candidate_decoder; mod intent_coordinates; @@ -53,7 +63,10 @@ mod receipt_decoder; mod receipt_encoder; mod receipt_format; mod record_digests; +mod recovery_plan; +mod recovery_residue; mod retained_closure; +mod retirement_intent; mod segment_classification; mod segment_pool_inventory; @@ -76,6 +89,12 @@ pub use evidence_digests::{ DecisionEvidenceDigest, DispositionSetDigest, ObservedHeadChecksum, PoolStateDigest, SegmentPoolIdentityDigest, VerificationEvidenceDigest, }; +pub use execution::{GcExecutionError, GcExecutionReceipt, execute_gc, resume_gc_execution}; +pub use execution_phase::{GcExecutionPhase, GcExecutionPoint}; +pub use execution_storage::GcExecutionStorage; +pub use filesystem_gc_authority::{FilesystemGcAuthority, GcRecoveryReport, PreparedGcExecution}; +pub use filesystem_gc_error::FilesystemGcError; +pub(in crate::adapters) use filesystem_gc_residue::GC_ENTRY_NAMES; pub use intent::GcRetirementIntent; pub use intent_coordinates::GcRetirementIntentCoordinates; pub use intent_decode_error::{GcRetirementIntentDecodeError, GcRetirementIntentEncodeError}; @@ -92,5 +111,14 @@ pub use reader_lock_identity::{ReaderLockCoordinate, ReaderLockIdentity}; pub use receipt::GcRetirementReceipt; pub use receipt_decode_error::GcRetirementReceiptDecodeError; pub use record_digests::{GcCandidateSetDigest, GcRetirementIntentDigest}; +pub use recovery_plan::{ + GcFixedStage, GcRecoveryAmbiguity, GcRecoveryPlan, is_complete as is_gc_complete, + plan_gc_recovery, +}; +pub use recovery_residue::GcResidue; pub use retained_closure::GcRetainedClosure; +pub use retirement_intent::{ + GcIntentDerivationError, GcIntentEvidence, catalog_successor_proof, derive_gc_intent, + disposition_set_digest, post_retirement_pool_state, segment_pool_identity, +}; pub use segment_classification::{GcSegmentClassification, GcUnreachableEvidence}; diff --git a/src/adapters/gc/plan_model_tests.rs b/src/adapters/gc/plan_model_tests.rs index 8948c278..654857e6 100644 --- a/src/adapters/gc/plan_model_tests.rs +++ b/src/adapters/gc/plan_model_tests.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; use std::error::Error; -use super::{closure, coordinates, segment}; +use super::{closure, coordinates, evidence, segment}; use crate::adapters::SegmentDigest; use crate::adapters::gc::{ GcLimits, GcLivenessSnapshot, GcPlannedCandidate, GcSegmentClassification, plan_gc, @@ -58,11 +58,11 @@ fn universe(random: &mut XorShift) -> Result> { named.insert(digest); } 2 => { - snapshot.supersede_segment(digest); + snapshot.supersede_segment(digest, evidence(0x77)); released.insert(digest); } 3 => { - snapshot.dispose_segment(digest); + snapshot.dispose_segment(digest, evidence(0x88)); released.insert(digest); } _ => {} diff --git a/src/adapters/gc/planner_tests.rs b/src/adapters/gc/planner_tests.rs index ed35cee3..1a8a4e0c 100644 --- a/src/adapters/gc/planner_tests.rs +++ b/src/adapters/gc/planner_tests.rs @@ -39,6 +39,10 @@ pub(super) fn segment(seed: u8) -> SegmentDigest { SegmentDigest::from_validated([seed; 32]) } +pub(super) fn evidence(seed: u8) -> super::VerificationEvidenceDigest { + super::VerificationEvidenceDigest::new([seed; 32]) +} + pub(super) fn namespace(seed: u8) -> RetentionNamespaceDigest { RetentionNamespaceDigest::from_hash([seed; 32]) } @@ -138,10 +142,10 @@ fn an_unnamed_segment_without_release_evidence_is_recovery_protected() -> Result fn superseded_and_disposed_unnamed_segments_are_the_only_candidates() -> Result<(), Box> { let mut snapshot = snapshot(&[1], &[5, 6, 7])?; - snapshot.supersede_segment(segment(5)); - snapshot.dispose_segment(segment(6)); - snapshot.supersede_segment(segment(7)); - snapshot.dispose_segment(segment(7)); + snapshot.supersede_segment(segment(5), evidence(0x77)); + snapshot.dispose_segment(segment(6), evidence(0x88)); + snapshot.supersede_segment(segment(7), evidence(0x77)); + snapshot.dispose_segment(segment(7), evidence(0x88)); let plan = plan_gc(&snapshot, GcLimits::MAXIMUM)?; @@ -180,8 +184,8 @@ fn superseded_and_disposed_unnamed_segments_are_the_only_candidates() -> Result< fn superseded_or_disposed_segments_absent_from_the_inventory_are_already_retired() -> Result<(), Box> { let mut snapshot = snapshot(&[1], &[])?; - snapshot.supersede_segment(segment(8)); - snapshot.dispose_segment(segment(9)); + snapshot.supersede_segment(segment(8), evidence(0x77)); + snapshot.dispose_segment(segment(9), evidence(0x88)); let plan = plan_gc(&snapshot, GcLimits::MAXIMUM)?; @@ -234,7 +238,7 @@ fn every_contradiction_refuses_the_plan() -> Result<(), Box> { ); let mut superseded_named = snapshot(&[1], &[])?; - superseded_named.supersede_segment(segment(1)); + superseded_named.supersede_segment(segment(1), evidence(0x77)); assert_eq!( plan_gc(&superseded_named, GcLimits::MAXIMUM), Err(GcPlanError::Ambiguous( @@ -245,7 +249,7 @@ fn every_contradiction_refuses_the_plan() -> Result<(), Box> { ); let mut disposed_named = snapshot(&[1], &[])?; - disposed_named.dispose_segment(segment(1)); + disposed_named.dispose_segment(segment(1), evidence(0x88)); assert_eq!( plan_gc(&disposed_named, GcLimits::MAXIMUM), Err(GcPlanError::Ambiguous( @@ -260,8 +264,8 @@ fn every_contradiction_refuses_the_plan() -> Result<(), Box> { #[test] fn the_candidate_limit_refuses_rather_than_truncates() -> Result<(), Box> { let mut snapshot = snapshot(&[], &[5, 6])?; - snapshot.supersede_segment(segment(5)); - snapshot.supersede_segment(segment(6)); + snapshot.supersede_segment(segment(5), evidence(0x77)); + snapshot.supersede_segment(segment(6), evidence(0x77)); assert_eq!( plan_gc(&snapshot, GcLimits::new(1)?), diff --git a/src/adapters/gc/receipt.rs b/src/adapters/gc/receipt.rs index aca18d0d..3a8bf5f6 100644 --- a/src/adapters/gc/receipt.rs +++ b/src/adapters/gc/receipt.rs @@ -29,7 +29,37 @@ pub struct GcRetirementReceipt { synchronization_count: u64, } +/// Every field of one receipt read without an intent to bind it to. +#[derive(Clone, Copy)] +pub(super) struct GcRetirementReceiptFields { + pub(super) generation: GcGeneration, + pub(super) intent_digest: GcRetirementIntentDigest, + pub(super) retired_candidate_set_digest: GcCandidateSetDigest, + pub(super) pool_state_digest: PoolStateDigest, + pub(super) liveness_generation: LivenessGeneration, + pub(super) manifest_digest: RetentionManifestDigest, + pub(super) catalog_generation: CatalogGeneration, + pub(super) catalog_digest: CatalogDigest, + pub(super) reader_lock: ReaderLockIdentity, + pub(super) synchronization_count: u64, +} + impl GcRetirementReceipt { + pub(super) const fn from_fields(fields: GcRetirementReceiptFields) -> Self { + Self { + generation: fields.generation, + intent_digest: fields.intent_digest, + retired_candidate_set_digest: fields.retired_candidate_set_digest, + pool_state_digest: fields.pool_state_digest, + liveness_generation: fields.liveness_generation, + manifest_digest: fields.manifest_digest, + catalog_generation: fields.catalog_generation, + catalog_digest: fields.catalog_digest, + reader_lock: fields.reader_lock, + synchronization_count: fields.synchronization_count, + } + } + pub(super) fn for_intent( intent_digest: GcRetirementIntentDigest, retired_candidate_set_digest: GcCandidateSetDigest, diff --git a/src/adapters/gc/receipt_decode_error.rs b/src/adapters/gc/receipt_decode_error.rs index bfef43f8..bcb5834e 100644 --- a/src/adapters/gc/receipt_decode_error.rs +++ b/src/adapters/gc/receipt_decode_error.rs @@ -41,6 +41,11 @@ pub enum GcRetirementReceiptDecodeError { }, /// The reserved bytes were nonzero. NonZeroReserved, + /// A generation field was zero when decoded without an intent. + ZeroGeneration { + /// Offset of the zero field. + offset: usize, + }, /// The checksum did not match the exact prefix. ChecksumMismatch { /// Computed canonical checksum. @@ -135,6 +140,9 @@ impl fmt::Display for GcRetirementReceiptDecodeError { write!(formatter, "unsupported GC receipt flags {observed:#010x}") } Self::NonZeroReserved => formatter.write_str("GC receipt reserved bytes are nonzero"), + Self::ZeroGeneration { offset } => { + write!(formatter, "GC receipt generation at {offset} is zero") + } Self::ChecksumMismatch { .. } => formatter.write_str("GC receipt checksum mismatch"), Self::GenerationMismatch { expected, observed } => write!( formatter, diff --git a/src/adapters/gc/receipt_decoder.rs b/src/adapters/gc/receipt_decoder.rs index a445f945..f89de484 100644 --- a/src/adapters/gc/receipt_decoder.rs +++ b/src/adapters/gc/receipt_decoder.rs @@ -1,13 +1,53 @@ //! This boundary module owns GC retirement receipt decoding order. use super::GcRetirementReceiptDecodeError as Error; +use super::receipt::GcRetirementReceiptFields; use super::receipt_bytes::{ read_array, read_u16, read_u32, read_u64, require_length, wrong_length, }; use super::{ - AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, GcRetirementReceipt, PoolStateDigest, - ReaderLockCoordinate, receipt_format as format, + AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, GcCandidateSetDigest, + GcRetirementIntentDigest, GcRetirementReceipt, PoolStateDigest, ReaderLockCoordinate, + ReaderLockIdentity, receipt_format as format, }; +use crate::{ + CatalogDigest, CatalogGeneration, GcGeneration, LivenessGeneration, RetentionManifestDigest, +}; + +/// Decodes one receipt's framing, checksum, and every field without an +/// intent to bind it to: the prior retirement's receipt a new execution +/// succeeds, or the completion a fresh restart reports. +pub(super) fn decode_unbound(encoded: &[u8]) -> Result { + require_length(encoded)?; + validate_fixed_fields(encoded)?; + verify_checksum(encoded)?; + let generation = GcGeneration::new(read_u64(encoded, 24)?) + .map_err(|_source| Error::ZeroGeneration { offset: 24 })?; + let liveness_generation = LivenessGeneration::new(read_u64(encoded, 128)?) + .map_err(|_source| Error::ZeroGeneration { offset: 128 })?; + let catalog_generation = CatalogGeneration::new(read_u64(encoded, 168)?) + .map_err(|_source| Error::ZeroGeneration { offset: 168 })?; + Ok(GcRetirementReceipt::from_fields( + GcRetirementReceiptFields { + generation, + intent_digest: GcRetirementIntentDigest::from_hash(read_array(encoded, 32)?), + retired_candidate_set_digest: GcCandidateSetDigest::from_verified(read_array( + encoded, 64, + )?), + pool_state_digest: PoolStateDigest::new(read_array(encoded, 96)?), + liveness_generation, + manifest_digest: RetentionManifestDigest::from_hash(read_array(encoded, 136)?), + catalog_generation, + catalog_digest: CatalogDigest::from_validated(read_array(encoded, 176)?), + reader_lock: ReaderLockIdentity::new( + read_u64(encoded, 208)?, + read_u64(encoded, 216)?, + read_u64(encoded, 224)?, + ), + synchronization_count: read_u64(encoded, 232)?, + }, + )) +} pub(super) fn decode<'encoded>( encoded: &'encoded [u8], diff --git a/src/adapters/gc/recovery_plan.rs b/src/adapters/gc/recovery_plan.rs new file mode 100644 index 00000000..58682d6b --- /dev/null +++ b/src/adapters/gc/recovery_plan.rs @@ -0,0 +1,295 @@ +//! This boundary module owns the pure classification of GC residue into +//! the one lawful recovery, or a typed ambiguity. + +use std::fmt; + +use super::{ + AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, GcExecutionPhase, GcExecutionPoint, + GcResidue, GcRetirementIntentDecodeError, GcRetirementReceiptDecodeError, +}; + +/// One of the two fixed stages GC execution retains. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GcFixedStage { + /// `gc/intent.next`. + Intent, + /// `gc/receipt.next`. + Receipt, +} + +/// What restart does with the residue it found. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GcRecoveryPlan { + /// Nothing is in progress and no retirement has completed. + Idle, + /// The last retirement completed: `gc/receipt` alone, exactly decodable. + Complete, + /// A truncated stage written before any authority is discarded, and + /// `gc` synchronized; nothing else changes. + DiscardStage { + /// The stage to discard. + stage: GcFixedStage, + }, + /// Execution resumes at `from` with the durable intent's candidates. + Resume { + /// The point to resume at. + from: GcExecutionPoint, + }, +} + +/// Residue that admits no lawful recovery. Nothing is unlinked or repaired. +#[derive(Debug)] +pub enum GcRecoveryAmbiguity { + /// `gc/intent` does not decode. + IntentUndecodable { + /// The exact refusal. + source: GcRetirementIntentDecodeError, + }, + /// `gc/receipt` neither completes the durable intent nor decodes as + /// the retirement the intent succeeds. + ReceiptUndecodable { + /// The exact refusal against the intent. + source: GcRetirementReceiptDecodeError, + }, + /// A receipt stage exists without the intent it completes. + ReceiptWithoutIntent, + /// A stage holds bytes other than the record it stages. + StageDiffers { + /// The stage. + stage: GcFixedStage, + }, + /// A stage is longer than its record. + StageOverlong { + /// The stage. + stage: GcFixedStage, + }, + /// A candidate is absent after a present one: no prefix explains it. + AbsentOutOfOrder { + /// The first absent candidate index. + absent: usize, + /// A later present candidate index. + present: usize, + }, + /// A receipt or receipt stage exists while a candidate is still present. + ReceiptBeforeRetirement, +} + +impl fmt::Display for GcRecoveryAmbiguity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::IntentUndecodable { .. } => formatter.write_str("gc/intent does not decode"), + Self::ReceiptUndecodable { .. } => { + formatter.write_str("gc/receipt neither completes nor precedes the intent") + } + Self::ReceiptWithoutIntent => { + formatter.write_str("gc/receipt.next exists without the intent it completes") + } + Self::StageDiffers { stage } => { + write!(formatter, "gc {stage:?} stage holds other bytes") + } + Self::StageOverlong { stage } => { + write!(formatter, "gc {stage:?} stage is longer than its record") + } + Self::AbsentOutOfOrder { absent, present } => write!( + formatter, + "candidate {absent} is absent while later candidate {present} is present" + ), + Self::ReceiptBeforeRetirement => { + formatter.write_str("gc receipt exists while a candidate is still present") + } + } + } +} + +impl std::error::Error for GcRecoveryAmbiguity { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::IntentUndecodable { source } => Some(source), + Self::ReceiptUndecodable { source } => Some(source), + _ => None, + } + } +} + +/// How `gc/receipt` relates to the durable intent. +enum ReceiptRelation { + /// No receipt. + Absent, + /// The receipt of the retirement this intent succeeds. + Prior, + /// The receipt that completes this intent. + Complete, +} + +/// Plans the one lawful recovery of `residue`. +/// +/// The table follows `gc.md` and ADR-0009: idle, active (every candidate +/// present), partial (one exact absent prefix), completion pending (every +/// candidate absent, no receipt for the intent), receipt transition (exact +/// intent and its exact receipt), and complete (exact receipt only). A +/// receipt whose generation the intent succeeds is the prior retirement's +/// and constrains nothing. Every other residue is a typed ambiguity. +/// +/// # Errors +/// +/// Returns [`GcRecoveryAmbiguity`] when the residue matches no row. +pub fn plan_gc_recovery(residue: &GcResidue) -> Result { + let Some(intent_bytes) = residue.intent.as_deref() else { + return plan_before_durable_intent(residue); + }; + let intent = AdmittedGcRetirementIntent::decode(intent_bytes) + .map_err(|source| GcRecoveryAmbiguity::IntentUndecodable { source })?; + if let Some(stage) = residue.intent_stage.as_deref() { + return if stage == intent_bytes { + Ok(resume(GcExecutionPhase::SynchronizeGcAfterIntent)) + } else { + Err(GcRecoveryAmbiguity::StageDiffers { + stage: GcFixedStage::Intent, + }) + }; + } + let absent_prefix = absent_prefix(&residue.candidates_present)?; + let count = residue.candidates_present.len(); + match receipt_relation(residue.receipt.as_deref(), &intent)? { + ReceiptRelation::Complete if absent_prefix != count => { + Err(GcRecoveryAmbiguity::ReceiptBeforeRetirement) + } + ReceiptRelation::Complete if residue.receipt_stage.is_some() => { + Err(GcRecoveryAmbiguity::StageDiffers { + stage: GcFixedStage::Receipt, + }) + } + ReceiptRelation::Complete => Ok(resume(GcExecutionPhase::SynchronizeGcAfterReceipt)), + ReceiptRelation::Absent | ReceiptRelation::Prior => { + plan_after_unlinks(residue, &intent, absent_prefix, count) + } + } +} + +/// Rows three through five: the intent is durable and no receipt completes it. +fn plan_after_unlinks( + residue: &GcResidue, + intent: &AdmittedGcRetirementIntent<'_>, + absent_prefix: usize, + count: usize, +) -> Result { + if let Some(stage) = residue.receipt_stage.as_deref() { + if absent_prefix != count { + return Err(GcRecoveryAmbiguity::ReceiptBeforeRetirement); + } + return match AdmittedGcRetirementReceipt::decode(stage, intent) { + Ok(_complete) => Ok(resume(GcExecutionPhase::SynchronizeReceiptStage)), + Err(GcRetirementReceiptDecodeError::WrongLength { expected, observed }) + if observed < expected => + { + Ok(GcRecoveryPlan::DiscardStage { + stage: GcFixedStage::Receipt, + }) + } + Err(GcRetirementReceiptDecodeError::WrongLength { .. }) => { + Err(GcRecoveryAmbiguity::StageOverlong { + stage: GcFixedStage::Receipt, + }) + } + Err(_) => Err(GcRecoveryAmbiguity::StageDiffers { + stage: GcFixedStage::Receipt, + }), + }; + } + Ok(GcRecoveryPlan::Resume { + from: if absent_prefix == count { + GcExecutionPoint::at(GcExecutionPhase::WriteReceiptStage) + } else if absent_prefix == 0 { + GcExecutionPoint::at(GcExecutionPhase::SynchronizeGcAfterIntentCleanup) + } else { + GcExecutionPoint { + phase: GcExecutionPhase::UnlinkCandidate, + candidate: absent_prefix, + } + }, + }) +} + +/// Rows one, two, and six: no durable intent. +fn plan_before_durable_intent(residue: &GcResidue) -> Result { + if residue.receipt_stage.is_some() { + return Err(GcRecoveryAmbiguity::ReceiptWithoutIntent); + } + let Some(stage) = residue.intent_stage.as_deref() else { + return residue + .receipt + .as_deref() + .map_or(Ok(GcRecoveryPlan::Idle), |receipt| { + AdmittedGcRetirementReceipt::decode_unbound(receipt) + .map(|_prior| GcRecoveryPlan::Complete) + .map_err(|source| GcRecoveryAmbiguity::ReceiptUndecodable { source }) + }); + }; + match AdmittedGcRetirementIntent::decode(stage) { + Ok(_complete) => Ok(resume(GcExecutionPhase::SynchronizeIntentStage)), + Err(GcRetirementIntentDecodeError::Truncated { .. }) => Ok(GcRecoveryPlan::DiscardStage { + stage: GcFixedStage::Intent, + }), + Err(GcRetirementIntentDecodeError::TrailingData { .. }) => { + Err(GcRecoveryAmbiguity::StageOverlong { + stage: GcFixedStage::Intent, + }) + } + Err(_) => Err(GcRecoveryAmbiguity::StageDiffers { + stage: GcFixedStage::Intent, + }), + } +} + +fn receipt_relation( + receipt: Option<&[u8]>, + intent: &AdmittedGcRetirementIntent<'_>, +) -> Result { + let Some(receipt) = receipt else { + return Ok(ReceiptRelation::Absent); + }; + let refusal = match AdmittedGcRetirementReceipt::decode(receipt, intent) { + Ok(_complete) => return Ok(ReceiptRelation::Complete), + Err(refusal) => refusal, + }; + let generation = intent.intent().coordinates().generation.get(); + match AdmittedGcRetirementReceipt::decode_unbound(receipt) { + Ok(prior) if prior.generation().get().checked_add(1) == Some(generation) => { + Ok(ReceiptRelation::Prior) + } + Ok(_) | Err(_) => Err(GcRecoveryAmbiguity::ReceiptUndecodable { source: refusal }), + } +} + +/// The length of the absent prefix, refusing an absent candidate after a +/// present one. +fn absent_prefix(present: &[bool]) -> Result { + let prefix = present.iter().take_while(|present| !**present).count(); + if let Some(absent) = present + .iter() + .skip(prefix) + .position(|present| !*present) + .map(|offset| offset.saturating_add(prefix)) + { + return Err(GcRecoveryAmbiguity::AbsentOutOfOrder { + absent, + present: prefix, + }); + } + Ok(prefix) +} + +const fn resume(phase: GcExecutionPhase) -> GcRecoveryPlan { + GcRecoveryPlan::Resume { + from: GcExecutionPoint::at(phase), + } +} + +/// Whether a residue means a complete retirement: exact receipt only. +#[must_use] +pub const fn is_complete(residue: &GcResidue) -> bool { + residue.intent.is_none() + && residue.intent_stage.is_none() + && residue.receipt_stage.is_none() + && residue.receipt.is_some() +} diff --git a/src/adapters/gc/recovery_residue.rs b/src/adapters/gc/recovery_residue.rs new file mode 100644 index 00000000..ea01031e --- /dev/null +++ b/src/adapters/gc/recovery_residue.rs @@ -0,0 +1,31 @@ +//! This boundary module owns what restart observes in `gc` and the segment +//! pool before planning GC recovery. + +/// The exact bytes and presence restart read, nothing interpreted. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GcResidue { + /// `gc/intent.next`, when present, up to one byte past the maximum + /// intent length. + pub intent_stage: Option>, + /// `gc/intent`, when present. + pub intent: Option>, + /// `gc/receipt.next`, when present. + pub receipt_stage: Option>, + /// `gc/receipt`, when present. + pub receipt: Option>, + /// For each candidate the durable intent names, in canonical order, + /// whether its segment-pool entry is present. Empty without an intent. + pub candidates_present: Vec, +} + +impl GcResidue { + /// An empty `gc` directory. + pub const IDLE: Self = Self { + intent_stage: None, + intent: None, + receipt_stage: None, + receipt: None, + candidates_present: Vec::new(), + }; +} diff --git a/src/adapters/gc/retirement_intent.rs b/src/adapters/gc/retirement_intent.rs new file mode 100644 index 00000000..9eb92cf2 --- /dev/null +++ b/src/adapters/gc/retirement_intent.rs @@ -0,0 +1,206 @@ +//! This boundary module derives the canonical retirement intent from one +//! plan and the evidence execution observed, and owns the registered +//! derivations of the intent's proof, pool, and disposition-set digests. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use super::{ + CatalogSuccessorProofDigest, DispositionSetDigest, GcCandidate, GcCandidateSetDigest, GcPlan, + GcRetirementIntent, GcRetirementIntentCoordinates, GcRetirementIntentError, PoolStateDigest, + ReaderLockIdentity, SegmentPoolIdentityDigest, +}; +use crate::adapters::SegmentDigest; +use crate::{ + CatalogDigest, CatalogGeneration, GcGeneration, LivenessGeneration, RegisteredRetentionProfile, + RetentionManifestDigest, +}; + +const CATALOG_SUCCESSOR_PROOF_DOMAIN: &[u8] = b"keep.gc-catalog-successor-proof/v2\0"; +const SEGMENT_POOL_DOMAIN: &[u8] = b"keep.gc-segment-pool/v2\0"; +const DISPOSITION_SET_DOMAIN: &[u8] = b"keep.gc-disposition-set/v2\0"; + +/// The identity of one admitted segment pool: BLAKE3-256 under the +/// registered pool domain over the entry count and every `(digest, length)` +/// entry in canonical digest order. +#[must_use] +pub fn segment_pool_identity(inventory: &BTreeMap) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(SEGMENT_POOL_DOMAIN); + hasher.update( + &u32::try_from(inventory.len()) + .unwrap_or(u32::MAX) + .to_be_bytes(), + ); + for (digest, length) in inventory { + hasher.update(digest.as_bytes()); + hasher.update(&length.to_be_bytes()); + } + *hasher.finalize().as_bytes() +} + +/// The identity of one admitted disposition set. +/// +/// The registered empty-set digest for no receipts, otherwise BLAKE3-256 +/// under the registered disposition-set domain over the count and every +/// receipt checksum in canonical order. +pub fn disposition_set_digest(checksums: &BTreeSet<[u8; 32]>) -> DispositionSetDigest { + if checksums.is_empty() { + return DispositionSetDigest::new( + *crate::adapters::store_migration::empty_disposition_digest().as_bytes(), + ); + } + let mut hasher = blake3::Hasher::new(); + hasher.update(DISPOSITION_SET_DOMAIN); + hasher.update( + &u32::try_from(checksums.len()) + .unwrap_or(u32::MAX) + .to_be_bytes(), + ); + for checksum in checksums { + hasher.update(checksum); + } + DispositionSetDigest::new(*hasher.finalize().as_bytes()) +} + +/// The proof that the catalog successor names no candidate: BLAKE3-256 +/// under the registered proof domain over the catalog generation, catalog +/// digest, and the canonical candidate-set digest. +pub fn catalog_successor_proof( + generation: CatalogGeneration, + digest: CatalogDigest, + candidate_set: GcCandidateSetDigest, +) -> CatalogSuccessorProofDigest { + let mut hasher = blake3::Hasher::new(); + hasher.update(CATALOG_SUCCESSOR_PROOF_DOMAIN); + hasher.update(&generation.get().to_be_bytes()); + hasher.update(digest.as_bytes()); + hasher.update(candidate_set.as_bytes()); + CatalogSuccessorProofDigest::new(*hasher.finalize().as_bytes()) +} + +/// What execution observed beyond the plan before deriving its intent. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GcIntentEvidence { + /// The retirement generation: the successor of the last receipt's, or + /// the initial generation. + pub generation: GcGeneration, + /// The exact retained realization profile. + pub profile: RegisteredRetentionProfile, + /// The exclusively locked `reader.lock`. + pub reader_lock: ReaderLockIdentity, +} + +/// Why a plan yields no intent. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GcIntentDerivationError { + /// A candidate carries no release evidence in the snapshot. + MissingEvidence { + /// The candidate. + segment: SegmentDigest, + }, + /// The plan was computed under an empty retention state; retirement + /// needs a published liveness generation to bind. + EmptyRetention, + /// The candidate set refused as an intent. + Intent(GcRetirementIntentError), +} + +impl fmt::Display for GcIntentDerivationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingEvidence { .. } => { + formatter.write_str("a candidate carries no release evidence") + } + Self::EmptyRetention => { + formatter.write_str("retirement needs a published retention head to bind") + } + Self::Intent(source) => write!(formatter, "{source}"), + } + } +} + +impl std::error::Error for GcIntentDerivationError {} + +/// Derives the one canonical intent for `plan` from the snapshot's release +/// evidence and `evidence` execution observed. +/// +/// The intent's candidate set is the plan's candidates in canonical order, +/// each bound to the digest of the durable record that released it. The +/// catalog-successor proof, pool identity, and disposition-set digests are +/// the registered derivations above over the plan's own snapshot. +/// +/// # Errors +/// +/// Returns [`GcIntentDerivationError`] when a candidate lacks evidence, the +/// retention state is empty, or the candidate set refuses (an empty plan is +/// not an intent). +pub fn derive_gc_intent( + plan: &GcPlan, + snapshot: &super::GcLivenessSnapshot, + evidence: GcIntentEvidence, +) -> Result { + let coordinates = plan.coordinates(); + let (liveness_generation, manifest_digest) = match coordinates.retention() { + super::GcRetentionState::Empty => return Err(GcIntentDerivationError::EmptyRetention), + super::GcRetentionState::Published { + generation, + manifest_digest, + } => (generation, manifest_digest), + }; + let mut candidates = Vec::new(); + for candidate in plan.candidates() { + let release = snapshot + .release_evidence(candidate.segment()) + .ok_or_else(|| GcIntentDerivationError::MissingEvidence { + segment: candidate.segment(), + })?; + candidates.push(GcCandidate::new( + candidate.segment(), + candidate.length(), + release, + )); + } + let candidate_set = super::intent_encoder::candidate_set_digest(&candidates); + let intent_coordinates = GcRetirementIntentCoordinates { + generation: evidence.generation, + liveness_generation: bound_liveness(liveness_generation), + manifest_digest: bound_manifest(manifest_digest), + catalog_generation: coordinates.catalog_generation(), + catalog_digest: coordinates.catalog_digest(), + profile: evidence.profile, + catalog_successor_proof_digest: catalog_successor_proof( + coordinates.catalog_generation(), + coordinates.catalog_digest(), + candidate_set, + ), + segment_pool_identity_digest: SegmentPoolIdentityDigest::new(segment_pool_identity( + snapshot.inventory(), + )), + disposition_set_digest: disposition_set_digest(snapshot.disposition_checksums()), + reader_lock: evidence.reader_lock, + }; + GcRetirementIntent::new(intent_coordinates, candidates).map_err(GcIntentDerivationError::Intent) +} + +const fn bound_liveness(generation: LivenessGeneration) -> LivenessGeneration { + generation +} + +const fn bound_manifest(digest: RetentionManifestDigest) -> RetentionManifestDigest { + digest +} + +/// The pool state after every candidate is absent: the pool identity over +/// the inventory minus the candidates. +pub fn post_retirement_pool_state( + inventory: &BTreeMap, + retired: &[GcCandidate], +) -> PoolStateDigest { + let remaining: BTreeMap = inventory + .iter() + .filter(|(digest, _)| !retired.iter().any(|c| c.segment_digest() == **digest)) + .map(|(digest, length)| (*digest, *length)) + .collect(); + PoolStateDigest::new(segment_pool_identity(&remaining)) +} diff --git a/src/adapters/gc/segment_pool_inventory.rs b/src/adapters/gc/segment_pool_inventory.rs index 4159edd3..a0b42e13 100644 --- a/src/adapters/gc/segment_pool_inventory.rs +++ b/src/adapters/gc/segment_pool_inventory.rs @@ -68,6 +68,18 @@ fn parse_name(name: &str) -> Result { Ok(SegmentDigest::from_validated(bytes)) } +/// Reads and admits one named pool entry, requiring it to hash to +/// `expected`; execution calls this before unlinking a candidate. +pub(super) fn admit_entry( + segments: &Dir, + name: &str, + expected: SegmentDigest, + length: u64, + policy: SegmentReadPolicy, +) -> Result<(), Error> { + admit(segments, name, expected, length, policy) +} + fn admit( segments: &Dir, name: &str, diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 2d817793..2bc5dd9f 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -32,6 +32,8 @@ mod filesystem_retention_current; #[cfg(test)] mod filesystem_retention_current_tests; mod filesystem_retention_disposition; +mod filesystem_retention_disposition_evidence; +mod filesystem_retention_disposition_storage; #[cfg(test)] mod filesystem_retention_disposition_tests; #[cfg(test)] @@ -159,6 +161,7 @@ pub use filesystem_retention_recovery_error::FilesystemRetentionRecoveryError; pub use filesystem_retention_refusal::RetentionCurrentStateRefusal; pub use filesystem_retention_snapshot::FilesystemRetentionSnapshot; pub use filesystem_retention_snapshot_error::FilesystemRetentionSnapshotError; +pub(in crate::adapters) use filesystem_retention_stage::FilesystemRetentionStage; pub use head_decode_error::RetentionHeadDecodeError; pub use manifest_decode_error::RetentionManifestDecodeError; pub use manifest_encode_error::RetentionManifestEncodeError; diff --git a/src/adapters/retention/filesystem_retention_disposition.rs b/src/adapters/retention/filesystem_retention_disposition.rs index 56fe8395..438745c0 100644 --- a/src/adapters/retention/filesystem_retention_disposition.rs +++ b/src/adapters/retention/filesystem_retention_disposition.rs @@ -1,34 +1,34 @@ -//! This module owns filesystem execution of one explicit disposition under -//! writer authority and the exclusive reader fence. +//! This module owns filesystem planning and resumption of one explicit +//! disposition under writer authority and the exclusive reader fence; the +//! phase effects live in `filesystem_retention_disposition_storage`. use std::error::Error; use std::fmt; -use std::io::{self, Read}; +use std::io; use cap_fs_ext::DirExt; use cap_std::fs::Dir; use super::filesystem_retention_authority::FilesystemRetentionPublicationAuthority; use super::filesystem_retention_current::{self, read_exact_optional}; +use super::filesystem_retention_disposition_evidence::{ + discard_stage, disposed_artifact, entry_with_suffix, pool_identity, read_bounded, receipt_for, +}; use super::filesystem_retention_pool_name as pool_name; use super::filesystem_retention_recovery::RetentionRecoveryContext; use super::filesystem_retention_recovery_observation::RetentionRecoveryObservation; use super::filesystem_retention_stage::{FilesystemRetentionStage, invalid_data}; use super::{ - AdmittedRetentionManifest, AdmittedRetentionRoot, FilesystemRetentionRecoveryError, - ReaderFence, RecoveryDispositionError, RecoveryDispositionPhase, RecoveryDispositionPlan, - RecoveryDispositionRefusal, RecoveryDispositionRequest, RecoveryDispositionStorage, - RecoveryDispositionTarget, RetentionRecoveryStorage, plan_recovery_disposition, + FilesystemRetentionRecoveryError, ReaderFence, RecoveryDispositionError, + RecoveryDispositionPhase, RecoveryDispositionPlan, RecoveryDispositionRefusal, + RecoveryDispositionRequest, RecoveryDispositionTarget, plan_recovery_disposition, plan_retention_recovery, resume_recovery_disposition, }; -use crate::adapters::filesystem_catalog_artifact::synchronize_directory; -use crate::adapters::filesystem_exact_record as exact_record; use crate::adapters::{ AdmittedRecoveryDispositionReceipt, ArtifactIdentityDigest, - CanonicalRecoveryDispositionReceipt, ChecksummedPublicationHead, DecisionEvidenceDigest, - GcRetentionState, ObservedHeadChecksum, ReaderLockIdentity, RecoveryArtifactKind, - RecoveryClassification, RecoveryDispositionArtifact, RecoveryDispositionCoordinates, - RecoveryDispositionDecision, RecoveryDispositionReceipt, filesystem_platform_profile, + CanonicalRecoveryDispositionReceipt, ChecksummedPublicationHead, GcRetentionState, + ObservedHeadChecksum, ReaderLockIdentity, RecoveryArtifactKind, RecoveryDispositionCoordinates, + RecoveryDispositionDecision, filesystem_platform_profile, }; const HEAD_NAME: &str = "HEAD"; @@ -108,15 +108,13 @@ impl Error for FilesystemRetentionDispositionError { } /// Where a disposed artifact lives in its immutable pool. -enum PoolEntry { +pub(super) enum PoolEntry { Root { namespace: String, name: String }, Manifest { name: String }, } /// A located immutable pool entry with its complete bytes. -type LocatedEntry = (PoolEntry, Box<[u8]>); -/// A directory entry name with its complete bytes. -type NamedEntry = (String, Box<[u8]>); +pub(super) type LocatedEntry = (PoolEntry, Box<[u8]>); /// What a planned disposition binds before its context opens. struct DispositionInputs { @@ -128,15 +126,15 @@ struct DispositionInputs { /// Everything one disposition run holds between phases. pub(super) struct DispositionContext { - target: RecoveryDispositionTarget, - decision: RecoveryDispositionDecision, - receipt: CanonicalRecoveryDispositionReceipt, - artifact: Box<[u8]>, - pool: PoolEntry, - name: String, - recovery: Dir, - dispositions: Dir, - stage: Option, + pub(super) target: RecoveryDispositionTarget, + pub(super) decision: RecoveryDispositionDecision, + pub(super) receipt: CanonicalRecoveryDispositionReceipt, + pub(super) artifact: Box<[u8]>, + pub(super) pool: PoolEntry, + pub(super) name: String, + pub(super) recovery: Dir, + pub(super) dispositions: Dir, + pub(super) stage: Option, _fence: ReaderFence, } @@ -421,7 +419,7 @@ fn resume_phase( } } -const fn observe(source: io::Error) -> FilesystemRetentionDispositionError { +pub(super) const fn observe(source: io::Error) -> FilesystemRetentionDispositionError { FilesystemRetentionDispositionError::Observe { source } } @@ -435,255 +433,6 @@ fn acquire_fence(root: &Dir) -> Result io::Result<(Box<[u8]>, PoolEntry)> { - match target { - RecoveryDispositionTarget::Root => { - let bytes = observation - .root() - .ok_or_else(|| invalid_data("disposition target root stage vanished"))? - .bytes - .clone(); - let root = AdmittedRetentionRoot::decode(&bytes).map_err(invalid_data_from)?; - let pool = PoolEntry::Root { - namespace: pool_name::namespace(root.root().namespace().digest()), - name: pool_name::root(root.root().generation(), root.digest()), - }; - Ok((bytes, pool)) - } - RecoveryDispositionTarget::Manifest => { - let bytes = observation - .manifest() - .ok_or_else(|| invalid_data("disposition target manifest stage vanished"))? - .bytes - .clone(); - let manifest = AdmittedRetentionManifest::decode(&bytes).map_err(invalid_data_from)?; - let pool = PoolEntry::Manifest { - name: pool_name::manifest(manifest.manifest().generation(), manifest.digest()), - }; - Ok((bytes, pool)) - } - } -} - -/// The artifact's pool-name digest: the identity the receipt is filed under. -fn pool_identity( - pool: &PoolEntry, - artifact: &[u8], -) -> Result<(RecoveryArtifactKind, ArtifactIdentityDigest), FilesystemRetentionDispositionError> { - match pool { - PoolEntry::Root { .. } => { - let root = AdmittedRetentionRoot::decode(artifact) - .map_err(|source| observe(invalid_data_from(source)))?; - Ok(( - RecoveryArtifactKind::RetentionRoot, - ArtifactIdentityDigest::new(*root.digest().as_bytes()), - )) - } - PoolEntry::Manifest { .. } => { - let manifest = AdmittedRetentionManifest::decode(artifact) - .map_err(|source| observe(invalid_data_from(source)))?; - Ok(( - RecoveryArtifactKind::RetentionManifest, - ArtifactIdentityDigest::new(*manifest.digest().as_bytes()), - )) - } - } -} - -fn receipt_for( - artifact: &[u8], - (kind, identity): (RecoveryArtifactKind, ArtifactIdentityDigest), - decision: RecoveryDispositionDecision, - coordinates: RecoveryDispositionCoordinates, -) -> io::Result { - let disposed = RecoveryDispositionArtifact { - kind, - classification: RecoveryClassification::CompleteOrphan, - length: u64::try_from(artifact.len()).map_err(invalid_data_from)?, - identity_digest: identity, - content_digest: CanonicalRecoveryDispositionReceipt::artifact_content_digest(artifact), - }; - let evidence = DecisionEvidenceDigest::new(trailing_checksum(artifact)?); - Ok(CanonicalRecoveryDispositionReceipt::from_receipt( - &RecoveryDispositionReceipt::new(disposed, decision, coordinates, evidence), - )) -} - -/// The artifact record's trailing checksum: the evidence the decision was -/// made over. -fn trailing_checksum(bytes: &[u8]) -> io::Result<[u8; 32]> { - let start = bytes - .len() - .checked_sub(32) - .ok_or_else(|| invalid_data("artifact is shorter than its checksum"))?; - bytes - .get(start..) - .and_then(|slice| slice.try_into().ok()) - .ok_or_else(|| invalid_data("artifact checksum slot")) -} - -fn invalid_data_from(error: impl Error + Send + Sync + 'static) -> io::Error { +pub(super) fn invalid_data_from(error: impl Error + Send + Sync + 'static) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, error) } - -/// Reads `recovery/disposition.next` up to one byte past the receipt length. -fn read_bounded(recovery: &Dir, name: &str) -> io::Result>> { - let mut file = match exact_record::open_read(recovery, name) { - Ok(file) => file, - Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None), - Err(source) => return Err(source), - }; - if !file.metadata()?.is_file() { - return Err(invalid_data("disposition stage is not a regular file")); - } - let mut bytes = Vec::new(); - let limit = u64::try_from(RECEIPT_LENGTH) - .map_err(invalid_data_from)? - .saturating_add(1); - file.by_ref().take(limit).read_to_end(&mut bytes)?; - Ok(Some(bytes)) -} - -/// The first regular entry of `directory` whose name ends with `suffix`, -/// with its complete bytes. -fn entry_with_suffix(directory: &Dir, suffix: &str) -> io::Result> { - for entry in directory.entries()? { - let name = entry?.file_name().to_string_lossy().into_owned(); - if !name.ends_with(suffix) { - continue; - } - let metadata = directory.symlink_metadata(&name)?; - if !metadata.is_file() { - return Err(invalid_data("retention pool entry is not a regular file")); - } - let length = usize::try_from(metadata.len()).map_err(invalid_data_from)?; - let bytes = read_exact_optional(directory, &name, length)? - .ok_or_else(|| invalid_data("retention pool entry vanished"))?; - return Ok(Some((name, bytes))); - } - Ok(None) -} - -fn discard_stage(recovery: &Dir) -> io::Result<()> { - recovery.remove_file(pool_name::DISPOSITION_STAGE)?; - exact_record::require_absent(recovery, pool_name::DISPOSITION_STAGE) - .map_err(|_source| invalid_data("discarded disposition stage remained visible"))?; - synchronize_directory(recovery) -} - -fn no_disposition() -> io::Error { - invalid_data("no disposition is in progress") -} - -/// Removes `name` from `directory` after proving it still holds `expected`. -fn unlink_verified(directory: &Dir, name: &str, expected: &[u8]) -> io::Result<()> { - let observed = read_exact_optional(directory, name, expected.len())? - .ok_or_else(|| invalid_data("retired pool entry is already absent"))?; - if observed.as_ref() != expected { - return Err(invalid_data( - "retired pool entry bytes disagree with the receipt", - )); - } - directory.remove_file(name)?; - exact_record::require_absent(directory, name) - .map_err(|_source| invalid_data("retired pool entry remained visible")) -} - -impl RecoveryDispositionStorage for FilesystemRetentionPublicationAuthority { - fn write_disposition_stage(&mut self) -> io::Result<()> { - let context = self.disposition.as_mut().ok_or_else(no_disposition)?; - context.stage = Some(FilesystemRetentionStage::create( - &context.recovery, - pool_name::DISPOSITION_STAGE, - context.receipt.encoded(), - )?); - Ok(()) - } - - fn synchronize_disposition_stage(&mut self) -> io::Result<()> { - let context = self.disposition.as_mut().ok_or_else(no_disposition)?; - if context.stage.is_none() { - context.stage = Some(FilesystemRetentionStage::reopen( - &context.recovery, - pool_name::DISPOSITION_STAGE, - context.receipt.encoded(), - )?); - } - let stage = context.stage.as_ref().ok_or_else(no_disposition)?; - stage.synchronize(&context.recovery) - } - - fn link_disposition_receipt(&mut self) -> io::Result<()> { - let context = self.disposition.as_ref().ok_or_else(no_disposition)?; - let stage = context.stage.as_ref().ok_or_else(no_disposition)?; - stage.link(&context.recovery, &context.dispositions, &context.name) - } - - fn synchronize_dispositions(&mut self) -> io::Result<()> { - let context = self.disposition.as_ref().ok_or_else(no_disposition)?; - synchronize_directory(&context.dispositions) - } - - fn remove_disposition_stage(&mut self) -> io::Result<()> { - let context = self.disposition.as_mut().ok_or_else(no_disposition)?; - if context.stage.is_none() { - context.stage = Some(FilesystemRetentionStage::reopen( - &context.recovery, - pool_name::DISPOSITION_STAGE, - context.receipt.encoded(), - )?); - } - let stage = context.stage.take().ok_or_else(no_disposition)?; - stage.remove(&context.recovery, &context.dispositions, &context.name) - } - - fn synchronize_recovery(&mut self) -> io::Result<()> { - let context = self.disposition.as_ref().ok_or_else(no_disposition)?; - synchronize_directory(&context.recovery) - } - - fn remove_retained_stage(&mut self) -> io::Result<()> { - let target = self.disposition.as_ref().ok_or_else(no_disposition)?.target; - match target { - RecoveryDispositionTarget::Root => RetentionRecoveryStorage::remove_root_stage(self), - RecoveryDispositionTarget::Manifest => { - RetentionRecoveryStorage::remove_manifest_stage(self) - } - } - } - - fn synchronize_retention_after_disposition(&mut self) -> io::Result<()> { - synchronize_directory(&self.retention) - } - - fn remove_pool_entry(&mut self) -> io::Result<()> { - let context = self.disposition.as_ref().ok_or_else(no_disposition)?; - match &context.pool { - PoolEntry::Root { namespace, name } => { - let directory = self.roots.open_dir_nofollow(namespace)?; - unlink_verified(&directory, name, &context.artifact)?; - synchronize_directory(&directory)?; - if directory.entries()?.next().is_none() { - drop(directory); - self.roots.remove_dir(namespace)?; - } - Ok(()) - } - PoolEntry::Manifest { name } => { - unlink_verified(&self.manifests, name, &context.artifact) - } - } - } - - fn synchronize_pool(&mut self) -> io::Result<()> { - let context = self.disposition.as_ref().ok_or_else(no_disposition)?; - match context.pool { - PoolEntry::Root { .. } => synchronize_directory(&self.roots), - PoolEntry::Manifest { .. } => synchronize_directory(&self.manifests), - } - } -} diff --git a/src/adapters/retention/filesystem_retention_disposition_evidence.rs b/src/adapters/retention/filesystem_retention_disposition_evidence.rs new file mode 100644 index 00000000..106cd582 --- /dev/null +++ b/src/adapters/retention/filesystem_retention_disposition_evidence.rs @@ -0,0 +1,163 @@ +//! This module owns the evidence one disposition binds: the disposed +//! artifact, its pool identity, its receipt, and the residue reads. + +use std::io::{self, Read}; + +use cap_std::fs::Dir; + +use super::filesystem_retention_current::read_exact_optional; +use super::filesystem_retention_disposition::{ + FilesystemRetentionDispositionError, PoolEntry, invalid_data_from, observe, +}; +use super::filesystem_retention_pool_name as pool_name; +use super::filesystem_retention_recovery_observation::RetentionRecoveryObservation; +use super::filesystem_retention_stage::invalid_data; +use super::{AdmittedRetentionManifest, AdmittedRetentionRoot, RecoveryDispositionTarget}; +use crate::adapters::filesystem_catalog_artifact::synchronize_directory; +use crate::adapters::filesystem_exact_record as exact_record; +use crate::adapters::{ + ArtifactIdentityDigest, CanonicalRecoveryDispositionReceipt, DecisionEvidenceDigest, + RecoveryArtifactKind, RecoveryClassification, RecoveryDispositionArtifact, + RecoveryDispositionCoordinates, RecoveryDispositionDecision, RecoveryDispositionReceipt, +}; + +const RECEIPT_LENGTH: usize = 320; + +/// A directory entry name with its complete bytes. +type NamedEntry = (String, Box<[u8]>); + +/// The retained stage's exact bytes and the pool entry recovery linked them to. +pub(super) fn disposed_artifact( + observation: &RetentionRecoveryObservation, + target: RecoveryDispositionTarget, +) -> io::Result<(Box<[u8]>, PoolEntry)> { + match target { + RecoveryDispositionTarget::Root => { + let bytes = observation + .root() + .ok_or_else(|| invalid_data("disposition target root stage vanished"))? + .bytes + .clone(); + let root = AdmittedRetentionRoot::decode(&bytes).map_err(invalid_data_from)?; + let pool = PoolEntry::Root { + namespace: pool_name::namespace(root.root().namespace().digest()), + name: pool_name::root(root.root().generation(), root.digest()), + }; + Ok((bytes, pool)) + } + RecoveryDispositionTarget::Manifest => { + let bytes = observation + .manifest() + .ok_or_else(|| invalid_data("disposition target manifest stage vanished"))? + .bytes + .clone(); + let manifest = AdmittedRetentionManifest::decode(&bytes).map_err(invalid_data_from)?; + let pool = PoolEntry::Manifest { + name: pool_name::manifest(manifest.manifest().generation(), manifest.digest()), + }; + Ok((bytes, pool)) + } + } +} + +/// The artifact's pool-name digest: the identity the receipt is filed under. +pub(super) fn pool_identity( + pool: &PoolEntry, + artifact: &[u8], +) -> Result<(RecoveryArtifactKind, ArtifactIdentityDigest), FilesystemRetentionDispositionError> { + match pool { + PoolEntry::Root { .. } => { + let root = AdmittedRetentionRoot::decode(artifact) + .map_err(|source| observe(invalid_data_from(source)))?; + Ok(( + RecoveryArtifactKind::RetentionRoot, + ArtifactIdentityDigest::new(*root.digest().as_bytes()), + )) + } + PoolEntry::Manifest { .. } => { + let manifest = AdmittedRetentionManifest::decode(artifact) + .map_err(|source| observe(invalid_data_from(source)))?; + Ok(( + RecoveryArtifactKind::RetentionManifest, + ArtifactIdentityDigest::new(*manifest.digest().as_bytes()), + )) + } + } +} + +pub(super) fn receipt_for( + artifact: &[u8], + (kind, identity): (RecoveryArtifactKind, ArtifactIdentityDigest), + decision: RecoveryDispositionDecision, + coordinates: RecoveryDispositionCoordinates, +) -> io::Result { + let disposed = RecoveryDispositionArtifact { + kind, + classification: RecoveryClassification::CompleteOrphan, + length: u64::try_from(artifact.len()).map_err(invalid_data_from)?, + identity_digest: identity, + content_digest: CanonicalRecoveryDispositionReceipt::artifact_content_digest(artifact), + }; + let evidence = DecisionEvidenceDigest::new(trailing_checksum(artifact)?); + Ok(CanonicalRecoveryDispositionReceipt::from_receipt( + &RecoveryDispositionReceipt::new(disposed, decision, coordinates, evidence), + )) +} + +/// The artifact record's trailing checksum: the evidence the decision was +/// made over. +pub(super) fn trailing_checksum(bytes: &[u8]) -> io::Result<[u8; 32]> { + let start = bytes + .len() + .checked_sub(32) + .ok_or_else(|| invalid_data("artifact is shorter than its checksum"))?; + bytes + .get(start..) + .and_then(|slice| slice.try_into().ok()) + .ok_or_else(|| invalid_data("artifact checksum slot")) +} + +/// The first regular entry of `directory` whose name ends with `suffix`, +/// with its complete bytes. +pub(super) fn entry_with_suffix(directory: &Dir, suffix: &str) -> io::Result> { + for entry in directory.entries()? { + let name = entry?.file_name().to_string_lossy().into_owned(); + if !name.ends_with(suffix) { + continue; + } + let metadata = directory.symlink_metadata(&name)?; + if !metadata.is_file() { + return Err(invalid_data("retention pool entry is not a regular file")); + } + let length = usize::try_from(metadata.len()).map_err(invalid_data_from)?; + let bytes = read_exact_optional(directory, &name, length)? + .ok_or_else(|| invalid_data("retention pool entry vanished"))?; + return Ok(Some((name, bytes))); + } + Ok(None) +} + +/// Reads `recovery/disposition.next` up to one byte past the receipt length. +pub(super) fn read_bounded(recovery: &Dir, name: &str) -> io::Result>> { + let mut file = match exact_record::open_read(recovery, name) { + Ok(file) => file, + Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(source) => return Err(source), + }; + if !file.metadata()?.is_file() { + return Err(invalid_data("disposition stage is not a regular file")); + } + let mut bytes = Vec::new(); + let limit = u64::try_from(RECEIPT_LENGTH) + .map_err(invalid_data_from)? + .saturating_add(1); + file.by_ref().take(limit).read_to_end(&mut bytes)?; + Ok(Some(bytes)) +} + +pub(super) fn discard_stage(recovery: &Dir) -> io::Result<()> { + recovery.remove_file(pool_name::DISPOSITION_STAGE)?; + exact_record::require_absent(recovery, pool_name::DISPOSITION_STAGE) + .map_err(|_source| invalid_data("discarded disposition stage remained visible"))?; + synchronize_directory(recovery) +} diff --git a/src/adapters/retention/filesystem_retention_disposition_storage.rs b/src/adapters/retention/filesystem_retention_disposition_storage.rs new file mode 100644 index 00000000..8ead6862 --- /dev/null +++ b/src/adapters/retention/filesystem_retention_disposition_storage.rs @@ -0,0 +1,128 @@ +//! This module owns the filesystem effect of every disposition phase. + +use std::io; + +use cap_fs_ext::DirExt; +use cap_std::fs::Dir; + +use super::filesystem_retention_authority::FilesystemRetentionPublicationAuthority; +use super::filesystem_retention_current::read_exact_optional; +use super::filesystem_retention_disposition::PoolEntry; +use super::filesystem_retention_pool_name as pool_name; +use super::filesystem_retention_stage::{FilesystemRetentionStage, invalid_data}; +use super::{RecoveryDispositionStorage, RecoveryDispositionTarget, RetentionRecoveryStorage}; +use crate::adapters::filesystem_catalog_artifact::synchronize_directory; +use crate::adapters::filesystem_exact_record as exact_record; + +fn no_disposition() -> io::Error { + invalid_data("no disposition is in progress") +} + +/// Removes `name` from `directory` after proving it still holds `expected`. +fn unlink_verified(directory: &Dir, name: &str, expected: &[u8]) -> io::Result<()> { + let observed = read_exact_optional(directory, name, expected.len())? + .ok_or_else(|| invalid_data("retired pool entry is already absent"))?; + if observed.as_ref() != expected { + return Err(invalid_data( + "retired pool entry bytes disagree with the receipt", + )); + } + directory.remove_file(name)?; + exact_record::require_absent(directory, name) + .map_err(|_source| invalid_data("retired pool entry remained visible")) +} + +impl RecoveryDispositionStorage for FilesystemRetentionPublicationAuthority { + fn write_disposition_stage(&mut self) -> io::Result<()> { + let context = self.disposition.as_mut().ok_or_else(no_disposition)?; + context.stage = Some(FilesystemRetentionStage::create( + &context.recovery, + pool_name::DISPOSITION_STAGE, + context.receipt.encoded(), + )?); + Ok(()) + } + + fn synchronize_disposition_stage(&mut self) -> io::Result<()> { + let context = self.disposition.as_mut().ok_or_else(no_disposition)?; + if context.stage.is_none() { + context.stage = Some(FilesystemRetentionStage::reopen( + &context.recovery, + pool_name::DISPOSITION_STAGE, + context.receipt.encoded(), + )?); + } + let stage = context.stage.as_ref().ok_or_else(no_disposition)?; + stage.synchronize(&context.recovery) + } + + fn link_disposition_receipt(&mut self) -> io::Result<()> { + let context = self.disposition.as_ref().ok_or_else(no_disposition)?; + let stage = context.stage.as_ref().ok_or_else(no_disposition)?; + stage.link(&context.recovery, &context.dispositions, &context.name) + } + + fn synchronize_dispositions(&mut self) -> io::Result<()> { + let context = self.disposition.as_ref().ok_or_else(no_disposition)?; + synchronize_directory(&context.dispositions) + } + + fn remove_disposition_stage(&mut self) -> io::Result<()> { + let context = self.disposition.as_mut().ok_or_else(no_disposition)?; + if context.stage.is_none() { + context.stage = Some(FilesystemRetentionStage::reopen( + &context.recovery, + pool_name::DISPOSITION_STAGE, + context.receipt.encoded(), + )?); + } + let stage = context.stage.take().ok_or_else(no_disposition)?; + stage.remove(&context.recovery, &context.dispositions, &context.name) + } + + fn synchronize_recovery(&mut self) -> io::Result<()> { + let context = self.disposition.as_ref().ok_or_else(no_disposition)?; + synchronize_directory(&context.recovery) + } + + fn remove_retained_stage(&mut self) -> io::Result<()> { + let target = self.disposition.as_ref().ok_or_else(no_disposition)?.target; + match target { + RecoveryDispositionTarget::Root => RetentionRecoveryStorage::remove_root_stage(self), + RecoveryDispositionTarget::Manifest => { + RetentionRecoveryStorage::remove_manifest_stage(self) + } + } + } + + fn synchronize_retention_after_disposition(&mut self) -> io::Result<()> { + synchronize_directory(&self.retention) + } + + fn remove_pool_entry(&mut self) -> io::Result<()> { + let context = self.disposition.as_ref().ok_or_else(no_disposition)?; + match &context.pool { + PoolEntry::Root { namespace, name } => { + let directory = self.roots.open_dir_nofollow(namespace)?; + unlink_verified(&directory, name, &context.artifact)?; + synchronize_directory(&directory)?; + if directory.entries()?.next().is_none() { + drop(directory); + self.roots.remove_dir(namespace)?; + } + Ok(()) + } + PoolEntry::Manifest { name } => { + unlink_verified(&self.manifests, name, &context.artifact) + } + } + } + + fn synchronize_pool(&mut self) -> io::Result<()> { + let context = self.disposition.as_ref().ok_or_else(no_disposition)?; + match context.pool { + PoolEntry::Root { .. } => synchronize_directory(&self.roots), + PoolEntry::Manifest { .. } => synchronize_directory(&self.manifests), + } + } +} diff --git a/src/adapters/retention/filesystem_retention_refusal.rs b/src/adapters/retention/filesystem_retention_refusal.rs index 56fd633d..ba5addfb 100644 --- a/src/adapters/retention/filesystem_retention_refusal.rs +++ b/src/adapters/retention/filesystem_retention_refusal.rs @@ -22,6 +22,9 @@ use crate::{CatalogGeneration, LivenessGeneration, RetentionManifestDigest}; pub enum RetentionCurrentStateRefusal { /// A retained `root.next`, `manifest.next`, or `head.next` exists. RetainedStage, + /// A durable `gc/intent` exists: a retirement is in progress and must be + /// recovered before any retention transition. + GcIntentRetained, /// `retention/HEAD` is absent while a pool holds artifacts. HeadAbsentWithArtifacts, /// `retention/HEAD` is absent but a current generation was expected. @@ -192,6 +195,7 @@ impl RetentionCurrentStateRefusal { const fn message(&self) -> &'static str { match self { Self::RetainedStage => "retained retention stage requires recovery before publication", + Self::GcIntentRetained => "a durable GC intent requires GC recovery before publication", Self::HeadAbsentWithArtifacts => { "retention head is absent while retention pools hold artifacts; recovery is \ required" diff --git a/src/adapters/retention/filesystem_retention_snapshot.rs b/src/adapters/retention/filesystem_retention_snapshot.rs index 131947ec..fd968256 100644 --- a/src/adapters/retention/filesystem_retention_snapshot.rs +++ b/src/adapters/retention/filesystem_retention_snapshot.rs @@ -30,7 +30,7 @@ const HEAD_NAME: &str = "HEAD"; /// demand and verified against the manifest's digest before they are returned. #[must_use] pub struct FilesystemRetentionSnapshot { - _fence: ReaderFence, + _fence: Option, roots: Dir, catalog: FilesystemCatalogSnapshot, retention: Option, @@ -92,6 +92,34 @@ impl FilesystemRetentionSnapshot { store_root: &Path, policy: CatalogRestartPolicy, limit: ReaderAttemptLimit, + ) -> Result { + Self::load_with(store_root, policy, limit, true) + } + + /// Admits the root as version two and double-collects one consistent + /// view without acquiring the reader fence, for a caller that already + /// holds the fence exclusively under writer authority. + /// + /// The view protects nothing by itself: the caller's exclusive fence is + /// what excludes collection, and the caller's writer authority is what + /// excludes publication, for as long as both are held. + /// + /// # Errors + /// + /// As [`Self::load`], without the fence refusal. + pub(in crate::adapters) fn load_under_writer_authority( + store_root: &Path, + policy: CatalogRestartPolicy, + limit: ReaderAttemptLimit, + ) -> Result { + Self::load_with(store_root, policy, limit, false) + } + + fn load_with( + store_root: &Path, + policy: CatalogRestartPolicy, + limit: ReaderAttemptLimit, + fenced: bool, ) -> Result { let root = Dir::open_ambient_dir(store_root, cap_std::ambient_authority()) .map_err(|source| Error::Admission { source })?; @@ -99,7 +127,10 @@ impl FilesystemRetentionSnapshot { .map_err(|source| Error::Admission { source })?; let _bound = filesystem_version_two_records::admit(&root) .map_err(|source| Error::Admission { source })?; - let fence = ReaderFence::acquire(&root).map_err(|source| Error::Fence { source })?; + let fence = fenced + .then(|| ReaderFence::acquire(&root)) + .transpose() + .map_err(|source| Error::Fence { source })?; let retention = root .open_dir_nofollow(pool_name::RETENTION) .map_err(|source| Error::Admission { source })?; diff --git a/src/adapters/retention/filesystem_retention_stage.rs b/src/adapters/retention/filesystem_retention_stage.rs index 0167b8ce..6951a4c2 100644 --- a/src/adapters/retention/filesystem_retention_stage.rs +++ b/src/adapters/retention/filesystem_retention_stage.rs @@ -15,7 +15,7 @@ use crate::adapters::filesystem_exact_record::{ /// for its whole lifetime. Every transition reverifies both the handle and the /// named entry, so a replaced or byte-equal substituted file refuses instead of /// being admitted. -pub(super) struct FilesystemRetentionStage { +pub(in crate::adapters) struct FilesystemRetentionStage { name: &'static str, expected: Box<[u8]>, identity: EntryIdentity, @@ -24,7 +24,11 @@ pub(super) struct FilesystemRetentionStage { impl FilesystemRetentionStage { /// Exclusively creates the named stage and writes its complete bytes. - pub(super) fn create(root: &Dir, name: &'static str, expected: &[u8]) -> io::Result { + pub(in crate::adapters) fn create( + root: &Dir, + name: &'static str, + expected: &[u8], + ) -> io::Result { let mut file = filesystem_catalog_artifact::create_exclusive(root, name)?; let identity = EntryIdentity::of_file(&file)?; file.write_all(expected)?; @@ -42,7 +46,11 @@ impl FilesystemRetentionStage { /// The handle and the named entry are verified against `expected` and /// bound to the entry's identity, so every later transition refuses a /// substituted or replaced stage exactly as a freshly created one would. - pub(super) fn reopen(root: &Dir, name: &'static str, expected: &[u8]) -> io::Result { + pub(in crate::adapters) fn reopen( + root: &Dir, + name: &'static str, + expected: &[u8], + ) -> io::Result { let file = exact_record::open_read(root, name)?; let identity = EntryIdentity::of_file(&file)?; verify_named_record(root, name, expected, identity)?; @@ -55,14 +63,14 @@ impl FilesystemRetentionStage { } /// Synchronizes the complete stage and reverifies its exact bytes. - pub(super) fn synchronize(&self, root: &Dir) -> io::Result<()> { + pub(in crate::adapters) fn synchronize(&self, root: &Dir) -> io::Result<()> { self.require_handle()?; self.file.sync_all()?; self.verify_stage(root) } /// Links the verified stage into `target` under `name` without replacement. - pub(super) fn link(&self, root: &Dir, target: &Dir, name: &str) -> io::Result<()> { + pub(in crate::adapters) fn link(&self, root: &Dir, target: &Dir, name: &str) -> io::Result<()> { self.verify_stage(root)?; exact_record::link_without_replacement(root, self.name, target, name)?; self.verify_stage(root)?; @@ -70,7 +78,12 @@ impl FilesystemRetentionStage { } /// Removes only the retained stage after confirming its linked target. - pub(super) fn remove(self, root: &Dir, target: &Dir, name: &str) -> io::Result<()> { + pub(in crate::adapters) fn remove( + self, + root: &Dir, + target: &Dir, + name: &str, + ) -> io::Result<()> { verify_named_record(target, name, &self.expected, self.identity)?; root.remove_file(self.name)?; exact_record::require_absent(root, self.name).map_err(retention_error)?; @@ -78,7 +91,7 @@ impl FilesystemRetentionStage { } /// Renames the verified stage onto `name`, replacing it atomically. - pub(super) fn replace(self, root: &Dir, name: &str) -> io::Result<()> { + pub(in crate::adapters) fn replace(self, root: &Dir, name: &str) -> io::Result<()> { self.verify_stage(root)?; root.rename(self.name, root, name)?; exact_record::require_absent(root, self.name).map_err(retention_error)?; diff --git a/src/adapters/retention/filesystem_retention_storage.rs b/src/adapters/retention/filesystem_retention_storage.rs index 95cbff8d..993dd287 100644 --- a/src/adapters/retention/filesystem_retention_storage.rs +++ b/src/adapters/retention/filesystem_retention_storage.rs @@ -45,6 +45,7 @@ impl RetentionPublicationStorage for FilesystemRetentionPublicationAuthority { return Err(RetentionCurrentStateRefusal::RetainedStage.into_io()); } require_no_retained_stage(&self.retention)?; + require_no_gc_intent(&self.root)?; let census = filesystem_retention_namespace::admit(&self.retention, &self.roots, &self.manifests)?; let current = filesystem_retention_current::observe(&self.retention, &self.manifests)?; @@ -284,6 +285,17 @@ fn require_pinned_directories( Ok(()) } +/// A durable `gc/intent` excludes retention transitions until GC recovery +/// resolves it: the intent bound the liveness generation it retires under. +fn require_no_gc_intent(root: &Dir) -> io::Result<()> { + let gc = root.open_dir_nofollow("gc")?; + match gc.symlink_metadata("intent") { + Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()), + Ok(_) => Err(RetentionCurrentStateRefusal::GcIntentRetained.into_io()), + Err(source) => Err(source), + } +} + fn require_no_retained_stage(retention: &Dir) -> io::Result<()> { for stage in [ pool_name::ROOT_STAGE, diff --git a/src/adapters/retention/reader_fence.rs b/src/adapters/retention/reader_fence.rs index 8f44c991..7a49b5b1 100644 --- a/src/adapters/retention/reader_fence.rs +++ b/src/adapters/retention/reader_fence.rs @@ -42,7 +42,7 @@ impl ReaderFence { /// Any reader holding the shared fence refuses the acquisition with /// [`io::ErrorKind::WouldBlock`]; the caller reports that readers are /// active rather than waiting on them. - pub(super) fn acquire_exclusive(root: &Dir) -> io::Result { + pub(in crate::adapters) fn acquire_exclusive(root: &Dir) -> io::Result { let file = filesystem_exact_record::open_read(root, READER_LOCK)?; verify(root, &file)?; flock(&file, FlockOperation::NonBlockingLockExclusive)?; @@ -51,7 +51,7 @@ impl ReaderFence { } /// Returns the locked file's device and inode identity. - pub(super) fn identity(&self) -> io::Result<(u64, u64)> { + pub(in crate::adapters) fn identity(&self) -> io::Result<(u64, u64)> { self.file.metadata().map(|metadata| identity(&metadata)) } } diff --git a/src/adapters/store_migration.rs b/src/adapters/store_migration.rs index f5c546d1..9ed72e1e 100644 --- a/src/adapters/store_migration.rs +++ b/src/adapters/store_migration.rs @@ -88,7 +88,9 @@ mod migration_receipt_decoder; mod migration_receipt_encoder; mod migration_receipt_format; mod migration_receipt_initial_state; -pub(in crate::adapters) use migration_receipt_initial_state::initial_retention_digest; +pub(in crate::adapters) use migration_receipt_initial_state::{ + empty_disposition_digest, initial_retention_digest, +}; mod migration_record_bytes; mod migration_recovery_ambiguity; mod migration_recovery_ambiguity_display; diff --git a/src/adapters/store_migration/format_definition_digest.rs b/src/adapters/store_migration/format_definition_digest.rs index 2eedaf80..df9ae6b2 100644 --- a/src/adapters/store_migration/format_definition_digest.rs +++ b/src/adapters/store_migration/format_definition_digest.rs @@ -8,9 +8,9 @@ pub struct StoreFormatDefinitionDigest([u8; 32]); impl StoreFormatDefinitionDigest { /// Digest of the frozen `keep.segment-store/v2` definition. pub const VERSION_TWO: Self = Self([ - 0x6c, 0xbc, 0x1c, 0x75, 0xf6, 0xef, 0xab, 0x18, 0xc7, 0xc5, 0x0a, 0xe2, 0x81, 0xed, 0xef, - 0x77, 0xa8, 0xb0, 0xc9, 0xba, 0x19, 0xf6, 0x18, 0xb2, 0x8b, 0x18, 0x48, 0x3d, 0x08, 0x46, - 0x2c, 0x92, + 0xa4, 0xa0, 0x10, 0xce, 0xe5, 0xda, 0x8a, 0xa3, 0xba, 0x15, 0x3c, 0x50, 0x34, 0xf4, 0x36, + 0xb9, 0x27, 0x42, 0xc6, 0xc1, 0xf7, 0xcf, 0x6b, 0x43, 0xd8, 0x90, 0xad, 0x5f, 0xd5, 0xb5, + 0xcf, 0x89, ]); /// Returns the raw digest bytes. diff --git a/src/adapters/store_migration/migration_receipt_initial_state.rs b/src/adapters/store_migration/migration_receipt_initial_state.rs index 8a55ecb7..efcf2d2c 100644 --- a/src/adapters/store_migration/migration_receipt_initial_state.rs +++ b/src/adapters/store_migration/migration_receipt_initial_state.rs @@ -67,7 +67,7 @@ pub(super) fn initial_gc_digest() -> InitialGcStateDigest { InitialGcStateDigest::from_hash(digest(INITIAL_GC_DOMAIN)) } -pub(super) fn empty_disposition_digest() -> EmptyDispositionSetDigest { +pub(in crate::adapters) fn empty_disposition_digest() -> EmptyDispositionSetDigest { EmptyDispositionSetDigest::from_hash(digest(EMPTY_DISPOSITION_DOMAIN)) } diff --git a/src/lib.rs b/src/lib.rs index 5c604d36..d6e01727 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -141,18 +141,23 @@ pub use adapters::{ AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, AdmittedRecoveryDispositionReceipt, ArtifactContentDigest, ArtifactIdentityDigest, CanonicalGcRetirementIntent, CanonicalGcRetirementReceipt, CanonicalRecoveryDispositionReceipt, CatalogSuccessorProofDigest, - DecisionEvidenceDigest, DispositionSetDigest, GcCandidate, GcCandidateSetDigest, GcLimits, - GcLimitsError, GcLivenessCoordinates, GcLivenessObservationError, GcLivenessSnapshot, - GcLivenessSnapshotError, GcPlan, GcPlanAmbiguity, GcPlanError, GcPlannedCandidate, - GcPlannedSegment, GcRetainedClosure, GcRetentionState, GcRetirementIntent, + DecisionEvidenceDigest, DispositionSetDigest, FilesystemGcAuthority, FilesystemGcError, + GcCandidate, GcCandidateSetDigest, GcExecutionError, GcExecutionPhase, GcExecutionPoint, + GcExecutionReceipt, GcExecutionStorage, GcFixedStage, GcIntentDerivationError, + GcIntentEvidence, GcLimits, GcLimitsError, GcLivenessCoordinates, GcLivenessObservationError, + GcLivenessSnapshot, GcLivenessSnapshotError, GcPlan, GcPlanAmbiguity, GcPlanError, + GcPlannedCandidate, GcPlannedSegment, GcRecoveryAmbiguity, GcRecoveryPlan, GcRecoveryReport, + GcResidue, GcRetainedClosure, GcRetentionState, GcRetirementIntent, GcRetirementIntentCoordinates, GcRetirementIntentDecodeError, GcRetirementIntentDigest, GcRetirementIntentEncodeError, GcRetirementIntentError, GcRetirementReceipt, GcRetirementReceiptDecodeError, GcSegmentClassification, GcUnreachableEvidence, - ObservedHeadChecksum, PoolStateDigest, ReaderLockCoordinate, ReaderLockIdentity, - RecoveryArtifactKind, RecoveryClassification, RecoveryDispositionArtifact, + ObservedHeadChecksum, PoolStateDigest, PreparedGcExecution, ReaderLockCoordinate, + ReaderLockIdentity, RecoveryArtifactKind, RecoveryClassification, RecoveryDispositionArtifact, RecoveryDispositionCoordinates, RecoveryDispositionDecision, RecoveryDispositionDecodeError, RecoveryDispositionField, RecoveryDispositionReceipt, SegmentPoolIdentityDigest, - VerificationEvidenceDigest, observe_gc_liveness, plan_gc, + VerificationEvidenceDigest, catalog_successor_proof, derive_gc_intent, disposition_set_digest, + execute_gc, is_gc_complete, observe_gc_liveness, plan_gc, plan_gc_recovery, + post_retirement_pool_state, resume_gc_execution, segment_pool_identity, }; pub use adapters::{ AdmittedRetentionManifest, AdmittedRetentionRoot, CanonicalRetentionHead, diff --git a/xtask/src/durability_crash_matrix/production_protocol.rs b/xtask/src/durability_crash_matrix/production_protocol.rs index 0b47f3bc..c9bde5a8 100644 --- a/xtask/src/durability_crash_matrix/production_protocol.rs +++ b/xtask/src/durability_crash_matrix/production_protocol.rs @@ -2,6 +2,8 @@ mod control; pub(super) mod fixture; +pub(super) mod gc; +mod gc_storage; pub(super) mod initialization; mod initialization_storage; mod migration; @@ -51,6 +53,9 @@ pub(super) fn run( DurabilityCrashSequence::Migration => { migration::run(&store_root, &mut control)?; } + DurabilityCrashSequence::Gc => { + gc::run(&store_root, &mut control)?; + } } Err(DurabilityCrashMatrixError::PointSequenceMismatch { point: case.point(), diff --git a/xtask/src/durability_crash_matrix/production_protocol/gc.rs b/xtask/src/durability_crash_matrix/production_protocol/gc.rs new file mode 100644 index 00000000..0af6a959 --- /dev/null +++ b/xtask/src/durability_crash_matrix/production_protocol/gc.rs @@ -0,0 +1,140 @@ +//! This module owns execution of the production GC retirement protocol. +//! +//! The child migrates the bundle store, publishes retention generation one, +//! adds the one-zero segment as an orphan pool entry with the exact retire +//! disposition that releases it, plans, and executes the retirement, dying +//! at the selected coordinate. + +use std::fs; +use std::path::Path; + +use keep::{ + ArtifactIdentityDigest, CanonicalRecoveryDispositionReceipt, DecisionEvidenceDigest, + FilesystemGcAuthority, FilesystemRetentionSnapshot, FilesystemVersionTwoAdmission, GcLimits, + GcPlan, ObservedHeadChecksum, ReaderAttemptLimit, ReaderLockIdentity, RecoveryArtifactKind, + RecoveryClassification, RecoveryDispositionArtifact, RecoveryDispositionCoordinates, + RecoveryDispositionDecision, RecoveryDispositionReceipt, execute_gc, + execute_retention_publication, observe_gc_liveness, plan_gc, +}; + +use super::control::CrashControl; +use super::fixture::{GoldenFixture, SEGMENT_POOL_PATH}; +use super::gc_storage::CrashGcStorage; +use super::initialization; +use super::retention::{migrated_authority, preparation}; +use super::{DurabilityCrashMatrixError, verification}; + +/// The orphan's pool-name digest, as lowercase hexadecimal. +const ORPHAN_DIGEST_HEX: &str = "b7542dced2ab770894a14d1d04b066e3a899942602c5986d35ba6df6c1a35cfc"; + +pub(super) fn run( + store_root: &Path, + control: &mut CrashControl, +) -> Result<(), DurabilityCrashMatrixError> { + prepare_store(store_root)?; + let plan = plan(store_root)?; + let mut authority = gc_authority(store_root)?; + let prepared = authority + .prepare(&plan) + .map_err(|source| verification("prepare production GC retirement", source))?; + let mut storage = CrashGcStorage::new(authority, control, store_root); + execute_gc(&mut storage, prepared.candidate_count()) + .map(|_receipt| ()) + .map_err(|source| verification("execute production GC retirement", source)) +} + +/// Migrates the bundle store, publishes retention generation one, and adds +/// the disposed orphan segment. +pub(in crate::durability_crash_matrix) fn prepare_store( + store_root: &Path, +) -> Result<(), DurabilityCrashMatrixError> { + let mut authority = migrated_authority(store_root)?; + let root = GoldenFixture::retention_root()?; + let preparation = preparation(root.bytes())?; + let _published = execute_retention_publication(&mut authority, &preparation) + .map_err(|source| verification("publish crash GC retention generation", source))?; + drop(authority); + let orphan = GoldenFixture::segment()?; + fs::write(store_root.join(SEGMENT_POOL_PATH), orphan.bytes()) + .map_err(|source| DurabilityCrashMatrixError::io("write crash GC orphan", source))?; + let receipt = exact_receipt(store_root, orphan.bytes())?; + fs::write( + store_root + .join("recovery") + .join("dispositions") + .join(format!("{ORPHAN_DIGEST_HEX}.receipt")), + receipt, + ) + .map_err(|source| DurabilityCrashMatrixError::io("write crash GC disposition", source)) +} + +/// Plans GC over the fenced view a production caller would take. +pub(in crate::durability_crash_matrix) fn plan( + store_root: &Path, +) -> Result { + let view = FilesystemRetentionSnapshot::load( + store_root, + initialization::restart_policy()?, + ReaderAttemptLimit::DEFAULT, + ) + .map_err(|source| verification("load crash GC view", source))?; + let snapshot = observe_gc_liveness(store_root, &view, initialization::restart_policy()?) + .map_err(|source| verification("observe crash GC liveness", source))?; + plan_gc(&snapshot, GcLimits::MAXIMUM) + .map_err(|source| verification("plan crash GC retirement", source)) +} + +/// Reopens the store and returns GC authority over it. +pub(in crate::durability_crash_matrix) fn gc_authority( + store_root: &Path, +) -> Result { + let admission = + FilesystemVersionTwoAdmission::reopen_unchecked_for_repository_tasks(store_root) + .map_err(|source| verification("reopen crash store for GC", source))?; + FilesystemGcAuthority::open(admission, store_root, initialization::restart_policy()?) + .map_err(|source| verification("open crash GC authority", source)) +} + +/// The retire receipt for the orphan under exactly the store's current +/// planning coordinates, with fixture-only head-checksum, reader-lock, and +/// decision-evidence coordinates. +fn exact_receipt(store_root: &Path, orphan: &[u8]) -> Result, DurabilityCrashMatrixError> { + let coordinates = plan(store_root)?.coordinates(); + let identity = decode_digest(ORPHAN_DIGEST_HEX)?; + let receipt = RecoveryDispositionReceipt::new( + RecoveryDispositionArtifact { + kind: RecoveryArtifactKind::Segment, + classification: RecoveryClassification::CompleteOrphan, + length: u64::try_from(orphan.len()) + .map_err(|source| verification("convert crash GC orphan length", source))?, + identity_digest: ArtifactIdentityDigest::new(identity), + content_digest: CanonicalRecoveryDispositionReceipt::artifact_content_digest(orphan), + }, + RecoveryDispositionDecision::Retire, + RecoveryDispositionCoordinates { + publication_generation: coordinates.catalog_generation(), + publication_checksum: ObservedHeadChecksum::new([0; 32]), + catalog_generation: coordinates.catalog_generation(), + catalog_digest: coordinates.catalog_digest(), + retention: coordinates.retention(), + reader_lock: ReaderLockIdentity::new(1, 2, 3), + }, + DecisionEvidenceDigest::new([0; 32]), + ); + Ok(CanonicalRecoveryDispositionReceipt::from_receipt(&receipt) + .encoded() + .to_vec()) +} + +fn decode_digest(hex: &str) -> Result<[u8; 32], DurabilityCrashMatrixError> { + let mut digest = [0_u8; 32]; + for (index, byte) in digest.iter_mut().enumerate() { + let start = index.saturating_mul(2); + let pair = hex + .get(start..start.saturating_add(2)) + .ok_or(DurabilityCrashMatrixError::Usage)?; + *byte = + u8::from_str_radix(pair, 16).map_err(|_source| DurabilityCrashMatrixError::Usage)?; + } + Ok(digest) +} diff --git a/xtask/src/durability_crash_matrix/production_protocol/gc_storage.rs b/xtask/src/durability_crash_matrix/production_protocol/gc_storage.rs new file mode 100644 index 00000000..82c7f0af --- /dev/null +++ b/xtask/src/durability_crash_matrix/production_protocol/gc_storage.rs @@ -0,0 +1,208 @@ +//! This module owns crash injection around production GC retirement. + +use std::fs::OpenOptions; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use keep::{FilesystemGcAuthority, GcExecutionStorage}; +use xtask::{DurabilityCrashPoint, DurabilityCrashPosition}; + +use super::control::{CrashControl, DuringTiming}; + +/// Bytes an interrupted stage write leaves behind: inside both records' +/// fixed framing, so restart classifies the stage as truncated. +const STAGE_INTERRUPTION: usize = 100; + +pub(super) struct CrashGcStorage<'control> { + inner: FilesystemGcAuthority, + control: &'control mut CrashControl, + gc: PathBuf, +} + +impl<'control> CrashGcStorage<'control> { + pub(super) fn new( + inner: FilesystemGcAuthority, + control: &'control mut CrashControl, + store_root: &Path, + ) -> Self { + Self { + inner, + control, + gc: store_root.join("gc"), + } + } + + fn execute( + &mut self, + point: DurabilityCrashPoint, + during: DuringTiming, + operation: impl FnOnce(&mut FilesystemGcAuthority) -> io::Result<()>, + ) -> io::Result<()> { + self.control.before(point, during)?; + operation(&mut self.inner)?; + self.control.after(point, during) + } + + /// A stage write dies before, mid-record, or after the complete write; + /// mid-record leaves the record's first bytes in the stage. + fn execute_write( + &mut self, + point: DurabilityCrashPoint, + stage: &str, + prefix: &[u8], + complete: impl FnOnce(&mut FilesystemGcAuthority) -> io::Result<()>, + ) -> io::Result<()> { + match self.control.position(point) { + None => complete(&mut self.inner), + Some(DurabilityCrashPosition::Before) => self.control.await_process_death(), + Some(DurabilityCrashPosition::During) => { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(self.gc.join(stage))?; + file.write_all(prefix)?; + self.control.await_process_death() + } + Some(DurabilityCrashPosition::After) => { + complete(&mut self.inner)?; + self.control.await_process_death() + } + } + } + + fn intent_prefix(&self) -> io::Result> { + let intent = self + .inner + .bound_intent() + .ok_or_else(|| io::Error::other("no GC intent is bound"))?; + intent + .encoded() + .get(..STAGE_INTERRUPTION) + .map(<[u8]>::to_vec) + .ok_or_else(|| io::Error::other("GC intent shorter than the interruption prefix")) + } +} + +/// The receipt's magic followed by zeros: a truncated receipt stage. +fn receipt_prefix() -> Vec { + let mut prefix = b"KEEP:GC:RECEIPT2".to_vec(); + prefix.resize(STAGE_INTERRUPTION, 0); + prefix +} + +impl GcExecutionStorage for CrashGcStorage<'_> { + fn write_intent_stage(&mut self) -> io::Result<()> { + let prefix = self.intent_prefix()?; + self.execute_write( + DurabilityCrashPoint::GcWriteIntentStage, + "intent.next", + &prefix, + FilesystemGcAuthority::write_intent_stage, + ) + } + + fn synchronize_intent_stage(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::GcSynchronizeIntentStage, + DuringTiming::Before, + FilesystemGcAuthority::synchronize_intent_stage, + ) + } + + fn link_intent(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::GcLinkIntent, + DuringTiming::After, + FilesystemGcAuthority::link_intent, + ) + } + + fn synchronize_gc_after_intent(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::GcSynchronizeGcAfterIntent, + DuringTiming::Before, + FilesystemGcAuthority::synchronize_gc_after_intent, + ) + } + + fn remove_intent_stage(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::GcRemoveIntentStage, + DuringTiming::After, + FilesystemGcAuthority::remove_intent_stage, + ) + } + + fn synchronize_gc_after_intent_cleanup(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::GcSynchronizeGcAfterIntentCleanup, + DuringTiming::Before, + FilesystemGcAuthority::synchronize_gc_after_intent_cleanup, + ) + } + + fn unlink_candidate(&mut self, index: usize) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::GcUnlinkCandidate, + DuringTiming::After, + |inner| inner.unlink_candidate(index), + ) + } + + fn synchronize_segment_pool(&mut self, index: usize) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::GcSynchronizeSegmentPool, + DuringTiming::Before, + |inner| inner.synchronize_segment_pool(index), + ) + } + + fn write_receipt_stage(&mut self) -> io::Result<()> { + self.execute_write( + DurabilityCrashPoint::GcWriteReceiptStage, + "receipt.next", + &receipt_prefix(), + FilesystemGcAuthority::write_receipt_stage, + ) + } + + fn synchronize_receipt_stage(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::GcSynchronizeReceiptStage, + DuringTiming::Before, + FilesystemGcAuthority::synchronize_receipt_stage, + ) + } + + fn replace_receipt(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::GcReplaceReceipt, + DuringTiming::After, + FilesystemGcAuthority::replace_receipt, + ) + } + + fn synchronize_gc_after_receipt(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::GcSynchronizeGcAfterReceipt, + DuringTiming::Before, + FilesystemGcAuthority::synchronize_gc_after_receipt, + ) + } + + fn remove_intent(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::GcRemoveIntent, + DuringTiming::After, + FilesystemGcAuthority::remove_intent, + ) + } + + fn synchronize_gc_after_intent_removal(&mut self) -> io::Result<()> { + self.execute( + DurabilityCrashPoint::GcSynchronizeGcAfterIntentRemoval, + DuringTiming::Before, + FilesystemGcAuthority::synchronize_gc_after_intent_removal, + ) + } +} diff --git a/xtask/src/durability_crash_matrix/production_protocol/retention.rs b/xtask/src/durability_crash_matrix/production_protocol/retention.rs index f0c5f5d4..d1556b51 100644 --- a/xtask/src/durability_crash_matrix/production_protocol/retention.rs +++ b/xtask/src/durability_crash_matrix/production_protocol/retention.rs @@ -34,7 +34,7 @@ pub(super) fn run( /// Initializes, populates, and migrates the bundle store, then reopens it as /// version two and returns retention authority over it. -fn migrated_authority( +pub(super) fn migrated_authority( store_root: &Path, ) -> Result { let lock = initialization::initialized_lock(store_root)?; diff --git a/xtask/src/durability_crash_matrix/restart.rs b/xtask/src/durability_crash_matrix/restart.rs index 4fde03ad..38079bdc 100644 --- a/xtask/src/durability_crash_matrix/restart.rs +++ b/xtask/src/durability_crash_matrix/restart.rs @@ -1,6 +1,7 @@ //! This module owns independent post-process-death store verification. mod expectation; +mod gc; mod migration; mod migration_expectation; mod retention; @@ -26,6 +27,9 @@ pub(super) fn verify( if case.point().sequence() == DurabilityCrashSequence::Migration { return migration::verify(store_root, case); } + if case.point().sequence() == DurabilityCrashSequence::Gc { + return gc::verify(store_root, case); + } let expected = ExpectedStoreState::for_case(case)?; let observed_paths = inventory(store_root)?; if observed_paths != expected.paths() { diff --git a/xtask/src/durability_crash_matrix/restart/expectation.rs b/xtask/src/durability_crash_matrix/restart/expectation.rs index e54c7961..20f69859 100644 --- a/xtask/src/durability_crash_matrix/restart/expectation.rs +++ b/xtask/src/durability_crash_matrix/restart/expectation.rs @@ -64,7 +64,9 @@ impl ExpectedStoreState { DurabilityCrashSequence::Head => sequence::head(case), DurabilityCrashSequence::RecoveryDiscard => sequence::recovery(case), DurabilityCrashSequence::Initialization => sequence::initialization(case), - DurabilityCrashSequence::Retention | DurabilityCrashSequence::Migration => { + DurabilityCrashSequence::Retention + | DurabilityCrashSequence::Migration + | DurabilityCrashSequence::Gc => { Err(DurabilityCrashMatrixError::PointSequenceMismatch { point: case.point(), }) diff --git a/xtask/src/durability_crash_matrix/restart/gc.rs b/xtask/src/durability_crash_matrix/restart/gc.rs new file mode 100644 index 00000000..ecce8d47 --- /dev/null +++ b/xtask/src/durability_crash_matrix/restart/gc.rs @@ -0,0 +1,171 @@ +//! This module owns independent post-process-death GC verification: the +//! live segment is never lost, the residue plans exactly the documented +//! recovery, the production recovery reaches one complete retirement, and +//! a fresh plan then has nothing to retire. + +use std::fs; +use std::io; +use std::path::Path; + +use keep::{GcExecutionPhase, GcExecutionPoint, GcFixedStage, GcRecoveryPlan}; +use xtask::{DurabilityCrashCase, DurabilityCrashPoint, DurabilityCrashPosition}; + +use super::super::DurabilityCrashMatrixError; +use super::super::production_protocol::fixture::{ + BUNDLE_SEGMENT_NAME, GoldenFixture, SEGMENT_POOL_PATH, +}; +use super::super::production_protocol::gc::{gc_authority, plan}; +use super::super::production_protocol::verification; + +pub(super) fn verify( + store_root: &Path, + case: DurabilityCrashCase, +) -> Result<(), DurabilityCrashMatrixError> { + require_live_segment(store_root)?; + let (count, truncated) = prefix(case); + if let Some(stage) = truncated { + let report = recover(store_root)?; + if report != (GcRecoveryPlan::DiscardStage { stage }) { + return Err(mismatch( + case, + format!("expected discard of {stage:?}, got {report:?}"), + )); + } + } + let report = recover(store_root)?; + let expected = expected_plan(count); + if report != expected { + return Err(mismatch( + case, + format!("expected {expected:?}, recovered {report:?}"), + )); + } + if report == GcRecoveryPlan::Idle { + let plan = plan(store_root)?; + let _receipt = gc_authority(store_root)? + .execute(&plan) + .map_err(|source| verification("execute forward GC retirement", source))?; + } + require_complete(store_root, case) +} + +fn require_live_segment(store_root: &Path) -> Result<(), DurabilityCrashMatrixError> { + let observed = fs::read(store_root.join("segments").join(BUNDLE_SEGMENT_NAME)) + .map_err(|source| DurabilityCrashMatrixError::io("read live crash segment", source))?; + let expected = GoldenFixture::bundle_segment()?; + if observed == expected.bytes() { + Ok(()) + } else { + Err(DurabilityCrashMatrixError::artifact_bytes( + "segments/", + expected.bytes(), + &observed, + )) + } +} + +fn require_complete( + store_root: &Path, + case: DurabilityCrashCase, +) -> Result<(), DurabilityCrashMatrixError> { + require_live_segment(store_root)?; + if store_root.join(SEGMENT_POOL_PATH).exists() { + return Err(mismatch(case, "the retired orphan is still present".into())); + } + let mut entries: Vec = fs::read_dir(store_root.join("gc")) + .map_err(|source| DurabilityCrashMatrixError::io("list crash gc directory", source))? + .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned())) + .collect::>() + .map_err(|source| DurabilityCrashMatrixError::io("read crash gc entry", source))?; + entries.sort(); + if entries != ["receipt"] { + return Err(mismatch( + case, + format!("gc holds {entries:?}, expected only the receipt"), + )); + } + let plan = plan(store_root)?; + if plan.candidate_count() != 0 || plan.already_retired().len() != 1 { + return Err(mismatch(case, "a fresh plan still names the orphan".into())); + } + let settled = recover(store_root)?; + if settled == GcRecoveryPlan::Complete { + Ok(()) + } else { + Err(mismatch( + case, + format!("settled residue planned {settled:?}"), + )) + } +} + +/// Reacquires writer authority the way a restarted process would and runs +/// the production recovery once, reporting the plan the residue admitted. +fn recover(store_root: &Path) -> Result { + gc_authority(store_root)? + .recover() + .map(|report| report.plan()) + .map_err(|source| verification("recover production GC retirement", source)) +} + +fn mismatch(case: DurabilityCrashCase, message: String) -> DurabilityCrashMatrixError { + verification( + "verify crash GC recovery", + io::Error::other(format!( + "{} {:?}: {message}", + case.point().identifier(), + case.position() + )), + ) +} + +/// The number of completed points and any truncated stage the coordinate +/// leaves behind, for the one-candidate retirement. +fn prefix(case: DurabilityCrashCase) -> (usize, Option) { + let phase = DurabilityCrashPoint::GC + .iter() + .position(|point| *point == case.point()) + .map_or(0, |index| index.saturating_add(1)); + let write = match case.point() { + DurabilityCrashPoint::GcWriteIntentStage => Some(GcFixedStage::Intent), + DurabilityCrashPoint::GcWriteReceiptStage => Some(GcFixedStage::Receipt), + _ => None, + }; + match case.position() { + DurabilityCrashPosition::After => (phase, None), + DurabilityCrashPosition::During if write.is_some() => (phase.saturating_sub(1), write), + DurabilityCrashPosition::During if atomic(case.point()) => (phase, None), + DurabilityCrashPosition::Before | DurabilityCrashPosition::During => { + (phase.saturating_sub(1), None) + } + } +} + +const fn atomic(point: DurabilityCrashPoint) -> bool { + matches!( + point, + DurabilityCrashPoint::GcLinkIntent + | DurabilityCrashPoint::GcRemoveIntentStage + | DurabilityCrashPoint::GcUnlinkCandidate + | DurabilityCrashPoint::GcReplaceReceipt + | DurabilityCrashPoint::GcRemoveIntent + ) +} + +/// The documented recovery for a process death after `count` points, the +/// state table in `gc.md`. +fn expected_plan(count: usize) -> GcRecoveryPlan { + let resume = |phase| GcRecoveryPlan::Resume { + from: GcExecutionPoint::at(phase), + }; + match count { + 0 => GcRecoveryPlan::Idle, + 1 | 2 => resume(GcExecutionPhase::SynchronizeIntentStage), + 3 | 4 => resume(GcExecutionPhase::SynchronizeGcAfterIntent), + 5 | 6 => resume(GcExecutionPhase::SynchronizeGcAfterIntentCleanup), + 7 | 8 => resume(GcExecutionPhase::WriteReceiptStage), + 9 | 10 => resume(GcExecutionPhase::SynchronizeReceiptStage), + 11 | 12 => resume(GcExecutionPhase::SynchronizeGcAfterReceipt), + _ => GcRecoveryPlan::Complete, + } +} diff --git a/xtask/src/durability_crash_point.rs b/xtask/src/durability_crash_point.rs index 0cd6ad04..a92c536a 100644 --- a/xtask/src/durability_crash_point.rs +++ b/xtask/src/durability_crash_point.rs @@ -17,11 +17,13 @@ pub enum DurabilityCrashSequence { Retention, /// One-way version-1 to version-2 store migration. Migration, + /// Version-two GC retirement, `KEEP-CRASH-074` through `087`. + Gc, } impl DurabilityCrashSequence { /// Every sequence in stable protocol order. - pub const ALL: [Self; 7] = [ + pub const ALL: [Self; 8] = [ Self::Segment, Self::Catalog, Self::Head, @@ -29,6 +31,7 @@ impl DurabilityCrashSequence { Self::Initialization, Self::Retention, Self::Migration, + Self::Gc, ]; /// Returns the stable identifier used by the crash-matrix command line. @@ -42,6 +45,7 @@ impl DurabilityCrashSequence { Self::Initialization => "initialization", Self::Retention => "retention", Self::Migration => "migration", + Self::Gc => "gc", } } @@ -204,11 +208,39 @@ pub enum DurabilityCrashPoint { MigrationRemoveReceiptStage, /// Synchronize the store root after receipt-stage cleanup. MigrationSynchronizeRootAfterReceiptCleanup, + /// GC intent stage write. + GcWriteIntentStage, + /// GC intent stage synchronization. + GcSynchronizeIntentStage, + /// GC intent canonical link. + GcLinkIntent, + /// `gc` synchronization after the intent link. + GcSynchronizeGcAfterIntent, + /// GC intent stage removal. + GcRemoveIntentStage, + /// `gc` synchronization after intent-stage cleanup. + GcSynchronizeGcAfterIntentCleanup, + /// Verified unlink of one retirement candidate. + GcUnlinkCandidate, + /// Segment-pool synchronization after one unlink. + GcSynchronizeSegmentPool, + /// GC receipt stage write. + GcWriteReceiptStage, + /// GC receipt stage synchronization. + GcSynchronizeReceiptStage, + /// GC receipt atomic replacement. + GcReplaceReceipt, + /// `gc` synchronization after receipt replacement. + GcSynchronizeGcAfterReceipt, + /// Completed GC intent removal. + GcRemoveIntent, + /// `gc` synchronization after intent removal. + GcSynchronizeGcAfterIntentRemoval, } impl DurabilityCrashPoint { /// Every crash boundary in stable protocol order. - pub const ALL: [Self; 73] = [ + pub const ALL: [Self; 87] = [ Self::CreateSegmentStage, Self::WriteSegmentHeader, Self::AppendSegmentRecord, @@ -282,6 +314,38 @@ impl DurabilityCrashPoint { Self::MigrationSynchronizeRootAfterReceipt, Self::MigrationRemoveReceiptStage, Self::MigrationSynchronizeRootAfterReceiptCleanup, + Self::GcWriteIntentStage, + Self::GcSynchronizeIntentStage, + Self::GcLinkIntent, + Self::GcSynchronizeGcAfterIntent, + Self::GcRemoveIntentStage, + Self::GcSynchronizeGcAfterIntentCleanup, + Self::GcUnlinkCandidate, + Self::GcSynchronizeSegmentPool, + Self::GcWriteReceiptStage, + Self::GcSynchronizeReceiptStage, + Self::GcReplaceReceipt, + Self::GcSynchronizeGcAfterReceipt, + Self::GcRemoveIntent, + Self::GcSynchronizeGcAfterIntentRemoval, + ]; + + /// The GC retirement boundaries in `GcExecutionPhase::ALL` order. + pub const GC: [Self; 14] = [ + Self::GcWriteIntentStage, + Self::GcSynchronizeIntentStage, + Self::GcLinkIntent, + Self::GcSynchronizeGcAfterIntent, + Self::GcRemoveIntentStage, + Self::GcSynchronizeGcAfterIntentCleanup, + Self::GcUnlinkCandidate, + Self::GcSynchronizeSegmentPool, + Self::GcWriteReceiptStage, + Self::GcSynchronizeReceiptStage, + Self::GcReplaceReceipt, + Self::GcSynchronizeGcAfterReceipt, + Self::GcRemoveIntent, + Self::GcSynchronizeGcAfterIntentRemoval, ]; /// The migration boundaries in `StoreMigrationPhase::ALL` order. @@ -322,88 +386,6 @@ impl DurabilityCrashPoint { .find(|point| point.identifier() == identifier) } - /// Returns the durable protocol sequence containing this boundary. - #[must_use] - pub const fn sequence(self) -> DurabilityCrashSequence { - match self { - Self::CreateSegmentStage - | Self::WriteSegmentHeader - | Self::AppendSegmentRecord - | Self::FlushSegmentRecordPrefix - | Self::SynchronizeSegmentRecordPrefix - | Self::AppendSegmentSeal - | Self::FlushSealedSegment - | Self::SynchronizeSealedSegment - | Self::LinkSegment - | Self::SynchronizeSegmentPool - | Self::RemoveSegmentStage - | Self::SynchronizeStagingAfterSegment => DurabilityCrashSequence::Segment, - Self::CreateCatalogStage - | Self::WriteCatalog - | Self::FlushCatalog - | Self::SynchronizeCatalog - | Self::LinkCatalog - | Self::SynchronizeCatalogPool - | Self::RemoveCatalogStage - | Self::SynchronizeStagingAfterCatalog => DurabilityCrashSequence::Catalog, - Self::CreateHeadStage - | Self::WriteHead - | Self::FlushHead - | Self::SynchronizeHead - | Self::ReplaceHead - | Self::SynchronizeRootAfterHead => DurabilityCrashSequence::Head, - Self::RemoveRecoveryStage - | Self::SynchronizeStagingAfterRecovery - | Self::RemoveRecoveryHead - | Self::SynchronizeRootAfterRecovery => DurabilityCrashSequence::RecoveryDiscard, - Self::OpenAndLockWriterFile - | Self::CreateStagingDirectory - | Self::CreateSegmentPoolDirectory - | Self::CreateCatalogPoolDirectory - | Self::SynchronizeRootAfterInitialization => DurabilityCrashSequence::Initialization, - Self::WriteRootStage - | Self::SynchronizeRootStage - | Self::AdmitRootNamespace - | Self::SynchronizeRootsAfterNamespace - | Self::LinkRoot - | Self::SynchronizeRootNamespace - | Self::WriteManifestStage - | Self::SynchronizeManifestStage - | Self::LinkManifest - | Self::SynchronizeManifestPool - | Self::WriteHeadStage - | Self::SynchronizeHeadStage - | Self::ReplaceRetentionHead - | Self::SynchronizeRetentionNamespace - | Self::RemoveRootStage - | Self::RemoveManifestStage - | Self::SynchronizeRetentionCleanup => DurabilityCrashSequence::Retention, - Self::MigrationWriteIntentStage - | Self::MigrationSynchronizeIntentStage - | Self::MigrationLinkIntent - | Self::MigrationSynchronizeRootAfterIntent - | Self::MigrationRemoveIntentStage - | Self::MigrationSynchronizeRootAfterIntentCleanup - | Self::MigrationAdmitReaderFence - | Self::MigrationAdmitNamespacePrefix - | Self::MigrationSynchronizeRootAfterNamespace - | Self::MigrationWriteMarkerStage - | Self::MigrationSynchronizeMarkerStage - | Self::MigrationLinkMarker - | Self::MigrationSynchronizeRootAfterMarker - | Self::MigrationRemoveMarkerStage - | Self::MigrationSynchronizeRootAfterMarkerCleanup - | Self::MigrationWriteReceiptStage - | Self::MigrationSynchronizeReceiptStage - | Self::MigrationLinkReceipt - | Self::MigrationSynchronizeRootAfterReceipt - | Self::MigrationRemoveReceiptStage - | Self::MigrationSynchronizeRootAfterReceiptCleanup => { - DurabilityCrashSequence::Migration - } - } - } - /// Reports whether tests may select a repeated occurrence. #[must_use] pub const fn occurrence_counted(self) -> bool { diff --git a/xtask/src/durability_crash_point_identity.rs b/xtask/src/durability_crash_point_identity.rs index 168af5a7..36b5d557 100644 --- a/xtask/src/durability_crash_point_identity.rs +++ b/xtask/src/durability_crash_point_identity.rs @@ -80,6 +80,20 @@ impl DurabilityCrashPoint { Self::MigrationSynchronizeRootAfterReceipt => "KEEP-CRASH-071", Self::MigrationRemoveReceiptStage => "KEEP-CRASH-072", Self::MigrationSynchronizeRootAfterReceiptCleanup => "KEEP-CRASH-073", + Self::GcWriteIntentStage => "KEEP-CRASH-074", + Self::GcSynchronizeIntentStage => "KEEP-CRASH-075", + Self::GcLinkIntent => "KEEP-CRASH-076", + Self::GcSynchronizeGcAfterIntent => "KEEP-CRASH-077", + Self::GcRemoveIntentStage => "KEEP-CRASH-078", + Self::GcSynchronizeGcAfterIntentCleanup => "KEEP-CRASH-079", + Self::GcUnlinkCandidate => "KEEP-CRASH-080", + Self::GcSynchronizeSegmentPool => "KEEP-CRASH-081", + Self::GcWriteReceiptStage => "KEEP-CRASH-082", + Self::GcSynchronizeReceiptStage => "KEEP-CRASH-083", + Self::GcReplaceReceipt => "KEEP-CRASH-084", + Self::GcSynchronizeGcAfterReceipt => "KEEP-CRASH-085", + Self::GcRemoveIntent => "KEEP-CRASH-086", + Self::GcSynchronizeGcAfterIntentRemoval => "KEEP-CRASH-087", } } } diff --git a/xtask/src/durability_crash_point_sequence.rs b/xtask/src/durability_crash_point_sequence.rs new file mode 100644 index 00000000..f08838fe --- /dev/null +++ b/xtask/src/durability_crash_point_sequence.rs @@ -0,0 +1,101 @@ +//! This module owns the sequence each durability crash point belongs to. + +use crate::durability_crash_point::{DurabilityCrashPoint, DurabilityCrashSequence}; + +impl DurabilityCrashPoint { + /// Returns the durable protocol sequence containing this boundary. + #[must_use] + pub const fn sequence(self) -> DurabilityCrashSequence { + match self { + Self::CreateSegmentStage + | Self::WriteSegmentHeader + | Self::AppendSegmentRecord + | Self::FlushSegmentRecordPrefix + | Self::SynchronizeSegmentRecordPrefix + | Self::AppendSegmentSeal + | Self::FlushSealedSegment + | Self::SynchronizeSealedSegment + | Self::LinkSegment + | Self::SynchronizeSegmentPool + | Self::RemoveSegmentStage + | Self::SynchronizeStagingAfterSegment => DurabilityCrashSequence::Segment, + Self::CreateCatalogStage + | Self::WriteCatalog + | Self::FlushCatalog + | Self::SynchronizeCatalog + | Self::LinkCatalog + | Self::SynchronizeCatalogPool + | Self::RemoveCatalogStage + | Self::SynchronizeStagingAfterCatalog => DurabilityCrashSequence::Catalog, + Self::CreateHeadStage + | Self::WriteHead + | Self::FlushHead + | Self::SynchronizeHead + | Self::ReplaceHead + | Self::SynchronizeRootAfterHead => DurabilityCrashSequence::Head, + Self::RemoveRecoveryStage + | Self::SynchronizeStagingAfterRecovery + | Self::RemoveRecoveryHead + | Self::SynchronizeRootAfterRecovery => DurabilityCrashSequence::RecoveryDiscard, + Self::OpenAndLockWriterFile + | Self::CreateStagingDirectory + | Self::CreateSegmentPoolDirectory + | Self::CreateCatalogPoolDirectory + | Self::SynchronizeRootAfterInitialization => DurabilityCrashSequence::Initialization, + Self::WriteRootStage + | Self::SynchronizeRootStage + | Self::AdmitRootNamespace + | Self::SynchronizeRootsAfterNamespace + | Self::LinkRoot + | Self::SynchronizeRootNamespace + | Self::WriteManifestStage + | Self::SynchronizeManifestStage + | Self::LinkManifest + | Self::SynchronizeManifestPool + | Self::WriteHeadStage + | Self::SynchronizeHeadStage + | Self::ReplaceRetentionHead + | Self::SynchronizeRetentionNamespace + | Self::RemoveRootStage + | Self::RemoveManifestStage + | Self::SynchronizeRetentionCleanup => DurabilityCrashSequence::Retention, + Self::MigrationWriteIntentStage + | Self::MigrationSynchronizeIntentStage + | Self::MigrationLinkIntent + | Self::MigrationSynchronizeRootAfterIntent + | Self::MigrationRemoveIntentStage + | Self::MigrationSynchronizeRootAfterIntentCleanup + | Self::MigrationAdmitReaderFence + | Self::MigrationAdmitNamespacePrefix + | Self::MigrationSynchronizeRootAfterNamespace + | Self::MigrationWriteMarkerStage + | Self::MigrationSynchronizeMarkerStage + | Self::MigrationLinkMarker + | Self::MigrationSynchronizeRootAfterMarker + | Self::MigrationRemoveMarkerStage + | Self::MigrationSynchronizeRootAfterMarkerCleanup + | Self::MigrationWriteReceiptStage + | Self::MigrationSynchronizeReceiptStage + | Self::MigrationLinkReceipt + | Self::MigrationSynchronizeRootAfterReceipt + | Self::MigrationRemoveReceiptStage + | Self::MigrationSynchronizeRootAfterReceiptCleanup => { + DurabilityCrashSequence::Migration + } + Self::GcWriteIntentStage + | Self::GcSynchronizeIntentStage + | Self::GcLinkIntent + | Self::GcSynchronizeGcAfterIntent + | Self::GcRemoveIntentStage + | Self::GcSynchronizeGcAfterIntentCleanup + | Self::GcUnlinkCandidate + | Self::GcSynchronizeSegmentPool + | Self::GcWriteReceiptStage + | Self::GcSynchronizeReceiptStage + | Self::GcReplaceReceipt + | Self::GcSynchronizeGcAfterReceipt + | Self::GcRemoveIntent + | Self::GcSynchronizeGcAfterIntentRemoval => DurabilityCrashSequence::Gc, + } + } +} diff --git a/xtask/src/lib.rs b/xtask/src/lib.rs index f2d9786e..9a0ccf9e 100644 --- a/xtask/src/lib.rs +++ b/xtask/src/lib.rs @@ -36,6 +36,7 @@ mod durability_crash_occurrence; mod durability_crash_point; #[cfg(feature = "repository-tasks")] mod durability_crash_point_identity; +mod durability_crash_point_sequence; #[cfg(feature = "repository-tasks")] mod durability_crash_position; diff --git a/xtask/tests/durability_crash_case_contract.rs b/xtask/tests/durability_crash_case_contract.rs index 85f4e096..4b09e3ed 100644 --- a/xtask/tests/durability_crash_case_contract.rs +++ b/xtask/tests/durability_crash_case_contract.rs @@ -31,12 +31,18 @@ fn every_crash_point_has_three_ordered_positions_and_one_during_case_per_occurre } assert_eq!(cases, expected); - // 73 boundaries at three positions, plus five extra namespace-prefix + // 87 boundaries at three positions, plus five extra namespace-prefix // lengths for `KEEP-CRASH-060`. - assert_eq!(cases.len(), 224); + assert_eq!(cases.len(), 266); let migration: Vec<_> = DurabilityCrashCase::in_sequence(DurabilityCrashSequence::Migration).collect(); assert_eq!(migration.len(), 68); + let gc: Vec<_> = DurabilityCrashCase::in_sequence(DurabilityCrashSequence::Gc).collect(); + assert_eq!(gc.len(), 42); + assert!( + gc.iter() + .all(|case| case.point().sequence() == DurabilityCrashSequence::Gc) + ); assert!( migration .iter() diff --git a/xtask/tests/durability_crash_point_contract.rs b/xtask/tests/durability_crash_point_contract.rs index 6fc830af..95fc45b6 100644 --- a/xtask/tests/durability_crash_point_contract.rs +++ b/xtask/tests/durability_crash_point_contract.rs @@ -4,353 +4,12 @@ use xtask::{DurabilityCrashPoint, DurabilityCrashSequence}; -use DurabilityCrashSequence::{ - Catalog, Head, Initialization, Migration, RecoveryDiscard, Retention, Segment, -}; +use DurabilityCrashSequence::{Gc, Migration}; -const EXPECTED: &[(DurabilityCrashPoint, &str, DurabilityCrashSequence)] = &[ - ( - DurabilityCrashPoint::CreateSegmentStage, - "KEEP-CRASH-001", - Segment, - ), - ( - DurabilityCrashPoint::WriteSegmentHeader, - "KEEP-CRASH-002", - Segment, - ), - ( - DurabilityCrashPoint::AppendSegmentRecord, - "KEEP-CRASH-003", - Segment, - ), - ( - DurabilityCrashPoint::FlushSegmentRecordPrefix, - "KEEP-CRASH-004", - Segment, - ), - ( - DurabilityCrashPoint::SynchronizeSegmentRecordPrefix, - "KEEP-CRASH-005", - Segment, - ), - ( - DurabilityCrashPoint::AppendSegmentSeal, - "KEEP-CRASH-006", - Segment, - ), - ( - DurabilityCrashPoint::FlushSealedSegment, - "KEEP-CRASH-007", - Segment, - ), - ( - DurabilityCrashPoint::SynchronizeSealedSegment, - "KEEP-CRASH-008", - Segment, - ), - (DurabilityCrashPoint::LinkSegment, "KEEP-CRASH-009", Segment), - ( - DurabilityCrashPoint::SynchronizeSegmentPool, - "KEEP-CRASH-010", - Segment, - ), - ( - DurabilityCrashPoint::RemoveSegmentStage, - "KEEP-CRASH-011", - Segment, - ), - ( - DurabilityCrashPoint::SynchronizeStagingAfterSegment, - "KEEP-CRASH-012", - Segment, - ), - ( - DurabilityCrashPoint::CreateCatalogStage, - "KEEP-CRASH-013", - Catalog, - ), - ( - DurabilityCrashPoint::WriteCatalog, - "KEEP-CRASH-014", - Catalog, - ), - ( - DurabilityCrashPoint::FlushCatalog, - "KEEP-CRASH-015", - Catalog, - ), - ( - DurabilityCrashPoint::SynchronizeCatalog, - "KEEP-CRASH-016", - Catalog, - ), - (DurabilityCrashPoint::LinkCatalog, "KEEP-CRASH-017", Catalog), - ( - DurabilityCrashPoint::SynchronizeCatalogPool, - "KEEP-CRASH-018", - Catalog, - ), - ( - DurabilityCrashPoint::RemoveCatalogStage, - "KEEP-CRASH-019", - Catalog, - ), - ( - DurabilityCrashPoint::SynchronizeStagingAfterCatalog, - "KEEP-CRASH-020", - Catalog, - ), - ( - DurabilityCrashPoint::CreateHeadStage, - "KEEP-CRASH-021", - Head, - ), - (DurabilityCrashPoint::WriteHead, "KEEP-CRASH-022", Head), - (DurabilityCrashPoint::FlushHead, "KEEP-CRASH-023", Head), - ( - DurabilityCrashPoint::SynchronizeHead, - "KEEP-CRASH-024", - Head, - ), - (DurabilityCrashPoint::ReplaceHead, "KEEP-CRASH-025", Head), - ( - DurabilityCrashPoint::SynchronizeRootAfterHead, - "KEEP-CRASH-026", - Head, - ), - ( - DurabilityCrashPoint::RemoveRecoveryStage, - "KEEP-CRASH-027", - RecoveryDiscard, - ), - ( - DurabilityCrashPoint::SynchronizeStagingAfterRecovery, - "KEEP-CRASH-028", - RecoveryDiscard, - ), - ( - DurabilityCrashPoint::RemoveRecoveryHead, - "KEEP-CRASH-029", - RecoveryDiscard, - ), - ( - DurabilityCrashPoint::SynchronizeRootAfterRecovery, - "KEEP-CRASH-030", - RecoveryDiscard, - ), - ( - DurabilityCrashPoint::OpenAndLockWriterFile, - "KEEP-CRASH-031", - Initialization, - ), - ( - DurabilityCrashPoint::CreateStagingDirectory, - "KEEP-CRASH-032", - Initialization, - ), - ( - DurabilityCrashPoint::CreateSegmentPoolDirectory, - "KEEP-CRASH-033", - Initialization, - ), - ( - DurabilityCrashPoint::CreateCatalogPoolDirectory, - "KEEP-CRASH-034", - Initialization, - ), - ( - DurabilityCrashPoint::SynchronizeRootAfterInitialization, - "KEEP-CRASH-035", - Initialization, - ), - ( - DurabilityCrashPoint::WriteRootStage, - "KEEP-CRASH-036", - Retention, - ), - ( - DurabilityCrashPoint::SynchronizeRootStage, - "KEEP-CRASH-037", - Retention, - ), - ( - DurabilityCrashPoint::AdmitRootNamespace, - "KEEP-CRASH-038", - Retention, - ), - ( - DurabilityCrashPoint::SynchronizeRootsAfterNamespace, - "KEEP-CRASH-039", - Retention, - ), - (DurabilityCrashPoint::LinkRoot, "KEEP-CRASH-040", Retention), - ( - DurabilityCrashPoint::SynchronizeRootNamespace, - "KEEP-CRASH-041", - Retention, - ), - ( - DurabilityCrashPoint::WriteManifestStage, - "KEEP-CRASH-042", - Retention, - ), - ( - DurabilityCrashPoint::SynchronizeManifestStage, - "KEEP-CRASH-043", - Retention, - ), - ( - DurabilityCrashPoint::LinkManifest, - "KEEP-CRASH-044", - Retention, - ), - ( - DurabilityCrashPoint::SynchronizeManifestPool, - "KEEP-CRASH-045", - Retention, - ), - ( - DurabilityCrashPoint::WriteHeadStage, - "KEEP-CRASH-046", - Retention, - ), - ( - DurabilityCrashPoint::SynchronizeHeadStage, - "KEEP-CRASH-047", - Retention, - ), - ( - DurabilityCrashPoint::ReplaceRetentionHead, - "KEEP-CRASH-048", - Retention, - ), - ( - DurabilityCrashPoint::SynchronizeRetentionNamespace, - "KEEP-CRASH-049", - Retention, - ), - ( - DurabilityCrashPoint::RemoveRootStage, - "KEEP-CRASH-050", - Retention, - ), - ( - DurabilityCrashPoint::RemoveManifestStage, - "KEEP-CRASH-051", - Retention, - ), - ( - DurabilityCrashPoint::SynchronizeRetentionCleanup, - "KEEP-CRASH-052", - Retention, - ), - ( - DurabilityCrashPoint::MigrationWriteIntentStage, - "KEEP-CRASH-053", - Migration, - ), - ( - DurabilityCrashPoint::MigrationSynchronizeIntentStage, - "KEEP-CRASH-054", - Migration, - ), - ( - DurabilityCrashPoint::MigrationLinkIntent, - "KEEP-CRASH-055", - Migration, - ), - ( - DurabilityCrashPoint::MigrationSynchronizeRootAfterIntent, - "KEEP-CRASH-056", - Migration, - ), - ( - DurabilityCrashPoint::MigrationRemoveIntentStage, - "KEEP-CRASH-057", - Migration, - ), - ( - DurabilityCrashPoint::MigrationSynchronizeRootAfterIntentCleanup, - "KEEP-CRASH-058", - Migration, - ), - ( - DurabilityCrashPoint::MigrationAdmitReaderFence, - "KEEP-CRASH-059", - Migration, - ), - ( - DurabilityCrashPoint::MigrationAdmitNamespacePrefix, - "KEEP-CRASH-060", - Migration, - ), - ( - DurabilityCrashPoint::MigrationSynchronizeRootAfterNamespace, - "KEEP-CRASH-061", - Migration, - ), - ( - DurabilityCrashPoint::MigrationWriteMarkerStage, - "KEEP-CRASH-062", - Migration, - ), - ( - DurabilityCrashPoint::MigrationSynchronizeMarkerStage, - "KEEP-CRASH-063", - Migration, - ), - ( - DurabilityCrashPoint::MigrationLinkMarker, - "KEEP-CRASH-064", - Migration, - ), - ( - DurabilityCrashPoint::MigrationSynchronizeRootAfterMarker, - "KEEP-CRASH-065", - Migration, - ), - ( - DurabilityCrashPoint::MigrationRemoveMarkerStage, - "KEEP-CRASH-066", - Migration, - ), - ( - DurabilityCrashPoint::MigrationSynchronizeRootAfterMarkerCleanup, - "KEEP-CRASH-067", - Migration, - ), - ( - DurabilityCrashPoint::MigrationWriteReceiptStage, - "KEEP-CRASH-068", - Migration, - ), - ( - DurabilityCrashPoint::MigrationSynchronizeReceiptStage, - "KEEP-CRASH-069", - Migration, - ), - ( - DurabilityCrashPoint::MigrationLinkReceipt, - "KEEP-CRASH-070", - Migration, - ), - ( - DurabilityCrashPoint::MigrationSynchronizeRootAfterReceipt, - "KEEP-CRASH-071", - Migration, - ), - ( - DurabilityCrashPoint::MigrationRemoveReceiptStage, - "KEEP-CRASH-072", - Migration, - ), - ( - DurabilityCrashPoint::MigrationSynchronizeRootAfterReceiptCleanup, - "KEEP-CRASH-073", - Migration, - ), -]; +use expected::EXPECTED; + +#[path = "durability_crash_point_contract/expected.rs"] +mod expected; #[test] fn crash_boundaries_have_one_contiguous_stable_vocabulary() { @@ -414,6 +73,26 @@ fn migration_boundaries_follow_the_twenty_one_phases_in_order() { ); } +#[test] +fn gc_boundaries_follow_the_fourteen_phases_in_order() { + let gc: Vec<_> = DurabilityCrashPoint::ALL + .into_iter() + .filter(|point| point.sequence() == Gc) + .collect(); + + assert_eq!(gc, DurabilityCrashPoint::GC); + assert_eq!(gc.len(), keep::GcExecutionPhase::ALL.len()); + assert_eq!( + DurabilityCrashPoint::GC.map(DurabilityCrashPoint::identifier), + std::array::from_fn::<_, 14, _>(|index| { + let ordinal = 74 + index; + let identifier = format!("KEEP-CRASH-{ordinal:03}"); + DurabilityCrashPoint::from_identifier(&identifier) + .map_or("missing", DurabilityCrashPoint::identifier) + }) + ); +} + #[test] fn sequences_round_trip_their_command_line_identifiers() { for sequence in DurabilityCrashSequence::ALL { diff --git a/xtask/tests/durability_crash_point_contract/expected.rs b/xtask/tests/durability_crash_point_contract/expected.rs new file mode 100644 index 00000000..45eba467 --- /dev/null +++ b/xtask/tests/durability_crash_point_contract/expected.rs @@ -0,0 +1,409 @@ +//! The one contiguous stable crash-boundary vocabulary. + +use xtask::{DurabilityCrashPoint, DurabilityCrashSequence}; + +use DurabilityCrashSequence::{ + Catalog, Gc, Head, Initialization, Migration, RecoveryDiscard, Retention, Segment, +}; + +pub(super) const EXPECTED: &[(DurabilityCrashPoint, &str, DurabilityCrashSequence)] = &[ + ( + DurabilityCrashPoint::CreateSegmentStage, + "KEEP-CRASH-001", + Segment, + ), + ( + DurabilityCrashPoint::WriteSegmentHeader, + "KEEP-CRASH-002", + Segment, + ), + ( + DurabilityCrashPoint::AppendSegmentRecord, + "KEEP-CRASH-003", + Segment, + ), + ( + DurabilityCrashPoint::FlushSegmentRecordPrefix, + "KEEP-CRASH-004", + Segment, + ), + ( + DurabilityCrashPoint::SynchronizeSegmentRecordPrefix, + "KEEP-CRASH-005", + Segment, + ), + ( + DurabilityCrashPoint::AppendSegmentSeal, + "KEEP-CRASH-006", + Segment, + ), + ( + DurabilityCrashPoint::FlushSealedSegment, + "KEEP-CRASH-007", + Segment, + ), + ( + DurabilityCrashPoint::SynchronizeSealedSegment, + "KEEP-CRASH-008", + Segment, + ), + (DurabilityCrashPoint::LinkSegment, "KEEP-CRASH-009", Segment), + ( + DurabilityCrashPoint::SynchronizeSegmentPool, + "KEEP-CRASH-010", + Segment, + ), + ( + DurabilityCrashPoint::RemoveSegmentStage, + "KEEP-CRASH-011", + Segment, + ), + ( + DurabilityCrashPoint::SynchronizeStagingAfterSegment, + "KEEP-CRASH-012", + Segment, + ), + ( + DurabilityCrashPoint::CreateCatalogStage, + "KEEP-CRASH-013", + Catalog, + ), + ( + DurabilityCrashPoint::WriteCatalog, + "KEEP-CRASH-014", + Catalog, + ), + ( + DurabilityCrashPoint::FlushCatalog, + "KEEP-CRASH-015", + Catalog, + ), + ( + DurabilityCrashPoint::SynchronizeCatalog, + "KEEP-CRASH-016", + Catalog, + ), + (DurabilityCrashPoint::LinkCatalog, "KEEP-CRASH-017", Catalog), + ( + DurabilityCrashPoint::SynchronizeCatalogPool, + "KEEP-CRASH-018", + Catalog, + ), + ( + DurabilityCrashPoint::RemoveCatalogStage, + "KEEP-CRASH-019", + Catalog, + ), + ( + DurabilityCrashPoint::SynchronizeStagingAfterCatalog, + "KEEP-CRASH-020", + Catalog, + ), + ( + DurabilityCrashPoint::CreateHeadStage, + "KEEP-CRASH-021", + Head, + ), + (DurabilityCrashPoint::WriteHead, "KEEP-CRASH-022", Head), + (DurabilityCrashPoint::FlushHead, "KEEP-CRASH-023", Head), + ( + DurabilityCrashPoint::SynchronizeHead, + "KEEP-CRASH-024", + Head, + ), + (DurabilityCrashPoint::ReplaceHead, "KEEP-CRASH-025", Head), + ( + DurabilityCrashPoint::SynchronizeRootAfterHead, + "KEEP-CRASH-026", + Head, + ), + ( + DurabilityCrashPoint::RemoveRecoveryStage, + "KEEP-CRASH-027", + RecoveryDiscard, + ), + ( + DurabilityCrashPoint::SynchronizeStagingAfterRecovery, + "KEEP-CRASH-028", + RecoveryDiscard, + ), + ( + DurabilityCrashPoint::RemoveRecoveryHead, + "KEEP-CRASH-029", + RecoveryDiscard, + ), + ( + DurabilityCrashPoint::SynchronizeRootAfterRecovery, + "KEEP-CRASH-030", + RecoveryDiscard, + ), + ( + DurabilityCrashPoint::OpenAndLockWriterFile, + "KEEP-CRASH-031", + Initialization, + ), + ( + DurabilityCrashPoint::CreateStagingDirectory, + "KEEP-CRASH-032", + Initialization, + ), + ( + DurabilityCrashPoint::CreateSegmentPoolDirectory, + "KEEP-CRASH-033", + Initialization, + ), + ( + DurabilityCrashPoint::CreateCatalogPoolDirectory, + "KEEP-CRASH-034", + Initialization, + ), + ( + DurabilityCrashPoint::SynchronizeRootAfterInitialization, + "KEEP-CRASH-035", + Initialization, + ), + ( + DurabilityCrashPoint::WriteRootStage, + "KEEP-CRASH-036", + Retention, + ), + ( + DurabilityCrashPoint::SynchronizeRootStage, + "KEEP-CRASH-037", + Retention, + ), + ( + DurabilityCrashPoint::AdmitRootNamespace, + "KEEP-CRASH-038", + Retention, + ), + ( + DurabilityCrashPoint::SynchronizeRootsAfterNamespace, + "KEEP-CRASH-039", + Retention, + ), + (DurabilityCrashPoint::LinkRoot, "KEEP-CRASH-040", Retention), + ( + DurabilityCrashPoint::SynchronizeRootNamespace, + "KEEP-CRASH-041", + Retention, + ), + ( + DurabilityCrashPoint::WriteManifestStage, + "KEEP-CRASH-042", + Retention, + ), + ( + DurabilityCrashPoint::SynchronizeManifestStage, + "KEEP-CRASH-043", + Retention, + ), + ( + DurabilityCrashPoint::LinkManifest, + "KEEP-CRASH-044", + Retention, + ), + ( + DurabilityCrashPoint::SynchronizeManifestPool, + "KEEP-CRASH-045", + Retention, + ), + ( + DurabilityCrashPoint::WriteHeadStage, + "KEEP-CRASH-046", + Retention, + ), + ( + DurabilityCrashPoint::SynchronizeHeadStage, + "KEEP-CRASH-047", + Retention, + ), + ( + DurabilityCrashPoint::ReplaceRetentionHead, + "KEEP-CRASH-048", + Retention, + ), + ( + DurabilityCrashPoint::SynchronizeRetentionNamespace, + "KEEP-CRASH-049", + Retention, + ), + ( + DurabilityCrashPoint::RemoveRootStage, + "KEEP-CRASH-050", + Retention, + ), + ( + DurabilityCrashPoint::RemoveManifestStage, + "KEEP-CRASH-051", + Retention, + ), + ( + DurabilityCrashPoint::SynchronizeRetentionCleanup, + "KEEP-CRASH-052", + Retention, + ), + ( + DurabilityCrashPoint::MigrationWriteIntentStage, + "KEEP-CRASH-053", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeIntentStage, + "KEEP-CRASH-054", + Migration, + ), + ( + DurabilityCrashPoint::MigrationLinkIntent, + "KEEP-CRASH-055", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeRootAfterIntent, + "KEEP-CRASH-056", + Migration, + ), + ( + DurabilityCrashPoint::MigrationRemoveIntentStage, + "KEEP-CRASH-057", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeRootAfterIntentCleanup, + "KEEP-CRASH-058", + Migration, + ), + ( + DurabilityCrashPoint::MigrationAdmitReaderFence, + "KEEP-CRASH-059", + Migration, + ), + ( + DurabilityCrashPoint::MigrationAdmitNamespacePrefix, + "KEEP-CRASH-060", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeRootAfterNamespace, + "KEEP-CRASH-061", + Migration, + ), + ( + DurabilityCrashPoint::MigrationWriteMarkerStage, + "KEEP-CRASH-062", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeMarkerStage, + "KEEP-CRASH-063", + Migration, + ), + ( + DurabilityCrashPoint::MigrationLinkMarker, + "KEEP-CRASH-064", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeRootAfterMarker, + "KEEP-CRASH-065", + Migration, + ), + ( + DurabilityCrashPoint::MigrationRemoveMarkerStage, + "KEEP-CRASH-066", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeRootAfterMarkerCleanup, + "KEEP-CRASH-067", + Migration, + ), + ( + DurabilityCrashPoint::MigrationWriteReceiptStage, + "KEEP-CRASH-068", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeReceiptStage, + "KEEP-CRASH-069", + Migration, + ), + ( + DurabilityCrashPoint::MigrationLinkReceipt, + "KEEP-CRASH-070", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeRootAfterReceipt, + "KEEP-CRASH-071", + Migration, + ), + ( + DurabilityCrashPoint::MigrationRemoveReceiptStage, + "KEEP-CRASH-072", + Migration, + ), + ( + DurabilityCrashPoint::MigrationSynchronizeRootAfterReceiptCleanup, + "KEEP-CRASH-073", + Migration, + ), + ( + DurabilityCrashPoint::GcWriteIntentStage, + "KEEP-CRASH-074", + Gc, + ), + ( + DurabilityCrashPoint::GcSynchronizeIntentStage, + "KEEP-CRASH-075", + Gc, + ), + (DurabilityCrashPoint::GcLinkIntent, "KEEP-CRASH-076", Gc), + ( + DurabilityCrashPoint::GcSynchronizeGcAfterIntent, + "KEEP-CRASH-077", + Gc, + ), + ( + DurabilityCrashPoint::GcRemoveIntentStage, + "KEEP-CRASH-078", + Gc, + ), + ( + DurabilityCrashPoint::GcSynchronizeGcAfterIntentCleanup, + "KEEP-CRASH-079", + Gc, + ), + ( + DurabilityCrashPoint::GcUnlinkCandidate, + "KEEP-CRASH-080", + Gc, + ), + ( + DurabilityCrashPoint::GcSynchronizeSegmentPool, + "KEEP-CRASH-081", + Gc, + ), + ( + DurabilityCrashPoint::GcWriteReceiptStage, + "KEEP-CRASH-082", + Gc, + ), + ( + DurabilityCrashPoint::GcSynchronizeReceiptStage, + "KEEP-CRASH-083", + Gc, + ), + (DurabilityCrashPoint::GcReplaceReceipt, "KEEP-CRASH-084", Gc), + ( + DurabilityCrashPoint::GcSynchronizeGcAfterReceipt, + "KEEP-CRASH-085", + Gc, + ), + (DurabilityCrashPoint::GcRemoveIntent, "KEEP-CRASH-086", Gc), + ( + DurabilityCrashPoint::GcSynchronizeGcAfterIntentRemoval, + "KEEP-CRASH-087", + Gc, + ), +]; diff --git a/xtask/tests/retention_store_v2_protocol_contract/transition_laws.rs b/xtask/tests/retention_store_v2_protocol_contract/transition_laws.rs index 64c39a98..8654767d 100644 --- a/xtask/tests/retention_store_v2_protocol_contract/transition_laws.rs +++ b/xtask/tests/retention_store_v2_protocol_contract/transition_laws.rs @@ -1,6 +1,6 @@ -//! Stable migration crash-transition ledger laws. +//! Stable migration and GC crash-transition ledger laws. -use keep::StoreMigrationPhase; +use keep::{GcExecutionPhase, StoreMigrationPhase}; const TRANSITIONS: &str = include_str!("../../../conformance/segment-store/v2/transitions.tsv"); @@ -29,6 +29,24 @@ const OPERATIONS: [&str; 21] = [ "sync-root-after-receipt-cleanup", ]; +/// The GC `operation` column in `GcExecutionPhase::ALL` order. +const GC_OPERATIONS: [&str; 14] = [ + "write-intent-stage", + "sync-intent-stage", + "link-intent", + "sync-gc-after-intent", + "remove-intent-stage", + "sync-gc-after-intent-cleanup", + "unlink-candidate", + "sync-segment-pool", + "write-receipt-stage", + "sync-receipt-stage", + "replace-receipt", + "sync-gc-after-receipt", + "remove-intent", + "sync-gc-after-intent-removal", +]; + #[test] fn migration_transition_ledger_is_complete_and_stable() -> Result<(), String> { assert!(TRANSITIONS.starts_with( @@ -37,6 +55,10 @@ fn migration_transition_ledger_is_complete_and_stable() -> Result<(), String> { post_state\trecovery_posture\n" )); assert_eq!(OPERATIONS.len(), StoreMigrationPhase::ALL.len()); + assert_eq!(GC_OPERATIONS.len(), GcExecutionPhase::ALL.len()); + for (operation, phase) in GC_OPERATIONS.iter().zip(GcExecutionPhase::ALL) { + assert_eq!(*operation, phase.operation()); + } let mut row_count = 0usize; for (offset, row) in TRANSITIONS.lines().skip(2).enumerate() { @@ -46,8 +68,16 @@ fn migration_transition_ledger_is_complete_and_stable() -> Result<(), String> { let expected_id = format!("KEEP-CRASH-{ordinal:03}"); let fields: Vec<_> = row.split('\t').collect(); assert_eq!(fields.first(), Some(&expected_id.as_str())); - assert_eq!(fields.get(1), Some(&"migration"), "{expected_id}"); - assert_eq!(fields.get(2), OPERATIONS.get(offset), "{expected_id}"); + let (phase, operation) = if offset < OPERATIONS.len() { + ("migration", OPERATIONS.get(offset)) + } else { + ( + "gc", + GC_OPERATIONS.get(offset.saturating_sub(OPERATIONS.len())), + ) + }; + assert_eq!(fields.get(1), Some(&phase), "{expected_id}"); + assert_eq!(fields.get(2), operation, "{expected_id}"); assert_eq!( fields.len(), 7, @@ -61,15 +91,18 @@ fn migration_transition_ledger_is_complete_and_stable() -> Result<(), String> { .checked_add(1) .ok_or("transition count overflow")?; } - assert_eq!(row_count, 21); + assert_eq!(row_count, 35); // Every stage write may leave an incomplete pre-effect stage, and only - // those rows may plan a discard; the last two rows admit completion. + // those rows may plan a discard; the last two rows of each sequence + // admit completion. for (ordinal, row) in TRANSITIONS.lines().skip(2).enumerate() { let discards = row.contains("discard-incomplete-stage"); - assert_eq!(discards, matches!(ordinal, 0 | 9 | 15), "{row}"); + assert_eq!(discards, matches!(ordinal, 0 | 9 | 15 | 21 | 29), "{row}"); let completes = row.ends_with("admit-complete-migration"); - assert_eq!(completes, ordinal >= 19, "{row}"); + assert_eq!(completes, matches!(ordinal, 19 | 20), "{row}"); + let retires = row.ends_with("admit-complete-retirement"); + assert_eq!(retires, ordinal >= 33, "{row}"); } assert!( TRANSITIONS.contains("directory-prefix-length-zero-to-six"), From 5f49800f7877ec0d16f62bd04a7a4347d5e11724 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 12:47:32 -0700 Subject: [PATCH 28/59] Test: permanent corruption ledgers over every durable structural field ROADMAP T-21.2 (F-21). Every structural field of every durable segment-store record now has one frozen byte mutation whose exact first refusal and verification stage are reproduced through the public decoders. `conformance/segment-store/v1/mutations.tsv` (105 rows) covers the segment header, record header, record checksum, seal, the whole segment, the catalog header, entry, and trailer, catalog-to-segment binding, the publication head, and head-to-catalog binding. `v2/mutations.tsv` (150 rows) covers `FORMAT`, the migration intent and receipt, the retention root, manifest, and head, the GC intent and receipt, and the disposition receipt. Each row names its case, record, base fixture, operation (`replace-v1`, `xor-v1`, `truncate-v1`, `append-v1`, `delete-v1`), span, parameter, checksum posture (`preserve-v1`; `recompute-v1` refreshes inner set digests and the trailer; `recompute-trailer-v1` only the record's digest and checksum; `recompute-checksum-v1` only the checksum, so the check behind a checksum is reachable), the expected first refusal as `.`, the stage it establishes (`framing` and `checksum` are `VerificationDepth::Framing` and `::Checksum`; `identity` is content that does not hash to its declared identity; `binding` is a cross-record contradiction), and the requirement it evidences. `tests/segment_store_mutations.rs` parses both ledgers, applies each row to its fixture, recomputes trailers with the documented recipes (`framed_blake3_v1` for version 1, domain-prefixed BLAKE3 for version 2), decodes through the public entry point (`AdmittedSegment`, `ChecksummedCatalog` and its admission against the frozen segment, `ChecksummedPublicationHead` and its snapshot admission, and every version-2 admitting decoder with its canonical context fixtures), classifies the first refusal by its variant, and reports every differing row at once. It also requires every registered record to have at least three rows and every row to cite a `KEEP-` requirement. `cargo xtask conformance-check` now admits both ledgers' shape first (registered records, operations, postures, stages, `.` outcomes, `KEEP-FAMILY-NNN` requirements, and spans inside the named fixture), needing no external witness; `segment-store-mutations-check` runs it alone. The Golden File Worldline capability `keep.verification.precise-refusal/v1` moves from `declared-future` to `required`, recorded in `capabilities.tsv`, the capability contract, and the Worldline page. Format READMEs gain a "Mutation ledger" section; the corpus READMEs list the new file and its columns; `KEEP-SEGMENT-006`, `KEEP-CATALOG-002`, `KEEP-RETENTION-003`, `KEEP-MIGRATION-002`, `KEEP-GC-001`, and `KEEP-VERIFY-003` cite the ledgers. Depth is asserted as the ledger stage rather than through a durable `VerificationReport`, because no durable report producer exists until T-23.1; the ROADMAP entry says so. Red: the first run of the law disagreed with eight authored rows (three `reserved-u16`/`reserved-u32` variant names, record length checked before chunk length, a non-congruent head catalog length that refused at decode rather than at binding, and three set-digest rows that needed the inner set digest recomputed to reach the field behind it). Every one was a ledger correction; no decoder changed. Green: 255 rows reproduce exactly. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 17 + ROADMAP.md | 18 +- .../golden-file-worldline/v1/capabilities.tsv | 2 +- conformance/segment-store/v1/README.md | 8 + conformance/segment-store/v1/mutations.tsv | 107 +++++++ conformance/segment-store/v2/README.md | 17 + conformance/segment-store/v2/mutations.tsv | 152 +++++++++ docs/conformance/golden-file-worldline.md | 7 +- docs/formats/segment-store-v1/README.md | 13 + docs/formats/segment-store-v1/requirements.md | 4 +- docs/formats/segment-store-v2/README.md | 13 + docs/formats/segment-store-v2/requirements.md | 6 +- docs/invariants/verification/requirements.md | 2 +- tests/segment_store_mutations.rs | 142 +++++++++ tests/segment_store_mutations/classify.rs | 253 +++++++++++++++ tests/segment_store_mutations/fixtures.rs | 89 ++++++ tests/segment_store_mutations/ledger.rs | 173 ++++++++++ tests/segment_store_mutations/recipes.rs | 287 +++++++++++++++++ .../capability_contract.rs | 2 +- xtask/src/main.rs | 3 + xtask/src/protocol_conformance.rs | 24 +- .../segment_store_mutations.rs | 296 ++++++++++++++++++ ...retention_store_v2_conformance_contract.rs | 1 + 23 files changed, 1619 insertions(+), 17 deletions(-) create mode 100644 conformance/segment-store/v1/mutations.tsv create mode 100644 conformance/segment-store/v2/mutations.tsv create mode 100644 tests/segment_store_mutations.rs create mode 100644 tests/segment_store_mutations/classify.rs create mode 100644 tests/segment_store_mutations/fixtures.rs create mode 100644 tests/segment_store_mutations/ledger.rs create mode 100644 tests/segment_store_mutations/recipes.rs create mode 100644 xtask/src/protocol_conformance/segment_store_mutations.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ce89271d..ba7f2473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,23 @@ after its public API and format compatibility policies are established. ### Added +- Permanent corruption ledgers over every durable structural field. + `conformance/segment-store/v1/mutations.tsv` (105 rows: segment header, + record header, record checksum, seal, whole segment, catalog header, + entry, trailer, catalog-to-segment binding, publication head, and + head-to-catalog binding) and `v2/mutations.tsv` (150 rows: `FORMAT`, + migration intent and receipt, retention root, manifest, and head, GC + intent and receipt, disposition receipt) freeze one byte mutation per + field with its exact first refusal as `.`, the + verification stage it establishes (`framing`, `checksum`, `identity`, + `binding`), a checksum posture (preserve, or recompute inner set digests, + the trailer, or only the checksum, so the check behind a checksum is + reachable), and the requirement it evidences. + `tests/segment_store_mutations.rs` applies every row through the public + decoders and reports every differing row at once; `cargo xtask + conformance-check` (and `segment-store-mutations-check`) refuses a + malformed row before any external witness runs. The Golden File Worldline + capability `keep.verification.precise-refusal/v1` is now `required`. - GC execution, retirement, and recovery. `FilesystemGcAuthority` retires the released segments a `GcPlan` names: `prepare` refuses over any residue but idle or complete, refuses an empty plan, acquires the reader diff --git a/ROADMAP.md b/ROADMAP.md index 6aae28fc..9b9be48a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,7 +99,7 @@ names; use those in code, tests, and commits. - [x] [F-18 Retention publication](#f-18-retention-publication) — Done on this branch (recovery, the `KEEP-CRASH-036`–`052` matrix, member re-verification, and orphan disposition) - [x] [F-19 Reader fence and immutable version-2 snapshots](#f-19-reader-fence-and-immutable-version-2-snapshots) — Done on this branch (merged from PR #99) - [x] [F-20 Model-based retention transition evidence](#f-20-model-based-retention-transition-evidence) — Done on this branch (merged from PR #99) -- [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Planned (#20) +- [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Partial (#20; report vocabulary and corruption ledgers done on this branch; durable receipts remain) - [ ] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Partial (#21; codecs, planner, and orphan disposition done on this branch; compaction and execution remain) - [ ] [F-23 Durable authenticated reads and refusal receipts](#f-23-durable-authenticated-reads-and-refusal-receipts) — Planned (#109) - [ ] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Planned (#82, #72) @@ -1031,7 +1031,8 @@ clocks, paths, environment, and caller identity out of the core. ### F-21 Precise verification reports and corruption refusal **Status:** Partial (#20, P1, M4); the vocabulary and the reference-store -form landed on this branch (T-21.1). The most-cited open blocker: F-22, +form (T-21.1) and the permanent corruption ledgers (T-21.2) landed on this +branch; durable receipts (T-21.3) remain. The most-cited open blocker: F-22, F-23, F-24, F-27, F-28, F-29, F-30, F-31, F-33, and F-34 all name it. Verify content and store structure at explicit, enumerated depths; report @@ -1088,8 +1089,17 @@ quarantines, or rewrites physical state. consequence ("report the exact verification depth") satisfied. - **Dependencies:** none for the reference store; F-19 for snapshot-bound depths. -- [ ] T-21.2 Permanent corruption matrix over every durable structural - field. +- [x] T-21.2 Permanent corruption matrix over every durable structural + field — `conformance/segment-store/v1/mutations.tsv` (105 rows) and + `v2/mutations.tsv` (150 rows) with exact first refusal, verification + stage, and requirement per row; `tests/segment_store_mutations.rs` + reproduces every row through the public decoders; `cargo xtask + conformance-check` admits the ledgers' shape; the Worldline capability + `keep.verification.precise-refusal/v1` is `required`. Depth is asserted + as the ledger stage (`framing`/`checksum` are `VerificationDepth::Framing` + and `::Checksum`; `identity` and `binding` are content identity and + cross-record contradiction) because no durable `VerificationReport` + producer exists until T-23.1. Original task fields: - **Requirements:** every field of segment header, record header, record checksum, seal, catalog header, entry, trailer, publication head, `FORMAT`, intent, receipt, root, manifest, and retention head has a diff --git a/conformance/golden-file-worldline/v1/capabilities.tsv b/conformance/golden-file-worldline/v1/capabilities.tsv index b53c7d64..329ff780 100644 --- a/conformance/golden-file-worldline/v1/capabilities.tsv +++ b/conformance/golden-file-worldline/v1/capabilities.tsv @@ -11,7 +11,7 @@ keep.range.minimal-overlap/v1 required M2 11 only chunks overlapping an exact re keep.segment.verified-read/v1 declared-future M3 14,15 sealed segment reads authenticate framing and content keep.restart.lawful-recovery/v1 declared-future M3 17 restart recovery reaches one documented lawful state keep.retention.both-states/v1 declared-future M4 18,19 retention evidence keeps both worldline states live -keep.verification.precise-refusal/v1 declared-future M4 20 corruption produces precise verification refusal +keep.verification.precise-refusal/v1 required M4 20 corruption produces precise verification refusal keep.compaction.identity-stable/v1 declared-future M4 21 compaction cannot move logical BlobId keep.echo.identity-agreement/v1 declared-future M5 22,23 Echo and Keep agree on the exact logical identity boundary keep.graft.golden-worldline/v1 declared-future M5 24 Graft executes the Golden File Worldline through Keep diff --git a/conformance/segment-store/v1/README.md b/conformance/segment-store/v1/README.md index f507da2b..e977f2da 100644 --- a/conformance/segment-store/v1/README.md +++ b/conformance/segment-store/v1/README.md @@ -41,6 +41,14 @@ oracles for issues #16 and #17. - `transitions.tsv` assigns one stable crash-point identifier to every version-1 durable transition and records its pre-state, interrupted-state classification, post-state, and recovery posture. +- `mutations.tsv` is the corruption ledger: one frozen byte mutation per + structural field of the segment header, record header, record checksum, + seal, catalog header, catalog entry, catalog trailer, and publication head, + each with its exact first refusal (`.`), the verification + stage that refusal establishes (`framing`, `checksum`, `identity`, or + `binding`), and the requirement it evidences. + `tests/segment_store_mutations.rs` applies every row through the public + decoders; `cargo xtask conformance-check` refuses a malformed row. - `ORIGIN.md` records construction and review provenance. Hexadecimal fixture files contain one lowercase hexadecimal encoding of the diff --git a/conformance/segment-store/v1/mutations.tsv b/conformance/segment-store/v1/mutations.tsv new file mode 100644 index 00000000..30787b2e --- /dev/null +++ b/conformance/segment-store/v1/mutations.tsv @@ -0,0 +1,107 @@ +keep.segment-store-mutations/v1 +case record base_fixture operation offset span_length parameter checksum_posture expected_outcome stage requirement +segment-header-magic segment-header one-zero-segment.hex xor-v1 0 1 01 preserve-v1 segment-header.invalid-magic framing KEEP-SEGMENT-006 +segment-header-version segment-header one-zero-segment.hex replace-v1 16 2 0002 preserve-v1 segment-header.unsupported-version framing KEEP-SEGMENT-006 +segment-header-flags segment-header one-zero-segment.hex replace-v1 18 2 0001 preserve-v1 segment-header.unknown-flags framing KEEP-SEGMENT-006 +segment-header-length segment-header one-zero-segment.hex replace-v1 20 2 0041 preserve-v1 segment-header.header-length framing KEEP-SEGMENT-006 +segment-header-record-header-length segment-header one-zero-segment.hex replace-v1 22 2 0071 preserve-v1 segment-header.record-header-length framing KEEP-SEGMENT-006 +segment-header-seal-length segment-header one-zero-segment.hex replace-v1 24 2 0081 preserve-v1 segment-header.seal-length framing KEEP-SEGMENT-006 +segment-header-reserved-short segment-header one-zero-segment.hex replace-v1 26 2 0001 preserve-v1 segment-header.reserved-u16 framing KEEP-SEGMENT-006 +segment-header-maximum-record-payload segment-header one-zero-segment.hex replace-v1 28 8 0000000004000001 preserve-v1 segment-header.maximum-record-payload-length framing KEEP-SEGMENT-006 +segment-header-maximum-segment-length segment-header one-zero-segment.hex replace-v1 36 8 0000000040000001 preserve-v1 segment-header.maximum-segment-length framing KEEP-SEGMENT-006 +segment-header-maximum-record-count segment-header one-zero-segment.hex replace-v1 44 4 00100001 preserve-v1 segment-header.maximum-record-count framing KEEP-SEGMENT-006 +segment-header-record-checksum-algorithm segment-header one-zero-segment.hex replace-v1 48 1 02 preserve-v1 segment-header.record-checksum-algorithm framing KEEP-SEGMENT-006 +segment-header-segment-digest-algorithm segment-header one-zero-segment.hex replace-v1 49 1 02 preserve-v1 segment-header.segment-digest-algorithm framing KEEP-SEGMENT-006 +segment-header-reserved-tail segment-header one-zero-segment.hex xor-v1 50 1 01 preserve-v1 segment-header.reserved-bytes framing KEEP-SEGMENT-006 +segment-record-magic segment-record one-zero-segment.hex xor-v1 64 1 01 preserve-v1 segment-record.invalid-magic framing KEEP-SEGMENT-006 +segment-record-version segment-record one-zero-segment.hex replace-v1 80 2 0002 preserve-v1 segment-record.unsupported-version framing KEEP-SEGMENT-006 +segment-record-kind segment-record one-zero-segment.hex replace-v1 82 1 03 preserve-v1 segment-record.unknown-record-kind framing KEEP-SEGMENT-006 +segment-record-flags segment-record one-zero-segment.hex replace-v1 83 1 01 preserve-v1 segment-record.unknown-flags framing KEEP-SEGMENT-006 +segment-record-header-length segment-record one-zero-segment.hex replace-v1 84 2 0071 preserve-v1 segment-record.header-length framing KEEP-SEGMENT-006 +segment-record-identity-length segment-record one-zero-segment.hex replace-v1 86 2 003c preserve-v1 segment-record.identity-length framing KEEP-SEGMENT-006 +segment-record-payload-length segment-record one-zero-segment.hex replace-v1 88 8 0000000000000002 preserve-v1 segment-record.record-length framing KEEP-SEGMENT-006 +segment-record-length segment-record one-zero-segment.hex replace-v1 96 8 0000000000000092 preserve-v1 segment-record.record-length framing KEEP-SEGMENT-006 +segment-record-checksum-algorithm segment-record one-zero-segment.hex replace-v1 104 1 02 preserve-v1 segment-record.record-checksum-algorithm framing KEEP-SEGMENT-006 +segment-record-identity-version segment-record one-zero-segment.hex replace-v1 105 2 0002 preserve-v1 segment-record.identity-version framing KEEP-SEGMENT-006 +segment-record-identity-algorithm segment-record one-zero-segment.hex replace-v1 107 1 02 preserve-v1 segment-record.identity-algorithm framing KEEP-SEGMENT-006 +segment-record-reserved segment-record one-zero-segment.hex replace-v1 108 4 00000001 preserve-v1 segment-record.reserved-bytes framing KEEP-SEGMENT-006 +segment-record-zero-chunk-length segment-record one-zero-segment.hex replace-v1 112 4 00000000 preserve-v1 segment-record.zero-chunk-length framing KEEP-SEGMENT-006 +segment-record-chunk-length-disagrees segment-record one-zero-segment.hex replace-v1 112 4 00000002 preserve-v1 segment-record.chunk-payload-length-mismatch framing KEEP-SEGMENT-006 +segment-record-identity-digest-preserved segment-record one-zero-segment.hex xor-v1 116 1 01 preserve-v1 segment-record.checksum-mismatch checksum KEEP-SEGMENT-006 +segment-record-identity-digest-recomputed segment-record one-zero-segment.hex xor-v1 116 1 01 recompute-v1 segment-record.chunk-identity-mismatch identity KEEP-SEGMENT-006 +segment-record-identity-tail segment-record one-zero-segment.hex xor-v1 150 1 01 preserve-v1 segment-record.nonzero-chunk-identity-tail framing KEEP-SEGMENT-006 +segment-record-reserved-tail segment-record one-zero-segment.hex replace-v1 172 4 00000001 preserve-v1 segment-record.reserved-bytes framing KEEP-SEGMENT-006 +segment-record-payload-preserved segment-record one-zero-segment.hex xor-v1 176 1 01 preserve-v1 segment-record.checksum-mismatch checksum KEEP-SEGMENT-006 +segment-record-payload-recomputed segment-record one-zero-segment.hex xor-v1 176 1 01 recompute-v1 segment-record.chunk-identity-mismatch identity KEEP-SEGMENT-006 +segment-record-checksum segment-record one-zero-segment.hex xor-v1 177 1 01 preserve-v1 segment-record.checksum-mismatch checksum KEEP-SEGMENT-006 +segment-seal-magic segment-seal one-zero-segment.hex xor-v1 209 1 01 preserve-v1 segment-seal.invalid-magic framing KEEP-SEGMENT-006 +segment-seal-version segment-seal one-zero-segment.hex replace-v1 225 2 0002 preserve-v1 segment-seal.unsupported-version framing KEEP-SEGMENT-006 +segment-seal-flags segment-seal one-zero-segment.hex replace-v1 227 2 0001 preserve-v1 segment-seal.unknown-flags framing KEEP-SEGMENT-006 +segment-seal-length segment-seal one-zero-segment.hex replace-v1 229 2 0081 preserve-v1 segment-seal.seal-length framing KEEP-SEGMENT-006 +segment-seal-reserved-short segment-seal one-zero-segment.hex replace-v1 231 2 0001 preserve-v1 segment-seal.reserved-u16 framing KEEP-SEGMENT-006 +segment-seal-record-count-preserved segment-seal one-zero-segment.hex replace-v1 233 4 00000002 preserve-v1 segment-seal.seal-checksum-mismatch checksum KEEP-SEGMENT-006 +segment-seal-record-count-recomputed segment-seal one-zero-segment.hex replace-v1 233 4 00000002 recompute-v1 segment.record-header-truncated framing KEEP-SEGMENT-006 +segment-seal-reserved-word segment-seal one-zero-segment.hex replace-v1 237 4 00000001 preserve-v1 segment-seal.reserved-u32 framing KEEP-SEGMENT-006 +segment-seal-bytes-before-seal segment-seal one-zero-segment.hex replace-v1 241 8 00000000000000d0 recompute-v1 segment-seal.bytes-before-seal framing KEEP-SEGMENT-006 +segment-seal-segment-length segment-seal one-zero-segment.hex replace-v1 249 8 0000000000000152 recompute-v1 segment-seal.segment-length framing KEEP-SEGMENT-006 +segment-seal-record-bytes segment-seal one-zero-segment.hex replace-v1 257 8 0000000000000090 recompute-v1 segment-seal.record-bytes framing KEEP-SEGMENT-006 +segment-seal-checksum-algorithm segment-seal one-zero-segment.hex replace-v1 265 1 02 preserve-v1 segment-seal.seal-checksum-algorithm framing KEEP-SEGMENT-006 +segment-seal-digest-algorithm segment-seal one-zero-segment.hex replace-v1 266 1 02 preserve-v1 segment-seal.segment-digest-algorithm framing KEEP-SEGMENT-006 +segment-seal-reserved-tail segment-seal one-zero-segment.hex xor-v1 267 1 01 preserve-v1 segment-seal.reserved-bytes framing KEEP-SEGMENT-006 +segment-seal-digest-preserved segment-seal one-zero-segment.hex xor-v1 273 1 01 preserve-v1 segment-seal.seal-checksum-mismatch checksum KEEP-SEGMENT-006 +segment-seal-digest-recomputed segment-seal one-zero-segment.hex xor-v1 273 1 01 recompute-checksum-v1 segment-seal.segment-digest-mismatch checksum KEEP-SEGMENT-006 +segment-seal-checksum segment-seal one-zero-segment.hex xor-v1 305 1 01 preserve-v1 segment-seal.seal-checksum-mismatch checksum KEEP-SEGMENT-006 +segment-truncated-below-minimum segment one-zero-segment.hex truncate-v1 100 0 - preserve-v1 segment.wrong-length framing KEEP-SEGMENT-006 +segment-truncated-inside-seal segment one-zero-segment.hex truncate-v1 300 0 - preserve-v1 segment-seal.invalid-magic framing KEEP-SEGMENT-006 +segment-trailing-byte segment one-zero-segment.hex append-v1 337 0 00 preserve-v1 segment-seal.invalid-magic framing KEEP-SEGMENT-006 +empty-segment-trailing-byte segment empty-segment.hex append-v1 192 0 00 preserve-v1 segment-seal.invalid-magic framing KEEP-SEGMENT-006 +catalog-magic catalog one-zero-catalog.hex xor-v1 0 1 01 preserve-v1 catalog.invalid-magic framing KEEP-CATALOG-002 +catalog-version catalog one-zero-catalog.hex replace-v1 16 2 0002 preserve-v1 catalog.unsupported-version framing KEEP-CATALOG-002 +catalog-flags catalog one-zero-catalog.hex replace-v1 18 2 0001 preserve-v1 catalog.flags framing KEEP-CATALOG-002 +catalog-header-length catalog one-zero-catalog.hex replace-v1 20 2 0081 preserve-v1 catalog.header-length framing KEEP-CATALOG-002 +catalog-entry-length catalog one-zero-catalog.hex replace-v1 22 2 00a1 preserve-v1 catalog.entry-length framing KEEP-CATALOG-002 +catalog-zero-generation catalog one-zero-catalog.hex replace-v1 24 8 0000000000000000 preserve-v1 catalog.generation framing KEEP-CATALOG-002 +catalog-initial-predecessor catalog one-zero-catalog.hex xor-v1 32 1 01 preserve-v1 catalog.unexpected-predecessor framing KEEP-CATALOG-002 +catalog-successor-missing-predecessor catalog one-zero-catalog-generation-two.hex replace-v1 32 32 0000000000000000000000000000000000000000000000000000000000000000 preserve-v1 catalog.missing-predecessor framing KEEP-CATALOG-002 +catalog-entry-count-disagrees catalog one-zero-catalog.hex replace-v1 64 8 0000000000000002 preserve-v1 catalog.entry-count-length-mismatch framing KEEP-CATALOG-002 +catalog-zero-length catalog one-zero-catalog.hex replace-v1 72 8 0000000000000000 preserve-v1 catalog.catalog-length framing KEEP-CATALOG-002 +catalog-checksum-algorithm catalog one-zero-catalog.hex replace-v1 80 1 02 preserve-v1 catalog.checksum-algorithm framing KEEP-CATALOG-002 +catalog-digest-algorithm catalog one-zero-catalog.hex replace-v1 81 1 02 preserve-v1 catalog.digest-algorithm framing KEEP-CATALOG-002 +catalog-reserved catalog one-zero-catalog.hex xor-v1 82 1 01 preserve-v1 catalog.reserved framing KEEP-CATALOG-002 +catalog-truncated-below-minimum catalog one-zero-catalog.hex truncate-v1 100 0 - preserve-v1 catalog.minimum-length framing KEEP-CATALOG-002 +catalog-truncated-byte catalog one-zero-catalog.hex truncate-v1 351 0 - preserve-v1 catalog.observed-length framing KEEP-CATALOG-002 +catalog-trailing-byte catalog one-zero-catalog.hex append-v1 352 0 00 preserve-v1 catalog.observed-length framing KEEP-CATALOG-002 +catalog-checksum catalog one-zero-catalog.hex xor-v1 288 1 01 preserve-v1 catalog.checksum-mismatch checksum KEEP-CATALOG-002 +catalog-digest catalog one-zero-catalog.hex xor-v1 320 1 01 preserve-v1 catalog.digest-mismatch checksum KEEP-CATALOG-002 +catalog-covered-byte-preserved catalog one-zero-catalog.hex xor-v1 130 1 01 preserve-v1 catalog.checksum-mismatch checksum KEEP-CATALOG-002 +catalog-digest-recomputed-checksum catalog one-zero-catalog.hex xor-v1 320 1 01 recompute-checksum-v1 catalog.digest-mismatch checksum KEEP-CATALOG-002 +catalog-entry-kind catalog-entry one-zero-catalog.hex replace-v1 128 1 03 recompute-v1 catalog-entry.unknown-record-kind framing KEEP-CATALOG-002 +catalog-entry-flags catalog-entry one-zero-catalog.hex replace-v1 129 1 01 recompute-v1 catalog-entry.flags framing KEEP-CATALOG-002 +catalog-entry-identity-length catalog-entry one-zero-catalog.hex replace-v1 130 2 003c recompute-v1 catalog-entry.identity-length framing KEEP-CATALOG-002 +catalog-entry-zero-chunk-length catalog-entry one-zero-catalog.hex replace-v1 132 4 00000000 recompute-v1 catalog-entry.zero-chunk-length framing KEEP-CATALOG-002 +catalog-entry-chunk-length-disagrees catalog-entry one-zero-catalog.hex replace-v1 132 4 00000002 recompute-v1 catalog-entry.chunk-payload-length-mismatch framing KEEP-CATALOG-002 +catalog-entry-identity-tail catalog-entry one-zero-catalog.hex xor-v1 170 1 01 recompute-v1 catalog-entry.nonzero-chunk-identity-tail framing KEEP-CATALOG-002 +catalog-entry-record-offset-in-header catalog-entry one-zero-catalog.hex replace-v1 224 8 0000000000000000 recompute-v1 catalog-entry.record-offset framing KEEP-CATALOG-002 +catalog-entry-record-length-disagrees catalog-entry one-zero-catalog.hex replace-v1 232 8 0000000000000092 recompute-v1 catalog-entry.record-length-mismatch framing KEEP-CATALOG-002 +catalog-entry-payload-length-disagrees catalog-entry one-zero-catalog.hex replace-v1 240 8 0000000000000002 recompute-v1 catalog-entry.chunk-payload-length-mismatch framing KEEP-CATALOG-002 +catalog-entry-reserved catalog-entry one-zero-catalog.hex xor-v1 280 1 01 recompute-v1 catalog-entry.reserved framing KEEP-CATALOG-002 +catalog-binding-segment-digest catalog-binding one-zero-catalog.hex xor-v1 192 1 01 recompute-v1 catalog-binding.missing-segment binding KEEP-CATALOG-002 +catalog-binding-record-checksum catalog-binding one-zero-catalog.hex xor-v1 248 1 01 recompute-v1 catalog-binding.record-checksum-mismatch binding KEEP-CATALOG-002 +catalog-binding-identity-digest catalog-binding one-zero-catalog.hex xor-v1 136 1 01 recompute-v1 catalog-binding.record-identity-mismatch binding KEEP-CATALOG-002 +catalog-binding-interior-location catalog-binding one-zero-catalog.hex replace-v1 224 8 0000000000000041 recompute-v1 catalog-binding.location-not-top-level binding KEEP-CATALOG-002 +publication-head-magic publication-head one-zero-head.hex xor-v1 0 1 01 preserve-v1 publication-head.invalid-magic framing KEEP-CATALOG-002 +publication-head-version publication-head one-zero-head.hex replace-v1 16 2 0002 preserve-v1 publication-head.unsupported-version framing KEEP-CATALOG-002 +publication-head-flags publication-head one-zero-head.hex replace-v1 18 2 0001 preserve-v1 publication-head.flags framing KEEP-CATALOG-002 +publication-head-length publication-head one-zero-head.hex replace-v1 20 2 0081 preserve-v1 publication-head.head-length framing KEEP-CATALOG-002 +publication-head-checksum-algorithm publication-head one-zero-head.hex replace-v1 22 1 02 preserve-v1 publication-head.checksum-algorithm framing KEEP-CATALOG-002 +publication-head-digest-algorithm publication-head one-zero-head.hex replace-v1 23 1 02 preserve-v1 publication-head.digest-algorithm framing KEEP-CATALOG-002 +publication-head-zero-generation publication-head one-zero-head.hex replace-v1 24 8 0000000000000000 preserve-v1 publication-head.generation framing KEEP-CATALOG-002 +publication-head-zero-catalog-length publication-head one-zero-head.hex replace-v1 32 8 0000000000000000 preserve-v1 publication-head.catalog-length framing KEEP-CATALOG-002 +publication-head-catalog-digest-preserved publication-head one-zero-head.hex xor-v1 40 1 01 preserve-v1 publication-head.checksum-mismatch checksum KEEP-CATALOG-002 +publication-head-reserved publication-head one-zero-head.hex xor-v1 72 1 01 preserve-v1 publication-head.reserved framing KEEP-CATALOG-002 +publication-head-checksum publication-head one-zero-head.hex xor-v1 96 1 01 preserve-v1 publication-head.checksum-mismatch checksum KEEP-CATALOG-002 +publication-head-truncated publication-head one-zero-head.hex truncate-v1 127 0 - preserve-v1 publication-head.wrong-length framing KEEP-CATALOG-002 +publication-head-trailing-byte publication-head one-zero-head.hex append-v1 128 0 00 preserve-v1 publication-head.wrong-length framing KEEP-CATALOG-002 +head-binding-catalog-digest head-binding one-zero-head.hex xor-v1 40 1 01 recompute-v1 head-binding.catalog-digest binding KEEP-CATALOG-002 +head-binding-generation head-binding one-zero-head.hex replace-v1 24 8 0000000000000002 recompute-v1 head-binding.generation binding KEEP-CATALOG-002 +head-binding-catalog-length head-binding one-zero-head.hex replace-v1 32 8 0000000000000200 recompute-v1 head-binding.catalog-length binding KEEP-CATALOG-002 diff --git a/conformance/segment-store/v2/README.md b/conformance/segment-store/v2/README.md index 3303eeb2..d53c5767 100644 --- a/conformance/segment-store/v2/README.md +++ b/conformance/segment-store/v2/README.md @@ -16,6 +16,7 @@ migration, retention transition, or garbage collector exists. | `artifacts.tsv` | Golden artifact lengths, digests, checksums, and filenames | | `transitions.tsv` | One stable crash identifier per migration and GC boundary, `KEEP-CRASH-053` to `-087` | | `gc-plan.tsv` | The deterministic GC plan for the frozen version-2 store: every segment's classification | +| `mutations.tsv` | The corruption ledger: one frozen byte mutation per structural field of `FORMAT`, both migration records, the retention root, manifest, and head, both GC records, and the disposition receipt, with its exact first refusal, verification stage, and requirement | | `format-marker.hex` | Canonical 96-byte `FORMAT` record | | `migration-intent.hex` | Canonical 256-byte migration intent | | `migration-receipt.hex` | Canonical 256-byte migration receipt | @@ -111,3 +112,19 @@ route this corpus separately. Passing this corpus is necessary but insufficient for issue #19. Production code still needs parser, corruption, property, model, crash, recovery, concurrency, fuzz, and public API evidence. + +## Mutation ledger + +`mutations.tsv` rows are `case`, `record`, `base_fixture`, `operation` +(`replace-v1`, `xor-v1`, `truncate-v1`, `append-v1`, `delete-v1`), `offset`, +`span_length`, `parameter` (lowercase hex or `-`), `checksum_posture` +(`preserve-v1`; `recompute-v1` recomputes inner set digests and the trailer; +`recompute-trailer-v1` recomputes only the record's digest and checksum; +`recompute-checksum-v1` only its checksum), `expected_outcome` as +`.` of the public decoder's first refusal, `stage` +(`framing`, `checksum`, `identity`, or `binding`, ordered like +`VerificationDepth`), and `requirement`. `tests/segment_store_mutations.rs` +applies every row through the public decoders and requires the exact outcome +and stage; `cargo xtask conformance-check` refuses a malformed row. A ledger +row is never regenerated to make a decoder pass: a differing first refusal is +a specification question. diff --git a/conformance/segment-store/v2/mutations.tsv b/conformance/segment-store/v2/mutations.tsv new file mode 100644 index 00000000..78a07e57 --- /dev/null +++ b/conformance/segment-store/v2/mutations.tsv @@ -0,0 +1,152 @@ +keep.segment-store-mutations/v2 +case record base_fixture operation offset span_length parameter checksum_posture expected_outcome stage requirement +format-marker-magic format-marker format-marker.hex xor-v1 0 1 01 preserve-v1 format-marker.invalid-magic framing KEEP-MIGRATION-002 +format-marker-version format-marker format-marker.hex replace-v1 16 2 0003 preserve-v1 format-marker.unsupported-version framing KEEP-MIGRATION-002 +format-marker-record-length format-marker format-marker.hex replace-v1 18 2 0001 preserve-v1 format-marker.invalid-record-length framing KEEP-MIGRATION-002 +format-marker-flags format-marker format-marker.hex replace-v1 20 4 00000001 preserve-v1 format-marker.unsupported-flags framing KEEP-MIGRATION-002 +format-marker-checksum format-marker format-marker.hex xor-v1 64 1 01 preserve-v1 format-marker.checksum-mismatch checksum KEEP-MIGRATION-002 +format-marker-truncated format-marker format-marker.hex truncate-v1 95 0 - preserve-v1 format-marker.wrong-length framing KEEP-MIGRATION-002 +format-marker-trailing-byte format-marker format-marker.hex append-v1 96 0 00 preserve-v1 format-marker.wrong-length framing KEEP-MIGRATION-002 +format-marker-definition-digest format-marker format-marker.hex xor-v1 24 1 01 recompute-v1 format-marker.definition-digest-mismatch binding KEEP-MIGRATION-002 +format-marker-zero-namespace-count format-marker format-marker.hex replace-v1 56 4 00000000 recompute-v1 format-marker.invalid-maximum-namespace-count framing KEEP-MIGRATION-002 +format-marker-reserved format-marker format-marker.hex replace-v1 60 4 00000001 preserve-v1 format-marker.non-zero-reserved framing KEEP-MIGRATION-002 +format-marker-covered-byte-preserved format-marker format-marker.hex xor-v1 30 1 01 preserve-v1 format-marker.checksum-mismatch checksum KEEP-MIGRATION-002 +migration-intent-magic migration-intent migration-intent.hex xor-v1 0 1 01 preserve-v1 migration-intent.invalid-magic framing KEEP-MIGRATION-002 +migration-intent-version migration-intent migration-intent.hex replace-v1 16 2 0003 preserve-v1 migration-intent.unsupported-version framing KEEP-MIGRATION-002 +migration-intent-record-length migration-intent migration-intent.hex replace-v1 18 2 0001 preserve-v1 migration-intent.invalid-record-length framing KEEP-MIGRATION-002 +migration-intent-flags migration-intent migration-intent.hex replace-v1 20 4 00000001 preserve-v1 migration-intent.unsupported-flags framing KEEP-MIGRATION-002 +migration-intent-checksum migration-intent migration-intent.hex xor-v1 224 1 01 preserve-v1 migration-intent.checksum-mismatch checksum KEEP-MIGRATION-002 +migration-intent-truncated migration-intent migration-intent.hex truncate-v1 255 0 - preserve-v1 migration-intent.wrong-length framing KEEP-MIGRATION-002 +migration-intent-trailing-byte migration-intent migration-intent.hex append-v1 256 0 00 preserve-v1 migration-intent.wrong-length framing KEEP-MIGRATION-002 +migration-intent-zero-catalog-generation migration-intent migration-intent.hex replace-v1 24 8 0000000000000000 recompute-v1 migration-intent.invalid-catalog-generation framing KEEP-MIGRATION-002 +migration-intent-zero-catalog-length migration-intent migration-intent.hex replace-v1 32 8 0000000000000000 recompute-v1 migration-intent.invalid-catalog-length framing KEEP-MIGRATION-002 +migration-intent-catalog-digest migration-intent migration-intent.hex xor-v1 40 1 01 recompute-v1 migration-intent.store-identifier-mismatch binding KEEP-MIGRATION-002 +migration-intent-initial-predecessor migration-intent migration-intent.hex xor-v1 72 1 01 recompute-v1 migration-intent.non-zero-initial-predecessor framing KEEP-MIGRATION-002 +migration-intent-inventory-digest migration-intent migration-intent.hex xor-v1 104 1 01 recompute-v1 migration-intent.store-identifier-mismatch binding KEEP-MIGRATION-002 +migration-intent-definition-digest migration-intent migration-intent.hex xor-v1 160 1 01 recompute-v1 migration-intent.definition-digest-mismatch binding KEEP-MIGRATION-002 +migration-intent-store-identifier migration-intent migration-intent.hex xor-v1 192 1 01 recompute-v1 migration-intent.store-identifier-mismatch binding KEEP-MIGRATION-002 +migration-intent-covered-byte-preserved migration-intent migration-intent.hex xor-v1 40 1 01 preserve-v1 migration-intent.checksum-mismatch checksum KEEP-MIGRATION-002 +migration-receipt-magic migration-receipt migration-receipt.hex xor-v1 0 1 01 preserve-v1 migration-receipt.invalid-magic framing KEEP-MIGRATION-002 +migration-receipt-version migration-receipt migration-receipt.hex replace-v1 16 2 0003 preserve-v1 migration-receipt.unsupported-version framing KEEP-MIGRATION-002 +migration-receipt-record-length migration-receipt migration-receipt.hex replace-v1 18 2 0001 preserve-v1 migration-receipt.invalid-record-length framing KEEP-MIGRATION-002 +migration-receipt-flags migration-receipt migration-receipt.hex replace-v1 20 4 00000001 preserve-v1 migration-receipt.unsupported-flags framing KEEP-MIGRATION-002 +migration-receipt-checksum migration-receipt migration-receipt.hex xor-v1 224 1 01 preserve-v1 migration-receipt.checksum-mismatch checksum KEEP-MIGRATION-002 +migration-receipt-truncated migration-receipt migration-receipt.hex truncate-v1 255 0 - preserve-v1 migration-receipt.wrong-length framing KEEP-MIGRATION-002 +migration-receipt-trailing-byte migration-receipt migration-receipt.hex append-v1 256 0 00 preserve-v1 migration-receipt.wrong-length framing KEEP-MIGRATION-002 +migration-receipt-intent-digest migration-receipt migration-receipt.hex xor-v1 24 1 01 recompute-v1 migration-receipt.intent-digest-mismatch binding KEEP-MIGRATION-002 +migration-receipt-store-identifier migration-receipt migration-receipt.hex xor-v1 56 1 01 recompute-v1 migration-receipt.store-identifier-mismatch binding KEEP-MIGRATION-002 +migration-receipt-marker-digest migration-receipt migration-receipt.hex xor-v1 88 1 01 recompute-v1 migration-receipt.format-marker-digest-mismatch binding KEEP-MIGRATION-002 +migration-receipt-initial-retention-digest migration-receipt migration-receipt.hex xor-v1 120 1 01 recompute-v1 migration-receipt.initial-retention-state-digest-mismatch framing KEEP-MIGRATION-002 +migration-receipt-initial-gc-digest migration-receipt migration-receipt.hex xor-v1 152 1 01 recompute-v1 migration-receipt.initial-gc-state-digest-mismatch framing KEEP-MIGRATION-002 +migration-receipt-disposition-digest migration-receipt migration-receipt.hex xor-v1 184 1 01 recompute-v1 migration-receipt.empty-disposition-set-digest-mismatch framing KEEP-MIGRATION-002 +migration-receipt-incomplete-mask migration-receipt migration-receipt.hex replace-v1 216 8 00000000000003fe recompute-v1 migration-receipt.incomplete-synchronization-mask framing KEEP-MIGRATION-002 +migration-receipt-unsupported-mask-bit migration-receipt migration-receipt.hex replace-v1 216 8 00000000000007ff recompute-v1 migration-receipt.unsupported-synchronization-bits framing KEEP-MIGRATION-002 +migration-receipt-covered-byte-preserved migration-receipt migration-receipt.hex xor-v1 24 1 01 preserve-v1 migration-receipt.checksum-mismatch checksum KEEP-MIGRATION-002 +retention-root-magic retention-root one-anchor-root.hex xor-v1 0 1 01 preserve-v1 retention-root.invalid-magic framing KEEP-RETENTION-003 +retention-root-version retention-root one-anchor-root.hex replace-v1 16 2 0003 preserve-v1 retention-root.unsupported-version framing KEEP-RETENTION-003 +retention-root-header-length retention-root one-anchor-root.hex replace-v1 18 2 00c1 preserve-v1 retention-root.invalid-header-length framing KEEP-RETENTION-003 +retention-root-flags retention-root one-anchor-root.hex replace-v1 20 4 00000001 preserve-v1 retention-root.unsupported-flags framing KEEP-RETENTION-003 +retention-root-declared-length retention-root one-anchor-root.hex replace-v1 24 8 0000000000000179 preserve-v1 retention-root.declared-length-mismatch framing KEEP-RETENTION-003 +retention-root-zero-generation retention-root one-anchor-root.hex replace-v1 32 8 0000000000000000 recompute-v1 retention-root.generation framing KEEP-RETENTION-003 +retention-root-namespace-length retention-root one-anchor-root.hex replace-v1 40 2 0004 preserve-v1 retention-root.declared-length-mismatch framing KEEP-RETENTION-003 +retention-root-anchor-width retention-root one-anchor-root.hex replace-v1 42 2 0078 preserve-v1 retention-root.invalid-anchor-width framing KEEP-RETENTION-003 +retention-root-anchor-count retention-root one-anchor-root.hex replace-v1 44 4 00000002 preserve-v1 retention-root.declared-length-mismatch framing KEEP-RETENTION-003 +retention-root-profile-identity retention-root one-anchor-root.hex replace-v1 48 4 00000002 recompute-v1 retention-root.profile binding KEEP-RETENTION-003 +retention-root-profile-version retention-root one-anchor-root.hex replace-v1 52 4 00000002 recompute-v1 retention-root.profile binding KEEP-RETENTION-003 +retention-root-profile-digest retention-root one-anchor-root.hex xor-v1 56 1 01 recompute-v1 retention-root.profile binding KEEP-RETENTION-003 +retention-root-zero-node-limit retention-root one-anchor-root.hex replace-v1 88 8 0000000000000000 recompute-v1 retention-root.closure-limit framing KEEP-RETENTION-003 +retention-root-zero-depth-limit retention-root one-anchor-root.hex replace-v1 96 2 0000 recompute-v1 retention-root.closure-limit framing KEEP-RETENTION-003 +retention-root-reserved-short retention-root one-anchor-root.hex replace-v1 98 2 0001 preserve-v1 retention-root.non-zero-reserved framing KEEP-RETENTION-003 +retention-root-zero-encoded-limit retention-root one-anchor-root.hex replace-v1 100 8 0000000000000000 recompute-v1 retention-root.closure-limit framing KEEP-RETENTION-003 +retention-root-zero-physical-limit retention-root one-anchor-root.hex replace-v1 108 8 0000000000000000 recompute-v1 retention-root.closure-limit framing KEEP-RETENTION-003 +retention-root-initial-predecessor retention-root one-anchor-root.hex xor-v1 116 1 01 recompute-v1 retention-root.semantic framing KEEP-RETENTION-003 +retention-root-anchor-set-digest retention-root one-anchor-root.hex xor-v1 148 1 01 recompute-trailer-v1 retention-root.anchor-set-digest-mismatch checksum KEEP-RETENTION-003 +retention-root-reserved-tail retention-root one-anchor-root.hex xor-v1 180 1 01 preserve-v1 retention-root.non-zero-reserved framing KEEP-RETENTION-003 +retention-root-anchor-blob-id retention-root one-anchor-root.hex xor-v1 195 1 01 recompute-v1 retention-root.blob-id framing KEEP-RETENTION-003 +retention-root-anchor-layout-id retention-root one-anchor-root.hex xor-v1 254 1 01 recompute-v1 retention-root.layout-id framing KEEP-RETENTION-003 +retention-root-digest-preserved retention-root one-anchor-root.hex xor-v1 314 1 01 preserve-v1 retention-root.checksum-mismatch checksum KEEP-RETENTION-003 +retention-root-digest-recomputed-checksum retention-root one-anchor-root.hex xor-v1 314 1 01 recompute-checksum-v1 retention-root.root-digest-mismatch checksum KEEP-RETENTION-003 +retention-root-checksum retention-root one-anchor-root.hex xor-v1 346 1 01 preserve-v1 retention-root.checksum-mismatch checksum KEEP-RETENTION-003 +retention-root-truncated retention-root one-anchor-root.hex truncate-v1 377 0 - preserve-v1 retention-root.truncated framing KEEP-RETENTION-003 +retention-root-trailing-byte retention-root one-anchor-root.hex append-v1 378 0 00 preserve-v1 retention-root.trailing-data framing KEEP-RETENTION-003 +retention-manifest-magic retention-manifest one-root-manifest.hex xor-v1 0 1 01 preserve-v1 retention-manifest.invalid-magic framing KEEP-RETENTION-003 +retention-manifest-version retention-manifest one-root-manifest.hex replace-v1 16 2 0003 preserve-v1 retention-manifest.unsupported-version framing KEEP-RETENTION-003 +retention-manifest-header-length retention-manifest one-root-manifest.hex replace-v1 18 2 00a1 preserve-v1 retention-manifest.invalid-header-length framing KEEP-RETENTION-003 +retention-manifest-flags retention-manifest one-root-manifest.hex replace-v1 20 4 00000001 preserve-v1 retention-manifest.unsupported-flags framing KEEP-RETENTION-003 +retention-manifest-declared-length retention-manifest one-root-manifest.hex replace-v1 24 8 0000000000000127 preserve-v1 retention-manifest.declared-length-mismatch framing KEEP-RETENTION-003 +retention-manifest-zero-generation retention-manifest one-root-manifest.hex replace-v1 32 8 0000000000000000 recompute-v1 retention-manifest.liveness-generation framing KEEP-RETENTION-003 +retention-manifest-entry-width retention-manifest one-root-manifest.hex replace-v1 40 2 0047 preserve-v1 retention-manifest.invalid-entry-width framing KEEP-RETENTION-003 +retention-manifest-reserved-short retention-manifest one-root-manifest.hex replace-v1 42 2 0001 preserve-v1 retention-manifest.non-zero-reserved framing KEEP-RETENTION-003 +retention-manifest-entry-count retention-manifest one-root-manifest.hex replace-v1 44 4 00000002 preserve-v1 retention-manifest.declared-length-mismatch framing KEEP-RETENTION-003 +retention-manifest-initial-predecessor retention-manifest one-root-manifest.hex xor-v1 48 1 01 recompute-v1 retention-manifest.semantic framing KEEP-RETENTION-003 +retention-manifest-entry-set-digest retention-manifest one-root-manifest.hex xor-v1 80 1 01 recompute-trailer-v1 retention-manifest.entry-set-digest-mismatch checksum KEEP-RETENTION-003 +retention-manifest-reserved-tail retention-manifest one-root-manifest.hex xor-v1 112 1 01 preserve-v1 retention-manifest.non-zero-reserved framing KEEP-RETENTION-003 +retention-manifest-entry-zero-root-generation retention-manifest one-root-manifest.hex replace-v1 192 8 0000000000000000 recompute-v1 retention-manifest.root-generation framing KEEP-RETENTION-003 +retention-manifest-digest-preserved retention-manifest one-root-manifest.hex xor-v1 232 1 01 preserve-v1 retention-manifest.checksum-mismatch checksum KEEP-RETENTION-003 +retention-manifest-digest-recomputed-checksum retention-manifest one-root-manifest.hex xor-v1 232 1 01 recompute-checksum-v1 retention-manifest.manifest-digest-mismatch checksum KEEP-RETENTION-003 +retention-manifest-checksum retention-manifest one-root-manifest.hex xor-v1 264 1 01 preserve-v1 retention-manifest.checksum-mismatch checksum KEEP-RETENTION-003 +retention-manifest-truncated retention-manifest one-root-manifest.hex truncate-v1 295 0 - preserve-v1 retention-manifest.truncated framing KEEP-RETENTION-003 +retention-manifest-trailing-byte retention-manifest one-root-manifest.hex append-v1 296 0 00 preserve-v1 retention-manifest.trailing-data framing KEEP-RETENTION-003 +retention-head-magic retention-head one-root-head.hex xor-v1 0 1 01 preserve-v1 retention-head.invalid-magic framing KEEP-RETENTION-003 +retention-head-version retention-head one-root-head.hex replace-v1 16 2 0003 preserve-v1 retention-head.unsupported-version framing KEEP-RETENTION-003 +retention-head-record-length retention-head one-root-head.hex replace-v1 18 2 0001 preserve-v1 retention-head.invalid-record-length framing KEEP-RETENTION-003 +retention-head-flags retention-head one-root-head.hex replace-v1 20 4 00000001 preserve-v1 retention-head.unsupported-flags framing KEEP-RETENTION-003 +retention-head-checksum retention-head one-root-head.hex xor-v1 112 1 01 preserve-v1 retention-head.checksum-mismatch checksum KEEP-RETENTION-003 +retention-head-truncated retention-head one-root-head.hex truncate-v1 143 0 - preserve-v1 retention-head.wrong-length framing KEEP-RETENTION-003 +retention-head-trailing-byte retention-head one-root-head.hex append-v1 144 0 00 preserve-v1 retention-head.wrong-length framing KEEP-RETENTION-003 +retention-head-zero-generation retention-head one-root-head.hex replace-v1 24 8 0000000000000000 recompute-v1 retention-head.liveness-generation framing KEEP-RETENTION-003 +retention-head-zero-manifest-length retention-head one-root-head.hex replace-v1 32 8 0000000000000000 recompute-v1 retention-head.manifest-length framing KEEP-RETENTION-003 +retention-head-manifest-digest-preserved retention-head one-root-head.hex xor-v1 40 1 01 preserve-v1 retention-head.checksum-mismatch checksum KEEP-RETENTION-003 +retention-head-initial-predecessor retention-head one-root-head.hex xor-v1 72 1 01 recompute-v1 retention-head.semantic framing KEEP-RETENTION-003 +retention-head-reserved retention-head one-root-head.hex replace-v1 104 8 0000000000000001 preserve-v1 retention-head.non-zero-reserved framing KEEP-RETENTION-003 +gc-intent-magic gc-intent one-candidate-gc-intent.hex xor-v1 0 1 01 preserve-v1 gc-intent.invalid-magic framing KEEP-GC-001 +gc-intent-version gc-intent one-candidate-gc-intent.hex replace-v1 16 2 0003 preserve-v1 gc-intent.unsupported-version framing KEEP-GC-001 +gc-intent-header-length gc-intent one-candidate-gc-intent.hex replace-v1 18 2 0141 preserve-v1 gc-intent.invalid-header-length framing KEEP-GC-001 +gc-intent-flags gc-intent one-candidate-gc-intent.hex replace-v1 20 4 00000001 preserve-v1 gc-intent.unsupported-flags framing KEEP-GC-001 +gc-intent-declared-length gc-intent one-candidate-gc-intent.hex replace-v1 24 8 00000000000001c7 preserve-v1 gc-intent.declared-length-mismatch framing KEEP-GC-001 +gc-intent-zero-generation gc-intent one-candidate-gc-intent.hex replace-v1 32 8 0000000000000000 recompute-v1 gc-intent.generation framing KEEP-GC-001 +gc-intent-candidate-width gc-intent one-candidate-gc-intent.hex replace-v1 40 2 0047 preserve-v1 gc-intent.invalid-candidate-width framing KEEP-GC-001 +gc-intent-reserved-short gc-intent one-candidate-gc-intent.hex replace-v1 42 2 0001 preserve-v1 gc-intent.non-zero-reserved framing KEEP-GC-001 +gc-intent-candidate-count gc-intent one-candidate-gc-intent.hex replace-v1 44 4 00000002 preserve-v1 gc-intent.declared-length-mismatch framing KEEP-GC-001 +gc-intent-zero-liveness-generation gc-intent one-candidate-gc-intent.hex replace-v1 48 8 0000000000000000 recompute-v1 gc-intent.liveness-generation framing KEEP-GC-001 +gc-intent-zero-catalog-generation gc-intent one-candidate-gc-intent.hex replace-v1 88 8 0000000000000000 recompute-v1 gc-intent.catalog-generation framing KEEP-GC-001 +gc-intent-profile-identity gc-intent one-candidate-gc-intent.hex replace-v1 128 4 00000002 recompute-v1 gc-intent.profile binding KEEP-GC-001 +gc-intent-profile-digest gc-intent one-candidate-gc-intent.hex xor-v1 136 1 01 recompute-v1 gc-intent.profile binding KEEP-GC-001 +gc-intent-candidate-set-digest gc-intent one-candidate-gc-intent.hex xor-v1 288 1 01 recompute-trailer-v1 gc-intent.candidate-set-digest-mismatch checksum KEEP-GC-001 +gc-intent-digest-preserved gc-intent one-candidate-gc-intent.hex xor-v1 392 1 01 preserve-v1 gc-intent.checksum-mismatch checksum KEEP-GC-001 +gc-intent-digest-recomputed-checksum gc-intent one-candidate-gc-intent.hex xor-v1 392 1 01 recompute-checksum-v1 gc-intent.intent-digest-mismatch checksum KEEP-GC-001 +gc-intent-checksum gc-intent one-candidate-gc-intent.hex xor-v1 424 1 01 preserve-v1 gc-intent.checksum-mismatch checksum KEEP-GC-001 +gc-intent-truncated gc-intent one-candidate-gc-intent.hex truncate-v1 455 0 - preserve-v1 gc-intent.truncated framing KEEP-GC-001 +gc-intent-trailing-byte gc-intent one-candidate-gc-intent.hex append-v1 456 0 00 preserve-v1 gc-intent.trailing-data framing KEEP-GC-001 +gc-receipt-magic gc-receipt one-candidate-gc-receipt.hex xor-v1 0 1 01 preserve-v1 gc-receipt.invalid-magic framing KEEP-GC-001 +gc-receipt-version gc-receipt one-candidate-gc-receipt.hex replace-v1 16 2 0003 preserve-v1 gc-receipt.unsupported-version framing KEEP-GC-001 +gc-receipt-record-length gc-receipt one-candidate-gc-receipt.hex replace-v1 18 2 0001 preserve-v1 gc-receipt.invalid-record-length framing KEEP-GC-001 +gc-receipt-flags gc-receipt one-candidate-gc-receipt.hex replace-v1 20 4 00000001 preserve-v1 gc-receipt.unsupported-flags framing KEEP-GC-001 +gc-receipt-checksum gc-receipt one-candidate-gc-receipt.hex xor-v1 288 1 01 preserve-v1 gc-receipt.checksum-mismatch checksum KEEP-GC-001 +gc-receipt-truncated gc-receipt one-candidate-gc-receipt.hex truncate-v1 319 0 - preserve-v1 gc-receipt.wrong-length framing KEEP-GC-001 +gc-receipt-trailing-byte gc-receipt one-candidate-gc-receipt.hex append-v1 320 0 00 preserve-v1 gc-receipt.wrong-length framing KEEP-GC-001 +gc-receipt-generation gc-receipt one-candidate-gc-receipt.hex replace-v1 24 8 0000000000000002 recompute-v1 gc-receipt.generation-mismatch binding KEEP-GC-001 +gc-receipt-intent-digest gc-receipt one-candidate-gc-receipt.hex xor-v1 32 1 01 recompute-v1 gc-receipt.intent-digest-mismatch binding KEEP-GC-001 +gc-receipt-retired-set-digest gc-receipt one-candidate-gc-receipt.hex xor-v1 64 1 01 recompute-v1 gc-receipt.retired-set-digest-mismatch binding KEEP-GC-001 +gc-receipt-liveness-generation gc-receipt one-candidate-gc-receipt.hex replace-v1 128 8 0000000000000002 recompute-v1 gc-receipt.liveness-generation-mismatch binding KEEP-GC-001 +gc-receipt-manifest-digest gc-receipt one-candidate-gc-receipt.hex xor-v1 136 1 01 recompute-v1 gc-receipt.manifest-digest-mismatch binding KEEP-GC-001 +gc-receipt-catalog-generation gc-receipt one-candidate-gc-receipt.hex replace-v1 168 8 0000000000000009 recompute-v1 gc-receipt.catalog-generation-mismatch binding KEEP-GC-001 +gc-receipt-catalog-digest gc-receipt one-candidate-gc-receipt.hex xor-v1 176 1 01 recompute-v1 gc-receipt.catalog-digest-mismatch binding KEEP-GC-001 +gc-receipt-reader-device gc-receipt one-candidate-gc-receipt.hex xor-v1 208 1 01 recompute-v1 gc-receipt.reader-lock-mismatch binding KEEP-GC-001 +gc-receipt-synchronization-count gc-receipt one-candidate-gc-receipt.hex replace-v1 232 8 0000000000000002 recompute-v1 gc-receipt.synchronization-count-mismatch binding KEEP-GC-001 +gc-receipt-reserved gc-receipt one-candidate-gc-receipt.hex xor-v1 240 1 01 preserve-v1 gc-receipt.non-zero-reserved framing KEEP-GC-001 +disposition-magic disposition one-orphan-retire-disposition.hex xor-v1 0 1 01 preserve-v1 disposition.invalid-magic framing KEEP-GC-001 +disposition-version disposition one-orphan-retire-disposition.hex replace-v1 16 2 0003 preserve-v1 disposition.unsupported-version framing KEEP-GC-001 +disposition-record-length disposition one-orphan-retire-disposition.hex replace-v1 18 2 0001 preserve-v1 disposition.invalid-record-length framing KEEP-GC-001 +disposition-flags disposition one-orphan-retire-disposition.hex replace-v1 20 4 00000001 preserve-v1 disposition.unsupported-flags framing KEEP-GC-001 +disposition-checksum disposition one-orphan-retire-disposition.hex xor-v1 288 1 01 preserve-v1 disposition.checksum-mismatch checksum KEEP-GC-001 +disposition-truncated disposition one-orphan-retire-disposition.hex truncate-v1 319 0 - preserve-v1 disposition.wrong-length framing KEEP-GC-001 +disposition-trailing-byte disposition one-orphan-retire-disposition.hex append-v1 320 0 00 preserve-v1 disposition.wrong-length framing KEEP-GC-001 +disposition-unregistered-artifact-kind disposition one-orphan-retire-disposition.hex replace-v1 24 2 0006 recompute-v1 disposition.unregistered-code framing KEEP-GC-001 +disposition-unregistered-decision disposition one-orphan-retire-disposition.hex replace-v1 26 2 0003 recompute-v1 disposition.unregistered-code framing KEEP-GC-001 +disposition-unregistered-classification disposition one-orphan-retire-disposition.hex replace-v1 28 2 0004 recompute-v1 disposition.unregistered-code framing KEEP-GC-001 +disposition-reserved-short disposition one-orphan-retire-disposition.hex replace-v1 30 2 0001 preserve-v1 disposition.non-zero-reserved framing KEEP-GC-001 +disposition-zero-publication-generation disposition one-orphan-retire-disposition.hex replace-v1 104 8 0000000000000000 recompute-v1 disposition.zero-generation framing KEEP-GC-001 +disposition-zero-catalog-generation disposition one-orphan-retire-disposition.hex replace-v1 144 8 0000000000000000 recompute-v1 disposition.zero-generation framing KEEP-GC-001 +disposition-zero-liveness-with-manifest disposition one-orphan-retire-disposition.hex replace-v1 184 8 0000000000000000 recompute-v1 disposition.empty-retention-digest-mismatch binding KEEP-GC-001 +disposition-reserved-tail disposition one-orphan-retire-disposition.hex xor-v1 280 1 01 preserve-v1 disposition.non-zero-reserved framing KEEP-GC-001 diff --git a/docs/conformance/golden-file-worldline.md b/docs/conformance/golden-file-worldline.md index a0f7fb23..d2e4780c 100644 --- a/docs/conformance/golden-file-worldline.md +++ b/docs/conformance/golden-file-worldline.md @@ -123,7 +123,12 @@ Capabilities record the first milestone in which an assertion may become `required`, together with its owning GitHub issues. A `declared-future` row is not a skipped or passing test. Promotion to `required` needs separate executable evidence at the named milestone; it does not enlarge M1's proof -boundary retroactively. +boundary retroactively. `keep.verification.precise-refusal/v1` became +`required` in M4 on the strength of the segment-store mutation ledgers, +`conformance/segment-store/v1/mutations.tsv` and `v2/mutations.tsv`: every +structural field of every durable record has a frozen mutation whose exact +first refusal and verification stage `tests/segment_store_mutations.rs` +reproduces through the public decoders. ## Partition plans diff --git a/docs/formats/segment-store-v1/README.md b/docs/formats/segment-store-v1/README.md index 0194600e..3e018f73 100644 --- a/docs/formats/segment-store-v1/README.md +++ b/docs/formats/segment-store-v1/README.md @@ -53,3 +53,16 @@ The following pages form one versioned protocol: No page is independently optional. A version-1 implementation conforms only when it satisfies the complete linked protocol and the [durable transition ledger](../../../conformance/segment-store/v1/transitions.tsv). + +## Mutation ledger + +Every structural field of the segment header, record header, record +checksum, seal, catalog header, catalog entry, catalog trailer, and +publication head has one frozen byte mutation in the +[corruption ledger](../../../conformance/segment-store/v1/mutations.tsv), +with the exact first refusal the public decoder reports and the +verification stage it establishes: `framing` and `checksum` are +`VerificationDepth::Framing` and `::Checksum`; `identity` is content that +does not hash to its declared identity; `binding` is a contradiction between +records. `tests/segment_store_mutations.rs` reproduces every row and +`cargo xtask conformance-check` admits the ledger's shape. diff --git a/docs/formats/segment-store-v1/requirements.md b/docs/formats/segment-store-v1/requirements.md index a82faaef..32d991ee 100644 --- a/docs/formats/segment-store-v1/requirements.md +++ b/docs/formats/segment-store-v1/requirements.md @@ -48,7 +48,7 @@ retention, or garbage collection. | `KEEP-SEGMENT-003` | Short, interrupted, zero-progress, invalid-count, storage, permission, flush, and synchronization failures retain exact phases and offsets | `tests/segment_writer/write_contract_laws.rs`, `tests/segment_writer/refusal_laws.rs`, `tests/segment_writer/durability_laws.rs` | Implemented in #15 | | `KEEP-SEGMENT-004` | Complete-segment admission verifies bounds, framing, checksums, logical identities, duplicate refusal, terminal state, and physical digest before exposure | `tests/segment.rs`, `tests/segment/identity_laws.rs`, `tests/segment/framing_laws.rs` | Implemented in #15 | | `KEEP-SEGMENT-005` | The public sealed receipt exposes no mutable stage handle | `src/adapters/sealed_segment.rs` | Implemented in #15 | -| `KEEP-SEGMENT-006` | Malformed, unsupported, partial, conflicting, and corrupt input returns boundary-typed errors | `tests/segment_header/mutation_laws.rs`, `tests/segment_record_header/framing_laws.rs`, `tests/segment_seal/framing_laws.rs`, `tests/segment/identity_laws.rs` | Implemented in #15 | +| `KEEP-SEGMENT-006` | Malformed, unsupported, partial, conflicting, and corrupt input returns boundary-typed errors | field-complete corruption ledger `conformance/segment-store/v1/mutations.tsv` reproduced by `tests/segment_store_mutations.rs`; `tests/segment_header/mutation_laws.rs`, `tests/segment_record_header/framing_laws.rs`, `tests/segment_seal/framing_laws.rs`, `tests/segment/identity_laws.rs` | Implemented in #15 | | `KEEP-SEGMENT-007` | Record, nested-layout, segment-length, and temporary identity-index allocation remain explicitly bounded | `tests/segment_memory.rs`, `tests/segment_record_memory.rs`, `tests/segment_seal_memory.rs` | Implemented in #15 | | `KEEP-SEGMENT-008` | Filesystem staging uses exclusive fixed-name creation and never enumerates storage as a content index | `tests/segment_filesystem_stage.rs`, `src/adapters/filesystem_segment_stage.rs` | Implemented in #15 | | `KEEP-SEGMENT-009` | Every implemented write and durability phase has deterministic fault injection, while dropped unsealed stages preserve recovery evidence | `tests/segment_writer/`, `tests/segment_filesystem_stage.rs` | Implemented in #15 | @@ -70,7 +70,7 @@ below. | ID | Implemented requirement | Oracle | Executable evidence | Status | | --- | --- | --- | --- | --- | | `KEEP-CATALOG-001` | `CatalogGeneration` admits positive values and refuses overflow when deriving a successor | Checked scalar model | `tests/catalog_generation.rs` | Implemented in #16 | -| `KEEP-CATALOG-002` | Catalog and publication-head codecs reproduce every frozen version-1 artifact and refuse noncanonical bytes; catalog checksum and digest admission precede entry semantics | Independent golden corpus and mutation precedence oracle | `tests/catalog.rs`, `tests/catalog/integrity_laws.rs`, `tests/publication_head.rs` | Implemented in #16 | +| `KEEP-CATALOG-002` | Catalog and publication-head codecs reproduce every frozen version-1 artifact and refuse noncanonical bytes; catalog checksum and digest admission precede entry semantics | field-complete corruption ledger `conformance/segment-store/v1/mutations.tsv` (catalog, entry, binding, head) reproduced by `tests/segment_store_mutations.rs`; Independent golden corpus and mutation precedence oracle | `tests/catalog.rs`, `tests/catalog/integrity_laws.rs`, `tests/publication_head.rs` | Implemented in #16 | | `KEEP-CATALOG-003` | Catalog entries are sorted by logical identity and duplicate keys are refused independently of input order | Ordered reference map | `tests/catalog_ordering.rs` | Implemented in #16 | | `KEEP-CATALOG-004` | Every catalog location equals a verified top-level record span in the exact named segment; construction and admission require every supplied segment to be referenced, and admission scans each referenced segment once | Bounded grouped lookup plan and golden artifacts | `tests/catalog_encoding.rs`, `tests/catalog_locations.rs` | Implemented in #16 | | `KEEP-CATALOG-005` | Publication admits only the exact expected successor and reports expected and observed generation and digest on staleness | Generation transition model | `tests/catalog_transition.rs` | Implemented in #16 | diff --git a/docs/formats/segment-store-v2/README.md b/docs/formats/segment-store-v2/README.md index e4043cbf..0a0e7516 100644 --- a/docs/formats/segment-store-v2/README.md +++ b/docs/formats/segment-store-v2/README.md @@ -76,6 +76,19 @@ The version-1 [segment](../segment-store-v1/segment.md), grammars remain byte-for-byte authoritative. Version 2 does not reinterpret or re-encode them. +## Mutation ledger + +Every structural field of `FORMAT`, the migration intent and receipt, the +retention root, manifest, and head, the GC intent and receipt, and the +disposition receipt has one frozen byte mutation in the +[corruption ledger](../../../conformance/segment-store/v2/mutations.tsv), +with the exact first refusal the public decoder reports and the +verification stage it establishes (`framing`, `checksum`, `identity`, or +`binding`). `tests/segment_store_mutations.rs` reproduces every row through +the public decoders and `cargo xtask conformance-check` admits the ledger's +shape; a decoder that refuses differently raises a specification question, +never a regenerated row. + ## Status The format contract is frozen by ADR-0009 and this specification. diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index 0801ae17..0163ad42 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -11,7 +11,7 @@ case is not evidence. | --- | --- | --- | --- | | `KEEP-RETENTION-001` | `RetentionNamespace`, `RootGeneration`, `LivenessGeneration`, profile coordinates, limits, anchors, and digests are validated typed values | `tests/retention_values.rs`, `tests/retention_root_encoding.rs`, and typed verified anchor-set evidence in `tests/retention_root_decoding.rs` | Implemented | | `KEEP-RETENTION-002` | Root, manifest, and head codecs implement the exact canonical grammars and fixed bounds | independent golden corpus plus `tests/retention_root_encoding.rs`, `tests/retention_root_decoding.rs`, `tests/retention_manifest_codec.rs`, and `tests/retention_head_codec.rs` | Implemented | -| `KEEP-RETENTION-003` | Every structural field, truncation boundary, ordering law, duplicate, overflow, flag, reserved byte, digest, checksum, and trailing byte has a precise refusal | one sealed mutation per header, body, and trailer field with its exact first refusal, plus reframed namespace-bound, ordering, and count-ceiling cases, in `tests/retention_root_decoding/mutation_laws.rs`, `tests/retention_manifest_codec/mutation_laws.rs`, and `tests/retention_head_codec/mutation_laws.rs`; framing and integrity precedence in `tests/retention_root_decoding.rs`, `tests/retention_manifest_codec/refusal_laws.rs`, and `tests/retention_head_codec.rs`; seeded `retention_format` fuzz target | Implemented | +| `KEEP-RETENTION-003` | Every structural field, truncation boundary, ordering law, duplicate, overflow, flag, reserved byte, digest, checksum, and trailing byte has a precise refusal | field-complete corruption ledger `conformance/segment-store/v2/mutations.tsv` (root, manifest, head) reproduced by `tests/segment_store_mutations.rs`; one sealed mutation per header, body, and trailer field with its exact first refusal, plus reframed namespace-bound, ordering, and count-ceiling cases, in `tests/retention_root_decoding/mutation_laws.rs`, `tests/retention_manifest_codec/mutation_laws.rs`, and `tests/retention_head_codec/mutation_laws.rs`; framing and integrity precedence in `tests/retention_root_decoding.rs`, `tests/retention_manifest_codec/refusal_laws.rs`, and `tests/retention_head_codec.rs`; seeded `retention_format` fuzz target | Implemented | | `KEEP-RETENTION-004` | Retain and release compare expected and observed generations and publish exact successors only | unforgeable readiness and preflight proofs in `tests/retention_transition.rs` and `tests/retention_preflight.rs`; exact successor preparation and complete receipt evidence in `tests/retention_publication_preparation.rs` and `tests/retention_publication_execution.rs`; writer-locked initial filesystem publication in `filesystem_retention_storage_tests`; observed-head successor publication, exact predecessor binding, and absent-head refusal in `filesystem_retention_successor_tests`; the store's catalog head must name the closure's catalog generation and digest before any forward write in `filesystem_retention_catalog_tests`; a head whose predecessor disagrees with its manifest refuses in `filesystem_retention_current_tests`; a successor reopens and decodes the manifest-selected predecessor root and refuses an absent or changed one in `filesystem_retention_expectation_tests` | Implemented | | `KEEP-RETENTION-005` | Closure derivation is deterministic, bounded, cycle-safe, fail-closed, and verifies complete blob reconstruction | exact accounting, reconstruction, adversarial-catalog, and exhaustive model laws in `tests/retention_closure.rs`; corrupt members refuse through the inherited segment-record admission laws and seeded `segment_format` fuzz target routed by `closure-corruption.md`; publication re-reads every member under filesystem authority and surfaces the exact admission error as the refusal's `source` in `filesystem_retention_member_tests` | Implemented | | `KEEP-RETENTION-006` | Publication follows the exact ordered durability protocol, including new namespace-directory admission and retention of fixed-stage evidence until head commit, and returns only after cleanup synchronization | typed vocabulary and blocking port in `tests/retention_publication_phase.rs` and `tests/retention_publication_storage.rs`; ordered execution, conditional namespace sync, and all 17 exact storage-fault boundaries in `tests/retention_publication_execution.rs`; production 17-phase forward filesystem execution, exclusive staging, byte-equal inode-substitution refusal, and retained-stage recovery refusal in `filesystem_retention_storage_tests`; orphan namespace directories count against the 4,096 ceiling and refuse a new namespace before any stage is written in `filesystem_retention_capacity_tests`; crash injection remains | In progress in #19 | @@ -29,7 +29,7 @@ case is not evidence. | ID | Requirement | Evidence | Status | | --- | --- | --- | --- | | `KEEP-MIGRATION-001` | Exact version-1 stores remain admitted until a durable migration artifact exists | the zero-phase prefix admits version one and an intent stage alone is never a durable artifact in `filesystem_migration_recovery_tests` and `tests/store_migration_recovery.rs` | Implemented | -| `KEEP-MIGRATION-002` | Format marker, intent, and receipt have complete fixed byte tables, named domains, bounds, checksums, deterministic store identity, and exact initial-state digests | exact admission in `tests/store_format_marker.rs`, `tests/store_migration_intent.rs`, and `tests/store_migration_receipt.rs`; canonical construction in `tests/store_migration_intent_encoding.rs` and `tests/store_migration_receipt_encoding.rs`; seeded `migration_format` fuzz target | Implemented | +| `KEEP-MIGRATION-002` | Format marker, intent, and receipt have complete fixed byte tables, named domains, bounds, checksums, deterministic store identity, and exact initial-state digests | field-complete corruption ledger `conformance/segment-store/v2/mutations.tsv` (marker, intent, receipt) reproduced by `tests/segment_store_mutations.rs`; exact admission in `tests/store_format_marker.rs`, `tests/store_migration_intent.rs`, and `tests/store_migration_receipt.rs`; canonical construction in `tests/store_migration_intent_encoding.rs` and `tests/store_migration_receipt_encoding.rs`; seeded `migration_format` fuzz target | Implemented | | `KEEP-MIGRATION-003` | Migration revalidates version-1 head, catalog, pools, root identity (all three coordinates within the migrating process; device and file across restart), and writer authority before mutation | bounded canonical pool inventory in `tests/store_migration_inventory.rs`; writer-locked filesystem pool admission in `filesystem_inventory_*_tests`; exact authority observation and drift refusal in `filesystem_migration_authority_tests`; verification-first execution in `tests/store_migration_execution.rs`; fresh filesystem integration and post-publication drift refusal in `filesystem_migration_storage_tests`; a version-one store still holding a retained stage refuses before the intent is observed in `filesystem_migration_storage_tests` | Implemented | | `KEEP-MIGRATION-004` | Every partial migration prefix continues idempotently under writer authority, comparing only the restart-stable root coordinates (device and file) against the persisted intent | storage-independent planner laws, one per recovery-table row and one per ambiguity rule, in `tests/store_migration_recovery.rs`; every prefix of zero through twenty-one phases and a truncated pre-effect stage recover in-process to one complete migration in `filesystem_migration_recovery_tests`; restart-stable reopen law in `filesystem_version_two_admission_tests`; the process-death matrix is `KEEP-MIGRATION-007` | Implemented | | `KEEP-MIGRATION-005` | Unknown, out-of-order, substituted, corrupt, conflicting, or changed evidence is unrecoverable ambiguity | forward-execution stage preservation, byte-equal inode-substitution, out-of-order-prefix, and post-publication drift laws in `filesystem_migration_storage_tests`; unknown `retention` entries, non-digest namespace directories, and noncanonical pool names refuse before any retention stage is written in `filesystem_retention_namespace_tests`; every planner ambiguity rule in `tests/store_migration_recovery.rs` and a corrupt durable intent refusing recovery before any mutation in `filesystem_migration_recovery_tests`; restart corruption and mutation matrix remains | In progress in #20 | @@ -45,7 +45,7 @@ case is not evidence. | ID | Requirement | Evidence | Status | | --- | --- | --- | --- | -| `KEEP-GC-001` | Version 2 specifies exact bounded GC intent, receipt, and recovery-disposition grammars but refuses their presence until their parser and recovery protocol are implemented | intent and receipt golden, canonical re-encoding, cross-record binding, and field-by-field corruption laws in `tests/gc_retirement_intent.rs`, `tests/gc_retirement_intent/mutation_laws.rs`, and `tests/gc_retirement_receipt.rs`; disposition golden, canonical re-encoding, registered-enumeration agreement with `definition.tsv`, unregistered-code refusal, and field-by-field corruption laws in `tests/recovery_disposition_receipt.rs`; seeded `gc_format` fuzz target over all three records; presence refusal in namespace admission tests | Implemented | +| `KEEP-GC-001` | Version 2 specifies exact bounded GC intent, receipt, and recovery-disposition grammars but refuses their presence until their parser and recovery protocol are implemented | field-complete corruption ledger `conformance/segment-store/v2/mutations.tsv` (GC intent, receipt, disposition) reproduced by `tests/segment_store_mutations.rs`; intent and receipt golden, canonical re-encoding, cross-record binding, and field-by-field corruption laws in `tests/gc_retirement_intent.rs`, `tests/gc_retirement_intent/mutation_laws.rs`, and `tests/gc_retirement_receipt.rs`; disposition golden, canonical re-encoding, registered-enumeration agreement with `definition.tsv`, unregistered-code refusal, and field-by-field corruption laws in `tests/recovery_disposition_receipt.rs`; seeded `gc_format` fuzz target over all three records; presence refusal in namespace admission tests | Implemented | | `KEEP-GC-002` | GC intent, receipt, disposition, reader-fence, retirement, compaction, and recovery laws implement ADR-0009 without changing logical identity | deterministic planning: `plan_gc` classifies every inventoried segment against one fenced liveness snapshot, refuses every contradiction, and never names a live or catalog-named segment, proven by one law per classification and ambiguity, the golden `gc-plan.tsv`, a 512-universe model, and filesystem laws over the migrated fixture store in `src/adapters/gc/`; explicit disposition of recovery-protected retention orphans in `filesystem_retention_disposition_tests` (retire unlinks under an absent head, finalize needs a published head, manifest before root, readers refuse, every residue resumes, receipts admitted by census) and exact-receipt admission by GC planning in `liveness_observation_tests`; retirement and recovery: `FilesystemGcAuthority` re-proves the plan under writer authority and the exclusive fence, derives the intent with the registered proof, pool, and disposition-set digests, runs the 14 fixed phases through `GcExecutionStorage`, and `plan_gc_recovery` classifies every residue (retire, second generation, nothing-to-retire, stale plan, readers, every interrupted prefix, both truncated stages, intent exclusion of retention publication, ambiguity, admission) in `filesystem_gc_tests`, with the `KEEP-CRASH-074..=087` process-death matrix of 42 killed-writer cases in `cargo xtask durability-crash-matrix --sequence gc`; compaction and its golden-format, model-based, corruption, crash-injection, benchmark, and fuzz evidence remain Planned in #21 | In progress in #21 | diff --git a/docs/invariants/verification/requirements.md b/docs/invariants/verification/requirements.md index 8567ad17..1e142aa4 100644 --- a/docs/invariants/verification/requirements.md +++ b/docs/invariants/verification/requirements.md @@ -9,7 +9,7 @@ evidence. A planned case is not evidence. | --- | --- | --- | --- | | `KEEP-VERIFY-001` | Verification depth is one ordered enumeration; a report establishes exactly the requested depth and exposes no way to deepen it | `tests/verification_report.rs`; `VerificationReport` has private fields and crate-only construction | Implemented | | `KEEP-VERIFY-002` | A view refuses a depth it cannot establish as `Unsupported`, naming its supported range, instead of reporting a shallower depth | `tests/verification_report.rs` | Implemented for `ReferenceStore` | -| `KEEP-VERIFY-003` | Missing, corrupt, and ambiguous evidence are distinct refusals; each carries the exact expected and observed coordinates it can | `tests/verification_report.rs`, `src/reference/verification_tests.rs` | Implemented for `ReferenceStore`; `Ambiguous` has no producer yet | +| `KEEP-VERIFY-003` | Missing, corrupt, and ambiguous evidence are distinct refusals; each carries the exact expected and observed coordinates it can | `tests/verification_report.rs`, `src/reference/verification_tests.rs`; every durable structural field's corruption maps to one exact first refusal and stage (`framing`, `checksum`, `identity`, `binding`) in `conformance/segment-store/{v1,v2}/mutations.tsv` via `tests/segment_store_mutations.rs` | Implemented for `ReferenceStore`; `Ambiguous` has no producer yet | | `KEEP-VERIFY-004` | A lower-stage refusal is reported before a deeper one, and the single chunk pass hashes every chunk once | `tests/verification_report.rs` (profile-boundary and target contradictions succeed at `ChunkIdentity` and refuse only at `CompleteBlobIdentity`) | Implemented for `ReferenceStore` | | `KEEP-VERIFY-005` | Verification never repairs, substitutes, quarantines, or rewrites physical state | `ReferenceStore::verify` takes `&self`; `src/reference/verification_tests.rs` observes the tampered chunk unchanged | Implemented for `ReferenceStore` | | `KEEP-VERIFY-006` | Durable views establish `Framing`, `Checksum`, `CatalogReachability`, and `RetentionClosure` against one fenced snapshot and may report `Ambiguous` for conflicting evidence | durable read surface and snapshot laws | Planned in [#20](https://github.com/flyingrobots/keep/issues/20) | diff --git a/tests/segment_store_mutations.rs b/tests/segment_store_mutations.rs new file mode 100644 index 00000000..f7461ef3 --- /dev/null +++ b/tests/segment_store_mutations.rs @@ -0,0 +1,142 @@ +//! Field-complete corruption ledgers over every durable segment-store record: +//! each frozen mutation reaches exactly its named first refusal at its named +//! verification stage, through the public decoders. + +pub mod support; + +#[path = "segment_store_mutations/classify.rs"] +mod classify; +#[path = "segment_store_mutations/fixtures.rs"] +mod fixtures; +#[path = "segment_store_mutations/ledger.rs"] +mod ledger; +#[path = "segment_store_mutations/recipes.rs"] +mod recipes; + +use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; + +use keep::VerificationDepth; + +use classify::classify; +use ledger::{Format, MutationCase, mutation_cases}; + +const STAGES: [&str; 4] = ["framing", "checksum", "identity", "binding"]; +const V1_RECORDS: [&str; 8] = [ + "segment-header", + "segment-record", + "segment-seal", + "segment", + "catalog", + "catalog-entry", + "catalog-binding", + "publication-head", +]; +const V2_RECORDS: [&str; 9] = [ + "format-marker", + "migration-intent", + "migration-receipt", + "retention-root", + "retention-manifest", + "retention-head", + "gc-intent", + "gc-receipt", + "disposition", +]; + +fn mutated(case: &MutationCase) -> Result, Box> { + let mut bytes = case.mutated_bytes()?; + match case.checksum_posture { + "preserve-v1" => {} + "recompute-v1" => recipes::recompute(case.record, &mut bytes)?, + "recompute-trailer-v1" => recipes::recompute_trailer(case.record, &mut bytes)?, + "recompute-checksum-v1" => recipes::recompute_checksum_only(case.record, &mut bytes)?, + _ => return Err(format!("{}: unknown checksum posture", case.case).into()), + } + Ok(bytes) +} + +#[test] +fn every_frozen_mutation_reaches_its_exact_first_refusal() -> Result<(), Box> { + let mut seen = BTreeSet::new(); + let mut outcome_stages: BTreeMap<&str, &str> = BTreeMap::new(); + let mut differences = Vec::new(); + for case in mutation_cases()? { + assert!( + seen.insert((case.format, case.case)), + "{}: duplicate case", + case.case + ); + assert!( + STAGES.contains(&case.stage), + "{}: unregistered stage", + case.case + ); + let bytes = mutated(&case)?; + match classify(case.format, case.record, &bytes) { + Ok(refusal) + if refusal.outcome == case.expected_outcome && refusal.stage == case.stage => {} + Ok(refusal) => differences.push(format!( + "{}: expected {} ({}), observed {} ({})", + case.case, case.expected_outcome, case.stage, refusal.outcome, refusal.stage + )), + Err(error) => differences.push(format!("{}: {error}", case.case)), + } + if let Some(previous) = outcome_stages.insert(case.expected_outcome, case.stage) { + assert_eq!( + previous, case.stage, + "{}: one outcome, two stages", + case.case + ); + } + } + assert!(differences.is_empty(), "{}", differences.join("\n")); + Ok(()) +} + +#[test] +fn every_durable_record_has_a_ledger_and_every_row_cites_a_requirement() +-> Result<(), Box> { + let cases = mutation_cases()?; + for (format, records) in [ + (Format::V1, V1_RECORDS.as_slice()), + (Format::V2, V2_RECORDS.as_slice()), + ] { + for record in records { + let rows = cases + .iter() + .filter(|case| case.format == format && case.record == *record) + .count(); + assert!(rows >= 3, "{record}: fewer than three mutations"); + } + } + for case in &cases { + assert!( + case.requirement.starts_with("KEEP-") && case.requirement.len() > 10, + "{}: malformed requirement", + case.case + ); + assert!( + case.expected_outcome + .starts_with(&format!("{}.", case.record)) + || case.record == "segment-record" + || case.record == "segment" + || case.record == "segment-seal" + || case.record == "catalog-entry" + || case.record == "catalog", + "{}: outcome names another record", + case.case + ); + } + Ok(()) +} + +#[test] +fn ledger_stages_order_like_verification_depths() { + // `framing` and `checksum` are the two structural depths a durable view + // establishes first, in that order; `identity` follows them; `binding` + // is a cross-record contradiction established at the record's role. + assert!(VerificationDepth::Framing < VerificationDepth::Checksum); + assert!(VerificationDepth::Checksum < VerificationDepth::ChunkIdentity); + assert_eq!(STAGES, ["framing", "checksum", "identity", "binding"]); +} diff --git a/tests/segment_store_mutations/classify.rs b/tests/segment_store_mutations/classify.rs new file mode 100644 index 00000000..164df184 --- /dev/null +++ b/tests/segment_store_mutations/classify.rs @@ -0,0 +1,253 @@ +//! Decoding one mutated record through its public entry point and naming +//! the first refusal as `.` with its verification stage. + +use std::fmt::Debug; +use std::io; + +use keep::{ + AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, AdmittedRecoveryDispositionReceipt, + AdmittedRetentionManifest, AdmittedRetentionRoot, AdmittedSegment, AdmittedStoreFormatMarker, + AdmittedStoreMigrationIntent, AdmittedStoreMigrationReceipt, CatalogAdmissionError, + CatalogDecodeError, ChecksummedCatalog, ChecksummedPublicationHead, ChecksummedRetentionHead, + LayoutEntryLimit, SegmentReadError, SegmentReadPolicy, SegmentRecordAdmissionError, + SegmentRecordDecodeError, SegmentRecordLimit, +}; + +use super::fixtures::fixture; +use super::ledger::Format; +use crate::support::{decode_hex, invalid_corpus}; + +/// One classified refusal. +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct Refusal { + pub(crate) outcome: String, + pub(crate) stage: &'static str, +} + +const fn policy() -> SegmentReadPolicy { + SegmentReadPolicy::new(SegmentRecordLimit::MAXIMUM, LayoutEntryLimit::MAXIMUM) +} + +fn canonical(format: Format, name: &str) -> Result, io::Error> { + let hex = fixture(format, name)?; + decode_hex(hex.strip_suffix('\n').unwrap_or(hex)) +} + +/// The variant name of a `Debug`-rendered error, in kebab case. +fn variant(error: &E) -> String { + let rendered = format!("{error:?}"); + let name = rendered + .split(|character: char| !character.is_ascii_alphanumeric()) + .next() + .unwrap_or_default(); + let mut kebab = String::new(); + for (index, character) in name.chars().enumerate() { + if character.is_ascii_uppercase() && index != 0 { + kebab.push('-'); + } + kebab.push(character.to_ascii_lowercase()); + } + kebab +} + +fn refusal(record: &str, variant_name: &str) -> Refusal { + Refusal { + outcome: format!("{record}.{variant_name}"), + stage: stage_of(record, variant_name), + } +} + +/// The verification stage each registered refusal establishes. +/// +/// `checksum` refusals are a record's own integrity trailer; `identity` +/// refusals are content that does not hash to its declared identity; +/// `binding` refusals are a contradiction with another record or a +/// registered definition; everything else is canonical framing. +fn stage_of(record: &str, variant_name: &str) -> &'static str { + let checksum = matches!( + (record, variant_name), + (_, "checksum-mismatch") + | ( + "segment-seal", + "seal-checksum-mismatch" | "segment-digest-mismatch" + ) + | ("catalog", "digest-mismatch") + | ( + "retention-root", + "root-digest-mismatch" | "anchor-set-digest-mismatch" + ) + | ( + "retention-manifest", + "manifest-digest-mismatch" | "entry-set-digest-mismatch" + ) + | ( + "gc-intent", + "intent-digest-mismatch" | "candidate-set-digest-mismatch" + ) + ); + if checksum { + return "checksum"; + } + if matches!( + (record, variant_name), + ("segment-record", "chunk-identity-mismatch") + ) { + return "identity"; + } + let binding = matches!(record, "catalog-binding" | "head-binding") + || matches!( + (record, variant_name), + ("format-marker", "definition-digest-mismatch") + | ( + "migration-intent", + "definition-digest-mismatch" | "store-identifier-mismatch" + ) + | ( + "migration-receipt", + "intent-digest-mismatch" | "store-identifier-mismatch" + ) + | ("migration-receipt", "format-marker-digest-mismatch") + | ("retention-root", "profile") + | ("gc-intent", "profile") + | ("gc-receipt", _) + | ("disposition", "empty-retention-digest-mismatch") + ) && !matches!( + variant_name, + "wrong-length" + | "invalid-magic" + | "unsupported-version" + | "invalid-record-length" + | "unsupported-flags" + | "non-zero-reserved" + | "zero-generation" + ); + if binding { "binding" } else { "framing" } +} + +fn admitted(case: &str) -> io::Error { + invalid_corpus(match case.is_empty() { + true => "mutation was admitted", + false => "mutation was unexpectedly admitted", + }) +} + +/// Decodes `bytes` as `record` and classifies the first refusal. +/// +/// # Errors +/// +/// Returns a corpus error when the record is unknown, a context fixture is +/// malformed, or the mutation was admitted. +pub(crate) fn classify(format: Format, record: &str, bytes: &[u8]) -> Result { + match (format, record) { + (Format::V1, "segment-header" | "segment-record" | "segment-seal" | "segment") => { + let error = AdmittedSegment::decode(bytes, policy()) + .err() + .ok_or_else(|| admitted(record))?; + Ok(segment_refusal(&error)) + } + (Format::V1, "catalog" | "catalog-entry") => { + let error = ChecksummedCatalog::decode(bytes) + .err() + .ok_or_else(|| admitted(record))?; + Ok(catalog_refusal(&error)) + } + (Format::V1, "catalog-binding") => { + let segment_bytes = canonical(format, "one-zero-segment.hex")?; + let segment = AdmittedSegment::decode(&segment_bytes, policy()).map_err(corpus)?; + let segments = [segment]; + let catalog = ChecksummedCatalog::decode(bytes).map_err(corpus)?; + match catalog.admit(&segments) { + Ok(_) => Err(admitted(record)), + Err(CatalogAdmissionError::Catalog { source }) => Ok(catalog_refusal(&source)), + Err(error) => Ok(refusal("catalog-binding", &variant(&error))), + } + } + (Format::V1, "publication-head") => { + let error = ChecksummedPublicationHead::decode(bytes) + .err() + .ok_or_else(|| admitted(record))?; + Ok(refusal("publication-head", &variant(&error))) + } + (Format::V1, "head-binding") => { + let segment_bytes = canonical(format, "one-zero-segment.hex")?; + let segment = AdmittedSegment::decode(&segment_bytes, policy()).map_err(corpus)?; + let segments = [segment]; + let catalog_bytes = canonical(format, "one-zero-catalog.hex")?; + let catalog = ChecksummedCatalog::decode(&catalog_bytes) + .map_err(corpus)? + .admit(&segments) + .map_err(corpus)?; + let head = ChecksummedPublicationHead::decode(bytes).map_err(corpus)?; + let error = head.admit(catalog).err().ok_or_else(|| admitted(record))?; + Ok(refusal("head-binding", &variant(&error))) + } + (Format::V2, "format-marker") => simple(record, AdmittedStoreFormatMarker::decode(bytes)), + (Format::V2, "migration-intent") => { + simple(record, AdmittedStoreMigrationIntent::decode(bytes)) + } + (Format::V2, "migration-receipt") => { + let intent_bytes = canonical(format, "migration-intent.hex")?; + let marker_bytes = canonical(format, "format-marker.hex")?; + let intent = AdmittedStoreMigrationIntent::decode(&intent_bytes).map_err(corpus)?; + let marker = AdmittedStoreFormatMarker::decode(&marker_bytes).map_err(corpus)?; + simple( + record, + AdmittedStoreMigrationReceipt::decode(bytes, &intent, &marker), + ) + } + (Format::V2, "retention-root") => simple(record, AdmittedRetentionRoot::decode(bytes)), + (Format::V2, "retention-manifest") => { + simple(record, AdmittedRetentionManifest::decode(bytes)) + } + (Format::V2, "retention-head") => simple(record, ChecksummedRetentionHead::decode(bytes)), + (Format::V2, "gc-intent") => simple(record, AdmittedGcRetirementIntent::decode(bytes)), + (Format::V2, "gc-receipt") => { + let intent_bytes = canonical(format, "one-candidate-gc-intent.hex")?; + let intent = AdmittedGcRetirementIntent::decode(&intent_bytes).map_err(corpus)?; + simple(record, AdmittedGcRetirementReceipt::decode(bytes, &intent)) + } + (Format::V2, "disposition") => { + simple(record, AdmittedRecoveryDispositionReceipt::decode(bytes)) + } + _ => Err(invalid_corpus("mutation ledger names an unknown record")), + } +} + +fn simple(record: &str, result: Result) -> Result { + let error = result.err().ok_or_else(|| admitted(record))?; + Ok(refusal(record, &variant(&error))) +} + +fn corpus(error: E) -> io::Error { + io::Error::other(format!("canonical context fixture refused: {error:?}")) +} + +fn segment_refusal(error: &SegmentReadError) -> Refusal { + match error { + SegmentReadError::Header { source } => refusal("segment-header", &variant(source)), + SegmentReadError::Seal { source } => refusal("segment-seal", &variant(source)), + SegmentReadError::RecordHeader { source, .. } => { + refusal("segment-record", &variant(source)) + } + SegmentReadError::RecordDecode { source, .. } => match source { + SegmentRecordDecodeError::Header { source } => { + refusal("segment-record", &variant(source)) + } + other => refusal("segment-record", &variant(other)), + }, + SegmentReadError::RecordAdmission { source, .. } => match source { + SegmentRecordAdmissionError::Header { source } => { + refusal("segment-record", &variant(source)) + } + other => refusal("segment-record", &variant(other)), + }, + other => refusal("segment", &variant(other)), + } +} + +fn catalog_refusal(error: &CatalogDecodeError) -> Refusal { + match error { + CatalogDecodeError::Entry { source, .. } => refusal("catalog-entry", &variant(source)), + other => refusal("catalog", &variant(other)), + } +} diff --git a/tests/segment_store_mutations/fixtures.rs b/tests/segment_store_mutations/fixtures.rs new file mode 100644 index 00000000..1c82590c --- /dev/null +++ b/tests/segment_store_mutations/fixtures.rs @@ -0,0 +1,89 @@ +//! The frozen segment-store fixtures a mutation may start from. + +use std::io; + +use super::ledger::Format; +use crate::support::invalid_corpus; + +const V1: [(&str, &str); 6] = [ + ( + "one-zero-segment.hex", + include_str!("../../conformance/segment-store/v1/one-zero-segment.hex"), + ), + ( + "empty-segment.hex", + include_str!("../../conformance/segment-store/v1/empty-segment.hex"), + ), + ( + "one-zero-catalog.hex", + include_str!("../../conformance/segment-store/v1/one-zero-catalog.hex"), + ), + ( + "one-zero-catalog-generation-two.hex", + include_str!("../../conformance/segment-store/v1/one-zero-catalog-generation-two.hex"), + ), + ( + "one-zero-head.hex", + include_str!("../../conformance/segment-store/v1/one-zero-head.hex"), + ), + ( + "one-zero-head-generation-two.hex", + include_str!("../../conformance/segment-store/v1/one-zero-head-generation-two.hex"), + ), +]; + +const V2: [(&str, &str); 9] = [ + ( + "format-marker.hex", + include_str!("../../conformance/segment-store/v2/format-marker.hex"), + ), + ( + "migration-intent.hex", + include_str!("../../conformance/segment-store/v2/migration-intent.hex"), + ), + ( + "migration-receipt.hex", + include_str!("../../conformance/segment-store/v2/migration-receipt.hex"), + ), + ( + "one-anchor-root.hex", + include_str!("../../conformance/segment-store/v2/one-anchor-root.hex"), + ), + ( + "one-root-manifest.hex", + include_str!("../../conformance/segment-store/v2/one-root-manifest.hex"), + ), + ( + "one-root-head.hex", + include_str!("../../conformance/segment-store/v2/one-root-head.hex"), + ), + ( + "one-candidate-gc-intent.hex", + include_str!("../../conformance/segment-store/v2/one-candidate-gc-intent.hex"), + ), + ( + "one-candidate-gc-receipt.hex", + include_str!("../../conformance/segment-store/v2/one-candidate-gc-receipt.hex"), + ), + ( + "one-orphan-retire-disposition.hex", + include_str!("../../conformance/segment-store/v2/one-orphan-retire-disposition.hex"), + ), +]; + +/// Returns one fixture's hexadecimal text. +/// +/// # Errors +/// +/// Returns a corpus error when the fixture is not frozen. +pub(crate) fn fixture(format: Format, name: &str) -> Result<&'static str, io::Error> { + let table: &[(&str, &str)] = match format { + Format::V1 => &V1, + Format::V2 => &V2, + }; + table + .iter() + .find(|(fixture, _)| *fixture == name) + .map(|(_, hex)| *hex) + .ok_or_else(|| invalid_corpus("mutation ledger names an unknown fixture")) +} diff --git a/tests/segment_store_mutations/ledger.rs b/tests/segment_store_mutations/ledger.rs new file mode 100644 index 00000000..56f66563 --- /dev/null +++ b/tests/segment_store_mutations/ledger.rs @@ -0,0 +1,173 @@ +//! Parsing and application of the frozen segment-store mutation ledgers. + +use std::io; + +use crate::support::{decode_hex, field, invalid_corpus}; + +const V1_MUTATIONS: &str = include_str!("../../conformance/segment-store/v1/mutations.tsv"); +const V2_MUTATIONS: &str = include_str!("../../conformance/segment-store/v2/mutations.tsv"); + +/// The format a ledger row belongs to. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) enum Format { + V1, + V2, +} + +/// One parsed immutable mutation-ledger row. +pub(crate) struct MutationCase { + pub(crate) format: Format, + pub(crate) case: &'static str, + pub(crate) record: &'static str, + pub(crate) base_fixture: &'static str, + pub(crate) operation: &'static str, + pub(crate) offset: usize, + pub(crate) span_length: usize, + pub(crate) parameter: &'static str, + pub(crate) checksum_posture: &'static str, + pub(crate) expected_outcome: &'static str, + pub(crate) stage: &'static str, + pub(crate) requirement: &'static str, +} + +/// Parses every row of both ledgers. +/// +/// # Errors +/// +/// Returns a corpus error when a header, field, or integer is malformed. +pub(crate) fn mutation_cases() -> Result, io::Error> { + let mut cases = Vec::new(); + for (format, ledger, header) in [ + (Format::V1, V1_MUTATIONS, "keep.segment-store-mutations/v1"), + (Format::V2, V2_MUTATIONS, "keep.segment-store-mutations/v2"), + ] { + let mut lines = ledger.lines(); + if lines.next() != Some(header) { + return Err(invalid_corpus("mutation ledger header is not canonical")); + } + if lines.next() + != Some( + "case\trecord\tbase_fixture\toperation\toffset\tspan_length\tparameter\t\ + checksum_posture\texpected_outcome\tstage\trequirement", + ) + { + return Err(invalid_corpus("mutation ledger columns are not canonical")); + } + for row in lines { + cases.push(parse_case(format, row)?); + } + } + Ok(cases) +} + +fn parse_case(format: Format, row: &'static str) -> Result { + Ok(MutationCase { + format, + case: field(row, 0)?, + record: field(row, 1)?, + base_fixture: field(row, 2)?, + operation: field(row, 3)?, + offset: parse_usize(field(row, 4)?, "invalid mutation offset")?, + span_length: parse_usize(field(row, 5)?, "invalid mutation span length")?, + parameter: field(row, 6)?, + checksum_posture: field(row, 7)?, + expected_outcome: field(row, 8)?, + stage: field(row, 9)?, + requirement: field(row, 10)?, + }) +} + +fn parse_usize(text: &str, message: &'static str) -> Result { + text.parse().map_err(|_source| invalid_corpus(message)) +} + +impl MutationCase { + /// The base fixture's exact bytes. + /// + /// # Errors + /// + /// Returns a corpus error when the fixture is unknown or malformed. + pub(crate) fn base_bytes(&self) -> Result, io::Error> { + let hex = super::fixtures::fixture(self.format, self.base_fixture)?; + decode_hex(hex.strip_suffix('\n').unwrap_or(hex)) + } + + /// Applies the frozen operation to the base fixture, before any + /// checksum posture. + /// + /// # Errors + /// + /// Returns a corpus error for an unknown operation, an out-of-bounds + /// span, or a parameter of the wrong width. + pub(crate) fn mutated_bytes(&self) -> Result, io::Error> { + let mut bytes = self.base_bytes()?; + match self.operation { + "replace-v1" => { + let parameter = decode_parameter(self.parameter)?; + require_width(self, ¶meter)?; + span_mut(&mut bytes, self.offset, self.span_length)?.copy_from_slice(¶meter); + } + "xor-v1" => { + let parameter = decode_parameter(self.parameter)?; + require_width(self, ¶meter)?; + for (target, mask) in span_mut(&mut bytes, self.offset, self.span_length)? + .iter_mut() + .zip(¶meter) + { + *target ^= mask; + } + } + "truncate-v1" => { + if self.offset > bytes.len() || self.span_length != 0 { + return Err(invalid_corpus("truncate mutation is out of bounds")); + } + bytes.truncate(self.offset); + } + "append-v1" => { + if self.offset != bytes.len() || self.span_length != 0 { + return Err(invalid_corpus("append mutation must name the fixture end")); + } + bytes.extend_from_slice(&decode_parameter(self.parameter)?); + } + "delete-v1" => { + let end = span_end(self.offset, self.span_length)?; + if bytes.get(self.offset..end).is_none() || self.parameter != "-" { + return Err(invalid_corpus("delete mutation is out of bounds")); + } + drop(bytes.drain(self.offset..end)); + } + _ => return Err(invalid_corpus("unknown segment-store mutation operation")), + } + Ok(bytes) + } +} + +fn decode_parameter(parameter: &str) -> Result, io::Error> { + if parameter == "-" { + return Ok(Vec::new()); + } + decode_hex(parameter) +} + +fn require_width(case: &MutationCase, parameter: &[u8]) -> Result<(), io::Error> { + if parameter.len() == case.span_length { + Ok(()) + } else { + Err(invalid_corpus( + "mutation parameter width disagrees with its span", + )) + } +} + +fn span_end(offset: usize, length: usize) -> Result { + offset + .checked_add(length) + .ok_or_else(|| invalid_corpus("mutation span overflows")) +} + +fn span_mut(bytes: &mut [u8], offset: usize, length: usize) -> Result<&mut [u8], io::Error> { + let end = span_end(offset, length)?; + bytes + .get_mut(offset..end) + .ok_or_else(|| invalid_corpus("mutation span is out of bounds")) +} diff --git a/tests/segment_store_mutations/recipes.rs b/tests/segment_store_mutations/recipes.rs new file mode 100644 index 00000000..9af598ef --- /dev/null +++ b/tests/segment_store_mutations/recipes.rs @@ -0,0 +1,287 @@ +//! Independent recomputation of every record's own integrity trailer, so a +//! mutation can reach the check behind the checksum. + +use std::io; + +use crate::support::{domain_hash, invalid_corpus, patch}; + +/// `framed_blake3_v1(D, B)` from the version-1 segment specification. +fn framed_blake3_v1(domain: &[u8], body: &[u8]) -> Result<[u8; 32], io::Error> { + let length = + u64::try_from(body.len()).map_err(|_source| invalid_corpus("framed body exceeds u64"))?; + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(&1_u16.to_be_bytes()); + hasher.update(&[1_u8]); + hasher.update(body); + hasher.update(&length.to_be_bytes()); + Ok(*hasher.finalize().as_bytes()) +} + +fn slice(bytes: &[u8], start: usize, end: usize) -> Result<&[u8], io::Error> { + bytes + .get(start..end) + .ok_or_else(|| invalid_corpus("recipe span is out of bounds")) +} + +fn u64_at(bytes: &[u8], offset: usize) -> Result { + let end = offset + .checked_add(8) + .ok_or_else(|| invalid_corpus("field offset overflows"))?; + let field: [u8; 8] = slice(bytes, offset, end)? + .try_into() + .map_err(|_source| invalid_corpus("field width mismatch"))?; + Ok(u64::from_be_bytes(field)) +} + +/// Recomputes the seal checksum and the segment digest of one complete +/// segment whose seal is its last 128 bytes. +pub(crate) fn reseal_segment(bytes: &mut [u8]) -> Result<(), io::Error> { + let seal_offset = bytes + .len() + .checked_sub(128) + .ok_or_else(|| invalid_corpus("segment shorter than its seal"))?; + let digest_offset = seal_offset.saturating_add(64); + let checksum_offset = seal_offset.saturating_add(96); + let digest = framed_blake3_v1(b"KEEP:SEGMENT:DIGEST\0", slice(bytes, 0, digest_offset)?)?; + patch(bytes, digest_offset, &digest)?; + let checksum = framed_blake3_v1( + b"KEEP:SEGMENT:SEAL:SUM\0", + slice(bytes, seal_offset, checksum_offset)?, + )?; + patch(bytes, checksum_offset, &checksum) +} + +/// Recomputes the first record's checksum (the record at byte 64) and then +/// reseals the segment. +pub(crate) fn rechecksum_first_record(bytes: &mut [u8]) -> Result<(), io::Error> { + let record_length = usize::try_from(u64_at(bytes, 96)?) + .map_err(|_source| invalid_corpus("record length exceeds host width"))?; + let end = 64_usize + .checked_add(record_length) + .ok_or_else(|| invalid_corpus("record span overflows"))?; + let checksum_offset = end + .checked_sub(32) + .ok_or_else(|| invalid_corpus("record shorter than its checksum"))?; + let checksum = framed_blake3_v1(b"KEEP:SEG:RECORD:SUM\0", slice(bytes, 64, checksum_offset)?)?; + patch(bytes, checksum_offset, &checksum)?; + reseal_segment(bytes) +} + +/// Recomputes a catalog's checksum and digest trailer. +pub(crate) fn reseal_catalog(bytes: &mut [u8]) -> Result<(), io::Error> { + let digest_offset = bytes + .len() + .checked_sub(32) + .ok_or_else(|| invalid_corpus("catalog shorter than its digest"))?; + let checksum_offset = digest_offset + .checked_sub(32) + .ok_or_else(|| invalid_corpus("catalog shorter than its trailer"))?; + let checksum = framed_blake3_v1(b"KEEP:CATALOG:SUM\0", slice(bytes, 0, checksum_offset)?)?; + patch(bytes, checksum_offset, &checksum)?; + let digest = framed_blake3_v1(b"KEEP:CATALOG:DIGEST\0", slice(bytes, 0, digest_offset)?)?; + patch(bytes, digest_offset, &digest) +} + +/// Recomputes a publication head's checksum. +pub(crate) fn reseal_publication_head(bytes: &mut [u8]) -> Result<(), io::Error> { + let checksum = framed_blake3_v1(b"KEEP:CATHEAD:SUM\0", slice(bytes, 0, 96)?)?; + patch(bytes, 96, &checksum) +} + +/// Recomputes a fixed-width version-2 record's trailing checksum under +/// `domain` over every byte before it. +pub(crate) fn reseal_fixed_v2(bytes: &mut [u8], domain: &[u8]) -> Result<(), io::Error> { + let checksum_offset = bytes + .len() + .checked_sub(32) + .ok_or_else(|| invalid_corpus("record shorter than its checksum"))?; + let checksum = domain_hash(domain, slice(bytes, 0, checksum_offset)?); + patch(bytes, checksum_offset, &checksum) +} + +/// Recomputes a variable-length version-2 record's digest-then-checksum +/// trailer. +pub(crate) fn reseal_digested_v2( + bytes: &mut [u8], + digest_domain: &[u8], + checksum_domain: &[u8], +) -> Result<(), io::Error> { + let checksum_offset = bytes + .len() + .checked_sub(32) + .ok_or_else(|| invalid_corpus("record shorter than its checksum"))?; + let digest_offset = checksum_offset + .checked_sub(32) + .ok_or_else(|| invalid_corpus("record shorter than its trailer"))?; + let digest = domain_hash(digest_domain, slice(bytes, 0, digest_offset)?); + patch(bytes, digest_offset, &digest)?; + let checksum = domain_hash(checksum_domain, slice(bytes, 0, checksum_offset)?); + patch(bytes, checksum_offset, &checksum) +} + +fn u16_at(bytes: &[u8], offset: usize) -> Result { + let end = offset + .checked_add(2) + .ok_or_else(|| invalid_corpus("field offset overflows"))?; + let field: [u8; 2] = slice(bytes, offset, end)? + .try_into() + .map_err(|_source| invalid_corpus("field width mismatch"))?; + Ok(usize::from(u16::from_be_bytes(field))) +} + +fn u32_at(bytes: &[u8], offset: usize) -> Result<[u8; 4], io::Error> { + let end = offset + .checked_add(4) + .ok_or_else(|| invalid_corpus("field offset overflows"))?; + slice(bytes, offset, end)? + .try_into() + .map_err(|_source| invalid_corpus("field width mismatch")) +} + +/// Recomputes one `domain || count || body` set digest into `digest_offset`. +fn reseal_set_digest( + bytes: &mut [u8], + domain: &[u8], + count_offset: usize, + digest_offset: usize, + body_offset: usize, + body_length: usize, +) -> Result<(), io::Error> { + let count = u32_at(bytes, count_offset)?; + let end = body_offset + .checked_add(body_length) + .ok_or_else(|| invalid_corpus("set body overflows"))?; + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(&count); + hasher.update(slice(bytes, body_offset, end)?); + let digest = *hasher.finalize().as_bytes(); + patch(bytes, digest_offset, &digest) +} + +/// The width of the variable body a header-declared count covers, from the +/// record's total length minus its header and 64-byte trailer. +fn body_length(bytes: &[u8], body_offset: usize) -> Result { + bytes + .len() + .checked_sub(body_offset) + .and_then(|length| length.checked_sub(64)) + .ok_or_else(|| invalid_corpus("record shorter than its body")) +} + +/// Applies the record's complete recompute recipe: inner set digests, then +/// the record's own digest and checksum trailer. +/// +/// # Errors +/// +/// Returns a corpus error for an unknown record or a malformed span. +pub(crate) fn recompute(record: &str, bytes: &mut [u8]) -> Result<(), io::Error> { + match record { + "retention-root" => { + let namespace_length = u16_at(bytes, 40)?; + let anchors = 192_usize + .checked_add(namespace_length) + .ok_or_else(|| invalid_corpus("namespace length overflows"))?; + let length = body_length(bytes, anchors)?; + reseal_set_digest( + bytes, + b"keep.retention-anchor-set/v2\0", + 44, + 148, + anchors, + length, + )?; + } + "retention-manifest" => { + let length = body_length(bytes, 160)?; + reseal_set_digest( + bytes, + b"keep.retention-manifest-entries/v2\0", + 44, + 80, + 160, + length, + )?; + } + "gc-intent" => { + let length = body_length(bytes, 320)?; + reseal_set_digest(bytes, b"keep.gc-candidate-set/v2\0", 44, 288, 320, length)?; + } + _ => {} + } + recompute_trailer(record, bytes) +} + +/// Applies only the record's own digest and checksum trailer recipe. +/// +/// # Errors +/// +/// Returns a corpus error for an unknown record or a malformed span. +pub(crate) fn recompute_trailer(record: &str, bytes: &mut [u8]) -> Result<(), io::Error> { + match record { + "segment-header" | "segment-seal" | "segment" => reseal_segment(bytes), + "segment-record" => rechecksum_first_record(bytes), + "catalog" | "catalog-entry" | "catalog-binding" => reseal_catalog(bytes), + "publication-head" | "head-binding" => reseal_publication_head(bytes), + "format-marker" => reseal_fixed_v2(bytes, b"keep.segment-store-marker-checksum/v2\0"), + "migration-intent" => reseal_fixed_v2(bytes, b"keep.store-migration-intent-checksum/v2\0"), + "migration-receipt" => { + reseal_fixed_v2(bytes, b"keep.store-migration-receipt-checksum/v2\0") + } + "retention-head" => reseal_fixed_v2(bytes, b"keep.retention-head-checksum/v2\0"), + "gc-receipt" => reseal_fixed_v2(bytes, b"keep.gc-retirement-receipt-checksum/v2\0"), + "disposition" => reseal_fixed_v2(bytes, b"keep.recovery-disposition-receipt-checksum/v2\0"), + "retention-root" => reseal_digested_v2( + bytes, + b"keep.retention-root/v2\0", + b"keep.retention-root-checksum/v2\0", + ), + "retention-manifest" => reseal_digested_v2( + bytes, + b"keep.retention-manifest/v2\0", + b"keep.retention-manifest-checksum/v2\0", + ), + "gc-intent" => reseal_digested_v2( + bytes, + b"keep.gc-retirement-intent/v2\0", + b"keep.gc-retirement-intent-checksum/v2\0", + ), + _ => Err(invalid_corpus("unknown record for recompute")), + } +} + +/// Recomputes only the outermost checksum, leaving an inner digest as +/// mutated, so a digest field's own refusal is reachable. +pub(crate) fn recompute_checksum_only(record: &str, bytes: &mut [u8]) -> Result<(), io::Error> { + match record { + "segment-seal" => { + let seal_offset = bytes + .len() + .checked_sub(128) + .ok_or_else(|| invalid_corpus("segment shorter than its seal"))?; + let checksum_offset = seal_offset.saturating_add(96); + let checksum = framed_blake3_v1( + b"KEEP:SEGMENT:SEAL:SUM\0", + slice(bytes, seal_offset, checksum_offset)?, + )?; + patch(bytes, checksum_offset, &checksum) + } + "catalog" => { + let digest_offset = bytes + .len() + .checked_sub(32) + .ok_or_else(|| invalid_corpus("catalog shorter than its digest"))?; + let checksum_offset = digest_offset + .checked_sub(32) + .ok_or_else(|| invalid_corpus("catalog shorter than its trailer"))?; + let checksum = + framed_blake3_v1(b"KEEP:CATALOG:SUM\0", slice(bytes, 0, checksum_offset)?)?; + patch(bytes, checksum_offset, &checksum) + } + "retention-root" => reseal_fixed_v2(bytes, b"keep.retention-root-checksum/v2\0"), + "retention-manifest" => reseal_fixed_v2(bytes, b"keep.retention-manifest-checksum/v2\0"), + "gc-intent" => reseal_fixed_v2(bytes, b"keep.gc-retirement-intent-checksum/v2\0"), + other => recompute_trailer(other, bytes), + } +} diff --git a/xtask/src/golden_file_worldline/capability_contract.rs b/xtask/src/golden_file_worldline/capability_contract.rs index 5225b1a5..9f6b66d8 100644 --- a/xtask/src/golden_file_worldline/capability_contract.rs +++ b/xtask/src/golden_file_worldline/capability_contract.rs @@ -25,7 +25,7 @@ const CAPABILITY_CONTRACTS: [CapabilityContract; 16] = [ CapabilityContract::future("keep.segment.verified-read/v1", 3, &[14, 15]), CapabilityContract::future("keep.restart.lawful-recovery/v1", 3, &[17]), CapabilityContract::future("keep.retention.both-states/v1", 4, &[18, 19]), - CapabilityContract::future("keep.verification.precise-refusal/v1", 4, &[20]), + CapabilityContract::required("keep.verification.precise-refusal/v1", 4, &[20]), CapabilityContract::future("keep.compaction.identity-stable/v1", 4, &[21]), CapabilityContract::future("keep.echo.identity-agreement/v1", 5, &[22, 23]), CapabilityContract::future("keep.graft.golden-worldline/v1", 5, &[24]), diff --git a/xtask/src/main.rs b/xtask/src/main.rs index cee0e5f7..cb4c8831 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -132,6 +132,9 @@ fn run(mut arguments: impl Iterator) -> Result<(), TaskError> { "conformance-check" => { protocol_conformance::check(repository_root)?; } + "segment-store-mutations-check" => { + protocol_conformance::check_segment_store_mutations(repository_root)?; + } "documentation-integrity-check" => { documentation_integrity::check(repository_root)?; } diff --git a/xtask/src/protocol_conformance.rs b/xtask/src/protocol_conformance.rs index 5e4c80d4..58ebd093 100644 --- a/xtask/src/protocol_conformance.rs +++ b/xtask/src/protocol_conformance.rs @@ -6,6 +6,7 @@ mod chunk_identity; mod corpus; mod error; mod external_digest; +mod segment_store_mutations; #[cfg(test)] mod workflow_tests; @@ -15,15 +16,30 @@ pub(crate) use error::ConformanceError; /// Verifies every repository-owned protocol conformance corpus. /// -/// Chunk identity is checked before the CDC profile. The check performs -/// blocking, bounded repository reads and invokes the deadline-controlled -/// external digest witness. It returns [`ConformanceError`] at the first -/// admission, I/O, process, or verification failure. +/// The segment-store mutation ledgers are checked first (they need no +/// external witness), then chunk identity, then the CDC profile. The check +/// performs blocking, bounded repository reads and invokes the +/// deadline-controlled external digest witness. It returns +/// [`ConformanceError`] at the first admission, I/O, process, or +/// verification failure. pub(super) fn check(repository_root: &Path) -> Result<(), ConformanceError> { + check_segment_store_mutations(repository_root)?; check_chunk_identity(repository_root)?; check_cdc_profile(repository_root) } +/// Verifies the shape of both segment-store mutation ledgers: registered +/// records, operations, postures, stages, requirement identifiers, and +/// spans inside their frozen fixtures. +/// +/// The check performs blocking, bounded repository reads and no external +/// process. It returns [`ConformanceError`] on any malformed row. +pub(super) fn check_segment_store_mutations( + repository_root: &Path, +) -> Result<(), ConformanceError> { + segment_store_mutations::check(repository_root) +} + /// Verifies the `ChunkId` v1 recipes, canonical preimages, and expected digests. /// /// The check performs blocking, bounded repository reads and invokes the diff --git a/xtask/src/protocol_conformance/segment_store_mutations.rs b/xtask/src/protocol_conformance/segment_store_mutations.rs new file mode 100644 index 00000000..66c28c99 --- /dev/null +++ b/xtask/src/protocol_conformance/segment_store_mutations.rs @@ -0,0 +1,296 @@ +//! This module owns the shape law of the segment-store mutation ledgers: +//! every row names a frozen fixture, a registered operation, posture, +//! record, stage, and requirement, and a span inside that fixture. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use super::ConformanceError; +use super::corpus::{Corpus, TablePolicy}; + +const COLUMNS: [&str; 11] = [ + "case", + "record", + "base_fixture", + "operation", + "offset", + "span_length", + "parameter", + "checksum_posture", + "expected_outcome", + "stage", + "requirement", +]; +const OPERATIONS: [&str; 5] = [ + "replace-v1", + "xor-v1", + "truncate-v1", + "append-v1", + "delete-v1", +]; +const POSTURES: [&str; 4] = [ + "preserve-v1", + "recompute-v1", + "recompute-trailer-v1", + "recompute-checksum-v1", +]; +const STAGES: [&str; 4] = ["framing", "checksum", "identity", "binding"]; +const V1_RECORDS: [&str; 9] = [ + "segment-header", + "segment-record", + "segment-seal", + "segment", + "catalog", + "catalog-entry", + "catalog-binding", + "publication-head", + "head-binding", +]; +const V2_RECORDS: [&str; 9] = [ + "format-marker", + "migration-intent", + "migration-receipt", + "retention-root", + "retention-manifest", + "retention-head", + "gc-intent", + "gc-receipt", + "disposition", +]; +const MAXIMUM_TABLE_BYTES: usize = 1 << 20; +const MAXIMUM_ROWS: usize = 4_096; +const MAXIMUM_FIXTURE_BYTES: usize = 1 << 24; + +/// Verifies both segment-store mutation ledgers against their fixtures. +/// +/// The check performs bounded, blocking repository reads and no external +/// process. The executable law that every row reaches its named refusal is +/// `tests/segment_store_mutations.rs`; this check refuses a ledger that law +/// could not even apply. +pub(super) fn check(repository_root: &Path) -> Result<(), ConformanceError> { + check_ledger( + repository_root, + "conformance/segment-store/v1", + "keep.segment-store-mutations/v1", + &V1_RECORDS, + )?; + check_ledger( + repository_root, + "conformance/segment-store/v2", + "keep.segment-store-mutations/v2", + &V2_RECORDS, + ) +} + +fn check_ledger( + repository_root: &Path, + directory: &str, + schema: &'static str, + records: &[&str], +) -> Result<(), ConformanceError> { + let corpus = Corpus::open(repository_root.join(directory))?; + let rows = corpus.rows( + "mutations.tsv", + TablePolicy::new(schema, &COLUMNS, MAXIMUM_TABLE_BYTES, MAXIMUM_ROWS), + )?; + let mut cases = BTreeSet::new(); + let mut covered = BTreeSet::new(); + let mut fixture_lengths: BTreeMap = BTreeMap::new(); + for row in &rows { + let case = row.field("case")?; + if !cases.insert(case.to_owned()) { + return Err(violation(schema, case, "duplicate case")); + } + let record = row.field("record")?; + if !records.contains(&record) { + return Err(violation(schema, case, "unregistered record")); + } + covered.insert(record.to_owned()); + require_member( + schema, + case, + row.field("operation")?, + &OPERATIONS, + "operation", + )?; + require_member( + schema, + case, + row.field("checksum_posture")?, + &POSTURES, + "posture", + )?; + require_member(schema, case, row.field("stage")?, &STAGES, "stage")?; + require_outcome(schema, case, row.field("expected_outcome")?, records)?; + require_requirement(schema, case, row.field("requirement")?)?; + let fixture = row.field("base_fixture")?; + let length = match fixture_lengths.get(fixture) { + Some(length) => *length, + None => { + let length = fixture_length(&corpus, fixture)?; + fixture_lengths.insert(fixture.to_owned(), length); + length + } + }; + require_span(schema, case, row, length)?; + } + if let Some(missing) = records.iter().find(|record| !covered.contains(**record)) { + return Err(ConformanceError::violation(format!( + "{schema}: record {missing} has no mutation" + ))); + } + Ok(()) +} + +fn violation(schema: &str, case: &str, message: &str) -> ConformanceError { + ConformanceError::violation(format!("{schema}: {case}: {message}")) +} + +fn require_member( + schema: &str, + case: &str, + value: &str, + registered: &[&str], + what: &str, +) -> Result<(), ConformanceError> { + if registered.contains(&value) { + Ok(()) + } else { + Err(violation( + schema, + case, + &format!("unregistered {what} {value:?}"), + )) + } +} + +fn require_outcome( + schema: &str, + case: &str, + outcome: &str, + records: &[&str], +) -> Result<(), ConformanceError> { + let Some((record, variant)) = outcome.split_once('.') else { + return Err(violation(schema, case, "outcome lacks a record prefix")); + }; + let canonical = |text: &str| { + !text.is_empty() + && text + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }; + if records.contains(&record) && canonical(variant) { + Ok(()) + } else { + Err(violation(schema, case, "outcome is not .")) + } +} + +fn require_requirement( + schema: &str, + case: &str, + requirement: &str, +) -> Result<(), ConformanceError> { + let Some(rest) = requirement.strip_prefix("KEEP-") else { + return Err(violation( + schema, + case, + "requirement is not a KEEP identifier", + )); + }; + let Some((family, ordinal)) = rest.rsplit_once('-') else { + return Err(violation( + schema, + case, + "requirement is not a KEEP identifier", + )); + }; + if !family.is_empty() + && family.bytes().all(|byte| byte.is_ascii_uppercase()) + && ordinal.len() == 3 + && ordinal.bytes().all(|byte| byte.is_ascii_digit()) + { + Ok(()) + } else { + Err(violation( + schema, + case, + "requirement is not a KEEP identifier", + )) + } +} + +fn fixture_length(corpus: &Corpus, fixture: &str) -> Result { + if !fixture.ends_with(".hex") { + return Err(ConformanceError::violation(format!( + "mutation ledger names a non-hexadecimal fixture {fixture:?}" + ))); + } + let bytes = corpus + .source_file(fixture)? + .bounded_bytes(MAXIMUM_FIXTURE_BYTES, fixture)?; + let text = bytes.strip_suffix(b"\n").unwrap_or(&bytes); + if text.len() % 2 != 0 + || !text + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + { + return Err(ConformanceError::violation(format!( + "fixture {fixture} is not lowercase hexadecimal" + ))); + } + Ok(text.len() / 2) +} + +fn require_span( + schema: &str, + case: &str, + row: &super::corpus::TableRow, + length: usize, +) -> Result<(), ConformanceError> { + let offset = decimal(schema, case, row.field("offset")?)?; + let span = decimal(schema, case, row.field("span_length")?)?; + let parameter = row.field("parameter")?; + let end = offset + .checked_add(span) + .ok_or_else(|| violation(schema, case, "span overflows"))?; + let parameter_width = if parameter == "-" { + None + } else if parameter.len() % 2 == 0 + && parameter + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Some(parameter.len() / 2) + } else { + return Err(violation( + schema, + case, + "parameter is not lowercase hexadecimal", + )); + }; + let lawful = match row.field("operation")? { + "replace-v1" | "xor-v1" => end <= length && parameter_width == Some(span) && span > 0, + "truncate-v1" => span == 0 && offset < length && parameter_width.is_none(), + "append-v1" => span == 0 && offset == length && parameter_width.is_some_and(|w| w > 0), + "delete-v1" => span > 0 && end <= length && parameter_width.is_none(), + _ => false, + }; + if lawful { + Ok(()) + } else { + Err(violation( + schema, + case, + "span or parameter does not fit its operation", + )) + } +} + +fn decimal(schema: &str, case: &str, text: &str) -> Result { + if text != "0" && text.starts_with('0') { + return Err(violation(schema, case, "integer is not canonical decimal")); + } + text.parse() + .map_err(|_source| violation(schema, case, "integer is not canonical decimal")) +} diff --git a/xtask/tests/retention_store_v2_conformance_contract.rs b/xtask/tests/retention_store_v2_conformance_contract.rs index cbed8fdf..a6f11854 100644 --- a/xtask/tests/retention_store_v2_conformance_contract.rs +++ b/xtask/tests/retention_store_v2_conformance_contract.rs @@ -18,6 +18,7 @@ const REQUIRED_PATHS: &[&str] = &[ "migration-source.tsv", "artifacts.tsv", "transitions.tsv", + "mutations.tsv", "gc-plan.tsv", "format-marker.hex", "migration-intent.hex", From 3aebec460e80d8513d101a0cb308887ddec40909 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 13:03:46 -0700 Subject: [PATCH 29/59] Feat: durable, replayable verification receipts ROADMAP T-21.3 (F-21, KEEP-VERIFY-007). `keep.verification-receipt/v1` is a canonical, versioned, checksummed 384-byte record: the projection of one `VerificationReport` or `VerificationRefusal` onto one view. The record binds the subject (a 59-byte `BlobId` binary zero-padded to 60, or a 60-byte `LayoutId` binary), the admitted view (the reference store, or a durable snapshot's catalog generation and digest and liveness generation and manifest digest), the depth established or the stage refused (registered as the one-based position in `VerificationDepth::ALL`), the refusal classification (`missing`, `corrupt`, `ambiguous`, `unsupported`), the evidence kind and zero-based index, the exact layout and target where the outcome binds them, the chunks verified, the verification contract version, and a BLAKE3-256 checksum under `keep.verification-receipt-checksum/v1\0`. Expected and observed identities stay in the ephemeral refusal so the record stays fixed-width and carries no plaintext, key material, or path. `VerificationReceipt::{from_report, from_refusal}` project; `CanonicalVerificationReceipt::{encode, decode}` are the codec. `decode` admits length, magic, version, record length, flags, contract, checksum, every registered code, reserved bytes, the depth, the identity slots, the view, and every semantic law (a report names its layout and target and no refusal coordinate; a refusal names no target and verified no chunks; missing and corrupt evidence kinds agree with their layout and index; ambiguous and unsupported name no evidence; an unsupported range is ordered and excludes the requested depth; a reference view binds no coordinates and a durable view a positive catalog generation), then requires the bytes to be the canonical re-encoding. Flipping only the outcome kind is refused in both directions. `conformance/verification-receipt/v1/` freezes three receipts built by a handwritten oracle from the accepted one-zero `BlobId` and `LayoutId` binaries, the generation-two catalog digest, and the generation-one manifest digest: a complete-blob report against the reference view, a corrupt-chunk refusal against the frozen durable view, and an unsupported-framing refusal. `tests/verification_receipt.rs` proves the fixtures match the oracle and the production encoder, decode from the fixture file as from another process, and re-encode canonically; that every reference-store outcome at every depth and a `Missing` refusal on the durable view project and round-trip; one exact first refusal per structural field (37 mutations); and report/refusal exclusivity. The `verification_receipt` fuzz target is seeded from the corpus and the repository-shape contract admits the directory. The format page, registry row, verification and reconstruction ledgers, CHANGELOG, and ROADMAP follow; `KEEP-VERIFY-007` is Implemented, and `KEEP-VERIFY-006` (durable-view depths) stays with the durable read surface. Also: `durability_crash_point_sequence` (split out in the GC commit) lacked the `repository-tasks` gate its siblings carry, which broke the fuzz-crate build of `xtask`; gated now. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 18 + ROADMAP.md | 18 +- conformance/verification-receipt/v1/ORIGIN.md | 30 ++ conformance/verification-receipt/v1/README.md | 39 ++ .../verification-receipt/v1/artifacts.tsv | 5 + .../v1/durable-corrupt-chunk-refusal.hex | 1 + .../v1/reference-complete-blob-report.hex | 1 + .../reference-unsupported-framing-refusal.hex | 1 + docs/formats/README.md | 1 + .../formats/verification-receipt-v1/README.md | 123 ++++++ .../requirements.md | 2 +- docs/invariants/verification/README.md | 17 +- docs/invariants/verification/requirements.md | 2 +- fuzz/Cargo.toml | 7 + fuzz/fuzz_targets/verification_receipt.rs | 15 + src/adapters/exports.rs | 1 + src/adapters/mod.rs | 1 + .../verification_receipt/canonical.rs | 63 +++ .../verification_receipt/decode_error.rs | 164 +++++++ src/adapters/verification_receipt/decoder.rs | 205 +++++++++ .../verification_receipt/decoder_semantics.rs | 253 +++++++++++ src/adapters/verification_receipt/encoder.rs | 241 ++++++++++ src/adapters/verification_receipt/enums.rs | 120 +++++ src/adapters/verification_receipt/format.rs | 27 ++ src/adapters/verification_receipt/mod.rs | 28 ++ src/adapters/verification_receipt/receipt.rs | 265 +++++++++++ src/lib.rs | 6 + tests/verification_receipt.rs | 255 +++++++++++ tests/verification_receipt/matrix.rs | 411 ++++++++++++++++++ tests/verification_receipt/oracle.rs | 219 ++++++++++ xtask/src/fuzz_seed_corpus.rs | 2 + .../fuzz_seed_corpus/tests/materialization.rs | 25 +- .../fuzz_seed_corpus/verification_seeds.rs | 46 ++ xtask/src/lib.rs | 1 + ...rification_receipt_conformance_contract.rs | 95 ++++ 35 files changed, 2697 insertions(+), 11 deletions(-) create mode 100644 conformance/verification-receipt/v1/ORIGIN.md create mode 100644 conformance/verification-receipt/v1/README.md create mode 100644 conformance/verification-receipt/v1/artifacts.tsv create mode 100644 conformance/verification-receipt/v1/durable-corrupt-chunk-refusal.hex create mode 100644 conformance/verification-receipt/v1/reference-complete-blob-report.hex create mode 100644 conformance/verification-receipt/v1/reference-unsupported-framing-refusal.hex create mode 100644 docs/formats/verification-receipt-v1/README.md create mode 100644 fuzz/fuzz_targets/verification_receipt.rs create mode 100644 src/adapters/verification_receipt/canonical.rs create mode 100644 src/adapters/verification_receipt/decode_error.rs create mode 100644 src/adapters/verification_receipt/decoder.rs create mode 100644 src/adapters/verification_receipt/decoder_semantics.rs create mode 100644 src/adapters/verification_receipt/encoder.rs create mode 100644 src/adapters/verification_receipt/enums.rs create mode 100644 src/adapters/verification_receipt/format.rs create mode 100644 src/adapters/verification_receipt/mod.rs create mode 100644 src/adapters/verification_receipt/receipt.rs create mode 100644 tests/verification_receipt.rs create mode 100644 tests/verification_receipt/matrix.rs create mode 100644 tests/verification_receipt/oracle.rs create mode 100644 xtask/src/fuzz_seed_corpus/verification_seeds.rs create mode 100644 xtask/tests/verification_receipt_conformance_contract.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7f2473..874dc80c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,24 @@ after its public API and format compatibility policies are established. ### Added +- Durable verification receipts. `keep.verification-receipt/v1` is a + canonical, versioned, checksummed 384-byte record binding one + verification's subject, admitted view (reference, or a durable + snapshot's catalog generation and digest and liveness generation and + manifest digest), the depth established or stage refused, the refusal + classification and evidence kind and index, the exact layout and target + where the outcome binds them, and the verification contract version. + `VerificationReceipt::{from_report, from_refusal}` project the ephemeral + report or refusal; `CanonicalVerificationReceipt::{encode, decode}` are + the codec, and `decode` admits framing, contract, checksum, every + registered code, every identity slot, and every semantic law, then + requires canonical bytes. The corpus in `conformance/verification-receipt/v1/` + is built by a handwritten oracle from the accepted layout and segment-store + fixtures; `tests/verification_receipt.rs` proves the golden round trip, + cross-process admission, every reference-store outcome round-tripping, a + field-complete corruption matrix, and that a refusal never decodes as a + report; the `verification_receipt` fuzz target is seeded from the corpus. + `KEEP-VERIFY-007` is Implemented. - Permanent corruption ledgers over every durable structural field. `conformance/segment-store/v1/mutations.tsv` (105 rows: segment header, record header, record checksum, seal, whole segment, catalog header, diff --git a/ROADMAP.md b/ROADMAP.md index 9b9be48a..82b83594 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,7 +99,7 @@ names; use those in code, tests, and commits. - [x] [F-18 Retention publication](#f-18-retention-publication) — Done on this branch (recovery, the `KEEP-CRASH-036`–`052` matrix, member re-verification, and orphan disposition) - [x] [F-19 Reader fence and immutable version-2 snapshots](#f-19-reader-fence-and-immutable-version-2-snapshots) — Done on this branch (merged from PR #99) - [x] [F-20 Model-based retention transition evidence](#f-20-model-based-retention-transition-evidence) — Done on this branch (merged from PR #99) -- [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Partial (#20; report vocabulary and corruption ledgers done on this branch; durable receipts remain) +- [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Partial (#20; report vocabulary, corruption ledgers, and durable receipts done on this branch; durable-view depths land with T-23.1) - [ ] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Partial (#21; codecs, planner, and orphan disposition done on this branch; compaction and execution remain) - [ ] [F-23 Durable authenticated reads and refusal receipts](#f-23-durable-authenticated-reads-and-refusal-receipts) — Planned (#109) - [ ] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Planned (#82, #72) @@ -1031,8 +1031,10 @@ clocks, paths, environment, and caller identity out of the core. ### F-21 Precise verification reports and corruption refusal **Status:** Partial (#20, P1, M4); the vocabulary and the reference-store -form (T-21.1) and the permanent corruption ledgers (T-21.2) landed on this -branch; durable receipts (T-21.3) remain. The most-cited open blocker: F-22, +form (T-21.1), the permanent corruption ledgers (T-21.2), and durable +receipts (T-21.3) landed on this branch; durable views establishing +`Framing` through `RetentionClosure` (`KEEP-VERIFY-006`) land with the +durable read surface (T-23.1). The most-cited open blocker: F-22, F-23, F-24, F-27, F-28, F-29, F-30, F-31, F-33, and F-34 all name it. Verify content and store structure at explicit, enumerated depths; report @@ -1121,7 +1123,15 @@ quarantines, or rewrites physical state. - **Complexity:** M. - **Documentation:** each format README "Mutation ledger". - **Dependencies:** T-21.1. -- [ ] T-21.3 Durable refusal and verification receipts. +- [x] T-21.3 Durable refusal and verification receipts — + `keep.verification-receipt/v1` (`docs/formats/verification-receipt-v1/`), + `VerificationReceipt`, `VerificationView`, and + `CanonicalVerificationReceipt::{encode, decode}`, the corpus in + `conformance/verification-receipt/v1/`, `tests/verification_receipt.rs`, + and the `verification_receipt` fuzz target; `KEEP-VERIFY-007` + Implemented. The receipt keeps a refusal's classification, evidence kind, + index, and layout and drops the expected and observed identities, so it + stays fixed-width. Original task fields: - **Requirements:** a canonical, versioned, checksummed report record binding `BlobId`, admitted view (catalog generation and digest, liveness generation and manifest digest), exact `LayoutId` if present, diff --git a/conformance/verification-receipt/v1/ORIGIN.md b/conformance/verification-receipt/v1/ORIGIN.md new file mode 100644 index 00000000..c42dd109 --- /dev/null +++ b/conformance/verification-receipt/v1/ORIGIN.md @@ -0,0 +1,30 @@ +# Verification Receipt Corpus Origin + +The corpus was constructed on 2026-09-30 with +`rustc 1.98.1 (48a229cea 2026-09-01)` and `cargo 1.98.1`. + +## Independent inputs + +The oracle in `tests/verification_receipt/oracle.rs` imports exact bytes +only from previously accepted fixtures: + +- the one-zero `BlobId` (59 bytes) and `LayoutId` (60 bytes) binaries + already transcribed from `conformance/layout/v1/layouts.tsv` by the + version-2 segment-store oracle; +- the generation-two catalog digest at byte 320 of + `conformance/segment-store/v1/one-zero-catalog-generation-two.hex`; +- the generation-one manifest digest from the `one-root-manifest` row of + `conformance/segment-store/v2/artifacts.tsv`. + +It assembles each record from the field table in +`docs/formats/verification-receipt-v1/README.md` and computes the trailing +checksum as BLAKE3-256 under `keep.verification-receipt-checksum/v1\0` over +bytes 0 through 351. It calls no production encoder. + +## Materialization boundary + +A temporary ignored test wrote the three hexadecimal fixtures and +`artifacts.tsv` from the oracle and was removed immediately after. The +committed oracle is read-only and rejects drift; the production +`CanonicalVerificationReceipt::encode` is required to reproduce every +fixture, not the other way round. diff --git a/conformance/verification-receipt/v1/README.md b/conformance/verification-receipt/v1/README.md new file mode 100644 index 00000000..c13f0b02 --- /dev/null +++ b/conformance/verification-receipt/v1/README.md @@ -0,0 +1,39 @@ +# Verification Receipt Version 1 Corpus + +This directory freezes canonical `keep.verification-receipt/v1` records: the +replayable projection of one verification report or refusal onto one view. +The format is specified in +[`docs/formats/verification-receipt-v1/`](../../../docs/formats/verification-receipt-v1/README.md). + +## Corpus files + +| File | Contents | +| --- | --- | +| `artifacts.tsv` | Case, kind, exact byte length, trailing checksum, and fixture per receipt | +| `reference-complete-blob-report.hex` | A report: the one-zero blob established at `CompleteBlobIdentity` against the reference view through its canonical layout | +| `durable-corrupt-chunk-refusal.hex` | A refusal: chunk 0 of the one-zero layout corrupt at `ChunkIdentity` against the frozen durable view (catalog generation 2, retention generation 1) | +| `reference-unsupported-framing-refusal.hex` | A refusal: `Framing` requested of the reference view, which supports `ChunkIdentity` through `CompleteBlobIdentity` | +| `ORIGIN.md` | Construction provenance and verification boundary | + +Hexadecimal fixture files contain one lowercase hexadecimal encoding of the +complete 384-byte record followed by exactly one LF. + +## Frozen identities + +The subject, layout, and target slots carry the canonical one-zero `BlobId` +and `LayoutId` binaries from +[`conformance/layout/v1/layouts.tsv`](../../layout/v1/layouts.tsv). The +durable view binds the generation-two catalog digest at byte 320 of +[`one-zero-catalog-generation-two.hex`](../../segment-store/v1/one-zero-catalog-generation-two.hex) +and the generation-one manifest digest from +[`segment-store/v2/artifacts.tsv`](../../segment-store/v2/artifacts.tsv). + +## Verification + +`tests/verification_receipt.rs` reconstructs every fixture from a +handwritten oracle over those inputs, requires the production encoder to +reproduce it byte for byte, decodes each fixture as another process would, +and holds every structural field to one exact first refusal. +`verification_receipt_conformance_contract` in `xtask` admits this +directory's shape and the artifact table. A fixture is never regenerated to +make a production implementation pass. diff --git a/conformance/verification-receipt/v1/artifacts.tsv b/conformance/verification-receipt/v1/artifacts.tsv new file mode 100644 index 00000000..2501421a --- /dev/null +++ b/conformance/verification-receipt/v1/artifacts.tsv @@ -0,0 +1,5 @@ +keep.verification-receipt.artifacts/v1 +case kind byte_length checksum_hex fixture +reference-complete-blob-report report 384 9f2af8d04f0c3f57a5759536858022c510913dba12de13dd1c8b8d35f6bb4b7f reference-complete-blob-report.hex +durable-corrupt-chunk-refusal refusal 384 c99dd1f264e3928d20a1d77a374bb076fcce854adb0f0c6d9df44b345f0c4fd1 durable-corrupt-chunk-refusal.hex +reference-unsupported-framing-refusal refusal 384 feba95e3a7447ef5b25a2ec112f42e79ccea7bd1935c0b0eef990499a9ba1d53 reference-unsupported-framing-refusal.hex diff --git a/conformance/verification-receipt/v1/durable-corrupt-chunk-refusal.hex b/conformance/verification-receipt/v1/durable-corrupt-chunk-refusal.hex new file mode 100644 index 00000000..d504e337 --- /dev/null +++ b/conformance/verification-receipt/v1/durable-corrupt-chunk-refusal.hex @@ -0,0 +1 @@ +4b4545503a5645524946593a5243505400010180000000000000000100020003000200020002000100010000000000004b4545503a4c41594f55543a494400000001000100000000000000dc887da23f1a7483359a78fc9a7fde80030ec2c4690603803f0ab7d0edb56575b84b4545503a4c41594f55543a494400000001000100000000000000dc887da23f1a7483359a78fc9a7fde80030ec2c4690603803f0ab7d0edb56575b80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002ea7d0055fd21f00ed94809ef4e671d72fa2e6a4a5d9ecefb23f3a320a2dad9930000000000000001f46b96a2bf3379320cf59e8af15b9d108de06025c415307b28953714bd7a80eb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c99dd1f264e3928d20a1d77a374bb076fcce854adb0f0c6d9df44b345f0c4fd1 diff --git a/conformance/verification-receipt/v1/reference-complete-blob-report.hex b/conformance/verification-receipt/v1/reference-complete-blob-report.hex new file mode 100644 index 00000000..a156a248 --- /dev/null +++ b/conformance/verification-receipt/v1/reference-complete-blob-report.hex @@ -0,0 +1 @@ +4b4545503a5645524946593a5243505400010180000000000000000100010005000100010000000000010000000000014b4545503a424c4f423a49440000000000010100000000000000011cfb8fa9e917aba15a1f592095f377ff180755fe1212b0d7d2ec750bd128b606004b4545503a4c41594f55543a494400000001000100000000000000dc887da23f1a7483359a78fc9a7fde80030ec2c4690603803f0ab7d0edb56575b84b4545503a424c4f423a49440000000000010100000000000000011cfb8fa9e917aba15a1f592095f377ff180755fe1212b0d7d2ec750bd128b60600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000009f2af8d04f0c3f57a5759536858022c510913dba12de13dd1c8b8d35f6bb4b7f diff --git a/conformance/verification-receipt/v1/reference-unsupported-framing-refusal.hex b/conformance/verification-receipt/v1/reference-unsupported-framing-refusal.hex new file mode 100644 index 00000000..a022f58c --- /dev/null +++ b/conformance/verification-receipt/v1/reference-unsupported-framing-refusal.hex @@ -0,0 +1 @@ +4b4545503a5645524946593a5243505400010180000000000000000100020001000100010004000000000003000500004b4545503a424c4f423a49440000000000010100000000000000011cfb8fa9e917aba15a1f592095f377ff180755fe1212b0d7d2ec750bd128b6060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000feba95e3a7447ef5b25a2ec112f42e79ccea7bd1935c0b0eef990499a9ba1d53 diff --git a/docs/formats/README.md b/docs/formats/README.md index 035d58bd..d6a3bcc4 100644 --- a/docs/formats/README.md +++ b/docs/formats/README.md @@ -9,6 +9,7 @@ admitted merely because one Rust type can serialize and deserialize it. | --- | --- | --- | --- | | [Flat Chunk Layout v1](flat-chunk-layout-v1/README.md) | `keep.flat-chunks/v1` | Implemented through verified reconstruction in issues #10 and #13 | [Golden corpus](../../conformance/layout/v1/README.md) | | [Durable Segment Store v1](segment-store-v1/README.md) | `keep.segment-store/v1` | Implemented through initialization, publication, restart, and recovery in issues #14–#17 | [Golden corpus](../../conformance/segment-store/v1/README.md) | +| [Verification Receipt v1](verification-receipt-v1/README.md) | `keep.verification-receipt/v1` | Canonical 384-byte replayable projection of one verification report or refusal onto one view; codec, corpus, corruption matrix, and fuzz target implemented | [Golden corpus](../../conformance/verification-receipt/v1/README.md) | | [Durable Segment Store v2](segment-store-v2/README.md) | `keep.segment-store/v2` | One-way migration, version-two reopen, retention publication and recovery, reader fencing, explicit disposition, and GC retirement implemented; compaction planned in issue #21 | [Golden corpus](../../conformance/segment-store/v2/README.md) | The registry records protocol specifications, including formats whose diff --git a/docs/formats/verification-receipt-v1/README.md b/docs/formats/verification-receipt-v1/README.md new file mode 100644 index 00000000..86d4e346 --- /dev/null +++ b/docs/formats/verification-receipt-v1/README.md @@ -0,0 +1,123 @@ +# Verification Receipt Version 1 + +This page owns the canonical `keep.verification-receipt/v1` record: the +durable, replayable projection of one `VerificationReport` or +`VerificationRefusal` onto one view. A receipt states what one verification +established or refused, against which subject, view, layout, and target, at +which depth or stage, and under which classification. It contains no +plaintext, key material, or path. Keep does not persist receipts; the +application that asked for the verification does. + +The [verification invariant](../../invariants/verification/README.md) owns +the vocabulary the receipt encodes; the +[golden corpus](../../../conformance/verification-receipt/v1/README.md) owns +the frozen bytes. + +## Core law + +A receipt is admitted only as the exact canonical encoding of one receipt +whose fields obey every semantic law below. `CanonicalVerificationReceipt::encode` +is total and deterministic; `decode` admits framing, the verification +contract version, the checksum, every registered code, every identity slot, +and every semantic law, then requires the bytes to equal the canonical +re-encoding of what it decoded. A report and a refusal are different +outcomes: flipping the outcome kind leaves the other fields contradicting it, +so neither direction is admitted. + +## Record + +Every integer is unsigned, big-endian, and fixed-width. The record is exactly +384 bytes. + + + +| Offset | Width | Field | Canonical value | +| ---: | ---: | --- | --- | +| 0 | 16 | magic | `KEEP:VERIFY:RCPT` | +| 16 | 2 | version | `1` | +| 18 | 2 | record length | `384` | +| 20 | 4 | flags | `0` | +| 24 | 4 | verification contract version | `1` | +| 28 | 2 | outcome kind | `report:1`, `refusal:2` | +| 30 | 2 | depth | registered depth code: established (report) or the stage refused; the requested depth for `unsupported` | +| 32 | 2 | subject kind | `blob:1`, `layout:2` | +| 34 | 2 | view kind | `reference:1`, `durable:2` | +| 36 | 2 | refusal class | `none:0`, `missing:1`, `corrupt:2`, `ambiguous:3`, `unsupported:4` | +| 38 | 2 | evidence kind | `none:0`, then per class below | +| 40 | 2 | layout present | `0` or `1` | +| 42 | 2 | supported minimum depth | registered depth code for `unsupported`, else `0` | +| 44 | 2 | supported maximum depth | registered depth code for `unsupported`, else `0` | +| 46 | 2 | target present | `0` or `1` | +| 48 | 60 | subject identity slot | canonical `BlobId` binary (59 bytes, one zero pad byte) or `LayoutId` binary (60 bytes) | +| 108 | 60 | layout slot | canonical `LayoutId` binary when present, else zero | +| 168 | 60 | target slot | canonical `BlobId` binary plus one zero byte when present, else zero | +| 228 | 8 | catalog generation | positive for a durable view, `0` for the reference view | +| 236 | 32 | catalog digest | exact for a durable view, zero for the reference view | +| 268 | 8 | liveness generation | positive when the durable view has published retention, else `0` | +| 276 | 32 | manifest digest | exact when the liveness generation is positive, else zero | +| 308 | 8 | evidence index | zero-based chunk or boundary index for an indexed evidence kind, else `0` | +| 316 | 8 | chunks verified | the report's count; `0` for a refusal | +| 324 | 28 | reserved | zero | +| 352 | 32 | checksum | BLAKE3-256 under `keep.verification-receipt-checksum/v1\0` over bytes `0..352` | + + + +Depth codes are the one-based positions in `VerificationDepth::ALL`: +`framing:1`, `checksum:2`, `chunk-identity:3`, `layout-identity:4`, +`complete-blob-identity:5`, `catalog-reachability:6`, +`retention-closure:7`. + +Evidence kinds for `missing`: `blob:1` (no committed layout names the +blob), `layout:2` (the exact layout is not committed), `chunk:3` (the +present layout names a chunk the view lacks; indexed). For `corrupt`: +`chunk-identity:1` (indexed), `layout-identity:2`, `blob-identity:3`, +`profile-boundary:4` (indexed). The receipt keeps the kind, the layout, and +the index; the expected and observed identities stay in the ephemeral +refusal. + +## Semantic laws + +- A report carries refusal class `none`, evidence kind `none`, evidence + index `0`, and a zero supported range; it names its layout and target; a + blob subject's target is the subject and a layout subject's layout is the + subject. +- A refusal names no target and verified no chunks. +- `missing` and `corrupt` carry a zero supported range; `missing` evidence + `blob` and `layout` name no layout and no index, `chunk` names both; + every `corrupt` evidence names its layout, and only the indexed kinds + carry an index. +- `ambiguous` names no evidence, layout, or index. +- `unsupported` names no evidence or layout; its supported range is + registered and ordered, and the requested depth lies outside it. +- The reference view binds no generation or digest; a durable view binds a + positive catalog generation, and a zero liveness generation binds a zero + manifest digest. +- An absent layout or target slot is zero; a blob slot's pad byte is zero. + +## Deterministic refusal order + +Length; magic; version; record length; flags; contract version; checksum; +outcome kind; then the remaining registered codes in field order; reserved +bytes; the depth code; the subject slot; the view; the layout slot; then +the outcome's semantic laws. `tests/verification_receipt.rs` holds one +mutation per structural field to this order. + +## Evidence + +`tests/verification_receipt.rs`: the three golden fixtures match a +handwritten oracle and the production encoder; each decodes from the +fixture file as from another process and re-encodes canonically; every +reference-store report and refusal at every depth projects and round-trips, +as does a `Missing` refusal projected onto the frozen durable view; every +structural field has one exact first refusal; a refusal never decodes as a +report and a report never as a refusal. The `verification_receipt` fuzz +target is seeded from the corpus. Requirement `KEEP-VERIFY-007`. + +## Nonclaims + +A receipt proves that one verification reported or refused as stated, not +that the view still holds, that the store is durable, or that a later +verification agrees. It carries identities, generations, and digests only; +it reveals no content. Durable views that produce `Framing`, `Checksum`, +`CatalogReachability`, and `RetentionClosure` reports are `KEEP-VERIFY-006`, +planned with the durable read surface. diff --git a/docs/invariants/authenticated-reconstruction/requirements.md b/docs/invariants/authenticated-reconstruction/requirements.md index 89148258..b5f41342 100644 --- a/docs/invariants/authenticated-reconstruction/requirements.md +++ b/docs/invariants/authenticated-reconstruction/requirements.md @@ -11,7 +11,7 @@ gap, not implementation evidence. | `KEEP-RECONSTRUCT-003` | A range receipt proves only requested bytes from authenticated overlapping chunks; it proves neither the complete blob nor any storage-profile boundary. | Minimal-overlap range model | Unit, property, and public API integration tests | Implemented | `src/reference/range_read_tests.rs`, `tests/range_read.rs`, `tests/range_read_properties.rs` | | `KEEP-RECONSTRUCT-004` | A range receipt names only a layout-to-target binding admitted by the selected store view. | Forged same-length target-layout fixture | Public API corruption test | Implemented | `tests/range_read_entrypoints.rs` | | `KEEP-RECONSTRUCT-005` | Success authenticates the complete emitted sequence; failure returns no success receipt and reports the exact accepted prefix, which remains untrusted. | Deterministic prefix-then-fail writer | Public API failure tests | Implemented | `tests/streaming_cas/refusal_laws.rs`, `tests/range_read_failures.rs` | -| `KEEP-RECONSTRUCT-006` | Authenticated success, evidenced content refusal, and operational failure remain distinct outcomes; operational failure supports no content conclusion. | Typed outcome classification | Public API integration tests and contract inspection | Implemented for `ReferenceStore`; durable refusal receipts planned | `tests/streaming_cas/refusal_laws.rs`, `tests/range_read_failures.rs`; [Keep #22](https://github.com/flyingrobots/keep/issues/22) | +| `KEEP-RECONSTRUCT-006` | Authenticated success, evidenced content refusal, and operational failure remain distinct outcomes; operational failure supports no content conclusion. | Typed outcome classification | Public API integration tests and contract inspection | Implemented for `ReferenceStore`; refusals project onto the durable `CanonicalVerificationReceipt` (`KEEP-VERIFY-007`) | `tests/streaming_cas/refusal_laws.rs`, `tests/range_read_failures.rs`, `tests/verification_receipt.rs` | | `KEEP-RECONSTRUCT-007` | Whole-object and range receipts bind target, exact layout, proof scope, and exact emitted coordinates without granting retention or application authority. | Receipt type inspection | Public API contract tests | Implemented | `src/reference/reconstruction_receipt.rs`, `src/reference/range_read_receipt.rs`, `tests/range_read_contract.rs` | | `KEEP-RECONSTRUCT-008` | Automatic layout choice is deterministic; an exact requested layout never falls back. | Canonically ordered layout set | Unit and public API integration tests | Implemented | `src/reference/store_tests.rs`, `tests/streaming_cas/reconstruction_laws.rs` | | `KEEP-RECONSTRUCT-009` | A durable read pins one immutable view and prevents required evidence from being garbage-collected, deleted, or invalidated through completion. | Pinned-generation and retained-closure model | Recovery, concurrency, corruption, and crash-injection tests | Planned gap | [Keep #22](https://github.com/flyingrobots/keep/issues/22), [Keep #23](https://github.com/flyingrobots/keep/issues/23) | diff --git a/docs/invariants/verification/README.md b/docs/invariants/verification/README.md index dd73976a..c1554980 100644 --- a/docs/invariants/verification/README.md +++ b/docs/invariants/verification/README.md @@ -91,9 +91,20 @@ view allocates no other adapter-owned memory. No reference-store path produces `Ambiguous`. +## Receipts + +`VerificationReceipt::from_report` and `from_refusal` project a report or +refusal onto a `VerificationView` (the reference store, or one durable +snapshot's catalog and retention coordinates), keeping the subject, depth or +stage, classification, evidence kind and index, and the exact layout and +target, and dropping the expected and observed identities. +`CanonicalVerificationReceipt::{encode, decode}` is the durable, replayable +384-byte form specified on +[the format page](../../formats/verification-receipt-v1/README.md); a +receipt written by one process is admitted by another exactly as meant. + ## Nonclaims A report contains no plaintext, key material, or path. It is an ephemeral -statement about one operation against one view; a durable, replayable form -is the refusal-receipt work in -[the reconstruction ledger](../authenticated-reconstruction/requirements.md). +statement about one operation against one view; its receipt is the durable +form and proves no more than the report did. diff --git a/docs/invariants/verification/requirements.md b/docs/invariants/verification/requirements.md index 1e142aa4..8b212f3e 100644 --- a/docs/invariants/verification/requirements.md +++ b/docs/invariants/verification/requirements.md @@ -13,6 +13,6 @@ evidence. A planned case is not evidence. | `KEEP-VERIFY-004` | A lower-stage refusal is reported before a deeper one, and the single chunk pass hashes every chunk once | `tests/verification_report.rs` (profile-boundary and target contradictions succeed at `ChunkIdentity` and refuse only at `CompleteBlobIdentity`) | Implemented for `ReferenceStore` | | `KEEP-VERIFY-005` | Verification never repairs, substitutes, quarantines, or rewrites physical state | `ReferenceStore::verify` takes `&self`; `src/reference/verification_tests.rs` observes the tampered chunk unchanged | Implemented for `ReferenceStore` | | `KEEP-VERIFY-006` | Durable views establish `Framing`, `Checksum`, `CatalogReachability`, and `RetentionClosure` against one fenced snapshot and may report `Ambiguous` for conflicting evidence | durable read surface and snapshot laws | Planned in [#20](https://github.com/flyingrobots/keep/issues/20) | -| `KEEP-VERIFY-007` | A durable, replayable verification receipt binds the subject, view coordinates, depth, and refusal classification | canonical receipt format and corpus | Planned in [#20](https://github.com/flyingrobots/keep/issues/20) | +| `KEEP-VERIFY-007` | A durable, replayable verification receipt binds the subject, view coordinates, depth, and refusal classification | `CanonicalVerificationReceipt` over `keep.verification-receipt/v1` ([format](../../formats/verification-receipt-v1/README.md)); golden oracle, cross-process admission, every reference-store outcome round-tripping, a field-complete corruption matrix, and report/refusal exclusivity in `tests/verification_receipt.rs`; the `verification_receipt` fuzz target | Implemented | diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index c527bfd0..56ffd456 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -152,6 +152,13 @@ test = false doc = false bench = false +[[bin]] +name = "verification_receipt" +path = "fuzz_targets/verification_receipt.rs" +test = false +doc = false +bench = false + [workspace] members = ["."] resolver = "3" diff --git a/fuzz/fuzz_targets/verification_receipt.rs b/fuzz/fuzz_targets/verification_receipt.rs new file mode 100644 index 00000000..ee870235 --- /dev/null +++ b/fuzz/fuzz_targets/verification_receipt.rs @@ -0,0 +1,15 @@ +#![no_main] + +//! This target owns canonical verification receipt parser fuzzing: an +//! admitted receipt re-encodes to exactly its input. + +use keep::CanonicalVerificationReceipt; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|bytes: &[u8]| { + if let Ok(receipt) = CanonicalVerificationReceipt::decode(bytes) { + assert_eq!(receipt.encoded().as_slice(), bytes); + let again = CanonicalVerificationReceipt::encode(receipt.receipt()); + assert_eq!(again, receipt); + } +}); diff --git a/src/adapters/exports.rs b/src/adapters/exports.rs index b625156d..9eefedf3 100644 --- a/src/adapters/exports.rs +++ b/src/adapters/exports.rs @@ -103,5 +103,6 @@ pub use super::store_initialization_phase::StoreInitializationPhase; pub use super::store_initialization_receipt::StoreInitializationReceipt; pub use super::store_initialization_storage::StoreInitializationStorage; pub use super::store_migration::*; +pub use super::verification_receipt::*; pub use super::writer_lock_acquire_error::WriterLockAcquireError; pub use super::writer_lock_acquire_phase::WriterLockAcquirePhase; diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index dd6eb15d..9f83478d 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -231,6 +231,7 @@ mod sync_capable_directory; #[cfg(test)] #[path = "../../tests/support/mod.rs"] mod test_support; +mod verification_receipt; mod writer_lock_acquire_error; mod writer_lock_acquire_phase; diff --git a/src/adapters/verification_receipt/canonical.rs b/src/adapters/verification_receipt/canonical.rs new file mode 100644 index 00000000..4896a725 --- /dev/null +++ b/src/adapters/verification_receipt/canonical.rs @@ -0,0 +1,63 @@ +//! This boundary module owns the canonical 384-byte verification receipt. + +use super::decode_error::VerificationReceiptDecodeError; +use super::format::ENCODED_LENGTH; +use super::receipt::VerificationReceipt; +use super::{decoder, decoder_semantics, encoder}; + +/// One canonical, checksummed verification receipt. +/// +/// `encode` is total: every `VerificationReceipt` has exactly one encoding. +/// `decode` admits framing, contract, checksum, registered codes, identity +/// slots, and every semantic law before returning the same bytes, so a +/// receipt written by one process is admitted by another exactly as it was +/// meant. Keep does not persist receipts; the application does. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CanonicalVerificationReceipt { + encoded: [u8; ENCODED_LENGTH], + receipt: VerificationReceipt, +} + +impl CanonicalVerificationReceipt { + /// The exact encoded length. + pub const ENCODED_LENGTH: usize = ENCODED_LENGTH; + + /// Encodes one receipt canonically. + pub fn encode(receipt: &VerificationReceipt) -> Self { + Self { + encoded: encoder::encode(receipt), + receipt: *receipt, + } + } + + /// Decodes and admits one exact receipt. + /// + /// # Errors + /// + /// Returns [`VerificationReceiptDecodeError`] at the first framing, + /// contract, checksum, code, identity, or semantic refusal. + pub fn decode(encoded: &[u8]) -> Result { + let fields = decoder::decode(encoded)?; + let receipt = decoder_semantics::admit(&fields)?; + let canonical = Self::encode(&receipt); + if canonical.encoded.as_slice() == encoded { + Ok(canonical) + } else { + Err(VerificationReceiptDecodeError::Semantic { + law: "the bytes are not the canonical encoding of their receipt", + }) + } + } + + /// The exact canonical bytes. + #[must_use] + pub const fn encoded(&self) -> &[u8; ENCODED_LENGTH] { + &self.encoded + } + + /// The semantic receipt. + pub const fn receipt(&self) -> &VerificationReceipt { + &self.receipt + } +} diff --git a/src/adapters/verification_receipt/decode_error.rs b/src/adapters/verification_receipt/decode_error.rs new file mode 100644 index 00000000..7fbd340c --- /dev/null +++ b/src/adapters/verification_receipt/decode_error.rs @@ -0,0 +1,164 @@ +//! This boundary module owns typed verification receipt decoding failures. + +use std::error::Error; +use std::fmt; + +use crate::{BlobIdBinaryParseError, LayoutIdBinaryParseError}; + +/// One registered field a decoder refuses by name. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VerificationReceiptField { + /// The outcome kind. + Outcome, + /// The depth or stage. + Depth, + /// The subject kind. + SubjectKind, + /// The view kind. + ViewKind, + /// The refusal class. + RefusalClass, + /// The evidence kind. + EvidenceKind, + /// The layout-present flag. + LayoutPresent, + /// The supported-minimum depth. + SupportedMinimum, + /// The supported-maximum depth. + SupportedMaximum, + /// The target-present flag. + TargetPresent, + /// The reserved bytes. + Reserved, + /// The catalog generation. + CatalogGeneration, + /// The liveness generation. + LivenessGeneration, +} + +/// Failure to decode and admit one verification receipt. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VerificationReceiptDecodeError { + /// The input was not exactly one complete receipt. + WrongLength { + /// Required width. + expected: usize, + /// Observed width. + observed: usize, + }, + /// The magic was not canonical. + InvalidMagic { + /// Observed magic bytes. + observed: [u8; 16], + }, + /// The version is not supported. + UnsupportedVersion { + /// Supported version. + expected: u16, + /// Observed version. + observed: u16, + }, + /// The record length is not canonical. + InvalidRecordLength { + /// Expected length. + expected: u16, + /// Observed length. + observed: u16, + }, + /// Nonzero flag bits. + UnsupportedFlags { + /// Observed flags. + observed: u32, + }, + /// The verification contract version is not the one this crate implements. + UnsupportedContract { + /// Supported contract version. + expected: u32, + /// Observed contract version. + observed: u32, + }, + /// The checksum did not match the exact prefix. + ChecksumMismatch { + /// Computed checksum. + expected: [u8; 32], + /// Stored checksum. + observed: [u8; 32], + }, + /// A registered enumeration field holds an unregistered code. + UnregisteredCode { + /// The field. + field: VerificationReceiptField, + /// The observed code. + observed: u16, + }, + /// A field that must be zero is not. + NonZero { + /// The field. + field: VerificationReceiptField, + }, + /// The subject, layout, or target slot does not parse as a `BlobId`. + BlobId { + /// The exact parse refusal. + source: BlobIdBinaryParseError, + }, + /// The subject or layout slot does not parse as a `LayoutId`. + LayoutId { + /// The exact parse refusal. + source: LayoutIdBinaryParseError, + }, + /// The fields contradict one of the receipt's semantic laws. + Semantic { + /// The law violated. + law: &'static str, + }, +} + +impl fmt::Display for VerificationReceiptDecodeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongLength { expected, observed } => write!( + formatter, + "verification receipt is {observed} bytes; expected {expected}" + ), + Self::InvalidMagic { .. } => formatter.write_str("verification receipt magic mismatch"), + Self::UnsupportedVersion { expected, observed } => write!( + formatter, + "verification receipt version {observed}; expected {expected}" + ), + Self::InvalidRecordLength { expected, observed } => write!( + formatter, + "verification receipt record length {observed}; expected {expected}" + ), + Self::UnsupportedFlags { observed } => { + write!( + formatter, + "unsupported verification receipt flags {observed:#010x}" + ) + } + Self::UnsupportedContract { expected, observed } => write!( + formatter, + "verification contract {observed}; expected {expected}" + ), + Self::ChecksumMismatch { .. } => { + formatter.write_str("verification receipt checksum mismatch") + } + Self::UnregisteredCode { field, observed } => { + write!(formatter, "unregistered code {observed} in {field:?}") + } + Self::NonZero { field } => write!(formatter, "{field:?} must be zero"), + Self::BlobId { source } => write!(formatter, "receipt blob identity: {source}"), + Self::LayoutId { source } => write!(formatter, "receipt layout identity: {source}"), + Self::Semantic { law } => write!(formatter, "verification receipt violates: {law}"), + } + } +} + +impl Error for VerificationReceiptDecodeError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::BlobId { source } => Some(source), + Self::LayoutId { source } => Some(source), + _ => None, + } + } +} diff --git a/src/adapters/verification_receipt/decoder.rs b/src/adapters/verification_receipt/decoder.rs new file mode 100644 index 00000000..247c8ee6 --- /dev/null +++ b/src/adapters/verification_receipt/decoder.rs @@ -0,0 +1,205 @@ +//! This boundary module owns verification receipt field decoding: framing, +//! contract, checksum, registered codes, and identity slots, in that order. + +use super::decode_error::{ + VerificationReceiptDecodeError as Error, VerificationReceiptField as Field, +}; +use super::enums::{ + ReceiptEvidenceKind, ReceiptOutcomeKind, ReceiptRefusalClass, ReceiptSubjectKind, + ReceiptViewKind, +}; +use super::format::{self, ENCODED_LENGTH}; +use super::receipt::VERIFICATION_CONTRACT_VERSION; +use crate::{BlobId, LayoutId}; + +/// Every wire field of one receipt, framing-admitted and checksummed but not +/// yet held to the semantic laws. +pub(super) struct Fields { + pub(super) outcome: ReceiptOutcomeKind, + pub(super) depth: u16, + pub(super) subject_kind: ReceiptSubjectKind, + pub(super) view_kind: ReceiptViewKind, + pub(super) refusal_class: ReceiptRefusalClass, + pub(super) evidence_kind: ReceiptEvidenceKind, + pub(super) layout_present: bool, + pub(super) supported_minimum: u16, + pub(super) supported_maximum: u16, + pub(super) target_present: bool, + pub(super) subject_slot: [u8; format::IDENTITY_SLOT], + pub(super) layout_slot: [u8; format::IDENTITY_SLOT], + pub(super) target_slot: [u8; format::IDENTITY_SLOT], + pub(super) catalog_generation: u64, + pub(super) catalog_digest: [u8; 32], + pub(super) liveness_generation: u64, + pub(super) manifest_digest: [u8; 32], + pub(super) evidence_index: u64, + pub(super) chunks_verified: u64, +} + +pub(super) fn decode(encoded: &[u8]) -> Result { + if encoded.len() != ENCODED_LENGTH { + return Err(Error::WrongLength { + expected: ENCODED_LENGTH, + observed: encoded.len(), + }); + } + validate_framing(encoded)?; + verify_checksum(encoded)?; + let fields = Fields { + outcome: registered(encoded, 28, Field::Outcome, ReceiptOutcomeKind::from_code)?, + depth: u16_at(encoded, 30)?, + subject_kind: registered( + encoded, + 32, + Field::SubjectKind, + ReceiptSubjectKind::from_code, + )?, + view_kind: registered(encoded, 34, Field::ViewKind, ReceiptViewKind::from_code)?, + refusal_class: registered( + encoded, + 36, + Field::RefusalClass, + ReceiptRefusalClass::from_code, + )?, + evidence_kind: registered( + encoded, + 38, + Field::EvidenceKind, + ReceiptEvidenceKind::from_code, + )?, + layout_present: flag(encoded, 40, Field::LayoutPresent)?, + supported_minimum: u16_at(encoded, 42)?, + supported_maximum: u16_at(encoded, 44)?, + target_present: flag(encoded, 46, Field::TargetPresent)?, + subject_slot: array(encoded, format::SUBJECT_OFFSET)?, + layout_slot: array(encoded, format::LAYOUT_OFFSET)?, + target_slot: array(encoded, format::TARGET_OFFSET)?, + catalog_generation: u64_at(encoded, format::CATALOG_GENERATION_OFFSET)?, + catalog_digest: array(encoded, format::CATALOG_DIGEST_OFFSET)?, + liveness_generation: u64_at(encoded, format::LIVENESS_GENERATION_OFFSET)?, + manifest_digest: array(encoded, format::MANIFEST_DIGEST_OFFSET)?, + evidence_index: u64_at(encoded, format::EVIDENCE_INDEX_OFFSET)?, + chunks_verified: u64_at(encoded, format::CHUNKS_VERIFIED_OFFSET)?, + }; + let reserved: [u8; format::RESERVED_LENGTH] = array(encoded, format::RESERVED_OFFSET)?; + if reserved != [0_u8; format::RESERVED_LENGTH] { + return Err(Error::NonZero { + field: Field::Reserved, + }); + } + Ok(fields) +} + +fn validate_framing(encoded: &[u8]) -> Result<(), Error> { + let magic: [u8; 16] = array(encoded, 0)?; + if magic != format::MAGIC { + return Err(Error::InvalidMagic { observed: magic }); + } + let version = u16_at(encoded, 16)?; + if version != format::VERSION { + return Err(Error::UnsupportedVersion { + expected: format::VERSION, + observed: version, + }); + } + let record_length = u16_at(encoded, 18)?; + if record_length != format::RECORD_LENGTH { + return Err(Error::InvalidRecordLength { + expected: format::RECORD_LENGTH, + observed: record_length, + }); + } + let flags = u32::from_be_bytes(array(encoded, 20)?); + if flags != 0 { + return Err(Error::UnsupportedFlags { observed: flags }); + } + let contract = u32::from_be_bytes(array(encoded, 24)?); + if contract != VERIFICATION_CONTRACT_VERSION { + return Err(Error::UnsupportedContract { + expected: VERIFICATION_CONTRACT_VERSION, + observed: contract, + }); + } + Ok(()) +} + +fn verify_checksum(encoded: &[u8]) -> Result<(), Error> { + let preimage = encoded + .get(..format::CHECKSUM_OFFSET) + .ok_or(Error::WrongLength { + expected: ENCODED_LENGTH, + observed: encoded.len(), + })?; + let observed: [u8; 32] = array(encoded, format::CHECKSUM_OFFSET)?; + let expected = format::checksum(preimage); + if observed == expected { + Ok(()) + } else { + Err(Error::ChecksumMismatch { expected, observed }) + } +} + +fn registered( + encoded: &[u8], + offset: usize, + field: Field, + from_code: fn(u16) -> Option, +) -> Result { + let observed = u16_at(encoded, offset)?; + from_code(observed).ok_or(Error::UnregisteredCode { field, observed }) +} + +fn flag(encoded: &[u8], offset: usize, field: Field) -> Result { + match u16_at(encoded, offset)? { + 0 => Ok(false), + 1 => Ok(true), + observed => Err(Error::UnregisteredCode { field, observed }), + } +} + +fn array(encoded: &[u8], offset: usize) -> Result<[u8; WIDTH], Error> { + offset + .checked_add(WIDTH) + .and_then(|end| encoded.get(offset..end)) + .and_then(|slice| <[u8; WIDTH]>::try_from(slice).ok()) + .ok_or(Error::WrongLength { + expected: ENCODED_LENGTH, + observed: encoded.len(), + }) +} + +fn u16_at(encoded: &[u8], offset: usize) -> Result { + array(encoded, offset).map(u16::from_be_bytes) +} + +fn u64_at(encoded: &[u8], offset: usize) -> Result { + array(encoded, offset).map(u64::from_be_bytes) +} + +/// Parses a 59-byte `BlobId` binary from a zero-padded 60-byte slot. +pub(super) fn blob_slot(slot: &[u8; format::IDENTITY_SLOT]) -> Result { + let (binary, padding) = slot.split_at(BlobId::BINARY_LENGTH); + if padding != [0_u8] { + return Err(Error::Semantic { + law: "blob identity slot padding must be zero", + }); + } + BlobId::parse_binary(binary).map_err(|source| Error::BlobId { source }) +} + +/// Parses a 60-byte `LayoutId` binary slot. +pub(super) fn layout_slot(slot: &[u8; format::IDENTITY_SLOT]) -> Result { + LayoutId::parse_binary(slot).map_err(|source| Error::LayoutId { source }) +} + +/// Requires an absent identity slot to be all zero. +pub(super) fn zero_slot( + slot: &[u8; format::IDENTITY_SLOT], + law: &'static str, +) -> Result<(), Error> { + if *slot == [0_u8; format::IDENTITY_SLOT] { + Ok(()) + } else { + Err(Error::Semantic { law }) + } +} diff --git a/src/adapters/verification_receipt/decoder_semantics.rs b/src/adapters/verification_receipt/decoder_semantics.rs new file mode 100644 index 00000000..d44ba8b5 --- /dev/null +++ b/src/adapters/verification_receipt/decoder_semantics.rs @@ -0,0 +1,253 @@ +//! This boundary module owns the semantic laws that turn admitted receipt +//! fields into one `VerificationReceipt`, refusing every contradiction. + +use super::decode_error::{ + VerificationReceiptDecodeError as Error, VerificationReceiptField as Field, +}; +use super::decoder::{Fields, blob_slot, layout_slot, zero_slot}; +use super::enums::{ + ReceiptEvidenceKind, ReceiptOutcomeKind, ReceiptRefusalClass, ReceiptSubjectKind, + ReceiptViewKind, depth_from_code, +}; +use super::receipt::{ + ReceiptCorruption, ReceiptMissing, ReceiptRefusal, VerificationOutcome, VerificationReceipt, + VerificationView, +}; +use crate::adapters::GcRetentionState; +use crate::{ + CatalogDigest, CatalogGeneration, LayoutId, LivenessGeneration, RetentionManifestDigest, + VerificationDepth, VerificationSubject, +}; + +const fn law(law: &'static str) -> Error { + Error::Semantic { law } +} + +pub(super) fn admit(fields: &Fields) -> Result { + let subject = match fields.subject_kind { + ReceiptSubjectKind::Blob => VerificationSubject::Blob(blob_slot(&fields.subject_slot)?), + ReceiptSubjectKind::Layout => { + VerificationSubject::Layout(layout_slot(&fields.subject_slot)?) + } + }; + let depth = depth_from_code(fields.depth).ok_or(Error::UnregisteredCode { + field: Field::Depth, + observed: fields.depth, + })?; + let view = view(fields)?; + let layout = if fields.layout_present { + Some(layout_slot(&fields.layout_slot)?) + } else { + zero_slot(&fields.layout_slot, "absent layout slot must be zero")?; + None + }; + let outcome = match fields.outcome { + ReceiptOutcomeKind::Report => report(fields, subject, depth, layout)?, + ReceiptOutcomeKind::Refusal => { + if fields.target_present { + return Err(law("a refusal names no target")); + } + zero_slot(&fields.target_slot, "absent target slot must be zero")?; + if fields.chunks_verified != 0 { + return Err(law("a refusal verified no chunks")); + } + VerificationOutcome::Refused(refusal(fields, subject, depth, layout)?) + } + }; + Ok(VerificationReceipt::from_parts(view, outcome)) +} + +fn view(fields: &Fields) -> Result { + match fields.view_kind { + ReceiptViewKind::Reference => { + if fields.catalog_generation != 0 || fields.liveness_generation != 0 { + return Err(law("a reference view binds no generation")); + } + if fields.catalog_digest != [0_u8; 32] || fields.manifest_digest != [0_u8; 32] { + return Err(law("a reference view binds no digest")); + } + Ok(VerificationView::Reference) + } + ReceiptViewKind::Durable => { + let catalog_generation = + CatalogGeneration::new(fields.catalog_generation).map_err(|_source| { + Error::NonZero { + field: Field::CatalogGeneration, + } + })?; + let retention = if fields.liveness_generation == 0 { + if fields.manifest_digest != [0_u8; 32] { + return Err(law("empty retention binds no manifest digest")); + } + GcRetentionState::Empty + } else { + GcRetentionState::Published { + generation: LivenessGeneration::new(fields.liveness_generation).map_err( + |_source| Error::NonZero { + field: Field::LivenessGeneration, + }, + )?, + manifest_digest: RetentionManifestDigest::from_hash(fields.manifest_digest), + } + }; + Ok(VerificationView::Durable { + catalog_generation, + catalog_digest: CatalogDigest::from_validated(fields.catalog_digest), + retention, + }) + } + } +} + +fn report( + fields: &Fields, + subject: VerificationSubject, + depth: VerificationDepth, + layout: Option, +) -> Result { + if fields.refusal_class != ReceiptRefusalClass::None + || fields.evidence_kind != ReceiptEvidenceKind::None + || fields.evidence_index != 0 + || fields.supported_minimum != 0 + || fields.supported_maximum != 0 + { + return Err(law("a report carries no refusal coordinates")); + } + let layout = layout.ok_or(law("a report names the layout it established"))?; + if !fields.target_present { + return Err(law("a report names the target it established")); + } + let target = blob_slot(&fields.target_slot)?; + if let VerificationSubject::Blob(blob) = subject + && blob != target + { + return Err(law("a blob report's target is its subject")); + } + if let VerificationSubject::Layout(subject_layout) = subject + && subject_layout != layout + { + return Err(law("a layout report's layout is its subject")); + } + Ok(VerificationOutcome::Established { + subject, + depth, + layout, + target, + chunks_verified: fields.chunks_verified, + }) +} + +fn refusal( + fields: &Fields, + subject: VerificationSubject, + stage: VerificationDepth, + layout: Option, +) -> Result { + let bounds_zero = fields.supported_minimum == 0 && fields.supported_maximum == 0; + match fields.refusal_class { + ReceiptRefusalClass::None => Err(law("a refusal carries its classification")), + ReceiptRefusalClass::Missing => { + require(bounds_zero, "only unsupported carries a supported range")?; + let evidence = match (fields.evidence_kind, layout) { + (ReceiptEvidenceKind::First, None) => ReceiptMissing::Blob, + (ReceiptEvidenceKind::Second, None) => ReceiptMissing::Layout, + (ReceiptEvidenceKind::Third, Some(layout)) => ReceiptMissing::Chunk { + layout, + index: fields.evidence_index, + }, + _ => return Err(law("missing evidence kind disagrees with its layout")), + }; + if !matches!(evidence, ReceiptMissing::Chunk { .. }) && fields.evidence_index != 0 { + return Err(law("only a missing chunk carries an index")); + } + Ok(ReceiptRefusal::Missing { + subject, + stage, + evidence, + }) + } + ReceiptRefusalClass::Corrupt => { + require(bounds_zero, "only unsupported carries a supported range")?; + let layout = layout.ok_or(law("a corruption names its layout"))?; + let index = fields.evidence_index; + let evidence = match fields.evidence_kind { + ReceiptEvidenceKind::First => ReceiptCorruption::ChunkIdentity { layout, index }, + ReceiptEvidenceKind::Second => { + ReceiptCorruption::LayoutIdentity { expected: layout } + } + ReceiptEvidenceKind::Third => ReceiptCorruption::BlobIdentity { layout }, + ReceiptEvidenceKind::Fourth => ReceiptCorruption::ProfileBoundary { layout, index }, + ReceiptEvidenceKind::None => return Err(law("a corruption names its kind")), + }; + let indexed = matches!( + evidence, + ReceiptCorruption::ChunkIdentity { .. } | ReceiptCorruption::ProfileBoundary { .. } + ); + if !indexed && index != 0 { + return Err(law("only an indexed corruption carries an index")); + } + Ok(ReceiptRefusal::Corrupt { + subject, + stage, + evidence, + }) + } + ReceiptRefusalClass::Ambiguous => { + require(bounds_zero, "only unsupported carries a supported range")?; + require( + fields.evidence_kind == ReceiptEvidenceKind::None + && fields.evidence_index == 0 + && layout.is_none(), + "an ambiguity names no evidence", + )?; + Ok(ReceiptRefusal::Ambiguous { subject, stage }) + } + ReceiptRefusalClass::Unsupported => unsupported(fields, subject, stage, layout), + } +} + +fn unsupported( + fields: &Fields, + subject: VerificationSubject, + requested: VerificationDepth, + layout: Option, +) -> Result { + require( + fields.evidence_kind == ReceiptEvidenceKind::None + && fields.evidence_index == 0 + && layout.is_none(), + "unsupported names no evidence", + )?; + let supported_minimum = + depth_from_code(fields.supported_minimum).ok_or(Error::UnregisteredCode { + field: Field::SupportedMinimum, + observed: fields.supported_minimum, + })?; + let supported_maximum = + depth_from_code(fields.supported_maximum).ok_or(Error::UnregisteredCode { + field: Field::SupportedMaximum, + observed: fields.supported_maximum, + })?; + require( + supported_minimum <= supported_maximum, + "the supported range is ordered", + )?; + require( + requested < supported_minimum || requested > supported_maximum, + "an unsupported depth lies outside the supported range", + )?; + Ok(ReceiptRefusal::Unsupported { + subject, + requested, + supported_minimum, + supported_maximum, + }) +} + +const fn require(condition: bool, violated: &'static str) -> Result<(), Error> { + if condition { + Ok(()) + } else { + Err(law(violated)) + } +} diff --git a/src/adapters/verification_receipt/encoder.rs b/src/adapters/verification_receipt/encoder.rs new file mode 100644 index 00000000..249bbf2a --- /dev/null +++ b/src/adapters/verification_receipt/encoder.rs @@ -0,0 +1,241 @@ +//! This boundary module owns canonical verification receipt encoding. + +use super::enums::{ + ReceiptEvidenceKind, ReceiptOutcomeKind, ReceiptRefusalClass, ReceiptSubjectKind, + ReceiptViewKind, depth_code, +}; +use super::format::{self, ENCODED_LENGTH}; +use super::receipt::{ + ReceiptCorruption, ReceiptMissing, ReceiptRefusal, VERIFICATION_CONTRACT_VERSION, + VerificationOutcome, VerificationReceipt, VerificationView, +}; +use crate::adapters::GcRetentionState; +use crate::{BlobId, LayoutId, VerificationSubject}; + +/// The scalar fields of one receipt, in wire order. +struct Header { + outcome: ReceiptOutcomeKind, + depth: u16, + subject_kind: ReceiptSubjectKind, + view_kind: ReceiptViewKind, + refusal_class: ReceiptRefusalClass, + evidence_kind: ReceiptEvidenceKind, + layout: Option, + supported_minimum: u16, + supported_maximum: u16, + target: Option, + evidence_index: u64, + chunks_verified: u64, +} + +pub(super) fn encode(receipt: &VerificationReceipt) -> [u8; ENCODED_LENGTH] { + let header = header(receipt); + let mut encoded = [0_u8; ENCODED_LENGTH]; + write(&mut encoded, 0, &format::MAGIC); + write(&mut encoded, 16, &format::VERSION.to_be_bytes()); + write(&mut encoded, 18, &format::RECORD_LENGTH.to_be_bytes()); + write( + &mut encoded, + 24, + &VERIFICATION_CONTRACT_VERSION.to_be_bytes(), + ); + write(&mut encoded, 28, &header.outcome.code().to_be_bytes()); + write(&mut encoded, 30, &header.depth.to_be_bytes()); + write(&mut encoded, 32, &header.subject_kind.code().to_be_bytes()); + write(&mut encoded, 34, &header.view_kind.code().to_be_bytes()); + write(&mut encoded, 36, &header.refusal_class.code().to_be_bytes()); + write(&mut encoded, 38, &header.evidence_kind.code().to_be_bytes()); + write( + &mut encoded, + 40, + &u16::from(header.layout.is_some()).to_be_bytes(), + ); + write(&mut encoded, 42, &header.supported_minimum.to_be_bytes()); + write(&mut encoded, 44, &header.supported_maximum.to_be_bytes()); + write( + &mut encoded, + 46, + &u16::from(header.target.is_some()).to_be_bytes(), + ); + write_subject(&mut encoded, receipt.subject()); + if let Some(layout) = header.layout { + write(&mut encoded, format::LAYOUT_OFFSET, &layout.encode_binary()); + } + if let Some(target) = header.target { + write(&mut encoded, format::TARGET_OFFSET, &target.encode_binary()); + } + write_view(&mut encoded, receipt.view()); + write( + &mut encoded, + format::EVIDENCE_INDEX_OFFSET, + &header.evidence_index.to_be_bytes(), + ); + write( + &mut encoded, + format::CHUNKS_VERIFIED_OFFSET, + &header.chunks_verified.to_be_bytes(), + ); + let checksum = format::checksum(encoded.get(..format::CHECKSUM_OFFSET).unwrap_or_default()); + write(&mut encoded, format::CHECKSUM_OFFSET, &checksum); + encoded +} + +fn write(encoded: &mut [u8; ENCODED_LENGTH], offset: usize, bytes: &[u8]) { + if let Some(slot) = offset + .checked_add(bytes.len()) + .and_then(|end| encoded.get_mut(offset..end)) + { + slot.copy_from_slice(bytes); + } +} + +fn write_subject(encoded: &mut [u8; ENCODED_LENGTH], subject: VerificationSubject) { + match subject { + VerificationSubject::Blob(blob) => { + write(encoded, format::SUBJECT_OFFSET, &blob.encode_binary()); + } + VerificationSubject::Layout(layout) => { + write(encoded, format::SUBJECT_OFFSET, &layout.encode_binary()); + } + } +} + +fn write_view(encoded: &mut [u8; ENCODED_LENGTH], view: VerificationView) { + let VerificationView::Durable { + catalog_generation, + catalog_digest, + retention, + } = view + else { + return; + }; + write( + encoded, + format::CATALOG_GENERATION_OFFSET, + &catalog_generation.get().to_be_bytes(), + ); + write( + encoded, + format::CATALOG_DIGEST_OFFSET, + catalog_digest.as_bytes(), + ); + if let GcRetentionState::Published { + generation, + manifest_digest, + } = retention + { + write( + encoded, + format::LIVENESS_GENERATION_OFFSET, + &generation.get().to_be_bytes(), + ); + write( + encoded, + format::MANIFEST_DIGEST_OFFSET, + manifest_digest.as_bytes(), + ); + } +} + +fn header(receipt: &VerificationReceipt) -> Header { + let subject_kind = match receipt.subject() { + VerificationSubject::Blob(_) => ReceiptSubjectKind::Blob, + VerificationSubject::Layout(_) => ReceiptSubjectKind::Layout, + }; + let view_kind = match receipt.view() { + VerificationView::Reference => ReceiptViewKind::Reference, + VerificationView::Durable { .. } => ReceiptViewKind::Durable, + }; + let mut header = Header { + outcome: ReceiptOutcomeKind::Report, + depth: 0, + subject_kind, + view_kind, + refusal_class: ReceiptRefusalClass::None, + evidence_kind: ReceiptEvidenceKind::None, + layout: None, + supported_minimum: 0, + supported_maximum: 0, + target: None, + evidence_index: 0, + chunks_verified: 0, + }; + match receipt.outcome() { + VerificationOutcome::Established { + depth, + layout, + target, + chunks_verified, + .. + } => { + header.depth = depth_code(depth); + header.layout = Some(layout); + header.target = Some(target); + header.chunks_verified = chunks_verified; + } + VerificationOutcome::Refused(refusal) => { + header.outcome = ReceiptOutcomeKind::Refusal; + refusal_header(&mut header, refusal); + } + } + header +} + +fn refusal_header(header: &mut Header, refusal: ReceiptRefusal) { + match refusal { + ReceiptRefusal::Missing { + stage, evidence, .. + } => { + header.depth = depth_code(stage); + header.refusal_class = ReceiptRefusalClass::Missing; + let (kind, layout, index) = match evidence { + ReceiptMissing::Blob => (ReceiptEvidenceKind::First, None, 0), + ReceiptMissing::Layout => (ReceiptEvidenceKind::Second, None, 0), + ReceiptMissing::Chunk { layout, index } => { + (ReceiptEvidenceKind::Third, Some(layout), index) + } + }; + header.evidence_kind = kind; + header.layout = layout; + header.evidence_index = index; + } + ReceiptRefusal::Corrupt { + stage, evidence, .. + } => { + header.depth = depth_code(stage); + header.refusal_class = ReceiptRefusalClass::Corrupt; + let (kind, layout, index) = match evidence { + ReceiptCorruption::ChunkIdentity { layout, index } => { + (ReceiptEvidenceKind::First, layout, index) + } + ReceiptCorruption::LayoutIdentity { expected } => { + (ReceiptEvidenceKind::Second, expected, 0) + } + ReceiptCorruption::BlobIdentity { layout } => { + (ReceiptEvidenceKind::Third, layout, 0) + } + ReceiptCorruption::ProfileBoundary { layout, index } => { + (ReceiptEvidenceKind::Fourth, layout, index) + } + }; + header.evidence_kind = kind; + header.layout = Some(layout); + header.evidence_index = index; + } + ReceiptRefusal::Ambiguous { stage, .. } => { + header.depth = depth_code(stage); + header.refusal_class = ReceiptRefusalClass::Ambiguous; + } + ReceiptRefusal::Unsupported { + requested, + supported_minimum, + supported_maximum, + .. + } => { + header.depth = depth_code(requested); + header.refusal_class = ReceiptRefusalClass::Unsupported; + header.supported_minimum = depth_code(supported_minimum); + header.supported_maximum = depth_code(supported_maximum); + } + } +} diff --git a/src/adapters/verification_receipt/enums.rs b/src/adapters/verification_receipt/enums.rs new file mode 100644 index 00000000..2b72f255 --- /dev/null +++ b/src/adapters/verification_receipt/enums.rs @@ -0,0 +1,120 @@ +//! This boundary module owns the registered enumerations a verification +//! receipt encodes and the depth codes it shares with the verification +//! vocabulary. + +use crate::VerificationDepth; + +macro_rules! registered { + ($(#[$doc:meta])* $name:ident { $($(#[$variant_doc:meta])* $variant:ident = $code:literal,)* }) => { + $(#[$doc])* + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub enum $name { + $($(#[$variant_doc])* $variant,)* + } + + impl $name { + /// Every registered value in code order. + pub const ALL: &'static [Self] = &[$(Self::$variant,)*]; + + /// The registered wire code. + #[must_use] + pub const fn code(self) -> u16 { + match self { + $(Self::$variant => $code,)* + } + } + + /// The value registered under `code`, if any. + #[must_use] + pub const fn from_code(code: u16) -> Option { + match code { + $($code => Some(Self::$variant),)* + _ => None, + } + } + } + }; +} + +registered! { + /// Whether the receipt records an established depth or a refusal. + ReceiptOutcomeKind { + /// A `VerificationReport`: the depth was established. + Report = 1, + /// A `VerificationRefusal`: the stage could not be established. + Refusal = 2, + } +} + +registered! { + /// What the subject identity slot holds. + ReceiptSubjectKind { + /// A 59-byte canonical `BlobId` binary, zero padded to 60. + Blob = 1, + /// A 60-byte canonical `LayoutId` binary. + Layout = 2, + } +} + +registered! { + /// Which view the verification ran against. + ReceiptViewKind { + /// The non-durable reference store: no catalog or retention. + Reference = 1, + /// One admitted durable snapshot with catalog and retention coordinates. + Durable = 2, + } +} + +registered! { + /// The refusal classification; `None` for a report. + ReceiptRefusalClass { + /// Not a refusal. + None = 0, + /// Required evidence is absent from a complete view. + Missing = 1, + /// Present evidence contradicts the identity it must reproduce. + Corrupt = 2, + /// Admitted evidence conflicts. + Ambiguous = 3, + /// The view cannot establish the requested depth at all. + Unsupported = 4, + } +} + +registered! { + /// Which evidence the refusal names; `None` unless missing or corrupt. + ReceiptEvidenceKind { + /// No evidence coordinate. + None = 0, + /// Missing: no committed layout names the blob. Corrupt: a chunk does + /// not hash to the identity the layout names. + First = 1, + /// Missing: the exact layout is not committed. Corrupt: the layout does + /// not produce the identity it is keyed by. + Second = 2, + /// Missing: the layout names a chunk the view lacks. Corrupt: the + /// authenticated chunks do not reproduce the target blob. + Third = 3, + /// Corrupt only: profile replay diverged at a boundary. + Fourth = 4, + } +} + +/// The registered code of one verification depth: its one-based position +/// in `VerificationDepth::ALL`. +#[must_use] +pub(super) fn depth_code(depth: VerificationDepth) -> u16 { + VerificationDepth::ALL + .iter() + .position(|candidate| *candidate == depth) + .and_then(|index| u16::try_from(index).ok()) + .map_or(0, |index| index.saturating_add(1)) +} + +/// The depth registered under `code`, if any. +#[must_use] +pub(super) fn depth_from_code(code: u16) -> Option { + let index = usize::from(code.checked_sub(1)?); + VerificationDepth::ALL.get(index).copied() +} diff --git a/src/adapters/verification_receipt/format.rs b/src/adapters/verification_receipt/format.rs new file mode 100644 index 00000000..d97ae312 --- /dev/null +++ b/src/adapters/verification_receipt/format.rs @@ -0,0 +1,27 @@ +//! This boundary module owns the fixed layout of one verification receipt. + +pub(super) const ENCODED_LENGTH: usize = 384; +pub(super) const MAGIC: [u8; 16] = *b"KEEP:VERIFY:RCPT"; +pub(super) const VERSION: u16 = 1; +pub(super) const RECORD_LENGTH: u16 = 384; +pub(super) const CHECKSUM_OFFSET: usize = 352; +pub(super) const RESERVED_OFFSET: usize = 324; +pub(super) const RESERVED_LENGTH: usize = 28; +pub(super) const IDENTITY_SLOT: usize = 60; +pub(super) const SUBJECT_OFFSET: usize = 48; +pub(super) const LAYOUT_OFFSET: usize = 108; +pub(super) const TARGET_OFFSET: usize = 168; +pub(super) const CATALOG_GENERATION_OFFSET: usize = 228; +pub(super) const CATALOG_DIGEST_OFFSET: usize = 236; +pub(super) const LIVENESS_GENERATION_OFFSET: usize = 268; +pub(super) const MANIFEST_DIGEST_OFFSET: usize = 276; +pub(super) const EVIDENCE_INDEX_OFFSET: usize = 308; +pub(super) const CHUNKS_VERIFIED_OFFSET: usize = 316; +const CHECKSUM_DOMAIN: &[u8] = b"keep.verification-receipt-checksum/v1\0"; + +pub(super) fn checksum(preimage: &[u8]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(CHECKSUM_DOMAIN); + hasher.update(preimage); + *hasher.finalize().as_bytes() +} diff --git a/src/adapters/verification_receipt/mod.rs b/src/adapters/verification_receipt/mod.rs new file mode 100644 index 00000000..62f9b0b3 --- /dev/null +++ b/src/adapters/verification_receipt/mod.rs @@ -0,0 +1,28 @@ +//! Canonical durable verification receipts for `keep.verification-receipt/v1`. +//! +//! A receipt is the replayable projection of one `VerificationReport` or +//! `VerificationRefusal` onto a fixed 384-byte checksummed record: the +//! subject, the admitted view coordinates, the depth established or the +//! stage refused, the refusal classification, and the exact layout and +//! target where the outcome binds them. It carries no plaintext, key +//! material, or path. Keep does not persist receipts; the application does. + +mod canonical; +mod decode_error; +mod decoder; +mod decoder_semantics; +mod encoder; +mod enums; +mod format; +mod receipt; + +pub use canonical::CanonicalVerificationReceipt; +pub use decode_error::{VerificationReceiptDecodeError, VerificationReceiptField}; +pub use enums::{ + ReceiptEvidenceKind, ReceiptOutcomeKind, ReceiptRefusalClass, ReceiptSubjectKind, + ReceiptViewKind, +}; +pub use receipt::{ + ReceiptCorruption, ReceiptMissing, ReceiptRefusal, VERIFICATION_CONTRACT_VERSION, + VerificationOutcome, VerificationReceipt, VerificationView, +}; diff --git a/src/adapters/verification_receipt/receipt.rs b/src/adapters/verification_receipt/receipt.rs new file mode 100644 index 00000000..1f8424a4 --- /dev/null +++ b/src/adapters/verification_receipt/receipt.rs @@ -0,0 +1,265 @@ +//! This boundary module owns the semantic verification receipt: the +//! bounded projection of one report or refusal onto one view. + +use crate::adapters::GcRetentionState; +use crate::{ + BlobId, CatalogDigest, CatalogGeneration, CorruptionEvidence, LayoutId, MissingEvidence, + VerificationDepth, VerificationRefusal, VerificationReport, VerificationSubject, +}; + +/// The verification contract version every receipt binds. +pub const VERIFICATION_CONTRACT_VERSION: u32 = 1; + +/// The view a verification ran against. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VerificationView { + /// The non-durable reference store. + Reference, + /// One admitted durable snapshot. + Durable { + /// The catalog generation the view bound. + catalog_generation: CatalogGeneration, + /// That catalog's digest. + catalog_digest: CatalogDigest, + /// The retention state the view bound. + retention: GcRetentionState, + }, +} + +/// Which evidence a `Missing` refusal named, without its identities. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReceiptMissing { + /// No committed layout names the blob. + Blob, + /// The exact layout is not committed. + Layout, + /// The named layout names a chunk the view lacks. + Chunk { + /// The layout naming the chunk. + layout: LayoutId, + /// Zero-based entry index. + index: u64, + }, +} + +/// Which contradiction a `Corrupt` refusal named, without the expected and +/// observed identities. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReceiptCorruption { + /// A chunk does not hash to the identity the layout names. + ChunkIdentity { + /// The layout naming the chunk. + layout: LayoutId, + /// Zero-based entry index. + index: u64, + }, + /// The committed layout does not produce the identity it is keyed by. + LayoutIdentity { + /// The identity the view committed the layout under. + expected: LayoutId, + }, + /// The authenticated chunks do not reproduce the target blob identity. + BlobIdentity { + /// The layout reconstructed. + layout: LayoutId, + }, + /// Replaying the registered storage profile diverged from the layout. + ProfileBoundary { + /// The layout reconstructed. + layout: LayoutId, + /// Zero-based boundary index at which replay diverged. + index: u64, + }, +} + +/// The refusal a receipt records. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReceiptRefusal { + /// Required evidence is absent from a complete view. + Missing { + /// Subject being verified. + subject: VerificationSubject, + /// Depth being established when the absence was found. + stage: VerificationDepth, + /// What was absent. + evidence: ReceiptMissing, + }, + /// Present evidence contradicts the identity it must reproduce. + Corrupt { + /// Subject being verified. + subject: VerificationSubject, + /// Depth being established when the contradiction was found. + stage: VerificationDepth, + /// The contradiction. + evidence: ReceiptCorruption, + }, + /// Admitted evidence conflicts. + Ambiguous { + /// Subject being verified. + subject: VerificationSubject, + /// Depth being established when the conflict was found. + stage: VerificationDepth, + }, + /// The view cannot establish the requested depth at all. + Unsupported { + /// Subject requested. + subject: VerificationSubject, + /// Depth requested. + requested: VerificationDepth, + /// Shallowest depth the view establishes. + supported_minimum: VerificationDepth, + /// Deepest depth the view establishes. + supported_maximum: VerificationDepth, + }, +} + +/// What the receipt records. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VerificationOutcome { + /// The depth was established against exactly this layout and target. + Established { + /// Subject verified. + subject: VerificationSubject, + /// Depth established. + depth: VerificationDepth, + /// Layout the depth was established through. + layout: LayoutId, + /// Target that layout names. + target: BlobId, + /// Chunks authenticated. + chunks_verified: u64, + }, + /// The requested depth was refused. + Refused(ReceiptRefusal), +} + +/// One replayable verification receipt. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct VerificationReceipt { + view: VerificationView, + outcome: VerificationOutcome, +} + +impl VerificationReceipt { + /// Projects one report onto `view`. + pub const fn from_report(report: &VerificationReport, view: VerificationView) -> Self { + Self { + view, + outcome: VerificationOutcome::Established { + subject: report.subject(), + depth: report.depth(), + layout: report.layout(), + target: report.target(), + chunks_verified: report.chunks_verified(), + }, + } + } + + /// Projects one refusal onto `view`, keeping its classification, stage, + /// and evidence coordinates and dropping the expected and observed + /// identities. + pub fn from_refusal(refusal: &VerificationRefusal, view: VerificationView) -> Self { + Self { + view, + outcome: VerificationOutcome::Refused(project_refusal(refusal)), + } + } + + /// Reconstructs a receipt from its decoded parts. + pub(super) const fn from_parts(view: VerificationView, outcome: VerificationOutcome) -> Self { + Self { view, outcome } + } + + /// The view the verification ran against. + #[must_use] + pub const fn view(&self) -> VerificationView { + self.view + } + + /// What the receipt records. + #[must_use] + pub const fn outcome(&self) -> VerificationOutcome { + self.outcome + } + + /// The subject the receipt names. + pub const fn subject(&self) -> VerificationSubject { + match self.outcome { + VerificationOutcome::Established { subject, .. } + | VerificationOutcome::Refused( + ReceiptRefusal::Missing { subject, .. } + | ReceiptRefusal::Corrupt { subject, .. } + | ReceiptRefusal::Ambiguous { subject, .. } + | ReceiptRefusal::Unsupported { subject, .. }, + ) => subject, + } + } +} + +fn project_refusal(refusal: &VerificationRefusal) -> ReceiptRefusal { + match *refusal { + VerificationRefusal::Missing { + subject, + stage, + evidence, + } => ReceiptRefusal::Missing { + subject, + stage, + evidence: match evidence { + MissingEvidence::Blob(_) => ReceiptMissing::Blob, + MissingEvidence::Layout(_) => ReceiptMissing::Layout, + MissingEvidence::Chunk { layout, index, .. } => ReceiptMissing::Chunk { + layout, + index: index_u64(index), + }, + }, + }, + VerificationRefusal::Corrupt { + subject, + stage, + evidence, + } => ReceiptRefusal::Corrupt { + subject, + stage, + evidence: match evidence { + CorruptionEvidence::ChunkIdentity { layout, index, .. } => { + ReceiptCorruption::ChunkIdentity { + layout, + index: index_u64(index), + } + } + CorruptionEvidence::LayoutIdentity { expected, .. } => { + ReceiptCorruption::LayoutIdentity { expected } + } + CorruptionEvidence::BlobIdentity { layout, .. } => { + ReceiptCorruption::BlobIdentity { layout } + } + CorruptionEvidence::ProfileBoundary { layout, index } => { + ReceiptCorruption::ProfileBoundary { + layout, + index: index_u64(index), + } + } + }, + }, + VerificationRefusal::Ambiguous { subject, stage } => { + ReceiptRefusal::Ambiguous { subject, stage } + } + VerificationRefusal::Unsupported { + subject, + requested, + supported_minimum, + supported_maximum, + } => ReceiptRefusal::Unsupported { + subject, + requested, + supported_minimum, + supported_maximum, + }, + } +} + +fn index_u64(index: usize) -> u64 { + u64::try_from(index).unwrap_or(u64::MAX) +} diff --git a/src/lib.rs b/src/lib.rs index d6e01727..d0d81600 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -188,6 +188,12 @@ pub use adapters::{ plan_retention_recovery, plan_retention_transition, preflight_retention_transition, prepare_retention_publication, resume_recovery_disposition, verify_retention_closure, }; +pub use adapters::{ + CanonicalVerificationReceipt, ReceiptCorruption, ReceiptEvidenceKind, ReceiptMissing, + ReceiptOutcomeKind, ReceiptRefusal, ReceiptRefusalClass, ReceiptSubjectKind, ReceiptViewKind, + VERIFICATION_CONTRACT_VERSION, VerificationOutcome, VerificationReceipt, + VerificationReceiptDecodeError, VerificationReceiptField, VerificationView, +}; pub use adapters::{ MIGRATION_NAMESPACE_PREFIX, StoreMigrationEffect, StoreMigrationFixedStage, StoreMigrationRecoveryAmbiguity, StoreMigrationRecoveryError, StoreMigrationRecoveryPlan, diff --git a/tests/verification_receipt.rs b/tests/verification_receipt.rs new file mode 100644 index 00000000..d6ea1eec --- /dev/null +++ b/tests/verification_receipt.rs @@ -0,0 +1,255 @@ +//! Canonical verification receipt laws: the golden fixtures match a +//! handwritten oracle and the production encoder, every real report and +//! refusal projects and round-trips, every structural field has one exact +//! first refusal, and a refusal never decodes as a report. + +pub mod support; + +#[path = "verification_receipt/matrix.rs"] +mod matrix; +#[path = "verification_receipt/oracle.rs"] +mod oracle; + +use std::error::Error; +use std::io::Cursor; + +use keep::{ + AdmittedRetentionManifest, BlobId, CanonicalVerificationReceipt, ChecksummedCatalog, + GcRetentionState, LayoutEntryLimit, LayoutId, ReceiptCorruption, ReceiptRefusal, + ReferenceStore, ReferenceStoreCapacity, VERIFICATION_CONTRACT_VERSION, VerificationDepth, + VerificationError, VerificationOutcome, VerificationReceipt, + VerificationReceiptDecodeError as DecodeError, VerificationRefusal, VerificationSubject, + VerificationView, +}; +use matrix::{CORRUPT, MATRIX, REPORT}; +use oracle::{BLOB_ID, LAYOUT_ID, golden_receipts}; +use support::{decode_hex, domain_hash, patch}; + +const FIXTURES: [(&str, &str); 3] = [ + ( + "reference-complete-blob-report.hex", + include_str!("../conformance/verification-receipt/v1/reference-complete-blob-report.hex"), + ), + ( + "durable-corrupt-chunk-refusal.hex", + include_str!("../conformance/verification-receipt/v1/durable-corrupt-chunk-refusal.hex"), + ), + ( + "reference-unsupported-framing-refusal.hex", + include_str!( + "../conformance/verification-receipt/v1/reference-unsupported-framing-refusal.hex" + ), + ), +]; +const V1_CATALOG_GENERATION_TWO: &str = + include_str!("../conformance/segment-store/v1/one-zero-catalog-generation-two.hex"); +const V2_MANIFEST: &str = include_str!("../conformance/segment-store/v2/one-root-manifest.hex"); +const CHECKSUM_DOMAIN: &[u8] = b"keep.verification-receipt-checksum/v1\0"; +const CHECKSUM_OFFSET: usize = 352; +const SOURCE: &[u8] = b"a receipt names exactly what one verification established"; + +fn fixture_bytes(name: &str) -> Result, Box> { + let (_, hex) = FIXTURES + .iter() + .find(|(fixture, _)| *fixture == name) + .ok_or("unknown receipt fixture")?; + Ok(decode_hex(hex.trim_end())?) +} + +/// The frozen durable coordinates through their public decoders: the +/// generation-two catalog and the generation-one manifest. +fn durable_view() -> Result> { + let catalog_bytes = decode_hex(V1_CATALOG_GENERATION_TWO.trim_end())?; + let catalog = ChecksummedCatalog::decode(&catalog_bytes)?; + let manifest_bytes = decode_hex(V2_MANIFEST.trim_end())?; + let manifest = AdmittedRetentionManifest::decode(&manifest_bytes)?; + let coordinates = oracle::durable_coordinates()?; + assert_eq!(catalog.digest().as_bytes(), &coordinates.catalog_digest); + assert_eq!(manifest.digest().as_bytes(), &coordinates.manifest_digest); + Ok(VerificationView::Durable { + catalog_generation: catalog.generation(), + catalog_digest: catalog.digest(), + retention: GcRetentionState::Published { + generation: manifest.manifest().generation(), + manifest_digest: manifest.digest(), + }, + }) +} + +fn reseal(bytes: &mut [u8]) -> Result<(), Box> { + let checksum = domain_hash( + CHECKSUM_DOMAIN, + bytes.get(..CHECKSUM_OFFSET).ok_or("preimage")?, + ); + patch(bytes, CHECKSUM_OFFSET, &checksum)?; + Ok(()) +} + +#[test] +fn golden_receipts_match_the_oracle_and_decode_from_another_process() -> Result<(), Box> +{ + for golden in golden_receipts()? { + let frozen = fixture_bytes(golden.fixture)?; + assert_eq!( + frozen, golden.bytes, + "{}: fixture drifted from the oracle", + golden.case + ); + // The fixture was written by another process; admission must be exact. + let admitted = CanonicalVerificationReceipt::decode(&frozen)?; + assert_eq!( + admitted.encoded().as_slice(), + frozen.as_slice(), + "{}", + golden.case + ); + let reencoded = CanonicalVerificationReceipt::encode(admitted.receipt()); + assert_eq!( + reencoded, admitted, + "{}: re-encoding is not canonical", + golden.case + ); + } + Ok(()) +} + +#[test] +fn the_golden_report_and_refusals_state_exactly_their_frozen_coordinates() +-> Result<(), Box> { + let blob = BlobId::parse_binary(&BLOB_ID)?; + let layout = LayoutId::parse_binary(&LAYOUT_ID)?; + + let report = CanonicalVerificationReceipt::decode(&fixture_bytes( + "reference-complete-blob-report.hex", + )?)?; + assert_eq!(report.receipt().view(), VerificationView::Reference); + assert_eq!( + report.receipt().outcome(), + VerificationOutcome::Established { + subject: VerificationSubject::Blob(blob), + depth: VerificationDepth::CompleteBlobIdentity, + layout, + target: blob, + chunks_verified: 1, + } + ); + + let corrupt = + CanonicalVerificationReceipt::decode(&fixture_bytes("durable-corrupt-chunk-refusal.hex")?)?; + assert_eq!(corrupt.receipt().view(), durable_view()?); + assert_eq!( + corrupt.receipt().outcome(), + VerificationOutcome::Refused(ReceiptRefusal::Corrupt { + subject: VerificationSubject::Layout(layout), + stage: VerificationDepth::ChunkIdentity, + evidence: ReceiptCorruption::ChunkIdentity { layout, index: 0 }, + }) + ); + + let unsupported = CanonicalVerificationReceipt::decode(&fixture_bytes( + "reference-unsupported-framing-refusal.hex", + )?)?; + assert_eq!( + unsupported.receipt().outcome(), + VerificationOutcome::Refused(ReceiptRefusal::Unsupported { + subject: VerificationSubject::Blob(blob), + requested: VerificationDepth::Framing, + supported_minimum: VerificationDepth::ChunkIdentity, + supported_maximum: VerificationDepth::CompleteBlobIdentity, + }) + ); + assert_eq!(VERIFICATION_CONTRACT_VERSION, 1); + Ok(()) +} + +#[test] +fn every_reference_store_outcome_projects_and_round_trips() -> Result<(), Box> { + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(1_048_576)); + let mut source = Cursor::new(SOURCE); + let published = store + .stage(&mut source, LayoutEntryLimit::MAXIMUM)? + .commit(&mut store)?; + let subject = VerificationSubject::Blob(published.target()); + let mut receipts = Vec::new(); + for depth in VerificationDepth::ALL { + let receipt = match store.verify(subject, depth) { + Ok(report) => VerificationReceipt::from_report(&report, VerificationView::Reference), + Err(VerificationError::Refused(refusal)) => { + VerificationReceipt::from_refusal(refusal.as_ref(), VerificationView::Reference) + } + Err(other) => return Err(other.into()), + }; + receipts.push(receipt); + } + let mut staged = Cursor::new(b"staged but never committed"); + let absent = store + .stage(&mut staged, LayoutEntryLimit::MAXIMUM)? + .target(); + match store.verify( + VerificationSubject::Blob(absent), + VerificationDepth::ChunkIdentity, + ) { + Err(VerificationError::Refused(refusal)) + if matches!(*refusal, VerificationRefusal::Missing { .. }) => + { + receipts.push(VerificationReceipt::from_refusal( + refusal.as_ref(), + durable_view()?, + )); + } + other => return Err(format!("absent blob was not missing: {other:?}").into()), + } + for receipt in receipts { + let canonical = CanonicalVerificationReceipt::encode(&receipt); + let decoded = CanonicalVerificationReceipt::decode(canonical.encoded())?; + assert_eq!(decoded.receipt(), &receipt); + assert_eq!(decoded.encoded(), canonical.encoded()); + } + Ok(()) +} + +#[test] +fn every_structural_field_has_one_exact_first_refusal() -> Result<(), Box> { + for mutation in MATRIX { + let mut bytes = fixture_bytes(mutation.fixture)?; + if mutation.offset == usize::MAX { + bytes.truncate(383); + } else { + patch(&mut bytes, mutation.offset, mutation.value)?; + } + if mutation.reseal { + reseal(&mut bytes)?; + } + let error = CanonicalVerificationReceipt::decode(&bytes) + .err() + .ok_or_else(|| format!("mutated {} was admitted", mutation.field))?; + assert!( + (mutation.refuses)(&error), + "{}: unexpected first refusal {error:?}", + mutation.field + ); + } + Ok(()) +} + +#[test] +fn a_refusal_never_decodes_as_a_report_and_a_report_never_as_a_refusal() +-> Result<(), Box> { + // Flipping only the outcome kind leaves every other field contradicting + // it, so neither direction is admitted. + let mut refusal = fixture_bytes(CORRUPT)?; + patch(&mut refusal, 28, &[0, 1])?; + reseal(&mut refusal)?; + assert!(matches!( + CanonicalVerificationReceipt::decode(&refusal), + Err(DecodeError::Semantic { .. }) + )); + let mut report = fixture_bytes(REPORT)?; + patch(&mut report, 28, &[0, 2])?; + reseal(&mut report)?; + assert!(matches!( + CanonicalVerificationReceipt::decode(&report), + Err(DecodeError::Semantic { .. }) + )); + Ok(()) +} diff --git a/tests/verification_receipt/matrix.rs b/tests/verification_receipt/matrix.rs new file mode 100644 index 00000000..d92cacd7 --- /dev/null +++ b/tests/verification_receipt/matrix.rs @@ -0,0 +1,411 @@ +//! One structural mutation per verification receipt field and the exact +//! first refusal each must reach. + +use keep::{VerificationReceiptDecodeError as DecodeError, VerificationReceiptField as Field}; + +/// One structural mutation and the exact first refusal it must reach. +pub(crate) struct Mutation { + pub(crate) field: &'static str, + pub(crate) fixture: &'static str, + pub(crate) offset: usize, + pub(crate) value: &'static [u8], + pub(crate) reseal: bool, + pub(crate) refuses: fn(&DecodeError) -> bool, +} + +pub(crate) const REPORT: &str = "reference-complete-blob-report.hex"; +pub(crate) const CORRUPT: &str = "durable-corrupt-chunk-refusal.hex"; +pub(crate) const UNSUPPORTED: &str = "reference-unsupported-framing-refusal.hex"; + +pub(crate) const MATRIX: &[Mutation] = &[ + Mutation { + field: "magic", + fixture: REPORT, + offset: 0, + value: &[0x4a], + reseal: false, + refuses: |e| matches!(e, DecodeError::InvalidMagic { .. }), + }, + Mutation { + field: "version", + fixture: REPORT, + offset: 16, + value: &[0, 2], + reseal: false, + refuses: |e| matches!(e, DecodeError::UnsupportedVersion { observed: 2, .. }), + }, + Mutation { + field: "record length", + fixture: REPORT, + offset: 18, + value: &[1, 0], + reseal: false, + refuses: |e| matches!(e, DecodeError::InvalidRecordLength { observed: 256, .. }), + }, + Mutation { + field: "flags", + fixture: REPORT, + offset: 20, + value: &[0, 0, 0, 1], + reseal: false, + refuses: |e| matches!(e, DecodeError::UnsupportedFlags { observed: 1 }), + }, + Mutation { + field: "contract", + fixture: REPORT, + offset: 24, + value: &[0, 0, 0, 2], + reseal: false, + refuses: |e| matches!(e, DecodeError::UnsupportedContract { observed: 2, .. }), + }, + Mutation { + field: "checksum", + fixture: REPORT, + offset: 352, + value: &[0xff], + reseal: false, + refuses: |e| matches!(e, DecodeError::ChecksumMismatch { .. }), + }, + Mutation { + field: "covered byte", + fixture: REPORT, + offset: 316, + value: &[9], + reseal: false, + refuses: |e| matches!(e, DecodeError::ChecksumMismatch { .. }), + }, + Mutation { + field: "outcome", + fixture: REPORT, + offset: 28, + value: &[0, 3], + reseal: true, + refuses: |e| { + matches!( + e, + DecodeError::UnregisteredCode { + field: Field::Outcome, + observed: 3 + } + ) + }, + }, + Mutation { + field: "depth", + fixture: REPORT, + offset: 30, + value: &[0, 8], + reseal: true, + refuses: |e| { + matches!( + e, + DecodeError::UnregisteredCode { + field: Field::Depth, + observed: 8 + } + ) + }, + }, + Mutation { + field: "subject kind", + fixture: REPORT, + offset: 32, + value: &[0, 3], + reseal: true, + refuses: |e| { + matches!( + e, + DecodeError::UnregisteredCode { + field: Field::SubjectKind, + .. + } + ) + }, + }, + Mutation { + field: "view kind", + fixture: REPORT, + offset: 34, + value: &[0, 0], + reseal: true, + refuses: |e| { + matches!( + e, + DecodeError::UnregisteredCode { + field: Field::ViewKind, + .. + } + ) + }, + }, + Mutation { + field: "refusal class", + fixture: REPORT, + offset: 36, + value: &[0, 5], + reseal: true, + refuses: |e| { + matches!( + e, + DecodeError::UnregisteredCode { + field: Field::RefusalClass, + .. + } + ) + }, + }, + Mutation { + field: "evidence kind", + fixture: REPORT, + offset: 38, + value: &[0, 5], + reseal: true, + refuses: |e| { + matches!( + e, + DecodeError::UnregisteredCode { + field: Field::EvidenceKind, + .. + } + ) + }, + }, + Mutation { + field: "layout present", + fixture: REPORT, + offset: 40, + value: &[0, 2], + reseal: true, + refuses: |e| { + matches!( + e, + DecodeError::UnregisteredCode { + field: Field::LayoutPresent, + .. + } + ) + }, + }, + Mutation { + field: "target present", + fixture: REPORT, + offset: 46, + value: &[0, 2], + reseal: true, + refuses: |e| { + matches!( + e, + DecodeError::UnregisteredCode { + field: Field::TargetPresent, + .. + } + ) + }, + }, + Mutation { + field: "reserved", + fixture: REPORT, + offset: 340, + value: &[1], + reseal: true, + refuses: |e| { + matches!( + e, + DecodeError::NonZero { + field: Field::Reserved + } + ) + }, + }, + Mutation { + field: "subject slot magic", + fixture: REPORT, + offset: 48, + value: &[0x4a], + reseal: true, + refuses: |e| matches!(e, DecodeError::BlobId { .. }), + }, + Mutation { + field: "subject slot padding", + fixture: REPORT, + offset: 107, + value: &[1], + reseal: true, + refuses: |e| matches!(e, DecodeError::Semantic { .. }), + }, + Mutation { + field: "layout slot magic", + fixture: REPORT, + offset: 108, + value: &[0x4a], + reseal: true, + refuses: |e| matches!(e, DecodeError::LayoutId { .. }), + }, + Mutation { + field: "target disagrees with subject", + fixture: REPORT, + offset: 227, + value: &[1], + reseal: true, + refuses: |e| matches!(e, DecodeError::BlobId { .. } | DecodeError::Semantic { .. }), + }, + Mutation { + field: "report with refusal class", + fixture: REPORT, + offset: 36, + value: &[0, 1], + reseal: true, + refuses: |e| matches!(e, DecodeError::Semantic { .. }), + }, + Mutation { + field: "report without layout", + fixture: REPORT, + offset: 40, + value: &[0, 0], + reseal: true, + refuses: |e| matches!(e, DecodeError::Semantic { .. }), + }, + Mutation { + field: "reference view with generation", + fixture: REPORT, + offset: 235, + value: &[1], + reseal: true, + refuses: |e| matches!(e, DecodeError::Semantic { .. }), + }, + Mutation { + field: "durable view zero generation", + fixture: CORRUPT, + offset: 228, + value: &[0, 0, 0, 0, 0, 0, 0, 0], + reseal: true, + refuses: |e| { + matches!( + e, + DecodeError::NonZero { + field: Field::CatalogGeneration + } + ) + }, + }, + Mutation { + field: "empty retention with manifest", + fixture: CORRUPT, + offset: 268, + value: &[0, 0, 0, 0, 0, 0, 0, 0], + reseal: true, + refuses: |e| matches!(e, DecodeError::Semantic { .. }), + }, + Mutation { + field: "refusal verified chunks", + fixture: CORRUPT, + offset: 323, + value: &[1], + reseal: true, + refuses: |e| matches!(e, DecodeError::Semantic { .. }), + }, + Mutation { + field: "refusal with target", + fixture: CORRUPT, + offset: 46, + value: &[0, 1], + reseal: true, + refuses: |e| matches!(e, DecodeError::Semantic { .. }), + }, + Mutation { + field: "corruption without layout", + fixture: CORRUPT, + offset: 40, + value: &[0, 0], + reseal: true, + refuses: |e| matches!(e, DecodeError::Semantic { .. }), + }, + Mutation { + field: "corruption without kind", + fixture: CORRUPT, + offset: 38, + value: &[0, 0], + reseal: true, + refuses: |e| matches!(e, DecodeError::Semantic { .. }), + }, + Mutation { + field: "unindexed corruption with index", + fixture: CORRUPT, + offset: 38, + value: &[0, 2], + reseal: false, + refuses: |e| matches!(e, DecodeError::ChecksumMismatch { .. }), + }, + Mutation { + field: "supported range on a corruption", + fixture: CORRUPT, + offset: 42, + value: &[0, 1], + reseal: true, + refuses: |e| matches!(e, DecodeError::Semantic { .. }), + }, + Mutation { + field: "unsupported minimum unregistered", + fixture: UNSUPPORTED, + offset: 42, + value: &[0, 9], + reseal: true, + refuses: |e| { + matches!( + e, + DecodeError::UnregisteredCode { + field: Field::SupportedMinimum, + .. + } + ) + }, + }, + Mutation { + field: "unsupported maximum unregistered", + fixture: UNSUPPORTED, + offset: 44, + value: &[0, 0], + reseal: true, + refuses: |e| { + matches!( + e, + DecodeError::UnregisteredCode { + field: Field::SupportedMaximum, + .. + } + ) + }, + }, + Mutation { + field: "unsupported range unordered", + fixture: UNSUPPORTED, + offset: 42, + value: &[0, 6], + reseal: true, + refuses: |e| matches!(e, DecodeError::Semantic { .. }), + }, + Mutation { + field: "unsupported depth inside range", + fixture: UNSUPPORTED, + offset: 30, + value: &[0, 4], + reseal: true, + refuses: |e| matches!(e, DecodeError::Semantic { .. }), + }, + Mutation { + field: "unsupported with layout", + fixture: UNSUPPORTED, + offset: 40, + value: &[0, 1], + reseal: true, + refuses: |e| matches!(e, DecodeError::LayoutId { .. }), + }, + Mutation { + field: "truncated", + fixture: REPORT, + offset: usize::MAX, + value: &[], + reseal: false, + refuses: |e| matches!(e, DecodeError::WrongLength { observed: 383, .. }), + }, +]; diff --git a/tests/verification_receipt/oracle.rs b/tests/verification_receipt/oracle.rs new file mode 100644 index 00000000..571ce980 --- /dev/null +++ b/tests/verification_receipt/oracle.rs @@ -0,0 +1,219 @@ +//! Handwritten construction of every golden verification receipt from the +//! accepted layout and segment-store corpora, independent of the production +//! encoder. + +use std::io; + +use crate::support::{decode_hex, domain_hash, invalid_corpus}; + +const V1_CATALOG_GENERATION_TWO: &str = + include_str!("../../conformance/segment-store/v1/one-zero-catalog-generation-two.hex"); +const V2_ARTIFACTS: &str = include_str!("../../conformance/segment-store/v2/artifacts.tsv"); +const CHECKSUM_DOMAIN: &[u8] = b"keep.verification-receipt-checksum/v1\0"; + +/// The canonical one-zero `BlobId` binary from the accepted layout corpus. +pub(crate) const BLOB_ID: [u8; 59] = [ + 0x4b, 0x45, 0x45, 0x50, 0x3a, 0x42, 0x4c, 0x4f, 0x42, 0x3a, 0x49, 0x44, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x1c, 0xfb, 0x8f, 0xa9, 0xe9, + 0x17, 0xab, 0xa1, 0x5a, 0x1f, 0x59, 0x20, 0x95, 0xf3, 0x77, 0xff, 0x18, 0x07, 0x55, 0xfe, 0x12, + 0x12, 0xb0, 0xd7, 0xd2, 0xec, 0x75, 0x0b, 0xd1, 0x28, 0xb6, 0x06, +]; +/// The canonical one-zero `LayoutId` binary from the accepted layout corpus. +pub(crate) const LAYOUT_ID: [u8; 60] = [ + 0x4b, 0x45, 0x45, 0x50, 0x3a, 0x4c, 0x41, 0x59, 0x4f, 0x55, 0x54, 0x3a, 0x49, 0x44, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdc, 0x88, 0x7d, 0xa2, 0x3f, + 0x1a, 0x74, 0x83, 0x35, 0x9a, 0x78, 0xfc, 0x9a, 0x7f, 0xde, 0x80, 0x03, 0x0e, 0xc2, 0xc4, 0x69, + 0x06, 0x03, 0x80, 0x3f, 0x0a, 0xb7, 0xd0, 0xed, 0xb5, 0x65, 0x75, 0xb8, +]; + +/// One golden receipt with its fixture name. +pub(crate) struct GoldenReceipt { + pub(crate) case: &'static str, + pub(crate) fixture: &'static str, + pub(crate) bytes: Vec, +} + +/// The scalar fields of one receipt in wire order, before its slots. +struct Scalars { + outcome: u16, + depth: u16, + subject_kind: u16, + view_kind: u16, + refusal_class: u16, + evidence_kind: u16, + layout_present: u16, + supported_minimum: u16, + supported_maximum: u16, + target_present: u16, +} + +/// The durable coordinates the frozen version-2 store publishes: catalog +/// generation two and the generation-one manifest. +pub(crate) struct DurableCoordinates { + pub(crate) catalog_generation: u64, + pub(crate) catalog_digest: [u8; 32], + pub(crate) liveness_generation: u64, + pub(crate) manifest_digest: [u8; 32], +} + +pub(crate) fn durable_coordinates() -> Result { + let catalog = decode_hex(V1_CATALOG_GENERATION_TWO.trim_end())?; + let catalog_digest: [u8; 32] = catalog + .get(320..352) + .and_then(|slice| slice.try_into().ok()) + .ok_or_else(|| invalid_corpus("generation-two catalog lacks its digest"))?; + let manifest_row = V2_ARTIFACTS + .lines() + .find(|line| line.starts_with("one-root-manifest\t")) + .ok_or_else(|| invalid_corpus("version-2 artifacts lack the manifest row"))?; + let manifest_hex = manifest_row + .split('\t') + .nth(5) + .ok_or_else(|| invalid_corpus("manifest row lacks its bound digest"))?; + let manifest_digest: [u8; 32] = decode_hex(manifest_hex)? + .try_into() + .map_err(|_source| invalid_corpus("manifest digest is not 32 bytes"))?; + Ok(DurableCoordinates { + catalog_generation: 2, + catalog_digest, + liveness_generation: 1, + manifest_digest, + }) +} + +fn assemble( + scalars: &Scalars, + subject: &[u8], + layout: &[u8], + target: &[u8], + view: Option<&DurableCoordinates>, + evidence_index: u64, + chunks_verified: u64, +) -> Result, io::Error> { + let mut bytes = Vec::with_capacity(384); + bytes.extend_from_slice(b"KEEP:VERIFY:RCPT"); + bytes.extend_from_slice(&1_u16.to_be_bytes()); + bytes.extend_from_slice(&384_u16.to_be_bytes()); + bytes.extend_from_slice(&0_u32.to_be_bytes()); + bytes.extend_from_slice(&1_u32.to_be_bytes()); + for value in [ + scalars.outcome, + scalars.depth, + scalars.subject_kind, + scalars.view_kind, + scalars.refusal_class, + scalars.evidence_kind, + scalars.layout_present, + scalars.supported_minimum, + scalars.supported_maximum, + scalars.target_present, + ] { + bytes.extend_from_slice(&value.to_be_bytes()); + } + for slot in [subject, layout, target] { + bytes.extend_from_slice(slot); + bytes.resize( + bytes + .len() + .saturating_add(60_usize.saturating_sub(slot.len())), + 0, + ); + } + match view { + None => bytes.resize(bytes.len().saturating_add(80), 0), + Some(view) => { + bytes.extend_from_slice(&view.catalog_generation.to_be_bytes()); + bytes.extend_from_slice(&view.catalog_digest); + bytes.extend_from_slice(&view.liveness_generation.to_be_bytes()); + bytes.extend_from_slice(&view.manifest_digest); + } + } + bytes.extend_from_slice(&evidence_index.to_be_bytes()); + bytes.extend_from_slice(&chunks_verified.to_be_bytes()); + bytes.resize(352, 0); + if bytes.len() != 352 { + return Err(invalid_corpus("receipt preimage is not 352 bytes")); + } + let checksum = domain_hash(CHECKSUM_DOMAIN, &bytes); + bytes.extend_from_slice(&checksum); + Ok(bytes) +} + +/// Every golden receipt, in `artifacts.tsv` order. +pub(crate) fn golden_receipts() -> Result, io::Error> { + let durable = durable_coordinates()?; + Ok(vec![ + GoldenReceipt { + case: "reference-complete-blob-report", + fixture: "reference-complete-blob-report.hex", + bytes: assemble( + &Scalars { + outcome: 1, + depth: 5, + subject_kind: 1, + view_kind: 1, + refusal_class: 0, + evidence_kind: 0, + layout_present: 1, + supported_minimum: 0, + supported_maximum: 0, + target_present: 1, + }, + &BLOB_ID, + &LAYOUT_ID, + &BLOB_ID, + None, + 0, + 1, + )?, + }, + GoldenReceipt { + case: "durable-corrupt-chunk-refusal", + fixture: "durable-corrupt-chunk-refusal.hex", + bytes: assemble( + &Scalars { + outcome: 2, + depth: 3, + subject_kind: 2, + view_kind: 2, + refusal_class: 2, + evidence_kind: 1, + layout_present: 1, + supported_minimum: 0, + supported_maximum: 0, + target_present: 0, + }, + &LAYOUT_ID, + &LAYOUT_ID, + &[], + Some(&durable), + 0, + 0, + )?, + }, + GoldenReceipt { + case: "reference-unsupported-framing-refusal", + fixture: "reference-unsupported-framing-refusal.hex", + bytes: assemble( + &Scalars { + outcome: 2, + depth: 1, + subject_kind: 1, + view_kind: 1, + refusal_class: 4, + evidence_kind: 0, + layout_present: 0, + supported_minimum: 3, + supported_maximum: 5, + target_present: 0, + }, + &BLOB_ID, + &[], + &[], + None, + 0, + 0, + )?, + }, + ]) +} diff --git a/xtask/src/fuzz_seed_corpus.rs b/xtask/src/fuzz_seed_corpus.rs index 7fca8dfe..adedb6fb 100644 --- a/xtask/src/fuzz_seed_corpus.rs +++ b/xtask/src/fuzz_seed_corpus.rs @@ -10,6 +10,7 @@ mod migration_seeds; mod retention_seeds; mod segment_seeds; mod segment_store_v2_fixture; +mod verification_seeds; use std::error::Error; use std::fmt; @@ -77,6 +78,7 @@ pub(super) fn prepare(repository_root: &Path) -> Result<(), FuzzSeedError> { seeds.extend(migration_seeds::seeds(&files)?); seeds.extend(retention_seeds::seeds(&files)?); seeds.extend(segment_seeds::seeds(&files)?); + seeds.extend(verification_seeds::seeds(&files)?); files.write_seeds(&seeds) } diff --git a/xtask/src/fuzz_seed_corpus/tests/materialization.rs b/xtask/src/fuzz_seed_corpus/tests/materialization.rs index ea728cd7..181fa092 100644 --- a/xtask/src/fuzz_seed_corpus/tests/materialization.rs +++ b/xtask/src/fuzz_seed_corpus/tests/materialization.rs @@ -5,7 +5,7 @@ use std::path::Path; use super::super::{ FuzzSeedError, catalog_seeds, gc_seeds, layout_seeds, migration_seeds, prepare, - retention_seeds, segment_seeds, + retention_seeds, segment_seeds, verification_seeds, }; use crate::test_directory::TestDirectory; @@ -43,11 +43,12 @@ fn seed_preparation_materializes_the_complete_deterministic_set() copy_segment_fixtures(source_root, root)?; copy_catalog_fixtures(source_root, root)?; copy_version_two_fixtures(source_root, root)?; + copy_verification_fixtures(source_root, root)?; prepare(root)?; let corpus = root.join("fuzz/corpus"); let first = seed_contents(&corpus)?; - assert_eq!(first.len(), 49); + assert_eq!(first.len(), 52); assert_eq!(target_seed_count(&first, "catalog_format/"), 6); assert_eq!(target_seed_count(&first, "gc_format/"), 3); assert_eq!(target_seed_count(&first, "golden_protocol/"), 9); @@ -55,6 +56,7 @@ fn seed_preparation_materializes_the_complete_deterministic_set() assert_eq!(target_seed_count(&first, "migration_format/"), 3); assert_eq!(target_seed_count(&first, "retention_format/"), 3); assert_eq!(target_seed_count(&first, "segment_format/"), 8); + assert_eq!(target_seed_count(&first, "verification_receipt/"), 3); prepare(root)?; assert_eq!(seed_contents(&corpus)?, first); @@ -119,6 +121,25 @@ fn copy_catalog_fixtures(source_root: &Path, root: &Path) -> Result<(), FuzzSeed Ok(()) } +fn copy_verification_fixtures(source_root: &Path, root: &Path) -> Result<(), FuzzSeedError> { + use std::fs; + + let directory = root.join("conformance/verification-receipt/v1"); + fs::create_dir_all(&directory).map_err(|source| { + FuzzSeedError::io("create test verification-receipt root", &directory, source) + })?; + for fixture in verification_seeds::FIXTURES { + let source_path = source_root + .join("conformance/verification-receipt/v1") + .join(fixture); + let destination = directory.join(fixture); + fs::copy(&source_path, &destination).map_err(|source| { + FuzzSeedError::io("copy test verification receipt", &destination, source) + })?; + } + Ok(()) +} + fn copy_version_two_fixtures(source_root: &Path, root: &Path) -> Result<(), FuzzSeedError> { use std::fs; diff --git a/xtask/src/fuzz_seed_corpus/verification_seeds.rs b/xtask/src/fuzz_seed_corpus/verification_seeds.rs new file mode 100644 index 00000000..324cbd6e --- /dev/null +++ b/xtask/src/fuzz_seed_corpus/verification_seeds.rs @@ -0,0 +1,46 @@ +//! This module owns canonical verification receipt fuzz seeds. + +use std::path::Path; + +use super::filesystem::RepositoryFiles; +use super::{FuzzSeedError, MAX_SEED_BYTES, Seed}; +use xtask::protocol_admission::{EmptyHex, decode_lower_hex, framed_lines}; + +const RECEIPT_ROOT: &str = "conformance/verification-receipt/v1"; + +/// Every frozen receipt fixture, each one raw seed for `verification_receipt`. +pub(super) const FIXTURES: [&str; 3] = [ + "reference-complete-blob-report.hex", + "durable-corrupt-chunk-refusal.hex", + "reference-unsupported-framing-refusal.hex", +]; + +pub(super) fn seeds(files: &RepositoryFiles) -> Result, FuzzSeedError> { + let mut seeds = Vec::new(); + for fixture in FIXTURES { + let name = fixture + .strip_suffix(".hex") + .ok_or_else(|| FuzzSeedError::violation(format!("{fixture} is not a hex fixture")))?; + seeds.push(Seed::new( + "verification_receipt", + name, + read_hex(files, fixture)?, + )?); + } + Ok(seeds) +} + +fn read_hex(files: &RepositoryFiles, fixture: &'static str) -> Result, FuzzSeedError> { + let relative = Path::new(RECEIPT_ROOT).join(fixture); + let transport = files.read_bounded(&relative, MAX_SEED_BYTES)?; + let lines = framed_lines(&transport, MAX_SEED_BYTES) + .map_err(|source| FuzzSeedError::violation(format!("{fixture} framing moved: {source}")))?; + let [encoded] = lines.as_slice() else { + return Err(FuzzSeedError::violation(format!( + "{fixture} must contain exactly one hexadecimal line" + ))); + }; + decode_lower_hex(encoded, MAX_SEED_BYTES, EmptyHex::Refuse).map_err(|source| { + FuzzSeedError::violation(format!("{fixture} is not canonical hexadecimal: {source}")) + }) +} diff --git a/xtask/src/lib.rs b/xtask/src/lib.rs index 9a0ccf9e..31ef8c7b 100644 --- a/xtask/src/lib.rs +++ b/xtask/src/lib.rs @@ -36,6 +36,7 @@ mod durability_crash_occurrence; mod durability_crash_point; #[cfg(feature = "repository-tasks")] mod durability_crash_point_identity; +#[cfg(feature = "repository-tasks")] mod durability_crash_point_sequence; #[cfg(feature = "repository-tasks")] mod durability_crash_position; diff --git a/xtask/tests/verification_receipt_conformance_contract.rs b/xtask/tests/verification_receipt_conformance_contract.rs new file mode 100644 index 00000000..6e524288 --- /dev/null +++ b/xtask/tests/verification_receipt_conformance_contract.rs @@ -0,0 +1,95 @@ +//! Repository-shape evidence for the verification-receipt corpus. + +#![cfg(feature = "repository-tasks")] + +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +const CORPUS_ROOT: &str = "conformance/verification-receipt/v1"; +const REQUIRED_PATHS: &[&str] = &[ + "README.md", + "ORIGIN.md", + "artifacts.tsv", + "reference-complete-blob-report.hex", + "durable-corrupt-chunk-refusal.hex", + "reference-unsupported-framing-refusal.hex", +]; + +fn repository_root() -> Result { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| io::Error::other("xtask manifest directory has no parent")) +} + +#[test] +fn receipt_corpus_has_one_complete_regular_file_shape() -> Result<(), io::Error> { + let root = repository_root()?.join(CORPUS_ROOT); + let expected: BTreeSet = REQUIRED_PATHS.iter().map(OsString::from).collect(); + let mut observed = BTreeSet::new(); + for entry in fs::read_dir(&root)? { + let entry = entry?; + assert!( + entry.file_type()?.is_file(), + "{} is not a regular file", + entry.path().display() + ); + observed.insert(entry.file_name()); + } + assert_eq!( + observed, expected, + "verification-receipt corpus shape drifted" + ); + Ok(()) +} + +#[test] +fn every_receipt_fixture_is_one_384_byte_record_in_the_artifact_table() -> Result<(), io::Error> { + let root = repository_root()?.join(CORPUS_ROOT); + let table = fs::read_to_string(root.join("artifacts.tsv"))?; + let mut lines = table.lines(); + assert_eq!(lines.next(), Some("keep.verification-receipt.artifacts/v1")); + assert_eq!( + lines.next(), + Some("case\tkind\tbyte_length\tchecksum_hex\tfixture") + ); + let mut rows = 0_usize; + for row in lines { + let fields: Vec<&str> = row.split('\t').collect(); + assert_eq!(fields.len(), 5, "{row}"); + assert_eq!(fields.get(2), Some(&"384"), "{row}"); + assert!( + matches!(fields.get(1), Some(&"report" | &"refusal")), + "{row}" + ); + let fixture = fs::read_to_string(root.join(fields.get(4).unwrap_or(&"")))?; + assert_eq!( + fixture.len(), + 769, + "{row}: fixture is not 384 bytes plus LF" + ); + assert!(fixture.ends_with('\n'), "{row}"); + let checksum = fields.get(3).unwrap_or(&""); + assert_eq!(checksum.len(), 64, "{row}"); + assert!( + fixture.trim_end().ends_with(checksum), + "{row}: checksum column drifted" + ); + rows = rows.saturating_add(1); + } + assert_eq!(rows, 3); + Ok(()) +} + +#[test] +fn format_registry_routes_to_the_receipt_corpus() -> Result<(), io::Error> { + let format_index = fs::read_to_string(repository_root()?.join("docs/formats/README.md"))?; + assert!( + format_index.contains("../../conformance/verification-receipt/v1/README.md"), + "format registry does not route to the verification-receipt corpus" + ); + Ok(()) +} From 393accb2a09cdef0e13e51278d9e18ecd0360214 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 13:34:02 -0700 Subject: [PATCH 30/59] Feat: identity-preserving compaction with recovery on version-two roots ROADMAP T-22.3 (KEEP-GC-002, now Implemented). A mixed segment, one the current catalog names with at least one record no retained closure reaches, can now be compacted: its live records are copied into one new immutable segment, a catalog successor names the copies and omits every unreachable record, and the old segment becomes an `unreachable-superseded` GC candidate. `BlobId`, `ChunkId`, and `LayoutId` never change. `observe_compaction` reads one view: every named record with its segment, every record a retained closure reaches (sharing the liveness observer's closure walk, now `visit_retained_closures`), and the admitted pool. `plan_compaction` is pure over it and gives every named segment a disposition (`retained`, `compacted` with its live records in canonical identity order, `omitted`) plus copied and reclaimable counts, refusing when no retention head is published, nothing is unreachable, a closure reaches an unnamed record, a named segment is missing, the copies exceed one segment's ceilings, or the generation overflows. `FilesystemCompactionAuthority` pins a version-two root through the catalog publisher (`FilesystemCatalogPublisher::open_version_two`), re-proves the plan under writer authority (`PlanStale`), reads the retained segments and the current catalog, copies the exact admitted records into a sealed `staging/current.seg`, builds the successor from the retained segments and the new one, runs the complete version-one catalog publication protocol, and revalidates that every superseded segment plans as a superseded GC candidate. `execute_with` lets a harness drive the protocol through a fault-injecting storage. `recover_compaction` drives the version-one recovery protocols over a version-two root: the recovery inventory now classifies the version-two root entries (`reader.lock`, `FORMAT`, `migration.intent`, `migration.receipt`, `retention`, `gc`, `recovery`) as an inert `VersionTwoProtocol` role, the inventory reader, stage discarder, and next-head finalizer gained version-two openers, and version-two admission admits an optional root `head.next` as recovery-required residue. Truncated stages go through the version-one evidence-bound discard; a complete `current.seg` is discarded only after every record proves byte-identical to what `HEAD`'s catalog names, a complete `current.cat` only when it is exactly `HEAD`'s successor candidate, a reusable prefix as never-published staging, and a complete `head.next` is finalized. Evidence in `src/adapters/compaction/`: over a migrated store whose second catalog generation adds a segment holding one anchored blob's chunk and layout beside an unanchored chunk, the plan copies exactly the two live records; execution publishes generation three, keeps every retained closure's root and members and every live record's bytes (the closure transcript digest binds the catalog coordinate and changes by design), drops the unreachable chunk, and GC retires the mixed segment; refusals happen before any stage; a death injected before each of the 22 publication phases leaves exactly the documented residue, recovers (discard, idle, or finalize) with readers unaffected, and reaches the same successor; recovery over an untouched store is idle. The Worldline capability `keep.compaction.identity-stable/v1` is `required`; the README gap row is removed; `docs/formats/segment-store-v2/compaction.md` owns the page. Still owed under #21 and recorded in the ROADMAP: a compaction-specific process-death sequence (the boundaries are the version-one publication boundaries), amplification and latency benchmarks, and re-encoding. Red: the first recovery run refused every interrupted phase because the version-one discard planner rejects a complete sealed stage (`NotTruncated`) and the recovery inventory rejected the version-two namespace outright. Green: version-two admission in the inventory reader and the derivability proof before compaction's own discard; no decoder or protocol changed. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 21 ++ README.md | 1 - ROADMAP.md | 27 +- .../golden-file-worldline/v1/capabilities.tsv | 2 +- docs/conformance/golden-file-worldline.md | 6 +- docs/formats/README.md | 2 +- docs/formats/segment-store-v1/recovery.md | 5 +- docs/formats/segment-store-v2/README.md | 5 +- docs/formats/segment-store-v2/compaction.md | 119 ++++++ docs/formats/segment-store-v2/gc.md | 8 +- docs/formats/segment-store-v2/recovery.md | 16 +- docs/formats/segment-store-v2/requirements.md | 2 +- src/adapters/compaction/error.rs | 92 +++++ src/adapters/compaction/filesystem.rs | 315 ++++++++++++++++ src/adapters/compaction/filesystem_tests.rs | 164 ++++++++ src/adapters/compaction/interruption_tests.rs | 233 ++++++++++++ src/adapters/compaction/mod.rs | 31 ++ src/adapters/compaction/observation.rs | 118 ++++++ src/adapters/compaction/plan.rs | 264 +++++++++++++ src/adapters/compaction/recovery.rs | 354 ++++++++++++++++++ src/adapters/compaction/test_fixture.rs | 219 +++++++++++ src/adapters/exports.rs | 1 + src/adapters/filesystem_catalog_publisher.rs | 21 +- .../filesystem_initialization_namespace.rs | 7 +- .../filesystem_recovery_inventory_reader.rs | 15 +- ...filesystem_recovery_next_head_finalizer.rs | 15 + .../filesystem_recovery_stage_discarder.rs | 46 ++- src/adapters/gc/liveness_observation.rs | 35 +- src/adapters/gc/liveness_observation_error.rs | 2 +- src/adapters/gc/mod.rs | 2 + src/adapters/gc/segment_pool_inventory.rs | 2 +- src/adapters/mod.rs | 1 + src/adapters/recovery/recovery_entry_role.rs | 4 + .../recovery/recovery_name_classification.rs | 5 +- src/adapters/retention.rs | 4 +- src/lib.rs | 6 + .../capability_contract.rs | 2 +- 37 files changed, 2130 insertions(+), 42 deletions(-) create mode 100644 docs/formats/segment-store-v2/compaction.md create mode 100644 src/adapters/compaction/error.rs create mode 100644 src/adapters/compaction/filesystem.rs create mode 100644 src/adapters/compaction/filesystem_tests.rs create mode 100644 src/adapters/compaction/interruption_tests.rs create mode 100644 src/adapters/compaction/mod.rs create mode 100644 src/adapters/compaction/observation.rs create mode 100644 src/adapters/compaction/plan.rs create mode 100644 src/adapters/compaction/recovery.rs create mode 100644 src/adapters/compaction/test_fixture.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 874dc80c..0eff1905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,27 @@ after its public API and format compatibility policies are established. ### Added +- Identity-preserving compaction. `observe_compaction` reads every record + the catalog names, every record a retained closure reaches, and the pool; + `plan_compaction` is pure over it and gives every named segment one + disposition (`retained`, `compacted` with its live records to copy, or + `omitted`), refusing when no retention is published, nothing is + unreachable, a closure reaches an unnamed record, a named segment is + missing, or the copies exceed one segment. `FilesystemCompactionAuthority` + re-proves the plan under writer authority, copies the live records + byte-identically into one new sealed segment, publishes the successor + through the complete version-one catalog protocol on the version-two root + (`FilesystemCatalogPublisher::open_version_two`), and revalidates that + every superseded segment is now an `unreachable-superseded` GC candidate. + `recover_compaction` drives the version-one stage discard and `head.next` + finalization over a version-two root (the recovery inventory now admits + the version-two root entries as inert, and version-two admission admits an + optional `head.next`), discarding a complete staged segment or catalog + only after proving it derivable from `HEAD`. Proven by + `src/adapters/compaction/filesystem_tests.rs`, including a death before + each of the 22 publication phases; `KEEP-GC-002` is Implemented and the + Worldline capability `keep.compaction.identity-stable/v1` is `required`. + Specified on `docs/formats/segment-store-v2/compaction.md`. - Durable verification receipts. `keep.verification-receipt/v1` is a canonical, versioned, checksummed 384-byte record binding one verification's subject, admitted view (reference, or a durable diff --git a/README.md b/README.md index f4ab8e24..265f94b9 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,6 @@ a publication. A version-1 store stays admitted until its owner migrates it. | --- | --- | | Durable authenticated reads bound to a fenced snapshot | [#109](https://github.com/flyingrobots/keep/issues/109) | | Verification reports at durable depths and a replayable receipt | [#20](https://github.com/flyingrobots/keep/issues/20) | -| Identity-preserving compaction | [#21](https://github.com/flyingrobots/keep/issues/21) | | Bounded production ingestion through the durable store | [#82](https://github.com/flyingrobots/keep/issues/82) | | Encrypted representations | [#86](https://github.com/flyingrobots/keep/issues/86) | diff --git a/ROADMAP.md b/ROADMAP.md index 82b83594..666e83bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -100,7 +100,7 @@ names; use those in code, tests, and commits. - [x] [F-19 Reader fence and immutable version-2 snapshots](#f-19-reader-fence-and-immutable-version-2-snapshots) — Done on this branch (merged from PR #99) - [x] [F-20 Model-based retention transition evidence](#f-20-model-based-retention-transition-evidence) — Done on this branch (merged from PR #99) - [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Partial (#20; report vocabulary, corruption ledgers, and durable receipts done on this branch; durable-view depths land with T-23.1) -- [ ] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Partial (#21; codecs, planner, and orphan disposition done on this branch; compaction and execution remain) +- [x] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Done on this branch (#21; codecs, planner, disposition, retirement, and compaction; crash sequences for disposition and compaction, stress, benchmarks, and re-encoding still owed) - [ ] [F-23 Durable authenticated reads and refusal receipts](#f-23-durable-authenticated-reads-and-refusal-receipts) — Planned (#109) - [ ] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Planned (#82, #72) @@ -1164,11 +1164,13 @@ quarantines, or rewrites physical state. ### F-22 Garbage collection, compaction, and recovery dispositions -**Status:** Partial (#21, P1, M4). All three codecs (T-22.1, T-22.1a; -`KEEP-GC-001` Implemented), the deterministic planner (T-22.2), explicit -orphan disposition (T-22.5), and retirement with recovery and its -process-death matrix (T-22.4) landed on this branch; identity-preserving -compaction (T-22.3) remains, so `KEEP-GC-002` stays In progress. +**Status:** Done on this branch (#21, P1, M4). All three codecs (T-22.1, +T-22.1a; `KEEP-GC-001` Implemented), the deterministic planner (T-22.2), +explicit orphan disposition (T-22.5), retirement with recovery and its +process-death matrix (T-22.4), and identity-preserving compaction (T-22.3; +`KEEP-GC-002` Implemented) landed on this branch. Still owed under #21: the +disposition-phase and compaction process-death sequences, the +65,536-candidate stress run, compaction benchmarks, and re-encoding. Plan GC from an immutable liveness snapshot; classify every segment as live, unreachable, corrupt, ambiguous, recovery-protected, @@ -1278,7 +1280,18 @@ disposition receipt exists. `BlobId`, `ChunkId`, and `LayoutId` never move. - **Documentation:** `gc.md` planning section; a warning per Documentation Standards §5.4 on every page that describes execution. - **Dependencies:** F-19, F-21 (planning consumes verification depth). -- [ ] T-22.3 Identity-preserving compaction. +- [x] T-22.3 Identity-preserving compaction — `observe_compaction`, + `plan_compaction`, `FilesystemCompactionAuthority::{execute, execute_with}`, + `recover_compaction`, `docs/formats/segment-store-v2/compaction.md`, and + `src/adapters/compaction/filesystem_tests.rs` (exact plan, identity and + closure stability, GC retirement, refusals, a death before each of the 22 + publication phases, idle recovery); the version-one recovery inventory + admits version-two roots and version-two admission admits a retained + `head.next`; `KEEP-GC-002` Implemented; Worldline + `keep.compaction.identity-stable/v1` required. Still owed under #21: a + compaction-specific process-death sequence (the boundaries are the + version-one publication boundaries), amplification and latency benchmarks, + and re-encoding compaction. Original task fields: - **Requirements:** copy live records into new immutable segments, verify them, publish a catalog successor naming the new locations, revalidate expected catalog and retention generations before acting, diff --git a/conformance/golden-file-worldline/v1/capabilities.tsv b/conformance/golden-file-worldline/v1/capabilities.tsv index 329ff780..1b72342e 100644 --- a/conformance/golden-file-worldline/v1/capabilities.tsv +++ b/conformance/golden-file-worldline/v1/capabilities.tsv @@ -12,7 +12,7 @@ keep.segment.verified-read/v1 declared-future M3 14,15 sealed segment reads auth keep.restart.lawful-recovery/v1 declared-future M3 17 restart recovery reaches one documented lawful state keep.retention.both-states/v1 declared-future M4 18,19 retention evidence keeps both worldline states live keep.verification.precise-refusal/v1 required M4 20 corruption produces precise verification refusal -keep.compaction.identity-stable/v1 declared-future M4 21 compaction cannot move logical BlobId +keep.compaction.identity-stable/v1 required M4 21 compaction cannot move logical BlobId keep.echo.identity-agreement/v1 declared-future M5 22,23 Echo and Keep agree on the exact logical identity boundary keep.graft.golden-worldline/v1 declared-future M5 24 Graft executes the Golden File Worldline through Keep keep.git-cas.import/v1 declared-future M5 25 imported git-cas material retains explicit identity posture diff --git a/docs/conformance/golden-file-worldline.md b/docs/conformance/golden-file-worldline.md index d2e4780c..17f1cef3 100644 --- a/docs/conformance/golden-file-worldline.md +++ b/docs/conformance/golden-file-worldline.md @@ -128,7 +128,11 @@ boundary retroactively. `keep.verification.precise-refusal/v1` became `conformance/segment-store/v1/mutations.tsv` and `v2/mutations.tsv`: every structural field of every durable record has a frozen mutation whose exact first refusal and verification stage `tests/segment_store_mutations.rs` -reproduces through the public decoders. +reproduces through the public decoders. `keep.compaction.identity-stable/v1` +became `required` in M4 on the compaction laws in +`src/adapters/compaction/filesystem_tests.rs`: every retained closure's root +and members and every live record's bytes are unchanged across a compaction +successor and its recovery from a death before any publication phase. ## Partition plans diff --git a/docs/formats/README.md b/docs/formats/README.md index d6a3bcc4..c3420965 100644 --- a/docs/formats/README.md +++ b/docs/formats/README.md @@ -10,7 +10,7 @@ admitted merely because one Rust type can serialize and deserialize it. | [Flat Chunk Layout v1](flat-chunk-layout-v1/README.md) | `keep.flat-chunks/v1` | Implemented through verified reconstruction in issues #10 and #13 | [Golden corpus](../../conformance/layout/v1/README.md) | | [Durable Segment Store v1](segment-store-v1/README.md) | `keep.segment-store/v1` | Implemented through initialization, publication, restart, and recovery in issues #14–#17 | [Golden corpus](../../conformance/segment-store/v1/README.md) | | [Verification Receipt v1](verification-receipt-v1/README.md) | `keep.verification-receipt/v1` | Canonical 384-byte replayable projection of one verification report or refusal onto one view; codec, corpus, corruption matrix, and fuzz target implemented | [Golden corpus](../../conformance/verification-receipt/v1/README.md) | -| [Durable Segment Store v2](segment-store-v2/README.md) | `keep.segment-store/v2` | One-way migration, version-two reopen, retention publication and recovery, reader fencing, explicit disposition, and GC retirement implemented; compaction planned in issue #21 | [Golden corpus](../../conformance/segment-store/v2/README.md) | +| [Durable Segment Store v2](segment-store-v2/README.md) | `keep.segment-store/v2` | One-way migration, version-two reopen, retention publication and recovery, reader fencing, explicit disposition, GC retirement, and identity-preserving compaction implemented; benchmarks and re-encoding compaction planned in issue #21 | [Golden corpus](../../conformance/segment-store/v2/README.md) | The registry records protocol specifications, including formats whose implementation is still planned. Each format page states its exact proof diff --git a/docs/formats/segment-store-v1/recovery.md b/docs/formats/segment-store-v1/recovery.md index 3c03da4e..0a420446 100644 --- a/docs/formats/segment-store-v1/recovery.md +++ b/docs/formats/segment-store-v1/recovery.md @@ -31,7 +31,10 @@ root and three no-follow child directories, bounds each scan by the remaining global budget, preserves raw Linux name bytes, and verifies child-directory identity before and after inventory. `classify_recovery_names` requires the four initialized root entries, types each fixed name and immutable-pool -coordinate, and refuses an unknown or conflicting name without artifact I/O. +coordinate, admits the version-two root entries (`reader.lock`, `FORMAT`, +`migration.intent`, `migration.receipt`, `retention`, `gc`, `recovery`) as +inert so the same protocols recover a migrated root, and refuses any other +unknown or conflicting name without artifact I/O. `fingerprint_recovery_stage` then reads a fixed stage through a zero-allocation bounded stream, refuses metadata or observed bytes above the name-selected maximum, and returns its exact observed length and diff --git a/docs/formats/segment-store-v2/README.md b/docs/formats/segment-store-v2/README.md index 0a0e7516..192f423d 100644 --- a/docs/formats/segment-store-v2/README.md +++ b/docs/formats/segment-store-v2/README.md @@ -52,6 +52,8 @@ The following pages form one protocol: completion, and recovery-disposition byte grammars. - [GC execution and recovery](gc-execution.md) owns the retirement phases, the residue state table, and `KEEP-CRASH-074` through `087`. +- [Identity-preserving compaction](compaction.md) owns the compaction plan, + the successor publication, and its recovery. - [Migration and recovery](recovery.md) owns the exact root namespace, version marker, reader fence, migration records, GC reservation, recovery-disposition reservation, and restart behavior. @@ -120,7 +122,8 @@ Migration recovery is proven in-process for every prefix and by the of its 68 boundary coordinates and recovers the restarted root. GC retirement is proven in-process for every prefix and by the `KEEP-CRASH-074..087` process-death matrix; identity-preserving compaction -is not implemented, issue #21. +is proven by its identity-stability and interruption laws. Compaction +benchmarks and re-encoding compaction are not implemented, issue #21. Reopen compares only the restart-stable root coordinates, device and inode, against the intent; see [root identity across restart](recovery.md#root-identity-across-restart). A diff --git a/docs/formats/segment-store-v2/compaction.md b/docs/formats/segment-store-v2/compaction.md new file mode 100644 index 00000000..8d92c2d2 --- /dev/null +++ b/docs/formats/segment-store-v2/compaction.md @@ -0,0 +1,119 @@ +# Identity-Preserving Compaction + +This page owns compaction for `keep.segment-store/v2`: reclaiming the +unreachable records of a mixed segment by copying its live records into one +new immutable segment and publishing a catalog successor that names the +copies and omits the rest. `BlobId`, `ChunkId`, and `LayoutId` never change: +the successor names the same logical identities at new physical locations, +which is the separation [ADR-0002](../../adr/0002-separate-identity-from-physical-storage.md) +draws (its compaction example moves a record between segments without moving +any identity). The design contract is +[ADR-0009](../../adr/0009-retention-roots-release-and-gc-liveness.md); the +records compaction releases are retired afterwards by +[GC execution](gc-execution.md). + +> **Warning.** Compaction publishes a catalog successor and therefore +> changes what the next GC may retire. It holds writer authority, re-proves +> its plan against the reopened store, copies before it publishes, publishes +> through the complete catalog protocol, and revalidates afterwards. +> `plan_compaction` is the dry run: it changes nothing on disk. + +## Observation and plan + +`observe_compaction` reads one view: every record the current catalog names +with the segment holding it, every record some retained closure reaches +(the same closure walk GC planning uses), and the admitted segment pool. +`plan_compaction` is pure and deterministic over that observation. Every +named segment gets one disposition: + + + +| Disposition | Meaning | Successor | +| --- | --- | --- | +| `retained` | every named record is live | names the segment unchanged | +| `compacted` | live and unreachable records share it | copies its live records into the new segment, in canonical identity order, and omits the rest | +| `omitted` | no named record is live | omits the whole segment | + + + +The plan carries the coordinates it was computed under, the successor +generation, the copied record and byte counts, and the reclaimable bytes +(the complete length of every superseded segment). It refuses rather than +plans when no retention head is published (nothing is retained, so +compaction would omit everything), when every named record is live, when a +retained closure reaches an unnamed record, when a named segment is absent +from the pool, when the copies exceed one segment's ceilings, or when the +successor generation would overflow. One compaction fills at most one new +segment; a store with more to reclaim compacts again. + +## Execution + +`FilesystemCompactionAuthority::open` pins a version-two root through the +catalog publisher. `execute(&plan)`: + +1. reopens the store under writer authority and requires + `plan_compaction` to reproduce the plan exactly (`PlanStale` otherwise); +2. reads every retained segment from the pool and the current catalog; +3. when the plan copies records, creates `staging/current.seg`, appends the + exact admitted records in canonical identity order, and seals it; the + copies are byte-identical records, so the new segment's bytes are a + deterministic function of the plan; +4. builds the successor catalog from the retained segments and the new one, + at the successor generation over the current digest; +5. runs the complete version-one catalog publication protocol + (`KEEP-CRASH-001` through `035` boundaries: segment link and pool sync, + catalog stage, link, and pool sync, `head.next`, atomic replacement, root + sync), refusing before any stage while a retained stage or `head.next` + exists; +6. reopens the store, verifies every retained closure, and requires every + superseded segment to plan as an `unreachable-superseded` GC candidate. + +The closure *transcript digest* changes across a successor by design (it +binds the catalog generation and digest); closure membership, root digests, +and every live record's bytes do not. + +## Recovery + +A compactor may die at any publication boundary. Version-two admission +admits a retained `head.next` beside `staging` residue, readers keep +reading `HEAD`, and every publication refuses until recovered. +`recover_compaction` acquires writer authority and drives the version-one +recovery protocols over the three fixed stages, with the version-two root +entries admitted as inert by the recovery inventory: + + + +| Residue | Recovery | +| --- | --- | +| truncated `current.seg`, `current.cat`, or `head.next` | version-one evidence-bound discard | +| complete `current.seg` | compaction discard, only after every record proves byte-identical to what `HEAD`'s catalog names; a segment already linked into the pool stays as a valid orphan the next run relinks identically | +| reusable `current.seg` prefix | compaction discard: never-published staging | +| complete `current.cat` | compaction discard, only when it is exactly `HEAD`'s successor candidate | +| complete `head.next` | version-one finalization into `HEAD` | + + + +After recovery the store re-plans: identically when the successor was not +published, or `NothingToCompact` when it was; either way the same successor +is reached and the same segments are GC candidates. Any other residue is a +typed refusal and nothing is touched. + +## Evidence + +`src/adapters/compaction/filesystem_tests.rs` over a migrated store whose +second catalog generation adds a segment holding one anchored blob's chunk +and layout beside an unanchored chunk: the plan copies exactly the two live +records and omits the third; execution publishes generation three, keeps +every retained closure's root and members and every live record's bytes, +drops the unreachable chunk from the catalog, and hands the mixed segment to +GC, which retires it; refusals (no retention, nothing to compact, a plan +executed twice) happen before any stage; a death injected before each of +the 22 publication phases recovers to the documented residue outcome and +then to the same successor; recovery over an untouched store is idle. The +Golden File Worldline capability `keep.compaction.identity-stable/v1` is +`required` on that evidence. + +Not implemented: a process-death matrix for compaction as its own sequence +(the boundaries are the version-one publication boundaries, proven by the +version-one matrix), amplification and latency benchmarks, and re-encoding +compaction (a representation change), issue #21. diff --git a/docs/formats/segment-store-v2/gc.md b/docs/formats/segment-store-v2/gc.md index d0839c35..5afd07d1 100644 --- a/docs/formats/segment-store-v2/gc.md +++ b/docs/formats/segment-store-v2/gc.md @@ -16,9 +16,10 @@ intent. `CanonicalRecoveryDispositionReceipt` and `definition.tsv`. The protocol that writes `gc/intent` and `gc/receipt`, its recovery, and its process-death matrix are owned by [GC execution and recovery](gc-execution.md); explicit disposition by -[recovery](recovery.md#explicit-disposition-of-protected-orphans). Namespace +[recovery](recovery.md#explicit-disposition-of-protected-orphans); +identity-preserving compaction by [compaction](compaction.md). Namespace admission admits exactly those records as regular files and nothing else in -`gc`. Identity-preserving compaction remains **Planned in #21**. +`gc`. Re-encoding compaction remains **Planned in #21**. ## Common rules @@ -257,4 +258,5 @@ recovery-protected. All three grammars have golden fixtures, parsers, corruption matrices, and a seeded fuzz target; the planner has its golden plan and model law; retirement and disposition have their in-process prefix laws and the process-death -matrix. Compaction and its benchmark evidence are **Planned in #21**. +matrix; compaction has its identity-stability and interruption laws. +Compaction benchmarks are **Planned in #21**. diff --git a/docs/formats/segment-store-v2/recovery.md b/docs/formats/segment-store-v2/recovery.md index d77fbf06..45c82728 100644 --- a/docs/formats/segment-store-v2/recovery.md +++ b/docs/formats/segment-store-v2/recovery.md @@ -67,17 +67,17 @@ recovery is absent; version-1 reopen and recovery both refuse a migrated root. ## Reader fence -`reader.lock` is a persistent regular zero-length file. Its contents and -existence alone prove nothing. +`reader.lock` is a persistent regular zero-length file whose contents and +existence alone prove nothing. Admission also admits an optional root +`head.next`, the residue of an interrupted [compaction](compaction.md) +successor, refused by every publication until recovered. A version-2 reader acquires a kernel-managed shared lock on `reader.lock` -before opening catalog `HEAD` or `retention/HEAD`. The returned `ReaderFence` -owns that lock for the complete snapshot lifetime. Close, drop, or process -death releases only the kernel lock and never deletes the persistent file. +before opening catalog `HEAD` or `retention/HEAD`; the `ReaderFence` owns it +for the snapshot's lifetime; release or process death never deletes the file. -GC takes writer authority and then `reader.lock` exclusively, refusing -without waiting while readers hold it. Catalog and retention publication -proceed beside readers because they publish immutable successors. +GC takes writer authority and then `reader.lock` exclusively, refusing while +readers hold it; publication proceeds beside readers (immutable successors). ## Migration records diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index 0163ad42..7fe27931 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -46,7 +46,7 @@ case is not evidence. | ID | Requirement | Evidence | Status | | --- | --- | --- | --- | | `KEEP-GC-001` | Version 2 specifies exact bounded GC intent, receipt, and recovery-disposition grammars but refuses their presence until their parser and recovery protocol are implemented | field-complete corruption ledger `conformance/segment-store/v2/mutations.tsv` (GC intent, receipt, disposition) reproduced by `tests/segment_store_mutations.rs`; intent and receipt golden, canonical re-encoding, cross-record binding, and field-by-field corruption laws in `tests/gc_retirement_intent.rs`, `tests/gc_retirement_intent/mutation_laws.rs`, and `tests/gc_retirement_receipt.rs`; disposition golden, canonical re-encoding, registered-enumeration agreement with `definition.tsv`, unregistered-code refusal, and field-by-field corruption laws in `tests/recovery_disposition_receipt.rs`; seeded `gc_format` fuzz target over all three records; presence refusal in namespace admission tests | Implemented | -| `KEEP-GC-002` | GC intent, receipt, disposition, reader-fence, retirement, compaction, and recovery laws implement ADR-0009 without changing logical identity | deterministic planning: `plan_gc` classifies every inventoried segment against one fenced liveness snapshot, refuses every contradiction, and never names a live or catalog-named segment, proven by one law per classification and ambiguity, the golden `gc-plan.tsv`, a 512-universe model, and filesystem laws over the migrated fixture store in `src/adapters/gc/`; explicit disposition of recovery-protected retention orphans in `filesystem_retention_disposition_tests` (retire unlinks under an absent head, finalize needs a published head, manifest before root, readers refuse, every residue resumes, receipts admitted by census) and exact-receipt admission by GC planning in `liveness_observation_tests`; retirement and recovery: `FilesystemGcAuthority` re-proves the plan under writer authority and the exclusive fence, derives the intent with the registered proof, pool, and disposition-set digests, runs the 14 fixed phases through `GcExecutionStorage`, and `plan_gc_recovery` classifies every residue (retire, second generation, nothing-to-retire, stale plan, readers, every interrupted prefix, both truncated stages, intent exclusion of retention publication, ambiguity, admission) in `filesystem_gc_tests`, with the `KEEP-CRASH-074..=087` process-death matrix of 42 killed-writer cases in `cargo xtask durability-crash-matrix --sequence gc`; compaction and its golden-format, model-based, corruption, crash-injection, benchmark, and fuzz evidence remain Planned in #21 | In progress in #21 | +| `KEEP-GC-002` | GC intent, receipt, disposition, reader-fence, retirement, compaction, and recovery laws implement ADR-0009 without changing logical identity | deterministic planning: `plan_gc` classifies every inventoried segment against one fenced liveness snapshot, refuses every contradiction, and never names a live or catalog-named segment, proven by one law per classification and ambiguity, the golden `gc-plan.tsv`, a 512-universe model, and filesystem laws over the migrated fixture store in `src/adapters/gc/`; explicit disposition of recovery-protected retention orphans in `filesystem_retention_disposition_tests` (retire unlinks under an absent head, finalize needs a published head, manifest before root, readers refuse, every residue resumes, receipts admitted by census) and exact-receipt admission by GC planning in `liveness_observation_tests`; retirement and recovery: `FilesystemGcAuthority` re-proves the plan under writer authority and the exclusive fence, derives the intent with the registered proof, pool, and disposition-set digests, runs the 14 fixed phases through `GcExecutionStorage`, and `plan_gc_recovery` classifies every residue (retire, second generation, nothing-to-retire, stale plan, readers, every interrupted prefix, both truncated stages, intent exclusion of retention publication, ambiguity, admission) in `filesystem_gc_tests`, with the `KEEP-CRASH-074..=087` process-death matrix of 42 killed-writer cases in `cargo xtask durability-crash-matrix --sequence gc`; identity-preserving compaction: `plan_compaction` over one observation, `FilesystemCompactionAuthority` through the complete catalog protocol, and `recover_compaction` in `src/adapters/compaction/filesystem_tests.rs` (exact plan, identity and closure stability, GC retirement of the superseded segment, refusals before any stage, a death before each of the 22 publication phases recovering to the documented residue outcome, idle recovery); compaction benchmarks and re-encoding compaction, with their golden-format, model-based, corruption, crash-injection, benchmark, and fuzz evidence, remain Planned in #21 | Implemented | diff --git a/src/adapters/compaction/error.rs b/src/adapters/compaction/error.rs new file mode 100644 index 00000000..ef9d24ce --- /dev/null +++ b/src/adapters/compaction/error.rs @@ -0,0 +1,92 @@ +//! This boundary module owns typed refusals of filesystem compaction. + +use std::error::Error; +use std::fmt; +use std::io; + +use super::CompactionRefusal; +use crate::adapters::gc::{GcLivenessObservationError, GcPlanError}; +use crate::adapters::{ + CatalogEncodeError, CatalogPublicationError, CatalogRestartError, + FilesystemRetentionSnapshotError, SegmentPublicationError, SegmentReadError, SegmentWriteError, +}; + +/// Why filesystem compaction refused. +#[derive(Debug)] +pub enum FilesystemCompactionError { + /// A read, open, or stage operation outside the publication protocol failed. + Observe { + /// The exact failure. + source: io::Error, + }, + /// The reopened view refused. + Snapshot(Box), + /// Re-observing the store refused. + Liveness(Box), + /// Re-planning refused where the plan expected a compaction. + Refused(CompactionRefusal), + /// The reopened store plans differently: the plan's evidence is stale. + PlanStale, + /// The current catalog could not be reloaded. + Catalog(Box), + /// A live record the plan copies is not in the current catalog. + RecordVanished, + /// A retained or staged segment refused admission. + Segment(Box), + /// Writing the new segment refused. + Stage(Box), + /// Selecting the sealed stage refused. + Selection(SegmentPublicationError), + /// Encoding the successor catalog refused. + Successor(Box), + /// A publication phase refused; the completed phases' effects remain + /// for [`recover_compaction`](super::recover_compaction). + Publish(Box), + /// After publication, the reopened store did not show every superseded + /// segment as a superseded retirement candidate. + Revalidation(GcPlanError), + /// After publication, a superseded segment is not a retirement candidate. + NotSuperseded, +} + +impl fmt::Display for FilesystemCompactionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Observe { .. } => formatter.write_str("compaction observation failed"), + Self::Snapshot(_) => formatter.write_str("compaction could not reopen the view"), + Self::Liveness(_) => formatter.write_str("compaction could not re-observe liveness"), + Self::Refused(source) => write!(formatter, "compaction refused: {source}"), + Self::PlanStale => formatter.write_str("the store no longer matches the plan"), + Self::Catalog(_) => formatter.write_str("the current catalog could not be reloaded"), + Self::RecordVanished => formatter.write_str("a planned live record is not named"), + Self::Segment(_) => formatter.write_str("a segment refused admission"), + Self::Stage(_) => formatter.write_str("the new segment stage refused"), + Self::Selection(source) => write!(formatter, "stage selection refused: {source}"), + Self::Successor(_) => formatter.write_str("the successor catalog refused to encode"), + Self::Publish(source) => write!(formatter, "publication refused: {source}"), + Self::Revalidation(source) => write!(formatter, "revalidation refused: {source}"), + Self::NotSuperseded => { + formatter.write_str("a superseded segment is not a retirement candidate") + } + } + } +} + +impl Error for FilesystemCompactionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Observe { source } => Some(source), + Self::Snapshot(source) => Some(source.as_ref()), + Self::Liveness(source) => Some(source.as_ref()), + Self::Refused(source) => Some(source), + Self::Catalog(source) => Some(source.as_ref()), + Self::Segment(source) => Some(source.as_ref()), + Self::Stage(source) => Some(source.as_ref()), + Self::Selection(source) => Some(source), + Self::Successor(source) => Some(source.as_ref()), + Self::Publish(source) => Some(source.as_ref()), + Self::Revalidation(source) => Some(source), + Self::PlanStale | Self::RecordVanished | Self::NotSuperseded => None, + } + } +} diff --git a/src/adapters/compaction/filesystem.rs b/src/adapters/compaction/filesystem.rs new file mode 100644 index 00000000..15a8ead5 --- /dev/null +++ b/src/adapters/compaction/filesystem.rs @@ -0,0 +1,315 @@ +//! This module owns filesystem compaction under writer authority: re-prove +//! the plan, copy the live records into one new sealed segment, publish the +//! successor through the complete catalog protocol, and revalidate. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use super::plan::superseded_set; +use super::{ + CompactionPlan, FilesystemCompactionError as Error, observe_compaction, plan_compaction, +}; +use crate::adapters::gc::{ + GcLimits, GcSegmentClassification, GcUnreachableEvidence, observe_gc_liveness, plan_gc, +}; +use crate::adapters::{ + AdmittedSegment, CanonicalCatalog, CatalogPublicationError, CatalogPublicationExpectation, + CatalogPublicationReceipt, CatalogRestartPolicy, FilesystemCatalogPublisher, + FilesystemCatalogSnapshot, FilesystemRetentionSnapshot, FilesystemVersionTwoAdmission, + ReaderAttemptLimit, SegmentDigest, SegmentPublication, SegmentRecordLimit, StagedSegment, + filesystem_exact_record as exact_record, physical_pool_name, publish_catalog_generation, +}; +use crate::{CatalogDigest, CatalogGeneration}; + +/// The publication call compaction drives: production passes +/// [`publish_catalog_generation`] on the authority's own publisher; a test +/// or crash harness wraps that publisher in a fault-injecting storage. +pub type CompactionPublish<'call> = &'call mut dyn for<'p, 's, 'r, 'c> FnMut( + &'p mut FilesystemCatalogPublisher, + CatalogPublicationExpectation, + SegmentPublication<'s, 'r>, + &CanonicalCatalog, + &[AdmittedSegment<'c>], +) -> Result< + CatalogPublicationReceipt, + CatalogPublicationError, +>; + +/// What one completed compaction established. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompactionReceipt { + plan: CompactionPlan, + generation: CatalogGeneration, + catalog_digest: CatalogDigest, + new_segment: Option, +} + +impl CompactionReceipt { + /// The plan that was executed. + pub const fn plan(&self) -> &CompactionPlan { + &self.plan + } + + /// The published successor generation. + pub const fn generation(&self) -> CatalogGeneration { + self.generation + } + + /// The published successor digest. + pub const fn catalog_digest(&self) -> CatalogDigest { + self.catalog_digest + } + + /// The new segment holding the copied records, when any were copied. + #[must_use] + pub const fn new_segment(&self) -> Option { + self.new_segment + } + + /// The segments the successor no longer names: GC's next candidates. + #[must_use] + pub fn superseded(&self) -> BTreeSet { + superseded_set(&self.plan) + } +} + +/// Exclusive authority to compact one pinned version-two root. +/// +/// The authority holds the writer lock through its catalog publisher for +/// its lifetime. Publication proceeds beside readers because it only adds +/// an immutable segment and a catalog successor; GC later takes the +/// exclusive reader fence to retire the superseded segments. +#[must_use] +pub struct FilesystemCompactionAuthority { + publisher: FilesystemCatalogPublisher, + store_root: PathBuf, + policy: CatalogRestartPolicy, +} + +impl FilesystemCompactionAuthority { + /// Pins one admitted version-two root for compaction. + /// + /// # Errors + /// + /// Returns [`FilesystemCompactionError::Observe`] when the publication + /// directories cannot be pinned. + pub fn open( + admission: FilesystemVersionTwoAdmission, + store_root: &Path, + policy: CatalogRestartPolicy, + ) -> Result { + let publisher = FilesystemCatalogPublisher::open_version_two(admission, policy) + .map_err(|source| Error::Observe { source })?; + Ok(Self { + publisher, + store_root: store_root.to_path_buf(), + policy, + }) + } + + /// Executes `plan` completely. + /// + /// # Errors + /// + /// Returns [`FilesystemCompactionError`] at the exact refusal; a + /// publication refusal leaves the completed phases' residue for + /// [`recover_compaction`](super::recover_compaction). + pub fn execute(&mut self, plan: &CompactionPlan) -> Result { + self.execute_with(plan, &mut |publisher, + expectation, + segment, + catalog, + segments| { + publish_catalog_generation(publisher, expectation, segment, catalog, segments) + }) + } + + /// Executes `plan` with `publish` driving the catalog protocol. + /// + /// The plan is re-proven against the reopened store first; the live + /// records are copied into `staging/current.seg` in canonical identity + /// order and sealed; the successor names every retained segment and the + /// new one; `publish` runs the protocol; then the store is reopened and + /// every superseded segment must plan as a superseded GC candidate. + /// + /// # Errors + /// + /// As [`Self::execute`]. + #[doc(hidden)] + pub fn execute_with( + &mut self, + plan: &CompactionPlan, + publish: CompactionPublish<'_>, + ) -> Result { + self.reprove(plan)?; + let catalog = FilesystemCatalogSnapshot::load(&self.store_root, self.policy) + .map_err(|source| Error::Catalog(Box::new(source)))?; + let snapshot = catalog + .snapshot() + .map_err(|source| Error::Catalog(Box::new(source)))?; + let retained_bytes = self.read_retained(plan)?; + let mut segments = Vec::new(); + for bytes in &retained_bytes { + segments.push(admit(bytes, self.policy)?); + } + let staged = if plan.copied_records() == 0 { + None + } else { + Some(self.stage_copies(plan, &snapshot)?) + }; + let (new_bytes, sealed) = match staged { + Some((bytes, sealed)) => (Some(bytes), Some(sealed)), + None => (None, None), + }; + let new_segment = new_bytes + .as_deref() + .map(|bytes| admit(bytes, self.policy)) + .transpose()?; + let selection = match (sealed, &new_segment) { + (Some(sealed), Some(admitted)) => self + .publisher + .select_segment(sealed, admitted) + .map_err(Error::Selection)?, + _ => SegmentPublication::none(), + }; + if let Some(admitted) = new_segment.as_ref() { + segments.push( + AdmittedSegment::decode(admitted.encoded(), self.policy.segment_read()) + .map_err(|source| Error::Segment(Box::new(source)))?, + ); + } + let successor = CanonicalCatalog::from_segments( + plan.successor_generation(), + Some(plan.coordinates().catalog_digest()), + &segments, + ) + .map_err(|source| Error::Successor(Box::new(source)))?; + let expectation = CatalogPublicationExpectation::successor_of(&snapshot); + let receipt = publish( + &mut self.publisher, + expectation, + selection, + &successor, + &segments, + ) + .map_err(|source| Error::Publish(Box::new(source)))?; + drop(segments); + drop(snapshot); + self.revalidate(plan)?; + Ok(CompactionReceipt { + plan: plan.clone(), + generation: receipt.generation(), + catalog_digest: receipt.catalog_digest(), + new_segment: new_segment.as_ref().map(AdmittedSegment::digest), + }) + } + + fn view(&self) -> Result { + FilesystemRetentionSnapshot::load_under_writer_authority( + &self.store_root, + self.policy, + ReaderAttemptLimit::DEFAULT, + ) + .map_err(|source| Error::Snapshot(Box::new(source))) + } + + fn reprove(&self, plan: &CompactionPlan) -> Result<(), Error> { + let view = self.view()?; + let observation = observe_compaction(&self.store_root, &view, self.policy) + .map_err(|source| Error::Liveness(Box::new(source)))?; + let fresh = plan_compaction(&observation).map_err(Error::Refused)?; + if fresh == *plan { + Ok(()) + } else { + Err(Error::PlanStale) + } + } + + fn read_retained(&self, plan: &CompactionPlan) -> Result>, Error> { + let mut retained = Vec::new(); + for digest in plan.retained() { + let name = physical_pool_name::segment(digest); + let mut file = exact_record::open_read(&self.publisher.segments, &name) + .map_err(|source| Error::Observe { source })?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|source| Error::Observe { source })?; + retained.push(bytes); + } + Ok(retained) + } + + /// Copies every planned live record into a fresh sealed stage and + /// returns the stage's exact bytes with the sealed handle. + fn stage_copies<'publisher>( + &'publisher self, + plan: &CompactionPlan, + snapshot: &crate::adapters::CatalogSnapshot<'_, '_, '_>, + ) -> Result< + ( + Vec, + crate::adapters::SealedSegment>, + ), + Error, + > { + let stage = self + .publisher + .create_segment_stage() + .map_err(|source| match source { + crate::adapters::SegmentStageCreateError::Create { source } => { + Error::Observe { source } + } + })?; + let mut staged = StagedSegment::begin(stage, SegmentRecordLimit::MAXIMUM) + .map_err(|source| Error::Stage(Box::new(source)))?; + let copies: BTreeSet<_> = plan.copied().collect(); + for identity in copies { + let record = snapshot.record(identity).ok_or(Error::RecordVanished)?; + staged = staged + .append(record) + .map_err(|source| Error::Stage(Box::new(source)))?; + } + let sealed = staged + .seal() + .map_err(|source| Error::Stage(Box::new(source)))?; + let mut file = exact_record::open_read( + &self.publisher.staging, + crate::adapters::filesystem_catalog_publisher::CURRENT_SEGMENT, + ) + .map_err(|source| Error::Observe { source })?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|source| Error::Observe { source })?; + Ok((bytes, sealed)) + } + + /// After publication every superseded segment must be a superseded + /// retirement candidate and every retained closure must still verify. + fn revalidate(&self, plan: &CompactionPlan) -> Result<(), Error> { + let view = self.view()?; + let liveness = observe_gc_liveness(&self.store_root, &view, self.policy) + .map_err(|source| Error::Liveness(Box::new(source)))?; + let gc_plan = plan_gc(&liveness, GcLimits::MAXIMUM).map_err(Error::Revalidation)?; + let superseded: BTreeMap = superseded_set(plan) + .into_iter() + .filter_map(|digest| gc_plan.classification(digest).map(|c| (digest, c))) + .collect(); + let expected = GcSegmentClassification::Unreachable(GcUnreachableEvidence::Superseded); + if superseded.len() == superseded_set(plan).len() + && superseded + .values() + .all(|classification| *classification == expected) + { + Ok(()) + } else { + Err(Error::NotSuperseded) + } + } +} + +fn admit(bytes: &[u8], policy: CatalogRestartPolicy) -> Result, Error> { + AdmittedSegment::decode(bytes, policy.segment_read()) + .map_err(|source| Error::Segment(Box::new(source))) +} diff --git a/src/adapters/compaction/filesystem_tests.rs b/src/adapters/compaction/filesystem_tests.rs new file mode 100644 index 00000000..3f62bcfe --- /dev/null +++ b/src/adapters/compaction/filesystem_tests.rs @@ -0,0 +1,164 @@ +//! Filesystem compaction laws over a migrated store holding one mixed +//! segment: the plan names exactly the live records to copy, the successor +//! preserves every identity and closure, GC then retires the old segment, +//! refusals happen before any stage, and recovery over an untouched store +//! is idle. + +use std::error::Error; + +use super::test_fixture::{ + authority, gc_plan, head_generation, logical_view, mixed_store, observe, plan, + pool_segment_bytes, recovery_is_idle, +}; +use super::{ + CompactionRefusal, CompactionSegmentDisposition, FilesystemCompactionError, plan_compaction, +}; +use crate::adapters::gc::{FilesystemGcAuthority, GcSegmentClassification, GcUnreachableEvidence}; +use crate::adapters::retention::filesystem_retention_test_fixture::{ + ROOT_HEX, catalog_policy, fixture, initial_preparation, migrated_store, reopen_authority, +}; +use crate::adapters::{FilesystemVersionTwoAdmission, SegmentDigest}; +use crate::execute_retention_publication; + +#[test] +fn a_mixed_segment_plans_its_live_records_for_copy_and_the_rest_for_omission() +-> Result<(), Box> { + let sandbox = mixed_store("compaction-plan")?; + let plan = plan(sandbox.path())?; + + assert_eq!(plan.successor_generation().get(), 3); + assert_eq!(plan.segments().len(), 2); + assert_eq!(plan.retained().count(), 1); + assert_eq!(plan.superseded().count(), 1); + let compacted: Vec<_> = plan + .segments() + .values() + .filter_map(|disposition| match disposition { + CompactionSegmentDisposition::Compacted { live, unreachable } => { + Some((live.len(), *unreachable)) + } + _ => None, + }) + .collect(); + assert_eq!(compacted, [(2, 1)]); + assert_eq!(plan.copied_records(), 2); + assert!(plan.copied_bytes() > 288); + assert!(plan.reclaimable_bytes() > plan.copied_bytes()); + assert_eq!( + plan, + plan_compaction(&observe(sandbox.path())?)?, + "planning is pure" + ); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn compaction_preserves_every_identity_and_closure_and_frees_the_mixed_segment() +-> Result<(), Box> { + let sandbox = mixed_store("compaction-execute")?; + let plan = plan(sandbox.path())?; + let before = logical_view(sandbox.path())?; + let superseded: Vec = plan.superseded().collect(); + + let receipt = authority(sandbox.path())?.execute(&plan)?; + + assert_eq!(receipt.generation().get(), 3); + assert_eq!(head_generation(sandbox.path())?, 3); + assert!(receipt.new_segment().is_some()); + assert_eq!( + receipt.superseded().into_iter().collect::>(), + superseded + ); + let after = logical_view(sandbox.path())?; + assert_eq!( + before.0, after.0, + "every retained closure's root and members are unchanged" + ); + assert_eq!( + before.1, after.1, + "every live record is byte-identical at its new location" + ); + let observation = observe(sandbox.path())?; + assert_eq!( + observation.named().len(), + 4, + "the unreachable chunk is no longer named" + ); + assert!(matches!( + plan_compaction(&observation), + Err(CompactionRefusal::NothingToCompact) + )); + let gc = gc_plan(sandbox.path())?; + assert_eq!(gc.candidate_count(), 1); + for digest in &superseded { + assert_eq!( + gc.classification(*digest), + Some(GcSegmentClassification::Unreachable( + GcUnreachableEvidence::Superseded + )) + ); + } + let admission = FilesystemVersionTwoAdmission::reopen_unchecked_for_tests(sandbox.path())?; + let mut collector = FilesystemGcAuthority::open(admission, sandbox.path(), catalog_policy()?)?; + let _retired = collector.execute(&gc)?; + drop(collector); + assert_eq!(pool_segment_bytes(sandbox.path())?.len(), 2); + assert_eq!( + logical_view(sandbox.path())?, + after, + "retirement changes no identity" + ); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn refusals_happen_before_any_stage_is_written() -> Result<(), Box> { + let unpublished = migrated_store("compaction-refuse-retention")?; + assert!(matches!( + plan_compaction(&observe(unpublished.path())?), + Err(CompactionRefusal::RetentionNotPublished) + )); + unpublished.remove()?; + + let all_live = migrated_store("compaction-refuse-nothing")?; + let root_bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&root_bytes)?; + let mut retention = reopen_authority(all_live.path())?; + let _published = execute_retention_publication(&mut retention, &preparation)?; + drop(retention); + assert!(matches!( + plan_compaction(&observe(all_live.path())?), + Err(CompactionRefusal::NothingToCompact) + )); + all_live.remove()?; + + // A plan executed once cannot execute again: the store re-plans differently. + let sandbox = mixed_store("compaction-refuse-stale")?; + let plan = plan(sandbox.path())?; + let _receipt = authority(sandbox.path())?.execute(&plan)?; + let error = authority(sandbox.path())? + .execute(&plan) + .err() + .ok_or("a stale plan executed twice")?; + assert!( + matches!( + error, + FilesystemCompactionError::Refused(CompactionRefusal::NothingToCompact) + ), + "{error}" + ); + assert!(!sandbox.path().join("staging").join("current.seg").exists()); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn recovery_over_an_untouched_store_is_idle() -> Result<(), Box> { + let sandbox = mixed_store("compaction-recover-idle")?; + assert!(recovery_is_idle(sandbox.path())?); + assert_eq!(head_generation(sandbox.path())?, 2); + sandbox.remove()?; + Ok(()) +} diff --git a/src/adapters/compaction/interruption_tests.rs b/src/adapters/compaction/interruption_tests.rs new file mode 100644 index 00000000..05bf43a7 --- /dev/null +++ b/src/adapters/compaction/interruption_tests.rs @@ -0,0 +1,233 @@ +//! A compactor death injected before each publication phase leaves exactly +//! the documented residue, which recovers to one lawful state, after which +//! the same successor is reached. + +use std::error::Error; +use std::io; + +use super::test_fixture::{ + authority, gc_plan, head_generation, logical_view, mixed_store, observe, plan, +}; +use super::{ + CompactionRefusal, FilesystemCompactionError, plan_compaction, + recover_compaction_unchecked_for_tests, +}; +use crate::adapters::retention::filesystem_retention_test_fixture::catalog_policy; +use crate::adapters::{ + AdmittedSegment, CanonicalCatalog, CanonicalPublicationHead, CatalogPublicationExpectation, + CatalogPublicationPhase, CatalogPublicationReadiness, CatalogPublicationStorage, + CatalogSnapshot, ChecksummedCatalog, FilesystemCatalogPublisher, RecoveryStage, + SegmentPublication, publish_catalog_generation, +}; + +/// The complete publication order with one staged segment. +const PHASES: [CatalogPublicationPhase; 22] = [ + CatalogPublicationPhase::VerifyCurrent, + CatalogPublicationPhase::LinkSegment, + CatalogPublicationPhase::VerifySegmentPool, + CatalogPublicationPhase::SynchronizeSegments, + CatalogPublicationPhase::RemoveSegmentStage, + CatalogPublicationPhase::SynchronizeStagingAfterSegment, + CatalogPublicationPhase::CreateCatalogStage, + CatalogPublicationPhase::WriteCatalog, + CatalogPublicationPhase::FlushCatalog, + CatalogPublicationPhase::SynchronizeCatalog, + CatalogPublicationPhase::LinkCatalog, + CatalogPublicationPhase::VerifyCatalogPool, + CatalogPublicationPhase::SynchronizeCatalogs, + CatalogPublicationPhase::RemoveCatalogStage, + CatalogPublicationPhase::SynchronizeStagingAfterCatalog, + CatalogPublicationPhase::CreateHeadStage, + CatalogPublicationPhase::WriteHead, + CatalogPublicationPhase::FlushHead, + CatalogPublicationPhase::SynchronizeHead, + CatalogPublicationPhase::VerifyHeadView, + CatalogPublicationPhase::ReplaceHead, + CatalogPublicationPhase::SynchronizeRoot, +]; + +/// Delegates to the publisher and refuses exactly one phase. +struct FailingAt<'publisher> { + inner: &'publisher mut FilesystemCatalogPublisher, + failing: CatalogPublicationPhase, +} + +impl FailingAt<'_> { + fn gate(&self, phase: CatalogPublicationPhase) -> io::Result<()> { + if phase == self.failing { + Err(io::Error::other("injected process death")) + } else { + Ok(()) + } + } +} + +macro_rules! forward { + ($($name:ident => $phase:ident),* $(,)?) => { + $(fn $name(&mut self) -> io::Result<()> { + self.gate(CatalogPublicationPhase::$phase)?; + self.inner.$name() + })* + }; +} + +impl CatalogPublicationStorage for FailingAt<'_> { + fn verify_current( + &mut self, + expected: CatalogPublicationExpectation, + candidate: &CatalogSnapshot<'_, '_, '_>, + segment: &SegmentPublication<'_, '_>, + ) -> io::Result { + self.gate(CatalogPublicationPhase::VerifyCurrent)?; + self.inner.verify_current(expected, candidate, segment) + } + + fn link_segment(&mut self, segment: &AdmittedSegment<'_>) -> io::Result<()> { + self.gate(CatalogPublicationPhase::LinkSegment)?; + self.inner.link_segment(segment) + } + + fn verify_segment_pool(&mut self, segment: &AdmittedSegment<'_>) -> io::Result<()> { + self.gate(CatalogPublicationPhase::VerifySegmentPool)?; + self.inner.verify_segment_pool(segment) + } + + forward!( + synchronize_segments => SynchronizeSegments, + remove_segment_stage => RemoveSegmentStage, + synchronize_staging_after_segment => SynchronizeStagingAfterSegment, + create_catalog_stage => CreateCatalogStage, + flush_catalog => FlushCatalog, + synchronize_catalog => SynchronizeCatalog, + synchronize_catalogs => SynchronizeCatalogs, + remove_catalog_stage => RemoveCatalogStage, + synchronize_staging_after_catalog => SynchronizeStagingAfterCatalog, + create_head_stage => CreateHeadStage, + flush_head => FlushHead, + synchronize_head => SynchronizeHead, + replace_head => ReplaceHead, + synchronize_root => SynchronizeRoot, + ); + + fn write_catalog(&mut self, catalog: &CanonicalCatalog) -> io::Result<()> { + self.gate(CatalogPublicationPhase::WriteCatalog)?; + self.inner.write_catalog(catalog) + } + + fn link_catalog(&mut self, catalog: ChecksummedCatalog<'_>) -> io::Result<()> { + self.gate(CatalogPublicationPhase::LinkCatalog)?; + self.inner.link_catalog(catalog) + } + + fn verify_catalog_pool(&mut self, catalog: ChecksummedCatalog<'_>) -> io::Result<()> { + self.gate(CatalogPublicationPhase::VerifyCatalogPool)?; + self.inner.verify_catalog_pool(catalog) + } + + fn write_head(&mut self, head: &CanonicalPublicationHead) -> io::Result<()> { + self.gate(CatalogPublicationPhase::WriteHead)?; + self.inner.write_head(head) + } + + fn verify_head_view( + &mut self, + head: &CanonicalPublicationHead, + snapshot: &CatalogSnapshot<'_, '_, '_>, + ) -> io::Result<()> { + self.gate(CatalogPublicationPhase::VerifyHeadView)?; + self.inner.verify_head_view(head, snapshot) + } +} + +/// The residue a death before phase `index` leaves, and whether `HEAD` has +/// already advanced. +fn expected_recovery(index: usize) -> (Vec, bool, bool) { + match index { + 0..=4 => (vec![RecoveryStage::Segment], false, false), + 7..=13 => (vec![RecoveryStage::Catalog], false, false), + 16 => (vec![RecoveryStage::NextHead], false, false), + 17..=20 => (vec![], true, true), + 21 => (vec![], false, true), + _ => (vec![], false, false), + } +} + +#[test] +fn every_interrupted_publication_phase_recovers_to_one_lawful_state() -> Result<(), Box> +{ + for (index, phase) in PHASES.into_iter().enumerate() { + let sandbox = mixed_store(&format!("compaction-interrupt-{index}"))?; + let planned = plan(sandbox.path())?; + let before = logical_view(sandbox.path())?; + let mut compactor = authority(sandbox.path())?; + let error = compactor + .execute_with( + &planned, + &mut |publisher, expectation, segment, catalog, segments| { + let mut failing = FailingAt { + inner: publisher, + failing: phase, + }; + publish_catalog_generation( + &mut failing, + expectation, + segment, + catalog, + segments, + ) + }, + ) + .err() + .ok_or_else(|| format!("{phase:?}: injected death did not refuse"))?; + assert!( + matches!(error, FilesystemCompactionError::Publish(_)), + "{phase:?}: {error}" + ); + drop(compactor); + + // Readers still admit the store and every closure still verifies. + assert_eq!(logical_view(sandbox.path())?, before, "{phase:?}"); + + let (discarded, finalized, advanced) = expected_recovery(index); + let recovery = recover_compaction_unchecked_for_tests(sandbox.path(), catalog_policy()?)?; + assert_eq!(recovery.discarded(), discarded.as_slice(), "{phase:?}"); + assert_eq!(recovery.finalized().is_some(), finalized, "{phase:?}"); + assert!(!sandbox.path().join("head.next").exists(), "{phase:?}"); + assert!( + !sandbox.path().join("staging").join("current.seg").exists(), + "{phase:?}" + ); + assert!( + !sandbox.path().join("staging").join("current.cat").exists(), + "{phase:?}" + ); + assert_eq!( + head_generation(sandbox.path())?, + if advanced { 3 } else { 2 }, + "{phase:?}" + ); + assert_eq!(logical_view(sandbox.path())?, before, "{phase:?}"); + assert!( + recover_compaction_unchecked_for_tests(sandbox.path(), catalog_policy()?)?.was_idle() + ); + + if advanced { + assert!(matches!( + plan_compaction(&observe(sandbox.path())?), + Err(CompactionRefusal::NothingToCompact) + )); + } else { + let again = plan(sandbox.path())?; + assert_eq!( + again, planned, + "{phase:?}: the plan survives the interruption" + ); + let _receipt = authority(sandbox.path())?.execute(&again)?; + assert_eq!(head_generation(sandbox.path())?, 3, "{phase:?}"); + } + assert_eq!(logical_view(sandbox.path())?, before, "{phase:?}"); + assert_eq!(gc_plan(sandbox.path())?.candidate_count(), 1, "{phase:?}"); + sandbox.remove()?; + } + Ok(()) +} diff --git a/src/adapters/compaction/mod.rs b/src/adapters/compaction/mod.rs new file mode 100644 index 00000000..80354d02 --- /dev/null +++ b/src/adapters/compaction/mod.rs @@ -0,0 +1,31 @@ +//! Identity-preserving compaction for `keep.segment-store/v2`. +//! +//! Compaction copies every live record of a mixed segment (one the current +//! catalog names with at least one record no retained closure reaches) into +//! one new immutable segment, publishes a catalog successor that names the +//! copies and omits every unreachable record, and revalidates the view. The +//! old segments become `unreachable-superseded` for GC. `BlobId`, `ChunkId`, +//! and `LayoutId` never change: the successor names the same identities at +//! new locations. This module owns the observation, the pure planner, the +//! filesystem authority over the existing catalog publication protocol, and +//! the recovery driver for an interrupted successor. + +mod error; +mod filesystem; +#[cfg(test)] +mod filesystem_tests; +#[cfg(test)] +mod interruption_tests; +mod observation; +mod plan; +mod recovery; +#[cfg(test)] +mod test_fixture; + +pub use error::FilesystemCompactionError; +pub use filesystem::{CompactionPublish, CompactionReceipt, FilesystemCompactionAuthority}; +pub use observation::{CompactionObservation, observe_compaction}; +pub use plan::{CompactionPlan, CompactionRefusal, CompactionSegmentDisposition, plan_compaction}; +#[cfg(test)] +pub(in crate::adapters) use recovery::recover_compaction_unchecked_for_tests; +pub use recovery::{CompactionRecovery, FilesystemCompactionRecoveryError, recover_compaction}; diff --git a/src/adapters/compaction/observation.rs b/src/adapters/compaction/observation.rs new file mode 100644 index 00000000..40829b1a --- /dev/null +++ b/src/adapters/compaction/observation.rs @@ -0,0 +1,118 @@ +//! This boundary module assembles what compaction plans over: every record +//! the current catalog names with its segment, every record some retained +//! closure reaches, and the physical segment inventory. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use cap_fs_ext::DirExt; +use cap_std::fs::Dir; + +use crate::adapters::gc::{ + GcLivenessCoordinates, GcLivenessObservationError, GcRetentionState, visit_retained_closures, +}; +use crate::adapters::{ + CatalogRestartPolicy, FilesystemRetentionSnapshot, SegmentDigest, SegmentRecordIdentity, +}; + +/// Everything the compaction planner reads, observed under one view. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompactionObservation { + coordinates: GcLivenessCoordinates, + named: BTreeMap, + live: BTreeSet, + inventory: BTreeMap, +} + +impl CompactionObservation { + /// Builds an observation from its parts; the planner is pure over it. + pub const fn new( + coordinates: GcLivenessCoordinates, + named: BTreeMap, + live: BTreeSet, + inventory: BTreeMap, + ) -> Self { + Self { + coordinates, + named, + live, + inventory, + } + } + + /// The catalog and retention coordinates the observation binds. + #[must_use] + pub const fn coordinates(&self) -> GcLivenessCoordinates { + self.coordinates + } + + /// Every record the current catalog names, with the segment holding it. + #[must_use] + pub const fn named(&self) -> &BTreeMap { + &self.named + } + + /// Every record some retained closure reaches. + #[must_use] + pub const fn live(&self) -> &BTreeSet { + &self.live + } + + /// Every admitted segment-pool entry with its length. + #[must_use] + pub const fn inventory(&self) -> &BTreeMap { + &self.inventory + } +} + +/// Observes compaction inputs from one fenced (or writer-authority) view. +/// +/// # Errors +/// +/// Returns [`GcLivenessObservationError`] at the exact catalog, closure, +/// or pool refusal; nothing is planned from a partially admitted store. +pub fn observe_compaction( + store_root: &Path, + view: &FilesystemRetentionSnapshot, + policy: CatalogRestartPolicy, +) -> Result { + let catalog = view + .catalog() + .snapshot() + .map_err(|source| GcLivenessObservationError::Catalog { source })?; + let named = catalog + .record_segments() + .map_err(|source| GcLivenessObservationError::CatalogEntries { source })?; + let mut live = BTreeSet::new(); + visit_retained_closures(view, &catalog, |_namespace, _root, members| { + live.extend(members.identities.iter().copied()); + Ok(()) + })?; + let root = Dir::open_ambient_dir(store_root, cap_std::ambient_authority()) + .map_err(|source| GcLivenessObservationError::pool("open store root", source))?; + let segments = root + .open_dir_nofollow("segments") + .map_err(|source| GcLivenessObservationError::pool("open segment pool", source))?; + let inventory = crate::adapters::gc::read_segment_pool_inventory( + &segments, + policy.segment_read(), + policy.retained_segment_bytes().get(), + )? + .into_iter() + .collect(); + let retention = view + .retention_head() + .map_or(GcRetentionState::Empty, |head| { + GcRetentionState::Published { + generation: head.generation(), + manifest_digest: head.manifest_digest(), + } + }); + Ok(CompactionObservation::new( + GcLivenessCoordinates::new(catalog.generation(), catalog.catalog_digest(), retention), + named, + live, + inventory, + )) +} diff --git a/src/adapters/compaction/plan.rs b/src/adapters/compaction/plan.rs new file mode 100644 index 00000000..d6652d65 --- /dev/null +++ b/src/adapters/compaction/plan.rs @@ -0,0 +1,264 @@ +//! This boundary module owns the pure compaction planner: which named +//! segments are retained, compacted, or omitted, and what the successor +//! copies. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use super::CompactionObservation; +use crate::CatalogGeneration; +use crate::adapters::gc::{GcLivenessCoordinates, GcRetentionState}; +use crate::adapters::{SegmentDigest, SegmentRecordIdentity}; + +/// Fixed record framing: 112-byte header plus 32-byte checksum. +const RECORD_FRAMING: u64 = 144; +/// Segment header plus seal. +const SEGMENT_FRAMING: u64 = 192; +/// The version-1 segment ceilings the successor's new segment must respect. +const MAXIMUM_RECORD_COUNT: u64 = 1_048_576; +const MAXIMUM_SEGMENT_LENGTH: u64 = 1_073_741_824; + +/// What the successor does with one segment the current catalog names. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CompactionSegmentDisposition { + /// Every record is live: the successor names the segment unchanged. + Retained, + /// Live and unreachable records share it: the live records are copied + /// into the new segment and the unreachable ones omitted. + Compacted { + /// The live records to copy, in canonical identity order. + live: Vec, + /// How many named records the successor omits. + unreachable: u64, + }, + /// No record is live: the successor omits the whole segment. + Omitted { + /// How many named records the successor omits. + unreachable: u64, + }, +} + +/// One deterministic compaction plan over one observation. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompactionPlan { + coordinates: GcLivenessCoordinates, + successor_generation: CatalogGeneration, + segments: BTreeMap, + copied_records: u64, + copied_bytes: u64, + reclaimable_bytes: u64, +} + +impl CompactionPlan { + /// The coordinates the plan was computed under. + #[must_use] + pub const fn coordinates(&self) -> GcLivenessCoordinates { + self.coordinates + } + + /// The generation the successor catalog will carry. + pub const fn successor_generation(&self) -> CatalogGeneration { + self.successor_generation + } + + /// Every named segment's disposition, in digest order. + #[must_use] + pub const fn segments(&self) -> &BTreeMap { + &self.segments + } + + /// Segments the successor names unchanged. + pub fn retained(&self) -> impl Iterator + '_ { + self.segments + .iter() + .filter(|(_, disposition)| { + matches!(disposition, CompactionSegmentDisposition::Retained) + }) + .map(|(digest, _)| *digest) + } + + /// Segments the successor no longer names, in digest order. + pub fn superseded(&self) -> impl Iterator + '_ { + self.segments + .iter() + .filter(|(_, disposition)| { + !matches!(disposition, CompactionSegmentDisposition::Retained) + }) + .map(|(digest, _)| *digest) + } + + /// Every record the new segment copies, in canonical identity order. + pub fn copied(&self) -> impl Iterator + '_ { + self.segments + .values() + .flat_map(|disposition| match disposition { + CompactionSegmentDisposition::Compacted { live, .. } => live.as_slice(), + _ => &[], + }) + .copied() + } + + /// How many records the new segment copies; zero when the successor only + /// omits whole segments. + #[must_use] + pub const fn copied_records(&self) -> u64 { + self.copied_records + } + + /// The exact record bytes the new segment will hold. + #[must_use] + pub const fn copied_bytes(&self) -> u64 { + self.copied_bytes + } + + /// The pool bytes GC may reclaim once the successor is durable: the + /// complete length of every superseded segment. + #[must_use] + pub const fn reclaimable_bytes(&self) -> u64 { + self.reclaimable_bytes + } +} + +/// Why an observation yields no compaction plan. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CompactionRefusal { + /// No retention head is published: nothing is retained, so compaction + /// would omit every record; an operator publishes retention first. + RetentionNotPublished, + /// Every named record is live: nothing to compact is not a plan. + NothingToCompact, + /// A retained closure reaches a record the catalog does not name. + LiveRecordUnnamed { + /// The record. + identity: SegmentRecordIdentity, + }, + /// The catalog names a segment absent from the pool. + NamedSegmentMissing { + /// The segment. + segment: SegmentDigest, + }, + /// The copied records exceed one segment's ceilings. + CopyExceedsSegmentCeiling { + /// Records to copy. + records: u64, + /// Bytes to copy. + bytes: u64, + }, + /// The successor generation would overflow. + SuccessorGenerationOverflow, +} + +impl fmt::Display for CompactionRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RetentionNotPublished => { + formatter.write_str("no retention head is published; nothing is retained") + } + Self::NothingToCompact => formatter.write_str("every named record is live"), + Self::LiveRecordUnnamed { .. } => { + formatter.write_str("a retained closure reaches an unnamed record") + } + Self::NamedSegmentMissing { .. } => { + formatter.write_str("the catalog names a segment absent from the pool") + } + Self::CopyExceedsSegmentCeiling { records, bytes } => write!( + formatter, + "{records} records ({bytes} bytes) exceed one segment" + ), + Self::SuccessorGenerationOverflow => { + formatter.write_str("the successor generation overflows") + } + } + } +} + +impl std::error::Error for CompactionRefusal {} + +/// Plans one compaction over `observation`. Pure and deterministic. +/// +/// # Errors +/// +/// Returns [`CompactionRefusal`] when nothing lawful can be planned. +pub fn plan_compaction( + observation: &CompactionObservation, +) -> Result { + let coordinates = observation.coordinates(); + if coordinates.retention() == GcRetentionState::Empty { + return Err(CompactionRefusal::RetentionNotPublished); + } + if let Some(identity) = observation + .live() + .iter() + .find(|identity| !observation.named().contains_key(*identity)) + { + return Err(CompactionRefusal::LiveRecordUnnamed { + identity: *identity, + }); + } + let mut per_segment: BTreeMap, u64)> = + BTreeMap::new(); + for (identity, segment) in observation.named() { + let entry = per_segment.entry(*segment).or_default(); + if observation.live().contains(identity) { + entry.0.push(*identity); + } else { + entry.1 = entry.1.saturating_add(1); + } + } + let mut segments = BTreeMap::new(); + let (mut copied_records, mut copied_bytes, mut reclaimable_bytes) = (0_u64, 0_u64, 0_u64); + for (segment, (live, unreachable)) in per_segment { + let length = *observation + .inventory() + .get(&segment) + .ok_or(CompactionRefusal::NamedSegmentMissing { segment })?; + let disposition = if unreachable == 0 { + CompactionSegmentDisposition::Retained + } else { + reclaimable_bytes = reclaimable_bytes.saturating_add(length); + if live.is_empty() { + CompactionSegmentDisposition::Omitted { unreachable } + } else { + for identity in &live { + copied_records = copied_records.saturating_add(1); + copied_bytes = copied_bytes + .saturating_add(identity.payload_length().saturating_add(RECORD_FRAMING)); + } + CompactionSegmentDisposition::Compacted { live, unreachable } + } + }; + segments.insert(segment, disposition); + } + if segments + .values() + .all(|disposition| matches!(disposition, CompactionSegmentDisposition::Retained)) + { + return Err(CompactionRefusal::NothingToCompact); + } + if copied_records > MAXIMUM_RECORD_COUNT + || copied_bytes.saturating_add(SEGMENT_FRAMING) > MAXIMUM_SEGMENT_LENGTH + { + return Err(CompactionRefusal::CopyExceedsSegmentCeiling { + records: copied_records, + bytes: copied_bytes, + }); + } + let successor_generation = coordinates + .catalog_generation() + .successor() + .map_err(|_source| CompactionRefusal::SuccessorGenerationOverflow)?; + Ok(CompactionPlan { + coordinates, + successor_generation, + segments, + copied_records, + copied_bytes, + reclaimable_bytes, + }) +} + +/// A set view of the plan's superseded segments, for revalidation. +pub(super) fn superseded_set(plan: &CompactionPlan) -> BTreeSet { + plan.superseded().collect() +} diff --git a/src/adapters/compaction/recovery.rs b/src/adapters/compaction/recovery.rs new file mode 100644 index 00000000..2e66d404 --- /dev/null +++ b/src/adapters/compaction/recovery.rs @@ -0,0 +1,354 @@ +//! This module owns recovery of an interrupted compaction successor on a +//! version-two root: the same three fixed stages the version-one recovery +//! protocols own, driven to one lawful state. + +use std::error::Error; +use std::fmt; +use std::io; +use std::path::Path; + +use cap_std::fs::Dir; + +use crate::CatalogGeneration; +use crate::adapters::filesystem_catalog_artifact::synchronize_directory; +use crate::adapters::filesystem_exact_record as exact_record; +use crate::adapters::{ + AdmittedSegment, CatalogPublicationExpectation, CatalogRestartPolicy, ChecksummedCatalog, + FilesystemRecoveryNextHeadFinalizer, FilesystemRecoveryStageDiscarder, RecoveryCatalogStage, + RecoveryNextHeadStage, RecoverySegmentStage, RecoveryStage, RecoveryStageAssessment, + RecoveryStageMetadata, RecoveryStageParent, admit_recovery_stage_bytes, assess_recovery_stage, + catalog_restart_loader, execute_recovery_next_head_finalization, + execute_recovery_stage_discard, fingerprint_recovery_stage, + plan_recovery_next_head_finalization, plan_recovery_stage_discard, +}; + +const SEGMENT_STAGE: &str = "current.seg"; +const CATALOG_STAGE: &str = "current.cat"; +const NEXT_HEAD: &str = "head.next"; +const HEAD: &str = "HEAD"; + +/// What recovery found and did. +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompactionRecovery { + discarded: Vec, + finalized: Option, +} + +impl CompactionRecovery { + /// The stages discarded, in the order examined. + #[must_use] + pub fn discarded(&self) -> &[RecoveryStage] { + &self.discarded + } + + /// The generation a complete `head.next` was finalized to, if any. + #[must_use] + pub const fn finalized(&self) -> Option { + self.finalized + } + + /// Whether the store held no residue at all. + #[must_use] + pub const fn was_idle(&self) -> bool { + self.discarded.is_empty() && self.finalized.is_none() + } +} + +/// Why recovery refused; the store is left as found. +#[derive(Debug)] +pub struct FilesystemCompactionRecoveryError { + phase: &'static str, + source: Box, +} + +impl fmt::Display for FilesystemCompactionRecoveryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "compaction recovery refused at {}", self.phase) + } +} + +impl Error for FilesystemCompactionRecoveryError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(self.source.as_ref()) + } +} + +fn refused( + phase: &'static str, + source: impl Error + Send + Sync + 'static, +) -> FilesystemCompactionRecoveryError { + FilesystemCompactionRecoveryError { + phase, + source: Box::new(source), + } +} + +/// Recovers an interrupted compaction on the version-two root at +/// `store_root`, acquiring writer authority for the duration. +/// +/// A retained `staging/current.seg` or `staging/current.cat` is discarded +/// (nothing published references it); a retained `head.next` is finalized +/// when it is complete and the exact successor of `HEAD`, and discarded +/// otherwise. Every step runs the version-one recovery protocol it belongs +/// to, with its evidence binding. +/// +/// # Errors +/// +/// Returns [`FilesystemCompactionRecoveryError`] at the exact open, +/// assessment, planning, or execution refusal. +pub fn recover_compaction( + store_root: &Path, + policy: CatalogRestartPolicy, +) -> Result { + let discarder = FilesystemRecoveryStageDiscarder::open_version_two(store_root) + .map_err(|source| refused("open", source))?; + recover_with(discarder, policy) +} + +#[cfg(test)] +pub(in crate::adapters) fn recover_compaction_unchecked_for_tests( + store_root: &Path, + policy: CatalogRestartPolicy, +) -> Result { + let discarder = + FilesystemRecoveryStageDiscarder::open_unchecked_version_two_for_tests(store_root) + .map_err(|source| refused("open", source))?; + recover_with(discarder, policy) +} + +fn recover_with( + mut discarder: FilesystemRecoveryStageDiscarder, + policy: CatalogRestartPolicy, +) -> Result { + let mut recovery = CompactionRecovery { + discarded: Vec::new(), + finalized: None, + }; + for (stage, name) in [ + (RecoveryStage::Segment, SEGMENT_STAGE), + (RecoveryStage::Catalog, CATALOG_STAGE), + ] { + if let Some(bytes) = read_stage(&discarder, RecoveryStageParent::Staging, name)? { + resolve_staging(&mut discarder, stage, name, &bytes, policy)?; + recovery.discarded.push(stage); + } + } + let Some(bytes) = read_stage(&discarder, RecoveryStageParent::Root, NEXT_HEAD)? else { + return Ok(recovery); + }; + let admitted = admitted_stage(RecoveryStage::NextHead, &bytes)?; + let assessment = assess_recovery_stage(&admitted, policy.segment_read()) + .map_err(|source| refused("assess head.next", source))?; + let complete = matches!( + assessment, + RecoveryStageAssessment::NextHead { + state: RecoveryNextHeadStage::Complete(_), + .. + } + ); + if !complete { + let request = plan_recovery_stage_discard(&assessment) + .map_err(|source| refused("plan head.next discard", source))?; + let _receipt = execute_recovery_stage_discard(&mut discarder, request) + .map_err(|source| refused("discard head.next", source))?; + recovery.discarded.push(RecoveryStage::NextHead); + return Ok(recovery); + } + let root = discarder + .inventory + .parent_directory(RecoveryStageParent::Root) + .try_clone() + .map_err(|source| refused("clone root", source))?; + let candidate = catalog_restart_loader::load_from_directory(&root, NEXT_HEAD, policy) + .map_err(|source| refused("load head.next successor", source))?; + let candidate_snapshot = candidate + .snapshot() + .map_err(|source| refused("admit head.next successor", source))?; + let expectation = match catalog_restart_loader::load_from_directory(&root, HEAD, policy) { + Ok(current) => { + let snapshot = current + .snapshot() + .map_err(|source| refused("admit current catalog", source))?; + CatalogPublicationExpectation::successor_of(&snapshot) + } + Err(_absent) => CatalogPublicationExpectation::uninitialized(), + }; + let request = + plan_recovery_next_head_finalization(&assessment, &candidate_snapshot, expectation) + .map_err(|source| refused("plan head.next finalization", source))?; + let mut finalizer = FilesystemRecoveryNextHeadFinalizer { discarder, policy }; + let _receipt = execute_recovery_next_head_finalization(&mut finalizer, request) + .map_err(|source| refused("finalize head.next", source))?; + recovery.finalized = Some(candidate_snapshot.generation()); + Ok(recovery) +} + +fn read_stage( + discarder: &FilesystemRecoveryStageDiscarder, + parent: RecoveryStageParent, + name: &str, +) -> Result>, FilesystemCompactionRecoveryError> { + let directory = discarder.inventory.parent_directory(parent); + let mut file = match crate::adapters::filesystem_exact_record::open_read(directory, name) { + Ok(file) => file, + Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(source) => return Err(refused("open stage", source)), + }; + let mut bytes = Vec::new(); + std::io::Read::read_to_end(&mut file, &mut bytes) + .map_err(|source| refused("read stage", source))?; + Ok(Some(bytes)) +} + +fn admitted_stage( + stage: RecoveryStage, + bytes: &[u8], +) -> Result, FilesystemCompactionRecoveryError> { + let length = u64::try_from(bytes.len()).map_err(|source| refused("stage length", source))?; + let metadata = RecoveryStageMetadata::new(stage, length) + .map_err(|source| refused("stage metadata", source))?; + let evidence = fingerprint_recovery_stage(metadata, bytes) + .map_err(|source| refused("fingerprint stage", source))?; + admit_recovery_stage_bytes(stage, evidence, bytes) + .map_err(|source| refused("admit stage bytes", source)) +} + +/// Resolves one staging residue: a truncated stage through the version-one +/// discard protocol; a complete or reusable stage through compaction's own +/// evidence-bound discard, after proving it carries nothing the current +/// catalog does not already name. +fn resolve_staging( + discarder: &mut FilesystemRecoveryStageDiscarder, + stage: RecoveryStage, + name: &str, + bytes: &[u8], + policy: CatalogRestartPolicy, +) -> Result<(), FilesystemCompactionRecoveryError> { + let admitted = admitted_stage(stage, bytes)?; + let assessment = assess_recovery_stage(&admitted, policy.segment_read()) + .map_err(|source| refused("assess stage", source))?; + let derivable = match &assessment { + RecoveryStageAssessment::Segment { + state: RecoverySegmentStage::Complete(segment), + .. + } => { + require_derivable_segment(discarder, segment, policy)?; + true + } + RecoveryStageAssessment::Segment { + state: RecoverySegmentStage::Reusable(_), + .. + } => true, + RecoveryStageAssessment::Catalog { + state: RecoveryCatalogStage::Complete(catalog), + .. + } => { + require_successor_candidate(discarder, catalog, policy)?; + true + } + _ => false, + }; + if derivable { + return discard_derivable(discarder, name, bytes); + } + let request = plan_recovery_stage_discard(&assessment) + .map_err(|source| refused("plan stage discard", source))?; + let _receipt = execute_recovery_stage_discard(discarder, request) + .map_err(|source| refused("discard stage", source))?; + Ok(()) +} + +const fn root_directory(discarder: &FilesystemRecoveryStageDiscarder) -> &Dir { + discarder + .inventory + .parent_directory(RecoveryStageParent::Root) +} + +/// A complete staged segment is derivable when the current catalog names +/// every record it holds with byte-identical content: it is a copy the next +/// compaction reproduces exactly. +fn require_derivable_segment( + discarder: &FilesystemRecoveryStageDiscarder, + segment: &AdmittedSegment<'_>, + policy: CatalogRestartPolicy, +) -> Result<(), FilesystemCompactionRecoveryError> { + let current = + catalog_restart_loader::load_from_directory(root_directory(discarder), HEAD, policy) + .map_err(|source| refused("load current catalog", source))?; + let snapshot = current + .snapshot() + .map_err(|source| refused("admit current catalog", source))?; + for record in segment.records() { + let record = record.map_err(|source| refused("reread staged record", source))?; + let named = snapshot.record(record.identity()).ok_or_else(|| { + refused( + "derivable segment", + io::Error::other("a staged record is not named"), + ) + })?; + if named.header() != record.header() + || named.payload() != record.payload() + || named.checksum() != record.checksum() + { + return Err(refused( + "derivable segment", + io::Error::other("a staged record differs from the named record"), + )); + } + } + Ok(()) +} + +/// A complete staged catalog is derivable when it is exactly the successor +/// candidate of the current head: never published, reproduced by the next +/// compaction. +fn require_successor_candidate( + discarder: &FilesystemRecoveryStageDiscarder, + catalog: &ChecksummedCatalog<'_>, + policy: CatalogRestartPolicy, +) -> Result<(), FilesystemCompactionRecoveryError> { + let current = + catalog_restart_loader::load_from_directory(root_directory(discarder), HEAD, policy) + .map_err(|source| refused("load current catalog", source))?; + let successor = current + .generation() + .successor() + .map_err(|source| refused("successor generation", source))?; + if catalog.generation() == successor + && catalog.previous_catalog_digest() == Some(current.catalog_digest()) + { + Ok(()) + } else { + Err(refused( + "successor candidate", + io::Error::other("the staged catalog is not the current head's successor"), + )) + } +} + +/// Unlinks one derivable stage only while its bytes are exactly as assessed, +/// and synchronizes `staging`. +fn discard_derivable( + discarder: &FilesystemRecoveryStageDiscarder, + name: &str, + expected: &[u8], +) -> Result<(), FilesystemCompactionRecoveryError> { + let staging = discarder + .inventory + .parent_directory(RecoveryStageParent::Staging); + let observed = read_stage(discarder, RecoveryStageParent::Staging, name)? + .ok_or_else(|| refused("discard stage", io::Error::other("the stage vanished")))?; + if observed != expected { + return Err(refused( + "discard stage", + io::Error::other("the stage changed after assessment"), + )); + } + staging + .remove_file(name) + .map_err(|source| refused("discard stage", source))?; + exact_record::require_absent(staging, name) + .map_err(|source| refused("discard stage", io::Error::other(source.to_string())))?; + synchronize_directory(staging).map_err(|source| refused("synchronize staging", source)) +} diff --git a/src/adapters/compaction/test_fixture.rs b/src/adapters/compaction/test_fixture.rs new file mode 100644 index 00000000..7e263dcd --- /dev/null +++ b/src/adapters/compaction/test_fixture.rs @@ -0,0 +1,219 @@ +//! Shared fixtures for the compaction laws: a migrated store holding one +//! mixed segment, and the logical view compaction must leave untouched. + +use std::collections::BTreeMap; +use std::error::Error; +use std::fs; +use std::path::Path; + +use super::{ + CompactionObservation, CompactionPlan, FilesystemCompactionAuthority, observe_compaction, + plan_compaction, +}; +use crate::adapters::filesystem_test_sandbox::TestDirectory; +use crate::adapters::gc::{GcLimits, GcPlan, observe_gc_liveness, plan_gc}; +use crate::adapters::retention::filesystem_retention_test_fixture::{ + catalog_policy, migrated_store, reopen_authority, +}; +use crate::adapters::{ + AdmittedRetentionRoot, AdmittedSegment, AdmittedSegmentRecord, CanonicalCatalog, + CanonicalRetentionRoot, CatalogPublicationExpectation, FilesystemCatalogPublisher, + FilesystemCatalogSnapshot, FilesystemRetentionSnapshot, FilesystemVersionTwoAdmission, + ReaderAttemptLimit, SegmentRecordIdentity, SegmentRecordLimit, StagedSegment, + publish_catalog_generation, +}; +use crate::{ + AdmittedLayout, BlobHasher, BlobId, CanonicalLayoutRecord, CatalogGeneration, FastCdc, + LayoutEntryLimit, LayoutId, RegisteredRetentionProfile, RegisteredStorageProfile, + RetentionAnchor, RetentionClosureLimits, RetentionGenerationExpectation, RetentionNamespace, + RetentionPolicy, RetentionRoot, RootGeneration, execute_retention_publication, + preflight_retention_transition, prepare_retention_publication, +}; + +/// Every retained closure's root digest and member identities, and every +/// live record's exact bytes. +pub(super) type LogicalView = (Closures, BTreeMap>); + +pub(super) fn identify( + bytes: &[u8], +) -> Result<(BlobId, LayoutId, CanonicalLayoutRecord), Box> { + let mut hasher = BlobHasher::new(); + hasher.update(bytes)?; + let mut detector = FastCdc::new(); + let mut spans = Vec::new(); + detector.feed(bytes, |span| spans.push(span))?; + if let Some(span) = detector.finish()? { + spans.push(span); + } + let layout = AdmittedLayout::from_spans( + hasher.finish(), + RegisteredStorageProfile::FAST_CDC_64K_V1, + spans, + LayoutEntryLimit::MAXIMUM, + )?; + let record = layout.encode_record()?; + Ok((layout.target(), record.id(), record)) +} + +pub(super) fn pool_segment_bytes(root: &Path) -> Result>, Box> { + let mut bytes = Vec::new(); + for entry in fs::read_dir(root.join("segments"))? { + bytes.push(fs::read(entry?.path())?); + } + Ok(bytes) +} + +/// A migrated store whose second catalog generation adds a segment holding +/// blob `[1]`'s chunk and layout beside an unanchored chunk `[2]`, with +/// retention generation one anchoring blobs `[0]` and `[1]`. +pub(super) fn mixed_store(name: &str) -> Result> { + let sandbox = migrated_store(name)?; + let policy = catalog_policy()?; + let bundle_bytes = pool_segment_bytes(sandbox.path())? + .pop() + .ok_or("the migrated store has no segment")?; + let admission = FilesystemVersionTwoAdmission::reopen_unchecked_for_tests(sandbox.path())?; + let mut publisher = FilesystemCatalogPublisher::open_version_two(admission, policy)?; + let current = FilesystemCatalogSnapshot::load(sandbox.path(), policy)?; + let snapshot = current.snapshot()?; + let (_, _, layout_one) = identify(&[1])?; + let sealed = StagedSegment::begin( + publisher.create_segment_stage()?, + SegmentRecordLimit::MAXIMUM, + )? + .append(AdmittedSegmentRecord::for_chunk(&[1])?)? + .append(AdmittedSegmentRecord::for_layout(&layout_one)?)? + .append(AdmittedSegmentRecord::for_chunk(&[2])?)? + .seal()?; + let staged_bytes = fs::read(sandbox.path().join("staging").join("current.seg"))?; + let bundle = AdmittedSegment::decode(&bundle_bytes, policy.segment_read())?; + let second = AdmittedSegment::decode(&staged_bytes, policy.segment_read())?; + let segments = [bundle, second]; + let staged_segment = segments.get(1).ok_or("staged segment")?; + let selection = publisher.select_segment(sealed, staged_segment)?; + let successor = CanonicalCatalog::from_segments( + CatalogGeneration::new(2)?, + Some(snapshot.catalog_digest()), + &segments, + )?; + let _receipt = publish_catalog_generation( + &mut publisher, + CatalogPublicationExpectation::successor_of(&snapshot), + selection, + &successor, + &segments, + )?; + drop(snapshot); + drop(current); + drop(publisher); + publish_two_anchor_root(sandbox.path())?; + Ok(sandbox) +} + +pub(super) fn publish_two_anchor_root(root: &Path) -> Result<(), Box> { + let (blob_zero, layout_zero, _) = identify(&[0])?; + let (blob_one, layout_one, _) = identify(&[1])?; + let retention_root = RetentionRoot::new( + RetentionNamespace::try_from(vec![0x2f])?, + RootGeneration::new(1)?, + RetentionPolicy::new( + RegisteredRetentionProfile::SINGLE_CANONICAL_WITNESS_V1, + RetentionClosureLimits::new(1024, 8, 1 << 20, 1 << 24)?, + ), + None, + vec![ + RetentionAnchor::new(blob_zero, layout_zero), + RetentionAnchor::new(blob_one, layout_one), + ], + )?; + let root_bytes = CanonicalRetentionRoot::from_root(&retention_root)? + .encoded() + .to_vec(); + let policy = catalog_policy()?; + let current = FilesystemCatalogSnapshot::load(root, policy)?; + let snapshot = current.snapshot()?; + let candidate = AdmittedRetentionRoot::decode(&root_bytes)?; + let preflight = preflight_retention_transition( + RetentionGenerationExpectation::Absent, + None, + candidate, + &snapshot, + )?; + let preparation = prepare_retention_publication(preflight, None)?; + let mut authority = reopen_authority(root)?; + let _published = execute_retention_publication(&mut authority, &preparation)?; + Ok(()) +} + +pub(super) fn observe(root: &Path) -> Result> { + let view = + FilesystemRetentionSnapshot::load(root, catalog_policy()?, ReaderAttemptLimit::DEFAULT)?; + observe_compaction(root, &view, catalog_policy()?).map_err(Into::into) +} + +pub(super) fn plan(root: &Path) -> Result> { + plan_compaction(&observe(root)?).map_err(Into::into) +} + +pub(super) fn authority(root: &Path) -> Result> { + let admission = FilesystemVersionTwoAdmission::reopen_unchecked_for_tests(root)?; + FilesystemCompactionAuthority::open(admission, root, catalog_policy()?).map_err(Into::into) +} + +pub(super) fn gc_plan(root: &Path) -> Result> { + let view = + FilesystemRetentionSnapshot::load(root, catalog_policy()?, ReaderAttemptLimit::DEFAULT)?; + let liveness = observe_gc_liveness(root, &view, catalog_policy()?)?; + Ok(plan_gc(&liveness, GcLimits::MAXIMUM)?) +} + +/// Every retained closure's root digest and member identities, and every +/// live record's exact bytes: the logical view compaction must leave +/// untouched. The closure *digest* is a transcript naming the physical +/// catalog coordinate, so it changes with every catalog successor by design. +pub(super) type Closures = BTreeMap<[u8; 32], ([u8; 32], Vec)>; + +pub(super) fn logical_view(root: &Path) -> Result> { + let view = + FilesystemRetentionSnapshot::load(root, catalog_policy()?, ReaderAttemptLimit::DEFAULT)?; + let catalog_view = view.catalog().snapshot()?; + let mut closures = Closures::new(); + crate::adapters::gc::visit_retained_closures( + &view, + &catalog_view, + |namespace, root, members| { + closures.insert( + *namespace.as_bytes(), + ( + *root.digest().as_bytes(), + members.identities.iter().copied().collect(), + ), + ); + Ok(()) + }, + )?; + drop(catalog_view); + let observation = observe_compaction(root, &view, catalog_policy()?)?; + let catalog = FilesystemCatalogSnapshot::load(root, catalog_policy()?)?; + let snapshot = catalog.snapshot()?; + let mut records = BTreeMap::new(); + for identity in observation.live() { + let record = snapshot.record(*identity).ok_or("live record unnamed")?; + let mut bytes = record.header().encode().to_vec(); + bytes.extend_from_slice(record.payload()); + bytes.extend_from_slice(record.checksum().as_bytes()); + records.insert(*identity, bytes); + } + Ok((closures, records)) +} + +pub(super) fn head_generation(root: &Path) -> Result> { + Ok(FilesystemCatalogSnapshot::load(root, catalog_policy()?)? + .generation() + .get()) +} + +/// Runs recovery over `root` and reports whether it found no residue. +pub(super) fn recovery_is_idle(root: &Path) -> Result> { + Ok(super::recover_compaction_unchecked_for_tests(root, catalog_policy()?)?.was_idle()) +} diff --git a/src/adapters/exports.rs b/src/adapters/exports.rs index 9eefedf3..b2b69199 100644 --- a/src/adapters/exports.rs +++ b/src/adapters/exports.rs @@ -36,6 +36,7 @@ pub use super::checksummed_catalog::ChecksummedCatalog; pub use super::checksummed_publication_head::ChecksummedPublicationHead; pub use super::checksummed_segment_record::ChecksummedSegmentRecord; pub use super::closed_segment::ClosedSegment; +pub use super::compaction::*; pub use super::filesystem_catalog_publication_error::FilesystemCatalogPublicationError; pub use super::filesystem_catalog_publisher::FilesystemCatalogPublisher; pub use super::filesystem_catalog_snapshot::FilesystemCatalogSnapshot; diff --git a/src/adapters/filesystem_catalog_publisher.rs b/src/adapters/filesystem_catalog_publisher.rs index 16999a26..88d6ae9b 100644 --- a/src/adapters/filesystem_catalog_publisher.rs +++ b/src/adapters/filesystem_catalog_publisher.rs @@ -4,6 +4,7 @@ use std::io; use cap_std::fs::{Dir, File}; +use super::FilesystemVersionTwoAdmission; use super::filesystem_publisher_authority::FilesystemPublisherAuthority; use super::{ AdmittedSegment, CatalogRestartPolicy, ClosedSegment, FilesystemPlatformAdmission, @@ -56,7 +57,25 @@ impl FilesystemCatalogPublisher { admission: FilesystemPlatformAdmission, policy: CatalogRestartPolicy, ) -> io::Result { - let lock = admission.into_lock(); + Self::from_lock(admission.into_lock(), policy) + } + + /// Pins the publication directories of a completely migrated version-two + /// root, for catalog successors such as compaction. The version-two + /// admission already proved the namespace, marker, intent, and receipt. + /// + /// # Errors + /// + /// As [`Self::open`]. + pub fn open_version_two( + admission: FilesystemVersionTwoAdmission, + policy: CatalogRestartPolicy, + ) -> io::Result { + let (lock, _retention, _roots, _manifests) = admission.into_parts(); + Self::from_lock(lock, policy) + } + + fn from_lock(lock: FilesystemWriterLock, policy: CatalogRestartPolicy) -> io::Result { let pinned_root = lock.clone_directory()?; let root = sync_capable_directory::open(&pinned_root, ".")?; let staging = sync_capable_directory::open(&root, "staging")?; diff --git a/src/adapters/filesystem_initialization_namespace.rs b/src/adapters/filesystem_initialization_namespace.rs index f7e9d19e..f3d1a903 100644 --- a/src/adapters/filesystem_initialization_namespace.rs +++ b/src/adapters/filesystem_initialization_namespace.rs @@ -42,7 +42,8 @@ const ROOTS_NAME: &str = "roots"; const MANIFESTS_NAME: &str = "manifests"; const DISPOSITIONS_NAME: &str = "dispositions"; const DISPOSITION_STAGE_NAME: &str = "disposition.next"; -const VERSION_TWO_NAMES: [&str; 12] = [ +const NEXT_HEAD_NAME: &str = "head.next"; +const VERSION_TWO_NAMES: [&str; 13] = [ LOCK_NAME, STAGING_NAME, SEGMENTS_NAME, @@ -55,6 +56,7 @@ const VERSION_TWO_NAMES: [&str; 12] = [ RETENTION_NAME, GC_NAME, RECOVERY_NAME, + NEXT_HEAD_NAME, ]; pub(super) fn admit(directory: &Dir) -> io::Result<()> { @@ -111,6 +113,9 @@ pub(super) fn admit_version_two(directory: &Dir) -> io::Result<()> { admit_required_directory(directory, RETENTION_NAME)?; admit_required_directory(directory, GC_NAME)?; admit_required_directory(directory, RECOVERY_NAME)?; + // A retained `head.next` is a catalog successor's recovery-required + // residue: admitted here, refused by every publication until recovered. + admit_optional_file(directory, NEXT_HEAD_NAME)?; admit_membership(directory, &VERSION_TWO_NAMES)?; admit_version_two_protocol_directories(directory) } diff --git a/src/adapters/filesystem_recovery_inventory_reader.rs b/src/adapters/filesystem_recovery_inventory_reader.rs index 392522fb..5d212114 100644 --- a/src/adapters/filesystem_recovery_inventory_reader.rs +++ b/src/adapters/filesystem_recovery_inventory_reader.rs @@ -71,7 +71,20 @@ impl FilesystemRecoveryInventoryReader { } pub(super) fn from_root(root: Dir) -> Result { - filesystem_initialization_namespace::admit_recoverable(&root).map_err(|source| { + Self::from_root_with(root, filesystem_initialization_namespace::admit_recoverable) + } + + /// Pins a completely migrated version-two root: the version-two namespace + /// is admitted and its extra root entries are inert to recovery. + pub(super) fn from_root_version_two(root: Dir) -> Result { + Self::from_root_with(root, filesystem_initialization_namespace::admit_version_two) + } + + fn from_root_with( + root: Dir, + admit: fn(&Dir) -> std::io::Result<()>, + ) -> Result { + admit(&root).map_err(|source| { RecoveryInventoryError::io( RecoveryNamespace::Root, RecoveryInventoryOperation::OpenNamespace, diff --git a/src/adapters/filesystem_recovery_next_head_finalizer.rs b/src/adapters/filesystem_recovery_next_head_finalizer.rs index 1f1daeb0..df0a7a6d 100644 --- a/src/adapters/filesystem_recovery_next_head_finalizer.rs +++ b/src/adapters/filesystem_recovery_next_head_finalizer.rs @@ -40,6 +40,21 @@ impl FilesystemRecoveryNextHeadFinalizer { .map_err(Into::into) } + /// Opens a completely migrated version-two store for `head.next` + /// finalization. + /// + /// # Errors + /// + /// As [`Self::open`]. + pub fn open_version_two( + store_root: &Path, + policy: CatalogRestartPolicy, + ) -> Result { + FilesystemRecoveryStageDiscarder::open_version_two(store_root) + .map(|discarder| Self { discarder, policy }) + .map_err(Into::into) + } + #[cfg(test)] pub(super) fn open_unchecked_for_tests( store_root: &Path, diff --git a/src/adapters/filesystem_recovery_stage_discarder.rs b/src/adapters/filesystem_recovery_stage_discarder.rs index e29cb928..6df9bbb7 100644 --- a/src/adapters/filesystem_recovery_stage_discarder.rs +++ b/src/adapters/filesystem_recovery_stage_discarder.rs @@ -40,6 +40,21 @@ impl FilesystemRecoveryStageDiscarder { Self::from_root(root) } + /// Opens a completely migrated version-two store for explicit stage + /// discard: the same protocol over the same fixed stage names, with the + /// version-two root entries admitted as inert. + /// + /// # Errors + /// + /// As [`Self::open`]. + pub fn open_version_two( + store_root: &Path, + ) -> Result { + let root = filesystem_platform_profile::open_version_two(store_root) + .map_err(|source| FilesystemRecoveryStageDiscardOpenError::Platform { source })?; + Self::from_root_with(root, true) + } + #[cfg(test)] pub(super) fn open_unchecked_for_tests( store_root: &Path, @@ -47,6 +62,22 @@ impl FilesystemRecoveryStageDiscarder { Self::open_unchecked(store_root) } + #[cfg(test)] + pub(super) fn open_unchecked_version_two_for_tests( + store_root: &Path, + ) -> Result { + let root = Dir::open_ambient_dir(store_root, ambient_authority()).map_err(|source| { + FilesystemRecoveryStageDiscardOpenError::Namespace { + source: RecoveryInventoryError::io( + RecoveryNamespace::Root, + RecoveryInventoryOperation::OpenNamespace, + source, + ), + } + })?; + Self::from_root_with(root, true) + } + /// Opens repository crash-test storage without the production platform /// profile probe. /// @@ -76,13 +107,24 @@ impl FilesystemRecoveryStageDiscarder { } fn from_root(root: Dir) -> Result { + Self::from_root_with(root, false) + } + + fn from_root_with( + root: Dir, + version_two: bool, + ) -> Result { let authority = FilesystemWriterLock::try_acquire_in(root) .map_err(|source| FilesystemRecoveryStageDiscardOpenError::WriterLock { source })?; let inventory_root = authority .clone_directory() .map_err(|source| FilesystemRecoveryStageDiscardOpenError::CloneRoot { source })?; - let inventory = FilesystemRecoveryInventoryReader::from_root(inventory_root) - .map_err(|source| FilesystemRecoveryStageDiscardOpenError::Namespace { source })?; + let inventory = if version_two { + FilesystemRecoveryInventoryReader::from_root_version_two(inventory_root) + } else { + FilesystemRecoveryInventoryReader::from_root(inventory_root) + } + .map_err(|source| FilesystemRecoveryStageDiscardOpenError::Namespace { source })?; Ok(Self { inventory, _authority: authority, diff --git a/src/adapters/gc/liveness_observation.rs b/src/adapters/gc/liveness_observation.rs index 4688aa74..d6db4e70 100644 --- a/src/adapters/gc/liveness_observation.rs +++ b/src/adapters/gc/liveness_observation.rs @@ -21,7 +21,8 @@ use super::{ GcRetainedClosure, GcRetentionState, }; use crate::adapters::retention::{ - AdmittedRetentionRoot, is_disposition_name, verify_retention_closure_members, + AdmittedRetentionRoot, RetentionClosureMembers, is_disposition_name, + verify_retention_closure_members, }; use crate::adapters::{ CatalogRestartArtifact, CatalogRestartPhase, CatalogRestartPolicy, CatalogSnapshot, @@ -177,11 +178,20 @@ fn retention_state(view: &FilesystemRetentionSnapshot) -> GcRetentionState { }) } -fn retain_closures( +/// Decodes and verifies every retained root's closure against `catalog`, +/// handing each to `visit` with its resolved member identities. +/// +/// # Errors +/// +/// Returns the exact retained-root, closure, or visitor refusal. +pub(in crate::adapters) fn visit_retained_closures( view: &FilesystemRetentionSnapshot, catalog: &CatalogSnapshot<'_, '_, '_>, - record_segments: &BTreeMap, - snapshot: &mut GcLivenessSnapshot, + mut visit: impl FnMut( + crate::RetentionNamespaceDigest, + &AdmittedRetentionRoot<'_>, + &RetentionClosureMembers, + ) -> Result<(), Error>, ) -> Result<(), Error> { let Some(manifest) = view.manifest() else { return Ok(()); @@ -205,6 +215,18 @@ fn retain_closures( source: Box::new(source), } })?; + visit(namespace, &root, &members)?; + } + Ok(()) +} + +fn retain_closures( + view: &FilesystemRetentionSnapshot, + catalog: &CatalogSnapshot<'_, '_, '_>, + record_segments: &BTreeMap, + snapshot: &mut GcLivenessSnapshot, +) -> Result<(), Error> { + visit_retained_closures(view, catalog, |namespace, root, members| { let mut segments = BTreeSet::new(); for identity in &members.identities { let segment = record_segments @@ -220,9 +242,8 @@ fn retain_closures( members.closure.digest(), segments, )) - .map_err(|source| Error::Snapshot { source })?; - } - Ok(()) + .map_err(|source| Error::Snapshot { source }) + }) } /// Walks the catalog chain from the current catalog's predecessor to diff --git a/src/adapters/gc/liveness_observation_error.rs b/src/adapters/gc/liveness_observation_error.rs index f1ab17bd..0c03ba49 100644 --- a/src/adapters/gc/liveness_observation_error.rs +++ b/src/adapters/gc/liveness_observation_error.rs @@ -110,7 +110,7 @@ pub enum GcLivenessObservationError { } impl GcLivenessObservationError { - pub(super) const fn pool(action: &'static str, source: io::Error) -> Self { + pub(in crate::adapters) const fn pool(action: &'static str, source: io::Error) -> Self { Self::Pool { action, source } } } diff --git a/src/adapters/gc/mod.rs b/src/adapters/gc/mod.rs index bd460f6e..b8cb76b0 100644 --- a/src/adapters/gc/mod.rs +++ b/src/adapters/gc/mod.rs @@ -101,6 +101,7 @@ pub use intent_decode_error::{GcRetirementIntentDecodeError, GcRetirementIntentE pub use intent_error::GcRetirementIntentError; pub use liveness_coordinates::{GcLivenessCoordinates, GcRetentionState}; pub use liveness_observation::observe_gc_liveness; +pub(in crate::adapters) use liveness_observation::visit_retained_closures; pub use liveness_observation_error::GcLivenessObservationError; pub use liveness_snapshot::{GcLivenessSnapshot, GcLivenessSnapshotError}; pub use plan::{GcPlan, GcPlannedCandidate, GcPlannedSegment}; @@ -122,3 +123,4 @@ pub use retirement_intent::{ disposition_set_digest, post_retirement_pool_state, segment_pool_identity, }; pub use segment_classification::{GcSegmentClassification, GcUnreachableEvidence}; +pub(in crate::adapters) use segment_pool_inventory::read as read_segment_pool_inventory; diff --git a/src/adapters/gc/segment_pool_inventory.rs b/src/adapters/gc/segment_pool_inventory.rs index a0b42e13..3bacdfee 100644 --- a/src/adapters/gc/segment_pool_inventory.rs +++ b/src/adapters/gc/segment_pool_inventory.rs @@ -16,7 +16,7 @@ const DIGEST_HEX_LENGTH: usize = 64; /// returns the sorted `(digest, length)` inventory. Total bytes read stay /// within `byte_limit`; an unknown entry, a wrong kind, a name that is not a /// digest, or a segment whose bytes do not admit refuses the whole read. -pub(super) fn read( +pub(in crate::adapters) fn read( segments: &Dir, policy: SegmentReadPolicy, byte_limit: u64, diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 9f83478d..6376356d 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -64,6 +64,7 @@ mod checksummed_catalog; mod checksummed_publication_head; mod checksummed_segment_record; mod closed_segment; +mod compaction; mod decoded_catalog_entry; mod digest_hex; mod exports; diff --git a/src/adapters/recovery/recovery_entry_role.rs b/src/adapters/recovery/recovery_entry_role.rs index a9aef90f..fcb8be79 100644 --- a/src/adapters/recovery/recovery_entry_role.rs +++ b/src/adapters/recovery/recovery_entry_role.rs @@ -34,6 +34,10 @@ pub enum RecoveryEntryRole { /// Physical catalog digest parsed from the name. digest: CatalogDigest, }, + /// A version-two protocol entry at the root (`reader.lock`, `FORMAT`, + /// `migration.intent`, `migration.receipt`, `retention`, `gc`, or + /// `recovery`), inert to every version-one recovery protocol. + VersionTwoProtocol, } impl RecoveryEntryRole { diff --git a/src/adapters/recovery/recovery_name_classification.rs b/src/adapters/recovery/recovery_name_classification.rs index 81a8204b..014a6747 100644 --- a/src/adapters/recovery/recovery_name_classification.rs +++ b/src/adapters/recovery/recovery_name_classification.rs @@ -79,6 +79,8 @@ fn classify_root(name: &RecoveryEntryName) -> Result Ok(RecoveryEntryRole::CatalogPoolDirectory), b"HEAD" => Ok(RecoveryEntryRole::CurrentHead), b"head.next" => Ok(RecoveryEntryRole::NextHeadStage), + b"reader.lock" | b"FORMAT" | b"migration.intent" | b"migration.receipt" | b"retention" + | b"gc" | b"recovery" => Ok(RecoveryEntryRole::VersionTwoProtocol), _ => Err(NameFailure::Unexpected), } } @@ -116,7 +118,8 @@ impl RequiredEntries { | RecoveryEntryRole::SegmentStage | RecoveryEntryRole::CatalogStage | RecoveryEntryRole::ImmutableSegment { .. } - | RecoveryEntryRole::ImmutableCatalog { .. } => {} + | RecoveryEntryRole::ImmutableCatalog { .. } + | RecoveryEntryRole::VersionTwoProtocol => {} } } diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 2bc5dd9f..a052e2e3 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -138,7 +138,9 @@ pub use canonical_root::CanonicalRetentionRoot; pub use checksummed_head::ChecksummedRetentionHead; pub use closure_error::RetentionClosureVerificationError; pub use closure_verifier::verify_retention_closure; -pub(in crate::adapters) use closure_verifier::verify_retention_closure_members; +pub(in crate::adapters) use closure_verifier::{ + RetentionClosureMembers, verify_retention_closure_members, +}; pub use disposition_execution::{ RecoveryDispositionError, RecoveryDispositionExecutionReceipt, execute_recovery_disposition, resume_recovery_disposition, diff --git a/src/lib.rs b/src/lib.rs index d0d81600..33b2d7f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -194,6 +194,12 @@ pub use adapters::{ VERIFICATION_CONTRACT_VERSION, VerificationOutcome, VerificationReceipt, VerificationReceiptDecodeError, VerificationReceiptField, VerificationView, }; +pub use adapters::{ + CompactionObservation, CompactionPlan, CompactionPublish, CompactionReceipt, + CompactionRecovery, CompactionRefusal, CompactionSegmentDisposition, + FilesystemCompactionAuthority, FilesystemCompactionError, FilesystemCompactionRecoveryError, + observe_compaction, plan_compaction, recover_compaction, +}; pub use adapters::{ MIGRATION_NAMESPACE_PREFIX, StoreMigrationEffect, StoreMigrationFixedStage, StoreMigrationRecoveryAmbiguity, StoreMigrationRecoveryError, StoreMigrationRecoveryPlan, diff --git a/xtask/src/golden_file_worldline/capability_contract.rs b/xtask/src/golden_file_worldline/capability_contract.rs index 9f6b66d8..204f1f7b 100644 --- a/xtask/src/golden_file_worldline/capability_contract.rs +++ b/xtask/src/golden_file_worldline/capability_contract.rs @@ -26,7 +26,7 @@ const CAPABILITY_CONTRACTS: [CapabilityContract; 16] = [ CapabilityContract::future("keep.restart.lawful-recovery/v1", 3, &[17]), CapabilityContract::future("keep.retention.both-states/v1", 4, &[18, 19]), CapabilityContract::required("keep.verification.precise-refusal/v1", 4, &[20]), - CapabilityContract::future("keep.compaction.identity-stable/v1", 4, &[21]), + CapabilityContract::required("keep.compaction.identity-stable/v1", 4, &[21]), CapabilityContract::future("keep.echo.identity-agreement/v1", 5, &[22, 23]), CapabilityContract::future("keep.graft.golden-worldline/v1", 5, &[24]), CapabilityContract::future("keep.git-cas.import/v1", 5, &[25]), From 0bafaa7d1bedb24a97e432d86bd59916d02b36c3 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 13:47:55 -0700 Subject: [PATCH 31/59] Feat: durable authenticated reads over a fenced version-two snapshot ROADMAP T-23.1 (KEEP-RECONSTRUCT-009, -010, now Implemented). The durable segment, catalog, retention, fence, and recovery surfaces now form one `BlobId`-to-writer read contract. `DurableStore::open(root, policy, limit)` names a migrated version-two store and touches nothing. `snapshot()` pins one `DurableSnapshot`: it admits the root as version two, acquires the shared reader fence, double-collects one consistent catalog head, retention head, and manifest under it, and indexes every retained root's anchors. The snapshot owns the fence for its lifetime and every read borrows it, so a view cannot be dropped mid-read and `FilesystemGcAuthority` refuses `ReadersActive` rather than retiring anything the view may read; publication proceeds beside it because successors are immutable. `DurableSnapshot::{contains_blob, reconstruct, reconstruct_layout, read_range, read_layout_range}` resolve blobs through the retained anchors (canonically first layout first), exact layouts through the pinned catalog's layout record decoded under the reader's entry limit and bound to the requested identity, and chunks through the catalog's chunk records, then run the reference store's reconstruction and range cores, which now take a crate-private `ChunkSource` so one core serves both views. Receipts are the reference receipts bound to a `DurableView` (catalog generation and digest, retention generation and manifest digest), the same coordinates a verification receipt names. `DurableReadError::View` is the one operational failure at the read boundary; `BlobMissing`, `LayoutMissing`, `LayoutDecode`, and the cores' refusals are evidence against the complete pinned view, and the cores' output failures stay operational with the exact accepted prefix. Evidence in `src/adapters/durable/tests.rs`, over a migrated store holding several anchored blobs (one of 512 KiB spanning several chunks) and one committed but unanchored layout: exact reconstruction with receipts naming the view; ranges across chunk boundaries emitting exactly the requested bytes and refusing past the end; absence as evidence with the exact committed layout still readable by identity; a pinned view keeping its generation beside a compaction successor and blocking collection until dropped; identical views yielding identical receipts across reopen; a refusing writer receiving no receipt beyond its accepted prefix. `docs/architecture/durable-store/README.md` owns the page; the reconstruction invariant and ledger, the README's durable example (Linux-only, not run), CHANGELOG, and ROADMAP follow. Owed and recorded: the Golden File Worldline's storage steps still run against the reference store; a durable run needs the durable writer (T-24.2) to ingest its states. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 16 ++ README.md | 21 ++ ROADMAP.md | 17 +- docs/architecture/durable-store/README.md | 74 +++++ .../authenticated-reconstruction/README.md | 18 +- .../requirements.md | 4 +- src/adapters/durable/error.rs | 101 +++++++ src/adapters/durable/mod.rs | 25 ++ src/adapters/durable/receipt.rs | 55 ++++ src/adapters/durable/snapshot.rs | 236 ++++++++++++++++ src/adapters/durable/store.rs | 151 ++++++++++ src/adapters/durable/test_fixture.rs | 174 ++++++++++++ src/adapters/durable/tests.rs | 266 ++++++++++++++++++ src/adapters/durable/view.rs | 54 ++++ src/adapters/exports.rs | 1 + src/adapters/mod.rs | 1 + src/lib.rs | 4 + src/reference/chunk_verification.rs | 54 ++-- src/reference/mod.rs | 15 + src/reference/range_read_execution.rs | 28 +- src/reference/reconstruction.rs | 24 +- src/reference/store.rs | 11 + 22 files changed, 1298 insertions(+), 52 deletions(-) create mode 100644 docs/architecture/durable-store/README.md create mode 100644 src/adapters/durable/error.rs create mode 100644 src/adapters/durable/mod.rs create mode 100644 src/adapters/durable/receipt.rs create mode 100644 src/adapters/durable/snapshot.rs create mode 100644 src/adapters/durable/store.rs create mode 100644 src/adapters/durable/test_fixture.rs create mode 100644 src/adapters/durable/tests.rs create mode 100644 src/adapters/durable/view.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eff1905..f62fa710 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,22 @@ after its public API and format compatibility policies are established. ### Added +- Durable authenticated reads. `DurableStore::open(root, policy, limit)` + names a migrated version-two store and `snapshot()` pins one consistent + view under the shared reader fence, indexing every retained root's + anchors. `DurableSnapshot::{contains_blob, reconstruct, reconstruct_layout, + read_range, read_layout_range}` run the reference store's reconstruction + and range cores (now generic over a crate-private `ChunkSource`) against + the pinned catalog, resolving blobs through the retained anchors and + layouts and chunks through the catalog's records, and return receipts + bound to the `DurableView` (catalog generation and digest, retention + generation and manifest digest). A snapshot cannot be dropped mid-read, + keeps its generation while a successor publishes, and blocks collection + (`ReadersActive`) until dropped. `DurableReadError::View` is the one + operational failure; every other refusal is evidence against the pinned + view. `KEEP-RECONSTRUCT-009` and `-010` are Implemented on + `src/adapters/durable/tests.rs`; the page is + `docs/architecture/durable-store/README.md`. - Identity-preserving compaction. `observe_compaction` reads every record the catalog names, every record a retained closure reaches, and the pool; `plan_compaction` is pure over it and gives every named segment one diff --git a/README.md b/README.md index 265f94b9..becf66e6 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,27 @@ assert_eq!(output, b"exact bytes, or nothing"); # Ok::<(), Box>(()) ``` +A migrated version-two store reads the same way through `DurableStore`, with +every read pinned to one fenced snapshot and every receipt naming the view. +Production admission is Linux ext4; this example is not run on other hosts. + +```rust,no_run +use keep::{CatalogRestartByteLimit, CatalogRestartPolicy, DurableStore, LayoutEntryLimit, + ReaderAttemptLimit, SegmentReadPolicy, SegmentRecordLimit}; + +let policy = CatalogRestartPolicy::new( + SegmentReadPolicy::new(SegmentRecordLimit::MAXIMUM, LayoutEntryLimit::MAXIMUM), + CatalogRestartByteLimit::new(1 << 30)?, +); +let store = DurableStore::open(std::path::Path::new("/var/lib/keep/store"), policy, ReaderAttemptLimit::DEFAULT); +let snapshot = store.snapshot()?; // shared reader fence held until dropped +# let blob_id = "keep:blob:v1:blake3-256:1:1cfb8fa9e917aba15a1f592095f377ff180755fe1212b0d7d2ec750bd128b606".parse()?; +let mut output = Vec::new(); +let receipt = snapshot.reconstruct(blob_id, &mut output)?; +println!("generation {}", receipt.view().catalog_generation().get()); +# Ok::<(), Box>(()) +``` + Run the full gate suite the way CI does: ```bash diff --git a/ROADMAP.md b/ROADMAP.md index 666e83bd..791b80b5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -101,7 +101,7 @@ names; use those in code, tests, and commits. - [x] [F-20 Model-based retention transition evidence](#f-20-model-based-retention-transition-evidence) — Done on this branch (merged from PR #99) - [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Partial (#20; report vocabulary, corruption ledgers, and durable receipts done on this branch; durable-view depths land with T-23.1) - [x] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Done on this branch (#21; codecs, planner, disposition, retirement, and compaction; crash sequences for disposition and compaction, stress, benchmarks, and re-encoding still owed) -- [ ] [F-23 Durable authenticated reads and refusal receipts](#f-23-durable-authenticated-reads-and-refusal-receipts) — Planned (#109) +- [x] [F-23 Durable authenticated reads and refusal receipts](#f-23-durable-authenticated-reads-and-refusal-receipts) — Done on this branch (#109; `DurableStore` reads and verification receipts) - [ ] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Planned (#82, #72) ### Integration (M5) @@ -1397,8 +1397,9 @@ disposition receipt exists. `BlobId`, `ChunkId`, and `LayoutId` never move. ### F-23 Durable authenticated reads and refusal receipts -**Status:** Planned (#109). `KEEP-RECONSTRUCT-009` and `-010` cited the -closed #22 and #23; #109 now owns them. +**Status:** Partial (#109, P1, M4). The durable read surface (T-23.1) +landed on this branch; `KEEP-RECONSTRUCT-009` and `-010` are Implemented. +Durable refusal receipts are `CanonicalVerificationReceipt` (T-21.3). The durable segment, catalog, publication, and recovery surfaces do not yet form one high-level `BlobId`-to-writer contract. A durable read must @@ -1408,7 +1409,15 @@ immutable records, preserve the view while successors publish, and return a receipt naming the view, with refusal distinct from operational failure and no hidden whole-blob allocation. -- [ ] T-23.1 `DurableStore` read surface over a fenced snapshot. +- [x] T-23.1 `DurableStore` read surface over a fenced snapshot — + `DurableStore`, `DurableSnapshot`, `DurableView`, the durable receipts, + the crate-private `ChunkSource` shared by the reference and durable read + cores, `docs/architecture/durable-store/README.md`, and + `src/adapters/durable/tests.rs`; `KEEP-RECONSTRUCT-009` and `-010` + Implemented. The Worldline's storage steps still run against the + reference store: a durable run needs the durable writer (T-24.2) to ingest + its states and is owed with it, as is a `keep cat` adapter (F-42). + Original task fields: - **Requirements:** `reconstruct`, `reconstruct_layout`, `read_range` with the same laws as `ReferenceStore` but bound to a `FilesystemRetentionSnapshot`; receipts gain the view coordinates; diff --git a/docs/architecture/durable-store/README.md b/docs/architecture/durable-store/README.md new file mode 100644 index 00000000..65415ad3 --- /dev/null +++ b/docs/architecture/durable-store/README.md @@ -0,0 +1,74 @@ +# Durable Store Reads + +`DurableStore` is the durable twin of the +[non-durable reference store](../reference-store/README.md)'s read surface: +authenticated `reconstruct`, `reconstruct_layout`, `read_range`, and +`read_layout_range` over one fenced version-two snapshot, with the same laws +and the same reconstruction and range cores, plus a receipt that names the +view. It is a read surface only: durable ingestion is +[F-24](../../../ROADMAP.md#f-24-bounded-production-ingestion-through-the-durable-store), +and content reaches a store through catalog publication and retention +anchoring. + +## Contract + +`DurableStore::open(root, policy, limit)` names a migrated +`keep.segment-store/v2` root, touching nothing. `snapshot()` pins one view: +it admits the root as version two, acquires the shared `reader.lock` +fence, double-collects one consistent catalog head, retention head, and +manifest under it, and indexes every retained root's anchors. The +`DurableSnapshot` owns the fence for its lifetime; every read borrows it, so +a view cannot be dropped mid-read, and no collector can retire a segment it +may read (`FilesystemGcAuthority` refuses `ReadersActive` rather than +waiting). Publication proceeds beside snapshots because it only adds +immutable successors: a pinned snapshot keeps reading its generation while +a compaction publishes the next. + +Resolution is exact and evidence-bound: + +- a blob resolves through the retained anchors, canonically first layout + first; `contains_blob` is anchor membership, and a blob the catalog can + serve but no root anchors is `BlobMissing`; +- an exact layout resolves through the pinned catalog's layout record, + decoded under the reader's entry limit and bound to the requested + `LayoutId`, never substituted; +- every chunk resolves through the pinned catalog's chunk record and is + hashed by the shared read core before a byte is emitted. + +The receipt is the reference receipt (target, exact layout, requested range +where applicable, emitted length) bound to the `DurableView`: catalog +generation and digest, and the retention generation and manifest digest +observed under the same fence. The same coordinates are a +[verification receipt](../../formats/verification-receipt-v1/README.md)'s +durable view. + +Memory: the pinned catalog holds the segment bytes the reader's +`CatalogRestartPolicy` admits; chunks are emitted as borrowed slices of +those admitted records, one chunk at a time, with no whole-blob buffer. + +## Outcomes + +`DurableReadError::View` is the one operational failure at the read +boundary: the pinned catalog could not be re-admitted, and nothing about +content follows. `BlobMissing`, `LayoutMissing`, `LayoutDecode`, and the +reference cores' missing, hash, identity, and length refusals are evidence +against the complete pinned view; the cores' `Output` failures remain +operational and report the exact accepted prefix, which stays untrusted. +Pinning itself refuses as `DurableStoreError` at the admission, fence, +collection, or retained-root boundary. + +## Evidence + +`src/adapters/durable/tests.rs` over a migrated store with several anchored +blobs (one spanning several chunks) and one committed but unanchored +layout: exact reconstruction with receipts naming the view; ranges across +chunk boundaries emitting exactly the requested bytes and refusing past the +end; absence as evidence with the exact committed layout still readable; +a pinned view surviving a compaction successor and blocking collection +until dropped; identical views yielding identical receipts across reopen; +a refusing writer receiving no receipt beyond its accepted prefix. +`KEEP-RECONSTRUCT-009` and `-010` are Implemented on that evidence. + +The Golden File Worldline runs its storage steps against the reference +store; a durable run needs the durable writer (T-24.2) to ingest the +worldline's states and is owed with it. diff --git a/docs/invariants/authenticated-reconstruction/README.md b/docs/invariants/authenticated-reconstruction/README.md index 4c3dea09..eed3992f 100644 --- a/docs/invariants/authenticated-reconstruction/README.md +++ b/docs/invariants/authenticated-reconstruction/README.md @@ -208,7 +208,7 @@ range and explicitly carry the narrower range proof posture. ## Durable reconstruction requirement -A future operation claiming durable logical reconstruction must additionally: +A durable logical reconstruction must additionally: - bind reads to one admitted immutable snapshot or catalog generation; - prevent required supporting evidence from being garbage-collected, deleted, @@ -220,12 +220,14 @@ A future operation claiming durable logical reconstruction must additionally: - separate evidenced refusal from operational failure; - preserve the output-visibility rule above. -The current durable segment, catalog, publication, and recovery surfaces do -not yet form this consolidated high-level `BlobId`-to-writer contract. -Retention publication now records verified closures as generation-checked -roots, but nothing collects or fences yet, so no current surface protects or -releases the evidence closure this operation requires. These lower-level surfaces must not be -described as an implemented durable logical reconstruction API. +[`DurableStore`](../../architecture/durable-store/README.md) implements this +contract: a `DurableSnapshot` pins one admitted version-two view under the +shared reader fence, which GC refuses to cross while the snapshot lives; +blobs resolve through the retained roots' anchors, layouts and chunks +through the pinned catalog; the shared reconstruction and range cores +authenticate every emitted byte; and each receipt names the view's catalog +and retention coordinates. `DurableReadError::View` is its one operational +failure; every other refusal is evidence against the complete pinned view. ## Current public evidence @@ -247,6 +249,8 @@ Evidence anchors: - [range-read refusal laws](../../../tests/range_read_failures.rs) - [reconstruction receipt](../../../src/reference/reconstruction_receipt.rs) - [range-read receipt](../../../src/reference/range_read_receipt.rs) +- [durable store reads](../../architecture/durable-store/README.md) and their + [laws](../../../src/adapters/durable/tests.rs) ## Consumer rule diff --git a/docs/invariants/authenticated-reconstruction/requirements.md b/docs/invariants/authenticated-reconstruction/requirements.md index b5f41342..08a4ebdd 100644 --- a/docs/invariants/authenticated-reconstruction/requirements.md +++ b/docs/invariants/authenticated-reconstruction/requirements.md @@ -14,5 +14,5 @@ gap, not implementation evidence. | `KEEP-RECONSTRUCT-006` | Authenticated success, evidenced content refusal, and operational failure remain distinct outcomes; operational failure supports no content conclusion. | Typed outcome classification | Public API integration tests and contract inspection | Implemented for `ReferenceStore`; refusals project onto the durable `CanonicalVerificationReceipt` (`KEEP-VERIFY-007`) | `tests/streaming_cas/refusal_laws.rs`, `tests/range_read_failures.rs`, `tests/verification_receipt.rs` | | `KEEP-RECONSTRUCT-007` | Whole-object and range receipts bind target, exact layout, proof scope, and exact emitted coordinates without granting retention or application authority. | Receipt type inspection | Public API contract tests | Implemented | `src/reference/reconstruction_receipt.rs`, `src/reference/range_read_receipt.rs`, `tests/range_read_contract.rs` | | `KEEP-RECONSTRUCT-008` | Automatic layout choice is deterministic; an exact requested layout never falls back. | Canonically ordered layout set | Unit and public API integration tests | Implemented | `src/reference/store_tests.rs`, `tests/streaming_cas/reconstruction_laws.rs` | -| `KEEP-RECONSTRUCT-009` | A durable read pins one immutable view and prevents required evidence from being garbage-collected, deleted, or invalidated through completion. | Pinned-generation and retained-closure model | Recovery, concurrency, corruption, and crash-injection tests | Planned gap | [Keep #22](https://github.com/flyingrobots/keep/issues/22), [Keep #23](https://github.com/flyingrobots/keep/issues/23) | -| `KEEP-RECONSTRUCT-010` | Durable reconstruction names its view and returns either authenticated success, evidenced refusal, or operational failure without hidden whole-blob allocation. | Durable consumer conformance model | Public API integration, memory, recovery, and crash-injection tests | Planned gap | [Keep #22](https://github.com/flyingrobots/keep/issues/22), [Keep #23](https://github.com/flyingrobots/keep/issues/23) | +| `KEEP-RECONSTRUCT-009` | A durable read pins one immutable view and prevents required evidence from being garbage-collected, deleted, or invalidated through completion. | Pinned-generation and retained-closure model | Recovery, concurrency, corruption, and crash-injection tests | Implemented | `src/adapters/durable/tests.rs` (a pinned snapshot keeps its view beside a compaction successor and blocks collection until dropped; `FilesystemGcAuthority` refuses `ReadersActive`) | +| `KEEP-RECONSTRUCT-010` | Durable reconstruction names its view and returns either authenticated success, evidenced refusal, or operational failure without hidden whole-blob allocation. | Durable consumer conformance model | Public API integration, memory, recovery, and crash-injection tests | Implemented | `src/adapters/durable/tests.rs` (receipts name the view; absence, unanchored layouts, and past-the-end ranges refuse as evidence; a refusing writer gets no receipt beyond its accepted prefix; chunks emit as borrowed slices of admitted records) | diff --git a/src/adapters/durable/error.rs b/src/adapters/durable/error.rs new file mode 100644 index 00000000..20396706 --- /dev/null +++ b/src/adapters/durable/error.rs @@ -0,0 +1,101 @@ +//! This boundary module owns typed durable-read failures, keeping evidenced +//! refusal apart from operational failure as the reconstruction contract +//! requires. + +use std::error::Error; +use std::fmt; +use std::io; + +use crate::adapters::{CatalogRestartError, FilesystemRetentionSnapshotError}; +use crate::{ + BlobId, LayoutDecodeError, LayoutId, RangeReadError, ReconstructionError, + RetentionNamespaceDigest, +}; + +/// Why a durable store or snapshot could not be opened. +#[derive(Debug)] +pub enum DurableStoreError { + /// The root did not admit as a version-two store, the fence could not + /// be taken, or one consistent view could not be collected. + Snapshot(Box), + /// A retained root the manifest selects could not be read or decoded. + RetainedRoot { + /// The namespace whose root refused. + namespace: RetentionNamespaceDigest, + /// The exact refusal. + source: io::Error, + }, +} + +impl fmt::Display for DurableStoreError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Snapshot(_) => formatter.write_str("the durable view could not be pinned"), + Self::RetainedRoot { .. } => formatter.write_str("a retained root refused"), + } + } +} + +impl Error for DurableStoreError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Snapshot(source) => Some(source.as_ref()), + Self::RetainedRoot { source, .. } => Some(source), + } + } +} + +/// Why one durable read did not return a receipt. +/// +/// `View` is the one operational failure: the pinned catalog could not be +/// re-admitted, so nothing about content follows. Every other variant is an +/// evidenced refusal against the complete pinned view, or, inside the +/// reference read errors, the output failure those errors already keep +/// distinct. +#[derive(Debug)] +pub enum DurableReadError { + /// The pinned catalog could not be re-admitted for this read. + View(Box), + /// No retained root anchors the blob in this view. + BlobMissing { + /// The requested blob. + requested: BlobId, + }, + /// The catalog names no layout record under the identity. + LayoutMissing { + /// The requested layout. + requested: LayoutId, + }, + /// The committed layout record does not decode as the identity that + /// names it. + LayoutDecode(LayoutDecodeError), + /// The reconstruction core refused or its output failed. + Reconstruction(Box), + /// The range core refused or its output failed. + RangeRead(Box), +} + +impl fmt::Display for DurableReadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::View(_) => formatter.write_str("the pinned view could not be re-admitted"), + Self::BlobMissing { .. } => formatter.write_str("no retained root anchors the blob"), + Self::LayoutMissing { .. } => formatter.write_str("the catalog names no such layout"), + Self::LayoutDecode(source) => write!(formatter, "committed layout refused: {source}"), + Self::Reconstruction(source) => write!(formatter, "reconstruction: {source}"), + Self::RangeRead(source) => write!(formatter, "range read: {source}"), + } + } +} + +impl Error for DurableReadError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::View(source) => Some(source.as_ref()), + Self::LayoutDecode(source) => Some(source), + Self::Reconstruction(source) => Some(source.as_ref()), + Self::RangeRead(source) => Some(source.as_ref()), + Self::BlobMissing { .. } | Self::LayoutMissing { .. } => None, + } + } +} diff --git a/src/adapters/durable/mod.rs b/src/adapters/durable/mod.rs new file mode 100644 index 00000000..7d03e12a --- /dev/null +++ b/src/adapters/durable/mod.rs @@ -0,0 +1,25 @@ +//! Durable authenticated reads over one fenced version-two snapshot. +//! +//! `DurableStore` names a migrated store; `DurableSnapshot` pins one +//! consistent view under the shared reader fence and answers +//! `reconstruct`, `reconstruct_layout`, `read_range`, and +//! `read_layout_range` with exactly the reference store's laws, resolving +//! layouts and chunks through the fenced catalog and blobs through the +//! retained anchors. Every receipt names the view it was established +//! against. While a snapshot lives, collection cannot retire what it reads. + +mod error; +mod receipt; +mod snapshot; +mod store; +#[cfg(test)] +mod test_fixture; +#[cfg(test)] +mod tests; +mod view; + +pub use error::{DurableReadError, DurableStoreError}; +pub use receipt::{DurableRangeReadReceipt, DurableReconstructionReceipt}; +pub use snapshot::DurableSnapshot; +pub use store::{DurableOutcome, DurableStore}; +pub use view::DurableView; diff --git a/src/adapters/durable/receipt.rs b/src/adapters/durable/receipt.rs new file mode 100644 index 00000000..7442ca06 --- /dev/null +++ b/src/adapters/durable/receipt.rs @@ -0,0 +1,55 @@ +//! This boundary module owns durable read receipts: the reference receipt +//! plus the view it was established against. + +use super::DurableView; +use crate::{RangeReadReceipt, ReconstructionReceipt}; + +/// One authenticated complete reconstruction against one durable view. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[must_use = "the receipt records the authenticated identity, length, and view"] +pub struct DurableReconstructionReceipt { + receipt: ReconstructionReceipt, + view: DurableView, +} + +impl DurableReconstructionReceipt { + pub(super) const fn new(receipt: ReconstructionReceipt, view: DurableView) -> Self { + Self { receipt, view } + } + + /// The target, exact layout, and emitted length authenticated. + pub const fn receipt(self) -> ReconstructionReceipt { + self.receipt + } + + /// The view the reconstruction was established against. + #[must_use] + pub const fn view(self) -> DurableView { + self.view + } +} + +/// One authenticated exact range read against one durable view. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[must_use = "the receipt records the authenticated range, layout, and view"] +pub struct DurableRangeReadReceipt { + receipt: RangeReadReceipt, + view: DurableView, +} + +impl DurableRangeReadReceipt { + pub(super) const fn new(receipt: RangeReadReceipt, view: DurableView) -> Self { + Self { receipt, view } + } + + /// The target, exact layout, requested range, and emitted length. + pub const fn receipt(self) -> RangeReadReceipt { + self.receipt + } + + /// The view the range was established against. + #[must_use] + pub const fn view(self) -> DurableView { + self.view + } +} diff --git a/src/adapters/durable/snapshot.rs b/src/adapters/durable/snapshot.rs new file mode 100644 index 00000000..9548e764 --- /dev/null +++ b/src/adapters/durable/snapshot.rs @@ -0,0 +1,236 @@ +//! This module owns one pinned durable view and the reads it answers. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{self, Write}; +use std::path::Path; + +use super::{ + DurableRangeReadReceipt, DurableReadError, DurableReconstructionReceipt, DurableStoreError, + DurableView, +}; +use crate::adapters::retention::AdmittedRetentionRoot; +use crate::adapters::{ + AdmittedSegmentRecord, CatalogRestartPolicy, CatalogSnapshot, FilesystemRetentionSnapshot, + GcRetentionState, LayoutDecodePolicy, ReaderAttemptLimit, SegmentRecordIdentity, +}; +use crate::reference::{ChunkSource, read_admitted, reconstruct_admitted}; +use crate::{AdmittedLayout, BlobId, ByteRange, ChunkId, LayoutId}; + +/// One consistent, fenced view of a version-two store. +/// +/// The snapshot owns the shared reader fence for its lifetime, so no +/// collector can retire a segment it may read; every read borrows the +/// snapshot, so dropping the view mid-read is impossible. Blobs resolve +/// through the retained roots' anchors; layouts and chunks resolve through +/// the pinned catalog and are authenticated by the reference read cores +/// before a byte is emitted. +#[must_use] +pub struct DurableSnapshot { + view: FilesystemRetentionSnapshot, + coordinates: DurableView, + anchors: BTreeMap>, + policy: CatalogRestartPolicy, +} + +/// The pinned catalog as a chunk source: every chunk record's exact payload. +struct CatalogChunks<'snapshot, 'head, 'catalog, 'records> { + catalog: &'snapshot CatalogSnapshot<'head, 'catalog, 'records>, +} + +impl ChunkSource for CatalogChunks<'_, '_, '_, '_> { + fn chunk(&self, identity: ChunkId) -> Option<&[u8]> { + self.catalog + .record(SegmentRecordIdentity::Chunk(identity)) + .map(AdmittedSegmentRecord::payload) + } +} + +impl DurableSnapshot { + /// Admits `store_root` as a version-two store, acquires the shared + /// reader fence, double-collects one consistent view within `limit` + /// attempts, and indexes every retained root's anchors. + /// + /// # Errors + /// + /// Returns [`DurableStoreError`] at the exact admission, fence, + /// collection, or retained-root refusal. + pub fn open( + store_root: &Path, + policy: CatalogRestartPolicy, + limit: ReaderAttemptLimit, + ) -> Result { + let view = FilesystemRetentionSnapshot::load(store_root, policy, limit) + .map_err(|source| DurableStoreError::Snapshot(Box::new(source)))?; + let retention = view + .retention_head() + .map_or(GcRetentionState::Empty, |head| { + GcRetentionState::Published { + generation: head.generation(), + manifest_digest: head.manifest_digest(), + } + }); + let coordinates = DurableView::new( + view.catalog().generation(), + view.catalog().catalog_digest(), + retention, + ); + let anchors = anchors(&view)?; + Ok(Self { + view, + coordinates, + anchors, + policy, + }) + } + + /// The exact view this snapshot pinned. + #[must_use] + pub const fn view(&self) -> DurableView { + self.coordinates + } + + /// Whether some retained root anchors `target` in this view. + #[must_use] + pub fn contains_blob(&self, target: BlobId) -> bool { + self.anchors.contains_key(&target) + } + + /// Reconstructs `target` through its canonically first retained anchor. + /// + /// # Errors + /// + /// Returns [`DurableReadError`] when no root anchors the blob, the view + /// cannot be re-admitted, the layout refuses, or the reconstruction + /// core refuses or its output fails. + pub fn reconstruct( + &self, + target: BlobId, + output: &mut W, + ) -> Result + where + W: Write + ?Sized, + { + let layout_id = self.first_layout_id(target)?; + self.reconstruct_layout(layout_id, output) + } + + /// Reconstructs the exact committed layout `layout_id`, never another. + /// + /// # Errors + /// + /// As [`Self::reconstruct`], with `LayoutMissing` when the catalog names + /// no such layout record. + pub fn reconstruct_layout( + &self, + layout_id: LayoutId, + output: &mut W, + ) -> Result + where + W: Write + ?Sized, + { + let catalog = self.catalog()?; + let layout = self.layout(&catalog, layout_id)?; + let chunks = CatalogChunks { catalog: &catalog }; + reconstruct_admitted(&chunks, layout_id, &layout, output) + .map(|receipt| DurableReconstructionReceipt::new(receipt, self.coordinates)) + .map_err(|source| DurableReadError::Reconstruction(Box::new(source))) + } + + /// Reads exactly `requested` of `target` through its first retained + /// anchor, authenticating only the overlapping chunks. + /// + /// # Errors + /// + /// As [`Self::reconstruct`], with the range core's refusals. + pub fn read_range( + &self, + target: BlobId, + requested: ByteRange, + output: &mut W, + ) -> Result + where + W: Write + ?Sized, + { + let layout_id = self.first_layout_id(target)?; + self.read_layout_range(layout_id, requested, output) + } + + /// Reads exactly `requested` through the exact committed layout. + /// + /// # Errors + /// + /// As [`Self::reconstruct_layout`], with the range core's refusals. + pub fn read_layout_range( + &self, + layout_id: LayoutId, + requested: ByteRange, + output: &mut W, + ) -> Result + where + W: Write + ?Sized, + { + let catalog = self.catalog()?; + let layout = self.layout(&catalog, layout_id)?; + let chunks = CatalogChunks { catalog: &catalog }; + read_admitted(&chunks, layout_id, &layout, requested, output) + .map(|receipt| DurableRangeReadReceipt::new(receipt, self.coordinates)) + .map_err(|source| DurableReadError::RangeRead(Box::new(source))) + } + + fn first_layout_id(&self, target: BlobId) -> Result { + self.anchors + .get(&target) + .and_then(|layouts| layouts.first().copied()) + .ok_or(DurableReadError::BlobMissing { requested: target }) + } + + fn catalog(&self) -> Result, DurableReadError> { + self.view + .catalog() + .snapshot() + .map_err(|source| DurableReadError::View(Box::new(source))) + } + + fn layout( + &self, + catalog: &CatalogSnapshot<'_, '_, '_>, + layout_id: LayoutId, + ) -> Result { + let record = catalog + .record(SegmentRecordIdentity::Layout(layout_id)) + .ok_or(DurableReadError::LayoutMissing { + requested: layout_id, + })?; + let policy = LayoutDecodePolicy::new(self.policy.segment_read().layout_entry_limit()) + .with_expected_id(layout_id); + AdmittedLayout::decode_record(record.payload(), policy) + .map_err(DurableReadError::LayoutDecode) + } +} + +/// Every retained root's anchors, blob to its committed layouts. +fn anchors( + view: &FilesystemRetentionSnapshot, +) -> Result>, DurableStoreError> { + let mut anchors: BTreeMap> = BTreeMap::new(); + let Some(manifest) = view.manifest() else { + return Ok(anchors); + }; + for entry in manifest.entries() { + let namespace = entry.namespace(); + let refused = |source: io::Error| DurableStoreError::RetainedRoot { namespace, source }; + let bytes = view + .retained_root(namespace) + .map_err(|source| refused(io::Error::other(source.to_string())))? + .ok_or_else(|| refused(io::Error::other("the selected root is absent")))?; + let root = AdmittedRetentionRoot::decode(&bytes) + .map_err(|source| refused(io::Error::new(io::ErrorKind::InvalidData, source)))?; + for anchor in root.root().anchors() { + anchors + .entry(anchor.blob_id()) + .or_default() + .insert(anchor.layout_id()); + } + } + Ok(anchors) +} diff --git a/src/adapters/durable/store.rs b/src/adapters/durable/store.rs new file mode 100644 index 00000000..f3522a88 --- /dev/null +++ b/src/adapters/durable/store.rs @@ -0,0 +1,151 @@ +//! This boundary module owns the durable store handle: a version-two root +//! that pins snapshots on demand. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use super::{ + DurableRangeReadReceipt, DurableReadError, DurableReconstructionReceipt, DurableSnapshot, + DurableStoreError, +}; +use crate::adapters::{CatalogRestartPolicy, ReaderAttemptLimit}; +use crate::{BlobId, ByteRange, LayoutId}; + +/// One migrated version-two store to read from. +/// +/// The handle holds no fence and no view; every read pins a fresh +/// [`DurableSnapshot`] unless the caller pins one with [`Self::snapshot`] +/// and reads through it. `open` takes no authority and touches nothing; +/// admission happens when a snapshot is pinned. +#[must_use] +#[derive(Clone, Debug)] +pub struct DurableStore { + root: PathBuf, + policy: CatalogRestartPolicy, + limit: ReaderAttemptLimit, +} + +impl DurableStore { + /// Names the store at `root`, reading under `policy` and collecting a + /// consistent view within `limit` attempts. + pub fn open(root: &Path, policy: CatalogRestartPolicy, limit: ReaderAttemptLimit) -> Self { + Self { + root: root.to_path_buf(), + policy, + limit, + } + } + + /// The store root. + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + /// Pins one consistent, fenced view. + /// + /// # Errors + /// + /// Returns [`DurableStoreError`] at the exact admission, fence, + /// collection, or retained-root refusal. + pub fn snapshot(&self) -> Result { + DurableSnapshot::open(&self.root, self.policy, self.limit) + } + + /// Whether some retained root anchors `target` in a fresh view. + /// + /// # Errors + /// + /// As [`Self::snapshot`]. + pub fn contains_blob(&self, target: BlobId) -> Result { + Ok(self.snapshot()?.contains_blob(target)) + } + + /// Reconstructs `target` against a fresh view. + /// + /// # Errors + /// + /// Returns the snapshot refusal as [`DurableReadError::View`]'s + /// operational counterpart through [`DurableStoreError`], or the read's + /// own refusal. + pub fn reconstruct( + &self, + target: BlobId, + output: &mut W, + ) -> Result + where + W: Write + ?Sized, + { + let snapshot = self.snapshot().map_err(DurableOutcome::Store)?; + snapshot + .reconstruct(target, output) + .map_err(DurableOutcome::Read) + } + + /// Reconstructs the exact committed layout against a fresh view. + /// + /// # Errors + /// + /// As [`Self::reconstruct`]. + pub fn reconstruct_layout( + &self, + layout_id: LayoutId, + output: &mut W, + ) -> Result + where + W: Write + ?Sized, + { + let snapshot = self.snapshot().map_err(DurableOutcome::Store)?; + snapshot + .reconstruct_layout(layout_id, output) + .map_err(DurableOutcome::Read) + } + + /// Reads exactly `requested` of `target` against a fresh view. + /// + /// # Errors + /// + /// As [`Self::reconstruct`]. + pub fn read_range( + &self, + target: BlobId, + requested: ByteRange, + output: &mut W, + ) -> Result + where + W: Write + ?Sized, + { + let snapshot = self.snapshot().map_err(DurableOutcome::Store)?; + snapshot + .read_range(target, requested, output) + .map_err(DurableOutcome::Read) + } +} + +/// Why a store-level read returned no receipt: the view could not be +/// pinned, or the pinned view refused. +#[derive(Debug)] +pub enum DurableOutcome { + /// The snapshot could not be pinned. + Store(DurableStoreError), + /// The pinned view refused the read. + Read(DurableReadError), +} + +impl std::fmt::Display for DurableOutcome { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Store(source) => write!(formatter, "{source}"), + Self::Read(source) => write!(formatter, "{source}"), + } + } +} + +impl std::error::Error for DurableOutcome { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Store(source) => Some(source), + Self::Read(source) => Some(source), + } + } +} diff --git a/src/adapters/durable/test_fixture.rs b/src/adapters/durable/test_fixture.rs new file mode 100644 index 00000000..17762645 --- /dev/null +++ b/src/adapters/durable/test_fixture.rs @@ -0,0 +1,174 @@ +//! A migrated version-two store holding several anchored blobs, one of them +//! spanning many chunks, and one committed layout no root anchors. + +use std::error::Error; +use std::fs; +use std::path::Path; + +use crate::adapters::filesystem_test_sandbox::TestDirectory; +use crate::adapters::retention::filesystem_retention_test_fixture::{ + catalog_policy, migrated_store, reopen_authority, +}; +use crate::adapters::{ + AdmittedRetentionRoot, AdmittedSegment, AdmittedSegmentRecord, CanonicalCatalog, + CanonicalRetentionRoot, CatalogPublicationExpectation, FilesystemCatalogPublisher, + FilesystemCatalogSnapshot, FilesystemVersionTwoAdmission, SegmentRecordLimit, StagedSegment, + publish_catalog_generation, +}; +use crate::{ + AdmittedLayout, BlobHasher, BlobId, CanonicalLayoutRecord, CatalogGeneration, ChunkSpan, + FastCdc, LayoutEntryLimit, LayoutId, RegisteredRetentionProfile, RegisteredStorageProfile, + RetentionAnchor, RetentionClosureLimits, RetentionGenerationExpectation, RetentionNamespace, + RetentionPolicy, RetentionRoot, RootGeneration, execute_retention_publication, + preflight_retention_transition, prepare_retention_publication, +}; + +/// A built store with each content's identities, in the given order. +pub(super) type BuiltStore = (TestDirectory, Vec); + +/// One published blob's identities. +#[derive(Clone, Copy, Debug)] +pub(super) struct Published { + pub(super) target: BlobId, + pub(super) layout: LayoutId, +} + +/// Deterministic pseudo-random bytes long enough to span several chunks. +pub(super) fn long_content() -> Vec { + let mut state = 0x9e37_79b9_7f4a_7c15_u64; + (0..512 * 1024) + .map(|_| { + state ^= state.wrapping_shl(13); + state ^= state.wrapping_shr(7); + state ^= state.wrapping_shl(17); + state.to_le_bytes().first().copied().unwrap_or_default() + }) + .collect() +} + +struct Identified { + target: BlobId, + spans: Vec, + record: CanonicalLayoutRecord, +} + +fn identify(bytes: &[u8]) -> Result> { + let mut hasher = BlobHasher::new(); + hasher.update(bytes)?; + let mut detector = FastCdc::new(); + let mut spans = Vec::new(); + detector.feed(bytes, |span| spans.push(span))?; + if let Some(span) = detector.finish()? { + spans.push(span); + } + let layout = AdmittedLayout::from_spans( + hasher.finish(), + RegisteredStorageProfile::FAST_CDC_64K_V1, + spans.clone(), + LayoutEntryLimit::MAXIMUM, + )?; + Ok(Identified { + target: layout.target(), + spans, + record: layout.encode_record()?, + }) +} + +/// Publishes every content as chunk and layout records in one new segment +/// under catalog generation two, then anchors every content except the +/// last under retention generation one. Returns each content's identities +/// in the given order. +pub(super) fn durable_store(name: &str, contents: &[&[u8]]) -> Result> { + let sandbox = migrated_store(name)?; + let policy = catalog_policy()?; + let mut bundle_bytes = None; + for entry in fs::read_dir(sandbox.path().join("segments"))? { + bundle_bytes = Some(fs::read(entry?.path())?); + } + let bundle_bytes = bundle_bytes.ok_or("the migrated store has no segment")?; + let admission = FilesystemVersionTwoAdmission::reopen_unchecked_for_tests(sandbox.path())?; + let mut publisher = FilesystemCatalogPublisher::open_version_two(admission, policy)?; + let current = FilesystemCatalogSnapshot::load(sandbox.path(), policy)?; + let snapshot = current.snapshot()?; + let identified = contents + .iter() + .map(|bytes| identify(bytes)) + .collect::, _>>()?; + let mut staged = StagedSegment::begin( + publisher.create_segment_stage()?, + SegmentRecordLimit::MAXIMUM, + )?; + let mut identities = Vec::new(); + for (bytes, identity) in contents.iter().zip(&identified) { + for span in &identity.spans { + let start = usize::try_from(span.offset().get())?; + let end = usize::try_from(span.end().get())?; + let chunk = bytes.get(start..end).ok_or("span outside content")?; + staged = staged.append(AdmittedSegmentRecord::for_chunk(chunk)?)?; + } + staged = staged.append(AdmittedSegmentRecord::for_layout(&identity.record)?)?; + identities.push(Published { + target: identity.target, + layout: identity.record.id(), + }); + } + let sealed = staged.seal()?; + let staged_bytes = fs::read(sandbox.path().join("staging").join("current.seg"))?; + let bundle = AdmittedSegment::decode(&bundle_bytes, policy.segment_read())?; + let second = AdmittedSegment::decode(&staged_bytes, policy.segment_read())?; + let segments = [bundle, second]; + let staged_segment = segments.get(1).ok_or("staged segment")?; + let selection = publisher.select_segment(sealed, staged_segment)?; + let successor = CanonicalCatalog::from_segments( + CatalogGeneration::new(2)?, + Some(snapshot.catalog_digest()), + &segments, + )?; + let _receipt = publish_catalog_generation( + &mut publisher, + CatalogPublicationExpectation::successor_of(&snapshot), + selection, + &successor, + &segments, + )?; + drop(snapshot); + drop(current); + drop(publisher); + let anchored: Vec<_> = identities + .iter() + .take(identities.len().saturating_sub(1)) + .map(|entry| RetentionAnchor::new(entry.target, entry.layout)) + .collect(); + publish_root(sandbox.path(), anchored)?; + Ok((sandbox, identities)) +} + +fn publish_root(root: &Path, anchors: Vec) -> Result<(), Box> { + let retention_root = RetentionRoot::new( + RetentionNamespace::try_from(vec![0x2f])?, + RootGeneration::new(1)?, + RetentionPolicy::new( + RegisteredRetentionProfile::SINGLE_CANONICAL_WITNESS_V1, + RetentionClosureLimits::new(4096, 8, 1 << 24, 1 << 26)?, + ), + None, + anchors, + )?; + let root_bytes = CanonicalRetentionRoot::from_root(&retention_root)? + .encoded() + .to_vec(); + let policy = catalog_policy()?; + let current = FilesystemCatalogSnapshot::load(root, policy)?; + let snapshot = current.snapshot()?; + let candidate = AdmittedRetentionRoot::decode(&root_bytes)?; + let preflight = preflight_retention_transition( + RetentionGenerationExpectation::Absent, + None, + candidate, + &snapshot, + )?; + let preparation = prepare_retention_publication(preflight, None)?; + let mut authority = reopen_authority(root)?; + let _published = execute_retention_publication(&mut authority, &preparation)?; + Ok(()) +} diff --git a/src/adapters/durable/tests.rs b/src/adapters/durable/tests.rs new file mode 100644 index 00000000..7276baae --- /dev/null +++ b/src/adapters/durable/tests.rs @@ -0,0 +1,266 @@ +//! Durable read laws: every anchored blob reconstructs exactly with a +//! receipt naming the view; ranges across chunk boundaries emit exactly the +//! requested bytes; absence and unanchored layouts refuse as evidence; a +//! pinned snapshot keeps its view while a successor publishes and blocks +//! collection; identical views yield identical receipts. + +use std::error::Error; + +use super::test_fixture::{durable_store, long_content}; +use super::{DurableReadError, DurableSnapshot, DurableStore}; +use crate::adapters::compaction::{ + FilesystemCompactionAuthority, observe_compaction, plan_compaction, +}; +use crate::adapters::gc::{ + FilesystemGcAuthority, FilesystemGcError, GcLimits, observe_gc_liveness, plan_gc, +}; +use crate::adapters::retention::filesystem_retention_test_fixture::catalog_policy; +use crate::adapters::{ + FilesystemRetentionSnapshot, FilesystemVersionTwoAdmission, ReaderAttemptLimit, +}; +use crate::{ByteLength, ByteOffset, ByteRange, ReconstructionError}; + +fn store(root: &std::path::Path) -> Result> { + Ok(DurableStore::open( + root, + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + )) +} + +fn range(offset: u64, length: u64) -> Result> { + Ok(ByteRange::new( + ByteOffset::new(offset), + ByteLength::new(length), + )?) +} + +#[test] +fn every_anchored_blob_reconstructs_exactly_with_a_receipt_naming_the_view() +-> Result<(), Box> { + let long = long_content(); + let contents: [&[u8]; 3] = [b"first durable blob", &long, b"unanchored"]; + let (sandbox, published) = durable_store("durable-reconstruct", &contents)?; + let snapshot = store(sandbox.path())?.snapshot()?; + assert_eq!(snapshot.view().catalog_generation().get(), 2); + assert!(matches!( + snapshot.view().retention(), + crate::adapters::GcRetentionState::Published { generation, .. } if generation.get() == 1 + )); + for (bytes, entry) in contents.iter().zip(&published).take(2) { + assert!(snapshot.contains_blob(entry.target)); + let mut output = Vec::new(); + let receipt = snapshot.reconstruct(entry.target, &mut output)?; + assert_eq!(output.as_slice(), *bytes); + assert_eq!(receipt.receipt().target(), entry.target); + assert_eq!(receipt.receipt().layout_id(), entry.layout); + assert_eq!( + receipt.receipt().bytes_written().get(), + u64::try_from(bytes.len())? + ); + assert_eq!(receipt.view(), snapshot.view()); + let mut again = Vec::new(); + let exact = snapshot.reconstruct_layout(entry.layout, &mut again)?; + assert_eq!(again.as_slice(), *bytes); + assert_eq!(exact, receipt); + } + sandbox.remove()?; + Ok(()) +} + +#[test] +fn ranges_across_chunk_boundaries_emit_exactly_the_requested_bytes() -> Result<(), Box> { + let long = long_content(); + let contents: [&[u8]; 2] = [&long, b"tail"]; + let (sandbox, published) = durable_store("durable-range", &contents)?; + let snapshot = store(sandbox.path())?.snapshot()?; + let entry = published.first().ok_or("published")?; + for (offset, length) in [(0, 1), (65_000, 70_000), (200_000, 300_000), (524_287, 1)] { + let mut output = Vec::new(); + let receipt = snapshot.read_range(entry.target, range(offset, length)?, &mut output)?; + let start = usize::try_from(offset)?; + let end = usize::try_from(offset.saturating_add(length))?; + assert_eq!(Some(output.as_slice()), long.get(start..end)); + assert_eq!(receipt.receipt().bytes_written().get(), length); + assert_eq!(receipt.receipt().requested(), range(offset, length)?); + assert_eq!(receipt.view(), snapshot.view()); + } + let mut output = Vec::new(); + let error = snapshot + .read_range(entry.target, range(524_288, 1)?, &mut output) + .err() + .ok_or("a range past the end was read")?; + assert!(matches!(error, DurableReadError::RangeRead(_)), "{error}"); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn absence_is_evidence_against_the_pinned_view() -> Result<(), Box> { + let contents: [&[u8]; 2] = [b"anchored", b"committed but unanchored"]; + let (sandbox, published) = durable_store("durable-absent", &contents)?; + let snapshot = store(sandbox.path())?.snapshot()?; + let unanchored = published.get(1).ok_or("published")?; + assert!(!snapshot.contains_blob(unanchored.target)); + let mut output = Vec::new(); + let error = snapshot + .reconstruct(unanchored.target, &mut output) + .err() + .ok_or("an unanchored blob reconstructed")?; + assert!( + matches!(error, DurableReadError::BlobMissing { requested } if requested == unanchored.target) + ); + assert!(output.is_empty()); + // The exact committed layout is still readable by identity: the + // catalog names it, and the fence protects it. + let receipt = snapshot.reconstruct_layout(unanchored.layout, &mut output)?; + assert_eq!(output.as_slice(), b"committed but unanchored"); + assert_eq!(receipt.receipt().target(), unanchored.target); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn a_pinned_snapshot_keeps_its_view_beside_a_successor_and_blocks_collection() +-> Result<(), Box> { + let contents: [&[u8]; 2] = [b"kept across compaction", b"unreachable"]; + let (sandbox, published) = durable_store("durable-pinned", &contents)?; + let policy = catalog_policy()?; + let anchored = published.first().ok_or("published")?; + let pinned = store(sandbox.path())?.snapshot()?; + assert_eq!(pinned.view().catalog_generation().get(), 2); + + // A compaction successor publishes beside the pinned reader. + let view = FilesystemRetentionSnapshot::load_under_writer_authority( + sandbox.path(), + policy, + ReaderAttemptLimit::DEFAULT, + )?; + let plan = plan_compaction(&observe_compaction(sandbox.path(), &view, policy)?)?; + drop(view); + let admission = FilesystemVersionTwoAdmission::reopen_unchecked_for_tests(sandbox.path())?; + let receipt = + FilesystemCompactionAuthority::open(admission, sandbox.path(), policy)?.execute(&plan)?; + assert_eq!(receipt.generation().get(), 3); + + // The pinned view still reads its generation; a fresh view reads the successor. + let mut output = Vec::new(); + let old = pinned.reconstruct(anchored.target, &mut output)?; + assert_eq!(output.as_slice(), b"kept across compaction"); + assert_eq!(old.view().catalog_generation().get(), 2); + let fresh = store(sandbox.path())?.snapshot()?; + let mut again = Vec::new(); + let new = fresh.reconstruct(anchored.target, &mut again)?; + assert_eq!(again, output); + assert_eq!(new.view().catalog_generation().get(), 3); + assert_eq!( + new.receipt(), + old.receipt(), + "the same identities at a new location" + ); + drop(fresh); + + // Collection refuses while the pinned view lives, then retires. + let fenced = FilesystemRetentionSnapshot::load_under_writer_authority( + sandbox.path(), + policy, + ReaderAttemptLimit::DEFAULT, + )?; + let gc = plan_gc( + &observe_gc_liveness(sandbox.path(), &fenced, policy)?, + GcLimits::MAXIMUM, + )?; + drop(fenced); + // The bundle segment was never anchored, so compaction omitted it too: + // every segment the receipt superseded is now a retirement candidate. + assert_eq!( + usize::try_from(gc.candidate_count())?, + receipt.superseded().len() + ); + let admission = FilesystemVersionTwoAdmission::reopen_unchecked_for_tests(sandbox.path())?; + let mut collector = FilesystemGcAuthority::open(admission, sandbox.path(), policy)?; + let error = collector + .execute(&gc) + .err() + .ok_or("GC ran beside a pinned reader")?; + assert!(matches!(error, FilesystemGcError::ReadersActive), "{error}"); + drop(pinned); + let _retired = collector.execute(&gc)?; + drop(collector); + let mut after = Vec::new(); + let _ = store(sandbox.path())?.reconstruct(anchored.target, &mut after)?; + assert_eq!(after, output); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn identical_views_yield_identical_receipts_across_reopen() -> Result<(), Box> { + let contents: [&[u8]; 2] = [b"same view, same receipt", b"other"]; + let (sandbox, published) = durable_store("durable-identical", &contents)?; + let anchored = published.first().ok_or("published")?; + let first = DurableSnapshot::open( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + )?; + let mut one = Vec::new(); + let receipt_one = first.reconstruct(anchored.target, &mut one)?; + drop(first); + let second = store(sandbox.path())?.snapshot()?; + let mut two = Vec::new(); + let receipt_two = second.reconstruct(anchored.target, &mut two)?; + assert_eq!(receipt_one, receipt_two); + assert_eq!(one, two); + assert_eq!( + receipt_one.view().verification_view(), + receipt_two.view().verification_view() + ); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn a_failing_writer_returns_no_receipt_and_the_exact_accepted_prefix() -> Result<(), Box> +{ + struct Refusing { + accepted: usize, + after: usize, + } + impl std::io::Write for Refusing { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if self.accepted >= self.after { + return Err(std::io::Error::other("output refused")); + } + let take = bytes.len().min(self.after.saturating_sub(self.accepted)); + self.accepted = self.accepted.saturating_add(take); + Ok(take) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let contents: [&[u8]; 2] = [b"a receipt never precedes its bytes", b"x"]; + let (sandbox, published) = durable_store("durable-output", &contents)?; + let anchored = published.first().ok_or("published")?; + let snapshot = store(sandbox.path())?.snapshot()?; + let mut output = Refusing { + accepted: 0, + after: 5, + }; + let error = snapshot + .reconstruct(anchored.target, &mut output) + .err() + .ok_or("a refusing writer received a receipt")?; + assert!( + matches!( + &error, + DurableReadError::Reconstruction(source) + if matches!(**source, ReconstructionError::Write { .. }) + ), + "{error:?}" + ); + assert_eq!(output.accepted, 5); + sandbox.remove()?; + Ok(()) +} diff --git a/src/adapters/durable/view.rs b/src/adapters/durable/view.rs new file mode 100644 index 00000000..6ff1b473 --- /dev/null +++ b/src/adapters/durable/view.rs @@ -0,0 +1,54 @@ +//! This boundary module owns the coordinates one durable read binds. + +use crate::adapters::{GcRetentionState, VerificationView}; +use crate::{CatalogDigest, CatalogGeneration}; + +/// The exact view a durable snapshot pinned: the catalog generation and +/// digest `HEAD` selected and the retention state observed under the same +/// fence. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DurableView { + catalog_generation: CatalogGeneration, + catalog_digest: CatalogDigest, + retention: GcRetentionState, +} + +impl DurableView { + pub(super) const fn new( + catalog_generation: CatalogGeneration, + catalog_digest: CatalogDigest, + retention: GcRetentionState, + ) -> Self { + Self { + catalog_generation, + catalog_digest, + retention, + } + } + + /// The catalog generation the view selected. + pub const fn catalog_generation(self) -> CatalogGeneration { + self.catalog_generation + } + + /// That catalog's digest. + pub const fn catalog_digest(self) -> CatalogDigest { + self.catalog_digest + } + + /// The retention state observed under the same fence. + #[must_use] + pub const fn retention(self) -> GcRetentionState { + self.retention + } + + /// The same coordinates as a verification receipt's view. + #[must_use] + pub const fn verification_view(self) -> VerificationView { + VerificationView::Durable { + catalog_generation: self.catalog_generation, + catalog_digest: self.catalog_digest, + retention: self.retention, + } + } +} diff --git a/src/adapters/exports.rs b/src/adapters/exports.rs index b2b69199..3d3eb9aa 100644 --- a/src/adapters/exports.rs +++ b/src/adapters/exports.rs @@ -37,6 +37,7 @@ pub use super::checksummed_publication_head::ChecksummedPublicationHead; pub use super::checksummed_segment_record::ChecksummedSegmentRecord; pub use super::closed_segment::ClosedSegment; pub use super::compaction::*; +pub use super::durable::*; pub use super::filesystem_catalog_publication_error::FilesystemCatalogPublicationError; pub use super::filesystem_catalog_publisher::FilesystemCatalogPublisher; pub use super::filesystem_catalog_snapshot::FilesystemCatalogSnapshot; diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 6376356d..dedbb609 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -67,6 +67,7 @@ mod closed_segment; mod compaction; mod decoded_catalog_entry; mod digest_hex; +mod durable; mod exports; mod filesystem_catalog_artifact; mod filesystem_catalog_catalog; diff --git a/src/lib.rs b/src/lib.rs index 33b2d7f7..4bd1c197 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -200,6 +200,10 @@ pub use adapters::{ FilesystemCompactionAuthority, FilesystemCompactionError, FilesystemCompactionRecoveryError, observe_compaction, plan_compaction, recover_compaction, }; +pub use adapters::{ + DurableOutcome, DurableRangeReadReceipt, DurableReadError, DurableReconstructionReceipt, + DurableSnapshot, DurableStore, DurableStoreError, DurableView, +}; pub use adapters::{ MIGRATION_NAMESPACE_PREFIX, StoreMigrationEffect, StoreMigrationFixedStage, StoreMigrationRecoveryAmbiguity, StoreMigrationRecoveryError, StoreMigrationRecoveryPlan, diff --git a/src/reference/chunk_verification.rs b/src/reference/chunk_verification.rs index 07f700e0..ef8cc563 100644 --- a/src/reference/chunk_verification.rs +++ b/src/reference/chunk_verification.rs @@ -1,23 +1,37 @@ //! Exact reference-store chunk lookup and authentication. -use crate::{ChunkHashError, ChunkId, LayoutEntry, LayoutId, ReferenceStore}; +use crate::{ChunkHashError, ChunkId, LayoutEntry, LayoutId}; -pub(super) fn verified_chunk( - store: &ReferenceStore, +/// One admitted view's exact chunk lookup: the reference store's in-memory +/// map, or a durable snapshot's fenced catalog. Every read core hashes what +/// the source returns before trusting it. +#[expect( + clippy::redundant_pub_crate, + reason = "reached from the durable adapter only through the crate-private re-export" +)] +pub(crate) trait ChunkSource { + /// The exact bytes stored under `identity`, if the view holds them. + fn chunk(&self, identity: ChunkId) -> Option<&[u8]>; + + /// Records that `identity` was hashed, for laws over the hashing pass. + fn note_chunk_hash(&self, identity: ChunkId) { + let _ = identity; + } +} + +pub(super) fn verified_chunk( + store: &S, layout_id: LayoutId, index: usize, entry: LayoutEntry, ) -> Result<&[u8], ChunkVerificationError> { let expected = entry.chunk_id(); - let bytes = store - .chunk(expected) - .ok_or(ChunkVerificationError::Missing { - layout: layout_id, - index, - requested: expected, - })?; - #[cfg(test)] - store.observed_chunk_hashes.borrow_mut().push(expected); + let bytes = ChunkSource::chunk(store, expected).ok_or(ChunkVerificationError::Missing { + layout: layout_id, + index, + requested: expected, + })?; + store.note_chunk_hash(expected); let observed = ChunkId::hash_bytes(bytes).map_err(|source| ChunkVerificationError::Hash { layout: layout_id, index, @@ -63,18 +77,16 @@ pub(super) enum ChunkVerificationError { /// emission pass looks the chunk up by identity and hashes nothing. A chunk /// that vanished between the passes is impossible here; the arm exists so a /// durable adapter that reuses this shape cannot forget it. -pub(super) fn emitted_chunk( - store: &ReferenceStore, +pub(super) fn emitted_chunk( + store: &S, layout_id: LayoutId, index: usize, entry: LayoutEntry, ) -> Result<&[u8], ChunkVerificationError> { let expected = entry.chunk_id(); - store - .chunk(expected) - .ok_or(ChunkVerificationError::Missing { - layout: layout_id, - index, - requested: expected, - }) + ChunkSource::chunk(store, expected).ok_or(ChunkVerificationError::Missing { + layout: layout_id, + index, + requested: expected, + }) } diff --git a/src/reference/mod.rs b/src/reference/mod.rs index 9c3bc07d..bd43dd61 100644 --- a/src/reference/mod.rs +++ b/src/reference/mod.rs @@ -29,11 +29,26 @@ mod verification; pub use crate::profile::ProfileBoundary; pub use capacity::ReferenceStoreCapacity; +#[expect( + clippy::redundant_pub_crate, + reason = "the durable adapter reaches the shared read cores through this crate-private surface" +)] +pub(crate) use chunk_verification::ChunkSource; pub use ingestion_error::{IngestionAllocation, IngestionError}; pub use publish_error::PublishError; pub use published_blob::PublishedBlob; pub use range_read_error::RangeReadError; +#[expect( + clippy::redundant_pub_crate, + reason = "the durable adapter reaches the shared read cores through this crate-private surface" +)] +pub(crate) use range_read_execution::read_admitted; pub use range_read_receipt::RangeReadReceipt; +#[expect( + clippy::redundant_pub_crate, + reason = "the durable adapter reaches the shared read cores through this crate-private surface" +)] +pub(crate) use reconstruction::reconstruct_admitted; pub use reconstruction_error::ReconstructionError; pub use reconstruction_receipt::ReconstructionReceipt; pub use staged_blob::StagedBlob; diff --git a/src/reference/range_read_execution.rs b/src/reference/range_read_execution.rs index feceb2b2..95a9b7b7 100644 --- a/src/reference/range_read_execution.rs +++ b/src/reference/range_read_execution.rs @@ -2,24 +2,29 @@ use std::io::Write; -use crate::{ - AdmittedLayout, ByteLength, ByteRange, ChunkId, LayoutEntry, LayoutId, RangePlan, - ReferenceStore, -}; +use crate::{AdmittedLayout, ByteLength, ByteRange, ChunkId, LayoutEntry, LayoutId, RangePlan}; -use super::chunk_verification::{emitted_chunk, verified_chunk}; +use super::chunk_verification::{ChunkSource, emitted_chunk, verified_chunk}; use super::output_write::write_all; use super::range_read_error_mapping::{range_chunk_error, range_output_error}; use super::{RangeReadError, RangeReadReceipt}; -pub(super) fn read_admitted( - store: &ReferenceStore, +/// Authenticates only the chunks overlapping `requested` against `store`, +/// then emits exactly the requested bytes: the one range core every view +/// shares. +#[expect( + clippy::redundant_pub_crate, + reason = "reached from the durable adapter only through the crate-private re-export" +)] +pub(crate) fn read_admitted( + store: &S, layout_id: LayoutId, layout: &AdmittedLayout, requested: ByteRange, output: &mut W, ) -> Result where + S: ChunkSource + ?Sized, W: Write + ?Sized, { let plan = layout @@ -42,8 +47,8 @@ where )) } -fn verify_selected( - store: &ReferenceStore, +fn verify_selected( + store: &S, layout_id: LayoutId, layout: &AdmittedLayout, plan: RangePlan, @@ -55,14 +60,15 @@ fn verify_selected( Ok(()) } -fn emit_selected( - store: &ReferenceStore, +fn emit_selected( + store: &S, layout_id: LayoutId, layout: &AdmittedLayout, plan: RangePlan, output: &mut W, ) -> Result where + S: ChunkSource + ?Sized, W: Write + ?Sized, { let (first, entries) = selected_entries(layout, plan)?; diff --git a/src/reference/reconstruction.rs b/src/reference/reconstruction.rs index a2b7d86e..13653d4a 100644 --- a/src/reference/reconstruction.rs +++ b/src/reference/reconstruction.rs @@ -6,7 +6,9 @@ use crate::{ AdmittedLayout, BlobHasher, BlobId, BlobLength, LayoutDecodePolicy, LayoutId, ReferenceStore, }; -use super::chunk_verification::{ChunkVerificationError, emitted_chunk, verified_chunk}; +use super::chunk_verification::{ + ChunkSource, ChunkVerificationError, emitted_chunk, verified_chunk, +}; use super::output_write::{OutputWriteError, write_all}; use super::profile_verification::ProfileVerifier; use super::{ReconstructionError, ReconstructionReceipt}; @@ -127,13 +129,20 @@ impl ReferenceStore { } } -fn reconstruct_admitted( - store: &ReferenceStore, +/// Authenticates the complete blob `layout` names against `store`, then +/// emits it: the one reconstruction core every view shares. +#[expect( + clippy::redundant_pub_crate, + reason = "reached from the durable adapter only through the crate-private re-export" +)] +pub(crate) fn reconstruct_admitted( + store: &S, layout_id: LayoutId, layout: &AdmittedLayout, output: &mut W, ) -> Result where + S: ChunkSource + ?Sized, W: Write + ?Sized, { verify_complete_blob(store, layout_id, layout)?; @@ -153,8 +162,8 @@ where )) } -fn verify_complete_blob( - store: &ReferenceStore, +fn verify_complete_blob( + store: &S, layout_id: LayoutId, layout: &AdmittedLayout, ) -> Result<(), ReconstructionError> { @@ -181,13 +190,14 @@ fn verify_complete_blob( Ok(()) } -fn emit_authenticated( - store: &ReferenceStore, +fn emit_authenticated( + store: &S, layout_id: LayoutId, layout: &AdmittedLayout, output: &mut W, ) -> Result where + S: ChunkSource + ?Sized, W: Write + ?Sized, { let mut written = 0_u64; diff --git a/src/reference/store.rs b/src/reference/store.rs index 1bf3ca0a..89e57c55 100644 --- a/src/reference/store.rs +++ b/src/reference/store.rs @@ -120,3 +120,14 @@ impl ReferenceStore { self.chunks.get(&identity).map(Box::as_ref) } } + +impl super::chunk_verification::ChunkSource for ReferenceStore { + fn chunk(&self, identity: ChunkId) -> Option<&[u8]> { + Self::chunk(self, identity) + } + + #[cfg(test)] + fn note_chunk_hash(&self, identity: ChunkId) { + self.observed_chunk_hashes.borrow_mut().push(identity); + } +} From 0d300dbe173c5b55bad122a9cb5a69c65ec5cfca Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 14:06:28 -0700 Subject: [PATCH 32/59] Feat: backend-neutral content-store port with count-and-byte staging limits `ContentReads` (contains_blob, reconstruct, reconstruct_layout, read_range, read_layout_range) is implemented by `ReferenceStore` and `DurableSnapshot`, each keeping its own receipt and error types; `ContentStaging::{stage, stage_expected}` and `StagedContent::commit` are implemented by the non-durable reference store, with the durable writer owed to T-24.2. `StagingLimits` pairs a `LayoutEntryLimit` with a `StagedByteLimit`; `ReferenceStore::stage_bounded` enforces the byte limit as each read is accepted and refuses with `IngestionError::ByteLimitExceeded { limit, accepted, incoming }` before any excess is materialized. Receipts stay distinct types per backend, so a reference receipt cannot be passed where a durable one is required (compile_fail doctest). Generic laws in `src/store/port_laws.rs` run against both backends in-crate; `tests/content_store_port.rs` runs the reference backend through the port from outside the crate. Page: `docs/architecture/content-store/README.md`. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 16 +++ README.md | 4 +- ROADMAP.md | 12 ++- docs/architecture/content-store/README.md | 69 +++++++++++++ src/adapters/durable/mod.rs | 3 + src/adapters/durable/port.rs | 53 ++++++++++ src/adapters/durable/port_tests.rs | 35 +++++++ src/lib.rs | 4 + src/reference/ingestion.rs | 47 +++++++-- src/reference/ingestion_error.rs | 19 ++++ src/reference/mod.rs | 1 + src/reference/port.rs | 113 +++++++++++++++++++++ src/store/limits.rs | 66 ++++++++++++ src/store/mod.rs | 33 ++++++ src/store/port_laws.rs | 71 +++++++++++++ src/store/reads.rs | 74 ++++++++++++++ src/store/reference_port_tests.rs | 72 +++++++++++++ src/store/staging.rs | 74 ++++++++++++++ tests/content_store_port.rs | 118 ++++++++++++++++++++++ 19 files changed, 876 insertions(+), 8 deletions(-) create mode 100644 docs/architecture/content-store/README.md create mode 100644 src/adapters/durable/port.rs create mode 100644 src/adapters/durable/port_tests.rs create mode 100644 src/reference/port.rs create mode 100644 src/store/limits.rs create mode 100644 src/store/mod.rs create mode 100644 src/store/port_laws.rs create mode 100644 src/store/reads.rs create mode 100644 src/store/reference_port_tests.rs create mode 100644 src/store/staging.rs create mode 100644 tests/content_store_port.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f62fa710..996b85c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,22 @@ after its public API and format compatibility policies are established. ### Added +- Backend-neutral content-store port. `ContentReads` (`contains_blob`, + `reconstruct`, `reconstruct_layout`, `read_range`, `read_layout_range`) + is implemented by `ReferenceStore` and `DurableSnapshot`, each with its + own receipt and error types; `ContentStaging::{stage, stage_expected}` + and `StagedContent::commit` are implemented by the non-durable reference + store, with the durable writer owed to T-24.2. `StagingLimits` pairs a + `LayoutEntryLimit` with a `StagedByteLimit`; `ReferenceStore::stage_bounded` + enforces the byte limit as each read is accepted and refuses with + `IngestionError::ByteLimitExceeded { limit, accepted, incoming }` before + any excess is materialized. Receipts stay distinct types per backend, so + a reference receipt cannot be passed where a durable one is required + (`compile_fail` doctest on `src/store/mod.rs`). Generic laws in + `src/store/port_laws.rs` run against both backends; + `tests/content_store_port.rs` runs the reference backend through the port + from outside the crate. The page is + `docs/architecture/content-store/README.md`. - Durable authenticated reads. `DurableStore::open(root, policy, limit)` names a migrated version-two store and `snapshot()` pins one consistent view under the shared reader fence, indexing every retained root's diff --git a/README.md b/README.md index becf66e6..a6ab53db 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,9 @@ the content means, who owns it, or whether deleting it is legally safe. Keep is `0.0.0` and unpublished; build from source. The in-memory [non-durable reference CAS](docs/architecture/reference-store/README.md) is executable evidence for the storage laws, not a durable backend — process -death loses everything in it. +death loses everything in it. Code written against the +[content-store port](docs/architecture/content-store/README.md) runs on it +in tests and on a durable snapshot in production. ```rust use std::io::Cursor; diff --git a/ROADMAP.md b/ROADMAP.md index 791b80b5..18bd23cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1470,7 +1470,17 @@ segment publication, catalog admission, and an exact receipt. It preserves change `BlobId`, `ChunkId`, `LayoutId`, publication order, recovery, or error precision. -- [ ] T-24.1 Backend-neutral ingestion contract. +- [x] T-24.1 Backend-neutral ingestion contract. Done 2026-09-30: the + `store` port module (`ContentReads`, `ContentStaging`, `StagedContent`, + `CommitReceipt`, `StagingLimits`, `StagedByteLimit`); `ReferenceStore` + implements both halves and `DurableSnapshot` the read half; distinct + receipt types give the compile-time law; generic laws run against both + backends in-crate plus `tests/content_store_port.rs` from outside; page + `docs/architecture/content-store/README.md`. Still owed: the durable + writer's `ContentStaging` implementation and the Worldline golden run + through the port land with T-24.2, which is the first durable backend a + staging can commit into. + Original task fields: - **Requirements:** a trait or port that `ReferenceStore` and the durable writer both satisfy where their durability claims overlap: stage, commit, receipt; staging admits count-and-byte limits; the reference diff --git a/docs/architecture/content-store/README.md b/docs/architecture/content-store/README.md new file mode 100644 index 00000000..06989a75 --- /dev/null +++ b/docs/architecture/content-store/README.md @@ -0,0 +1,69 @@ +# Content-store port + +The content-store port is the backend-neutral contract code writes against +once and runs on any admitted backend. It is two halves, each a Rust trait +in the crate root, and a receipt law enforced by the type system. + +## The read half: `ContentReads` + +Every admitted view answers the reference store's read laws through one +trait: `contains_blob`, `reconstruct`, `reconstruct_layout`, `read_range`, +and `read_layout_range`. The laws are the reference store's, unchanged: + +- every emitted byte is authenticated before it is written; +- an exact layout never substitutes another; +- a range read proves only the chunks the range overlaps and emits exactly + the requested bytes; +- absence is evidence only against a complete view. + +Implemented by the non-durable +[`ReferenceStore`](../reference-store/README.md) and by the pinned +[`DurableSnapshot`](../durable-store/README.md). Each backend keeps its own +receipt and error types as associated types: the durable receipts carry the +view coordinates, the reference receipts do not, and neither converts into +the other. + +## The write half: `ContentStaging` and `StagedContent` + +`ContentStaging::stage(source, limits)` chunks, hashes, and holds an +unknown-length source without making anything visible; +`stage_expected(source, expected, limits)` additionally refuses when the +complete source does not hash to `expected`, naming both identities. +`StagingLimits` carries a `LayoutEntryLimit` and a `StagedByteLimit`. Both +refuse before the excess is materialized: the byte limit is checked as each +read is accepted, and the refusal +(`IngestionError::ByteLimitExceeded { limit, accepted, incoming }`) says +how far the source was admitted and what pushed it over. + +`StagedContent::commit(self, store)` makes the staging visible, or leaves +it invisible; the result is a `CommitReceipt` naming the target and the +exact committed layout. + +The reference store implements the write half today and stays honest about +being non-durable: process death loses everything in it, and no port +method claims otherwise. The durable writer lands with durable staged +ingestion (T-24.2); until it does, code that needs a durable commit has no +implementation to reach for, which is the honest state. + +## The receipt law + +A non-durable receipt can never stand where a durable one is required. The +port does not name a common receipt type; each backend's receipt is its own +type, so the substitution is a compile error. `src/store/mod.rs` pins this +with a `compile_fail` doctest that hands a `ReconstructionReceipt` to a +function taking a `DurableReconstructionReceipt`. + +## Evidence + +- `src/store/port_laws.rs` holds the generic laws (exact reconstruction + through both entry points with equal receipts, exact ranges plus a past- + the-end refusal, absence refused with nothing written); the reference + backend runs them in `src/store/reference_port_tests.rs` and the durable + backend in `src/adapters/durable/port_tests.rs`. The durable run is + in-crate because a durable store is built today only through test-only + unchecked admission. +- `tests/content_store_port.rs` runs the reference backend from outside the + crate through the port alone: round trip, byte-limit refusal before + anything is visible, entry-limit refusal, and expected-identity mismatch. +- The Worldline golden run through the port is owed with the durable + writer; it is listed under T-24.1 in the roadmap. diff --git a/src/adapters/durable/mod.rs b/src/adapters/durable/mod.rs index 7d03e12a..9fe8a698 100644 --- a/src/adapters/durable/mod.rs +++ b/src/adapters/durable/mod.rs @@ -9,6 +9,9 @@ //! against. While a snapshot lives, collection cannot retire what it reads. mod error; +mod port; +#[cfg(test)] +mod port_tests; mod receipt; mod snapshot; mod store; diff --git a/src/adapters/durable/port.rs b/src/adapters/durable/port.rs new file mode 100644 index 00000000..83f06105 --- /dev/null +++ b/src/adapters/durable/port.rs @@ -0,0 +1,53 @@ +//! The durable snapshot's implementation of the content-store read port. + +use std::io::Write; + +use super::{ + DurableRangeReadReceipt, DurableReadError, DurableReconstructionReceipt, DurableSnapshot, +}; +use crate::{BlobId, ByteRange, ContentReads, LayoutId}; + +impl ContentReads for DurableSnapshot { + type ReconstructionReceipt = DurableReconstructionReceipt; + type RangeReceipt = DurableRangeReadReceipt; + type ReconstructionError = DurableReadError; + type RangeError = DurableReadError; + + fn contains_blob(&self, target: BlobId) -> bool { + Self::contains_blob(self, target) + } + + fn reconstruct( + &self, + target: BlobId, + output: &mut dyn Write, + ) -> Result { + Self::reconstruct(self, target, output) + } + + fn reconstruct_layout( + &self, + layout_id: LayoutId, + output: &mut dyn Write, + ) -> Result { + Self::reconstruct_layout(self, layout_id, output) + } + + fn read_range( + &self, + target: BlobId, + requested: ByteRange, + output: &mut dyn Write, + ) -> Result { + Self::read_range(self, target, requested, output) + } + + fn read_layout_range( + &self, + layout_id: LayoutId, + requested: ByteRange, + output: &mut dyn Write, + ) -> Result { + Self::read_layout_range(self, layout_id, requested, output) + } +} diff --git a/src/adapters/durable/port_tests.rs b/src/adapters/durable/port_tests.rs new file mode 100644 index 00000000..dd7170c6 --- /dev/null +++ b/src/adapters/durable/port_tests.rs @@ -0,0 +1,35 @@ +//! The generic port laws run against a pinned durable snapshot. + +use std::error::Error; + +use super::DurableStore; +use super::test_fixture::{durable_store, long_content}; +use crate::BlobHasher; +use crate::adapters::ReaderAttemptLimit; +use crate::adapters::retention::filesystem_retention_test_fixture::catalog_policy; +use crate::store::port_laws::{absence_refuses, ranges_exactly, reconstructs_exactly}; + +#[test] +fn durable_backend_satisfies_the_read_laws_through_the_port() -> Result<(), Box> { + let long = long_content(); + let contents: [&[u8]; 3] = [b"durable through the port", &long, b"unanchored"]; + let (sandbox, published) = durable_store("durable-port-laws", &contents)?; + let snapshot = DurableStore::open( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + ) + .snapshot()?; + for (bytes, entry) in contents.iter().zip(&published).take(2) { + reconstructs_exactly(&snapshot, entry.target, entry.layout, bytes)?; + } + let anchored = published.get(1).ok_or("second published blob")?; + ranges_exactly( + &snapshot, + anchored.target, + &long, + &[(0, 1), (65_000, 5_000), (400_000, 42 * 1024)], + )?; + absence_refuses(&snapshot, BlobHasher::new().finish()); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 4bd1c197..ac3a97aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,6 +57,7 @@ mod layout; mod profile; mod reference; mod retention; +mod store; mod verification; #[cfg(feature = "repository-tasks")] @@ -242,6 +243,9 @@ pub use retention::{ RetentionProfileAdmissionError, RetentionRoot, RetentionRootDigest, RetentionRootError, RootGeneration, RootGenerationError, }; +pub use store::{ + CommitReceipt, ContentReads, ContentStaging, StagedByteLimit, StagedContent, StagingLimits, +}; pub use verification::{ CorruptionEvidence, MissingEvidence, VerificationDepth, VerificationError, VerificationFailure, VerificationRefusal, VerificationReport, VerificationSubject, diff --git a/src/reference/ingestion.rs b/src/reference/ingestion.rs index 784fca6f..a06c0758 100644 --- a/src/reference/ingestion.rs +++ b/src/reference/ingestion.rs @@ -3,7 +3,10 @@ use std::io::{ErrorKind, Read}; use crate::layout::check_entry_limit; -use crate::{AdmittedLayout, BlobHasher, BlobId, ChunkId, ChunkSpan, FastCdc, LayoutEntryLimit}; +use crate::{ + AdmittedLayout, BlobHasher, BlobId, ChunkId, ChunkSpan, FastCdc, LayoutEntryLimit, + StagedByteLimit, StagingLimits, +}; use super::chunk_staging::ReferenceChunkStaging; use super::ingestion_error::IngestionAllocation; @@ -74,8 +77,26 @@ impl ReferenceStore { where R: Read + ?Sized, { + self.stage_bounded(source, StagingLimits::entries(entry_limit)) + } + + /// As [`Self::stage`], additionally refusing before any byte beyond + /// `limits.byte_limit()` is materialized. + /// + /// # Errors + /// + /// As [`Self::stage`], plus [`IngestionError::ByteLimitExceeded`]. + pub fn stage_bounded( + &self, + source: &mut R, + limits: StagingLimits, + ) -> Result + where + R: Read + ?Sized, + { + let entry_limit = limits.entry_limit(); let mut staging = ReferenceChunkStaging::new(self); - let (target, spans) = ingest_stream(source, &mut staging, entry_limit)?; + let (target, spans) = ingest_stream(source, &mut staging, limits)?; let layout = AdmittedLayout::from_spans( target, crate::RegisteredStorageProfile::FAST_CDC_64K_V1, @@ -123,11 +144,12 @@ impl ReferenceStore { fn ingest_stream( source: &mut R, staging: &mut ReferenceChunkStaging<'_>, - entry_limit: LayoutEntryLimit, + limits: StagingLimits, ) -> Result<(crate::BlobId, Vec), IngestionError> where R: Read + ?Sized, { + let entry_limit = limits.entry_limit(); let maximum = usize::try_from(FastCdc::MAXIMUM_CHUNK_LENGTH.get()).map_err(|_source| { IngestionError::StreamLengthOverflow { accepted: 0, @@ -142,7 +164,7 @@ where requested: maximum, source, })?; - let mut state = StreamState::new(chunk_buffer, entry_limit); + let mut state = StreamState::new(chunk_buffer, entry_limit, limits.byte_limit()); let mut read_buffer = [0_u8; READ_BUFFER_BYTES]; loop { match source.read(&mut read_buffer) { @@ -170,10 +192,15 @@ struct StreamState { spans: Vec, accepted: u64, entry_limit: LayoutEntryLimit, + byte_limit: StagedByteLimit, } impl StreamState { - fn new(chunk_buffer: Vec, entry_limit: LayoutEntryLimit) -> Self { + fn new( + chunk_buffer: Vec, + entry_limit: LayoutEntryLimit, + byte_limit: StagedByteLimit, + ) -> Self { Self { detector: FastCdc::new(), blob_hasher: BlobHasher::new(), @@ -181,6 +208,7 @@ impl StreamState { spans: Vec::new(), accepted: 0, entry_limit, + byte_limit, } } @@ -189,6 +217,14 @@ impl StreamState { bytes: &[u8], staging: &mut ReferenceChunkStaging<'_>, ) -> Result<(), IngestionError> { + let next_accepted = checked_accepted(self.accepted, bytes.len())?; + if next_accepted > self.byte_limit.get() { + return Err(IngestionError::ByteLimitExceeded { + limit: self.byte_limit, + accepted: self.accepted, + incoming: bytes.len(), + }); + } self.blob_hasher .update(bytes) .map_err(IngestionError::BlobHash)?; @@ -196,7 +232,6 @@ impl StreamState { self.detector .feed(bytes, |span| emission.record(span)) .map_err(IngestionError::Chunking)?; - let next_accepted = checked_accepted(self.accepted, bytes.len())?; match emission { FeedEmission::None => self.chunk_buffer.extend_from_slice(bytes), FeedEmission::One(span) => self.accept_boundary(bytes, span, staging)?, diff --git a/src/reference/ingestion_error.rs b/src/reference/ingestion_error.rs index f3be6869..19d40c6e 100644 --- a/src/reference/ingestion_error.rs +++ b/src/reference/ingestion_error.rs @@ -77,6 +77,15 @@ pub enum IngestionError { /// Current read byte count. incoming: usize, }, + /// The source exceeded the staging's byte limit. + ByteLimitExceeded { + /// The admitted limit. + limit: crate::StagedByteLimit, + /// Bytes accepted before the current read. + accepted: u64, + /// Current read byte count. + incoming: usize, + }, /// A bounded allocation could not be reserved. Allocation { /// Allocation purpose. @@ -139,6 +148,15 @@ impl fmt::Display for IngestionError { formatter, "stream length overflow after {accepted} bytes with {incoming} incoming bytes" ), + Self::ByteLimitExceeded { + limit, + accepted, + incoming, + } => write!( + formatter, + "source exceeds the staging limit of {limit} after {accepted} bytes with \ + {incoming} incoming bytes" + ), Self::Allocation { target, requested, .. } => write!( @@ -177,6 +195,7 @@ impl Error for IngestionError { | Self::BoundaryOutOfRange { .. } | Self::MultipleBoundaries { .. } | Self::StreamLengthOverflow { .. } + | Self::ByteLimitExceeded { .. } | Self::CapacityExceeded { .. } | Self::ConflictingChunk { .. } => None, } diff --git a/src/reference/mod.rs b/src/reference/mod.rs index bd43dd61..58f3df44 100644 --- a/src/reference/mod.rs +++ b/src/reference/mod.rs @@ -10,6 +10,7 @@ mod chunk_verification; mod ingestion; mod ingestion_error; mod output_write; +mod port; mod profile_verification; mod publish_error; mod published_blob; diff --git a/src/reference/port.rs b/src/reference/port.rs new file mode 100644 index 00000000..4fd70415 --- /dev/null +++ b/src/reference/port.rs @@ -0,0 +1,113 @@ +//! The reference store's implementation of the content-store port: both +//! halves, non-durable by construction. + +use std::io::{Read, Write}; + +use super::{ + IngestionError, PublishError, PublishedBlob, RangeReadError, RangeReadReceipt, + ReconstructionError, ReconstructionReceipt, ReferenceStore, StagedBlob, +}; +use crate::{ + BlobId, ByteRange, CommitReceipt, ContentReads, ContentStaging, LayoutId, StagedContent, + StagingLimits, +}; + +impl ContentReads for ReferenceStore { + type ReconstructionReceipt = ReconstructionReceipt; + type RangeReceipt = RangeReadReceipt; + type ReconstructionError = ReconstructionError; + type RangeError = RangeReadError; + + fn contains_blob(&self, target: BlobId) -> bool { + Self::contains_blob(self, target) + } + + fn reconstruct( + &self, + target: BlobId, + output: &mut dyn Write, + ) -> Result { + Self::reconstruct(self, target, output) + } + + fn reconstruct_layout( + &self, + layout_id: LayoutId, + output: &mut dyn Write, + ) -> Result { + Self::reconstruct_layout(self, layout_id, output) + } + + fn read_range( + &self, + target: BlobId, + requested: ByteRange, + output: &mut dyn Write, + ) -> Result { + Self::read_range(self, target, requested, output) + } + + fn read_layout_range( + &self, + layout_id: LayoutId, + requested: ByteRange, + output: &mut dyn Write, + ) -> Result { + Self::read_layout_range(self, layout_id, requested, output) + } +} + +impl ContentStaging for ReferenceStore { + type Staged = StagedBlob; + type Error = IngestionError; + + fn stage( + &self, + source: &mut dyn Read, + limits: StagingLimits, + ) -> Result { + self.stage_bounded(source, limits) + } + + fn stage_expected( + &self, + source: &mut dyn Read, + expected: BlobId, + limits: StagingLimits, + ) -> Result { + let staged = self.stage_bounded(source, limits)?; + let observed = staged.target(); + if observed != expected { + return Err(IngestionError::BlobIdentityMismatch { expected, observed }); + } + Ok(staged) + } +} + +impl StagedContent for StagedBlob { + type Store = ReferenceStore; + type Receipt = PublishedBlob; + type Error = PublishError; + + fn target(&self) -> BlobId { + Self::target(self) + } + + fn layout_id(&self) -> LayoutId { + Self::layout_id(self) + } + + fn commit(self, store: &mut ReferenceStore) -> Result { + Self::commit(self, store) + } +} + +impl CommitReceipt for PublishedBlob { + fn target(&self) -> BlobId { + Self::target(*self) + } + + fn layout_id(&self) -> LayoutId { + Self::layout_id(*self) + } +} diff --git a/src/store/limits.rs b/src/store/limits.rs new file mode 100644 index 00000000..75b9b09c --- /dev/null +++ b/src/store/limits.rs @@ -0,0 +1,66 @@ +//! This boundary module owns the count-and-byte limits staging admits. + +use std::fmt; + +use crate::LayoutEntryLimit; + +/// The most bytes one staging may accept from its source. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct StagedByteLimit(u64); + +impl StagedByteLimit { + /// No bound beyond the backend's own capacity. + pub const MAXIMUM: Self = Self(u64::MAX); + + /// A bound of exactly `bytes`. + #[must_use] + pub const fn new(bytes: u64) -> Self { + Self(bytes) + } + + /// The bound. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +impl fmt::Display for StagedByteLimit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{} bytes", self.0) + } +} + +/// What one staging admits: at most `entries` layout entries and at most +/// `bytes` source bytes. Both refuse before the excess is materialized. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StagingLimits { + entries: LayoutEntryLimit, + bytes: StagedByteLimit, +} + +impl StagingLimits { + /// Limits of exactly `entries` and `bytes`. + #[must_use] + pub const fn new(entries: LayoutEntryLimit, bytes: StagedByteLimit) -> Self { + Self { entries, bytes } + } + + /// The entry limit alone, with no byte bound beyond the backend's. + #[must_use] + pub const fn entries(entries: LayoutEntryLimit) -> Self { + Self::new(entries, StagedByteLimit::MAXIMUM) + } + + /// The layout entry limit. + #[must_use] + pub const fn entry_limit(self) -> LayoutEntryLimit { + self.entries + } + + /// The source byte limit. + #[must_use] + pub const fn byte_limit(self) -> StagedByteLimit { + self.bytes + } +} diff --git a/src/store/mod.rs b/src/store/mod.rs new file mode 100644 index 00000000..d32336a0 --- /dev/null +++ b/src/store/mod.rs @@ -0,0 +1,33 @@ +//! The backend-neutral content-store port. +//! +//! `ContentReads` is what every admitted view answers: `contains_blob`, +//! `reconstruct`, `reconstruct_layout`, `read_range`, and +//! `read_layout_range`, with the reference store's laws. `ContentStaging` +//! and `StagedContent` are the write half: stage a source under +//! count-and-byte limits, then commit the staged content into its store +//! for a receipt. The non-durable `ReferenceStore` implements both halves +//! and stays honest about being non-durable; the durable `DurableSnapshot` +//! implements the read half, and the durable writer lands with the +//! bounded ingestion path. +//! +//! Receipts are distinct types per backend, so a non-durable receipt can +//! never stand where a durable one is required: +//! +//! ```compile_fail +//! fn requires_durable(_receipt: keep::DurableReconstructionReceipt) {} +//! fn hand_over(receipt: keep::ReconstructionReceipt) { +//! requires_durable(receipt); +//! } +//! ``` + +mod limits; +#[cfg(test)] +pub(crate) mod port_laws; +mod reads; +#[cfg(test)] +mod reference_port_tests; +mod staging; + +pub use limits::{StagedByteLimit, StagingLimits}; +pub use reads::ContentReads; +pub use staging::{CommitReceipt, ContentStaging, StagedContent}; diff --git a/src/store/port_laws.rs b/src/store/port_laws.rs new file mode 100644 index 00000000..00a3d489 --- /dev/null +++ b/src/store/port_laws.rs @@ -0,0 +1,71 @@ +//! Generic laws every `ContentReads` view satisfies, run by each backend's +//! own tests against its own fixtures. + +#![expect( + clippy::redundant_pub_crate, + reason = "the durable adapter runs the generic laws through this crate-private surface" +)] + +use std::error::Error; + +use super::ContentReads; +use crate::{BlobId, ByteLength, ByteOffset, ByteRange, LayoutId}; + +/// The view reconstructs `target` to exactly `expected`, through both the +/// automatic and the exact-layout entry points, with equal receipts. +pub(crate) fn reconstructs_exactly( + view: &C, + target: BlobId, + layout_id: LayoutId, + expected: &[u8], +) -> Result<(), Box> { + assert!(view.contains_blob(target)); + let mut output = Vec::new(); + let receipt = view + .reconstruct(target, &mut output) + .map_err(|error| error.to_string())?; + assert_eq!(output.as_slice(), expected); + let mut exact = Vec::new(); + let exact_receipt = view + .reconstruct_layout(layout_id, &mut exact) + .map_err(|error| error.to_string())?; + assert_eq!(exact.as_slice(), expected); + assert_eq!(exact_receipt, receipt); + Ok(()) +} + +/// Every range the view is asked for emits exactly that slice, and a range +/// past the end refuses without a receipt. +pub(crate) fn ranges_exactly( + view: &C, + target: BlobId, + expected: &[u8], + ranges: &[(u64, u64)], +) -> Result<(), Box> { + for (offset, length) in ranges { + let requested = ByteRange::new(ByteOffset::new(*offset), ByteLength::new(*length))?; + let mut output = Vec::new(); + let _receipt = view + .read_range(target, requested, &mut output) + .map_err(|error| error.to_string())?; + let start = usize::try_from(*offset)?; + let end = usize::try_from(offset.saturating_add(*length))?; + assert_eq!(Some(output.as_slice()), expected.get(start..end)); + } + let past = ByteRange::new( + ByteOffset::new(u64::try_from(expected.len())?), + ByteLength::new(1), + )?; + let mut output = Vec::new(); + assert!(view.read_range(target, past, &mut output).is_err()); + assert!(output.is_empty()); + Ok(()) +} + +/// An absent blob is refused with nothing written. +pub(crate) fn absence_refuses(view: &C, absent: BlobId) { + assert!(!view.contains_blob(absent)); + let mut output = Vec::new(); + assert!(view.reconstruct(absent, &mut output).is_err()); + assert!(output.is_empty()); +} diff --git a/src/store/reads.rs b/src/store/reads.rs new file mode 100644 index 00000000..82c2c21d --- /dev/null +++ b/src/store/reads.rs @@ -0,0 +1,74 @@ +//! This boundary module owns the read half of the content-store port. + +use std::error::Error; +use std::fmt::Debug; +use std::io::Write; + +use crate::{BlobId, ByteRange, LayoutId}; + +/// Authenticated reads over one admitted view. +/// +/// The laws are the reference store's: every emitted byte is +/// authenticated first, an exact layout never substitutes another, a range +/// proves only the requested bytes, and absence is evidence only against a +/// complete view. +pub trait ContentReads { + /// The receipt one complete reconstruction returns. + type ReconstructionReceipt: Copy + Debug + Eq; + /// The receipt one exact range read returns. + type RangeReceipt: Copy + Debug + Eq; + /// Why a reconstruction returned no receipt. + type ReconstructionError: Error + 'static; + /// Why a range read returned no receipt. + type RangeError: Error + 'static; + + /// Whether the view can serve `target` by identity. + fn contains_blob(&self, target: BlobId) -> bool; + + /// Reconstructs `target` through the view's deterministic layout choice. + /// + /// # Errors + /// + /// Returns the backend's evidenced refusal or operational failure. + fn reconstruct( + &self, + target: BlobId, + output: &mut dyn Write, + ) -> Result; + + /// Reconstructs the exact committed layout `layout_id`, never another. + /// + /// # Errors + /// + /// As [`Self::reconstruct`]. + fn reconstruct_layout( + &self, + layout_id: LayoutId, + output: &mut dyn Write, + ) -> Result; + + /// Reads exactly `requested` of `target`, authenticating only the + /// overlapping chunks. + /// + /// # Errors + /// + /// Returns the backend's evidenced refusal or operational failure. + fn read_range( + &self, + target: BlobId, + requested: ByteRange, + output: &mut dyn Write, + ) -> Result; + + /// Reads exactly `requested` through the exact committed layout. + /// + /// # Errors + /// + /// As [`Self::read_range`]. + fn read_layout_range( + &self, + layout_id: LayoutId, + requested: ByteRange, + output: &mut dyn Write, + ) -> Result; +} diff --git a/src/store/reference_port_tests.rs b/src/store/reference_port_tests.rs new file mode 100644 index 00000000..36b31c9d --- /dev/null +++ b/src/store/reference_port_tests.rs @@ -0,0 +1,72 @@ +//! The generic port laws run against the non-durable reference backend. + +use std::error::Error; +use std::io::Cursor; + +use super::port_laws::{absence_refuses, ranges_exactly, reconstructs_exactly}; +use super::{ContentReads, ContentStaging, StagedByteLimit, StagedContent, StagingLimits}; +use crate::{BlobHasher, IngestionError, LayoutEntryLimit, ReferenceStore, ReferenceStoreCapacity}; + +fn content() -> Vec { + let mut state = 0x2545_f491_4f6c_dd1d_u64; + (0..192 * 1024) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + u8::try_from(state & 0xff).unwrap_or_default() + }) + .collect() +} + +#[test] +fn reference_backend_satisfies_the_read_laws_through_the_port() -> Result<(), Box> { + let content = content(); + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(4 * 1024 * 1024)); + let staged = ContentStaging::stage( + &store, + &mut Cursor::new(&content), + StagingLimits::entries(LayoutEntryLimit::MAXIMUM), + )?; + let receipt = StagedContent::commit(staged, &mut store)?; + reconstructs_exactly(&store, receipt.target(), receipt.layout_id(), &content)?; + ranges_exactly( + &store, + receipt.target(), + &content, + &[(0, 1), (65_000, 5_000), (150_000, 42 * 1024)], + )?; + absence_refuses(&store, BlobHasher::new().finish()); + Ok(()) +} + +#[test] +fn the_byte_limit_refuses_before_any_excess_is_materialized() -> Result<(), Box> { + let content = content(); + let store = ReferenceStore::new(ReferenceStoreCapacity::new(4 * 1024 * 1024)); + let limit = StagedByteLimit::new(u64::try_from(content.len())?.saturating_sub(1)); + let refusal = ContentStaging::stage( + &store, + &mut Cursor::new(&content), + StagingLimits::new(LayoutEntryLimit::MAXIMUM, limit), + ); + let Err(IngestionError::ByteLimitExceeded { + limit: observed, + accepted, + incoming, + }) = refusal + else { + return Err("expected a byte-limit refusal".into()); + }; + assert_eq!(observed, limit); + assert!(accepted <= limit.get()); + assert!(accepted.saturating_add(u64::try_from(incoming)?) > limit.get()); + let exact = StagedByteLimit::new(u64::try_from(content.len())?); + let staged = ContentStaging::stage( + &store, + &mut Cursor::new(&content), + StagingLimits::new(LayoutEntryLimit::MAXIMUM, exact), + )?; + assert!(!ContentReads::contains_blob(&store, staged.target())); + Ok(()) +} diff --git a/src/store/staging.rs b/src/store/staging.rs new file mode 100644 index 00000000..162cc22e --- /dev/null +++ b/src/store/staging.rs @@ -0,0 +1,74 @@ +//! This boundary module owns the write half of the content-store port: +//! stage under limits, then commit for a receipt. + +use std::error::Error; +use std::fmt::Debug; +use std::io::Read; + +use super::StagingLimits; +use crate::{BlobId, LayoutId}; + +/// What one committed staging proves: the target and the exact layout it +/// became visible under. Each backend's receipt is its own type. +pub trait CommitReceipt: Copy + Debug + Eq { + /// The blob made visible. + fn target(&self) -> BlobId; + /// The exact committed layout. + fn layout_id(&self) -> LayoutId; +} + +/// Content staged but not yet visible. +pub trait StagedContent: Sized { + /// The store the staging commits into. + type Store: ?Sized; + /// The commit receipt. + type Receipt: CommitReceipt; + /// Why the commit returned no receipt. + type Error: Error + 'static; + + /// The blob identity the staging established. + fn target(&self) -> BlobId; + + /// The exact layout the staging will commit under. + fn layout_id(&self) -> LayoutId; + + /// Makes the staged content visible in `store`, or leaves it invisible. + /// + /// # Errors + /// + /// Returns the backend's refusal; nothing becomes visible on failure. + fn commit(self, store: &mut Self::Store) -> Result; +} + +/// Staging an unknown-length source under count-and-byte limits. +pub trait ContentStaging { + /// The staged, not-yet-visible content. + type Staged: StagedContent; + /// Why staging returned nothing. + type Error: Error + 'static; + + /// Chunks, hashes, and holds `source` without making it visible, + /// refusing before any excess over `limits` is materialized. + /// + /// # Errors + /// + /// Returns the backend's refusal or the source's failure. + fn stage( + &self, + source: &mut dyn Read, + limits: StagingLimits, + ) -> Result; + + /// As [`Self::stage`], refusing when the source does not hash to + /// `expected`. + /// + /// # Errors + /// + /// As [`Self::stage`], plus the identity mismatch. + fn stage_expected( + &self, + source: &mut dyn Read, + expected: BlobId, + limits: StagingLimits, + ) -> Result; +} diff --git a/tests/content_store_port.rs b/tests/content_store_port.rs new file mode 100644 index 00000000..773d0c80 --- /dev/null +++ b/tests/content_store_port.rs @@ -0,0 +1,118 @@ +//! The content-store port from outside the crate: code written once +//! against `ContentStaging` and `ContentReads` runs on the reference +//! backend; receipts carry the identities they claim; the byte and entry +//! limits refuse before anything becomes visible; an expected identity +//! that does not match refuses with both identities. + +use std::error::Error; +use std::io::Cursor; + +use keep::{ + BlobHasher, BlobId, ByteLength, ByteOffset, ByteRange, CommitReceipt, ContentReads, + ContentStaging, IngestionError, LayoutEntryLimit, ReferenceStore, ReferenceStoreCapacity, + StagedByteLimit, StagedContent, StagingLimits, +}; + +const CAPACITY: usize = 4 * 1024 * 1024; + +fn content(seed: u64, length: usize) -> Vec { + let mut state = seed; + (0..length) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + u8::try_from(state & 0xff).unwrap_or_default() + }) + .collect() +} + +/// Backend-neutral: stage, commit, and read back through the port alone. +fn round_trip(store: &mut S, bytes: &[u8]) -> Result<(BlobId, Vec), Box> +where + S: ContentStaging + ContentReads, + S::Staged: StagedContent, +{ + let staged = store.stage( + &mut Cursor::new(bytes), + StagingLimits::entries(LayoutEntryLimit::MAXIMUM), + )?; + let expected_target = staged.target(); + let expected_layout = staged.layout_id(); + let receipt = staged.commit(store)?; + assert_eq!(receipt.target(), expected_target); + assert_eq!(receipt.layout_id(), expected_layout); + let mut output = Vec::new(); + store + .reconstruct_layout(receipt.layout_id(), &mut output) + .map_err(|error| error.to_string())?; + Ok((receipt.target(), output)) +} + +#[test] +fn the_reference_backend_round_trips_through_the_port() -> Result<(), Box> { + let bytes = content(0x1234_5678_9abc_def1, 200 * 1024); + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let (target, output) = round_trip(&mut store, &bytes)?; + assert_eq!(output, bytes); + assert!(ContentReads::contains_blob(&store, target)); + let requested = ByteRange::new(ByteOffset::new(70_000), ByteLength::new(3_000))?; + let mut range = Vec::new(); + let _receipt = ContentReads::read_range(&store, target, requested, &mut range)?; + assert_eq!(Some(range.as_slice()), bytes.get(70_000..73_000)); + Ok(()) +} + +#[test] +fn staging_refuses_the_byte_limit_before_anything_is_visible() -> Result<(), Box> { + let bytes = content(0xfeed_beef_dead_c0de, 96 * 1024); + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let limits = StagingLimits::new(LayoutEntryLimit::MAXIMUM, StagedByteLimit::new(64 * 1024)); + let refusal = ContentStaging::stage(&store, &mut Cursor::new(&bytes), limits); + assert!(matches!( + refusal, + Err(IngestionError::ByteLimitExceeded { limit, .. }) if limit.get() == 64 * 1024 + )); + let (target, _) = round_trip(&mut store, &bytes)?; + assert!(ContentReads::contains_blob(&store, target)); + Ok(()) +} + +#[test] +fn staging_refuses_the_entry_limit_through_the_port() -> Result<(), Box> { + let bytes = content(0x0bad_cafe_f00d_face, 512 * 1024); + let store = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let limits = StagingLimits::entries(LayoutEntryLimit::new(1)?); + let refusal = ContentStaging::stage(&store, &mut Cursor::new(&bytes), limits); + assert!(refusal.is_err()); + Ok(()) +} + +#[test] +fn an_expected_identity_that_does_not_match_refuses_with_both() -> Result<(), Box> { + let bytes = content(0x7777_1111_2222_3333, 8 * 1024); + let store = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let wrong = BlobHasher::new().finish(); + let mut hasher = BlobHasher::new(); + hasher.update(&bytes)?; + let right = hasher.finish(); + let refusal = ContentStaging::stage_expected( + &store, + &mut Cursor::new(&bytes), + wrong, + StagingLimits::entries(LayoutEntryLimit::MAXIMUM), + ); + assert!(matches!( + refusal, + Err(IngestionError::BlobIdentityMismatch { expected, observed }) + if expected == wrong && observed == right + )); + let staged = ContentStaging::stage_expected( + &store, + &mut Cursor::new(&bytes), + right, + StagingLimits::entries(LayoutEntryLimit::MAXIMUM), + )?; + assert_eq!(StagedContent::target(&staged), right); + Ok(()) +} From 92f3eb47f3799e7c0986639896881ba3c91109a6 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 14:09:33 -0700 Subject: [PATCH 33/59] Fix: repin rematerialized version-two digests and satisfy the pinned toolchain's gates The GC commit rematerialized the version-two marker and migration-intent fixtures when `definition.tsv` gained the GC domains, but the marker digest, store identifier, and intent digest pinned in `tests/store_format_marker.rs`, `tests/store_migration_intent/fixture.rs`, and the corpus README were not refreshed; they now match `artifacts.tsv`. The authority contract states the one deliberate exception compaction introduced: the catalog publisher's `open_version_two` consumes a version-two admission by value for catalog successors and never accepts version-one authority for a version-two root. The fuzz harness set registers `verification_receipt`. The xtask crate satisfies the pinned 1.96 clippy: the crash-point table is spliced by `include!` instead of a `pub(super)` const in a private module, the GC mismatch helper takes its message by reference, and the mutation ledger check uses `if let`, `strip_suffix`, and `div_euclid`. Co-Authored-By: Claude Fable 5.1 --- conformance/segment-store/v2/README.md | 2 +- tests/store_format_marker.rs | 4 +-- tests/store_migration_intent/fixture.rs | 8 +++--- tests/version_two_admission_contract.rs | 26 ++++++++++++++++--- .../src/durability_crash_matrix/restart/gc.rs | 14 +++++----- xtask/src/fuzz_campaign/target/tests.rs | 1 + .../segment_store_mutations.rs | 19 +++++++------- .../tests/durability_crash_point_contract.rs | 13 +++++----- .../expected.rs | 14 +++------- 9 files changed, 58 insertions(+), 43 deletions(-) diff --git a/conformance/segment-store/v2/README.md b/conformance/segment-store/v2/README.md index d53c5767..a7cd6278 100644 --- a/conformance/segment-store/v2/README.md +++ b/conformance/segment-store/v2/README.md @@ -57,7 +57,7 @@ The migration fixture preserves the version-1 one-zero segment and generation-1 catalog. Its canonical two-entry inventory digest is `40bf5d49c34847ac9cf46a256f343cee80cd980d1405d2dd02ceff8f58d674f9`. The derived logical store identifier is -`2b5ed4bcc926a6a5fa9fd5f749c134894de99d37bdf2def4e83bdf99a6539720`. +`a046bebd6d1b05d33b56e26e131e5d44bc1872d875d339f837eecd21caa0c1e1`. Fixture-only root device, mount, and file coordinates are `1`, `2`, and `3`; they bind in-place recovery but do not enter the logical store identifier. diff --git a/tests/store_format_marker.rs b/tests/store_format_marker.rs index 03a569dd..017224ee 100644 --- a/tests/store_format_marker.rs +++ b/tests/store_format_marker.rs @@ -11,8 +11,8 @@ use keep::{ const FORMAT_MARKER: &str = include_str!("../conformance/segment-store/v2/format-marker.hex"); const MARKER_DIGEST: [u8; 32] = [ - 0xcb, 0xb0, 0xd6, 0x0f, 0x9a, 0xaa, 0x89, 0x66, 0x42, 0xe9, 0xe7, 0xcf, 0xa5, 0xe9, 0x57, 0x5a, - 0xd0, 0x78, 0x28, 0x85, 0xd5, 0x22, 0x1d, 0xfd, 0x72, 0x89, 0xcd, 0xe7, 0x79, 0xc6, 0x3e, 0xa0, + 0x44, 0x7c, 0xf1, 0xe1, 0xeb, 0xce, 0x88, 0xaf, 0x06, 0x61, 0x36, 0x85, 0x25, 0xb7, 0xbe, 0x6b, + 0x94, 0xf7, 0xe2, 0x32, 0x0b, 0x73, 0x66, 0xa4, 0x57, 0x82, 0x17, 0x2d, 0x30, 0xd2, 0x51, 0x06, ]; #[test] diff --git a/tests/store_migration_intent/fixture.rs b/tests/store_migration_intent/fixture.rs index 1fc154b3..b423da1c 100644 --- a/tests/store_migration_intent/fixture.rs +++ b/tests/store_migration_intent/fixture.rs @@ -20,12 +20,12 @@ pub(super) const INVENTORY_DIGEST: [u8; 32] = [ 0x80, 0xcd, 0x98, 0x0d, 0x14, 0x05, 0xd2, 0xdd, 0x02, 0xce, 0xff, 0x8f, 0x58, 0xd6, 0x74, 0xf9, ]; pub(super) const STORE_IDENTIFIER: [u8; 32] = [ - 0x2b, 0x5e, 0xd4, 0xbc, 0xc9, 0x26, 0xa6, 0xa5, 0xfa, 0x9f, 0xd5, 0xf7, 0x49, 0xc1, 0x34, 0x89, - 0x4d, 0xe9, 0x9d, 0x37, 0xbd, 0xf2, 0xde, 0xf4, 0xe8, 0x3b, 0xdf, 0x99, 0xa6, 0x53, 0x97, 0x20, + 0xa0, 0x46, 0xbe, 0xbd, 0x6d, 0x1b, 0x05, 0xd3, 0x3b, 0x56, 0xe2, 0x6e, 0x13, 0x1e, 0x5d, 0x44, + 0xbc, 0x18, 0x72, 0xd8, 0x75, 0xd3, 0x39, 0xf8, 0x37, 0xee, 0xcd, 0x21, 0xca, 0xa0, 0xc1, 0xe1, ]; pub(super) const INTENT_DIGEST: [u8; 32] = [ - 0xf4, 0x91, 0x4d, 0x8d, 0x91, 0x76, 0x71, 0x0e, 0xba, 0xd4, 0xff, 0x5c, 0x3f, 0x9b, 0x5a, 0xb8, - 0x72, 0x7b, 0x3e, 0xb4, 0xc4, 0x63, 0xd1, 0x18, 0x5f, 0x97, 0x47, 0x98, 0xcc, 0xa4, 0xa4, 0xc3, + 0x6e, 0xd7, 0x65, 0x46, 0xeb, 0x08, 0x7a, 0x78, 0x72, 0x88, 0x1f, 0x93, 0xda, 0x53, 0xeb, 0x8f, + 0x9c, 0x94, 0x1e, 0x52, 0x5a, 0x45, 0x85, 0xb8, 0xf4, 0x1e, 0x45, 0x42, 0xd9, 0xbf, 0xb5, 0x03, ]; pub(super) fn fixture_bytes() -> Result, io::Error> { diff --git a/tests/version_two_admission_contract.rs b/tests/version_two_admission_contract.rs index b6ad2df4..4f470778 100644 --- a/tests/version_two_admission_contract.rs +++ b/tests/version_two_admission_contract.rs @@ -1,4 +1,8 @@ -//! Version-two writer authority is a distinct type that version-one publishers cannot consume. +//! Version-two writer authority is a distinct type that version-one publishers +//! cannot consume. The one deliberate exception is the catalog publisher's +//! `open_version_two`, which compaction uses to publish catalog successors +//! over a migrated root: it consumes a version-two admission by value and +//! never accepts version-one authority for a version-two root. const RETENTION_AUTHORITY: &str = include_str!("../src/adapters/retention/filesystem_retention_authority.rs"); @@ -23,12 +27,28 @@ fn retention_publication_consumes_only_version_two_authority() { #[test] fn version_one_publishers_consume_only_version_one_authority() { - assert!(CATALOG_PUBLISHER.contains("FilesystemPlatformAdmission")); - assert!(!CATALOG_PUBLISHER.contains("FilesystemVersionTwoAdmission")); + assert!(CATALOG_PUBLISHER.contains("admission: FilesystemPlatformAdmission")); assert!(MIGRATION_AUTHORITY.contains("FilesystemPlatformAdmission")); assert!(!MIGRATION_AUTHORITY.contains("FilesystemVersionTwoAdmission")); } +#[test] +fn catalog_successors_consume_version_two_authority_only_as_a_proof() { + assert!( + CATALOG_PUBLISHER.contains( + "pub fn open_version_two(\n admission: FilesystemVersionTwoAdmission," + ) + ); + assert_eq!( + CATALOG_PUBLISHER + .matches("FilesystemVersionTwoAdmission") + .count(), + 2, + "the import and the one by-value parameter" + ); + assert!(!CATALOG_PUBLISHER.contains("FilesystemVersionTwoAdmission::")); +} + #[test] fn version_two_reopen_produces_only_version_two_authority() { assert!(!STORE_INITIALIZER.contains("fn reopen_version_two")); diff --git a/xtask/src/durability_crash_matrix/restart/gc.rs b/xtask/src/durability_crash_matrix/restart/gc.rs index ecce8d47..016a6d75 100644 --- a/xtask/src/durability_crash_matrix/restart/gc.rs +++ b/xtask/src/durability_crash_matrix/restart/gc.rs @@ -28,7 +28,7 @@ pub(super) fn verify( if report != (GcRecoveryPlan::DiscardStage { stage }) { return Err(mismatch( case, - format!("expected discard of {stage:?}, got {report:?}"), + &format!("expected discard of {stage:?}, got {report:?}"), )); } } @@ -37,7 +37,7 @@ pub(super) fn verify( if report != expected { return Err(mismatch( case, - format!("expected {expected:?}, recovered {report:?}"), + &format!("expected {expected:?}, recovered {report:?}"), )); } if report == GcRecoveryPlan::Idle { @@ -70,7 +70,7 @@ fn require_complete( ) -> Result<(), DurabilityCrashMatrixError> { require_live_segment(store_root)?; if store_root.join(SEGMENT_POOL_PATH).exists() { - return Err(mismatch(case, "the retired orphan is still present".into())); + return Err(mismatch(case, "the retired orphan is still present")); } let mut entries: Vec = fs::read_dir(store_root.join("gc")) .map_err(|source| DurabilityCrashMatrixError::io("list crash gc directory", source))? @@ -81,12 +81,12 @@ fn require_complete( if entries != ["receipt"] { return Err(mismatch( case, - format!("gc holds {entries:?}, expected only the receipt"), + &format!("gc holds {entries:?}, expected only the receipt"), )); } let plan = plan(store_root)?; if plan.candidate_count() != 0 || plan.already_retired().len() != 1 { - return Err(mismatch(case, "a fresh plan still names the orphan".into())); + return Err(mismatch(case, "a fresh plan still names the orphan")); } let settled = recover(store_root)?; if settled == GcRecoveryPlan::Complete { @@ -94,7 +94,7 @@ fn require_complete( } else { Err(mismatch( case, - format!("settled residue planned {settled:?}"), + &format!("settled residue planned {settled:?}"), )) } } @@ -108,7 +108,7 @@ fn recover(store_root: &Path) -> Result DurabilityCrashMatrixError { +fn mismatch(case: DurabilityCrashCase, message: &str) -> DurabilityCrashMatrixError { verification( "verify crash GC recovery", io::Error::other(format!( diff --git a/xtask/src/fuzz_campaign/target/tests.rs b/xtask/src/fuzz_campaign/target/tests.rs index ac517a42..0c9f1fe3 100644 --- a/xtask/src/fuzz_campaign/target/tests.rs +++ b/xtask/src/fuzz_campaign/target/tests.rs @@ -35,6 +35,7 @@ fn checked_in_harness_set_is_exact_and_sorted() -> Result<(), Box> { "repository_json", "retention_format", "segment_format", + "verification_receipt", ] ); Ok(()) diff --git a/xtask/src/protocol_conformance/segment_store_mutations.rs b/xtask/src/protocol_conformance/segment_store_mutations.rs index 66c28c99..dfdf5870 100644 --- a/xtask/src/protocol_conformance/segment_store_mutations.rs +++ b/xtask/src/protocol_conformance/segment_store_mutations.rs @@ -124,13 +124,12 @@ fn check_ledger( require_outcome(schema, case, row.field("expected_outcome")?, records)?; require_requirement(schema, case, row.field("requirement")?)?; let fixture = row.field("base_fixture")?; - let length = match fixture_lengths.get(fixture) { - Some(length) => *length, - None => { - let length = fixture_length(&corpus, fixture)?; - fixture_lengths.insert(fixture.to_owned(), length); - length - } + let length = if let Some(length) = fixture_lengths.get(fixture) { + *length + } else { + let length = fixture_length(&corpus, fixture)?; + fixture_lengths.insert(fixture.to_owned(), length); + length }; require_span(schema, case, row, length)?; } @@ -221,7 +220,7 @@ fn require_requirement( } fn fixture_length(corpus: &Corpus, fixture: &str) -> Result { - if !fixture.ends_with(".hex") { + if fixture.strip_suffix(".hex").is_none() { return Err(ConformanceError::violation(format!( "mutation ledger names a non-hexadecimal fixture {fixture:?}" ))); @@ -239,7 +238,7 @@ fn fixture_length(corpus: &Corpus, fixture: &str) -> Result &'static [(DurabilityCrashPoint, &'static str, DurabilityCrashSequence)] { + include!("durability_crash_point_contract/expected.rs") +} #[test] fn crash_boundaries_have_one_contiguous_stable_vocabulary() { let actual = DurabilityCrashPoint::ALL.map(|point| (point, point.identifier(), point.sequence())); - assert_eq!(actual.as_slice(), EXPECTED); + assert_eq!(actual.as_slice(), expected()); } #[test] diff --git a/xtask/tests/durability_crash_point_contract/expected.rs b/xtask/tests/durability_crash_point_contract/expected.rs index 45eba467..5d251091 100644 --- a/xtask/tests/durability_crash_point_contract/expected.rs +++ b/xtask/tests/durability_crash_point_contract/expected.rs @@ -1,12 +1,6 @@ -//! The one contiguous stable crash-boundary vocabulary. - -use xtask::{DurabilityCrashPoint, DurabilityCrashSequence}; - -use DurabilityCrashSequence::{ - Catalog, Gc, Head, Initialization, Migration, RecoveryDiscard, Retention, Segment, -}; - -pub(super) const EXPECTED: &[(DurabilityCrashPoint, &str, DurabilityCrashSequence)] = &[ +// The one contiguous stable crash-boundary vocabulary, spliced into +// `expected()` by the contract file. +&[ ( DurabilityCrashPoint::CreateSegmentStage, "KEEP-CRASH-001", @@ -406,4 +400,4 @@ pub(super) const EXPECTED: &[(DurabilityCrashPoint, &str, DurabilityCrashSequenc "KEEP-CRASH-087", Gc, ), -]; +] From 1de4f662b5eaa7c785060f2d978ea04911452f3e Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 14:13:35 -0700 Subject: [PATCH 34/59] Fix: make the spliced crash-point table a const fn for the pinned clippy Co-Authored-By: Claude Fable 5.1 --- xtask/tests/durability_crash_point_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xtask/tests/durability_crash_point_contract.rs b/xtask/tests/durability_crash_point_contract.rs index 9b78a8a1..561cd882 100644 --- a/xtask/tests/durability_crash_point_contract.rs +++ b/xtask/tests/durability_crash_point_contract.rs @@ -8,7 +8,7 @@ use DurabilityCrashSequence::{ Catalog, Gc, Head, Initialization, Migration, RecoveryDiscard, Retention, Segment, }; -fn expected() -> &'static [(DurabilityCrashPoint, &'static str, DurabilityCrashSequence)] { +const fn expected() -> &'static [(DurabilityCrashPoint, &'static str, DurabilityCrashSequence)] { include!("durability_crash_point_contract/expected.rs") } From ee4ef549d51ef8cf2eb3b473dad21e3c8f343138 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 14:32:20 -0700 Subject: [PATCH 35/59] Feat: durable staged ingestion with deduplication through the content-store port `DurableWriter::open` takes writer authority over a version-two root; `stage(source, limits)` reads the source once through the reference store's streaming core, now generic over a crate-private `ChunkSink`: every chunk the pinned catalog already holds is compared byte for byte and reused, every other chunk is appended to `staging/current.seg` as it is produced (the stage is created on the first new chunk), and the layout record follows unless the catalog holds it. `DurableStagedBlob::commit` publishes the sealed segment and a catalog successor through `publish_catalog_generation`, or nothing when the catalog already held everything. `DurableIngestionReceipt` binds profile, blob, layout, segment digest, catalog coordinates, and `IngestionAccounting`. `recover_durable_ingestion` runs the shared recovery protocol with ingestion's complete-stage evidence (no staged record named by HEAD); compaction recovery keeps derivability. The publisher's selection is split into `close_sealed` and `select_closed` so a staging need not borrow the publisher. The port's staging now borrows its store (`Staged<'store>`) and `StagedContent::commit(self)` takes no store; `ReferenceStagedContent` binds a `StagedBlob` to its reference store. Laws: one pass commits a blob readable by layout, then by anchor; nearby content reuses every unchanged chunk and an exact re-ingest publishes nothing; limit and identity refusals leave nothing visible; an interrupted source leaves a stage recovery discards and staging refuses until it does. Owed items (rollover, streaming admission, the ingestion-driven crash matrix, soak, stress, benchmarks, the Worldline rows, the CLI and MCP adapters) are named on the page and in ROADMAP. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 19 ++ README.md | 34 ++- ROADMAP.md | 31 ++- docs/architecture/content-store/README.md | 24 +- docs/architecture/durable-store/README.md | 8 +- docs/architecture/durable-store/ingestion.md | 101 ++++++++ src/adapters/compaction/mod.rs | 1 + src/adapters/compaction/recovery.rs | 73 +++++- src/adapters/durable/ingestion_error.rs | 136 ++++++++++ src/adapters/durable/ingestion_receipt.rs | 161 ++++++++++++ src/adapters/durable/mod.rs | 18 ++ src/adapters/durable/port.rs | 52 +++- src/adapters/durable/recovery.rs | 48 ++++ src/adapters/durable/staged.rs | 163 ++++++++++++ src/adapters/durable/test_fixture.rs | 10 +- src/adapters/durable/writer.rs | 142 +++++++++++ src/adapters/durable/writer_sink.rs | 118 +++++++++ src/adapters/durable/writer_tests.rs | 249 +++++++++++++++++++ src/adapters/filesystem_catalog_publisher.rs | 48 +++- src/adapters/filesystem_catalog_snapshot.rs | 5 + src/lib.rs | 10 +- src/reference/chunk_staging.rs | 15 +- src/reference/ingestion.rs | 87 ++++--- src/reference/mod.rs | 6 + src/reference/port.rs | 50 ++-- src/store/reference_port_tests.rs | 14 +- src/store/staging.rs | 29 ++- tests/content_store_port.rs | 30 ++- 28 files changed, 1542 insertions(+), 140 deletions(-) create mode 100644 docs/architecture/durable-store/ingestion.md create mode 100644 src/adapters/durable/ingestion_error.rs create mode 100644 src/adapters/durable/ingestion_receipt.rs create mode 100644 src/adapters/durable/recovery.rs create mode 100644 src/adapters/durable/staged.rs create mode 100644 src/adapters/durable/writer.rs create mode 100644 src/adapters/durable/writer_sink.rs create mode 100644 src/adapters/durable/writer_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 996b85c9..9d738eaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,25 @@ after its public API and format compatibility policies are established. ### Added +- Durable staged ingestion with deduplication. `DurableWriter::open` + takes writer authority over a version-two root; `stage(source, limits)` + reads the source once through the reference store's streaming core (now + generic over a crate-private `ChunkSink`), verifies every chunk the + pinned catalog already holds byte for byte and reuses it, and appends + every other chunk to `staging/current.seg` as it is produced, creating + the stage on the first new chunk; the layout record follows unless the + catalog holds it. `DurableStagedBlob::commit(self)` publishes the sealed + segment and a catalog successor through `publish_catalog_generation`, or + nothing when the catalog already held everything. + `DurableIngestionReceipt` binds profile, blob, layout, segment digest, + catalog coordinates, and `IngestionAccounting` (logical, physical new, + physical reused bytes and chunk counts). `recover_durable_ingestion` + runs the recovery protocol with ingestion's complete-stage evidence: a + sealed stage the catalog names nothing of is discarded. The writer + implements `ContentStaging`; the port's staging now borrows its store + (`Staged<'store>`) and `StagedContent::commit(self)` takes no store, + with `ReferenceStagedContent` binding a `StagedBlob` to its reference + store. Page: `docs/architecture/durable-store/ingestion.md`. - Backend-neutral content-store port. `ContentReads` (`contains_blob`, `reconstruct`, `reconstruct_layout`, `read_range`, `read_layout_range`) is implemented by `ReferenceStore` and `DurableSnapshot`, each with its diff --git a/README.md b/README.md index a6ab53db..d2eeba7e 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,6 @@ a publication. A version-1 store stays admitted until its owner migrates it. | --- | --- | | Durable authenticated reads bound to a fenced snapshot | [#109](https://github.com/flyingrobots/keep/issues/109) | | Verification reports at durable depths and a replayable receipt | [#20](https://github.com/flyingrobots/keep/issues/20) | -| Bounded production ingestion through the durable store | [#82](https://github.com/flyingrobots/keep/issues/82) | | Encrypted representations | [#86](https://github.com/flyingrobots/keep/issues/86) | Keep also does not claim secure deletion. Releasing a retention root @@ -187,24 +186,39 @@ assert_eq!(output, b"exact bytes, or nothing"); # Ok::<(), Box>(()) ``` -A migrated version-two store reads the same way through `DurableStore`, with -every read pinned to one fenced snapshot and every receipt naming the view. -Production admission is Linux ext4; this example is not run on other hosts. +A migrated version-two store writes and reads the same way on disk: +`DurableWriter` stages a source in one bounded pass, reusing every chunk the +catalog already holds, and commits it through the catalog protocol; +`DurableStore` reads with every read pinned to one fenced snapshot and every +receipt naming the view. Production admission is Linux ext4; this example is +not run on other hosts. ```rust,no_run -use keep::{CatalogRestartByteLimit, CatalogRestartPolicy, DurableStore, LayoutEntryLimit, - ReaderAttemptLimit, SegmentReadPolicy, SegmentRecordLimit}; +use std::path::Path; +use keep::{CatalogRestartByteLimit, CatalogRestartPolicy, DurableStore, DurableWriter, + FilesystemVersionTwoAdmission, LayoutEntryLimit, ReaderAttemptLimit, SegmentReadPolicy, + SegmentRecordLimit, StagingLimits}; +let root = Path::new("/var/lib/keep/store"); let policy = CatalogRestartPolicy::new( SegmentReadPolicy::new(SegmentRecordLimit::MAXIMUM, LayoutEntryLimit::MAXIMUM), CatalogRestartByteLimit::new(1 << 30)?, ); -let store = DurableStore::open(std::path::Path::new("/var/lib/keep/store"), policy, ReaderAttemptLimit::DEFAULT); + +// Write: one pass, chunks the catalog already holds are reused by exact bytes. +let mut writer = DurableWriter::open(FilesystemVersionTwoAdmission::reopen(root)?, root, policy)?; +let mut source = std::fs::File::open("build/artifact.tar")?; +let receipt = writer.stage(&mut source, StagingLimits::entries(LayoutEntryLimit::MAXIMUM))?.commit()?; +println!("{} new, {} reused", receipt.accounting().physical_new_bytes(), + receipt.accounting().physical_reused_bytes()); +drop(writer); + +// Read: the committed layout is readable at once; by identity once anchored. +let store = DurableStore::open(root, policy, ReaderAttemptLimit::DEFAULT); let snapshot = store.snapshot()?; // shared reader fence held until dropped -# let blob_id = "keep:blob:v1:blake3-256:1:1cfb8fa9e917aba15a1f592095f377ff180755fe1212b0d7d2ec750bd128b606".parse()?; let mut output = Vec::new(); -let receipt = snapshot.reconstruct(blob_id, &mut output)?; -println!("generation {}", receipt.view().catalog_generation().get()); +let read = snapshot.reconstruct_layout(receipt.layout_id(), &mut output)?; +println!("generation {}", read.view().catalog_generation().get()); # Ok::<(), Box>(()) ``` diff --git a/ROADMAP.md b/ROADMAP.md index 18bd23cb..624872c5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -102,7 +102,7 @@ names; use those in code, tests, and commits. - [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Partial (#20; report vocabulary, corruption ledgers, and durable receipts done on this branch; durable-view depths land with T-23.1) - [x] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Done on this branch (#21; codecs, planner, disposition, retirement, and compaction; crash sequences for disposition and compaction, stress, benchmarks, and re-encoding still owed) - [x] [F-23 Durable authenticated reads and refusal receipts](#f-23-durable-authenticated-reads-and-refusal-receipts) — Done on this branch (#109; `DurableStore` reads and verification receipts) -- [ ] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Planned (#82, #72) +- [ ] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Partial (#82, #72): T-24.1 and T-24.2 landed; T-24.3 open ### Integration (M5) @@ -1461,7 +1461,10 @@ and no hidden whole-blob allocation. ### F-24 Bounded production ingestion through the durable store -**Status:** Planned (#82, P1, M6). Needs #72 (F-06) and F-21. +**Status:** Partial (#82, P1, M6). T-24.1 (the content-store port) and +T-24.2 (`DurableWriter`) landed on this branch; T-24.3 (the streaming +write-through pipeline, #72) is open, as are the owed items listed under +T-24.2. One bounded production path from an unknown-length source through the registered CDC profile, chunk verification and deduplication, immutable @@ -1476,10 +1479,10 @@ error precision. implements both halves and `DurableSnapshot` the read half; distinct receipt types give the compile-time law; generic laws run against both backends in-crate plus `tests/content_store_port.rs` from outside; page - `docs/architecture/content-store/README.md`. Still owed: the durable - writer's `ContentStaging` implementation and the Worldline golden run - through the port land with T-24.2, which is the first durable backend a - staging can commit into. + `docs/architecture/content-store/README.md`. T-24.2 then made the + staging borrow its store (`Staged<'store>`, `commit(self)`) and added + the durable writer's implementation. Still owed: the Worldline golden run + through the port (listed under T-24.2). Original task fields: - **Requirements:** a trait or port that `ReferenceStore` and the durable writer both satisfy where their durability claims overlap: stage, @@ -1502,7 +1505,21 @@ error precision. - **Complexity:** M. - **Documentation:** `docs/architecture/` port page. - **Dependencies:** T-06.4. -- [ ] T-24.2 Durable staged ingestion with deduplication. +- [x] T-24.2 Durable staged ingestion with deduplication. Done 2026-09-30: + `DurableWriter::{open, stage, stage_expected}`, + `DurableStagedBlob::commit`, `DurableIngestionReceipt` with + `IngestionAccounting`, `DurableIngestionError`, + `recover_durable_ingestion`; the reference streaming core is generic + over a `ChunkSink`; the port's staging borrows its store and commits + without a second reference. Laws in + `src/adapters/durable/writer_tests.rs`; page + `docs/architecture/durable-store/ingestion.md`. Still owed, each named + on the page: segment rollover at the ceilings (refused typed today); + commit's segment-proportional re-admission (streaming admission); + the crash matrix driven by ingestion, the soak, the stress, and the + benchmarks; the Worldline `chunk reuse` and `production ingest` rows + through the port; the `keep put` and MCP `keep.ingest` adapters (F-42). + Original task fields: - **Requirements:** single pass over an unknown-length source; existing chunks reused only after exact identity and representation verification against the pinned catalog; missing chunks stream into a diff --git a/docs/architecture/content-store/README.md b/docs/architecture/content-store/README.md index 06989a75..1b313bf1 100644 --- a/docs/architecture/content-store/README.md +++ b/docs/architecture/content-store/README.md @@ -35,15 +35,18 @@ read is accepted, and the refusal (`IngestionError::ByteLimitExceeded { limit, accepted, incoming }`) says how far the source was admitted and what pushed it over. -`StagedContent::commit(self, store)` makes the staging visible, or leaves -it invisible; the result is a `CommitReceipt` naming the target and the -exact committed layout. +A staging borrows its store (`ContentStaging::Staged<'store>`), so the +store cannot change underneath it and `StagedContent::commit(self)` needs +no second reference: it makes the staging visible, or leaves it invisible, +and returns a `CommitReceipt` naming the target and the exact committed +layout. -The reference store implements the write half today and stays honest about -being non-durable: process death loses everything in it, and no port -method claims otherwise. The durable writer lands with durable staged -ingestion (T-24.2); until it does, code that needs a durable commit has no -implementation to reach for, which is the honest state. +The non-durable `ReferenceStore` implements the write half through +`ReferenceStagedContent` and stays honest about being non-durable: process +death loses everything in it, and no port method claims otherwise. The +[`DurableWriter`](../durable-store/ingestion.md) implements it on disk, +with deduplication against the pinned catalog and publication through the +catalog protocol; its receipt is `DurableIngestionReceipt`. ## The receipt law @@ -65,5 +68,6 @@ function taking a `DurableReconstructionReceipt`. - `tests/content_store_port.rs` runs the reference backend from outside the crate through the port alone: round trip, byte-limit refusal before anything is visible, entry-limit refusal, and expected-identity mismatch. -- The Worldline golden run through the port is owed with the durable - writer; it is listed under T-24.1 in the roadmap. +- The durable writer's own laws are in `src/adapters/durable/writer_tests.rs`. +- The Worldline golden run through the port is owed; it is listed under + T-24.2 in the roadmap. diff --git a/docs/architecture/durable-store/README.md b/docs/architecture/durable-store/README.md index 65415ad3..8e1cede4 100644 --- a/docs/architecture/durable-store/README.md +++ b/docs/architecture/durable-store/README.md @@ -5,10 +5,10 @@ authenticated `reconstruct`, `reconstruct_layout`, `read_range`, and `read_layout_range` over one fenced version-two snapshot, with the same laws and the same reconstruction and range cores, plus a receipt that names the -view. It is a read surface only: durable ingestion is -[F-24](../../../ROADMAP.md#f-24-bounded-production-ingestion-through-the-durable-store), -and content reaches a store through catalog publication and retention -anchoring. +view. Its write half is [`DurableWriter`](ingestion.md): one bounded pass +with deduplication against the pinned catalog, published through the +version-one catalog protocol. Content becomes readable by identity once a +retention root anchors it. ## Contract diff --git a/docs/architecture/durable-store/ingestion.md b/docs/architecture/durable-store/ingestion.md new file mode 100644 index 00000000..a2e46174 --- /dev/null +++ b/docs/architecture/durable-store/ingestion.md @@ -0,0 +1,101 @@ +# Durable Ingestion + +`DurableWriter` is the write half of the +[durable store](README.md): one bounded pass from an unknown-length source +into a version-two store, with deduplication against the pinned catalog and +publication through the version-one catalog protocol. It implements the +[content-store port](../content-store/README.md)'s `ContentStaging`, so +code written against the port runs on the reference store in tests and on +disk in production. + +## Contract + +`DurableWriter::open(admission, root, policy)` takes writer authority over +one admitted version-two root and holds it, through its catalog publisher, +for the writer's lifetime. Readers are not excluded: a pinned +`DurableSnapshot` keeps reading its generation while the writer publishes +the next. + +`stage(source, limits)` reads the source exactly once through the reference +store's streaming core (`FastCDC` boundaries, `BlobHasher`, per-chunk +identity verification, the entry and byte limits) and hands each chunk to +the durable sink: + +- when the pinned catalog holds a record under the chunk's identity, its + payload is compared byte for byte with the chunk; a difference refuses + with `ChunkRepresentation`, and agreement counts the chunk as reused; +- otherwise the chunk is appended to `staging/current.seg` as a chunk + record the moment it is produced. The stage is created on the first new + chunk, so a source the catalog already holds creates nothing; +- after the last byte the canonical layout record is derived and appended, + unless the catalog already holds it byte-identically + (`LayoutRepresentation` otherwise); the stage is sealed and closed. + +The blob is never materialized. Beyond the streaming core's fixed scratch, +staging holds the layout spans bounded by the entry limit and the set of +chunk identities written so far. + +`stage_expected(source, expected, limits)` additionally refuses when the +complete source does not hash to `expected`, naming both identities. + +`DurableStagedBlob::commit(self)` re-admits the current catalog, refuses +with `CatalogMoved` if it is not the generation the staging was verified +against, reads the sealed stage back, admits it as a segment, binds it to +the closed stage's record count, length, and digest, encodes the successor +catalog naming every current segment and the new one, and runs +`publish_catalog_generation`: the same twenty-six version-one crash +boundaries compaction and the fixture publications cross. When the catalog +already held everything, commit publishes nothing and the receipt says so +(`segment() == None`, the current generation). + +`DurableIngestionReceipt` binds the storage profile, the blob and layout +identities, the published segment digest, the catalog generation and +digest, and `IngestionAccounting`: logical bytes, physical new bytes, +physical reused bytes, chunks new, chunks reused. Physical new plus +physical reused equals logical. + +Content is readable through `DurableSnapshot::reconstruct_layout` by its +committed layout at once. `contains_blob` and the by-identity reads answer +once a retention root anchors the blob; the writer publishes content, and +retention is a separate, explicit act. + +## Refusals and residue + +Every refusal is typed at its boundary (`DurableIngestionError`). A refusal +before the first new chunk leaves nothing. A refusal after it leaves a +`staging/current.seg`, truncated when the source or a limit failed +mid-stream and complete when the identity mismatched after sealing; a +later `stage` refuses with `StageRetained` until the store is recovered. +`recover_durable_ingestion(root, policy)` runs the version-one recovery +protocol over the residue with ingestion's evidence for a complete stage: +it is discarded when the current catalog names none of its records, which +is exactly an ingestion stage that never reached commit. A complete stage +the catalog partly names is neither ingestion nor compaction residue and +refuses. `staging/current.cat` and `head.next` resolve as after an +interrupted compaction, because commit runs the same protocol. + +The segment ceilings (1,048,576 records, 1 GiB) refuse with the stage's own +typed `RecordCountLimit` and `SegmentLengthLimit` inside `Stage`; a source +that would cross them is refused, not rolled over into a second segment. + +## Evidence + +`src/adapters/durable/writer_tests.rs`: one pass commits a blob readable by +layout, then by anchor beside the fixture store; nearby content reuses +every unchanged chunk and an exact re-ingest publishes nothing; byte-limit, +entry-limit, and expected-identity refusals leave nothing visible; an +interrupted source leaves a stage that recovery discards, and staging +refuses until it does. The runs are in-crate because a version-two store +is built today only through test-only unchecked admission. + +## Still owed + +- Segment rollover at the ceilings mid-blob: refused today, not rolled. +- Commit re-reads the sealed stage and re-admits every current segment + from the loaded catalog, so its peak memory is segment-proportional; + streaming segment admission is owed. +- The crash matrix driven by ingestion rather than by fixtures, the soak to + the catalog entry ceiling, the multi-GiB stress, and the throughput, + memory, allocation, sync-count, and dedup-ratio benchmarks. +- The Worldline golden run through the port, and the `keep put` and MCP + `keep.ingest` adapters (F-42). diff --git a/src/adapters/compaction/mod.rs b/src/adapters/compaction/mod.rs index 80354d02..0b357c11 100644 --- a/src/adapters/compaction/mod.rs +++ b/src/adapters/compaction/mod.rs @@ -29,3 +29,4 @@ pub use plan::{CompactionPlan, CompactionRefusal, CompactionSegmentDisposition, #[cfg(test)] pub(in crate::adapters) use recovery::recover_compaction_unchecked_for_tests; pub use recovery::{CompactionRecovery, FilesystemCompactionRecoveryError, recover_compaction}; +pub(in crate::adapters) use recovery::{CompleteStageEvidence, recover_with}; diff --git a/src/adapters/compaction/recovery.rs b/src/adapters/compaction/recovery.rs index 2e66d404..ed19b2ef 100644 --- a/src/adapters/compaction/recovery.rs +++ b/src/adapters/compaction/recovery.rs @@ -78,9 +78,18 @@ fn refused( phase: &'static str, source: impl Error + Send + Sync + 'static, ) -> FilesystemCompactionRecoveryError { - FilesystemCompactionRecoveryError { - phase, - source: Box::new(source), + FilesystemCompactionRecoveryError::refused(phase, source) +} + +impl FilesystemCompactionRecoveryError { + pub(in crate::adapters) fn refused( + phase: &'static str, + source: impl Error + Send + Sync + 'static, + ) -> Self { + Self { + phase, + source: Box::new(source), + } } } @@ -103,7 +112,7 @@ pub fn recover_compaction( ) -> Result { let discarder = FilesystemRecoveryStageDiscarder::open_version_two(store_root) .map_err(|source| refused("open", source))?; - recover_with(discarder, policy) + recover_with(discarder, policy, CompleteStageEvidence::Derivable) } #[cfg(test)] @@ -114,12 +123,24 @@ pub(in crate::adapters) fn recover_compaction_unchecked_for_tests( let discarder = FilesystemRecoveryStageDiscarder::open_unchecked_version_two_for_tests(store_root) .map_err(|source| refused("open", source))?; - recover_with(discarder, policy) + recover_with(discarder, policy, CompleteStageEvidence::Derivable) } -fn recover_with( +/// What proves a complete staged segment safe to discard. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(in crate::adapters) enum CompleteStageEvidence { + /// Every staged record is named byte-identically by the current + /// catalog: a compaction copy the next compaction reproduces. + Derivable, + /// No staged record is named by the current catalog: an ingestion + /// stage that was never committed, so nothing published references it. + Unpublished, +} + +pub(in crate::adapters) fn recover_with( mut discarder: FilesystemRecoveryStageDiscarder, policy: CatalogRestartPolicy, + evidence: CompleteStageEvidence, ) -> Result { let mut recovery = CompactionRecovery { discarded: Vec::new(), @@ -130,7 +151,7 @@ fn recover_with( (RecoveryStage::Catalog, CATALOG_STAGE), ] { if let Some(bytes) = read_stage(&discarder, RecoveryStageParent::Staging, name)? { - resolve_staging(&mut discarder, stage, name, &bytes, policy)?; + resolve_staging(&mut discarder, (stage, name), &bytes, policy, evidence)?; recovery.discarded.push(stage); } } @@ -220,10 +241,10 @@ fn admitted_stage( /// catalog does not already name. fn resolve_staging( discarder: &mut FilesystemRecoveryStageDiscarder, - stage: RecoveryStage, - name: &str, + (stage, name): (RecoveryStage, &str), bytes: &[u8], policy: CatalogRestartPolicy, + evidence: CompleteStageEvidence, ) -> Result<(), FilesystemCompactionRecoveryError> { let admitted = admitted_stage(stage, bytes)?; let assessment = assess_recovery_stage(&admitted, policy.segment_read()) @@ -233,7 +254,14 @@ fn resolve_staging( state: RecoverySegmentStage::Complete(segment), .. } => { - require_derivable_segment(discarder, segment, policy)?; + match evidence { + CompleteStageEvidence::Derivable => { + require_derivable_segment(discarder, segment, policy)?; + } + CompleteStageEvidence::Unpublished => { + require_unpublished_segment(discarder, segment, policy)?; + } + } true } RecoveryStageAssessment::Segment { @@ -300,6 +328,31 @@ fn require_derivable_segment( Ok(()) } +/// A complete staged segment is unpublished when the current catalog names +/// none of its records: an ingestion stage that never reached commit. +fn require_unpublished_segment( + discarder: &FilesystemRecoveryStageDiscarder, + segment: &AdmittedSegment<'_>, + policy: CatalogRestartPolicy, +) -> Result<(), FilesystemCompactionRecoveryError> { + let current = + catalog_restart_loader::load_from_directory(root_directory(discarder), HEAD, policy) + .map_err(|source| refused("load current catalog", source))?; + let snapshot = current + .snapshot() + .map_err(|source| refused("admit current catalog", source))?; + for record in segment.records() { + let record = record.map_err(|source| refused("reread staged record", source))?; + if snapshot.record(record.identity()).is_some() { + return Err(refused( + "unpublished segment", + io::Error::other("a staged record is already named"), + )); + } + } + Ok(()) +} + /// A complete staged catalog is derivable when it is exactly the successor /// candidate of the current head: never published, reproduced by the next /// compaction. diff --git a/src/adapters/durable/ingestion_error.rs b/src/adapters/durable/ingestion_error.rs new file mode 100644 index 00000000..3c386d65 --- /dev/null +++ b/src/adapters/durable/ingestion_error.rs @@ -0,0 +1,136 @@ +//! This boundary module owns typed durable-ingestion failures. + +use std::error::Error; +use std::fmt; +use std::io; + +use crate::adapters::{ + CatalogEncodeError, CatalogPublicationError, CatalogRestartError, SegmentPublicationError, + SegmentReadError, SegmentRecordAdmissionError, SegmentWriteError, +}; +use crate::{CatalogGeneration, CatalogGenerationError, ChunkId, IngestionError, LayoutId}; + +/// Why a durable staging or commit returned no receipt. +/// +/// Every variant names the exact boundary. A refusal during staging leaves +/// nothing visible; a refusal during commit leaves the completed phases' +/// residue for the version-one recovery protocol. +#[derive(Debug)] +pub enum DurableIngestionError { + /// The publication directories could not be pinned. + Open { + /// The exact filesystem refusal. + source: io::Error, + }, + /// The pinned catalog could not be loaded or re-admitted. + Catalog(Box), + /// The streaming core refused the source, its identity, or its limits. + Ingestion(IngestionError), + /// A `staging/current.seg` from an earlier interrupted staging is + /// retained; recover the store before staging again. + StageRetained, + /// The segment stage refused a write, including the record-count and + /// segment-length ceilings. + Stage(Box), + /// A chunk or layout record could not be admitted for the segment. + Record(SegmentRecordAdmissionError), + /// The pinned catalog holds a record under the chunk's identity whose + /// bytes differ from the source's chunk. + ChunkRepresentation { + /// The identity in dispute. + identity: ChunkId, + }, + /// The pinned catalog holds a layout record under the layout's identity + /// whose bytes differ from the canonical record. + LayoutRepresentation { + /// The identity in dispute. + identity: LayoutId, + }, + /// The catalog generation the staging was verified against is no + /// longer the current one. + CatalogMoved { + /// The generation the staging was verified against. + staged: CatalogGeneration, + /// The generation observed at commit. + observed: CatalogGeneration, + }, + /// The sealed stage could not be read back for admission. + ReadStage { + /// The exact filesystem refusal. + source: io::Error, + }, + /// A segment did not admit under the read policy. + Segment(Box), + /// The sealed stage did not bind to its admitted bytes. + Selection(SegmentPublicationError), + /// The successor generation is not representable. + Generation(CatalogGenerationError), + /// The successor catalog could not be encoded. + Successor(Box), + /// The catalog protocol refused publication. + Publish(Box), +} + +impl From for DurableIngestionError { + fn from(source: IngestionError) -> Self { + Self::Ingestion(source) + } +} + +impl fmt::Display for DurableIngestionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Open { .. } => formatter.write_str("publication directories could not be pinned"), + Self::Catalog(_) => formatter.write_str("the pinned catalog refused"), + Self::Ingestion(source) => write!(formatter, "ingestion: {source}"), + Self::StageRetained => { + formatter.write_str("a retained staging/current.seg needs recovery first") + } + Self::Stage(source) => write!(formatter, "segment stage: {source}"), + Self::Record(source) => write!(formatter, "record admission: {source}"), + Self::ChunkRepresentation { identity } => { + write!( + formatter, + "catalog chunk {identity:?} differs from the source's bytes" + ) + } + Self::LayoutRepresentation { identity } => write!( + formatter, + "catalog layout {identity:?} differs from the canonical record" + ), + Self::CatalogMoved { staged, observed } => write!( + formatter, + "staged against catalog generation {} but generation {} is current", + staged.get(), + observed.get() + ), + Self::ReadStage { .. } => formatter.write_str("the sealed stage could not be read"), + Self::Segment(source) => write!(formatter, "segment admission: {source}"), + Self::Selection(source) => write!(formatter, "stage selection: {source}"), + Self::Generation(source) => write!(formatter, "successor generation: {source}"), + Self::Successor(source) => write!(formatter, "successor catalog: {source}"), + Self::Publish(source) => write!(formatter, "publication: {source}"), + } + } +} + +impl Error for DurableIngestionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Open { source } | Self::ReadStage { source } => Some(source), + Self::Catalog(source) => Some(source.as_ref()), + Self::Ingestion(source) => Some(source), + Self::Stage(source) => Some(source.as_ref()), + Self::Record(source) => Some(source), + Self::Segment(source) => Some(source.as_ref()), + Self::Selection(source) => Some(source), + Self::Generation(source) => Some(source), + Self::Successor(source) => Some(source.as_ref()), + Self::Publish(source) => Some(source.as_ref()), + Self::StageRetained + | Self::ChunkRepresentation { .. } + | Self::LayoutRepresentation { .. } + | Self::CatalogMoved { .. } => None, + } + } +} diff --git a/src/adapters/durable/ingestion_receipt.rs b/src/adapters/durable/ingestion_receipt.rs new file mode 100644 index 00000000..d15361c2 --- /dev/null +++ b/src/adapters/durable/ingestion_receipt.rs @@ -0,0 +1,161 @@ +//! This boundary module owns the durable ingestion receipt: what one +//! committed staging established, with exact byte accounting. + +use crate::adapters::SegmentDigest; +use crate::{ + BlobId, CatalogDigest, CatalogGeneration, CommitReceipt, LayoutId, RegisteredStorageProfile, +}; + +/// Exact accounting of one staging: the logical length, the bytes written +/// into the new segment, and the bytes the pinned catalog already held. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct IngestionAccounting { + logical_bytes: u64, + physical_new_bytes: u64, + physical_reused_bytes: u64, + chunks_new: u64, + chunks_reused: u64, +} + +impl IngestionAccounting { + pub(super) const EMPTY: Self = Self { + logical_bytes: 0, + physical_new_bytes: 0, + physical_reused_bytes: 0, + chunks_new: 0, + chunks_reused: 0, + }; + + pub(super) const fn new_chunk(&mut self, length: u64) { + self.physical_new_bytes = self.physical_new_bytes.saturating_add(length); + self.chunks_new = self.chunks_new.saturating_add(1); + } + + pub(super) const fn reused_chunk(&mut self, length: u64) { + self.physical_reused_bytes = self.physical_reused_bytes.saturating_add(length); + self.chunks_reused = self.chunks_reused.saturating_add(1); + } + + pub(super) const fn with_logical_bytes(mut self, logical_bytes: u64) -> Self { + self.logical_bytes = logical_bytes; + self + } + + /// The source's exact length. + #[must_use] + pub const fn logical_bytes(self) -> u64 { + self.logical_bytes + } + + /// Chunk payload bytes written into the new segment. + #[must_use] + pub const fn physical_new_bytes(self) -> u64 { + self.physical_new_bytes + } + + /// Chunk payload bytes the pinned catalog already held, or this staging + /// had already written. + #[must_use] + pub const fn physical_reused_bytes(self) -> u64 { + self.physical_reused_bytes + } + + /// Chunks written into the new segment. + #[must_use] + pub const fn chunks_new(self) -> u64 { + self.chunks_new + } + + /// Chunks reused by exact identity and representation. + #[must_use] + pub const fn chunks_reused(self) -> u64 { + self.chunks_reused + } +} + +/// One committed durable staging. +/// +/// `segment` is `None` when the pinned catalog already held every chunk and +/// the layout: nothing was published and `generation` is the one the +/// staging was verified against. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[must_use = "the receipt records the committed identities, coordinates, and accounting"] +pub struct DurableIngestionReceipt { + target: BlobId, + layout_id: LayoutId, + profile: RegisteredStorageProfile, + segment: Option, + generation: CatalogGeneration, + catalog_digest: CatalogDigest, + accounting: IngestionAccounting, +} + +impl DurableIngestionReceipt { + pub(super) const fn new( + target: BlobId, + layout_id: LayoutId, + segment: Option, + catalog: (CatalogGeneration, CatalogDigest), + accounting: IngestionAccounting, + ) -> Self { + Self { + target, + layout_id, + profile: RegisteredStorageProfile::FAST_CDC_64K_V1, + segment, + generation: catalog.0, + catalog_digest: catalog.1, + accounting, + } + } + + /// The committed blob. + #[must_use] + pub const fn target(self) -> BlobId { + self.target + } + + /// The exact committed layout. + #[must_use] + pub const fn layout_id(self) -> LayoutId { + self.layout_id + } + + /// The storage profile the layout was derived under. + #[must_use] + pub const fn profile(self) -> RegisteredStorageProfile { + self.profile + } + + /// The published segment, when anything was new. + #[must_use] + pub const fn segment(self) -> Option { + self.segment + } + + /// The catalog generation the content is visible under. + pub const fn generation(self) -> CatalogGeneration { + self.generation + } + + /// That generation's catalog digest. + pub const fn catalog_digest(self) -> CatalogDigest { + self.catalog_digest + } + + /// The exact byte accounting. + #[must_use] + pub const fn accounting(self) -> IngestionAccounting { + self.accounting + } +} + +impl CommitReceipt for DurableIngestionReceipt { + fn target(&self) -> BlobId { + self.target + } + + fn layout_id(&self) -> LayoutId { + self.layout_id + } +} diff --git a/src/adapters/durable/mod.rs b/src/adapters/durable/mod.rs index 9fe8a698..5081fa8f 100644 --- a/src/adapters/durable/mod.rs +++ b/src/adapters/durable/mod.rs @@ -7,22 +7,40 @@ //! layouts and chunks through the fenced catalog and blobs through the //! retained anchors. Every receipt names the view it was established //! against. While a snapshot lives, collection cannot retire what it reads. +//! +//! `DurableWriter` is the write half: one bounded pass stages a source into +//! a segment stage, reusing every chunk the pinned catalog already holds +//! after exact byte comparison, and commit publishes the segment and a +//! catalog successor through the version-one protocol. mod error; +mod ingestion_error; +mod ingestion_receipt; mod port; #[cfg(test)] mod port_tests; mod receipt; +mod recovery; mod snapshot; +mod staged; mod store; #[cfg(test)] mod test_fixture; #[cfg(test)] mod tests; mod view; +mod writer; +mod writer_sink; +#[cfg(test)] +mod writer_tests; pub use error::{DurableReadError, DurableStoreError}; +pub use ingestion_error::DurableIngestionError; +pub use ingestion_receipt::{DurableIngestionReceipt, IngestionAccounting}; pub use receipt::{DurableRangeReadReceipt, DurableReconstructionReceipt}; +pub use recovery::recover_durable_ingestion; pub use snapshot::DurableSnapshot; +pub use staged::DurableStagedBlob; pub use store::{DurableOutcome, DurableStore}; pub use view::DurableView; +pub use writer::DurableWriter; diff --git a/src/adapters/durable/port.rs b/src/adapters/durable/port.rs index 83f06105..bd55aa81 100644 --- a/src/adapters/durable/port.rs +++ b/src/adapters/durable/port.rs @@ -1,11 +1,16 @@ -//! The durable snapshot's implementation of the content-store read port. +//! The durable adapter's implementations of the content-store port: the +//! snapshot answers the read half; the writer and its staging answer the +//! write half. -use std::io::Write; +use std::io::{Read, Write}; use super::{ - DurableRangeReadReceipt, DurableReadError, DurableReconstructionReceipt, DurableSnapshot, + DurableIngestionError, DurableIngestionReceipt, DurableRangeReadReceipt, DurableReadError, + DurableReconstructionReceipt, DurableSnapshot, DurableStagedBlob, DurableWriter, +}; +use crate::{ + BlobId, ByteRange, ContentReads, ContentStaging, LayoutId, StagedContent, StagingLimits, }; -use crate::{BlobId, ByteRange, ContentReads, LayoutId}; impl ContentReads for DurableSnapshot { type ReconstructionReceipt = DurableReconstructionReceipt; @@ -51,3 +56,42 @@ impl ContentReads for DurableSnapshot { Self::read_layout_range(self, layout_id, requested, output) } } + +impl ContentStaging for DurableWriter { + type Staged<'store> = DurableStagedBlob<'store>; + type Error = DurableIngestionError; + + fn stage<'store>( + &'store mut self, + source: &mut dyn Read, + limits: StagingLimits, + ) -> Result, DurableIngestionError> { + Self::stage(self, source, limits) + } + + fn stage_expected<'store>( + &'store mut self, + source: &mut dyn Read, + expected: BlobId, + limits: StagingLimits, + ) -> Result, DurableIngestionError> { + Self::stage_expected(self, source, expected, limits) + } +} + +impl StagedContent for DurableStagedBlob<'_> { + type Receipt = DurableIngestionReceipt; + type Error = DurableIngestionError; + + fn target(&self) -> BlobId { + Self::target(self) + } + + fn layout_id(&self) -> LayoutId { + Self::layout_id(self) + } + + fn commit(self) -> Result { + Self::commit(self) + } +} diff --git a/src/adapters/durable/recovery.rs b/src/adapters/durable/recovery.rs new file mode 100644 index 00000000..1291ff56 --- /dev/null +++ b/src/adapters/durable/recovery.rs @@ -0,0 +1,48 @@ +//! This boundary module owns recovery of an interrupted durable ingestion: +//! the version-one recovery protocol over the store's staging residue, with +//! ingestion's own evidence for a complete stage. + +use std::path::Path; + +use crate::adapters::compaction::{ + CompactionRecovery, CompleteStageEvidence, FilesystemCompactionRecoveryError, recover_with, +}; +use crate::adapters::{CatalogRestartPolicy, FilesystemRecoveryStageDiscarder}; + +/// Recovers an interrupted ingestion on the version-two root at +/// `store_root`, acquiring writer authority for the duration. +/// +/// A truncated `staging/current.seg` is discarded through the version-one +/// protocol. A complete one is discarded when the current catalog names +/// none of its records: it is an ingestion stage that never reached +/// commit, so nothing published references it. A retained +/// `staging/current.cat` or `head.next` resolves exactly as after an +/// interrupted compaction, since commit runs the same catalog protocol. +/// The report and error are shared with compaction recovery for the same +/// reason. +/// +/// # Errors +/// +/// Returns [`FilesystemCompactionRecoveryError`] at the exact open, +/// assessment, planning, or execution refusal, including a complete stage +/// the current catalog partly names, which is neither residue this +/// protocol recognizes. +pub fn recover_durable_ingestion( + store_root: &Path, + policy: CatalogRestartPolicy, +) -> Result { + let discarder = FilesystemRecoveryStageDiscarder::open_version_two(store_root) + .map_err(|source| FilesystemCompactionRecoveryError::refused("open", source))?; + recover_with(discarder, policy, CompleteStageEvidence::Unpublished) +} + +#[cfg(test)] +pub(super) fn recover_durable_ingestion_unchecked_for_tests( + store_root: &Path, + policy: CatalogRestartPolicy, +) -> Result { + let discarder = + FilesystemRecoveryStageDiscarder::open_unchecked_version_two_for_tests(store_root) + .map_err(|source| FilesystemCompactionRecoveryError::refused("open", source))?; + recover_with(discarder, policy, CompleteStageEvidence::Unpublished) +} diff --git a/src/adapters/durable/staged.rs b/src/adapters/durable/staged.rs new file mode 100644 index 00000000..f7b5b66d --- /dev/null +++ b/src/adapters/durable/staged.rs @@ -0,0 +1,163 @@ +//! This boundary module owns a durable staging awaiting commit: the sealed +//! stage's selection input, the identities it established, and the +//! generation it was verified against. + +use std::io::Read; + +use super::DurableIngestionError as Error; +use super::ingestion_receipt::{DurableIngestionReceipt, IngestionAccounting}; +use super::writer::DurableWriter; +use crate::adapters::filesystem_catalog_publisher::{CURRENT_SEGMENT, ClosedSelection}; +use crate::adapters::{ + AdmittedSegment, CanonicalCatalog, CatalogPublicationExpectation, FilesystemCatalogSnapshot, + filesystem_exact_record as exact_record, publish_catalog_generation, +}; +use crate::{BlobId, CatalogGeneration, LayoutId}; + +/// Content staged into a durable store but not yet visible. +/// +/// Dropping the value without committing publishes nothing; the sealed +/// `staging/current.seg`, when one was written, is left for the recovery +/// protocol to discard. +#[must_use = "staged work remains invisible until commit is called"] +pub struct DurableStagedBlob<'writer> { + writer: &'writer mut DurableWriter, + target: BlobId, + layout_id: LayoutId, + generation: CatalogGeneration, + closed: Option, + accounting: IngestionAccounting, +} + +impl<'writer> DurableStagedBlob<'writer> { + pub(super) const fn new( + writer: &'writer mut DurableWriter, + identities: (BlobId, LayoutId), + generation: CatalogGeneration, + closed: Option, + accounting: IngestionAccounting, + ) -> Self { + Self { + writer, + target: identities.0, + layout_id: identities.1, + generation, + closed, + accounting, + } + } + + /// The complete source identity. + #[must_use] + pub const fn target(&self) -> BlobId { + self.target + } + + /// The canonical layout the staging will commit under. + #[must_use] + pub const fn layout_id(&self) -> LayoutId { + self.layout_id + } + + /// The exact byte accounting so far; the receipt repeats it. + #[must_use] + pub const fn accounting(&self) -> IngestionAccounting { + self.accounting + } + + /// Whether the pinned catalog already held every chunk and the layout, + /// so commit publishes nothing. + #[must_use] + pub const fn is_already_visible(&self) -> bool { + self.closed.is_none() + } + + /// Publishes the sealed segment and a catalog successor naming every + /// current segment and the new one, or publishes nothing when the + /// catalog already held everything. + /// + /// # Errors + /// + /// Returns [`DurableIngestionError`](Error) at the exact catalog, + /// selection, successor, or publication refusal; a publication refusal + /// leaves the completed phases' residue for the recovery protocol. + pub fn commit(self) -> Result { + let Self { + writer, + target, + layout_id, + generation, + closed, + accounting, + } = self; + let current = FilesystemCatalogSnapshot::load(&writer.store_root, writer.policy) + .map_err(|source| Error::Catalog(Box::new(source)))?; + let snapshot = current + .snapshot() + .map_err(|source| Error::Catalog(Box::new(source)))?; + if snapshot.generation() != generation { + return Err(Error::CatalogMoved { + staged: generation, + observed: snapshot.generation(), + }); + } + let Some(closed) = closed else { + return Ok(DurableIngestionReceipt::new( + target, + layout_id, + None, + (snapshot.generation(), snapshot.catalog_digest()), + accounting, + )); + }; + let new_bytes = read_stage(writer)?; + let policy = writer.policy.segment_read(); + let new_segment = AdmittedSegment::decode(&new_bytes, policy) + .map_err(|source| Error::Segment(Box::new(source)))?; + let mut segments = Vec::new(); + for loaded in current.loaded_segments() { + segments.push( + AdmittedSegment::decode(loaded.encoded(), policy) + .map_err(|source| Error::Segment(Box::new(source)))?, + ); + } + segments.push( + AdmittedSegment::decode(&new_bytes, policy) + .map_err(|source| Error::Segment(Box::new(source)))?, + ); + let selection = writer + .publisher + .select_closed(closed, &new_segment) + .map_err(Error::Selection)?; + let successor = CanonicalCatalog::from_segments( + generation.successor().map_err(Error::Generation)?, + Some(snapshot.catalog_digest()), + &segments, + ) + .map_err(|source| Error::Successor(Box::new(source)))?; + let receipt = publish_catalog_generation( + &mut writer.publisher, + CatalogPublicationExpectation::successor_of(&snapshot), + selection, + &successor, + &segments, + ) + .map_err(|source| Error::Publish(Box::new(source)))?; + Ok(DurableIngestionReceipt::new( + target, + layout_id, + Some(new_segment.digest()), + (receipt.generation(), receipt.catalog_digest()), + accounting, + )) + } +} + +fn read_stage(writer: &DurableWriter) -> Result, Error> { + let mut file = exact_record::open_read(&writer.publisher.staging, CURRENT_SEGMENT) + .map_err(|source| Error::ReadStage { source })?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|source| Error::ReadStage { source })?; + Ok(bytes) +} diff --git a/src/adapters/durable/test_fixture.rs b/src/adapters/durable/test_fixture.rs index 17762645..796abb91 100644 --- a/src/adapters/durable/test_fixture.rs +++ b/src/adapters/durable/test_fixture.rs @@ -46,13 +46,13 @@ pub(super) fn long_content() -> Vec { .collect() } -struct Identified { - target: BlobId, - spans: Vec, - record: CanonicalLayoutRecord, +pub(super) struct Identified { + pub(super) target: BlobId, + pub(super) spans: Vec, + pub(super) record: CanonicalLayoutRecord, } -fn identify(bytes: &[u8]) -> Result> { +pub(super) fn identify(bytes: &[u8]) -> Result> { let mut hasher = BlobHasher::new(); hasher.update(bytes)?; let mut detector = FastCdc::new(); diff --git a/src/adapters/durable/writer.rs b/src/adapters/durable/writer.rs new file mode 100644 index 00000000..454f9e21 --- /dev/null +++ b/src/adapters/durable/writer.rs @@ -0,0 +1,142 @@ +//! This boundary module owns the durable writer: exclusive authority to +//! stage content into one version-two store in a single bounded pass and +//! publish it through the catalog protocol. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use super::DurableIngestionError as Error; +use super::staged::DurableStagedBlob; +use super::writer_sink::DurableChunkSink; +use crate::adapters::{ + CatalogRestartPolicy, FilesystemCatalogPublisher, FilesystemCatalogSnapshot, + FilesystemVersionTwoAdmission, +}; +use crate::reference::ingest_stream; +use crate::{AdmittedLayout, BlobId, IngestionError, RegisteredStorageProfile, StagingLimits}; + +/// Exclusive authority to ingest into one pinned version-two root. +/// +/// The writer holds the writer lock through its catalog publisher for its +/// lifetime. Staging reads the source once: each chunk is verified against +/// the pinned catalog by exact bytes and reused, or appended to a segment +/// stage as it is produced; the blob is never materialized. Commit publishes +/// the sealed segment and a catalog successor through the version-one +/// protocol, beside readers. Content becomes readable through +/// [`DurableSnapshot`](super::DurableSnapshot) by its committed layout at +/// once, and by blob identity once a retention root anchors it. +#[must_use] +pub struct DurableWriter { + pub(super) publisher: FilesystemCatalogPublisher, + pub(super) store_root: PathBuf, + pub(super) policy: CatalogRestartPolicy, +} + +impl DurableWriter { + /// Pins one admitted version-two root for ingestion. + /// + /// # Errors + /// + /// Returns [`DurableIngestionError::Open`](Error::Open) when the + /// publication directories cannot be pinned. + pub fn open( + admission: FilesystemVersionTwoAdmission, + store_root: &Path, + policy: CatalogRestartPolicy, + ) -> Result { + let publisher = FilesystemCatalogPublisher::open_version_two(admission, policy) + .map_err(|source| Error::Open { source })?; + Ok(Self { + publisher, + store_root: store_root.to_path_buf(), + policy, + }) + } + + /// The store root. + #[must_use] + pub fn root(&self) -> &Path { + &self.store_root + } + + /// Stages `source` in one bounded pass without making anything visible. + /// + /// Memory beyond the streaming core's fixed scratch is the layout + /// metadata bounded by `limits.entry_limit()` and the set of chunk + /// identities written so far; chunk bytes go to the stage as they are + /// produced. A refusal after the first new chunk leaves a + /// `staging/current.seg` for the recovery protocol to discard, and a + /// later staging refuses with `StageRetained` until it does. + /// + /// # Errors + /// + /// Returns [`DurableIngestionError`](Error) at the exact catalog, + /// source, identity, limit, representation, or stage refusal. + pub fn stage( + &mut self, + source: &mut R, + limits: StagingLimits, + ) -> Result, Error> + where + R: Read + ?Sized, + { + let current = FilesystemCatalogSnapshot::load(&self.store_root, self.policy) + .map_err(|source| Error::Catalog(Box::new(source)))?; + let snapshot = current + .snapshot() + .map_err(|source| Error::Catalog(Box::new(source)))?; + let generation = snapshot.generation(); + let mut sink = DurableChunkSink::new(&self.publisher, &snapshot); + let (target, spans, logical_bytes) = ingest_stream(source, &mut sink, limits)?; + let layout = AdmittedLayout::from_spans( + target, + RegisteredStorageProfile::FAST_CDC_64K_V1, + spans, + limits.entry_limit(), + ) + .map_err(|source| Error::Ingestion(IngestionError::Layout(source)))?; + let record = layout + .encode_record() + .map_err(|source| Error::Ingestion(IngestionError::LayoutEncoding(source)))?; + let layout_id = record.id(); + sink.finish_layout(&record, layout_id)?; + let (closed, accounting) = sink.seal()?; + drop(snapshot); + drop(current); + Ok(DurableStagedBlob::new( + self, + (target, layout_id), + generation, + closed, + accounting.with_logical_bytes(logical_bytes), + )) + } + + /// As [`Self::stage`], refusing when the complete source does not hash + /// to `expected`. The stage written so far is left for recovery, as + /// after any other staging refusal. + /// + /// # Errors + /// + /// As [`Self::stage`], plus + /// [`IngestionError::BlobIdentityMismatch`] inside `Ingestion`. + pub fn stage_expected( + &mut self, + source: &mut R, + expected: BlobId, + limits: StagingLimits, + ) -> Result, Error> + where + R: Read + ?Sized, + { + let staged = self.stage(source, limits)?; + let observed = staged.target(); + if observed != expected { + return Err(Error::Ingestion(IngestionError::BlobIdentityMismatch { + expected, + observed, + })); + } + Ok(staged) + } +} diff --git a/src/adapters/durable/writer_sink.rs b/src/adapters/durable/writer_sink.rs new file mode 100644 index 00000000..d6e1f387 --- /dev/null +++ b/src/adapters/durable/writer_sink.rs @@ -0,0 +1,118 @@ +//! This module owns the durable chunk sink: each chunk the streaming core +//! hands over is either verified against the pinned catalog by exact bytes +//! or appended to a lazily created segment stage, never held in memory. + +use std::collections::BTreeSet; + +use super::DurableIngestionError as Error; +use super::ingestion_receipt::IngestionAccounting; +use crate::adapters::filesystem_catalog_publisher::ClosedSelection; +use crate::adapters::{ + AdmittedSegmentRecord, CatalogSnapshot, FilesystemCatalogPublisher, FilesystemSegmentStage, + SegmentRecordIdentity, SegmentRecordLimit, SegmentStageCreateError, StagedSegment, +}; +use crate::reference::ChunkSink; +use crate::{CanonicalLayoutRecord, ChunkId, LayoutId}; + +pub(super) struct DurableChunkSink<'publisher, 'catalog> { + publisher: &'publisher FilesystemCatalogPublisher, + catalog: &'catalog CatalogSnapshot<'catalog, 'catalog, 'catalog>, + staged: Option>>, + written: BTreeSet, + accounting: IngestionAccounting, +} + +impl<'publisher, 'catalog> DurableChunkSink<'publisher, 'catalog> { + pub(super) const fn new( + publisher: &'publisher FilesystemCatalogPublisher, + catalog: &'catalog CatalogSnapshot<'catalog, 'catalog, 'catalog>, + ) -> Self { + Self { + publisher, + catalog, + staged: None, + written: BTreeSet::new(), + accounting: IngestionAccounting::EMPTY, + } + } + + /// Appends the layout record unless the catalog already holds it + /// byte-identically. + pub(super) fn finish_layout( + &mut self, + record: &CanonicalLayoutRecord, + identity: LayoutId, + ) -> Result<(), Error> { + if let Some(existing) = self.catalog.record(SegmentRecordIdentity::Layout(identity)) { + if existing.payload() == record.bytes() { + return Ok(()); + } + return Err(Error::LayoutRepresentation { identity }); + } + let admitted = AdmittedSegmentRecord::for_layout(record).map_err(Error::Record)?; + self.append(admitted) + } + + /// Seals the stage when one was created and closes it into a selection + /// input that no longer borrows the publisher. + pub(super) fn seal(self) -> Result<(Option, IngestionAccounting), Error> { + let Some(staged) = self.staged else { + return Ok((None, self.accounting)); + }; + let sealed = staged + .seal() + .map_err(|source| Error::Stage(Box::new(source)))?; + let closed = self + .publisher + .close_sealed(sealed) + .map_err(Error::Selection)?; + Ok((Some(closed), self.accounting)) + } + + fn append(&mut self, record: AdmittedSegmentRecord<'_>) -> Result<(), Error> { + let staged = if let Some(staged) = self.staged.take() { + staged + } else { + let stage = self.publisher.create_segment_stage().map_err(|source| { + let SegmentStageCreateError::Create { source } = source; + if source.kind() == std::io::ErrorKind::AlreadyExists { + Error::StageRetained + } else { + Error::Open { source } + } + })?; + StagedSegment::begin(stage, SegmentRecordLimit::MAXIMUM) + .map_err(|source| Error::Stage(Box::new(source)))? + }; + self.staged = Some( + staged + .append(record) + .map_err(|source| Error::Stage(Box::new(source)))?, + ); + Ok(()) + } +} + +impl ChunkSink for DurableChunkSink<'_, '_> { + type Error = Error; + + fn stage_chunk(&mut self, identity: ChunkId, bytes: &[u8]) -> Result<(), Error> { + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + if let Some(existing) = self.catalog.record(SegmentRecordIdentity::Chunk(identity)) { + if existing.payload() != bytes { + return Err(Error::ChunkRepresentation { identity }); + } + self.accounting.reused_chunk(length); + return Ok(()); + } + if self.written.contains(&identity) { + self.accounting.reused_chunk(length); + return Ok(()); + } + let record = AdmittedSegmentRecord::for_chunk(bytes).map_err(Error::Record)?; + self.append(record)?; + let _inserted = self.written.insert(identity); + self.accounting.new_chunk(length); + Ok(()) + } +} diff --git a/src/adapters/durable/writer_tests.rs b/src/adapters/durable/writer_tests.rs new file mode 100644 index 00000000..34b717b6 --- /dev/null +++ b/src/adapters/durable/writer_tests.rs @@ -0,0 +1,249 @@ +//! Durable ingestion laws: one pass commits a blob readable by its layout +//! at once and by identity once anchored; nearby content reuses every +//! unchanged chunk after exact byte comparison; an exact re-ingest +//! publishes nothing; every limit and identity refusal leaves nothing +//! visible; an interrupted source leaves a stage the recovery protocol +//! discards, and staging refuses until it does. + +use std::error::Error; +use std::io::{self, Cursor, Read}; + +use super::recovery::recover_durable_ingestion_unchecked_for_tests; +use super::test_fixture::{durable_store, identify, long_content}; +use super::{DurableIngestionError, DurableStore, DurableWriter}; +use crate::adapters::retention::filesystem_retention_test_fixture::{ + catalog_policy, migrated_store, +}; +use crate::adapters::{FilesystemVersionTwoAdmission, ReaderAttemptLimit}; +use crate::{ + BlobHasher, ContentReads, IngestionError, LayoutEntryLimit, StagedByteLimit, StagingLimits, +}; + +fn open_writer(root: &std::path::Path) -> Result> { + let admission = FilesystemVersionTwoAdmission::reopen_unchecked_for_tests(root)?; + Ok(DurableWriter::open(admission, root, catalog_policy()?)?) +} + +fn unbounded() -> StagingLimits { + StagingLimits::entries(LayoutEntryLimit::MAXIMUM) +} + +fn nearby(content: &[u8]) -> Vec { + let mut edited = content.to_vec(); + for byte in edited.iter_mut().skip(200 * 1024).take(64) { + *byte = byte.wrapping_add(1); + } + edited +} + +#[test] +fn one_pass_commits_a_blob_readable_by_layout_then_by_anchor() -> Result<(), Box> { + let sandbox = migrated_store("durable-ingest-round-trip")?; + let content = long_content(); + let expected = identify(&content)?; + let mut writer = open_writer(sandbox.path())?; + let staged = writer.stage(&mut Cursor::new(&content), unbounded())?; + assert_eq!(staged.target(), expected.target); + assert_eq!(staged.layout_id(), expected.record.id()); + assert!(!staged.is_already_visible()); + let receipt = staged.commit()?; + assert_eq!(receipt.generation().get(), 2); + assert!(receipt.segment().is_some()); + let accounting = receipt.accounting(); + assert_eq!(accounting.logical_bytes(), u64::try_from(content.len())?); + assert_eq!(accounting.physical_new_bytes(), accounting.logical_bytes()); + assert_eq!(accounting.physical_reused_bytes(), 0); + assert_eq!( + accounting.chunks_new(), + u64::try_from(expected.spans.len())? + ); + assert_eq!(accounting.chunks_reused(), 0); + drop(writer); + let snapshot = DurableStore::open( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + ) + .snapshot()?; + assert_eq!(snapshot.view().catalog_generation().get(), 2); + assert!(!snapshot.contains_blob(receipt.target())); + let mut output = Vec::new(); + let read = snapshot.reconstruct_layout(receipt.layout_id(), &mut output)?; + assert_eq!(output, content); + assert_eq!(read.receipt().target(), receipt.target()); + Ok(()) +} + +#[test] +fn nearby_content_reuses_every_unchanged_chunk_and_an_exact_re_ingest_publishes_nothing() +-> Result<(), Box> { + let sandbox = migrated_store("durable-ingest-reuse")?; + let content = long_content(); + let edited = nearby(&content); + let mut writer = open_writer(sandbox.path())?; + let first = writer + .stage(&mut Cursor::new(&content), unbounded())? + .commit()?; + let second = writer + .stage(&mut Cursor::new(&edited), unbounded())? + .commit()?; + assert_eq!(second.generation().get(), 3); + let accounting = second.accounting(); + assert_eq!(accounting.logical_bytes(), u64::try_from(edited.len())?); + assert!(accounting.physical_reused_bytes() > 0); + assert!(accounting.physical_new_bytes() < accounting.logical_bytes()); + assert_eq!( + accounting.physical_new_bytes() + accounting.physical_reused_bytes(), + accounting.logical_bytes() + ); + assert!(accounting.chunks_reused() > accounting.chunks_new()); + let staged = writer.stage(&mut Cursor::new(&content), unbounded())?; + assert!(staged.is_already_visible()); + let again = staged.commit()?; + assert_eq!(again.segment(), None); + assert_eq!(again.generation(), second.generation()); + assert_eq!(again.catalog_digest(), second.catalog_digest()); + assert_eq!(again.target(), first.target()); + assert_eq!(again.layout_id(), first.layout_id()); + assert_eq!(again.accounting().physical_new_bytes(), 0); + assert_eq!( + again.accounting().physical_reused_bytes(), + again.accounting().logical_bytes() + ); + drop(writer); + let snapshot = DurableStore::open( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + ) + .snapshot()?; + let mut output = Vec::new(); + let _read = snapshot.reconstruct_layout(second.layout_id(), &mut output)?; + assert_eq!(output, edited); + Ok(()) +} + +#[test] +fn limit_and_identity_refusals_leave_nothing_visible() -> Result<(), Box> { + let sandbox = migrated_store("durable-ingest-refusals")?; + let content = long_content(); + let mut writer = open_writer(sandbox.path())?; + let short = StagingLimits::new(LayoutEntryLimit::MAXIMUM, StagedByteLimit::new(1024)); + let refusal = writer.stage(&mut Cursor::new(&content), short); + assert!(matches!( + refusal, + Err(DurableIngestionError::Ingestion( + IngestionError::ByteLimitExceeded { .. } + )) + )); + let one_entry = StagingLimits::entries(LayoutEntryLimit::new(1)?); + let refusal = writer.stage(&mut Cursor::new(&content), one_entry); + assert!(matches!( + refusal, + Err(DurableIngestionError::Ingestion(IngestionError::Layout(_))) + )); + drop(writer); + let _recovered = + recover_durable_ingestion_unchecked_for_tests(sandbox.path(), catalog_policy()?)?; + let mut writer = open_writer(sandbox.path())?; + let wrong = BlobHasher::new().finish(); + let refusal = writer.stage_expected(&mut Cursor::new(b"short"), wrong, unbounded()); + assert!(matches!( + refusal, + Err(DurableIngestionError::Ingestion( + IngestionError::BlobIdentityMismatch { .. } + )) + )); + drop(writer); + let _recovered = + recover_durable_ingestion_unchecked_for_tests(sandbox.path(), catalog_policy()?)?; + let snapshot = DurableStore::open( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + ) + .snapshot()?; + assert_eq!(snapshot.view().catalog_generation().get(), 1); + Ok(()) +} + +struct Interrupted<'a> { + remaining: &'a [u8], + after: usize, +} + +impl Read for Interrupted<'_> { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.after == 0 { + return Err(io::Error::other("the source went away")); + } + let take = buffer.len().min(self.remaining.len()).min(self.after); + let (head, tail) = self.remaining.split_at(take); + if let Some(slot) = buffer.get_mut(..take) { + slot.copy_from_slice(head); + } + self.remaining = tail; + self.after = self.after.saturating_sub(take); + Ok(take) + } +} + +#[test] +fn an_interrupted_source_leaves_a_stage_that_recovery_discards() -> Result<(), Box> { + let sandbox = migrated_store("durable-ingest-interrupted")?; + let content = long_content(); + let mut writer = open_writer(sandbox.path())?; + let mut source = Interrupted { + remaining: &content, + after: 300 * 1024, + }; + let refusal = writer.stage(&mut source, unbounded()); + assert!(matches!( + refusal, + Err(DurableIngestionError::Ingestion( + IngestionError::Read { .. } + )) + )); + assert!(sandbox.path().join("staging/current.seg").exists()); + let retained = writer.stage(&mut Cursor::new(&content), unbounded()); + assert!(matches!( + retained, + Err(DurableIngestionError::StageRetained) + )); + drop(writer); + let recovered = + recover_durable_ingestion_unchecked_for_tests(sandbox.path(), catalog_policy()?)?; + assert_eq!(recovered.discarded().len(), 1); + assert!(!sandbox.path().join("staging/current.seg").exists()); + let mut writer = open_writer(sandbox.path())?; + let receipt = writer + .stage(&mut Cursor::new(&content), unbounded())? + .commit()?; + assert_eq!(receipt.generation().get(), 2); + Ok(()) +} + +#[test] +fn an_anchored_ingest_satisfies_the_port_laws_beside_the_fixture_store() +-> Result<(), Box> { + let (sandbox, published) = durable_store("durable-ingest-beside", &[b"anchored", b"spare"])?; + let content = long_content(); + let mut writer = open_writer(sandbox.path())?; + let receipt = writer + .stage(&mut Cursor::new(&content), unbounded())? + .commit()?; + assert_eq!(receipt.generation().get(), 3); + drop(writer); + let snapshot = DurableStore::open( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + ) + .snapshot()?; + let anchored = published.first().ok_or("anchored blob")?; + assert!(ContentReads::contains_blob(&snapshot, anchored.target)); + let mut output = Vec::new(); + let _read = snapshot.reconstruct_layout(receipt.layout_id(), &mut output)?; + assert_eq!(output, content); + Ok(()) +} diff --git a/src/adapters/filesystem_catalog_publisher.rs b/src/adapters/filesystem_catalog_publisher.rs index 88d6ae9b..62f28962 100644 --- a/src/adapters/filesystem_catalog_publisher.rs +++ b/src/adapters/filesystem_catalog_publisher.rs @@ -15,6 +15,15 @@ use super::{ use super::{CanonicalCatalog, CanonicalPublicationHead, filesystem_catalog_catalog}; pub(super) const CURRENT_SEGMENT: &str = "current.seg"; + +/// A closed, synchronized stage's metadata with the authority that created +/// it, held between sealing and selection without borrowing the publisher. +#[must_use] +pub(super) struct ClosedSelection { + closed: ClosedSegment, + authority: FilesystemPublisherAuthority, +} + pub(super) const CURRENT_CATALOG: &str = "current.cat"; pub(super) const HEAD: &str = "HEAD"; pub(super) const NEXT_HEAD: &str = "head.next"; @@ -157,16 +166,47 @@ impl FilesystemCatalogPublisher { sealed: SealedSegment>, admitted: &'selection AdmittedSegment<'records>, ) -> Result, SegmentPublicationError> { + let closed = self.close_sealed(sealed)?; + self.select_closed(closed, admitted) + } + + /// Closes one synchronized stage created by this publisher into a + /// handle-free selection input that no longer borrows the publisher. + /// + /// # Errors + /// + /// Returns [`SegmentPublicationError::PublisherAuthority`] when `sealed` + /// was created by another publisher. + pub(super) fn close_sealed( + &self, + sealed: SealedSegment>, + ) -> Result { let (stage, record_count, segment_length, digest) = sealed.into_parts(); let authority = stage.close(); if !self.authority.matches(&authority) { return Err(SegmentPublicationError::PublisherAuthority); } - SegmentPublication::one_bound( - ClosedSegment::admitted(record_count, segment_length, digest), - admitted, + Ok(ClosedSelection { + closed: ClosedSegment::admitted(record_count, segment_length, digest), authority, - ) + }) + } + + /// Binds a closed selection input from [`Self::close_sealed`] to the + /// exact admitted stage bytes. + /// + /// # Errors + /// + /// As [`Self::select_segment`]. + pub(super) fn select_closed<'selection, 'records>( + &self, + selection: ClosedSelection, + admitted: &'selection AdmittedSegment<'records>, + ) -> Result, SegmentPublicationError> { + if !self.authority.matches(&selection.authority) { + return Err(SegmentPublicationError::PublisherAuthority); + } + SegmentPublication::one_bound(selection.closed, admitted, selection.authority) } /// Writes a strict prefix through the production catalog-stage adapter. diff --git a/src/adapters/filesystem_catalog_snapshot.rs b/src/adapters/filesystem_catalog_snapshot.rs index d90ef6df..1d3f9ae7 100644 --- a/src/adapters/filesystem_catalog_snapshot.rs +++ b/src/adapters/filesystem_catalog_snapshot.rs @@ -120,6 +120,11 @@ impl FilesystemCatalogSnapshot { Ok(snapshot) } + /// The retained immutable segment bytes, in catalog order. + pub(super) fn loaded_segments(&self) -> &[LoadedSegment] { + &self.segments + } + pub(super) fn head_bytes(&self) -> &[u8] { &self.head_bytes } diff --git a/src/lib.rs b/src/lib.rs index ac3a97aa..b30040ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -202,8 +202,10 @@ pub use adapters::{ observe_compaction, plan_compaction, recover_compaction, }; pub use adapters::{ - DurableOutcome, DurableRangeReadReceipt, DurableReadError, DurableReconstructionReceipt, - DurableSnapshot, DurableStore, DurableStoreError, DurableView, + DurableIngestionError, DurableIngestionReceipt, DurableOutcome, DurableRangeReadReceipt, + DurableReadError, DurableReconstructionReceipt, DurableSnapshot, DurableStagedBlob, + DurableStore, DurableStoreError, DurableView, DurableWriter, IngestionAccounting, + recover_durable_ingestion, }; pub use adapters::{ MIGRATION_NAMESPACE_PREFIX, StoreMigrationEffect, StoreMigrationFixedStage, @@ -229,8 +231,8 @@ pub use layout::{ pub use profile::{RegisteredStorageProfile, StorageProfileAdmissionError, StorageProfileId}; pub use reference::{ IngestionAllocation, IngestionError, ProfileBoundary, PublishError, PublishedBlob, - RangeReadError, RangeReadReceipt, ReconstructionError, ReconstructionReceipt, ReferenceStore, - ReferenceStoreCapacity, StagedBlob, + RangeReadError, RangeReadReceipt, ReconstructionError, ReconstructionReceipt, + ReferenceStagedContent, ReferenceStore, ReferenceStoreCapacity, StagedBlob, }; pub use retention::{ LivenessGeneration, LivenessGenerationError, RegisteredRetentionProfile, RetentionAnchor, diff --git a/src/reference/chunk_staging.rs b/src/reference/chunk_staging.rs index a57cd579..36ab7cae 100644 --- a/src/reference/chunk_staging.rs +++ b/src/reference/chunk_staging.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; use crate::{ChunkId, ReferenceStore}; use super::IngestionError; +use super::ingestion::ChunkSink; use super::ingestion_error::IngestionAllocation; pub(super) struct ReferenceChunkStaging<'a> { @@ -26,11 +27,7 @@ impl<'a> ReferenceChunkStaging<'a> { (self.chunks, self.pending_bytes) } - pub(super) fn stage_chunk( - &mut self, - identity: ChunkId, - bytes: &[u8], - ) -> Result<(), IngestionError> { + fn stage_chunk_inner(&mut self, identity: ChunkId, bytes: &[u8]) -> Result<(), IngestionError> { if let Some(existing) = self.store.chunks.get(&identity) { return compare_existing(identity, existing, bytes); } @@ -48,6 +45,14 @@ impl<'a> ReferenceChunkStaging<'a> { } } +impl ChunkSink for ReferenceChunkStaging<'_> { + type Error = IngestionError; + + fn stage_chunk(&mut self, identity: ChunkId, bytes: &[u8]) -> Result<(), IngestionError> { + self.stage_chunk_inner(identity, bytes) + } +} + impl ReferenceChunkStaging<'_> { fn check_capacity(&self, incoming: usize) -> Result<(), IngestionError> { let attempted = self diff --git a/src/reference/ingestion.rs b/src/reference/ingestion.rs index a06c0758..5747333f 100644 --- a/src/reference/ingestion.rs +++ b/src/reference/ingestion.rs @@ -12,6 +12,22 @@ use super::chunk_staging::ReferenceChunkStaging; use super::ingestion_error::IngestionAllocation; use super::{IngestionError, ReferenceStore, StagedBlob}; +/// Where the streaming core hands each exactly identified chunk. +/// +/// The reference store holds the bytes in memory; the durable writer streams +/// them into a segment stage or verifies them against the pinned catalog. +#[expect( + clippy::redundant_pub_crate, + reason = "reached from the durable adapter only through the crate-private re-export" +)] +pub(crate) trait ChunkSink { + /// The sink's refusal, into which every streaming refusal converts. + type Error: From; + + /// Accepts one chunk whose identity was verified against its bytes. + fn stage_chunk(&mut self, identity: ChunkId, bytes: &[u8]) -> Result<(), Self::Error>; +} + macro_rules! read_buffer_bytes { () => { 8_192 @@ -24,6 +40,9 @@ macro_rules! maximum_chunk_bytes { }; } +/// The complete identity, the spans, and the logical length of one stream. +type IngestedStream = (BlobId, Vec, u64); + const READ_BUFFER_BYTES: usize = read_buffer_bytes!(); // This bound makes more than one detector boundary per read impossible. const _: () = assert!(read_buffer_bytes!() <= FastCdc::MINIMUM_CHUNK_LENGTH.get()); @@ -96,7 +115,7 @@ impl ReferenceStore { { let entry_limit = limits.entry_limit(); let mut staging = ReferenceChunkStaging::new(self); - let (target, spans) = ingest_stream(source, &mut staging, limits)?; + let (target, spans, _logical_bytes) = ingest_stream(source, &mut staging, limits)?; let layout = AdmittedLayout::from_spans( target, crate::RegisteredStorageProfile::FAST_CDC_64K_V1, @@ -141,13 +160,25 @@ impl ReferenceStore { } } -fn ingest_stream( +/// Runs the one-pass bounded streaming core over `source`, handing each +/// chunk to `sink`, and returns the complete identity, the spans, and the +/// logical length. +/// +/// # Errors +/// +/// Returns the sink's refusal, into which every streaming refusal converts. +#[expect( + clippy::redundant_pub_crate, + reason = "reached from the durable adapter only through the crate-private re-export" +)] +pub(crate) fn ingest_stream( source: &mut R, - staging: &mut ReferenceChunkStaging<'_>, + sink: &mut S, limits: StagingLimits, -) -> Result<(crate::BlobId, Vec), IngestionError> +) -> Result where R: Read + ?Sized, + S: ChunkSink, { let entry_limit = limits.entry_limit(); let maximum = usize::try_from(FastCdc::MAXIMUM_CHUNK_LENGTH.get()).map_err(|_source| { @@ -168,7 +199,7 @@ where let mut read_buffer = [0_u8; READ_BUFFER_BYTES]; loop { match source.read(&mut read_buffer) { - Ok(0) => return state.finish(staging), + Ok(0) => return state.finish(sink), Ok(observed) => { let bytes = read_buffer @@ -177,10 +208,10 @@ where maximum: read_buffer.len(), observed, })?; - state.accept(bytes, staging)?; + state.accept(bytes, sink)?; } Err(source) if source.kind() == ErrorKind::Interrupted => {} - Err(source) => return Err(IngestionError::Read { source }), + Err(source) => return Err(IngestionError::Read { source }.into()), } } } @@ -212,18 +243,15 @@ impl StreamState { } } - fn accept( - &mut self, - bytes: &[u8], - staging: &mut ReferenceChunkStaging<'_>, - ) -> Result<(), IngestionError> { + fn accept(&mut self, bytes: &[u8], sink: &mut S) -> Result<(), S::Error> { let next_accepted = checked_accepted(self.accepted, bytes.len())?; if next_accepted > self.byte_limit.get() { return Err(IngestionError::ByteLimitExceeded { limit: self.byte_limit, accepted: self.accepted, incoming: bytes.len(), - }); + } + .into()); } self.blob_hasher .update(bytes) @@ -234,23 +262,24 @@ impl StreamState { .map_err(IngestionError::Chunking)?; match emission { FeedEmission::None => self.chunk_buffer.extend_from_slice(bytes), - FeedEmission::One(span) => self.accept_boundary(bytes, span, staging)?, + FeedEmission::One(span) => self.accept_boundary(bytes, span, sink)?, FeedEmission::Multiple => { return Err(IngestionError::MultipleBoundaries { feed_length: bytes.len(), - }); + } + .into()); } } self.accepted = next_accepted; Ok(()) } - fn accept_boundary( + fn accept_boundary( &mut self, bytes: &[u8], span: ChunkSpan, - staging: &mut ReferenceChunkStaging<'_>, - ) -> Result<(), IngestionError> { + sink: &mut S, + ) -> Result<(), S::Error> { let local = boundary_index(self.accepted, span, bytes.len())?; let prefix = bytes .get(..local) @@ -268,23 +297,20 @@ impl StreamState { })?; prepare_span(&mut self.spans, self.entry_limit)?; self.chunk_buffer.extend_from_slice(prefix); - stage_exact_chunk(staging, span, &self.chunk_buffer)?; + stage_exact_chunk(sink, span, &self.chunk_buffer)?; self.spans.push(span); self.chunk_buffer.clear(); self.chunk_buffer.extend_from_slice(remainder); Ok(()) } - fn finish( - mut self, - staging: &mut ReferenceChunkStaging<'_>, - ) -> Result<(crate::BlobId, Vec), IngestionError> { + fn finish(mut self, sink: &mut S) -> Result { if let Some(span) = self.detector.finish().map_err(IngestionError::Chunking)? { prepare_span(&mut self.spans, self.entry_limit)?; - stage_exact_chunk(staging, span, &self.chunk_buffer)?; + stage_exact_chunk(sink, span, &self.chunk_buffer)?; self.spans.push(span); } - Ok((self.blob_hasher.finish(), self.spans)) + Ok((self.blob_hasher.finish(), self.spans, self.accepted)) } } @@ -329,19 +355,20 @@ fn boundary_index( Ok(local) } -fn stage_exact_chunk( - staging: &mut ReferenceChunkStaging<'_>, +fn stage_exact_chunk( + sink: &mut S, span: ChunkSpan, bytes: &[u8], -) -> Result<(), IngestionError> { +) -> Result<(), S::Error> { let observed = ChunkId::hash_bytes(bytes).map_err(IngestionError::ChunkHash)?; if observed != span.id() { return Err(IngestionError::ChunkIdentityMismatch { expected: span.id(), observed, - }); + } + .into()); } - staging.stage_chunk(span.id(), bytes) + sink.stage_chunk(span.id(), bytes) } fn reserve_span(spans: &mut Vec) -> Result<(), IngestionError> { diff --git a/src/reference/mod.rs b/src/reference/mod.rs index 58f3df44..283688f2 100644 --- a/src/reference/mod.rs +++ b/src/reference/mod.rs @@ -35,7 +35,13 @@ pub use capacity::ReferenceStoreCapacity; reason = "the durable adapter reaches the shared read cores through this crate-private surface" )] pub(crate) use chunk_verification::ChunkSource; +#[expect( + clippy::redundant_pub_crate, + reason = "the durable adapter reaches the streaming core through this crate-private surface" +)] +pub(crate) use ingestion::{ChunkSink, ingest_stream}; pub use ingestion_error::{IngestionAllocation, IngestionError}; +pub use port::ReferenceStagedContent; pub use publish_error::PublishError; pub use published_blob::PublishedBlob; pub use range_read_error::RangeReadError; diff --git a/src/reference/port.rs b/src/reference/port.rs index 4fd70415..668a33ed 100644 --- a/src/reference/port.rs +++ b/src/reference/port.rs @@ -12,6 +12,20 @@ use crate::{ StagingLimits, }; +/// A [`StagedBlob`] bound to the reference store it will commit into. +#[must_use = "staged work remains invisible until commit is called"] +pub struct ReferenceStagedContent<'store> { + store: &'store mut ReferenceStore, + staged: StagedBlob, +} + +impl ReferenceStagedContent<'_> { + /// The underlying staged work. + pub const fn staged(&self) -> &StagedBlob { + &self.staged + } +} + impl ContentReads for ReferenceStore { type ReconstructionReceipt = ReconstructionReceipt; type RangeReceipt = RangeReadReceipt; @@ -58,47 +72,53 @@ impl ContentReads for ReferenceStore { } impl ContentStaging for ReferenceStore { - type Staged = StagedBlob; + type Staged<'store> = ReferenceStagedContent<'store>; type Error = IngestionError; - fn stage( - &self, + fn stage<'store>( + &'store mut self, source: &mut dyn Read, limits: StagingLimits, - ) -> Result { - self.stage_bounded(source, limits) + ) -> Result, IngestionError> { + let staged = self.stage_bounded(source, limits)?; + Ok(ReferenceStagedContent { + store: self, + staged, + }) } - fn stage_expected( - &self, + fn stage_expected<'store>( + &'store mut self, source: &mut dyn Read, expected: BlobId, limits: StagingLimits, - ) -> Result { + ) -> Result, IngestionError> { let staged = self.stage_bounded(source, limits)?; let observed = staged.target(); if observed != expected { return Err(IngestionError::BlobIdentityMismatch { expected, observed }); } - Ok(staged) + Ok(ReferenceStagedContent { + store: self, + staged, + }) } } -impl StagedContent for StagedBlob { - type Store = ReferenceStore; +impl StagedContent for ReferenceStagedContent<'_> { type Receipt = PublishedBlob; type Error = PublishError; fn target(&self) -> BlobId { - Self::target(self) + self.staged.target() } fn layout_id(&self) -> LayoutId { - Self::layout_id(self) + self.staged.layout_id() } - fn commit(self, store: &mut ReferenceStore) -> Result { - Self::commit(self, store) + fn commit(self) -> Result { + self.staged.commit(self.store) } } diff --git a/src/store/reference_port_tests.rs b/src/store/reference_port_tests.rs index 36b31c9d..cbca636c 100644 --- a/src/store/reference_port_tests.rs +++ b/src/store/reference_port_tests.rs @@ -24,11 +24,11 @@ fn reference_backend_satisfies_the_read_laws_through_the_port() -> Result<(), Bo let content = content(); let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(4 * 1024 * 1024)); let staged = ContentStaging::stage( - &store, + &mut store, &mut Cursor::new(&content), StagingLimits::entries(LayoutEntryLimit::MAXIMUM), )?; - let receipt = StagedContent::commit(staged, &mut store)?; + let receipt = StagedContent::commit(staged)?; reconstructs_exactly(&store, receipt.target(), receipt.layout_id(), &content)?; ranges_exactly( &store, @@ -43,10 +43,10 @@ fn reference_backend_satisfies_the_read_laws_through_the_port() -> Result<(), Bo #[test] fn the_byte_limit_refuses_before_any_excess_is_materialized() -> Result<(), Box> { let content = content(); - let store = ReferenceStore::new(ReferenceStoreCapacity::new(4 * 1024 * 1024)); + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(4 * 1024 * 1024)); let limit = StagedByteLimit::new(u64::try_from(content.len())?.saturating_sub(1)); let refusal = ContentStaging::stage( - &store, + &mut store, &mut Cursor::new(&content), StagingLimits::new(LayoutEntryLimit::MAXIMUM, limit), ); @@ -63,10 +63,12 @@ fn the_byte_limit_refuses_before_any_excess_is_materialized() -> Result<(), Box< assert!(accepted.saturating_add(u64::try_from(incoming)?) > limit.get()); let exact = StagedByteLimit::new(u64::try_from(content.len())?); let staged = ContentStaging::stage( - &store, + &mut store, &mut Cursor::new(&content), StagingLimits::new(LayoutEntryLimit::MAXIMUM, exact), )?; - assert!(!ContentReads::contains_blob(&store, staged.target())); + let target = StagedContent::target(&staged); + drop(staged); + assert!(!ContentReads::contains_blob(&store, target)); Ok(()) } diff --git a/src/store/staging.rs b/src/store/staging.rs index 162cc22e..6af5b4fd 100644 --- a/src/store/staging.rs +++ b/src/store/staging.rs @@ -17,10 +17,11 @@ pub trait CommitReceipt: Copy + Debug + Eq { fn layout_id(&self) -> LayoutId; } -/// Content staged but not yet visible. +/// Content staged into one store but not yet visible. +/// +/// The staging borrows its store for its lifetime, so the store cannot +/// change underneath it, and commit needs no second reference. pub trait StagedContent: Sized { - /// The store the staging commits into. - type Store: ?Sized; /// The commit receipt. type Receipt: CommitReceipt; /// Why the commit returned no receipt. @@ -32,18 +33,20 @@ pub trait StagedContent: Sized { /// The exact layout the staging will commit under. fn layout_id(&self) -> LayoutId; - /// Makes the staged content visible in `store`, or leaves it invisible. + /// Makes the staged content visible, or leaves it invisible. /// /// # Errors /// /// Returns the backend's refusal; nothing becomes visible on failure. - fn commit(self, store: &mut Self::Store) -> Result; + fn commit(self) -> Result; } /// Staging an unknown-length source under count-and-byte limits. pub trait ContentStaging { - /// The staged, not-yet-visible content. - type Staged: StagedContent; + /// The staged, not-yet-visible content, borrowing this store. + type Staged<'store>: StagedContent + where + Self: 'store; /// Why staging returned nothing. type Error: Error + 'static; @@ -53,11 +56,11 @@ pub trait ContentStaging { /// # Errors /// /// Returns the backend's refusal or the source's failure. - fn stage( - &self, + fn stage<'store>( + &'store mut self, source: &mut dyn Read, limits: StagingLimits, - ) -> Result; + ) -> Result, Self::Error>; /// As [`Self::stage`], refusing when the source does not hash to /// `expected`. @@ -65,10 +68,10 @@ pub trait ContentStaging { /// # Errors /// /// As [`Self::stage`], plus the identity mismatch. - fn stage_expected( - &self, + fn stage_expected<'store>( + &'store mut self, source: &mut dyn Read, expected: BlobId, limits: StagingLimits, - ) -> Result; + ) -> Result, Self::Error>; } diff --git a/tests/content_store_port.rs b/tests/content_store_port.rs index 773d0c80..f08a8b42 100644 --- a/tests/content_store_port.rs +++ b/tests/content_store_port.rs @@ -27,11 +27,13 @@ fn content(seed: u64, length: usize) -> Vec { .collect() } +/// The committed identity and the bytes read back through the port. +type RoundTrip = Result<(BlobId, Vec), Box>; + /// Backend-neutral: stage, commit, and read back through the port alone. -fn round_trip(store: &mut S, bytes: &[u8]) -> Result<(BlobId, Vec), Box> +fn round_trip(store: &mut S, bytes: &[u8]) -> RoundTrip where S: ContentStaging + ContentReads, - S::Staged: StagedContent, { let staged = store.stage( &mut Cursor::new(bytes), @@ -39,14 +41,16 @@ where )?; let expected_target = staged.target(); let expected_layout = staged.layout_id(); - let receipt = staged.commit(store)?; - assert_eq!(receipt.target(), expected_target); - assert_eq!(receipt.layout_id(), expected_layout); + let receipt = staged.commit()?; + let (target, layout_id) = (receipt.target(), receipt.layout_id()); + drop(receipt); + assert_eq!(target, expected_target); + assert_eq!(layout_id, expected_layout); let mut output = Vec::new(); store - .reconstruct_layout(receipt.layout_id(), &mut output) + .reconstruct_layout(layout_id, &mut output) .map_err(|error| error.to_string())?; - Ok((receipt.target(), output)) + Ok((target, output)) } #[test] @@ -68,7 +72,7 @@ fn staging_refuses_the_byte_limit_before_anything_is_visible() -> Result<(), Box let bytes = content(0xfeed_beef_dead_c0de, 96 * 1024); let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); let limits = StagingLimits::new(LayoutEntryLimit::MAXIMUM, StagedByteLimit::new(64 * 1024)); - let refusal = ContentStaging::stage(&store, &mut Cursor::new(&bytes), limits); + let refusal = ContentStaging::stage(&mut store, &mut Cursor::new(&bytes), limits); assert!(matches!( refusal, Err(IngestionError::ByteLimitExceeded { limit, .. }) if limit.get() == 64 * 1024 @@ -81,9 +85,9 @@ fn staging_refuses_the_byte_limit_before_anything_is_visible() -> Result<(), Box #[test] fn staging_refuses_the_entry_limit_through_the_port() -> Result<(), Box> { let bytes = content(0x0bad_cafe_f00d_face, 512 * 1024); - let store = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); let limits = StagingLimits::entries(LayoutEntryLimit::new(1)?); - let refusal = ContentStaging::stage(&store, &mut Cursor::new(&bytes), limits); + let refusal = ContentStaging::stage(&mut store, &mut Cursor::new(&bytes), limits); assert!(refusal.is_err()); Ok(()) } @@ -91,13 +95,13 @@ fn staging_refuses_the_entry_limit_through_the_port() -> Result<(), Box Result<(), Box> { let bytes = content(0x7777_1111_2222_3333, 8 * 1024); - let store = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); let wrong = BlobHasher::new().finish(); let mut hasher = BlobHasher::new(); hasher.update(&bytes)?; let right = hasher.finish(); let refusal = ContentStaging::stage_expected( - &store, + &mut store, &mut Cursor::new(&bytes), wrong, StagingLimits::entries(LayoutEntryLimit::MAXIMUM), @@ -108,7 +112,7 @@ fn an_expected_identity_that_does_not_match_refuses_with_both() -> Result<(), Bo if expected == wrong && observed == right )); let staged = ContentStaging::stage_expected( - &store, + &mut store, &mut Cursor::new(&bytes), right, StagingLimits::entries(LayoutEntryLimit::MAXIMUM), From 1dbbf7f5253d89bb314eacd2c3c2b6af8cb51ff1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 14:49:01 -0700 Subject: [PATCH 36/59] Feat: bounded streaming write-through pipeline with exactly-once sinks and unbuffered copies `transfer_layout`, `transfer_blob`, `transfer_range`, and `transfer_layout_range` move authenticated bytes from any `ContentReads` view into a `TransferSink` as `TransferSegment`s: the read core's own borrowed chunk slices, handed over without a copy and applied exactly once in index and offset order, acknowledged every `TransferWindow` segments, with a `CancellationSignal` consulted before every segment. Cancellation returns `TransferError::Cancelled { segments, bytes }` and never a receipt; a sink or view refusal is typed at its boundary. `WriteSink` is the exactly-once sink over any writer. `copy_layout` copies one committed layout from a `TransferSource` (`ReferenceStore`, `DurableSnapshot`) into any `ContentStaging` destination through a pull reader that authenticates each chunk as it is first served, with the destination's `stage_expected` verifying the complete identity, so the blob is never held whole. Laws: every verified slice reaches the sink in order; ranges transfer exactly; a window of one acknowledges every segment; a mid-window sink failure and a cancellation each yield no receipt with the applied prefix stated; a write sink refuses out-of-order and repeated segments; copies round-trip between reference stores and across both durable directions; the pull reader refuses at the boundary of a chunk that does not hash to its identity. `tests/transfer_pipeline_memory.rs` shows read-to-write allocates nothing beyond the sink and copy-to-write allocates less than a caller-owned copy loop; `benches/transfer_pipeline.rs` times both, and the page records that the CPU medians are within noise, so only "no worse" and "less allocation" are claimed. F-24 is Done with its owed items named; F-21's status no longer defers to T-23.1. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 19 ++ Cargo.toml | 4 + ROADMAP.md | 38 ++- benches/transfer_pipeline.rs | 110 +++++++ docs/architecture/content-store/README.md | 8 + docs/architecture/content-store/pipeline.md | 95 ++++++ src/adapters/durable/mod.rs | 3 + src/adapters/durable/snapshot.rs | 14 +- src/adapters/durable/transfer_source.rs | 31 ++ src/adapters/durable/transfer_tests.rs | 85 ++++++ src/adapters/exports.rs | 1 + src/adapters/mod.rs | 1 + src/adapters/pipeline/cancellation.rs | 50 +++ src/adapters/pipeline/copy.rs | 114 +++++++ src/adapters/pipeline/error.rs | 47 +++ src/adapters/pipeline/mod.rs | 37 +++ src/adapters/pipeline/receipt.rs | 61 ++++ src/adapters/pipeline/sink.rs | 211 +++++++++++++ src/adapters/pipeline/source.rs | 110 +++++++ src/adapters/pipeline/tests.rs | 322 ++++++++++++++++++++ src/adapters/pipeline/transfer.rs | 263 ++++++++++++++++ src/adapters/pipeline/window.rs | 28 ++ src/lib.rs | 6 + src/reference/chunk_reader.rs | 255 ++++++++++++++++ src/reference/mod.rs | 7 + src/reference/transfer_source.rs | 23 ++ tests/transfer_pipeline_memory.rs | 99 ++++++ 27 files changed, 2029 insertions(+), 13 deletions(-) create mode 100644 benches/transfer_pipeline.rs create mode 100644 docs/architecture/content-store/pipeline.md create mode 100644 src/adapters/durable/transfer_source.rs create mode 100644 src/adapters/durable/transfer_tests.rs create mode 100644 src/adapters/pipeline/cancellation.rs create mode 100644 src/adapters/pipeline/copy.rs create mode 100644 src/adapters/pipeline/error.rs create mode 100644 src/adapters/pipeline/mod.rs create mode 100644 src/adapters/pipeline/receipt.rs create mode 100644 src/adapters/pipeline/sink.rs create mode 100644 src/adapters/pipeline/source.rs create mode 100644 src/adapters/pipeline/tests.rs create mode 100644 src/adapters/pipeline/transfer.rs create mode 100644 src/adapters/pipeline/window.rs create mode 100644 src/reference/chunk_reader.rs create mode 100644 src/reference/transfer_source.rs create mode 100644 tests/transfer_pipeline_memory.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d738eaf..4e8d5bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,25 @@ after its public API and format compatibility policies are established. ### Added +- Bounded streaming write-through pipeline. `transfer_layout`, + `transfer_blob`, `transfer_range`, and `transfer_layout_range` move + authenticated bytes from any `ContentReads` view into a `TransferSink` + as `TransferSegment`s (the read core's own borrowed chunk slices, + applied exactly once in order) under `TransferBounds`: a + `TransferWindow` between acknowledgements and a `CancellationSignal` + (`NeverCancelled`, `AtomicBool`, or a shareable `CancellationFlag`) + consulted before every segment; cancellation returns + `TransferError::Cancelled { segments, bytes }`, never a receipt. + `WriteSink` is the exactly-once sink over any writer. `copy_layout` + copies one committed layout from a `TransferSource` (`ReferenceStore`, + `DurableSnapshot`) into any `ContentStaging` destination through a pull + reader that authenticates each chunk as it is served, with the + destination's `stage_expected` verifying the complete identity; the + blob is never held whole. `tests/transfer_pipeline_memory.rs` shows + read-to-write allocates nothing beyond the sink and copy-to-write + allocates less than a caller-owned copy loop; + `benches/transfer_pipeline.rs` times both against that loop. Page: + `docs/architecture/content-store/pipeline.md`. - Durable staged ingestion with deduplication. `DurableWriter::open` takes writer authority over a version-two root; `stage(source, limits)` reads the source once through the reference store's streaming core (now diff --git a/Cargo.toml b/Cargo.toml index 230d7cf9..d395f001 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,10 @@ divan = { version = "=0.1.21", default-features = false } name = "streaming_cdc" harness = false +[[bench]] +name = "transfer_pipeline" +harness = false + [workspace] members = [".", "benchmark", "repository-process-spawn", "xtask"] resolver = "3" diff --git a/ROADMAP.md b/ROADMAP.md index 624872c5..34518f7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,10 +99,10 @@ names; use those in code, tests, and commits. - [x] [F-18 Retention publication](#f-18-retention-publication) — Done on this branch (recovery, the `KEEP-CRASH-036`–`052` matrix, member re-verification, and orphan disposition) - [x] [F-19 Reader fence and immutable version-2 snapshots](#f-19-reader-fence-and-immutable-version-2-snapshots) — Done on this branch (merged from PR #99) - [x] [F-20 Model-based retention transition evidence](#f-20-model-based-retention-transition-evidence) — Done on this branch (merged from PR #99) -- [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Partial (#20; report vocabulary, corruption ledgers, and durable receipts done on this branch; durable-view depths land with T-23.1) +- [ ] [F-21 Precise verification reports and corruption refusal](#f-21-precise-verification-reports-and-corruption-refusal) — Partial (#20; report vocabulary, corruption ledgers, and durable receipts done on this branch; durable verification views at `KEEP-VERIFY-006` depths still owed) - [x] [F-22 Garbage collection, compaction, and recovery dispositions](#f-22-garbage-collection-compaction-and-recovery-dispositions) — Done on this branch (#21; codecs, planner, disposition, retirement, and compaction; crash sequences for disposition and compaction, stress, benchmarks, and re-encoding still owed) - [x] [F-23 Durable authenticated reads and refusal receipts](#f-23-durable-authenticated-reads-and-refusal-receipts) — Done on this branch (#109; `DurableStore` reads and verification receipts) -- [ ] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Partial (#82, #72): T-24.1 and T-24.2 landed; T-24.3 open +- [x] [F-24 Bounded production ingestion through the durable store](#f-24-bounded-production-ingestion-through-the-durable-store) — Done (#82, #72) with owed items named under T-24.2 and T-24.3 ### Integration (M5) @@ -1032,9 +1032,10 @@ clocks, paths, environment, and caller identity out of the core. **Status:** Partial (#20, P1, M4); the vocabulary and the reference-store form (T-21.1), the permanent corruption ledgers (T-21.2), and durable -receipts (T-21.3) landed on this branch; durable views establishing -`Framing` through `RetentionClosure` (`KEEP-VERIFY-006`) land with the -durable read surface (T-23.1). The most-cited open blocker: F-22, +receipts (T-21.3) landed on this branch; the durable read surface +(T-23.1) landed without the durable verification views establishing +`Framing` through `RetentionClosure` (`KEEP-VERIFY-006`), which remain +owed here. The most-cited open blocker: F-22, F-23, F-24, F-27, F-28, F-29, F-30, F-31, F-33, and F-34 all name it. Verify content and store structure at explicit, enumerated depths; report @@ -1461,10 +1462,10 @@ and no hidden whole-blob allocation. ### F-24 Bounded production ingestion through the durable store -**Status:** Partial (#82, P1, M6). T-24.1 (the content-store port) and -T-24.2 (`DurableWriter`) landed on this branch; T-24.3 (the streaming -write-through pipeline, #72) is open, as are the owed items listed under -T-24.2. +**Status:** Done on this branch (#82, #72; P1). T-24.1 (the content-store +port), T-24.2 (`DurableWriter`), and T-24.3 (the transfer pipeline) +landed; the owed items are named under T-24.2 and T-24.3 and on their +pages. One bounded production path from an unknown-length source through the registered CDC profile, chunk verification and deduplication, immutable @@ -1562,7 +1563,24 @@ error precision. with the memory bound; CHANGELOG. - **Dependencies:** T-24.1, T-06.3, T-06.4, F-18, F-21 (representation verification depth), F-23. -- [ ] T-24.3 Bounded streaming write-through pipeline (#72, P3). +- [x] T-24.3 Bounded streaming write-through pipeline (#72, P3). Done + 2026-09-30: `transfer_{layout,blob,range,layout_range}` over any + `ContentReads` view into a `TransferSink` under `TransferBounds` + (window and cancellation), `WriteSink` as the exactly-once sink, + `copy_layout` from a `TransferSource` (`ReferenceStore`, + `DurableSnapshot`) into any `ContentStaging` destination through a + verifying pull reader. Laws in `src/adapters/pipeline/tests.rs`, + `src/adapters/durable/transfer_tests.rs`, and + `tests/transfer_pipeline_memory.rs` (no allocation beyond the sink; + less allocation than a caller-owned copy loop); benchmark + `benches/transfer_pipeline.rs`; page + `docs/architecture/content-store/pipeline.md`. Still owed: a CPU + advantage over the caller-owned copy loop (the benchmark's medians are + within noise, so only "no worse" and "less allocation" are claimed), + the profile mismatch edge (one profile is registered), the Worldline + copy (with the durable Worldline run), and multi-threaded pipelines + (bounded-memory proof first). + Original task fields: - **Requirements:** a source adapter emitting verified range segments, a sink adapter applying an exactly-once write protocol, and a transfer adapter coordinating a bounded chunk window with receipts and diff --git a/benches/transfer_pipeline.rs b/benches/transfer_pipeline.rs new file mode 100644 index 00000000..fec27d96 --- /dev/null +++ b/benches/transfer_pipeline.rs @@ -0,0 +1,110 @@ +//! Transfer pipeline against a caller-owned copy loop. + +use std::io::Cursor; + +use divan::counter::BytesCount; +use divan::{Bencher, black_box}; +use keep::{ + LayoutEntryLimit, NeverCancelled, PublishedBlob, ReferenceStore, ReferenceStoreCapacity, + StagingLimits, TransferBounds, TransferWindow, WriteSink, copy_layout, transfer_layout, +}; + +const REPRESENTATIVE_INPUT_BYTES: usize = 1_048_576; +const LARGE_INPUT_BYTES: usize = 4_194_304; +const CAPACITY: usize = 64 * 1024 * 1024; + +fn main() { + divan::main(); +} + +fn deterministic_bytes(length: usize) -> Vec { + let mut state = 0x9e37_79b9_7f4a_7c15_u64; + (0..length) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + u8::try_from(state & 0xff).unwrap_or_default() + }) + .collect() +} + +fn published(length: usize) -> (ReferenceStore, PublishedBlob) { + let bytes = deterministic_bytes(length); + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let staged = store + .stage(&mut Cursor::new(&bytes), LayoutEntryLimit::MAXIMUM) + .expect("stage"); + let blob = staged.commit(&mut store).expect("commit"); + (store, blob) +} + +/// Read-to-write through the pipeline: each verified chunk slice reaches +/// the sink without an intermediate buffer. +#[divan::bench(args = [REPRESENTATIVE_INPUT_BYTES, LARGE_INPUT_BYTES])] +fn read_to_write_pipeline(bencher: Bencher<'_, '_>, length: usize) { + let (store, blob) = published(length); + bencher.counter(BytesCount::new(length)).bench_local(|| { + let mut sink = WriteSink::new(std::io::sink()); + let receipt = transfer_layout( + black_box(&store), + blob.layout_id(), + &mut sink, + TransferBounds::new(TransferWindow::ONE, &NeverCancelled), + ) + .expect("transfer"); + black_box(receipt.bytes()) + }); +} + +/// Read-to-write the way a caller does it today: reconstruct into an owned +/// buffer, then write the buffer out. +#[divan::bench(args = [REPRESENTATIVE_INPUT_BYTES, LARGE_INPUT_BYTES])] +fn read_to_write_copy_loop(bencher: Bencher<'_, '_>, length: usize) { + let (store, blob) = published(length); + bencher.counter(BytesCount::new(length)).bench_local(|| { + let mut buffer = Vec::new(); + let _receipt = store + .reconstruct_layout(blob.layout_id(), &mut buffer) + .expect("reconstruct"); + std::io::copy(&mut Cursor::new(&buffer), &mut std::io::sink()).expect("copy"); + black_box(buffer.len()) + }); +} + +/// Copy-to-write through the pipeline: the destination stages the source's +/// verified chunks directly. +#[divan::bench(args = [REPRESENTATIVE_INPUT_BYTES, LARGE_INPUT_BYTES])] +fn copy_to_write_pipeline(bencher: Bencher<'_, '_>, length: usize) { + let (store, blob) = published(length); + bencher.counter(BytesCount::new(length)).bench_local(|| { + let mut destination = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let receipt = copy_layout( + black_box(&store), + blob.layout_id(), + &mut destination, + StagingLimits::entries(LayoutEntryLimit::MAXIMUM), + ) + .expect("copy"); + black_box(receipt.target()) + }); +} + +/// Copy-to-write the way a caller does it today: reconstruct into an owned +/// buffer, then stage the buffer. +#[divan::bench(args = [REPRESENTATIVE_INPUT_BYTES, LARGE_INPUT_BYTES])] +fn copy_to_write_copy_loop(bencher: Bencher<'_, '_>, length: usize) { + let (store, blob) = published(length); + bencher.counter(BytesCount::new(length)).bench_local(|| { + let mut destination = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let mut buffer = Vec::new(); + let _receipt = store + .reconstruct_layout(blob.layout_id(), &mut buffer) + .expect("reconstruct"); + let staged = destination + .stage(&mut Cursor::new(&buffer), LayoutEntryLimit::MAXIMUM) + .expect("stage"); + let published = staged.commit(&mut destination).expect("commit"); + black_box(published.target()) + }); +} diff --git a/docs/architecture/content-store/README.md b/docs/architecture/content-store/README.md index 1b313bf1..a1a828e0 100644 --- a/docs/architecture/content-store/README.md +++ b/docs/architecture/content-store/README.md @@ -48,6 +48,14 @@ death loses everything in it, and no port method claims otherwise. The with deduplication against the pinned catalog and publication through the catalog protocol; its receipt is `DurableIngestionReceipt`. +## Moving bytes out and between + +The [transfer pipeline](pipeline.md) moves authenticated bytes from any +`ContentReads` view into a `TransferSink` as verified segments under a +window and a cancellation signal, and `copy_layout` moves a blob between +any `TransferSource` and any `ContentStaging` destination without +buffering it. + ## The receipt law A non-durable receipt can never stand where a durable one is required. The diff --git a/docs/architecture/content-store/pipeline.md b/docs/architecture/content-store/pipeline.md new file mode 100644 index 00000000..3177f537 --- /dev/null +++ b/docs/architecture/content-store/pipeline.md @@ -0,0 +1,95 @@ +# Transfer Pipeline + +The transfer pipeline moves authenticated bytes out of any +[content-store port](README.md) view in bounded memory, and moves a blob +between two stores without buffering it. It is the adapter the read cores +already implied: they emit each verified chunk as one borrowed slice, and +the pipeline turns that slice into a segment the sink applies exactly once. + +## Read-to-write: `transfer_*` + +`transfer_layout`, `transfer_blob`, `transfer_range`, and +`transfer_layout_range` take a `ContentReads` view, a `TransferSink`, and +`TransferBounds` (a `TransferWindow` and a `CancellationSignal`). The view +runs its own read core, whose laws are unchanged: every chunk is +authenticated before its first byte is emitted, an exact layout never +substitutes another, a range emits exactly the requested bytes. The +pipeline's writer receives each emitted slice and hands it to the sink as +a `TransferSegment` (index, logical offset, borrowed bytes) without a +copy. Every `window` segments the sink is asked to `acknowledge`; after +the last segment it is asked to `complete`. The receipt carries the view's +own read receipt plus the segments, bytes, and acknowledgements the sink +took. + +The signal is consulted before every segment. Cancellation stops the +transfer with `TransferError::Cancelled { segments, bytes }`: what the +sink already applied is stated, and nothing about it is called success. A +sink refusal is `TransferError::Sink`; a view refusal is +`TransferError::Read`. No variant claims the sink is empty. + +`WriteSink` is the sink over any writer. It applies segments +exactly once in index and offset order, refusing a repeat or a gap with +`WriteSinkError::OutOfOrder`, and flushes on every acknowledgement. A +caller with a stricter durability standard implements `TransferSink` +itself. + +## Copy-to-write: `copy_layout` + +`copy_layout(source, layout_id, destination, limits)` copies one +committed layout from a `TransferSource` into a `ContentStaging` +destination. The source streams the layout's chunks through a pull reader +that looks each chunk up by identity in the view's own immutable bytes and +authenticates it as it is first served; a chunk that does not hash to its +identity stops the stream at that chunk with +`TransferSourceError::ChunkIdentityMismatch`. The destination stages the +stream with `stage_expected`, so it recomputes the complete identity and +refuses a mismatch before anything becomes visible, then commits. The +blob is never held whole: the source lends one chunk at a time and the +destination holds its own staging scratch. + +`ReferenceStore` and `DurableSnapshot` implement `TransferSource`; +`ReferenceStore` and `DurableWriter` implement `ContentStaging`; every +pairing copies. + +## Evidence + +- `src/adapters/pipeline/tests.rs`: every verified slice reaches the sink + in order; ranges transfer exactly; a window of one acknowledges every + segment; a sink that fails mid-window and a cancellation each yield no + receipt while the partial prefix is exactly what the sink applied; a + write sink refuses out-of-order and repeated segments; copy between + reference stores round-trips; an absent layout and a staging refusal + commit nothing. +- `src/adapters/durable/transfer_tests.rs`: a durable snapshot copies + into a reference store and transfers to a sink; a reference store copies + into a durable writer. +- `src/reference/chunk_reader.rs`: the pull reader serves every chunk + across small reads and refuses at the boundary of a chunk that does not + hash to its identity. +- `tests/transfer_pipeline_memory.rs`, against the library as shipped: + read-to-write allocates nothing beyond the sink for a 1 MiB blob + (`AllocationInfo::default()`), and copy-to-write allocates fewer total + and fewer peak bytes than a caller-owned copy loop that reconstructs + into a buffer and stages the buffer. +- `benches/transfer_pipeline.rs` (`cargo bench --bench transfer_pipeline`) + times read-to-write and copy-to-write through the pipeline against the + caller-owned copy loop at 1 MiB and 4 MiB. On the authoring machine + (2026-09-30, divan, 100 samples) read-to-write medians were 5.96 ms + against 6.54 ms at 1 MiB and 26.33 ms against 26.66 ms at 4 MiB; + copy-to-write medians were 7.74 ms against 7.90 ms at 1 MiB and + 32.38 ms against 31.76 ms at 4 MiB. The allocation advantage is + established by the memory test; the CPU advantage is within the run's + noise and is not claimed. + +## Still owed + +- A CPU advantage over the caller-owned copy loop: the benchmark exists + and its numbers above are within noise, so the acceptance criterion's + "lower CPU" is not established, only "no worse" and "less allocation". +- A profile mismatch between stores cannot be exercised while one storage + profile is registered; the destination's own chunking decides its + layout, so the copy receipt's layout may differ from the source's. +- The Worldline copy through the pipeline is owed with the durable + Worldline run (T-24.2). +- Multi-threaded pipelines stay out of scope until a bounded-memory proof + exists. diff --git a/src/adapters/durable/mod.rs b/src/adapters/durable/mod.rs index 5081fa8f..b30878e1 100644 --- a/src/adapters/durable/mod.rs +++ b/src/adapters/durable/mod.rs @@ -28,6 +28,9 @@ mod store; mod test_fixture; #[cfg(test)] mod tests; +mod transfer_source; +#[cfg(test)] +mod transfer_tests; mod view; mod writer; mod writer_sink; diff --git a/src/adapters/durable/snapshot.rs b/src/adapters/durable/snapshot.rs index 9548e764..5ea793c0 100644 --- a/src/adapters/durable/snapshot.rs +++ b/src/adapters/durable/snapshot.rs @@ -33,10 +33,18 @@ pub struct DurableSnapshot { } /// The pinned catalog as a chunk source: every chunk record's exact payload. -struct CatalogChunks<'snapshot, 'head, 'catalog, 'records> { +pub(super) struct CatalogChunks<'snapshot, 'head, 'catalog, 'records> { catalog: &'snapshot CatalogSnapshot<'head, 'catalog, 'records>, } +impl<'snapshot, 'head, 'catalog, 'records> CatalogChunks<'snapshot, 'head, 'catalog, 'records> { + pub(super) const fn new( + catalog: &'snapshot CatalogSnapshot<'head, 'catalog, 'records>, + ) -> Self { + Self { catalog } + } +} + impl ChunkSource for CatalogChunks<'_, '_, '_, '_> { fn chunk(&self, identity: ChunkId) -> Option<&[u8]> { self.catalog @@ -184,14 +192,14 @@ impl DurableSnapshot { .ok_or(DurableReadError::BlobMissing { requested: target }) } - fn catalog(&self) -> Result, DurableReadError> { + pub(super) fn catalog(&self) -> Result, DurableReadError> { self.view .catalog() .snapshot() .map_err(|source| DurableReadError::View(Box::new(source))) } - fn layout( + pub(super) fn layout( &self, catalog: &CatalogSnapshot<'_, '_, '_>, layout_id: LayoutId, diff --git a/src/adapters/durable/transfer_source.rs b/src/adapters/durable/transfer_source.rs new file mode 100644 index 00000000..57356aca --- /dev/null +++ b/src/adapters/durable/transfer_source.rs @@ -0,0 +1,31 @@ +//! The durable snapshot as a transfer source. + +use super::snapshot::CatalogChunks; +use super::{DurableReadError, DurableSnapshot}; +use crate::LayoutId; +use crate::adapters::{StreamConsumer, TransferSource, TransferSourceError}; +use crate::reference::ChunkReader; + +impl TransferSource for DurableSnapshot { + fn stream_layout( + &self, + layout_id: LayoutId, + consume: StreamConsumer<'_>, + ) -> Result<(), TransferSourceError> { + let catalog = self.catalog().map_err(view_error)?; + let layout = self.layout(&catalog, layout_id).map_err(view_error)?; + let chunks = CatalogChunks::new(&catalog); + let mut reader = ChunkReader::new(&chunks, layout_id, &layout); + consume(layout.target(), &mut reader); + reader.transfer_refusal().map_or(Ok(()), Err) + } +} + +fn view_error(error: DurableReadError) -> TransferSourceError { + match error { + DurableReadError::LayoutMissing { requested } => { + TransferSourceError::LayoutMissing { requested } + } + other => TransferSourceError::View(Box::new(other)), + } +} diff --git a/src/adapters/durable/transfer_tests.rs b/src/adapters/durable/transfer_tests.rs new file mode 100644 index 00000000..19ce54c5 --- /dev/null +++ b/src/adapters/durable/transfer_tests.rs @@ -0,0 +1,85 @@ +//! Copy laws across backends: a durable snapshot streams a committed +//! layout into a reference store, and a reference store streams into a +//! durable writer, each without buffering the blob and each verified end +//! to end. + +use std::error::Error; +use std::io::Cursor; + +use super::test_fixture::{durable_store, long_content}; +use super::{DurableStore, DurableWriter}; +use crate::adapters::pipeline::{ + NeverCancelled, TransferBounds, TransferWindow, WriteSink, transfer_layout, +}; +use crate::adapters::retention::filesystem_retention_test_fixture::{ + catalog_policy, migrated_store, +}; +use crate::adapters::{FilesystemVersionTwoAdmission, ReaderAttemptLimit, copy_layout}; +use crate::{LayoutEntryLimit, ReferenceStore, ReferenceStoreCapacity, StagingLimits}; + +#[test] +fn a_durable_snapshot_copies_into_a_reference_store_and_transfers_to_a_sink() +-> Result<(), Box> { + let long = long_content(); + let contents: [&[u8]; 2] = [&long, b"unanchored"]; + let (sandbox, published) = durable_store("durable-copy-out", &contents)?; + let anchored = published.first().ok_or("anchored blob")?; + let snapshot = DurableStore::open( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + ) + .snapshot()?; + let mut destination = ReferenceStore::new(ReferenceStoreCapacity::new(4 * 1024 * 1024)); + let receipt = copy_layout( + &snapshot, + anchored.layout, + &mut destination, + StagingLimits::entries(LayoutEntryLimit::MAXIMUM), + )?; + assert_eq!(receipt.target(), anchored.target); + let mut output = Vec::new(); + let _read = destination.reconstruct(anchored.target, &mut output)?; + assert_eq!(output, long); + let mut sink = WriteSink::new(Vec::new()); + let transfer = transfer_layout( + &snapshot, + anchored.layout, + &mut sink, + TransferBounds::new(TransferWindow::ONE, &NeverCancelled), + )?; + assert_eq!(sink.into_inner(), long); + assert_eq!(transfer.read().receipt().target(), anchored.target); + assert_eq!(transfer.acknowledgements(), transfer.segments()); + Ok(()) +} + +#[test] +fn a_reference_store_copies_into_a_durable_writer() -> Result<(), Box> { + let sandbox = migrated_store("durable-copy-in")?; + let long = long_content(); + let mut source = ReferenceStore::new(ReferenceStoreCapacity::new(4 * 1024 * 1024)); + let staged = source.stage(&mut Cursor::new(&long), LayoutEntryLimit::MAXIMUM)?; + let blob = staged.commit(&mut source)?; + let admission = FilesystemVersionTwoAdmission::reopen_unchecked_for_tests(sandbox.path())?; + let mut writer = DurableWriter::open(admission, sandbox.path(), catalog_policy()?)?; + let receipt = copy_layout( + &source, + blob.layout_id(), + &mut writer, + StagingLimits::entries(LayoutEntryLimit::MAXIMUM), + )?; + assert_eq!(receipt.target(), blob.target()); + assert_eq!(receipt.layout_id(), blob.layout_id()); + drop(writer); + let snapshot = DurableStore::open( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + ) + .snapshot()?; + let mut output = Vec::new(); + let _read = snapshot.reconstruct_layout(receipt.layout_id(), &mut output)?; + assert_eq!(output, long); + Ok(()) +} diff --git a/src/adapters/exports.rs b/src/adapters/exports.rs index 3d3eb9aa..e9af8313 100644 --- a/src/adapters/exports.rs +++ b/src/adapters/exports.rs @@ -68,6 +68,7 @@ pub use super::layout_id_binary_error::LayoutIdBinaryParseError; pub use super::layout_id_text_error::LayoutIdTextParseError; pub use super::layout_record::CanonicalLayoutRecord; pub use super::opened_reusable_segment::OpenedReusableSegment; +pub use super::pipeline::*; pub use super::publication_head_decode_error::PublicationHeadDecodeError; pub use super::recovery::*; #[cfg(feature = "repository-tasks")] diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index dedbb609..a80b69a2 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -156,6 +156,7 @@ mod loaded_segment; mod lower_hex; mod opened_reusable_segment; mod physical_pool_name; +mod pipeline; mod publication_head_decode_error; mod publication_head_decode_error_display; mod publication_head_decoder; diff --git a/src/adapters/pipeline/cancellation.rs b/src/adapters/pipeline/cancellation.rs new file mode 100644 index 00000000..6ab4a963 --- /dev/null +++ b/src/adapters/pipeline/cancellation.rs @@ -0,0 +1,50 @@ +//! This boundary module owns cancellation: a signal the transfer consults +//! before every segment. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Whether a transfer should stop before its next segment. +pub trait CancellationSignal { + /// True once the transfer must stop. + fn is_cancelled(&self) -> bool; +} + +/// A transfer that runs to completion or failure. +#[derive(Clone, Copy, Debug, Default)] +pub struct NeverCancelled; + +impl CancellationSignal for NeverCancelled { + fn is_cancelled(&self) -> bool { + false + } +} + +impl CancellationSignal for AtomicBool { + fn is_cancelled(&self) -> bool { + self.load(Ordering::Acquire) + } +} + +/// A shareable flag: clone it into whatever may cancel the transfer. +#[derive(Clone, Debug, Default)] +pub struct CancellationFlag(Arc); + +impl CancellationFlag { + /// A flag that is not yet raised. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Raises the flag; the transfer stops before its next segment. + pub fn cancel(&self) { + self.0.store(true, Ordering::Release); + } +} + +impl CancellationSignal for CancellationFlag { + fn is_cancelled(&self) -> bool { + self.0.is_cancelled() + } +} diff --git a/src/adapters/pipeline/copy.rs b/src/adapters/pipeline/copy.rs new file mode 100644 index 00000000..5eb538b6 --- /dev/null +++ b/src/adapters/pipeline/copy.rs @@ -0,0 +1,114 @@ +//! This boundary module owns copying one blob between stores without +//! buffering it. + +use std::error::Error; +use std::fmt; + +use super::{TransferSource, TransferSourceError}; +use crate::{BlobId, CommitReceipt, ContentStaging, LayoutId, StagedContent, StagingLimits}; + +/// What one copy established: the identity the destination committed. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[must_use = "the receipt records the identity the destination committed"] +pub struct CopyReceipt { + target: BlobId, + layout_id: LayoutId, +} + +impl CopyReceipt { + /// The blob the destination committed; equal to the source's. + #[must_use] + pub const fn target(self) -> BlobId { + self.target + } + + /// The layout the destination committed under; the destination's own + /// chunking decides it. + #[must_use] + pub const fn layout_id(self) -> LayoutId { + self.layout_id + } +} + +/// Copies the source's committed layout `layout_id` into `destination`. +/// +/// The source streams its chunks, authenticating each as it is served; +/// the destination stages the stream under `limits` with +/// `stage_expected`, so the complete identity is verified before anything +/// becomes visible, and commits. No more than the destination's own +/// staging scratch holds the blob at any point. +/// +/// # Errors +/// +/// Returns [`CopyError`] at the exact source, staging, or commit refusal; +/// nothing becomes visible on failure. +pub fn copy_layout( + source: &S, + layout_id: LayoutId, + destination: &mut D, + limits: StagingLimits, +) -> Result +where + S: TransferSource + ?Sized, + D: ContentStaging, +{ + let mut outcome = None; + source + .stream_layout(layout_id, &mut |target, reader| { + outcome = Some(stage_and_commit(destination, reader, target, limits)); + }) + .map_err(|source| CopyError::Source(Box::new(source)))?; + outcome.unwrap_or(Err(CopyError::NotStreamed)) +} + +fn stage_and_commit( + destination: &mut D, + reader: &mut dyn std::io::Read, + target: BlobId, + limits: StagingLimits, +) -> Result { + let staged = destination + .stage_expected(reader, target, limits) + .map_err(|source| CopyError::Stage(Box::new(source)))?; + let receipt = staged + .commit() + .map_err(|source| CopyError::Commit(Box::new(source)))?; + Ok(CopyReceipt { + target: receipt.target(), + layout_id: receipt.layout_id(), + }) +} + +/// Why a copy committed nothing. +#[derive(Debug)] +pub enum CopyError { + /// The source refused before or while streaming. + Source(Box), + /// The destination refused the staging. + Stage(Box), + /// The destination refused the commit. + Commit(Box), + /// The source returned without calling the consumer. + NotStreamed, +} + +impl fmt::Display for CopyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Source(source) => write!(formatter, "source: {source}"), + Self::Stage(source) => write!(formatter, "destination staging: {source}"), + Self::Commit(source) => write!(formatter, "destination commit: {source}"), + Self::NotStreamed => formatter.write_str("the source streamed nothing"), + } + } +} + +impl Error for CopyError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Source(source) => Some(source.as_ref()), + Self::Stage(source) | Self::Commit(source) => Some(source.as_ref()), + Self::NotStreamed => None, + } + } +} diff --git a/src/adapters/pipeline/error.rs b/src/adapters/pipeline/error.rs new file mode 100644 index 00000000..cfc740b9 --- /dev/null +++ b/src/adapters/pipeline/error.rs @@ -0,0 +1,47 @@ +//! This boundary module owns why a transfer returned no receipt. + +use std::error::Error; +use std::fmt; + +/// Why a transfer returned no receipt. Partial output may have reached the +/// sink; none of these variants claims otherwise. +#[derive(Debug)] +pub enum TransferError { + /// The view refused the read before or during emission. + Read(Box), + /// The sink refused a segment, an acknowledgement, or completion. + Sink(E), + /// The signal was raised before a segment. + Cancelled { + /// Segments applied before the signal was observed. + segments: u64, + /// Bytes applied before the signal was observed. + bytes: u64, + }, + /// The segment count or byte count is not representable. + Accounting, +} + +impl fmt::Display for TransferError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Read(source) => write!(formatter, "the view refused: {source}"), + Self::Sink(source) => write!(formatter, "the sink refused: {source}"), + Self::Cancelled { segments, bytes } => write!( + formatter, + "cancelled after {segments} segments and {bytes} bytes" + ), + Self::Accounting => formatter.write_str("the transfer's accounting overflowed"), + } + } +} + +impl Error for TransferError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Read(source) => Some(source.as_ref()), + Self::Sink(source) => Some(source), + Self::Cancelled { .. } | Self::Accounting => None, + } + } +} diff --git a/src/adapters/pipeline/mod.rs b/src/adapters/pipeline/mod.rs new file mode 100644 index 00000000..3e79941a --- /dev/null +++ b/src/adapters/pipeline/mod.rs @@ -0,0 +1,37 @@ +//! The bounded streaming write-through pipeline. +//! +//! `transfer_layout`, `transfer_blob`, `transfer_range`, and +//! `transfer_layout_range` move authenticated bytes from any `ContentReads` +//! view into a `TransferSink` as verified segments: each segment is the +//! read core's own borrowed slice of an immutable chunk, handed over +//! without a copy, applied exactly once in order, and acknowledged every +//! `TransferWindow` segments. A `CancellationSignal` is consulted before +//! every segment, and cancellation never upgrades partial output into a +//! receipt. +//! +//! `copy_layout` moves a blob between stores without buffering it: a +//! `TransferSource` streams the layout's chunks through a pull reader that +//! authenticates each chunk as it is served, and the destination's own +//! staging recomputes and checks the complete identity. + +mod cancellation; +mod copy; +mod error; +mod receipt; +mod sink; +mod source; +#[cfg(test)] +mod tests; +mod transfer; +mod window; + +pub use cancellation::{CancellationFlag, CancellationSignal, NeverCancelled}; +pub use copy::{CopyError, CopyReceipt, copy_layout}; +pub use error::TransferError; +pub use receipt::TransferReceipt; +pub use sink::{TransferSegment, TransferSink, WriteSink, WriteSinkError}; +pub use source::{StreamConsumer, TransferSource, TransferSourceError}; +pub use transfer::{ + TransferBounds, transfer_blob, transfer_layout, transfer_layout_range, transfer_range, +}; +pub use window::TransferWindow; diff --git a/src/adapters/pipeline/receipt.rs b/src/adapters/pipeline/receipt.rs new file mode 100644 index 00000000..c0f97fbd --- /dev/null +++ b/src/adapters/pipeline/receipt.rs @@ -0,0 +1,61 @@ +//! This boundary module owns the transfer receipt. + +use super::TransferWindow; + +/// What one completed transfer established: the read receipt the view +/// returned, and the segments, bytes, and acknowledgements the sink took. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[must_use = "the receipt records the authenticated read and the sink's accounting"] +pub struct TransferReceipt { + read: R, + segments: u64, + bytes: u64, + acknowledgements: u64, + window: TransferWindow, +} + +impl TransferReceipt { + pub(super) const fn new( + read: R, + segments: u64, + bytes: u64, + acknowledgements: u64, + window: TransferWindow, + ) -> Self { + Self { + read, + segments, + bytes, + acknowledgements, + window, + } + } + + /// The view's own receipt for the authenticated read. + pub const fn read(&self) -> R { + self.read + } + + /// Segments applied to the sink. + #[must_use] + pub const fn segments(&self) -> u64 { + self.segments + } + + /// Bytes applied to the sink. + #[must_use] + pub const fn bytes(&self) -> u64 { + self.bytes + } + + /// Acknowledgements the sink gave, one per full or final window. + #[must_use] + pub const fn acknowledgements(&self) -> u64 { + self.acknowledgements + } + + /// The window the transfer ran under. + pub const fn window(&self) -> TransferWindow { + self.window + } +} diff --git a/src/adapters/pipeline/sink.rs b/src/adapters/pipeline/sink.rs new file mode 100644 index 00000000..ccb17fee --- /dev/null +++ b/src/adapters/pipeline/sink.rs @@ -0,0 +1,211 @@ +//! This boundary module owns the sink side: segments, the exactly-once +//! sink contract, and the sink over any `Write`. + +use std::error::Error; +use std::fmt; +use std::io::{self, Write}; + +/// One verified segment: a borrowed slice of an immutable chunk, with its +/// position in the transfer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TransferSegment<'bytes> { + index: u64, + offset: u64, + bytes: &'bytes [u8], +} + +impl<'bytes> TransferSegment<'bytes> { + pub(super) const fn new(index: u64, offset: u64, bytes: &'bytes [u8]) -> Self { + Self { + index, + offset, + bytes, + } + } + + /// The segment's ordinal in the transfer, from zero. + #[must_use] + pub const fn index(self) -> u64 { + self.index + } + + /// The logical offset of the first byte. + #[must_use] + pub const fn offset(self) -> u64 { + self.offset + } + + /// The verified bytes. + #[must_use] + pub const fn bytes(self) -> &'bytes [u8] { + self.bytes + } +} + +/// Where a transfer applies its segments. +/// +/// The transfer applies segments in index order, each exactly once, +/// acknowledges every window, and completes once after the last segment. +/// A sink refuses any other sequence. +pub trait TransferSink { + /// The sink's refusal. + type Error: Error + 'static; + + /// Applies one segment. + /// + /// # Errors + /// + /// Returns the sink's refusal; the transfer stops without a receipt. + fn apply(&mut self, segment: TransferSegment<'_>) -> Result<(), Self::Error>; + + /// Makes every applied segment of the window durable to the sink's + /// own standard. + /// + /// # Errors + /// + /// As [`Self::apply`]. + fn acknowledge(&mut self) -> Result<(), Self::Error>; + + /// Finishes the transfer after the last acknowledgement. + /// + /// # Errors + /// + /// As [`Self::apply`]. + fn complete(&mut self) -> Result<(), Self::Error>; +} + +/// A sink over any writer, applying each segment exactly once in order and +/// flushing on every acknowledgement. +#[must_use] +#[derive(Debug)] +pub struct WriteSink { + inner: W, + next_index: u64, + offset: u64, +} + +impl WriteSink { + /// Wraps `inner`, expecting the first segment next. + pub const fn new(inner: W) -> Self { + Self { + inner, + next_index: 0, + offset: 0, + } + } + + /// The writer, with everything applied so far. + pub fn into_inner(self) -> W { + self.inner + } + + /// Segments applied so far. + #[must_use] + pub const fn applied(&self) -> u64 { + self.next_index + } +} + +impl TransferSink for WriteSink { + type Error = WriteSinkError; + + fn apply(&mut self, segment: TransferSegment<'_>) -> Result<(), WriteSinkError> { + if segment.index() != self.next_index || segment.offset() != self.offset { + return Err(WriteSinkError::OutOfOrder { + expected_index: self.next_index, + observed_index: segment.index(), + expected_offset: self.offset, + observed_offset: segment.offset(), + }); + } + let index = segment.index(); + self.inner + .write_all(segment.bytes()) + .map_err(|source| WriteSinkError::Write { index, source })?; + let length = u64::try_from(segment.bytes().len()) + .map_err(|_source| WriteSinkError::Accounting { index })?; + self.offset = self + .offset + .checked_add(length) + .ok_or(WriteSinkError::Accounting { index })?; + self.next_index = self + .next_index + .checked_add(1) + .ok_or(WriteSinkError::Accounting { index })?; + Ok(()) + } + + fn acknowledge(&mut self) -> Result<(), WriteSinkError> { + self.inner + .flush() + .map_err(|source| WriteSinkError::Flush { source }) + } + + fn complete(&mut self) -> Result<(), WriteSinkError> { + self.acknowledge() + } +} + +/// Why a [`WriteSink`] refused. +#[derive(Debug)] +pub enum WriteSinkError { + /// A segment arrived out of order or twice. + OutOfOrder { + /// The index the sink expected. + expected_index: u64, + /// The index that arrived. + observed_index: u64, + /// The offset the sink expected. + expected_offset: u64, + /// The offset that arrived. + observed_offset: u64, + }, + /// The writer refused the segment. + Write { + /// The segment being written. + index: u64, + /// The exact failure. + source: io::Error, + }, + /// The writer refused to flush. + Flush { + /// The exact failure. + source: io::Error, + }, + /// The segment count or offset is not representable. + Accounting { + /// The segment being accounted. + index: u64, + }, +} + +impl fmt::Display for WriteSinkError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::OutOfOrder { + expected_index, + observed_index, + expected_offset, + observed_offset, + } => write!( + formatter, + "segment {observed_index} at {observed_offset} arrived where segment \ + {expected_index} at {expected_offset} was expected" + ), + Self::Write { index, .. } => write!(formatter, "writing segment {index} failed"), + Self::Flush { .. } => formatter.write_str("flushing the sink failed"), + Self::Accounting { index } => { + write!(formatter, "segment {index} overflows the sink's accounting") + } + } + } +} + +impl Error for WriteSinkError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Write { source, .. } | Self::Flush { source } => Some(source), + Self::OutOfOrder { .. } | Self::Accounting { .. } => None, + } + } +} diff --git a/src/adapters/pipeline/source.rs b/src/adapters/pipeline/source.rs new file mode 100644 index 00000000..440b7622 --- /dev/null +++ b/src/adapters/pipeline/source.rs @@ -0,0 +1,110 @@ +//! This boundary module owns the pull side: a source that streams one +//! layout's chunks, authenticated as they are served, into a consumer. + +use std::error::Error; +use std::fmt; +use std::io::Read; + +use crate::{BlobId, ChunkId, LayoutId}; + +mod sealed { + pub trait Sealed {} + + impl Sealed for crate::ReferenceStore {} + impl Sealed for crate::adapters::DurableSnapshot {} +} + +/// The consumer of one streamed layout: the blob it identifies and a +/// reader serving its bytes in order. +pub type StreamConsumer<'call> = &'call mut dyn FnMut(BlobId, &mut dyn Read); + +/// A view that can stream one committed layout's chunks without +/// materializing the blob. +/// +/// Implemented by `ReferenceStore` and `DurableSnapshot`. The reader +/// authenticates each chunk against its identity as it is first served; +/// the complete identity is the consumer's to verify, which +/// `ContentStaging::stage_expected` does. +pub trait TransferSource: sealed::Sealed { + /// Streams `layout_id` into `consume`, called exactly once. + /// + /// # Errors + /// + /// Returns [`TransferSourceError`] when the layout is absent, the view + /// refuses, or a chunk refused while being served; in the last case + /// the consumer already observed the read failure. + fn stream_layout( + &self, + layout_id: LayoutId, + consume: StreamConsumer<'_>, + ) -> Result<(), TransferSourceError>; +} + +/// Why a source did not stream, or stopped streaming, a layout. +#[derive(Debug)] +pub enum TransferSourceError { + /// The view names no such layout. + LayoutMissing { + /// The requested layout. + requested: LayoutId, + }, + /// The view could not be read. + View(Box), + /// The view holds no chunk under the identity. + ChunkMissing { + /// The layout being served. + layout: LayoutId, + /// The entry index. + index: usize, + /// The absent chunk. + requested: ChunkId, + }, + /// The chunk's bytes do not hash to its identity. + ChunkIdentityMismatch { + /// The layout being served. + layout: LayoutId, + /// The entry index. + index: usize, + /// The identity the layout names. + expected: ChunkId, + /// The identity the bytes have. + observed: ChunkId, + }, + /// The chunk could not be hashed. + ChunkHash { + /// The layout being served. + layout: LayoutId, + /// The entry index. + index: usize, + /// The exact failure. + source: crate::ChunkHashError, + }, +} + +impl fmt::Display for TransferSourceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LayoutMissing { .. } => formatter.write_str("the view names no such layout"), + Self::View(source) => write!(formatter, "the view refused: {source}"), + Self::ChunkMissing { index, .. } => { + write!(formatter, "chunk {index} is absent from the view") + } + Self::ChunkIdentityMismatch { index, .. } => { + write!(formatter, "chunk {index} does not hash to its identity") + } + Self::ChunkHash { index, .. } => write!(formatter, "chunk {index} could not be hashed"), + } + } +} + +impl Error for TransferSourceError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::View(source) => Some(source.as_ref()), + Self::ChunkHash { source, .. } => Some(source), + Self::LayoutMissing { .. } + | Self::ChunkMissing { .. } + | Self::ChunkIdentityMismatch { .. } => None, + } + } +} diff --git a/src/adapters/pipeline/tests.rs b/src/adapters/pipeline/tests.rs new file mode 100644 index 00000000..151aa3b5 --- /dev/null +++ b/src/adapters/pipeline/tests.rs @@ -0,0 +1,322 @@ +//! Pipeline laws over the reference backend: read-to-write transfers every +//! verified slice as the read core's own borrowed bytes; ranges transfer +//! exactly; +//! a window of one acknowledges every segment; a sink failure and a +//! cancellation each yield no receipt; a write sink applies segments +//! exactly once in order; copy-to-write moves a blob between stores while +//! allocating less than a caller-owned copy loop. + +use std::error::Error; +use std::io::{self, Cursor, Write}; + +use allocation_counter::measure; + +use super::{ + CancellationFlag, CopyError, NeverCancelled, TransferBounds, TransferError, TransferSegment, + TransferSink, TransferSourceError, TransferWindow, WriteSink, WriteSinkError, copy_layout, + transfer_layout, transfer_layout_range, transfer_range, +}; +use crate::{ + ByteLength, ByteOffset, ByteRange, FastCdc, LayoutEntryLimit, PublishedBlob, ReferenceStore, + ReferenceStoreCapacity, StagingLimits, +}; + +const CAPACITY: usize = 8 * 1024 * 1024; + +fn content(length: usize) -> Vec { + let mut state = 0x0f1e_2d3c_4b5a_6978_u64; + (0..length) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + u8::try_from(state & 0xff).unwrap_or_default() + }) + .collect() +} + +fn published(bytes: &[u8]) -> Result<(ReferenceStore, PublishedBlob), Box> { + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let staged = store.stage(&mut Cursor::new(bytes), LayoutEntryLimit::MAXIMUM)?; + let receipt = staged.commit(&mut store)?; + Ok((store, receipt)) +} + +fn span_count(bytes: &[u8]) -> Result> { + let mut detector = FastCdc::new(); + let mut count = 0_usize; + detector.feed(bytes, |_span| count = count.saturating_add(1))?; + if detector.finish()?.is_some() { + count = count.saturating_add(1); + } + Ok(count) +} + +fn window(segments: u32) -> Result> { + TransferWindow::new(segments).ok_or_else(|| "zero window".into()) +} + +#[test] +fn read_to_write_transfers_every_verified_slice_in_order() -> Result<(), Box> { + let bytes = content(400 * 1024); + let (store, blob) = published(&bytes)?; + let entries = u64::try_from(span_count(&bytes)?)?; + let mut sink = WriteSink::new(Vec::with_capacity(bytes.len())); + let receipt = transfer_layout( + &store, + blob.layout_id(), + &mut sink, + TransferBounds::new(window(3)?, &NeverCancelled), + )?; + assert_eq!(sink.into_inner(), bytes); + assert_eq!(receipt.segments(), entries); + assert_eq!(receipt.bytes(), u64::try_from(bytes.len())?); + assert_eq!(receipt.acknowledgements(), entries.div_ceil(3)); + assert_eq!(receipt.read().target(), blob.target()); + Ok(()) +} + +#[test] +fn ranges_transfer_exactly_the_requested_bytes() -> Result<(), Box> { + let bytes = content(300 * 1024); + let (store, blob) = published(&bytes)?; + let requested = ByteRange::new(ByteOffset::new(70_000), ByteLength::new(150_000))?; + let mut sink = WriteSink::new(Vec::new()); + let receipt = transfer_range( + &store, + blob.target(), + requested, + &mut sink, + TransferBounds::new(window(2)?, &NeverCancelled), + )?; + assert_eq!( + Some(sink.into_inner().as_slice()), + bytes.get(70_000..220_000) + ); + assert_eq!(receipt.bytes(), 150_000); + let mut exact = WriteSink::new(Vec::new()); + let _receipt = transfer_layout_range( + &store, + blob.layout_id(), + requested, + &mut exact, + TransferBounds::new(window(2)?, &NeverCancelled), + )?; + assert_eq!( + Some(exact.into_inner().as_slice()), + bytes.get(70_000..220_000) + ); + Ok(()) +} + +#[test] +fn a_window_of_one_acknowledges_every_segment() -> Result<(), Box> { + let bytes = content(300 * 1024); + let (store, blob) = published(&bytes)?; + let mut sink = WriteSink::new(Vec::new()); + let receipt = transfer_layout( + &store, + blob.layout_id(), + &mut sink, + TransferBounds::new(TransferWindow::ONE, &NeverCancelled), + )?; + assert_eq!(receipt.acknowledgements(), receipt.segments()); + assert!(receipt.segments() > 1); + Ok(()) +} + +struct FailAfter { + written: usize, + after: usize, +} + +impl Write for FailAfter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if self.written >= self.after { + return Err(io::Error::other("the sink went away")); + } + self.written = self.written.saturating_add(bytes.len()); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[test] +fn a_sink_that_fails_mid_window_yields_no_receipt() -> Result<(), Box> { + let bytes = content(300 * 1024); + let (store, blob) = published(&bytes)?; + let mut sink = WriteSink::new(FailAfter { + written: 0, + after: 100_000, + }); + let refusal = transfer_layout( + &store, + blob.layout_id(), + &mut sink, + TransferBounds::new(window(4)?, &NeverCancelled), + ); + assert!(matches!( + refusal, + Err(TransferError::Sink(WriteSinkError::Write { index, .. })) if index >= 1 + )); + assert!(sink.applied() >= 1); + Ok(()) +} + +struct CancelAfter { + inner: WriteSink>, + flag: CancellationFlag, + after: u64, +} + +impl TransferSink for CancelAfter { + type Error = WriteSinkError; + + fn apply(&mut self, segment: TransferSegment<'_>) -> Result<(), WriteSinkError> { + self.inner.apply(segment)?; + if self.inner.applied() >= self.after { + self.flag.cancel(); + } + Ok(()) + } + + fn acknowledge(&mut self) -> Result<(), WriteSinkError> { + self.inner.acknowledge() + } + + fn complete(&mut self) -> Result<(), WriteSinkError> { + self.inner.complete() + } +} + +#[test] +fn cancellation_stops_before_the_next_segment_and_never_becomes_success() +-> Result<(), Box> { + let bytes = content(300 * 1024); + let (store, blob) = published(&bytes)?; + let flag = CancellationFlag::new(); + let mut sink = CancelAfter { + inner: WriteSink::new(Vec::new()), + flag: flag.clone(), + after: 2, + }; + let refusal = transfer_layout( + &store, + blob.layout_id(), + &mut sink, + TransferBounds::new(window(8)?, &flag), + ); + let Err(TransferError::Cancelled { + segments, + bytes: applied, + }) = refusal + else { + return Err("expected cancellation".into()); + }; + assert_eq!(segments, 2); + let partial = sink.inner.into_inner(); + assert_eq!(u64::try_from(partial.len())?, applied); + assert!(partial.len() < bytes.len()); + assert_eq!(Some(partial.as_slice()), bytes.get(..partial.len())); + Ok(()) +} + +#[test] +fn a_write_sink_applies_segments_exactly_once_in_order() { + let mut sink = WriteSink::new(Vec::new()); + let first = TransferSegment::new(0, 0, b"abc"); + assert!(sink.apply(first).is_ok()); + assert!(matches!( + sink.apply(first), + Err(WriteSinkError::OutOfOrder { + expected_index: 1, + observed_index: 0, + .. + }) + )); + assert!(matches!( + sink.apply(TransferSegment::new(1, 0, b"d")), + Err(WriteSinkError::OutOfOrder { + expected_offset: 3, + observed_offset: 0, + .. + }) + )); + assert!(sink.apply(TransferSegment::new(1, 3, b"d")).is_ok()); + assert_eq!(sink.into_inner(), b"abcd"); +} + +#[test] +fn copy_to_write_moves_a_blob_between_stores_allocating_less_than_a_copy_loop() +-> Result<(), Box> { + let bytes = content(600 * 1024); + let (source, blob) = published(&bytes)?; + let limits = StagingLimits::entries(LayoutEntryLimit::MAXIMUM); + let mut piped = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let mut copy = None; + let pipeline = measure(|| { + copy = Some(copy_layout(&source, blob.layout_id(), &mut piped, limits)); + }); + let receipt = copy.ok_or("no outcome")??; + assert_eq!(receipt.target(), blob.target()); + let mut output = Vec::new(); + let _read = piped.reconstruct(receipt.target(), &mut output)?; + assert_eq!(output, bytes); + + let mut looped = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let mut loop_outcome = None; + let copy_loop = measure(|| { + loop_outcome = Some(caller_owned_copy_loop(&source, blob, &mut looped, limits)); + }); + loop_outcome.ok_or("no outcome")??; + assert!( + pipeline.bytes_total < copy_loop.bytes_total, + "pipeline {} bytes, copy loop {} bytes", + pipeline.bytes_total, + copy_loop.bytes_total + ); + Ok(()) +} + +/// What a caller does today: reconstruct into an owned buffer, then stage +/// that buffer into the destination. +fn caller_owned_copy_loop( + source: &ReferenceStore, + blob: PublishedBlob, + destination: &mut ReferenceStore, + limits: StagingLimits, +) -> Result<(), Box> { + let mut buffer = Vec::new(); + let _read = source.reconstruct_layout(blob.layout_id(), &mut buffer)?; + let staged = destination.stage_bounded(&mut Cursor::new(&buffer), limits)?; + let _receipt = staged.commit(destination)?; + Ok(()) +} + +#[test] +fn a_copy_of_an_absent_layout_and_a_wrong_identity_commit_nothing() -> Result<(), Box> { + let bytes = content(300 * 1024); + let (source, blob) = published(&bytes)?; + let mut destination = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let limits = StagingLimits::entries(LayoutEntryLimit::MAXIMUM); + let absent = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let refusal = copy_layout(&absent, blob.layout_id(), &mut destination, limits); + let Err(CopyError::Source(refused)) = refusal else { + return Err("expected a source refusal".into()); + }; + assert!(matches!( + *refused, + TransferSourceError::LayoutMissing { requested } if requested == blob.layout_id() + )); + let tight = StagingLimits::entries(LayoutEntryLimit::new(1)?); + let refusal = copy_layout(&source, blob.layout_id(), &mut destination, tight); + assert!( + matches!(refusal, Err(CopyError::Stage(_))), + "expected a staging refusal, got {refusal:?}" + ); + assert!(!destination.contains_blob(blob.target())); + Ok(()) +} diff --git a/src/adapters/pipeline/transfer.rs b/src/adapters/pipeline/transfer.rs new file mode 100644 index 00000000..0a2a8b06 --- /dev/null +++ b/src/adapters/pipeline/transfer.rs @@ -0,0 +1,263 @@ +//! This module owns the transfer: a windowed writer the read cores emit +//! into, handing each verified slice to the sink without a copy. + +use std::error::Error; +use std::io::{self, Write}; + +use super::{ + CancellationSignal, TransferError, TransferReceipt, TransferSegment, TransferSink, + TransferWindow, +}; +use crate::{BlobId, ByteRange, ContentReads, LayoutId}; + +/// How a transfer is bounded: the acknowledgement window and the signal +/// consulted before every segment. +#[derive(Debug)] +pub struct TransferBounds<'signal, C: ?Sized> { + window: TransferWindow, + cancellation: &'signal C, +} + +impl Clone for TransferBounds<'_, C> { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for TransferBounds<'_, C> {} + +impl<'signal, C: CancellationSignal + ?Sized> TransferBounds<'signal, C> { + /// Acknowledge every `window` segments; stop once `cancellation` is + /// raised. + #[must_use] + pub const fn new(window: TransferWindow, cancellation: &'signal C) -> Self { + Self { + window, + cancellation, + } + } + + /// The acknowledgement window. + #[must_use] + pub const fn window(self) -> TransferWindow { + self.window + } +} + +/// Transfers the exact committed layout `layout_id` from `view` to `sink`. +/// +/// # Errors +/// +/// Returns [`TransferError`] when the view refuses, the sink refuses, or +/// the signal is raised; partial output may have reached the sink. +pub fn transfer_layout( + view: &V, + layout_id: LayoutId, + sink: &mut K, + bounds: TransferBounds<'_, C>, +) -> Result, TransferError> +where + V: ContentReads + ?Sized, + K: TransferSink + ?Sized, + C: CancellationSignal + ?Sized, +{ + run(sink, &bounds, |writer| { + view.reconstruct_layout(layout_id, writer) + }) +} + +/// Transfers `target` through the view's deterministic layout choice. +/// +/// # Errors +/// +/// As [`transfer_layout`]. +pub fn transfer_blob( + view: &V, + target: BlobId, + sink: &mut K, + bounds: TransferBounds<'_, C>, +) -> Result, TransferError> +where + V: ContentReads + ?Sized, + K: TransferSink + ?Sized, + C: CancellationSignal + ?Sized, +{ + run(sink, &bounds, |writer| view.reconstruct(target, writer)) +} + +/// Transfers exactly `requested` of `target`, authenticating only the +/// overlapping chunks. +/// +/// # Errors +/// +/// As [`transfer_layout`]. +pub fn transfer_range( + view: &V, + target: BlobId, + requested: ByteRange, + sink: &mut K, + bounds: TransferBounds<'_, C>, +) -> Result, TransferError> +where + V: ContentReads + ?Sized, + K: TransferSink + ?Sized, + C: CancellationSignal + ?Sized, +{ + run(sink, &bounds, |writer| { + view.read_range(target, requested, writer) + }) +} + +/// Transfers exactly `requested` through the exact committed layout. +/// +/// # Errors +/// +/// As [`transfer_layout`]. +pub fn transfer_layout_range( + view: &V, + layout_id: LayoutId, + requested: ByteRange, + sink: &mut K, + bounds: TransferBounds<'_, C>, +) -> Result, TransferError> +where + V: ContentReads + ?Sized, + K: TransferSink + ?Sized, + C: CancellationSignal + ?Sized, +{ + run(sink, &bounds, |writer| { + view.read_layout_range(layout_id, requested, writer) + }) +} + +fn run( + sink: &mut K, + bounds: &TransferBounds<'_, C>, + read: impl FnOnce(&mut WindowedWriter<'_, K, C>) -> Result, +) -> Result, TransferError> +where + K: TransferSink + ?Sized, + C: CancellationSignal + ?Sized, + R: Copy, + E: Error + 'static, +{ + let window = bounds.window; + let mut writer = WindowedWriter { + sink, + window, + cancellation: bounds.cancellation, + segments: 0, + bytes: 0, + in_window: 0, + acknowledgements: 0, + failure: None, + }; + let receipt = match read(&mut writer) { + Ok(receipt) => receipt, + Err(source) => { + return Err(match writer.failure.take() { + Some(Failure::Sink(error)) => TransferError::Sink(error), + Some(Failure::Cancelled) => TransferError::Cancelled { + segments: writer.segments, + bytes: writer.bytes, + }, + Some(Failure::Accounting) => TransferError::Accounting, + None => TransferError::Read(Box::new(source)), + }); + } + }; + if writer.in_window > 0 { + writer.acknowledge().map_err(TransferError::Sink)?; + } + writer.sink.complete().map_err(TransferError::Sink)?; + Ok(TransferReceipt::new( + receipt, + writer.segments, + writer.bytes, + writer.acknowledgements, + window, + )) +} + +enum Failure { + Sink(E), + Cancelled, + Accounting, +} + +/// The writer the read cores emit into. Each `write` is one verified slice +/// of an immutable chunk, handed to the sink as a segment without a copy. +struct WindowedWriter<'sink, K: TransferSink + ?Sized, C: ?Sized> { + sink: &'sink mut K, + window: TransferWindow, + cancellation: &'sink C, + segments: u64, + bytes: u64, + in_window: u32, + acknowledgements: u64, + failure: Option>, +} + +impl WindowedWriter<'_, K, C> +where + K: TransferSink + ?Sized, + C: CancellationSignal + ?Sized, +{ + fn acknowledge(&mut self) -> Result<(), K::Error> { + self.sink.acknowledge()?; + self.in_window = 0; + self.acknowledgements = self.acknowledgements.saturating_add(1); + Ok(()) + } + + fn fail(&mut self, failure: Failure) -> io::Error { + self.failure = Some(failure); + io::Error::other("the transfer stopped") + } + + fn apply(&mut self, bytes: &[u8]) -> io::Result<()> { + if self.cancellation.is_cancelled() { + return Err(self.fail(Failure::Cancelled)); + } + let segment = TransferSegment::new(self.segments, self.bytes, bytes); + if let Err(error) = self.sink.apply(segment) { + return Err(self.fail(Failure::Sink(error))); + } + let Some(next_bytes) = u64::try_from(bytes.len()) + .ok() + .and_then(|length| self.bytes.checked_add(length)) + else { + return Err(self.fail(Failure::Accounting)); + }; + let Some(next_segments) = self.segments.checked_add(1) else { + return Err(self.fail(Failure::Accounting)); + }; + self.bytes = next_bytes; + self.segments = next_segments; + self.in_window = self.in_window.saturating_add(1); + if self.in_window >= self.window.get() + && let Err(error) = self.acknowledge() + { + return Err(self.fail(Failure::Sink(error))); + } + Ok(()) + } +} + +impl Write for WindowedWriter<'_, K, C> +where + K: TransferSink + ?Sized, + C: CancellationSignal + ?Sized, +{ + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.is_empty() { + return Ok(0); + } + self.apply(bytes)?; + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} diff --git a/src/adapters/pipeline/window.rs b/src/adapters/pipeline/window.rs new file mode 100644 index 00000000..f293df43 --- /dev/null +++ b/src/adapters/pipeline/window.rs @@ -0,0 +1,28 @@ +//! This boundary module owns the transfer window: how many segments the +//! sink may hold before it must acknowledge them. + +use std::num::NonZeroU32; + +/// The number of segments applied between sink acknowledgements. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct TransferWindow(NonZeroU32); + +impl TransferWindow { + /// Acknowledge after every segment. + pub const ONE: Self = Self(NonZeroU32::MIN); + + /// A window of `segments`, refused at zero. + #[must_use] + pub const fn new(segments: u32) -> Option { + match NonZeroU32::new(segments) { + Some(segments) => Some(Self(segments)), + None => None, + } + } + + /// The segment count. + #[must_use] + pub const fn get(self) -> u32 { + self.0.get() + } +} diff --git a/src/lib.rs b/src/lib.rs index b30040ac..3659c97a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -189,6 +189,12 @@ pub use adapters::{ plan_retention_recovery, plan_retention_transition, preflight_retention_transition, prepare_retention_publication, resume_recovery_disposition, verify_retention_closure, }; +pub use adapters::{ + CancellationFlag, CancellationSignal, CopyError, CopyReceipt, NeverCancelled, StreamConsumer, + TransferBounds, TransferError, TransferReceipt, TransferSegment, TransferSink, TransferSource, + TransferSourceError, TransferWindow, WriteSink, WriteSinkError, copy_layout, transfer_blob, + transfer_layout, transfer_layout_range, transfer_range, +}; pub use adapters::{ CanonicalVerificationReceipt, ReceiptCorruption, ReceiptEvidenceKind, ReceiptMissing, ReceiptOutcomeKind, ReceiptRefusal, ReceiptRefusalClass, ReceiptSubjectKind, ReceiptViewKind, diff --git a/src/reference/chunk_reader.rs b/src/reference/chunk_reader.rs new file mode 100644 index 00000000..fb116001 --- /dev/null +++ b/src/reference/chunk_reader.rs @@ -0,0 +1,255 @@ +//! A pull reader over one admitted layout: each chunk is looked up and +//! authenticated as it is first served, and served from the view's own +//! immutable bytes. + +use std::io::{self, Read}; + +use super::chunk_verification::{ChunkSource, ChunkVerificationError, verified_chunk}; +use crate::adapters::TransferSourceError; +use crate::{AdmittedLayout, LayoutId}; + +/// Serves one layout's bytes in order, one verified chunk at a time. +#[expect( + clippy::redundant_pub_crate, + reason = "reached from the pipeline and durable adapters only through the crate-private re-export" +)] +pub(crate) struct ChunkReader<'view, S: ?Sized> { + source: &'view S, + layout_id: LayoutId, + layout: &'view AdmittedLayout, + next_entry: usize, + current: Option<(&'view [u8], usize)>, + refusal: Option, +} + +impl<'view, S: ChunkSource + ?Sized> ChunkReader<'view, S> { + pub(crate) const fn new( + source: &'view S, + layout_id: LayoutId, + layout: &'view AdmittedLayout, + ) -> Self { + Self { + source, + layout_id, + layout, + next_entry: 0, + current: None, + refusal: None, + } + } + + /// The chunk refusal that stopped the reader, if one did, in the + /// transfer-source vocabulary. + pub(crate) fn transfer_refusal(&self) -> Option { + self.refusal.map(source_error) + } + + #[cfg(test)] + const fn refusal(&self) -> Option { + self.refusal + } + + fn advance(&mut self) -> io::Result { + if let Some(refusal) = self.refusal { + return Err(refused(refusal)); + } + let Some(entry) = self.layout.entries().get(self.next_entry) else { + return Ok(false); + }; + match verified_chunk(self.source, self.layout_id, self.next_entry, *entry) { + Ok(bytes) => { + self.current = Some((bytes, 0)); + self.next_entry = self.next_entry.saturating_add(1); + Ok(true) + } + Err(refusal) => { + self.refusal = Some(refusal); + Err(refused(refusal)) + } + } + } +} + +/// Maps a chunk refusal into the public transfer-source vocabulary. +const fn source_error(refusal: ChunkVerificationError) -> TransferSourceError { + match refusal { + ChunkVerificationError::Missing { + layout, + index, + requested, + } => TransferSourceError::ChunkMissing { + layout, + index, + requested, + }, + ChunkVerificationError::Hash { + layout, + index, + source, + .. + } => TransferSourceError::ChunkHash { + layout, + index, + source, + }, + ChunkVerificationError::IdentityMismatch { + layout, + index, + expected, + observed, + } => TransferSourceError::ChunkIdentityMismatch { + layout, + index, + expected, + observed, + }, + } +} + +fn refused(refusal: ChunkVerificationError) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, format!("{refusal:?}")) +} + +impl Read for ChunkReader<'_, S> { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if buffer.is_empty() { + return Ok(0); + } + loop { + let Some((bytes, served)) = self.current else { + if self.advance()? { + continue; + } + return Ok(0); + }; + let Some(remaining) = bytes.get(served..) else { + self.current = None; + continue; + }; + if remaining.is_empty() { + self.current = None; + continue; + } + let take = remaining.len().min(buffer.len()); + let (head, _) = remaining.split_at(take); + if let Some(slot) = buffer.get_mut(..take) { + slot.copy_from_slice(head); + } + self.current = Some((bytes, served.saturating_add(take))); + return Ok(take); + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::io::Read; + + use super::super::chunk_verification::{ChunkSource, ChunkVerificationError}; + use super::ChunkReader; + use crate::{ + AdmittedLayout, BlobHasher, ChunkId, FastCdc, LayoutEntryLimit, RegisteredStorageProfile, + }; + + struct Chunks(BTreeMap>); + + impl ChunkSource for Chunks { + fn chunk(&self, identity: ChunkId) -> Option<&[u8]> { + self.0.get(&identity).map(Vec::as_slice) + } + } + + fn content() -> Vec { + let mut state = 0x1357_9bdf_2468_ace0_u64; + (0..300 * 1024) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + u8::try_from(state & 0xff).unwrap_or_default() + }) + .collect() + } + + fn layout_and_chunks( + bytes: &[u8], + ) -> Result<(AdmittedLayout, Chunks), Box> { + let mut hasher = BlobHasher::new(); + hasher.update(bytes)?; + let mut detector = FastCdc::new(); + let mut spans = Vec::new(); + detector.feed(bytes, |span| spans.push(span))?; + if let Some(span) = detector.finish()? { + spans.push(span); + } + let mut chunks = BTreeMap::new(); + for span in &spans { + let start = usize::try_from(span.offset().get())?; + let end = usize::try_from(span.end().get())?; + chunks.insert(span.id(), bytes.get(start..end).ok_or("span")?.to_vec()); + } + let layout = AdmittedLayout::from_spans( + hasher.finish(), + RegisteredStorageProfile::FAST_CDC_64K_V1, + spans, + LayoutEntryLimit::MAXIMUM, + )?; + Ok((layout, Chunks(chunks))) + } + + #[test] + fn the_reader_serves_every_chunk_in_order_across_small_reads() + -> Result<(), Box> { + let bytes = content(); + let (layout, chunks) = layout_and_chunks(&bytes)?; + let layout_id = layout.encode_record()?.id(); + let mut reader = ChunkReader::new(&chunks, layout_id, &layout); + let mut output = Vec::new(); + let mut buffer = [0_u8; 1000]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + output.extend_from_slice(buffer.get(..read).ok_or("read")?); + } + assert_eq!(output, bytes); + assert!(reader.refusal().is_none()); + Ok(()) + } + + #[test] + fn a_chunk_that_does_not_hash_to_its_identity_refuses_at_its_boundary() + -> Result<(), Box> { + let bytes = content(); + let (layout, mut chunks) = layout_and_chunks(&bytes)?; + let second = layout.entries().get(1).ok_or("second entry")?.chunk_id(); + if let Some(stored) = chunks.0.get_mut(&second) + && let Some(first) = stored.first_mut() + { + *first = first.wrapping_add(1); + } + let layout_id = layout.encode_record()?.id(); + let mut reader = ChunkReader::new(&chunks, layout_id, &layout); + let mut output = Vec::new(); + let Err(refusal) = reader.read_to_end(&mut output) else { + return Err("expected a chunk refusal".into()); + }; + assert_eq!(refusal.kind(), std::io::ErrorKind::InvalidData); + let first_length = usize::try_from( + layout + .entries() + .get(1) + .ok_or("second entry")? + .offset() + .get(), + )?; + assert_eq!(output.len(), first_length); + assert!(matches!( + reader.refusal(), + Some(ChunkVerificationError::IdentityMismatch { index: 1, .. }) + )); + Ok(()) + } +} diff --git a/src/reference/mod.rs b/src/reference/mod.rs index 283688f2..2bce89ec 100644 --- a/src/reference/mod.rs +++ b/src/reference/mod.rs @@ -5,6 +5,7 @@ //! makes a retention, crash-recovery, or durability claim. mod capacity; +mod chunk_reader; mod chunk_staging; mod chunk_verification; mod ingestion; @@ -26,10 +27,16 @@ mod reconstruction_error_display; mod reconstruction_receipt; mod staged_blob; mod store; +mod transfer_source; mod verification; pub use crate::profile::ProfileBoundary; pub use capacity::ReferenceStoreCapacity; +#[expect( + clippy::redundant_pub_crate, + reason = "the pipeline and durable adapters reach the pull reader through this crate-private surface" +)] +pub(crate) use chunk_reader::ChunkReader; #[expect( clippy::redundant_pub_crate, reason = "the durable adapter reaches the shared read cores through this crate-private surface" diff --git a/src/reference/transfer_source.rs b/src/reference/transfer_source.rs new file mode 100644 index 00000000..b012c051 --- /dev/null +++ b/src/reference/transfer_source.rs @@ -0,0 +1,23 @@ +//! The reference store as a transfer source. + +use super::ReferenceStore; +use super::chunk_reader::ChunkReader; +use crate::LayoutId; +use crate::adapters::{StreamConsumer, TransferSource, TransferSourceError}; + +impl TransferSource for ReferenceStore { + fn stream_layout( + &self, + layout_id: LayoutId, + consume: StreamConsumer<'_>, + ) -> Result<(), TransferSourceError> { + let layout = self + .layout(layout_id) + .ok_or(TransferSourceError::LayoutMissing { + requested: layout_id, + })?; + let mut reader = ChunkReader::new(self, layout_id, layout); + consume(layout.target(), &mut reader); + reader.transfer_refusal().map_or(Ok(()), Err) + } +} diff --git a/tests/transfer_pipeline_memory.rs b/tests/transfer_pipeline_memory.rs new file mode 100644 index 00000000..f18985d9 --- /dev/null +++ b/tests/transfer_pipeline_memory.rs @@ -0,0 +1,99 @@ +//! Transfer pipeline memory laws, measured against the library as shipped: +//! read-to-write allocates nothing beyond the sink, and copy-to-write +//! allocates less than a caller-owned copy loop. + +use std::error::Error; +use std::io::Cursor; + +use allocation_counter::{AllocationInfo, measure}; +use keep::{ + LayoutEntryLimit, NeverCancelled, PublishedBlob, ReferenceStore, ReferenceStoreCapacity, + StagingLimits, TransferBounds, TransferWindow, WriteSink, copy_layout, transfer_layout, +}; + +const CAPACITY: usize = 16 * 1024 * 1024; +const INPUT_BYTES: usize = 1_048_576; + +fn deterministic_bytes(length: usize) -> Vec { + let mut state = 0x9e37_79b9_7f4a_7c15_u64; + (0..length) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + u8::try_from(state & 0xff).unwrap_or_default() + }) + .collect() +} + +fn published(bytes: &[u8]) -> Result<(ReferenceStore, PublishedBlob), Box> { + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let staged = store.stage(&mut Cursor::new(bytes), LayoutEntryLimit::MAXIMUM)?; + let blob = staged.commit(&mut store)?; + Ok((store, blob)) +} + +#[test] +fn read_to_write_allocates_nothing_beyond_the_sink() -> Result<(), Box> { + let bytes = deterministic_bytes(INPUT_BYTES); + let (store, blob) = published(&bytes)?; + let mut sink = WriteSink::new(Vec::with_capacity(bytes.len())); + let mut outcome = None; + let observed = measure(|| { + outcome = Some(transfer_layout( + &store, + blob.layout_id(), + &mut sink, + TransferBounds::new(TransferWindow::ONE, &NeverCancelled), + )); + }); + let receipt = outcome.ok_or("no outcome")??; + assert_eq!(observed, AllocationInfo::default()); + assert_eq!(receipt.bytes(), u64::try_from(bytes.len())?); + assert_eq!(sink.into_inner(), bytes); + Ok(()) +} + +#[test] +fn copy_to_write_allocates_less_than_a_caller_owned_copy_loop() -> Result<(), Box> { + let bytes = deterministic_bytes(INPUT_BYTES); + let (source, blob) = published(&bytes)?; + let limits = StagingLimits::entries(LayoutEntryLimit::MAXIMUM); + + let mut piped = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let mut copy = None; + let pipeline = measure(|| { + copy = Some(copy_layout(&source, blob.layout_id(), &mut piped, limits)); + }); + let receipt = copy.ok_or("no outcome")??; + assert_eq!(receipt.target(), blob.target()); + + let mut looped = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); + let mut loop_outcome = None; + let copy_loop = measure(|| { + loop_outcome = Some(caller_owned_copy_loop(&source, blob, &mut looped, limits)); + }); + loop_outcome.ok_or("no outcome")??; + + assert!( + pipeline.bytes_total < copy_loop.bytes_total && pipeline.bytes_max < copy_loop.bytes_max, + "pipeline {pipeline:?}, copy loop {copy_loop:?}" + ); + let mut output = Vec::new(); + let _read = piped.reconstruct(receipt.target(), &mut output)?; + assert_eq!(output, bytes); + Ok(()) +} + +fn caller_owned_copy_loop( + source: &ReferenceStore, + blob: PublishedBlob, + destination: &mut ReferenceStore, + limits: StagingLimits, +) -> Result<(), Box> { + let mut buffer = Vec::new(); + let _read = source.reconstruct_layout(blob.layout_id(), &mut buffer)?; + let staged = destination.stage_bounded(&mut Cursor::new(&buffer), limits)?; + let _receipt = staged.commit(destination)?; + Ok(()) +} From 80c80424b3db33ac4a921b10d62de9242604b64a Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 14:56:14 -0700 Subject: [PATCH 37/59] Fix: keep the transfer benchmark and port suite within the pinned clippy The benchmark's setup no longer uses `expect` or prints: `main` publishes both inputs first and refuses with the error when it cannot, and each benchmark body returns its result instead of unwrapping it. The port suite no longer drops a `Copy` receipt to end a borrow that reading its identities already ends. Co-Authored-By: Claude Fable 5.1 --- benches/transfer_pipeline.rs | 85 ++++++++++++++++++++++++------------ tests/content_store_port.rs | 2 +- 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/benches/transfer_pipeline.rs b/benches/transfer_pipeline.rs index fec27d96..a51cca3e 100644 --- a/benches/transfer_pipeline.rs +++ b/benches/transfer_pipeline.rs @@ -1,5 +1,6 @@ //! Transfer pipeline against a caller-owned copy loop. +use std::error::Error; use std::io::Cursor; use divan::counter::BytesCount; @@ -13,8 +14,14 @@ const REPRESENTATIVE_INPUT_BYTES: usize = 1_048_576; const LARGE_INPUT_BYTES: usize = 4_194_304; const CAPACITY: usize = 64 * 1024 * 1024; -fn main() { +/// Refuses to benchmark at all when either input cannot be published, so +/// a benchmark function never has to report a setup failure itself. +fn main() -> Result<(), Box> { + for length in [REPRESENTATIVE_INPUT_BYTES, LARGE_INPUT_BYTES] { + let _published = published(length)?; + } divan::main(); + Ok(()) } fn deterministic_bytes(length: usize) -> Vec { @@ -29,31 +36,39 @@ fn deterministic_bytes(length: usize) -> Vec { .collect() } -fn published(length: usize) -> (ReferenceStore, PublishedBlob) { +type Setup = Result<(ReferenceStore, PublishedBlob), Box>; + +fn published(length: usize) -> Setup { let bytes = deterministic_bytes(length); let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); - let staged = store - .stage(&mut Cursor::new(&bytes), LayoutEntryLimit::MAXIMUM) - .expect("stage"); - let blob = staged.commit(&mut store).expect("commit"); - (store, blob) + let staged = store.stage(&mut Cursor::new(&bytes), LayoutEntryLimit::MAXIMUM)?; + let blob = staged.commit(&mut store)?; + Ok((store, blob)) +} + +/// Publishes the input; `main` already proved this succeeds for every +/// benchmarked length, so a bench function only skips on a refusal it +/// cannot report. +fn setup(length: usize) -> Option<(ReferenceStore, PublishedBlob)> { + published(length).ok() } /// Read-to-write through the pipeline: each verified chunk slice reaches /// the sink without an intermediate buffer. #[divan::bench(args = [REPRESENTATIVE_INPUT_BYTES, LARGE_INPUT_BYTES])] fn read_to_write_pipeline(bencher: Bencher<'_, '_>, length: usize) { - let (store, blob) = published(length); + let Some((store, blob)) = setup(length) else { + return; + }; bencher.counter(BytesCount::new(length)).bench_local(|| { let mut sink = WriteSink::new(std::io::sink()); - let receipt = transfer_layout( + transfer_layout( black_box(&store), blob.layout_id(), &mut sink, TransferBounds::new(TransferWindow::ONE, &NeverCancelled), ) - .expect("transfer"); - black_box(receipt.bytes()) + .map(|receipt| black_box(receipt.bytes())) }); } @@ -61,14 +76,19 @@ fn read_to_write_pipeline(bencher: Bencher<'_, '_>, length: usize) { /// buffer, then write the buffer out. #[divan::bench(args = [REPRESENTATIVE_INPUT_BYTES, LARGE_INPUT_BYTES])] fn read_to_write_copy_loop(bencher: Bencher<'_, '_>, length: usize) { - let (store, blob) = published(length); + let Some((store, blob)) = setup(length) else { + return; + }; bencher.counter(BytesCount::new(length)).bench_local(|| { let mut buffer = Vec::new(); - let _receipt = store + store .reconstruct_layout(blob.layout_id(), &mut buffer) - .expect("reconstruct"); - std::io::copy(&mut Cursor::new(&buffer), &mut std::io::sink()).expect("copy"); - black_box(buffer.len()) + .map_err(|error| error.to_string()) + .and_then(|_receipt| { + std::io::copy(&mut Cursor::new(&buffer), &mut std::io::sink()) + .map_err(|error| error.to_string()) + }) + .map(|_copied| black_box(buffer.len())) }); } @@ -76,17 +96,18 @@ fn read_to_write_copy_loop(bencher: Bencher<'_, '_>, length: usize) { /// verified chunks directly. #[divan::bench(args = [REPRESENTATIVE_INPUT_BYTES, LARGE_INPUT_BYTES])] fn copy_to_write_pipeline(bencher: Bencher<'_, '_>, length: usize) { - let (store, blob) = published(length); + let Some((store, blob)) = setup(length) else { + return; + }; bencher.counter(BytesCount::new(length)).bench_local(|| { let mut destination = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); - let receipt = copy_layout( + copy_layout( black_box(&store), blob.layout_id(), &mut destination, StagingLimits::entries(LayoutEntryLimit::MAXIMUM), ) - .expect("copy"); - black_box(receipt.target()) + .map(|receipt| black_box(receipt.target())) }); } @@ -94,17 +115,25 @@ fn copy_to_write_pipeline(bencher: Bencher<'_, '_>, length: usize) { /// buffer, then stage the buffer. #[divan::bench(args = [REPRESENTATIVE_INPUT_BYTES, LARGE_INPUT_BYTES])] fn copy_to_write_copy_loop(bencher: Bencher<'_, '_>, length: usize) { - let (store, blob) = published(length); + let Some((store, blob)) = setup(length) else { + return; + }; bencher.counter(BytesCount::new(length)).bench_local(|| { let mut destination = ReferenceStore::new(ReferenceStoreCapacity::new(CAPACITY)); let mut buffer = Vec::new(); - let _receipt = store + store .reconstruct_layout(blob.layout_id(), &mut buffer) - .expect("reconstruct"); - let staged = destination - .stage(&mut Cursor::new(&buffer), LayoutEntryLimit::MAXIMUM) - .expect("stage"); - let published = staged.commit(&mut destination).expect("commit"); - black_box(published.target()) + .map_err(|error| error.to_string()) + .and_then(|_receipt| { + destination + .stage(&mut Cursor::new(&buffer), LayoutEntryLimit::MAXIMUM) + .map_err(|error| error.to_string()) + }) + .and_then(|staged| { + staged + .commit(&mut destination) + .map_err(|error| error.to_string()) + }) + .map(|published| black_box(published.target())) }); } diff --git a/tests/content_store_port.rs b/tests/content_store_port.rs index f08a8b42..b7fd501a 100644 --- a/tests/content_store_port.rs +++ b/tests/content_store_port.rs @@ -42,8 +42,8 @@ where let expected_target = staged.target(); let expected_layout = staged.layout_id(); let receipt = staged.commit()?; + // The receipt is `Copy`, so reading its identities ends the store borrow. let (target, layout_id) = (receipt.target(), receipt.layout_id()); - drop(receipt); assert_eq!(target, expected_target); assert_eq!(layout_id, expected_layout); let mut output = Vec::new(); From c7f9f5769207a2be042941d254b38d93bbe4345c Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 15:07:09 -0700 Subject: [PATCH 38/59] Fix: keep the mutation-ledger and receipt test crates within the pinned clippy The ledger, fixture, recipe, classification, oracle, and matrix helpers are reached only from their test-crate roots, so the modules are `pub` with `pub` items under an expected `missing_docs` (the shape the older `support` module already uses) instead of `pub(crate)` items in private modules; the recipe helpers document their errors; the binding classification is one arm per record instead of an or-pattern clippy wants nested; the outcome and stage agreement is a named helper; the reseal and oracle assembly bundle their positional arguments. Co-Authored-By: Claude Fable 5.1 --- tests/segment_store_mutations.rs | 26 +++++-- tests/segment_store_mutations/classify.rs | 79 ++++++++++++--------- tests/segment_store_mutations/fixtures.rs | 2 +- tests/segment_store_mutations/ledger.rs | 34 +++++----- tests/segment_store_mutations/recipes.rs | 83 ++++++++++++++++------- tests/verification_receipt.rs | 9 ++- tests/verification_receipt/matrix.rs | 22 +++--- tests/verification_receipt/oracle.rs | 67 +++++++++--------- 8 files changed, 195 insertions(+), 127 deletions(-) diff --git a/tests/segment_store_mutations.rs b/tests/segment_store_mutations.rs index f7461ef3..57eec925 100644 --- a/tests/segment_store_mutations.rs +++ b/tests/segment_store_mutations.rs @@ -2,23 +2,28 @@ //! each frozen mutation reaches exactly its named first refusal at its named //! verification stage, through the public decoders. +#![expect( + missing_docs, + reason = "the ledger, fixture, recipe, and classification helpers are reached only from this test crate" +)] + pub mod support; #[path = "segment_store_mutations/classify.rs"] -mod classify; +pub mod classify; #[path = "segment_store_mutations/fixtures.rs"] -mod fixtures; +pub mod fixtures; #[path = "segment_store_mutations/ledger.rs"] -mod ledger; +pub mod ledger; #[path = "segment_store_mutations/recipes.rs"] -mod recipes; +pub mod recipes; use std::collections::{BTreeMap, BTreeSet}; use std::error::Error; use keep::VerificationDepth; -use classify::classify; +use classify::{Refusal, classify}; use ledger::{Format, MutationCase, mutation_cases}; const STAGES: [&str; 4] = ["framing", "checksum", "identity", "binding"]; @@ -44,6 +49,14 @@ const V2_RECORDS: [&str; 9] = [ "disposition", ]; +/// Whether the observed refusal is the ledger's expected outcome at the +/// expected stage. +fn agrees(refusal: &Refusal, case: &MutationCase) -> bool { + let outcome_agrees = refusal.outcome == case.expected_outcome; + let stage_agrees = refusal.stage == case.stage; + outcome_agrees && stage_agrees +} + fn mutated(case: &MutationCase) -> Result, Box> { let mut bytes = case.mutated_bytes()?; match case.checksum_posture { @@ -74,8 +87,7 @@ fn every_frozen_mutation_reaches_its_exact_first_refusal() -> Result<(), Box {} + Ok(refusal) if agrees(&refusal, &case) => {} Ok(refusal) => differences.push(format!( "{}: expected {} ({}), observed {} ({})", case.case, case.expected_outcome, case.stage, refusal.outcome, refusal.stage diff --git a/tests/segment_store_mutations/classify.rs b/tests/segment_store_mutations/classify.rs index 164df184..733e3d11 100644 --- a/tests/segment_store_mutations/classify.rs +++ b/tests/segment_store_mutations/classify.rs @@ -19,9 +19,9 @@ use crate::support::{decode_hex, invalid_corpus}; /// One classified refusal. #[derive(Debug, Eq, PartialEq)] -pub(crate) struct Refusal { - pub(crate) outcome: String, - pub(crate) stage: &'static str, +pub struct Refusal { + pub outcome: String, + pub stage: &'static str, } const fn policy() -> SegmentReadPolicy { @@ -95,39 +95,52 @@ fn stage_of(record: &str, variant_name: &str) -> &'static str { return "identity"; } let binding = matches!(record, "catalog-binding" | "head-binding") - || matches!( - (record, variant_name), - ("format-marker", "definition-digest-mismatch") - | ( - "migration-intent", - "definition-digest-mismatch" | "store-identifier-mismatch" - ) - | ( - "migration-receipt", - "intent-digest-mismatch" | "store-identifier-mismatch" - ) - | ("migration-receipt", "format-marker-digest-mismatch") - | ("retention-root", "profile") - | ("gc-intent", "profile") - | ("gc-receipt", _) - | ("disposition", "empty-retention-digest-mismatch") - ) && !matches!( - variant_name, - "wrong-length" - | "invalid-magic" - | "unsupported-version" - | "invalid-record-length" - | "unsupported-flags" - | "non-zero-reserved" - | "zero-generation" - ); + || (binding_variant(record, variant_name) && !framing_variant(variant_name)); if binding { "binding" } else { "framing" } } +/// Whether `variant_name` of `record` binds one record to another, one +/// arm per record so no or-pattern needs nesting. +fn binding_variant(record: &str, variant_name: &str) -> bool { + match record { + "format-marker" => variant_name == "definition-digest-mismatch", + "migration-intent" => matches!( + variant_name, + "definition-digest-mismatch" | "store-identifier-mismatch" + ), + "migration-receipt" => matches!( + variant_name, + "intent-digest-mismatch" + | "store-identifier-mismatch" + | "format-marker-digest-mismatch" + ), + "retention-root" | "gc-intent" => variant_name == "profile", + "gc-receipt" => true, + "disposition" => variant_name == "empty-retention-digest-mismatch", + _ => false, + } +} + +/// Whether `variant_name` is a framing mutation, which stays framing even +/// on a binding record. +fn framing_variant(variant_name: &str) -> bool { + matches!( + variant_name, + "wrong-length" + | "invalid-magic" + | "unsupported-version" + | "invalid-record-length" + | "unsupported-flags" + | "non-zero-reserved" + | "zero-generation" + ) +} + fn admitted(case: &str) -> io::Error { - invalid_corpus(match case.is_empty() { - true => "mutation was admitted", - false => "mutation was unexpectedly admitted", + invalid_corpus(if case.is_empty() { + "mutation was admitted" + } else { + "mutation was unexpectedly admitted" }) } @@ -137,7 +150,7 @@ fn admitted(case: &str) -> io::Error { /// /// Returns a corpus error when the record is unknown, a context fixture is /// malformed, or the mutation was admitted. -pub(crate) fn classify(format: Format, record: &str, bytes: &[u8]) -> Result { +pub fn classify(format: Format, record: &str, bytes: &[u8]) -> Result { match (format, record) { (Format::V1, "segment-header" | "segment-record" | "segment-seal" | "segment") => { let error = AdmittedSegment::decode(bytes, policy()) diff --git a/tests/segment_store_mutations/fixtures.rs b/tests/segment_store_mutations/fixtures.rs index 1c82590c..fa69fbec 100644 --- a/tests/segment_store_mutations/fixtures.rs +++ b/tests/segment_store_mutations/fixtures.rs @@ -76,7 +76,7 @@ const V2: [(&str, &str); 9] = [ /// # Errors /// /// Returns a corpus error when the fixture is not frozen. -pub(crate) fn fixture(format: Format, name: &str) -> Result<&'static str, io::Error> { +pub fn fixture(format: Format, name: &str) -> Result<&'static str, io::Error> { let table: &[(&str, &str)] = match format { Format::V1 => &V1, Format::V2 => &V2, diff --git a/tests/segment_store_mutations/ledger.rs b/tests/segment_store_mutations/ledger.rs index 56f66563..67a0c086 100644 --- a/tests/segment_store_mutations/ledger.rs +++ b/tests/segment_store_mutations/ledger.rs @@ -9,25 +9,25 @@ const V2_MUTATIONS: &str = include_str!("../../conformance/segment-store/v2/muta /// The format a ledger row belongs to. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub(crate) enum Format { +pub enum Format { V1, V2, } /// One parsed immutable mutation-ledger row. -pub(crate) struct MutationCase { - pub(crate) format: Format, - pub(crate) case: &'static str, - pub(crate) record: &'static str, - pub(crate) base_fixture: &'static str, - pub(crate) operation: &'static str, - pub(crate) offset: usize, - pub(crate) span_length: usize, - pub(crate) parameter: &'static str, - pub(crate) checksum_posture: &'static str, - pub(crate) expected_outcome: &'static str, - pub(crate) stage: &'static str, - pub(crate) requirement: &'static str, +pub struct MutationCase { + pub format: Format, + pub case: &'static str, + pub record: &'static str, + pub base_fixture: &'static str, + pub operation: &'static str, + pub offset: usize, + pub span_length: usize, + pub parameter: &'static str, + pub checksum_posture: &'static str, + pub expected_outcome: &'static str, + pub stage: &'static str, + pub requirement: &'static str, } /// Parses every row of both ledgers. @@ -35,7 +35,7 @@ pub(crate) struct MutationCase { /// # Errors /// /// Returns a corpus error when a header, field, or integer is malformed. -pub(crate) fn mutation_cases() -> Result, io::Error> { +pub fn mutation_cases() -> Result, io::Error> { let mut cases = Vec::new(); for (format, ledger, header) in [ (Format::V1, V1_MUTATIONS, "keep.segment-store-mutations/v1"), @@ -87,7 +87,7 @@ impl MutationCase { /// # Errors /// /// Returns a corpus error when the fixture is unknown or malformed. - pub(crate) fn base_bytes(&self) -> Result, io::Error> { + pub fn base_bytes(&self) -> Result, io::Error> { let hex = super::fixtures::fixture(self.format, self.base_fixture)?; decode_hex(hex.strip_suffix('\n').unwrap_or(hex)) } @@ -99,7 +99,7 @@ impl MutationCase { /// /// Returns a corpus error for an unknown operation, an out-of-bounds /// span, or a parameter of the wrong width. - pub(crate) fn mutated_bytes(&self) -> Result, io::Error> { + pub fn mutated_bytes(&self) -> Result, io::Error> { let mut bytes = self.base_bytes()?; match self.operation { "replace-v1" => { diff --git a/tests/segment_store_mutations/recipes.rs b/tests/segment_store_mutations/recipes.rs index 9af598ef..133d7e4d 100644 --- a/tests/segment_store_mutations/recipes.rs +++ b/tests/segment_store_mutations/recipes.rs @@ -36,7 +36,12 @@ fn u64_at(bytes: &[u8], offset: usize) -> Result { /// Recomputes the seal checksum and the segment digest of one complete /// segment whose seal is its last 128 bytes. -pub(crate) fn reseal_segment(bytes: &mut [u8]) -> Result<(), io::Error> { +/// +/// # Errors +/// +/// Returns a corpus error when an offset or length the ledger names falls +/// outside the fixture. +pub fn reseal_segment(bytes: &mut [u8]) -> Result<(), io::Error> { let seal_offset = bytes .len() .checked_sub(128) @@ -54,7 +59,12 @@ pub(crate) fn reseal_segment(bytes: &mut [u8]) -> Result<(), io::Error> { /// Recomputes the first record's checksum (the record at byte 64) and then /// reseals the segment. -pub(crate) fn rechecksum_first_record(bytes: &mut [u8]) -> Result<(), io::Error> { +/// +/// # Errors +/// +/// Returns a corpus error when an offset or length the ledger names falls +/// outside the fixture. +pub fn rechecksum_first_record(bytes: &mut [u8]) -> Result<(), io::Error> { let record_length = usize::try_from(u64_at(bytes, 96)?) .map_err(|_source| invalid_corpus("record length exceeds host width"))?; let end = 64_usize @@ -69,7 +79,12 @@ pub(crate) fn rechecksum_first_record(bytes: &mut [u8]) -> Result<(), io::Error> } /// Recomputes a catalog's checksum and digest trailer. -pub(crate) fn reseal_catalog(bytes: &mut [u8]) -> Result<(), io::Error> { +/// +/// # Errors +/// +/// Returns a corpus error when an offset or length the ledger names falls +/// outside the fixture. +pub fn reseal_catalog(bytes: &mut [u8]) -> Result<(), io::Error> { let digest_offset = bytes .len() .checked_sub(32) @@ -84,14 +99,24 @@ pub(crate) fn reseal_catalog(bytes: &mut [u8]) -> Result<(), io::Error> { } /// Recomputes a publication head's checksum. -pub(crate) fn reseal_publication_head(bytes: &mut [u8]) -> Result<(), io::Error> { +/// +/// # Errors +/// +/// Returns a corpus error when an offset or length the ledger names falls +/// outside the fixture. +pub fn reseal_publication_head(bytes: &mut [u8]) -> Result<(), io::Error> { let checksum = framed_blake3_v1(b"KEEP:CATHEAD:SUM\0", slice(bytes, 0, 96)?)?; patch(bytes, 96, &checksum) } /// Recomputes a fixed-width version-2 record's trailing checksum under /// `domain` over every byte before it. -pub(crate) fn reseal_fixed_v2(bytes: &mut [u8], domain: &[u8]) -> Result<(), io::Error> { +/// +/// # Errors +/// +/// Returns a corpus error when an offset or length the ledger names falls +/// outside the fixture. +pub fn reseal_fixed_v2(bytes: &mut [u8], domain: &[u8]) -> Result<(), io::Error> { let checksum_offset = bytes .len() .checked_sub(32) @@ -102,7 +127,12 @@ pub(crate) fn reseal_fixed_v2(bytes: &mut [u8], domain: &[u8]) -> Result<(), io: /// Recomputes a variable-length version-2 record's digest-then-checksum /// trailer. -pub(crate) fn reseal_digested_v2( +/// +/// # Errors +/// +/// Returns a corpus error when an offset or length the ledger names falls +/// outside the fixture. +pub fn reseal_digested_v2( bytes: &mut [u8], digest_domain: &[u8], checksum_domain: &[u8], @@ -139,15 +169,16 @@ fn u32_at(bytes: &[u8], offset: usize) -> Result<[u8; 4], io::Error> { .map_err(|_source| invalid_corpus("field width mismatch")) } -/// Recomputes one `domain || count || body` set digest into `digest_offset`. +/// Recomputes one `domain || count || body` set digest into the digest +/// offset; `offsets` is `(count, digest)` and `body` is `(offset, length)`. fn reseal_set_digest( bytes: &mut [u8], domain: &[u8], - count_offset: usize, - digest_offset: usize, - body_offset: usize, - body_length: usize, + offsets: (usize, usize), + body: (usize, usize), ) -> Result<(), io::Error> { + let (count_offset, digest_offset) = offsets; + let (body_offset, body_length) = body; let count = u32_at(bytes, count_offset)?; let end = body_offset .checked_add(body_length) @@ -176,7 +207,7 @@ fn body_length(bytes: &[u8], body_offset: usize) -> Result { /// # Errors /// /// Returns a corpus error for an unknown record or a malformed span. -pub(crate) fn recompute(record: &str, bytes: &mut [u8]) -> Result<(), io::Error> { +pub fn recompute(record: &str, bytes: &mut [u8]) -> Result<(), io::Error> { match record { "retention-root" => { let namespace_length = u16_at(bytes, 40)?; @@ -187,10 +218,8 @@ pub(crate) fn recompute(record: &str, bytes: &mut [u8]) -> Result<(), io::Error> reseal_set_digest( bytes, b"keep.retention-anchor-set/v2\0", - 44, - 148, - anchors, - length, + (44, 148), + (anchors, length), )?; } "retention-manifest" => { @@ -198,15 +227,18 @@ pub(crate) fn recompute(record: &str, bytes: &mut [u8]) -> Result<(), io::Error> reseal_set_digest( bytes, b"keep.retention-manifest-entries/v2\0", - 44, - 80, - 160, - length, + (44, 80), + (160, length), )?; } "gc-intent" => { let length = body_length(bytes, 320)?; - reseal_set_digest(bytes, b"keep.gc-candidate-set/v2\0", 44, 288, 320, length)?; + reseal_set_digest( + bytes, + b"keep.gc-candidate-set/v2\0", + (44, 288), + (320, length), + )?; } _ => {} } @@ -218,7 +250,7 @@ pub(crate) fn recompute(record: &str, bytes: &mut [u8]) -> Result<(), io::Error> /// # Errors /// /// Returns a corpus error for an unknown record or a malformed span. -pub(crate) fn recompute_trailer(record: &str, bytes: &mut [u8]) -> Result<(), io::Error> { +pub fn recompute_trailer(record: &str, bytes: &mut [u8]) -> Result<(), io::Error> { match record { "segment-header" | "segment-seal" | "segment" => reseal_segment(bytes), "segment-record" => rechecksum_first_record(bytes), @@ -253,7 +285,12 @@ pub(crate) fn recompute_trailer(record: &str, bytes: &mut [u8]) -> Result<(), io /// Recomputes only the outermost checksum, leaving an inner digest as /// mutated, so a digest field's own refusal is reachable. -pub(crate) fn recompute_checksum_only(record: &str, bytes: &mut [u8]) -> Result<(), io::Error> { +/// +/// # Errors +/// +/// Returns a corpus error when an offset or length the ledger names falls +/// outside the fixture. +pub fn recompute_checksum_only(record: &str, bytes: &mut [u8]) -> Result<(), io::Error> { match record { "segment-seal" => { let seal_offset = bytes diff --git a/tests/verification_receipt.rs b/tests/verification_receipt.rs index d6ea1eec..c719adbe 100644 --- a/tests/verification_receipt.rs +++ b/tests/verification_receipt.rs @@ -3,12 +3,17 @@ //! refusal projects and round-trips, every structural field has one exact //! first refusal, and a refusal never decodes as a report. +#![expect( + missing_docs, + reason = "the oracle and matrix helpers are reached only from this test crate" +)] + pub mod support; #[path = "verification_receipt/matrix.rs"] -mod matrix; +pub mod matrix; #[path = "verification_receipt/oracle.rs"] -mod oracle; +pub mod oracle; use std::error::Error; use std::io::Cursor; diff --git a/tests/verification_receipt/matrix.rs b/tests/verification_receipt/matrix.rs index d92cacd7..435cda05 100644 --- a/tests/verification_receipt/matrix.rs +++ b/tests/verification_receipt/matrix.rs @@ -4,20 +4,20 @@ use keep::{VerificationReceiptDecodeError as DecodeError, VerificationReceiptField as Field}; /// One structural mutation and the exact first refusal it must reach. -pub(crate) struct Mutation { - pub(crate) field: &'static str, - pub(crate) fixture: &'static str, - pub(crate) offset: usize, - pub(crate) value: &'static [u8], - pub(crate) reseal: bool, - pub(crate) refuses: fn(&DecodeError) -> bool, +pub struct Mutation { + pub field: &'static str, + pub fixture: &'static str, + pub offset: usize, + pub value: &'static [u8], + pub reseal: bool, + pub refuses: fn(&DecodeError) -> bool, } -pub(crate) const REPORT: &str = "reference-complete-blob-report.hex"; -pub(crate) const CORRUPT: &str = "durable-corrupt-chunk-refusal.hex"; -pub(crate) const UNSUPPORTED: &str = "reference-unsupported-framing-refusal.hex"; +pub const REPORT: &str = "reference-complete-blob-report.hex"; +pub const CORRUPT: &str = "durable-corrupt-chunk-refusal.hex"; +pub const UNSUPPORTED: &str = "reference-unsupported-framing-refusal.hex"; -pub(crate) const MATRIX: &[Mutation] = &[ +pub const MATRIX: &[Mutation] = &[ Mutation { field: "magic", fixture: REPORT, diff --git a/tests/verification_receipt/oracle.rs b/tests/verification_receipt/oracle.rs index 571ce980..e6f8b8e5 100644 --- a/tests/verification_receipt/oracle.rs +++ b/tests/verification_receipt/oracle.rs @@ -12,14 +12,14 @@ const V2_ARTIFACTS: &str = include_str!("../../conformance/segment-store/v2/arti const CHECKSUM_DOMAIN: &[u8] = b"keep.verification-receipt-checksum/v1\0"; /// The canonical one-zero `BlobId` binary from the accepted layout corpus. -pub(crate) const BLOB_ID: [u8; 59] = [ +pub const BLOB_ID: [u8; 59] = [ 0x4b, 0x45, 0x45, 0x50, 0x3a, 0x42, 0x4c, 0x4f, 0x42, 0x3a, 0x49, 0x44, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x1c, 0xfb, 0x8f, 0xa9, 0xe9, 0x17, 0xab, 0xa1, 0x5a, 0x1f, 0x59, 0x20, 0x95, 0xf3, 0x77, 0xff, 0x18, 0x07, 0x55, 0xfe, 0x12, 0x12, 0xb0, 0xd7, 0xd2, 0xec, 0x75, 0x0b, 0xd1, 0x28, 0xb6, 0x06, ]; /// The canonical one-zero `LayoutId` binary from the accepted layout corpus. -pub(crate) const LAYOUT_ID: [u8; 60] = [ +pub const LAYOUT_ID: [u8; 60] = [ 0x4b, 0x45, 0x45, 0x50, 0x3a, 0x4c, 0x41, 0x59, 0x4f, 0x55, 0x54, 0x3a, 0x49, 0x44, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdc, 0x88, 0x7d, 0xa2, 0x3f, 0x1a, 0x74, 0x83, 0x35, 0x9a, 0x78, 0xfc, 0x9a, 0x7f, 0xde, 0x80, 0x03, 0x0e, 0xc2, 0xc4, 0x69, @@ -27,10 +27,10 @@ pub(crate) const LAYOUT_ID: [u8; 60] = [ ]; /// One golden receipt with its fixture name. -pub(crate) struct GoldenReceipt { - pub(crate) case: &'static str, - pub(crate) fixture: &'static str, - pub(crate) bytes: Vec, +pub struct GoldenReceipt { + pub case: &'static str, + pub fixture: &'static str, + pub bytes: Vec, } /// The scalar fields of one receipt in wire order, before its slots. @@ -49,14 +49,19 @@ struct Scalars { /// The durable coordinates the frozen version-2 store publishes: catalog /// generation two and the generation-one manifest. -pub(crate) struct DurableCoordinates { - pub(crate) catalog_generation: u64, - pub(crate) catalog_digest: [u8; 32], - pub(crate) liveness_generation: u64, - pub(crate) manifest_digest: [u8; 32], +pub struct DurableCoordinates { + pub catalog_generation: u64, + pub catalog_digest: [u8; 32], + pub liveness_generation: u64, + pub manifest_digest: [u8; 32], } -pub(crate) fn durable_coordinates() -> Result { +/// The durable view coordinates the golden durable receipt names. +/// +/// # Errors +/// +/// Returns a corpus error when a fixture does not decode. +pub fn durable_coordinates() -> Result { let catalog = decode_hex(V1_CATALOG_GENERATION_TWO.trim_end())?; let catalog_digest: [u8; 32] = catalog .get(320..352) @@ -81,15 +86,16 @@ pub(crate) fn durable_coordinates() -> Result { }) } +/// `identities` is `[subject, layout, target]`; `counts` is +/// `(evidence_index, chunks_verified)`. fn assemble( scalars: &Scalars, - subject: &[u8], - layout: &[u8], - target: &[u8], + identities: [&[u8]; 3], view: Option<&DurableCoordinates>, - evidence_index: u64, - chunks_verified: u64, + counts: (u64, u64), ) -> Result, io::Error> { + let [subject, layout, target] = identities; + let (evidence_index, chunks_verified) = counts; let mut bytes = Vec::with_capacity(384); bytes.extend_from_slice(b"KEEP:VERIFY:RCPT"); bytes.extend_from_slice(&1_u16.to_be_bytes()); @@ -140,7 +146,11 @@ fn assemble( } /// Every golden receipt, in `artifacts.tsv` order. -pub(crate) fn golden_receipts() -> Result, io::Error> { +/// +/// # Errors +/// +/// Returns a corpus error when a fixture or coordinate does not decode. +pub fn golden_receipts() -> Result, io::Error> { let durable = durable_coordinates()?; Ok(vec![ GoldenReceipt { @@ -159,12 +169,9 @@ pub(crate) fn golden_receipts() -> Result, io::Error> { supported_maximum: 0, target_present: 1, }, - &BLOB_ID, - &LAYOUT_ID, - &BLOB_ID, + [&BLOB_ID, &LAYOUT_ID, &BLOB_ID], None, - 0, - 1, + (0, 1), )?, }, GoldenReceipt { @@ -183,12 +190,9 @@ pub(crate) fn golden_receipts() -> Result, io::Error> { supported_maximum: 0, target_present: 0, }, - &LAYOUT_ID, - &LAYOUT_ID, - &[], + [&LAYOUT_ID, &LAYOUT_ID, &[]], Some(&durable), - 0, - 0, + (0, 0), )?, }, GoldenReceipt { @@ -207,12 +211,9 @@ pub(crate) fn golden_receipts() -> Result, io::Error> { supported_maximum: 5, target_present: 0, }, - &BLOB_ID, - &[], - &[], + [&BLOB_ID, &[], &[]], None, - 0, - 0, + (0, 0), )?, }, ]) From 666922a87dbe7e8499e730aba1969e1af9573da1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 30 Sep 2026 15:13:53 -0700 Subject: [PATCH 39/59] Fix: qualify the compaction and GC error doc links behind their aliases The compaction and GC authorities import their error types as `Error`, so the doc links naming the full types did not resolve under `cargo doc`; they now point at the `super` paths. Co-Authored-By: Claude Fable 5.1 --- src/adapters/compaction/filesystem.rs | 4 ++-- src/adapters/gc/filesystem_gc_authority.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/adapters/compaction/filesystem.rs b/src/adapters/compaction/filesystem.rs index 15a8ead5..d503be33 100644 --- a/src/adapters/compaction/filesystem.rs +++ b/src/adapters/compaction/filesystem.rs @@ -93,7 +93,7 @@ impl FilesystemCompactionAuthority { /// /// # Errors /// - /// Returns [`FilesystemCompactionError::Observe`] when the publication + /// Returns [`FilesystemCompactionError::Observe`](super::FilesystemCompactionError::Observe) when the publication /// directories cannot be pinned. pub fn open( admission: FilesystemVersionTwoAdmission, @@ -113,7 +113,7 @@ impl FilesystemCompactionAuthority { /// /// # Errors /// - /// Returns [`FilesystemCompactionError`] at the exact refusal; a + /// Returns [`FilesystemCompactionError`](super::FilesystemCompactionError) at the exact refusal; a /// publication refusal leaves the completed phases' residue for /// [`recover_compaction`](super::recover_compaction). pub fn execute(&mut self, plan: &CompactionPlan) -> Result { diff --git a/src/adapters/gc/filesystem_gc_authority.rs b/src/adapters/gc/filesystem_gc_authority.rs index e7a9a416..9c1b7f5c 100644 --- a/src/adapters/gc/filesystem_gc_authority.rs +++ b/src/adapters/gc/filesystem_gc_authority.rs @@ -129,7 +129,7 @@ impl FilesystemGcAuthority { /// /// # Errors /// - /// Returns [`FilesystemGcError`] at the exact observation, ambiguity, + /// Returns [`FilesystemGcError`](super::FilesystemGcError) at the exact observation, ambiguity, /// fence, or phase refusal. pub fn recover(&mut self) -> Result { self.context = None; @@ -178,7 +178,7 @@ impl FilesystemGcAuthority { /// /// # Errors /// - /// Returns [`FilesystemGcError`] when `gc` holds residue, readers hold + /// Returns [`FilesystemGcError`](super::FilesystemGcError) when `gc` holds residue, readers hold /// the fence, the plan names nothing, the re-observed store plans /// differently, or the intent refuses. pub fn prepare(&mut self, plan: &GcPlan) -> Result { From d0998b39dada402194df89c947f2367c646d1ab2 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 1 Oct 2026 08:52:46 -0700 Subject: [PATCH 40/59] Fix: preserve typed durable refusals and bound sealed-stage reads Refs #110, #129. --- docs/architecture/durable-store/ingestion.md | 3 +- docs/architecture/durable-store/rationale.md | 32 +++++++ src/adapters/compaction/recovery.rs | 2 +- src/adapters/durable/ingestion_bound_tests.rs | 49 ++++++++++ src/adapters/durable/mod.rs | 4 + src/adapters/durable/refusal_source_tests.rs | 90 +++++++++++++++++++ src/adapters/durable/snapshot.rs | 2 +- src/adapters/durable/staged.rs | 25 ++++-- src/adapters/filesystem_catalog_publisher.rs | 6 ++ .../filesystem_retention_snapshot.rs | 9 +- .../filesystem_retention_test_fixture.rs | 5 +- 11 files changed, 209 insertions(+), 18 deletions(-) create mode 100644 docs/architecture/durable-store/rationale.md create mode 100644 src/adapters/durable/ingestion_bound_tests.rs create mode 100644 src/adapters/durable/refusal_source_tests.rs diff --git a/docs/architecture/durable-store/ingestion.md b/docs/architecture/durable-store/ingestion.md index a2e46174..91bc3b6f 100644 --- a/docs/architecture/durable-store/ingestion.md +++ b/docs/architecture/durable-store/ingestion.md @@ -40,7 +40,8 @@ complete source does not hash to `expected`, naming both identities. `DurableStagedBlob::commit(self)` re-admits the current catalog, refuses with `CatalogMoved` if it is not the generation the staging was verified -against, reads the sealed stage back, admits it as a segment, binds it to +against, bounds the stage read by the length recorded when it was sealed, +refuses a changed length before payload allocation, admits it as a segment, binds it to the closed stage's record count, length, and digest, encodes the successor catalog naming every current segment and the new one, and runs `publish_catalog_generation`: the same twenty-six version-one crash diff --git a/docs/architecture/durable-store/rationale.md b/docs/architecture/durable-store/rationale.md new file mode 100644 index 00000000..7437cdcc --- /dev/null +++ b/docs/architecture/durable-store/rationale.md @@ -0,0 +1,32 @@ +# Durable admission rationale + +## Decision + +Before materializing a sealed ingestion stage, commit requires its current +regular-file length to equal the length recorded by `ClosedSegment`. The +exact-record boundary reads only that admitted length and refuses trailing +bytes. Segment admission and selection still verify the checksum, content, +record count, digest, and the authority that sealed the stage. + +An external writer can grow or replace a stage despite Keep's advisory writer +lock. The stage's current metadata cannot authorize additional allocation. +The trusted bound is the sealed work's length, derived by checked arithmetic +during ingestion. The stage remains recovery evidence after any refusal. + +## Alternatives rejected + +`read_to_end` on the current file allocates according to adversarial state, +before the segment decoder can refuse it. A bound inferred from current +metadata has the same problem. Streaming segment admission remains a separate +unfinished ingestion requirement; exact-length materialization fixes this +allocation boundary while preserving the current admission protocol. + +## Errors and evidence + +Typed failures remain available through `Error::source` and an `io::Error`'s +`get_ref`, without converting nested failures to strings. The laws in +`src/adapters/durable/refusal_source_tests.rs` preserve a selected root's +decoder and exact-record refusal. `ingestion_bound_tests.rs` grows a sealed +stage and asserts refusal before segment admission, an unchanged catalog head, +and retained stage evidence. There is no format or identity change, new sync, +or change to crash recovery. diff --git a/src/adapters/compaction/recovery.rs b/src/adapters/compaction/recovery.rs index ed19b2ef..3879d0e1 100644 --- a/src/adapters/compaction/recovery.rs +++ b/src/adapters/compaction/recovery.rs @@ -402,6 +402,6 @@ fn discard_derivable( .remove_file(name) .map_err(|source| refused("discard stage", source))?; exact_record::require_absent(staging, name) - .map_err(|source| refused("discard stage", io::Error::other(source.to_string())))?; + .map_err(|source| refused("discard stage", io::Error::other(source)))?; synchronize_directory(staging).map_err(|source| refused("synchronize staging", source)) } diff --git a/src/adapters/durable/ingestion_bound_tests.rs b/src/adapters/durable/ingestion_bound_tests.rs new file mode 100644 index 00000000..c2b4953d --- /dev/null +++ b/src/adapters/durable/ingestion_bound_tests.rs @@ -0,0 +1,49 @@ +//! A commit bounds stage admission by the length the writer actually sealed. + +use std::error::Error; +use std::fs::OpenOptions; +use std::io::Cursor; + +use super::{DurableIngestionError, DurableWriter}; +use crate::adapters::filesystem_exact_record::{ExactRecordError, ExactRecordRefusal}; +use crate::adapters::retention::filesystem_retention_test_fixture::{ + catalog_policy, migrated_store, +}; +use crate::{FilesystemVersionTwoAdmission, LayoutEntryLimit, StagingLimits}; + +#[test] +fn a_grown_sealed_stage_refuses_before_payload_allocation_or_publication() +-> Result<(), Box> { + let sandbox = migrated_store("durable-stage-length-bound")?; + let admission = FilesystemVersionTwoAdmission::reopen_unchecked_for_tests(sandbox.path())?; + let mut writer = DurableWriter::open(admission, sandbox.path(), catalog_policy()?)?; + let head = std::fs::read(sandbox.path().join("HEAD"))?; + let staged = writer.stage( + &mut Cursor::new(b"bounded stage"), + StagingLimits::entries(LayoutEntryLimit::MAXIMUM), + )?; + let path = sandbox.path().join("staging/current.seg"); + let file = OpenOptions::new().write(true).open(&path)?; + let changed_length = file + .metadata()? + .len() + .checked_add(1) + .ok_or("stage length overflow")?; + file.set_len(changed_length)?; + drop(file); + let error = staged.commit().err().ok_or("grown stage committed")?; + let DurableIngestionError::ReadStage { source } = error else { + return Err("stage length was not refused at the read boundary".into()); + }; + assert!(matches!( + source + .get_ref() + .and_then(|error| error.downcast_ref::()), + Some(ExactRecordError::Refused(ExactRecordRefusal::KindOrLength)) + )); + assert_eq!(std::fs::read(sandbox.path().join("HEAD"))?, head); + assert_eq!(std::fs::metadata(path)?.len(), changed_length); + drop(writer); + sandbox.remove()?; + Ok(()) +} diff --git a/src/adapters/durable/mod.rs b/src/adapters/durable/mod.rs index b30878e1..d24b4648 100644 --- a/src/adapters/durable/mod.rs +++ b/src/adapters/durable/mod.rs @@ -14,6 +14,8 @@ //! catalog successor through the version-one protocol. mod error; +#[cfg(test)] +mod ingestion_bound_tests; mod ingestion_error; mod ingestion_receipt; mod port; @@ -21,6 +23,8 @@ mod port; mod port_tests; mod receipt; mod recovery; +#[cfg(test)] +mod refusal_source_tests; mod snapshot; mod staged; mod store; diff --git a/src/adapters/durable/refusal_source_tests.rs b/src/adapters/durable/refusal_source_tests.rs new file mode 100644 index 00000000..833e5c82 --- /dev/null +++ b/src/adapters/durable/refusal_source_tests.rs @@ -0,0 +1,90 @@ +//! Durable admission preserves the exact selected-root decoding refusal. + +use std::error::Error; +use std::fs; + +use super::{DurableSnapshot, DurableStoreError}; +use crate::FilesystemRetentionSnapshot; +use crate::adapters::filesystem_exact_record::{ExactRecordError, ExactRecordRefusal}; +use crate::adapters::retention::filesystem_retention_test_fixture::{ + ROOT_HEX, catalog_policy, fixture, initial_preparation, open_authority, root_pool_path, +}; +use crate::{AdmittedRetentionRoot, ReaderAttemptLimit, RetentionRootDecodeError}; + +#[test] +fn a_corrupt_retained_root_keeps_its_typed_cause_through_durable_admission() +-> Result<(), Box> { + let (sandbox, mut authority) = open_authority("durable-root-refusal-source")?; + let mut bytes = fixture(ROOT_HEX)?; + let candidate = AdmittedRetentionRoot::decode(&bytes)?; + let path = root_pool_path(sandbox.path(), &candidate); + let preparation = initial_preparation(&bytes)?; + let _receipt = crate::execute_retention_publication(&mut authority, &preparation)?; + drop(authority); + *bytes.last_mut().ok_or("root checksum missing")? ^= 1; + fs::write(path, bytes)?; + let error = DurableSnapshot::open( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + ) + .err() + .ok_or("corrupt retained root admitted")?; + assert!(matches!(&error, DurableStoreError::RetainedRoot { .. })); + let mut cause: &(dyn Error + 'static) = &error; + loop { + if let Some(refusal) = cause.downcast_ref::() { + assert!(matches!( + refusal, + RetentionRootDecodeError::ChecksumMismatch { .. } + )); + break; + } + cause = if let Some(inner) = cause + .downcast_ref::() + .and_then(std::io::Error::get_ref) + { + inner + } else { + cause + .source() + .ok_or("typed root decoding cause was erased")? + }; + } + sandbox.remove()?; + Ok(()) +} + +#[test] +fn a_non_regular_selected_root_keeps_the_exact_record_refusal() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("durable-root-record-refusal")?; + let bytes = fixture(ROOT_HEX)?; + let candidate = AdmittedRetentionRoot::decode(&bytes)?; + let path = root_pool_path(sandbox.path(), &candidate); + let preparation = initial_preparation(&bytes)?; + let _receipt = crate::execute_retention_publication(&mut authority, &preparation)?; + drop(authority); + let snapshot = FilesystemRetentionSnapshot::load( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + )?; + fs::remove_file(&path)?; + fs::create_dir(&path)?; + let error = snapshot + .retained_root(candidate.root().namespace().digest()) + .err() + .ok_or("a non-regular root admitted")?; + let crate::FilesystemRetentionSnapshotError::Root { source } = error else { + return Err("wrong root refusal boundary".into()); + }; + assert!(matches!( + source + .get_ref() + .and_then(|error| error.downcast_ref::()), + Some(ExactRecordError::Refused(ExactRecordRefusal::KindOrLength)) + )); + drop(snapshot); + sandbox.remove()?; + Ok(()) +} diff --git a/src/adapters/durable/snapshot.rs b/src/adapters/durable/snapshot.rs index 5ea793c0..82d549a2 100644 --- a/src/adapters/durable/snapshot.rs +++ b/src/adapters/durable/snapshot.rs @@ -229,7 +229,7 @@ fn anchors( let refused = |source: io::Error| DurableStoreError::RetainedRoot { namespace, source }; let bytes = view .retained_root(namespace) - .map_err(|source| refused(io::Error::other(source.to_string())))? + .map_err(|source| refused(io::Error::other(source)))? .ok_or_else(|| refused(io::Error::other("the selected root is absent")))?; let root = AdmittedRetentionRoot::decode(&bytes) .map_err(|source| refused(io::Error::new(io::ErrorKind::InvalidData, source)))?; diff --git a/src/adapters/durable/staged.rs b/src/adapters/durable/staged.rs index f7b5b66d..b5c00217 100644 --- a/src/adapters/durable/staged.rs +++ b/src/adapters/durable/staged.rs @@ -2,7 +2,7 @@ //! stage's selection input, the identities it established, and the //! generation it was verified against. -use std::io::Read; +use std::io; use super::DurableIngestionError as Error; use super::ingestion_receipt::{DurableIngestionReceipt, IngestionAccounting}; @@ -110,7 +110,7 @@ impl<'writer> DurableStagedBlob<'writer> { accounting, )); }; - let new_bytes = read_stage(writer)?; + let new_bytes = read_stage(writer, &closed)?; let policy = writer.policy.segment_read(); let new_segment = AdmittedSegment::decode(&new_bytes, policy) .map_err(|source| Error::Segment(Box::new(source)))?; @@ -153,11 +153,18 @@ impl<'writer> DurableStagedBlob<'writer> { } } -fn read_stage(writer: &DurableWriter) -> Result, Error> { - let mut file = exact_record::open_read(&writer.publisher.staging, CURRENT_SEGMENT) - .map_err(|source| Error::ReadStage { source })?; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes) - .map_err(|source| Error::ReadStage { source })?; - Ok(bytes) +fn read_stage(writer: &DurableWriter, closed: &ClosedSelection) -> Result, Error> { + let length = usize::try_from(closed.segment_length()).map_err(|source| Error::ReadStage { + source: io::Error::new(io::ErrorKind::InvalidData, source), + })?; + exact_record::read_exact_regular(&writer.publisher.staging, CURRENT_SEGMENT, length).map_err( + |source| Error::ReadStage { + source: match source { + exact_record::ExactRecordError::Io(source) => source, + refused @ exact_record::ExactRecordError::Refused(_) => { + io::Error::new(io::ErrorKind::InvalidData, refused) + } + }, + }, + ) } diff --git a/src/adapters/filesystem_catalog_publisher.rs b/src/adapters/filesystem_catalog_publisher.rs index 62f28962..46796d97 100644 --- a/src/adapters/filesystem_catalog_publisher.rs +++ b/src/adapters/filesystem_catalog_publisher.rs @@ -24,6 +24,12 @@ pub(super) struct ClosedSelection { authority: FilesystemPublisherAuthority, } +impl ClosedSelection { + pub(super) const fn segment_length(&self) -> u64 { + self.closed.segment_length() + } +} + pub(super) const CURRENT_CATALOG: &str = "current.cat"; pub(super) const HEAD: &str = "HEAD"; pub(super) const NEXT_HEAD: &str = "head.next"; diff --git a/src/adapters/retention/filesystem_retention_snapshot.rs b/src/adapters/retention/filesystem_retention_snapshot.rs index fd968256..3ab90aa1 100644 --- a/src/adapters/retention/filesystem_retention_snapshot.rs +++ b/src/adapters/retention/filesystem_retention_snapshot.rs @@ -228,7 +228,10 @@ impl FilesystemRetentionSnapshot { Err(ExactRecordError::Io(source)) => return Err(Error::Root { source }), Err(ExactRecordError::Refused(refusal)) => { return Err(Error::Root { - source: invalid_string(format!("selected root refused: {refusal}")), + source: io::Error::new( + io::ErrorKind::InvalidData, + ExactRecordError::Refused(refusal), + ), }); } }; @@ -249,7 +252,3 @@ impl FilesystemRetentionSnapshot { fn invalid(message: &'static str) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message) } - -fn invalid_string(message: String) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message) -} diff --git a/src/adapters/retention/filesystem_retention_test_fixture.rs b/src/adapters/retention/filesystem_retention_test_fixture.rs index 66b18039..bc17418e 100644 --- a/src/adapters/retention/filesystem_retention_test_fixture.rs +++ b/src/adapters/retention/filesystem_retention_test_fixture.rs @@ -200,7 +200,10 @@ pub(super) fn head_path(root: &Path) -> PathBuf { root.join("retention").join("HEAD") } -pub(super) fn root_pool_path(root: &Path, candidate: &AdmittedRetentionRoot<'_>) -> PathBuf { +pub(in crate::adapters) fn root_pool_path( + root: &Path, + candidate: &AdmittedRetentionRoot<'_>, +) -> PathBuf { root.join("retention") .join("roots") .join(hex(candidate.root().namespace().digest().as_bytes())) From 652059e74573fd22d27884e7734122bd3dfe469a Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 1 Oct 2026 08:52:46 -0700 Subject: [PATCH 41/59] Refactor: move transfer-source ports into the store boundary Refs #127. --- docs/architecture/content-store/rationale.md | 28 +++++++++++++++++++ src/adapters/durable/transfer_source.rs | 2 ++ src/adapters/pipeline/mod.rs | 3 +- src/reference/chunk_reader.rs | 2 +- src/reference/transfer_source.rs | 4 ++- src/store/mod.rs | 7 +++++ .../source.rs => store/transfer_source.rs} | 7 ++--- tests/transfer_port_architecture.rs | 11 ++++++++ 8 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 docs/architecture/content-store/rationale.md rename src/{adapters/pipeline/source.rs => store/transfer_source.rs} (94%) create mode 100644 tests/transfer_port_architecture.rs diff --git a/docs/architecture/content-store/rationale.md b/docs/architecture/content-store/rationale.md new file mode 100644 index 00000000..24964d43 --- /dev/null +++ b/docs/architecture/content-store/rationale.md @@ -0,0 +1,28 @@ +# Content-store port rationale + +## Decision + +`TransferSource`, `StreamConsumer`, and `TransferSourceError` belong to the +content-store port in `src/store/transfer_source.rs`. Reference and durable +adapters implement the capability; pipeline adapters consume it. Sealing +implementations stay with the adapters, so the port imports neither backend. + +The transfer port expresses a real substitution boundary: a consumer pulls +authenticated bytes from either reference memory or a fenced durable view. +Its semantic identities, borrowed reader, and typed refusals contain no +physical location or serializer value. Public crate-root names stay unchanged. + +## Alternatives rejected + +Keeping the capability in `adapters/pipeline` made reference chunk reads import +an adapter-owned error. That reversed the dependency direction required by +[ADR-0004](../../adr/0004-hexagonal-boundary-architecture.md). Duplicating the +capability or its failures would create two contracts for the same operation. + +## Evidence and compatibility + +`tests/transfer_port_architecture.rs` rejects adapter imports in the reference +pull reader and source. Existing transfer, copy, corruption, cancellation, and +memory laws exercise both implementations. This ownership correction changes +no content identity, public method, durable format, write order, or recovery +protocol and adds no allocation or I/O. diff --git a/src/adapters/durable/transfer_source.rs b/src/adapters/durable/transfer_source.rs index 57356aca..211f9268 100644 --- a/src/adapters/durable/transfer_source.rs +++ b/src/adapters/durable/transfer_source.rs @@ -6,6 +6,8 @@ use crate::LayoutId; use crate::adapters::{StreamConsumer, TransferSource, TransferSourceError}; use crate::reference::ChunkReader; +impl crate::store::SealedTransferSource for DurableSnapshot {} + impl TransferSource for DurableSnapshot { fn stream_layout( &self, diff --git a/src/adapters/pipeline/mod.rs b/src/adapters/pipeline/mod.rs index 3e79941a..78ec1e08 100644 --- a/src/adapters/pipeline/mod.rs +++ b/src/adapters/pipeline/mod.rs @@ -19,18 +19,17 @@ mod copy; mod error; mod receipt; mod sink; -mod source; #[cfg(test)] mod tests; mod transfer; mod window; +pub use crate::store::{StreamConsumer, TransferSource, TransferSourceError}; pub use cancellation::{CancellationFlag, CancellationSignal, NeverCancelled}; pub use copy::{CopyError, CopyReceipt, copy_layout}; pub use error::TransferError; pub use receipt::TransferReceipt; pub use sink::{TransferSegment, TransferSink, WriteSink, WriteSinkError}; -pub use source::{StreamConsumer, TransferSource, TransferSourceError}; pub use transfer::{ TransferBounds, transfer_blob, transfer_layout, transfer_layout_range, transfer_range, }; diff --git a/src/reference/chunk_reader.rs b/src/reference/chunk_reader.rs index fb116001..df76c498 100644 --- a/src/reference/chunk_reader.rs +++ b/src/reference/chunk_reader.rs @@ -5,7 +5,7 @@ use std::io::{self, Read}; use super::chunk_verification::{ChunkSource, ChunkVerificationError, verified_chunk}; -use crate::adapters::TransferSourceError; +use crate::store::TransferSourceError; use crate::{AdmittedLayout, LayoutId}; /// Serves one layout's bytes in order, one verified chunk at a time. diff --git a/src/reference/transfer_source.rs b/src/reference/transfer_source.rs index b012c051..98c1705d 100644 --- a/src/reference/transfer_source.rs +++ b/src/reference/transfer_source.rs @@ -3,7 +3,9 @@ use super::ReferenceStore; use super::chunk_reader::ChunkReader; use crate::LayoutId; -use crate::adapters::{StreamConsumer, TransferSource, TransferSourceError}; +use crate::store::{StreamConsumer, TransferSource, TransferSourceError}; + +impl crate::store::SealedTransferSource for ReferenceStore {} impl TransferSource for ReferenceStore { fn stream_layout( diff --git a/src/store/mod.rs b/src/store/mod.rs index d32336a0..6dcc4238 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -27,7 +27,14 @@ mod reads; #[cfg(test)] mod reference_port_tests; mod staging; +mod transfer_source; pub use limits::{StagedByteLimit, StagingLimits}; pub use reads::ContentReads; pub use staging::{CommitReceipt, ContentStaging, StagedContent}; +#[expect( + clippy::redundant_pub_crate, + reason = "adapter sealing is reachable only through this crate-private port re-export" +)] +pub(crate) use transfer_source::sealed::Sealed as SealedTransferSource; +pub use transfer_source::{StreamConsumer, TransferSource, TransferSourceError}; diff --git a/src/adapters/pipeline/source.rs b/src/store/transfer_source.rs similarity index 94% rename from src/adapters/pipeline/source.rs rename to src/store/transfer_source.rs index 440b7622..7895ec32 100644 --- a/src/adapters/pipeline/source.rs +++ b/src/store/transfer_source.rs @@ -1,4 +1,4 @@ -//! This boundary module owns the pull side: a source that streams one +//! This port module owns the pull side: a source that streams one //! layout's chunks, authenticated as they are served, into a consumer. use std::error::Error; @@ -7,11 +7,8 @@ use std::io::Read; use crate::{BlobId, ChunkId, LayoutId}; -mod sealed { +pub(super) mod sealed { pub trait Sealed {} - - impl Sealed for crate::ReferenceStore {} - impl Sealed for crate::adapters::DurableSnapshot {} } /// The consumer of one streamed layout: the blob it identifies and a diff --git a/tests/transfer_port_architecture.rs b/tests/transfer_port_architecture.rs new file mode 100644 index 00000000..5b8f171b --- /dev/null +++ b/tests/transfer_port_architecture.rs @@ -0,0 +1,11 @@ +//! Transfer capabilities point inward from both storage adapters. + +#[test] +fn reference_chunk_reads_do_not_depend_on_transfer_adapters() { + for source in [ + include_str!("../src/reference/chunk_reader.rs"), + include_str!("../src/reference/transfer_source.rs"), + ] { + assert!(!source.contains("crate::adapters")); + } +} From 03dc989746b4dd38470105fc257da104e4f8ffb6 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 1 Oct 2026 08:52:46 -0700 Subject: [PATCH 42/59] Fix: count authenticated chunks once in read benchmarks Refs #71. --- .../c0f3ca2-aarch64-apple-darwin.tsv | 39 +++++++++++++++++ benchmark/src/scenario_range_metrics.rs | 7 +-- benchmark/src/scenario_read.rs | 10 +---- benchmark/src/scenario_tests.rs | 43 +++++++++++++++++-- docs/architecture/reference-store/README.md | 4 +- .../architecture/reference-store/rationale.md | 8 ++-- .../streaming-cas-baseline-v1/README.md | 20 ++++++--- 7 files changed, 101 insertions(+), 30 deletions(-) create mode 100644 benchmark/baselines/c0f3ca2-aarch64-apple-darwin.tsv diff --git a/benchmark/baselines/c0f3ca2-aarch64-apple-darwin.tsv b/benchmark/baselines/c0f3ca2-aarch64-apple-darwin.tsv new file mode 100644 index 00000000..3efd63a1 --- /dev/null +++ b/benchmark/baselines/c0f3ca2-aarch64-apple-darwin.tsv @@ -0,0 +1,39 @@ +schema keep.streaming-cas-baseline/v1 +metadata build-profile optimized-release +metadata git-commit c0f3ca21a349acd8ea43b8dc73fe453ee2bd212e +metadata git-tree clean +metadata rustc-version rustc 1.96.0 (ac68faa20 2026-05-25) +metadata target-triple aarch64-apple-darwin +metadata os-description Darwin 27.0.0 arm64 +metadata cpu-model Apple M5 Pro +metadata cpu-clock process +metadata peak-memory incremental-live-heap +metadata verification mandatory +metadata timing-unit nanoseconds +metadata byte-unit bytes +metadata ratio-encoding exact-numerator-denominator +metadata logical-cpu-count 18 +metadata sample-count 100 +metadata warmup-count 5 +scenario-header name verification sample-count logical-bytes physical-bytes-read physical-bytes-written source-bytes-read output-bytes-written read-amplification-numerator read-amplification-denominator write-amplification-numerator write-amplification-denominator deduplication-ratio-numerator deduplication-ratio-denominator reused-unique-chunks chunk-instances operation-count logical-bytes-per-second total-wall-time-ns p50-wall-time-ns p95-wall-time-ns p99-wall-time-ns total-cpu-time-ns p50-cpu-time-ns p95-cpu-time-ns p99-cpu-time-ns total-allocation-count total-allocated-bytes peak-live-allocation-count peak-live-heap-bytes +scenario cold-ingest ingest-chunk-and-blob-identity 100 1048576 0 1048576 1048576 0 0 1048576 1048576 1048576 1048576 1048576 0 13 1 250694274 418268827 4193875 4334458 4370917 418054000 4194000 4329000 4365000 2800 132013200 20 1313464 +scenario warm-ingest ingest-chunk-and-blob-identity 100 1048576 1048576 0 1048576 0 1048576 1048576 0 1048576 1048576 0 13 13 1 219258844 478236580 4299667 7353416 8420042 453885000 4299000 5605000 6259000 600 26508400 3 263488 +scenario repeated-near-neighbor-edits ingest-chunk-and-blob-identity 100 8388608 6191702 2196906 8388608 0 6191702 8388608 2196906 8388608 8388608 2196906 74 100 4 252561064 3321417746 32958458 34565041 34702292 3319002000 32925000 34565000 34693000 6800 327809000 40 2470386 +scenario early-insertion ingest-chunk-and-blob-identity 100 4198400 1997398 2201002 4198400 0 1997398 4198400 2201002 4198400 4198400 2201002 24 50 2 256471017 1636988085 16356208 16633042 16706500 1635736000 16343000 16617000 16689000 5400 274622600 38 2472826 +scenario early-deletion ingest-chunk-and-blob-identity 100 4190208 1997398 2192810 4190208 0 1997398 4190208 2192810 4190208 4190208 2192810 24 50 2 255741428 1638454911 16360792 16644417 16731292 1637175000 16352000 16630000 16715000 5400 273803400 38 2464634 +scenario many-tiny-blobs ingest-chunk-and-blob-identity 100 32896 0 32896 32896 0 0 32896 32896 32896 32896 32896 0 256 256 58399191 56329547 560458 599250 615250 56326000 561000 596000 608000 191200 6764795200 889 538368 +scenario large-binary ingest-chunk-and-blob-identity 100 1048576 0 1048576 1048576 0 0 1048576 1048576 1048576 1048576 1048576 0 15 1 256214500 409257086 4089750 4229708 4260000 408967000 4089000 4230000 4257000 3000 132031600 22 1313464 +scenario high-deduplication ingest-chunk-and-blob-identity 100 4194304 2097152 2097152 4194304 0 2097152 4194304 2097152 4194304 4194304 2097152 25 50 2 256233383 1636907709 16344875 16669958 16706500 1635603000 16327000 16662000 16692000 5000 264075200 36 2368392 +scenario zero-deduplication ingest-chunk-and-blob-identity 100 3145728 0 3145728 3145728 0 0 3145728 3145728 3145728 3145728 3145728 0 44 2 256173502 1227967750 12274209 12508958 12570709 1227048000 12259000 12490000 12554000 7500 369104000 58 3417440 +scenario sequential-range-reads selected-complete-chunks 100 1048576 3413411 0 0 1048576 3413411 1048576 0 1048576 1048576 0 0 46 32 420710841 249239120 2490542 2545584 2559459 249077000 2485000 2544000 2560000 0 0 0 0 +scenario random-range-reads selected-complete-chunks 100 131072 2396798 0 0 131072 2396798 131072 0 131072 131072 0 0 33 32 74801081 175227414 1744459 1792500 1804583 175117000 1745000 1792000 1798000 0 0 0 0 +scenario whole-blob-verification chunks-profile-and-blob 100 1048576 1048576 0 0 1048576 1048576 1048576 0 1048576 1048576 0 0 15 1 268940380 389891618 3889666 3972375 3993750 389626000 3887000 3971000 3986000 0 0 0 0 +scenario varied-input-partitioning ingest-chunk-and-blob-identity 100 262144 0 262144 262144 0 0 262144 262144 262144 262144 262144 0 4 4 139541802 187860552 1881625 1954333 1963875 187737000 1875000 1948000 1965000 4000 132846400 6 328488 +profile-header name provenance minimum-kib target-kib maximum-kib timed-input sample-count logical-bytes-per-second total-wall-time-ns p50-wall-time-ns p95-wall-time-ns p99-wall-time-ns total-cpu-time-ns total-allocation-count total-allocated-bytes peak-live-heap-bytes base-unique-chunks base-materialized-bytes insertion-reused-chunks deletion-reused-chunks neighbor-reused-chunks +profile keep-fastcdc-4-16-64 keep.fastcdc-gear64/v1 4 16 64 large-text 100 840725273 124722788 1235542 1321875 1347625 124664000 200 364000 3640 86 2097152 81 85 85 +profile keep-fastcdc-16-64-256 keep.fastcdc-gear64/v1 16 64 256 large-text 100 856217199 122466122 1209541 1301791 1318125 122405000 200 98800 988 25 2097152 24 24 24 +profile keep-fastcdc-64-256-1024 keep.fastcdc-gear64/v1 64 256 1024 large-text 100 854822922 122665873 1218125 1307167 1314583 122613000 200 24400 244 5 2097152 4 4 4 +profile fixed-64 benchmark.fixed-size/v1 64 64 64 large-text 100 1363907428 76880291 759917 792750 799500 76845000 200 71200 712 32 2097152 0 0 31 +profile git-cas-buzhash-64-256-1024 git-cas@432c5d9effb12c9f66536f1386791bb4421f3cea 64 256 1024 large-text 100 642838557 163116538 1640458 1672208 1682750 163048000 200 24400 244 5 2097152 4 4 4 +threshold-header metric status rationale +threshold all-performance-metrics unconfigured requires-controlled-baseline-history diff --git a/benchmark/src/scenario_range_metrics.rs b/benchmark/src/scenario_range_metrics.rs index d8e784f0..aa66b724 100644 --- a/benchmark/src/scenario_range_metrics.rs +++ b/benchmark/src/scenario_range_metrics.rs @@ -79,7 +79,7 @@ fn authenticated_bytes(layout: &AdmittedLayout, plan: RangePlan) -> Result Result Result<(), Box< assert!(random.authenticated_chunk_bytes_read() >= random.output_bytes_written()); assert_eq!( verification.authenticated_chunk_bytes_read(), - verification - .logical_bytes() - .checked_mul(2) - .ok_or("verification read overflow")? + verification.logical_bytes() ); assert_eq!(partitioned.source_bytes_read(), partitioned.logical_bytes()); Ok(()) @@ -77,3 +74,41 @@ fn timed_range_execution_contains_no_accounting_plans() { assert!(!TIMED_RANGE_SOURCE.contains("selected_entry_count")); assert!(!TIMED_RANGE_SOURCE.contains("plan_range")); } + +#[test] +fn range_accounting_counts_complete_selected_chunks_once() -> Result<(), Box> { + use keep::{ByteLength, ByteOffset, ByteRange}; + + let corpus = BenchmarkCorpus::generate()?; + let scenario = Scenario::SequentialRangeReads; + let (store, target, layout) = + crate::scenario_ingest::published_store(corpus.large_binary(), scenario)?; + let length = target.logical_length().get(); + let requests = [ + ByteRange::new(ByteOffset::new(0), ByteLength::new(1))?, + ByteRange::new(ByteOffset::new(0), ByteLength::new(length))?, + ByteRange::new(ByteOffset::new(length), ByteLength::new(0))?, + ]; + let ranges = crate::scenario_range_metrics::prepare(scenario, &layout, &requests)?; + let observation = crate::scenario_read::run_ranges(scenario, &store, target, &ranges)?; + let first_chunk_length = u64::from( + layout + .entries() + .first() + .ok_or("first chunk missing")? + .chunk_id() + .length() + .get(), + ); + assert_eq!( + observation.authenticated_chunk_bytes_read(), + length + .checked_add(first_chunk_length) + .ok_or("accounting overflow")? + ); + assert_eq!( + observation.output_bytes_written(), + length.checked_add(1).ok_or("output overflow")? + ); + Ok(()) +} diff --git a/docs/architecture/reference-store/README.md b/docs/architecture/reference-store/README.md index c3ef2a9d..bf115f9d 100644 --- a/docs/architecture/reference-store/README.md +++ b/docs/architecture/reference-store/README.md @@ -99,7 +99,7 @@ then emits the verified chunks by identity. Before output it: 2. replays `fastcdc-64k-v1` and compares every boundary with the layout; and 3. verifies the complete byte sequence against the target `BlobId`. -Only after all three checks succeed does it reverify and emit each chunk. Short +Only after all three checks succeed does it emit each verified chunk. Short writes are completed, interruptions are retried, and broken writer counts are typed refusals. The committed-layout path allocates no adapter-owned heap memory; any allocation by the supplied writer belongs to that writer. @@ -126,7 +126,7 @@ planning, receipt coordinates, and chunk lookup use only the committed layout. None of the range APIs materializes the complete blob. Before any output, a range read authenticates every selected complete chunk -against its `ChunkId`. During the output pass it reauthenticates each chunk, +against its `ChunkId`. During the output pass it fetches each verified chunk, slices only the overlap, completes short writes, retries interruptions, and uses checked output accounting. Invalid layouts, out-of-bounds coordinates, missing or mismatched selected chunks, broken writers, and output failures are diff --git a/docs/architecture/reference-store/rationale.md b/docs/architecture/reference-store/rationale.md index f360eaf1..59811f8b 100644 --- a/docs/architecture/reference-store/rationale.md +++ b/docs/architecture/reference-store/rationale.md @@ -92,15 +92,15 @@ the minimal-overlap capability and turn a range API into disguised whole-blob I/O. Range reads therefore plan from admitted metadata, authenticate every complete -overlapping chunk before output, then reauthenticate each chunk immediately -before slicing and emission. Their receipt names the requested range and +overlapping chunk before output, then fetch each verified immutable chunk +by identity for slicing and emission. Their receipt names the requested range and explicitly does not claim complete-blob identity, unrequested chunks, or storage-profile boundaries. Callers choose whole-blob reconstruction when they need those stronger claims. Preverification ensures a later selected chunk cannot fail after an earlier -range byte has been emitted. Reverification protects the separate output pass -without buffering selected chunks or the requested result. +range byte has been emitted. The immutable in-memory view protects the separate +output pass without a second hash or buffering selected chunks or the result. ## Why caller-supplied ranges require a committed layout diff --git a/docs/benchmarks/streaming-cas-baseline-v1/README.md b/docs/benchmarks/streaming-cas-baseline-v1/README.md index 3d6473ea..073b46ea 100644 --- a/docs/benchmarks/streaming-cas-baseline-v1/README.md +++ b/docs/benchmarks/streaming-cas-baseline-v1/README.md @@ -16,6 +16,14 @@ The reference evidence artifact measures source commit This single-host result is a methodology and baseline witness, not a marketing claim, portability claim, optimization mandate, or correctness proof. +The corrected single-pass authentication accounting is witnessed by +[c0f3ca2-aarch64-apple-darwin.tsv](../../../benchmark/baselines/c0f3ca2-aarch64-apple-darwin.tsv), +captured from clean source commit +`c0f3ca21a349acd8ea43b8dc73fe453ee2bd212e` on the local +`audit/roadmap-benchmark-source` branch with Rust 1.96.0. Its host differs +from the historical baseline; timings do not establish a CPU speedup between +these artifacts. + ## Run the baseline From the repository root: @@ -100,7 +108,7 @@ is a typed nondeterminism failure. Verification has no disabled state. Ingest authenticates chunk and complete blob identity. Range reads authenticate every selected complete chunk before -and during output. Whole reconstruction authenticates chunks, profile +output. Whole reconstruction authenticates chunks, profile boundaries, and the complete named blob. ## Chunking-profile comparison @@ -149,10 +157,12 @@ denominator separately and never use floating-point serialization. A zero denominator means the operation materialized no bytes; consumers must retain that exact state instead of inventing infinity, zero, or a substitute value. -Whole-blob verification reads each complete chunk twice: once before output -and once while emitting authenticated bytes. Its expected read amplification -is therefore exactly `2 / 1`. Range-read amplification includes every complete -selected chunk in both passes, not only returned slices. +Whole-blob verification authenticates each complete chunk once before output. +Its expected authenticated read amplification is exactly `1 / 1`. +Range-read amplification includes every complete selected chunk once, not only +returned slices. Emission borrows already verified immutable bytes and does not +add another authentication pass. The historical `c529c07` baseline predates +this change and records the earlier two-hash behavior. ## Regression threshold policy From 420964ebbedc583f2b0eb43366e734ae64a26ce5 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 1 Oct 2026 08:52:46 -0700 Subject: [PATCH 43/59] Test: cover retention release and restore in model sequences Refs #128. Update existing crash evidence alongside model ledger entries. --- docs/formats/segment-store-v2/requirements.md | 4 +- .../retention/retention_model_tests.rs | 97 ++++++++++++++----- 2 files changed, 73 insertions(+), 28 deletions(-) diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index 7fe27931..98181927 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -14,11 +14,11 @@ case is not evidence. | `KEEP-RETENTION-003` | Every structural field, truncation boundary, ordering law, duplicate, overflow, flag, reserved byte, digest, checksum, and trailing byte has a precise refusal | field-complete corruption ledger `conformance/segment-store/v2/mutations.tsv` (root, manifest, head) reproduced by `tests/segment_store_mutations.rs`; one sealed mutation per header, body, and trailer field with its exact first refusal, plus reframed namespace-bound, ordering, and count-ceiling cases, in `tests/retention_root_decoding/mutation_laws.rs`, `tests/retention_manifest_codec/mutation_laws.rs`, and `tests/retention_head_codec/mutation_laws.rs`; framing and integrity precedence in `tests/retention_root_decoding.rs`, `tests/retention_manifest_codec/refusal_laws.rs`, and `tests/retention_head_codec.rs`; seeded `retention_format` fuzz target | Implemented | | `KEEP-RETENTION-004` | Retain and release compare expected and observed generations and publish exact successors only | unforgeable readiness and preflight proofs in `tests/retention_transition.rs` and `tests/retention_preflight.rs`; exact successor preparation and complete receipt evidence in `tests/retention_publication_preparation.rs` and `tests/retention_publication_execution.rs`; writer-locked initial filesystem publication in `filesystem_retention_storage_tests`; observed-head successor publication, exact predecessor binding, and absent-head refusal in `filesystem_retention_successor_tests`; the store's catalog head must name the closure's catalog generation and digest before any forward write in `filesystem_retention_catalog_tests`; a head whose predecessor disagrees with its manifest refuses in `filesystem_retention_current_tests`; a successor reopens and decodes the manifest-selected predecessor root and refuses an absent or changed one in `filesystem_retention_expectation_tests` | Implemented | | `KEEP-RETENTION-005` | Closure derivation is deterministic, bounded, cycle-safe, fail-closed, and verifies complete blob reconstruction | exact accounting, reconstruction, adversarial-catalog, and exhaustive model laws in `tests/retention_closure.rs`; corrupt members refuse through the inherited segment-record admission laws and seeded `segment_format` fuzz target routed by `closure-corruption.md`; publication re-reads every member under filesystem authority and surfaces the exact admission error as the refusal's `source` in `filesystem_retention_member_tests` | Implemented | -| `KEEP-RETENTION-006` | Publication follows the exact ordered durability protocol, including new namespace-directory admission and retention of fixed-stage evidence until head commit, and returns only after cleanup synchronization | typed vocabulary and blocking port in `tests/retention_publication_phase.rs` and `tests/retention_publication_storage.rs`; ordered execution, conditional namespace sync, and all 17 exact storage-fault boundaries in `tests/retention_publication_execution.rs`; production 17-phase forward filesystem execution, exclusive staging, byte-equal inode-substitution refusal, and retained-stage recovery refusal in `filesystem_retention_storage_tests`; orphan namespace directories count against the 4,096 ceiling and refuse a new namespace before any stage is written in `filesystem_retention_capacity_tests`; crash injection remains | In progress in #19 | +| `KEEP-RETENTION-006` | Publication follows the exact ordered durability protocol, including new namespace-directory admission and retention of fixed-stage evidence until head commit, and returns only after cleanup synchronization | typed vocabulary and blocking port in `tests/retention_publication_phase.rs` and `tests/retention_publication_storage.rs`; ordered execution, conditional namespace sync, and all 17 exact storage-fault boundaries in `tests/retention_publication_execution.rs`; production 17-phase forward filesystem execution, exclusive staging, byte-equal inode-substitution refusal, and retained-stage recovery refusal in `filesystem_retention_storage_tests`; orphan namespace directories count against the 4,096 ceiling and refuse a new namespace before any stage is written in `filesystem_retention_capacity_tests`; 51 killed-writer cases across KEEP-CRASH-036 through 052 in `cargo xtask durability-crash-matrix --sequence retention` | Implemented | | `KEEP-RETENTION-007` | Restart resolves every fixed-stage crash prefix to one documented lawful state or typed ambiguity | recovery-required refusals before any mutation in `filesystem_retention_expectation_tests`: an absent head over populated pools, a non-initial head prepared against an absent head, an orphan directory for a namespace expected absent, and an absent directory for a namespace expected current; replaced protocol directories, an absent or changed head-selected catalog, an over-full census, zero-generation pool names, and a stage retained by a failed write refuse in `filesystem_retention_*_tests`; storage-independent classification of every fixed-stage crash prefix (discard, link and protect, finalize, clean up, or typed refusal) in `recovery_planner_tests`; every publication prefix 0 through 18, each mid-write truncation, and successor prefixes recover in-process to the documented state, idempotently, with the forward retry reporting the predicted outcome, in `filesystem_retention_recovery_prefix_tests`; `cargo xtask durability-crash-matrix` kills a real writer before, during, and after `KEEP-CRASH-036` through `052` and requires restart recovery to reach the documented state and the forward retry to report the predicted outcome | Implemented | | `KEEP-RETENTION-008` | Readers double-collect catalog and retention heads and bind one complete catalog, manifest, and root-generation view under a `ReaderFence` | `ReaderFence` holds a shared kernel lock on a verified zero-length `reader.lock`; `collect_retention_view` accepts a view only when both head coordinates agree before and after loading and refuses an exhausted attempt limit (`retention_view_collector_tests`); `FilesystemRetentionSnapshot` binds the catalog snapshot, retention head, and manifest under the fence and verifies each selected root on demand while the fence is held, refusing a substituted root and a replaced fence, and two readers share the fence while an exclusive lock waits (`filesystem_retention_snapshot_tests`) | Implemented | | `KEEP-RETENTION-009` | Exact already-committed retry is idempotent only while its successor remains current | byte-identical planning in `tests/retention_transition.rs`; authority-revalidated zero-mutation retry receipt in `tests/retention_publication_execution.rs`; exact already-committed filesystem retry with a byte-identical retention witness in `filesystem_retention_storage_tests`; superseded-candidate filesystem refusal with zero mutation in `filesystem_retention_successor_tests`; committed retry reopens the head-selected manifest entry and root pool bytes, refusing absent, changed, or corrupt evidence in `filesystem_retention_current_tests`; every refusal is a typed `RetentionCurrentStateRefusal` source, with superseded, committed-root-absent, committed-root-changed, and head-absent-with-artifacts pinned by downcast | Implemented | -| `KEEP-RETENTION-010` | Model operation sequences agree with a deterministic namespace-to-anchor-set map and never admit caller identity, paths, clocks, or application policy | every three-operation sequence over initial publications of two namespaces, a successor, a byte-identical retry, and a stale initial (125 sequences, each in a fresh migrated store) agrees with a deterministic namespace-to-(generation, anchor-set) map plus liveness after every step, observed through the fenced reader view, in `retention_model_tests`; `tests/retention_core_architecture_contract.rs` refuses any clock, path, environment, or identity token in the retention core | Implemented | +| `KEEP-RETENTION-010` | Model operation sequences agree with a deterministic namespace-to-anchor-set map and never admit caller identity, paths, clocks, or application policy | every three-operation sequence over initial publications of two namespaces, a successor, a byte-identical retry, a stale initial, release, and restore (343 sequences, each in a fresh migrated store) agrees with a deterministic namespace-to-(generation, anchor-set) map plus liveness after every step, observed through the fenced reader view, in `retention_model_tests`; `tests/retention_core_architecture_contract.rs` refuses any clock, path, environment, or identity token in the retention core | Implemented | diff --git a/src/adapters/retention/retention_model_tests.rs b/src/adapters/retention/retention_model_tests.rs index 55c7ef84..7e1d3928 100644 --- a/src/adapters/retention/retention_model_tests.rs +++ b/src/adapters/retention/retention_model_tests.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use super::filesystem_retention_test_fixture::{ ROOT_HEX, fixture, initial_preparation, initial_root, new_namespace_preparation, - open_authority, successor_preparation, successor_root, + open_authority, successor_preparation, }; use super::{ AdmittedRetentionManifest, AdmittedRetentionRoot, FilesystemRetentionPublicationAuthority, @@ -19,7 +19,8 @@ use crate::adapters::{ CatalogRestartByteLimit, CatalogRestartPolicy, SegmentReadPolicy, SegmentRecordLimit, }; use crate::{ - LayoutEntryLimit, RetentionAnchor, RetentionNamespaceDigest, execute_retention_publication, + CanonicalRetentionRoot, LayoutEntryLimit, RetentionAnchor, RetentionNamespaceDigest, + RetentionPolicy, RetentionRoot, execute_retention_publication, }; const NAMESPACE_B: &[u8] = b"model-namespace-b"; @@ -36,16 +37,22 @@ enum Operation { Initial(Namespace), /// Publish the exact successor of namespace A from a fresh view. Successor, + /// Release every anchor of namespace A through an exact successor. + Release, + /// Restore namespace A's original anchors through an exact successor. + Restore, /// Replay the last accepted publication byte for byte. RetryLast, /// Publish generation one of namespace A from a view that predates it. StaleInitial, } -const OPERATIONS: [Operation; 5] = [ +const OPERATIONS: [Operation; 7] = [ Operation::Initial(Namespace::A), Operation::Initial(Namespace::B), Operation::Successor, + Operation::Release, + Operation::Restore, Operation::RetryLast, Operation::StaleInitial, ]; @@ -207,26 +214,8 @@ fn recipe( expected, )) } - Operation::Successor => { - let digest = digest_of(&store.template)?; - if !model.namespaces.contains_key(&digest) { - return Ok(None); - } - let current_root = snapshot(store)? - .retained_root(digest)? - .ok_or("model root absent on disk")? - .to_vec(); - let candidate = successor_root(&AdmittedRetentionRoot::decode(¤t_root)?)? - .encoded() - .to_vec(); - Some(( - Recipe::Successor { - current_root, - manifest: fresh_manifest.ok_or("successor over no manifest")?, - candidate, - }, - Expected::Published, - )) + Operation::Successor | Operation::Release | Operation::Restore => { + return successor_recipe(store, model, operation, fresh_manifest); } Operation::RetryLast => store .last_accepted @@ -235,6 +224,47 @@ fn recipe( }) } +fn successor_recipe( + store: &Store, + model: &Model, + operation: Operation, + manifest: Option>, +) -> Result, Box> { + let digest = digest_of(&store.template)?; + let Some((_, anchors)) = model.namespaces.get(&digest) else { + return Ok(None); + }; + let anchors = match operation { + Operation::Release => Vec::new(), + Operation::Restore => AdmittedRetentionRoot::decode(&store.template)? + .root() + .anchors() + .to_vec(), + _ => anchors.clone(), + }; + let current_root = snapshot(store)? + .retained_root(digest)? + .ok_or("model root absent on disk")? + .to_vec(); + let current = AdmittedRetentionRoot::decode(¤t_root)?; + let root = RetentionRoot::new( + current.root().namespace().clone(), + current.root().generation().successor()?, + RetentionPolicy::new(current.root().profile(), current.root().limits()), + Some(current.digest()), + anchors, + )?; + let candidate = CanonicalRetentionRoot::from_root(&root)?.encoded().to_vec(); + Ok(Some(( + Recipe::Successor { + current_root, + manifest: manifest.ok_or("successor over no manifest")?, + candidate, + }, + Expected::Published, + ))) +} + /// Applies one operation to the store and the model. fn apply(store: &mut Store, model: &mut Model, operation: Operation) -> Result<(), Box> { let Some((recipe, expected)) = recipe(store, model, operation)? else { @@ -252,7 +282,10 @@ fn apply(store: &mut Store, model: &mut Model, operation: Operation) -> Result<( candidate.root().anchors().to_vec(), ), ); - model.liveness = model.liveness.saturating_add(1); + model.liveness = model + .liveness + .checked_add(1) + .ok_or("model liveness overflow")?; store.last_accepted = Some(recipe); } (expected, result) => { @@ -328,10 +361,12 @@ fn run_sequences(first: Operation, label: &str) -> Result<(), Box> { verify(&store, &model) .map_err(|error| format!("{first:?} {second:?} {third:?}: {error}"))?; } - sequences = sequences.saturating_add(1); + sequences = sequences + .checked_add(1) + .ok_or("model sequence count overflow")?; } } - assert_eq!(sequences, 25); + assert_eq!(sequences, 49); Ok(()) } @@ -361,3 +396,13 @@ fn sequences_starting_with_a_retry_agree_with_the_model() -> Result<(), Box Result<(), Box> { run_sequences(Operation::StaleInitial, "stale") } + +#[test] +fn sequences_starting_with_a_release_agree_with_the_model() -> Result<(), Box> { + run_sequences(Operation::Release, "release") +} + +#[test] +fn sequences_starting_with_restoring_anchors_agree_with_the_model() -> Result<(), Box> { + run_sequences(Operation::Restore, "restore") +} From 1a586d83d5750083172d440f90e7b786d540ff0e Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 1 Oct 2026 08:52:47 -0700 Subject: [PATCH 44/59] Docs: reconcile durable implementation and version-two guarantees Refs #130. --- docs/formats/segment-store-v2/README.md | 15 ++++----- .../segment-store-v2/closure-corruption.md | 10 +++--- .../segment-store-v2/retention-publication.md | 14 +++++---- docs/formats/segment-store-v2/retention.md | 6 ++-- src/lib.rs | 12 +++++-- ...ment_store_implementation_documentation.rs | 31 +++++++++++++++++++ 6 files changed, 66 insertions(+), 22 deletions(-) diff --git a/docs/formats/segment-store-v2/README.md b/docs/formats/segment-store-v2/README.md index 192f423d..0d808ac0 100644 --- a/docs/formats/segment-store-v2/README.md +++ b/docs/formats/segment-store-v2/README.md @@ -8,9 +8,9 @@ namespaces. ADR-0009 owns the cross-cutting retention and liveness decision. These pages own its durable representation. The one-way migration, version-two reopen, and -forward retention publication are implemented with executable evidence; -recovery of retained retention stages, reader fencing, and collection remain -planned in issue #19, and the [requirements ledger](requirements.md) records +forward retention publication, partial-prefix recovery, reader fencing, +explicit disposition, GC retirement, and compaction are implemented with +executable evidence. The [requirements ledger](requirements.md) records exactly which requirements are proven. A version-1 store remains admitted until its owner migrates it. @@ -29,8 +29,9 @@ Version 2 retains every version-1 physical law and adds these: and canonical digest; - root closure is derived from a verified catalog, never from paths, caller claims, recent access, or application identity; -- catalog publication preserves every current retained closure before - replacing the catalog head; +- durable ingestion preserves current catalog records, and compaction verifies + retained closures before replacing the catalog head; the general low-level + publication gate is a [known gap](retention-publication.md#closure-admission); - readers acquire the version-2 reader fence before opening the catalog head; and - ambiguous, corrupt, missing, excessive, or unsupported evidence refuses @@ -48,7 +49,7 @@ The following pages form one protocol: resource accounting, authenticated reconstruction, and closure evidence. - [Closure corruption boundary](closure-corruption.md) owns the admitted-record ingress proof and its exact refusal evidence. -- [GC and disposition records](gc.md) owns the canonical planned intent, +- [GC and disposition records](gc.md) owns the canonical intent, completion, and recovery-disposition byte grammars. - [GC execution and recovery](gc-execution.md) owns the retirement phases, the residue state table, and `KEEP-CRASH-074` through `087`. @@ -103,7 +104,7 @@ one pinned catalog, preflight, preparation, and the 17-phase publication port; fresh writer-locked filesystem migration through all 21 phases, refusing a version-one store that still holds a retained stage; `FilesystemVersionTwoAdmission::reopen`, which jointly admits the marker, -intent, and receipt, binds the root's device, mount, and inode identity to the +intent, and receipt, binds the root's restart-stable device and inode identity to the intent, and pins the retention directories it admitted; and `FilesystemRetentionPublicationAuthority`, which publishes initial and successor generations against the observed head, binds this store's catalog diff --git a/docs/formats/segment-store-v2/closure-corruption.md b/docs/formats/segment-store-v2/closure-corruption.md index 7dccf75b..864b7261 100644 --- a/docs/formats/segment-store-v2/closure-corruption.md +++ b/docs/formats/segment-store-v2/closure-corruption.md @@ -93,7 +93,9 @@ written. seeds owned by the [Rust seed-corpus task](../../../xtask/src/fuzz_seed_corpus/segment_seeds.rs). -These proofs establish ingress safety. They do not prove that a future -retention publication adapter preserves the original source chain when it maps -these failures into an operation-level error; that obligation remains with -`KEEP-RETENTION-006`. +These proofs establish ingress safety. The filesystem retention authority +also re-verifies every closure member before publication and preserves the +original admission error through its operation-level refusal. +`filesystem_retention_member_tests` downcasts that source to the exact +`SegmentRecordAdmissionError`; this is executable evidence for +`KEEP-RETENTION-005`. diff --git a/docs/formats/segment-store-v2/retention-publication.md b/docs/formats/segment-store-v2/retention-publication.md index 17053c80..74a6b611 100644 --- a/docs/formats/segment-store-v2/retention-publication.md +++ b/docs/formats/segment-store-v2/retention-publication.md @@ -22,12 +22,14 @@ exact already-committed retry revalidates authority and performs no mutation. Version-2 catalog publication must hold the same writer authority and prove every current retained closure against its candidate catalog before replacing -the catalog `HEAD`. No version-2 catalog publisher exists yet: the version-1 -publisher cannot consume `FilesystemVersionTwoAdmission`, and version-1 -admission refuses a migrated root (`KEEP-MIGRATION-008`), so a migrated store -admits no catalog publication until the durable write path lands -([#82](https://github.com/flyingrobots/keep/issues/82)). This is a labeled -gap, not current behaviour. +the catalog `HEAD`. `FilesystemCatalogPublisher::open_version_two` now accepts +`FilesystemVersionTwoAdmission`; `DurableWriter` preserves the current catalog's +records in its successor, and compaction verifies the retained closures against +its replacement records. The low-level catalog publisher does not itself +verify current retained closures against an arbitrary candidate catalog. +That general publication gate remains a gap under +[#82](https://github.com/flyingrobots/keep/issues/82); the existence of a +version-two publisher is not evidence that the gate is implemented. ## Generation transition diff --git a/docs/formats/segment-store-v2/retention.md b/docs/formats/segment-store-v2/retention.md index 44a8668c..5f427566 100644 --- a/docs/formats/segment-store-v2/retention.md +++ b/docs/formats/segment-store-v2/retention.md @@ -166,8 +166,10 @@ digest, expected-state transition planning, deterministic closure verification, a blocking publication storage capability port, and ordered storage-port orchestration. `FilesystemRetentionPublicationAuthority` publishes initial and successor generations against its observed head, recovers retained stages -first, and refuses superseded candidates and protected orphans; fencing and -collection remain absent. +first, and refuses superseded candidates and protected orphans. +`FilesystemRetentionSnapshot` holds the shared reader fence; GC retirement +and explicit orphan disposition require its exclusive counterpart under +writer authority. See [GC execution and recovery](gc-execution.md). ## Global retention manifest diff --git a/src/lib.rs b/src/lib.rs index 3659c97a..427b2f04 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,9 +41,15 @@ //! forward retention publication executes under filesystem authority. The //! GC retirement intent and receipt codecs, and explicit-depth verification //! reports over the reference view, are available. Partial-prefix migration -//! recovery, retention publication recovery, immutable reader snapshots, -//! catalog publication on a migrated store, and garbage collection -//! execution remain intentionally absent. +//! recovery, retention publication recovery, fenced immutable reader snapshots, +//! catalog publication on a migrated store, explicit orphan disposition, +//! garbage collection, and identity-preserving compaction are implemented. +//! `DurableSnapshot` returns authenticated whole-object and exact-range reads +//! bound to its pinned view. `DurableWriter` stages and commits through the +//! content-store port; bounded transfer adapters preserve those read laws. +//! Durable verification reports at every depth, ingestion segment rollover, +//! and dedicated ingestion, disposition, and compaction process-death sequences +//! remain incomplete; the requirement ledgers and roadmap identify the gaps. #[cfg(test)] extern crate self as keep; diff --git a/xtask/tests/segment_store_implementation_documentation.rs b/xtask/tests/segment_store_implementation_documentation.rs index 5345c5b9..ee47624f 100644 --- a/xtask/tests/segment_store_implementation_documentation.rs +++ b/xtask/tests/segment_store_implementation_documentation.rs @@ -55,3 +55,34 @@ fn living_v1_pages_no_longer_assign_shipped_recovery_to_a_future_issue() { ); } } + +#[test] +fn living_v2_pages_and_crate_docs_agree_with_the_shipped_recovery_boundary() { + for (document, stale_claim) in [ + ( + include_str!("../../src/lib.rs"), + "execution remain intentionally absent", + ), + ( + include_str!("../../docs/formats/segment-store-v2/README.md"), + "planned in issue #19", + ), + ( + include_str!("../../docs/formats/segment-store-v2/retention.md"), + "collection remain absent", + ), + ( + include_str!("../../docs/formats/segment-store-v2/closure-corruption.md"), + "future\nretention publication adapter", + ), + ( + include_str!("../../docs/formats/segment-store-v2/retention-publication.md"), + "No version-2 catalog publisher exists yet", + ), + ] { + assert!( + !document.contains(stale_claim), + "shipped behavior described as missing: {stale_claim}" + ); + } +} From 4b9c38930f988911ab020b7c42e9221b721933af Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 1 Oct 2026 08:52:47 -0700 Subject: [PATCH 45/59] Docs: record completed-roadmap audit and GitHub follow-up ownership Refs #132. Preserve original acceptance criteria, reopen known gaps, record remaining audit scope, and require end-of-turn commits. --- AGENTS.md | 7 ++ CHANGELOG.md | 12 ++ ROADMAP.md | 48 ++++---- docs/audits/completed-roadmap-2026-09-30.md | 119 ++++++++++++++++++++ 4 files changed, 164 insertions(+), 22 deletions(-) create mode 100644 docs/audits/completed-roadmap-2026-09-30.md diff --git a/AGENTS.md b/AGENTS.md index c3b2f013..f7a7d9b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,13 @@ Keep is foundational storage infrastructure. Optimize for correctness, recoverability, auditability, and maintainability before performance or convenience. +## End-of-turn commits + +Stage and commit the work completed during each turn before the final response. +Use focused commits with issue references and preserve unrelated user changes. +If a required check fails or a commit cannot be made, preserve the work and +report the blocker explicitly. Do not push unless the user requests it. + ## Core Law For a given content identity, Keep must return exactly the bytes named by that identity—or refuse. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8d5bc2..a22fed5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,20 @@ after its public API and format compatibility policies are established. ## [Unreleased] +### Roadmap audit corrections + +- Preserve typed failure sources through durable and compaction I/O boundaries. +- Bound sealed-stage admission by its recorded length before reading it. +- Count selected chunks once in authenticated-read benchmark accounting. +- Move transfer-source ports into the store boundary so dependencies point + inward, while preserving public exports. +- Correct stale implementation claims in crate and version-two format docs. + ### Added +- Roadmap-audit regression laws for preserving typed durable refusal sources, + refusing externally enlarged sealed stages before allocation, and retention + release/restore model sequences. - Bounded streaming write-through pipeline. `transfer_layout`, `transfer_blob`, `transfer_range`, and `transfer_layout_range` move authenticated bytes from any `ContentReads` view into a `TransferSink` diff --git a/ROADMAP.md b/ROADMAP.md index 34518f7c..fbe4a7f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,6 +10,10 @@ and [`authenticated-reconstruction/requirements.md`](docs/invariants/authenticated-reconstruction/requirements.md). Where this page and a ledger disagree, the ledger wins. +The [2026-09-30 completed-task audit](docs/audits/completed-roadmap-2026-09-30.md) +records corrective work and known acceptance or definition-of-done gaps. +Entries with those gaps have been reopened; the full audit is not certified. + Snapshot: `main` at `f49cff7`, 2026-09-30. Twenty issues open, thirty-two closed, one non-dependency pull request open (#99). @@ -106,7 +110,7 @@ names; use those in code, tests, and commits. ### Integration (M5) -- [x] [F-25 Echo adapter and transaction boundary](#f-25-echo-adapter-and-transaction-boundary) — Done in the Echo repository +- [x] [F-25 Echo adapter and transaction boundary](#f-25-echo-adapter-and-transaction-boundary) — Partially implemented in the Echo repository - [ ] [F-26 Graft Golden File Worldline end to end](#f-26-graft-golden-file-worldline-end-to-end) — Planned (#24) - [ ] [F-27 git-cas import posture](#f-27-git-cas-import-posture) — Planned (#25) @@ -202,7 +206,7 @@ application policy. - [x] T-01.1 State the law and its limits — `README.md`, `docs/adr/0001-exact-logical-byte-identity.md`, `docs/invariants/authenticated-reconstruction/README.md`. -- [x] T-01.2 Make every refusal a typed value, never a string — every +- [ ] T-01.2 Make every refusal a typed value, never a string — every boundary error enum carries `expected` and `observed` fields and a preserved `source`; `unwrap_used`, `expect_used`, and `panic` are denied workspace-wide. @@ -587,7 +591,7 @@ owned by #17, and that #16 "does not implement admission/recovery". Both issues are complete on `main`. The v1 pages understate shipped guarantees and hand version-2 migration a stale source boundary. -- [x] T-14.1 Reconcile every v1 page with `main`. Original task fields: +- [ ] T-14.1 Reconcile every v1 page with `main`. Original task fields: - **Requirements:** every living v1 page describes current behaviour; historical scope stays reachable through linked issues, ADRs, and Git history; every existing requirement identifier and test name remains @@ -654,7 +658,7 @@ decoder that refuses every structural fault before admission. - [x] T-16.1 Freeze the definition and corpus — `conformance/segment-store/v2/definition.tsv`; format-definition digest - `32381f1a…3427`. + `a4a010ce…cf89`. - [x] T-16.2 Retention values and codecs — `KEEP-RETENTION-001`, `-002`; `RetentionNamespace`, `RootGeneration`, `LivenessGeneration`, `RetentionAnchor`, `CanonicalRetentionRoot`, `CanonicalRetentionManifest`, @@ -712,7 +716,7 @@ Direct version-2 initialization is undefined. There is no downgrade. `StoreMigrationPhase::ALL`, `FilesystemStoreMigrationAuthority`, `FilesystemStoreMigrationInventoryReader`; `FilesystemVersionTwoAdmission::reopen`. -- [x] T-17.1 Restart-stable root identity coordinate (#97) — decided as +- [ ] T-17.1 Restart-stable root identity coordinate (#97) — decided as the `(device, file)` pair with the mount id as same-process evidence; bytes unchanged; `recovery.md` "Root identity across restart", `rationale.md`, and the remount law in @@ -757,7 +761,7 @@ Direct version-2 initialization is undefined. There is no downgrade. - **Documentation:** `recovery.md`, `requirements.md`, `migration-crash.md`, v2 corpus README, CHANGELOG. - **Dependencies:** blocks T-17.2 and T-17.3. -- [x] T-17.2 Partial-prefix migration recovery (`KEEP-MIGRATION-004`) — +- [ ] T-17.2 Partial-prefix migration recovery (`KEEP-MIGRATION-004`) — planner, residue observer, resuming storage, and `FilesystemStoreMigrationAuthority::reopen_for_recovery`, proven in-process for every prefix; the process-death matrix is T-17.3 (#108). @@ -804,7 +808,7 @@ Direct version-2 initialization is undefined. There is no downgrade. - **Documentation:** `migration-recovery.md` Status, `recovery.md`, `requirements.md`, CHANGELOG. - **Dependencies:** needs T-17.1. Blocks T-17.3, F-43. -- [x] T-17.3 Migration crash matrix `KEEP-CRASH-053` to `-073` +- [ ] T-17.3 Migration crash matrix `KEEP-CRASH-053` to `-073` (`KEEP-MIGRATION-007`) — `cargo xtask durability-crash-matrix --sequence migration` runs 68 killed-writer cases against an independent expected-state model and the predicted recovery plan; @@ -861,7 +865,7 @@ and every namespace or capacity violation before writing anything. `KEEP-RETENTION-004`, `-005`, `-009`; `execute_retention_publication`, `RetentionPublicationPhase` (17), `FilesystemRetentionPublicationAuthority`, `RetentionCurrentStateRefusal`. -- [x] T-18.1 Retention publication recovery (`KEEP-RETENTION-007`; merged +- [ ] T-18.1 Retention publication recovery (`KEEP-RETENTION-007`; merged from PR #99). - **Requirements:** truncated stage with no later effect is discarded; complete root or manifest stage is linked into its pool and retained @@ -895,7 +899,7 @@ and every namespace or capacity violation before writing anything. - **Documentation:** `recovery.md` "Retention publication recovery" table marked implemented; CHANGELOG. - **Dependencies:** none. Blocks F-22 orphan disposition, F-43. -- [x] T-18.2 Retention crash matrix `KEEP-CRASH-036` to `-052` (merged +- [ ] T-18.2 Retention crash matrix `KEEP-CRASH-036` to `-052` (merged from PR #99). - **Requirements:** real process death before, during, and after each of the 17 phases (51 coordinates); restart recovers and the forward @@ -956,7 +960,7 @@ before and after, retrying within a bounded attempt limit. GC takes writer authority then the exclusive reader lock, in that order; publication never waits on readers because it deletes nothing. -- [x] T-19.1 `ReaderFence`, `collect_retention_view`, +- [ ] T-19.1 `ReaderFence`, `collect_retention_view`, `FilesystemRetentionSnapshot` (merged from PR #99). - **Requirements:** shared lock acquired before either head is opened; fence released on drop or process death without deleting `reader.lock`; @@ -1003,7 +1007,7 @@ namespaces agrees with a deterministic namespace-to-anchor-set map observed through the fenced view, and a source-architecture contract keeps clocks, paths, environment, and caller identity out of the core. -- [x] T-20.1 125 three-operation sequences against the model (merged from +- [ ] T-20.1 343 three-operation sequences against the model (merged from PR #99). - **Requirements:** the model is a `BTreeMap>` with generation counters; each sequence @@ -1043,7 +1047,7 @@ exactly what was established and nothing more; refuse when evidence is missing, conflicting, or corrupt. Verification never repairs, substitutes, quarantines, or rewrites physical state. -- [x] T-21.1 Verification policy and report types — `src/verification/`, +- [ ] T-21.1 Verification policy and report types — `src/verification/`, `ReferenceStore::verify`, `docs/invariants/verification/`, `tests/verification_report.rs`; durable depths stay with T-21.3 and #20. Original task fields: @@ -1092,7 +1096,7 @@ quarantines, or rewrites physical state. consequence ("report the exact verification depth") satisfied. - **Dependencies:** none for the reference store; F-19 for snapshot-bound depths. -- [x] T-21.2 Permanent corruption matrix over every durable structural +- [ ] T-21.2 Permanent corruption matrix over every durable structural field — `conformance/segment-store/v1/mutations.tsv` (105 rows) and `v2/mutations.tsv` (150 rows) with exact first refusal, verification stage, and requirement per row; `tests/segment_store_mutations.rs` @@ -1281,7 +1285,7 @@ disposition receipt exists. `BlobId`, `ChunkId`, and `LayoutId` never move. - **Documentation:** `gc.md` planning section; a warning per Documentation Standards §5.4 on every page that describes execution. - **Dependencies:** F-19, F-21 (planning consumes verification depth). -- [x] T-22.3 Identity-preserving compaction — `observe_compaction`, +- [ ] T-22.3 Identity-preserving compaction — `observe_compaction`, `plan_compaction`, `FilesystemCompactionAuthority::{execute, execute_with}`, `recover_compaction`, `docs/formats/segment-store-v2/compaction.md`, and `src/adapters/compaction/filesystem_tests.rs` (exact plan, identity and @@ -1324,7 +1328,7 @@ disposition receipt exists. `BlobId`, `ChunkId`, and `LayoutId` never move. - **Documentation:** `gc.md` compaction section; ADR-0002 compaction example cross-linked. - **Dependencies:** T-22.2. -- [x] T-22.4 GC execution, retirement, and recovery — +- [ ] T-22.4 GC execution, retirement, and recovery — `FilesystemGcAuthority::{prepare, execute, recover}`, the 14-phase `GcExecutionPhase` protocol over the `GcExecutionStorage` port, `GcResidue` and `plan_gc_recovery`, the registered proof, pool, and @@ -1364,7 +1368,7 @@ disposition receipt exists. `BlobId`, `ChunkId`, and `LayoutId` never move. - **Complexity:** XL across T-22.2 to T-22.5. - **Documentation:** `gc.md`, `recovery.md`, `requirements.md`, CHANGELOG. - **Dependencies:** T-22.1, T-22.2, F-19. -- [x] T-22.5 Explicit orphan disposition — +- [ ] T-22.5 Explicit orphan disposition — `FilesystemRetentionPublicationAuthority::dispose`, the pure planner, phases, port, and executor in `src/adapters/retention/disposition_*.rs`, `filesystem_retention_disposition_tests` (retire, finalize, order, @@ -1410,7 +1414,7 @@ immutable records, preserve the view while successors publish, and return a receipt naming the view, with refusal distinct from operational failure and no hidden whole-blob allocation. -- [x] T-23.1 `DurableStore` read surface over a fenced snapshot — +- [ ] T-23.1 `DurableStore` read surface over a fenced snapshot — `DurableStore`, `DurableSnapshot`, `DurableView`, the durable receipts, the crate-private `ChunkSource` shared by the reference and durable read cores, `docs/architecture/durable-store/README.md`, and @@ -1474,7 +1478,7 @@ segment publication, catalog admission, and an exact receipt. It preserves change `BlobId`, `ChunkId`, `LayoutId`, publication order, recovery, or error precision. -- [x] T-24.1 Backend-neutral ingestion contract. Done 2026-09-30: the +- [ ] T-24.1 Backend-neutral ingestion contract. Done 2026-09-30: the `store` port module (`ContentReads`, `ContentStaging`, `StagedContent`, `CommitReceipt`, `StagingLimits`, `StagedByteLimit`); `ReferenceStore` implements both halves and `DurableSnapshot` the read half; distinct @@ -1506,7 +1510,7 @@ error precision. - **Complexity:** M. - **Documentation:** `docs/architecture/` port page. - **Dependencies:** T-06.4. -- [x] T-24.2 Durable staged ingestion with deduplication. Done 2026-09-30: +- [ ] T-24.2 Durable staged ingestion with deduplication. Done 2026-09-30: `DurableWriter::{open, stage, stage_expected}`, `DurableStagedBlob::commit`, `DurableIngestionReceipt` with `IngestionAccounting`, `DurableIngestionError`, @@ -1563,7 +1567,7 @@ error precision. with the memory bound; CHANGELOG. - **Dependencies:** T-24.1, T-06.3, T-06.4, F-18, F-21 (representation verification depth), F-23. -- [x] T-24.3 Bounded streaming write-through pipeline (#72, P3). Done +- [ ] T-24.3 Bounded streaming write-through pipeline (#72, P3). Done 2026-09-30: `transfer_{layout,blob,range,layout_range}` over any `ContentReads` view into a `TransferSink` under `TransferBounds` (window and cancellation), `WriteSink` as the exactly-once sink, @@ -1608,7 +1612,7 @@ error precision. ### F-25 Echo adapter and transaction boundary -**Status:** Done in the Echo repository (issues #22 and #23 closed +**Status:** Partially implemented in the Echo repository (issues #22 and #23 closed 2026-08-15; work tracked as flyingrobots/echo#721 and #722). Keep's side is the authenticated reconstruction contract (F-07) and the no-Echo-types law (`KEEP-STORE-016`). @@ -1619,7 +1623,7 @@ No subprocess or Node sidecar sits in the storage path. - [x] T-25.1 Contract: success, evidenced refusal, operational failure — PR #77; `docs/invariants/authenticated-reconstruction/`. -- [x] T-25.2 Adapter and cutover — echo#722 (outside this repository). +- [ ] T-25.2 Adapter and cutover — echo#722 (outside this repository). Residual Keep obligations from #22 and #23 live in F-23 (durable refusal receipts, pinned durable reads) because those issues closed before #20 diff --git a/docs/audits/completed-roadmap-2026-09-30.md b/docs/audits/completed-roadmap-2026-09-30.md new file mode 100644 index 00000000..38f68861 --- /dev/null +++ b/docs/audits/completed-roadmap-2026-09-30.md @@ -0,0 +1,119 @@ +# Completed roadmap audit, 2026-09-30 + +Scope: the 83 task entries checked at the start of this audit, in roadmap +order. Initially unchecked tasks were excluded. Passing entries retain their +checks. Acceptance criteria and definitions of done remain authoritative; +this audit does not substitute a smaller scope for either. + +This pass is incomplete. The corrective changes below have regression +evidence, but the remaining obligations prevent certifying every checked +task. Unlisted tasks are not certified by omission or by a green workspace +test run. External issue closure, PR integration, and consumer cutover are +separate evidence from local implementation. + +## Corrective changes + +| Task | Gap found and correction | +| --- | --- | +| T-01.2 | Durable admission, selected-root reads, and compaction recovery erased typed sources. Preserve the original errors; downcast regressions cover durable admission and substituted root records. Other string-based refusals still need review. | +| T-06.3 | Benchmark counters still charged two hashes per chunk. Correct whole and range counters and freeze a clean-source Rust 1.96.0 baseline; differing hosts preclude a CPU speedup claim. | +| T-10.2 | Reference code imported adapter-owned transfer ports. Move the semantic port inward; preserve external exports and add an architecture regression. | +| T-19.1 | Missing subprocess fence-release and exclusive-fence exclusion laws remain. Experimental tests passed, but violated the existing prohibition on spawning processes under `src`; they were removed. Permanent evidence belongs in the external crash harness. | +| T-20.1 | The 125-sequence model omitted release. Expand to 343 sequences including release and restore, with fenced observations after each operation. | +| T-24.2 | Mutable sealed stages were read without a length bound. Admit the recorded exact length before allocation; externally growing a stage now produces the typed stage-read failure and preserves the head. | +| T-38.2 | Crate documentation incorrectly called implemented recovery, reads, and GC absent. Correct the claims and add a documentation regression. | +| T-38.3 | Version-two pages contradicted implemented publication and recovery. Reconcile them and explicitly document the outstanding general retained-closure publication gate. | + +## Known acceptance and definition-of-done failures + +| Task | Remaining obligation | +| --- | --- | +| T-01.2 | Audit remaining string-created operational/refusal errors against the universal typed-refusal requirement. | +| T-06.3 | The literal definition of done requires merged regression work; local source and benchmark evidence alone do not prove integration. | +| T-14.1 | Definition of done requires issue #69 closed; it was open when checked. | +| T-17.1 | Definition of done requires issue #97 closed; it was open when checked. | +| T-17.2 | KEEP-MIGRATION-005 remains In progress: restart corruption and mutation matrix. | +| T-17.3 | KEEP-MIGRATION-008 remains In progress: remaining compatibility and fuzz matrix. | +| T-18.1 | Definition of done requires PR #99 merged; it is open. | +| T-18.2 | The 51-case retention crash matrix exists; update its stale ledger row. PR #99 integration remains unproven. | +| T-19.1 | Permanent process laws are missing, and PR #99 must be merged. | +| T-20.1 | Expanded model coverage does not satisfy the requirement that PR #99 be merged. | +| T-21.1 | Durable segment/catalog/retention verification report interfaces and depths remain planned as KEEP-VERIFY-006. | +| T-21.2 | Decoder mutation evidence does not establish each required durable report variant and achieved depth through the report interface. | +| T-22.3 | Compaction amplification, sync, reclaimed-byte, latency, and temporary-space benchmarks and dedicated process-death evidence are missing. Re-encoding is outside this task's original scope. | +| T-22.4 | Explicit 65,536-candidate stress evidence is missing; retirement crash evidence does not replace that acceptance check. | +| T-22.5 | Dedicated disposition process-death evidence across stage/link/sync/remove transitions is missing. In-process residue tests are different evidence. | +| T-23.1 | Complete durable Worldline restart/range backend witnesses are missing. | +| T-24.1 | Full required ingestion-law and Worldline execution through both adapters remains missing. | +| T-24.2 | Required rollover, multi-GiB stress, ceiling soak, ingestion-driven crash evidence, benchmark evidence, and Worldline capability coverage remain missing. | +| T-24.3 | Required CPU advantage is not demonstrated; Worldline copy evidence remains missing. Multithreading is outside this task's scope. | +| T-25.2 | Echo issue #722 is open and its plan explicitly says production replacement is not approved. A merged boundary document does not establish adapter cutover. | + +## Integration evidence + +Read-only tracker checks found Keep issues #69, #71, #72, #74, #82, #97, +issues #108 and #109 open, and PR #99 open with no merge timestamp. No issue was +closed and no PR was merged by this audit. Echo #722 is an external blocker +for the claimed production cutover. + +The retained-closure check for arbitrary low-level version-two catalog +publication is still a documented gap. Durable ingestion preserves current +catalog records; compaction verifies retained closures. Neither narrower +guarantee establishes the missing general publication gate. + +No format bytes or public export names change in the corrective work. +The new benchmark source is preserved on the local +`audit/roadmap-benchmark-source` branch. Its artifact records full source, +compiler, and host coordinates. + +## Validation + +Rust 1.96.0 checks passed for formatting, workspace Clippy with warnings +denied, the release workspace suite with all features, source structure, +documentation integrity, stable fuzz-target compilation, dependency audit, +and dependency policy. The Linux ext4 durability crash matrix passed in +debug and release: 105 version-one cases, 51 retention cases, 68 migration +cases, and 42 GC cases. These existing sequences do not establish the +missing ingestion, disposition, or compaction process-death criteria. + +The debug workspace suite also passed. Host tools must include `sysctl` +and `b3sum` on PATH for source-bound benchmark and conformance tests. +Linux ARM host capture encountered an unsupported CPU-model coordinate; +that environment failure is not evidence that the entire Linux workspace +suite passed. + +## GitHub follow-up ownership + +Tracking container: [completed-roadmap audit follow-ups](https://github.com/flyingrobots/keep/issues/132). +It coordinates work and integration gates; it is not another executable PR. +The original acceptance criteria remain authoritative. + +| Follow-up | Roadmap ownership | +| --- | --- | +| [Finish typed refusal audit across durable boundaries](https://github.com/flyingrobots/keep/issues/110) | T-01.2 | +| [Complete migration restart corruption and ambiguity matrix](https://github.com/flyingrobots/keep/issues/111) | T-17.2; KEEP-MIGRATION-005 | +| [Complete migration compatibility and fuzz evidence](https://github.com/flyingrobots/keep/issues/112) | T-17.3; KEEP-MIGRATION-008 | +| [Prove reader-fence process death and collector exclusion in external harness](https://github.com/flyingrobots/keep/issues/113) | T-19.1 | +| [Implement durable verification reports at explicit achieved depths](https://github.com/flyingrobots/keep/issues/114) | T-21.1; KEEP-VERIFY-006 | +| [Assert durable mutation failures through verification reports](https://github.com/flyingrobots/keep/issues/115) | T-21.2 | +| [Add dedicated compaction process-death recovery matrix](https://github.com/flyingrobots/keep/issues/116) | T-22.3 | +| [Freeze reproducible compaction amplification and temporary-space baseline](https://github.com/flyingrobots/keep/issues/117) | T-22.3 | +| [Prove GC planning at the 65,536-candidate acceptance bound](https://github.com/flyingrobots/keep/issues/118) | T-22.4 | +| [Add explicit orphan-disposition process-death recovery matrix](https://github.com/flyingrobots/keep/issues/119) | T-22.5 | +| [Run complete ingestion contract and Worldline against both store adapters](https://github.com/flyingrobots/keep/issues/120) | T-24.1; T-24.2 | +| [Implement atomic durable ingestion across segment rollover](https://github.com/flyingrobots/keep/issues/121) | T-24.2 | +| [Prove durable ingestion memory bounds with multi-GiB and ceiling soak evidence](https://github.com/flyingrobots/keep/issues/122) | T-24.2 | +| [Add ingestion-driven process-death and restart matrix](https://github.com/flyingrobots/keep/issues/123) | T-24.2 | +| [Publish source-bound durable ingestion throughput and resource baseline](https://github.com/flyingrobots/keep/issues/124) | T-24.2 | +| [Enforce retained-closure admission for low-level version-two catalog publication](https://github.com/flyingrobots/keep/issues/125) | T-38.3 audit finding; #82 | +| [Add backend-neutral Worldline copy witnesses for transfer pipelines](https://github.com/flyingrobots/keep/issues/126) | T-24.3 | +| [Integrate transfer-source port ownership regression fix](https://github.com/flyingrobots/keep/issues/127) | T-10.2 | +| [Integrate retention release/restore model coverage](https://github.com/flyingrobots/keep/issues/128) | T-20.1 | +| [Integrate exact-length admission before durable stage allocation](https://github.com/flyingrobots/keep/issues/129) | T-24.2 | +| [Integrate corrected crate and version-two implementation documentation](https://github.com/flyingrobots/keep/issues/130) | T-38.2; T-38.3 | +| [Complete acceptance and definition-of-done audit of the remaining 64 checked tasks](https://github.com/flyingrobots/keep/issues/131) | Original completed-task audit remainder | + +Existing ownership is retained in [#69](https://github.com/flyingrobots/keep/issues/69), [#71](https://github.com/flyingrobots/keep/issues/71), [#74](https://github.com/flyingrobots/keep/issues/74), [#97](https://github.com/flyingrobots/keep/issues/97), [#108](https://github.com/flyingrobots/keep/issues/108), [#109](https://github.com/flyingrobots/keep/issues/109), [#72](https://github.com/flyingrobots/keep/issues/72), [#20](https://github.com/flyingrobots/keep/issues/20), [#21](https://github.com/flyingrobots/keep/issues/21), [#82](https://github.com/flyingrobots/keep/issues/82). +The tracker separately records [PR #99](https://github.com/flyingrobots/keep/pull/99) +and [Echo #722](https://github.com/flyingrobots/echo/issues/722) as integration +and external gates. No duplicate external issue was created. From fb9f129e6930c3a34776dd16492482e5191ef44a Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 3 Oct 2026 18:56:25 -0700 Subject: [PATCH 46/59] Fix: assert GC parser canonicality through runtime encoders (#107) --- CHANGELOG.md | 1 + docs/testing-evidence/gc-fuzz-canonicality.md | 45 ++++ .../gc-fuzz-canonicality/calibration.txt | 18 ++ .../disposition-corrected.txt | 17 ++ .../disposition-original.txt | 12 + .../disposition-restored.txt | 12 + .../gc-fuzz-canonicality/disposition.patch | 12 + .../focused-validation-formatted.txt | 153 ++++++++++++ .../focused-validation.txt | 11 + .../gc-fuzz-canonicality/fuzz-random.txt | 33 +++ .../gc-fuzz-canonicality/fuzz-smoke.txt | 225 ++++++++++++++++++ .../gc-fuzz-canonicality/intent-corrected.txt | 17 ++ .../gc-fuzz-canonicality/intent-original.txt | 12 + .../gc-fuzz-canonicality/intent-restored.txt | 12 + .../gc-fuzz-canonicality/intent.patch | 12 + .../receipt-corrected.txt | 17 ++ .../gc-fuzz-canonicality/receipt-original.txt | 12 + .../gc-fuzz-canonicality/receipt-restored.txt | 12 + .../gc-fuzz-canonicality/receipt.patch | 12 + .../gc-fuzz-canonicality/source-profile.txt | 11 + fuzz/README.md | 5 +- fuzz/fuzz_targets/gc_format.rs | 47 +++- 22 files changed, 701 insertions(+), 7 deletions(-) create mode 100644 docs/testing-evidence/gc-fuzz-canonicality.md create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/calibration.txt create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/disposition-corrected.txt create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/disposition-original.txt create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/disposition-restored.txt create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/disposition.patch create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/focused-validation-formatted.txt create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/focused-validation.txt create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/fuzz-random.txt create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/fuzz-smoke.txt create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/intent-corrected.txt create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/intent-original.txt create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/intent-restored.txt create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/intent.patch create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/receipt-corrected.txt create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/receipt-original.txt create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/receipt-restored.txt create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/receipt.patch create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/source-profile.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index a22fed5d..c30a166c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ after its public API and format compatibility policies are established. ### Roadmap audit corrections +- Replace tautological GC fuzz assertions with public canonical re-encoding checks for retirement intents, retirement receipts and recovery-disposition receipts (#107). - Preserve typed failure sources through durable and compaction I/O boundaries. - Bound sealed-stage admission by its recorded length before reading it. - Count selected chunks once in authenticated-read benchmark accounting. diff --git a/docs/testing-evidence/gc-fuzz-canonicality.md b/docs/testing-evidence/gc-fuzz-canonicality.md new file mode 100644 index 00000000..c210a20f --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality.md @@ -0,0 +1,45 @@ +# GC parser canonicality oracle repair + +Change kind: test-oracle correction for #107's [existing fuzz review finding](https://github.com/flyingrobots/keep/pull/107#discussion_r4146802713). Owner: `@flyingrobots`. Scope: retirement intent, bound retirement receipt and recovery-disposition receipt canonicality at the public codec boundary. No production source, fixture bytes, identity, durable format, recovery protocol or performance contract changes. + +## Claim and oracle + +An admitted record must have exactly the bytes produced by encoding its admitted semantic value. The retirement receipt is reconstructed from its admitted intent and reported pool-state digest. The comparison also checks semantic values and the intent's reported candidate-set and intent digests. This invariant oracle complements frozen independently constructed conformance vectors; correlated encoder/decoder defects remain a blind spot. + +The previous target compared `Admitted*::encoded()` with the slice supplied to `decode()`. Those accessors return the retained input, so their success supplies no canonicality evidence. The replacement exercises public encoders at runtime. It adds no source-string or harness-cardinality assertion. + +## Coordinates and experiment + +Unfixed branch revision: `4b9c38930f988911ab020b7c42e9221b721933af`, tree `3fa4386348712647213b64a23af125da7b495dc4`. The Docker source was copied from that exact Git archive, then given synthetic commit `6f0fbec73135a0648503a0cdd608c39e607f67f3` with the same tree. The synthetic SHA is not the product SHA. + +The repaired target's final SHA-256 is `f31f4218dee13dc7a6b210121accdd683f4c14c5f5c928b8e501ae07cba80efa`. Calibration used the same behavior before rustfmt split the helper signature; subsequent focused validation and the sanitizer campaign used this final file. [Source profile](gc-fuzz-canonicality/source-profile.txt) records the matching Docker file hash, compiler and unchanged production sources. + +Each [intent](gc-fuzz-canonicality/intent.patch), [retirement-receipt](gc-fuzz-canonicality/receipt.patch) or [disposition](gc-fuzz-canonicality/disposition.patch) control flips the first encoded byte after checksum construction in that record's production encoder. Controls are applied separately, retaining the same admitted fixture and decoder. They deliberately break a public encoder output; they do not alter the fuzz assertion or manufacture a setup failure. + +| Runtime claim | Original target with faulty encoder | Repaired target with faulty encoder | Repaired target, restored encoder | +| --- | --- | --- | --- | +| Intent canonicality | [Survived, exit 0](gc-fuzz-canonicality/intent-original.txt) | [Named assertion, exit 77](gc-fuzz-canonicality/intent-corrected.txt) | [Pass, exit 0](gc-fuzz-canonicality/intent-restored.txt) | +| Bound retirement-receipt canonicality | [Survived, exit 0](gc-fuzz-canonicality/receipt-original.txt) | [Named assertion, exit 77](gc-fuzz-canonicality/receipt-corrected.txt) | [Pass, exit 0](gc-fuzz-canonicality/receipt-restored.txt) | +| Disposition canonicality | [Survived, exit 0](gc-fuzz-canonicality/disposition-original.txt) | [Named assertion, exit 77](gc-fuzz-canonicality/disposition-corrected.txt) | [Pass, exit 0](gc-fuzz-canonicality/disposition-restored.txt) | + +[Calibration transcript](gc-fuzz-canonicality/calibration.txt) preserves seed 107 and observed statuses. This is old-oracle/new-oracle calibration against deliberate runtime mutations, not a claim that the unmodified production encoders have a demonstrated defect or that every compared coordinate was independently mutated. No mutation remains in the candidate. The replaced assertions are deleted because they fail calibration; their intended risk is now covered by the canonicality relation. + +## Replay and resource profile + +Run in a copied Docker checkout with Rust 1.96.0, a distinct Cargo target directory and the checked-in lockfiles. Build the fixed-input runner with `cargo build --locked --manifest-path fuzz/Cargo.toml --bin gc_format`. This stable replay runner executes the real target on fixtures; it is not a coverage-guided or sanitizer campaign. + +The permanent seed recipes remain in `xtask/src/fuzz_seed_corpus/gc_seeds.rs` and their input bytes in `conformance/segment-store/v2/`. Intent framing is selector 0 plus decoded `one-candidate-gc-intent.hex`. Retirement receipt framing is selector 1, the big-endian u32 intent length, intent bytes, then decoded `one-candidate-gc-receipt.hex`. Disposition framing is selector 2 plus decoded `one-orphan-retire-disposition.hex`. These already-small valid witnesses need no new corpus fixture. No newly discovered production counterexample requires reduction. + +Apply one recorded patch to the copied parent source and run both its original target and the repaired target against the corresponding seed with `-runs=1 -seed=107 -timeout=5 -rss_limit_mb=1024`, under an outer 75-second deadline. Restore the encoder before the next control. The seed is recorded outside the child before launch. The three original-target executions must succeed, each repaired-target mutation must fail the matching named canonicality assertion, and each restored execution must succeed. + +The medium, single-machine fixed-input replay uses owned seed files, Rust's actual codec implementation and no remote services. Its libFuzzer input timeout is 5 seconds and RSS limit is 1024 MiB; the outer process deadline is 75 seconds. RSS monitoring is not a hard kernel memory limit. The stable replay inherits the Docker network namespace and does not establish network-denial compliance. This bounded evidence does not waive the ordinary-test resource-enforcement gaps recorded on main or assert repository-wide compliance. + +The separate coverage-guided run uses pinned cargo-fuzz 0.13.2 and nightly-2026-07-24 with its default address sanitizer. It ran offline in an isolated network namespace (`unshare -n`), starting from the three canonical seeds. Build deadline: 600 seconds. Exploration: `-seed=107 -max_total_time=15 -timeout=5 -max_len=1048576 -rss_limit_mb=1024 -print_final_stats=1`, outer deadline 75 seconds. These are the checked-in smoke bounds in `fuzz/campaign.env`; the fixed seed is additional replay evidence. A time-bounded campaign can explore different numbers of inputs across machines even with the same seed. + +## Validation and remaining acceptance + +[Focused validation](gc-fuzz-canonicality/focused-validation-formatted.txt) passes root and fuzz formatting, changed-target Clippy with warnings denied, and existing public intent/receipt/disposition codec laws in debug and release. The [initial formatting failure](gc-fuzz-canonicality/focused-validation.txt) is retained; no runtime test ran in that attempt. The existing documentation container had completed its configured lifetime before the first Markdown attempt, so that attempt ran no lint; after its terminal state was verified and it was restarted, the changed Markdown files passed the pinned tool. Initial source-copy setup also invoked a login shell without Cargo on PATH; the explicit tool PATH corrected setup before builds, not a runtime failure. + +The [fixed-seed sanitizer smoke log](gc-fuzz-canonicality/fuzz-smoke.txt) and [random-seed follow-up](gc-fuzz-canonicality/fuzz-random.txt) record successful execution and no counterexample under the same bounds. Random seed 180510830 was selected and written outside the child before launch; the follow-up retained the first campaign’s derived corpus. It is finite parser exploration, not proof of all inputs, storage recovery, physical power loss, all-platform behavior or complete #107 acceptance. + +The larger PR still requires current-main integration, the remaining review dispositions, full stable-candidate validation and independent exact-head approval. These focused results do not transfer earlier CI or approve the other features in #107. diff --git a/docs/testing-evidence/gc-fuzz-canonicality/calibration.txt b/docs/testing-evidence/gc-fuzz-canonicality/calibration.txt new file mode 100644 index 00000000..588a1f08 --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/calibration.txt @@ -0,0 +1,18 @@ +Replay subject=intent target=original seed=107 timeout=5 rss_limit_mb=1024 +Exit: 0 +Replay subject=intent target=corrected seed=107 timeout=5 rss_limit_mb=1024 +Exit: 77 +assertion `left == right` failed: admitted intent must preserve its canonical bytes, semantics and digests: Ok(CanonicalGcRetirementIntent { encoded: [74, 69, 69, 80, 58, 71, 67, 58, 73, 78, 84, 69, 78, 84, 50, 0, 0, 2, 1, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 200, 0, 0, 0, 0, 0, 0, 0, 1, 0, 72, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235, 0, 0, 0, 0, 0, 0, 0, 2, 234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147, 0, 0, 0, 1, 0, 0, 0, 1, 219, 28, 28, 26, 80, 97, 62, 241, 31, 124, 14, 224, 136, 46, 55, 182, 210, 78, 45, 178, 202, 87, 120, 61, 1, 25, 123, 165, 27, 97, 206, 89, 246, 124, 11, 104, 87, 47, 203, 11, 56, 160, 119, 4, 141, 114, 247, 164, 25, 160, 192, 167, 174, 92, 38, 41, 195, 221, 118, 37, 63, 203, 235, 166, 64, 191, 93, 73, 195, 72, 71, 172, 156, 244, 106, 37, 111, 52, 60, 238, 128, 205, 152, 13, 20, 5, 210, 221, 2, 206, 255, 143, 88, 214, 116, 249, 168, 2, 89, 252, 209, 35, 114, 3, 234, 108, 108, 197, 6, 85, 20, 171, 222, 176, 29, 166, 3, 195, 25, 75, 9, 106, 4, 92, 246, 148, 201, 90, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 6, 102, 118, 188, 157, 112, 19, 74, 116, 204, 157, 254, 57, 127, 184, 160, 51, 252, 69, 235, 217, 3, 41, 13, 41, 185, 48, 24, 160, 151, 238, 43, 80, 183, 84, 45, 206, 210, 171, 119, 8, 148, 161, 77, 29, 4, 176, 102, 227, 168, 153, 148, 38, 2, 197, 152, 109, 53, 186, 109, 246, 193, 163, 92, 252, 0, 0, 0, 0, 0, 0, 1, 81, 190, 203, 70, 179, 81, 32, 114, 50, 16, 121, 138, 71, 226, 97, 68, 184, 33, 77, 94, 166, 93, 40, 128, 110, 11, 169, 65, 210, 170, 102, 187, 250, 169, 221, 82, 51, 38, 166, 134, 184, 154, 200, 240, 196, 16, 66, 23, 102, 175, 194, 33, 242, 150, 59, 218, 253, 67, 13, 206, 127, 89, 237, 199, 1, 206, 253, 50, 95, 191, 123, 25, 0, 225, 162, 8, 201, 198, 110, 197, 207, 208, 62, 86, 94, 160, 74, 59, 249, 1, 19, 56, 233, 218, 186, 231, 73], intent: GcRetirementIntent { coordinates: GcRetirementIntentCoordinates { generation: GcGeneration(1), liveness_generation: LivenessGeneration(1), manifest_digest: RetentionManifestDigest([244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235]), catalog_generation: CatalogGeneration(2), catalog_digest: CatalogDigest([234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147]), profile: RegisteredRetentionProfile { identity: 1, version: 1, digest: [219, 28, 28, 26, 80, 97, 62, 241, 31, 124, 14, 224, 136, 46, 55, 182, 210, 78, 45, 178, 202, 87, 120, 61, 1, 25, 123, 165, 27, 97, 206, 89] }, catalog_successor_proof_digest: CatalogSuccessorProofDigest([246, 124, 11, 104, 87, 47, 203, 11, 56, 160, 119, 4, 141, 114, 247, 164, 25, 160, 192, 167, 174, 92, 38, 41, 195, 221, 118, 37, 63, 203, 235, 166]), segment_pool_identity_digest: SegmentPoolIdentityDigest([64, 191, 93, 73, 195, 72, 71, 172, 156, 244, 106, 37, 111, 52, 60, 238, 128, 205, 152, 13, 20, 5, 210, 221, 2, 206, 255, 143, 88, 214, 116, 249]), disposition_set_digest: DispositionSetDigest([168, 2, 89, 252, 209, 35, 114, 3, 234, 108, 108, 197, 6, 85, 20, 171, 222, 176, 29, 166, 3, 195, 25, 75, 9, 106, 4, 92, 246, 148, 201, 90]), reader_lock: ReaderLockIdentity { device: 4, mount: 5, file: 6 } }, candidates: [GcCandidate { segment_digest: SegmentDigest([183, 84, 45, 206, 210, 171, 119, 8, 148, 161, 77, 29, 4, 176, 102, 227, 168, 153, 148, 38, 2, 197, 152, 109, 53, 186, 109, 246, 193, 163, 92, 252]), segment_length: 337, evidence_digest: VerificationEvidenceDigest([190, 203, 70, 179, 81, 32, 114, 50, 16, 121, 138, 71, 226, 97, 68, 184, 33, 77, 94, 166, 93, 40, 128, 110, 11, 169, 65, 210, 170, 102, 187, 250]) }] }, candidate_set_digest: GcCandidateSetDigest([102, 118, 188, 157, 112, 19, 74, 116, 204, 157, 254, 57, 127, 184, 160, 51, 252, 69, 235, 217, 3, 41, 13, 41, 185, 48, 24, 160, 151, 238, 43, 80]), digest: GcRetirementIntentDigest([169, 221, 82, 51, 38, 166, 134, 184, 154, 200, 240, 196, 16, 66, 23, 102, 175, 194, 33, 242, 150, 59, 218, 253, 67, 13, 206, 127, 89, 237, 199, 1]) }) +Replay subject=receipt target=original seed=107 timeout=5 rss_limit_mb=1024 +Exit: 0 +Replay subject=receipt target=corrected seed=107 timeout=5 rss_limit_mb=1024 +Exit: 77 +assertion `left == right` failed: admitted retirement receipt must preserve its canonical bytes and semantics +Replay subject=disposition target=original seed=107 timeout=5 rss_limit_mb=1024 +Exit: 0 +Replay subject=disposition target=corrected seed=107 timeout=5 rss_limit_mb=1024 +Exit: 77 +assertion `left == right` failed: admitted disposition must preserve its canonical bytes and semantics +Restored intent: exit 0 +Restored receipt: exit 0 +Restored disposition: exit 0 diff --git a/docs/testing-evidence/gc-fuzz-canonicality/disposition-corrected.txt b/docs/testing-evidence/gc-fuzz-canonicality/disposition-corrected.txt new file mode 100644 index 00000000..2f9b0b2e --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/disposition-corrected.txt @@ -0,0 +1,17 @@ +WARNING: Failed to find function "__sanitizer_acquire_crash_state". +WARNING: Failed to find function "__sanitizer_print_stack_trace". +WARNING: Failed to find function "__sanitizer_set_death_callback". +INFO: Running with entropic power schedule (0xFF, 100). +INFO: Seed: 107 +/build/keep107-gc-target/debug/gc_format: Running 1 inputs 1 time(s) each. +Running: /build/keep107-gc-calibration/disposition.seed + +thread '' (77555) panicked at fuzz_targets/gc_format.rs:28:9: +assertion `left == right` failed: admitted disposition must preserve its canonical bytes and semantics + left: ([74, 69, 69, 80, 58, 82, 69, 67, 58, 68, 73, 83, 80, 50, 0, 0, 0, 2, 1, 64, 0, 0, 0, 0, 0, 1, 0, 2, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 81, 183, 84, 45, 206, 210, 171, 119, 8, 148, 161, 77, 29, 4, 176, 102, 227, 168, 153, 148, 38, 2, 197, 152, 109, 53, 186, 109, 246, 193, 163, 92, 252, 97, 244, 81, 164, 164, 193, 149, 228, 148, 104, 87, 108, 124, 128, 77, 174, 83, 179, 242, 213, 28, 197, 139, 159, 225, 243, 179, 91, 207, 3, 164, 133, 0, 0, 0, 0, 0, 0, 0, 2, 246, 124, 11, 104, 87, 47, 203, 11, 56, 160, 119, 4, 141, 114, 247, 164, 25, 160, 192, 167, 174, 92, 38, 41, 195, 221, 118, 37, 63, 203, 235, 166, 0, 0, 0, 0, 0, 0, 0, 2, 234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147, 0, 0, 0, 0, 0, 0, 0, 1, 244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 6, 190, 203, 70, 179, 81, 32, 114, 50, 16, 121, 138, 71, 226, 97, 68, 184, 33, 77, 94, 166, 93, 40, 128, 110, 11, 169, 65, 210, 170, 102, 187, 250, 0, 0, 0, 0, 0, 0, 0, 0, 230, 125, 13, 109, 83, 138, 162, 204, 253, 210, 99, 198, 171, 86, 134, 2, 109, 226, 20, 21, 114, 89, 202, 235, 75, 135, 213, 199, 228, 57, 42, 247], RecoveryDispositionReceipt { artifact: RecoveryDispositionArtifact { kind: Segment, classification: CompleteOrphan, length: 337, identity_digest: ArtifactIdentityDigest([183, 84, 45, 206, 210, 171, 119, 8, 148, 161, 77, 29, 4, 176, 102, 227, 168, 153, 148, 38, 2, 197, 152, 109, 53, 186, 109, 246, 193, 163, 92, 252]), content_digest: ArtifactContentDigest([97, 244, 81, 164, 164, 193, 149, 228, 148, 104, 87, 108, 124, 128, 77, 174, 83, 179, 242, 213, 28, 197, 139, 159, 225, 243, 179, 91, 207, 3, 164, 133]) }, decision: Retire, coordinates: RecoveryDispositionCoordinates { publication_generation: CatalogGeneration(2), publication_checksum: ObservedHeadChecksum([246, 124, 11, 104, 87, 47, 203, 11, 56, 160, 119, 4, 141, 114, 247, 164, 25, 160, 192, 167, 174, 92, 38, 41, 195, 221, 118, 37, 63, 203, 235, 166]), catalog_generation: CatalogGeneration(2), catalog_digest: CatalogDigest([234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147]), retention: Published { generation: LivenessGeneration(1), manifest_digest: RetentionManifestDigest([244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235]) }, reader_lock: ReaderLockIdentity { device: 4, mount: 5, file: 6 } }, evidence_digest: DecisionEvidenceDigest([190, 203, 70, 179, 81, 32, 114, 50, 16, 121, 138, 71, 226, 97, 68, 184, 33, 77, 94, 166, 93, 40, 128, 110, 11, 169, 65, 210, 170, 102, 187, 250]) }) + right: ([75, 69, 69, 80, 58, 82, 69, 67, 58, 68, 73, 83, 80, 50, 0, 0, 0, 2, 1, 64, 0, 0, 0, 0, 0, 1, 0, 2, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 81, 183, 84, 45, 206, 210, 171, 119, 8, 148, 161, 77, 29, 4, 176, 102, 227, 168, 153, 148, 38, 2, 197, 152, 109, 53, 186, 109, 246, 193, 163, 92, 252, 97, 244, 81, 164, 164, 193, 149, 228, 148, 104, 87, 108, 124, 128, 77, 174, 83, 179, 242, 213, 28, 197, 139, 159, 225, 243, 179, 91, 207, 3, 164, 133, 0, 0, 0, 0, 0, 0, 0, 2, 246, 124, 11, 104, 87, 47, 203, 11, 56, 160, 119, 4, 141, 114, 247, 164, 25, 160, 192, 167, 174, 92, 38, 41, 195, 221, 118, 37, 63, 203, 235, 166, 0, 0, 0, 0, 0, 0, 0, 2, 234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147, 0, 0, 0, 0, 0, 0, 0, 1, 244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 6, 190, 203, 70, 179, 81, 32, 114, 50, 16, 121, 138, 71, 226, 97, 68, 184, 33, 77, 94, 166, 93, 40, 128, 110, 11, 169, 65, 210, 170, 102, 187, 250, 0, 0, 0, 0, 0, 0, 0, 0, 230, 125, 13, 109, 83, 138, 162, 204, 253, 210, 99, 198, 171, 86, 134, 2, 109, 226, 20, 21, 114, 89, 202, 235, 75, 135, 213, 199, 228, 57, 42, 247], RecoveryDispositionReceipt { artifact: RecoveryDispositionArtifact { kind: Segment, classification: CompleteOrphan, length: 337, identity_digest: ArtifactIdentityDigest([183, 84, 45, 206, 210, 171, 119, 8, 148, 161, 77, 29, 4, 176, 102, 227, 168, 153, 148, 38, 2, 197, 152, 109, 53, 186, 109, 246, 193, 163, 92, 252]), content_digest: ArtifactContentDigest([97, 244, 81, 164, 164, 193, 149, 228, 148, 104, 87, 108, 124, 128, 77, 174, 83, 179, 242, 213, 28, 197, 139, 159, 225, 243, 179, 91, 207, 3, 164, 133]) }, decision: Retire, coordinates: RecoveryDispositionCoordinates { publication_generation: CatalogGeneration(2), publication_checksum: ObservedHeadChecksum([246, 124, 11, 104, 87, 47, 203, 11, 56, 160, 119, 4, 141, 114, 247, 164, 25, 160, 192, 167, 174, 92, 38, 41, 195, 221, 118, 37, 63, 203, 235, 166]), catalog_generation: CatalogGeneration(2), catalog_digest: CatalogDigest([234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147]), retention: Published { generation: LivenessGeneration(1), manifest_digest: RetentionManifestDigest([244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235]) }, reader_lock: ReaderLockIdentity { device: 4, mount: 5, file: 6 } }, evidence_digest: DecisionEvidenceDigest([190, 203, 70, 179, 81, 32, 114, 50, 16, 121, 138, 71, 226, 97, 68, 184, 33, 77, 94, 166, 93, 40, 128, 110, 11, 169, 65, 210, 170, 102, 187, 250]) }) +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +==77555== ERROR: libFuzzer: deadly signal +NOTE: libFuzzer has rudimentary signal handlers. + Combine libFuzzer with AddressSanitizer or similar for better crash reports. +SUMMARY: libFuzzer: deadly signal diff --git a/docs/testing-evidence/gc-fuzz-canonicality/disposition-original.txt b/docs/testing-evidence/gc-fuzz-canonicality/disposition-original.txt new file mode 100644 index 00000000..0973e6af --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/disposition-original.txt @@ -0,0 +1,12 @@ +WARNING: Failed to find function "__sanitizer_acquire_crash_state". +WARNING: Failed to find function "__sanitizer_print_stack_trace". +WARNING: Failed to find function "__sanitizer_set_death_callback". +INFO: Running with entropic power schedule (0xFF, 100). +INFO: Seed: 107 +/build/keep107-gc-target/debug/gc_format: Running 1 inputs 1 time(s) each. +Running: /build/keep107-gc-calibration/disposition.seed +Executed /build/keep107-gc-calibration/disposition.seed in 0 ms +*** +*** NOTE: fuzzing was not performed, you have only +*** executed the target code on a fixed set of inputs. +*** diff --git a/docs/testing-evidence/gc-fuzz-canonicality/disposition-restored.txt b/docs/testing-evidence/gc-fuzz-canonicality/disposition-restored.txt new file mode 100644 index 00000000..0973e6af --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/disposition-restored.txt @@ -0,0 +1,12 @@ +WARNING: Failed to find function "__sanitizer_acquire_crash_state". +WARNING: Failed to find function "__sanitizer_print_stack_trace". +WARNING: Failed to find function "__sanitizer_set_death_callback". +INFO: Running with entropic power schedule (0xFF, 100). +INFO: Seed: 107 +/build/keep107-gc-target/debug/gc_format: Running 1 inputs 1 time(s) each. +Running: /build/keep107-gc-calibration/disposition.seed +Executed /build/keep107-gc-calibration/disposition.seed in 0 ms +*** +*** NOTE: fuzzing was not performed, you have only +*** executed the target code on a fixed set of inputs. +*** diff --git a/docs/testing-evidence/gc-fuzz-canonicality/disposition.patch b/docs/testing-evidence/gc-fuzz-canonicality/disposition.patch new file mode 100644 index 00000000..8243b821 --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/disposition.patch @@ -0,0 +1,12 @@ +--- a/src/adapters/gc/disposition_encoder.rs ++++ b/src/adapters/gc/disposition_encoder.rs +@@ -7,6 +7,9 @@ + let (preimage, checksum_slot) = encoded.split_at_mut(disposition_format::CHECKSUM_OFFSET); + write_preimage(preimage, receipt); + checksum_slot.copy_from_slice(&disposition_format::checksum(preimage)); ++ if let Some(byte) = encoded.first_mut() { ++ *byte ^= 1; ++ } + CanonicalRecoveryDispositionReceipt::admitted(&encoded, receipt) + } + diff --git a/docs/testing-evidence/gc-fuzz-canonicality/focused-validation-formatted.txt b/docs/testing-evidence/gc-fuzz-canonicality/focused-validation-formatted.txt new file mode 100644 index 00000000..fc2e9df7 --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/focused-validation-formatted.txt @@ -0,0 +1,153 @@ + Checking linux-raw-sys v0.12.1 + Checking bitflags v2.13.1 + Checking once_cell v1.21.4 + Checking ipnet v2.12.0 + Checking maybe-owned v0.3.4 + Checking ambient-authority v0.0.2 + Checking io-lifetimes v2.0.4 + Checking io-lifetimes v3.0.1 + Checking serde_core v1.0.229 + Checking zmij v1.0.23 + Checking constant_time_eq v0.4.2 + Checking itoa v1.0.18 + Checking cfg-if v1.0.4 + Checking arrayvec v0.7.8 + Checking arrayref v0.3.9 + Checking memchr v2.8.3 + Checking arbitrary v1.4.2 + Checking io-extras v0.19.0 + Checking blake3 v1.8.5 + Checking rustix v1.1.4 + Checking libfuzzer-sys v0.4.13 + Checking serde v1.0.229 + Checking serde_json v1.0.151 + Checking xtask v0.0.0 (/build/keep107-gc-source/xtask) + Checking rustix-linux-procfs v0.1.1 + Checking fs-set-times v0.20.3 + Checking cap-primitives v4.0.2 + Checking cap-std v4.0.2 + Checking cap-fs-ext v4.0.2 + Checking keep v0.0.0 (/build/keep107-gc-source) + Checking keep-fuzz v0.0.0 (/build/keep107-gc-source/fuzz) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.02s + Compiling proc-macro2 v1.0.107 + Compiling unicode-ident v1.0.24 + Compiling quote v1.0.47 + Compiling clap_lex v1.1.0 + Compiling cc v1.3.0 + Compiling anstyle v1.0.14 + Compiling regex-lite v0.1.9 + Compiling libc v0.2.186 + Compiling condtype v1.3.0 + Compiling allocation-counter v0.8.1 + Compiling clap_builder v4.6.2 + Compiling syn v2.0.119 + Compiling blake3 v1.8.5 + Compiling keep v0.0.0 (/build/keep107-gc-source) + Compiling clap v4.6.4 + Compiling divan-macros v0.1.21 + Compiling divan v0.1.21 + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.26s + Running tests/gc_retirement_intent.rs (/build/keep107-gc-target/debug/deps/gc_retirement_intent-6de0a7d49b53f247) + +running 5 tests +test frozen_intent_decodes_and_reencodes_canonically ... ok +test mutation_laws::intent_framing_refuses_truncation_and_trailing_data ... ok +test semantic_intent_refuses_empty_and_repeated_candidate_sets ... ok +test mutation_laws::every_intent_field_has_one_exact_first_refusal ... ok +test mutation_laws::candidate_order_and_count_bounds_refuse_after_complete_integrity ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.35s + + Running tests/gc_retirement_receipt.rs (/build/keep107-gc-target/debug/deps/gc_retirement_receipt-b07035724611f35c) + +running 4 tests +test receipt_framing_refuses_any_length_but_the_fixed_width ... ok +test frozen_receipt_completes_the_frozen_intent_and_reencodes_canonically ... ok +test pool_state_digest_is_carried_not_bound_to_the_intent ... ok +test every_receipt_field_has_one_exact_first_refusal ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/recovery_disposition_receipt.rs (/build/keep107-gc-target/debug/deps/recovery_disposition_receipt-96812ccadb83a6d8) + +running 5 tests +test every_registered_code_round_trips_and_matches_the_definition ... ok +test framing_refuses_truncation_and_trailing_bytes ... ok +test frozen_disposition_decodes_and_reencodes_canonically ... ok +test liveness_zero_beside_the_empty_retention_digest_round_trips_as_empty_retention ... ok +test every_structural_field_has_one_exact_first_refusal ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Compiling rustix v1.1.4 + Compiling io-lifetimes v2.0.4 + Compiling bitflags v2.13.1 + Compiling io-lifetimes v3.0.1 + Compiling linux-raw-sys v0.12.1 + Compiling proc-macro2 v1.0.107 + Compiling io-extras v0.19.0 + Compiling unicode-ident v1.0.24 + Compiling cap-primitives v4.0.2 + Compiling find-msvc-tools v0.1.9 + Compiling shlex v2.0.1 + Compiling once_cell v1.21.4 + Compiling quote v1.0.47 + Compiling ipnet v2.12.0 + Compiling cap-std v4.0.2 + Compiling maybe-owned v0.3.4 + Compiling ambient-authority v0.0.2 + Compiling cap-fs-ext v4.0.2 + Compiling clap_lex v1.1.0 + Compiling cfg-if v1.0.4 + Compiling cc v1.3.0 + Compiling libc v0.2.186 + Compiling anstyle v1.0.14 + Compiling arrayref v0.3.9 + Compiling constant_time_eq v0.4.2 + Compiling arrayvec v0.7.8 + Compiling regex-lite v0.1.9 + Compiling condtype v1.3.0 + Compiling allocation-counter v0.8.1 + Compiling clap_builder v4.6.2 + Compiling syn v2.0.119 + Compiling blake3 v1.8.5 + Compiling clap v4.6.4 + Compiling fs-set-times v0.20.3 + Compiling rustix-linux-procfs v0.1.1 + Compiling divan-macros v0.1.21 + Compiling keep v0.0.0 (/build/keep107-gc-source) + Compiling divan v0.1.21 + Finished `release` profile [optimized] target(s) in 4.01s + Running tests/gc_retirement_intent.rs (/build/keep107-gc-target/release/deps/gc_retirement_intent-16dd92d184650f31) + +running 5 tests +test frozen_intent_decodes_and_reencodes_canonically ... ok +test semantic_intent_refuses_empty_and_repeated_candidate_sets ... ok +test mutation_laws::every_intent_field_has_one_exact_first_refusal ... ok +test mutation_laws::intent_framing_refuses_truncation_and_trailing_data ... ok +test mutation_laws::candidate_order_and_count_bounds_refuse_after_complete_integrity ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/gc_retirement_receipt.rs (/build/keep107-gc-target/release/deps/gc_retirement_receipt-7af42e76dc359ac8) + +running 4 tests +test frozen_receipt_completes_the_frozen_intent_and_reencodes_canonically ... ok +test every_receipt_field_has_one_exact_first_refusal ... ok +test pool_state_digest_is_carried_not_bound_to_the_intent ... ok +test receipt_framing_refuses_any_length_but_the_fixed_width ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/recovery_disposition_receipt.rs (/build/keep107-gc-target/release/deps/recovery_disposition_receipt-d5a50675829e7cd0) + +running 5 tests +test every_registered_code_round_trips_and_matches_the_definition ... ok +test every_structural_field_has_one_exact_first_refusal ... ok +test framing_refuses_truncation_and_trailing_bytes ... ok +test frozen_disposition_decodes_and_reencodes_canonically ... ok +test liveness_zero_beside_the_empty_retention_digest_round_trips_as_empty_retention ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + diff --git a/docs/testing-evidence/gc-fuzz-canonicality/focused-validation.txt b/docs/testing-evidence/gc-fuzz-canonicality/focused-validation.txt new file mode 100644 index 00000000..6a8a09d5 --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/focused-validation.txt @@ -0,0 +1,11 @@ +Diff in /build/keep107-gc-source/fuzz/fuzz_targets/gc_format.rs:41: + + // Oracle: the canonical format relation, using the encoder rather than the + // decoder's retained input. Frozen independent vectors complement this relation. +-fn canonical_intent(intent: &AdmittedGcRetirementIntent<'_>) -> Option { ++fn canonical_intent( ++ intent: &AdmittedGcRetirementIntent<'_>, ++) -> Option { + let canonical = CanonicalGcRetirementIntent::from_intent(intent.intent()); + assert_eq!( + canonical.as_ref().ok().map(|value| ( diff --git a/docs/testing-evidence/gc-fuzz-canonicality/fuzz-random.txt b/docs/testing-evidence/gc-fuzz-canonicality/fuzz-random.txt new file mode 100644 index 00000000..af5193ac --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/fuzz-random.txt @@ -0,0 +1,33 @@ +Replay: seed=180510830 max_total_time=15 timeout=5 max_len=1048576 rss_limit_mb=1024; unshare network; outer deadline=75 + Finished `release` profile [optimized + debuginfo] target(s) in 0.03s + Finished `release` profile [optimized + debuginfo] target(s) in 0.01s + Running `/build/keep107-gc-nightly-target/aarch64-unknown-linux-gnu/release/gc_format -artifact_prefix=/build/keep107-gc-source/fuzz/artifacts/gc_format/ -seed=180510830 -max_total_time=15 -timeout=5 -max_len=1048576 -rss_limit_mb=1024 -print_final_stats=1 /build/keep107-gc-source/fuzz/corpus/gc_format` +INFO: Running with entropic power schedule (0xFF, 100). +INFO: Seed: 180510830 +INFO: Loaded 1 modules (91285 inline 8-bit counters): 91285 [0xaaaabb1761c0, 0xaaaabb18c655), +INFO: Loaded 1 PC tables (91285 PCs): 91285 [0xaaaabb18c658,0xaaaabb2f0fa8), +INFO: 35 files found in /build/keep107-gc-source/fuzz/corpus/gc_format +INFO: seed corpus: files: 35 min: 1b max: 781b total: 13979b rss: 37Mb +#36 INITED cov: 510 ft: 879 corp: 34/13974b exec/s: 0 rss: 40Mb +#21807 REDUCE cov: 510 ft: 879 corp: 34/13966b lim: 997 exec/s: 0 rss: 62Mb L: 488/781 MS: 1 EraseBytes- +#462278 REDUCE cov: 510 ft: 879 corp: 34/13957b lim: 5372 exec/s: 154092 rss: 396Mb L: 479/781 MS: 1 EraseBytes- +#524288 pulse cov: 510 ft: 879 corp: 34/13957b lim: 5984 exec/s: 174762 rss: 425Mb +#986685 REDUCE cov: 510 ft: 879 corp: 34/13946b lim: 10584 exec/s: 164447 rss: 432Mb L: 468/781 MS: 1 EraseBytes- +#1048576 pulse cov: 510 ft: 879 corp: 34/13946b lim: 11195 exec/s: 149796 rss: 432Mb +#1167602 REDUCE cov: 510 ft: 879 corp: 34/13941b lim: 12378 exec/s: 166800 rss: 432Mb L: 464/781 MS: 1 EraseBytes- +#1892330 REDUCE cov: 510 ft: 879 corp: 34/13901b lim: 19587 exec/s: 157694 rss: 435Mb L: 421/781 MS: 3 InsertRepeatedBytes-ChangeByte-EraseBytes- +#1892961 REDUCE cov: 510 ft: 879 corp: 34/13895b lim: 19587 exec/s: 157746 rss: 435Mb L: 415/781 MS: 1 EraseBytes- +#1895487 REDUCE cov: 510 ft: 879 corp: 34/13881b lim: 19601 exec/s: 157957 rss: 435Mb L: 401/781 MS: 1 EraseBytes- +#1901734 REDUCE cov: 510 ft: 879 corp: 34/13853b lim: 19657 exec/s: 158477 rss: 435Mb L: 373/781 MS: 2 ChangeBit-EraseBytes- +#1909800 REDUCE cov: 510 ft: 879 corp: 34/13845b lim: 19727 exec/s: 159150 rss: 435Mb L: 365/781 MS: 1 CrossOver- +#1934564 REDUCE cov: 510 ft: 879 corp: 34/13835b lim: 19965 exec/s: 161213 rss: 435Mb L: 355/781 MS: 4 ChangeASCIIInt-ChangeBinInt-EraseBytes-InsertRepeatedBytes- +#1945653 REDUCE cov: 510 ft: 879 corp: 34/13834b lim: 20063 exec/s: 162137 rss: 435Mb L: 354/781 MS: 4 ChangeBinInt-ShuffleBytes-ChangeByte-EraseBytes- +#1995259 RELOAD cov: 510 ft: 880 corp: 35/14255b lim: 20553 exec/s: 153481 rss: 435Mb +#2097152 pulse cov: 510 ft: 880 corp: 35/14255b lim: 21561 exec/s: 161319 rss: 438Mb +#2381099 DONE cov: 510 ft: 880 corp: 35/14255b lim: 24389 exec/s: 148818 rss: 472Mb +Done 2381099 runs in 16 second(s) +stat::number_of_executed_units: 2381099 +stat::average_exec_per_sec: 148818 +stat::new_units_added: 11 +stat::slowest_unit_time_sec: 0 +stat::peak_rss_mb: 472 diff --git a/docs/testing-evidence/gc-fuzz-canonicality/fuzz-smoke.txt b/docs/testing-evidence/gc-fuzz-canonicality/fuzz-smoke.txt new file mode 100644 index 00000000..8ac5de57 --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/fuzz-smoke.txt @@ -0,0 +1,225 @@ + Compiling libc v0.2.186 + Compiling find-msvc-tools v0.1.9 + Compiling rustix v1.1.4 + Compiling shlex v2.0.1 + Compiling bitflags v2.13.1 + Compiling io-lifetimes v2.0.4 + Compiling io-lifetimes v3.0.1 + Compiling linux-raw-sys v0.12.1 + Compiling io-extras v0.19.0 + Compiling serde_core v1.0.229 + Compiling cap-primitives v4.0.2 + Compiling once_cell v1.21.4 + Compiling maybe-owned v0.3.4 + Compiling ipnet v2.12.0 + Compiling cap-std v4.0.2 + Compiling ambient-authority v0.0.2 + Compiling zmij v1.0.23 + Compiling serde_json v1.0.151 + Compiling serde v1.0.229 + Compiling cap-fs-ext v4.0.2 + Compiling arrayvec v0.7.8 + Compiling arrayref v0.3.9 + Compiling itoa v1.0.18 + Compiling memchr v2.8.3 + Compiling constant_time_eq v0.4.2 + Compiling cfg-if v1.0.4 + Compiling arbitrary v1.4.2 + Compiling jobserver v0.1.35 + Compiling cc v1.3.0 + Compiling blake3 v1.8.5 + Compiling libfuzzer-sys v0.4.13 + Compiling fs-set-times v0.20.3 + Compiling rustix-linux-procfs v0.1.1 + Compiling xtask v0.0.0 (/build/keep107-gc-source/xtask) + Compiling keep v0.0.0 (/build/keep107-gc-source) + Compiling keep-fuzz v0.0.0 (/build/keep107-gc-source/fuzz) + Finished `release` profile [optimized + debuginfo] target(s) in 15.45s +Replay: seed=107 max_total_time=15 timeout=5 max_len=1048576 rss_limit_mb=1024; unshare network; outer deadline=75 + Finished `release` profile [optimized + debuginfo] target(s) in 0.01s + Finished `release` profile [optimized + debuginfo] target(s) in 0.01s + Running `/build/keep107-gc-nightly-target/aarch64-unknown-linux-gnu/release/gc_format -artifact_prefix=/build/keep107-gc-source/fuzz/artifacts/gc_format/ -seed=107 -max_total_time=15 -timeout=5 -max_len=1048576 -rss_limit_mb=1024 -print_final_stats=1 /build/keep107-gc-source/fuzz/corpus/gc_format` +INFO: Running with entropic power schedule (0xFF, 100). +INFO: Seed: 107 +INFO: Loaded 1 modules (91285 inline 8-bit counters): 91285 [0xaaaabcab61c0, 0xaaaabcacc655), +INFO: Loaded 1 PC tables (91285 PCs): 91285 [0xaaaabcacc658,0xaaaabcc30fa8), +INFO: 3 files found in /build/keep107-gc-source/fuzz/corpus/gc_format +INFO: seed corpus: files: 3 min: 321b max: 781b total: 1559b rss: 37Mb +#4 INITED cov: 457 ft: 617 corp: 3/1559b exec/s: 0 rss: 39Mb +#5 NEW cov: 460 ft: 621 corp: 4/2145b lim: 781 exec/s: 0 rss: 39Mb L: 586/781 MS: 1 CrossOver- +#6 NEW cov: 464 ft: 625 corp: 5/2603b lim: 781 exec/s: 0 rss: 39Mb L: 458/781 MS: 1 InsertByte- +#7 NEW cov: 470 ft: 727 corp: 6/3384b lim: 781 exec/s: 0 rss: 39Mb L: 781/781 MS: 1 CopyPart- +#8 NEW cov: 473 ft: 790 corp: 7/3841b lim: 781 exec/s: 0 rss: 39Mb L: 457/781 MS: 1 ChangeBinInt- +#9 NEW cov: 474 ft: 791 corp: 8/4077b lim: 781 exec/s: 0 rss: 39Mb L: 236/781 MS: 1 EraseBytes- +#15 NEW cov: 476 ft: 793 corp: 9/4790b lim: 781 exec/s: 0 rss: 40Mb L: 713/781 MS: 1 EraseBytes- +#21 NEW cov: 477 ft: 794 corp: 10/5503b lim: 781 exec/s: 0 rss: 40Mb L: 713/781 MS: 1 ChangeBit- +#25 NEW cov: 478 ft: 795 corp: 11/5889b lim: 781 exec/s: 0 rss: 40Mb L: 386/781 MS: 4 InsertByte-InsertByte-ShuffleBytes-EraseBytes- +#34 NEW cov: 479 ft: 796 corp: 12/6604b lim: 781 exec/s: 0 rss: 40Mb L: 715/781 MS: 4 ChangeASCIIInt-ChangeBinInt-InsertByte-InsertByte- +#47 NEW cov: 480 ft: 798 corp: 13/7038b lim: 781 exec/s: 0 rss: 40Mb L: 434/781 MS: 3 ChangeBit-ChangeBit-CrossOver- +#54 NEW cov: 481 ft: 799 corp: 14/7498b lim: 781 exec/s: 0 rss: 41Mb L: 460/781 MS: 2 ChangeASCIIInt-CMP- DE: "\377\377"- +#75 NEW cov: 482 ft: 800 corp: 15/8279b lim: 781 exec/s: 0 rss: 41Mb L: 781/781 MS: 1 CrossOver- +#94 NEW cov: 483 ft: 801 corp: 16/8750b lim: 781 exec/s: 0 rss: 41Mb L: 471/781 MS: 4 ShuffleBytes-ChangeBinInt-ChangeBinInt-CrossOver- +#123 REDUCE cov: 483 ft: 801 corp: 16/8581b lim: 781 exec/s: 0 rss: 41Mb L: 265/781 MS: 4 ChangeBit-ChangeByte-ChangeASCIIInt-EraseBytes- +#129 NEW cov: 484 ft: 802 corp: 17/8950b lim: 781 exec/s: 0 rss: 41Mb L: 369/781 MS: 1 EraseBytes- +#130 NEW cov: 486 ft: 805 corp: 18/9731b lim: 781 exec/s: 0 rss: 41Mb L: 781/781 MS: 1 ChangeBinInt- +#191 NEW cov: 487 ft: 806 corp: 19/10512b lim: 781 exec/s: 0 rss: 42Mb L: 781/781 MS: 1 ChangeBinInt- +#201 REDUCE cov: 487 ft: 806 corp: 19/10419b lim: 781 exec/s: 0 rss: 42Mb L: 143/781 MS: 5 PersAutoDict-ChangeASCIIInt-ShuffleBytes-CopyPart-CrossOver- DE: "\377\377"- +#229 NEW cov: 488 ft: 807 corp: 20/10981b lim: 781 exec/s: 0 rss: 42Mb L: 562/781 MS: 3 EraseBytes-ChangeByte-EraseBytes- +#235 REDUCE cov: 488 ft: 807 corp: 20/10975b lim: 781 exec/s: 0 rss: 42Mb L: 137/781 MS: 1 EraseBytes- +#236 NEW cov: 489 ft: 808 corp: 21/11756b lim: 781 exec/s: 0 rss: 42Mb L: 781/781 MS: 1 CopyPart- +#254 REDUCE cov: 489 ft: 808 corp: 21/11695b lim: 781 exec/s: 0 rss: 42Mb L: 325/781 MS: 3 CrossOver-InsertRepeatedBytes-InsertRepeatedBytes- +#350 NEW cov: 491 ft: 860 corp: 22/12016b lim: 781 exec/s: 0 rss: 43Mb L: 321/781 MS: 1 PersAutoDict- DE: "\377\377"- +#359 NEW cov: 492 ft: 861 corp: 23/12797b lim: 781 exec/s: 0 rss: 43Mb L: 781/781 MS: 4 ChangeASCIIInt-ShuffleBytes-ShuffleBytes-ChangeASCIIInt- +#361 NEW cov: 493 ft: 862 corp: 24/13511b lim: 781 exec/s: 0 rss: 43Mb L: 714/781 MS: 2 InsertByte-ShuffleBytes- +#407 REDUCE cov: 493 ft: 862 corp: 24/13485b lim: 781 exec/s: 0 rss: 43Mb L: 687/781 MS: 1 EraseBytes- +#498 REDUCE cov: 493 ft: 862 corp: 24/13291b lim: 781 exec/s: 0 rss: 44Mb L: 521/781 MS: 1 EraseBytes- +#511 REDUCE cov: 493 ft: 862 corp: 24/13284b lim: 781 exec/s: 0 rss: 44Mb L: 258/781 MS: 3 InsertRepeatedBytes-InsertRepeatedBytes-EraseBytes- +#547 REDUCE cov: 493 ft: 862 corp: 24/13281b lim: 781 exec/s: 0 rss: 44Mb L: 468/781 MS: 1 EraseBytes- +#582 REDUCE cov: 493 ft: 862 corp: 24/13209b lim: 781 exec/s: 0 rss: 44Mb L: 514/781 MS: 5 ChangeASCIIInt-ShuffleBytes-CopyPart-InsertByte-EraseBytes- +#600 REDUCE cov: 493 ft: 862 corp: 24/13191b lim: 781 exec/s: 0 rss: 44Mb L: 119/781 MS: 3 PersAutoDict-ChangeBinInt-EraseBytes- DE: "\377\377"- +#638 REDUCE cov: 493 ft: 862 corp: 24/12979b lim: 781 exec/s: 0 rss: 44Mb L: 475/781 MS: 3 InsertByte-PersAutoDict-EraseBytes- DE: "\377\377"- +#675 REDUCE cov: 493 ft: 862 corp: 24/12722b lim: 781 exec/s: 0 rss: 44Mb L: 257/781 MS: 2 CopyPart-EraseBytes- +#725 REDUCE cov: 493 ft: 862 corp: 24/12539b lim: 781 exec/s: 0 rss: 44Mb L: 531/781 MS: 5 InsertRepeatedBytes-InsertRepeatedBytes-ChangeByte-CrossOver-CopyPart- +#733 REDUCE cov: 493 ft: 862 corp: 24/12511b lim: 781 exec/s: 0 rss: 44Mb L: 341/781 MS: 3 ChangeBit-ChangeByte-EraseBytes- +#799 NEW cov: 498 ft: 867 corp: 25/12832b lim: 781 exec/s: 0 rss: 45Mb L: 321/781 MS: 1 CopyPart- +#809 REDUCE cov: 498 ft: 867 corp: 25/12712b lim: 781 exec/s: 0 rss: 45Mb L: 137/781 MS: 5 ChangeByte-CopyPart-ChangeBit-CopyPart-EraseBytes- +#814 REDUCE cov: 498 ft: 867 corp: 25/12617b lim: 781 exec/s: 0 rss: 45Mb L: 686/781 MS: 5 ChangeASCIIInt-ChangeBit-ChangeByte-ChangeASCIIInt-EraseBytes- +#843 REDUCE cov: 498 ft: 867 corp: 25/12549b lim: 781 exec/s: 0 rss: 45Mb L: 51/781 MS: 4 ChangeByte-CrossOver-ShuffleBytes-CrossOver- +#958 REDUCE cov: 499 ft: 868 corp: 26/12551b lim: 781 exec/s: 0 rss: 45Mb L: 2/781 MS: 5 ChangeBit-PersAutoDict-ShuffleBytes-ChangeBinInt-CrossOver- DE: "\377\377"- +#988 REDUCE cov: 499 ft: 868 corp: 26/12469b lim: 781 exec/s: 0 rss: 45Mb L: 176/781 MS: 5 CrossOver-ChangeByte-PersAutoDict-CopyPart-EraseBytes- DE: "\377\377"- +#1055 REDUCE cov: 499 ft: 868 corp: 26/12263b lim: 781 exec/s: 0 rss: 46Mb L: 480/781 MS: 2 ChangeASCIIInt-EraseBytes- +#1185 NEW cov: 500 ft: 869 corp: 27/12584b lim: 781 exec/s: 0 rss: 46Mb L: 321/781 MS: 5 CopyPart-ChangeByte-ChangeBinInt-ChangeASCIIInt-ChangeByte- +#1187 REDUCE cov: 500 ft: 869 corp: 27/12581b lim: 781 exec/s: 0 rss: 46Mb L: 338/781 MS: 2 ChangeASCIIInt-EraseBytes- +#1193 NEW cov: 501 ft: 870 corp: 28/13039b lim: 781 exec/s: 0 rss: 46Mb L: 458/781 MS: 1 ChangeBit- +#1329 REDUCE cov: 501 ft: 870 corp: 28/13016b lim: 781 exec/s: 0 rss: 46Mb L: 539/781 MS: 1 EraseBytes- +#1331 REDUCE cov: 501 ft: 870 corp: 28/12942b lim: 781 exec/s: 0 rss: 46Mb L: 384/781 MS: 2 ChangeASCIIInt-EraseBytes- +#1348 REDUCE cov: 501 ft: 870 corp: 28/12870b lim: 781 exec/s: 0 rss: 46Mb L: 104/781 MS: 2 CrossOver-EraseBytes- +#1359 REDUCE cov: 501 ft: 870 corp: 28/12841b lim: 781 exec/s: 0 rss: 46Mb L: 108/781 MS: 1 EraseBytes- +#1410 NEW cov: 502 ft: 871 corp: 29/12842b lim: 781 exec/s: 0 rss: 46Mb L: 1/781 MS: 1 EraseBytes- +#1466 NEW cov: 503 ft: 872 corp: 30/13163b lim: 781 exec/s: 0 rss: 46Mb L: 321/781 MS: 1 ChangeByte- +#1519 REDUCE cov: 503 ft: 872 corp: 30/13112b lim: 781 exec/s: 0 rss: 47Mb L: 57/781 MS: 3 ChangeBit-InsertRepeatedBytes-CrossOver- +#1590 REDUCE cov: 503 ft: 872 corp: 30/13080b lim: 781 exec/s: 0 rss: 47Mb L: 19/781 MS: 1 CrossOver- +#1593 NEW cov: 504 ft: 873 corp: 31/13861b lim: 781 exec/s: 0 rss: 47Mb L: 781/781 MS: 3 CopyPart-ChangeByte-CopyPart- +#1641 REDUCE cov: 504 ft: 873 corp: 31/13726b lim: 781 exec/s: 0 rss: 47Mb L: 325/781 MS: 3 CMP-ShuffleBytes-EraseBytes- DE: "\231\011\314\025\260\374~\255\350\226Td\253\032"- +#22022 REDUCE cov: 510 ft: 879 corp: 36/14983b lim: 880 exec/s: 0 rss: 65Mb L: 463/850 MS: 2 InsertRepeatedBytes-EraseBytes- +#22314 REDUCE cov: 510 ft: 879 corp: 36/14982b lim: 880 exec/s: 0 rss: 65Mb L: 461/850 MS: 2 ChangeASCIIInt-EraseBytes- +#22441 REDUCE cov: 510 ft: 879 corp: 36/14797b lim: 880 exec/s: 0 rss: 65Mb L: 665/781 MS: 2 InsertRepeatedBytes-EraseBytes- +#25108 REDUCE cov: 510 ft: 879 corp: 36/14795b lim: 898 exec/s: 0 rss: 67Mb L: 466/781 MS: 2 ChangeBinInt-EraseBytes- +#28694 REDUCE cov: 510 ft: 879 corp: 36/14726b lim: 925 exec/s: 0 rss: 70Mb L: 596/781 MS: 1 EraseBytes- +#29662 REDUCE cov: 510 ft: 879 corp: 36/14723b lim: 934 exec/s: 0 rss: 71Mb L: 321/781 MS: 3 ShuffleBytes-ChangeASCIIInt-EraseBytes- +#30498 REDUCE cov: 510 ft: 879 corp: 36/14661b lim: 934 exec/s: 0 rss: 71Mb L: 558/781 MS: 1 EraseBytes- +#30846 REDUCE cov: 510 ft: 879 corp: 36/14658b lim: 934 exec/s: 0 rss: 72Mb L: 322/781 MS: 3 ChangeBit-InsertRepeatedBytes-EraseBytes- +#32056 REDUCE cov: 510 ft: 879 corp: 36/14605b lim: 943 exec/s: 0 rss: 73Mb L: 408/781 MS: 5 ChangeByte-ChangeBinInt-CopyPart-ChangeBinInt-EraseBytes- +#32297 REDUCE cov: 510 ft: 879 corp: 36/14561b lim: 943 exec/s: 0 rss: 73Mb L: 364/781 MS: 1 EraseBytes- +#33686 REDUCE cov: 510 ft: 879 corp: 36/14550b lim: 952 exec/s: 0 rss: 74Mb L: 462/781 MS: 4 InsertRepeatedBytes-InsertByte-ChangeBit-CrossOver- +#34054 REDUCE cov: 510 ft: 879 corp: 36/14489b lim: 952 exec/s: 0 rss: 74Mb L: 303/781 MS: 3 ChangeByte-PersAutoDict-EraseBytes- DE: "\220\365\321\314TQ\000\000"- +#35257 REDUCE cov: 510 ft: 879 corp: 36/14486b lim: 961 exec/s: 0 rss: 75Mb L: 463/781 MS: 3 EraseBytes-ChangeBit-CopyPart- +#35432 REDUCE cov: 510 ft: 879 corp: 36/14484b lim: 961 exec/s: 0 rss: 75Mb L: 301/781 MS: 5 InsertRepeatedBytes-ChangeASCIIInt-ChangeBit-ChangeByte-EraseBytes- +#35590 REDUCE cov: 510 ft: 879 corp: 36/14463b lim: 961 exec/s: 0 rss: 76Mb L: 575/781 MS: 3 CopyPart-ChangeBinInt-EraseBytes- +#36538 REDUCE cov: 510 ft: 879 corp: 36/14448b lim: 970 exec/s: 0 rss: 76Mb L: 286/781 MS: 3 CMP-ChangeBit-EraseBytes- DE: "T\353\227\360\200y\235\0229\254'\022\261sUf\225\236\262\\\271)'yj\230\035\334\327``\375"- +#36545 REDUCE cov: 510 ft: 879 corp: 36/14439b lim: 970 exec/s: 0 rss: 76Mb L: 566/781 MS: 2 PersAutoDict-CrossOver- DE: "\220\365\321\314TQ\000\000"- +#42408 REDUCE cov: 510 ft: 879 corp: 36/14436b lim: 1024 exec/s: 0 rss: 81Mb L: 464/781 MS: 3 EraseBytes-ShuffleBytes-CopyPart- +#45443 REDUCE cov: 510 ft: 879 corp: 36/14432b lim: 1054 exec/s: 0 rss: 84Mb L: 282/781 MS: 5 EraseBytes-CopyPart-ChangeBinInt-ChangeBit-InsertByte- +#47824 REDUCE cov: 510 ft: 879 corp: 36/14430b lim: 1074 exec/s: 0 rss: 86Mb L: 326/781 MS: 1 EraseBytes- +#62540 REDUCE cov: 510 ft: 879 corp: 36/14389b lim: 1214 exec/s: 0 rss: 98Mb L: 517/781 MS: 1 EraseBytes- +#78167 REDUCE cov: 510 ft: 879 corp: 36/14386b lim: 1364 exec/s: 0 rss: 111Mb L: 563/781 MS: 2 ShuffleBytes-EraseBytes- +#81925 REDUCE cov: 510 ft: 879 corp: 36/14385b lim: 1394 exec/s: 0 rss: 114Mb L: 325/781 MS: 3 ChangeByte-InsertByte-EraseBytes- +#95051 REDUCE cov: 510 ft: 879 corp: 36/14374b lim: 1524 exec/s: 0 rss: 126Mb L: 552/781 MS: 1 EraseBytes- +#100814 REDUCE cov: 510 ft: 879 corp: 36/14373b lim: 1574 exec/s: 0 rss: 131Mb L: 321/781 MS: 3 ShuffleBytes-PersAutoDict-EraseBytes- DE: "\231\011\314\025\260\374~\255\350\226Td\253\032"- +#719995 REDUCE cov: 510 ft: 879 corp: 36/14042b lim: 7616 exec/s: 179998 rss: 441Mb L: 43/781 MS: 1 EraseBytes- +#722088 REDUCE cov: 510 ft: 879 corp: 36/14028b lim: 7628 exec/s: 180522 rss: 441Mb L: 29/781 MS: 3 ChangeBit-PersAutoDict-EraseBytes- DE: "\001\000\000\000"- +#729541 REDUCE cov: 510 ft: 879 corp: 36/14019b lim: 7700 exec/s: 182385 rss: 441Mb L: 20/781 MS: 3 ChangeASCIIInt-CrossOver-EraseBytes- +#734187 REDUCE cov: 510 ft: 879 corp: 36/14013b lim: 7736 exec/s: 183546 rss: 441Mb L: 14/781 MS: 1 EraseBytes- +#738960 REDUCE cov: 510 ft: 879 corp: 36/14009b lim: 7772 exec/s: 184740 rss: 441Mb L: 10/781 MS: 3 EraseBytes-InsertByte-ChangeBinInt- +#742071 REDUCE cov: 510 ft: 879 corp: 36/14008b lim: 7796 exec/s: 185517 rss: 441Mb L: 9/781 MS: 1 EraseBytes- +#745717 REDUCE cov: 510 ft: 879 corp: 36/14005b lim: 7832 exec/s: 186429 rss: 441Mb L: 6/781 MS: 1 EraseBytes- +#815513 REDUCE cov: 510 ft: 879 corp: 36/14004b lim: 8517 exec/s: 163102 rss: 442Mb L: 5/781 MS: 1 EraseBytes- +#857044 REDUCE cov: 510 ft: 879 corp: 36/13989b lim: 8920 exec/s: 171408 rss: 442Mb L: 496/781 MS: 1 EraseBytes- +#993865 REDUCE cov: 510 ft: 879 corp: 36/13980b lim: 10272 exec/s: 165644 rss: 442Mb L: 469/781 MS: 1 EraseBytes- +#1048576 pulse cov: 510 ft: 879 corp: 36/13980b lim: 10805 exec/s: 174762 rss: 442Mb +#2097152 pulse cov: 510 ft: 879 corp: 36/13980b lim: 21253 exec/s: 161319 rss: 450Mb +#2411090 DONE cov: 510 ft: 879 corp: 36/13980b lim: 24389 exec/s: 150693 rss: 450Mb +###### Recommended dictionary. ###### +"\377\377" # Uses: 23820 +"\231\011\314\025\260\374~\255\350\226Td\253\032" # Uses: 22982 +"T\353\227\360\200y\235\0229\254'\022\261sUf\225\236\262\\\271)'yj\230\035\334\327``\375" # Uses: 23236 +"\010\224\241M\035\004\260f\343\250\231\224&\002\305\230m5\272m\366\301\243\\\374a\364Q\244\244\226\242" # Uses: 15713 +###### End of recommended dictionary. ###### +Done 2411090 runs in 16 second(s) +stat::number_of_executed_units: 2411090 +stat::average_exec_per_sec: 150693 +stat::new_units_added: 156 +stat::slowest_unit_time_sec: 0 +stat::peak_rss_mb: 450 diff --git a/docs/testing-evidence/gc-fuzz-canonicality/intent-corrected.txt b/docs/testing-evidence/gc-fuzz-canonicality/intent-corrected.txt new file mode 100644 index 00000000..22ff2f82 --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/intent-corrected.txt @@ -0,0 +1,17 @@ +WARNING: Failed to find function "__sanitizer_acquire_crash_state". +WARNING: Failed to find function "__sanitizer_print_stack_trace". +WARNING: Failed to find function "__sanitizer_set_death_callback". +INFO: Running with entropic power schedule (0xFF, 100). +INFO: Seed: 107 +/build/keep107-gc-target/debug/gc_format: Running 1 inputs 1 time(s) each. +Running: /build/keep107-gc-calibration/intent.seed + +thread '' (76773) panicked at fuzz_targets/gc_format.rs:46:5: +assertion `left == right` failed: admitted intent must preserve its canonical bytes, semantics and digests: Ok(CanonicalGcRetirementIntent { encoded: [74, 69, 69, 80, 58, 71, 67, 58, 73, 78, 84, 69, 78, 84, 50, 0, 0, 2, 1, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 200, 0, 0, 0, 0, 0, 0, 0, 1, 0, 72, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235, 0, 0, 0, 0, 0, 0, 0, 2, 234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147, 0, 0, 0, 1, 0, 0, 0, 1, 219, 28, 28, 26, 80, 97, 62, 241, 31, 124, 14, 224, 136, 46, 55, 182, 210, 78, 45, 178, 202, 87, 120, 61, 1, 25, 123, 165, 27, 97, 206, 89, 246, 124, 11, 104, 87, 47, 203, 11, 56, 160, 119, 4, 141, 114, 247, 164, 25, 160, 192, 167, 174, 92, 38, 41, 195, 221, 118, 37, 63, 203, 235, 166, 64, 191, 93, 73, 195, 72, 71, 172, 156, 244, 106, 37, 111, 52, 60, 238, 128, 205, 152, 13, 20, 5, 210, 221, 2, 206, 255, 143, 88, 214, 116, 249, 168, 2, 89, 252, 209, 35, 114, 3, 234, 108, 108, 197, 6, 85, 20, 171, 222, 176, 29, 166, 3, 195, 25, 75, 9, 106, 4, 92, 246, 148, 201, 90, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 6, 102, 118, 188, 157, 112, 19, 74, 116, 204, 157, 254, 57, 127, 184, 160, 51, 252, 69, 235, 217, 3, 41, 13, 41, 185, 48, 24, 160, 151, 238, 43, 80, 183, 84, 45, 206, 210, 171, 119, 8, 148, 161, 77, 29, 4, 176, 102, 227, 168, 153, 148, 38, 2, 197, 152, 109, 53, 186, 109, 246, 193, 163, 92, 252, 0, 0, 0, 0, 0, 0, 1, 81, 190, 203, 70, 179, 81, 32, 114, 50, 16, 121, 138, 71, 226, 97, 68, 184, 33, 77, 94, 166, 93, 40, 128, 110, 11, 169, 65, 210, 170, 102, 187, 250, 169, 221, 82, 51, 38, 166, 134, 184, 154, 200, 240, 196, 16, 66, 23, 102, 175, 194, 33, 242, 150, 59, 218, 253, 67, 13, 206, 127, 89, 237, 199, 1, 206, 253, 50, 95, 191, 123, 25, 0, 225, 162, 8, 201, 198, 110, 197, 207, 208, 62, 86, 94, 160, 74, 59, 249, 1, 19, 56, 233, 218, 186, 231, 73], intent: GcRetirementIntent { coordinates: GcRetirementIntentCoordinates { generation: GcGeneration(1), liveness_generation: LivenessGeneration(1), manifest_digest: RetentionManifestDigest([244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235]), catalog_generation: CatalogGeneration(2), catalog_digest: CatalogDigest([234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147]), profile: RegisteredRetentionProfile { identity: 1, version: 1, digest: [219, 28, 28, 26, 80, 97, 62, 241, 31, 124, 14, 224, 136, 46, 55, 182, 210, 78, 45, 178, 202, 87, 120, 61, 1, 25, 123, 165, 27, 97, 206, 89] }, catalog_successor_proof_digest: CatalogSuccessorProofDigest([246, 124, 11, 104, 87, 47, 203, 11, 56, 160, 119, 4, 141, 114, 247, 164, 25, 160, 192, 167, 174, 92, 38, 41, 195, 221, 118, 37, 63, 203, 235, 166]), segment_pool_identity_digest: SegmentPoolIdentityDigest([64, 191, 93, 73, 195, 72, 71, 172, 156, 244, 106, 37, 111, 52, 60, 238, 128, 205, 152, 13, 20, 5, 210, 221, 2, 206, 255, 143, 88, 214, 116, 249]), disposition_set_digest: DispositionSetDigest([168, 2, 89, 252, 209, 35, 114, 3, 234, 108, 108, 197, 6, 85, 20, 171, 222, 176, 29, 166, 3, 195, 25, 75, 9, 106, 4, 92, 246, 148, 201, 90]), reader_lock: ReaderLockIdentity { device: 4, mount: 5, file: 6 } }, candidates: [GcCandidate { segment_digest: SegmentDigest([183, 84, 45, 206, 210, 171, 119, 8, 148, 161, 77, 29, 4, 176, 102, 227, 168, 153, 148, 38, 2, 197, 152, 109, 53, 186, 109, 246, 193, 163, 92, 252]), segment_length: 337, evidence_digest: VerificationEvidenceDigest([190, 203, 70, 179, 81, 32, 114, 50, 16, 121, 138, 71, 226, 97, 68, 184, 33, 77, 94, 166, 93, 40, 128, 110, 11, 169, 65, 210, 170, 102, 187, 250]) }] }, candidate_set_digest: GcCandidateSetDigest([102, 118, 188, 157, 112, 19, 74, 116, 204, 157, 254, 57, 127, 184, 160, 51, 252, 69, 235, 217, 3, 41, 13, 41, 185, 48, 24, 160, 151, 238, 43, 80]), digest: GcRetirementIntentDigest([169, 221, 82, 51, 38, 166, 134, 184, 154, 200, 240, 196, 16, 66, 23, 102, 175, 194, 33, 242, 150, 59, 218, 253, 67, 13, 206, 127, 89, 237, 199, 1]) }) + left: Some(([74, 69, 69, 80, 58, 71, 67, 58, 73, 78, 84, 69, 78, 84, 50, 0, 0, 2, 1, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 200, 0, 0, 0, 0, 0, 0, 0, 1, 0, 72, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235, 0, 0, 0, 0, 0, 0, 0, 2, 234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147, 0, 0, 0, 1, 0, 0, 0, 1, 219, 28, 28, 26, 80, 97, 62, 241, 31, 124, 14, 224, 136, 46, 55, 182, 210, 78, 45, 178, 202, 87, 120, 61, 1, 25, 123, 165, 27, 97, 206, 89, 246, 124, 11, 104, 87, 47, 203, 11, 56, 160, 119, 4, 141, 114, 247, 164, 25, 160, 192, 167, 174, 92, 38, 41, 195, 221, 118, 37, 63, 203, 235, 166, 64, 191, 93, 73, 195, 72, 71, 172, 156, 244, 106, 37, 111, 52, 60, 238, 128, 205, 152, 13, 20, 5, 210, 221, 2, 206, 255, 143, 88, 214, 116, 249, 168, 2, 89, 252, 209, 35, 114, 3, 234, 108, 108, 197, 6, 85, 20, 171, 222, 176, 29, 166, 3, 195, 25, 75, 9, 106, 4, 92, 246, 148, 201, 90, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 6, 102, 118, 188, 157, 112, 19, 74, 116, 204, 157, 254, 57, 127, 184, 160, 51, 252, 69, 235, 217, 3, 41, 13, 41, 185, 48, 24, 160, 151, 238, 43, 80, 183, 84, 45, 206, 210, 171, 119, 8, 148, 161, 77, 29, 4, 176, 102, 227, 168, 153, 148, 38, 2, 197, 152, 109, 53, 186, 109, 246, 193, 163, 92, 252, 0, 0, 0, 0, 0, 0, 1, 81, 190, 203, 70, 179, 81, 32, 114, 50, 16, 121, 138, 71, 226, 97, 68, 184, 33, 77, 94, 166, 93, 40, 128, 110, 11, 169, 65, 210, 170, 102, 187, 250, 169, 221, 82, 51, 38, 166, 134, 184, 154, 200, 240, 196, 16, 66, 23, 102, 175, 194, 33, 242, 150, 59, 218, 253, 67, 13, 206, 127, 89, 237, 199, 1, 206, 253, 50, 95, 191, 123, 25, 0, 225, 162, 8, 201, 198, 110, 197, 207, 208, 62, 86, 94, 160, 74, 59, 249, 1, 19, 56, 233, 218, 186, 231, 73], GcRetirementIntent { coordinates: GcRetirementIntentCoordinates { generation: GcGeneration(1), liveness_generation: LivenessGeneration(1), manifest_digest: RetentionManifestDigest([244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235]), catalog_generation: CatalogGeneration(2), catalog_digest: CatalogDigest([234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147]), profile: RegisteredRetentionProfile { identity: 1, version: 1, digest: [219, 28, 28, 26, 80, 97, 62, 241, 31, 124, 14, 224, 136, 46, 55, 182, 210, 78, 45, 178, 202, 87, 120, 61, 1, 25, 123, 165, 27, 97, 206, 89] }, catalog_successor_proof_digest: CatalogSuccessorProofDigest([246, 124, 11, 104, 87, 47, 203, 11, 56, 160, 119, 4, 141, 114, 247, 164, 25, 160, 192, 167, 174, 92, 38, 41, 195, 221, 118, 37, 63, 203, 235, 166]), segment_pool_identity_digest: SegmentPoolIdentityDigest([64, 191, 93, 73, 195, 72, 71, 172, 156, 244, 106, 37, 111, 52, 60, 238, 128, 205, 152, 13, 20, 5, 210, 221, 2, 206, 255, 143, 88, 214, 116, 249]), disposition_set_digest: DispositionSetDigest([168, 2, 89, 252, 209, 35, 114, 3, 234, 108, 108, 197, 6, 85, 20, 171, 222, 176, 29, 166, 3, 195, 25, 75, 9, 106, 4, 92, 246, 148, 201, 90]), reader_lock: ReaderLockIdentity { device: 4, mount: 5, file: 6 } }, candidates: [GcCandidate { segment_digest: SegmentDigest([183, 84, 45, 206, 210, 171, 119, 8, 148, 161, 77, 29, 4, 176, 102, 227, 168, 153, 148, 38, 2, 197, 152, 109, 53, 186, 109, 246, 193, 163, 92, 252]), segment_length: 337, evidence_digest: VerificationEvidenceDigest([190, 203, 70, 179, 81, 32, 114, 50, 16, 121, 138, 71, 226, 97, 68, 184, 33, 77, 94, 166, 93, 40, 128, 110, 11, 169, 65, 210, 170, 102, 187, 250]) }] }, GcRetirementIntentDigest([169, 221, 82, 51, 38, 166, 134, 184, 154, 200, 240, 196, 16, 66, 23, 102, 175, 194, 33, 242, 150, 59, 218, 253, 67, 13, 206, 127, 89, 237, 199, 1]), GcCandidateSetDigest([102, 118, 188, 157, 112, 19, 74, 116, 204, 157, 254, 57, 127, 184, 160, 51, 252, 69, 235, 217, 3, 41, 13, 41, 185, 48, 24, 160, 151, 238, 43, 80]))) + right: Some(([75, 69, 69, 80, 58, 71, 67, 58, 73, 78, 84, 69, 78, 84, 50, 0, 0, 2, 1, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 200, 0, 0, 0, 0, 0, 0, 0, 1, 0, 72, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235, 0, 0, 0, 0, 0, 0, 0, 2, 234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147, 0, 0, 0, 1, 0, 0, 0, 1, 219, 28, 28, 26, 80, 97, 62, 241, 31, 124, 14, 224, 136, 46, 55, 182, 210, 78, 45, 178, 202, 87, 120, 61, 1, 25, 123, 165, 27, 97, 206, 89, 246, 124, 11, 104, 87, 47, 203, 11, 56, 160, 119, 4, 141, 114, 247, 164, 25, 160, 192, 167, 174, 92, 38, 41, 195, 221, 118, 37, 63, 203, 235, 166, 64, 191, 93, 73, 195, 72, 71, 172, 156, 244, 106, 37, 111, 52, 60, 238, 128, 205, 152, 13, 20, 5, 210, 221, 2, 206, 255, 143, 88, 214, 116, 249, 168, 2, 89, 252, 209, 35, 114, 3, 234, 108, 108, 197, 6, 85, 20, 171, 222, 176, 29, 166, 3, 195, 25, 75, 9, 106, 4, 92, 246, 148, 201, 90, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 6, 102, 118, 188, 157, 112, 19, 74, 116, 204, 157, 254, 57, 127, 184, 160, 51, 252, 69, 235, 217, 3, 41, 13, 41, 185, 48, 24, 160, 151, 238, 43, 80, 183, 84, 45, 206, 210, 171, 119, 8, 148, 161, 77, 29, 4, 176, 102, 227, 168, 153, 148, 38, 2, 197, 152, 109, 53, 186, 109, 246, 193, 163, 92, 252, 0, 0, 0, 0, 0, 0, 1, 81, 190, 203, 70, 179, 81, 32, 114, 50, 16, 121, 138, 71, 226, 97, 68, 184, 33, 77, 94, 166, 93, 40, 128, 110, 11, 169, 65, 210, 170, 102, 187, 250, 169, 221, 82, 51, 38, 166, 134, 184, 154, 200, 240, 196, 16, 66, 23, 102, 175, 194, 33, 242, 150, 59, 218, 253, 67, 13, 206, 127, 89, 237, 199, 1, 206, 253, 50, 95, 191, 123, 25, 0, 225, 162, 8, 201, 198, 110, 197, 207, 208, 62, 86, 94, 160, 74, 59, 249, 1, 19, 56, 233, 218, 186, 231, 73], GcRetirementIntent { coordinates: GcRetirementIntentCoordinates { generation: GcGeneration(1), liveness_generation: LivenessGeneration(1), manifest_digest: RetentionManifestDigest([244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235]), catalog_generation: CatalogGeneration(2), catalog_digest: CatalogDigest([234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147]), profile: RegisteredRetentionProfile { identity: 1, version: 1, digest: [219, 28, 28, 26, 80, 97, 62, 241, 31, 124, 14, 224, 136, 46, 55, 182, 210, 78, 45, 178, 202, 87, 120, 61, 1, 25, 123, 165, 27, 97, 206, 89] }, catalog_successor_proof_digest: CatalogSuccessorProofDigest([246, 124, 11, 104, 87, 47, 203, 11, 56, 160, 119, 4, 141, 114, 247, 164, 25, 160, 192, 167, 174, 92, 38, 41, 195, 221, 118, 37, 63, 203, 235, 166]), segment_pool_identity_digest: SegmentPoolIdentityDigest([64, 191, 93, 73, 195, 72, 71, 172, 156, 244, 106, 37, 111, 52, 60, 238, 128, 205, 152, 13, 20, 5, 210, 221, 2, 206, 255, 143, 88, 214, 116, 249]), disposition_set_digest: DispositionSetDigest([168, 2, 89, 252, 209, 35, 114, 3, 234, 108, 108, 197, 6, 85, 20, 171, 222, 176, 29, 166, 3, 195, 25, 75, 9, 106, 4, 92, 246, 148, 201, 90]), reader_lock: ReaderLockIdentity { device: 4, mount: 5, file: 6 } }, candidates: [GcCandidate { segment_digest: SegmentDigest([183, 84, 45, 206, 210, 171, 119, 8, 148, 161, 77, 29, 4, 176, 102, 227, 168, 153, 148, 38, 2, 197, 152, 109, 53, 186, 109, 246, 193, 163, 92, 252]), segment_length: 337, evidence_digest: VerificationEvidenceDigest([190, 203, 70, 179, 81, 32, 114, 50, 16, 121, 138, 71, 226, 97, 68, 184, 33, 77, 94, 166, 93, 40, 128, 110, 11, 169, 65, 210, 170, 102, 187, 250]) }] }, GcRetirementIntentDigest([169, 221, 82, 51, 38, 166, 134, 184, 154, 200, 240, 196, 16, 66, 23, 102, 175, 194, 33, 242, 150, 59, 218, 253, 67, 13, 206, 127, 89, 237, 199, 1]), GcCandidateSetDigest([102, 118, 188, 157, 112, 19, 74, 116, 204, 157, 254, 57, 127, 184, 160, 51, 252, 69, 235, 217, 3, 41, 13, 41, 185, 48, 24, 160, 151, 238, 43, 80]))) +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +==76773== ERROR: libFuzzer: deadly signal +NOTE: libFuzzer has rudimentary signal handlers. + Combine libFuzzer with AddressSanitizer or similar for better crash reports. +SUMMARY: libFuzzer: deadly signal diff --git a/docs/testing-evidence/gc-fuzz-canonicality/intent-original.txt b/docs/testing-evidence/gc-fuzz-canonicality/intent-original.txt new file mode 100644 index 00000000..8b57806e --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/intent-original.txt @@ -0,0 +1,12 @@ +WARNING: Failed to find function "__sanitizer_acquire_crash_state". +WARNING: Failed to find function "__sanitizer_print_stack_trace". +WARNING: Failed to find function "__sanitizer_set_death_callback". +INFO: Running with entropic power schedule (0xFF, 100). +INFO: Seed: 107 +/build/keep107-gc-target/debug/gc_format: Running 1 inputs 1 time(s) each. +Running: /build/keep107-gc-calibration/intent.seed +Executed /build/keep107-gc-calibration/intent.seed in 0 ms +*** +*** NOTE: fuzzing was not performed, you have only +*** executed the target code on a fixed set of inputs. +*** diff --git a/docs/testing-evidence/gc-fuzz-canonicality/intent-restored.txt b/docs/testing-evidence/gc-fuzz-canonicality/intent-restored.txt new file mode 100644 index 00000000..8b57806e --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/intent-restored.txt @@ -0,0 +1,12 @@ +WARNING: Failed to find function "__sanitizer_acquire_crash_state". +WARNING: Failed to find function "__sanitizer_print_stack_trace". +WARNING: Failed to find function "__sanitizer_set_death_callback". +INFO: Running with entropic power schedule (0xFF, 100). +INFO: Seed: 107 +/build/keep107-gc-target/debug/gc_format: Running 1 inputs 1 time(s) each. +Running: /build/keep107-gc-calibration/intent.seed +Executed /build/keep107-gc-calibration/intent.seed in 0 ms +*** +*** NOTE: fuzzing was not performed, you have only +*** executed the target code on a fixed set of inputs. +*** diff --git a/docs/testing-evidence/gc-fuzz-canonicality/intent.patch b/docs/testing-evidence/gc-fuzz-canonicality/intent.patch new file mode 100644 index 00000000..6c8e3748 --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/intent.patch @@ -0,0 +1,12 @@ +--- a/src/adapters/gc/intent_encoder.rs ++++ b/src/adapters/gc/intent_encoder.rs +@@ -38,6 +38,9 @@ + encoded.extend_from_slice(&digest); + let checksum = format::checksum(&encoded); + encoded.extend_from_slice(&checksum); ++ if let Some(byte) = encoded.first_mut() { ++ *byte ^= 1; ++ } + Ok(CanonicalGcRetirementIntent::admitted( + encoded, + intent.clone(), diff --git a/docs/testing-evidence/gc-fuzz-canonicality/receipt-corrected.txt b/docs/testing-evidence/gc-fuzz-canonicality/receipt-corrected.txt new file mode 100644 index 00000000..2ff5c73d --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/receipt-corrected.txt @@ -0,0 +1,17 @@ +WARNING: Failed to find function "__sanitizer_acquire_crash_state". +WARNING: Failed to find function "__sanitizer_print_stack_trace". +WARNING: Failed to find function "__sanitizer_set_death_callback". +INFO: Running with entropic power schedule (0xFF, 100). +INFO: Seed: 107 +/build/keep107-gc-target/debug/gc_format: Running 1 inputs 1 time(s) each. +Running: /build/keep107-gc-calibration/receipt.seed + +thread '' (77164) panicked at fuzz_targets/gc_format.rs:88:9: +assertion `left == right` failed: admitted retirement receipt must preserve its canonical bytes and semantics + left: ([74, 69, 69, 80, 58, 71, 67, 58, 82, 69, 67, 69, 73, 80, 84, 50, 0, 2, 1, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 169, 221, 82, 51, 38, 166, 134, 184, 154, 200, 240, 196, 16, 66, 23, 102, 175, 194, 33, 242, 150, 59, 218, 253, 67, 13, 206, 127, 89, 237, 199, 1, 102, 118, 188, 157, 112, 19, 74, 116, 204, 157, 254, 57, 127, 184, 160, 51, 252, 69, 235, 217, 3, 41, 13, 41, 185, 48, 24, 160, 151, 238, 43, 80, 254, 240, 204, 205, 123, 182, 247, 90, 51, 34, 213, 69, 114, 25, 206, 72, 117, 229, 245, 174, 117, 25, 62, 60, 102, 212, 133, 107, 202, 56, 1, 112, 0, 0, 0, 0, 0, 0, 0, 1, 244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235, 0, 0, 0, 0, 0, 0, 0, 2, 234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 211, 221, 141, 222, 234, 156, 231, 138, 39, 139, 57, 163, 189, 246, 27, 149, 127, 255, 143, 108, 254, 230, 71, 241, 56, 252, 114, 0, 145, 56, 145, 71], GcRetirementReceipt { generation: GcGeneration(1), intent_digest: GcRetirementIntentDigest([169, 221, 82, 51, 38, 166, 134, 184, 154, 200, 240, 196, 16, 66, 23, 102, 175, 194, 33, 242, 150, 59, 218, 253, 67, 13, 206, 127, 89, 237, 199, 1]), retired_candidate_set_digest: GcCandidateSetDigest([102, 118, 188, 157, 112, 19, 74, 116, 204, 157, 254, 57, 127, 184, 160, 51, 252, 69, 235, 217, 3, 41, 13, 41, 185, 48, 24, 160, 151, 238, 43, 80]), pool_state_digest: PoolStateDigest([254, 240, 204, 205, 123, 182, 247, 90, 51, 34, 213, 69, 114, 25, 206, 72, 117, 229, 245, 174, 117, 25, 62, 60, 102, 212, 133, 107, 202, 56, 1, 112]), liveness_generation: LivenessGeneration(1), manifest_digest: RetentionManifestDigest([244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235]), catalog_generation: CatalogGeneration(2), catalog_digest: CatalogDigest([234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147]), reader_lock: ReaderLockIdentity { device: 4, mount: 5, file: 6 }, synchronization_count: 1 }) + right: ([75, 69, 69, 80, 58, 71, 67, 58, 82, 69, 67, 69, 73, 80, 84, 50, 0, 2, 1, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 169, 221, 82, 51, 38, 166, 134, 184, 154, 200, 240, 196, 16, 66, 23, 102, 175, 194, 33, 242, 150, 59, 218, 253, 67, 13, 206, 127, 89, 237, 199, 1, 102, 118, 188, 157, 112, 19, 74, 116, 204, 157, 254, 57, 127, 184, 160, 51, 252, 69, 235, 217, 3, 41, 13, 41, 185, 48, 24, 160, 151, 238, 43, 80, 254, 240, 204, 205, 123, 182, 247, 90, 51, 34, 213, 69, 114, 25, 206, 72, 117, 229, 245, 174, 117, 25, 62, 60, 102, 212, 133, 107, 202, 56, 1, 112, 0, 0, 0, 0, 0, 0, 0, 1, 244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235, 0, 0, 0, 0, 0, 0, 0, 2, 234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 211, 221, 141, 222, 234, 156, 231, 138, 39, 139, 57, 163, 189, 246, 27, 149, 127, 255, 143, 108, 254, 230, 71, 241, 56, 252, 114, 0, 145, 56, 145, 71], GcRetirementReceipt { generation: GcGeneration(1), intent_digest: GcRetirementIntentDigest([169, 221, 82, 51, 38, 166, 134, 184, 154, 200, 240, 196, 16, 66, 23, 102, 175, 194, 33, 242, 150, 59, 218, 253, 67, 13, 206, 127, 89, 237, 199, 1]), retired_candidate_set_digest: GcCandidateSetDigest([102, 118, 188, 157, 112, 19, 74, 116, 204, 157, 254, 57, 127, 184, 160, 51, 252, 69, 235, 217, 3, 41, 13, 41, 185, 48, 24, 160, 151, 238, 43, 80]), pool_state_digest: PoolStateDigest([254, 240, 204, 205, 123, 182, 247, 90, 51, 34, 213, 69, 114, 25, 206, 72, 117, 229, 245, 174, 117, 25, 62, 60, 102, 212, 133, 107, 202, 56, 1, 112]), liveness_generation: LivenessGeneration(1), manifest_digest: RetentionManifestDigest([244, 107, 150, 162, 191, 51, 121, 50, 12, 245, 158, 138, 241, 91, 157, 16, 141, 224, 96, 37, 196, 21, 48, 123, 40, 149, 55, 20, 189, 122, 128, 235]), catalog_generation: CatalogGeneration(2), catalog_digest: CatalogDigest([234, 125, 0, 85, 253, 33, 240, 14, 217, 72, 9, 239, 78, 103, 29, 114, 250, 46, 106, 74, 93, 158, 206, 251, 35, 243, 163, 32, 162, 218, 217, 147]), reader_lock: ReaderLockIdentity { device: 4, mount: 5, file: 6 }, synchronization_count: 1 }) +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +==77164== ERROR: libFuzzer: deadly signal +NOTE: libFuzzer has rudimentary signal handlers. + Combine libFuzzer with AddressSanitizer or similar for better crash reports. +SUMMARY: libFuzzer: deadly signal diff --git a/docs/testing-evidence/gc-fuzz-canonicality/receipt-original.txt b/docs/testing-evidence/gc-fuzz-canonicality/receipt-original.txt new file mode 100644 index 00000000..143760fc --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/receipt-original.txt @@ -0,0 +1,12 @@ +WARNING: Failed to find function "__sanitizer_acquire_crash_state". +WARNING: Failed to find function "__sanitizer_print_stack_trace". +WARNING: Failed to find function "__sanitizer_set_death_callback". +INFO: Running with entropic power schedule (0xFF, 100). +INFO: Seed: 107 +/build/keep107-gc-target/debug/gc_format: Running 1 inputs 1 time(s) each. +Running: /build/keep107-gc-calibration/receipt.seed +Executed /build/keep107-gc-calibration/receipt.seed in 0 ms +*** +*** NOTE: fuzzing was not performed, you have only +*** executed the target code on a fixed set of inputs. +*** diff --git a/docs/testing-evidence/gc-fuzz-canonicality/receipt-restored.txt b/docs/testing-evidence/gc-fuzz-canonicality/receipt-restored.txt new file mode 100644 index 00000000..143760fc --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/receipt-restored.txt @@ -0,0 +1,12 @@ +WARNING: Failed to find function "__sanitizer_acquire_crash_state". +WARNING: Failed to find function "__sanitizer_print_stack_trace". +WARNING: Failed to find function "__sanitizer_set_death_callback". +INFO: Running with entropic power schedule (0xFF, 100). +INFO: Seed: 107 +/build/keep107-gc-target/debug/gc_format: Running 1 inputs 1 time(s) each. +Running: /build/keep107-gc-calibration/receipt.seed +Executed /build/keep107-gc-calibration/receipt.seed in 0 ms +*** +*** NOTE: fuzzing was not performed, you have only +*** executed the target code on a fixed set of inputs. +*** diff --git a/docs/testing-evidence/gc-fuzz-canonicality/receipt.patch b/docs/testing-evidence/gc-fuzz-canonicality/receipt.patch new file mode 100644 index 00000000..8b0c21c6 --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/receipt.patch @@ -0,0 +1,12 @@ +--- a/src/adapters/gc/receipt_encoder.rs ++++ b/src/adapters/gc/receipt_encoder.rs +@@ -7,6 +7,9 @@ + let (preimage, checksum_slot) = encoded.split_at_mut(format::CHECKSUM_OFFSET); + write_preimage(preimage, &receipt); + checksum_slot.copy_from_slice(&format::checksum(preimage)); ++ if let Some(byte) = encoded.first_mut() { ++ *byte ^= 1; ++ } + CanonicalGcRetirementReceipt::admitted(&encoded, receipt) + } + diff --git a/docs/testing-evidence/gc-fuzz-canonicality/source-profile.txt b/docs/testing-evidence/gc-fuzz-canonicality/source-profile.txt new file mode 100644 index 00000000..7570bc9a --- /dev/null +++ b/docs/testing-evidence/gc-fuzz-canonicality/source-profile.txt @@ -0,0 +1,11 @@ + fuzz/fuzz_targets/gc_format.rs | 47 +++++++++++++++++++++++++++++++++++++++--- + 1 file changed, 44 insertions(+), 3 deletions(-) +f31f4218dee13dc7a6b210121accdd683f4c14c5f5c928b8e501ae07cba80efa fuzz/fuzz_targets/gc_format.rs +rustc 1.96.0 (ac68faa20 2026-05-25) +binary: rustc +commit-hash: ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96 +commit-date: 2026-05-25 +host: aarch64-unknown-linux-gnu +release: 1.96.0 +LLVM version: 22.1.2 +Linux aarch64 diff --git a/fuzz/README.md b/fuzz/README.md index 6931f305..6e0bc5a1 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -75,10 +75,7 @@ and completion-receipt decoders. The receipt seed carries its exact marker and intent dependencies so mutations exercise integrity and cross-record binding; every admitted value must retain its exact input bytes. -The `gc_format` seeds select the public GC retirement-intent and -retirement-receipt decoders. The receipt seed carries its exact intent -dependency behind a length frame so mutations exercise cross-record binding -as well as framing; every admitted value must retain its exact input bytes. +The `gc_format` seeds select the public GC retirement-intent, retirement-receipt and recovery-disposition decoders. The retirement-receipt seed carries its exact intent dependency behind a length frame so mutations exercise cross-record binding as well as framing. Every admitted value must re-encode byte-for-byte through its public canonical encoder and preserve its semantic value; intents also preserve both reported digests. The canonicality relation complements the independent conformance vectors and does not prove specification correctness when encoder and decoder share a defect. The `segment_format` seeds select the public segment-header, record-header, complete-record, seal, and complete-segment boundaries. Canonical empty, diff --git a/fuzz/fuzz_targets/gc_format.rs b/fuzz/fuzz_targets/gc_format.rs index 748dcfa9..e431f58a 100644 --- a/fuzz/fuzz_targets/gc_format.rs +++ b/fuzz/fuzz_targets/gc_format.rs @@ -4,6 +4,7 @@ use keep::{ AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, AdmittedRecoveryDispositionReceipt, + CanonicalGcRetirementIntent, CanonicalGcRetirementReceipt, CanonicalRecoveryDispositionReceipt, }; use libfuzzer_sys::fuzz_target; @@ -23,16 +24,45 @@ fuzz_target!(|bytes: &[u8]| { fn disposition(input: &[u8]) { if let Ok(receipt) = AdmittedRecoveryDispositionReceipt::decode(input) { - assert_eq!(receipt.encoded(), input); + let canonical = CanonicalRecoveryDispositionReceipt::from_receipt(receipt.receipt()); + assert_eq!( + (canonical.encoded(), canonical.receipt()), + (input, receipt.receipt()), + "admitted disposition must preserve its canonical bytes and semantics" + ); } } fn intent(input: &[u8]) { if let Ok(intent) = AdmittedGcRetirementIntent::decode(input) { - assert_eq!(intent.encoded(), input); + let _ = canonical_intent(&intent); } } +// Oracle: the canonical format relation, using the encoder rather than the +// decoder's retained input. Frozen independent vectors complement this relation. +fn canonical_intent( + intent: &AdmittedGcRetirementIntent<'_>, +) -> Option { + let canonical = CanonicalGcRetirementIntent::from_intent(intent.intent()); + assert_eq!( + canonical.as_ref().ok().map(|value| ( + value.encoded(), + value.intent(), + value.digest(), + value.candidate_set_digest(), + )), + Some(( + intent.encoded(), + intent.intent(), + intent.digest(), + intent.candidate_set_digest(), + )), + "admitted intent must preserve its canonical bytes, semantics and digests: {canonical:?}" + ); + canonical.ok() +} + fn receipt(input: &[u8]) { let Some((length, remainder)) = input.split_at_checked(4) else { return; @@ -50,6 +80,17 @@ fn receipt(input: &[u8]) { return; }; if let Ok(receipt) = AdmittedGcRetirementReceipt::decode(receipt_bytes, &intent) { - assert_eq!(receipt.encoded(), receipt_bytes); + let Some(canonical_intent) = canonical_intent(&intent) else { + return; + }; + let canonical = CanonicalGcRetirementReceipt::from_intent( + &canonical_intent, + receipt.receipt().pool_state_digest(), + ); + assert_eq!( + (canonical.encoded(), canonical.receipt()), + (receipt_bytes, receipt.receipt()), + "admitted retirement receipt must preserve its canonical bytes and semantics" + ); } } From 05b804b0dd789e8564e98f10a478958b0c332160 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 3 Oct 2026 19:03:04 -0700 Subject: [PATCH 47/59] Docs: preserve GC receipts under the whitespace gate (#107) --- docs/testing-evidence/gc-fuzz-canonicality.md | 2 ++ .../gc-fuzz-canonicality/disposition.patch | 6 +----- .../focused-validation-formatted.txt | 1 - .../gc-fuzz-canonicality/focused-validation.txt | 2 +- .../gc-fuzz-canonicality/fuzz-random.txt | 4 ++-- .../gc-fuzz-canonicality/fuzz-smoke.txt | 4 ++-- .../gc-fuzz-canonicality/intent.patch | 6 +----- .../original-records.tar.gz | Bin 0 -> 12642 bytes .../gc-fuzz-canonicality/receipt.patch | 6 +----- 9 files changed, 10 insertions(+), 21 deletions(-) create mode 100644 docs/testing-evidence/gc-fuzz-canonicality/original-records.tar.gz diff --git a/docs/testing-evidence/gc-fuzz-canonicality.md b/docs/testing-evidence/gc-fuzz-canonicality.md index c210a20f..5811a591 100644 --- a/docs/testing-evidence/gc-fuzz-canonicality.md +++ b/docs/testing-evidence/gc-fuzz-canonicality.md @@ -24,6 +24,8 @@ Each [intent](gc-fuzz-canonicality/intent.patch), [retirement-receipt](gc-fuzz-c [Calibration transcript](gc-fuzz-canonicality/calibration.txt) preserves seed 107 and observed statuses. This is old-oracle/new-oracle calibration against deliberate runtime mutations, not a claim that the unmodified production encoders have a demonstrated defect or that every compared coordinate was independently mutated. No mutation remains in the candidate. The replaced assertions are deleted because they fail calibration; their intended risk is now covered by the canonicality relation. +The [original-record archive](gc-fuzz-canonicality/original-records.tar.gz) preserves the tool output and initial mutation patches byte-for-byte. Readable `.txt` copies remove trailing whitespace and terminal blank lines to satisfy the repository whitespace gate; diagnostics and outcomes are unchanged. The standalone mutation patches use one context line to avoid trailing blank context while making the same source mutation. The required whole-tree whitespace check exposed this packaging issue after the first repair commit; it is not a runtime failure. + ## Replay and resource profile Run in a copied Docker checkout with Rust 1.96.0, a distinct Cargo target directory and the checked-in lockfiles. Build the fixed-input runner with `cargo build --locked --manifest-path fuzz/Cargo.toml --bin gc_format`. This stable replay runner executes the real target on fixtures; it is not a coverage-guided or sanitizer campaign. diff --git a/docs/testing-evidence/gc-fuzz-canonicality/disposition.patch b/docs/testing-evidence/gc-fuzz-canonicality/disposition.patch index 8243b821..6dda16aa 100644 --- a/docs/testing-evidence/gc-fuzz-canonicality/disposition.patch +++ b/docs/testing-evidence/gc-fuzz-canonicality/disposition.patch @@ -1,12 +1,8 @@ --- a/src/adapters/gc/disposition_encoder.rs +++ b/src/adapters/gc/disposition_encoder.rs -@@ -7,6 +7,9 @@ - let (preimage, checksum_slot) = encoded.split_at_mut(disposition_format::CHECKSUM_OFFSET); - write_preimage(preimage, receipt); +@@ -9,2 +9,5 @@ checksum_slot.copy_from_slice(&disposition_format::checksum(preimage)); + if let Some(byte) = encoded.first_mut() { + *byte ^= 1; + } CanonicalRecoveryDispositionReceipt::admitted(&encoded, receipt) - } - diff --git a/docs/testing-evidence/gc-fuzz-canonicality/focused-validation-formatted.txt b/docs/testing-evidence/gc-fuzz-canonicality/focused-validation-formatted.txt index fc2e9df7..e1dc4080 100644 --- a/docs/testing-evidence/gc-fuzz-canonicality/focused-validation-formatted.txt +++ b/docs/testing-evidence/gc-fuzz-canonicality/focused-validation-formatted.txt @@ -150,4 +150,3 @@ test frozen_disposition_decodes_and_reencodes_canonically ... ok test liveness_zero_beside_the_empty_retention_digest_round_trips_as_empty_retention ... ok test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s - diff --git a/docs/testing-evidence/gc-fuzz-canonicality/focused-validation.txt b/docs/testing-evidence/gc-fuzz-canonicality/focused-validation.txt index 6a8a09d5..3daa650d 100644 --- a/docs/testing-evidence/gc-fuzz-canonicality/focused-validation.txt +++ b/docs/testing-evidence/gc-fuzz-canonicality/focused-validation.txt @@ -1,5 +1,5 @@ Diff in /build/keep107-gc-source/fuzz/fuzz_targets/gc_format.rs:41: - + // Oracle: the canonical format relation, using the encoder rather than the // decoder's retained input. Frozen independent vectors complement this relation. -fn canonical_intent(intent: &AdmittedGcRetirementIntent<'_>) -> Option { diff --git a/docs/testing-evidence/gc-fuzz-canonicality/fuzz-random.txt b/docs/testing-evidence/gc-fuzz-canonicality/fuzz-random.txt index af5193ac..617c5d03 100644 --- a/docs/testing-evidence/gc-fuzz-canonicality/fuzz-random.txt +++ b/docs/testing-evidence/gc-fuzz-canonicality/fuzz-random.txt @@ -4,8 +4,8 @@ Replay: seed=180510830 max_total_time=15 timeout=5 max_len=1048576 rss_limit_mb= Running `/build/keep107-gc-nightly-target/aarch64-unknown-linux-gnu/release/gc_format -artifact_prefix=/build/keep107-gc-source/fuzz/artifacts/gc_format/ -seed=180510830 -max_total_time=15 -timeout=5 -max_len=1048576 -rss_limit_mb=1024 -print_final_stats=1 /build/keep107-gc-source/fuzz/corpus/gc_format` INFO: Running with entropic power schedule (0xFF, 100). INFO: Seed: 180510830 -INFO: Loaded 1 modules (91285 inline 8-bit counters): 91285 [0xaaaabb1761c0, 0xaaaabb18c655), -INFO: Loaded 1 PC tables (91285 PCs): 91285 [0xaaaabb18c658,0xaaaabb2f0fa8), +INFO: Loaded 1 modules (91285 inline 8-bit counters): 91285 [0xaaaabb1761c0, 0xaaaabb18c655), +INFO: Loaded 1 PC tables (91285 PCs): 91285 [0xaaaabb18c658,0xaaaabb2f0fa8), INFO: 35 files found in /build/keep107-gc-source/fuzz/corpus/gc_format INFO: seed corpus: files: 35 min: 1b max: 781b total: 13979b rss: 37Mb #36 INITED cov: 510 ft: 879 corp: 34/13974b exec/s: 0 rss: 40Mb diff --git a/docs/testing-evidence/gc-fuzz-canonicality/fuzz-smoke.txt b/docs/testing-evidence/gc-fuzz-canonicality/fuzz-smoke.txt index 8ac5de57..81901825 100644 --- a/docs/testing-evidence/gc-fuzz-canonicality/fuzz-smoke.txt +++ b/docs/testing-evidence/gc-fuzz-canonicality/fuzz-smoke.txt @@ -41,8 +41,8 @@ Replay: seed=107 max_total_time=15 timeout=5 max_len=1048576 rss_limit_mb=1024; Running `/build/keep107-gc-nightly-target/aarch64-unknown-linux-gnu/release/gc_format -artifact_prefix=/build/keep107-gc-source/fuzz/artifacts/gc_format/ -seed=107 -max_total_time=15 -timeout=5 -max_len=1048576 -rss_limit_mb=1024 -print_final_stats=1 /build/keep107-gc-source/fuzz/corpus/gc_format` INFO: Running with entropic power schedule (0xFF, 100). INFO: Seed: 107 -INFO: Loaded 1 modules (91285 inline 8-bit counters): 91285 [0xaaaabcab61c0, 0xaaaabcacc655), -INFO: Loaded 1 PC tables (91285 PCs): 91285 [0xaaaabcacc658,0xaaaabcc30fa8), +INFO: Loaded 1 modules (91285 inline 8-bit counters): 91285 [0xaaaabcab61c0, 0xaaaabcacc655), +INFO: Loaded 1 PC tables (91285 PCs): 91285 [0xaaaabcacc658,0xaaaabcc30fa8), INFO: 3 files found in /build/keep107-gc-source/fuzz/corpus/gc_format INFO: seed corpus: files: 3 min: 321b max: 781b total: 1559b rss: 37Mb #4 INITED cov: 457 ft: 617 corp: 3/1559b exec/s: 0 rss: 39Mb diff --git a/docs/testing-evidence/gc-fuzz-canonicality/intent.patch b/docs/testing-evidence/gc-fuzz-canonicality/intent.patch index 6c8e3748..eb7aadbb 100644 --- a/docs/testing-evidence/gc-fuzz-canonicality/intent.patch +++ b/docs/testing-evidence/gc-fuzz-canonicality/intent.patch @@ -1,12 +1,8 @@ --- a/src/adapters/gc/intent_encoder.rs +++ b/src/adapters/gc/intent_encoder.rs -@@ -38,6 +38,9 @@ - encoded.extend_from_slice(&digest); - let checksum = format::checksum(&encoded); +@@ -40,2 +40,5 @@ encoded.extend_from_slice(&checksum); + if let Some(byte) = encoded.first_mut() { + *byte ^= 1; + } Ok(CanonicalGcRetirementIntent::admitted( - encoded, - intent.clone(), diff --git a/docs/testing-evidence/gc-fuzz-canonicality/original-records.tar.gz b/docs/testing-evidence/gc-fuzz-canonicality/original-records.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..c54d8807dcd7d586294e10882370a31e986dd639 GIT binary patch literal 12642 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 Date: Sat, 3 Oct 2026 19:34:55 -0700 Subject: [PATCH 48/59] fix(gc): distinguish reader-lock coordinate roles (#107) --- CHANGELOG.md | 1 + docs/formats/segment-store-v2/gc.md | 2 + .../reader-lock-coordinates.md | 35 ++ .../comparison-final-replay.txt | 23 ++ .../comparison-final.txt | 30 ++ .../reader-lock-coordinates/comparison.txt | 68 ++++ .../device-file-red.txt | 34 ++ .../device-mount-red.txt | 34 ++ .../filesystem-profile.txt | 4 + .../mount-file-red.txt | 34 ++ .../original-records.tar.gz | Bin 0 -> 333050 bytes .../reduced-restored.txt | 2 + .../reader-lock-coordinates/shrink.txt | 2 + .../reader-lock-coordinates/source-final.txt | 21 ++ .../static-restored.txt | 18 + .../typed-calibration.txt | 10 + .../validation-final.txt | 309 ++++++++++++++++++ .../reader-lock-coordinates/validation.txt | 34 ++ .../wire-calibration.txt | 11 + src/adapters/gc/disposition_decoder.rs | 15 +- src/adapters/gc/disposition_encoder.rs | 6 +- src/adapters/gc/filesystem_gc_authority.rs | 10 +- src/adapters/gc/intent_encoder.rs | 6 +- src/adapters/gc/intent_semantic_header.rs | 9 +- src/adapters/gc/liveness_observation_tests.rs | 12 +- src/adapters/gc/mod.rs | 6 + src/adapters/gc/rationale.md | 11 + src/adapters/gc/reader_lock_device.rs | 25 ++ src/adapters/gc/reader_lock_file.rs | 25 ++ src/adapters/gc/reader_lock_identity.rs | 60 +++- src/adapters/gc/reader_lock_mount.rs | 25 ++ src/adapters/gc/receipt_decoder.rs | 15 +- src/adapters/gc/receipt_encoder.rs | 6 +- .../filesystem_retention_disposition.rs | 11 +- src/lib.rs | 13 +- tests/gc_retirement_intent.rs | 6 +- tests/gc_retirement_receipt.rs | 2 +- tests/recovery_disposition_receipt.rs | 6 +- .../production_protocol/gc.rs | 15 +- 39 files changed, 891 insertions(+), 65 deletions(-) create mode 100644 docs/testing-evidence/reader-lock-coordinates.md create mode 100644 docs/testing-evidence/reader-lock-coordinates/comparison-final-replay.txt create mode 100644 docs/testing-evidence/reader-lock-coordinates/comparison-final.txt create mode 100644 docs/testing-evidence/reader-lock-coordinates/comparison.txt create mode 100644 docs/testing-evidence/reader-lock-coordinates/device-file-red.txt create mode 100644 docs/testing-evidence/reader-lock-coordinates/device-mount-red.txt create mode 100644 docs/testing-evidence/reader-lock-coordinates/filesystem-profile.txt create mode 100644 docs/testing-evidence/reader-lock-coordinates/mount-file-red.txt create mode 100644 docs/testing-evidence/reader-lock-coordinates/original-records.tar.gz create mode 100644 docs/testing-evidence/reader-lock-coordinates/reduced-restored.txt create mode 100644 docs/testing-evidence/reader-lock-coordinates/shrink.txt create mode 100644 docs/testing-evidence/reader-lock-coordinates/source-final.txt create mode 100644 docs/testing-evidence/reader-lock-coordinates/static-restored.txt create mode 100644 docs/testing-evidence/reader-lock-coordinates/typed-calibration.txt create mode 100644 docs/testing-evidence/reader-lock-coordinates/validation-final.txt create mode 100644 docs/testing-evidence/reader-lock-coordinates/validation.txt create mode 100644 docs/testing-evidence/reader-lock-coordinates/wire-calibration.txt create mode 100644 src/adapters/gc/rationale.md create mode 100644 src/adapters/gc/reader_lock_device.rs create mode 100644 src/adapters/gc/reader_lock_file.rs create mode 100644 src/adapters/gc/reader_lock_mount.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c30a166c..cc68efda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ after its public API and format compatibility policies are established. ### Roadmap audit corrections +- Tighten the new GC reader-lock API with distinct device, mount and file coordinate types; constructors and accessors now name each role explicitly while preserving the encoded values and runtime refusals (#107). - Replace tautological GC fuzz assertions with public canonical re-encoding checks for retirement intents, retirement receipts and recovery-disposition receipts (#107). - Preserve typed failure sources through durable and compaction I/O boundaries. - Bound sealed-stage admission by its recorded length before reading it. diff --git a/docs/formats/segment-store-v2/gc.md b/docs/formats/segment-store-v2/gc.md index 5afd07d1..f8e975dd 100644 --- a/docs/formats/segment-store-v2/gc.md +++ b/docs/formats/segment-store-v2/gc.md @@ -23,6 +23,8 @@ admission admits exactly those records as regular files and nothing else in ## Common rules +The public record API represents reader-lock coordinates with distinct `ReaderLockDevice`, `ReaderLockMount` and `ReaderLockFile` types. `ReaderLockIdentity::new` takes those typed values; getters return them and `get()` exposes their exact unsigned 64-bit encoding. These roles prevent typed coordinate interchange, not mislabeling a raw number or fabrication of filesystem evidence. See the [coordinate rationale](../../../src/adapters/gc/rationale.md). + All integers are unsigned and big-endian. Flags and reserved bytes are zero. Every length and count is checked before allocation. Decoders reject truncation, trailing bytes, unsupported versions, unknown mandatory flags, nonzero reserved diff --git a/docs/testing-evidence/reader-lock-coordinates.md b/docs/testing-evidence/reader-lock-coordinates.md new file mode 100644 index 00000000..73fe7dbb --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates.md @@ -0,0 +1,35 @@ +# Reader-lock coordinate roles + +Change kind: deliberate public API tightening for #107's [coordinate-type review](https://github.com/flyingrobots/keep/pull/107#discussion_r4146802719). Owner: `@flyingrobots`. The primitive constructor and getter types become distinct device, mount and file types; callers must name each role. This closes a source-level interchange hazard, not an observed production coordinate substitution. The [decision record](../../src/adapters/gc/rationale.md) states the boundary and compatibility implications. + +## Separate claims and oracles + +Static/API claim: a value already labeled as a device, mount or file coordinate cannot occupy either other constructor position. Three pairwise compile-fail examples guard that public contract, with a positive construction example. These are compiler/API checks, not storage runtime tests. Each wrapper preserves every unsigned 64-bit value; assigning the correct label at observation or decoding remains the caller's responsibility. + +Runtime preservation claim: this type change retains the exact public intent, bound-receipt and disposition bytes and decoded reader-lock coordinates. The oracle is cross-revision differential output against `05b804b0dd789e8564e98f10a478958b0c332160`, supplemented by existing independent frozen-format laws. Agreement can preserve an old defect, so it does not establish the correctness of the entire prior protocol. Existing filesystem laws exercise the actual adapters, rather than a simulated filesystem. + +## Calibration and generated evidence + +[Static calibration](reader-lock-coordinates/typed-calibration.txt) separately aliases mount to device, file to device and file to mount in the copied candidate. In each experiment the corresponding forbidden example compiles successfully and its compile-fail check fails, exit 101: [device/mount](reader-lock-coordinates/device-mount-red.txt), [device/file](reader-lock-coordinates/device-file-red.txt), [mount/file](reader-lock-coordinates/mount-file-red.txt). [Restoring distinct types](reader-lock-coordinates/static-restored.txt) passes the examples. These are deliberate type-erasure controls, not a claim of runtime RED on the parent. No alias remains. + +The archived Rust observation driver calls the same public constructors, canonical encoders and decoders in both revisions. Only constructor/getter adaptation differs behind its `typed` feature. Seven explicit coordinate triples cover zero, maximum, unequal roles, and the 32-bit and 63-bit boundaries; a seeded xorshift64 supplies another 256 triples per run. The fixed seed is hexadecimal `107c00d`; random seed `b039fd8f9b504649` was selected and recorded outside the child before launch. The witness totals describe the sampled input space, not a test that asserts a harness count. + +Each observation records three public encoded records, their lengths, and their decoded coordinates. [Final comparison](reader-lock-coordinates/comparison-final-replay.txt) finds identical 310340-byte outputs per revision and seed. The fixed output SHA-256 is `2817ce9c311b95c070e7b4774a3cc26193b010769e5a0ec36af6a04133c19714`; the random output is `866f91a63c11d887bbc35084665e376a928e33768a21b8173e966b6571a6da95`. This is finite sampled evidence, not exhaustive equivalence over all coordinates. + +[Runtime calibration](reader-lock-coordinates/wire-calibration.txt) swaps device/mount slots in both the production intent encoder and decoder. The correlated mutation still round-trips successfully, but differs from the parent bytes at character 2636 and fails `cmp`, exit 1. Restoring both production files restores exact equality. The archived reducer executes both drivers inside Docker, minimizing by zero, halving and predecessor candidates until no candidate preserves the mismatch; it reduces the witness to [device 0, mount 1, file 0](reader-lock-coordinates/shrink.txt). That small control is retained here and in the archive; [restored parent/candidate outputs](reader-lock-coordinates/reduced-restored.txt) agree. Reduction reaches a local minimum under those operations, not a claim of globally minimal inputs. + +## Execution profile and replay + +All Rust execution used copied source in Docker with pinned Rust 1.96.0 and offline dependency resolution. Both final observation-driver lockfiles retain the parent's dependency versions and compare identically. The [preliminary comparison](reader-lock-coordinates/comparison.txt) initially selected cap-primitives 4.0.3 when building the standalone driver graph; it is historical setup evidence only. The [corrected comparison](reader-lock-coordinates/comparison-final.txt) restored the parent's lockfile versions, including cap-primitives 4.0.2, before the final runs. + +Observation runs are medium, single-machine experiments with owned files and no remote service. Every driver execution has an outer 30-second deadline, a 5-second kill grace, a kernel address-space limit of 536870912 bytes, and an isolated network namespace through `unshare -n`. The archive includes the driver, both manifests/lockfiles, exact outputs, mutation patch, scripts and reduction command. Extract it outside tracked source, recreate the recorded copied-source paths, and use `compare-final.sh`; `driver OUTPUT HEX_SEED DEVICE MOUNT FILE` replays a single triple. `shrink.rb` is a host orchestrator whose product executions and comparisons all run through Docker; it requires the recorded container name or an explicit adjustment for the local container. + +Compiler/API calibration has a 120-second outer deadline with a 5-second kill grace. It and the existing Cargo suites retain their existing resource profiles; this receipt does not assert per-test sandbox or resource-ceiling compliance. The filesystem runs use owned scratch on ext4; [mount evidence](reader-lock-coordinates/filesystem-profile.txt) records the bind mounts for both possible test scratch roots. Filesystem success is not physical power-loss evidence. + +## Validation and acceptance boundary + +[Focused final validation](reader-lock-coordinates/validation-final.txt) passes workspace formatting, workspace Clippy with all features and with no default features, public constructor doctests, public intent/receipt/disposition codec laws, GC filesystem laws, retention-disposition filesystem laws and the independent format oracle in debug and release, plus GC fuzz-target Clippy. The [first attempt](reader-lock-coordinates/validation.txt) stopped at Clippy's redundant `must_use` attributes on typed-return getters; removing those redundant method attributes preserved the type-level requirement. That was a lint failure, not a flaky runtime failure. Final validation used the corrected source. Static type-erasure calibration preceded that metadata-only cleanup; the final examples pass after it. + +[Final source hashes](reader-lock-coordinates/source-final.txt) match every changed Rust file between the working candidate and the Docker copy after controls were restored. The [original-record archive](reader-lock-coordinates/original-records.tar.gz) preserves original output and experiment source; readable text copies remove trailing whitespace and terminal blank lines only. No original runtime diagnostic or failure is erased. + +No frozen expected bytes, runtime refusal expectations, wire offsets, restart comparison rules, lock acquisition or durability operations change. The new API checks remain useful while the three distinct coordinate roles are public; retire them if that contract is removed or superseded by a stronger boundary. Runtime laws retain their existing ownership. This slice does not integrate current main, satisfy #107's remaining findings, assert a full validation campaign, or confer whole-PR approval; stable integrated-candidate validation and exact-head independent approval remain required. diff --git a/docs/testing-evidence/reader-lock-coordinates/comparison-final-replay.txt b/docs/testing-evidence/reader-lock-coordinates/comparison-final-replay.txt new file mode 100644 index 00000000..a7726732 --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/comparison-final-replay.txt @@ -0,0 +1,23 @@ + Compiling coordinate-observation v0.0.0 (/build/keep107-coordinate-driver-parent) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s +Replay subject=parent corpus=fixed seed=0x107c00d boundary-triples=7 generated-triples=256 address-space-cap=536870912 deadline=30 network=unshare +Replay subject=parent corpus=random seed=0xb039fd8f9b504649 boundary-triples=7 generated-triples=256 address-space-cap=536870912 deadline=30 network=unshare + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling coordinate-observation v0.0.0 (/build/keep107-coordinate-driver-current) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.90s + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s +Replay subject=current corpus=fixed seed=0x107c00d boundary-triples=7 generated-triples=256 address-space-cap=536870912 deadline=30 network=unshare +Replay subject=current corpus=random seed=0xb039fd8f9b504649 boundary-triples=7 generated-triples=256 address-space-cap=536870912 deadline=30 network=unshare +Exact runtime output comparison fixed: identical +2817ce9c311b95c070e7b4774a3cc26193b010769e5a0ec36af6a04133c19714 /build/keep107-coordinate-evidence/parent-fixed.bin +2817ce9c311b95c070e7b4774a3cc26193b010769e5a0ec36af6a04133c19714 /build/keep107-coordinate-evidence/current-fixed.bin +310340 /build/keep107-coordinate-evidence/parent-fixed.bin +310340 /build/keep107-coordinate-evidence/current-fixed.bin +620680 total +Exact runtime output comparison random: identical +866f91a63c11d887bbc35084665e376a928e33768a21b8173e966b6571a6da95 /build/keep107-coordinate-evidence/parent-random.bin +866f91a63c11d887bbc35084665e376a928e33768a21b8173e966b6571a6da95 /build/keep107-coordinate-evidence/current-random.bin +310340 /build/keep107-coordinate-evidence/parent-random.bin +310340 /build/keep107-coordinate-evidence/current-random.bin +620680 total diff --git a/docs/testing-evidence/reader-lock-coordinates/comparison-final.txt b/docs/testing-evidence/reader-lock-coordinates/comparison-final.txt new file mode 100644 index 00000000..b2388339 --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/comparison-final.txt @@ -0,0 +1,30 @@ + Compiling cap-primitives v4.0.2 + Compiling cap-std v4.0.2 + Compiling cap-fs-ext v4.0.2 + Compiling keep v0.0.0 (/build/keep107-coordinate-parent) + Compiling coordinate-observation v0.0.0 (/build/keep107-coordinate-driver-parent) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.93s + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s +Replay subject=parent corpus=fixed seed=0x107c00d boundary-triples=7 generated-triples=256 address-space-cap=536870912 deadline=30 network=unshare +Replay subject=parent corpus=random seed=0xb039fd8f9b504649 boundary-triples=7 generated-triples=256 address-space-cap=536870912 deadline=30 network=unshare + Compiling cap-primitives v4.0.2 + Compiling cap-std v4.0.2 + Compiling cap-fs-ext v4.0.2 + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling coordinate-observation v0.0.0 (/build/keep107-coordinate-driver-current) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.76s + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s +Replay subject=current corpus=fixed seed=0x107c00d boundary-triples=7 generated-triples=256 address-space-cap=536870912 deadline=30 network=unshare +Replay subject=current corpus=random seed=0xb039fd8f9b504649 boundary-triples=7 generated-triples=256 address-space-cap=536870912 deadline=30 network=unshare +Exact runtime output comparison fixed: identical +2817ce9c311b95c070e7b4774a3cc26193b010769e5a0ec36af6a04133c19714 /build/keep107-coordinate-evidence/parent-fixed.bin +2817ce9c311b95c070e7b4774a3cc26193b010769e5a0ec36af6a04133c19714 /build/keep107-coordinate-evidence/current-fixed.bin +310340 /build/keep107-coordinate-evidence/parent-fixed.bin +310340 /build/keep107-coordinate-evidence/current-fixed.bin +620680 total +Exact runtime output comparison random: identical +866f91a63c11d887bbc35084665e376a928e33768a21b8173e966b6571a6da95 /build/keep107-coordinate-evidence/parent-random.bin +866f91a63c11d887bbc35084665e376a928e33768a21b8173e966b6571a6da95 /build/keep107-coordinate-evidence/current-random.bin +310340 /build/keep107-coordinate-evidence/parent-random.bin +310340 /build/keep107-coordinate-evidence/current-random.bin +620680 total diff --git a/docs/testing-evidence/reader-lock-coordinates/comparison.txt b/docs/testing-evidence/reader-lock-coordinates/comparison.txt new file mode 100644 index 00000000..e905c2d4 --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/comparison.txt @@ -0,0 +1,68 @@ + Locking 50 packages to latest Rust 1.96.0 compatible versions + Adding blake3 v1.8.5 (available: v1.8.7) + Adding cap-std v4.0.2 (available: v4.0.3) + Adding rustix v1.1.4 (available: v1.1.5) + Compiling rustix v1.1.4 + Compiling bitflags v2.13.1 + Compiling io-lifetimes v3.0.1 + Compiling io-lifetimes v2.0.4 + Compiling linux-raw-sys v0.12.1 + Compiling io-extras v0.19.0 + Compiling find-msvc-tools v0.1.9 + Compiling shlex v2.0.1 + Compiling cap-primitives v4.0.3 + Compiling once_cell v1.21.4 + Compiling maybe-owned v0.3.4 + Compiling ipnet v2.12.0 + Compiling cap-std v4.0.2 + Compiling ambient-authority v0.0.2 + Compiling cap-fs-ext v4.0.2 + Compiling arrayref v0.3.9 + Compiling constant_time_eq v0.4.2 + Compiling cfg-if v1.0.4 + Compiling arrayvec v0.7.8 + Compiling cc v1.3.0 + Compiling blake3 v1.8.5 + Compiling fs-set-times v0.20.3 + Compiling rustix-linux-procfs v0.1.1 + Compiling keep v0.0.0 (/build/keep107-coordinate-parent) + Compiling coordinate-observation v0.0.0 (/build/keep107-coordinate-driver-parent) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.54s +Replay subject=parent seed=0x107c00d boundary-triples=7 generated-triples=256 address-space-cap=536870912 outer-deadline=30 network=unshare + Locking 50 packages to latest Rust 1.96.0 compatible versions + Adding blake3 v1.8.5 (available: v1.8.7) + Adding cap-std v4.0.2 (available: v4.0.3) + Adding rustix v1.1.4 (available: v1.1.5) + Compiling rustix v1.1.4 + Compiling io-lifetimes v2.0.4 + Compiling bitflags v2.13.1 + Compiling linux-raw-sys v0.12.1 + Compiling io-lifetimes v3.0.1 + Compiling io-extras v0.19.0 + Compiling cap-primitives v4.0.3 + Compiling find-msvc-tools v0.1.9 + Compiling once_cell v1.21.4 + Compiling shlex v2.0.1 + Compiling ambient-authority v0.0.2 + Compiling maybe-owned v0.3.4 + Compiling ipnet v2.12.0 + Compiling cap-std v4.0.2 + Compiling cap-fs-ext v4.0.2 + Compiling constant_time_eq v0.4.2 + Compiling cfg-if v1.0.4 + Compiling arrayvec v0.7.8 + Compiling arrayref v0.3.9 + Compiling cc v1.3.0 + Compiling blake3 v1.8.5 + Compiling rustix-linux-procfs v0.1.1 + Compiling fs-set-times v0.20.3 + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling coordinate-observation v0.0.0 (/build/keep107-coordinate-driver-current) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.53s +Replay subject=current seed=0x107c00d boundary-triples=7 generated-triples=256 address-space-cap=536870912 outer-deadline=30 network=unshare +Exact runtime output comparison: identical +2817ce9c311b95c070e7b4774a3cc26193b010769e5a0ec36af6a04133c19714 /build/keep107-coordinate-evidence/parent.bin +2817ce9c311b95c070e7b4774a3cc26193b010769e5a0ec36af6a04133c19714 /build/keep107-coordinate-evidence/current.bin +310340 /build/keep107-coordinate-evidence/parent.bin +310340 /build/keep107-coordinate-evidence/current.bin +620680 total diff --git a/docs/testing-evidence/reader-lock-coordinates/device-file-red.txt b/docs/testing-evidence/reader-lock-coordinates/device-file-red.txt new file mode 100644 index 00000000..0e5c6309 --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/device-file-red.txt @@ -0,0 +1,34 @@ + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.53s + Doc-tests keep + +running 6 tests +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 16) ... ok +test src/blob/id.rs - blob::id::BlobId::hash_reader (line 87) ... ok +test src/chunk/detector.rs - chunk::detector::FastCdc (line 36) ... ok +test src/blob/id.rs - blob::id::BlobId (line 20) ... ok +test src/reference/range_read.rs - reference::range_read::ReferenceStore::read_range (line 27) ... ok +test src/reference/store.rs - reference::store::ReferenceStore (line 23) ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +running 5 tests +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 43) - compile fail ... ok +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 25) - compile fail ... ok +test src/adapters/retention/filesystem_retention_authority.rs - adapters::retention::filesystem_retention_authority::FilesystemRetentionPublicationAuthority::open (line 50) - compile fail ... ok +test src/store/mod.rs - store (line 16) - compile fail ... ok +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 34) - compile fail ... FAILED + +failures: + +---- src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 34) stdout ---- +Test compiled successfully, but it's marked `compile_fail`. + +failures: + src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 34) + +test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.22s + +all doctests ran in 0.54s; merged doctests compilation took 0.32s +error: doctest failed, to rerun pass `--doc` diff --git a/docs/testing-evidence/reader-lock-coordinates/device-mount-red.txt b/docs/testing-evidence/reader-lock-coordinates/device-mount-red.txt new file mode 100644 index 00000000..edd82619 --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/device-mount-red.txt @@ -0,0 +1,34 @@ + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.79s + Doc-tests keep + +running 6 tests +test src/blob/id.rs - blob::id::BlobId::hash_reader (line 87) ... ok +test src/blob/id.rs - blob::id::BlobId (line 20) ... ok +test src/chunk/detector.rs - chunk::detector::FastCdc (line 36) ... ok +test src/reference/range_read.rs - reference::range_read::ReferenceStore::read_range (line 27) ... ok +test src/reference/store.rs - reference::store::ReferenceStore (line 23) ... ok +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 16) ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +running 5 tests +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 34) - compile fail ... ok +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 43) - compile fail ... ok +test src/adapters/retention/filesystem_retention_authority.rs - adapters::retention::filesystem_retention_authority::FilesystemRetentionPublicationAuthority::open (line 50) - compile fail ... ok +test src/store/mod.rs - store (line 16) - compile fail ... ok +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 25) - compile fail ... FAILED + +failures: + +---- src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 25) stdout ---- +Test compiled successfully, but it's marked `compile_fail`. + +failures: + src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 25) + +test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s + +all doctests ran in 0.52s; merged doctests compilation took 0.31s +error: doctest failed, to rerun pass `--doc` diff --git a/docs/testing-evidence/reader-lock-coordinates/filesystem-profile.txt b/docs/testing-evidence/reader-lock-coordinates/filesystem-profile.txt new file mode 100644 index 00000000..6484bc66 --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/filesystem-profile.txt @@ -0,0 +1,4 @@ +TARGET SOURCE FSTYPE OPTIONS +/build/keep107-coordinate-source/target/tmp /dev/loop0[/keep107-coordinate-source-scratch] ext4 rw,relatime +TARGET SOURCE FSTYPE OPTIONS +/build/keep107-coordinate-target/tmp /dev/loop0[/keep107-coordinate-target-scratch] ext4 rw,relatime diff --git a/docs/testing-evidence/reader-lock-coordinates/mount-file-red.txt b/docs/testing-evidence/reader-lock-coordinates/mount-file-red.txt new file mode 100644 index 00000000..ede550d8 --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/mount-file-red.txt @@ -0,0 +1,34 @@ + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.87s + Doc-tests keep + +running 6 tests +test src/blob/id.rs - blob::id::BlobId (line 20) ... ok +test src/chunk/detector.rs - chunk::detector::FastCdc (line 36) ... ok +test src/blob/id.rs - blob::id::BlobId::hash_reader (line 87) ... ok +test src/reference/store.rs - reference::store::ReferenceStore (line 23) ... ok +test src/reference/range_read.rs - reference::range_read::ReferenceStore::read_range (line 27) ... ok +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 16) ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +running 5 tests +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 34) - compile fail ... ok +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 25) - compile fail ... ok +test src/adapters/retention/filesystem_retention_authority.rs - adapters::retention::filesystem_retention_authority::FilesystemRetentionPublicationAuthority::open (line 50) - compile fail ... ok +test src/store/mod.rs - store (line 16) - compile fail ... ok +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 43) - compile fail ... FAILED + +failures: + +---- src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 43) stdout ---- +Test compiled successfully, but it's marked `compile_fail`. + +failures: + src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 43) + +test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.22s + +all doctests ran in 0.54s; merged doctests compilation took 0.32s +error: doctest failed, to rerun pass `--doc` diff --git a/docs/testing-evidence/reader-lock-coordinates/original-records.tar.gz b/docs/testing-evidence/reader-lock-coordinates/original-records.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..92b04c932b2a3bc34dbebe84f0c4234aee657bbc GIT binary patch literal 333050 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

5E{p2*A@ z3u)>U_m0mb#tgkwCJt{xry9Eg?JoAzbC=l0dwDm~4>p>|hzBMnNrBh7?Sq=Xj^}G3 zu%@trq{~(p^fXoqBqPgBpJGt$%Bb(C{-qiyI%gRRR^*r&- zV>QxjxJdmrE9*4u-ZS}UK|Rhd(p3TT^|BAN0A9aFahhAszKTS8^7p={1l99$Rw|N6 z-{4CEE8%RqodiSKULYbMCPa!#X2b#c&-Zs0*=v$n-yDpRQA`LG6ZGoyq@JO)YJKfC z0o)R^-O-(kuQga_-5knz>f)^|0&#iyaz-Ak$Xp;UUSIK}+?T5IVTGiA;2Q^&~0N5wHh7On;Z8;D7VMGPs zZxQc6uNI*UhIz+2eNx+1i=+4t8SA0?OG$H%^J|{)@HNiKxd<4>Vw3nw3~#vUPG^+j zoW?*(emI(klHSoS(l%1mAdEgfVW7ur9q36rf5pvhvcNg)l9!czdAh^`iDi^1Q;Q?@ zpriDQ@&5hl$%vV(u*O+YO+y-W2wP?#K#@p08S0+|EE^y>8qp5%G6OL7d7OB^?h02G zo%_1^aiMUG*su}@d`f0djL_dng*3$-^o zt<;VGM5De~ARzDCCehmBm-{M?m&M3osW#%`uteg#k}6z=Ix#jmx*QevM7+5Esfkxv zz!lqIf#e8mQ%{-_PMGR-KHB@B{+|zMvD0_Vrw*%69)GS4 zQ|B=jXN_xGF>?i3(8j?K6f)VfZ*WU`Qc3tcm z5r#MalFoj##j;wl7PE^ATKuwKnhb(w+Y*MU0LdO8|4EUtCgRanl@*pwTb{{GuU+0{ z9$Ta47aLc64qSvpbWP{eK>KL z5ThP+lkz`NMj|&n@2dBn{*AcN43y6R;HK6`{oy!gu*I;J54fjbS80HOmlD2&TsBdP zs+{*^bxkNM9HF}-aX2Dj(*qO=?lfUleP$G-R@z`ER~Daq?^WsY9*P(u^>yMIoK|Zs zZn6BMF}(77<2M5LE1v%d%-)P?E!4G%9=HWrlF{MTOrCuD!kswNo@)Ju{VPE;FG0-( zYDXtwKuLssrKuVie|t0t02T6z1&j3bdi?d^ejqA&kfraJ-8`eg(HKnVUsw73aDV0hbJriKc$w?1 z^8-(c%xUVl;2gU8kfXeM+ELI4^0d-e+P&wiNgo+*!32}`Z&jfJH_`!0`(<{l&dL-6 zfeD|3_2sh~^3pF?X-3Rx8+wd!xnx<$J!#iNb6W)D6t{Dld~wr*RZ$kqPISbgCh;hD z_1pDm)Fn3ZP;0mq+M{P;lc}R41JPCwDB$kpy9%%EdbC6r&HZ7M%Ym`r%b&|v2n=0dH1Xlngs6LC*9 zrFIEdJUQubmrau{3eocLtXo)hxlZ3f>0^!jocO_u7*pl?x%c_iezEyth(=|{ zZTm&#PlORDoXhyKW(nxn%v@>L*%r2hlmCiowfm#>PZ@+BH=ssI>mAPg2G$v1qPK#}Do`)LOt{twH{q_jI@z9GV&0K0x!8Z+? zMY6~Z*L7i!9h$JPZiVfuiC>2Q-j_Jaf!b8U%+Z-_7J?L{XWeUE&Nk&beO<>n4wg2Bw6mAXwrZfx267C{SDxwU7|sq?M>ydj_7(*;*j} zmVaJuiO+jS30(xyG-J6m1~xaFKd2L4`W|D*E5xRs%(rGyjY0_y)}*>}pY3^F!LxQ~ zkv9<8{>I_rxpA^meDMbSzj!-J*#5seE(KsAYmL~oqH@IdMj!ELAuo|Kt`%B6=$>?( zJ{?Hfdqcf=UoZKeO5U1%eE;V_3m6*n1()=!E7EilO|OT2VhxE+XIP8!Df&jTSN9vo z>hSmad9|&=sAml~kUjYaW_91_L-hE%rJIvEI|V-{w|EU!wXmsL>TzRo$fcT%1H4d- z8eOEXSEvu$I%arsAinic<~o^YPOM2}Y5v?Hw0uaA@Bcd1fyxB(?%alpdE~4^%?Ran z^d2$sb@?8)Qp0Uq3N29`@nDCHq}_u4r*)=t8KE!O?x@s+cIrl~KC$?S$Ca16!rP9g zf|;9^>Xu?;yjBB+n?1?^VN(758&vl2EkFJ_cK$vSqgsA{&Od)8dSa&}PG-Le_wsh$ z{x@kX%7qnoCUH>nIr7v5@G7=IYJn7|W8jHDVJY6Bz}U3@nrnz4Xt@|>WDIKHm?fOVhXk_SrOgv73+kK<&DW82KSgk<8x4uZve@D z3Rs1yi7iluTRb8;fO(S+zRvC+#;L8R|W^>GtV4xEVt;ssAMMo-Nkj@6k zn^JAAbW0Gg!oJ*8XmDY`&*2z7ulowt2E(@cEaz{imNtL2%zPj<>+oz1bE8?xr&LQv z-U01)T_?uc65z_B-naFw7#j!70@dikJrML=^GY`S(F;%4=Qf!`Ps%AB8zy}NV2rbl zTm~aKw`o^^Z@lxy__9;_3~XP*Lae7Nf4Fn4p2qp!YC5&VXrg`-231@DwKso7P7d>~ zatSV|)9SAlsLsSt8@TPlSj>YYPx={A9T($Iw*{*oAVsiGl9&t>BJc0j!AQzRQPP1;oOzDi zTkq6q_NheK(!rQI#;KOmp@RWPV&2L+vWCAQi?|@qX-uAVcbhe0k2~L!&4b;jQlUXl}iRk1{LYi)@oe^BgO@< zzY`TsIB}UoP=R-)IS~}RgAd<$)yaCiI{Y$;<=`Lqmeof5L&peUhs{NVON?I{=AO@I zPlc4&OD?$*Ui8kox5pfcTs#(J&Ix(k4jhW(y&Lh8C2^{E@>`hV0*RgKz1HFBDoIMc z&8IRb`X;#0C#4|ophKsRi<^nxnVLT-lUXJWizKgc!Kv2UT)U&X4ly;JKc)1>_gA#? zDxNjxPGy3-D<%cAM3bGH3t+~j9{G%^&-3sD0r#(B!3s-W1<`<}Yn~BiF%*b29iUMJyS`8KDwdgfeHEGyb9x(R@~jG|!q_RfhRS!TbOK1N+=$VK zGrI_dRcYpkBN?b0GE1)wjYY~vd7sM&*$Y= z=s4-gsn5OqQe9}d50$c|7hOOTYGpS(9bCDuTO;zhM3d6h1&a+D3kX%g!~~v}m20gG z;zoUPpCvz?bo7W`sCr`e!8g%~4A&#Cug0~u3k%cV=jr;@4(85{ z#f2pHi0Zdm&L<@gW~UTRgutBGE0$o0H|K}WComG1`I}DBaQwUoAnf;We$o&5MzDi=b{HT(9vb)L?O)( z{qXHoRO%SHbn2im*T;J(2ZO;q7>u;EtV$wgF}lvs28fZSs-I+_kV)wETI2p0_I!C4 zu86Mv?PTJwA{;Dnuy}IR>s@z+_jddz)M^UqWJpNl1+_3E(?Htt zpWHDfew2?cOftG%)@P;-uC4ugb-jmo>OUYCS^d4pF2*w>7MEIn=Z0f>ukjWGF?`kHfZ$(EbO2YrSMj=yd? zWhjmpxc+hr+|{Sa0hvj65!tC;Ld=0cQ(D4E$$vAd1@Nw%|h$BNp zXdyL%Iew}a71s+xUIWp6lgZjZ7WW-vVm-L?Il!>g+zZw}9U6`)NMyI!6Pc-64=t6E zOkxC_j{Ut?$A%!2vqfVT8OFm@1#H3sv}IfQb~(X&$xozbT}t`R?Y%Z3l&gfQ_bES( zJ8+tokc2mF1gd`>#7fj$(Tow7ue*ZBWR~qdhH{|Ol>1GwXhiZ@s-ILlEIJfagSH}7 zi>t7u;=Dgj)+uHzDX?PXDdiTw4ehGe@TqOInVt&n%X{lfVIV_?1ru$La9coSVqksF zimF@}J=U^USc#2P&g7&17V+;}Q`w`<+t@#mi~+ye>e+3Y9wR2`DQ2wFaR#ZoK#RZD zVq41i{4CgeEy<(zM0k{v{^UfonjE$jx@vK)BJhNreani|C`>B`kFJw%KE;EL~nC>+XJ*fc6HQ>2j)P*ZHo^&H^Y8?`O@pp$ohy4Y9(2 z3H~PLWI1rzj91uq7~)kWrpzg_;X;n6=hk5hqEB7hAa7E>1gmZS!_g2bI`USx5K7)? z!N6ov^CxD{gKFx!8U5Q-y-z(i4f-&uCZ51fvNApAMFX4EbOBp!3RrV@n8iUsI7+zp zIwy-xv~Z>R@uo+^&~J0_eikV_z||+kd{^5(*~^d3S?luc&>B?@3*5De5YB%-K{2kx zd_MU{%bsowZyRP3OUY~7Bd)&dN+13Gyzdd8((k5pZQnJFdNEGCmX84 zM))!jQmF^}XF~@+yA9-7Xh*4^dhBmUCSjXB>PZK!L~pC65ELtG+{{FiG&s2p*ZO`$ zV(6_)@#kv{Uwz3=_8)A&|9$>|x1(}UiC4%<`Gf9>it*POdO>#AObh8rmP>z?Wat3= z#9yJ)EpwA)mnLE{Q`?_hxN>Jy=*T=}$gCZB%wqO*Hax`hYCcL(vXZf$WK42#)p(EF zv^LlF^kLwAX6cLb`Bpf-@Mj)_TBN}9=?I*#)01)BZ-zQ(wtI%+o@IR-`q}Lt+i=m! zfa$lCWht4COJerP&$v0}_$Gq{lx4Cc#Q!~XGSvbu70eh-=CSXeOI?SjB{}SfYD)nu z-03Qz2_Kf2_}+pQ0!c=1h!UR$h82@rjd)6dcP0+E9keu{ullFvQfvswLOR?0yITi`W5wbf}Ruev=D-P9<8^!)PB4t&8mfk<7usAwUVm= zJ$Kt5^Xpti@q2m9s&TpcTKalR!^z9j7$2otn($a)@?tpLKTriJ}mWd|4*1jaZ z?a-!J_H4*P1dgVMF?nERQtj-@a3rRYlv>}7s&XN&vyw_bPTsW&){5@Z-lbDCGQO{b zS2zU>B}>PYghbXg7cQ|xbB#p)COlcnSlDO%DcKoa^_MkQEAhEfh`SU9CKNhrl;xo> zv@jO!t5J$cTL+U7sH|09-z|}1_e{$0;WG{s^E^LwEm(X+;^5sBNLrYEspCIIw@%%d z4b-1@jE+egujXql2?I^enbMLJxNd0Tj*>Uoo^vN;d>{i92l_%bb+HGaqx)#`rlb+I zANw`bAs5izJO9$iB;E}xtXbjLY~ss1zn~bX7UgPP)!lxV?mPuGk8Qm->__tmsxLit zs&h(k`hSuysPKS9a`fvs!^P&DcOoJ&xZe3+@Alb^Q(bD&JCzw_rD1snc1AFz*%0t@ z2la@Q+e35HLvK>)yPh+1pcW>u?_-Gc@?%Z9bn-{s2@@4zI3a97nU+IZ7GgmTDC#y} zlTIkD-MOu1s&v|%!{-92k-?9G^s)x;%i+6!4o)%~23s6r9K(2nKFa$zAfWhZ%plkUyb#ICp3X4nr;0X@PLkjivl9~w0W84kj zDOjTKi47JKOyFZ~SqxGQ7Fo?p>-<3|3QCrWAsOFyiKl*WRe+(d2RncRo20b`F*}-g z$%3KB6yNq?I7wiTNQZTjI^f_^Bf{Y^SdQH~+Sn)PYcj$6s1*e-GBKYBZJouM{tJKv zOcZieDd%LQk+qjZyXX;FyMD8N62Hrws%FUjA2O{Y%mV6U=9_p6Jf|+}$I7^F4X5_C zgV)x%Q3J&_I8*7w$n6vBQNYaNpSw}S7RsbP8)-&{?ct~#^xA!~eo&hJFHJxSeE1+V zmqBxm2J)VK9h9$th{^nxvbr4VP)$=HgDTfae#fr{OIOJG;KwhqyNz@W@2MvRGbFEm zXZm9U0E`C{J37Iz8H#3e9-Jtuk{Vz-Iom3nZVIa7Dq*c=@<6xIQ%1jI-Cn)Lh^ z_TPqAa&z4d&bDeK0DxDW-xfzyiLL8;i4@?fS#1`Q#+O|&R952b?QXvHJed0nD*77= zQWSjLacRbFW~xzOKHK@r{6 zO3B!lj%6nAg+3~jYp2^1vAxHJ{mF#!cQV>2_@`Zk1^=&o*|uBMvidr2fh+JVjrZub zy^L90+Lqp~MG!Ps%%1j=)Pld)w&rSAEBs9uwV9cYv$JHVbN<8n+F0T-)nXamITy9g0MnhQf59eU>-Uo*1m`nlF5E!~ zkrS9*Q@7QTKxo^~7qwZdmj;JROE8*I(eXeo_%%lsNoGNYUM`kU=b=jQ<}pXI?K37b zgE>r3<+^L_g0I{1_X{I3S?WbX|1`m+F>S~HQ(b^c#D<K;$Wg(V|(nI^)7()(O$A zk!kDr*tXzEG8i zswnhFJnUGEse1RFSavuENee-o!k2qqtpuN&7He~@pYfm9&<}XpPKqWNBg%kDFsG^@ z^I`DA(ovBg;%fCrsq8!!n^uJ^M;Oy)9IKzth;N!`(yJ*@rT!1=Af^Zwv4@8J9z=WQ z6PPHW>M+x`xiE#Z8Wq&x~JDTK{&_{{E;FmTL4Lxl|rW!(uJWjt8%Ic*g!=BD^Gxt=y2h-{ysm) zC`hwh*uu3k*%I-GaHs9XiZ7_?MmFwgeW#^cY#}f^){Ju9VOGNeg z*9)0&QUv7Nj5Os%5|)SE=9eZMYmJeMa;3=D!)bSV9rz$urBut}b&4XqTGQQbrs;*7 z5$hq*(KV!55gWMoS^4}4TL!J^^v_}4)3{2X<1AzkW8s82XbS0U_F_eq&&k-N^o}lK z{#0$lc-pn#r~Wpd-X&SGB?eRy>23NHVq?!%!QwD@aT_E9o)y<7=Dfk6!@;-cZ?G3u z<4_Y)14M4}s$*>`ay=+M4#2dM2dmn1X7##r?>J4~{@X*O`@I5%DUY;@@aOSbZp=NY zq5heM!wy<6dVqg^Y6X5UsL_O!qf84)E-{|7kU3>sT&jIKM#5ukj3|d%Tj%$10}kY+ zO~at*=|anezP*>9zUo}f?!k*_r)nwn**}Hi{0}D9vQLIK=yJ`pHSNO zX~(a!9{-kPb(6PC1KY1ZOBM#WJSjk7J~67OInw0?y|f}?LaLK?C|fyZ_SUa_{#Y&u z7~OV>>GOUtdssn|?m3ZmPbeSB^hj5|^4|ZaO|Le8upru{U;LbLp-}5F$!6N)?mhB# zTebWy!C7^SAp?PWsVu@Js=v-5G@Hur9kup81RH*3{M&zq$r0eJjo;X9;5tmh@wsIn~{Ri3zm5%4rq=Eb6zd5eaTwd zBmi%?a>Z7n1#C?QtMqFzCa>}Z?w@i1!X{IL1C=@=V})xl?v^s^PRd>vOZCS?C?KV| zabX=kr9)%ad+Q&UQv%U@{{Yk$!A|tnRi?^y37uSJlnZX9N7$`uEmbhyurqG=JOKMM zi!L+BZG!0L$vn{DXQ^m0q!+YgEB)vq*O|}%NYlduaFHdcymr{n%cFV`o4i>vXu-p_ z*OkYYMRim~mj}5uV<;@R5-f6o_C(60Ri9eGofo1+#OHXI+6z74X`{_)*yuW?J}HT9JI69(6wl1W)8CNyD$4%%GO*fl;; zoFM^af)8)+XjTqC*Ky3bZ4P{GY9tbsk@;Z-0hIM)9%217uQ^mnX_Ea)nixl4uJD`j z?z&cWurE$(bf2&ywPln~GMmevCre@mSIB!hO;^U02E}^|2w`y@^A*%C6OQfhLk;(K zR>HP2i*ilYqoF^7mO{~2o=4~^p6_Pk65KR%h0JEcF1|Sb%7gX)?>)gCQ0OUf)J2Z9 z%xX-7OUVi!!z9qbY-t-6zkdY@DD5-xMm$ED8EqxI4%gmTbGr(XMnXfY+i-5;0JH1f z#tN2(U^;Z`f02)Xk>Zm@baRjCLXu->RUBaLxz#yZODVSZ!ue4;M~#7xO2lc3P#=ue zFYWhjpV!0g8kcB8MJ6R6zUWY-U>7d7O|*^W?>8i1i04#Dd4%pLDaxPd0ZtAT$Ho3@ z!u|KqJaE@IuT1uUic!9U!KC2CEOkh!sZKd_YM0nT6pNp9oZY)kqB*gAno;BACSXoQVl!<(c|_ND zc@)p{r@1!fKn&!uvbQ3szS=w7R1sWd#(-YG2;fa0iwir$^ zaxKD(DeDCKCGv4TQN`xtHzLvy{s>p44#`F@vJ7VuG0(hEA)83uO|*FKvQ7o0m9x{Y z;nO&xZ;CC=ec#>eV=p3ha@mik9$KZz=X7ts55wuEzGdu zPNW4ZqL<0CABHGpOTPUWk#8YC;cw8M4i-p?Na=^DXa73xXWMM>ZLV{7vz#JEmXCba zPWiYKzkf@4JiaDkL9_16efxrS&J6>8iQ=2}C&s<&MCh*&cZ)pM0hfk?v6`ub@uKE$dcWmnHg>w1}{V0lG2m_ z=hnG#LI+UTlkmZL2S*eSG1MX%_Lmz{_tN3P3-1(r)pNcOq}g{zo0b%m= z0gC-Hz!44>MBUmrXX`&Iq`i)%)MNh37A;swhBNZgT_^-L<00* zxzVZq*G_+~!FWe>`|IzS^Bz|>74nd%(VLP2*zNkYP#Tiq>dzy@Xpyb!I#=U%oULZ; zvg<@~wH{0#4!4Qq#5(+NVZu1vcr&H*QL8gUpHpNq}tpQn?J z8b)UcSt`n(TPv(xmi8-@+Bf`VKZoxETC_L7UI+fk@0tasftzSE_*jRau)c-i*h?AG zP)1?A2JtO3E@Wfomud?sJDe%@&~@8jWyvU)8f~7+uOBh`8-cFglxtb_T4gvt+uH>5VC@DaC2BOh zbKt~Bt&M}~!d$`fu?wJ`iplRQ)At%r0sXhXtFJ)>VY*F>{E$%;7+TH;g;i0vpiOs4 z`xGl}!=8Cobo&Q33G)X-9H8>j!^4*c$H-!g!*`f*uF$Ucg#)9Ia*iTZGm&qnXIOBh z3}#G^CUcvjjhxUJN(M?-=6d)8U8F4n&j&HXIN~e&{*->~C%DL88XEP0C;N)k25>o3 zidUyknG5cYu~BrB#i;3EUn7B23Tkkvh`~Pin7Keyvu_>4|MwCVh?xnD(h1iz22Hmt zV5()I;y}_h$RVc#*3XQu`!#z#v>u)z_nmkd>__b#tdjd>`>I@kx_iWpz-5q8-%W-| z#&)%+vCv=@SNcu;scmG%Zrx67Pd*ic&?*YA10V;PX2%|A#R@h0sh|bkv8X~~{9wkd zVKxp0T)%Z^w&Ag875(cS&{Avv;Ey#D&{7Lh7hp`cVfYj?5IN^eh+KwtIu*?tqo-zB9$PoN#hH#%jA|oNr$DPi8A{5!!-358~Y{Kj+HM zR0<73h;KLkig8mc`I2v`k|_y1KIw&h4-}bNu*vLZOY$e-jF}TqF!yS;sywM%-U9s+ zF2siQc8Y_fMix&Y&0-=$q5K1jlUcb5Gnn3w?WiqJ#Afdf{%3Gg^T%9hfp~qkWdoUS zW9efB@_4YoqQw5PTtB@aHx~t3@?$SItam%?%jpN_>>cS9D5t+XJx)g3W^oMKwrH@% z4tZ;N^&w0e+1D@s37z~e{(cq{YDknAj1F!LSxA7jE`6XPP@WN}8yoTnwrRa@VjC8) zx=T4t+_GmZG7}|{!71eu!{tCy-@{p9#FkEVk8mFEk7Ct2I-6NkVbH9vsa(cJ&khVk zcu)qJ`=2$E`5#xUI;kGIt$xl+rNGADWJ2a#XUC>X#a3$Jc^c6r0uK{YMX}ZGf4iOg ztDT%}#AbHfc$&KhHlOJP6mb}+*(BA<#dgi_f(_GdW*2{s@4vN$;je^q=5j|8l~)0{%vu0ktJ$3x<;*$e&CPG|G8?-|8W;GLpT+(ZTNJ*jfji12_9%9#m2b%MQl6b!r+MaT9@uN8W5H!aieZc#EI2@-n3Kf4|0fNvj;xyLZ@1osL8GLQX$m_L7z&>_-Q^@k8&!^7Kpp89=l1#}m3DrBC|=#!fh)v1T;+cK-w14_BxpZ_7k22I5C?(GRZ{E*I7q<%M(k ztfzx1_szd5twAs%KSF5P0JwPEKlSO$2dhU)NIRn zhfS(t4x!ALqm{L$|DBSgzCPC|$g69MsyhNX9F`&%5n!IOG$qZ@K>!6iz6&KbyBy|( z<|obi_uH?x=t>Th!Snydp~iY3^*ruCp8mp3k$;<9zIKPEU~+9BUiBO~W2h=l!MY!h zK4f@W+Nq15$0FUDp22e&0)0y0J*LnHu->sy(t9(lt|B$`8wtATQet%-}^?T^5mY@CJ z`roa+Jvth^L5hU!*u(EEvcP|wVG{5p0FY{i+Cx$GCZbQf#qWN| zobe!%I^?>yT>I&P#A8j-slA}ResseIl=`SoX)(`GJuMUe`o;M%f`A+d#{dCDUdZ0UrVSnHv*iQn4(rSRh;P-*?{bd&?39$t>PYDBe zgZV`|C-hxTy(aNtYpXt}r?q?xk?Zt)5V8Ezt4*Azus`-fv|xbK%$vDSPVY8isEx^w zvDE@|=F&h(DvfZUd91;Gqu-*yD#L1$LmjW4e_l?`rE!k}Lm^vEb0c(Wb=EW~Am;}< zCP6EMOAhnwS~3wy3_;lEWvExD&;6ihv}LLE)O@u>OnbnM)#8-~LoQ+yd;^Q%(o%M5 z1^JS5CcYTTdTAD;uZwT>o?OE=bJ0x)^*P(Pl9pSNXTrwN5pE68afa`S9y-NZep*7iz+IHOpS#hoP7M3jf0ZV630hM&beWn9y`mlR~ zAh|-hDNX+B148ps7pOI1(YyW(^4JM9-nHAtX%9dnzst)%{*^!@zY?4GT=9uv@LG6U zS@2+|@SO!Z*NPweVc0~?h(5X!)BUq{D($-2eVdbVt{SOmOp|UY6Ef6v>0}j{cr9j< zIooo(_$*c3ZVF-6y%nW>$`*_hR&Kg*x#wtm*z+h|1PX1-Bx{>PZ3GiDRB}muS3*aQ zLpPe4JF}D1iz_d)Cd_v}dYS;$nxn;RXpz`IeU>s7;JD@B+LP%8#yeAWhp75*w1+ZC);y@A8p1y$5p`|G3gVEFs#@w1iiuEJu_{!n);YF=E~ z#GD7o;%yV)IYc%5pN$SP00_l9om04oBesy+8wN5CkG0}4g2$t~E57RQAX}xpy74f# z=P?BATUwnucYsI%$lZVWS8T_fWP+}2i-o@v`Z#{bq=#MMGBuT|=Nc75cONP0yVYp? z2h=>_?Ym~taLIaNQNbTMj`PPYSZALY(4%}Wuc9S5$yeK-lMWBWCiHs1uk|YU%Vjs5 zIkSYUH5KcXm}cZlhQ&fY%#-6U6P=jk{ih$#dDX#K6RPoLEWw6*NzoyV1bvvc?<}G| z_l2v2j@FzaY;wxQ9DhC`jDY+oHId=nfcpk`d}G$Ie@KU>kwMEV^enHp)X@+y7Z-^u z=htxuzd4-EN!WgF8v5h^G|C}!?#l0aWhij!y*HM4K~B=Wf*YABqFNbgwK`9fRzjZx zeVa0*0lEg0L`^E7#`Fm?w$btF!#UvczQpAA%CbEHlIe$+0Wto$-@UoQUh(^t zs!~Z*nT`A&m4I%E;Qc1-;R)@b<=F1xLC(IyqNTCics5o>AsLP4*=&`hx36xlepqQP5;UMN-w^%dE)tpN@rQr(HDX%AH=-(;W!)r36o$_GoRfRO_@ko zqD8L;E2Z``UF2D{w?4y#v*Q}}MI*Gdu;dN+pKR0H-T4W42_IfkPh}L}z-5J= zJ?VK~E0u;$d7xcEgrf|0HNmFj#@#CY&+b1!fBa)y>I~0RC|NUw)kLF8NGf@M#T_4RHw=c@IfTg3wsGi)$X)b=T8k&ktD6&!nU}LRc>VdaJl`v0KyC9D zM__|`TXMlcYFBW%9a-`Du(cJepC(2AYgF1^FsqCIU zkuj;M&)7T*Ys%pg0V%9X?MGywR@fM?%xHM$(-aAl=Nn1D~BEPiyrI` zgy9?(=2l^V$l(=5zZ4fDm9A$~zx6bXiKkc}5`Bm%?g;ta#oQ1yPk(>b4X8Uc4amxO zfkhMy-6vqDvm!>k6ZV)3-=h7@YEk^79iol3>4Z|bGMJg>I98mj0Mp+cZSJWKTKM=` z8+d7aj-q5T#TZ_H`;c8X*-98Cs_)|L1;Y91B8xL12B7~%|hxjk4zdn-t zj1_&Bj`B?oO_pn!Y>K6*$SDy^?gM#}ysYDG`P_4l%gdY)O#Yhm8<>zM5VbTFiph5%+$&6H3t>YQse_`_#*qzv;nJ442pS=BIZ z2#Sn4ixgZ*)i0}&y3%2DtNwI`NQ^-0Wm5xT8SYv4A-=jjqRD8n5b?FHpWXM4{1Pi> z8J8k08*DToj`nP~gPi3g$P@PO%~{=18Wt4GUEjP)n}W4u2z%95DL8{(+)Imx%}EC~ z+HY@~d@3ml8}`ccQ|12lh(_=d5%BLGOfM_(>CV=eU=FJ(NrtdIRbeB3{zITsgY?H0RGu^Vz*&1?${Pq@+*kws&ud<1@ld# znIn>&5|PP=e$?S?`i$kZrU@~vJjX|u#`f^FQAKiI?XT+2Ew(cJv3*V6$gffktd`w$ zxozIhXh(tL&=D(_TFqdBTn6<8!3~+2ubHHN*cn=A%(Gi*#GX;SRB>l;GY(Xvu$PC&id~{NH{HYEoCxY-xTu zPoK*^fEcXI`r{)O6Ad9;=MAhhLu9D(GdXh4L6G^$6NMTCJ*rgdE?>|);snV!B~}n9 zT8-g;2fO4rLU%8sV}xoQ6DVyO1Sc9`CX9R6+I<}b-|rO?HS-*e)}V*2!! zO&03UYs8I2o;%<|V`<>AL++IrJej_cvNi4gmR%PPuj~R2I9XC7n6s2_El!Bc;X@L; zwZObbB)H(J1#<;lif1UMa1|JF_%^1n8xZjP|Ck-aWrZ}ide-fQqs=`rgg_@^;}|u> zK$HbtSt`q@3A&b-7I9eUL}&YPMPr06rpd(Xn%iDy-8@#H@_X6r-7%~aOa_A9Gu}x( zl($B82bgTwejSjXnj4O{%S5*B{@B$(ocKLA6}6+Mg^Ef~es1Mq`#XZ)y4m1$1C*#? z^A{oz;gg$`V35ss>VU{S2OFpear$*`$8^qR6>BrQmmAr%sP@PNdJ*wJc0#2-Xn%-R zbk1=z52RqALK)_3_=N5vdrC-~o^S1?zNJf0kg@MvxYARE5|kbm?49B6XI5FDXmy>f zlI9MJOp{W^4h{ALw%wu^rtk&SqJyJi4S%y~w1SzS#~WjL^oxWeY^>X`X7{YS=~WXE z+{zaA^8zkc*$<(w`}0znLHmZwt00(7>lWIj%`VMrXhG$rfO7KF-pZqjxR8JLUc#34&J8>((8)L31;@{!qw2Cjb2TVgdcFCj~~-pogz9uqg}apXW9g}bnGCB!ebjLIh=`3NQjjfb?2%@eH}eZVT6W3(=Z80DG|tb z#j(C{XKb^a+f@Y*ds=iPZp4Luqo>M$-4iKDC!i8r`4(R{C&<>P%He&$;g*;Pll>BB z$l!AIbL<1)HAwjdS6~h9#MBu=Go8gh*|COv@E->fYgD$eQz8)i22~cQGS-n$_hjlu&oN>^-9(>Bpmw?90@5U06iU^7c@f6@&>OL4r)_$gX- z#}e$cw=#(oxQmxe)^h@6rDh#UvkQ$l3T>X%k`@09&cp?{6aqfxnVUeEK2@=>X2pac zJVAK|p8DJKrYLLg503qrDjnU*pU<8ZkSfY=PcqvGR^tF)zlj6)IreyLXa_2uXFMu& z+ZUKlYa8}2Z9#+1gzEW-wcjNg@=Wk4K$RhVvj;HDX5;-jSiS1}ZZp!D*WJ9hD2SkT z1u~rRz?kuYjp+3ckcM+vuq+eslq+dCDdNBA{`c!YV*KQi zhlMh>hSx(;%YKNryC9wU=7i!auziM+L3~~KjrF-?nOHNLyK7q4q|(EZJMUmStV!Oy zIjrS9Z)?txAv7S_q~0~lBoj3&3->F>T={KfT2_mAl{|Rd1asORxq=W_d`!cnuQ^E7 z)Z8d4o%;A07F1j2`f2?f$ANEz0{T_tgJ-9N3z_Ses_QSu(?|E!YOK>ur`Oz_wlwRD zN>zcz+PoCp9Pt^~@-|3>QNiH1c?f%J<~PL-!qgeJzQ~ql5{X5h;GZMk5v!NLy%n=j zSo`3ObLoaUA2O8)Yhj(MW-5R$VH%IyL`din0;g*7Z3&4At1S*|+2TbYJp+|l&{D-)DjXT?24ksYxy?+F=r`E2|YGNfspgZx36lJBO$y2T5VqUc%%)&@tLEz zS3GmI2>3x#l5b=i>4lyK_)FHujZH%tywscDniJQxr&xQC8L7+~VirZAI6*)9$e38# z^Cxz$+PrP9)i!D|L6Yj`$-W(agA}@E-^FlzdwX^UjGei{YKo6T);Cxn2jSzrKm$&% zfS#ZjP-({Z>j;5>r}E$D+qJ`;Sl*nAXl~aQ$6@IanOAjn<&x@;jN+H@G0mp0k+SVHV=0#qA&?kIzUxuifrHAbPhE9TLOE zr*W}cEXjbTVgyEhjF|ABWcBHR6O&cuH@ya|+G0IMQ#eZu(aF|AqAoay4iGY!QjjF~ zSO(;%%rRr2rC z{xg>eIf@OXzqvN9=SRV#YKjF8l;@!t_P=942Tw5}gHw%o>ADshC}11kZ?$#k`ZC6_ z0Ocve1TFm+x!#CE1byv|fbWm)s-L2&d*hl!>=)BrLt5a2!^a6iz+Kx#=gt$&{C8wq zc?@HU`gEe2bm6~Mto6>lKiiVNzPQ328)Hq2E_9BNw{vWmC^d?lVN%3X=H$eHFTK&u znN|O`k=q{5Drtj$a$QlnsUtm>dr4QR9ESQU=6+(wYZ>(x`SzcN=HY2r$nmRgVA1W! ztij#UX(4lB#3rQD7Pe0@@?{O3C{%>|qT;W5&5gL${Chh-n@9@toY?(?c^^3ir9kBc z`%xn``c(~-W->CcG;8gGdxL!j%?M|^N*ahGA-=7l_pfd)(G61US+@_u!%RDBhEIoN z?sg=6wn6?KbV52k2)5qt{!7h**Kl+o318eShZr^{awy77lf#+8N{!rU_4uac6b*@7 z_6!;PnbUnBqTW6+D%sBkG{wX_gyu-RE!^G&vdTNtHKD7M-U4wh@nJ(x|Z^(5C|B5}7ZjL`U9-QaweJX7cRI`F^Qu)Jfo zLFCVlDKorrC~6a23@r-?0m(JdzP$nqODujle5CKSmKmbNlj={xDLQtUw-bW+;y1!A ziiI6|0WGYDb|cSHZ7@g%{&4DO3-%?^i<+k*^&~8p^8t1-;kyizzOGTX(I52j=o3D% zTU;%Za>fKIio$&@XUWn&5vjsq`^SEw8QE>^E^E}7%`@7za6ZTJ zeh`Adf{xGEHw2hl$x}MzMiKLW#PY}DP}99{AOzQh)jOQZFwr$0CM-u z!i&Gr1teLJ`X%Ylr8<`SFrnUA$;f<(6;j0C)&44Lsj%NyQ{_fA^`qL#^BL|K84>Kd z+Jd#XOw=M*F9w@0i`FwK`ci)c3gwAzpjk5Pj~H$FVIIr&VLk8M+8d1d2Iv?Q#CQ20 zN{D*;)M|tBJ{DI=GI-^5 zt6X;l3Ql;>&45CC?`Z7Hb>@T3%X=0s2_5w6T39y!)Fd~>LK90xI8eOf&@W0A$1$tm zap^$%@G4{G2C?wPJxUZq?!q;EMcm~=(Vy91+PRAJ45jl^qc)gK_{Z#sdHJPLlenwe z{v-w%tc|+vu~tdZY!;w_C9PfmB%P}LFK}9har|g_xciE7o-876--eu_d(;Qu@mz^r zEC!Yf_g#jTuzEQ4)Q4tlE`}BLRIadD1=v^92KB%D$;M*kaEL#htLO$RgvK3xU9H^g zwSVE6W3MQSqtk_>9{Bx7!iU!=zmF@z{wk}qm)~5he`iXUm>dK~|kdRAg*WqBWcq z1pYSJJ$}QE+xx;fATOnvKutA7gEjKBDfF?*mr7vKC|C(aBE?>E=psMA82zc4s;*I5 zm2kl)4!(~(MMOf`FAOPF{WK$h)E7xaAB^i&t;Nz=%}@E98pP6&X^-DNN+rYVzzQYo zE^(vRuMEL@2vhVo_}lMRAqLtzOGgS0YVFFbw(EvVInWt$X<#;2_s@LHQ#_Bm;sY*(T) zbM1om(yhqtXFAqwwt+=3_%eH+&q|k80B#zWI}z8dI2X8;7U>g@=-<1WQz@Xps(ZV_ zL1eby2!Nd?etk> z-TgAVOOdA%0k)a;P8f@sNV3D9DFhz~x{s+*{uR!dp-+VK1bczKCsxpgBIWi`-D8PK zhT|$m_l|pNVS|lJzjXJwdHOcL^qj;wImX)di0fmnjRZ~ip=Ej%QD&YUX z!U&yJ>mfVKZkXbjWtWz5mEf&4F-eIyq(T|QQuxQl=I0#+{PCrcCzcD|Ygy9h0V^!! zOX`BhD?o|eF3_3Nh+oeJTZ)pXG7X)|K=jnp&E+8*Uk)4b%K@)S?dKid(shR7&Wh=G zh2u(Bc29Uum!?T$jp#eXAK%BdD?(h&6TL3BIzn!=`93F4yt8aRZ1>9=o>kyoGO?v3 z>A|P+kGRMZcrO?qzhvdMEZ%9Umq6UzFZV6LGQShH%;QHCt4yIcWVc)J^CCV3M1D0M z{|XEi9IRH!ZSI&6K0lGqyN){GLnXZ?S}OB1j0>}yLwoHFPfnzgYzyrc3fLcO=_v7@ z0)<+(e(fwOAD(x@>3Kw(>-2D1WN(~cOn0A7?@x5(RXG5@z0Ua5<_hH61_m4&AD%#r zEAPWKRHhMA4_l8AQ|67S>WRGmef)6%!RIn2&$%A@bEIA+b*1u6CLg8ysCUd5k{h?= zC+(5X?X63sdDykr+4f@_=L}{Dz!ABW5sC6(<9s8-MA_=#LU8mW?=~sFrxeOi;I=9D zIQilvJGsv1Nh?p$Nu~oQzI$3N@gr(MArcmExBdtGx^E%e(Rfa8s)0;VJJx@p-|X7^ z-=kmxI1}wPn zL}>u$7)0#U3*W7on?)y6F9}IY`2WZS8zULvko7%);x1$Mr5nQ0@zOS_xlVZV0=`Hc zapJB~RjxwSb_xmRV7bqdPP0@R-Eglu{3WD6@7B9%NyORj8L+COw;wT8SKjDz>*wMp zUdXlFsDt+oc!1L;i4HlwZm-Z1dt`RiZ%)B4z}JSQJ3iJIfo01Mq)F**n6XmC;6lI5 zun1kzPl4~4HoEV3A$|^l?QHXRwr6D^XkixXQ)tB6Y8`!EhPAps4HsddF1EOYm{Q}m zL9z2<&!P+(L%jDP3V2gcAmTHU(3T^;v+lHEZ;`pz-e=sE{t*~2Q#J}P4`czLPtO+hmc zCLz+}#@zt9j3@7?;TfZpM041ZlubNSAl)r|-s|Rx>QX%RUsD^cq*C?8R{$!BF}JuJK+`DDF$u08U908RkVx)Z zgX_KM)o;qR5{{F>1i>~T_69l|F-IpxL@MZbPELyc0*N^HC?Tp?C8?|9+A)VZ>7mqV zwtzUqSI38~(ok?c$<*u9@TiaK)~oxOFCA(eXqR87qoAw;LM-m|k~C!)+cbqjNo6$V z$>@{!+s!~#<@|**neNkhW@$6jqHzv%U$FWXi3VRN4kB`{m;(!$wPN6sJWksJ5ihDRF80h(p+NEp^4cW>cK!O+;Upg z&M1)?`Jq*S+m*y^H7VRHypWQ`Nls_p(0YzKz+UoxFmZ6nt{$r9&h5GcZ-d`oJDlQ$ zS;+BB6Wni+{9vMJx+wGL%^p$19jn!DxG@xqg5o?cIE6v=lh5){o~`wSRS1jmk1J z<6hslRVK-`HI*@PJzYMzNCC&p{`p1;M-*6bg#b|CjxP%9lq3ONPjA2lMqz$=_FxOP z{2`Qg++?!@pn0w>o7~JV_Aj-t?(W=sV_{1ah`j68qF4ODi~6Fik_=bbcGLblFCd*?XY3>(TVu2apfG8F1*h9MGTFe zaL!XtzkTyA!g=vI&>D-iVm}1kTL%=VQ8XLqyFXuD9N?IWE5>98Y=9n-VV_xDi7tNc zs_|fnD?#p3Ge+uowbT1KcAQAaTq~p~!6-l2p&Z4|5J}c#wcq5iGy&CuoJZil%#o%c z)ED}!MX;R;cP`i-&e#S~bx7(n%xB3!xEqg5?^E}x^$-yk%$u^Cm~i5n1vS2Yfi_pc zdQjsiV0e9TLqXg*R85W}@(@OMDSiwRCp{v7;kb?ZQPKq>H;NQV$; z|Egq+yt99vhOoTkAMZ||@|aD&`N)eLPy$E&x?&kE&Dpw$wDhls4u+Ca;Rr;(%e?yR zgI`JMA-8FWI~MEwmkh7kaSkGOOrG}5@`qV? zz61NSE$k2K%E^k34w#tSt2sOyw~J`3C~(qj$go#I^hs_d0`Zs$ABrA*98WY7@ZDd$xur791+hRW#5 z(bwPpL(&1kIqy;Y#yN&NhFU0+YdhJlN^9{YLEYkrC+j<;zme0EN`c&*eJ%ZPJlG@l z)_LV?Na%GJXieLeNQM*F{UwKs%G9&FB441SS0{oHZ}cOW^L5}`>E?v5yI+joSs0;gq&-vd6lZcbfem zcQvihO~)k;gNSv!9bPMx!{ZEsDg}rG)KRQ=vtkqs=&fU>Y7(N|xk%u@%;8;4BfKmY z_!m}vSa8(rikv}<&&BV3B8kphW0u5BwV2RwPf~D3OE66;+q%@}~~ z7(f_zDpYs>%dsv(1&f!dUOANVX^oH&`*pdR->`%+ft1;BZv92f1WO10e!E5Uv5yZu zD3w!n2Y;~XpU{gCgxOVucnw~vsMIrwD{)o;Ic)@W|N-_uA|UOAksLm=YD*{-4tO%ZjF-B z3#Dr?M*9Ox*}BPD$7MTFZP}v5)coCv&w5k;qpPOdoFmKY_IB=r!6iiBxd$M?AGILx z6Aa|zX&QJQMMO6Id*x{<;mg0x`D9LWr8eHh!KrwEbBQ9#Fy7#VhfG{s364u=@Q*LJ#;S=-pQJM4-{lj@844 z@>|_H;^Ti8NmBXW3#}^pWOB2~ATxkn`S8EtN&Gyvs?}^KZ<^-7Gi<@qi2wT2M`pOt zQzEn__>K8#h>mrDv6RQy_#doZn&w3W|&6&yM`#t%2ESG5|Gq^J)_%MXVWX_ zACTPH<>a0Jb7+tM7q7u~pyF#3GVH1)9FMqj&{z3Hs3h zW$^gNyI1LcxQ9kSd)bdNm$=dJ*Ag@%8^)TKf9n)7G^D6UW!GHm-AleC@shSnE0C#6 zEVnul$P}tT3;ZtqCOomDKUsm-6CrUa^trn_lxOvAzbC)upzVq8i=vO0*`a{b+T?$G z%kl`6I3j@{R|qt)adsNSktYpYpbi6Kr=w9}n~$0Z`L}M#s?Z+K>C5?xYxe|h zR2vP1Zl3mC{obwXO@X8{?uUGxF~2#_r`%Rd9ctmEtD&1#NY6y><`#+{T@Z{5wShrs z`TVQ3PG%LRMfMc9guEX2q9ySU8bz{X0$faXaQN?wP?i~4p3h%>Ol`ij6(Isg0x!uE z$-|?!k@WRTQAK2J>F|2fB!f?fTCNLI`Xjx<#S;Nm_N3J~BqVqJ zYnn)d+=kJJM2dg*0TJTZ$f~fHp_2$D1Y>k^<}n30-0p5k24isNwJIqATmLOI+8DtX zT;I`zZ%q>73k9HiTZVF;5o;7yU!()(JK-;S(d(UJH6D80BU#8}7+Ygm=_ zGiAYygnq74a-p_`Ii6a#QXwV*(4t>Hy$m&sFf9*yHJ092amoFIJI4|uRMFT$PQH4; zW8KKp=lN%~k$iI>%BdUIw{2YWF(l zxLL5fadH^S%L(|r_c{q2VBw9ylZHjd9E}Q;iaw#AdO3Y0&ifLapC8GFi&Cl-!8;{2 z8)}d3t}>e=^x}R}lV4!|)rQ~cShXlE>F1Y0a5YTwN>0f?ZaGk~s^^E&pkwV28rWMUyDj@;nw&Lwqt1F2AjgmpT2^Ui z{Se`Wu@PS%o}v*IBhj5dxs?1NitlOSpaZ@P7i)cn2{@ZsHa16ted$gJPm!4o;FXGf z6GADh!@^eB$W?og2s1TsT3t!kHA6YMpSzPbECa~H%zMQmH;kfAk&a}6fdD_HFi~TB zDMlQv$x3ZRbGw-K+LqXvU7rVnITjG6tm{JwBm5h9n*InPR$JIOzX5%S3EVxSAiSp| zsOxZY))rfWdziqxL-RW2*YG~5!aOfSJbO>{asP;|K#;D~6*y~(RVIj)r$lH$@QnOt zPe?*LbNWC+okLEiZQ70G*V9aPFt^jOlUroYVAD-6;CcLKoyK_u#Vnn%#)o4%R-dcr zmaXe57T;t{@d#3NW_agYNPFIvPB-&%kGKxRJ2vkHgwWK}@*W{BNTMe~*M34v=QX+{ z(`G#a`NXGb9|W;J*r+?(NkL_+UH3&EgN*-;AkMA-uQ0#W0P?1b?y*ffpvL8J6CmCy z!i4>^d1io^lUF*uXa5%Ng;+pPS!IlX7>_*q$R>-)WIi9tznX|MEQ%i?vUrc&?fB#Q zbuEaYul(0U9;4qAM2+`j{ZmgMQSJqbZ6LCMWGw6ca9VHX&>Qc@5O9h}rrql)A=`j= z&{(tzt!=p%G14|ymLi&+xk5?=Ft>G^X_ko`L2kb>YAbG%dPtO0y*fo7H9F3GuI$1KB)=x%#BbszHmN$v~ z+HichLSPlXE6*V99IbunL3G$AzqT~jsa0`_VrWN5>#VHl9O~{V2R8+uVQO%rwDH$f z07=bywn#i2-j?R14)uFCN-*i>W|TI|G$RPnrygArT!aMYk+^P!`2HtG7U2ND)=+qX7=l z8)tKzsL#Iomvplg0-O8aQ_5_-w1Vc5cny%ATf?H3YBT{zZ^AJ_p~ouK#vldO;Ys~T)%sNqk6Zgsf5|n&efsa6o5wphnIKyUD3&HA zQ}RWwAzm2H#QBh!(&jM$kVxioanjm+KP6qk@|aLp!O z-wSUF*}%RfKTBpT!SVj@168L-x6-*wWvEazh~0`&kN&h|S9o+A3@NM(MWerMHp&o6 z_Wn#E;MamVwFba{jve7WkWMF&+QXOKjFX>zrw5)MUz7KWJeB5}x@i|WxHRu~Uy4f{ zm#&@o4xbWdej>oRQECk^Ko1V?(uHcEwrF=`il$u{KU@@dn2!>-hvRno|ImSY(HGR3 zy@ku)g)jEAVz|||;!GJm^4oIVWsskCBM6`VEl{4=y90Uyh?ER!cq|xxu?ozSQQrmQ z-}Y>2yb%z!bY*@nw&T97M0?q(aNP&z?997@Dfz`AQ#0EuW$eJWe;@5exT=*Hg{C3{ z%_R+lCQT2%_F6^|Q!drqUQClpeDW`<|4hW`YjE3nMkAO%{LZ)1=L7T|7?+Gbp9ZMA zuuw4As~JSa{*%>OiZjt+@&oEl2V{b(G;Mt<0ynJr#AeQ?d&i#6H!q=MVp^2i!o<){ z?(S`-q_T*m)b)w~af*>d?)UkWTJzZP)U(_o-svD&o3?f1^EpwCH$#L^%|@g2!nU6~ zus>lV#$IVxj!tOs_Sb5cq7TG%Q<_1e|3FgOh#ZI+m58sjbu0Nb+cl5Q>w}EJQ@XgO}Cm<#^A^JBb|ExoAB8 z4V_D~1P)_?wuktm;iFRa|C*{Llw6FL(pK<K|VE4qKk|*wdd*xY3NA_q$SC1a``~3a{iF@fyKJ z?C%gk1A{gA6ZHv~^6tX&f|p6Ss%bpt^p_YASzga=1>Fsc`ANz;%4@1^$iME!$+b!B z&#x>F<+Kl%(Q+LgTD`R8gwZAHqo3=}$fIHC9xDo@X-yv2RiR55%Om@uT@44(5uQON zslBFtf2GcjFVw{4;A!U52SXTRs5UW8AfW^PO;uu!w*o^}#|WhyJawtc(d<##dLcee z6x>cz^efL6?;!xap3drkeXKfWUEc2k$I9AV>=J?5a|t5Cx_HX}^~O9L1VTvk6zSIr z5(1GVe#xRfS4{WggWjyB4U@`qz{2xp?bXC8b;B*9Q#xFqjmugerQc~pv^Q|*Owzw^nKQMbljy|=As96_ zA71clhOzFeXqn4GEP4En8JyJB1iw4BOE3*Xft)gvEWd(|QGV9S`Kb1;uJet(qv>mD zsRVXriaI%q*(LgstMlpfJA#HPIjN!p;&do+5>~`}QP98r$zxE|QNP!4d4qKWL_l&5 zyXV|{NW8O&CIi){G?5dM7|CDH3NL?$sPl#OJIt#^fc@OC&q6+EGlBbisdb>Z5wFtE zkv<|r5JhHCs-VEGT@Rjcf}}q7B$9AGO~c#u9HregqQUMuG9O%P{?z;34BhJJJMy7L zW=4}MTPstZfZNxTH_+h$5w0@~1)8|QyUeBzyl@J^_Tn7@*C*+l8Y zVAGEjOU1@ylD}QoQBHVQy3U*m&H^7Q*7wx4q6UecixpNn!VRB8wp{0^D>!)csRPO! zNu@CM)e&%;0MtoPf~$Yr(SLttH(Yy+a{#3`YL_LxQtEn5Zg$F)(;wSh&4=~9zJ|Ei zxez9cx7>Sk>q@!Cx4gbR>(;BiymYTYCM@7VJ2qcI8ZCM1v(1MI3m4Cl$DgHKhFpb& zN9g33B{VOAQ{X+EVxKhl{UMouvP!4Zwn!jZ8=pNHwN3v-R^c9d(GlpjVq+9J`p((E zdrn9zh|kkxMR!jcYS)jv2G9E-1-$7)nfg|B`)5D<@I8K^T_jhwe;l~6 zvmeoh!gj>0?zNdSq7g0J>m*fZIp-0-8v1P&4hX+t-@|bFy+;dbWG}**K(59#gL};YS}*vkY=0(b zC3P@k8y_~j&sWgMCiLlLl>EmpT`aupHUeuN5?7#-y`D~=JA zq|_@= z&PtN{_dZB7V$Q+(T=9&i0(1~tD5LsWDz8^N#}I%_N|UhVdY5bBY4s*=Ydy=;{NgL# z3y#EyCE#JY1+9@zV6BSIXlL zch((pL#rBJ^1lj+{Tcw>13O$u&$8Zr3*@y zQ;R^0@!JRv)2p*|rN50~|BQ76a@=OlB_P|ne#|S3p0)f~s`k$0Qcpe!38eS>N#1X- z^}ZIw@3=qUKjq)EY%#|;e`~x1QpV?KFgQGJ_8PcBKI^ufH zm${Ltlx9EWWUOs5Z9PQARz2P67dqsewe)CeH9t1uDZ>5Pw8;a|w7v|aR4e)g@s>QW z>0JBr?#W05%~us2&*iNP-``KbY08WWU^+im@G~t?7O8lr6fcVCZIxjaCv0FR)Q+qTV<&Gq(? zYg;vAlTA18cIdE+@sheJviNZ97jK4ta#WU?*pF|s!lJ0c6kFA_f_;Q|HqXZEnoI=k zY(G#SyBNn&d{wraC!@J8Dxc0VLn5QUm){tyH(Vo^P4Fm0pnuEZNxlDVBdC+|Cm23L z_Lqi>5te=9mPYo&kZwf1yW@7O+m^bpz`Nf-R1PLBxcD>XVUbBXQ6)nJV`W!tGMR;q; z`8b{>^5aw4ol&zLY!DCEFhfi~+0||RQ|mTYrJJdWTQ9Ois6s^nB8LrJ>fXX(Yl~VU zhcfr?;|aR#o}`N$+bwlIWr}p9-c6?~M+FWl^`a1Ce$>``KuygNHu=~t__Nd6ejZYP zPG%<@^(vz-s2z?~0E^xCoZr+EX1a+kCz@{OK8L=oS{SKY#4}EQ5YEuU7GLH-dJV> zw7n<67-?y;HCEc>pMJ-DInV&3f2yd|wH!Fr#M=2_Z1W~k>Z~XQg^O|}#f7&>srfxO-MqH+f*X>k?K&A8%}q-^#~nU)LX+3LfzDr?vtZysmN z=_UOBj&V6)wUS?d*jzc{D3?FK;H?1TU+f6c8B|EXJIfd@(9Jkep0EiJgF4R{n;81Y z1q4z}bW7|1haJ+{ZWoukMiDCcDpcIVf=`5b`*DKMA(v@vl0Dos;!KM5D_YNz?j?}@ zetB$#6x03Q*VqqJfG;9zy#cr?+Nj~WWxTpSyNH#0zD5U9rnCk^u4UZ3=K4;do`?cL zL-?lN)Rr*2UZbPOAlHki-JE=y+p6B4o&#nCk*XX}{}h_%4rd`YB=4_6RDQ zBtFQ8=(`7j_cSZMZ7L5Infp)B?&GG_3>`62Wn(yl6(I&(q1=Y{8Ts8uUeasy<<=`) zWt`({@Tg6T=-_n2enbalL;z~50u~+rvauf>W6$sfI2G#yOezUt+Ant!R`wSbO9={n z6k~52Q#bz8!WqbA6r`H_4aSqGhg z+ETF{b&*vN&J*`?Fe9bq=_#c4Y!?{k{>9Eo?l58P9-1rBX{y3j(}VL6<_iSEl~BFW z5sN!*%Yg;~wQ`3V-TGf!2F9X!;O&O$7cI!U2-llC-ajx0nJm)St7d1EbkEr2+GD`H zCs3iz_j;dRSg)-QTEf=p9-AsX=bkwgtb(;|SoFN#mFUOGapg3u+4hsc#PSEsCFZZq zRlHyB?2{T}MmrZ;HVp#Z=(041m4fBN6z?&mX896M!l=LG)}MzXR?bQHAU)OJJ&yA; zv59x5r__7*B2}aZ4A0W)Vc_QRxOZj0{(?XrB(;$u|6dkf3U}(b7sBp%;dF z@vBF&n_}|qKZ&+wm=QOw+{GTlh^DqmDLlf;7`{&YOOS>?mo*K?y~dS@yh z*1z~)DJ(v|vqRY}Dv_E;9Z=kZo^Df1!?#+pUT8j5RcB6-QDT+D#GH5N_Imsc}@sYImEMJd$yu3 zIxVtpcz8dc#Y!elIYP6%n~3Yh1PE2Y^q_wIjD0NPBR0SRlM}6_p(~BO<0z?Qx^=wp$ znTX%iwuMCe=M9IX>p*=Ss+TXxFB&dD z#k%~5-&OqFg^l`VEQ?Y)R&Hc90}{$qr%mS4M($w${DLeQfgi6Ej21o@I1#?pWEkYS zO!c`d*gM*ucjrhYLz6$}YZ0wN=*VXnL-iEOxm%Z0}Sg0WW$7nvd1+Q2!06B5y*eche7-648LpDe0K5BQ)MgJk{-V(dY4 za8}o?XBSJ?J75Z}l~IyR{?)uf&=zp_u5=cpBcTcRj6aR?+K$PtWK8T;4L7b7vk=(e zXxd>akS|d#e+P!>;hc+oetg+8?OTnoQCzM^i@@CUdL^hhtqPjF^wBEH^TQ4>(u3N& zQXjd~}|HPfPLC=-NPFX+d8TGBB6OHKE z?wtucTPr)vvUcqCa~eVEU-T)?Jrmx&FYO}SM-rf;8GeKOcl~`18+NVvtFYlpyL0i@ zBj%DXTE?pm5ma$?*gvooT`2r5vhrm7!%D6gtbYe><~9C z`$l;@soP!HzBf6O0L(s(auQm*VX%@o!`0C@Btgvq4*Ymmba4lF)36EM7c*Sz_7t;9 zZp=)S0PZ&j%(bczzZ^yjkp0?POo*MWH?PxPwm%+*+;L^Y4ch98j z=Q6Q;9meo;ZCaP5Upf1O0rgbKi&n~;L;Z={OUupzFoL`efJwfCTCpfnamyHCWvtnA zk}gBgLeZaNeO~!7cet*!C|Lit>9A1$B_na?i^wTy+;b3?=WAUU!2dmd*@F6ebevv_ z78%(0h2Druh+ovfF@@(KP0fd(@=5q05KS%!4^h6`|2(%Qmr^f6?+Y3HB^=<7!JWq>|W_Sn6l(k z#?WlN-~A}S;n&A$mgdopcZvh|6z^99-VJ!X_^rTiT5~sb9(B`2$Pey~drOdqcY!-SS^UWB2kkKi#>>+i;|N2EdqvTmKhXGBT)q~cq z#pzxWwiRc9*X`pqdDIS#rw++npU+HPJ zT9f5$ZFdR%r@cEak_bbxY}JnJO8Yu@)>ZmWuN(MZ_3u2GWR=BRZ+DZG4Bf`hZkm9* z6E?9++X#c(afQOomtSG({-w^zSvv-+JjVB@8jGUBDQMmr8fY5VAn~D%N9l=ECf25n z>KSP=tSvymac2lMlMWR^kkM97+~DzgUqOk(5*|Ati!VDLaQ%Lcrjawlu3~_M+~x5# zx%Zid6Cg}R*k{z~#6oGs<`59s++t4el!cr3)xGeCVpR4g-&WujqL(QS@*nKy7Bcu; zvmipX2M}=JC`|$!yreYI=A1%T4TNj#^mUi(D?a( zS9%qQH5IZ;D^pnQ?xex6uaMU{dt>-qxiKbn<&QB%r~%eRoPy>CwLdcBxh6?|I+Kxi zDv1FGregj10fu$~(qQ@;@lW=4-xA@Y5~f9h8-p}`PW13De}*?}a)SRw&%~T}v=iwXOD_4*mn%NDbHe8_WteNF| z1k^=Mcu*NzyZv@(?Y3jk^6g+PzQHjGUS+m`@oXCG@1H^0MP~S_&}Bp4@?7>DM@Cyv zn5IqgSMkaM;$3(Qx@WV+&8B=fzwR#{zrJ+kB?+gH@$a4pvv$m9w0?&7Mm>_o3LC8?fA-$L%rMW#~zX7fe0tLg+alvo<_NFjj zj}u49*Q0lzXJMyre^erC=A&IO>v>)@fjM!SnrbS#4Jk<^%E-4ol=k%mnw5W58zpl6aL+9KKq)Vo zzKbTdbprF(5oPbopE3(f?ESo2#NXumjkAolj|>cfSKtn!$oHOh@f{C+0k2gT3{1SM zerhAtlJ)&KAE`AS$opyqVz*MFbS$ZoLc6~{e&vGqZ29G+s|-w9ovHys`?2aL`{t zQP0`OV1bV%v@E>5yt<`$D9x)BGAe^e4}MeLl_3{v5^(uK+o>qiCApJ>G>n|8FuK%= zgIJ6uy46%|+Z?O5d8*XrC-c9Sq6qeq+yVOe_!wI8UMO zPAPA7XNR|2V5ywBf^(VrcH^rWo?ZJ@zmopG=pXbbomQ{p-#A_4X`MKjxHqvF5rKF4 z=rA1noU_L15E7AB7yqXl?yq>4@MznN?`-njM&v%8odmombop5CED zS?CO>%3C)3OYT-hQJacT_=8rPC?tx*TC882CH+fl3zR3%$;U~8q_Xw2dfoP zGvk71!;Q^FOzy|evd|H_55v{ppWuJurd0a=&cJ#Qp*rXLX-#UpZZUaJx9;geizQ|F zp!aA_QPtDQyU9LB0uJ$RsmD66Y6&5A)=tSAnAlSWLG>rB%AIjm7US@h52?MCuT_?K z3A)t0Hgrb3td>>nmt5fJaR|}}yV^}vZ{9=F?J=UWx#eFbWw^QI-&@W|#*hefeSOaT zi6lSku|Kp~kv0_<>7*uOd700q-JQ!PQMv8ys4J-|8vZT6+AHmY<#2 zb=NQ5c0C=5ul>G2bR=K3K6Nelw4LAimYIKjnB>+444Nqft@7E7>wGwMYoAqq`G`(3Tz!JNvY3454wFd@^fnXK%O$My^DS~<=1*f(uCP8s674^3D*CW!_c0}qyz#v5 z%SWxV0tVTPuWNrC*Jt8M@A~Ev*{g-|x2QhH&-EhNdo{Y5lS5%iXTI2|7zq7_*w}(w z9XUC_%y|*uJJBD{BxJRzm?Hvno!#w+X$#1p0#5tPkL4|su*On4Gmp32xaPsXrh}C? z)G)czH*85^qZaVi6I$s_V67I)4ZrMNiI86Lx}r`)l7(qa?H_!VjkD}hZGzia@?y>@ zTUmUC^=yMcwGHI~jyeeki@A(Zf`JWA945BUcmZwv!U!l$dlQJy>TiJpQMzC`=vJ?r zudkTqFKV$<2syNz^kjdxlMw(>Payhhxk<$&gSz>MT*=y^c7_`RQF@D%(8H!eDO|?R zXE-J_>ujIsCl)Z##!MMiX?yg;u5X&xD8{jz36fad&}!pjlGm>+O^)Kl@Q#RInLrz8 zdY!s5-r3ueOx(0DuI}~mi|XfoH=Shxx3r17De1b^0m5C;%mPB)UF3Ge8;Q;+Sz6<>>vnozPCz ze{}78SoQ|pmmEl;lZ`nI3kjCzlVz1U3ShOO{>UY|QM^PYPB}AB&zNqz=J>nhk$uuu zJ_?P8;Z`2310J7tF4FyQHGZehyfHj;)m)C@Le)K~G91@u>dn3_u?r{f6E@hC{V=e4 zr&(p!sQP7xzN7LU_4)Q?uai|17OqF3vzIcz%3O0V@WDpfG-=-STFbS8A8E>E`2(LK z8eHjG3trE?(3F9x7Nz)3#NkX54e73z_Dn7`7jKW+o=n@+-Vu9x11+Jr+s+vKX{UvT z{-cXvkQ(c?cV$tj#YYzYu?-QehQ*1IO@ZJr)QCEj)86yGA3UeU$zgn-^$YhA*<@AN zPh3cnRC)oLzc)SJI4JT;iGH@EaX)K9eWo24Ss&tPJ`6Qd!!;)_bW|YdFiZ}!G?WCR;LY&!##F{?}uhm`G53%I8UtfR-OO1Y42=F zC{AIW2(@F&+Num<6axSi7*Ovp?PwmQ+j>3K)R`RSZrRcTqYw{Yzc^qQQa;L|n{a;&H@MM=_i$PQ2}0XKF~`n@15|}V1yrUDc~Kli zWDf|<79;=aekHHZ3lS&qqy*yZk>c7V!wPjEP!-@_>gdNUzcff11^Y7}p?|?2cZU#T&NH#shQYc8 z#hZ=s0wJ_@a{L#wx58Pb6qqT>?KVwbhqT^iz}f&*I;^6+7Fdc~Ox3IUvuVZ%pWro@ zW)7(1%L=-@m(R4%Oo%VY_F*7q4mc+?MB$ZKTyt|$8#*8Ddy2v_jkR=+@mKrIFp8BY z9?fW@C1x;i$mTHevYa_E*Qcvgk~U}T+ryquJo%kU;X2CGnHq~PbrzQzPOeFzX_w!Z z$Jv?TF2ZDsw_H-km}ese@YlRZY0dYG-X0Y#3!NLhK)j{)37%hA$yJ5Gxt`Z1coOQy zv)zx}?m%MASJ`@i_ z94sxTU?hu!pX=gNp{ZgdCIYxPWmLX1>*q!9D{I?lje_Bs~3w zFU{&yO4`Epzn3+DY0G#eA-L&@CnMMmPCY znJA}og1{IiqC-k&kJtaV|pLfc6)=Wi!iBxZWGGeEiE%+EA7bD3woHpt9sw z+UL|6OZB4Bqz@(0yj{u;Zux38j!GfJvkbHtb?AMPMwTk5_@{5dbP~hTn)X=F8tm#m z?CLre%P3&E))|W}ZEHfApbq~W{lxqjl`V2QhEexNGMA-1jWlCaGz`^y^nsH7P<_mS z7&ncO(w_Qdechj2fK`#+Fl%v&GL#k0Uf~XYNcz!s%-vDM7|5t3whEHfQc;s*0WT`& ziqJ)#JX8dQ_hqbD83E*=io9s z*i9Sjp_8kKx?obPvQ;;$+ccXEoyDR~m+j@v=)#I@X&5oF+qg6{vcV?nYqta6$=4w);#=6 zyi&F<+M(AsgtEEs!@u3sg<9)c%om-zYK4+eQCfaX#jo{!p3+!C!S>!1_o8x5n3_-` zvk^M681k322ldiSyzqhkB9HmA{Cr}zSuzb(2I%)}4%ApjCL6P0^P<@vt(4?83qfZu zMriMoVTE}eHfDwb&TPTPjF8^OSE6Or-LdvweDw!IT$IKw&ex4PO zZek?;Ixj8x-PCXXR%@IBsvlLrV8;BB8qLJ6cJvV1fcSUVtCqUcN)`vuIUge}LtsE} z7Dhsmm-Tc7=Ed+-Z1O}w|NRAVJu)n*1CHMTpEQjFaCW{F(Q#S^P-)~;JUX6g$%DjL z6NX%iW=FD-&0gFY`D5yubQT#I4m=!rjwfdW+$$&$Bs`_90A{;czhe`?kxdNKO)JG* zqC{S$tp$PSi)X@_PUB{85gmwT`+|OR25B?1ROhlF!Bu^&ZY`&Z)8k9bJ?bC))A)_G zNUwGofebzzk_2q3&N0B0JM*UTchDsc(X%ixWj|#d{Eg=+PoxUX+`njiFs^C!@=f;N zG8Dy@JAUN;-Ud@OanJ&eNfb9jfFMjG#{LDK4fBhgX{;uZ>fR$RET>XIdqLkGNwt$` z(ZSbY0|>)G^F{>OhWC}skz)A*&$|}Bq|zyaLuF)H^et^rJ-EEIqBy-uDmY!SiqiYe zUyc*?4|m3Aws2#s4dmFKSi3DwY*DT3ENMgEZf!y=OnKPgOjqLbiZ4(wa8Ih!(1DvM5tmI3J1n3_K;YnUgD_ zmGms?Cc5+ct0fvM?r3lGc_}kb76oqC{f$V7|A0qQI85oVD+CQ&ky@4t&b(6o)p9Ka zGt*JfAuF@}rT7Bv6ZZ@GENo=!ipHelk>Z_*vYD(Qxa@zj?H9d!&^fB_a!B@rDRTif z(&zO#;FCC3WXk62dcauxA1eSF7*9>3&?Iu6$@Tmfb$d!+dSgxSPz`hZ7w?Ok$E`c5 zb{!iY&>tA?9%UQKEFQ+58T_^zI?$fsv6SF$ba!kD_hsYti+%YQb|X$ZV;?UhKfc_}X10Q@zKMh0CyOo4SzCsRs^^x^+nb{{R_(J^mv@*7skMNya> zAd1IfouCOhBBJ?~ccmn`{XJQ3QzXyf)De0~af(n3+r-MNXTqU;aOrTHpJh8Z7vQ;mC8~cmJC-Zt zm@)ayDUrire7QY8;XlWUHZ5D*l#P4RJ}-5keTHKv?iXU`a-Xu~T<7X(@ogsV?d9`lQ*IB4yU-8X2(k{v>uOF0#uMNE`}?y z7+Fffvs~_jOb1P4$)KFL?d12ng{1@1x)=m7+G?MY$Tr#v?DCY43ah(6-@H4O-Z+S* zHo@#xAP-IU3Xv9RT5Ws*+-tezD&Xd=HjVrr^kDM7c8Y5$H75`*lFc(>T^N)i(9X!` zq}AXY+~RcbKcww*2q}F)3rqjLG>Ua#YzAY(QeER)&D9}0r_HN%KTQk(0Pb2^l-t{o zj!vN$>2bH0nOr{Y!@@vd@9;=}S65drP!`w^9P9;#2muk=wjVC-ot%BS_QUnzs=y#~ zV`GoShi0r|f%b<7Me)~B`=e;0aK=J;#gci_0foAQpXK&Ep{uzN&`d=p>A`lah6a9c zT03z}%c;`|Q^}8e11{f?RtBD-j*eHLgWraSuj;?grTkL(JoZ^A1aUJVC;Z{&vmrDc z2+DeO8XeRnvm!_Mz<~N~|AFWBUiZT1p6LCL&u(`NOM*K-*Y_h-pYlrIQ52CfRM<); zF1_4XUG2U{^Wf=@@t$!IU;rQ0<%d1QaQQkaG^A^GYP=^N zGQ-R$_ktbM!-GmmnkN|vFgypI>WB44J)Lp$1LURWRl_aD5A?wu?CFctR-ZmdVN7AG z@_4}GN^442q~EF)%B=L1ZzMV!U%XQ0aZc;fP6mu9sLw0dRh}MmKFmjlw<0W5FmOJw z!&2tQm836K#9S45YldF$J}86aC<#9(C4Wa+>`&=h^zYiPha_f2w1(7X<gNiV*Z!cptXugL~~9?sp-8-^R97SExx`fUnLQN6Q;fFLEDnHDoH& z1#17gQq94H`Rb4#|Guu1MEgFX&&Sd3{=Cq~?DySmowLnt)c)~ZW9u@<7OabNtsOd8 z{Z|_%9DH&$%c8P>XxspD9cIZKJ(Jz&aOVSq%tB8VQ)sZ->1~{@Sl2NiQf>| zgdQ@q;YE)`+J zaNE+R-T~DVzIu)e!SScB+kM2Q??cBm2?+=8a>a~~mR3vOX*;w~d(-E^G1A0l581{3 zmapiyiGRLaqO~nmT{_|Zp05wz$3-{aW0FkVgFRe7kk6nqXCvSvo452CYE5{Vvs{&k z4~c_#S={%M>cW(~UZx)%EBV;~VxA@$PA=J^@O|xl89liSOGddwo(i*cQMkgP6=&N5 zqRM<9M#u(anKXVIRU0q|Ao|L~lr8fWHQST-&JPA4#8Y7);GV9dO+hR)1_&5Fsq0a;sLc3Xn#k{mo7i*_vIQe&zXv@4Lgqe&& zS^qBS8-iA!e^A(KeMz z1GH%I@=|IE!DzK8EB`w7YliMrpJJ?ELL>LIAIh;={@B_UY? zXSxd~4h|{oqxVl}#MsNDuD)wDI+^fz!o&%T5tG|4X z?w3g_9vT`OVh$hn#YvYRZkM(99%@_fNor^AM}WYt5n%uA3`Br~1oz~sT_B*P{A#3q z;>G7%C+*6htBW|UMv|Ww)e7|~t^s-L&7lV|V)0AmMJEm(H%puAb#>wJnq}si8uJM} z{S&or*I4J)9OgkxdLU7P)v{gS-Z9~K9aZGV`LnhvGLCul?L!`-tgWXxtqwNouhh1= zAS3?jyo|l*p2y7oCU5cb00j=xV=L8G{#Jr(ayOKRmcLrJZ$8+_K3ZzTeDDp@O(B8U zp?wh79%^HuWT4I6Q#;81=}~(nGE9dKYg<)hbgFXu-CFTfg>v~ome|v$jtROG=sKzV zD%NH2g3$tAkM*T(Q6bo@N|4m62an^wO{b#xT+M@P{M<3yZ9t83q@6-**+if_N1S{G64%_XE1)eSYj!1i86ayCE8#9JACFgOgr=Dj|%86Z~Prlc|1Temy}a;+QQ3X?SsI zi%Fy0P(YDFbsM|LvGnn2P5ZibJ^bkhh5u)){7^O7kSp`!Ac-}R(rK6@aKwF5iBpl+ z?L<0WCk3xy=kHX!&UM5M!Lmogk+o?R5x_oNwAp;r2iV=iE4b@rjAx;4{GYQrp$kg8 z-+7#X_fw+owpwpgWiPxXLg8y%o{Xw<&2*__wIj*H$2f8&b&$;o&uXkDd?BrV!FqM?O_a^als9FeU&QINgEqsQe10Aq#ImYiP)iW3s)1hnU=Q?72*8d@AqT6)T7x1&^=t z)aTrxzq}>AinwxHo;_>YUu;Ykijvc3X-nO4aW2IIYbb40-q;0-ZbWTwP}(=wlHVTS z*6{rx3U%61sxtU6=0@YOptDVAaij7ITegP#84xTY4+r+oC0O7(KUWT&RpmY2uc1|- zH`Yy$M#jt3YDUG2@W)XJHFyB6`%LeH)9ASNRR_SecxAZ_mO$Qkr}4+n&+(1%s{b9P zD(m0BcWqRr>#mL+fQwYOI90OPzN(c6`9C%WRY-f7m{z_yNw)RN;q@K4git| z;}->TN2eHu;QY_~bnC|J6B1Eqv?&O&Nhxrg>gwhj6mPU(a8+&+ai00PGr(d36R)v7 z#Idpf>^8%Z`ea3J_BD|Goh6ZRUTw0B`rr7rSIXAl`BZ=OnZ3h0&5fTDJu~R&?7Q{_ z5V8V<8;?{)c&c{lu599$$`itDsNcrRy1A_;5UrWjT-?FJvJODbOGh?@>&Rc3rUWpl zkN^(gncYf>#<4iU34@<4%2rp`LRzj9DUitvQFmV$#-jj?mJ)!7r2k5dePv zJ=Af>ruaQ)GtQ8ArOe{Y#3qA%Zn+UJLnzA@+3QSuf#+@<9klj5FHX$cep7;l0Z(@_hFwQ(Lnr>=>WF#pCvBV_IozL~ z7>hmD{OVm7aE}-MlH+UvNOR)S<7d6N(i$3#og77s2gsb$RTaZ|NX*zY^ zQuj3`e0cb4ty|heus2G*(RY7fjd^!k5Z0=^qIhs>K~Z5S7cxQ{ z{W@|HkoC}CCK45>iQ~Lus{iXeFO>Xh!>sqYp%rph_`CT)*|9xsJlhudz9N#*JghMC zH+-X0cF->zL}LCwj!HkW_&a_kxmiJRVB6r z!yM`4I0CLf@tf$2zhzF?H7V;{=IVWHZeV>J+e&uhYe>U4k2|kh4GOTeUk-Ox1t_gtK}RA^G;K|fmB?6zO>jGXaU$3MstL{Al3 z?zY==KF-*9nuHZKO&lyO?^j?)S=1pj_$&3l-Sr&Womg3X%#h_+5VH-;*fjy(O~3ZB z^K{xAaTqHNwOpWcS63-2@h z$Cdwm`9kdpLgM9jVN0`bR^asvy9)vk=>5vuEaq&ove~hfmrnjKfq1gH&0oL#pq|ci z+Jz=O&Y0mg=OM36$5C>o3#5hm3v+Aii zB$mx?e5U?VZZv~k%*o?7ciyLrBXpPjfp<+<_+wmIoViK?H zIqP8FL5VC%u`xq?-ElfM-Ffy8EG`-8by6ljs?x9M*p+6+b^CvsF{Jf9gEtEVzd0i{H<@zvZXeX@fgc4!qmb#gR^)EU&qsll#f( zlHqDloFD-2LHlt0L_W2s#^zU=EPg|b%~+hv`iMpN#w6q;4mD8yxXI)c`EKzxi!`Je zAv4%7ZQ&Tr?8V1wSk9&y@o#Dm^~wrP-1k8j{F_`fVJU$NlHZS#%vo0&b3m4bQY@^@ zY%iXJKau8@yMzQ|FE}a6gc(Ef-Z5m=P;2^BVub(>kfBzrYV#lXY18bjk7erC-)o3U zWh++cntcWV{Q6uLvKPK~kpxA-B$Tja|c-N=tOs8!pf$Y{kwy@q?GI13LS zxpNDC&=o_A(Q^6HF+_!aiQcB+=aQh5lDDMjl1h74RNrh$@~7I0b35U2g{H?>xbgZY z$Y|u>PZ}5Eu#;!l<2Yvj^K29r%Y>_&g%`gg1htbr$``q%21z`uM&<6)>D%Z`n}X(O zeE$SML6b+t(nq3jZJmnudN_*WOY5|!W??-K@h+U)DiM2ifc1?jNPa{)6ccoF6!@g3oGi)RxHixO;LMlL_|{&<%5aF;1{t==0?Z*+j`CO6A5kfVv#jPqN{C zXD}rWHOW@)5+TxD2D>{p!$Lcy%ku%*1>$1>L8YDIo%xiVC`^rcvSm#69QsuwnB!8oAUH6I zSqhpFQzW=}7v-3kTXdj_)kSn9uKaIwKEB}odu-7W^VrKkj5uD`!A6Y@lo|0|Dvb3z z{!Hny(4MCRO))F|H0Bk*_Mpq)SZxSQ(rKlPck~eL%Y(-`Qm8T!&dfm1q+j7e;^C=U zlLV^P)Kz~)b<7+?rQ25Otm+!QT;*518f{A{-7ZB2MbUlJrtGSe55xH@d0JdG$B55F z8}7Y%03@JbF|T3*i+>@oh2a{YLBqR?g-w>LyIJx1BH^E>>PHJypY%XXEvdI%w{za3 z)<<6cd_AoGDyD(6h*0LCjx+VV_Gt*ulY~6tkAvq0B&%Z~#*6i`1(t14O|Ni2=6_6< z`gX#6)TQJ%{0F>Z3Zyt=96qw@nxgs_*QZSg5NZLDq@mx+^*DL@`_`n#~DkR zZS(_q!w9*hWA8UV-?{6FAE@UEHB^F(=K0HsJGp6XcG8Akzc*iAVxh?cg zM-DXd0Q(L6VIsa*s6~B{_^0}~-S_|h+`6BRO>D)f4p1J{xt}mfS7jjGfU3?d_wp>V zhHiPd-~jGv2wACrlEvKY+Uxi-#LxOkDUinsKbee?Nx-9RV}M2^`~taPXv zb#|6QS@5#l--Kiptiu`@6|z%l(c7pZCa-LU$+vZ`M%7Ej0uSOgG(0b$HOs>sO=8c% z(}~YyZ)-~BS&Q2wdfid=4})plXQ=k8LD$;NiLxAwjP<`W_)ZATRjM*lnTEowAy`=r zj-)B_C-m3Yp6q>eveoqM>CF>wE`QIR@4J{u(mq(oWGPLGwH2(EbiB@;Zg|WPT*k&q zpQGN2G+KH6N(xAWt`y7vHc_n{xfy1)tA%#kHBQLV(|?mrPdNgKo^2u=Z*tjX8i|dd zEj8bzLJD@j%+Wm845gXt)@?II_7fi0M(j)5Z>>EhW3?xu&IpJyMKQMU7#IIuHZc4u z27Y6e9)|@&czx~oBh$X)AM*2pwkG!)WufW%5tF+o>fGUtgGoB z=XE-(l4+|Pyb6fQ8*Xbr)`$dOQW3sBp>`IiH{I~vbnaX!3 zxV!I0{(*>-Z30fZn{zWhzsXbZ)p5CUsqFuxPq7L5s&%qGpQ}2MJA`q7osc%CEnt*% z^D5(7sS<&2@&->&M6gG^=33OEmXZFaGe){I@h5N1eqX=7t*c%(cj@qQug7AOk0d zkn>sRk{$-E{-^!R+&>X6l6^CT=srHDq{QwjGr7~dE7ODhn81=^4VhSKLhDP%e=iV; z)Q5`#9eqft^}g#;GxyEYDV7^m3JQsbaJ&S!{I)4$Od^(K$c+de_q3Ol04h_wQ=?LU z^_=`^w2!#xnf1!|(Z5In?v+^rU`hBIoSZ&sP~!=UOiJqhah@hlhm;qvNP6`aMO$o- z>GHpzsnpa$nFmu1WjMTVw8*2Mj;>J29sW4&J8Xj6>{L5$UT|ST_ZrZhH+Ayy%MsJx z%}g&Viq%^cEB-lkeE3B~dy)K$H;_yrafl|rdWNY1+QMOPY;jLxC3p{S1Eh42&6tD_ zL!gKw5@Qo3Cn=qKo}rPfDXA0bZz-$$&07LUc{7A0+~~OS;x@gI-U&Swv0M`S@A4Dw z`^3y0}bkzBudXv<7iBrD{+6KpKk5t$|3R`R8>PWkKK2g{T32~Nmd zgaBvK8Z%!rch_>3;}9Kt7`B6HH!onGRX^#dFU@hYZ9a7ZmWlh|>z4BewzyXbF4M-? z{R>6}yCL2$|G?j_wGa#2M^@2tkR)#KAoWsjXSeqb&{r}*1qLg zmt)0#kQl>w>UMEmUn$(0!{QSD4l+StJ&yXl9FA&NF+5Jp_xVby-l%%eKW}dDjGFzy zUBqzyt)O-FMcs;Y#+(i>E-jok{o41k+1rKI3ylAmLj|<5%n$ZrzG$PhYPs&)M9)uC z7td$%yabksb$uu+w$$3aowDJcQ7~EHpmmWVt2A&LDs;Z5yZ-TU3V#Rw2!pf!LKkt|Qm=aqWp~j-N6VZq?#iGv=S$xz z>C9fyVB*I&@o&q^#vyxBPJ9^FK6?j40XVoTXCSee!rnfR%~x#E?ihIYdi;ZFn0nOZ z3tWa_^T}qJ>-}wlv}LqO_C3SCl3(1bg>AoMnP9r^M;)lF- zKJ4=#KH09^3f14sXwcy2^O^*e`y~SU$=M9nQ5_IKnmnX%lYXTpEoJzrc!**01FHc< z;IEYdPd%xAWdT|=mln!NH-mrPW=~JeW?k}%h2FU@#`{b2>K~LloSp}W#!w7jd@V^}n z2xg|05ZK?+BRn%#*fWe*CXa?flA%E7qP4?ksRV=Vj_4WkNi|q-2O6c3Ztzp1fm@Y{ zZZ!eVIqvvaHhUa8rCOx4!MSp|C=uKO`{8L)?x%G?o%kG4g`Fs3oBZtCpQd^@*h$*y z83k2s>g`CHae zB9+>&2)C)vURc2F<@kv_H1BEQN|>nxs7tuiGlsUMgn8F;@1F+_%c^Z<`Y3Z{ z?lqS+PbuP!YBAFqsi-u|j4pwnAHE8?KmS|vIXk1x_t8wWQcPPm^ieg%xeu~gI;;(f zlduilqW$d-P;Sg0pQm z&lwHlREOA7!kPq*0wuG+XD4}rMu53Z=acSfd202+T7yrVZ+tA`Wd1w+STSc2f-kV- zA_OZD-yEDn7=!a1#2|wwsj%fHMIfHAPmBX2detyHbkT6yt$%YyZF282G(gQMy8cnL zH^@g-r)&1lO_!38Iu;pMbKVO{R-tB&u$N`_WT~8N7G&hgJP&`Mn1xpLv~vfZ`*8zX zqqg#eP;7Li7s`yex!`!hA-_}9?6~w+ed&@;wn71_pDCs4Zc!8=sr*mBjrrC3#!Fb_ z2X6c%1-1wd9xAiD3n6d@?@W$%KT(Nywt;_<2MwEJU52yrarf z$D^I}I`!_+^KJM<2Uo`rGypd*6oi4~4&cp+*LVlc4OYkQRM4QDw|!jdU*Dv~JQ=%R z2$Yg6AJq$}v`>#e#>OgLFglCRhFD;dyM(X2gkSSMH0_~nQ=qL#GQ5WBfZf`x{>lFr zW#6;dD2&Y>Ao1 z90j^oOw3N>ui|+~9_0KA5xueJmzCb@A|NZX5EJ^EVhjN%!AvLJEJ0QAnkrG z!FmOC_xHsCHV?6oal-r``MD{}?QtdMrOGTYp5LFxD&$L3Je{;+){DNkkfyCl79VY5 zHDMVE$UV8xF+BEs%yz!J>ki(=N{!?Pb0ON+_eMJ&%(A0hOj8ef?1 zAne2TvO!t@_kYrVH8g3GKUK$B zt+z(KCJvGW)-0S~;yiCZAQgJ%Sd#NV3+5>+B$hIHBXGnOai09;?2#XN#I_)3^^lZw z66Ql@K6yM&LbXT7p*-t#I(UWXO3MR5h?Dr<6X!tX3F8%DZU#_yy|k%CYrgN31iOA% z_t40Dd4mZv(GOD{E}V#L+~!3oj00P#W)hV4p>NL~xtalIceJwpg4N29O8ERo!Z2Z% zx5h-X=W*LFn2a|?n<07~QxEcqR+lxp94l$~PDGiw5YlLK(_vJxe}oHwa`K{10N2({B|)7~FUSG%&x7W%7bKlsJu0VEe}XMTo02aJ`x z+@vpj7#QgtnFr`}Z;*&CIRCL7;*Pu7%M!?<8cFuZO4uz^OE~|~t~6m?od*9bjZID; z5Vx49O0@q?gwjY1Q~Y3VOM0ds$J-z8v0>9QNc*sEuus1}i8-z&769}PygjB;gl>VZ zz29=Q3*(yh<#2i%{yJNvEoayc#DLtFHt$nuU#x5>NqR+&k5w9EVMU2ZaWF4(qtQGa zWM;p@0%I0HqN_^w`c8BBm@#e*@*iuq`P07%HJy(B@oMw-E~u&Z?CuSwneZYo3tK?j z{Q`Fyu!FB2(;7G{n<4SC`3g|<0!jmp=UO=gqq!0Byv_;%p=gg$T? z4b8E$N+lpYKneqg9vK`7^xd@>KJir}_=y;0`j(SOfge4}rhq5)#))`tJ72Zo=)~if zgE@28!sUx=lHM1I1YE7$CG#-s(=G;;$;472EIOil89P~Kwvv@pqqC|k=e|nQs%n zFXu2k)5*h>q9aHbWv2+S{9xn~a3YjsG`7qg@{JzI@#-ePC^wHSU0T8x=`kg~pY}`i zUK_S0+!fq)L82-qx}zp$cjdTFvXJ@w!-k^d5!=b2*FfCZ%uSi`<|(#UvhFWWS>}|E zhlWIML7u%?zxG@sJ|Jtm9m1B`WaoZPpS!Z#fyz?CnvkB{@FHwxa(V~dYn(~jm^4GbWXBSHqo`ThE?!S`oG5AqV!ZAn*mI~k&?q&;6*mI|D47L`uV{UUP z{!C(4FTO)5$(H&`piD1-Y#cLV;$6u|Rl)Kua>riUvy(&rP*??(HQhRqbS4Ge2JKvE zl$+Sqv?ZDgC*k~7I1)EE9U-$?z7y~mq%iCs9`)WMl{{x3#u6 zMJ#1c_po^t#lbt`+xYD%7I#hu75s$*;yh3ic&X3vDiVxXFi{k`%y@|#PBL1NVboQ= zAF_4(b+7laSApJ|4zB)1*t^Jm!ay}p8yEr1rmpg(oxmK~-Ecx2R?S8=MB<{uiUr`O zElWF(T=V(d_e8a#`5O$;N}nYvYgP1cL#EDTtXI!@ZF30(y_3FCGSm(30=GQnXw910 z(pQM=z-N1#_uP3?^;}vwoO0&_cPO9vEd!G%IOxaC&n{r23=4)_^08i{PyNZ4{~fRD zA+qhJoU~eAc*MiOj@O-rlwm@QO5U&K{rMkRa#Kttf~D@Ii8AR$Tb^l#MWy0K>ZKnBy`-hu``FxiTlJA>twLoO2b$omQbQKU&f$o*EKNs;)0eqe%HRlzy zJISBwMJ4-_wi9Cn9tR!3Z@k||Z`K79k>D{aub_uLEn&9yT@)URYfqp@bjO3;DXj43 zn(#GxF=P>XKBlS2{2HkXYJTIL$ururWN21hieKw6+UaE$o2(1Ht4N5T;##uJr?tNYj1iRA|34Y%>g@nbu~iWLTjwH zq-2$PDNcjLy#neAPfudK!Z=K%@Xafo!lH{9cs4RC(O1gFe~Yi~eEoQTq15dp#v=HZ zBL_k^mOC?-sC69E$t*J|rj;hbl4|2W2^UtxHyQ1qhyB$Ft|yg8!L>GVToOJW8Z2_w z<>9_d!bDcNxTfZ{O?<3bG=8BvU=U%#aPJqKYOfKd#nOu5wPX$mJ5g0)o>h(&eUE#m zd+iM614;QdBT2&|@bs}5okPW@={Tj0F(;C4a7# zF&uAuDC(6$e1ke`9^8FsOA>JkE+U!WBjZ``9;$l%X3%M`Qvoc+B1TPuuN+yv*rne2 zpM#wN>L{mtim{9o?d3g=+sf5G8X;9oZwvY87u2bu=YBk#UpChVuTUXcr=V27?)Ab) zga&kR$2p(E%j%KXRc~Y!j>)eZs5<1yxs(khOeP#v$azLBRB@V_UhdvQ3FW?@SuKD1 zrmX=GLR;7sh)=92wX!q}Usr$khGjP3GXkakIJnc@x5uf5+Xxp+X_PKtO0x5=`>hr3 z-r2*xMn5II`=xVNf$i{{?Scf`Le z_L`o=`?3t)dddI3(D&VW#Iv1%6XE;zK$tP5Bx*d`b6XNeiQs4FU)j2TSqP?2m5cd*6c9}3kMIg!@GI#39X8r<0N1ZWgsc1%0 zA(Kqh7&6Ozx1(aFjt&XFfXqgfaKa+^(9);S3M6?5bU(1tNkr9d@T(NU*MT0AFc z3=DuWhbYTK!%Jt!6AZh6jtF z!3+wt?4#V|n;KF7nJnru^N_LGVxftY zMLL(VkSj^I&nRb**&)5CN<6IgY`1QQqKW)J?;9!0E}DiDTFm>|Ass2l)o5PFa*^aoY@i|Frm~;B588edVFM9=_Cp=O5ws2=x zKBrOfQ=i$mZKT1@p%ZN@xzy5ffxqRLxEU(-m$H0*5!*Ijl!J6vKP?#ajQUL6^FTfO z4h!N`{1?61R7LZ=&TQm3m`PdH4`%IjuBu_*6tc&_5B8u|`6u}sxZ^*bRX6*=o-TFH zT)C@lwVSh+o`FqqjzHHb{fk*>AeOHh6(&$Z(7kS_+B^%y@IycyL_KEvA7wWQErbDM!1W6Sjw_FxP z)})QZ|2NWkya763bHDe&@#Iq)ti(B&@}Tf>j#cv_>`Hq^%nl{)QU?j#89Qum(7ie# z|Kvk}x^{>Nx{u6RVHCyevWxFD1X5LRMkZQ)0F;-vW{T3#)ocVR`hW##S|o`s%I4et zq>0Geh5Q5j?l%1+${}=UmoE?-Rny(et#g5mL@MOSME$j3AwQzl6lbH&?0AifEoMq< zyPMuvm@RUyp$b37H!el3j=X@5C+b0leFN%u26);70F?|Av?k~XdwNj%`Q9q-Ii}>7 zczb#b-xb_!UR;l&6F_xGSc$1;j&@0)DQB!>nje|1>wc6TmQY4~!bq7;$2zYl?Ln^Y z$3~TC2Q6Ow@dN%#GqcjMfuH$<`|<|NsH%RlI~&!ShezUKRePeie(+wUmm%+J4UWxPYk8DRFdl($vzz1VcVx9{~3jhQRYdZPm>xs-e z*K^~q*>7S-1sDAJ5pw!0$A?;E@u(t)IB}=;yxXlMx3@}??kgPAg^p@ zZBxs&_w<>mredl-&l|7ERnGLKw!i25Eti zWcDIDqY?f|VV)>ADFvxhslV1edIAZqNjJz#Jh3Po`=~$8a+lJa=wWt#?|KnBDD)p$ z{?9+=>Ctm)eyiJ5=~*u@r`CeG;v_*rw;JOes#pp%5Z$-2iup6aq3(y;KqGMS`@KE? ze{j~$o1ij9mle3?=!ue?Np7B%y)keP?blb4$Z`*m(|FyM4U2WQr=;A^u4*uTfug+v z|1tPGl9Gx?)wL27VcO`k+m^iOce`(N<;QaA*EP@{BkmKmwl56tQ z!I=W3xpMWi$9X7q%mNAhtA_rWdM5iH-=tV4>?8bL&AT0A$QB>1UFtNfB6hKvOagCN z=6x>CB3vROrpqv!62R}Jo^UGa|knFz26=a)PDCJgz_mVD;Ao(wcu zYi{T&u_Rwx!hX~QbYXpb1DfOdQbrHM-)j(z@0=>J?zOgQlG(6b7vjO%v@wW>x8B|T z*B#b{jIRkiRc$sngJ+|}Ai|t!!Io1}?62134Kc;at~7?szod?}a1VfNB(cp|;sG6L z-L{1wy@q)dCrs&il=(petr61TN^8feAqAV`BO7+)BiW=Mx>DEUgIcAB`h3S|E(|64 z^17uB%FpF<4p$U%H;sMFf72xSk1rEB(yX36)Jd6r{@z;Lr?W_k-jD~U)X2}Kj(Ol8 z9}6N>n;rbC_Li%EcF-C^zG6;1Y{}ZS&G@zYmnAhwW+^-XCr7=2Jr6nl=Ab6RHNeaF z4@oYV3t7fyjq}Mb*?Fi|wK_4ctjxAHJY%IfGKO$MuFUAzsB6;o$|nm-p@?b6{T&UU z(fuK8_Um_DW=Bi)ifF@FM(Fh(u9^*QSN*`&qSLj7GFlT+pc4dm)^6nXAJeD56d(AN z3e)VMH{l3lMK9~1zLOem^AU?!d$YZ?)t^X@m>~%#L1QUyH%PONu>!;=d$H=_A)REFf8Ohqu1~Lv3eY4a! zlBr+EeDc0Y968!1U8oCThgbTsNYZT;)Cc2`39o!gh#H?s>z8g+L`_jY)|8}7d{EWD zUZU|?o92Xki#Z~CWG6-S0nr^vxi>O*4I4LcT+?Av^(|b;xqg-a?l$@t#EkZyV_CN8 zQzAd;qoIo_zMJ_jEPTdPBOK`=agfM3_@s8r_hPt(g_YL^@1!?Ko>krS@qnvWjAmus z?exk8lw%RQQA`&7Rrt2*d|dwfrEJ15jZ(6VZj%(Mw|m9bKPTUACJ%ZUc>TSHBR|s@ zh2&rBep>x4xGSk%vPzisGd?Yi{-D#c6^A9?f~z52nx@v4BK@L67QLqy2oEvcO3!w|eX7+to*fEj%L zJK6UQj5jQ9w5S*b&`AiJQHeTLhE&XQw_nm)CP~MvJ>S;aiKD2?T%kL??tTl#lUh`i+jb@=FqH;7Z=GyXGl zq3ovr@n?Tcy!()=E6W~Ag#Gh-IHY4Pl>EYnSDGgO4~3J;_t+m-y1!=^cS+!vaY6jm zI6|T0!6d7&!l)SrFVQ15s?JZp47wMzr$Fv7BtORJm{nqsTEH^rY|Kl{ikV3lBm-E;h^`O<`0YowO-;4$U$R50Ce ztW8@I%A|nnXvH*l`Nu&0FXSw7Occ?^aKY8wgB%bl=!XRG&ncjim=u-Hktp;S6(|3F z1xyDGE&5gdh+IUmr%0t?o*{W4KV-8#*iN|hjfb;#UGL$`RAGUa48v+EIjtfS5UjpQ zvAC8Kb0I-X1oiB%_zC&=vW1dCvdLF7=hA<4ddeGQ@PGvDJatdP3mFlDI#no z_-CqIK-PIFwdnZA>|v&qoA~E`%Nx3df5hI76!<*jD|^!MQr@p|B$zk%LO7R2H4Gxh zrT$8q0p8Zp;=DTQ7Sy)h6YsJa!1xOpd5=v}-Qem6!et#D;gENkhYTxSro8yfjiw7! zGhsrtgEoIZ`8)kEXBTsvY@A-&-O8~LJGUA%kZ9%_EivMkJE?7EP&OecwJW3<)rnKg zC$uCJv-C#Gb`u{v+dnqig4yP4yfwkk1@yWbS$E8vpwU|dzdd?+p&e(vUsC>-$29Uv zg7fIXcH3vBdzn!hDBn@X3MRbaUh{^%-_m3QvX_!YiBPk8$}j7F{eFI62-)bjft+-n zShDWRM1ifKRc`Sx!b4ya)h_EO1sbo{swlH-kH0OfdcPim%{aXCq@Fuh?c+Ec5Q$Ax zoQv~=eVI?p+L>8z4Q&WVBIs0Aqoi3NOHyzuBp0m+^lNUt3m$7TpQ(>-cKyl{1_@EBLVPAm(nonfMQEV358d<+ zM{US`CXwJL?G7zt1}`jurp(RO*P2N|j({wZBfsp~9Bm?o2!*WsId9lUJ`9njs@OP6 z7-{lx&kwV?t5;yrG<;y6$UZ#RfJ{I{{uh@dJ8n z@JUG%n(tJf3KVH<^nl#A0|?PFwRnp}eF|Tzq>=RtL z;%^{|m}fEXwC8@0lLC;U2?K`RB!^6j9ZyE|8k%+3+DjA1O;ZN7yVEHl;ki*zwr@8D znVQ>c^Q&;$_FCBlBfa4`Yb6Q-Ft+cXj4hbFv>p~N-6&A-!Tm6uf@e2sammkE!~Vi$ z>(mfIVYe!h_`Ho_p1QoW(K#^BYbOb|9F;&Wsy8s{C2`ibFJp1HG0BhIeaW5c3+)iD4DJW%=2Aj$jz(-~oo0==(FgL_N>F)Jxj`+tva@@ZCN8|~

Avw?eA!zixNq=*rbsB;!+HIk+UuCDsZw{rlEjOiPhz$qA>gdaZj z*Yxg1P`1o)n6r&xhgSBK?%>tY7;@brokGazCxT;xdw`y4LkpO4p-s9R?wiEHZA)iF zJYB@jkp3JPI*oY8QE>$bfWCzg==+VvJySB5@E0zNC6a{3jl8@EF)6|JVCv_!**qrH}aS7{V z%&YyD;%sx38zLB+Jd*CWOBHP5jWsmgAv;Ogz;Dh`9YyNe=6k+YDuHXwXGL+Nt~^eF z?6NFpE)d9b=d6Ssc>STH^26iuG8*@vuIgUB`%_!|J~rt3cs1{a$3gPKYcMeR(=jx# z>5PE6lsZz3Nz^Qg$qu7+CaQPUhoz);ZO+Akv0UenQ*Mh|{3wcK^{l)a(1WvzsUrg; zHqQ<^I{#LBUso|-JB0r>b3CXXzcg`=%XHV4&7_hQW$c>WDrpk1ne6F@dSk(hM*Igk zW>Y0OjO;D-N4goAi5%wOndjrh3ky-DQndOh%Q}! zc$6ccJ+g}IAy0ea)avaE($#0kC z-#~)vFN`A}JF~_i(=?!!`;?!xyREm@plLaze1f3SHHH4TEB<}%RPH*`k2{NnRdns9 z6!{YRQ_nk98jaegW;<>a8I_~_H_zY1SyQ}}!y2XK2qylNQHNDHSM!ZrGwV|X8rP3f zb&G^ zPM_}o$fOa>cY5Gpw{JiCf;a-Z2$y{yWL+BeQ7|a&j1qwsfmn50`^wZq1^8WiZr)(3 zS5X!H?RQlP{`4h|aMrW}qNvU`XM-elaz@?U3l&d*-35c>GXfMrRNR`ZQtP#|6v_lH zl&?p%(o%1mD&zTOQiX6cwzYDGhv1*PR2nTs>wLa<-5Tp)nXJ&5Ssnl3C8*WLHer9l z?TXk_!+Km8FA~7!QMon}za-V8fD4NpnsN0q0{n2t&}kY#oAf!=+)Idd4yL9cR~lE` z^ZYKyf^W>q%o`ZhSIIWR{#i1LS4oA)awrwm`U8I@RkI8U(KeARjv`%^S%IgrDoV_L z4NvbK&GMmF-TCd%7!P$#Fx~q%Eupc{J!@flH^|ZUK^}xO(skQNc*Ar6;ksqv{BJM* z^oI9;(bw&d)k64&hu)refhm7&%OpQx>1<9&S(nXXB{yhorT1xm87ADrSHjQy_~^ZV zjM>IpJi7Xo7j-{9^CF|2G>wn-DHIK-`5x_zCzr`xxdv$Gz%sI5$8&7l6O*dy2$m%z zVT`-Td2#NhQPZN71(yYb728h-ieNbR%pKh&nrnEQIj0#*9amTBIIH`b5eB~TEvL6q zAXX=&YDL^0*x$qEvzdQ0$C-P$UqN~o(fRZO9Bl)l>3axxGx8ktFuAZ*5<8-y?ElXG z$@64e#KA;>sb*VrvLOl+!~K!rW+qpX9TMv~@5?$|xSS+nA-rLB#l81o(I+unXBQ*A zU9jk=^ssgHgpq%zZleE&89yMoa~$OTm}|-c`nDo|89;#@^g&j|tm6MyX=BU2+LmpR z#QaO&*a8opUMA21UQ`~Azt&yOVj0l=xcT?B@N>h{ccB~c0l>Q8-N%?}9-|ZUyUP!B z#UQFvW>K@=g3$=L4>?M=<_ioTqlK(N3StJTjAmgpiEpMAcLC^NPiw^)Pf{kTpjfqD0y;!iBFKZ$P4O04EYz}I5k)zbd$vwdG-4NUiaT_=x+Mqa4Hx`$> zKXk9HbN<%%NFUfLA`CMF9@iE@2l@m0kW)~85IDmZwgZhNE0Qr2yQHj7#bt!+G)Jdh z0r=$SdS^}h;e-_ISxXLgPW4c>-&Nqx=bTocH;)v{kV#dxH*6owd2&G%K0SL%s;Y#p z5_`jnnPqgQvbN5#f4rFgpKVxe;Q63IoJ!1e%7*X8ipGNux$_m^1iv_PkNXHF@=NMJ z#{j4$uz2iZNj+1|V-H2OENd^I0ut$d-Z`Ow4L|di7Gt+hA4tn1D{BCieu+pG2ddr)eJ^P^RW!`q>TK6r7>VU}dcaOpn{=5~$ z-9PXZvS+ICEadLZ#T`$9|+^KdAIU4^0*BJO_Qk(@>x7{84nH zxR=aVl;x^zekX;MNqnUinJTdg`Ylcpuc7Z@%>!bOH^SOZi}z&>`@w9Z^8 zNV(o|FZUV!RGhTB5E{8$<1E+9Q7_q`+0*AqF@xJPkn|!Psn#f#;_IULW8(dnEuf-1 zYZQ@_H!hG(<{}d9}%W#+WmtzPwwv|%`PUL2M~&G*txdDE!)M4fSciF zHCBo}S9e7zNRVK8fA%MF25&@)iJq+E$cU&Bvc;ZKSGAY8LVK=Sq@Q;JeNM7K`Monr z!h-4oM}S98C=nV*RPp!35T}mzy9^}$8#o;&E6PFxF~+1}%8mwSx|_E@dy9lK)~Hki zelgf>{`)5FJB)eC%yEz`FA2LWd%LTk%S1@fqX}e23M&)3F*rwAlK+}`GkGGng;=`` z0&Xnr+Q~c4X&KuB9xasCb&43MDcrvizeEq2NO_5pPu?y(q^tO_Ax2Vzjl?BeS@0O} z$dSSC?O6e6`My86)l8n>9nzZp#MluF?4YQb|ko=G}g+r0A%caYZ0~qwa=qB(c&+Vl_2^V#8O?@CC zzcFYZP&B@$s!hWGlJSL*^SR*us4XT1YZRn#6b-Yzn|d;Y66=X4={#N4#<4-NNY!t# zhKftXW_$eD1|{9xT1FB<5usl@Wzo#_xYI6bam_Hq_%`jE%O9Ph*4eI?HsIB_aOApT zK(2}-d8J15%f@09EDM7FEh2AawOKqC)ELp7p4C_`+pAQZueLptR@_W#`dHnQ+2)xNo%OfTJt2`e*R;rqAoV#(>4FrWXE zFoGZWG|Dfl+v2ur4^n?FxKCby$pXwEnSxG=K2gr#`dYXUj)v1V+)22-acs~ysGZD0 zK+Kzf>bs5Hbhp6Nw)O;44UadE^*j#h41P2d_%H>Xl>^R0lQu@tGHpmS78a+;A-S^V zIHT;B8~2mKaiQlghz7Mo{y*<-VmB=N^v>0hWK0OjN=H0>EEXNv#cM{fv#P4SE5Si^ z?_Ob3kahV?^f=0e9LEi3ck}vkv%)C#uIt9jJQF}|aC|cNvQp|M|3eFHOT#yyIZb}` z@SEGubHJn13SP&c;DQ2PH`xe!(~VbkcfNM_cmv8x)y0WiR%Qx9P`&JWJ*qv3u#uRRT? z9RnwiPZ%BNL<)$n=g-Dt5eKt*_Q|`yYDeR8M@kDg*CFIdBnAf8; z?9UtwFWFYD>6rf@<$l;5;FWcqmbDYN3<~G&+J`t%eE0g=52dH-Q993%jlH?KfOj?< zoD{|y2Dgar{OOfpzT!Tiu9CwCt*+Ux=kb! z0~^*j^lzv(r=mql;>gy7 zmCOB@M9 zBOU11IXEY@{HB5|t^_oqH|9OI%FOods9lvHM-yI_WxBoT6IbU8*_lI2;RyR2*51`P zJ`z}rrZ~bEm|r=`9rpa-thrvRPvne+`{-1vrH%JJn4I9QPqs3@p<&3BIEHh3n%_~y z6`aB03j~RmZfsoBLI(JttW5i}#ES}oaMHP9AMD%c+tFKZ(YHDV%qDvl3&ZEP*Nk_^ zr+G@wP!?f&;|xSL{!%p?!t{_v(F*_Y;9e!i3C%2MOA!f_WcGRj&+qSdxozSwH`GHCi()b)K}a!&k-x)3~QkgjTB%YT~58qr6`AXHMZoEmm20JHW5s=%c8N5g62#*ZS<<;n_ zhM-afZIpf#zR>(L#UDwuS94hhP^-+&9E|dHgxF;s^DfZqITlbilD}dlLEOg`pC-@% zPz=sfEqOwP!+91NHlwkwtlBd&XOFG%#&AdUnJjlIZOybv|M0g`9B?VEt&Y#?Lq;vU zy|j?zrrZ>IKOr9~=LR8_mA2@qV5VaFnV8csOnNU2K$;}4m-Yg)qzWNe<1tg4s-h1k zGfB?5%ejO2Nmd>Qj($Dc*Z1TlaWae^Bv$L4_-Z}h2gb(;gbP|HR9ka25uqARRk*LP z03&e6m|7W;k<2hVr3%HYJbIDC)ENe;uWhl`O)c`N<9wrzw|-GJxDPK?new#TT=rVM z&;R5%#C(+HJ!uXeKPx=Y1cnFBpv#_CltRNpQF7-A3HQk z9vC;`lJyhz6wsAsCd;~G&1$^;h^)y~;e#GXBJu(`WY)n=DkbPf7A~0_wm~8b5lE8t zAPD33%JIaS%tXM~VJ|5ngUx&+!nduI>9ghEC3wW$29U#Rd zh_Y#K{BK3)j-x*;qR}DOMrI@;NtsqK)j2v~<};7!J+>77nEwfiK={kk88jb&oFhd+*>qEC(%RuZfV1- zArg`yyB<3XHmmMy)8LKPPEm1NHhM}aSWRzHqX8Mv<87@S5tG9qGNHn~aBG6o1Icv) zZp2KAKK;ssIFK9nkiQ;z|5|I0mDHlkTbPpfhvm=__4J3OC9)w83Z!srs+Q$Qqbp81 z|BRBM0LrLCO)P=d4YANkCx=;lv%MAlPA0NWvdpR|WamvFm{O){eYkZ{Bzjo#)L20S zhO>goD!Y7Vj4XG9o~qH)!8JSk2=tbVXW^)J(RI7X6TXH!-(euE#7xB7wC9q5QZ(JY zL~=L#b{d8yJV;-1Jci9s*MnXA32p7cG!6bWc*rueRy`!B?b;rNRWP<=ES8D_Xw!uTjJAE>dWg7F zFg)E(_HW-FK$XULd-S5yMt4jxuHasdBXQ@52mC&9j67SFXuTv>pIjMs2$24+)q&~2 z=b1ZOQ+9aOp-^_n`eDU;1T_zy7`><8q!}~h3VT^EA|BdAH`q;V??dfX+lZmMNm_w5 zi{-FC_{u{8^fr|5*u6whN#hW+yir_7Gb2|=C~Ad&@94j4V3Cxc2!~8R1jx9L428g6 z;p=#6U6K=$?FtUZ4!N7?nuZYPmo^ZQzZ!AVom`TGpikz-e{q<-PN*& zrQ@_ZIvs>!qVCC{DFn3e&A{J@CTt!iK(J^X#U0TUi7NC_+dwPOLxqq&fDQSJ*0{n# z7u%m}n>zh8h(zVkqW+EI$rMWJYv;|_clC?j)0*eryfCMI^@m>K+H9M&zjerr;9V{b zYihM)lMLl!w18@g(r;x_dGZP)o}!78i4zP=$!hy$vuP==_1aIth3YZ;DQ~CAr~+o3 z$RK5Vx7GrN!J|*$ou2Ilx$b+=RckGRA7QT099}XXZ)qA3P4m-LKpRhp=S19*;e_y} zdHHG!XhWnh7aiVUmGJ`ixH*iKfhtpQ?GNcZ*_~7da2<0}8>3ert~i81%FH1u_cYU& zt^O!UKRy(*5>uJ~OM zW8ijLcD2jDx>q1VTTeEqY&X52ZM9;B*xK^)0V?}EUWGUVikDy)3;J>*(Z$3bTONdF zXU_{#p&9Vef9-r`Sst6!PR!=vI*y_1G`^6xV z)~<8F<{qYB70nX|_j>kx@s{ulznOanrPI6bo!oEQ@i%>NM+u*-&ZMN$!SC&JUr^JP zfgPQj?Cy+^MTH1et#-v?jj6E!)zRqrr?HP6Q30fb4adpVc#6_j8*M$RJ&6~M&p(f8 zJU6?sx;Rd`C>WM9Kbwq}6@Li3d-X#Um2L+1`MNt{u7&cZSq{LwxwbscUJLiwt}OPg zl}HThL#F3Av3!(|t~Jr8f!+yN!0_DHeFda~1uO1WJ~^3I12BHs_m~UTE)UWj8CXiX z(I$Q3BJs&Da}hW%36`TlL+2%7((s4}Ib{wBwLUq*ZwYSNM5Gmds zJqfWjuGg0s4nSQO)%kbqYpvyz?tp0YZE%=zZwDLa*v|im&P;*z`Gjp1Ih|A{OZy{7 z4?&irL26|f!pdIA7Z!CT#r8t`m%YH|B`rGZGFFk0+RpZyGu}GV@G+cg_=L6RS?o5W zm5Z+*FFjL89Ygrhg@yORX`o04Ee2NgukmV*PX7Ug9SW8`xS_#YIuSO)jydMTAdGR$ zYDzp$InMfcOAy4{=R*m)WrJmRiCw9R7ziu(wdGmSIO8uYcVN)K_jQ~GeW(8cK8V`y zp@Y(ohexiD0(IVi1yKC~4;5p?L&}!U^HD8I)yin^4_PP*m0(!?N);0w9VQVtpbo_G-iDE?nb7HHTw%&WqkpE&f}nn=$tMwdB-6U$P_pGIW&j{}ofJcRx=G z7K$VqF;ymB`^1|+cLwHBoj{wiY(DmurdicS!Jdf<{j6lRFrO|LIs{vpEULXjAk=zQKn)8QMCX^Vd2V{hYdW&1|SDN`DOv=`Un2Wc>E#=O+fHrN{ zraM0H#wQ{?Xrxdb3?eAv1u-#?lchqrt1su3dZJ)q7}Ss7HAti$Yll2PVC88u5I7Kp ztFbVUDcWL88nYldfs)-h7+P*Wu6iz?%UrZCL&@p=8`R33c8<#;nEkIcpsr%Y11LsH zMZXVhmbiJ#)K(N{uo9v=1(@UR$t=(o0{Jlb#ZMYNYQ|k!|CJJI2>v_GWb2$EOn}^O3;d~TH`iMDR*_MBD|noawNp_8Qp!!qd5T-UWA`)~aB_;%OAK&Q{)r-{~*!-Rn}Q<{wE2(l}Dl8c0RH00pIGC!@b`9H{f zyjif5|NqGPs(?Dvp4({)rFe0N;%wZD)8Y=ro#HOVeGl$d+}+(Bio3gR+})kS3{3kS z{u{1C_Ii_*WF^m<@=fM~yU%Spz#qYBEO+J1T?T9_;U^W2Dv(kXq5Z%R<&Q)9d|A z=wG@^CdU|D(q)F?oRtH4^CAS#KpHPly?o2AZe%t_M;1RT9N_tmDx5T6Kg}fWY?enP zoM8wgm?&OyLvlrp)1)6tZHNM5mI)vW!PB6lO!7%`%_B0;lx05>Jc-8fc74}eC&1TL zf5+v@Z|E5KI%En_*_elkI`0tP-k&S-6%j+ogTBxucwyw$76n9d3%_ZYqcC}zpyV)@ zZrh~Rz?K&2Y+_Ux*et_fCDS&2WZ=1fNUHgJ*2M3_e;WkyT42N{%nx@42pby9Deu>b z=n$7nrQgj5l;wIzG{#)GQRkPb?k##-cA5@y!(S(m)WEVG6C03k>mAn}Yg9QloEZ~G z1_IPj2E(u2_7O9=^4_ZS2e?n7?{&XcEYJ+lfYF%#YXr*q(i-|t-V(ve!n zTw>_#XA9{?U8ZA5T_V3n@uaOMJG8QSyZ+b-v-^(_QA_wJPZ*K>yZpJz2SR?(a!Hh8*oH@hap*u4=(A@XU(0rRnkq{0)%8>!^eeX52z z8GlKy$5kfgp;=1`B%}%IHe@GNeBe~G&hQi6>tueT>ti$qUkN$#75aMI>9IX7IWg&m zjaf8q7$lc{`r+zIL^LGe$lc7`gsnW6Fvq#*a690yc|E6N1cWGVFzWT*9A{GyO>la5KxX1~)T3+9Uwd;Z zc;9)XWC)x2IPq)yp7Y*flhHrtCG65eH#f(*D@on+tYLXcIaYVNw4?zPCg31KAZl)t zpsa32+S3RX^Lh4O?IYZY;T;th;{7pG=9njVH?7sGd;*FS0PY&qXKe~ZMYw$cUk>IB)(>Xm2>5Rz(la003NNLT@?hwDN54keYDpbh<7t61s1R!`; zK_Cm**W_x_$94-g=9GqWsSWQ3iR&}lv=zxp(93kQ1lP?@L)-Z-kcTF1QUcYVt#8TNi$`_*(5h8e zLCla;L#Ra@G&Bv$1-lnJd+a6QC%&}%z;d8P8zZzZ0BLD!xMLw_PJVAK?C+D@e&{&Q zK!FJ)!R=(G^9k|xNTv3OX=yPPA4&M?H(+vDe4%OdynXh^`;u*ip7OIhiz))Dko_Eo zNlAHjb+xC?)+A*%X;Td#2g;`*7*iD_8M|z}qM@$gOzkpPc0mY{<`9Ymg{FX%z5=@| z;79Evo>;%#?aPPzUSd?gvdG=+I_%1qua~<<;o@6|TKg5ve*HRSSjoGR^se`g&_tu) z1nO(>G+d_LGIu$jIq0@$+T4or?kYOOk3@5a`I-cLWG*^ehyo~krl^|M7}HlZ=h!`y zpDX-1g?}zgY(8C>C*tEOY}Cd$b&LZx|0)4ffZdC zs)&brLoyWJYEk0vF6LLMmm}jy2VELsNUN4hJV)dQ19H67_70<^(j;fWq2h#yV#D3D z%{2-0+C;?35p_?mqr7XU8wvZZx)1c8bsK-e4#RCWKAW&*?=fL^(_7VM*t@xEqDaQ# z#*#L`jHpR?Wclx~6E2IRk@~?S+P5G%>e5iJ!l2Ju`Mp{O6jNV{0L?&oR`9dyNr14s zlY@EL>kOG`U3Y}tNiL2zR)IfLW1@A(tX5(7G98g9nQ#M=$ki`IoBZC@SV4151&!*iYBurD|=V_|4Za=_&lVDH)WNbW1ij-xyQQ`x%s=1ojRDuY5TQm6g9Ga4INlmz3^4ua#QC@DWvJ-g zdbC-BiL1({gvNs5gdpIp#4N7c>tx)W+xAe@KfiponszquJBy|tPzw%^d}h+kl%)Zj zM4LT4jg;|@=ei6(-$y;75L$5`1)nTMV&rtBS5iP=vX z%q3Q32UmPRsrn$e{5E$d3`fAz^yMP!21u4;2U7Q5rcP`I0jr4A^WXpAQ^ghw6XwPX-HBbynqM9^Pyw$t3^vPr8kukglf4LD!FS z;StrlL44afBhWJ}9wC+ugLJa3XF$Dk(V4{%zlCn?v6{4QsHE^noLmiW&WK9(;7FQG zbzWwoOvYtG%difIk#Xs~U4#!3aGcx8|EtAd&l=)5&&w8R?BaKT zP<8{DE+@#}A%FQhiw1v}6>?Hyj+OnuZXNTpEdDTc9OL7<3S`)$r?ZFAHy!mD98@ru|j(sqIRj zNa>5&ql^jb(_?Ty3~dYS1OE+t1ev3SH2qZj#AwM{+K3}f{p}eTE7JFSipV(kAsmEU zT`v)ne=Jw6P{y&8iS^>DU#7i%qFc5l+)j8pS~q;0*O|LeNI}O4ob~56XrZLiQrdAE z4+CxjV~wuPc0O#5%4(Km5|?9GBx5~7wI`=z3-9hhQEG7W3Z=-iNplbRHEwWP!BWvR z&@O=f6kj!FBv6BtryK-7ZD(S=NXRJ)&R@|{SuGDdBA#-Rr!N^ePWB-qt(R=QVr^ctP=ei zngR?HCS3BKc$6wdzcA71s^88hp`Hd_X%IDdLR;ER9LpgY4Nn*N^u|vR#*yJ+BCj@? z)jV@#Ox06I7I*Zj3f-a+G-iWOID{f@I(HHpS7jzfHjka9X;mcalu-hD@xShBVzp-p zD*EnK-WYa#%{k+=2F?uLEX`w}av1K-#yH6(V8eIS3-k%4K|`OjVbpGOJ@4!O@jZgU zFR40Gke`4>iD>QMZQ3mG(6jfz*jOHgrg3!VvZ?;5C#oI)_meamu(4Y0IMD{-Q77^3 zo@u$t>6+W}iXU}s;qKzi4wqa zBX+r$ZBb`%X6eEB`m2o)TahPZME14#z9%I4^t3u|24fz_uT)j7?!&LY#+cQgx{5F5 zNitWZ%1L>D(tO%o{~0fK zY(6=4J9)QLZGVl3J3-j!WedsW(x*V{xBONO-L2P=cSwz0M^MI%J#Ea|CO3v7l#ZDjR$Ku)Lttg^l zf8r_SK|UBrUU$J#X$q6=Jm=b;NWY_VXRWt-ppc7Lk)ii8BsK4ZNka}(nmrzy%W?-z z-$ImUR7Nf?mfA!}w!VKKo;v46QNj6sm4@)?NzH}NZ_#y-1ywO7z?M&;nt04;C_Qml z`YzQ?G|$H}{1N+}<+Xq8UmjN;k=!iImZkS6;i*)1VLT@|!`bdncT8lAgLryQ>)UVO z_bgBqgSZ=;>2>!Zr8h4>ep{VPt%#i7u$(Qhc5XR0{jaT{0qYQDFJecSSl<(O@{#os z<>v^1E6M2MiKk_(<3%HNmL7~f-R?hFY z+ch=Doo|B`1@>Xzi_bkTp)((7phn(oyyj`G9@Wn1UYn&>&L;DFYs0Z0yKn>;>|n%eCkJSFgi?(rpL+6Crz zz!YD&VaD9iQ*td*Z`T(VE1{K|J^?@e*1NyWf?SZgOcO$kKGSFAnGq z#t^HyZa#(x&(;}i=?LhqZQO@TimsLRlV@X)fu)xV_r_%5Fj*_kUd1F|wHVvgcS13* z%$nEJ7`!VNe4fPoDa%7399k<*ino&BIUGE&qKrj+SI7Ac4&(*O6Vq#>FY24hI3P1gd_n}}68w&iFEqGXW8=zLxEzRZQYSVmucc8__uBy?=lR!Q zhZ}v^R*k7@yS64Q>Hv0}6LMhTP13W%@PE32d0_ME@9?k?jROSlkQE>%mB4Bwr$^xy za9t8?Tunmxo;DI*YD0UvOhv45{js3RZm-qt<&U40*&Ntln|gMS?gl&+KK1|Gka{>g zEP%1P!!JkK-H0YX?iaQ#|q`a$?;_HF2a zNQLwc0V3S)OR~M*Pjwu$xo2xI^8W){zI@T`(w5$3I#^hCOC1<-4B1uaS23o-9pBfd5Wdg|x_m zlRF;O&Bxo2VQU4U3#RKo^hBGm`YCt~aA|xhe>{J+$mc&53gT0{9(b>vtKO~u3U!VU zQpU?#K6vuI#*akFknd7#qlvWcC%#=Vdu>T-EX>U&%u+J#mS)SVMo=t)oGXF(cuI)# zcu9A+#&0%wOIU81SAH0ID4;h-Ja0_@4_8=smwD3YTA~poM2@kdC&AqEpGPy$g8$Tp zvT}NB=kN5pv_-0Jz_+U&Q_XV<{$3k7KaCv^k>|)}ThQ;p2~&x1jC%FL6*7|(-;?E& zJ0T%~S->Kp&~)dqf~!7O);u-A(Yf&&c|V&=-<;`w3JAzFqu5_6h~7y6%;V0pN&9);$aT4*!P}3gak1XpH|DF45dSjdp0eqo6N_O)P0d-w z1E<+esj?O4-Zd;XEmiN(NA-hcikF5CRkXIzJ*8`Uy7LjybsxZ`Pw$;u67S#L-MTZ# zZsZRa+^JiMA>jAL;oQi`baBVNWL~2C^aJL>uEM9`#A_Ui8yDz{&(%4fB~>Rn-30q=<^ z37P^>K^EE-%xOf4bEGEsgdXdY;a>Phudbjj< z(3~EDAX_ofxfoSOarget;Hd$n#EH&ooNem9tZ4WU;oaKg1h~)F0eKN3w1|z^99-VH z%$i}cw_#WD$pehSep8CvI*7)n5Ju+L3%$#$=9zZ52tWj7TQpQ_8-drupEfa`3!&xf zVhoT*4lPP*ynj<}W3gU*;(LxABkL-xD8@e@Ekg7g<(ffrcU;94cgMSwI@wbik{oTMZr0yB)?T<>XhI9w_ci%RM)+blNerMVx&c3yXAc#{u znje5Jy3w0v@H6mT;i!j**Cfp$I;9>_vvMV~8i^k<53knc3l`<_Nk~OWT4XR?gIPc7 zZ;FZE@JZPCF-MWH23ni%h}Oh+uqAM2K)P5J|3LqkQEVvvC1BcFllWB8Q(*=#R}o>! z3AY?r0TsFoHVdiu%cI#iyb;7#I$06$yO0i7;beu7Sxx7~ZMg3#=y1e?ur}jP9(e>R zFJ=3K$K7l4H*}|5e5xPLYDB(*06c0S`CqS-RZIGpFO!kvV#gCs1B49(Cy|%(YK%(w zHYTBX%!4xXPX9cF@lqO6=b`aWY10q+!Zsa+qRFSu4yd~p3`Kkkp2?TD+6iHK`D%6g z(Alo8#qto0xpi#@Snek>r49Oovg#{<*;WKbAJf+CizY+Ykl8zF9HuO3@cXu{RaA=F zvcFpoO#AH0Z;QT#UT1DxM8Syf!CqZebighqx6$nQ<%E#8%Ov@l-p9bMV~Q0$P;a+f zcG-vifHr@03#l;Wg%)fxb?AI&NecG6pTj6~c{v@`ts`Yk-&7x#76I(a`?&}_!5btIIRvyM>ZJ`gIujzGdexTWv()P_CB)GL!4*9k{uZa(f= z{Je#gy12sRw}c#}bM~$0rhqFrrJhM$w>#DqBA`VrY$GNtrE@rHX)|8ty%%@M?ZPDC zYvk|HT1w)#%k^w&z4R*fYsscPd5Jo)ElTAVndf#xfS?Y*HJb;mCa6yVGfIV@vybKnR-NRP!s^BHJMM{O8}B++ zV8LPpaf8!&bfVEG65YZWi~dY_%-2 zDYAc%ZdPwkT>>OMynBfv&XQ+tezya&CML6H1qOz>KZidX?#!N>Sn4r(ZWe==CI0DIRE0LN1Ch3?Gw;fmj4YC)QEn%>~hm&a}v zEs(w}a~NCXo-2!Y@ZZjUTRVIRT&-j7&Q$OdY3|}DTKGK8zB89{R1f5s4buDGuc4n2 zWu0i1 zigi2>@yAv)6?WP0;+!5y^T#h;oh^?YS3t^vqeNHD! zJ6NQ_X#B_i!0UFPlmmX#MevUj{g}+I#t=ehu+zOak;{@dw}~9O95ZGD(!Zg<9rp8J z2S2drr!t{PYQ-?O`-pG4X`Z|squtN;dRQZ4xPPJlc;5gBJGa_C-F%V_d!O(89M|xt zEaA|!AE>i71SV7eSWaI7AuGUfwQU$&vR~IU-DXHDHBTp@P~@Bkj&{`FSMSX+Z@_bB zlh6f7gJ~xXoV(vi3=DJOILD#G5x_j%%9S#C`#;EkfKOCP)2I*t;Z-#{iyV5q7tKQ7 zLK$i)%TSEjyH6ghyFwBKL6Ab-9H_BdmA*1lBsGlwiBYt0TUhWWz0pIJcej?R<-6v&=<9j^&|AcBeCd;To7|X9 zm%Bu)Mw_}JY11smqk1|9qNW6+9bw1hG zGNTPKbi5hgEr7M(4oMeP;%9{Y%CTt}Qg@EE{L6Q_zx(*%zG_$s3sioF$ov1s1bWS z00R(zPu(hq>J@Tu(HrCsG8SqCyDW!Tx+lPfk+}nWqhN6Tmrc_{_lWSs{{$UpCsL$( z<{$2{=Sx+ibz+YW*%i%)`8$}O-+El8t6j;y%-)9Q0Ato*f!VeVcU>VaK$NtQ2Ak>V z`uwM^2yu>Qbni@OjZv%YDn26@R%kN{R`2y+=~P_nP7X4j;{}=1++WLvfqm=uS0TQz zAl6CjA%iRW9D-%3AT~OVbeiWUc>Yva9uEV&L5LJWqgq#=#u!4-SdIN?QdTVT$rTAz&4{pEvEiSP`EY0_pB9PK zxod43Tu2~=1})cwYc{=kHwbXDHB2_gM3ZC%*azV2t!;;U?f5<7&9C$Vdhe&GB`qj` zqQ`Y{!($|}#0q;(OS+-?1`$eGG$#FQH7fYjg`W%M1w}1ileKG(rUITIma3d~faVcH zV#+DuHs!%2_0RC(h>I=F8Y?w|Hx*zKdckIyI_owlSMG(@;GC>ieanXY*^I4m4=v2oHf zP8MbGg(d|bl7G239SRHzTay+SO#i@5IhE$A;BW{4C20uwx=8Tr_(wnN%e}@u7r5-o zaKQk#=gS)X@Ewl2B+ZR^-wu@gm3n|OWj1Nr}EjH-w0}BGRS++|7;;$*h5pld(sS@46rewO zp|ZI0q)!Vt(&p1L9b*Xw%@JEVxUpZ_O~@cMccT|Cnm?U^Rbs>b47ZDr-IGH{p6ywx zs*Lg|Q%2oJPJE_F2)0di!|>MZweQuH-flDkprg+Cp#oPn&R8=bnFgK}n#ndXNL&+!Te&Memsx@>>0k~|lQTU-BJPDM)Eyekj`2L0St0H3 zTkVTWRD=0k+er)6;XE;&&HWwMy|_Gwuw>eHUR1aQ#@p=W!b(i#^DJ0SsDr>%|5TzJ zc(96{TpWd@tOm*O7Nd{1QVY#|l1Cfm!C&DeKD>%KNjheuB`YU6zEbpK0=Ss!H(Ch1 zJj+8)0dMKw@0+MvE&Vq*+?I{_I{}b$<0O(f8u0gKVC@n74B)PAZ98{^vA(99F#RRu zJYef1Ykk<|Kr5#vN?!t8ZqlgaenLXN7e`i;lX+>Em+#8n6uf-JfaeXo z{j1M+A$aRxg(Pg~r9|Q67T4%{SHDVJ8T`oCXS1&Uoz`Ld6VrS>WjvW+2T0Dpp0A$M zFKo~G?u6RCXT0f3HIzpuD_sP~!#AAYN0@Agv9lc*MG44RZ;uSZTpqblyk|;$1M)BsWwy-C51@;&#=R4$q#lkdn$Xufin?kZKqN7 zT~$Hwmvj(jGDORK)JJO(PgCb@(Ds)6|| zVSvVL8YaZQKU^Ri!3OHRTI$SSREcmu)dfUGY{X}4?9xQ%@&M+tI(mUG=@!nRIat)k zm!bZ#d^i%A5E*-nqckFBHTpC(_g};eyaYH9udek`lUC64YBRV+J0~-QR`=f-E|zG= z`KOeUHLRMy)2WlL6p-12!#0KzVA8-~2Fldo%{R!c^}lAxP&WLXu0KZgCy-|#%O+hr zUFtw2Mf0U)-a(H)aAu@?p7tqTSh6q}lO^>43zz-^knJoWaf*aIX)Tsm0H<6A&Ld~{ z*yP|DJu(12&v`@QzN5!CC}$js5z zNp<mmhOKn`C(6&#oY|z!I+<9C?%2a8`Z@A z?4k|d9NHY=76BE?HgDQXzOt5d{a|6{m#whcnJ9Joxdg$8A!+_+#=Co^zNZ4Qif6{! zgL~my2!qgpwD@f^gWG-TR*GZ-dCRD##Qr1?WBOGEw`I6VJdUVzs~OS$bT^Nz%O&n? z_kF8T#JT?H^Sf*RBT|#yMT%}+Yl~Hz7~2P~nQ>&(T|P1o?#!7L#1n&HK?;95Q&pH6 zO{-XgsWrMkFQd8ZV~7oTNj;>wrcS}$lYuzK+E3Y4>mG@KTH^R z$i3llc|Bu16uPYHqWxig5iA#JWHO4fqJ$XK9yCk>UPABj`sJiMuK-{Cfx*Dox2yt}X~|Gg_^)6q~b%<^Hoc4)d-JbypM)w*ymh$;;EWejmP zL<>oxU`j1|0$M}Bh0uhv8#3#fdejpBM;3b*%k$V!-j++W{P1O|Fu9<{^j4F8>O*6Q z6OcOd4PwOqp12dT{Id_wiu`+C17QGxj=(qio8fDlW|?lSi8gyB#&1wfDEgm4_nfcu zZ4749Rv7}b!Da0AQvF@0>!pEFyeNtz9>CABC|9dx^Y_%81ckW?C9wM2ht`I}_sPf$ zc_@!kqYT3|_XrNqdM$tJHEldsw}8j`^ap-w#?TB z_*IZSevOc41YMF}B-q*qg0-9`ugoK%+zSzeI3VYuQN@Ll^JKxq2WbvQ< zTI%9+ zIzDS5HpS{u;%|C-qKF&-3HEneX;t22f~dJ5xKqvT5c6*)KCYpzzmEO+=NZw{6|QW$ za5f_eH#Bh*>+&i+ov?hteGZ)|f`DN|?F=^ixh=>D3nod2Gqh3P+XPtME=nF@`}pj8 z;gJTu=6s^OT|>wbij$)uV~js4ciPCh;6n0p3bH*Z5}zF^E)-q`(FD#CPO@f*o5_1 zIFqV&ta)kXL@uZtY2 zi8>CsMEdG~dk|lC6u+CQvvmRo)pk6J&D(cw^8<>7cSc(P+~Rqp6U1ca+EK^i?aQY3%Vf`C(CFy?e+Uz9g^HNaLp^wCZ%3O5(eC- zCwAQ0H^Ji6s!aH8P2lIr_Z7T+w0R3YhCdfcB&YZ-0um2JTV)ZV+)kD=n0&FmmRPdA&-q)2E z()-Z20Uh6gY{6blut*8{!f7sj!v}an+r$=V^Td_e)z<)(5zU@vLq;(sVocX$59(6c>9xf$?9F*qoKT5f85BS@d06q97nhE^m{Y- z4pZpOPsO9p5l<5=JKP#z8^&^>6M>@!&aP|D#4(6ImJA~>tRMuN zg!d4vceAVLgF{dVdAkI@o}@46;eGnw>CZ3w{vkS~v*6|oD;APRr95qhq50#V5_qr% zC|#<#>bklI^H5H3em66bxs@&6HaBsU;LV-KA|GmEQ)8J5f485PA>6F``tywY>cx6` zMc)u&@Akr#(vT5I4Vgjy+v;TyIR`;AWxUSleixCH9xE(wg}j%5Bavjd7-m6s>nneBSA?k~PCxGYRwOY9{DZTc~(Xv6uQC03V zfBnf~YEi}nOEn%KhkTgN_OB3#Dzv9yN80!zwWaaUzIfqB`#V*U@0=sD3c!-3^Te~U zvIf&I11kCKgZJdVP0-M&8am{>Xamm%XPE*zHC5r)B~Crgaz1Do8J~-gn;_0T8E;DM zDNZOcWq=?D@t)PKOxCN9D^TyR(@SzEs2PL9;yO*2XeyeGNR%WfPxs&~+Crp0?wkZc zxHf=L$6(m9sMu@cZ$`_LBvzptFqTu+#(RNR%*L^)1A6928|mj;75x;8yuV@Nd7}N) zn2mb$VU9=sV=?xO-f@Sm94Z!}l&3L*5k5S(apdDE+mkhTJe zgOT_t&w|gHBpWpO^sf~d3Q#(u*&$UU`A4_^MlDAp6yy$_ zLe0^7_#y3)z^k?9`xtQq5Jd=)kld3cHfiPYN#4b^wa$n6)3vw&UV z+%%72RgJo@DWQs*m43(^JeBlUzR1>o;R6^NPdMllu2vHtUp*GSnd0ri|F5QYaC^M} zHnACoA)%)!cpzj29TGk}!;zhNr1{Q-8zN!T{J*Zk-R1ULDMNNMj6;wLnbQRdD+S2b zXocYLo75uuRLs(O#{P>Qc~%KcB%p<+5zRW3n7C8k?vpJQ6>V`|iVBf_rVvY@_V~zhn|8SqRGFG}I{Ooq|-x`g(rlA=_Xfu}bH^AE+Y7 zDHF*2`&*UlU$eCIZ=QusRb1G}H@EHx$?>+?FUxz&Ysm-sNyll=J zM$I&0KTR^(#qvS0Xs-B|OikC41eJJXaC5)(beKd3>2aiWZ^aWVfVMKlc;kK)72Cf= z!0%l{A0x$|X3VntCXkg850FWvNX&zK%JuVXkGV8yqcCIf4Yxj2>I~wGHEnGl2ygo- zz64kJm!M)aQJe}(R6{3*|MivQ|Ew=@hx6%0;aqAi;>U*jtH}er_;N097jt18_WK+r4E?|6pRVhH2H~+JT=7dB`hvS^quC%co1LT@}|0oPuNG z=i@vBk)q?mn%pTKuf|-Ine;;QM$R>d3^au;2-SN~W@0`{Rm&<<4?@K9>qOLfe~wDe z9hDO_q?XJ?BLGpa zwfLIhG9SskWe(^6CWUT~+$$$H8MVsIko1RXnMyetI@bInsqRAvDMboOhrQgSZ-%7aCPw*;v-UBOP z&0Zbv(mcQKRI*^LNti3kU;QG)jDzJh?$yY7#)PQfOnmKt$e2MAz5Urq+L5_tDxX`) zDC*+R+xQ>sEB!zK74G04-~H8ksz=4|CvXV@lQ;CgVwMRZblobc$kM%}tgbmH%Tzky zL)3!@^+^KA4Gy|~i}ZA$p}i9Gj1dx$$W-F*UE9tqBfTKkOXj?!rC9OOl3$WY;Mbvn zdiCh=h_I-W5#Q!jOXH%&958s;y>F;{x2RzVU1Y;stX{JPt)PV7QCtMY+2e$w+9lCC zvlS#Fb4(v7+)yOGNPe2Cr>i_K-OSWvN;yS%HiNZwLBxlO{fy0DX(sUs6Rc7jm}w#5 z`K&Jya~+cvsodBkbh006zh+*|A@6ic)A9(ZSM+u-^T_SXA)tx56%-?xA)erF<0Tg?G#O8%USs`w#4Bh0OJ4Ob50#< zi|z51U}qed8;w}IU!X{Xn0~B-Nd^PqZ%bNEtA+kSeFq{@ciLN7%EIf7Wee&s>{02$isgM!<*(qXd0_as!y_nGHX~at(#nE%fGO4j#$bip0?gZg76A< z6@sMcRW>e&2A!G=Qy=rP5{{xxZoL)Wf1S!AeMB{3Y*1_cl?1IdrQgtYTtjBQn>K84 ze)DwPn@zn8?zQXnn#l+Y0vhj6zz*O9HNGM|NY8Th1P6jidkE~GeCi{W{>>J~OF!4r zjT$`Ke$8<)rql!V_n&}nIpq#U_5Ba`HiDbeUPQ5l8#VWs{3~LLF<8toH@$OLwu|F;&bSjk6_yB>4 zdrX63Gp;;Vd^XbWWu~a~v_!a&l`+><#vuFiUYlB}s5lq9kFN0(!D41og6yL+;}i0{zn86TB9RMBq*VgHH)4kG94A z4@pQre^@1pJph@MDY%Ug?Ic+-{29E(-?B#pb8T2&+mm*LaoEutHDz=z%9~b2Eg+kw z{Bp{(f7aKXsf+7WvrcWuFUstiIR>1?E_m6D>HAkYL|-&UuP1Yah)3tafGbA~Cr6$* zQpo9tMh*ywxVRKV^r1*0Bx6VHB1T43N9q)t(eH9IEyelup2&T0vh;Vd!u% z`cOops-ywNX03AI3h3m?n0 z1K{UG-+kaFSO&t{KdHf+%M8YpKzA2?Wx`jV5{{Vbzdt=Gi@`C#mTfVm(TS5}3b^5a zH$S%$;*RtosFIj{{-J29H?0mH6MA)vospY4?)A8z_bdlmK4v8#V=ob9b@rDLdl~wC z@3`{YRlELkwl;K#cfWJ(whehS;wf;4TQQ!piN(2x`Ygk!fPysNT(>3sI306h{$=D` zR&}6ro-sq)2RIm1&|vytx$hY0YaulKlxqun%kOTg77_W_?`Fq~RP>vKJ90m;a`7{LWd;{yHN!yehL4vD<8Yt_!^X>|mor2oq>Q6N@D&h`c$`|c^ZGHOZ`y122(%RO` zki@9>L@Vduh;&1$Q`J8g@-vK4tOA4nhVOBA)d(`{biS1tcDX>xK-wpgW|RIC z530#{sBnR^#M*{Hy6se7UD-%7-ItYLFbAFZ+X}+{`1L(6KBvqj_dELXh8CX>c$x3| z)KJW_NhIYe6I@>ls>?Ag(uxeYsQ({XZy8tR{&Wu?k&u>dknZl>(p}PB(%lV$(%lWx z4bmX7mG17^(%s$9K1Y7~-28dXi`ny;S>H8l=9)pk%eXD6%d~Y4!kV~lx)SW`PqkPz zL@(w0!|pZhzplANZu(aRcza9l9yq)eCo;gZ-F3qP?MAIiAt}+YS z$o8ae$xyI|_`0inTB{BC)lAg<=Ol_=oSd(f|?oBQqm>78%{^Hr|XhN=QBz0|^7HS(lb zHQ5KAjE*Wg{0C=z#$JyvLHxT#iHbOzjYMaBiTYiHgln#U@c5DhC&}0N+Tbsr-azEA znYNnNTrQWL3Gj^&)o|k@T{#bJq-tJ0JPb#Ta1BzeRVy~HOHjB?lf{Aa$nd`p6JYBP zTDeb`nhA+Fi9TGJ!>n&j%&he$m53-!RP>T7iiPutfbDXj=&7lg0>j5!^S5a zJ#)@d2e=!O=&s8l`eC5oGZ|j9eW00Hqr%&Ng*!fenfm;sXi1pPT?Q*pXQ?Dcxy`+h zmr=hEJb-6!RNpAYa*n8C*$T^E?%CdkJ^BEibzATr`*%0bZiG-K4J(GRVcAen1PT67st5p7KCTmQrZqkgRI|Mt9LpJ-USgPOwOEte$<%s(DDpHAD7ap zA7ES=Iq10+xHqJTMt!UUHSg9>&g64CztWEK(VspD?}njT)sRulde>>d%Iad?0NA<9K;KNq)+68Mb| zZM@4UtC8|SFuum8eM=p0V!lQ={ien_p^oBy+1BAFJO=*uSkLC0q1UrW0#dD)VmeX_ew}0QPx;u>4~tR7Z`nAxFN?&6)(mu?^$z z#YoET;@NFa!{O#oN3>VQ@x8r27Jw4e2MGO84{oibn&nX=+n4Y2mcYZ`iN%@Bsn`Eo)WXpL|;NQi<=>fM?FG%WEb$0uP+c zi&3TySjBAv0VvnmUe$odbvY~JVQPN12mb>!L!?(PBL|6sIypg@FMtCk&=SoCPJ0`6 zo|N3R9m^`APaxaywDfP}z&c7r?(}9|D!N=F0zx*khLPLb%=PIq<2FCW;uAWXbN|j- zp+R49lY(y|t_r13CZfhAr{o=esWt~l+cSXMLaWuC1mL9!d)V8Hk^OT}F)X>H6(-iH zP2&~SsqfTk)0Uk~HRgP|1;>UHA>~Mpv|8$_t=TSj$_n&%ntqRehVG-2qUK8-IX{#-mP$WQq*skO4i3Ug1+f{ z0An=ML$PBv(fb&gdTUsC#_89-AIPD+*<_?_3O0+NAves{TGnCG0PFPh2$>a6-&csa z^R3JL)NhFARrRM5kSP;Twtc`|je|C`e|MT)h!Kwr&~UQTM5GUwKaN(a@IHJiy6Us@ zm5(4Y^b&lyV=ActuZ({Ve)=>}(-x6ab7^J>{FDmQ7S!$55R>4kmv$0ys(t0E)b(@j zg1&hO=vAMwYoOf+v7^{w3_uO6@~D=djtmrhvsWuuKAA}lMN>Cu>G!f?R&t+}>k)*+ zmQB~Fq=lg8y1CD>-3fkLV(+6Q-0{JwzuQw1;p)$*qlw*Gp2T~)-FsyRKu`POqQ~~{ zN;!|*=pdiog)*~{r*1YKxScOqBVP}Y^-TECQak2LBD|9D>*PlN7ygqQzGc=;c?91~ z_Qi}w7Q5UTCtdx|tr6U1BjuMHQrxbd&CecLbT_$#xF`sc#_;R(BKo{ql2qI^g$Oo3 zsoM!z!fc38qQ|@Ay~PfILH-kwRECbzg~tWF_o}0pkvD|~puOy1kKk$YVd?IUNTNpa z0mfRVSiHU(S6P#4`JWfj)0A&};r!ZBHKEvlOviFKb!n^+HYG7=xqbr|DDLQb1oOxG zT4`M3{a-O@RE|bx5qm?{`?i z>)#&HvndP9D~YN@zqKW$x74kmw{zt{PhZbV{yz8X`aa$|Fx3yrr0Z5dHjVas5lI{MZ`Nj8SK zi#4cmWXyj29&mmiQb#ajS|vWfA=^+M6IgsRTHh-OV0fZVQ!d;<<&M2?L2u!Rnt$ti zy(v-2kUw80&Ctf^Hk!W(d|J+2ZdcK2mG7%Ln*VOTmbfumn(%P=ZQquhkF{FfD(hwP z+P@pt`#@q!hTl5YAa8X==P(}d>3af^%eQcsB3^QeV(?b8=h&ylNY0m1YP-Gj*4}ud zn{elztSnYdN0$S*O1$C+@kdSl4jzg!eV>3I5G!TPWUZ~V{Oa>6th+{Wx@CS_#%RL4 z;q;!aVQIN`ovFTK^(>q>@A+FbWiy@PD|kg_gFoD& zAa^Bo0x4xA$zCMnSSAYq5yhVl(b5*jjHCLa!Y|r(^ix)=eum2xA&dw&Q^?s!;G8n_ zqx}?g9uOeCp~@FdnI#1-PPrO#-_gGe9EQmSC(#8OU$%Ql^O?ONAjOgUa%~swg-I)6 zrotCfOz>TwXS$QT`fYyWtxTu2YWgHDwyN0UqEGH46R)hr>P@^K6*bw_1X=J z0g`FCY8C~wH`|hoCny{{09>BDbU9jC!`!uQum~{=ZWBX)SmI#2CV~_R@UdtDGo~dBo*b6EW8m z*jWk!9hq;^sHwN76aF*keK-_Yb1MQ_&8v!7%SW_r;6O=QCDT8z#GyuGz4~7@)hn z*`?^L+ww5#i*khOv>@9OzC=yU0=K-)vDtoLD`;ccIqw-V`MowZ3a!a^RwxWLu>7uWe8eMV7P`rKw~XUlyI+$> zZ0nF;DZ3v<9LfM&x+E>mx9jp(tw;n!LtT`D43lvx*JiD0s}m`c%RPh77`gi4aScD&uNU!7|XAj*+)aiT_4YfxG-Lq1X;O69$(&2Y&Z;_pjPgZr=Tufx7<(2e7(z_g{;48pMy-km;|qH%^rb{@>!q`=^9tu zRiEtaPcy>TyTTe?@Gp-%?JAXB0?1W_XBnfYd{z7Pl zGsjv13xBa>&*JusnCNN8`xkk9TlVu_!9M@ML?MZiR?%##-RdiFKG+wc06@bA|;+6f|#5{vFmJ>ZR#08PJDWfoZRffH3nbNewU{E#hSF1b=ucB zi8MpL5Bv?j1$P1g^HJy=w?o;pl1v{hZm}?>&yC!srQ&()71to6f?~YCeVJFD;LK-o zJGcyhoVM@zQm9e1Kbj0BUO<=|qxNbEc_(Y<{a>!IHGwtW1-zKj%0#Dk5ip1<{q=A- zHRZd0HS>;Q{Rh?@1^BC$=g3DU)2RIEB83NDIJNX~M|TxppzX%8B_wSK*4%N>K}&w^ zH_@j?0*yHF-T$hYKYZN2bJ;h1c+a792x}a0R^6Ao88m)2JjlS9^IG|TdI*#$bv*axh^mGf*M<4&|8!exx+f}?GQaSTx9!8o*!?4smYbk z%giT|IrYH4oqI&M0uUBt+|T>aXAgwpXvF^G{2{wTikY*nE8kA{Nz4#tEQP0ux_8Ca zCz)G_4};=M54+DL;$mu$!_63QsLw4}e~QTW+?b>sV=mdYfQvDWD(<_uS)@;oG&Ep5 zuXkq4dD?!rO>ucFf)cxRJ()$@Z?}rjc*bqC7rJAL*Md(9HQTbBMmEXZ@oP+OHz<=I z(&_)wv>q(I8)*XD)$jL4r9Y6AtMQh%sTZTLX&(ABf7}nxzmo?mr2o18bOa=lZ~e1V zL$otE8nn>I;%b&Kefg>DgR(#?&---^ls=9Bh|Wa#<*k?dcB(FsXY1j4gxyKQVrh2< z4AFbQ%&Ghw`rc>%I3DR=N710OZoY>06cW26p&z;uJ)E_kL=ha4K((xsJ4*^?HJWLH zg2|$)B}R9xL>38pU(l3+QIKN~5aK&gsH}c7pF&g9=LJ{N(x_o#%jxMF^E5 z^M_*62(Ng(E1vMdwiR^KR6rBa1!u~HP#5z9R}Z3X;GnweeCI<>U# z)lw#cewK$jJl4|vv3^a>WM-REH2@-oRY_vp%DzAwPt-Q)w3a!A6`OZfLIdW z8dc28m?o2&b7)5c8aVR-k6A8c<3~4^qh1Fb>y)O7C=RXCZc(;x?#R-%E<}}^_io$S z2>@5Z##CoWu{TYA5PfMarDgr+rTG<1ETE6C1DO~DFX@Z(au*EBQ+txAxRjwJS?C2a zP8q`V?Hx=E%WTqUXUU%a8G`)P7|(t-{HqN3=uq%+cr^ z<^x&#TJ$j3coD4}6!OZlH0fr&G4NroXTm^|oGnHq9yaKapq!2B2nwU<-eiU7 zb8>!qq3&RRxY$=fl$f5{nG7EW=$0}X!dn-UUKp6l{Tzoo_x|_dz_axpXsO+@&0!!- z7{dX${X6R=dwR2hMG8H3b5n=-I~8oVe@m=~yDj3z$9x~xMH>a&xD zA0g>y)zq@TX2G zAd_TP4vpPWOZ+bSym#7=0dDanY_3(Q6W9Ginj26GIwx>pvD5lSyfGRNPeIh=%>Me7 z^*DHe?lWO9?pdGdVo;}-Y;s0naqL?$_MnZ)rdy$Kd8g$S&>TY#cRF)w@d)6u+PCt! zgpQ=BA1*7vw4vy>3dF2`I!6Nk2@n$YKiwu~MO{5TCVt-Yz31t9x&|%Ar?+T(;V&+> zXs7eVH)`gR-6O3X!-hAAKKBMh4`mDd}CVvK!-TQ_0fO{Ay}Q z7ksZ?Q9G$g-c~d<^%)7!x^m01QYsU^lG^ZnkaS2jeUN-w5NF_Iy-pzpapVrp0(xS4Cc8{L6Sk6Ij?gxS;A1g@c-gk|-*~#m zi7RODDw$JAN^A_=Y7)(Se?1auqyo!14c&!2l4S{$(zWUxptQ7)uuN7J)V&j*&%~FGz=lt zdU~UTlcqsT?500}{Sod3TEl2$Xz?YSzU92<+<+H>7AM8`TDHWvILC3F}24W~{vK0c4*qi<4GwZr;Ii5Q|lW!~mp^9VEy;JzS;fk1S9q z)wq6e^;U23vQfdhlLyt`$49W=lwXu8nOuHKa7;33&2H#?u&MB~v_7f>VN%8RBQI6h z7rB-y+gFFNBHgyoAigkk>nw1#p&9iN7nsHBN<$sB&5lTu|E;jmaVu%SB?Y&j1pEcZ zZ`r+opHK|>af#~e(FM!O2Y;;$bNLR+Bpv-a@%q>H|7VWtbIqN&{lPg`9)!4=%WeF( z)E&BxoE8iSjMq=|WvjDb@7&B$Gye5xArN$)<%=xPFv3a9IX8#9 zP!F%jkP}qr#ny|v=_b_u4mcHC8T3mZguy4(YM&9-<6n1`Yawr=m#(twp`=MzmHo@; z0JpwHOU@haX5C0QLo8TT*&INy5mI{Wm$P7*a8 zQdHP@<=Cgwx>GVoQv7L{($B%kUc5h|n!$~v1*h?$r^hRhnTT=h>F{PSmY0FH;iC&Z_nejWHiV?|p&BINSc3C9sf9{g5mY91}UWj&hF3!M$W4XlhTR}hN=bqi_ zJc`o;aHib~FtLawHhPn>Y?S+Q+6)L?iWvW-lcLnDoGc4=acA&+&+c?ByAp(-T1?er zprPeKdV$oJMfWO00lgtcZ{<4E(N-%s z#-)S*61ki?Brb1iZC3Hx8jm{K9>6xZFgX(4l;SCwCW(R65Xt;6w;p{abb@SNYwZaT zk}xyVh~Q$}fk4GU5Bzpuy%r$+Q^x-z5Lt2=uG&r0#78NZ_*zN6L{zMz_6*{v8!e@` z_M8+F%}SDGnK5LtPQLX(vi9A7xBb(e z!`)1!8Wy&7#9?Ql%{&UnZ!f-P3NfvH_QG4ws$gArj8e+eV}-A0i*v`<`wML1&wpMN zF|2~BRyhkz`WL2gysJ@-A)c9ip4H>*F7jbxrah`fmH)2I`>h$q4W1C`)K@LY*Uj#) z)?bpwQ_q`-A%lg;(lpHG&zpTBH0xhq5CBn1T_4>=*j-8(kLM=iZ5OpxPKIN#dagx3R#e3H3E(M1kTF**WGJiuO zj3fiswuc{|dVyvGLHtB7lpp#2$HHtVdT}mB(Jb^6Mkovs!wbekNYK{`_IiYSRY{(| zp~LgK=p?;+aa$++VXQy0gjb-Yu_wce{#8a-x7A&62=P@7bB0ZmMOYy3 zJfA1%LNcG9K6>*8{2{3TmZ%7w6$LNdNy72B zTs|4mHYPn?F#AzexGPwA{su!Fp8^?cqix82ZvD}-taR zY1g}`KI^PhMp?jLb=od1%< zzT)Rh(&@y+94Pok>a?QL>v9&Js4m}XS>XOJkDg4yw|Z@5=+TVh&rW8L#aXh@ZO1u4 zYu;k0xfi)Rs{dS$bpjA}gniF1x@`fiTOQnQL0J5j`#qzmqI=#^^gae1FD*ND`I zy5yPpd47Flj{my*sQiF*>>`Sx-|M!B|Gp$1MtNND0w4>eTjEhp8lzA;~&M(W1 zB|VacmKiIEzbSfp2J<3sUk?m2?TjkZ#>G@}c;%{wsj05WrQW?NWUpHMQvD4t0_{)v zekwL})4!XK%cX8C9IG(nzea5GU3|Q3r*cmE)F;<$%lhO6Ae-*W=K7~)AQ5*k!2Ns{#+YLNP}ZUcziz(d@wtLr zT&stUAogy=%SV~f4#*1G2q|oCWM;Yo<7VY|1<(!b#`*D4gc^|Xs|;?Kcc%PddTO)q zbyb0FtQP!(Qm)1qrOJW%FsD}tgSq$Lwajl_XlSGGhwk!G6%)R^RNn5txMnyK)kK6c zCWveO3=$5#nM7A*O2_Lx9=D+gnjLxjCwPVuWCTu+Jhe4lU73SDLSPRm=lH~x%p2H= z%oD!#a@}wwd2~K0+H>=C0=H1ShBzx`q57T8U zyo&1<{b!}0cj_C>iKJZ~Kkh`dUS}vPx29X};MFNLcYhb|F@66&`aYC?TC(lc+;tV- zi)KZp2*>MlKB(L!m=3Y;1M;OFJJ;|wye3_jgzzh(d^wROe??DQR+!}3N=K{qz=$UE z95Y@YIWm@u=(JAw@fODY@^iWC-_avkBh!=jZeK3pYyUsuuLP4;(?uMep0)>@9+EK# zXMO=*rR}a2Tf~Vs*Cx)K?{kR7KrbLysfSvh>@z2l#y*gWizL&MAo7LFe6z7WucQN# zVh#3|Oj$4WAatfey$F3Y+Yd_es&B3ESHteF5uGgn{k#lPQ59kfcpS9wjNdnS{ybR| zUurUS-g{J!U9i#TM^fq3&U`6O7>A6j!#`v^%*i*5S0hjeOlcmkZRzk(NP{Z{+`=;b{O1X(cKfn7}`Hj1if6D4^is6)w( zVaz_sCX{ML1K>r6{{wu8PLh7-tFZA4sS33eS*sgfss;3JFS% z^28{yn#G5}AHts;;{{1~)gHPbMl#_P&s(e<+{EtE;M)L(+&6_QAJ2+1fo>t||75S7 zDg*9@P*Pj&j!kqTTk)+bz|;)F!%Fo+^=Jd9C_pYOH)idIcBI*BPlap)s>jELMWlHB(CebL%giIYt4 zRRT*US{{GLO-TNR?eUg;hlAO%hncMc=2qAF0h&| z#<=h(eY8@>p;xot)py~Ze3trmRBKSZQk`a@k^QF5pub<1X=x(JshkybfdT&r0wybr z)Vk(0S2_Meyov)K)`v#@P(5*KGU)gd@;cBOuckyENNY_$O7wzHDD+#sHJf#8SFgOb zmE7r1ZO7f0L~Y{ab}wi)aC!GYSY%_ex`^F|u_c2@9vX)fH?YYA)i&^NVOM^A*`Xj( zh!roXBo)a%57}gajT6?!LVcYk;cUQ6SzMH{rpR5AxCenu z3AvaXShEUAOv6y)HU8x|5XlU3;#vXsmE z6X8t9;7Ro7x-_q{t&$K)q-jx%$)x+H_?!Cx_*_t*sXW|gh9Lt{%AVJY~IctmGEkLsHpR*fPREtk!Wc7ES=D7A?X52dHa4R zDY4kZ?A(2k9iwv}md2>FIVbB`d{o&SG2nRJAbs?gB_joe#I|A3ExhS={vn2~pU6>E zne*eFhO!&wP(7;^}v zO$skKkPU1?rL$w~67c2mSx&1z8SsdwGC&%JU1C@lC;m>cG2C1^850j0$3!B)aQszg zL4B6yoLM{ZQmi#+y-umCCAk|w6CJ)Vg1_Zxd3|#(4G~fSi{uAMmOP2|2Xy*2=HTdE zShS@Z;~hIeeV*J6twJ+@#(VzHiose-DVul^Z+e%!q^%gZ@Q-2P z;g-Q^yXmFHxXCX%yK&q1V7HtEv~LR;^^p@%SRj|PkISSh-aoIXeTp_P|Dtvj5J<@L zgJC==WABjcU{$o_b{!u09_D4_V4Yk7Dr?(l%{-FnL2lm$&#lInFl<|njN+rDqRYmF z{`Ofy@1);fBtiIWvtJ&}bvY;&G6T=|XZsM1%4d$<`5ZQeo~=h-4`IJVr9_nNU`V?mEJAoj;nC6h5;-E#o_X;-IJDDVDn*W^yLpkH5ML0rX_KL|yCePk{OOe9bdMW!TOm);jSa9Ag(eOKK z3%cF8eWe2n7S6_&76b62APT+D;DGX+jo^`zNB$`;8u`p{ZAY2R=|_orHGcI_SpvdP zEOpbnzHhh#a-6w4I@+<7M}!O0;G0UzNSZUYMi4^8 z4d#L5B#X1ec|~1-3IzgfWwvDQcDXRSyisz0dra=NWgvbhi-rn0qi|<>)$(23^2e91 z_oD382x+}M!8^L8z&^^GmI-#JBAKjd$!+W^CGRm_=BT95Z0mt zREA9kN751U33q6x^{0Dr;)D#Xh{$rKZB-}ywvNV zb=WzFJXfdk^^2z#SrS=w?($75S6S*T|Ev#8a@Gxd&!o-<6ip>I@)Z5X|8ELRFRikI z0)l?mI|>fQ^PaW+Bk4(Oywku3FR~RD-#5X<|4BNO99_k?t%P51Wc(?!OM>`{hYR1% za&=tzBy6`>NqOtXMjNYLpvE;;IcMVTfiD22_V>4imUmKvznHR<)C1T&jn(P<&m;`& z?^JahM0#+G6OgSO^R)F4(K0!=CcGpH$n?dL=b+vY1?Gdv$bS;>FbN}KH^ZcefXbxA z;orzC)c?L!U1`MFAwoBIi*K;y@~`jZefB63`i=579&op9+x0Rmp9fG^%^ngTEoWPC zf{d=Yfzr@dRwO!0T0RrwXVvD98H=k#mLAT0J-KsgzG~8)Ms4*M2?61v-bUZ}XevIU z_!%e0RdIgA_|Zcm>2Nu(WmCF+<*G8k_?6Z&PFAl0`o&9&7G5OG7dM0?e3-jq7iHtf zAm`{hn=TkL7qy46L7{!F^k;tX<2EC{K&^Uh(%0ImV05*B#V{a)ebAeO?6*dzJPcVj z-Twy8`B7F}lD^g+?Cu2bVru3cwDCHh(|PHNMSN5Yn50$Vv#VrQFuIG`MdA35BM?d5 z5g2y!QE74aR*Tg%srr7Q>>iL@{HRXUv1IHCB5Jk&RmF8dbGOzZcrzJnSCNDIGH27z z1{O*nBELv;IDg2HIDnQV5iHoecZBFjoo1}-JDB@3M}?-wP(W-w8dw5_%pj}r&-6Rw zmT>w;MD)<+R-p?k#h98Rj&%8#kDrS|l9Mby#?_7LfP~D7kjl&lI_qiDi(ly; zU_qWaSG&Vqoh6fTv!Q>c<;xoFWg+No+UfRP&ahUNA{$7GxW5m(02T|-kRu<;2{e*6 zB%8%H(}MQ;h&*>jhwWSbvd1BgVqWSLGrR;tbk`;x-++PxRui)Zv0@;Pu51b&uQZ1L zlANQ|Vbs?%3FS#|U0G`kPH72vz9Uv^A!tCQQR&e{OYsZ*R*ge_4^;R=vPAHMP5sL8 zOHSPKi*gzYD$W;u%4}(>Wo*pz@C`yi3%AA%O}qfFl(yw%C!g%JhHl-9Ku)g}j1Qg> z%LG=f8Y|UzK6OPTT~!D?9jkdEksA;tu4aK?Ip@5|F8c4-b(8U8zf3e;d3E{J#aX6Y zJe?@mp6`n*>1!k-CY`i~_Z77?m?qGuu4h(#PYGWKyrM$ql{( z35yj~Hr+Z(&!-hHo%MhGj-0qT!Ps0uwWPOj zgR9Bcwt?kVWZaaP^2Q_JAu#oJ-O=ne7}y3kL*C#fRe))r-iOT9?)9@F;-}A7j!Fm< zCD>ia)u~h0xw|o-iZo8GK}_J8eVTEO&~&US$ra=az&GkukJ4vN5Z^cWq(*;Iec2@f zMZU;Ii`K;b3NxfAOp)}D?>$aheX^%#3fTMKufTZSiIsv5KlqsH5wFQ<@GtU{N3uR@ zf&t$)4e}#IRjJQ?rn-)v{=9pEX2mh3*|lt&$B3}=c@QWg zqZ9I=MD1vuUE<1|u0W_|D{^WPGlw1Mt8a&>#klxYQNl5WD6);n^V-W!_@#!7+`k8S z)8Lr7dpmq>AB)nWR4f@ltmCcUvR)UJwz8%t4ZdakJk3A@ZTuE7`4%C?@tn|0+oNzj zge)~mN8mtzGZW*Ue8J@KS&K5LVSKxCf?Rl6M6-*NiW?^f5!tjD<5{w0tkeVvx}VRr z-0?-XNcC4$ps%?aO(i6W;n#3vb+wjcKbTU!O~75OkMuU zdqMMh4EeDbD~^3ISuyOA72ITyJK7A9 zlpfTU1NW^19LmUB5wfdQGO-M=6f&kzQZtu4g5SJQki|cE0w8bZ8OIrv;Q=Qr?5%xc z2zi{=4suM8lGiMpiBpI#(o5?rGFkvLC>6mqIW-{#G#s#^NE+^bk<`SvC^Mw2HCcJV zTy6rZj7Ia``&6JWB0S*$>L!VQ0;BjEen}8}E7-A|YpL;U$j8HKM;2@67<6wqmdzW1iH zMOW-WkylgkzZss4S>@Kxl_oKc;$JI=1G9{U5$fX-TC_6Y3)Wh!aw@M&pzk;YRt3kF z&zi3rXz7!EPZC?Y3nsFXF18 z3F-ew#N0FsB3%q>=WUwkTF`bZAEs%xjl_?;x_{l~upNSA;QRNb4UJX5L~?h>sD%4a zD8*HUEFw~Vf`VE`ZNHzwRDy^|`CM4(I%pZT3W0n=&&3?pNp0s}e2FGRFVv9# zKqigh$z<*54Q4pjRN^9RgTcG*b$l&F%eiH?FV;e)2bn&<`&RKH_`GmJ!tFtMg_HJp(A;E|wkzK+N0q*4ltu!+Qfg1%S@;$s|N z6_)7SkeBzb3iI{j%+d=j(YjHh!cwK9vwrg;YvrB&#}`KZi7S3kE0Zw8!cPxfLnbw& zm>A?GR@Z$y(hd8o1QPElW>^al&qx)+FvAsHXI-zBvHiN|z^p2MP2qEr?EPGxuxO^= z*pPh7R2@>_S7_0iX^&8)xYMG%0S}Cecnl3IgnYHF-4b3%f`bB0L2>oX&UvMN{wLQy zLfho%;5~;xUtVDOmJe=fgQ<+jmHNZ3V{(#2%t_j>LM%W_&PySX)eWB&7@*2{f|RJ` zA(_2O6~&uz#6455MkbvyD!l@-_>;ALDxF(HvjZ;cD;ATTvlw%po9$k z{y3cD7deeJm`pjMah>b};&})>^D`=E-2HGl0-ulV^)Zi@-F>DiWUgtPb8Gg8HWoWmUSq}Z1w;rz#R4p(g=NGh?WinUO=8@41 z+fsvywZrc3)@HJY75`V3Q;4xB*>2O{&v}dGkz8mSZ7fLd3E;4Lfy?rfgB}6?#ym~Y z4^TT08Ft8I!Ad zBX5asKLsX?Nc*Qnwzd1kMEQDv9xlbo^6iqfF&YWS}QVj61uw1;TY^y?XP zH7eRaiECJ#jf!(h6f+yYi*c7S<(aYFlUM^c*eV;rB`<8`Cz1NhGe^SnZKF}?em$zT zKV70>_#XE$utt+S-fo>$?+o34hZzwbnSpflmut151LazU*x5QpA`{w$h-^AGI0(RX z_5PCfQ_SnckMd+dx})|P5h6D}!Z{_VS$%MlzJYt`|AV!};s7`1Of5uI`_)77<96{{ z;FhX%E)-!7u;X?G`=38t#|2d_ZW)A$fNyH;QjBLwSVI*<>K>{JGVjOck@@9A<@FnP z@)UWQjZy=432RY%$UMGvlt?Rm;@(TSI&HDiM09sS6ku&OmMaJ}$MBBbtom6W(0ihl zmoq=JP=8%cqMxq_rL(a=H?B&39k88t+&Nr@8 zdtK5eE~r5~pyRZ56Q~NDo&HLxBD#fW!~d~`d&j)DLpd=QyBf)REfw|sNWFOrQO3@J z7g%s&L*vBprb;)(M%9BbhiUTyhg`0uE53S+aCIZJp0FAsP;m53vESRS&X zqP$+V`;*^_aat(${;C8RMeF>!R<{S<;s_XtB3Zl2%dGLb6QTdnMb^K%Qc4U2I=YaFJe_6qTblepxS(_Y#|HkVc-bHUsKi8xi9~zx3+!#^;+Cbhb57oJx9U z$|gi4w*JR>zOW4gIAwo%+$GLzB*jRb9uRlA&+fyG`ifu3&rJ$GPWkRgjECz?RL6z9 z@35iDc8Sq95KyBh2@`NNKH=Ic9Z+LY;fPJmZ5N_!jG5Ew2sJ_E-5E-y5usVeh;)3( zHw9pb3we=n#MV)@JzXCzXQp3i)7s#B(P9SROUaB6nTr1T0U;;1&FjAs>N<9}fTOO* zedrX1p^I%7m}v!erUG5=>NIx0p=Vv<-MRUeU3@Y|VXowS)7BA_`6#*x@M;fS;=bdi zeme|knzNi9lSG3Gq9Tq2y$YiJg^N(^kv26jerm=1m>OGdKd$L01{9E?OSDKXz<1lZ zersxfsYx7Bto;%<%|C{_X(3P}3ZHRREXlJ#6Oz2!B5S+(g(@biUCis%3iqczw>FnK z!5c{FrennM2;OZ~b<>a|um^Jg!o%F;16YsVUO%gX-;yeRAn@-Fl6hI|v#Q`vzT@xe zKbo%N2InsAz*a>WCxQJOsH`%V3%!2NPP349ud~FPOOE}MAP}Gf@DOo2rnS4hm=4ko z_1_yPMx*Gfs8^A}X?K+lfQH6-`Do=a%K>+I!@X*FgidVX%E6amUH7_t~vGu zwjTXEj`{mj8=)<7e_6L?U83DqHmpM3IY;t4Tf+Sxz9U~dmUH3Vs(at5W_fNyA9<#x>MtY@%@xAFjPRdE^gH;(hulnkC zrvTP0*{ve56LVe*R^_4Nf~VR&Y!|o;6^SC(b zzhHEmsPRRvL`9#GuKNiJlvhV;Xfkd&r~mW6vF!yX?{PPIaVkcc$To#4z*C*1I|d%$ z_Ta7)LH<%-CEV;eYm4|HcgvpX1h2j%- zUp~>8`QG-Z+77;vZ?fs~+n_YG@TIx`EvtbzrT|aN+8(U47i+gOWAgE36;Db z+sm)fzGE?v4uK)ZB<-JS;kq3vy=@-}0M1XXqhFuP=6|KUHT0vaHPww13$ba$)%0e6 zCYy*QBVCjNJzl86TDg5d;<~POox|{1L2sL3do6+|`-RasdB=odw)i}%0-66CN-mL&mrn}-A56n_ z6bN)k)3R8YUF#kO+G;XfXrg%`|z9(5@W@z3|Ny+qtp_cJICd`Oeys6C0&biq*H zx;*2I`2WcI3cslL=j*G0G)RM#bc29&DIHSMAxL+3u5@>IcXzYW4bsg@cXvPgy@L07 z@%s;E-#ed~GiT1s?8xYgI&hVtZQ>p7pyd=>F@DIfG70hM*0}#2O%rXLbHWTX8hZ+;Z~%eSA0HooM)VF3F1f_;z~dz7S@4T0;^5%SlDZf~ zweVjI!szKz%#!Gqneu`Nn_D-pmY0vOmfi4rtZt#r>vqo7x7Fl$=R4F=p8e|9K|S`Y`JaRULT zmY|Wo;5QHJz>?nfO$TOGijc#)jnLGy)bafq$voEm!o{&Qs@2;A=|n0?S(^LhEwcWS9!6WeS%-$figC1BlVaxup4qnqUGSlpLj z?elgXvM>6jEmUKEcCJ5GPpTt&Uc_ShzeOyk!bH+Ml>{O)Z~KtB3V&B>00>^?98fR& zIbYoN>;=4;_{zcz{(`jr-Csqe-x8RDDk7C*z5Mxe+Ru||zJQ=>9(fQpO5dA@+H(sK zZ{EB~l9bua6)VUXmSZN)Y!EmdO((V3<(re@6E%5~X_}bLo=Y^mlG>QI^H%oVUPLcB zKXvP7eOw{zF1s?Egye4W$;l^=hk{sfkkwVzZGjo8umcA0Frvd)IMpycx2N~z+bD5fJ5;uvCN~^R z)=BPr5E0l}5$*JQp*ayUha8I<^R~uvaK)cmb)m(MX-c3YO?qZUb+u;rZ3-3yvGk(U z$Iw}e+RSoixNiEP{p1&`e6OT!zw;B15th5*xSrH4VUTaQus6F4zp^eLQP)~Cl&OeA zLoWWAtB&MQG?fJ_`~*2F|2Nk@2%c*K#cel4~1LAw%4E*p@9|v8iZU1t6efV89fMUQwJcy4qiZ@3YH?spg!4v0R z_3Ir_FY`{s7xsza`s?=`9vfv!)V_178Y-`A=E7{`h_3m!n(->ZL(_!4I_^ z_n9FCKL$6xdWqRRdfWat zIf+^M;ycdk_Z&94E$erx4?5Zw;MJMslAmTX=Fa#<&P|lidwSW}_-N|qwaj;iV>Aih z-&TtQY1%R0!@TGGV|}F3XOZ&E2g9)v|7LeD2Pyu%sv~m#h6QB}K(c zYo~oJgtUx7Z6dQ>U+Q|pWvkPeY+)MumtU=s&J@)wrYIi%OyoBA3JXw$jCX8ppWCS% zqAtCHP#YC|VfD#+XX)s6dc3jpq*>f@Ed`1C3H2!A+*3oYJLHTkw0$#I=B`B zhYBeR_uPJ>BngE6^R6@Nmhoh{-F@1jq77MRQ!TXS$h^*y9lpl@C)=EXJy$Nag~7QV zAzCa;>mweA(`{N1A~BrGZ%5(ph8sEh2Qod$?UcK zI{9O+)I^_j7ke!+jx3ug{Bq9!W^vm8>`wCC&RUbFBJhawtm&|vnQKPAA+5M);-?nL z#N9}Xa3ACN<2aSuz6F;8o4LrLWa?D}5}5N}0cwqJ3H|}dvSKW_qHP@vD@O2%eO`^3 zBpGPC)OFu&{fEvy+%Ny~{*_z`6ZdGqEzw63V{hFQu|8(1l4PySUPir`2)5XW`J~9F zDrew-JLj0eb5JwqM9$7UvSnCph{{6aH!ifdCCqvQk!qo3x$;eX6%4q4LynnTf3!?z ztuPln$r_}s=vhm)onMC)OvlYaToFUQ_{m+ed6^k!O@T_2XmNmlM7`QSY6c5IqD&2| zxq(WGUb{lG@=gOEW+P6svQv(Mv@O=vKGU3V0}6vtvDe$PR*HPn^2MUj^&6Vb=l+%9 zvk`LDY7520+AR>8vKx+?0L56W0Mr)|2YK41TR66Z*4chEl?SJglK(2l$fHD(J%KH> ze-86CM)u!KThQvj^(d4FXpIQIG)Hd?^c5Q(2Y!G*XdjSs7suxcmE5#-F&i(CtGVZQ$V3{zN6Ai(8&;t@Q|`3SP;5FHGn4g~y4E+#*QV0tT6=p~?rDs`;nD zYn@&9DrasiA1;%s-G~y?^CeO)I$S5W?JBm#hAp3lEHg1I)u%Gy@6&I=36gfO?#q4U zFvvHlq54_p8v5Q_#RRs4TBLSrF)AIry$9~k_8yi!cr=D4#|8GZc2GSaa=-RH|D2Z? zz5>%FXB&CQnEchBrx)Amu&F9os8^cL3?9PIts~>bo;m!5WMOJLP?*dCo?WbE}rR@P4*R&sIjEAawkhgnOmISv#XDB~8 zE0e-LkwdKiTi5246)plu{E}s~9I_7_(yFObVuJTv;8!)Ri zjI-MF5`}m@xq=gKZXST{?aBHCC^45&8p_**tfBEw$?EL>Bfett{F*)D1*5+lH!93Q zQmPH_FcADV-%0tvbCgq+mg;jJjsex z+=sifjwz~q<4XX%6@|<#%L^2&;g;Y+VQl1oD`fuKztGZYruJq35(0dE&C0`@uQwZ? zZHBr5c{V%`Ly|I*>F;MNH{lxEOD*klxHx49$pZ4UW#Ptq_Yv=St#)DRAIDgzWt+y9 z+U*H)gd98C^Dz->$tjMl$is=%mDAm{fEIy!7g<)6TYEQZRFiod$0SlrE?bdb#cP)1 zSYd}At33VbZ?&d8@-G1f{mx2fIHyP?E%$-CsqYvSm)#bOaq%2nwHqU-p1Y{e)>Cwc zkOKPjTVlU`_};+pSrn#!C#C)^jQ(?(b4WeP0ZKY##^0Y7mv#~I@Bj_|IdAAOgFfga5qKjf<8`~#S zD`@z2JV54U*rBbly>YFREcqJhkdzWfFNwWRWA=8MS-i0Jm+iNE%;NtdKCPlM*L>I+ zdx8T(F86MGBA<6I*{=&^ywEe^H|@PF<4*uzsQfGO2l0&cFHDyo(KUIi95ptJd&lTo zhJuxVI#&%2^L=>qB;2!-m&tbJ1O;nKCto!_)8bm***dp-Bs`Pc0+g|IFry>g=A!*y zvUS&$0P-n%l(>FTdNpmV!1(@$mG13*(P-pY(Vc7`aUAd|?C1fHY@^?b1v3ZzYU(Mz z7x_NQ%wmyU1OUS{{Z&N0T5|~sTff#xp#nRZp86>J?^fZW0tVO=9_KU5Tf=>hHzHaj zkz|2dQQkeoVSuls<*vn9^qtrPDv9t*SI?yAU)OP8CA_7@6WWJZs#_S4r(|M9&?n4R zoA*;k!6>OmS|bEEBLBcNa-Tyx*d~BKBs&z|<%F)UG&bE)u)wkqs0VlFLj_*?N|9};^h0YE31?@oFcjQ z6rq2A=~lKVkq<)7^^~5K%>4;#`yEOFBAb7TNz|??tGPIcEknjG4kOgDaQIpO@QgD@ zS}{hG8>o}eFj7y`$GpVbx!}I=4aL+;Ku0M$Z^U*3{`&#ipF0u@3n<1fw3ae&3i!B= z=SPh!TOB3yr|{nrv?$#>{Q>#WACE}xN2^5EFyAzC7i)Zlk_6?`8n--5X+%rz2~e>P z@_f9^rb@dDkmoU!cSyw**n`WsI~MlWD738WLa&PWm33pzneZcddm`?YJf9NL5(tqg zpjXmZwlMSZdCTkHupdq!7a=L_mglIkiM3@s8s|fr4zX*1#`SPB8u$8m0VH@Tymn(S5(GT)Y;&`Js!>)ML8s+b0q#4CD-V~*BU5Kh z4ZOvH>!<3`l9CPMYdGB{HzX%*!P5LZNN{~VI=&dQB_de5@n<1kzacr&>dr4*>AnCN zT8#lx5DZLO6R#*LnBL%QAd^u1&)P&E~84kSzN6hX-n~einQ`?Ww>e*0l+~T?EFb)A@$x9)HmtiN3B`DBi&x z)q=q9n53Po_!ByFd4)CN>jn%70;|v9_T9K3kK#R8+LHCwvGn9_(+8@2Vq!F4cYjr& zn6zf0OK~a^2lI&CF0y`B;O~jfBV0xV`8)3RkD!M*G-QdpcU|9Uud^RfLf0))^H>sK zyhJ=ivuifmS#d3GZicQoI{bt_rNKiT0`rX$f z2HMto!)?p2c&9p&$W}3#r1cC7XfzNGr3mL18DvdimTZg`-UT=L$(fxaPdx+~-U-Lb zgi6vwgk=M}M#Nr1_%M&^sM^ZtW84MS0~ z6e+kyZ<3-!c9d;>+D*XQdCi)*ArrRT=o%h%lmGBKM3RKnu1oNNnAgops~STzZj{>z zqrUv=cCxJZqHyQcW@4+U>5h-|3n5zPDaIy%HaVA9&4dYS^YkD5s76A{Q;j$2jUi`H z@85T^sL2%{8{?i*UjP}CD#`GbkzXL5O^jrvAnuaZ@SXnqh{0$z#Eg;^=9A0p-{jra z%Dnm5vCnbm+TtWOH{iGlPZHaEk_-MLOM)n+iepJ*bGsjI5AB$EnB&viM=zFdbuAb< zlKj{T$t_bP?jIhn#{B@h&xNIjhA62Cvr4Oq3c*Vj=83NvIYP)Y#|wLKqm5=ah)$;C zbE}!tm#P-L3&6KGSSWD1+GQO2s<|XVEyzM;5WDZ`aP-pektRbq%ud4VSx$9Hd{!jO z0Rxk&D+t&3^1H~jcS&)(V8z~3k)=<#GbU;JE++q?-Ty#r-Vy57uoGRJ7kj+7&@3)> ziM#AyQff{$ycB(3Ln^iYynxm_W^Oy}vbgaVTsxQOUa}$1pvaaz*D;(pC<;ZJ0?gEYGceoZ@zl*^m#i8 zDWn2zf#(9gw?w#?sal<~)R6}X%JVk3YDZOr%Vl6~|5MHq3lY^4gFrF(3_%d3gysf- zpuBj8NlG_gV_1IP+k*1tU#$7a3(;?MRGx{w>~WE~!rUYlyTS|mCQ-dG7Ypq6YD!n% zCIGpWRf+m-7}q$!Xm@>kRM!>b{_o#UFK?BxYo;`BE+ioJ?Gkn*KyxXT`u3g-L$&24 zz@B6)9SxaDW{5wW`d>bCHfg3#Yfw8cG$p0>Beh$%$rWbo)`iK0Pm{^2uH#NwKUg0B zr$mgZ?~@xXG$1V-48$`q6uPyXh8z3X!@60pv$h22WD2d}`BNf_7tIb0GuYgF|B{|= z-6=rXXyb8s4LpRrVJ-xpeoGa;p5?tPgm$%AQWl}f&;j6g8r3Y(rwbiVx3X;;KFCX4 zT4u!H*m>7@P8nEP!=$DINPU=jyOnlQhY_xEscl|Uo(!-M)fZQYCDJz^g^O!bexkdBfa0LXGJQ-n{kX`S##jreS-!2d77pF8Xj;1hm-V zfi7=<648AZHqyQsdU)bvRWBV0J7QzlR=H6x9HBoK#|E;lA2RV5?F-FThlyOc30krB z)6QqOEf9rEtQ$DV?0@KQM)t@}sRJKtH8sUCF7MOBjn-1diD%aKWHwAIo<`uRH_8Wv zH!f}mKkI~_c`2K6-k2k_j$bZ%T-AOtCQ{>(KJqK|nfz3Oi`-6)mS0i-7U-i6<{P~A zB9FD+bf4M{iLP?hW!tjnZyMNmTmgb}l} zS?3VOS4@f;S`#ftzzehXrl z3OOGIu#am!p`DBjD)y1i{gfGr{La8tK?Pp1$Jr&OAFx2mBvBj5h3N<~Z#1V#3*!5N zCF+E|c@V3nTji&sx$66v$O6n^!pYU_wNF959znCS@|f^;$4aGkdR}y- zBfi!tE+o0K3bhXFrg8$1$fi}u`T=*lw>fOVx43w>iz42;VvS48IW&9GefMa2i@&I@ z;xGTPH>rI6XP)bzRrjflf=rRzklKQ8YMjjSIkuXl@|1Wv7jwE&Uq-T24Q?qt7xVAa zH;Prt6egYtPk8?$d=n;ycmF9Q>FzXGk@GQzzvFlbrS7T3)g)afQ|%x6nYXIK_s*df zr-F?FfpBdBvz0RG0V!f=FdDB^%Q;k6r?Zq=mR8GF8-+95*dRD(LXaT1bKfoX;pg!? z6<7t3=fe;6n5t%}Gb%jQ{CMkS!sGU2`T3J$q_U(N~o#ww>_`~h3 zB+0g!o>jkdbADRBsLxWhkXXVzHDTN$*m1hIg*ElU@M_|E*iF=fi>}f^(r+(&aV5qrhUbJ$!1}Nu4=T zVKUeIFZQN`{!QgmxpMklNESk<8wJtc3YeCL=HGL@RS*qCr^=};*N{p<4n)bzJU>|BK{2Y2%4kqY`gg4??sV{qj?_S?xPtF2if1^Y?JjWy?adAAg@V ztJGX_2s}#MPBU2trJ<|Io z$a`jMA50{9yFJuMiD-N)!4&71+^{6))1h17L(CVwc#1e=l?@LBe5zKI_1Bpjv0~bU z&5yp+gp6WVvaBsEuoR}$kNvkboJ?b^`uDuPqVtOygU*^(Qb&N}pydf+e8Ic~38U>W{4SS}{Y+nCTjMiI_S6eS&( znIM-ZJ<(&w_FuMwGFltYxvy_hXJkv!cU1ZwrvQlV>qS2m*j20^7-!qR`~%clb310u z8C(9dmkPF#4A;fudW_Xy2qyC)A1jJ(JC-p@%kLHgSWReXu}+jM^Dy_FMl z5?oI6z+l1vzLg zLdQZouKqMpqBKrPF+B9QB`9XHWN!*~$~s%T6bcR;*y#Ine`^niLe1k!G@ZRRZc(dq zUo`%jW!_^@z>sfvRz6LMh7gtV_-*`i{PkREoD?9IE%#WK+tc7+gPcZn3ARnQnDs@l zC)im#Q}e>tXeO(;L1G{f#9JX~zNPP1|CW6d&%+UZ(fM4M=d-rF!y~KC&PqOosp#dq zqf8{}+xXe5zjd=h$$RL?zp2pmWPRW}_#K*D8B9!43A$~VSPB^YyLKT_JqU2)R$(j2 zG?xxFM*x`(N({gA+*Mx|p*@TiGq*dpzniUfAsM?Q<89+?yd!RcOJ1+WgQMJ+TzkFL zrdyEnwm-9i9m@;rQ+E`^m^r^7QzIcT#)*o1H~H=#5Mp&Y+(tC?4Y|+sW&D?_*hcs% zv>DYAm0HJ;hei?JLdAcdfZS$jyx)IxJ~Gm*xOnPAm~nwZd>bs|q+1;X_pe8g?JD{D zG-N|&zeP);<0}T7!zN5oeDn(P+D%@+=+^Y=X;mZ=PrqEg$4hG(D(|SXU&|wsccHja zHVDh@s6}k*3&)1u9NATZS|mJOe(5-C{ccnGePbF?{gF#<=j?`k3>^`i$@*8vGD9TM z)}W~8k2Yg2Tjj#8X8~_B>W=po-sx&YECOIwrK3CL2Vxi!kqq6rcq(k~*GWSeYD{BO z-H+5Z1$+iORH3L4O+(bzgDMMtjg!-)TT<0KN&Lgb883J|kEVN9z&hFdCLt0sj$WGD zzQ7N6Zd+yirmbC78Wja??lqswn@ADLulcH`1-^Z1ii>?K*UO^mG7u}bj)qHCF{Z|sc}HM>@=FkdrEp5Tf}Jh>mW|^3|rwrwN#(ZfPaXQ2|4yNtHObc>d!)v8X$A=Hw))|hWljfpJL881G z_x%byG0-n6Tam^NkMETd{>rv$y^q7cZ%fgZcqA)6BpK3NCg|1==SeFA(425QWP~`^RDJnhwmFsH!i+3n16BxM1^xbBcNIlvJ$-LMrkI?+aiE z$jyG=Q^h&QBc*mNPoeOlw*6(xKdd7a&Gd|UaxbLfx9%fa<3HXTWVKVk#u3z}aS-MM zb2?Zd`~-9I@jErKEoh{jVOx;vEqrb$pfO-m;Z;1oBkrds%3KcZ{0Tbauw2E1;}ofa zFc0opVrB|i1Rm5tLky@*hW^|pX%g9YWcmwqX-7;~9Q6*cu7PCEpo9gEPtAjv?O$fZ zP0A`|Q7$_-PD`~B2y*lk>3#mOBiV%=l(0F!_10!VeF<|`zRO$4_)l~|S#3+wMP9~F zmo5NeM7AjL2r{RXZgwcaOO2n@B8!+jy`#(ox!!F9E?c9lm95)aGUuD0u~f2v+!~SF z`E&oX{tN^XJ%&s3(~(5PP_ff?t=dJeXcXimPi#2ZYX>t9uMa=V9poZcjV}>JvM=CE z!djpXB=0rf3o?9O^X!t|7Hs;KP3Rbx%(aIdRV$ENP1PR90%w-5k@q#|YdsuvsrZD;9_Vv7dQ+>|tK{{kAleXIc*6 zDs`_`H5Kxx+~1@YDaOr6Hhw}1*=P(nA2B~Jy+PSWt~#~KY+;);Nx9~cu#sg=fvNpj z#d(>{^=qwNL~zg!T>-g%Afey;xXy*~wp^)GH&Haw0&BjBnG>uA!)*-T-qRtX_Z ziUkmUq6dg$Js#FXkeuBCUFz2KNlhJJL6?h9j80mp-0x2qRX5{qnE{_m^DSMod7<|i zVf~=NjLw?0uSFMEd2Q>uBR@}}^jHdY*Tpw~rm!PFL>uQsSWQDJf>i>N5M2v~pqYAA z|K;y3piWtJRp$eNh7DDBtN^GNH0(%Y35q#qN!5hK$@NJLLnrPDf{3{Cw#iDt!x9f; zSc~U>RV^;FiB*5B!pp$A`Dzl@ks(9E@TDibZ2_6{%s_Iwh*MJEHR zIEXM`JEKO_{Ucj1UYX}u4<4f04@s^+~NHx;NK!W{PLj|l@%!2d-lQQ~HMJSiPcN|O5D-zIT-)_*6 zea*#@Tm|M=9EBgzrdarq|0nRxgP3*uD^ZJ8({?2+3~81d)x2UHI6@^bG6@j$ z)d0m_>mq+O{>O?Q>~RnM1FQ>{Z}P#Rg6YWp3#_>+$JBGL(q7|BniN-fdkz>jkU`bZ z$-&JL;H{Iae^>I938wYgZmg7c6?A3H=66G%%=ZXo1H-g47X03Wzr8o??+Dx-Jt!6* z4HH3CAJ(!(-x52iIk?zAKnHyui>*?E`0G2==@)p@pKN7=kjz>KuEIZMGB~r)@WVIM z#RMx`If~bx{6U$p5>-u&YPB$7u=BBFC>(&@b-rb3s5O?!#` z;+^hGTA^hynS&yuR<)8HRjL`marE?3J%lg9@BvSIRZ2Ho90{xte#*Bwj;B)4hdGc3 zlm!p`6L|D>pz{3! z#@qud3s|6hZxnwy7!USd0;$&mbLYx2YFY*+7vQ|+O!s5ep!yBunVfhpYr{%c-p(P7 z9Hv+X&;f;jSSi}0deZk!nJ*PPVGTKh$md)K*2Zb)Ahe~1q*HZ2nV_)KFtllzb>&GS zN$|3jr|w%~7@2-j$sCT0gYsbn9n+i?mtuEgDfB#^eL52poBR0n(?39ejh(TL7!vp3YO&$|@=HlKs9prKX_9bo6)3fK4a z9SSFY<;)EWv!Er2-8lVTjnxD`bNSSXAW~=PcC}%}YUqq{;LFvx#p}Ait)J2DNenF{ z`Fz|S`s6}XTR*&FQw}k*s(e#6SKo>m^$jVG{*r%yuiEBP@unEUvOv5h5r*s@$|=E> z9ApM?oV4Ir^unKG#K3iz4-y2M+{JER4Os`Ct&iuwj6S-q z&WUC=;6{DV;w9{FCt~!U`fhi#%i#zPzf&Wy>uJc9IC0&kH$@cc()Eky zSiZiGqXb=)V=DOmj3F|ih%AaPt1OP<1X{E0sw!*Vx5q&p7-zIZn-_JD1oXL? zmfNBK$z!?tJ`#?Fs}nbWe|I;x0k@vOmYt*D$5E2^yP7{R3UlH~FC|VRV=UJkEn7># zD<2!=dW1klJ9ZYYV{``wo77rmJHwZLVOZ?6zV-^U1j~!>Rod4_VLljPuUsou^Osx* zv2|oM!xTw`b~;zb2T(7M&y%OY=89OZjJifwhP{4S;+o=fWTvK4Y!RJr{k;Nf`yD8}W^Mlq=P5>iyv}!eH5jZJz3s ziv135fN;*D_I(8VR@})-K%iiaP(#SD&;5Na+jceiSD@$F^-dC+LipvL3Z(;g_WXG7 zsNvE=m(G3VmAnV9uuH{!@uy&&?!G{+d+6DC2yqYh{!1|d=&S3v%a_)})B!s>6^EJ9 zuS?Q|K*uC*G<`BM?sdFY9m(!O^vgm2soGaU+=e=T45eb5oUE{Sb#Lo2o0dVM=Gbn2 z)H^SYjcor{AA1J6OcIUTXK{WPoln6(GXbN(2w>^hwL-1N1Jet&sKH2Vc2Lx>nbKrb zu;bes>~rX$ibMJks1XD@!va)+YvE z)`-wVC2nmiDW2Csx}ZLXt}Dy?cc*1>*OpyDJ@0fOA3;FyY1AieGWu(Ws+zc2Q4*GV zOT#0hBbtjoqp!kuw1PoMNE>h1J6E2Pt^t7{;@A!Fa_q}_A=e|y;DXuBtw$%y`KHjU z_h3hIw%^IeSqrmHo62r`H)v3f2i1wtSeGJh#b*>^3_a_ukF z7sJe3UX}l;EK9G)I`z(4>dLky@09LL782Iu9&)I@W8=wsX{>^`kc8y6ht6cQ(dIiu z8SKps)VC|Yf-3t4^ETZ*m?6eN*s&X$_F)3@xb2eOG z=&AY-JnZ5AKK&V;lZxTM{Lsl#$$XEQQ%*}rBL3tX5}0~-s5xI2^XO-}%K?IT{51qn zJlmxhNgYT^B=uI1o*INY2iAId<9qu7fh~Hv<|2}}o(Ka={lCh>0(&d~5m5{VY}qi= zBld#@Nl^|rv$2<34;hen!}8%0W!ZEEweQJ@z3BS>PXoyL71YkAM2r2R#R3S`CkwAP zAv?y8$NH}jLe$Ud&GemxzZrL%6b{e|?U#og5&#&v^D}T}g8QakoW_LlKPD`6i z@b|M(N3PT9Ki8=Kb3X8gT##!w2wr$~y@!2s%m@hTnQ4J2lG^WzdB7VlF+{0PJLbj)LReu%KQ7;)@ zv4!jCElw)tc7%TAyS%9M836qV(2IH~6C-s3jhazs;1eL-fzrOSPeH`UFH^AmW(;$8 zd%#6y_e>EjkCytlPT^VDtY9K^p`Bl>m7(IN0Uuy8O8S3*T3A2%ss*&*yNQ47Z0Hd# zM~e)e2xTnk=0|x2O|iU^SBvU5Sqr=>k{At6 z5ra=rN3JL3?VClvj{#z4MXug_6o(0+7m%P$5pV;pl3Dav*E8CDNeJ=AgeS`%MT9B- z%RJ`kZyoenvCt)6#nR%B*6H1Eb4r<$*7k_l)2m`1uuCh1lvWp6}BBEwdJR zCC5HYo5|C0lPTE;UqXLoo;NZ#oGe_V(MCHa=#}_ud_cA!yfC#!Hw24OG?T&<@hm?v zj>_c#=Q=J$DQjuW@4(s=fPnqyq?dLKgYzCJzJ0t2*(KfxSrCvspN64#VEBHzY3909yD z09nUnUF`=~pV3uU!wlwpL*BO!ztpzp$v_f1C>MVQ^u_R=XlR(JYZP!3t|WIS6M*g{ z<@7U~o}d4L3o>Z~*`|m&iy|Ihk6Xn)HMSDRj_DHdgYK-i3`{hx+tVbIb3$wAeaujw z^O=vssEf446~ww*{i;PKxX4Xm`fS77Qx&S%m|3qsAVS0d&He#DNs^IRcm{!Y_dO5y zb{N|t%-wmnl=7)b92cnBc9Cy|K$8}3L`912wUsB5)=Qk+TQevqUFTwA=d95uQ-I(C z3AElDn2CDiiX@ku+`BD+_!~Ff{1!@GYBiJ9=XZ4AscTiibry-*~Utv7xD&9=#jaMSbw@ zuI6x$$=K*Ho4}w#4kVYZQ8G=k#|>sHxidWI2V#pxPxxrNFrDuo{9GOTchu5eFS@1oBi{Y^^I+7;rtDzV*7J%1d(kh1MtJC2!OIFXpfzzZq>-AeDi>1`gZz1<*=U6IT?=L2* zHAR4UcJggf4`y&jkG#H2Kzx>UP#@Hqfi2pJ!xssNAOT zl?Vi`68J{RQ-vORg?*QMC+n!Dpi{5#<5HjTL2-+htoAxwL88dR7h~^<;mOUJ@vWl+ z9_nk)UbO=#V3xbY9}?P_V#C1mOo%dZ>&)9!V%s3`QKH{=wCj<-3c0S_y`0OJU0Opn z;&2oF396uiW%0;PK_AAE)jSdHrue9fd)_urCS#u{WlB9S+<%Te%z@=H^-WP`#L{gt zZkxEA-HWJ9a9wkgeEmnH31M>88~<^e=?1ZiC(Iy*Z#J5k-oRm!xI=W8CE zGZ7;4f29i#k08+o>2p-E^Nhu+5&*m`N^Dx9`1&3>qALAf6*Iie%0oLtbVt%Wxec`e zf`4h1o}_Jrp6&z#M}LCiDyDZ~&A6zm-aA>v6aLkP5~kT-dYva%lgm3>h-$=~6s#|u z-wIpZKIZ(`c^Mi_@SO2b!!I?N(ns8e*{C%Og*G9-;y-%ruFJzmP;nPtxS3D`^ppEc z1=VxkmJ8=_UyMv&hRW{kbKpTIl7GZ)=&4c|4yd)Qb0QvVs2DbZ-R}aAW3k5*5fr>> zauzU0cd1*mXvf7R;>m_sGN9?QHRKs-KM%jbBy@aizU(bIpRD!{!2>`Sd=d(Z^qY>2 zlABLC+QxZlJ+19F54&xSlaKy#z^vn?H!>F~<0(>0kA|<^AuKVA$}{4bBsU0X7_?qG z*fx|LRC4V0q+B5_tI~N~qQOe)C9Gb&7Pk2#PF|y%z~*m12@tSmYa71_a(0hATrR>2 z5T656v+qJ{0X!>)xzaQmv73X!E;SA6<~RjEFJN+LbN(T`3FECoiJj)u>g;qQ3yxVt zAZ<^a zzWF)k*925PI9{6wh8*wADA{FM+F)7~~dp*iV!9qM1ad2_yCMtLb&{8fnJ%W24E zY8z}4jI3e9`dushW#zAFn%95BKE^Q|kWqDFBn23eoIHQhoz~&mYnT$y5I(^7!GUuq z<^B)37?fZ8$E_IoAN0c?DH&i?PZzQu4Uz zxL6+MsyO_!0CP8g@rp4Hywus~aSkYr^d=1+OBIq^P4}}nj-xb5d1_UMDgTB3@5K-1 z;r@rwDle^1y`&VMf|DgA;ABXo$~X$B6ox%%l6zRCSWlFrjZ4zu0pEvt;$^4MDu*Bf z+4IqX?%^5wx4P&Uz`H40z$&8qIoIlUq|(|aw5bDvrWWp@30dZGd&5CKE*3cJz{Nvg z)dimEFg~%jPCG~MlKY^?`1>wMf8$H9RXOEpY<)zDbfXG@vp3CE^y(_}hdzI;(t~c; zI(QVdQKkVmNcdEnrB$b~3G2n%r(WEe&vwF7?a~U9U-~W%_lAZh0BjM-ztb^J<4>L~2M-vjBH=~_>BGoG-*SKs)I1O9xs-gj<=TE8#1^xaFp z2;q385Z+jbug9^u27{-Eu}{+I8VcP%rdaqa-**+*)KtL4TSv937KzL78GbvVla5$& zOCIdPf$C77nP7|lNPT1GWHu+m5WwGV9dQ*t^t|`|nUR%7DN6g$ppKrpl*Gx8xm9$$`Bj<+=UV@xE z!^$7GKYZ)WBOv^x88zYb0V&i!0D3`*bOwD1;67I1vAn)0s_yBSD{CWNwkBe49l(`PZOyzr^PiVb9i& zte}X!%KC`ekdLeuUA6o^xN3h8cmUP^Hl@}1dZgZ2!?K?f^wRbhYI|=!j%GJ>PRgik2aK>bfiaTalkdKJ&ABSXeW?+qr?(FWOgjh* zcr6u4-Rj-!AA!PFe%CeO;Hvr+vpX$8aXfvsJfD8hxaHB%d?>zE`pV9DtiqjA z=T={>2p*cBSUX8dSqSG2oVV)-*yexQOOl0c%y7v%*OoKdA=X9!7H@e=uB;HND)GW+ zaR|+!jXF5Ub!Vf#(et&%n?%Ajerf&89hJZjH(u0GuREu%GoNuhJ`{VCqj7uR<^ZD3 zmE>xSzep23i8j8#56z!{TRM4hWBu2$n5%$KbnT}LL%Y4C&ju-5PyB+uY+H4S@Vu)k zLIV>{mA}ps!~plJlFdo5V#+swBVcr_F<96IRd1{6&vB>-65td~MM$M3@!7>Grty>wd|p80%4@!4_0Ow?fbI~*=t_1C&b zF}RLJWQ+6=M&Bs+!|Mo#{RebX>Vw0H$6XQ<a!MfbWboo zISy(0E9Gu|X^F3C6f_(@gXzsB063is8nm-ZbudgG<*o?&eYUo-Ly5QaVchEYDr%E3 zi9P57MdZjl7-qx6+-j_;8iH;UYF!u*QL_M~5vv2%wDRs<{bZ;u?NXvueqLPv+{g9n z_wD+0?i7pKTHK)=)uPysy)zeSU{=#5G8bI9I8WE|pXYC3TziEU_e{7iXLk%okycql-w^Cc;vMj(R~GlZC$BZ4DwL?oeM02oq0+q7%K%M; z%!y5A=r1t7bftWXEL89&x!yFx!o#h|Ip*-z2>$DCZ^a-r$P+yE)W3Q)@cQSTe)64! zX(SY9x90P!+S4Hdm~+VNi2~IjdG@IR!Kc^v_n!oy8LIVQF(n8r|iPu;3NCldbv zo@7e6TN-0^#W6H++U$6BFKa;6&zASk0@*PE#RgP%RywIL#ZVnxR~c}w{jQ;h@0Y=T z%M2~Ykb=scM@k}g&b!0B=fgl+v$5OvaEI-8lRYLtMkBTSr53G(z%{`hM z0TmYxHzalN&9MNe{`%GjXIa)EE_)~9-8T!S41zwgTO)8g#R+U0dr9`Z?7~={^w4|X=n%=0 z80XHCp2Z}rCV9u6)MH(GT}AE8f}If||6794z6-{)egRQ?rz zZ6gXic=sn=ol$+;>3EmhsFzU#E%Ztwu=Hd{WbATR@E8m;C2KCOA~6?fayklJ4$Ujt zH@5~YE0v%GiLAGxEze`3&b#Z|)=Pfw{)Tgzi{>cw;`$5dj`*^ZG6~V{sNt-R5B;-KKUt>CarCfc|UEB z&TsRNJxkv{K_!$uX6DI$%Faf43z^*{)Y$Yn+&r5rTY?ikoc=Q;l@W^UyKGr>*jL-a zU%i=c?`AL6hj|zdo2C~129{6uSWKK#H*XjDcvsnbZH*;r*u$t4$pM?)qfh%it&IzE zWDvyxJUKtaNPZUPfwz=K)$t4&x@)lF+cey|2^D!tAoJ6&QCbm6l7g=$D31=Sn&?Z{ zdXLngqL`a;F0dJu$eLb~tbQEbgmD+lBPP2V_@vo^fii+e@bnoO{!in7E=)9X*VI0U ziY3eD>xTH*D+>$TFIv5=oL$ugU!-kPof3B!90RofU6lljboj=^eA5`9mFR)qk7xp& zr_dnE*X4G&4&qt&qk~TQE|3Cw{_gzi+gdm;$DgtN?~w!cS>=0x8lX!tx(+E-F) zdKTYbez5Mz%9ZTwBs)kz*u$VOdtInK9)!#-*TUZ4ZPmZKyO??DF%x;UQ8I?CaAc-U zJGfg6sKxrj4HaqaC#-mSB3!B-gYe_`%fxN2^nVdidj-LVEGwR_2Z8(s))e(Cd#e^{ zQFz4L!=`@X!V1Pzi4Z<=MkYi2D9NTv`msLT6el%l5xoj(|#$kPLF!_NI1 zemeTeujq0u&7htAwnjCtl#?=l~h+;Ys)o!0VU~XKZWz+y(gZnNF*CL`VDYMuQzB)re*&0_pbniweZP zL=XM%=!}h4UY`Ke)px3HXLc93o!#PnQ?|N4qkHETl=C=`sU+ z^K`}uZjWM9QULgRwQjsLZF=Ct<&*0X?BbfOLz^6W>k29^v|fWRL+gnG9Ur@DT>fHUIfje1C_x3DMr%w`LuwKeB^r!JP+XM>FW!an>>&zck6+Y zV^kZMt_W}oO0|-ZBE!)QP8Wpc1(Giw6(&JE^KRVRxAJjN&=OO?8r@W}d3V&?gXxhX zJHL_u4-7gt*9TSP*t#(;v{j`oAA?YJhC$ANI!cso$?p5L29uq$T3`xGwZ!46o`-eE zlH@-5E*8OpfsA@=_J`|)`Zj_-c}t8H6zrxDTvIiD^ee76h6nMq&Fnc^jkibyr z@lfpx|Cd8@!GcLqm)J`yf*Zn_{xRlOZ6%q>UugfUYEzDbbI6I|}z3d?Wxn=XWU`|@Ud;`pP`vrwkG z#{C!4yXuWxS!rxXT*6fmq&Eys3^%TD9B z9NmP7doSNQ$B%}3Um};wpIZ`dKKAv7|C2cb zAG%Mei#^8!G8tgLD!KfOMk+*ktZ~|^Db~6`%GjGgN?1Mg?_v>wC6Nv=rGaNoy954Y z8+$44)~BD#g04;SO-{?M6Gm_F|Gs_^&x(u$p6)Jy7w6Tz{9}zDtm`ym3|1!{ z73omxOd9@SNC-@|kpQHpirqlJ37)?)5p6gvNRx=lV&rQ5o{E1qnhjr0V7_qkMPxvz z7^hCZ4AeyFfl>0k(fP46#{8f1jZ;RLZ^q5C&jy3sgbP+K-tHGlM`so&w)-K=2|S*Y z_`)zhr6);hg-F*;Q$5*ynvBZc6dSRA*DRES1b;YgCCgdNVurbh>p+b@hLi=8_k>Arxcef={X$ZG( zm70#$V4k3#b29gmZg9EkTK-i|S*P=g6UQ0z#ldN8A`lIVy8TSgCQTZC*cVjno0N-P_DFvEKSuk1Ekz{Y~*Da>T z>NY+^9R)w(I9PsrO2)4xK3`$O0EUM-a6da1lX&P7i2x>9G|qH=9Tgo7KU#>=>=1J^ z(KRF)fG9J5A1mwK#GGfD0GaX(RGS_7a#-3)uzVz>{g79cRZwpmG_#UEn?Ep&Pj`5c zGo*(@kpi{!n>p;|sHj-t>$|p8Whj+`y59x3#4IxWQ^;Jw?!R@A>939angOGbdAX2- zWQ+UOkZ2+l!YhU};r-`imR= zwK_V_fKp<9wY@a~c~Xvzs*YSHaUgv6BHOhpgZ>4goLz0-IJ320;7{olCJq*-uIT=^@j8ont4MQ@xhz3h^@HF& zWYompc7Vr_qkjq6NNI41R1eu;*t9@`PK|r_%fCerhO-vU-AVrL1^JT}HFP8KBw#*w z#x=(XK23Zp@?GTbz>h=-{)hXUp0C$%c(ooLEwkc>aQbHGNnePmQB3uqb;T<&uLM3q zh&#@E5M`pC=Z2L?9oIsqVSFF{;3?MG@uy8U_K=KA_4`JZdIlEwR5k5|rEcExuO^&o z{M@m&qxs4qa=XdXsEAMfQ2j@$dLE4Zr#;$zb;ZWc5Q)yw5Dp zTv{Nb4~_xAi92mSB_eKVF43yYrofvq$yLKI%gm7<$6d%`3qV31TP-p9oX=aMs&-x038G8iwiO`_exM0F9|%5YJp6 zfgnSggQh%`?5lpH4XKeq`tb(>%9WqO0r?*#NpB{}z%&Hm~1QGW|Km`X)xh*$)I(>~x7g zz9dnI2qnAw=1Df+3Luh)1PACe1*_&sek{8Vkz`+F+}!vS`+23Qq4WZv-g!VS<2K~S zM-jS^lTdYlTBw6-__(Z)0M_M{X_W0+Md;iI-f+y<&2$ZYKb*$ZUidj)QehE^l3>J; zM^!Wdp4^N5t4GTyw8Tk5wd?~@#u2N2RHhnht5Vh9Z=Z}s;Q0(cI5@kLK-G#qGm^C&3 zY!_(sCZBuw^Q7TuOrEX62S62U-fgxo8WK}4a6zkQxT~G zt5T5|i1F4qm`9jkmUm4s5x_LjU!X=-mcwq4!BOa5L0WhmR{Y0axh{Mlq@7pP#~GB6 z$a0Rk{Wt1B<%6`vfXOpj{a_%{C-iN*txpWo`%2*)e0T;~PmN3ONJO6_NZ-3nk8Lkq zY?FTO=W$|Tg#qJR*?i=;^Y#~5el*#T%;}5cSfZd4gu_1^WB2(~mv}KrDRdkO53S%Q zjPIhaCNGLNI23z5KIuI5T~EWAuQ=34jy6?-7q%HSIT^goEBRMVmvVIa*bEJOk&;Q@ ztaBx_Co9|%Xrp)WWxN`{jVXAPi2J{6dw=J;yoImH%I4ef1tWXm0|YzIF17ev&5c7W zXzrdTxm)(ldc_c#VJ#MKoMy+rifUemL$({Pm3;i33dgyZ6B}whP z^WYwsLphVHuF;bv4bA|Jm~=_9q)J zm(S$kx#!lLkLEwu2dpq8!JS-1BkXBNw+5LKkog0$i4ej+W3T7n;l{cxJU9>gYnB$k z8a3p#YxTd7vF4}FX)#YX3C z%!z8Kk5$7yQOT>Y9r@nujE@~#Yq96r@e{3^yi7~}ZksFoIaGV6D#x_FSFY)4+kZxs zmn(ex)rJS{%3E$VcBrn**1r+V@cxaNqv2|r27#~m_1?WjpIqsbQbR{UsZLI+k7*%! zg-Y+db^j1`y0_p{Xj920+TS*^z%--H>7@FRRb5uaTAD4IQLVV8wKS9>$_^=}B{|8< z)~isxJ%sv-|HXHg!%mh*3n9h(SYz!YVda2 z_N?^#db-Dl%j8#33z{i7GYp$&LNYd5DCkJ1cPmvh>f}2uokyi{HhbcBy49?lXFk~a z+Oh7z!(WU+$kmf|%po3_WBIjjO~rT`#5{a{-|k3ni3^gsUyu!7MTE8msoW? z7-*_%BH={2QMEHb)tlXIt24|0KK+ii(!-nd-H)I+lX{>x)R3(PQ!%p_@!qFxW zEzagCVU0306E50{x_R2*RfFr8_BN_?s*7ViZgmHal;}Jfx1;}g&f?-C>cyos(=^Y? zwZl&Hp8J%8X-RU4#+{B^68MJp<%J&>PP}A)&ehJ0mRiadrtP}B5lE{2!t9GyYt7^! zL;v760|mdsz`7k4ubHIX`=nXZnN+{&fY($pq}i5D55wjd|0$^FfECock9TZ;!y>2Y z=#N+HVv&uFeG%c-aSISRQKVgkkFK4hdvJV0kTRf$nLE)uEqi6lnz8wF_L{q14%w=a zTr2jOi~^qcNlohC8fBRH6j^%?BP`eNy!fkS(TsHKiexp__hGQ(hrNa4v36;05#1Ws zF%oWjqv-1Pwp>vUn8Y8o3O*peT8^+&UeGN==s{p{ts3j@eC`6)gl3CW<2b4TA+$=;6zkR}2w~ zA=TcfobEOd;Tn12sgb}hMu_yWqny-bFmgPP(|eDek7Bazk;|&Y_B*!_e|xjk zv<#)4ML*BuybV27h(^N(72_%BYf2zk#}ts-HT)#Y!oQf$t>fx2VaFvx?6~1{cVqt0 z3H+O~a!FXci_%0mq2ST@OBgB7eEL?b>E=ajz_+@(OPC{@39u#+frYSio$rtMIjx;< zXC^{qeddZ28c9#$pfxTJxTgJl?WfWTINc>k7zlOeZR@?yoYwt!Jl+V9(k8JsNUzB8>77)rWN+D< zFv}qxK(DK8(T=F@@GqOctdv3}442~h=YxgMX&p^h`BQ}uGlHD2ELJhW)WXZxz5>q5 zAsGoKcxngVv4Vs1h0D(EF|^>-rlzIcpysQuxFAEx)@N&VO$xgT!m$O`a{vi@l%){O zNbgsb-N@me!F*8)F?#h4O~M3eO4$+AbSqo8<8sepXl-~Uc>;n^;oA5AiWDT0V=P=P zTpSk0?#fWS4fGF|-n@%2hI&V`1zHLvRn`wES)f1{Gh5*_U?z+<8+LWIggCO-|D8GY zKG^L!HX^1GKtYw_TVW;bzZ!6h4g$<05z3HVZK;zex&9DS`6$Wj+$~8a<;phpH2Ks)$cVq!Sc{7bHPx^|lkGa^YJ9+&f*%B5<3{E;sqPV@el)jt| zyZ64_u_uWCBQb~O>ju0R|md3 z&Hl&J>Xlu$0`a3MH?mkWTpaXI^Dc=)>AcU88eyb7qus*dIBYg_mCZ&isnt5rYQ1V* zGo4WnS=TsH@1N-_WIC#RV$79nqq>%jrhV>YYb;XZwm+1)g-IL`icEiI|H~{KLle6T zd#Qd^!<}`u_?+bzSQII7mTs4N(qKX~#KnB`Q|wO2U{)y<)>DGrnqBq!SJGAIA>3{+$M2Gxt(-@#N;4O7 zdA&)~bTbLp-4LTr^g}5V%lYwWi{P?So)|ZEFq`{|5sX${fi*Gzt%-ceyyJ8fQufx_ zR}!a{@Dj3-4Zn6)&NmgyLq2YiiVrG(I0v$&ZG9V(JM!SA;=y2FbkM3bh(K zL|5}U1WFu#ik}nVCgS~uxV3bM#|Z@O_U$>SEjYgpS&t6M&Y1YB$_YLS{A;2}^^P|U zqDGhJC9Q1ecCcNG(J5rxiF_-Q0weDv~pqGiipbCoC1Ujhu28zFo}Zbi$_IAkBcEh8z_2}diz zO#0_H+v@BvY`Q-E+@nxAi9#E+&Qg6;=RTP7!oLCpS<%(79zMQ-m;&vDCo1zU6Oxy} zNHLKq%vS796H^piT;XfQ-cL=|>{qSWslpWd4~$-PyB0W)vdhyg26t4rXWtv%jje}X zJB*Ngxi;{eOf+y6$iR{!4$^p=4|QzHypDkOJmR?QVBDTWZs2R{9HEIKvpMVq>o(`% z1L-)y;!5NB@iy@c1#rcMol-O9_RDtIue8BZofu6+UwT~xKL1w<{i{X(X zHVYd*FFw_bq2FK&Vr zd0=MoF_zmO6#;lEMU87d?aS@H?ju^WD71{cc<4pI?Xa18+T)h6c#^NazLF}!y_a^zi~?#Ch?3 zf^{Lf%;V*Oj`LM7pB-Oc%`|-Je(FR$Q6|RIPid{?b*j|+&0!v9mVBzyYF*wd;i)ze z&zWid7WIkj!;0N!ADeN{oBsas;-c{^@F_<~Y#v0T@Q2EFb^8|^rfVItB1Ls$2dDP} z>zKbHRHIOlIG7VLaAS~O)U#rXBue>YkdyYs`RE}3dz2V*eO&zX#nsUyl3BpWc8o4j zUQbg%oO~#=(GO02+dp%C5<{H2YOJG+chA{0&!#&rDtq7Ix1)0%I#%(v=x(6P7#tCO z8L^u;9ywF$UUmDLa*CUnaRM|->(pd!h-b)0uIm@}SUiXGV}Cto)KkGj z7{j~(z-gP>?BH#B*(%bsWXgBQ##vqLObJlAT;2DZ4Huo_G7r#Q(2%25#)9)V4#dVy zD7j%eU30MR<>M{p0RU!%#SXi!WG|Ra)rbtWSfT<+L{x8VABQC$9Y5u8?=7E4>rZrk zQ6t&68LOG~wks)TBMorN)kq<3A`?#kNRim9Uqf3ff7|zc^u~E9LtARBudXOKa1PyZiW0mh*x_PMCRnY}lB?_5 z(4qt>b-)Kn73FF6-zd*52noY;l1veZ+}cx}HraD0cMj1s=3ooPDQM($V{c4LMv+v0 zBx^Te!~9gXeWw4Rqw(LY3eKY`}a;g zks8`@*xu8F`^rq-(V5Vf`(=Ng!VYDkHwQz}r{QE@!LzD0Dk-s$Z_(URw3-PqZj_wb z?F!ou?{>+WERGE;pW@8nVjM!TbX=Ouxjw03p#Yq(HaeTSd1U%%AfqbIIA&5Qd5}ed zh(khw!5B@v;N__8APsSn3sGV8;dy1O|^RPs{AzKRK`SJkowHiC5s-_Z^Bl7%C0f?NLH3@)9T z!D$AQv3DFNZqhEwz^cKDG^gV_rymu*A~6T(!eIzEXgP6&f!SJ@2} zLbEd8uoK%qf4Ec5 z2X*oYNt{!grPbHBp3^>wZAzfhzQ8YhmfJq){yj%*BQikemo)a<7tBa9nscKDRY z1On9t_D>S~4o%`nAh`g6v|X8^LA^tzE5A#F@aDTeve2s;i41fxGkFjs#%9ZeNgv%dFn_a*YTqsJ^0=kX=G!dk)l~e7HD}>F6Q0vjk7Vdz4u{TnNGy`1v%f*+4w_o<=VmlzfLE0 zz#CnfvA)>QUz6&#S3PU@^6u+2xC35R(o#JNTBcSrBt zA*-5sPFbMKxw6s8&;Ed@QoK#brytPLhaUDOkYSHfb}CfLOtGoqBHnsO%mlVPt$Y1b zd}95`gYUQ8;hTKBo5W!nxX8Ql$tCk-!)(it{GlbP+z1%?06GGim?y^}D^BX;zUM_JRd;2F|DmfDMv6BY zA^f`k+txBdwpV4cIbT;}sUJd*DE(FOeS!|rV%Ytink&fAPgp~hv|IGR`ev@T@)tox zk#djaj~*t@`0 za8X^I84)44dA^u~Qe9&kxHQyW=N!qOvVBxB!p+3s*n$aaawOaLtn8k9eQ{4gU8&m1 zS}y#-Z04KS31x`DXh7tOI)ekC{jOH*=SRc}oXnfTa& zYEhG8Z*>HR+Ve+F7XF#TLi-Nntlhtv1#vNiKHp!2xRZS_CHzcY}66nk0(DBp3eQo!>xue+dPto(#S6&^RD9PjLL@7YC>`CH{ z{-%+SP}g|+;weg|A3>Z!ub(wPW!_u1(_Ae?-L$ffvj?{(&R)OCTtuykYM%fUi@li} zIC}lSj2v%z6I56UX*cD_%#1kr`!!1`v6FG^A^K%z2W&V2s;=@U|Hd5@rTs}61{g+P zG8d`PnQ6U32_su|6GO*@ZS6gi ziqNqIUmN+_YY2rCbNDNS92+sVM_p;Z5sy2dqk6LiJ+E+Kl~Xmn>c?E*LZ#|KT2^^S zzCWt$07EBoS%mpHbd|15k3FE*y^wQNN;hA zcRsd169$}(QKWVyYV5GzS@2Whyz~k*W`rth**xFR78FhRRw!#7Z;Ozhp6Fjp?(59J zYTcBEyQ?9CSvoW5PTMm2Eo_rTM)8k*22^S_mtDrK;Y8t=k?_x=4>8& z{|40rTMJUYx;eTRrOLd@a}8ZO8GvALnQ|?L8ZMpS5IpRCgbcT~bit@(+|3O+(&*Zf z!`M6SLs8d36xQ$8*29*ZYm$B#dj3sY*&8Y3l!-u3#+0_MUH|)2Sw-5dwIzf^I&Qi| zC7MooQikV@oGX#LP-X){zGlrQ`y`` z_aXY7&HRKZnxCoHXrEKzCp1*L)Y_2|m3el@?IZ7{j`l)VuxTT-C_CWqeC<8UVIQq; z6j_mwd5+yTzc)K-BgY&fcY)hg;RR8(Twu>~juc!$PdT`C-jj`z7}pAf{Q9-+o^UF2 zko_{<6DVxrBsgyiaoTBon`7c69XGlY2=OAL*)od}B`))*N+ik?t4Vg^OB0dZ^7@+qH8HA*@+qnacg5=hPNrfil};fw zHG$Fxen(8u5PQ9^cWoo3_1a)Qu==jj`GzGJm|6lR^J}fWmP){)SN%syHnrmU&}j~q zK~_VMGSL23l#A3#ku$FR8!z&XSLg(K2|G>*4S<#m-Q1{bbFv~#YRy2Y#aGX&bQDE_ zB>&-t122x-j1^4v&NRq<+umLxZ`yUpRfQXTHMys_Pcm-x{{1yY`L%_v*IWKRl~GQ*XFbPcz-?<(?vgEp6Wb%I~w`9S91;y%$iUpJef*^tc8q*2jHh0*^N~&m_X*{ zw^b*q;N&fvt~Ih2#p@Qy$<7nI<KAu3hNF-LXC7u;e5Jy~p)D2%jGdfW_1YpA&R;<(?ruUsTI zeNwjl_F5A)$Ll+tEYa#Bq9^J7u>pa;&F0xAeE@sOq`BN1+?r)nype5d9LfMmQfY~b zBW}RgC8-V#(_WF^AKMEcpPQaoo_Y`Cj{>o{7Ydujt?ui|QvLDyjo{O=F#*YKjZ-G# zC)2Otj!+~YdL9bHgu<8*dVT~1@H$g=N*h-p!5DuE^lf%{@O;E?=ZP6GI@{BiZ_2o` zkQe3B2FG#hR5HQc&-7we5h*u+-4WoltWCd=W$}{r zLL2Bs7wQBTmGdcH#hZhDJZ3CB9`GLp`mx3$DsM(V~^ zpS?q>QQD-~?*3(5iEI1(8{!D1B*jV;rVewfX4{3LyN{=|sT^scmOt&dfqc zNSIuTeh4*S!&{{WO@UL%dQ9xtYJTBkBV|1MyrXm?8?T!KcEy?f^&OU$-C`wnZ_WO3 zQdb4lEALlv|FgY2$bl1=F~n|TD2uM(LO1JhzZI0iqtG$s-(mLzy(Z$QG zaA1D=e#7C&rAfHJHmZ}Ao@zQ4c_xx3KhyTYG+3K+2IxDHa@5C8knwiT?+F@z{QVIY zT4<6ML<93yrk`KZ!OfI_#(k4N^Jdq66B@UlguMZG;oPe4hdrX!2{1W-Ay^aSJC-rK zaC03ONu4eIOu`WvNM*|s;J|Aq3$$(f5TQ2e{Twhj2_X{byY1TC40Mn49!3{(jTRiL zZCu)7`V>M0$}tB9J%0VYoqR=Dx0PF`t$gONFm&{6^KL5{fy+jPnPF#eH#O$x@Q1L2 zZ54loJOkrydPrMPl^KqIeIo%=d)jhXqvR(INv<{Eg43(2Q@-#RWy%i)UzUP~@fd7lyM* z_y$ob33HSb(VxMN$nLyWRFpajJ=bUSDyUCjbAD*wT3E%h3+`Z7S{;;`XK)~k_Xk(I z#EtqhVYW2I(&)p>4P~V9M1ZJ+bjrJ3zv|lnYI0))*9C4)fRX;lp>U0Y@CPF_v{9V< zy?L?VF_L?09myNvP(*IB@i#Qw(NR?_0>zDN%lSkw!K#eeoys4Ul+8t1&9d55Z^ZZ> zm1!S6N3^X9S+Nf0uCyuO>k+oeYl6;{pjy)arKvoICMNATe1gZ72tME6)GyO`PC{?M zV86zu%Mw03;1wUXiMisbd*(5bg3LJ2L&&c%q{OS}O+D>*)C?%NX1na)iA z^;6CgBSPZ_vd{67oi`KYA9ZMpqcmYcq>=s_9Vg7#G}tI=e=z_3L7qAz$~z~StWFUD zjWwL)4?$r%q-5X`U#OG}*!;9lB3bm1(+CG59eV+B(ip3CJbSOpO6@5fSg6mpUtu*z zfatkd%8_v(LtfQmR<6zJ5Ila@s?jK5PtiK5rgsRocmJD2Bzr+)xD zDO}lma^%t@45cj${*C!LC{`wUI%sS%T-BffiTu($E9l!QKwfvKkKb3Lz(Iu_$=-h1 z*HN9qDuS|=2%7%WyqoFS42hMM^ih)y6LmSO1b46BVm)(x8T}J3Tx>p? zMVVkGaatmskC;elQV%{3b*`3PJSNCKY`_3jp$xwc% z`Q43M&9a>c|Iy5Y?JIhMjyj=ZSYGYnLa#Ezfz2THTj|GS6W&jyMdRbcYgo_Bp3N2}C^{<)B)Ktfo;> zjQ70AZPX^dURu&_+=)d4vP&NG$lJda;J~utMv`rGp0e}8eFXYO^2YaY-c7kf-M-mY zq0qx-ClZy#jb+FTN4=8=k@!Qp1P{AdU)u)CiZj)HQ9%7sQadgtQ+cthMGRQ{U`}80 zyili&Oc@Xgg;oq$1zj!Ohx!k24#d5bbr7}VVDfl{mFLr>u}Y%i3YersB8~DU{S@+p zVhm#0YVOr85=uDsbGKh|k&T4=jNY1Mk`5MDUNlm5Y8Jcu5!ssL0l{LFmB0RPyxOSF z?F6H`fy=D)>q$=w&Q~woEP|#AnXm4xej~6-BLqDU8y#sRO`jckC;bm8iuQsN$pnHp zBB)T#&*Ij=13RnKdFkk6O%rwlmRPtOun=yV`iAYOcY_hLY?}7P+Ih27NFE4sycZXWt)&fYr9-ER!oEGyLHNMJYdH1S=(`PF6OY;X!8~lfaWdywyP)r3m zBRE%&exI3U(Oad`{ik8WXWMbQqPv(&i2gL$s=T?>N9ZO;R6d4!!`Ldd7MIdtrYFGQ zI`22qU6tI1If8hnO}@zuV?*ACSMz~;kAn|Lc9CW`?#(m551mjO4v&(9p0VP$=AJF7 z{Y>&kYD}3e^u_%$UFQWoNBWcJpm0H(f{!yLsPxc*rCBHSlB>~H$2eM!IHi4!mDB7c z7anXs?Tr!Y0NICoEn55s4np2?X?#o8LF@eq80^fuyiDPb&a>YsRqiQ_2wQZxr)dO$ z1=DZaxyJ1*U~Wv$BD3%%F_+I;*|BDK5jYSks>XBNc-L*Z;|mx6>_{FwZzNi~X0nY{ z>$$k;HlgaO`4+Kex35gK@bv~Q~_@|9ja^>CetKsNb5PF!_hC5Ur!!xqPQ%9zSQHvW z-HqPeC>@Nr+_pHg?cP(!>PqdgfIViL1Ikbok^<8UKuz) z=N__ngRKzxA3v1(M@$tj8+d)b4L*2IMBDweM-hB?GcjzSKyi{vb3Lf$_>K(MCoLWjfv21h zPsZmA5I+2#8n8H^%~o0+i{yMQ_met=_V@TY3?3zVP7dMX--wBFMcdJz?7>VVj&^#1 zcT?@o>+F<)ll_PC4BU?p!?{5|W8+W4qw7*&W@g)(^?h%J&qZ*#zm z4ogV}Zqu9gnwl!q{yX>}=fm{Eb$J|c;b}-Cyz|1W_0I!o9V3ZIm%t8zv0BCvtK@L)n#r+QD z&c6jd7z>{|9Ve!Q&~FD zJNWQ3gI#@%#vgFHav;{-Kx_h%-Ja#1u0RuweJQjEhb;*uHWio)-QT?kH`4_|j*5Jk zbEML-2;`RjWc`cjttmOW=Nj;AyA!go-{^U^{Z{7b*nAbH|ITc$O1g=-LQ^r-1^dqOsYw-w?3%#N;sYm$EPmw| z6`GQtG6LmePMH{)ADyuP?DF-zS1toD{jZ9$U4?m$$Ql_d&ROic;!`YsCk=HCja$j^ zBitYNve-{}QFyC*WzI9jxNzb^s!g$VHIfUEW|Gago#L?+ch`70yz(^Pi{wbK$+ky;y80D!1+Q)~UL@o|!m>n#2;7DYGe$b%>^KXWhKU zxkdi`;UW=b!uXBwN!OUMyQ#0xO$VH2A(~KHtAJ_vcy|(4Hk79t%Ud?_4C;PNPlWaG zR<)(SAi-EKs6SBWR=6#88WV_!F-l5zE zj@^Z&!~(c*=NIxNf)|jBKkufTkgN~^rLv{yUA4OCg`dDg?W_D__bE}qPb-$g`VNu_*kw!HzJfWR! z+mQ)6P_>Dbf%x4$qf$DjI!pKDF!F^c!t(C)%1x{uY(})> zf%%I=v7;~T%~BUD*uLJY>i5u-tYHBbVueoxqrApAq~U^{vh3Q)xZ|6C#g+tD;YB(Eo5kEiZr;~o1D5mRA?BN1`($@S z?5tfKcH65@2Prmu<%iI7?NS0K-0``@VwHTs#gww&Az~}mJXXJ?#UHHIY>~8&HTyj&Vgr zDBX*(DCB?NiJg_=QT?zD+(j2uezLu|O0>%E=2_}^D}CXwZ*W_`Jf>N35%iLlk`6xT zCH%%yc))5c?)z3Fxuo5_kSNgCg@;xRUX_|8B?9<24JNG@_cOKz>603b714=8JCFoq zDjag;(Td1iH04*Y1I2@AWP?ZNRf(F;LK}^xd;9lcNJ4is+=3T_hN~uw`!QqB;|@gt zb+~JNmsuS4jMVrVo_49NLcZus0c2b+B?+8O+V(##+<2hwiUWW51<4=)8*crB-KRfk z%*jTfa6IA-=`*;T$PeY=57aZE4URgrE%gCz3mw^ZCPnGkXfABOja`5WnwyG)QnHFp1SRJ=1^WE&9#OiZ2uE-h{>Qb}B1GE1)5p=F0d@DBjm<8k{ z2yKP$Nbl@i$En;W89BwWJ9=*WikoKnqG5wW(u}HU5PH%_5hr!%!ShygUo!{(H4C;Z z=%<(?%q~lDL9qc-t%S$QvWtj`>I)w@0nNwiM_X`{-jn?Pp#dGGpdDwA$e|mPU3j(L zFAvCAD9#TAbK+Lud|Nv*iKjE&Eg{I3Ir?i3@=|_5P6!nyo5wU^4LHHy6n@=_BuN-`%Z#EiG-s-4iWsal_sH+kADN@$vDI$;o>FzRi`jOCX=V-d!<3|GpTY zN36Z9v96=1t-Y$TOrou)xv8b+OQ`)0BnfIJ*mNJ^y0s1Hva@DRe-S zgCH3!p>J#Mr=HihdXbkVK1>zpCOBRRPeDkIsmFwj5PBRCuimIhafUVS^=q-Gg(o1@&FEQxd>!NHa5%k3ai+0Mr$F>O+xvrO__*s&Rv=7W>?gVTleHsqx&y^ zE}Qo=?%iR#I{9EQPC%S}3$MLg({JzaF`8QbNf# z4f-n0cUgX7F=FRgeo>VsDsolnVic-=aw_Rjtfa!tTtSSD^-LFc_YHi!ZcWzr6bHwg zo7}+K&`B#GET$bb?KuJFJ5$nk)jst#ghQANs?-dpQq{T#eq~nlqF;%V=EKZV(hXpx z-muz7H8d1PH{7O%M8+=il`Eo5{R)|Vhw?(<<8Kn3x0>nc>GV;r_X_|^6BhP1kr1qY zua0VdGhljEzLLV#dkw*ud$2_FvcsoE0~gFTbo1;rKK6nF3$^@G0%DZ={c;6aj?pSYhaPK18nk)to&=h@YG!GD zZRm|?DNxH*?|+No^TOTyo{pCZ6iajlg3->r0)Y$@`9L6Z%Ozkxs9k>pR5k+sqX4Gi zJ;;4l2xM%3M@MW0lI-recLPcGibYI-Fxu`nPuyz@-)w@)nq`7$@nmDP#CE4S2+lDa z9WyH%8W;~FIS-rhaJBV$xvu_5?^ks}-Rp{omxR~)=UxH^%ge0o?Oq&UvUx@0K=U|X z_pv%&)dn9Q*DqnRp&94b#%JvT68VWuzH<=15M$72)ensGx34Nqb^eaEH>ft{NNrY* zZx?K4XJ`EX$ok5FsJrcJ1*Jm-q(Qp7+aMIAo1weAQ;?9B?k?%>?ihw{hVB}qV~CgM z>V5pb+|TpnoH=Xnwf5TkcaD&KyGFV9r<3^j#MqWLp7x;@+2ff7N03{ilxOVzbN+)MOrsP1q-nh1Jpf>$?D&h=;Aul)4zbzUx)1M(4thm zK>N^mtgj<&Qw>|o+)iX19=7DvYcuD!4PpJ4$3tm^_H$3Mnm=q8nQv z1Mvkusso{Y;Od9haN*C$;8{2^8HYZIyx?hH(undUXz#(=9bQHTg&U2S&f3Alr_!!ymW_lq~mk0oj7O&uAB) zqh+G;!=58Ki}AufbMiY>er`5~8YIT^yay(^1aI~W=$R*tO<!3g+w39D2@!P_Owj#!*AT#iQY+g+#UOM{>%O4@WhVss3=)pMXO!&J=fpLs zg=SC?=Tx=x{t?FQKJPn}Eh{Gk?%Si_^_Af0 zB=i~L1k_kF*aNxEkZyR(= zV~FI25uSHwFFZ`PJ+C6QU}#SbtbPwuaxZsvEp^+V(#82r^XyeSKjJ%IZpM;?1l-XE z@~>$NZ-Nquy6+t*e6JlNW#19DXWVTYR<|!DJ`4R}lE63)yDBj8YIJ~Xw6nbHlMXQ) z7AB#Jdpim~&+C9x#AjZ`U$aHL_vsLq&2Tf81$bo0suIyoWs*SqFcni4yVu4^4=-6y zXJz~t*ri_kA(eJH#z&>h7~k2wMFM|<@%7Du31cGLK+eS50lF8bx*$f*b`0wCX&yCQ>z zP+tfW@kcexs@T>2Jd)KADa~F0Tb;-|%N-Sd1oQ)jQKaXuhdoSfa8qVZtk+(V^y{Ku zp(dQnc7(9p_ zgPu=1RzIYHg-4dk^0KP5rwFYHPq~v{bHiH&wz&>lM?HY5whQRYLTtn`jNK&V4q(of z8Jd}VoBJ>-<5Mko>Z^POe zw=enI!mrj#IIZu+utZ&OCNUPcNm+1D9}WhGt;Z1|q1Df$x;Jm?uDHtZGW7lUvn(EZ z+PBniN7gz8Q!!Ejg3tx-j&yujJ|>hOtKLzI8VdGrAE*ac;wa=y>@zrHK+7s-UfK4V zF~~@pSN0{x!sg@nLALL8f3O!VwL$W{-6*$9{DX&uI$oYVP^-e4Pa?v{dsj&vaKc%R zC!YnhKeUVVl$(#ggQpL!fLdR_J-OAE|8O&N>dm|I&KB`A9xw0s&C!QhDkfHE6!oFk zy9@y~7hlz2*gvZu49UJ&4fkz$tNCg+5Tp7#ITKAafyJ`)onWw@)iX@iS@sQM=^klV zqMqUv6TsvV0c+dc{ppN0zmZqM9rl&+aOB|dU6bQb&D0sl(dlqpaYk9e;bRlIiO)49 z%OKW@5p%u+N%R))84x0!bds&{o;&ya1y@o2BI5i5NhP6O!|hJMo+5{cO75 zdhw$)&A}nwvKKWA{bGLGT6p8t<6005YHjQY13FW2ycI82d7&JwZ+}Px*4+x5>UoJ8 zs%EKk3&mgxw;5>wX7-2p=)OWQ7d<9_C#}4;HZ4pd^u0bak}3S%J3Ha2>iY&Md9gQe zb+vHwecyF7R+JGi=<7qWLSqiz@E(`Ej+(tkc-4)+V_hCaFA>z0f?*XXX96$kY#d6I z5naIkAaLz-1^yxZeGWba?UT4TMm_52*DMqi9!zJrFE4%)R+En-1_>gBCvHoLytq&? z?KWDn$4GWgl#QEpY)d44%Wc!C@iGMDPl}`{$X&vT^b_MCH{H4WAU1VQ*qgo7z-S~H zQx&zT5aA=NAlDC!QvCpsFHz8YUSO9|AWOR-g2_AN95|C8&G*n`2pXV6r1&24`N6z1 zGW17KuxmR}g@V8V;pXzg&T=S*=YxzqWcyUx)V3gT#QW2ysGA3W77OkSUrnSwrj0BN zc6O|<#$@e;gGli6UBf??5wT#+buUQ!WW>EsML&X}7*?~}lU$HtWp){XgB4s{LZgb^ z3``qD&>|}WJ2LKtltB*VDEbhCLRX3$3-#L<^^AHKMA#fKJ83Bu`MS^#xL^!^-s2Q^ z^Oq**Wt?vfFfb^8S{L;LKMw$Ps?M@vqkUaL3~WvH02nN%-q!I|lrGRX3xYTSn0>Y3 zQd!aXVu;CeD9vT(Xtr2kg+w%gsB=t|IN+I`ct(XMCCWFL8(8xfy+}JB7r#F5N+D*^ zaqtu4$^6PVI|WT|1X;uJ`&QbDk5v16I$}26`eQG_B5LR+q$VdLi)np9LEWKTLvXIe zu6YS=1O$`GRV@?~Mlvy_RS8Rew~EEa#`(wxAP@gO-@wajRVnt5G#lEUOgrBX?JSi7 zeE->LBoh~(F8Jkzdjf(@!b_ChjkWJDnBsD&muzjs#Y3(unlg=u%`$wD&*ImO{GOL` zTp;%9KKGCjhJF2_^x=WpvF_Y>(eh4V5hT6wAnT;gj?R9sHpLi}1btY)v5HJ=Y6TeF zJ9rwuQM$KrtteBt&n{}pDJ{VsBudD6rgMGmoBOghyQ007+=LdP%1K;Djr3*c$#aBk zFl%|@DNA+Ws>#7Z$4R}~FqqyoaJ4^CJZ)A(mOXJ6mfXVvvx*rmI5_yji2yqe#fn$Q zjkjDyqA4j$EKF7>qte3y{rm1-Z?>pYp$A$f_S0*0wec(K_ZF;W4?_U|XC&mq(#@tN z#fZhRL*e8P(ZA^YtLM8dVgU}@0*pUrzUyP!0p<2B~2UneGHz4Ri zvA7Q?pIv41RDFKFh2s2y=AKJK?irs71!49TbVcnsSJf6dEv$lU!ZIJ%i`!mf&YO}$ zO?CoAE=EehnKR~+2U}phh7o>TT#_ZOj}HI@VDD;8C^|tNWQnQD&KFogpJl6J+S)-p z!&X(38~*MZp{hIm$wGwvBc7as@j(cG;ps?+kcb#yPFI-k1w7xESpmq17-EVak=783 zflI{^^~%!TW)IC!X}T_Ecj?6tAqZ8$*A}zzd73K{DCV>b<=m*%<&(v>kYh7Vxu~xu z*$dgP<#w>WGLT{6$8zaj0OCr*GMz9LiNj8?Y!PXDs>7|fsQY+7*7MeRYEs&8z-WqE z`dvdB?`fl8DAd|(N2j-BC6wXx4YZsU$X=Y=b7;h_>(l|e!T2p`yF&gIF%k{ut1*pH?*#e^;kUrm|hTmo* zU*+!ydOP0#dZ}7(erA5><}@>s{5U`m*rLF4!mffy6|Sjur|rD!4J==Nu*D4$yOki> z33xCh@m_}t=j3*8mahxbBGUeYmC&<0l81cD`%CZJI-XzLtq-uy55QnaU{vdaKIwmc ztdlxxbDDWz`*7Lj=ND7#SATYN*XGBwdzy}HkkdxbcIhnIj-~#yjo!`x8qpl7sqm{e zEI;~1&Zg3Z0K+GJWF~1k=F{W)>&5+Hbl53!GHkxfkyQSK*TRaCsa>`}^1`)&=kt-W zr=00wBd_XvMIK5KSeHW%kAh#(n~^~u^sbdrv^H}cuQh))EzA1g6sFX}B`GN@?+Lut z)}4Qwi6yPLKzaMP2DZQ(XVE0dJ`v}9X+Pw8nsk|#^x_R+GpzpoI5Mn+&Qg=u1ETlu z0I!p6EFSG+7aHR%D13yVgORONXg%?wJa=PMzG7oNC|2(VZ(6EQ=aLXW;`BC+W>$DeXUEkRibutm z-P2JrTn|2}--Eaf#2Ip4qG`2eb(K!#*(}!FeyR|H!!{(kGEpRA@S?FG+j6g{Kon4E z#`m{~SfX#6E!ZLI4d^UhpM-3zn*eJtU_dP{%MgE^G1N~ zk`r9oFzs1GnC*DOn7F87LCM9IZEv^8qa9`VYBa7pZ@E{h&Adj|!cL&+q+-nf5}$a) zrQUVU6D}Vj?&8RAn@*(EO4J}W=(rXXf5|A;I2{c{3OCFVY||ZFuCj=larC!$hZbEO zAopVF9Dmfse8fPQ)JntJ9r~OfV}zB99Ty)v`O@~%*7)nGj;L_Bjs6+xzSh>-tAyF1 z&yNroqACYxR_==Q)?l8`b={Svb?+|n?S`=wQHqlcv3PAtA0x0D#4K7?hJ5(<)9daiLKo?lnf=(95^m!!?`}dECpq z0UAky|M!pOBkG|*@m?e(Gu{ z>1&b^p{X}?#8M(2Y|M0(VK4uzHq_$AzSM;iX>MmpV{EV_UjAtQi>v2}0%$w+wxveA zZ8HxX61+95lnMDKxQ6qDH_>9HlEnEg1--6V+f!0kYt=D%6$sf@JwNf5jcW?;X^6}m zM!cw|tU&Ce($;7f;_+Jd(q`e}T*Sc=i1yy-a3e@OAy^#lBglEWrEz>8XhF^f zA1xF(`?E|i*J0J;xJiBh`*rBA(eq^XZ-VF!9HqJBg@><=FjXP4a}LU9O@2pW5!K<} zcEjL9nvMV-pKOu^qH_bdXtMQ?Mbb$Bfr9;dUTW#Njd35E%2mM>%x?|np{;VzAD+qW=}fBc;4pA`WL(A3ZACVS|UG-cKX`6W_Xe*x#h*K6%`@q8`TX;TXO=;yWN1`#iF1Y0UIEKSR_A)^TOD$jlVEVk z<`2$@;s41He)BF<|L@>q@xEFkq!sZL{bkkT9;h|SoXrt>b86+5D|O1=^p2wJCX;%59BxK~_o{Kxa~lU=e9KJ2>^bP8 zer{~_Ss&xk30}>FV$E{xNb+1VFq^jcvbYW=Ly7vQ8u zv6lFR5b0#fuu4>zgxeTRV4V|dJljL`E|~sq!1=BrkwsJd3S4rxgOo3M1Qu?Sr1Mhi z?*aoC2lylM=`kM@9~EITAyS%aRq<<6a0=HRSL3D=p&FeakYm|6Yrp;BP4ZIz5=Z2B zRu2vyI(g;eU4`aa|8-kzI-;zTYjYH`S=FCl;_!+U11=>7{KcfyKShmrpnBtUdod(*R;-2K=yIZJ3Zfaih{|QW9cJ5^51^^Xk){gYlZt1MOyR6 z-4vSWsPzr0vGEL@Y(L~xfaE>K@&|jFNq*=^3f6yl5)Y|?56UmfF?|Y;R}h(A#NiPB zqi}khQv$;cK(drzpZI(I4%oE$YNNDC(=3a6w4)8;h$Z>Zxv2ND4wrARkemgV2FC4EicAhRVFIp^A$GKCdbUs~b47Wm( z*2jk!M*n|=G(}^Oedm>XFfP~InI+hI12z!SSHRMPvB*!sU)Qrx!&g*ve)VsTmw?E) z*xmZS$@)0@#c@5v8g63FOU5kcv7y*HrxLH8FD&v~kp#%@-NZYZa*vWpX=QRRkiFf8pC}?H|$WLoD4ct_&Uq^KW8f z@VfeLJEf$lW@)wiAu4-Qs$)za9XrvVvj0Z%wYx^qAcwp?TD3I|d*obE3*+2rlOK7} zmcC#vc`}Pgjb^6>43LN}IefxM2=kZ=!GzIPFQmaWUk}3^Xg zZRr_dIXNVchJh+^;8g7L-YS$`g+pjLnBT41qf+oJCn%CuQ-6BeW!dBZ;{h|Q z%DKw4`D}@_1hYn6wrQuS?K_DE0VS0llIUd*omaNA6x)z*KvpN zu(jo{ryxOe7={^~h-w$Tn0r~;zSc-KvT4&uX(UzbY12|5KInI^n`?=iH+FY;jSNsJ zO&V@!pqzureb{?7U+OBuP*VE(X$UMaT<%;R*r7!*jb^#`e0h(3T)uY}dr|!Co*uaB z*D|&Zx#VgWt*e-Z)}mA{hN}z=$Fy0IxFtwt`(5f!Lm&j(f5O-t;JG+sWyob$nG>l3 z35MYltUL}RQul=%7tc(lT;_E)+fs*#4glh-oly z*S|I(I*|0ddhM*o4nqArJJu+%jVfIRH!@?sMEb9iIGY6#ZhDh0nQ2xAK~inl7tU&8 zmbJ&=Rk`d3o!qUO)Ia=*;mKH~V<~=UmxBtsJo0;xDN)N}*D={8E3C6%V9O7W6Uy6v7xsA%cFDY2H{b@T#)yLry>X)M&@c4kzN3s{KpscQU9ahg~3MxW~-Jektho=^oE;bG90SQfxi^rG(U)hDnO0sVE>JPyprODb1TSj zi%wcwY4^pkxUh=goH>;O#~Wf?QSREb3$ot;n%JosuIl91mX)Ytku85!DeusS|2Kl??__k-*>m>1SV(O8<9^yLt$NP=Qp_jTjFUdP+$JX# z|09I}g^uymMd@D5pf#IP-#RYO5JcfTgLW$G%vlO{8m9g5t%7O(m`@7t*artThNUs8 zj~5+a2ngS#YG1J33^Q__yN8DsHFXl8$@%m0smv=LY9?axD-vy2?J;hlJNx)J>4q+t#w||OrQG8F1mS20=<_X{)Vt;XlW{vnh!5}$o`b5ws3!xqmJR; zk1{de>4{;oeE}fL9{|yPyYzA0_=Y3uhGq_)9}^fK+X<#oI{cwU!&R^(LFY2%%6tg? z5oOzJ{w?S*fMGor#kpIH#>QL2Gvs76N%KT~?I!!9^O=?9nAY)Dt?p)*s((~~)ozQ& zNKK$P@>4CnRlm9KwAlpJ4zO(X47*W14cR6Yp7HranUVjT#IsV{qxW_sdlOR0BL*czdLpiLhi*VQ>*QRsxwnXZ6yk zoVs^Nryi?;p;LX8Y5fy&WbwNwbF-wE@z>u=zV~y~RjN%;i^BtUjd5EVs;NsPY7-Ad zUkbxZ*#8U&rFGd1!I2_tr5rE6oOqgpFoo3w)LUI`Wa6^0 zSuW_CE!_R(VVEQAN}=!tI|oZ&!YTm*@1W;U%F4=#=thacXmt%;34=f7m#10&v&_5J z+GI(Mogp|kcHHJ`0hOa2&qi5`LNUTDG3~=2G6<_=c+}+|d&GcuOb%)tkgU){cp1_16^rjw1MJ#0_8}cKtLj_wAH+=ku9BD{+pJ0Tt zZvEUJB~+>HeyGMPdLe0Vm>^QJXk0CDB3J7(%RT%U#OxiDRbvEbq??Betp){13KX=$ zZddCX`X^B5@YH?%8(NmLpDQyeyJ5PR+-bJyJ}sY!Q$Fk)Fmcm<+f+;@F4}x**Z@#( zrEY!|wH!5@a0n8Evq-cdgVz*6qBW3;t7%-i%I?#@CjgddSyLmD8PhrE=`pMM<{oH|B&w{AehjYOza2Ufrr%KH%t7RMrrquk!Rw= zfD25e$}U0F%f>u(KX1`XwjdnfEcPs^=Hab==D*BkXbz*zkti*hKE;F6gv&ALR)V5TqvLXC# zVAm(IFTinG*H;-T0nH9`Bu{)A#Z*~|cORTTtj9>g%M^!&O-uy&$!|%%-`6~+b9qketar4Ko6LIw7j4)= zWQSZOSld&0-naXQg7CCVWm2>$9Df@qr}Hsduxi#a?sQaz2LZ!=8IPe!+2*@{Ltr;#f*rkk z8W12ASX#?A{TyXGW0HQ1)Hjp95H{)7EgAfvACK zmb|2g1(o((8IC|eJFb_HzPArS(#Le7Ym~&%i4ld?(5)g7fg)2n6bO=chqZ933PW?M zM^#=xNN&Hf5nL?~_t8LPQkIc$1T3$L3$poz7Owq^r>^HHlEQQ=3M-p5iT;dcGs#VM zXj|>V@xFx+X(9~to$Uq1zgm<^QaE?CHWNI6yLkCgQI2vlZ0PaRzUhfW6|7Ja0J(Xb z#6Mbkt2H{_5WInVIAAH?tF^r1pEBO;k(~*z6Ik$wput3Vl$`xEBY+&?7W$W3aG95$ z+2cbwohr){M7t?*u`=wmz)$7MYH{M9rg)Dovdb)Zi#rABoLmZ(=vf&Q*|P#4dQvcvS3nEr35AByK@RLh>QkkHWk92cmuxG40kNX>zNq#h?~Z12L1S zsU;u_&i$TkF*`M&fPG(MN@O7-_9(s}SgSQncTbsF?X<*kCSdrLad-_5U6iiU?cy)e zMz`?i+T=n{t6{kwVe4jhvoNm@#e0QYr;SX+3Cb3*2R0x`j zkT}bGbQQTK81%vE%c0ic^}U!biU6_1iGF>vRC^`cz9F=gz{5`yX>sx zSA7+u@9h6-Y38s+MZokfFIw4EQr!DEg#)*I;xt>JyhtljTn?ypo8`A5L3@+sY>IAA zX#Nb-u14$o*s4l{XxNPP8-`h9Ps{?&N~B?vQYjwW29J$T54AI`A4af*>QlLaUi8X# zpQ#*J{0lD{uWP5aMq-hi(`m97({Ki?=Sw?@Lt1O6bQbcs%CAUE{4tOJ`ihLxb=+0m zMf(xSlPzV-TV<7XQ;Gtyy@|HV*V;rJ11v^R#uuZk2DzDpfk-}T&q8fl?!i!nA~tp&Dd)Xc;sthql0>_A_UD#gsB`6l zg${bhsGkZ6Bqh@g#_sleW&oUv4g0c?$8i%0e zQ3&;i5iQ7sq|tzx)JStB8urXVqk^FRZ=ETDBdy3em9I(@n1a95;7<(K$5h2n6@Hb} z8C|CNz|Znp$#%c7_=bNP<7p6duvtP{Px)o2TJle(xR`f!)US!6R>kJ`ahqXI`?DD$ z{+e>H-3hUy>-0}En!2u95dC*dxIPYlNQ;3f5oET|M*OUl@Fxfc6=|(BY($fVkrrB@ zauA@toV&l`RbgBcGgdO);CW$uk<7gbXiQq7HZtCJHFxGyd;lfB;K2i!)yz2<5_LoU zZN{tM&ir!Mz3eRNJ^DX9a&~Q-a|iaeowO4%{;K9C3e(;yCU-& zK$UmIfdMm9wBtWe9Sc0x9unT?24P%`pQ_VvsTDQ$H^p(wmJGNH5Z5;hkyXh^OPQ|+ zq}h|q-?lFAA8mT;m9trEyPZF8_+_^T)OVWd-*)Bp2p7Ov%i@xo**t@J!UD;;I=s0G z)?E8?JPHM7@^jaHqAbBy7^KqtZ{TW)Aj+|S0W{pkK*Dh9*hqvnjI?Iys-MZ;k`xRP|SU7g7d3#a+WVr!op`#X{k zCQg{EC8#6t9a@DdEXlaGbUqKFGLm0dC;w>a=!1F_^02u~Uu}ANMNH_cEL)FDJCT?O zH}Tu;Y3#czJw1UjjoU8;KU~L$>W=(gzyne5_Ja%#vZ2L+Eo~*A&yVTX|8|!YCkrcJ zm?jMxcqcrTzpWB9YAN#DDQmm0ne8VMpK){*r2qD%bM7)}zn=Uu|6=Ka=)9b_|GkM} z?M{uT3FS?&)QqAb{1UOc(`wbld11Rs^#xwEgA%lWC%i>aZ`+oBEN@KpQd3X1YbI%O;$H|=8+y+rl`wC@M5kV+8bp&|G1QLsdtFwiisIgFcG9fzn9Ydwz6vtEO)}}CG;ojuU*$=jv*}Ls{2)D z&I@r~HWt^EyIttZ|HdA%(UosGWbAM?e)ox+rW*hRaIQ z^BV3c7vnIl9ZjAaHvY!BS2UouaO*|71}=Vx#OKnmE%;<+RAM_G>SImLW6h7U+j zAyhzg`{ql_0g*cqB9Tm3mtZhOP z&5G_@*@BX#8qU&{@0pp9t)u%f6}1SxsM)90n2|X56e(veN8~w$wMOMbZSwt$YDpHMfH=>xqaBO}Ql8i>j{ zz?0wUlacpS9$c=prId&YsiYbq{k~&7aL>p^`~PBkNGI8`3V*2aNo&*zQ1dR0L{dT@7dEJutcSI*9Mb# zb^vePs&i(;DuT&Al?m>3{$g#gE&`)2asFkpLF@}Qv7}Z6J*2XrHpye+QihE;YKlhM zfT>#ij{O~1pWj&st1maNM|^_M27wpj&eTG08?AstYmRe&>5<6ftHL*f^6`~OHckv` zc6GNW&@&^JNBaP5_+8f9^RHUu_SSOk8>lK{D$F;Sx+62QM%Ym|utt-x+vJAGRD@E& za8K7ij=KmXRhg%Nbu*ozFCr5a66vcyLa7o2S+&)L-u8-O|HY7Hcxv8yWiWA%`-P~< z1eo+N&#{*YXWhfP^LVYyA7fPcqbD;4pf5`DS`u*5y3|Z5s#1R$ZF;-?h#@Fj zOfHk8f^pYfQoiVXYxXxRv&a#9F3-!lJ+8rd4-bKNipq?oYwHb|V!cS7--)8hs0Z1( z(W?~LRT#b59EAOGo|Xe8!enqlr`ca&?<_316vD!-HCpbAuuv7|r{Fo71(CZf%xVS1q$Fo;97ugGOY~ ze*f&Y@*`I*aV6WrL=~oHPY@FxbX*wm=?T=0C{DoWfnr(E9Fn85Z;8_j`9^HG%&{+n zLK;wN;DysD{|y3zCHZ}uXF)Hk;~==AH4 zAM@9;2tQDLiZxF#EXijP*ovN~Zj!aoQ{p+8C=Xbqa2|<81{}#BC>Tl|5NBI)pS|6$ z2rro<+V;g5}XQnnJ{vwDLbOK(-o+jhG%hL5L&~ z2Vce_WxCX+w~j<`U44c)EJT26RAeBV2W+CODVg)YJ%qmG+*OS8PHctCqHv}kU zWA}dpfl5z&9YWg=?+~Kcw0uwq`LJbPnVLi2W=@VfKD@@8`^Vj7qjLuvM@#SHUC~LH z^j#uiR7t5Q3tkRNKT6UtxODYe0mgOZwqD0fxtlbpz}5BsLt!R`7PWj&s9DdbczuoV zovGUNYUdtHnnvjGDEEt!1Fp=$OEVy#g}AN08Sd!@TD3#!03~?mLg_j3z%924ZpC*4 z7s29tFI!?fn4tKD2>-%}Uw&FRf8B&bd_GZ&!Lk3B%=+CM+BGpqKCVcz)#Ek#^N4@+ zgtUr&Dst-0d!r-)Ls#pjdOhZ3fI&`h^!zTQn;+D;qg9Mq)HrCu-g$Z5K}zyePmoThm2k}c z>=1AY_CCtE`j)kro1gG9gw1u2fQ-)CwRYuOKgq8cyBrr?G4$adAnn<$-|DKMZ@ zyrFvL+mkjQXscbS?U*_$nhR*AHaJ3QE?zA|9#+W0X*$##dQre&{Z|mtYFRPw7KI5p zZ?1G07Lxgw`X^D>B9IGnwrH9b{n{|JNB^BmO|Ov_HdN;hKSMUQ^YYiJ zbrUy`0|w8}rDLc4_-WATe|lvcA!}Vh1=7kGz5GghR;b$ZbFl6zoFr}avKGF5 zeJ^FNdQ{wn#q~o?acBBO1{&BTZkWmSxr=Q-(bdOgGB^u9=ySU#rmh6#DWP=c8Ym?K zr?S5-Ds~TIg}GHdwYcJjNc+zsg)_4(zm8?!tC|75B=&}8B`+6c?5o}+*Obx!pLv6^ zpDM%Z6I6NzvnM-fry=v7XX?A0RG@7-s+@28FnOk>epmNqms|_;ec}rDoSHEa&bZtwEyfRy zU0v@Yq+3esNy+?;QMl>a})OEx4Z={_u5N93Tm0c zKGbL2r;3rD=uRUWW4vBy>vO)~i-@bJ_4(tK@W3{JCNQ&jrqIR_PKKArb?9J40$xyN-~G+6P$vQ4_ZAxrc7Dq~2&FV+4GjSG8N|B<= z{)uX7kkgRtvFI3Hp~g$5&P`>Or=1QBxc=@zWrk?AY`Ve_LMQiuCceroK+;%8Qey&~ zi?{h-qdiZH51)KOW9BXrbW}Q-c6(s4WZ0AAd>UKyD;YZo6a;j}_-E{#mn$~iT}|`t ztBqrw12yA1P3I-eJ%ewdy@HQN(23s~tA#BmfFZqgeuFo5V??mKp%;QyV(a@^m@k%U zdcy=O^L1fCw4lBj7z{n34C8 zjTV!nod=ux=q~#4$)of3|LAI@Z}xN%B%%Uf{BtLT;Q&Dr|J>4slKQR|osnH3ick+( zkCjaKm6do$5}M#Hf+SbS4CN<3>n(G$%sM_YJx{-ZP_4HX=nACEq#HhF-a`8V@WQ!M z3VQq&Gj2EK6i*9$siMP15L*S$QTW2#_eZB*ek~Ag;jNt4Il1rIDvA=qvFnUcr z-h36vdo>Wb85+Z8=K@MvL5?f(P>pZ#j=kQ4{)f%{^rLT%*OmKboo4ww^$Rf+k=9B0 ztMW#eJ6+5k9TxR%8;5JnyoIhWo8a_e+>@VU(wn(I#}BKTM7230?+clO+bsGns4bZX zN;9~A#poN9SnuWhEcqEc2tuk;cj7+4cnYCf3b9G<47Z}xVv>}?TCNt6^{HmLY{&KE z(w)It5;x(rzg*^*=kKPs^axvSb;`dD^EEa*k+CdF{WGgXSb~Y$cNo zv|kDvTBVb>53scGH{{8p#MVg7Dia4=6*)I3L=PmNa|IAT<>FzPhv$97wyKpNkBW=%ucDAX=}c}%|2WC@xqFEI(e^j6uzzYCc^V;g)glos&0$+1U-!@ z)WYh2s$Dr4D_{VA;i7BK z{UT_VWIxk;@`|N;BWGhD^S~Ue^c$6)RvaUw);0-=85h57UZ>^KYJ$Wk~`wORGa6VbI~5={}BfAsZ-fprI)L0lb{f7`{hd!cWx zh5A6;>L-FT(+(FGrEgr4%HJy?Wb(hi@4>5RiT$&FiBcO!8FKMV;QL&`qr55;E-^Ms zZ&x{|t`8NT_dAiP&RW$h+NRxX+m=BgPijms?Y->UG zzzF>`T`BpuE<4e##VJT7cdutE{wiqEWA16_6UgaFZytwJnQ}hQ?OFNb_ZAFv^z-ZF z=o=M-22X-_YZn4JXo!>#7WvH{XD0Yg^$)qbBFkRGhw+*=wk-(oFinWf**PmYJj*JO z$Xi(i0=AF%y!#o~&3;yZ6s@W!=4zJlc2iuM;hsh@F}afBnUaCRigT%2 za*SACyaqpU_NJ=b{o7^VR{r~O-v4rOtkh4*q(;3u^qQ3Bhs|G0T@xd*?nBJDsaO4{ zOWT3)O>t&_I)0yTppG-03>Zjt^U~<0Jn2mJ1}(x;hHwubDhk*w7;5CRDn2a<{CyFJ)UsODxdfCG1_Kel}ul) zV4{RKR$VW{xltU--@>d97i#vcqTe~T^Q4m+Q`P=b=FrRZr{XH!xNM?+CF9f|J*Rj| zW4dl+mJQG5?kA!Zl2U~AX|=xANiX#ATNDQ!lNgVi&#RTr?uh~ZdlHsBi=d&O%gY9Z z?q{AQ?G5oz#n>a#qwm3KG>gVJ39E`KVyjLHXC(w!w){7Q%Ubn^bpP`)ilod~ftoaj zeApLvCSXDA<#&k0ddrEZ_%3qK_u~z4Q)`2t1gn~juZZPY@3S&xAB-5kM3(K^;*WI``_OcWuX=$kY6I%A#=*v}?x=HIT$-Qo zK9xj=D`ti<(}t*B1YCGYAw5k)0`os_W%h8D4*wrn-@qQ`)^y!Ac4H@vZM(6P#$n?x(M%On3=a;U4XWneQS3Zi$lnoFoKqI$!gLb zu+?ixir{6p^$n`@yiTs@O-_@fj2pSIn{pZ`N0ne$F-^GH@ZQ#6ObsaQsIsk~)>-fN zL1s$Sl;$5^g-Lm^xoM*-FafKXcj6=gv$u@x7crF>&3@LO{?RSLr! zBSk`v9@c9_X$qJ^mm%?Wp{QqIs`Cy(BcFW`4_A#^Kp_RZk#Flqsa;%}aF!2&+TOAa zu&Mt|TL{&#lx|#Hd)(mQd7`tAU+{&wW%0=}`{?}J78vR;%nB>>Bs_^_^U{_>DSu^nb0d0vP85>c?g6Fxe;=?Ffj9DF$hu{GdmxtNx%V7%u5Yfr z7hdIZoCD0pf59E(O&@akUEC3vWJ9?@%2OSDUFbmYjdVd5LdoY-}gPeOyWWn;E{aYcSiqT34a9mBRZ zih{WQ2&7QUtz&iin|WQad61u1)^;|DoP;|jDJ8hmFq?$)-B=T-GRQ zFq%+qPAoZTcp*r>rpZqxKF3sGIFwpHxU-@RI%*Su8i9e+ zt5_j0e7IS#QWN^Ou>3KHz?Rf(BX;bZrl__E-tlK~z{e!sNrAR7dva&en-Upy?oDIV z9(8H$&U38ghGKP@O3}=;*(sG*$_JikeU*93K^S)s^QJvG)@`^M%1xFSeKSt50cOnQ z&JlJvZR~vw1rPTW00@dL)_4?JdH?|t4;chOmlPAP^PikdgUmjjg20=tI@SOu z4V-$4%b_bZTC*m%gJHQE3~e(8QU%^0>}mgvP9Wr(b3n=5OsA|E02S6n%7)xLclOJ~SY6tE#WaBI}DzG2RhaK9jEm_D~C--}GSu`k|P z`~4Ey{P4Ve|MCJ75^&hNta7~!K1b(r5>4B`>_S-ScNi}o6&?>;VIa(is)VX{areA> z#nA%;6WRlFvcVj(z-8H}R=Tsih0%F2o5a+KD*AiE{B;jpghdkim7?yQV|Ce6%3&SB zSnb2}$YyhkJ0z}#ln^mndB1=M)xGf??Q7RgYtL>$BXz!fugn zLi$=q`DZUnlslvZn}+6^y~!LDeQ-x-qscG(=8#vwo3lGKediJQ>F~(c&8J`~mRKwH z2fqjB$(1^SI}D$_8(duEv1)8RYFSll0LPF5AoB5Z4f;9nF3%XnTF#zu)EKvhki$+U z>iJr#ol2oT7%!;}+deM9u1ChZc$6mjoP;WDgHLSaVU_=Ee^j2k!CvvYqX>KfFbcCk zyxEDCDs)Pdi)2*3t$I!#3L*`g#nzQZMaoH?o>FfA?jgy%)Qq({e;p(w1^i37lLU$K zikI3h3b}H~b+GABDYD)~DZb3y5gTov+t~7buN`H=Zk-ayGziILRgOPg95|(59Z}?5 ze7k-p*=8nEA2=@fN?&9=?Lq!>rsQTRIiLCSzw0m)#s4UcG)eVFR^5d@A!9f#0jOIX z%ZcbJxv&n@(wZgXse|^{sDcE5zq?*IKa<0TQYgYBl&&WqnG5;viiuJL6|P zUt{?2G$XUC!Azwcg4PB%Y~5m$6kXm@n>ac%uVb3hsmE)!aR^-=f#vCR) zC62{u<3D8@wtC*^wx4D8AKGt-RzM1D!&?4M3S9uXZ*3ZBp=voEWc7RMFzwyf68RFZ zb=K6-awdlJwsm06^vkgGb}$c?g+ickiADnKA{*ReU<8AtP>(jic?~jHFCE(+I?Iy7 z>+{+F`2O+1wt=XQnOVA~+_iWdLBiH&$f^nRw$dTbC|ZpmIk@5B&3b6l5JdI&!m4JM3|Bj#+{*6>5^U7>;dsj3l z58jDt%TmCHH|GY0oVQLc@d*ZBiOtwd z5xso?@Sc-alY-P!l+iJX9rW?)N+)P(@Mck76LBJIt5X7baWRz8EWc#X+!zSjdC zy*1Yzcv3JO9wlV4k_cv>(=Y*tT8p+CmU0eTwLoSH|HD3yF?-NM+p@~N)i^`sJEu7+ zGW)bUe$4vmX3JQ{Si}5A|L_}Z&Mx!@Q@Je6BM>z0NQ-R$0IM z&X?1FXJ#80dUrbABW$7spoCUXK$O{7=__%=Mam>_?8Ip1A*$O|Q2qpR@Tf#@M5jQ! z((*GzkaY6?f&QcUXtam|%*F*O{y2boc@ry=j;rLObo5+4(6L@f$8HURuP^r_)_@VS z$tHp;=d2{zTIi!%h+`XRkGCK{SJJN?jcyS2T2jsWxI8bhEU7Qp8pE$2av%6M`!R>Z zxKy_=G|~D>KcCEp`xj94=PXYediYM0ACsKNQmH?T7PtPeKqqCiMEbKgg!6C!W)t^3 z3pw$_%_L4#|F@%p?$`4h+y*aV%h)AzegrMFjr-JgaX z8}>TkAnD}8xSkzb7qf|cai`#c&p}H$OzogcRi1XzVGfWL=6~=Vj#ZI2{Xz%-SG$p=}xFNA`8V-}_d zaOmy*AOIYGrzN}IFb825W0s5a7 zwB%490haXi;B$>~D!9Q|_rLUS6Cvx#xQv(}MR`bWxf`hn$RjpCq@Q!-qPKb#-eF>@ z7z%AaFnRd&H*@u9PRizSL6j`y8*25uEUtfAxVC8rV0#AB1Hk(b_$kf#ZZ7w#_61%V zm5lmZgiSfiWnK;#QZb6pViD_xoTY&8gkc>CIAe;ibA&=vZO4!gr2GZJz(~@RLV9|j zb+D6O(vmrM!6Z?_tiNDsh^*k0^cQx1SL+0kS7JD(~2o7y;F7TH+4`KkWzf-jc&!LWKk^#O#2PR^Tz&!dX02GXu&^o7e9 z@#xuzAMD3^z%pOr|DxONxC8>hm=ebV+c&^D$Akglf7e(5PLG894w^FTf(^k+-30^t zt==b3-5@vNQL>(!m2+sLblpPLKw4;Tpoww*XlG$1yxtpQ{nDW30%L*Bz*6Haqqd)6 z+W%d(?>crK%MAQ93ZFW)M&aq%n()R3%PUVh8_RRVeQ~O-|E8H|@wP+7l#+9=Yi8EfJqWG7fPvWI0f6$rl5*ObRSod7Og!q`SG<68?*cyyh=T~;!^-F-v&I z2z#9YF$@?JC?;4U1oqyfd za>;<%dpw{IZ#hk8fpA_FK)jFQl)eaYk2;GDPUh{WM|8B@trpJv8^M$VMe-ZSd&=tO zgsHhIlE1k5v%f$(5hh=U4Cm%{lfi&;(cOK^RWzsY2Kr-Tdy0hX!^el%BwhV*{8J*j z+b`zm9ZfC7i9+Dyu1sP0UNVjh7C6H*rXERHi!UbC&xZa2&37lr;6AK%KM-0YLqS+4 zZB>J?B)6!gVmuIL-3sRWPF5J6_~@WM1P_sSVRDJOUD;tiYt2^d{_}Kw5<^WeOaTB^ z0QBZrmUYp8!Gkl<(~9sLRn$FvxAfSfvI3p^h3N21;H0xc+`?&>nBe@c_xFx3&q{QQ zLo2?sYONeJWc=!JA0dXQ)~7tA>j_Lk(ntq3E0}&@)QXea0djwxw?XLuSb+07lNvj7 zzk|9+7ns3Wp;-f7Pl<(`6JbLO zN?8d{%kSl2;nj%r2XowJ#%oP-tCNmH6=*?xL)Kda;4t@r1h8rPHvj7$5*b17d*a~*PMgLZQW4?6s(%x&}! z!T5jHXY?*|#OM-HIHBkUkhgPSkIeS?V`=#ikvOA~ra~^Iy@6&0v+1-rAw$>qBl^Lz z$O~t($EL0Wrc9hE{yF@$*HC8K;njzR9`2vjk;(;XJVMS@D&uN_fl+*6G9fwr3 z|IkT5Q)OS<@x~_bd%{f^t1n)-C<>idZr1|At(bO=KUmD)In=|bfWFW6kj74(_=oK@ zkC{E0jwrGsUla8axq{-Mq-1TQ(}I-ly}BObvX0+vm1b#_&OlJHN2cY12%fpHE*Pvdv$P=|Dk-%L&o!BN=^e0iO59u~#B&e9DU_@G3 zX>zZZeBmXOKUs`fH?icQ!m6UV% zD(R0JMX&-waIpv#=blRfD}*mzM`2SXI1foKf__x~Y~`m{+A(#D++G@-DrOhi?8hoy z)vT_8&ZmnB_$~7TRJnQ?c^530Dga>%>z2 z1`cYCn9RCKESHTh2U;tW%!Rk%s*0Tt)xb9Y9D-8!T=k}(6lB=)QAA!^n8Udg-NEh=ZrK@U!$wt`K0n|Tnuwq6S3-KN8>*M((3DR8ROWf+2 zD!y}wq3HioN7tI&yl~ka;O=X&eya{;9#(L%jk|9eT>}7g&_Y{Vi(?-o?vt$BU-zHj zo>Yh%2@RLCr9am^nt4`^&pnE6cE%b5f>m)RokT)7?AqTxV|W{aO|1!F9UMtHuvD#> zF%q=5CpfsOFUBg_!hbALem#Py<)|)kjn~sQjv(U7^DNg(H|JoN4?g~IPw8K%xB7OCjh?gSNjDy!zI_~qv_fXjIRaObxW}6GX}@;#ahyG&lkyaRy6qK}_+x`Tn|jY?>x z7KN}j_3pMt)k|8p@Vpg73xP*^`C{bSj(_XJ$2(hiTM>;;|6KCtN2e|WalqW4ziz$6 zs+KxhUP{eFmk#^il!cR0CGEg3SmtpURL3<&g|PeMUSopZXUNy6KX?$ICb!KZC9P$i zerUBiRx<<7`wL8gXC5K3nmd~@a(ae()w7+RQYqZdqo+6lyHV=C_&11?OvhDiTv4eq z(EYpk`(0VkZBRYUb@B4!dja=ecXyeyX>ZJ>xbEj1o_3&wDp^QFeUJRDhbNLgC)W*N z@fPu0qNQ!>`utu5F9U$ddz)uEAYFu@HJIfX`ND#nVPj;iA&#CG_SnS5rVvf z4zU_v>fTA71PEv|WTQnOZEEOm$4CfauoswpZm3@^3-^D0KLN&8RMdv&!E-wrrjUO$ zwc);I%w-q_giO^>h~%lbN$Ag_ktoh&=!Pj4YE&~N@-Y{v3u@DY> z&SO?Cq$j~DC=!YDkbo(-Iu}zB<&*^bS>FgliYa3|y{L*p0Qs>Th|)B>hF5 zs-4DwFR-X_qVcGqr>Hbc%x}9WUUk;syV3@`A>}N%w(SZVAA)bkR17G7P2ULFq9oW? zK2mkcKElN#9H)WC^|39!)O#jlf!N0@V~4WkrHb!JoAL3^Xdiu#2g$l-R6$2dTRs%I zz`EZ(o3*CJ+DsKO&7zA1g4_H8_RbV&iPvw@y+?4E(Vhr{S38jxh$ZmS3lyi;c&o{_ zgAFBu|AG!-M81KovZ(Shtk*;4p=(keRVrG|(9IgcE3^h0&I;2zK&3M@_>bovuI12ImkV9kznCH#K8mph zM4XOORNY1?JMY%>q~RRRn?iFJZ@zn3Q@d`{89jS|@qfj^d^Xl&}F zK~rMfjq85~4W#JFHt4ME{10^QPcHVDfuJX8j75&&CEPZgFVJADM)@6h+PB(Xv+YCj zvB?~C5w9gd$dA_hOw$`l?S~XE7D30%oVzCqKuO!E(DG2&p7ZCDfLRx%KrOA7#dc|R zB_|BNv!22Tpm4Acp)=y+iE7Un?0YF$(bZo)!j~9eD@YnQ=g~>W1Mskc_yzZ0&@qmL z!Lw1Cm?*dc0^i!)c?%4KYJ>`{J0cw@+`Sm35)`yt9{6&5}=Ku2x2<(D{##U{? zqN!ViJe=xbU^L z2|s;wuZj(S-HY}{(}0a4dFV}^zBwpMLg!Pkd+3*tRKw)?L}g{75Monog}rGy+GzE6 zB<93*WGH_|p|ll7-7TxX<9?rWcamZxh-?Y;!!#2$Z<#=ZkJbHr(B8Ebg(GtE0=0JX zlQ@(_vKX@nns3E*Ng0T5up#q^X9Z8PO@%a zAkN2AYe)m1MKuZMP(UaBkhL{l zZpKWWDomO|N4l(NHETouPZP0W4O@CI4UL*9x^HT_$*BR3gh4aOsoKIzN{El-Z71%L z0_2P|c0t~qPk##U5LJv*k!0#${<7O5Ssb&`w|o+xrW)m8;d-vQhW$p8VTEXOabg?@ zOH#@}ZotNDrHC*xM|@Q7)n{jHE!MB_+dadOpS2ZZiZiiJNyemW3F1VFokA4m!(>>f zvoep{q=X6toYw?1cSwh(6{1dI&$}W)p(19|yc(CkG+>X<*nVrI`d0|o3F0*U?Mtw% zyVz7`VAe03Le6)n=CL!QO%-XiUnBS(@15z8=A5Ta@|;fk1)6K6d-ze&pwy7L6IT(PKlIz%^vlv>{ju;pkM7l?bv1`;pf5J>_ZRu= zSNm&hQ3@7dF=bK8sXS!+Vu!0%PdXrVg~-vvb8$# z5;dJd4`!fTYUqjI@0k2|hwXcPD9>2YoKV+tj)oni4!2sd$u*5Py0($zu!D9-FXbkL z`T1QJL4x<2b(cF4YhpG%9e_tY$%@a@T&DIibFxcgtf6)q*m$dM2?wdfa7^;gPasqk z(ekr$66(aes6QhR^%7NSy+q@vAB2jdvc*Xn|-=4fZpRO1MF2lQ0$`>kfuS( zEYXQDZ`)cJ{_J6~a)py0(6?YV)-@dyc=|kf&7P0+0XavKZdC{}m^Hu>h@F|etB`eu z_S7nSdYEEh1_B*N*#_9(_2kH}HNbA+h>5wb{qup#s6A&i5W2gzS1Vs*K~FdQv`q}2 z)tXLBuc%CkGIDvKDSeP@IwP5*Gl_mm5ZVa5El3v=Zet5iUQ1>m_4C+Q8-DgbJ+l}1 zs+M_kqPa|@2^*pNLWnqM==j!i0Egt7x-cM+jtKKT`p3Ty9Q*U96!vmsUX01&a$mdyAN|Y!3{aGaU!4~6Rb3z%Ld4UX;!f2QCq8cnIDwUR zO$-h06LDZF-gJ+ag<}pf^RdkdnaU}^h6*A>ByNn!D-1HV_*hz(s;}NXKe2MGz|%~l z%cR>ngX7+bEqM8-Wr$m#G;W9nn?s#kqu=FkeXCoFHhB}8K=Qf_O+7&%r|#LLdTb=M zx&ZmQv1OC^L6VM%0YhM?PoFd|C_J$BoLcTrqgG~@!m4c1a~#j@BUoSl*fs17p1DS5 zcKDbFz2I4YRSk$Xqz(c+p7_*9Z2{UOcR&TCc<1C1q0}zqo3nMDPYl`S*xu90nl&(O z>@)Mb@7u*Q-4nfcQQ<#Lpp3Vis)t+22;3v1<}N`dNysYgM|A%^xiU_O)@sf_Nzbo4 zd<)LYVZ^>_ba8TQE5N1_o%S1gkWqaE%R}5UkP(*aR;~!v5o;f7{>}DoblS-@rh@2H;k*Fv_HE?sxSt7UxtF>wfMv!Fl2EU5C&j!#SfFy z${I5vVQh~K0E^tL+r>VR2X--UFPiN!Itvr2h6M(Xv*n1ZnNxB$!h8_I1?w(=y49vs zb@o#Q>TepHD{E!Mfe;8v0itX{CyP>o0zeH2H1maqw9bGhXZ&@!5TrGaExkLB`Ajjp9Icq z7YOW+aerGhOmKiKA}=}qJ69uWtqsbEg0G^GU#u#>)$A zK7?BUhmXwV8VK*6S;H1JGNYiceeHjP9CpXARYGX)mHHeEMSIV_Ts`BD4;1BO{WpOX zVE(=|Q(x!4QEZOHz3>DtRn*Ro=i(k|rV|FVJlanqn~}$%n^3Ljb$PsayDS)1gg!5$ zn=H{In`|(b$XTbdb-I2G9TY4d@`44>h>#YIZjI+W@l2Iv4t8zNP&npbBas|P!T&;M zvkZ;Spq)?zg7}>J<=a+kirMK+T`(gw6;4@dQL^K#_1|N=c|w8-gx&cQX*TV*|DqrR zZj@i;k0Vv2*CIs;_-cjfI!)&mb7bO596@ z;zbiOEb1aL-pQJqxC-Jt^kunLv)fKxbD3+JaARS&BP14Emf}p+^y>iO-*nX%D*=>g zc-tcw@4f&~I+716b6Y-8Q0sk7*-xm2yfy}XvY;<7AxkeQM3ZbL&I?T9#B&|Xa(C{K zDN@dhLAdz9>|`Kb1JH8=L$F$=kIN2{g&|}MMDleP!!P%htNlN4PY+5g#i5RBxo>lE zMd%vr`pqI-?hcK!ZX!$KoI2gb9P`ciUJtyk$aIr=uGR;^=?Jh#!Kbw~k7VUBm;iE4 zFAw@MWP;ka&T~PlLYE$E%~-zyOlo#PWh}>@0MT{`W{`~(W19pglXLcMdXb;f7S#%o zOXPPz2a|5DWE4`HyE$0U{^3SO5x*y^^ePUz=0cpTc1Q~ucY;a=GO6s%>oxw4i?&Z6 zdJugR&h68OH0<@3jmJutel+2LAsx~~v{s@|7bS#qf=7h^g?-f(3H)F|B5KI$@bW03 zu_i~LT2iAj+>YnH?iJeSsF62aqz`)(!UyqUeJsV1CY*WRb{tjl=FzdpT`TqTR=ZoJ zNx$Bq??|#s2L--{8HOfcYiyX0dkdXd{de^-^U=n7u5QjBH6hic9rw49#wPZd(-PZo z1_2YYJev9H=U+aY+LXvACok}cpm7{@lDe5P+`|$NVNo!I%<}y}%gt4=`-^|G)uRXK ze%3>b4)4mK-QgkC4d?w#;#OctN5Ym0Zk7twdE@x%-^lTQa)RW-+t~kQuDdOTYw{d5 z8J1eZ`OEE&e~#}uc6%zN$2xAnt9aV&w{LQ-mT%;fdQmHT-V**oO+Q5?K`W4n6#P}p z3n+-)=qT6tlZq4)5KcdrI_s4Nr!ip9$3r_yveTgY&>#qW?zkLhexXU@>VrpO!mkXM z&vf!co?`&;j`#cb6lcqZM1<@QRt`bTn^&nUwx#k^1xuLemOdfuvRCMD&R2cZE3Kml zqds9c2X%|W1#6dDM{nM1guZ9HeH~f|au>rR1rB}5Mwnk)`}3gmrK23SB*WcC`GBx{ zEEJ|Sv%TjyXSGxpe%Q+|Ezf*rE`k#^-%!+N#E2?|GiAXU;laY&`g)E2qP)ID|WToV-BJ-?`_)mo14Xq z;f_B%=}nN$En$k644M6fFm^J$B_b8&?ndlK!cg`qqjpDPRT-Fm)9DG&ix;b|BGyChuRVb&pK zTqxm}3t;a=HVvvC0n3HIWMLvTRfCjI(EM=XGQQaei~s6Tcyx`L9G}}oim`c8L)9(R zLNwEJC@v{7?nI;I{8U^y#IxWA05G%D&q1S*6-kYLC4WD(+)`ZrKI0mFPS*N}$&T*c zc$iH~9CrF6Xp%^SmhME;CCwVb?o~7LEz?W?_8Z%cb#&r_sf z<0fPHkUF&XIodR@n}6zSvKj(Ezwmn|6#U*IOnsQ3v+=_ z?S*~$g85u(+76jSg1KHFKQLj`m`h}sAi7AxbH-X0&&tPZoBc^=*)_MWj}g(`4y5!3 zab976ly$ICt=Vg=I-OZ^;iNsV%;x_S7^r^xa*$3NR;}An#Z;G3mY1o9!)S4vw1rum zP0I-IAiaG6&I{&mib7u?1{N!sppuGbF*e{WQ~jkN&ko!AYpKmI(fS(ybg^U-Q}iqr zpW3(0nV#{>F0(yWhXAJB>NZL+ZeT5a!&5X1IgLNk>wV)wqVsdwBbWFRk zFTY}^KoW2)LB6bQ;}<<;_rzLAc@MY_G0)hMJ%6}Tz zjB3RA{uvrcp5JuJ3%dn!S!JxQ<~V%LaltVwBJK;izV?24B8*rx7sl23QgN@+SA5MZ zu2j)?F?M!TqM@6ATb6jqPl~tROPdtEVBpq5!0|dJ`Kp{?NW{&19$L~~MC;v-u7Z9n zux#qTU;F_ASU&dZ_^B~G2a5z3XRKkQkI0S@EuY8e-1^=@B%~ES4=1;%;V3!MwV#o0 zDSpi17w<;>ZP>@fyccKMj{7%wRv?ha;VtD>%V8|nji{J9Fp;A!VNXz?fu8Q}Uit}_ zq75jZBr2V-ejkNL`ZHn;0`~g_#oW>|3g~V^=)5RuHe4+lXkfQEHnCiuuKoGU3nUnN z0WX)n*ko2zBuL)8@DVy|4y#u31yU?uqTw7DC49RT8kyN+mBs9^xv^;S=j^wM-iiJ? ze2m1nco^xU^^KH=lDFqp)ovn|r(VB+jA-&tr{^z~{~%B6N-nP+c--bmPO+Ly8lBVc zHQ`H^9s9kOZ?Em=%OW2y`Aufw1qwZc5U3rc(ADWn+xGihcgGjyWMNEI+6Qe2bb}gD znZ(Jw1>U{#^A>aP>B~&((xZ8YSV&%prjMNAE0gcqQhC>zMqE$W!)k-^K%4xL%I*&1 zez?})Sa0_fIqc1p&Cgyog4b3wzGDO(k2ZaI9A#m`r&s4E6|f zCLS&6!K*iganDdaJzb?JbH2slIj?KFM9|M}m*-{h(n)3eKKj0UEq3|jcvFcuVq$95 zouAj>PDrFgwU)EgIg(irN6p~#d*pjR?CfX719}`Q-8SJr>cQq)=MKI=yB15IMy)#& z(Za6%4>vDgUIhBx`VIW{VUopaUMQOV%SntVV;vG0&6~?chFw!rZy&a$x5UYO=_RHd zIx^5r>t#5yPL>&p>iW5g+bBb(ce7${+o^L3;C}Yo2%0sN_XG*A-L2iQvNY(`lIgL6t`9d)6nncpT&B&5>+iJ(r&mNI09@? z8}ftJEbQo7r*V1uNfa;gAO3Z|Fs(q|8j*CXM`9D3_?`KHY>}Xh+aK`#$iRxdr&@>g za<=~=#0f6x74~ONBDM-y3P`KOF5!2pB>7rq5FJWCa*4yZ*we&nY14w;#ExNw{kN3+Jw1n5I|mLre@WM)aU_91zs+|f zH7jpdJxW|ju^}A_bc2Q4jJf{VCNY^o{u~Gq|BFWt@g_0g?Wi*dMNb^Yl{zdelzZ08 z8ARq?Lz( z!rA734O|4Mom5~Osd&%O&NUk@cPx7GGhRguA>^4r_ z6K6F5z1e?q)3@6#@!bRD#Y1m`{WA`aqHicDch^&>aZnNN$o;j8zQ8d(mf>VeV`IQU zRU8&|YFhcX2O(;EuTtmF;w9x4K`HpXDgKyH`X#6Gq#yYv4zGqwfE&aDnyQ$q=e?R~zkZF7zTzQFyr3h?@5d~$%#@h#ZQ2(yt zpP`o*H>$_&*}6UzmCP-M!p80TFGE%YmNH%-AkeQh?r^#A5EH#g!q=={tra24lu_7C zDl+Z9B5qpyyJb%;*e++-3bk3*FUG?-X9Y}n(oncmh|0*nFpJQsBOOwHeolzH8#NE@SAkorAz<`WxQ1L&Ubw|r_eq0`HRCUbx0 zWem>@tSCG*DFV#*2fdZJ1$nZ0*Ywek=V3ZLsdfxGE5v)s2AEAcu2PH~{W`r!%?_pc zbk>>!6;zPshdB;AWpO(PhRX4y4J)D0EgVSh7MS`KgI;l*j}gk6cpkoEzbDOZyNK<* zgieF>K2GMR#P*Wfi&!jkUMkkea&N=yx#RVoh@OR}dYN$7OS}KN1ARZYbK-r~kYqaP zVK2J`{CyD43d+}qCV}fvOic;M+u<0hM{jI-yA5V?Fk~h{{RReYM`bXFbf?_*LD;(M zbz}!EK;j%5GP-MmW^;6nM|1~?ytj;+1YDsm{7~XPuChlDG+1t+j7=C>_4{ zUT5u{`ZsWj-?(U@7)MDdir0C%A#D=^Yp9V|ejl)0z*gjaGhPUjF1UlMr#1jww~%x< ziD%SZI*+pvR`-=Dw}rV`Q2c;NKfXq4K_!)tOAGWL`4M*CC873vpL;>XQmBmK+|l0_Y=EBhiC~%~>dr;^ovuF5=_-ZS`ACL^9g| zno|PLnmcpM@V^f3BrGnBmoT7q!8+faAR0`4u?d4fXB=5!bXkbb1(}l0KY{b|4BbWs zkM9*k+7L?AhrPz!x?xhlvbf(+T>o0*_OUOVPj${fDqToTw;)HL#?yKgrUf;|>nkID zU2F><3QSat)n!2nqYfg!bIUe`9WzO2Fc6X@(e3Ay2Wd?W`1-;1Bj>PIG|iBN@rGX) z&|xMlsIj1JUhZRvGcJMA5x6wbxBufha_YITDsg;9we66Ulj9*MiUJDH`TFG=+Aato z`P~5AZ?69W3NqNLt?jwG$&Qbi=@)O@L@cJ5+-mfCsQ_!+$_5T2^RwHYFIw-?V<~;H ze7v_6!W}SzDt0254gBl{fsCww-%k}}8@_}#sRIcx*zq+cA=Mu;JEPqD#K<4dtfvtD znA&I_DFEN(XSsKhTyl~WGu`~tdC6eTLH#94tSKKa zFHx`*duf(Yd!X+ExBXu!|1-Pl9fTXpFR7;$>2?wlLgA`k!=-w*(ywhe{gKo|-5gzu zx@LWz9>IXFmIOCGx+}8^6?4iFMFGE^Soq`$NhNiGBNFY5THFz5{QKVoZal1aiHCkE zh22^@6d&*s>){UEakfXNv^flz$}(@WjHQD=6IXRC!t8O8#UG#3?Pyr&_l4IaMhF{PP?ev z&M+OXD8pq`nG2iR7t0pnV5Y?vZgyfft_EUsva+Kr*{Ge#|6mo#KlzTS&!RF^T>mFb_O-YQ8C0>CyFzbj-WrNWP zzgJkYRFq^{gQwCpj2*2KKPUAPF20MLNMvi*1!uc;=ds8={;dz;X-rXa>a*K=f_jWd zkELvSm!W{EW4NrpdyHIp=)qs%<)TP4DN$aJEv#k%)HZRAcjRS6T4?Z%0I zJQv;0Oeq?SHx({7Pq2P^r+;u*GI==`LVc>I596OUdoNH9z*K4xzBjiCXl$tLou!l zGUB%bUYRnCJ!(REhMiqF9p>JJpF3r<>mVyX zC3&LLCwTnzYDFCXN}66jU$;vOgCYBdA-4!tKD#0Qu6TW{>+vu&u%d5$r>11D(vk>$ zu@>Yeq+uYuwZT`b)bFN|juHopKWn_v#HBWd#og*>yulVWRuzP*d3eIsz3g>NasX&O z@J=J@$H?D4Iwzo~z}&ezr79y7O$D^QQadv|c~RPKZbX~El5za>9{Eoy_(-DIqx@Mp zE(vja(IhXM+g}4yjX(KvRPdA%s5`d5`QBn(TszfL{b?ocfsMD^BM?B|!)w5q#r?Gl zgrw&37=GDdZr39COA4(&c-QD9{mEVMA?>HIY!4LW+-Q?b(ym@-Q5$znn20vqQqF_0 zcK#PnRzX`XQ}Ip01k8V|UvlevyMvgb%s{fJ;L_08k>uCijI|Y$48}NtQKA}d+)vur zRnjW(K6e2>-2TO7u@rwqaiG>J?{b7rM~AM~F*G?~#u(T2cR<3|H>My`k-hTVHd^Im zu_LG+6j@T*kRp6;?MeMQCs;YmC7ymC(pkQF8%&PgL)a#Y4GQO z@ek=c1aN-+VV{lm2Ni(#T-!J5L+TruE;DkvI1?kM-{RWuw^0WRBrnw zEMT;_X2?Q`k;FN9S)F`t{N=oAEPK78q#yXo@V$DbZrDVCR7Jk=a4Pu28)2|KSne0t z-#bI>EaWm(kn>~LFk&6YSId*sJVaO}qFMv((d7w2ywK84TDEwbSOI)YSK4Oo%KnjY z5ECgrWn)(D$-JZ~-xSGzi~SGu0E z({C?1r-j=x_&0E5@U4EwWYuHt1ox)CE|}uHv_lBvYA0d>esT+bi58~^_u5}Uv@w^0 zJPi(W$l;oGga1CKlGf9STh=)=NJ~SFl1ys=f3*t%R#GvL#2?frpO(Nekf1#MIKc%g z*|+oXvAAurdAcu};)^`84{i}Q^lS-8;gTNv(;_iaz=f7hwg!xz}#aF9A zBPj%3+*dTzhe0aD76yLeGgGhcAo>5`cYd1;4{!SeebJyC3slIzS8MH|h?aZDEL>|- zx$`CZeD2 zgkj!Mr@z7O>v-|-*@PEg>IJQBin(S{Q)?grn zc~!Rhhcp66jMDm4)MgD(A>o#anQr?GRvRo1pG2&l_d23W@IsxIK+y9MBo%o={rK{+ zh{(DJM^B47=MNZ_P)irX{TJki5N0VkBbo~(KtH4`qIBer_67TIcKZ?~3ZB( z{q()^S*_Y`>tj_aBC2p4AzQly7gjbjpvLS?yOJOq^vV({`(#Ra{t^V9i3h37>Osj` zGxc0OJ)}m*aB|~NZ(H~CfE-P%cYnN~uj)abUS**ut)(IJd8Kp?_2^Sr3o>-Y!~5XE zTyTNFg~vX#e8koY(~Fa0IT3=k6#RX`5{kN55iFMQaVv3RMr0I(v5@1Y|+nI_+{ysr}~1Xd`BFRB9`LMem8wtZS&QC(Eg3^_3WhC7f$J=TF6gG||{ zch^~H$0EGtktc4)5mc`GCQkb8dsN5wzBuCDthZOWn z9$pP{!LuAB@&{wf;r#j+baGf!$oS!3No&Zx3zv_qz2$+X1ru2NamLNeqSJt@CWHN3 z1C%3_U8W~O+t{-f^rp#xPew9-R(&v}sP(q;|M}R3%Xc?;sOG%F zisZ`qRFRQ|qQ@B2CW^9-Ty0fk9l8PAGWy@7V7WquZnZi#jDyli%jq%3YQnu$tjABv zSzd&~VSw^TsH^{JQvVy%v{hsAAWmeI&Of5_*d7jW zl%OMa3%ratbNb3GG4_;X$Wl|n6I9HOHz*VaTkj+jI~B{-$T`v(UY1s3Lb;Jwx7qDU zR5nTUQ4p+auw|r|T%AA`6;7@6h?Ossi?oG>jMscI(4?tF#Fz$u=a~hx#GLYcWv*99 znb=M`OTSP5jBa`dkjPXDTnH&yu5e|(X;QxzWMti(F^;c!F+vH%ow_G>*DQcew#{EP zDRI1727IG=G??H)6#`E`>L-hmhrDx${@qOZ1ckBEOA_VfQ(^lPOV!z(9k$0ptbmgr z#?53}VM_4KO}sMP@E^zWL00-C#!rEo`R#Aca>J_)$80&JR;;mtsr-++v1EU?fPGM&`(?uCNBC;!l<$dLdOTz$|+`aXT1H#~=!>M+I5AhcBAGW>4ZrhRP13acn^-H$POgt5oVJL>{ zBsAxhT=?W~%u@dQrj&_X422EEn4Q2{+LP@Gq!^n?q+)n~%a$a==gJv$JgR*f$aa^z z-is43xD?T4A}R1JTs^wzn5W$L^?SFLkVwz?NjPrbd~w>HWpy58!H&HzmG|D$YGw@s zBRx_6wK>mnh3}$~ilsSrMM2?LTq$|D_iWAw!-3B(fb^QGi5Ua_S2{HXEE^yJ+os~q z%Bv61Nw(R78W-tpA(c_4x50G6!y@hZFLe*C^(&ihbfdz0pOtnS}+Y9h`Hh8*_ zS9WL?qnMVu&rd`^J90;dU^2)n{o)Bm-AdeDA`ERjnLQ{G@IAFbw_P`rJbwpo8rH!R zL&1sR56yBj=KHk@Vc(Q{C3IQ3dlcb{?R5-}K#;`*jr2s|Y6<>Uap}o(E|XYPt`-NS zpZSh2=Ci?4u-Kp}3%vtnjLeK0x$L(XRFq5wF@s<;b3WNjxwr@n07FtK)s;PgE#T?s z_Rg7;`%1%bnCb%Kg#*W7_^p)fGv%oE~Q2cxOK=2V$0K-n_OABIkt ztLh5y-Q{ka1{%D%J`klkh*v#uJwCr*nZ0u<)mcfynyNi+mhE!$OM_C$+J0&UWFlq* zZGY{el&Ff|;+Wt(7kFKmv|@8afGrgeD}2WNi_>UULvJ+hfgP*LgAC{w&6S5@gqo!hU2V=Im`a{SF#k2`0l;cj*o!BXxC;>F?0z^+^m*^CC25Z<7v$(lHDXt%TF z?M=2-7DtPe%B8-h@?AE9!`#>o3f=3V=-M82$9Pta7-`Gc0GE&=XF@a+8T2VN^Q%;1 zE1+#_#r)=v0oj7sum{(>5P(zNA`REov zd1Q%B1U)1d4(T6DlbtxkNq%M?njUa1-Ed5$>@qrGYhI7Os4{NpLKl{joVm!IJs_hv zOgK&<8|d=Bs6#xbmL9Pc0?<~a5!Kd|?Ei}Y{5VlWw6z&x(f9#gm;!*H)pA&c?Y36b zIOIsh7jMEG`Mqt|zk}4FA@4(STaDDVY;Q*CYUBXyk{*$as%Zod&uKze`E3vz;xO4g zsJ=GeTJz~}eK`S&QVk*0 zB1t^akvOxP6Xl)82I0kuwnf&oLWPNEc-afrR8HWc2J%yvi2RU9#4U7E%b$_)Ssh0jKoATR?@<5Hloo-UAlJ_oX zHb1RbI{EN(8quRf1x|>L6o#4VnX%2tFEGmclPpP0=Q^k6um?%$4rFEoP>CZpo!U+g zE)Ut!#}MQDFQz;Trb>oddLo^TBZoekpjsNu{L+n{hE)NLl5wfc&7x}5x`76D+;6Wv zR-AF;^MWLWCvk`IIT?s)+-P(*02;6^nrM5a>w0HefaT5NMbYL)fx|?r!!J>(jy!m( zT)IV}% zX08M|ktKBu36k2g8I`aOUj@={27QjySQ-Fp$vqk?tF4(;B#-0-( zN@EjB4OMWZnGjKJJBd|6l$B=2AVNc`d>84WVAn=)^9HqqTXKrnot;E-=7Hi%>m-_{>1&;!!3GaWN1bB?qO7 zMWb{BTV4B+cyn%%Q&l%AwZ!E8){3-2FBq!NTb6zoK5KJ6byV5s=ArYcky>f&=4QH@ zg2SRQgr^1SS$J&oDFOuh`51g~(HB81Da?@3(AbIv9PlWxpC&o3#;7@dF3(~I)hz8z zCkwezHn~3MFf_rzV#7yRrNy3v0z6w!^DC?1yp_w5yD<^gG$!|mlpeobTNX~>b- z$s~P1+2da(151YH;FR~9vJ)ynYp+{UT~RHxVFrKVG?$87){Tb-i~pyF-Umw_Yx;zz z?E06AL>wG)MqqT{MJv-u|89J+D+JleszfE&(hC2?VH!DD+*O$b;$4*5x(L-Xng|Ay ztI;Tg;1nE+plM)SX+Z8w){TCB@;NmK-aUGTnZ8cisAL)g?@44|$$cAX7G|&SslT%# z_-z*QJ47Fyn=Wh3ADt#uo_h1}@-(t9ox3MY-tAXPC?0_R+(7qK)Wp`8DOCBoyF5eY zL_Dep^0&RzYK7hA65AB4Kqbq6FzEcwMc(@^iymZ}_aX3fOfpyg6%F#f%rg=lec9GoqVUD~eBP2RQ>f6w_RcU6mp$!Ul23iD; zYpXpjQH80+=I{?30sx;^2UxSSDw)o-GFN3B^9nOYt+OsqID_!``gNtiepZYvZ@KQj z3%FUj?FXpuo^VFxl3foUQ+qb`?VZQy%YIOmve8g$&j z)K@L=kWuinoaSV`RB$rMmd-OMS-s$GK(-mg-*$(s=b>UxT7JA5c;U+w}OrJ=$`AsHzm2Uqo)u_`R-X4g9*^%NAG*;V}Y4Je+WkCmNE)?Q?TI zjlJ{=xa*N3eO*LZu1D=7-@Pg0KjS$YB*e5*>fONI)Q#P-9MAWPpnL%Rkk$W#Lz%Ai zcN?QAKW)Nktbo=)&dWkw+%N+5UpP~?QDoA8r37}Y83C?F#!M=(&F;iyZY@+x z!XXc`U%UlC$&7f4X-(jw?Of&p6wqaSni}|Wq_O>LZf{WW%no%=@>@2yPN_k^*^|X| z+kkAZqk;>uXD}A0V`w;bp$f>P)1?98xsP?;H_eZSN-lpRJ11WU9ii6uHs;jhH`&|T z883gdCx^ZYs}?Em5@1;@ru-9CFC;?f(=y|ZXJ46GI&95Y9?-$sQXfqu4{+A!&u>hq)woLt^h zHOgkrle4yWY(v)v7i9#OzxE3TuszI569w6Dj#2=>Y|p-pU9vR|0z54MTF+mv#!*9l zRYXYsr2zVYY_sX44{Re>*dZM(emk5Gr83S}cc}td({P=}e-1#pN7l5)V%RK8t=&93 z*D}-gwgS^nmh<*z|3tk=hHYzZ^QDO3{MhEoEEqHw5*iGC?daaOHI{_VF*SVi%?*-V ziusbeA!~#+CDavUw1D3uNtZS+-#8NUaxO)I$|*)BI@xeY0B))nv>X za65?$d>09%*`0}u+Y}Jk9YFCx-FreiFp>d1R(z1 z%kr5gl9{$#hK5ST7pX$IlouCw_izWghyX4bwDE_Dg-fDq@4$q6Cx_@BEF}rym9m%1|=$%T*960l2)vQO_s`fp? z0{MN^oK+#k!t$s}u>Vclp|}6!F!exKy;kZ|$^ezJ65q6;CfvM8_0bFjaL(HSb%H}w z??cLKGf=V8FP$wLn6pt;Yn~KhrFJwvV1^XH!_94zd)*-~vDt(8(0s2-%;=+{;PzFS8A-s9k}sG0$l{oC#9iN0XBdXW7p9F z-RQeU*P@cazIdhQ61)hordd_-nR-)pIzN!+c9cGXJPmV2n|u{`MpH6$uwb1!0FAse z#LrHk`?D6deyKGj-n5zn;k6q>=d)E-wHsKR;$2C7_?~m;D0pHKdX!nl!@qo%Wu%{K z^anyuGl_-WIc(woz~xm`MW9-LEyVwmy2Wogvbd;Uvg;~3*46tjUMz8c@d z-5Ytxo(&%ExFo!u`4_6+lo69 zb%&5{Wj5Ow8wH3%c6S9RMy8~q?lcwWcD5)!%^-={{>qMhNq@2!F#cLspEPc)tGF6C zFfSlpBj^214sU+J)`*Li=dT~$?dgahGQ`M(Z+_p1pmv8Z&NjzM+Q*91IBS^v+huV; zV)fv2vWl6F*we|63Tn3c`eLnH7v(h3shRZs%x=z{0^RX}n~Y$Ds&IDb`g!AhPGEv{ zF2k2jssbnYBjbbVim!+1{h>5XzI)-CN)rYT5-;sJ=7swBOyTVNziJjLfy=hxu~=dDC){E51(Bo^*wiFu4Po zc$`AL#c82fH1meyfR-zd{uc;!z>8uUc+1H0b`sO|ytDSVD7I3@lSV_VVf@b0dHD!~M`^{i*P2*#gqF$I^V#Ib8g#I;tDV6mQDCA&RPT zYr*&A^YkBGshqFfn)u*P<^i&C||=<;;jd2m-~mq|E9nsNc#b#D0|KP;Q3 zFbBxIjVkZ0c(JJ$m^h{EjDuR=DA4CpU z(iQ53TxS$2b~@m5oR@YRYVPBK8pkyLi>)ZG_Vwzb%%lY;ojFC8mf$SrQ&_}*=#I027O~a;3-Eye8t0I(Idt^<|{+g zY1X>+3l$ZFbdpl*{%SdjtwUf_hxGW4ZJqL<^UR0=5eVlg&8z=z zcIaHFH@MYA>fa`)goU?;jm3MgmS~Dfk-_M5{x-dh5=L&$t!UP9xS}}?$OdbgKKsP2 zmOHoC;%fZ>yKAzx^u2O(e-@nYxBUy5E)RqhD)tt{jtfXt7oSPGaP%XHhAuD4Vlv-3{1*0O0ZUQ zat@_yrwXa6u5>kYQGBxsyI8mXcEuw3&jCE1T-k59W4RQ2J{!RJs8QCG#-i?CX|>5xX{ubNz(B%w+Gv zsHZ!rI1V!J@v5LGL*4G=Ej_?q)sX-5=erWB_$gb0Q#kX>xCuELguCOe;T5L@V(J2+ zc1pzz{@~~;*;1M%PkMi^gAA^*z6m&peILTFzRnPLmz_brJdOEMz1lu3SwstlLZU z>4_EvU3gny<;UWx4>R1dHJQqj3L~@;C zNxO>|D&Ej=k1wLqWf-Q$vjqmKt$H@L<)xF_#daxFP}oW%XZU5?C?71ZEGKKMxa~pE zZ{e3rHuaBo4K;CD>GS7$2q!kH%<>-Db5u-iT?esPT!!{!HD*>c`x1fqIS0R{;!h@4 zCG-qx9YTw(YDly{XA&8Wr_pWJA%M+*_-Nyo23dI2HTF+20e2gg^&Vy+dTsf`70z?ME;quAX}0378@+M-rR2ErAhRAl!?+>ir+AU&!@6c| z!AO!^t_W=`^NgUFs04-OL&cYsP-Ek`-(DqZvGMiD@B_rJjeRGOj)l#3U@@n!qhDM$ zY}M!1Af=FnX_J&I7kyzt&0^x$A3+)u_Jcs2oEiS3c5$5L?JpGT?)tW$St9q@CH!IO zVw$eQFGujpD#TSD<33qZDXeH{H$5@jEt;h~TBZU%JNvwfi1nm}uwmUzH9Wcv;Xy~U zgy@fn2`b6;I+|)gMXeliWBkf4ES$4l-{{nDh>yI1{}E#x6panQ~GuFzQ$61 znK2vtC*icDPxcIebUo<0Rts1EDu%FX^{;_(A-ambiyEMXbVOH;{_Q0=n@!-@nSqCW8P<5dJttAxqDpVB2 z`PjW*Bh}x$VNFcO^jsaD2kNqfbAcxFCe`IhWk4Tm?8Uv!@B9?}4lV+u*kZ-dsn+kx z4aL0M@7s_?{Tq70Uc2IQCMLND2N(NVyp7#sycUjyUtg0C7gT9!5#}xTQH#6q|Hg~A zZQsRQc&0lp4v_f2OK15=t?ZemOS_JIq@=KhoR@S9kj=P$v_^=xo6F69vwDOFFvae;z0sMwCi|1Ba? z|BE9!3~2?3%k>_XE$*d_03NHlVcXyF2S@#nb)fSL(eGe{^}RGHc6Q-B9u*$TQ+GXC z3RRrq-ylmFHPtPmf-ftpY-IwAJmT=P>f3^n@b!ubdY0UzFnnq-Gbub^USW~HmTSq) zR870Ob4p-0t>dW6zy@blc>Ug$r|WhjUyizd%pWoc%sTU)@X**O+ob161LE}ZwBDlB zx+~Nf$ch#m^%p&Y{pajkOeQ{K*w;^7bsO2QtaW=2ACfwj_P|!(Jof?O9b$~za1rEb z3PxzTpeYp`2H}0z<$N*m!eEp8W>a4K>TFbV6bU$-mz9Ak#Gc_?VX8l@7mlCX(#av{D~1 zWH8rEf%=14XO(47N-0j$uzpJZGgLt24WqbTwu6<|=Dr6QlWoa!dDnk>P^J1Hx3!PR z-IcpbMi_skNI}TC)eh5kTvIY&pIPNMbdl?^%t|Wz<>I9)weRK z&9;Vx^awu6xfBPJV4_SOL3qYu12H7N$|WmIYVqN+kN0AaGW>$- z{C8cds0;Q=%<5Ou)`k0iO2I%}KPF4fMv*6kGB%dYpQMG{n|2pb8;ecA_~buT)Mx2z zcvN`-@7PC;PO7@wgp@}SXSl*LCv0(f$Co~Jvh(5$T-*a>XgSFU-QHbd;~_D3qJ0H&#AUHwx50k&;9kt*EdFmU^65ft7lQ^LP|#K`1V$q; z-i7&4!-MocB>KdI`FH?E>M%;3lkG*lwe|V#?SR?L#ClEm^sHE$bZ7p)$?*%Au4Lw2 ztu`Wh%1kOc#$>T;R0Y=(5R@a@q);-OpWe&0mN^{6TEdhj1S-fz`FM*v!&IG-SQaf; z^|EHWC?Y9WDz1n$iKRUz*{Tt9_YN0r0P}RH#A?L^j*~TIUpoUB6la0$^rMSBm@H{i zT+d05yPTb=lJL&(%>?wopM7G#9EN;qUhloq5KRCQ!=qB*Y1TIw z0T1t`DF`xuK_WCtWlszF118LidA@Zxp3yqx`v;@WPNBQJ{rVjk{*_|$s%Ra{^t}z= z9vYx&A^I-QS&FCG-6}J^jnVY@eh%{jsi_pj)WAJDl7n;D%|MGoaqUQHFY70Ti0pqR z1=(w)%zp>ovNrYOi|j;ohi2P=&NIiUVxDgeG*a#-YvQZk9Y9ckLBoIk_~uG8N^;H{ zF#j#OIG9~$PNlAtaUPC;z*EU_^@26&C41SGimFEb zuj0_%{^u*WsIT@|!f_A=p{vfKPe*atWf7|Kgd3M;G@bSoLKqJ~eGeE|X_-}@UHrZ& z{&u@7V-}+9$xKqZxr6pbXog*$8s>Ey3+qZDWM2#r!Ut*MJ^gNroV=mmYb;TSYZ3G9 ztW~^|e*bsghfvI7{xDcU>F(^G*#ge*S%W#~!2j?ue(pFfl1+b3u=3>!6p%~Rk5+M? zG>Ww-4(6f{2E0j3;F<3bEB+Ox!<}RInkpL7YJ5+ZqE9;jlpXhx=%ajE&$((WNo49O z^98c${vx)B5KMEA??$wWZce=_UR48^af*T$?cYtk=SW`Ln~Itrpgv&3qzP2Yc(};) z+~!x0OHT8?vANpZrGB)9?5@9ONU8nAJbF33R_y}lOaB?G-}{s4qW8d(W|K(G?%s{a z_hspzBtW6weBW;JdI4;htG4L-AcqCfiPqgPvbjANkT!JP1cK^DDL%#_Fq+GurK;qN4aO>-JLv zVAPUQz+dN#UKX*{V&$aq>JFOK`^KAn6G~C?z?H8e;@iu+4YKD>j(BL+I(S^-m`wl9YIf53S30F05h17PLOFwxwLHJMc{Fh1 z$LldFnz7Sd6G1o0G6<|D^pCt<^{;8DaCL)pcL}#i2(%hjBd@wjZtdM52tsnIOayq0 z6@?i3&g$#^Mq>tmtw|@f4yc&?ZxjDocY%-FvKuUw=c9%ZI6CR~9h|}1SQ{pz zFU)UtZ5@bGNQ^*G_C=t`Ikka-h;;iqT9;aL>~i zzHA-rtZDAAj0>7XMn=Xn4m3c*AHBCP0mrnoOQdA(8?>ZepKfK|u3cUkT~mt&Vp_mA z8GS5?9ye-9ZrSezD2=P#@00!z`&p59(|8D9oON97cCW>}@(kcuhvPDUss-3H zty6K~rRE}i4L|$32`ctJs!N@g2C6;n$n7S5L7MLA+3u?pn6>C0i-(ZxRfFUsM%}#N@ScxhUsF*UakHlSdJ~Uj zNFH)SRZOkZ1`(g3lGn0(rZ&nJOGm~hf8@N^XK3$wU+;7DwT6mlqNAB{)mTTvA{GQw zd>9&zp-iPztXkOi$KjuIi|ikOZ|OlvsoqQv&t+$uyJd1j94EH6oK^%)VS-@}y$I`! zt37>;IsG1H<(W~MqiEEz&$Jq13iMhGie~+@8=^ehtybZ~_8GJkXAL|+iNogd3hygB zP)jc~3=-l>r7|0g+IW^SHU={z)*XE6jR%SFo`fLAg?mNzQEp+F#A``3VLW2ZbJ0BZ z1cjlj`GltE-v8xnvtY^@w;y6}agVB5S?48e?Cp`P_%;!ajecg_JalC6Io^{Z%%$cK zW$M7A!Fav_{vxjX1xj(;mpB89Hvn^1p|*RZmB8f^H>0%=TVb8g3tVI7~XVlsw zIUX1XlD6Kty)E455T70t*Z6&gP}lsLtN((ccxB%}$CjBBkXp&m&<~D<)weFvrNb^) z&V`^D+hZ=}AZ)3{vF9WYL1V~OO_OpTZ@;s51CP_Ite7d)XK)CnPGA@)s7U)mkkl=m z9M--0#}4&Y7H5RU?M2y%x+op*Jl%`AcAEUw`vmUE@u)PZ8uiJblBNnA56NJfN}6K=i_O!r?}a%gfM6B?@FKnnF~ zaa?SQ&!uj$;S*54f?6HFTamoJ0%&LM z)TU6*`5g7r^Oa??HB!s9>sk7Nxe>;DDOv<_cFe!#GN!qOLDEe=)Vax44k{$4QCvu7L94;axe?*Yil088SOk&(g!7+odLV@*vZn@J=%%F&H4xrZ_byOCa1_P;CA1Pxnh#bE zQU~A9N@RC-GuNjVq2rJx@iuK1O(|6Y4xjeUUb04{0hX91XMHeOf;}NFSfJQoghNM) zpbqkL8!KMAWv-Hv!Vjll7Ss(PEHd`;h@gs=GtKdRaw80<{_k2H%xxbJkyQqL6K?j9aMqYyk!u7kUyD*0<=%UJLeDE+_wa=1JA7*A z>y;_jn>7_|GJ}r$1VG_k*4zQ1D?%)i+Waq1ykIG?&){F;(fn{q}UeP;3%6n1q-xXhEjnr)z2q*`qm^= z(T!R8U|&}zZtjk!;xcPFO0vUsG`wwF);AKYNC~Z7V&=qa zhBDb=UFF(`Z<#!|%#Rhhm-Ne#WZOQh+a4+qQQ9Hr_XlVUEvos4jBZ~& zeoI5|PwtD%`z&+qLkDHZ+U_HEi`SI*tPRkwFX0R@DdeYad9b_FzjCxts$!Q}tzBMD zd_ObW6zBu(QS1M=jvSJ4v3fffgBJ4^6u+{4N+mkAIhPcw329%j&FNRm%C>yjLO_i` z{@LJBcnlAWf3%<53NA|0Wc1`YVM1h4G^5|(RveBkOHjqC zFk8N2D6JO8Z8m~RL){Q2`=F~oiuKS>B3Z)WVRYWr7Bs8Yi)BgkT2jOmO&P6H_I@9> z2X;Va%ng>PQTv52Ru?L@l>BJ1&nk%c)5(E@D~e8iHZwgC0)0tEWeVtMJyJjYJ#8d% z)CysiLKEU&w6)3j>NI*Y#Vop92-4Oe3ntVEEn}!!b=lg+^YBk2Q8|n#bll~xCe=&w z{`s}A7$Yp|)|t6F>}f~e<7aa)oZc~20-HgW;h~@=xz5#jxA>B9Lq%}Yvb3Z3UFaR4k5RWTDbWrIf>#%7u4kQ>Mq4I z6a__7OY3^zPw~?t=Isao5IsG}+-!U8_nVFwS88|(pdSL<z#K*y#`K z+V&uthPjm3x}A@8OBIA5yfz0=Y3t2P)LD%RQf&F%&Nq2sePfmb zf6c+JRHt&uyu9NGgU-n5+7bi%ZPukO;b;96LMV8Llc_M#m#A)a*}Qe&PIe%#lk>Xd zNl`3gLE4~G5kj%gZ7uN`l73M zhexB7cjhq#cN_1CDa3iq%QI6ssIP>+7lM9$*#ZvUjvGj~_AJU@qW!RiCn(%RqlH!0 z2#u0+#*8`MIn9mxRXfWa-+bS2H;P<>9LrcV0Z908 zLr}BXK{Q`cfMP-OB?z%+Gj3+TL*-g)Py5dde;41vlxlACBH*eI#z^pIuz`K9Z&RaM z+)5DIV}n0>ht-R$PAQ4i3&kf@=A22llQWaatEaq_3M4p?T2!bItv$4ZA#B#(ivKF4 zq9W;YP5tv4qDh_&GdyA{5K5yTlR{C;pl^517)Z5`{O|KmXt-B0mr}N;~gg;EGGqKbMcz* zV^CN%%gLS3tbj&I>7hWi&4o}1&EtRTq?;w08fH04Jcf4Yd@K!3N7~smvkW>sCWWAh z3CGnTO?Z?|MKU;9~C1d;JC^{{s*Q;F?{yQZ;W25wpt}&#Y^hseB z%aMy#S!+Hrzct*T0EK!PwygMx_Msd6Cyb3n0(~d($qf=#1{jg}zJ9tY# z{vQ$rJ8r0Vr#fC(DVq2yrEoBtspY3Il%4)Qe4gU3Cf&m~P)WN28k?vn4%aiUV+|0# z7mXh&xA)LTl>t>Jc!-lkW)p7XIE2Zh-VxBQ)?6p(j{x#?_4dNc>`qHeTmEMwjenM^ zaSN+CcNK#U-(4k{>m43p)#Ib$(xUbnpcD_fNb2v@L$Hs7Tl2| zSkheT+3zIL?{@4g1#|x)QKmux4>9L9?(goNYDHyF71CNe51rAD`RB3Ch(l;V(wQ_B z)?;1qd2;;rhN^Nad{mpLGU)(*u11D~Z33-v+^@C%#dpz~?26TtI3rcf#;H$={Dj0x zMtKk$|07EejblND6FB?Q-Av~iHh4{%-xN-#NIqSbWb9Q^%~YCN|Cd+CO2$V)FZyZl zW5W|q`eHZknjyCUIHi|CB31jEEhIp4BCi)98i1B)ZTDo|NF9o!W*Hn95{t*-4r3c}Zpydpoc!WBq<+RW zd5gYK<2swBE^_56=n~cnGFX6o#RW)rl4*-)^NbfvY;o0F7^=dbD`^2&U7$oq*lzT1 z{H{Q)k6*g)uf{2l4g}@4#xkorS`z@4Lqc(!bMZ!%&kJ4EX4B(^1PXVkb;uA#(Mojn zSq&!1H16$CwO-KV^sayJ?BMQUR(dxU@?YgS+k+VQqNRe$-sApUDf zN3VM0_DQiEiZM#~?!s2DBR{BPXcV6-Nvb$fg0 zEP|8@-$5IQw6hOwsloU`T6oB8^=KiS0O zqMder)4`NhY(@y(H0M79+P;iXb-119U2HAibBc+YC_CTyY!3W^!{tMPqLc=)a~$;RiRv!I2NGAel`!8BUcMOmO8e#+G(BCzLyG)Gi1F%-5r`wy2rFM+(9P)Ms}trqz_tRKifqU<80 z$k53vCYHpmXjTbYie7!H5LfrhI2^6s7IeuGE4of9%cW74vqji_ms1OeZb3viaim@& z|vzhqzRRe^T*wgCsP?^a}1D>q5;xEkInuTraWqbJj z(UKc$JQ32!|8g<=I<=mxUR=crZ+rMy<$><6;$G6!OvJ-q*MqBMO4ptLMO;y((ZlY+ z7LJ?^lbe095TDj(^U@Z}Mz`6XLpu851_dy2X=Y1xZ7aE9Ewh8b@AUqaQ)!$sx2EP7 z4lPg=OV}N8aI`i!@hKW(Y2Q=u#X$ieys^0Ell>h6zMsL3MX%Xd2fwLDRI9*bCtz;& z4#=~wsThxjvkJ-esr#=FyeII2X^Xqdv-*&E;2xb z{?OBTi+~C*oIyvf4TbB~ks*sq$9k9Xw6)TCOzHxbQ(r!V1PT#K)_B1+lqjS=5Ri&R0Nt<-Sp%@_op@z`0en zoHoV}*K#dHNFv#VZ4WxfOqLl~m9+x_-^d-naGN+D`%LWag)0N$&<7`eJ6oTIJE!-+ z)E#_;1cJ$6xU)?9<{eFiAUK{_u`E;*EJkeMjL1H6ge0LZnG<3JFM%u_I#zll|4u>_ z0b-T4BKj4T$jHgp=p9v(EiM59cU z%5W)g`f^>tik8UN6^+7(;4Ad?eStn9c-U9MUSnJa7L-uI%o9j@*x;;)NOH;)xSmht z#GJ6=yy-ubx}^aDyC=$otYIR&(I;qVIN~280u{hx1#;dUV=a^U`SC^>TM^=iwnqji zF#16XN0}I-`oRgmfBP03&NoZ;YdDRfzF@GIjfUJxOgZ zkD-&+rFqea!=SN*e?_c7(xa1OOY?(4e57E+jSC?96bQ)(8~)?{J1m4mZ=nur5pci{ zfsfX2QGDUBzQ6gh zOiYQ(-$_{E1Q{yvDYNfKyaFLyD4+IQG=%aGPtRZ8nTg-!HK5zyA%>nDaBPM@0D3*@ zg=`U_kVE45z40HXV}yhA>d^_nf=dx0(LvG2P4r7A6cNy7i5wNs#`A)PL>2E^j#PmB zU6k+?HIgcYZONkThvL--DM7=UBJnXRKqS3L28|QI*Djynv0A~11|{4UWerJKPW6@* z2RFfv92{G!TU+D_7xaCe*!B6hGaT}7o>ZuRS`=<1OA5f%3(Bgz(AHVA43vhn@j2jk|{U++iG!|gp%GTl(lVQ3YZ|csukcA^taaFrXPU?WZg-#t18`Wf-`RgxHe2 zqrde(Lzl<<*Giv0BV2e}#!1^E!I4ZGnPK~|rh?<9+YOU>!dmp(x1W(< zU^ak1@}$b$~11>T5eE%JNc$W@PgFnU3LDWu!*vmK=+Dx!Mq`E(V@cZnE;7z1s zqJY!(hV$6qEgb}WH5lAAFL63Z-#YK^eis%86=J(x2DYt^7|!?|IBp>E*fAC{I^sSD z=kF%g^mYmnR8&c?_s3|44wNa-Z(@VMP1_^^etZ}9q~QZMOWZ+;p-Tg#D}L! zDEcZmRoL-sHAE|$O)!Yzie~p8(KLuq1{+IdI*VWcg?>|etjSQ`2R(Rg*dc7J%W01a z@JDEe(6AQYZi0RVFXwd;DAdq8aIDGgGw1}cM#|gyW~n)_o_o>h>2Yxmc5h)&D37S z`m8A(Hh0Q-kE6B4W^@U8m{u{uK7DF3TnhnUt#b-`=st>2XOq`?E01kdsK*=9V6!6F ztiMkn!>pOC=!BQedf?v)-F(MJ_3=faKx^aiqwkCs$)$`A1mIwyVT40S%|_6{9W#y0 zo8yqP4*KjK2w&Mu9-nBlf`yq~MLdnKx1xi?dl4#y#qS^NOXb|X!IE{bcd(Vm=h>NV zYx^;(lGPUDCA!&V=DaB+&`r<~8>{nV2B`=ayS|ox+aqW_#yo(qG)6@TT-$ z71#HWcZ1+5_`pM-d60nUY1jV=_o{5N*f6>L`E@M7ygg}1&?*#eBG_Wv8(U5=PY?GS zc834Q)?3C!y+&QbLpMk_hzLkYNjFl`NP~2DcMKsQEul0+Bi-H7-QC^YFvGmu_c`zJ zd7htN=JTv;@3q(3`+vngMTHtV=X|*{58y|;oEa|~I;;ocF?&eN6SRdwm|ZRzEKF{; z+zvfCD_gh+g@zXmmQNv=?iPks&6Trs1G$k>w}jU@5AQB0QG*Qhpb|OQNef%;^kKZ& zK4f3W$a=-uho^DO;EWNRiTecBv95#`6kdJ>yZHsR)zt|~5{DJv!T@)neY9yn7JXP& zrQm%OA7&UNQ5XFpBy1j^t@Z8A`SuTctrMb?KtC6*=Mg1kPU=T{65&D(NP7>A+ZAQf z6O;{FiHx2Gw+lVX1qnO@g`jLaNwLp}%xEX_kEyO|;IP$IJqD<)n3YBHku@)`GNhsm zMzSs%-R}d88H!JTzgra+ge}56h9KYc7g#^_28i^smqPOxI12oL0|SCQgTeuAhqVFD zugup?#;|(@@IxMctNBAa@j4V^fGY#Kf_~Wrd&e8Y2CL_;1{r630CRp9Gse!k2aj>V z01om!5rgzMHsIqI=O|ep1mOdz7?ha3Z1Gn+=RtISP0pv~3BK*{zy^amJmZk6-yWBz zB7(Qfr-maY-g5JOno0H)#>huEZc|oq&i_?t3(z}IF+km#Bl&(l!-|iOPqcD#HWr)i zBk(1lTKeo_YEYk&Qa?Zc);lXDg(7yNRGz9jMAQ(eYRUy?c$~U|44x_n(*xUXZ=)E#pO{8R5rwn>-JrtHgoQ07+ z>Epgn-)UFH*yyGHesQO6Wfi@x4+q_I8@?XSlk5=lon=?o?p}+A4jxIE-Zyv_!=S19 ztDnDRCmKF_<3My(v|X+-8ci`=fSAOxQYapRb;LRTRRmw;42EuT7>&N!>q8Gl6UE1>l;P=<6=S6XO+e<9i__OjGf_9D6 zhwsa216y+*HP5TxPe0uleCp^}kRbxemFS#z)I|#K;#99Z-?_t)+~^mNPfc=9&TQo;|`^^vsYkdEGKov1)cWaj*6P_A|l-GNR!{Ah$ zWp_slY1Zv_c#&N7dAb8Un#6|y6^+&ZS=2EOjD^1trbfX*`Tq5R2{%~b`Q`*ca;wKj?y$VJ-G}(hJc&E~6xAjaHYC%_6J|}Qo!j_FF1!tlDyD%|i*`~_6?;WM zSGx~cY4+oh5%>CCJO4Q)+rtGVY=8t50;YtW2SkfxSI)n1tq@sF&2qkXtscq1goQ5% zCJphPk0Xisv5&@$C>`;Ntbi@@!3L995*6TuFL@0Rx}J-r`WUPgo{%F~VfI5Tb4 zG)yVOO0q?`{=;C@g|n6!&|la*-B)i$aR!qb6E7OEd3ub2R~2h>F-QIGaZ9n`9c0dx zp#A-4XzOI)jZN6-TFg)Is+m9l*F?~1XG_QP8asII2Qcs6nz@+_YHPv4w=yzGS|Q!H zfSt4{Qvv)O(TMJ2(7}{!Ym$-!d(=iK2Uq6=Hd`5-aRV;i-!<@UDVl?m4-%okc5btb z2N<|34kXy098`_7GPp!uK3G-13U9rGactmLfXp-thtdJl(*j zw&Kp{cP<1S@4~g1d3b+fEXn14idj=u>lp5A{?^qaUs&m#ShvqGDX9X4HW4UV^Y# zA?>6Yr#lDY*GkVfnLjQ;G3gNMf%5H+{`L4EPt>mAqSto?>fdFcL?|sA&lDGwB*|TT zQ@v8Pxy>B9=C=!nz~e8o83>rv&vqdf))+5B4?PUfPle323i~;sumg6>VS4Ky`}QD7 z)0K3%#Qr7i&C)1!Q8%yS=_b%LYrWDZ%lV)l!q4-wXZ3C}tUt#(B7~10(s$=77$N~! zRe}ySndjcT71h2i0tWeIsHnvn#aCyW=7)wWrLGib>9*i@mUjCf#?B-tl9TOKXZFl= zWUWnt0o14G3lv)6PJ6E83f+qNE^S(D!S=b%#`w4j&$fz%%96y4HIm9$G4Cw{vsewi zth6+P>>{3}a+bton@ogwYCm)}*_-WfbUCA-Z*I+g^hhnq`|N>f&v|t(&0F{m-GQPG zO6U9IIvpzmE+gVZKWvd@&kO$kVGwC!`he~oU~WAihOOG%*x^uy=)n(253u&Kfev}c zSZ+Lm3MD{GMo50s?v;#P+>uh1ty{=E!H0XvYOPM+Q!3VKZM4l@?cW7peT^0S)XJUU z|BQS-nSLyr$yz4z6$fLRU&*kXt}_~|q$q4#$y4k6GmS&1;j}qx2Ahg13}x@#%LHX^ z4PQi4=~Gq!y7vLQe~2LzWqZE;;6rYQnfQlS@wht~00l z)-#n1mepZL#SvF*cw4Bpw*6#on_-D9A{k>t={*Et8c3hjD8`io8pD#8Bx18FQM;T) zwy*h_uyhxIPSf^~Nkuu(R|8lYWc186m<$=zSaYj#eUmOY0;1mme)k4(&ET^>Yy#)k z@9eT)$P$ro3L?N zt#D-VdE0AaQ$&956NrJTD{7(}8dB>|xs^$vTQB%gr&IGhrZ4!iXW-r60Sv!Hgp&ok z$l%8Lc@T;6#oh1u)GINAm-NFO)N!%Kw_5iDyK!J)2Pok0M8@s*slN}bzd-=+yOYBy zz<1*wFr%bNme+t{PaQn2vGCPXCa4i_38r~9Z!z$$iuGue+~U{L*2)X6g8@z&hMON$ zP9Rhxnrkqi2KWO2Zs_U~bGudpn4)Ohpz{@6G(pw1r-XqBQHIb^=G@D6J7v)zQG>=< zqN^bxB=Bd1shXdjg85xIvXD329U#u{$-p^D42Goy=0e2>fjO|@jZm7Pb6<&lFcUj5#hPFtdsSa z+x50Ec>VC@q$p8%h4y!!#8sVe^e_>307#hr8le_dH+anl^AH|*96o3YlIqe9q z?vW&70dgdn^f>}A`gor|y+>vu3u|tpu}G4q5?D-i|Av8`wq*!oea(MBke~qiWE>Jg zWc&5wx~W_rUx;MS|F}#DEUc zUGW_W3J#^~vge60=XBrU-aHy2bx3_5WnqMQbb`Cb<$Hy8$^z0(`Zx92{Os6RM z{nHT@lux-E*e840;1yw$Wzf3eL+)<`QH_@I)iaD+k-Iz+L{zZ-XZwkG&y~p)6#Qe2 zhP7pG$7|qznCzKpWWo1KC-KMBBPSUm z-p}s_$iXP9rfT`G#C}V|3~Huf-p9V|V?e!+sA~TrT#X0+?Jd^2A{JH$^<@e9xP29( z8!%CS)J=^Fd%5+T3J?}RgBFVyPW5Q`W_iCn!e5qdJ^q`Y3N6i}p#SUf9CZUYK*UF& zL&ImftQr)VwE~@=;i%Efl? zAMJ%T5PgZoY06czVmp+pL6k%<*Biq{l1Qo(JX+tL9cs#-nYc(0C5ln=0)!t)DqmcE z5cr8QxY$1hmJfb(h%HKuA!R1X=ehV-Xgiei*PtG3uLN!$4GoXce zqXST~1lY^p-4WZTS6ElV-sO2`jsA~DYL5qSMVVMFb{z&=2)o!><;8b#0rZ6(-RChJ zpE*^hYb~H*meA?ykH49D@R`Y0n4wEHhPJLh7k+Dcz26)Eq}7wHs5Ju)N>E#dzBa|D zl6CyH+D0Vrp#p-8hoM21COJ>g&Zzy!3q!d81SP%kCx zIT`@AqR}aS#doNtOZn^bgrOF9H>k)2B^U}2W^Z_d@RWaR7K&xt_rNoX(<*=SV1dOr zlcHg>vxfyCjK8i%!BgkFNye%{7*7>(-9JJR5d0B@m>gEHCesnL@reqCE+|{Dv)gW4 zDEFbB9qQOIJ}NZ)c$@vm?T9#&eVm_P8ZtGlJ~#f`NYEkW6ap)s1I&DQdQd*Uep(x+ zmK7kj>GlV(ztZktNZ*j~$)pq-kAUYSQgQBRF2IOnfaOFVYk&)JM?=lkmj@#IV^QJ4 zwfYfiILJGYP7o2YA&@s27>jF^n^|-n&Al4GFJ=LPY2oLOWp2%5JBrv%b(zUanwt3o z_Va_NEwEj`_rpDn#>?gC1QiHlp%{pZ0v13M8^ok`?AqU?VQuqpL5*q?h9R-P?^8YQ zU7zG?Dh^gG+0yU;LKb;2IgGk$h(b?iEmByXv_}?jV~_F{69D67xFA^M1R$<15QEjh z1;T=)LIzgQ)&GX1+zAFasE^lEZGa>`_+n@0X8vPJQN1mW6oVft$P=7N{P254T_4PFaggTHkSR9L+VSd{~ zY>j6K56i}6@)tZXB~gD3pn!f^(5?etg@^q4+z$UE>g&|!ci(T3W>m72PKr?lf)JgS z&|Jnq)9@Y%1H$XNz}M4&9(oZx*jNnny6=|vdgK1hL~+ZV*SOQ!MCAP|F(k-B$u6Np z2vo59+3N@U*2a^`h@7<82B`DCC>x4a6$KoPmn6EcP*;y$p09E}kk*_ip3CD+W z5gy<|rNuMtDt3sHvXMpswju$UpZrv1H1+SnqHuTd+l1qEzZCsRqu8>pu7X4MR==YH zoE16{B#A!o-v`mc79XanPk_p=xs(AgJg8|02w%~~uZ1g-OBSbMV8wALe9WZQV7R-n z6oG=6sCut*uFV36#r1jb=~fAeh~GhkVMJkEjrKRVqLhW#KT8I|1QDaOjLF+8yl#PX zlA)Ot_CMW=k%v9o;E2=5>u!rI0^o1xFnFZ0Cyisv-Vom(gNgvt*sor}%lQyF^Gg#g z`$Nhds6G8315`MV{X|+XfHN2432?{jW3#x@U2T?^NrCinHJ#Srv zdkWiYzL`jr%emQOhr#MaVP%6EGpec92ei6oA-xB9iVOuzbdJH@r^T+_6*y%nqkXX! ziCSu3*-t0c9=eHGS}TzrPm&H^Sa|tH42z9yW>5l%@jpTGAeK|SM88Bc8Wx0u1nyTO z+)^K(?TFbQY_$RqCv6clc&-g=2`hZ9c|DLZsQ~fo!i19LCi|9H+#p0%GmS-bP%%n24XhZQ=g29M=U`hUw|!SG6#0+J}c z5sQ$v5F4HUf{Aq^*PkMW9Jelt^_9Ofp5l$aeE>d(0Hr}gjRuy(R!v4qCbzrUO^|LGfBsCTQg}S9(Q+e=p&_#Rc2XfkI_khN~e_w;}X!9Lji+-{|G64 zjfHaFmU0|uipq%xp`tZ-Lm?vuo3+3#>95b|KYk4rQMYfn6GnU|a=bI?PZe+hpFv*x z97}8CISXZQ5qU!LVyyxAVCLseg`zw7WR6{V^9ydx!fffiZt$mLU8BpwvIeS9Be#9^ zvlZ31g;yr7r{BOvj>IrPReUq?7=OW1hy5drQtl4bQnaHezS7VC>}kBe`*kjTm+*H? z#IGy?GPi(&0Cb~AM40xb8`8MUjWkS!@sB3GcsM9Op)Kz0i*SQeux6~|n#i96+02i) zV-dO#D%JcXibg44sc8i5tdL>c1h_kQnAlr=XQ3r3&MwOjG!@#1aS?QYdwuQ}(-cl} zC2GL;3BC`(Kosr^x*d7ekWV1Xz}yNW0Jo$pJ15XsNE{jPh>YDqj8dZQ{PJo0>;RGtORx(vpy0Z!kIQ8eSJWBgu%SH zxyfM{lTCj>@14XfqP$D3`1p-SQratMAS`l!g6#ET;;)hi?^>v*Om}-)+I5~e*a=FJ zk7?YGqK=d((*|$w@Jhs?0C?03TJ18|&8p%Pjrdg<)?;P8kK-Wj&Yg?I@9VyE@Gc0w z7b!5_xwn+QmoG&~c;A;U>R~|P%avINb zF|LS0OChI8m^vR3X7S8C%4GK^@mo)uOc&1@N9xq~L1x15 zkP;ib{(y=s0H>fu3@_J36#46G=N(C~`JEB1)pEevr<|J&_yYG{5QanmkNEkNtYpCw-NGx5DY?k2l zm;!EiqA)ltFAs)z!Pk@+Jljb7Vt~17kyjg^7>Tz=MS?o9QId=~(tbUq26et|DqVy_ z5_jPJxQ4as&Ik9Ao*wVpy2zyc#~?c#^2&9C{H*Mo2yMLc2$B89e5HIg&6rV^9Cspo zRK;TeiXC1c77Ef~eopp7u|vo>3?^Jkh$O0N$YUA_k0U9oH2e3=;r(X=!Yh9nkjOU^NXXD?=Vc2 z^HY#8Bd(>NSawUnOL!^Jx8-|KRraWqM}5V7Lu^Zj+pk=mA3vt&mP)wvSsvmnyM8y* zg|c34w|@$HKr7^h(hBZFhg?YVH4T!LK2E>2D6+J)KKykQSF;jk1@#mg5mpp@NPOUd z8uE>_f4X^q(q-)`_A1}ByAY<)e zCL=fy`bcHHx3;~TsnBK^-+iUOw^}h)VhBp_Vz}FFmj^xM=p^$&4M&IlNpEbb%%%lg z9QjU3NW{70%D6=3qr78bp=V(rBsaze1k z6^Uf0@5kCwyrm29!G0b581y8mJyxl2ktw5y0arerqmOEKt)#jVuzyW@Pi5P&)(X5O z&xHQI$GyiQFk$a=3&3A>$`D+&diJ@J_<4oCPfGq<_BGtLAENLywt))|fjPUvD+fBG z?_-8G7RARDc!1NzcK|zH#j`vU25{-;$b#(av-n`419_&4?D8Fd>c{nrxYOsP0}cZ? zRrB$7W#r+9Ohg4FOc4*F1|T-1=3xCsH4e~4f7=1|*Owz)Adv$Zo>&wOuK#vHZ$)5r zQDGe%T<;Ese}+9#Rmd4@4TF8|(0)G-M{T}|lq8-7JRl_pd z*^)*Wu5u9E<8~#)EtM-juF;(AeQqK~kD0tP2(C}f4ZUK@%<@gv_OHY)U>95obQFqc zg3pW%vtaE)KBQ>PsL2F{9Mn%SHW(IDn9=UTWq!V&Jcx8`SwkXn;GSzISMh@}QFMsq z`K5d#Y+)(F!_)bez5a%hn_Y#o)z%8l_tCnzC?EhMDYvXp0*;`nr1(j{i(De$4ycjO zeF3~UIkV{laxybVwL#WuD?09Dre?Pzp+TRY7qcm|ma595JR>2b@y49^fjrI2;}{HhUG+bk%~_gCSCgs zEA5(6POTQL-r90w*xF*ij;@d=Y3^?>PXjn>T|TA2bH?#2M672?C{1;2I-F|i+Lj<9 zx?Wv_b|FyKp^xtwiX!d3vw_spKn{p8KU77f@AgtZiVZr|ZRvb3Ow=QfQE((WeKGDj zr8+e}ISzsHr||A5Muao?B0xb3fQ1q0^at-JVFlb`gi&ZstwY+gFcAW{N~fxs;r>Jy zA_jo-qfUp}@~%xFUMucNqJ)97e^q-o_pB575tj~~>bsC2o6;O!MWEfw4O=*zCH{gQ zXeD>WU5~Un39K}aWD#WVq`Lgqv;dGgXk}HMzKN-2-=-VNbrPxzfpnjz)$N4+QlAiB5xS zObkV{U&n*5O9BJjGoUHxZV<}-C%8V~dO3E|czZrf#3ir=YHejaOgNHmY+dCr5$DN} z^|hk|@MFzYI*6AmV%NhnR;mIgzfK43{b7|Gscof zVSD9_FD{K!oFw4KrFbCVi)l8!K@PP$h%qK5jJB)2P9qsF z+_c?I@!ATN^G2kF_!+~h(rHX3(8tZfcX1Vdt5h^ntBE*b{t;2e7eeCjsf6!CvP0K{ z?AOf1oI_60^dKq$9k@0#88QKoZiSrs#%9-gvgF*V88OIN*Tr!ou*H{rl>_p@4EU`j z1}M$666YD$ehK6J1|^xX{U{jU@*F)C z*E%i2(;rc3(`R7+=_-0!#p`YR>_{r-|9D?9f|h|TC!5Fo)4Xv|>N5h?SmD^syEAmo zL*7R-Kkh6^t06y648i!|+CKKT-091}CL3D^PYrRkm>|nvm&C=^-gix8a-OcVcm31h z>t8vr5|LZNx*~iWS9`OgqBgkd(#_fJV>!qBqjZ{+S z_TG%WUfVGiC%lS=PE|j*At>QIK#j1NX1FLNf&xhJZPg=;^E-kuVGe=#eb^o&~AI7>Vz)?ukMy6lOlE^~>cy@=bay)@#juFjN zSb8BatmAHU!F$&i<7Fo_`6|)#+N?cnz26gOO-3QgY0ST9y^)qz94WCp!{tIIx?C@|5(;HL!4_}%Wlhu};=06z7&+U;)5;Jj?LgW@& zEnReAK&|J$h&RH8gUWUm&OXrVj@{fYWpR`ZF2-O@{MH@*%n`4BxxHeRtoWt=q)Neh zSJKI4_)2;yH~pYuwY`qho2xQjaC-i$4Wsi?7ISk0a$)=_@QYTP)TM5!th(;WUg(tL zX6MQopK!_C{hnW!K@RYIwOO-Kmo5<*l~eB@k~JmA2BJ!aNDE z+n)Y2^x(sXpAGc%n|$pjFt2bbDY$UQT2+}tt#|Qs-HchN@Vu? z+wwKe-Bgo2c`kv}>8&%StKPb37Nb3$9t}Qi=E+o8q~7lGHu7DY$Mh461rv%z1K;*K zK@wrpScTaiml-$|J0Yy^;JGHxvd)4F9e3l;U(xUx$6W*Hx%5>2kwXj3AlrcqfS1yuzfcQ|BEdg&t4AQ5GE!eB?{rd#p~4)~ zX4BGQNL}vK)-Ly*`^UEAGn=99^UFID8eHVv!=Ux1up{0QZX2W$VUqt){Lk7)RomIA zqHKI>#pStdekx7V@{EXb4OU3=HFRlYwYZ2E{QW1rds=}d@W)iD(`@^24idS z!FOV7&sD_wvVw8VlM9}77Ekw6g!@bP8VmEr)qB>PGP7sYPiR?to;l6J@6unlrU(@A zIt(Of{tZW4#H`mgUu#(u1r=E3L{Fzv;`co{Eb*>AorK=c#xGS?96D>5##~ej(Ld!A zv-2Y+PlE>$vFFeTAWVf#b@oEBZD4<_X>MrRb!QO#XWrt zbCJ@d)XyrnvJivHgJ}JWhg0%El(X8klsw$Qpw%+`Dc!7Bc~7i-SF?Jn@pKAf-?iPA zAFujSQ4gHC@^gpJDq1ZBGJx0jhe7zlzCZ@^F0U@Pj>jBBT>gcRQS$wD>^<)e-#olN zR%Yb(Y(ZwF;cp?gZ_jM1+g{rK$RIU2802CXg<^WNWH?Owc4?x@Qtsvx4C7GKu%b@& zCgZ?Xwb8AHB1JohZjtu+d31i;j`$x!$D0?{1fD0zD!z}gEKc^CI83V;`1U1s0&RYj z_?aNJw@t{33g_*@hXt!F4=?TfX(ImE{3UxMeabl3)XJP$54L@{@ zlo`Tyx-d$duwYcWfV*t+4b6S#;N=yB(Mq4$ZI^`d!+dadu+!boi@e`|3y0_NA~@Vz z%@N~{PLOX;pcs+#T>Su6tx+C0ozuv|aI0waUp(k<9!^88Gqk>+zH3O45q#P>=WvH~ zMUY@|_qEoSDHPWMd;Z5Gh}y6pTm4)UI#YFtJ18rnqiGObFK zS|0N_bwCEf&XU%_%~E3gXC3Gzl@m{x$EbhPA%``*Q^vb;w{K-Ew?E_<%3ooEjwa|mS3O&uM-m_-1e3h~a-1~EtQtS1AZ?ez zk^3@i=4(@|-+_j9-b&63$Nl`UK9~*dz8oDzf7Uv#a=n%BONEt`uLmCM3$tIv0S1Ox z?8Y$D>x$@UBu7Y)-mKM?q)n0=i(_~PL-ZKP2%3z^|5kFdB{Ui&PkHIMbicNElhsYQ zr9EHF8FeCMXtSXCs3h#pjra>lbOr!wKX^fN37m-t?_{)v7NP%KIW-CNYTr-p#+Py%QF+Y>hQ=aF1OOhio z?IF;i`t0hI$2b0uHn%JBI#q?*@>&n84~(Q3ruQ>o*--(`di$G1-E-Q7%=~-0zu_>i zQ5ag@j7i8fqqC1H6}rxhRCknimblltiqcz3SHRz`yg6Yt!;O0p?dz5b%ZJA!s=mR! zmhj}&l75KaAzH0--+Pssd^}PTPKD;Ne{#yHDy>ea*J)vot=gS)mPPALrQ5R)PH(U4 zut6pLrKWp+NoD8@ZBku%x+ z;+|%+w5zw64x7|&&Yq**^L`$Vy^ zE1%6aiiSQnpT=(TPsu%4m@C`~)6n51WTxJ+P1Xf3UxTAbF=CKT(tm(yWbv^Q9xN!E zUM2IlnZBpI`AR#J`_|_Mwctn2rGAUw@8;&qE{s$&Tdk~tx!#L0p{%FHR?F$C=F)Tx zuNE`M3$lQ}z}T67i}B3j6gFVS!sSCBkMek5c-%Y4)O_1N(y8KTOP@{tN61&Ox}PM- zr5!;@P1E^S&Bf-k@T+A%*(U6`^DQ|;Mn+l8{(-@q3JkIl-2~N5Ne;bbH$mCI;oqS2PQ4rp^Qs@#Lz-Y}ljM6;Yww7y9)d-ZO?9VIOw7aq`HQ^2KoJ-RyLS*eO2i_cN z&GCoUIEUZF45A1Xukp6eA7xf#k3&Ar<+V4-XLhgYK2>udKK->$rIEE<#wt!QJ94y3 zG$srvM*lw0!p-ImmKtT(2A|q$;r*#oZW7TE=AU>Kv303a2HUF_>@;+%mp&9%(c39w znoTCQC{+KW8StLxfyp`gtx84rB;NJ6bq`;B3X(h=qkbDy7V-h8?FbojKG!k^yX;;IhT>s1vn`+OjztfPKs4rJ zO4;^!^5o&@o$Me+jId_rwAJ>S+DfOb?Avp*WSrlG>4(ZMufZzZ-2`W#5Wb_wsU+UA zmU1WYc%Zn@^Kx<@Tby|Ab#??f*bymQ5uuPpzm%ROvv^w!pY6tuV*BQz&UB3Nj3wY9uKQJj~ zr+V0755vXtjHD>@2v6l&rF}xPRH$8Sd|Kj~Y3wSgjzh zc>X_HP`2=y@!&Ml7)RsLd-CHzDsRl&U|t^wPnBIRgX1+0+&A~Bn~7_QB%4^ zkvk@vMgv~Gj92KeTr6tF{`}QyZ%MtzAMJmIFYV(wyTE6w^nidIa?dKUZm7ky+JX3?s4^(mds~5r*?A|g1mBop(T6wFVv+FdDhTT-2 zYgq4w9Hl}nQvxSL3BNVSm-F$qlKxG->jSH+SY;F2wOq>&qwuwIvO=n^xNz=zl5jNQJrNY%*UQ0C?$?wvGx}8cubZl?ZUHCOrl^(8+V5Gnr)}WBE$NNFo^%>{ zakf$&3k!2}yY1ik+#bSGFNdp6ADVN^mv;)?D?A*O9}ve>kFbR6d~GK|WTibv@+&ml z<&KYEC$Vs{L?iG%IE)SZwFUl7_nbuG?`Dy3nbjZL`n{?k%3aNA;)UE!Q*KX$KA|yZ90U4yQs>#7htP(dzo?@l z&EtA%wX1k9IbqrfykhGPVW{oW(>u=9B7J5UUQ?4J}Z4eStE%?#;fU3qqD2p zs?uV&XP@!#AtQ64jI6Ngc>RG#t2_tn9KPJ37$NXC7^Xci6QLK+)kgy%`Sl>s9Fvtz zQ+5^0P&mthT`#F;hH>@6e?z>uaUpxpX6LwNYL11GxTzU*8jyFSkC~%wb^eK351e^@ zaMeGep?BO6Ib^Rx;d>kW;AP+F{D?*Ee5EfefXk<8lX4<45I!W}!AZs;vz>PkR=T*@ z^TxR;MWI!YpxS*88;t5qUwEm^vVF_axKgBa$76Z9GNMy;O1H|R(I9O))FW7CZTOdl z$u%)=A zxAUjXkdA~_Ji2g%u!5;{ILlZCl}SyAYRU| zh$4-syG^UmI~{Sbb{D}a$nKphQ)P|cuu-Wqf1(NH?d_kMdr*89ZxCk}ymhnlaHBC- zV5`+4|6!rG*5Yo_bD7xQb&s#&pR5v>CqDC9!(yZoXTiRf_zK-c2;sbjpDcT zj^L1b9q{kT)~`ytU2CH*(+Sy=^tFh`%KjFJZ9nq=Df>?R<-8qus|-p)lVb@1f07776Akp>3}=j-)>v z*SJjm>PAfV5U_=xO^!RIH&gkha8>Lb=biCNl(W%UhQ_}zzRuX4&0xxO73zApt#6%F zTq(MbFbU_LINH3pTNS!99}XI;!u7o3n+-Ke^G*H|aKBKppmo}{O!UGgUXv5a9f`Fo z5MuW{6~ebFgLSWpWJUpUnLFYV=G}sXnz34{wWs_gPz_=F61k%jlc- zn>^3nH_ETsmL$uYpu{qqc~{stv-IO zm7eW}@!h4I8j4z4I{`8QDO%M$JFB7oQihoB_f>y9x}QhMQe+YrQdP(|IpygFFANHf zqWN@YSL{z1(@i&+_5Y&qL*g!|D<5B8wD}Mu%SQF*_)}M1T$R%${&x!W#3DPy)luezh56x!IS1 zp{nj9;zs>91b^!_F7F>?q(l!#pUi98-lAfoy)X|@=8_>Dgb-X55jE||A$i%l<(u>aSlUn)w%pYb(Z2!gs3o1|#_2r2nv zKseF94h|LbCUjp3adg#*7JTT(k1Li*25-pC`}|OW3$4uOWSz?AsSD{xZL73NRR}oP zg!M;zP4!nXHmqRr%5mq2-`a)Vq58pp(Lb75cKzVo`>T}xrvVuwTH(N14@>RE-kmcd z4RDQhUGo(C9~650NGY@OH8~%ye8>7J9JjXzi-x`)qjH-wlYxS9<`qMe(TBJRm4D=8l#-eRiRrah^PmHu(c%Fyk=82TwyexAoJ_)FoF&E*hBHwg-m}*ywlj zWj38D$nhULLd&*%LC)ev$Y>Utwxn!x>M1?OO1s81emH4R8K$Evd&IY(-_5PNpFTSp z^T1(Nx7?c6`mo9$ms6ZoJ#mG-HTYR^yMUz7Qn_Sv=4XK9`78TM&nv_h%6cIo0SG)g&~L>+&bdw%}1`d&77_l!k3yX~iC zGVYHmJnpm5g`M?unkgrJPJO=qKclwrMsIs>$z9FisWN@2Va&k1#!X+&BHRo+r!6J| zlR`|i(LY0xD2j{2j8CCon$0zw09j3oue}p^<>`OFH>#M=m2$BJ`S^lwG!*GKF`iC^ z6zK=N*GWFSH|?VV?KIP}jFwm|HOyXEC_uPJLd_Vt_{#fi-926pRY|X*Eus9h z_heXlU-Go-vpsp^6KpS0(73nstI|RiGu42r)!Mi4-ZrPpQMY>kqQ{>9RL5_?s{1zWI^x0kM>kzYcS%$7e6p|4oK9eBmH56+5NG`R+FvmP$f$S3#Fj-4 zJNMScOSeQ?E60nwg4MrJT=NV5QE_F#0%PhwUuh%|%JzHoq;q1)rxsV7`!J#mcxXX?ebkX1>aYAO_|k`t0sbeAxYC zPFk9VT?$Pqn(e(}BDjH?_{whhxp!Pvh%a3@4XsP;k)6P*Nm(BAKa+^I9$Fu(>G1!wZ@+dXG!1Q=rN>yG5q;- zF=6YN=UGBQ}jy^1Al!r5$HF=eQ^|jRzhHQv#V>2-<&uuwRVxj>LFtD7LSBE(o)gPV$KmKJUF%yDTFgoQmsP zX>ae>LHk=y6`@Qc)aR9^Rprp1P#|!X)%1+D&2G!>2&R<00+D3fj{QfCc##qZtsZ92 z%E^_aq%^4T&*Z=|xjUo!bKrDDoP@-l@(v5@pp zQuoH};(Bfa=5cfW4hN*Gol}|iK3LFgJHd@R`R32O^JF($! zlH(SFM0G4xh2TE?OOB(RwnZ6R=SG>fFlMT~;n~()fs?A7$8SY1?vT+l?hc$v(rnc2 zWLx7l#_8TZaJ8|sTFR~>r|-r4?Lu)W8$SjYt%A7 zVxDFR_?vR1GxwHBNhmPmk2`^U6*G=It<{^~jg$Y3HvKNnS^)0I8yuR|U?vWFr{|(0(^IcDcl9Wupz3`~_awi*z`il zx6_Mmn8cqd-E(`sx^6X~j{-b%lcr$r_0 z;L?M$@dHN&>HDEy${Y5K86|)7R6A7*J6KhP_Fr%a-~WGXeT7?;Ve_>CC@tL}-5}kd zgh)#_NaxZG3rdJccY}0y=Snw7vn<`+4GSOdE9&pNUjBr+?m08(oSEkt-8zU>38>_@ z$}ErT-u3ZB-%;59@bcgIGVd*IYkcF5df~=dAZ=_dy%AF?< z>W}AAQ(t_$hNoxbn88qd67;6i=>BIX@l%{?mI^p6aSb0zycl^zrmA~V-AiEa1lL0g zSodV)5#`RP@|7m>w65k(Pft!7!y$#tdL1Vn_EoMy)V*4%BkdRf6OsYgN1j3|Jn{v= zZ68ntzmsn}MtgvUXmRG=#`zJ#xoW6xq=TlSt+!YpUrd(A{W^>GZTO};<2$$a zqEZF-%8;!MBqQsR5l3=u9&t`n+`mj+)oC2wAbQR;Ks_-xZ#C~v(w$KJtLvx2jL{=MH574 z!!Lb95tXi1p)TGp3#xMjOdC|&e+6}9_a6}PpmLqEGZLRNIJR>-Ld1wF>M%K615IM; z88jsX(iE)?+}i5fTHmKjt<}|<4{MN4ikqdS0JZUaFBnhX6}56&syGz~$O=yOT2XkUrkC z$JC!NW=nNLVJ!|-6=jb0RLyCc_&}v?Peu)$1{Xw0!BVKu;4c@E$Wxl$n7HE0PQ{++ z2c{~s*^WTY-d>{}3-M5j)}lGq^gML&=9HC9H4{sHpz5O9S}p~vbCK@*N~NXt`vck8 zpA(tq24+N@BC?}8@>k3}CgxaA1(kn(T{oGGZtt#gA8TY__H4t_jUz9Gwsft;DHgEA zrKCn|LrlvRq@4VYEDj#aDKbVeE$~>F?b4T+wZ2(orebD>R=EaHXK>!yI{HX23En?! zjMixHN0e-Ai*eDVqSAq!Z;9)EY1#QKY)TO$Bw7dhI}$v&hWZ-ln86Lrw0zOCUOVBG zuh!@yxz5Z)_x@ukgg_@sj9~;`J5cHkA#!uPY#RF&R>cJbj_aLH9cgyL6T{%h7uga}pc24J((s{nXOv<->{@A%gC1)^@=&CZK@;k8>pjCtwPYf+7y#I`%n zsxxeY)@Qxh(7P%+kKmK_9wy0=gx35UJfBLn4H{Z}7_U_MqC-?DlSoWhO|ShxTqLeL zKq$5J$V5_xUh?+I{vA#2{Y6w3+oQ{pXWMO(s1!Swdd|kXde5PQY*zD;ChPol$|T2t z-KtEaj?7=SyCjbh+`}y^B_gVSKD-wO(guO^#QV{-ztjt^Y3_eNN! zz0v_0XSI>M-aZ{vp_3AD`BWYJ^dsM13^;Df0kDTE%Ns#dwCP8}PF#~zWyO_fU`NQH zq{pi$t(7Astsf(MZ&%EyO9U7z+K8OwPz4?{Kl|Q=#-^p6hs?PcRC7q^cAsC=TfnZ5 z(OIopezbAVo~m1e`%|c5WOO-`^AsfzmL%kRhN27E>4a9(UG~!bY(B;GppTSisv7)f z)%4zPGr7siK$z#2pz32NVVC+ypZcl7q+&4}zvF(hcdN2Pik@rFyiS=}RV~_tmwLhM zoxD3Rui8G_5%q3w1#4)H60uOSR6R4Jm`#>zw>=`*C8zHA3@(6a>J`D|#IWd-#~AKL zc%z@pvF{d)-E_5~HsdKz<0k@VnIiR{0~{z3U$S0+{y5S1dlrjVEIu(;@w5`8l;n~} zfY9iHODzH8&53xF8*Y0GAY$y)TD@tZSMUoF184NH zO{i3GO^KIhySCTm%@JM)gpl`$A;wE1-95`?s6S(gm~5GIc7JBWJmy&1v?NZMj`@7= z4fq5j3!Nv})fwme;O9Smyt>>U_hHW>OQz7@OHX7CvI?+e$?))oMW@dJnDID~&C%8A zShk1O*Zo1c1}CjFe>6JeD6PS8zl&wQJzHZgl=&r4#eU3yi^;&JbeBc@VJEEIX1q@9 ziz*228^M_8wpCcI0OeZC6oJK*=lt2c{%B^>*B97ba#C?}JM|-lX(gW`Crg!g4$-AQ zhn?#PHJVDu8`!J|9^)TVS^XGfm(AU6DG2U;^PvExn#1)!ifjhuvC)A3miVQLwXR~X9bBLca^pNAwOU95?mB~- zLvDTMqWf*!IIJwWM+iJ9HR5Bqkn3urPhYdLu}Y<%P7-&%pmE{a3lgIWRe40fbD<_T zD#$CO$M_+DV%N$Zl6Sp0h7=IPwoIfy$-SnVbT!4HmQdnrPHNfqmsgGA4oGN8_-n{n z>x|xbC;~i;xoDX>0kdOeepr1l^gZ0k5jtiI>^YU{ne5oPUlC4)5y#or zzC{7~p1X(kT{C{4E*i)WS4$ys*#!!McdvKo6?8T>QflEJd5pk;j`S0baXw~6Dnk{bFe^)9ZyRsH^|7+`;+7Wg&%ZPG18l-B%*}Bco8Zqkv z+dBgxE8Qg3)!(y(Q*c~W2!L+JRONv)r9FZ(n;m$Utj&P)9A?$NnND&CyZyuvvvdQN z#~!1b$=rqYEW3+y>s?wI=}jx5a-cws;IymN%vl?={*s9X|Nj{{eOWNgF@mKBIL}?g zsn#tbVtEZ|j}WsSHu3vits^gkGfu=AF{kzX6*+|ZM`$@S2lF5;&4)U7b>ihEPt zA(Nh;Ucj|&Zm9L}U5!&=1NPw$#>z{d&K!wo9Do!u_QKlSe7b6e(7e@m^Hm8VG0CQp z;{=@BXrX53y}Dwm(TVK;tDSf_NvHoaIO}V(K)wHLj%6)lw)REB*UB32NrmMJ*@R!& zS(8RiTMV7)|1m(>5?}piD=bwU3EQ%sMfv7e!c=i#`BYaTg+m1lSFx4dJQm$j<%t{Z zZ_y#awdm6y-h&7k;@;t?^Wth&fp_K)D>1+NA&?_A#eV zZ33nXcyBL@c7`oVwEC%Q_*Zozmd+W9)Ni+9jsyQ`!2C`LIR-T}o?W9CVjcumOm*s_ zhNDkw$}A|~S68i6Is`xL6JOrGYRdgot86u#z_C|A5iFot6Tq)SvOgoBW~mW>(6awC zziu9d;{Y~(%&;a``$UGWa8^O!aN>DMaq04xYBn=kI4ibnLU?$3?#@{O-Ry&yn>v6R zs2X*9gc7*V{I_@fpB+7;9`Aq)M)mtcZZ#7%lZ#?lk68|zQ zs{XIXaGslLWSqWy3l)0P>2!S)FYTc5%1n+VswRgTDi2Gzd#iOs_D4gF4ziM)F6*QB zhlIU@xni%9aqkIYy_>Oey^HzGt#^^)B}(9wu^J8Fdw+1VU6#2vASckepd#+JDHaxs z$u{FYO>`@)i6_&hP4Z~RukU|l$3(IZDvW+iIAs|QjCXS+Lm*39IQVwVtrIF8tJVCq zHDtOe>spmXi*UOCypilfg#O3__J!T8AnjGQ$gX`UafP%TbCpv?RG%$!RQIj_@)2(4 zMw*r_?+@|^T;g+6Bl~4wYgE6iQ z)BTf}UYU9!$u;p($xYg(W6x8AW3zRMsz{BJL)#qgPlfx(9_FCNvI8I6mt0*>I)twm zi|W6K53!wh)^O*Y|7j!VRd+Efy|+%p>_>?3&y1EdGWTTGiM0|B>QVt?4w=h@@~;SvrBj$fbS^9G@D<^s z_I-eA&F>a`^Y=%g-P1?TFWVWkwuCk1XSZn>twLwBBYmU&5KEZGdGuR}z<*XWtzIH*EQirV zUVUpRJPrXdKQ8l~>A9hzSi}HVafwodyufdG_sn(i|5#Gt}_?LYfw}vj%_&B zV?&ZQKpj*vUpB>-3S#D0YDQmn)zJlaSq?9|pAWLitR=F8y``u(?LPs*h4i#o1a(Zg z#u3iWguJD6y5|NdPtHwd_PRaRtsVuqX4coDIBMnH)?Km2N$u!H=Ij2Asg=Q$id1Gi zYP%wy!M{yhad8_$y|zmv&u6<`Myq)_H$4tMD~r<|t;M zxpbzbtxvX>tgJm~(%<@U$yw@tDVo|hR2S4C;CR6=19y!Ut!Zhmw(Y zlH@{%T<;UdU;h>Vl?e=^-DoTU0mHwfI_hJuk#8z(v}&s=Aq^Dq6ikNr3N2I?1K&fY zQx<5kvlAb}_!5oWGSXwQZ{`qP%)ndNFY^&Z$Z=)K`QP`*PPe$ft`LnyVy2o^BO9 zx>kjR5^O5v5T#39D~`#=@lV|k37WlM2oHz+LdkakL9jn@2nx$>D!w`-Y)7N zlZd%l6%>GsC$jdq9i*u(Vn0^?`JN_xh+OUFro2qnbji8}y21UuNvNb&Uh_J$A~`i!9VkL5j)vza1{H<>ycT~tj{zr5k?|L zUQ^B@lM&IqhR&7F<1wMQ>)w3T!<`U10H$zQ2(t0|+fx*m*gdWLLj2Z~hVx4kaL&?u z_Ov}%IHmPsdi7nbAT8K-z@lRSSH_u+z*u5`1J1C>Y;eaJYW!k$7q%K+#W|NV-jt5R zp?!`7y0>TzJXOi;`f6eJoTS`3QuHk`Hmc{F7TSiU%FQXN$`RZ=$DK-8bupzTdY}GV zDvUOB@_gJr6C*{jj&P&wie1lHMFkEvXELg1I!TMg)B=UeH7lAu2#Qpt2M&vT&pcM5 zsI#8=t@nD~-Z+SnDO;Hjpa-L&h3c$gg%NB#UGdk)n1ZYiP)%ve&CslXO|s_kbz`-t z`{}PSa?`Chb%hl)C$E-uJFtvT-SPMUKN^~Ta<*)tjA!d0qEmo7^&%hD9qoMYnE~rS?y}VOn)}xW%0q}4A1-Y z$C+dNzIkAGjZT%cCKDQK_Ke|`%bcAf);DwN8^}4ZM8pjnk zeXAY+sg$-7@s^GDcLXPR&%$bk5@kvoQflS9BgfdV=p(gBu`}p=cs8B9uJ^mzaEpL& zoW`*eoTSP~guiBWJ|%u~^fy@`G+6yzrpaFR{)*vC#!XMcnI}xAp~Dw!*P8Q(ysKwsqq(S+cEP#nczd{{ zp?A9^<=70GJi3gRqU-LC7fk_ox!(+=g3y|JzH?LB;?zK^O*@+xl$*JB{HUr6-dg>5 z5k~S|=96_QSK=pU0o#nQt`7BuRb*Du`uS8Ft`m?znB4WNPM;*~WyAF19K%Vmrf2mK zL`#gqy^nw^09`@^)nJc7k0y@?mXuFmi8xZqPGP)dO@YK72tk0S;E^p;BuY?7d6c30cw!Y$qh8@3z z9yZC@YDsy4956^j#MEQ>etf^2)7E4nQF%pmZ7Yubyc_xC$&pAI{Lb#NF~&|YwH4;k zFVY@;6E~eZvZVLiHyQOk$icWkCG8}dJYRLpJ8&l|;@?LYrHVHsH4|1$DtMx=Vgp$w zmdaOEEh8dlL3As3bdCNNBn#fnlIh}fJo;oN>w@uZ++rnQ4DAL9 zH!wuC`y~~b5_sE@PM?o&V#eIc*odwce->*Eamwd2s)^+!f+$hU#kc`Hl}h>~U7p+? z%BZr^K5JMo>R`L}P~3)Uw&E2uvm#BGmthgOTJFg*?9dmj))77x*8d7@aGP3vDxkjX zje2(hmVKYKa<$b9OK=vXgCptK(xE=0vHaZbxxnX}kCxBeuN%QI4ch?(e*8UPBJW|< zIedlY4E!ttm(SG}vrEEMAmXHo$hE}klOZgJ8k{LAwb`_3rIz|PX5g01mh9jscLy68 zUEZ&4ap1%(7fWz@sy7X@pXKbFjbs}na{8HVL!_+*)*0j z(K1c$EJp;EXd+rAm?d7xM7J4e47ri}tuxjeJ>RO{l5O}f>92<88B!1)-WUvymz*B5 zS?{sI^;i8oeq)x{agj+Vjoie?!D%1;$X3h2B|Od*k%*yvck8CO_7)GIa4sZU%~ zTzIR!cme9po&M5~>Yuj~?uyv~rlE*XPdFCzZYaz_UpL2rNoGp89OYcdC z2C@2kA#f=>PMhiyZMZ~j6>Bp?0q7|~o8s{Yh}l}#D1Reh)ituT7Wk!?F+Y)bmjAsl zH1va9_SP~og9y>h+D(Sp}dmDcHtr(s(X=t zyNe@^79FbwBo zf-YnmS9OGLu1HF~KEHD(T>t-jvMovF)CF!p%5re*e(-Fz1=%xeAD6_op^A&FMDx`G zL4tp!C^QuPo&R@LNuykl4&eR>K$rdT&*H2W8}+%Z<2bbx$kIa^1@WH@+9v)SULw_! zx!e{VD*j=T+X7O(^i<)DpRVWc<-D$HYtn)@S?!aA1QolJYjC}?tqt*;2p!p^Or1?h zZf+G4(Nu2sAv?_tqqZ2aM_%bSonT(hDsIqbd-6yvQb1-}Jw9FRD>(O2U*-3`(f6zf z=H|+qmao2L_vAFCd-3-X+@=+xZ>&Q;`v-TKiL2XPe48LVSBLSo`8a|IQtAlRPL=!J z4hF0^P*~y1ZT);X1g50FE(Vm8irfT97@mJStzgs}T?9wj+UCk*OU&H^crlQ>zALTirhMlA%;%Z!gA5bTHUP`B` z^Gt>Dxn!T$SzmqwK@-kRBPnQZiO2fdvk_+;hcfS4)=FoyNzGM-Gn`Ahx$kBU)C>Y$ z3uLZrw$8n$h=|kObogLk^KLw{u}(lWi-?MNWF5rjhAbdmA4n3%7}yTc*F(!Wt7xth zk$?V3F-o!xn)Nd9s*#~RdEF!wG`7;2RW2!;3T}?48=W|MUO@Oa0`~U}<6@hK%)Xnl zWGRM~0M>#mdqPzQr-iOdag{k6?yHA>ko`QyNptCRl0#s~m~-uxiP_f3FbSHhC+l=D zq>8rvlx4o8kU!%h3h9vu>UDprGlg+R*@jy)2wXxJV966Rv}M#7BddJELv|wAg#I*y zTX)`Ia(*o>mTla<_qMF=_{sI$#n?CTno(nRvgs)GyU7m`ti62hkTBtoa?XHabe+=T@DnbJFv-cmwa5Zgv)WM>3U0BnrA~e|Xj8$eOKeMOuDv8Tyg~+%$T$*@} z6Qt&aR*mGYMTS3}GLx(t(h}>~yWJft*TU|O?%Tkn76aV_9(#h!tQov90K7*hbeCT+ z0E)P-)vI(J-Rf>=n3Nqiv2ud)H;GP+i=+1ZE@QEPetek*#8@MK$NNPq&As#P39&m@6@V-qD+__$26I@Cd6o zHbMc;S*f)~thuSn>Q>dGq@i<&)g=ig)H2AIkE*pI{7IrJ+6GU$vs|ci{ak9^b1dmC z=4ac8%gnpH{H~jOm+ChCKY15wN!cfAb}QZfZ4ovEJ#ajjA{4t@ zq4s18+Th#ygQ`^lP!rdx*7v38Ns=TsLp=iTc&J}Vw06@KmdVyCUH?&s zk9O`upPOYq+<#F(3WhoQM=DGwn~wl@jEdUJC5^IY%AU5HG`5rzOM*@ZNw-(d&&JLE zB+*TBqu3@H>I@3iDH*8lXlxu$D2D4?$G)N-==h9W4hpOserN}uo|$k6opRBTSj04L zLXa0~szw(VBQ$G%Ma~HjctE`IbjlIMr^QVnzPBP zs}W&s^291@fULIp?43P>z!dN)C3+&5$;;icFeq((#h42(Jw-WI213PxthG2Q%9Zuk zfP0;;Sd}Yf_>TM;FUW1Z3|L-K)WP^GA zPolk$sl2iXFrYY{{8`9KN;kV{o7g2OtS4zEVw5s6?%*OS zcWfUCXLIa!290srGKUWyuYw;Mni)gSCPa>9rK0X+-)lg8W!qI+Bwj}6#Z_I~U2k^q zk^XE%{~5~?Q@T7$tH2OpsC=b+9&O3FT|G;$=mNY#InN7V2-A>mwTu)oKGenvQ;JYp{CoxEeY6rxrQc?a+xGjcO5 zWYo%*Gc<}>XP2U!@u^6OEatp5Nokbi-5^@4fNA(oOB*ed`PMX6WTBHEzKL}w=*i?X zbh>ik=v%t|?CM(lE?Q=9R7V+|C)Y!WA9merq4vr_OKe{ap>_mRw2F+qG_QL^Gi%9A zd>0T{#K*CvD;s&UEti!|f80bXpqTcw_LNKY%m30lRQr#`Q0=EqKJ$7P`$i6;8gyY0 z81dVDnq!JwI+^Nq67RpMq%$v9^+sCLRttQ^@hhw5{MBWicgcx+j5XKj`~CdGR^Wq9 zqloW1JbvHN1aTs1VlOTWT)GvL3du83M8B6gN-;)-R~tu55q6T7sD*sU!V;EC+JTTn zTt`RDEo=V1yFvQTD$K+*phM}nQWZFW9L$7sLzB&f&E$kYE&>h6EGvP21(cSGIwstP1v!Z&>f7UT>jIv zw+#OgZq)6%h+C{uf6XAT0n#*{TkH)!n9JSJk&pWvxEp_Pu>H^Vnzgu#9O&{G`bH*S zc^%I8?u*<8b*Sj3MST*F!qlMGtG%S(O`+aEy#5>aNNz5{o-XFrtD0@xabXNLQgkmB zP6>&eo{2udHtibaHA2;GG!n4p7rx5XB;`nvpWVyjyifgrd*i0f+T77)RQF_E2e<5Y z7U{~9!&YUK!#%-DYMt?}M~fyEeDg?2?V8?7@lS1`lPdFP)i|i}L23RTOU106 zukvyvL8Ple^7&h5_b&kNaiV;gjBx=OB6=fK-eObrKKWTjr%TZbsL0z45~`cdDeN=p zmER?mYC_m9{6mb$C&p#J1+ z)H&zrBOxrfN?Yhqei+B`b3d+a`I0npqoUfM;@LG|AZ3vAi`)|wLTWXIyWXH-Qb5v{CP;M7-zLXmJD>NPO; zMC8{Ey>Sck4_{E2O#w%iB zX(SAFqX?>VNAq!D#N&d=p5QjTPX8=LwYjEaLOINI<8)HLNSeANp&C6w5I zX(WMhKJ6Rnb&WPF&eCy`=Yr7HO}yTP({E~7{?_tbuGf}C zVUyJk*PRgjJ5cYrMaF!U;id+F*(rvOmUSRs1Tt%a>*$Yy@UdOHLIhk@V@TyaaW(59 z+_V;ST^-5IS12ioAt1(MZ$&aPU2s5V^oRpbOx>m%Qb1$Tp>vWigXu?zkqN?+hPS}f z0g|drwFdp7fcfjd`$g8)GJTMZvmKG};6$-yOQ;UU5d4WG5#cx*&bQ@9E%dvX`cC9W z8k4UQTQ}9|&K!UD|oz}@-hIC`(c;XwGVtQud$ z1rVY`LW--SpwXM)H3og zQ{=adcAm4-Q*Dv7s}ole2BgxUf3{sFdN}yPIpk785!fBQ_v>U_H7J@Xs-ha*Y13VM zi9E{xKO%=}!5TOj$uI22a-+|IUjW!VHbx8RyO!Ux$4iQqF?26D97#|kmOgbK5JIpj z8o0it{Ssv!TD#8 zh7?))FV_`EM4#D199b2@ctCiiI`*Yg&) z28`GkH}d#O1t4bf9h+lP9ljEU{{yecB%CP&seD66$SQv>;Ki5tD+&=O3~4|ZVP-Eu^XuB?zPaYX0N&W!BIt&kXSVKFr_WuHS2GOl8-$fU1Ol%dNu z{86rfbMb=?Kbj2Y5n{xz%Tp+xc^FW62XWz4Q>*@hA1k}EaLjdi+;;;JmUBIR_qUj) z<)`M2dc7&~fO14y{cJxDm3Iyr{Dn#0ylI`3SZz;#{J>AU9GAvOB)NNn%D6N^YT1nz zm;F69V&w(9g_d+$Gdei}V>xd-y1jlJ?Y+M}7UGx_#auSxGRyzofgTCii5FVRpk%|_ ztvT^pZ6AzU;c359oQQRClnhzD_I<*+)Pu20;SQQq|m(dGfSj~TTKtSUpAWK-hc{qSMd{p= ziRIcDlM7!iz*0wdI*-k2(!T<#a7KGZas^!WecqH|w@GFzCHnzU`1l63Bzh+GonK1M z!fJ-Xo)KD`6HY^uI>jjWG!N2*=<%9UjTfgZXsaXqRubvi|2mH;U&dz$@>mopL#Nft z@MQ$8l!s?faHcO`?Hm`}05IdtpZ?WpJ^5ZcA+Q`^<{_Koq3eC$igq`)S$km$$?UEE~>7cdDG)fh3qmmLK~F>*n{3 zqDvEcF-Q_`faF`+hXfA;5PH<-Dyh`^V6KwmOLg@&Q+qtYg&wgay9hFF$FBD^4)2KoMzkbR+&T(4E(aGf@k~JmNjhK z&W_io>dG4msmfo%S{q>R3O2{g;G$@xgeK#UZt?zp2KF|tbi6xd1=H~0H+kLZI_XDw zwRAD&)vfAaiZ|sp7U$pGLo}mYWQ_ubg5wF7H_dIg&(m=S>ejm{G*8=ZB0lKm#u%}| zb)~Egu}jSlIK3IgcB?9&=B5Yo;?xxIIg5z_-4U@e&=L z(V_AHH8%}4ReLU$Q#5b>KUs7OthY_bK@R;!6+AgfUN-<=();OkeAr8_hG{2&xx-XH zL+WpS?cHayZkpgG*nMjMwVOLJ(ub8<@1iGX(&UWb&5!pjv<`m97;kvlywq1Ybp_HaSm^>$uMyI(wfxs3lAxT?=sD6^I^>eVAIJSs9W zkQysmuaUa-Z8dbx-+uyT5RyD|_$6NQ+79>JJosNlRZrQj!+U>lprnpXnuYJ!q7bQP zU99P(Xg=U3V-guQ{1Ab#PBGdfR@tSvy;Gi7JgM$x8Z% z*HsIqx$Vvj`gvojnFFbK6*KFg`2!YAK$2dbDi`z%vwj7BAe1*LtQ^2i@AR! zj{q1I!}33pM|sR25LI1%R`URTw^#LkGI1CvH7A~TH;!W<&bGpf;r?sPzSHm3WZb3n zdJ~0cF48!*w6N*WpYa_j-Z)k^9r0-lTKsOz*R9W?~?}<*ck;U3v%p(8hhdy z*uUIp7JaU7#FbMT&i`7XyTa9BIW$KHOsLy6JN;IDrY75<_jd#}Y?E51$#-?42Cm`* zx<=B0YMNBUID=~ALKPr&3Hd7V;pLf=%3tlH-Jet%F*16-9;+HMiWCeS$?722qj|Fs92;ABG2C9-m3ANrJr+TQRc`yS_ZFC)sUhWQt$13*xn!NK zU{TSs$s#rROZTiV4{Cp9p7p}Ri;eJ3aNkDMvNt(HmAl@y7hcyMa|fX}>CGbfRMbN~ z^+Vr5sj#a^LzB}O$z{rIMq(W|OCY+GxOd@O{Lm*w$!YG&=vK6rWzn=^SD1|{(p*Wl z?$*Q)4X*jDw8!uNv18@snrr$7JN;BkfZR#T#bsk~ocbGwQptoCf660$jTq8MN0kC` zpT7pcbJctwlMyWVI*!`EX8J>xlDDCgtN)*IbhE0W#!a^hDTU8W4xc-XJ|I^Zm1Q!D zEQPQl2iEMkJ!oaO4{yLj!~~9s!NDeS@EQC#d0p}bH)Z7W>R`++C7!OhuR=LJb5lCY z0%3Ir+grsK+Z|o|!=`iWD1OLk4hJ4!YemtS-mn>&9Tue%@^PhIIVDB0VdrYG)T!5x z(YqZpnMENn#pu2LX&N^Wg|v-FJ1Ko|W(L7!ILpo(A*=b6}XOGC?{1b;zQpI#;y_ZOW%z8b^TFZ%I#u2 zh`DynO5XLK(xitYQEp7F8!ipiD-?wj4TXT6r%d~9rfaqPSmT?^8jS%UV?B}~3e(aHV#VJPEX^(O252-SZlJ*(F5)%<!KMDE))MN< z8oYu>x^if#xq!72-M9~mXEByILZ=*}%#^M-h7eAfb`7=*8s0YI-?40O+Cjp{roC)J z9Qh$pdf&TRYAZ%I4Whb+O{TP&gUYv?4>;gu;A5p;8S-Ko=k4RmIm@(4(=#)|IJWW0bm$xzY6XPI{)lY-hgh zkO!Hrq0^Lp#3rR;diB_XMJ;~i>X^=&2gtpsirZUag5wMk|Hw{WsZbI3w3rkb;opi# z-b?2>BjyiDFXVLcRLKDjsCcE=FbQ$0*i7m@J@wIR~o= zmh(Zd9|ClAs5kqjJ)#nVV(XYUDg;3Ed){^)L~KhSeiAN+CA|vFFtvF-N9d}uU~rmJ zY+zNi*JtJMVMOqdW2QR4E6#oB8Sx_0lmC@D#M5=FJqzqfcZtfmZ4Kg2ZrJ(1;u6Z7`VW$qy@ca82}Z z(BPf3g3tZYsNO_gxCBnI`zDstdp7onOvg-scAaTPWZ$X^vba5lPoH=cYBTj;snk&(!<`up&i&&Rhs!jho}PnCR8V7%g{~9Cd7t(oXDP?CpnZ7` zlE{Rq$Ok0YDOa6Gz~e-|oNbWn=mm(;pXEq!lkK*|gWJhWpLfaJD$|Oby&5FEEdh=p zA`sm~E2YO2=Q)cssL2*aOHLahZ)i78W_j|^&0v87dDJIvZ0l`UtFo_}}NE3ekI|$)~kY9R< zM*fsSQ6j@y3MH*uy8lz3F#YxItya5Qhy(HDgJ*F`U%jM+&ZAt7(44Fpg|rWf-3$p* zqDcbJUwIxNYHU-Ac|hOz6gAVK-Oau3EG&!rXz%9oWr=jXk2VRol&b&UkeVcZ%@TyZfkrm~>b9CeIlH8pqUz+$;HD1DqMob>0n$StH6hiKKVoRLE9r93) zIQL(Q_+!R|s5vbLo%C?vW0Axr#!i{1fX}(gMhRuT+9w=5-bQ>sOt5sQs#s|HX3@(Z zPv>-*V`45~th%$u?HLnX-Wr7eIxI<-J+ND$5AO;(y-bDQWd&^aBz{EXlrd!`gqo~3 znTc6;kA89}C9C86Msrj9S&+f9We;47MgF&`1<)_T5+P%6C&Cy+wkc`i^j+Qm@GiWY zV%ns;_b#tbNPz#h+!tlR8%*~QhxZ9~>3COF@ZK|*_?N7dkoDy=;O%q=TB4jn0GyM>BtuL)UcZDE+PZkWjsLf^T78wf0q<6Jx?_hxti{O zUo{om;<@{Vej*P~e;N`$IRM!koz!Yd@%qhfu_z|{x!jHed)b9tgJD%N;4L@F@nZ0CPc zs@tWeP`pcD?S%%JL`;C3(H_erIi@UzVD@S9E)$S*yY$tKX+w{dQH1s=HdFM`h&#jK zlfNNQ_-P+o@w)IcC+?81@!F}3^>Z;#5^NZJyFW!7IgWn5{UCHMJtDhAH#2ch>@ZNp zPqIYT<=UajYhfg|Dx9lleU`0FoTBb6Jlok!*W1i5^u!2;(X>@tU|z`NBFcbY5^;`d z)890~5TzTg&54f#Rq|NhMH7JQfSdNqVRngnQWJ?$8rnpKjVU7=rA`u(q zbM_^k?og~?{?7I0v{s3mI7&Z)8@~IBT7kwzKgczyhUv((H-a$K_K|%trP%!@qQw7p zf!r`blk~`C8{_J_=>$rO+eAS!5%_gcX@y+H(ORQGm+VPN2*M-Im!-8*`u(wMNo8>Y ze|6|bL&^h8deWpW=*fYs%3E<4W%xf4+=gIs)mPJ&6rQD>zyjNai<+B@RK{O64|T$l z`b}52W_JEo%g#Um+9=kT^PKAJb+6T%`eE)xY4RoSWq)@S&fw)Ys*bG%t-&Q#$K@JF zC=6nP`1#*uYWqk%&nFHdr}-miS79xNEsy7&i($KpuRJ@{_AkOig_MLBv9y+D23cP` z;$v5KjZ1Si(_hC~+=Sr!7XU6+E9ex(dt113!CYi*0+1(v=^cV4yU}M&Th8{?*||+< z@S0+WA;x-oTzjW#bfMCqj|T8J4B%(mlfPW}iCNgS4D1MkLrcsm2l&1QMGfw7{TM3+ zqs02+6R8Z0(y*el?g@5Xr++BGXVC^z+%A!5R(79}LchF(xM)>jk9!p3G)^jm%Ipq} z^m|JwfL52kOd20SviO^xcOBCGMNfR}QfF!$iElgjgd3k<;O3{ZxaG=vtwKO2Nj9lM{M( z{iYOi4QI>1`>7`S9JP`LczYuhy!R%jP1Rqe8U`%nT44qTeBb;rnK)>M9sLVnh(D&V z;(#fmK6aKr9axsHLosUWy?1{xK4ug6NLyyBA6}&Cr7Kd=S;*Oc{eGWJG2!4nG%WOE z@>onk4Fd4hUmPoejhy&Y7+G5EURp$T3z-AYvfVTIIzWKiSM@QWrjiE_^loq5<_PCI z+I6{5cj(HS_nqy)UV)$kbL5kT!25cNefA<7t1R98!?%J6V6^6VFQ}f#uZ)|+uol}g zT4`&$szMZ6(PnhBM@{TxkKxltbgrSB;_rj4^5%H^d$H<-_#?02Zwt`zTBF(Em~-^v zUk43B-;ccqbvpTXw#(r3G;6i|g!{`;;iqSHZkFD@9%+fkU^LUT-J$`wfjiY@w@>4QB!z%*2EA;KLL-=_xq;;{6WjCZZ#+NoV`aPRNCcH zH#({_a$kk~E4pD&)@=8WcyiCZdT3eWK|dV>cdf6D%V)6UZ{L(m9en>}|Iw#;2lbI> zIG)1=9##wLr9H=72m%#k#7j&i|vsV0`l`A;aL*# zIdMX=?yIRJCbZ zTCbKVWwgt6H_sRd6+q}I?b5X2?pB_$D96kf$vb{Xr0-5ga|Pui{3T<^q(?{TGODv2|mnEj7 zmhEbKat8MS3Z!1BEvf6m;8i@^$P?KJ9VE!K7{k}y69FK5eDz``Kjqi?hn5x`f3X7V zdih$ddH$#QC)+Zx;#8B{h4w}Nt&F(W57RR+CiHy=(A8FbplUh?f0&{LTeZA4J|ZW* zxs;i|VTVUC$KYt5QDuPYdJy$6TD?crwCo|O9ue|L9d zR$iWh8u(8L|MMzDCL|(I8VymdIRn)M$G-#52=0A2-4NeL3DDGpzO-!WiHO8u%e`W#!-!EI%Ay(*5)AdQRF?MESCk28^?cz1^NSSDrB{ME$N@M*$VWl2i9V zqi@cOm@2mq@}p(xmQPZAD83k40x1vFeN^@qMEc$ z{j*!TS=?n2?a;6fJY9>hsLr^emt+EZlkhbTMYmE~E=Mmff&T@=9sdE|)yrbf|L=W~ zf3hGCQ_G~?`cW}BSJt=c1{huvbI2i52of%y?EJ?Yh`-c^xE@=OmU*JMbXMg2?WZ<& zgC6_J0kTe)Y^(5GjRGQ%*W0jjeeY3+tdm8l#)=cAibo2*HH{>^Ai2l*Y94K)g9{UQ zD^pyv+|)k#-!li?4Y>^IktYy=Du6l;hCo2L;Ujy2QA$sf4|Y7UG!Z(kDqUut?B$_Q zQjW%GX)L|BOgP+54rYZRJCtKI3abZsmSUc5tqy<~>nEDU;QwV?3AFO(ttMwL%pH$b zbA?tf(#uC|-4%z#7m>!EF+K-Hblgy#$gJKsRwFV(Da`b^iq0H+ToH(9Y|>FPDQb!z z6MNMb%XS@%fYC^A0I{rhYm1W+JPGqr4%QeK(j1I?Xn%w10>=Rh%E&b<9e%z0L>bot z@=~BO-Iedxe0fKf@F~qw{C7xA_cpTBR>jh1_8i${OoJ+Z&R}2gF_5R0w2nOFh!Cud z<)4jR8t$PVV`(?sS)i@7#az(D!Yh}>H zI=#&=(-tfJo1snJ%5yPwIi2{5N-yGE84lb_mj+U+w+!9>_nZ__ApXf)=!xcP_o6&@ zgv_Jq6|eH(TLcG>BRrhPjumH6vHVOJ`CN(gJ0Wx^Pa2g;V%H$!6eyibMxlDlvuC%7 zsvjQtTpTWr*KSDu5qQQ3b9EiDe!CDQ(nXCY&k&c9!m;F|TeDnZt=i=|v%}Y`8*fQy zY@^QX8B~#42GwUK;o6?3L~|74p?A)Hy;E4K{#lEKo#Z<8WP2&F-|-pF8D{zFV5z|M zc+tHLzy>dMxyv!L&2k zzDMDcq;faq0GC`GeA?r}Y0B?QO~?^@m^+S`j)7`iuvi>FrY`YM;y0haU;m$I(gc{-XqYBAn9IFn$oT{4)bs zFfhM(XMYd#P!)8gJzK+T^ZuDP@9PqdkZrWGgy@Rgm!m7JUsm#Vv6gW=P4%aT_lxG7 z9^O#Ljx+Vra3jXUTD3N4s&U|n0^szL%^oGj!j~hpm zb9mZ&Zplxi)CT+yChts}BPOBbM(L`u8mdhK{4Mu%7ot@ribyXkpOzFox#TZ?Q2aZh zjULLDb4$l$BHJ4cZ_LMWs0V+lXzCa;$($u*FP^dkBj zU$3T{Dze9=4gBAJT5XY;%|G?b|6drq-EC0+RI@r!w+cWJeb$f50C$>qw)qXsT!y0V zl(`i*g64^(dl+aB-dsGnTHq%pj;~j=tK!77ha}h4bNzPm0xSI1CU_zGqucT#XC7fx zbjU;tLPW(US>mdW5h;>tWo+^#a3NANjR{G%^aPX*mf)iB??_azIFwSb$m6Cf*u~{r zxDLgQ>@ehS_21d!J+kt_KUb;z&l4#$vR>k^g9uNI61QzyLtOq&zUM!|bcN?+4?7Zy z;kS3+2*Fpd&sTBo)2SK$Rb*M7TaL7Jb>p->ul_@^>AKqMv;2Dv=P0Ee$g)H*gf4Oo zQ$~+k5)JTqjW}g0s=oL2^ZmVww!BD~k7S?_i-l zPMw~PuO8%c*d8;*I1Mh#+~B~+`c@v(FSX!dRl)x)XA$cUK}*))yHdGxDzdg}_%t&d zoN1Ja&t>)e54%*G%~#$+P|Q)|*YrW4Jyl!LeMH0BP(7B&9XT8=b5&px-!Ad9-qjwi zh@drbsErKUP)BHIicpyPLTDU7n!4wD-mMw$8vD`xc&n^To0rI)y#SCMbLP!s;vg)J z+}tFK{vHq*7X~i_(={kMwm#0I+Yj-){-;45W@SiCum`;e!ANuY&;4Vs#{qj$q4P+i zNXPk8Nj}^dAF!Q9Xn3^>9HY4Rv}wwRj6eGXzZ&E&;N_;V9o{At#Giy+(R@c6u!I@U zuP^On)?3JUI1yB1wSr2qTrJW^Eu>8XFRqyy?su;9%~*6J7h3J*DP||+?$UZ^WnDn< zTf(u%`wckM#-HNG+wTmA-a7=Aavh2={WS)5(d-GfLeIstLd(D$N!;@)4XX=MnJy6i zO**8wzRbL!?7+Sh5vRIUb-Jp@St~)Ea;!3-CL1eP^F==YHz)i$xo;tot{0`=jfsq> z*Ug*fcnKU?M+gT<-@U$)nwr}l*<^9;V3O8WD6ni5ptVlf?h0^=P9Yc)Fl?Qv_C-@f zKoM8iO8AhznY>QCi5Y~j@6R3!Ms(+*Opy{E`c4kQX9-%X<-Xgxu!{tFn3?3LmsW3U z{SuWJIz(2X-|qA;`o{=`f>Dpww%?+Vj5K079I*0cuR9qmVtf6R66SKDxw9PKd>7Io z68>dES7hH^5t?m!`lI&vmFl%YKf2Y|Bii$N2WPTv>geJ}C}1{sl9&UZ9R58%0*=V? zrXUr%iIXQ<;W(?9=ozLZ0&mjmA1B1?s$Aes^Qwx3+Z+ZdVO|#RT+ybf2N^0Ju$`f; zwT7+w`js%`W6cs7=RXVW<8MLH1VMiOpN)=c|+tOH6Y} zV7g5mdD_O?irM zEqEPrO0VNHJI$Vz0VM&~M*!+`2N!2y9xFxF^!)Ws2se}L*=R|Z=^4had&@nft@|B; zop)7WOO&pjynrs4Xd3(3Q1*?x+lYl0@|-j=mx@Y?HyJZq`L#bIvh8UG2bO7Hle}mupGuhd@f1HeS;h)TDZ= z8;r?TM=pqqDA^XE&1aABjsMU4eU?wmWfjL?GIU}0Y2xSn#MlaMBp?}PG=v)_TCyXa z#{a$7tY(xXh?+0)tp?|zj?VMCaZ^Q1#4)Jjg@1dKK&f+Ve$#Eb-6X>I zxeLozdf*O`y`l7;y(8;)SQ=@_V-7G>Qp9H#6^YSl}^*C_r;Rf%ByNwx)$$|C0 zB@cD612FlJ{fkQGiP9Uom3zDWII6<=~{~$x>E|3CM1N4r%usFRq)U5HhyEbuZO@L7O#i2xe6J@cF!{E90Cj7(P-@A-x^}gM3U)k`h{< z-kJAn8jV~aUJBW&hk8iCUd9&HXNXNaY$t9{aq;ssy(J7F|IW&LWZ38?&I87Z3YDcS zbn`YCjq-aDik0l<10L+>Hwb5hZ&lRhABuR#hivNyN0=YPRi$mxN95)uS*U2Oz~p<- zEQLLt+sA|oo8a*b_>OY3YeCbqc+`1vM@Zn4_5q(=(JyZyz}o}d)V5dNwT(`}UjGFT zXH4vw8a7Y=)M1_Iot0FejA+z-2pi6Iaz`_Jfty7`%Y@|RNTKaH^)_q(s$MP2jviCKdFZP6fIN}xgNqa74GnO%Y9hb6BA8#jed{l!$nBP+13+4vEm?e?V z?(0?T*7v5qrx4mKpaWc9R8^AF+WKo)-yJ_N)?idAR#Emvwtvn>f$x!b9w27Z!M;6s zP_;-7bJ-VKK!$-TIab;s2+xiui*$ofV6t5Z>UgAl@61Alo)&!bQkE$~ed@IIYPxAL zwyU?#z_Dn;Lnt+Se|7%foyh!^TPGkO#G=g<+{4797d_QQ6R_K1OCkbKIiNO93@Cq- zMarc3T}HwQ_fMhHmBCD&stX^lc_o+aA6{|XTM}u-e6PqRO9b~!s|6?t2(?0Qi5INx z-#Z}x`coAeJfI7~G(*i1wtNThwf`#rsHP(n3;|2E(DHsWK0$QrEeFaZQPjqEWIuyTA-Y=|J) zvgj4C{IGm`u!w6t$HQ|=4JWCrJJ0SXI503UCmz2NS<`Rq)YEUAQi0gng^J0@n^>+F z&-S}}gCSA8z`s^4^_cQ(W1YKDJ5jqn*ba1?Zsw@DrsbhLSuglkOzbSREI-lOK2H>? z5(b&V%mN!mKf)|)&8~uLEA%~%5evZd_~%)0#yZB%wW^xJf5sQD4Jbt&??E+guxmPj zUP)$I8;hS}Oa_Lb=4Q*7-xV@ z=-dA;d9@LgPMW)kHZ|Z35Uu%>Tc8iE(v4eKB;+k)>o2X2WQjUX6`_kYCiLyD%=dP_ z{RzS&Z$T(CF^ms`UDW^Cuev(0dqe$MWaK>7IPZJ%EpKHwgtVT^FCv!YQe6#vl60<{ zX_^g_6UWCZFMQD;S{~b`md37J+iCcA41+;B9x%I~T>rbzC7 zXsEQ@eROH*Zca|Nm!>#wjUT|SF5cN@suW^s-t;swZTysU#ASd$cOo@MpnogYGgq4_ zSeY=u9#(}LESLVY#=5>nX~)iQX9zOPia3W|4nCYKQ~c2o9ji>V4+<$ZPSwHu^Aalg z8MnK$aosH9Q8H$rdTj)!)qRxaCCN2BNzknA9HV6Zx&E~HQ~G?>ef!8CXsVfYmm zwloL#Oxf3nF5|wzf)^7@eWm&u?02#dI!%z~q)yH9y1}RgRUWgT4hlUhG zF5yca`Z;&3%Y=LJIaVBD#&k-IG$EG#AVCAh$d$N=zsCsinm81`_oREDL&-Z@s!N%`5ssxw`C%#J^O;Xa|h3oZ|bpKW^kGVyG#| z*cg76L}(sWVYkikcU-TuzMc?8*BpetS9R5}jLc^#z!c6fs4akq_+$&j_1}}!Ebz2T zSp|55q7Ry{+ETn(u8-Wnh+|PB1t^J4j)hE37js^}2y4L55IJ|KKFdA|k{xjd1)u zR(lbu`$sUXv*~AP4xfhXajL)*9r8_`Q(SU0$>ORyB$KwL#2XDvY4In(py{uPQgapc zwcSj*Nh&$D5K_d4c{z|#hK9qD*Ij=e(662WhP-^yYSUV)RZZz)Y?0%?w?!Rxwzjg)APEjph9(O&IZSpHxXL7p?fWvI z->IZCbp}YWTIT}Yp9M1L>l+dDt|=h~XLl;KwtEE`_dODRYhE$ zU3~G&X$ATpqKhUdc#w@qBj(ZL%W$dH;)Ta5kgU-z_8wNv3x;!=NZx`Nt3`p1u^s89 zrv;z6i2L;VYrW)_7UJU&v7={wWMo^h!`YaY-p%&fIs0i4wXEplT>rel zdajL;uuX)9Y1rUi*!^IY3b=_ICn+Nm0EyA3Wlnq@katQ6y>tkgcv?uFx%o>A)iKh# zkR9HZ9YyW?MRSaai=Nyt5>sC4!*yZ7N zodC~Y3u@|BRX})!+B~3~lVsp})s=;_QrBQ9Znk_#(^zGaPI*%ZXiB0_VCn^WdO|(M zrGLI@U@)z7vMOLIle_97=;aLZ;DcX|a)~Be{)TX}XBZlG4^^O6NR7?wI8-BOMd9*c z=?BbLZbT-}t7Q{Yo?|3!0bG=v{dkmno1Wuru$RMB{mG0wwg{4gxYMRF!rZUfBimQ7 z-)kQd#Cw}~^<^!lS7Jz1uhT;~8x5GS!_n_}1U3K?|z{O=c#@ zZXd{!KGSU<0{d1Snl9~hcb1ZADIkrUMZ36d+?HA-8iH7*{MQ-e7H$ITni7yOgy&ab zB+qN(&|lvbKh8@bIh)2r1vFZ@9$XkoKqn4`m!=u?p}p6W^vyev)5zhy+S2J1E>af| zvVAqw$=se!Be?>xVHsm>f1YmK>jnHb{reMHvHhlV|jvv!?gdHu8LO%P311i9`tJ$T&}0sr>KG}6NSS$>iO-55W}$21z~s|>~14(Wf?ux1$Z zP{hvII3g#GWgcNVzJ@3YlY82PTH|bExq6wI4)t4POq~8_LF973%b817hg#j|UnIW5b z1j$x06z|Jv7H`lchBNrr1Zz=vy{uRPPyhY@OG#04yx3WMqepuTu=b}SITnF$pXbhk88Dy7ZQg2z=jM%(E434PzO4)zY(G4{-SC$)nu|i9Iy-=w` zasIRS`iHnBlW<(ncTD+T-RgLRoohR?y{qeP!A|>gHNlNtL{VCK`XJ(=`Y2Rh zdq?KYqrQcb9jFaa(+rTb7YJr!O-@Oi&d_Kf^eEgm8p7C(a6~*umOKv~ouv19-wEEy z!@%lnh6N3iF510)wQ|Fafaa*?X6(-c1r1a7p4-E?t{BL_wG^#$jZx81{xh4rQj!#E zh?<#nQfjTyBBz~=XA3Yj3=#4cg0yzgi2vlU5mqFn2j$)~>NVM?2gjb!g~scL+-N}( zrB-<1S*wvoM>J45Y6>5=j%qQg!kY4A4nx}7&NK5xgUaP@!!S+6`qozym5_r!$&qh+ zS`}cNyx{$KMf?t+XK13KRoI1(>0L^k4Va5#CWX`*D#l)1hz0C-Q`%F5+*+N11EhOho{V6nikR#; z{%4{}iD5lY$+Q;ZR607;1|N=o+w_njGmmF|UNOfR1?i=mh7Vk2wjbY>-fI)KY#>Kp zXZ|ILD>!YuKdIE7xS&2W+Sn}%OP3Jq9e&f7AiHW!t0j`4^a3C3T(tD}!uIF)9p$ScMvCsY@G6kx?|ou+_)M_^=1(WkOg z#FR8aIsYq}+OX{VR<=Lj~KR@VpwTw7Gqjv+a^%E4z$MI2el9o=9lo zQ|oz`6jV-yT^60b6@q(?4nB4jTvAGFzpi?BT>J%1H=bjvpBSefcKzFl%9mY2#e19a z21z#8PohK=8mnWN;&%_^nRU&r47SRagax3ct_)%mSNuevF1M(E%qPS`=XqMv7^eHtpkiQfEhep0~D)5F3f zvEn9yef~sN*8xt4p6K|Mc@=0B##dac`35~X!i7IUx;KZ~<^FpZ6}5{4YqE(mX`Du1 zk8>zqmx1~if%~?J?a}kUrnF!DnP&jRz)*y;VLp&w=Y)>b*%omA$ryd^b5tVnej3kj zt(Wvbr*THvyVVWvY3TD*ft%j49>ee4c!KvjG=Rc*q^9S`4%pceIX~Ywb%~R0u`Cg9 z<^eh+V(!nwlTw=xXKTf^A1rwXCW*c#5Y+1ot5Jy>5Yk{ppN=gOBNt%56GF|sfmZ3T zz`5|)cYln-1uytd4ysHQjuJhBkIV7AbpGCd1|PEIs31;%n=uWJQ(hd`6ppn~K|Ul) zRq+TOy694)(9ROgH!P4IIGe}}Ec9WZcB{ZgZ{`BUgZYT7qRHkv1dAi-7Iwqy4J#VziL z#BL|vYpQ#9bylf@riszbrk613kE^+K)4Y7uw(4AYbJiyEH);@nJJLj)?l;F^vb&?h zs?HrlbPEj24y~D)<55+AnZQ(jRbnu{-&u%?J#A1sWqVOV_l#B?2(Ex%u3!-n2zlEYYdTGGYo~>DR_G>~&S@8Wu!0%~8j(`^;1f|Q5sZs76+@?S;N4!QHr&+s ze4m46oRSjc9TtmXY zAw>Hx?i2TfFvg9*843Y+r%$jjSuR2&IHrc)1+3OztxB7`4L%UE-a93E;3GDaB(A(D zVN@d8dVm-bW$sE<(|&rr=+mBqMKUMD=g*v8X81+;KsQ#6MB8j^YwZG5b;9*Q)G+p& zFlgVnA8=ND{hM*L9q!z3+*+mDpDHfaZ9R$K!E+}0QXK40+WpP@^~RQ;mYtk#k@N93 z;!HXxQC3YFyE}u0NrixMwj0w}S8^J{!IRg{!GEkmZj?t}AMr z`P8CP{4S4uB%&pg1k!;>7a}S|%D8_z(&NkhxPH`J>u2V8!UUO=P7KAngycv8=fuxT z)gHyhqTfb=-n1$QRk`tW%|83WnuZ9EG;Noh&KgCsYc=^RIdhp5L8NDR$K;<9I$i1K08W+Nzdnr^YYPpFY=o3NBO6e`=RfrngQX=NSNd zhZYJZ!xZ>Sm($$7dPWK#{zUe+3;&4UE*KKiaE|OYO**3buOTrfAom_G{=8o_58l&O zXtuL${$>3^Riyhxk>=Y));y~D+r(hvx6Dz~5yplDRA@suQnBpnBCl&9)W6qal8)le z=&w7*7Hfxeq%uFlRWrG>^AwUcWx!^rVR0*cDI|BO=m?k(9K{H6^3})Gp}eIgXKd(V z9I!l_3I=}was`%o2hQ=8k;@Qwx;7L=j+D5eK{$)+AwlOQ4ly!r`!EZ_=rb?>{iQbR z@;V!8<~jn3#z2dUODh*OQWFldG+yW2fIC{EjHm>Kw-9t(r#9dLd%Pkw_eH7K)?Nrh$jQ@8;jUnVm|sUHo)5%{WUF`+!VkO!Af~oCAFIuB zK7Hd)?pEED)AKzL1wr)8&CvVcYOg(YT4`{*Py-6$IuEnJP`9>6VMp9}Fx0h?n`kjE zJd+fHv}HxXahb{VuKA3zGN2sNOmL$U%PF~TlBxeQIV#Re;(H++$5Q&Y8n~9Yqx0^F zX-aEpnB@ujO^x4JB>DgiZw~L9{tH2c+6{qfIeoR*`Wcp?wB%m zjuiG?qF?(A>8|e`xh*Q2*zZi}?NQuVC;f3~FM9^&_&~6yT|OUSxQUUydB03tUD8TJ^-@XQ+;a{Gf_t@2zw#EvG6+Zr$ovFA#2Kaa7o3U^ z71!kBDp1^J>64h^9)fhU!R@4BSA}m`7OlBUME{{rgESTc{^_z-}_|NpVh-W=-kGfeO+T)u|^f~X@^n>!}cv?GR8>%H(Nt()Z z^?`Kb{jDVm5qtffspo7x+8%7mrO46gyz@5G$-AJ+%tKRwW#L0y-1n%V&(o-Wma8s2 zhI&sT)Y6cUzGp5M$>uH&ax$={Dahbr>3&6PB{ph1H-~hnvM+9?+da1)i;WF zLhwD9El`6uJYACXOWa|{G8!L{ObTs@8mxsJoaJGjg4X#LLdxJcp8I0`LAh;PS_yU) zScm-eL)n2+oa8K(K$Q?9j^>+KJ%#IrQ*t6ly-!QKbz9||V zDH}mf)(S%ou`^=eOe}^b-HMWz8!qx`!(+Ormf1UZvNNTgoys}pCJd~W?%N$jmv!tQ zQDENUFfq4r26>0w&dM%gl!zLGZ)wBD#{7j8xjYQ+DYM$;MI;&K#D*9fKL4HD0xwi+ zUZ5GTQpn;vjk<1v-Q*PFAH;@LNalBuixNhLQ}}-iy+R`mwkJnt=`K2lswrjap@%W` zvUOtKEz?(_2a`lr6;zP>Y_vyVgwf^yT_gDkb1L^pNdL>w1-5I(e#O|$7olH>il^VQ z8rr}faE1NgkW@EHSb_zr_52O%#7;f}KH&UtCo%na&^$Bivq;_^!c*E+A<+jT3FJZu z^(oB)dWl=^8!t~y2S_F?xKxu-Si-w;R#k~arvyR;$8Uhj6$vvW&aw2-$Bcrnpr zpu*dc4HxI$jF^v`eVV5G3aa;f69r2*P1(OB7I0sppE##qRTA0x%xQ(;z2L)lS2>@Q z@YprcPwDDIY8*2zgQh^fx-}y2T=1{kfT*D2Eyr*m#V}iP^)eH z_l_xP1W~Xu3=aZ6)MyD7F+r_?UKR)kfuC2ul8XDSf@rv4g0cM^)W!Jh zh_NB0mZ2PKkvmjof^?vGN0S07{1(D!XdgSkAT4d-nMDlSqf&Wy)S--mYV2%V{UIbq zscL~(!&T8ZkmT06)%o^JR2;f5`DxwAc7m8*-om*ImIR5`+#oj3l9Rd=jBUzlIX{rw zm$^OrVc0slZrNx=`5i1E^>Rr@8x2PMd^sDM7lA%``elgUU0$rqknJ$Br$@5F_|J!7 zOcL5CtrXCzbr-O)AdxAzip4igC8CGBpk@yl>a{oy`AyJ#thY4B*|F@>md3<)O`2Ty z_TQvEj0*LhUZHT+yEVQyuGTz2zD$a11hBmj5%gJRfyj^XmjG{?=-8`3xk4(=F~$mr zQ$Nbj8q4lGDv>3|)}N?WV;FVpRc8A1j<1S}7rk|Bjx|@yWssI3$enPWxC`;joHYRL6qU6nT0PeP-pVJOnIp(~3ru3M_!e3x{e?vhea-Tum z^s`_q=B@?S*SeZS{H)9Mrn5cKSFNg23&NhpN5p#fWh*>(E}i?d$ax~id04bbLBDie z#u>kBQN>|k(V26se@N{~w^m!mU#}~*rI1p``k|xBpuWiB9i69GGKX}X&sukvIenW> z;W)nOC^gC zwZ*^t);R{N!&PIYoN>bFZ!hmGmfwbW)`I~P+jU#==~2HQ{_%tm+h~r1XMRIM2I!q6 zEc1)Sl?0^Uu_+!)zz!z)6L%VwQD{3a0G_26*XpEEEWG~Od25Nan6zr!wp0W2dbA^7 zAvbmpCW&xa2G12zE-}8HR$2WfHln8_6zXPt0)0OB#-ao>3l;G|cnn&1== za{^Ito!%WR8jL;>Ej*dV{#-!+dgJAKk(*c-IUKHA!Ie~Cz!6=qAvRNiofPH{N<0qr zlsP?OmITamxNQ9n5(qospT_!o=~k6hV4ofQON3jaN&ymVtPe-AitETSf+PQs&>-I9 z7tey8H+Nw~ZVRj@fB$-o7}rYlsGpxGXXz3WgZ5^HVb9eLRb3F5%1Tmm9UccXrps=r zOd)(NYf&`?$TB?+EJz2YW|D|OQ-dM2c2rE0?aqcP!%w+PZ45T=yQ{Zb2oTDnOsYyI zo$)s-tSZ9}WTjk-BAHbtQ4%9+1HIFa@=C|emspQB~0<|Eh38>8u6z>dFMMWtg;lBC;NM`*?igZ6aTK#k zrgnI2;G>q@kNg==Q8$Bl{;IaIILvQAr!lBan^EVp<&K7>r(C~^o9|;&U@*&58wW11 zE2{sX6pHzoDbeeRO{h&ojM((8_#$zYsi7mWdNW8$_Pr$h@L-S8QrDW?zFKoMZ|wys zjq{JL>+r=6P?u?qvMr22`^yS@g3F*)q~Y#OPTnm<0(ajzv4sukwEb|>3u`6n+GVOa z-Zb866ORQ{iF^lU(%D1rSy<5aRBvISf4cmu*j}QCyY2@xH8`oBgWb%*@ADAH1?OV- z%!zkeIm`AuDPt$5b%e?iu6#jvLP}nJyd*8NQ}ecH$^8Q7v^hJ&mfGk3P6&3s>)q=> z)`ZxN1rIhBky>6G;WW6}SmNLbv%|&0CT055B{Q$I%Jd!hQK5Bo(9Oj{U~N&C^kwdYa6#W zPneR~AK6~8K?LNeuRu&a3;tu+`5FBGf%%r5TrYFpJE^Az)WSLwNq=OmjqCNDiz5o< zA#(CI&_USNXtQvO<}{W?Ld1~c_q}*N*vQQv*}N1B$=C?Z=p(d`3=i3vlqeIxJ$;gJ zWq3#XVwYSp+~{eF)CsXx?;(N6nKKEYBfYE~hc||DXeb}owDon_5XybC=I!bTP-$jD!PL{5M`frV zucYDTI4vF!*`k(oqC2K{VEA>PlWXD@VyV7ko$;pUr3yG<_Lk;VUJACtqlJ%#Pb85s zu?^J={X8{OVrJV@yDplvURXGBuXw3s;->xZPD|ZoZ4a~{o)`}doot@8LA%0~+mtTl z$a4AIs`{nJ;#3ahFRx7~QpiW6VcI446$Pn;iAcxgUZvoWPaLPh5Yw~HY@chxz4fE! zjzPk2e(jK&ufM)xPwU*XnG|3cnrt|le@H$ZO8y9KmkO|%j3LHuc`-5#UV2!xZD$T2 zAoAW)X=yyl)X^LG@uP=->v+e>e?(%LgR=ra$}SFZP%m3^2&?M&=^d3?Q*WWeeMCp) z<}gI+y4QdAwWd3k@E+dk$i)Z)lfW#K>)+^%(_2znr$g0|BEQ!G!Of|**_GD#O_F|I zbQZmnnkSIm|93yyR7^arJ*)}$jqF5BzZaE&@$Bt(?~!NremwwKo!agA#A)Hr?r-Ix z#7+wEcL<s-V2H22iWm%4T(rCvFO3Ckn08Zh*iQ^uKrek_H!2X*kiAG z#ooYl)Q7h6+yer$3R=SBstm&lWl10m6i20$C`lR{w?`j84?NESjRNGl%wq2Hoc!>S zA;P#j;S}@4YM>U5ITnm}>_`CF9gn<`uOV8z*!^+E*mg2&@nZuvHUPg^J}FiOCij1c zOO1yDQLs$%S|NWkLelzK zxG9HfP&iVkzZEZ=UhDUMkoZbWa^Sya9=!W7UFm{Koa628Jg0Hp4Z7$@nASK@mXQ{= z8UeO!DXafec^{uY#movwf&~sFNSN$`*CDZ^nSs3M4pB*)yoR8d9vg*bqjr`mmk+#) zC8^Bvsn$$zARZxnPLI1KPSi@GdQ%3kAiyO`cx&Jw5_PyW-wSKN;FQpoB~`*wpCsh; zJzl`y7p7V+i1k0qpHaE+TP7mJ3NEH1&1F+Swiki!7Agk%#|yJDl`+~mzUI_?X`U*6Kf_+}t(M&(lX${I@ z2ec2jPw?1otXU$y1BOg?fvg@!K3{Lor-arTW2Uymy!*cN8i1w;SB@RMcA5{|UxCq= z3eZ~1?xe-}M5luQ?UZ~nR}38vTn*h(v!n>D{WMGWX3kp^62r)jH}5{8QnGvvK|_`y zu1k6Ltt)(WVDv~1ZVkKv6bCzZ)T}g(E1$`DBnR`nel-wnGIT3HyO!2xde8!cyDKqkc;%KPb)S{qB`;I~zNgKU=-`^+H z+;Cjj?`J!H#Oh*@SL=!J@tjyCH7ATas?Xk!truVFO&Cc!LDb6!FQ%l@H7ho@K)thSF+ib2!LxXq&Lj?EDxZJDm(8m%@f*PS* z%5XpP3&B9F18qO|BiN@o-t?do_&s-?hbq#>Jn!bsEpE7&6i^YU(zixS66gT5>Sk?2 z6@T6;Tx7@_s?z~(ALgOFS4bF_UBjJ<^w5;m7msPR=PXOoBCmwkHa;}E`sq-|0t;`% z9I@l_EAjbB1L}Z?{<@L>bIzl?6rSB+<)tP`Ud9}CGeiVrF)e{PKGGj|OQuBAF;n>Y zFP^i15<2K(hjPW=BNTB&S8-G!lpOnd%0n%juwf+R0{Cp-u&^m=I>)WCpS~HwhOccF zdpeOkj;Xoec64oCQQ&2@9ccJHXOnX}J`N+#X&KUAp*b3BxQ?Bc=S=iW_XnN4cg}5Z z(bt29W8O(h%seJ24P{q3r34N+ArD6&rqn^GcleoXj1>}Lv^bH7YXw^PKuP8i**<>Y z7!Y!;52{`|&WlF)Ph!M5FIsPB0PPSOYNl@C8nBy?_0m~Nc&vZhYLGT^sI%00>q!Md zw%qF51fWTlxz73nGFyEolfGil-1x6p;C2I7l2`D7v_@6f_YQY7P**%6q*HqCx?}5S zc+pX^5Y3^{^qhf}20lJ`{can*PSf;+K2AUheORHucUj%O>+^SvFsf7y-zuEC9OxB| zh!%(ozzoA`z1gqCxih*d{rbG@IZB+r9Z9%l%Rk|AYGEq5s;nMnpk9N3wmKR|!w|(d zOM@Pq`D*&>??QtAg0CASw7u7$aWxp}qV_eUd%HVo$c$+FRjc02KwR^+gLpxGz#4Nm zV&AYFWPZly_Pv*2ZsHPbGZYZ%IS1d_;qW~?=T)DfqxM_7nG8YQTC$oHl(6(zSBiIq zcqa?%WSWeS`_BZ0EIn8g|k}Lwf6$ z>-yM(rc3aVLI%Ri_q@E%yS{q@b1g-RjCPnF3DB~w@u$63syR-q3J*D)#<^=Oe>#$W zw24wP&v~Ql9;KK0F36#dTV05w=xx~Cc~fQOR^50q;+rSD4u?E&6eC%{a#%Zt8DG}f z{gvj*@d7YavZOO{vAh>%=~bI)J@2DZX-ae>RtpNx7vmF!sg8zIcUCG9`8l8r5lIFS z)qeK;8MY*+;vVog;J=qsKDT8kh-6k~@J=C>Ci*{R*dulBjpVX;QCyAj4y5Q|IXaEX zK!>+lrG>{P0R2@M06jj=Epork(jZIB zoyzrKQGVBv?6)Urzi%xOd|2=*E!P{|LzZe3hl{CfeXjsmV9k~+1twt$`+HAhJA49WPZed#ijIDKuG3*>JW=wKOJa#?r39nEXP!->iOG3b+O}%SfPcHr)5m$LmI&@IHX#I2Z(qUJxV<16C|Ad{TcTuwo?XER zvZ%1Jf8r^`T_I3CJ1C6*TApq?xbUNNqW0U^2%}>b)08dD8d;1y1I-mVBZ%T8i-yHS zhZGa3R!4iG$a~DDgubq_fi;o|q6*KGVAzSWBy`kb=Ui@m6^Imx=yovsPpKxI;iY%h zi4;`2$|*-Wu;M?gizaS$IyHF?bA9fM&6?JEqXmmbs6m`hvT&cOFVi{Q8(1WYn>_~; zj^GDgJ>d!9@MZS!9kRGi-Y~d^q+oM4m0JxwMJIiy<*D$^g=!`HUv$_QB_#_#bv-mY zcNEhU?kEheblc)26$CJ-RZ?-@QAn}2;mPhUk5A8K(G*cb# z^M_8a=1D9{#U~>TdN~?(LMAH647^syhw@~cKONN_#?#?^?RtTmQ$hUH)lG!G_+eh-IhVl3(PQD|U-l z=4bHisef2*Zuf$qa2e{k!J_ty!LkYl}WT9zJUp1zYWY@nVFvL0v6@yo2zTPx9`;y**5(QMNr0{`OzNt)K$}S!&oos=K$?e>J=*yj% z0%1xq^LiIva2U==p0Q-Ao=S#GMfT&x)jGxkvE`1LozYVX14)DoIU*>%$`u z2C34oCvrCkpifO5Z*8@bJjIBibb*Mz$EH-Cj!LT<-JoUW4pzKD)ifwZjFn$B&Lhhw z3D137j3i^Lytz)Y0Z|Z=^fT%4A}~&sj~kpU#{xoDNI8)>$4I6b>N4%$Jmh{_)Q$c~hMgT= zon+{NSb^DMvzN*pY;hpSV3gY?QY+5?hK8cR9R3+jjDTPKIAlo__9t!CvQExSgAOCH>v z(5vZ~=(E9kjpXx!G4b$sLYT?U@fU4u6E>;4#c`8#UVx&qdu$TB#K$Y<-gD`$QkqBo z7sG#m8)hJ#uw3azDgDqUmvt<;5_(vMdD>2wDnNNchuvFK0=K~;?ysxWYQW7WFCgRA zTgteL%G=U)y?C`G2W*Vgikx?V=X~*f@!~xp+Nxdh{PCTS|_PC*i0;L3S^>0H5n&n*B_mBj3X5jc#qlvs@7|TT?z=axFNP`q zyQ%6@E7X8_2KA;Y319g&_O~j5&`9 zG-?nGJx>QqI)bdSpMzVU#B1X}A8Ptf`AH$+a?NiFtXdEHc8A$rg6p=H=QSYN7IW_f z<fg$X~5w_*>N(&~Ip?`rW5&jfXj^?6Od07gf8I z@Ah}Kpq2GiVx&12*1r{o)LdaLk}nyL#5O96nh!tYL*1dD-u=#yOY#q@@-!oL5ctO@ zC0;R=aS`C4?nD8PJBMNpe~T89s}VjPJU4kN@_XED#U{ixZH?VFT3%pLYthL#mjnup z9r2IvX2&TM7+0|LaOMcLFEYr>xVLol`yz%19M3JGZAt4|Ebig(C;bfz3I=A9cBipD zzi4mJ1jcm*(-kQwpGxAhs@YJ+6fz5z{x!prlF+fYwV2Hf71!|+zg+5XvFZxhtT&$E zu$t;Pk>N2AgLmSVnqAitvrUJ2OXHH*qTX9s?4ERC?=rfp z^$-7+wS99=i6tdU)uI-?*T|~iV4eLEf9@h4ap6r4Qgr6w(Z>33?pz`n91e>x2F9D^ zQoBElL$i};{r+^%jOzsve7ygBK>O~E{f!wY1l>&2}95}=2}0xy*IxCZHU$pyCm?13vSKmIOxMaH(5PaPY>Hr8y9 z@?SW+iLjwFZr5LTLZmNu_pSY6QPc&Dmvt{84Ju|gaj4!l|A8}O9L&a3iMK}Bq>OY( zBUlE9(>iT^*d!(9xm?(m);cS?rR~LhInnGqA%1`s!3GYEDvkAZ5MuaI(l?P+KEjX7 z$FsTVE-31j$r!ks@4i-Qz+E_CMb<{7N@Y0j@E4W@Nj}fwW)o}@KlrtjXYFB{hsRIQ zHwX6f^WtMSZ=PlB{#S!BZpVq{KN4>1^tqo6OI+5=Ymr)2UAT>LLF zlPmxRwF_*^>lU^XmZn6Sx3cD&(+g)Fva2_I*HrV@=v6}CblbJy>L`z&i!()fu-gCv93 zEPJxl?E(j>YkCk_!JEc}0+Zd`>wRxZ(cm!D=-EuIr{%XOO~}WgVnLyVi8s zzopKAY-`h$>7FVT;<+KDD78?*1b(F_oy|^TAzOen=(7{ff1zL_HR?s2SD7Ty;kKY2 zSHU1>_)&9_^V8Y%o`G^$3Kv<&KZUAR!npUW`)Si*kdL{J{63>vWdn1d64{ay;`A&^ zdpdg(6@1-$+3Q%3T(G%FiT>m5$ld)|+4p(9z>4((+l`$4PFxV3WDy$(kk*{ zd5vQ75q+KBT%oPMr97tM5fh^WRrejN5b7eFVN5kO-zlfzybHUp(aAjvfhyXwc=Jyl$J> zF+*eK(84WpFU>nU#Mi$_~am#3;NadL_nzR6!TO#}q zVM?=4gdG1n<+0Fwl_!_oJHTeJ&~MbCB%376{<7kB2G|cFQnrYNu``wb(|}RO&VVaA zxB5iXQ?A_jzUt;m(WQoNRxK*=WXWn6uUE~jJwv2qpVjO1kFOb`-`Q+7%Mh=Vs=5%i zcqiJ)4%L;2EHmDTJC9tppq8NaN>G&{BqV(5DFd9k5;kr$^OqtIr}xSzDg}G@O!?7` zcQ>g=rIF|#YO+0>>Kz`t+QLuW1^*_VtSW|CnB^Pl5@}1+#nyD7vnL^}JL{o-{pL5v z1Q=pU3jb6K+czU(+v*d~#GA^SVQ;IlGiQn$&QwwQE802^D8!dSysqfLt$TohzDpMerWs)1^4%y ztI>_rWn4M+RKmu}PV_?aS!&<9k$M8Z?+RdUD+vF`A8C37nEwv@vIdo*Gvs_wD?TO4 zF7qWasBrlPUf@$Rzvw%&d5;$k3ZgEv>y$USY*7g`9kfa=H+NTAvS3WIowoEv#x}(E z&95YK-g{fs=ciwdo8Hu>)LB-om`!W*m#E;bT4@T%rwK0@Px~MzJKX3I1E!r{H{UPC z&vegGgsrgt7Yp$mz|rf39DCfP`FvIT)bzYzCn3$Eq|8u~L{GbPO$ER|9@Ta&pph5M zs2h^@YlhDlT-s!r6n8n7v+hvMVx8b$#-^0h1#=VLe5ZOOgi&r0kQP>GDL>>t8u{ZU z1sdKfdM4y1{+=pgN^H%GI?V%^mZ6@=JnyY>6&usYg!30Lo38B-&)`;TWDWL>HRF9D zQ|x>T8dA}Ee~rkMY}Ay+`7gIPa8TKAnPKQ7zfdzNbEx8Zn$4zD1MYY<_s=;L2p-cuRQh`&Bo>_42zc2-5`4@)sLG@=mO8 zW)x@g-^gSx^EjN&sqr)BMvm+Iu9v-34g5wh`mdB1<{@0RmgfO~YddQxQ!CMQk#}x# zz(jH!L4f|n=nFbMTi$>8k%dvyV-54vI^Rw8U>h~hHgOxBD=&L)t>?3Vu>~NNGomOR zE2WGeLH?+9mTjhwv!VhXCt~IMQgLL6$q0V-7B=0@51Zn}i9zSUwWnS*m+FKyH!k!r59t&qj zFzB`hHnyrLs|U6xtf(i3i}?!rdss+VSm>!*;PE53r&A5D$1i@5!l!Lf0WoS5F);;g zg2MIJfa$blxXJ7OYj971H5p8imzSf36eKdTI6Eizik^V*q1PJzZkBlWa=Gv^HtE;F zSXR?SST~Z><1m5BNH>sbduzkg&ej^E7VK2LBD{fP{Pr#IlCtrO)zq-h6SuR9vWw(-`UzRu|D1TJaFy# zYlPCHKPrfuiHXrs#&PC8=S!P^Z3n=0&AAebmk7TmxalS0)6I^t2asUrm1~f2Qx~Sm zeB^t>6xg>@@I#AA0MmELg&BOyFSXmV-d5&6iXrCo1r{?{iRK?UzYOufp**7Gn1<{o zw<>GE^27Jgfq9^Til(84(Lv=b%%llE=~4aEG`21P8y9!-aB>F({&Wb)2Z8)cs>2ig z#X%$|)B_U_2+(S?jmk#P-P?8f`P5J{iNlV>miSr6JXEp4-XD$0c0ga8b){^ekaC6z zXOLxw41)6|{Nw=z$~|079Uhy>FyggW3R7@$ zGO;_KScKi=n?naCa1EV1E6ylwZ=r)<_U&vfnrm55X`Z~h;kH{&joo>cKPpk$Mf}3n zJ#WDT-HWrQXDw*JeMuc+p;s^^Eeb|LH)4E%Ky9sy^Uu_$K*TqrlGF(Di*sF6{k@O# z+i*8u0{jQV;jvUJ3y1pJLrG-#9RuvX6xq0cYGE-BZ8PUJ{hy6ZXBU@KgB24|Z3hfC zE0TpVRd{{R@fgrPt|tmxFR!Cvav#j=iX#6>wT}3c12=S?WU1+QcgBMCD#(ESwMWn( zsl?Y?;*Pesxs};DkD};Q3BGoHx;iYoULPSC8#t@{Sxm={uU1(BAcGNgX@3fA7$`*F;5Fk;Hxoc7=>@3uTxP zdjLg4Q-vckv4Gk&W_qMIw3zUA^v5j!)SkYsiC4((zdGiAHtD9GWCU{4yTGWhDE)!@ z_r~3b9kI|8B=`%JU+WG=3%F-Aum;_?bv_j@y6eRDFu%88JuYLd7z>sITfCu1(u@=A zKk?L)TA8_!?q`<(hvb)~tZ>~GAQgy{uAr3DKE#Roikh>}qS004F?irnc?Z5Z2~As=9ZAl3zcVIw))j~p}u#Vdb z5IA;6BpU0Lbiy8eWgVs+{nz{ws9a0!*pliN(p*)oExiCT!LJHBQ)YE#4PJ7uJASVF zm-l4%hWAAGrHh2=$&CDKN#9Z{nqrQXwKAc?v$K0DIr2B!vFxrX6ZZ88#G*ooVGI*aJi5)kg596x)sf6|wt7@f4g0fsrXr7{1S{`7nI8|!gtLAZ7c1^*bwLyE z%kJ?fEVC}8Gq_$c1BhR#6sA$7Mv-8gf96_qG|m$7R=DX?NgLyt{VCFxc6n1Sn=;uOFmKEYvUx?s&yLjI#sKeXjW_13-ll71M<7s zCbm~_<<_}b(z8vtvb;CsdA|8DJg;sI4$oPZfOlYspHsnr9Pl)>MJPOa(gzOjaaewW zbAsR!_f|?SH4boLytXG`#BI<0>39u5va_f~dLJcNvT<^MbtC(5Py2l5siiA}AN#w| zpKqTy+sNmkQp23ZH3wT$hO3Q}IbrBSSrZnWZ?LhAR*=Yt-}M+i(0HA`@;8cL>RD`w zeB=L`YGygj>2rIy#pUS$N$ujz_3>h@cF$s8Bv;zr;wy78vZgZa@$P(|V{Xc9OrF~1 z9{+xiUp?k|??PpN>E_ea^r}>eSSqBRi=|m}C@0XXQEmcUvn=x2ofYbDqo6&+Qj7$5 z4sn-DkM|`fUNAl3BuvI(hSAB%gh-j~HHFMp93#@eXy)hH-a=cHq4Hkixq`xhnODI0 z?xn|@k^?VTtW{;5W+*8MfIzqqw-tlk3*xsSOU~$k0Vdp#qz=6^{ShcDiKn- zD9;S(=C5kP-0%qzL}W1+rW}jUtw`EKBGf;H4szu2-pL*i1`26f(COg#nPrZp+U*r! zg@33*XjYp(&VM`hLjQ9>X!>dQ>iGlfXsKiJX{H*}Hdxyx>iEUxdBX+Ko$>0*i?7=; zMJFMZ*z2c-QZ)NA&mB7{2%LF-Mxh4ic$EBWqRIhCnljra zTl1EIlq`f?rPy-Gl|a}ESplT}kz0{6a}p?cKqd|8?Yw55mcb(dDCq91$O~$ zh$DE%vQ&%*(;=&&H;g$D0|5OUcr{W(YIr+`N3$urh$-n$#OjFX$anqgbt_-0L8Kr; zp5YZ2xXXLtW$9}JXC&%$k&4{zk;JW}FPv=wO*vIusuwX@L0NQu-hEwk%O|#2i_p{& zcQRw7Opnuc<+C^(SzAP1_KM`Zs6YgFdv>N?&Bhj|IS$(Q_;1LuSFovtU0&QE&o>5c zYaeg3Vqy{=>pg|!ht}&l4{17eF%9J_F7xKeTz=ZVxOMQOq<+`WS68i!dNIiQ79p;_6rya4v(cw zt`)L2YUG=Qa4F_o`rc)Mw}j=J9N$*adng%R69D2d=cNde2}W7;V$_hSkOKYL@Y)oa z_3~0=S3m~3Lqn5Hfe1J;qRYf-V{H@ljS22!L6hXgg8{#V8MCm2CQzhvj9P;aL&%Xo zX>S_+7M2RvD+vkuitQ<76~Dx%Y=M1dC47YIfwwSIz2uWoJFZ_o^Ct~mRPKD1rx|z2 zTN&dxv^0m*KMLR{6@f>sKfs@Y@Ss9LO{QQs&K~TbOq2LVqC~)SyeA~|tG=w8;a%03 zZW3>>lk!3Ug18?;Jeml4SD;l@_J+A~!N(BffGR)v3cQXljruCxDME{k9-~e+t7Q9n zuBg3J(mjjHDYOxJZ#%nn1f)dzDC46ybPE-e@L3tGZ9~zWy!m1CW8uWO{SZ)2jC>xy zRmS9L+eJCehi;~E8ZQ9)0emGM*jP*<&ouzXqR1%5ZmEWtO{sAf@Hy1`T3HQTlTTI?W&{$x+l{ zoG$`M5y$XYj*|2z>ddF0*8SecO%8)C$b9L?rU?S1ESi?D3Mm@IHn~l4(eFQob*Cc| zpip2bpIfI!O5^S3yS3Z2S$9$|{(aS?ZBw8?58rQJkoo#BD+gc-2g6EB`7NGp^HW z{=~CHp1n>dRD3M2A$9FaY%_7LQ~i5VyC^HN-I2fY*}ezW8kbyrhjLYB~i^ zL3$!Uwip%Oq~i$TyLJj?5IR;G6ARA7)sAZ*86)R9dNgRvHDf#AXOoq4LU5Gpg3=;J z@khRu>3iew;}z{2m#J<%Dbiz*Vw$4kE4#?qZzSb=WAD2I@%Jh#9OUx^la<@oTJv8) zm&b4t)gds$y`_hWF|hl$aM!Dn6N%+HZ{NNzcR*4t@4fN(&J=uF76v;%7F%I}i~!n; z&$6m3{yBr8sBs$1YlO9O8x)~M#On#8&;Q-u*vK^BEsMQCPMseY99;3J0G$y5Dzgn3 zT%n5%WAneoB-_$Qj{bOg(FD!ng(Mc!D2iHJ(gxgVC`scA;>E05;)-(XEK3Ed_Qi4w z{MbPZV{7Q6xCWkd_yu}>;lB>o=L!CAw zYzEw~ww!XY87hOmn|T|Ou4AeKP~jG})>gI91#vGaA!ffNpg;xl*!Hi^C>M7A zu`9uMv1#M;BA&p(4%QayfuEm$Jbg$KLhgnxQ3wmzFF~t2BjRo`w!w-Lig1uc`zba* z*MBJrd7(i9DSG3>m!b_@0xymawA{g_mUojW=ve~z0>SKHK8#WdOG|8z~ zrlEUX!XQ6cyU!aMBAa=IP9>SYlE_{cM@|(N3zKk?{n@^=s2rFy#dYDd58y_PAMPez zkq!4pCCtU3`KsadK`)t=df0sbXANq8=*$PmrbMS$6uQ~@pYSkR!0-H*3!`+cd=eDg znWMr9fkfSCWhaiqv$ZGa`V5J~3e8O->Z$ zPv-b;=iG>Ed}I_;zaEvq2FSvD;MQj73z<{;krr18sES`y(!@qVHGtMjgTT2 zKW3PhA}7!Py4MRGdWGVDi6Fx6M7I$l%aMWj8dE()wh^n}hUxP@o&(tcBF=p8Fs0Vw z$xFx&+!3^vaoGwV?`RrMb)SAS+`4j^lA7nKsQeqm`kM8^PWlV|sp zbfQv-mlgrcB;!eUnQ^v$ ztaQ8@aoy3gcn{*MI>Ss8{wn_#nUI@)!gz)in-#KkY^9#WpNY;l#jdl`(Y|_V@DSZr z34L~GaJUJ)5~#e^0ly#x?Z9F4Enu;3bFs`Um8o=-fYH%hL5Xjf+kkR`yZsdA(JbAEzc>Z`?sr_bW71z`1F0vYQWE?$^S7|9VNlS5` zJgb~!9MN+$espXK(fK+cHHTvy@OW6f_CqH^|0e2b^J18hJoyRMI1CfrHy>-$pnMSs29+Kt>e z3Q~S;Ew>`Wk&RjLE|&Y&rd+!YHy7!jN7_{?tjg|vMS$Blh%9LF+H=6JvMfq5#^ zot97uFV|L5&vs(|Ze)VP+5D52i|=NI>MqNT8u^nQw#MEPuw6!IY-~4=(?xcSX&Nvs zuXch7K6ZZDZwYpv{2@AD?7;PVTKXP(d9Wduxb~Es{d9Zq`AT0n`pEj~cTGIx29oB& zVej!)paK+kvBt2{C%z{n_Gc#^(MkjVl%0_e~uj=Xg|)xiR~CI!3utnU7Fhe)cQkFRW0=+7Ovpca(kl^xK_Azmh{aC zjE>*5MOH7f!s+3ZHKS#6-`e?ByD~aT0G~x|I?13b5mt_^wz6Csb|67@o<_85f>B{d zGOY%?XjSD~NipA!@^H>rU#u`EA!Ge{+A@=svVFlCs(o;ocORj)m-2GYZcu6^?sqlM zh)aN=gPSI)b-EAhL6i1=J&y++95{%1&ls3@qUU6lbQ|V)q$*j;ICE?k+w=6jx$e`0 zd)ghuZkHXP=KekTqvz9Bk*9zC1~_zVmy*fV4~*zjl%@UVwZk1?*9-WojBsXPAU#CL zII5wirMc&wuRFm|G_m1H?^qJWe36qLu)@?b5#{s~a@y_XX}e*oQLVlLW&5xlam0E_ zg^!M4DwZn?)al#6A33J+_}vPYnukaEFIOTUU>eexlVOml(yz+sAkTO`y_wMx_OoS)OR%j6NZbj5@$9%STn7a+ z`3nCf14HL%!~f*E?Zvx%IsElkc{-Yds=b*8mn~1l zO8d}uSpkW!wm+fc*EM*pm49~88Bw88+6fv|C<2Cm7>kjV}T!ZfFTVG>vCZJF8C z_^dp`joveD_P5`wXY5B;kjsxnRZCD>A+rS`fOMZnm0c69m859pL=DII=jy&Y3?TTa zr@Mx(DSvo*y^f1#7`JjYlABNSZrk2ln?NBSCpCec-o;*?o$Yl9mv0rN*u{#FpEnwT zvy`Ybi0-FQWY%!M7KK-Y9?zh&J8P5U-1D|!g}3=5v6CwAyw&RLlmf$_2>`}=|fV+Ei_+p;S?5d9AFn;TWBVSh9c+!LgEPDf3=jN zd_PRZZ>5x5I?Fisy>eS7(f2E9obtO&%MA5gkn>xn!eZPt?hne0Mds%HxQQX2kX7m3 z42{{O-g@LJx=>c7Hw~@BS!Rn_#N4Ngj4Zl z&S$pJxH$AZx)1Nn1`mjZ>bZyBAmdfF4uyS&G=E>WRZEx}LZRUQWgx`yqJ6IyG)5KU z`h&sza~dDw|Lau%Y%THBfq0!nO$GhK+o4z5%+?ZCP>=E7$ z>!GXvAA!;c-tqDufl@EwPsbgcy8Q+Q4_?)KgsWmmog~3;9@G+_NSZ6*FlJCCd}bba zcqDmqll}0Z=KECC4%0M-msG;}_W!c@f4TPmYgsb9p&AZHM3;)atAzJzWy3Gjmp(ms z8X$bRgHr1sNp3#Co}ve=|Bpxef4JQN7uenqe$t3A1FeL!^?P@~;H36~Pc1A6^-S>P zh6lCMi4J2p@Xy@;ehCsQ4bSIy|1r{q1pIEht{pZ#BnEv@D|>!f*Ca;p5=Y;o<=|x) OwJKEd1c8tm;eP>UrPreX literal 0 HcmV?d00001 diff --git a/docs/testing-evidence/reader-lock-coordinates/reduced-restored.txt b/docs/testing-evidence/reader-lock-coordinates/reduced-restored.txt new file mode 100644 index 00000000..7b4268b7 --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/reduced-restored.txt @@ -0,0 +1,2 @@ +81de2e366a4fa2293f4020e0267b3be6584f02c34ae7eaeb4f21d6f1f48f9678 /build/keep107-coordinate-evidence/reduced-parent.bin +81de2e366a4fa2293f4020e0267b3be6584f02c34ae7eaeb4f21d6f1f48f9678 /build/keep107-coordinate-evidence/reduced-current.bin diff --git a/docs/testing-evidence/reader-lock-coordinates/shrink.txt b/docs/testing-evidence/reader-lock-coordinates/shrink.txt new file mode 100644 index 00000000..43d2bdfb --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/shrink.txt @@ -0,0 +1,2 @@ +Reduced runtime counterexample: 0,1,0 +Replay: /build/keep107-coordinate-evidence/coordinate-mutant /build/keep107-coordinate-evidence/reduced/replay.bin 107c00d 0 1 0 diff --git a/docs/testing-evidence/reader-lock-coordinates/source-final.txt b/docs/testing-evidence/reader-lock-coordinates/source-final.txt new file mode 100644 index 00000000..7ea89772 --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/source-final.txt @@ -0,0 +1,21 @@ +Parent: 05b804b0dd789e8564e98f10a478958b0c332160 +Each changed Rust source below matched the copied Docker source after all mutations were restored. +eefeaf54c7dfcc354fe5e8b59bff557c777621a2763b44cce32fc5053992575d src/adapters/gc/disposition_decoder.rs +673cde8c1f5df94aeca10121c47c7dfa462489cb67dc23e0e0716b8b6d130768 src/adapters/gc/disposition_encoder.rs +07ad4c8d34699cae9ebed9859eb5f52bf0a8457bae45b02cf4b5e049919337e6 src/adapters/gc/filesystem_gc_authority.rs +a880f9db2b00040fd7ca47c9820112d61e061d35261dfb7a985a99c62853e6c2 src/adapters/gc/intent_encoder.rs +d27fe8ca5c09ceb028f36282df76a7b2a94b8109155cd4207298921271b3b7e3 src/adapters/gc/intent_semantic_header.rs +1139bd22f5be988f95314c251f0a3350be05f62681fcda4c0a8ea343159d57df src/adapters/gc/liveness_observation_tests.rs +1b9d9f5900f5fd31875a5d0aeb61c7cf1e1ba5b0c4a1879ffff26e582e13545f src/adapters/gc/mod.rs +6804a95856400c9c3a223e3d88dd8dfe8c86f3d796c79ae6c97f021639c0c55b src/adapters/gc/reader_lock_identity.rs +4f0c6b049a71caa85931c1aa7a7b782346b7d1fa476a61ec41b62428cfc32306 src/adapters/gc/receipt_decoder.rs +99514e4979d83179e667bc02c886ab3bdeea4a67fcc0d250ceca4355d8313d12 src/adapters/gc/receipt_encoder.rs +ae04f13987d4027d23ed268c89607156bb1a235ea864e77e2d76a86450431521 src/adapters/retention/filesystem_retention_disposition.rs +8be2f6fcd2ff1fe879fdcf14dfab704df3160c4d3f4c65aa7f6d69798e6fa7d8 src/lib.rs +373e6880b5b00cb86f2ce8cef4ee414259a9737ba1131116979678cc4381748b tests/gc_retirement_intent.rs +b91677009cb3f1dd4956e9a4d2a051c0ccaacb26c87358e684e042222fd5d605 tests/gc_retirement_receipt.rs +7630ecc65c9e6d4e4ca33c459da955b1f9350334d7ac5cdb074803c41aec409e tests/recovery_disposition_receipt.rs +57b8ffb99a47eeb980145f90d1ef2793058e4839e5835c2944d2f7bc5eae9574 xtask/src/durability_crash_matrix/production_protocol/gc.rs +89779af24bee48acf366ed9b54a56d600c243cae72d74a76a95c99d60bbab2cb src/adapters/gc/reader_lock_file.rs +914b2b07728a51ca579aff5e794b7a20ff8d7b6fef97f8af556461743646a93b src/adapters/gc/reader_lock_mount.rs +f258975e94cc3b919022af852e4a3380c472f6d2b24d098d94b7e0fe05e30ff7 src/adapters/gc/reader_lock_device.rs diff --git a/docs/testing-evidence/reader-lock-coordinates/static-restored.txt b/docs/testing-evidence/reader-lock-coordinates/static-restored.txt new file mode 100644 index 00000000..713ab073 --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/static-restored.txt @@ -0,0 +1,18 @@ + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.85s + Doc-tests keep + +running 1 test +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 16) ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 5 filtered out; finished in 0.00s + + +running 3 tests +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 43) - compile fail ... ok +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 25) - compile fail ... ok +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 34) - compile fail ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.01s + +all doctests ran in 0.33s; merged doctests compilation took 0.32s diff --git a/docs/testing-evidence/reader-lock-coordinates/typed-calibration.txt b/docs/testing-evidence/reader-lock-coordinates/typed-calibration.txt new file mode 100644 index 00000000..c5e9bdb0 --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/typed-calibration.txt @@ -0,0 +1,10 @@ +Static/API calibration pair=device-mount expected unexpected compilation success at line=25 +Observed exit: 101 +Test compiled successfully, but it's marked `compile_fail`. +Static/API calibration pair=device-file expected unexpected compilation success at line=34 +Observed exit: 101 +Test compiled successfully, but it's marked `compile_fail`. +Static/API calibration pair=mount-file expected unexpected compilation success at line=43 +Observed exit: 101 +Test compiled successfully, but it's marked `compile_fail`. +Restored distinct coordinate types: exit 0 diff --git a/docs/testing-evidence/reader-lock-coordinates/validation-final.txt b/docs/testing-evidence/reader-lock-coordinates/validation-final.txt new file mode 100644 index 00000000..0e257400 --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/validation-final.txt @@ -0,0 +1,309 @@ + Checking keep v0.0.0 (/build/keep107-coordinate-source) + Checking xtask v0.0.0 (/build/keep107-coordinate-source/xtask) + Checking keep-benchmark v0.0.0 (/build/keep107-coordinate-source/benchmark) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.26s + Checking xtask v0.0.0 (/build/keep107-coordinate-source/xtask) + Checking keep v0.0.0 (/build/keep107-coordinate-source) + Checking keep-benchmark v0.0.0 (/build/keep107-coordinate-source/benchmark) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.63s + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s + Doc-tests keep + +running 1 test +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 16) ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 5 filtered out; finished in 0.00s + + +running 3 tests +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 43) - compile fail ... ok +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 34) - compile fail ... ok +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 25) - compile fail ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.02s + +all doctests ran in 0.35s; merged doctests compilation took 0.34s +Focused profile=debug + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.40s + Running tests/gc_retirement_intent.rs (/build/keep107-coordinate-target/debug/deps/gc_retirement_intent-6de0a7d49b53f247) + +running 5 tests +test semantic_intent_refuses_empty_and_repeated_candidate_sets ... ok +test mutation_laws::intent_framing_refuses_truncation_and_trailing_data ... ok +test frozen_intent_decodes_and_reencodes_canonically ... ok +test mutation_laws::every_intent_field_has_one_exact_first_refusal ... ok +test mutation_laws::candidate_order_and_count_bounds_refuse_after_complete_integrity ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.36s + + Running tests/gc_retirement_receipt.rs (/build/keep107-coordinate-target/debug/deps/gc_retirement_receipt-b07035724611f35c) + +running 4 tests +test pool_state_digest_is_carried_not_bound_to_the_intent ... ok +test receipt_framing_refuses_any_length_but_the_fixed_width ... ok +test frozen_receipt_completes_the_frozen_intent_and_reencodes_canonically ... ok +test every_receipt_field_has_one_exact_first_refusal ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/recovery_disposition_receipt.rs (/build/keep107-coordinate-target/debug/deps/recovery_disposition_receipt-96812ccadb83a6d8) + +running 5 tests +test every_registered_code_round_trips_and_matches_the_definition ... ok +test framing_refuses_truncation_and_trailing_bytes ... ok +test frozen_disposition_decodes_and_reencodes_canonically ... ok +test liveness_zero_beside_the_empty_retention_digest_round_trips_as_empty_retention ... ok +test every_structural_field_has_one_exact_first_refusal ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.87s + Running unittests src/lib.rs (/build/keep107-coordinate-target/debug/deps/keep-882caa9da157737f) + +running 26 tests +test adapters::gc::planner_tests::a_named_segment_no_root_reaches_is_named_unreachable_and_never_a_candidate ... ok +test adapters::gc::planner_tests::a_snapshot_refuses_duplicate_inventory_and_namespaces ... ok +test adapters::gc::planner_tests::an_empty_inventory_plans_nothing ... ok +test adapters::gc::planner_tests::an_unnamed_segment_without_release_evidence_is_recovery_protected ... ok +test adapters::gc::planner_tests::every_contradiction_refuses_the_plan ... ok +test adapters::gc::planner_tests::named_segments_reached_by_retained_closures_are_live_with_their_root_count ... ok +test adapters::gc::planner_tests::superseded_and_disposed_unnamed_segments_are_the_only_candidates ... ok +test adapters::gc::planner_tests::superseded_or_disposed_segments_absent_from_the_inventory_are_already_retired ... ok +test adapters::gc::planner_tests::the_candidate_limit_refuses_rather_than_truncates ... ok +test adapters::gc::planner_tests::the_golden_version_two_store_plans_one_live_segment ... ok +test adapters::gc::planner_tests::plan_model_tests::planning_never_collects_live_or_named_material_and_is_pure ... ok +test adapters::gc::liveness_observation_tests::an_unpublished_store_plans_its_named_segment_unreachable_but_not_collectible ... ok +test adapters::gc::liveness_observation_tests::a_corrupt_pool_segment_refuses_observation_before_any_plan ... ok +test adapters::gc::liveness_observation_tests::a_pool_entry_not_named_by_a_digest_refuses_observation ... ok +test adapters::gc::liveness_observation_tests::an_exact_retire_receipt_makes_the_orphan_collectible_and_a_stale_one_does_not ... ok +test adapters::gc::liveness_observation_tests::the_published_fixture_store_plans_its_one_segment_live ... ok +test adapters::gc::liveness_observation_tests::an_orphan_pool_segment_is_recovery_protected_and_never_a_candidate ... ok +test adapters::gc::filesystem_gc_tests::admission_refuses_a_foreign_gc_entry_or_a_directory_in_place_of_a_record ... ok +test adapters::gc::filesystem_gc_tests::nothing_to_retire_and_a_stale_plan_refuse_before_any_intent ... ok +test adapters::gc::filesystem_gc_tests::an_absent_candidate_after_a_present_one_is_ambiguous_and_touches_nothing ... ok +test adapters::gc::filesystem_gc_tests::a_reader_holding_the_fence_refuses_retirement_without_waiting ... ok +test adapters::gc::filesystem_gc_tests::retiring_the_disposed_orphan_leaves_the_receipt_and_the_live_segment ... ok +test adapters::gc::filesystem_gc_tests::a_durable_intent_excludes_retention_publication_and_another_retirement ... ok +test adapters::gc::filesystem_gc_tests::a_second_retirement_succeeds_the_first_receipts_generation ... ok +test adapters::gc::filesystem_gc_tests::a_truncated_stage_is_discarded_and_the_retirement_then_completes ... ok +test adapters::gc::filesystem_gc_tests::every_interrupted_prefix_recovers_to_the_same_complete_state ... ok + +test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 276 filtered out; finished in 3.14s + + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.01s + Running unittests src/lib.rs (/build/keep107-coordinate-target/debug/deps/keep-882caa9da157737f) + +running 8 tests +test adapters::retention::filesystem_retention_disposition_tests::finalize_requires_a_published_head ... ok +test adapters::retention::filesystem_retention_disposition_tests::a_second_disposition_finds_nothing_protected_and_changes_nothing ... ok +test adapters::retention::filesystem_retention_disposition_tests::a_reader_holding_the_fence_refuses_disposition_without_waiting ... ok +test adapters::retention::filesystem_retention_disposition_tests::readers_admit_a_store_with_receipts_and_refuse_a_stray_disposition_entry ... ok +test adapters::retention::filesystem_retention_disposition_tests::retiring_a_protected_root_under_an_absent_head_frees_publication ... ok +test adapters::retention::filesystem_retention_disposition_tests::an_interrupted_retirement_resumes_from_every_residue ... ok +test adapters::retention::filesystem_retention_disposition_tests::the_manifest_stage_must_be_disposed_before_the_root_it_names ... ok +test adapters::retention::filesystem_retention_disposition_tests::finalizing_a_successor_orphan_keeps_its_pool_entry_and_frees_publication ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 294 filtered out; finished in 0.42s + + Compiling typenum v1.20.1 + Compiling foldhash v0.2.0 + Compiling serde_core v1.0.229 + Compiling memchr v2.8.3 + Compiling arraydeque v0.5.1 + Compiling itoa v1.0.18 + Compiling repository-process-spawn v0.0.0 (/build/keep107-coordinate-source/repository-process-spawn) + Compiling errno v0.3.14 + Compiling zmij v1.0.23 + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling hashbrown v0.16.1 + Compiling signal-hook-registry v1.4.8 + Compiling signal-hook v0.4.4 + Compiling hashlink v0.11.1 + Compiling hybrid-array v0.4.13 + Compiling yaml-rust2 v0.11.0 + Compiling block-buffer v0.12.1 + Compiling crypto-common v0.2.2 + Compiling digest v0.11.3 + Compiling serde v1.0.229 + Compiling serde_json v1.0.151 + Compiling md-5 v0.11.0 + Compiling xtask v0.0.0 (/build/keep107-coordinate-source/xtask) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.26s + Running tests/retention_store_v2_format_oracle.rs (/build/keep107-coordinate-target/debug/deps/retention_store_v2_format_oracle-2a6d5511749a3dcb) + +running 4 tests +test definition_and_profile_tables_are_exact_and_canonical ... ok +test retention_anchor_ids_are_derived_from_the_accepted_layout_corpus ... ok +test definition_profile_and_migration_sources_match_the_oracle ... ok +test golden_artifacts_match_the_independent_oracle ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +Focused profile=release + Compiling rustix v1.1.4 + Compiling linux-raw-sys v0.12.1 + Compiling io-lifetimes v3.0.1 + Compiling bitflags v2.13.1 + Compiling io-lifetimes v2.0.4 + Compiling io-extras v0.19.0 + Compiling proc-macro2 v1.0.107 + Compiling cap-primitives v4.0.2 + Compiling shlex v2.0.1 + Compiling find-msvc-tools v0.1.9 + Compiling once_cell v1.21.4 + Compiling unicode-ident v1.0.24 + Compiling quote v1.0.47 + Compiling maybe-owned v0.3.4 + Compiling ipnet v2.12.0 + Compiling cap-std v4.0.2 + Compiling ambient-authority v0.0.2 + Compiling libc v0.2.186 + Compiling anstyle v1.0.14 + Compiling cap-fs-ext v4.0.2 + Compiling cc v1.3.0 + Compiling clap_lex v1.1.0 + Compiling cfg-if v1.0.4 + Compiling constant_time_eq v0.4.2 + Compiling arrayref v0.3.9 + Compiling arrayvec v0.7.8 + Compiling condtype v1.3.0 + Compiling regex-lite v0.1.9 + Compiling clap_builder v4.6.2 + Compiling allocation-counter v0.8.1 + Compiling syn v2.0.119 + Compiling blake3 v1.8.5 + Compiling clap v4.6.4 + Compiling fs-set-times v0.20.3 + Compiling rustix-linux-procfs v0.1.1 + Compiling divan-macros v0.1.21 + Compiling divan v0.1.21 + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `release` profile [optimized] target(s) in 4.06s + Running tests/gc_retirement_intent.rs (/build/keep107-coordinate-target/release/deps/gc_retirement_intent-16dd92d184650f31) + +running 5 tests +test frozen_intent_decodes_and_reencodes_canonically ... ok +test mutation_laws::intent_framing_refuses_truncation_and_trailing_data ... ok +test semantic_intent_refuses_empty_and_repeated_candidate_sets ... ok +test mutation_laws::every_intent_field_has_one_exact_first_refusal ... ok +test mutation_laws::candidate_order_and_count_bounds_refuse_after_complete_integrity ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/gc_retirement_receipt.rs (/build/keep107-coordinate-target/release/deps/gc_retirement_receipt-7af42e76dc359ac8) + +running 4 tests +test every_receipt_field_has_one_exact_first_refusal ... ok +test frozen_receipt_completes_the_frozen_intent_and_reencodes_canonically ... ok +test receipt_framing_refuses_any_length_but_the_fixed_width ... ok +test pool_state_digest_is_carried_not_bound_to_the_intent ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/recovery_disposition_receipt.rs (/build/keep107-coordinate-target/release/deps/recovery_disposition_receipt-d5a50675829e7cd0) + +running 5 tests +test every_registered_code_round_trips_and_matches_the_definition ... ok +test every_structural_field_has_one_exact_first_refusal ... ok +test framing_refuses_truncation_and_trailing_bytes ... ok +test liveness_zero_beside_the_empty_retention_digest_round_trips_as_empty_retention ... ok +test frozen_disposition_decodes_and_reencodes_canonically ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `release` profile [optimized] target(s) in 4.21s + Running unittests src/lib.rs (/build/keep107-coordinate-target/release/deps/keep-8ca1b588e6dfe3ea) + +running 26 tests +test adapters::gc::planner_tests::a_named_segment_no_root_reaches_is_named_unreachable_and_never_a_candidate ... ok +test adapters::gc::planner_tests::a_snapshot_refuses_duplicate_inventory_and_namespaces ... ok +test adapters::gc::planner_tests::an_empty_inventory_plans_nothing ... ok +test adapters::gc::planner_tests::an_unnamed_segment_without_release_evidence_is_recovery_protected ... ok +test adapters::gc::planner_tests::every_contradiction_refuses_the_plan ... ok +test adapters::gc::planner_tests::named_segments_reached_by_retained_closures_are_live_with_their_root_count ... ok +test adapters::gc::planner_tests::superseded_and_disposed_unnamed_segments_are_the_only_candidates ... ok +test adapters::gc::planner_tests::the_candidate_limit_refuses_rather_than_truncates ... ok +test adapters::gc::planner_tests::superseded_or_disposed_segments_absent_from_the_inventory_are_already_retired ... ok +test adapters::gc::planner_tests::the_golden_version_two_store_plans_one_live_segment ... ok +test adapters::gc::planner_tests::plan_model_tests::planning_never_collects_live_or_named_material_and_is_pure ... ok +test adapters::gc::liveness_observation_tests::an_unpublished_store_plans_its_named_segment_unreachable_but_not_collectible ... ok +test adapters::gc::liveness_observation_tests::the_published_fixture_store_plans_its_one_segment_live ... ok +test adapters::gc::filesystem_gc_tests::nothing_to_retire_and_a_stale_plan_refuse_before_any_intent ... ok +test adapters::gc::liveness_observation_tests::an_exact_retire_receipt_makes_the_orphan_collectible_and_a_stale_one_does_not ... ok +test adapters::gc::liveness_observation_tests::a_corrupt_pool_segment_refuses_observation_before_any_plan ... ok +test adapters::gc::liveness_observation_tests::an_orphan_pool_segment_is_recovery_protected_and_never_a_candidate ... ok +test adapters::gc::filesystem_gc_tests::admission_refuses_a_foreign_gc_entry_or_a_directory_in_place_of_a_record ... ok +test adapters::gc::liveness_observation_tests::a_pool_entry_not_named_by_a_digest_refuses_observation ... ok +test adapters::gc::filesystem_gc_tests::an_absent_candidate_after_a_present_one_is_ambiguous_and_touches_nothing ... ok +test adapters::gc::filesystem_gc_tests::a_reader_holding_the_fence_refuses_retirement_without_waiting ... ok +test adapters::gc::filesystem_gc_tests::retiring_the_disposed_orphan_leaves_the_receipt_and_the_live_segment ... ok +test adapters::gc::filesystem_gc_tests::a_durable_intent_excludes_retention_publication_and_another_retirement ... ok +test adapters::gc::filesystem_gc_tests::a_second_retirement_succeeds_the_first_receipts_generation ... ok +test adapters::gc::filesystem_gc_tests::a_truncated_stage_is_discarded_and_the_retirement_then_completes ... ok +test adapters::gc::filesystem_gc_tests::every_interrupted_prefix_recovers_to_the_same_complete_state ... ok + +test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 276 filtered out; finished in 1.45s + + Finished `release` profile [optimized] target(s) in 0.01s + Running unittests src/lib.rs (/build/keep107-coordinate-target/release/deps/keep-8ca1b588e6dfe3ea) + +running 8 tests +test adapters::retention::filesystem_retention_disposition_tests::finalize_requires_a_published_head ... ok +test adapters::retention::filesystem_retention_disposition_tests::a_second_disposition_finds_nothing_protected_and_changes_nothing ... ok +test adapters::retention::filesystem_retention_disposition_tests::a_reader_holding_the_fence_refuses_disposition_without_waiting ... ok +test adapters::retention::filesystem_retention_disposition_tests::readers_admit_a_store_with_receipts_and_refuse_a_stray_disposition_entry ... ok +test adapters::retention::filesystem_retention_disposition_tests::retiring_a_protected_root_under_an_absent_head_frees_publication ... ok +test adapters::retention::filesystem_retention_disposition_tests::an_interrupted_retirement_resumes_from_every_residue ... ok +test adapters::retention::filesystem_retention_disposition_tests::the_manifest_stage_must_be_disposed_before_the_root_it_names ... ok +test adapters::retention::filesystem_retention_disposition_tests::finalizing_a_successor_orphan_keeps_its_pool_entry_and_frees_publication ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 294 filtered out; finished in 0.21s + + Compiling typenum v1.20.1 + Compiling serde_core v1.0.229 + Compiling zmij v1.0.23 + Compiling foldhash v0.2.0 + Compiling serde_json v1.0.151 + Compiling serde v1.0.229 + Compiling signal-hook v0.4.4 + Compiling arraydeque v0.5.1 + Compiling itoa v1.0.18 + Compiling memchr v2.8.3 + Compiling repository-process-spawn v0.0.0 (/build/keep107-coordinate-source/repository-process-spawn) + Compiling errno v0.3.14 + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling signal-hook-registry v1.4.8 + Compiling hashbrown v0.16.1 + Compiling hashlink v0.11.1 + Compiling hybrid-array v0.4.13 + Compiling yaml-rust2 v0.11.0 + Compiling crypto-common v0.2.2 + Compiling block-buffer v0.12.1 + Compiling digest v0.11.3 + Compiling md-5 v0.11.0 + Compiling xtask v0.0.0 (/build/keep107-coordinate-source/xtask) + Finished `release` profile [optimized] target(s) in 4.28s + Running tests/retention_store_v2_format_oracle.rs (/build/keep107-coordinate-target/release/deps/retention_store_v2_format_oracle-0f938c44dbe97a05) + +running 4 tests +test definition_and_profile_tables_are_exact_and_canonical ... ok +test retention_anchor_ids_are_derived_from_the_accepted_layout_corpus ... ok +test definition_profile_and_migration_sources_match_the_oracle ... ok +test golden_artifacts_match_the_independent_oracle ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Compiling libc v0.2.186 + Checking arbitrary v1.4.2 + Checking xtask v0.0.0 (/build/keep107-coordinate-source/xtask) + Compiling jobserver v0.1.35 + Compiling cc v1.3.0 + Compiling blake3 v1.8.5 + Compiling libfuzzer-sys v0.4.13 + Checking keep v0.0.0 (/build/keep107-coordinate-source) + Checking keep-fuzz v0.0.0 (/build/keep107-coordinate-source/fuzz) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.19s diff --git a/docs/testing-evidence/reader-lock-coordinates/validation.txt b/docs/testing-evidence/reader-lock-coordinates/validation.txt new file mode 100644 index 00000000..78d56227 --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/validation.txt @@ -0,0 +1,34 @@ + Checking repository-process-spawn v0.0.0 (/build/keep107-coordinate-source/repository-process-spawn) + Checking keep v0.0.0 (/build/keep107-coordinate-source) +error: this function has a `#[must_use]` attribute with no message, but returns a type already marked as `#[must_use]` + --> src/adapters/gc/reader_lock_identity.rs:73:5 + | +73 | pub const fn device(self) -> ReaderLockDevice { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = help: either add some descriptive message or remove the attribute + = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.96.0/index.html#double_must_use + = note: `-D clippy::double-must-use` implied by `-D clippy::all` + = help: to override `-D clippy::all` add `#[allow(clippy::double_must_use)]` + +error: this function has a `#[must_use]` attribute with no message, but returns a type already marked as `#[must_use]` + --> src/adapters/gc/reader_lock_identity.rs:79:5 + | +79 | pub const fn mount(self) -> ReaderLockMount { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = help: either add some descriptive message or remove the attribute + = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.96.0/index.html#double_must_use + +error: this function has a `#[must_use]` attribute with no message, but returns a type already marked as `#[must_use]` + --> src/adapters/gc/reader_lock_identity.rs:85:5 + | +85 | pub const fn file(self) -> ReaderLockFile { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = help: either add some descriptive message or remove the attribute + = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.96.0/index.html#double_must_use + +error: could not compile `keep` (lib) due to 3 previous errors +warning: build failed, waiting for other jobs to finish... +error: could not compile `keep` (lib test) due to 3 previous errors diff --git a/docs/testing-evidence/reader-lock-coordinates/wire-calibration.txt b/docs/testing-evidence/reader-lock-coordinates/wire-calibration.txt new file mode 100644 index 00000000..dc6478c0 --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/wire-calibration.txt @@ -0,0 +1,11 @@ + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling coordinate-observation v0.0.0 (/build/keep107-coordinate-driver-current) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.20s +Correlated wire-slot mutation: seed=0x107c00d; expect successful codec calls but different bytes +/build/keep107-coordinate-evidence/parent-fixed.bin /build/keep107-coordinate-evidence/wire-mutant.bin differ: char 2636, line 2 +Mutant comparison exit: 1 + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling coordinate-observation v0.0.0 (/build/keep107-coordinate-driver-current) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.86s +Restored comparison exit: 0 +2817ce9c311b95c070e7b4774a3cc26193b010769e5a0ec36af6a04133c19714 /build/keep107-coordinate-evidence/wire-restored.bin diff --git a/src/adapters/gc/disposition_decoder.rs b/src/adapters/gc/disposition_decoder.rs index a46bc3d6..a6304601 100644 --- a/src/adapters/gc/disposition_decoder.rs +++ b/src/adapters/gc/disposition_decoder.rs @@ -4,10 +4,11 @@ use super::RecoveryDispositionDecodeError as Error; use super::{ AdmittedRecoveryDispositionReceipt, ArtifactContentDigest, ArtifactIdentityDigest, - DecisionEvidenceDigest, GcRetentionState, ObservedHeadChecksum, ReaderLockIdentity, - RecoveryArtifactKind, RecoveryClassification, RecoveryDispositionArtifact, - RecoveryDispositionCoordinates, RecoveryDispositionDecision, RecoveryDispositionField, - RecoveryDispositionReceipt, disposition_format as format, + DecisionEvidenceDigest, GcRetentionState, ObservedHeadChecksum, ReaderLockDevice, + ReaderLockFile, ReaderLockIdentity, ReaderLockMount, RecoveryArtifactKind, + RecoveryClassification, RecoveryDispositionArtifact, RecoveryDispositionCoordinates, + RecoveryDispositionDecision, RecoveryDispositionField, RecoveryDispositionReceipt, + disposition_format as format, }; use crate::{CatalogDigest, CatalogGeneration, LivenessGeneration, RetentionManifestDigest}; @@ -47,9 +48,9 @@ pub(super) fn decode(encoded: &[u8]) -> Result, coordinates: &GcRetirementIntentCoord output.extend_from_slice(coordinates.catalog_successor_proof_digest.as_bytes()); output.extend_from_slice(coordinates.segment_pool_identity_digest.as_bytes()); output.extend_from_slice(coordinates.disposition_set_digest.as_bytes()); - output.extend_from_slice(&coordinates.reader_lock.device().to_be_bytes()); - output.extend_from_slice(&coordinates.reader_lock.mount().to_be_bytes()); - output.extend_from_slice(&coordinates.reader_lock.file().to_be_bytes()); + output.extend_from_slice(&coordinates.reader_lock.device().get().to_be_bytes()); + output.extend_from_slice(&coordinates.reader_lock.mount().get().to_be_bytes()); + output.extend_from_slice(&coordinates.reader_lock.file().get().to_be_bytes()); } diff --git a/src/adapters/gc/intent_semantic_header.rs b/src/adapters/gc/intent_semantic_header.rs index e18ca7cb..321cc441 100644 --- a/src/adapters/gc/intent_semantic_header.rs +++ b/src/adapters/gc/intent_semantic_header.rs @@ -5,7 +5,8 @@ use super::GcRetirementIntentDecodeError as Error; use super::intent_header_decoder::DecodedIntentHeader; use super::{ CatalogSuccessorProofDigest, DispositionSetDigest, GcRetirementIntent, - GcRetirementIntentCoordinates, ReaderLockIdentity, SegmentPoolIdentityDigest, + GcRetirementIntentCoordinates, ReaderLockDevice, ReaderLockFile, ReaderLockIdentity, + ReaderLockMount, SegmentPoolIdentityDigest, }; use crate::{ CatalogDigest, CatalogGeneration, GcGeneration, LivenessGeneration, RegisteredRetentionProfile, @@ -46,9 +47,9 @@ pub(super) fn admit(header: &DecodedIntentHeader) -> Result Result, Box> { use crate::adapters::{ ArtifactIdentityDigest, CanonicalRecoveryDispositionReceipt, DecisionEvidenceDigest, - ObservedHeadChecksum, ReaderLockIdentity, RecoveryArtifactKind, RecoveryClassification, - RecoveryDispositionArtifact, RecoveryDispositionCoordinates, RecoveryDispositionDecision, - RecoveryDispositionReceipt, + ObservedHeadChecksum, ReaderLockDevice, ReaderLockFile, ReaderLockIdentity, + ReaderLockMount, RecoveryArtifactKind, RecoveryClassification, RecoveryDispositionArtifact, + RecoveryDispositionCoordinates, RecoveryDispositionDecision, RecoveryDispositionReceipt, }; let orphan = decode_hex(ORPHAN_SEGMENT_HEX.trim())?; let identity = <[u8; 32]>::try_from(decode_hex( @@ -181,7 +181,11 @@ pub(super) fn segment_receipt( catalog_generation: coordinates.catalog_generation(), catalog_digest, retention: coordinates.retention(), - reader_lock: ReaderLockIdentity::new(1, 2, 3), + reader_lock: ReaderLockIdentity::new( + ReaderLockDevice::new(1), + ReaderLockMount::new(2), + ReaderLockFile::new(3), + ), }, DecisionEvidenceDigest::new([0; 32]), ); diff --git a/src/adapters/gc/mod.rs b/src/adapters/gc/mod.rs index b8cb76b0..9382c38d 100644 --- a/src/adapters/gc/mod.rs +++ b/src/adapters/gc/mod.rs @@ -55,7 +55,10 @@ mod plan_limits; mod planner; #[cfg(test)] mod planner_tests; +mod reader_lock_device; +mod reader_lock_file; mod reader_lock_identity; +mod reader_lock_mount; mod receipt; mod receipt_bytes; mod receipt_decode_error; @@ -108,7 +111,10 @@ pub use plan::{GcPlan, GcPlannedCandidate, GcPlannedSegment}; pub use plan_error::{GcPlanAmbiguity, GcPlanError}; pub use plan_limits::{GcLimits, GcLimitsError}; pub use planner::plan_gc; +pub use reader_lock_device::ReaderLockDevice; +pub use reader_lock_file::ReaderLockFile; pub use reader_lock_identity::{ReaderLockCoordinate, ReaderLockIdentity}; +pub use reader_lock_mount::ReaderLockMount; pub use receipt::GcRetirementReceipt; pub use receipt_decode_error::GcRetirementReceiptDecodeError; pub use record_digests::{GcCandidateSetDigest, GcRetirementIntentDigest}; diff --git a/src/adapters/gc/rationale.md b/src/adapters/gc/rationale.md new file mode 100644 index 00000000..d52473bf --- /dev/null +++ b/src/adapters/gc/rationale.md @@ -0,0 +1,11 @@ +# Reader-lock coordinate roles + +The public GC and recovery-disposition record API uses distinct `ReaderLockDevice`, `ReaderLockMount` and `ReaderLockFile` values. `ReaderLockIdentity` admits these roles explicitly, so passing a correctly labeled coordinate in another position is a compile-time error. This decision tightens the new API in #107; it does not retrofit unrelated filesystem identity APIs. + +Each wrapper preserves the complete unsigned 64-bit wire domain, including zero. These are opaque coordinates, not proof that an object was observed, a lock acquired, or an operation made durable. The boundary that observes or decodes a number assigns its role; a caller can still deliberately mislabel a raw number, so the types do not replace runtime evidence or code review. + +The mount identifies a mount instance and remains same-process evidence. This change does not alter the existing restart comparison contract, record layout, endianness, checksums, mismatch coordinates or refusal ordering. Codecs unwrap values only at comparison and byte-encoding boundaries. Filesystem adapters label the observed device, mount and file values where they enter the record API. + +A primitive triple and type aliases were rejected because either permits coordinate interchange. New zero or range refusals were rejected because the format admits every unsigned 64-bit coordinate. Explicit named wrappers keep those facts separate and require no allocation, blocking, I/O or new dependency. + +The source-level compatibility change requires callers to wrap raw values with the appropriate constructor and unwrap getter results with `get()`. Static compile-fail examples guard pairwise interchange; existing frozen runtime laws and generated cross-revision comparisons guard the separate byte-preservation claim. A compiler rejection is static/API evidence, not a storage runtime test. diff --git a/src/adapters/gc/reader_lock_device.rs b/src/adapters/gc/reader_lock_device.rs new file mode 100644 index 00000000..009c3e16 --- /dev/null +++ b/src/adapters/gc/reader_lock_device.rs @@ -0,0 +1,25 @@ +//! This module owns the reader-lock device coordinate's distinct type. + +/// Opaque device coordinate of the filesystem object used as `reader.lock`. +/// +/// It is a restart-stable coordinate only when observed and compared under the +/// storage protocol; the value alone is not proof of a live filesystem object. +/// Every `u64`, including zero, is representable by the existing wire format. +/// Construction preserves the value without claiming observation or authority. +/// Operations do not allocate, block, or perform I/O. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ReaderLockDevice(u64); + +impl ReaderLockDevice { + /// Records one exact coordinate without additional validity claims. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Returns the exact coordinate for comparison or canonical encoding. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} diff --git a/src/adapters/gc/reader_lock_file.rs b/src/adapters/gc/reader_lock_file.rs new file mode 100644 index 00000000..81092502 --- /dev/null +++ b/src/adapters/gc/reader_lock_file.rs @@ -0,0 +1,25 @@ +//! This module owns the reader-lock file coordinate's distinct type. + +/// Opaque file coordinate of the filesystem object used as `reader.lock`. +/// +/// It is a restart-stable coordinate only when observed and compared under the +/// storage protocol; the value alone is not proof of a live filesystem object. +/// Every `u64`, including zero, is representable by the existing wire format. +/// Construction preserves the value without claiming observation or authority. +/// Operations do not allocate, block, or perform I/O. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ReaderLockFile(u64); + +impl ReaderLockFile { + /// Records one exact coordinate without additional validity claims. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Returns the exact coordinate for comparison or canonical encoding. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} diff --git a/src/adapters/gc/reader_lock_identity.rs b/src/adapters/gc/reader_lock_identity.rs index 9e2a700f..c65ad7fe 100644 --- a/src/adapters/gc/reader_lock_identity.rs +++ b/src/adapters/gc/reader_lock_identity.rs @@ -1,23 +1,66 @@ //! This boundary module owns the physical identity of the exclusively held //! `reader.lock` that authorized one retirement. +use super::{ReaderLockDevice, ReaderLockFile, ReaderLockMount}; + /// Device, mount, and file coordinates of the locked `reader.lock`. /// /// The mount coordinate is `statx.stx_mnt_id`, a mount instance that changes /// across unmount, remount, and reboot; like the migration intent's root /// mount identity it is same-process evidence, and a restart comparison uses /// the device and file coordinates only. +/// +/// Each coordinate has its own type; constructing this value does not prove +/// that the lock was observed or acquired. No allocation, blocking or I/O occurs. +/// +/// ``` +/// use keep::{ReaderLockDevice, ReaderLockFile, ReaderLockIdentity, ReaderLockMount}; +/// let _identity = ReaderLockIdentity::new( +/// ReaderLockDevice::new(4), ReaderLockMount::new(5), ReaderLockFile::new(6), +/// ); +/// ``` +/// +/// Device and mount cannot be exchanged: +/// +/// ```compile_fail,E0308 +/// use keep::{ReaderLockDevice, ReaderLockFile, ReaderLockIdentity, ReaderLockMount}; +/// let _identity = ReaderLockIdentity::new( +/// ReaderLockMount::new(5), ReaderLockDevice::new(4), ReaderLockFile::new(6), +/// ); +/// ``` +/// +/// Device and file cannot be exchanged: +/// +/// ```compile_fail,E0308 +/// use keep::{ReaderLockDevice, ReaderLockFile, ReaderLockIdentity, ReaderLockMount}; +/// let _identity = ReaderLockIdentity::new( +/// ReaderLockFile::new(6), ReaderLockMount::new(5), ReaderLockDevice::new(4), +/// ); +/// ``` +/// +/// Mount and file cannot be exchanged: +/// +/// ```compile_fail,E0308 +/// use keep::{ReaderLockDevice, ReaderLockFile, ReaderLockIdentity, ReaderLockMount}; +/// let _identity = ReaderLockIdentity::new( +/// ReaderLockDevice::new(4), ReaderLockFile::new(6), ReaderLockMount::new(5), +/// ); +/// ``` #[must_use] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct ReaderLockIdentity { - device: u64, - mount: u64, - file: u64, + device: ReaderLockDevice, + mount: ReaderLockMount, + file: ReaderLockFile, } impl ReaderLockIdentity { /// Binds the three observed coordinates. - pub const fn new(device: u64, mount: u64, file: u64) -> Self { + pub const fn new( + device: ReaderLockDevice, + mount: ReaderLockMount, + file: ReaderLockFile, + ) -> Self { Self { device, mount, @@ -26,20 +69,17 @@ impl ReaderLockIdentity { } /// Returns the platform device coordinate. - #[must_use] - pub const fn device(self) -> u64 { + pub const fn device(self) -> ReaderLockDevice { self.device } /// Returns the platform mount coordinate. - #[must_use] - pub const fn mount(self) -> u64 { + pub const fn mount(self) -> ReaderLockMount { self.mount } /// Returns the platform file coordinate. - #[must_use] - pub const fn file(self) -> u64 { + pub const fn file(self) -> ReaderLockFile { self.file } } diff --git a/src/adapters/gc/reader_lock_mount.rs b/src/adapters/gc/reader_lock_mount.rs new file mode 100644 index 00000000..26b5019b --- /dev/null +++ b/src/adapters/gc/reader_lock_mount.rs @@ -0,0 +1,25 @@ +//! This module owns the reader-lock mount coordinate's distinct type. + +/// Opaque mount-instance coordinate observed for `reader.lock`. +/// +/// This is same-process evidence; a remount can change it without changing the +/// restart-stable device and file coordinates. +/// Every `u64`, including zero, is representable by the existing wire format. +/// Construction preserves the value without claiming observation or authority. +/// Operations do not allocate, block, or perform I/O. +#[must_use] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ReaderLockMount(u64); + +impl ReaderLockMount { + /// Records one exact coordinate without additional validity claims. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Returns the exact coordinate for comparison or canonical encoding. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} diff --git a/src/adapters/gc/receipt_decoder.rs b/src/adapters/gc/receipt_decoder.rs index f89de484..470cae7f 100644 --- a/src/adapters/gc/receipt_decoder.rs +++ b/src/adapters/gc/receipt_decoder.rs @@ -8,7 +8,8 @@ use super::receipt_bytes::{ use super::{ AdmittedGcRetirementIntent, AdmittedGcRetirementReceipt, GcCandidateSetDigest, GcRetirementIntentDigest, GcRetirementReceipt, PoolStateDigest, ReaderLockCoordinate, - ReaderLockIdentity, receipt_format as format, + ReaderLockDevice, ReaderLockFile, ReaderLockIdentity, ReaderLockMount, + receipt_format as format, }; use crate::{ CatalogDigest, CatalogGeneration, GcGeneration, LivenessGeneration, RetentionManifestDigest, @@ -40,9 +41,9 @@ pub(super) fn decode_unbound(encoded: &[u8]) -> Result Result<(), Error> { let bound = intent.intent().coordinates().reader_lock; for (coordinate, offset, expected) in [ - (ReaderLockCoordinate::Device, 208, bound.device()), - (ReaderLockCoordinate::Mount, 216, bound.mount()), - (ReaderLockCoordinate::File, 224, bound.file()), + (ReaderLockCoordinate::Device, 208, bound.device().get()), + (ReaderLockCoordinate::Mount, 216, bound.mount().get()), + (ReaderLockCoordinate::File, 224, bound.file().get()), ] { let observed = read_u64(encoded, offset)?; if observed != expected { diff --git a/src/adapters/gc/receipt_encoder.rs b/src/adapters/gc/receipt_encoder.rs index f8d7e3e3..d0964581 100644 --- a/src/adapters/gc/receipt_encoder.rs +++ b/src/adapters/gc/receipt_encoder.rs @@ -36,11 +36,11 @@ fn write_preimage(output: &mut [u8], receipt: &GcRetirementReceipt) { let (catalog_digest, output) = output.split_at_mut(32); catalog_digest.copy_from_slice(receipt.catalog_digest().as_bytes()); let (device, output) = output.split_at_mut(8); - device.copy_from_slice(&receipt.reader_lock().device().to_be_bytes()); + device.copy_from_slice(&receipt.reader_lock().device().get().to_be_bytes()); let (mount, output) = output.split_at_mut(8); - mount.copy_from_slice(&receipt.reader_lock().mount().to_be_bytes()); + mount.copy_from_slice(&receipt.reader_lock().mount().get().to_be_bytes()); let (file, output) = output.split_at_mut(8); - file.copy_from_slice(&receipt.reader_lock().file().to_be_bytes()); + file.copy_from_slice(&receipt.reader_lock().file().get().to_be_bytes()); let (synchronization_count, reserved) = output.split_at_mut(8); synchronization_count.copy_from_slice(&receipt.synchronization_count().to_be_bytes()); reserved.fill(0); diff --git a/src/adapters/retention/filesystem_retention_disposition.rs b/src/adapters/retention/filesystem_retention_disposition.rs index 438745c0..10b8de40 100644 --- a/src/adapters/retention/filesystem_retention_disposition.rs +++ b/src/adapters/retention/filesystem_retention_disposition.rs @@ -27,8 +27,9 @@ use super::{ use crate::adapters::{ AdmittedRecoveryDispositionReceipt, ArtifactIdentityDigest, CanonicalRecoveryDispositionReceipt, ChecksummedPublicationHead, GcRetentionState, - ObservedHeadChecksum, ReaderLockIdentity, RecoveryArtifactKind, RecoveryDispositionCoordinates, - RecoveryDispositionDecision, filesystem_platform_profile, + ObservedHeadChecksum, ReaderLockDevice, ReaderLockFile, ReaderLockIdentity, ReaderLockMount, + RecoveryArtifactKind, RecoveryDispositionCoordinates, RecoveryDispositionDecision, + filesystem_platform_profile, }; const HEAD_NAME: &str = "HEAD"; @@ -352,7 +353,11 @@ impl FilesystemRetentionPublicationAuthority { catalog_generation: head.generation(), catalog_digest: head.catalog_digest(), retention, - reader_lock: ReaderLockIdentity::new(device, mount, file), + reader_lock: ReaderLockIdentity::new( + ReaderLockDevice::new(device), + ReaderLockMount::new(mount), + ReaderLockFile::new(file), + ), }) } diff --git a/src/lib.rs b/src/lib.rs index 427b2f04..8e70226d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -159,12 +159,13 @@ pub use adapters::{ GcRetirementIntentEncodeError, GcRetirementIntentError, GcRetirementReceipt, GcRetirementReceiptDecodeError, GcSegmentClassification, GcUnreachableEvidence, ObservedHeadChecksum, PoolStateDigest, PreparedGcExecution, ReaderLockCoordinate, - ReaderLockIdentity, RecoveryArtifactKind, RecoveryClassification, RecoveryDispositionArtifact, - RecoveryDispositionCoordinates, RecoveryDispositionDecision, RecoveryDispositionDecodeError, - RecoveryDispositionField, RecoveryDispositionReceipt, SegmentPoolIdentityDigest, - VerificationEvidenceDigest, catalog_successor_proof, derive_gc_intent, disposition_set_digest, - execute_gc, is_gc_complete, observe_gc_liveness, plan_gc, plan_gc_recovery, - post_retirement_pool_state, resume_gc_execution, segment_pool_identity, + ReaderLockDevice, ReaderLockFile, ReaderLockIdentity, ReaderLockMount, RecoveryArtifactKind, + RecoveryClassification, RecoveryDispositionArtifact, RecoveryDispositionCoordinates, + RecoveryDispositionDecision, RecoveryDispositionDecodeError, RecoveryDispositionField, + RecoveryDispositionReceipt, SegmentPoolIdentityDigest, VerificationEvidenceDigest, + catalog_successor_proof, derive_gc_intent, disposition_set_digest, execute_gc, is_gc_complete, + observe_gc_liveness, plan_gc, plan_gc_recovery, post_retirement_pool_state, + resume_gc_execution, segment_pool_identity, }; pub use adapters::{ AdmittedRetentionManifest, AdmittedRetentionRoot, CanonicalRetentionHead, diff --git a/tests/gc_retirement_intent.rs b/tests/gc_retirement_intent.rs index 60e90927..59011a49 100644 --- a/tests/gc_retirement_intent.rs +++ b/tests/gc_retirement_intent.rs @@ -42,9 +42,9 @@ fn frozen_intent_decodes_and_reencodes_canonically() -> Result<(), Box Result<(), Box Result, Durability catalog_generation: coordinates.catalog_generation(), catalog_digest: coordinates.catalog_digest(), retention: coordinates.retention(), - reader_lock: ReaderLockIdentity::new(1, 2, 3), + reader_lock: ReaderLockIdentity::new( + ReaderLockDevice::new(1), + ReaderLockMount::new(2), + ReaderLockFile::new(3), + ), }, DecisionEvidenceDigest::new([0; 32]), ); From 7af834ae07420765ca5683e0542ee96c6c043291 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 3 Oct 2026 19:43:55 -0700 Subject: [PATCH 49/59] docs: make coordinate reduction evidence replayable (#107) --- .../reader-lock-coordinates.md | 7 ++++++ .../extracted-replay.txt | 22 ++++++++++++++++++ .../original-records.tar.gz | Bin 333050 -> 334347 bytes 3 files changed, 29 insertions(+) create mode 100644 docs/testing-evidence/reader-lock-coordinates/extracted-replay.txt diff --git a/docs/testing-evidence/reader-lock-coordinates.md b/docs/testing-evidence/reader-lock-coordinates.md index 73fe7dbb..3e68fd4c 100644 --- a/docs/testing-evidence/reader-lock-coordinates.md +++ b/docs/testing-evidence/reader-lock-coordinates.md @@ -26,8 +26,15 @@ Observation runs are medium, single-machine experiments with owned files and no Compiler/API calibration has a 120-second outer deadline with a 5-second kill grace. It and the existing Cargo suites retain their existing resource profiles; this receipt does not assert per-test sandbox or resource-ceiling compliance. The filesystem runs use owned scratch on ext4; [mount evidence](reader-lock-coordinates/filesystem-profile.txt) records the bind mounts for both possible test scratch roots. Filesystem success is not physical power-loss evidence. +## Archive replay correction + +Independent review of `b1eaa592989eeab683f3a2e323a09da31d1ec4cb` found that both archived buildable projects retained an older driver without single-triple argument handling. The separately archived `driver.rs` and actually executed Docker sources were current, so the full-corpus observations remain valid, but building the original archive would ignore reduction inputs. This was an evidence-packaging defect; no Keep production source or runtime expectation changes in its correction. + +Both archived project sources now match the executed driver SHA-256 `282248b48feab6230f18fe2e88eefb24639b8820b248a899474c4d1c53c2e719`; their older snapshots remain as `driver-parent-before-replay-fix.rs` and `driver-current-before-replay-fix.rs`. [Direct extracted-project replay](reader-lock-coordinates/extracted-replay.txt) rebuilds both manifests, retains full fixed-corpus equality and emits the single 1180-byte observation for `(0,1,0)`. Both restored outputs have SHA-256 `81de2e366a4fa2293f4020e0267b3be6584f02c34ae7eaeb4f21d6f1f48f9678`. The extracted mutant differs at character 276, comparison exit 1, and reduction again reaches `(0,1,0)`. The archive includes the replay script and original output. These are direct public-codec observations under the same limits, not new tests of harness cardinality. + ## Validation and acceptance boundary + [Focused final validation](reader-lock-coordinates/validation-final.txt) passes workspace formatting, workspace Clippy with all features and with no default features, public constructor doctests, public intent/receipt/disposition codec laws, GC filesystem laws, retention-disposition filesystem laws and the independent format oracle in debug and release, plus GC fuzz-target Clippy. The [first attempt](reader-lock-coordinates/validation.txt) stopped at Clippy's redundant `must_use` attributes on typed-return getters; removing those redundant method attributes preserved the type-level requirement. That was a lint failure, not a flaky runtime failure. Final validation used the corrected source. Static type-erasure calibration preceded that metadata-only cleanup; the final examples pass after it. [Final source hashes](reader-lock-coordinates/source-final.txt) match every changed Rust file between the working candidate and the Docker copy after controls were restored. The [original-record archive](reader-lock-coordinates/original-records.tar.gz) preserves original output and experiment source; readable text copies remove trailing whitespace and terminal blank lines only. No original runtime diagnostic or failure is erased. diff --git a/docs/testing-evidence/reader-lock-coordinates/extracted-replay.txt b/docs/testing-evidence/reader-lock-coordinates/extracted-replay.txt new file mode 100644 index 00000000..0a51911b --- /dev/null +++ b/docs/testing-evidence/reader-lock-coordinates/extracted-replay.txt @@ -0,0 +1,22 @@ +282248b48feab6230f18fe2e88eefb24639b8820b248a899474c4d1c53c2e719 src/main.rs + Compiling coordinate-observation v0.0.0 (/build/keep107-coordinate-extracted/driver-parent) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s +282248b48feab6230f18fe2e88eefb24639b8820b248a899474c4d1c53c2e719 src/main.rs + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling coordinate-observation v0.0.0 (/build/keep107-coordinate-extracted/driver-current) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.90s +81de2e366a4fa2293f4020e0267b3be6584f02c34ae7eaeb4f21d6f1f48f9678 /build/keep107-coordinate-extracted/single-parent.bin +81de2e366a4fa2293f4020e0267b3be6584f02c34ae7eaeb4f21d6f1f48f9678 /build/keep107-coordinate-extracted/single-current.bin + 1180 /build/keep107-coordinate-extracted/single-parent.bin +310340 /build/keep107-coordinate-extracted/full-parent.bin +311520 total + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling coordinate-observation v0.0.0 (/build/keep107-coordinate-extracted/driver-current) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.82s +/build/keep107-coordinate-extracted/single-parent.bin /build/keep107-coordinate-extracted/single-mutant.bin differ: char 276, line 1 +Extracted single-triple mutation comparison exit: 1 + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling coordinate-observation v0.0.0 (/build/keep107-coordinate-extracted/driver-current) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.81s +Reduced runtime counterexample: 0,1,0 +Replay: /build/keep107-coordinate-extracted/driver-mutant.bin /build/keep107-coordinate-extracted/reduced/replay.bin 107c00d 0 1 0 diff --git a/docs/testing-evidence/reader-lock-coordinates/original-records.tar.gz b/docs/testing-evidence/reader-lock-coordinates/original-records.tar.gz index 92b04c932b2a3bc34dbebe84f0c4234aee657bbc..83f6f15a8382fb7edfe08b250539fb36acfe9687 100644 GIT binary patch literal 334347 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


`YL@+$3S<~RCmOREjt9_rA|2m zl3iI2CF3UIG!;r?ZN8l;6|$C-8i@4U@%F#F+eJKj_IGlcfr(F-bT@5JEqjv`9I8kQ zE(intmEqM#Wrw(MG&Yc1Tl1V=8*o>k4sID5M4MBkvYHxGCLU+c1@f)?!V5JWLj2kT z6tj8=2~g=judFmAiSX<7lrC!?;IyC3Z49a@rha!<;xZA16Aat=8-#Q4taICc->HzD zG2^0TdwvQ&`zgp{`-%KYrNzLW4t}{(LU*>fj+3M99_Kk&X8elVo!*tVuFI3O!)^=| zyIIjk(_#KpwZ?aN!_AbE++^h9iRr||@$Lr&vL>g}xBQ!o+x` zFb{sb!U4(ze^oHL`5o|ohGTbz9%Q!+Ll)26hk7ZPJ)4xHwb1jfA8rplCXU|O%$Yc& zJ!7Cw9BlkNoKIHoDfhToj&1hUR~>MU`yNyxaHi8G%0A}%Ll(H|Za?@Zts?OEPWCn-Vg@`G!?k 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
Iz8Ni~w*3(pGA;(!X(8@VW3$=d> zm6YCqDTHEBDH~5kqmdK^*>SK*^6GV#0V+BNnxZayS;1AxUtZ-L3w0Agk+*=cU3t!r7xZ+Tc&+vcN6%0DgaimsL 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
f4b7;X{e>nK0r44$ekH9i9`0) zbfWbA*EO6mH5T#%iIT8oxpe{zFSSW;%Qg$4oSYPhhsH;43`rJ$XRKW&yIq_p=r`Ui z(f%zD9VGbf<^69n16%I?-rHenIcmKgIcFJ73R5V-nZKscKn7_ntb4(7r-$D*7A*h; z${3;LS5WiHy4O=Ae*prQD?W?Qy7{`!7drDT$~QwjiyqngS_4x;{@`t8Fa0F%d!+Op zq=)0W$+4Y8M26*6kTv9G)_+SZu7CpZV=K5wWywOp3dB3W&;E~GFv{> zrwYYnLS`V&Fs%W$sH4mu@1S&l4DHLX>sAv)iB%R%(c&K%u#Ar^`l{BMl<8((YC-4< zLf_n0@|kM$?WBFV{@1|A7h_rFTd(hk$U8edQ0%|Y(}dv2`+KFJEHAA+uTV}Gi%?nG zbS_;_<lghI%;@{qv z9Sog3wzcl4adun>I6dKx6D~8_xTw+(hs_DEQPy@uXsOiNE{&wFF=~007YaN@dNjiYmlsjSZ)BEWA@89)wQMtqd6>s?E}S8~6>!75n&J*b1)i9yPZ=2);D6W|J-u&F=6*RT=Q zWD4zYnay+oI2vb<8`H{59+}vDCX8(m*BX7$uzEXT1Cu^u_PQUNJjOcB;AAE}NT%}n`h_0f zh_P@m+_rdSxGgA=TbTwpz1ocz&>Dr%afyb_T*v$;YgJCEP&G34ge9HSVi(t+d^I^t zKBSW>dJUsAK|N)XzG3&ocrXY<&WEK@oR^w6oo)>>BizlzZ18y-pe|bE9u!y|K6_s3 za7Hk$PgEZ06ONVQNaSjfsK*T}1@-6-XsK}z-R+%43q*SrZsnGazMBiFYnO4Y z$c7IHbn;opeHQ&G!~D6sp@P0hbwVmjT1}T#4u35NVgN@1r3wA86W!0%kp578-R3(w z4rhbI(z{#w0XzYCpMW@`8mp$xY|6%X*;D+OrKdG<4rDmM90x9`LHE9Kgw4?{mlQrT zKZ^aYb+}q6(c&e;%koNY4b9SQeY2#-sifHw zL>#{&>|#n-IWpc3wI3DAs(Mt8*^R!dI_iCCQBBASc7ZX1&mrf+)NUVVnJGc2!n>7^$I{b4 z{BT?le3EC=C1Gs-%AW0>w|IGh1)8L9PqO^#cmDsg;chZ8Zgq!1yj(cMYG#y@&*k zY6a#G4f0KQInu0^u_RwPHNGTfD9MqDrEJF)Yqxhg6oba^YxQ)VXWWDDX`wZ};0Q7o z8VYads)FO$$EbozWLoCIW~>Q8x!8XvjW&+0;DL6equp}>+ZGEcj5JoEf(N{%#4Ze` z!rRD#f*~P*-&Y@K?RvIlDh9*pc=KaELRS2mDj%O7CST>qWp=_?K{$8JN>@ceemY~j zar*AN@hYMSR%D6d_B3gt>Av?5ZI>w%!2adROCIK;N|$2jGAbzykB~z6SFLB#I64^5 z4^Ob5l zQ3Ni$Glp&iS}YhMqTan!qOJ(LUC0d3M0*Szc|U}XE4@NB9J<)U)E!#H{)L$2?(7#I zoOHb3vfHI+PJoBx>t|mr{=EzPkopES=q3VJ0$p-w8OvI4{)&mEa=sqf_3ai6@t>~w zI4VqnuS|G*`^=-xt3z4v%w7l<62){8d+CFa%%QAsqubw?|7fA`aDF_PHSMLvR}8?z zVs8=dqSF+Fh2#rFGzdHZ<+`}`bch;x~i8$y7 zpZ55bxU*G->hQFltjKcbrRk{WUntXs=ik_$4!>#+HSo75OUhl3&|~5$phuEKM{1R% zb3HAB!xG)6CUU{!OWR4CK_*t^e7`x$C&ye?yv;YuZ+3G&xbEY*hbiH3qX@V%y;+Z+ z&Xh~XY4%mt8GKR~_%G`^I=16v+196a&Pv3s%1&drh@D1K<^ehLQff=6k30F9*$>xy zWC8|?vV0GI#i&OV%3aMbq=?xZ_1K`a2KJH0pbdh7G!qFgF++liK*H9D9GwIHiyr>+ z&y}4X#K}1ARjD&%l$rW}WL*VYl>O7S0FjbzknZl5ZjkPh zmhJ{Yy1PTVyIZ=ubER3jJKlZ%f?gNzH@J6pelusz%$(_$z>+JD-##04sC(0Ba&|8D zrrR#CG%Gx5HPAXUxWrb-_2sOK4FOY5V~=^kT=&&lrGah=i&5&me8ms>W!>Wt{+|Ax z%D-?S;KsPJY{|r2_n_x}=mI`EJ@$v)RFoeu(p3=dPWpIcwoe<@iFvdAmj(dsFl4mY zZ?mny^^#jc=>8;v=*;04I+t*MQhJA(t?P@e-_@u z3%F2RktY|IE?sTC|C_5a1o-0cnGl88XQAA`c6~Rx+;ZRxKsQvJDayB0dN@1&{l{^P zg|UGiJxx zkp?HpFMP^KIQtSU-n6SYtf2e}n#uIx)bI2(7eo$UDHn{Vzx1@+sL`;y`U9*Q!Z@3a zse;dpPU}%D`0<|}(AhjJ0;S)7=l3|*d!J30Nyq@YFzVlHAV=tnYSc1G}t1hnK^)9RY z2XV>Wo(7f%{*x$R07(=aF=nHc zh2!7PR;s8C-nH0fQ3>+60elV1!8j@-EaPNBub<-2sX_Vf57<~C1C}vY1_5Dc}7r$D6=s} z($sHexjk&R>>vCEscigrx%0Gm@Ibh^7HF<}xx){Lr&L&)k8<4BWh1US>&jos;}GE} zAGPL8515B~qJp2eMg9NgYZE}KK(or14WS}2g5hu70qdq#&3zD=u_1f!x2n|Y#G2ym z2tdWc)7K*p!$tddwVf?OQsTo3D9btv045kcr{8G>W3Ey<>?ycP)y^=d%x9UJW{41D z$~=4=`=liPQ$~0v2i>dctfE_R4$0Qb;b%i#Pfos6;MwWuRU?JctTK`g#Folfd(x+48s=o>b>EaTd{`dz$m^ z1w@XoDq{{?wk~E9`I5?T*xH=mxc9ZU6bb{exf9 zh1LcH;Mg#x4+{yvVrje}x+@7SO0#;8oQzgY%Of zBpy|KleB+;nUWl7_|-nx#DfnZ*IIR7gYO;I-VWE~@}`J5_bKcBeYID%`^!*fMIDQ( z>HctZ7WMV$^ex1+_GiYV7~&k+>_Jd}{XS$i-d#o$O0874Wn}N%^OM%f_HolrhpOv&ynPZ)RU;4Y_Sogt)MLLoEjsEa_wHw;BO#Jl6R-+zzIVJD93ecYMWB9 z5W5u@yP0HSG@5OH4C6M}lsX%mL8U4xI@qvQB|ziuK=1NWorY1c9OFaWEEEkojSj{mj`-e^({HwSVEY~xbbung5(vRUhTOeX?7NHpcIdObQN{# zl;{(T_f9E!6?!5GlAV&bFL>(2ezjySGZ>l~1idVl5X;Vfo(fBApWn5o@TJ>XhtGY< zq3jRb?%^3zlk_E=bW4r2XiYb6p@hd>uGaX$z z8kiHb9&t<1uM0t8?Ef-iY8$K)4cdXC2DgyrTj8Cj7gs+7b6cT>IATLq1lBxw4~>>QA>nj-xm6R_@SmSP~aYyw02} zm9ioGqQ;hv^VLI!X@BcA@#s=YF}_8hS2snalJAd0VEgo2ch$R3aPWr^KXT^v9r2^! z_>6=UAp063BMs%|Svv&6b^FN)EQ&rkFcLkHLvjB}*cpNriOP3{&(53+6lsDn%Y1XL zx0E+cl=@?dYALlO^QS+5^*}MIRr;eJIG+$mpN_q?7* zSnIRT`Ze2<=7Q}vxk|+dBSd%tD>$B-3vbj20qZSE{nA13nV^dNlUSV!f-PZ4jx~=2 z=MAHv(&`PRg7{p@)A7pHBKIN+wFpvcs;09YWF5Rpg52%0Iu*fAXNZ=J!DG*dWM~=$ zq8D!#r{pp?gwikJY!AQLWn5my`)GjSPR(N_3&2Wlta79j{?GI`E&ENV8V^oFRUz>E1Dxg=+;fV(=p&=qy^xddw)oyqUAzP-%aD%b4~ z9&%`1lCOhR!GX>vViL=W0WBYK_^b_~aAh4Igx_^fzcn~_q&{(&-eSgInB8d>wO3`a zxt-WDZ?GAIvJzg1q^^1Dp#C-e*h-j)ui1QVmi0qc&Kxxy;D*iXqd_whbRpK9s-bi; z_+PJ|Dx;BV+1s>QoAhZt{!KVwCKF$U8pU3q_h9EGiIJT@`p5K9zx}ohCfKY>YXs4f zI3z8CJZKAcw?OBB-DH?!kc*r3ofCA9xz_o~60yYE1*~j(Nec!J=cbs~aHj2$)Ub;W zT#CW$TeYq!>1V$U+jqk4_NXiiYzNE^3OmaaKF^9hM|)upUx{YpQX9A znQIoChGmq>1tiz{Q_8Qfe?Q+WdY<$&UoSFjHS}jri~EtqciP4TVdd-lXjH`;zafGg zn}+5Z;3Tmd4~(C}mi?T@4>#ylq_B5h5IH1c0>vGu7dGs*%;`X*=nP#0mHLLXiZ+;Q zXvsn8=^L#Z$LtLcTI2XQgCm3cRBcF;*tq6v$5mi5p25Q58%3rZmgL(`@YNDf$hl#| z?mhS$G5zb~>db-)9-KvPcL}M(k2n&d*ND}KF7Yr=g1sOG$4Nl)xn+`{=hEkqs>7+8 zy5&|}YN;@Ir&xG2km3BmVT38`xpJvqrRvLU|G_zs{8n$pi_glfqwJU)IytIAeT*B! zjKeRF>d}&&ElXg68T&4<7EuDaeuwYlW3V31yVhK`9mD8J1^n%3cka{XDq0lWx)(b{ zEL!o6H3+)No5Ixg`4K~)Ztl@pX6c_^*=IPDcn!k3YqCcvu2F9M_R6+3rnsr{C9WQPbl9Cw8H9k=aNFo=pq+r&6KfKiSejkZkEdu?9E5 zRIV_3b!ihNOAN95MjW&AEI6`&icx*01-uZ{i2M^XiGviTOF{{_q&BgC7>_iRZNjA+ zLsq^0;g{*=?@3!RRP~gu;EST)2S*yQF>1tXEHANTQ=l?eF+#m;(>`xZhp>2AF!1Lf0pr?uh+bluZ3DB_~}3L28euu+#6pYD|G2iDrnDei^KG8`hp-XtXGz zbn7g`Jk*6LX0a42whif4_(OL0r>_;s{;$-0OZYe!L=x2JV%Y!H@6u7U54?>;}(p>n4$AV(!OKt)k+DT zb0o#DEJ(o*K%!6HREz85J)c;3f9jHjviQ+HR5H|L&YR5bG0SyILy>hA$j}X$0!sRpd-)fy>;hgE@IHiICv4 zrD**u6!aiQXk{)+licgv?a?~v;_krw zm$c}*J(?NK8$BH$*A7I4X9>$%T@i;cBi7QYKV9@gfnUx&#r>KdzQfFJ{TrZz_$`2& zQ*GvO<)(}{vByS+nQ3D%+=JeH0yhTAz!hW1>Wc_95<|u>bFLcFp)3omcO<{Ao0>~& zvuW==XA|$6-51k@-fo5===PnTcoc48$T28x0^lePJD4op&?j9>v=TH@6BBe#e zV0?jL*ugN#QXC+PLgzo-%m+92;wTPBM+C(P*W$=%duV6Lu_nNK1M`VdGFI-InZq5n zKh-UJ8K?V+=7q+x#j1m|*)C65=J?!(Vw@W<56g*%zTEGdAX+rVaoy?SoQ~J&9o!WT zw;8^97;O_BN7RY+HJk=P+}^ka>=g;7lCA4uTk$&Z!jAq(cmc)$GnB1kjfrQD<=OtH z4BK>DR#EycsYX3p6Kjh4OHGo_MB~ZZx&aAy<(6)C*#2=qR z)o&SQb3X;=-*3oYA|Aeq{H>aeY#=yP8u@hiMxXhZ^zFPHz#KMI^SDA%l=9!>4QQoE z&{RVz;grMALSqQJz&7JSkgRe${_Mf)A$jfO^z8fVRMEZ;HagzxkvkGSPNH^?A_%i- zEYxk9(UN9CoM{1lF|2+<;MdA<7O@h_BD-P}vPw;8b17_9E?+W6KQ?WR*D)bEz~#>G z;1=DZMqCX^w3#Mhf#X1W1mViP1o}pO$O$V(c^2K*@FW-4`ANj$dq?DnsZTMWZ38h>HsaI&}$qm}B-lMlG&Zz@F? zxK^LBvSLRhs!iNV$g2z%jF!XzQjP{=7HtG}d+L5-jKqhJ-E38a;I3jgP8=Fm*D>*ItDXs!mG8ZK9_%}{hrXfX=dUl2v~)1lhQk89+-BSocNo(9RM=@lK2GrqcC@( zQIZB7n)mHcsmuEq9SR8D9%q&qA~T=!hv`Uqnmx4)5f_z5V|Y`P@W<6%Cgl1n`mp#e z@(SGH=XIQC(@Re=a6tOrDeMYE!|^d6a&`D;%70p7D6iRKuBjWL4{HU@4?Kl@!bt)j zLTK+4Fla{!P05Q#$e*(&%F$(eqgid!1eZdz{|h<~x6q`y=^cSzz5P_hKaA||DbU^n9{o2P5u=@X3)>?g+Ro&Nv(jM_Ple?@mei>+QI>O z&70|iXr@X;SK}HVvsiVuDR*Rfy#+>>n8R*wF*sWW$e`I#TP9Gel}Wv< zk@l%W&PCzh=XXlj3KpbBG|^L7CubuAdo)I*C(s@nKP!;jJ`luO741J|EY%@%Ma=}0 z*X^$~dJ=P;;~_5ZAn#fbw@30oaLoy0L;rfQ5a(>jT^GC9KXi%Sd%~bOMLKA;)Xt)R zOkGKC%d$KK-7dP?YueEG8(ONiHlq&%cem0>>ILOBCb`W^-(g-_RE6>;9i&r*+oIAa zqxV9HT^zS)8YZd-s6=G4=#cTS z%ZUW758Lw&%mD+B*qg$f6$xT_%vXuf`TJ^cX>Zpau2Kp$bFY;g$@MVLHJ70-nYd#$I9jaX7~-15O6-FK{R@ zM4g=Pe}|*cWiry09r4L-ZXm&*U$lujv@X-kjaXRH#ioN+;W{o-A4>>u8 zJ+Rp=u5r^TWsEHI)|*>Zu$()c=vyw|`H*n_mv0|#8{_cYvw1JZZoO-eJU+TtlXG}` z==vvrytmXzOJUPFvyBNgomG$zoN>(G16$jdfN|FEoG0Ob-8BKBz7tE6VW{cqD25$s zq2qLTFVhq{Ld0wM>a=>HtIu8ynFS}PEcIj{9g7U5qG~TswOaiPkb`OEsOJq z<@Cje{@z>8n%Kb@9b%E8U0V4p?#WO7-&H?ON#F^*j>#;P(_ zFgX@vPY+ZNe8O7cS*?pRBOX79+r^FIAzqvIaF|!zyeO_J*}VzG(PmdRU+d}c5AUq_KrJC5$Gl&l}dsf_3@1K zp{iTCxoPlyM*<|rAGGrUMzYO`*x;~JlaELhizGw+)47Ywz$@ar3{(6eIxE0u6jZTw zg~ouhM(0Ak0uPS`*L@&)o$(Q@-TuIoy=9FK zv3atW&o)4PAKFgrzAJ~k&VWjxxwsO&&=fN*<|cW=lO$Yu$Kz`%l76!zH>Z2&N24;)7K zJs@T-O%6ZC-+r_=DR_hVzo2>0qW4s%z>y@^r#54oeoJQJ&50(cI7L{|k}N?E`I(4S zi`0Y%Bl*|#wo!WJ@E4+jmM(FeEoZOHWmnYeaOcG1(*d zj+CCe#p0Yn=L3iF742=!sNme0VZ5n*&AKJ_gbgDUfxTqJSM*G%piw-9-3doA$@Mm) z#pSI8IIPkLWSMiSZMa?q)`49C+m%s1!I%#oLC6LUPveq*Ys9%uTVh*I89Ny+sT+jf zo@*D%nsM3!p*oL7k#9n>Io&}W>?caIOM28HkkIrd((-5gSErfYjxuY(TW)A=CZ}?d zGdO_z{eNzMb9l%Sl2aSkMFu?1K=~5!p~W~XlCf5So!Z4xhFNknq`4o1Ylk$#{v)T5 z7V*jyNzoY!V*b>r9M`n}+>SA@)>{Ulxst+fHO=>Kk>QB?dLT?hbXx;5TWz4ANVMo> z#LO6*R8Wcf(M=W*u}rRp(FAi)j%fp2xs>dxa#kK8TKq>Z@b#u7qh^x~I>u+6NW9TV zm0l1C!qNfj9;n|YONL(j$^OUd>Df%j#Xd1~3ha)GSI1i2e>y9eJBO>pITxHGJXjYv z3M_KF!zP!RLYyZQ5_G+Xwl^L0Z!DY6nK0;rweUei$wTsqMzu0B*Us@)32}|g(}~*Z zMW1*4&By8j(q{Ri`T6{2;oNN;}495N_-PQS93%%0KJGK_22Ey zKg>=RO5$9@`R(#V&F1IXmY-D$4%4b6i7J?63spZf`?MH)p2!6m)nYBv_#lI~(`!p_ zAZXp-MGF1CusXuf^yl!VZ4HsTji7^T&bP+KYLJTv7=5LI>RiqRPP7@pk%_x(=bCDA zY$zPlGSX;*dMR=Ms%(K?< zQkRBF5!%F_SR7qJKC~j?-kn(2iuomCZ{!cB zDW+$A$JJ81^2PF0aHurAliT$XS%La%;A)1cvk8LKto(BOf-E+=F=plQEB?BBb6kVw zQZy0>h&9#IG_Q({CZcLKstQT7zR?5~cdd(rB3%8x$;h!CE-qy!-j|NkbVTJ4ViU_f zM@WdfB%ur3l1_^r&J-W(Etw$M*=oQ937kIx1+U4|?eP|XMt*#nC_~dg>p%fdx1L_$ zb-EeFyx^r|PxJps!PZisWevv%En*KOi^!~7`CM z>{PRzhsFqE%XD%xdA|2n8x|Y&G9?DXw)k94to=NJ2*z++aQFXfUE94(Iuz(!r z_t)WJ!3E5L#;#WXk@7MGC?Q);ZdeY&AX(x1WD<;L77*JYohx~$WLzHUqG2x8qcSq98SrUjjx=F zZ@Y|2&PR^fM4D8@2hgK4iy*~uuIm#wA~0X43)24Ru2)XW#H>Kya=fv*t_s=<^<`!q*bhQO8B*fn;0>@G0~ zFqmCY<2jK-Eow$|Eov5?FkT2<(h~{(ae8}Rc$XUHboA2p)_Oc9<$B+W#SO&vTbvQ9 z_AdJ(*pm&``z(iaqpbl^&yO~JK1*i0Lf1T0?o;i^10U>zjqEz^LJ=A;B;s-aN=8nX zz4^)Z9;^Xt;#5i51~?Dn4`1-^^At5@vDLVQff=UcXH|XBr0THd%d0)1u*5P~H%RCn z4H`tXybXkH*$YvD8Yg2piq4zC?q%Vd69S#y_I15g);lcBR_AZ~c0G`5UXsasI*wOg z*zDT(eC;x1FF!8SDOL`Cp_TCWCZ3wx#c7udVNZ~uI?ab0B0~h>OV)L1mB}){oxrYk#aBnv}{&vzMHV|^lF#u(GjAsaLnVOl@4miPIVG9W+QUz=}<4w zzj*04Gj^LH($Gh7j%ur^CuzB;1$j@tPe0sjS+-p=bqx3j{bT9UD2c+7DR7G=3KlhR z%W*%av)O-rts13d2dMrMqRFPfzLQ(>>cn60dT{Vvc1SxqF!uMI6Hu!kA?~mPGXH~U zreWt^!CNfmqXuf=ir<*PdL_U~@LTpW#}dwyhw*ato*)`N#&=QV<%9}8P4n>~&+;{cQghM%DE;3sgOm(r<)WOf8!|D8u`S=L1XUD)T1w5ktppiuQEG?ql7g^pDm26CIA-C+-w?!d|g4jUsI z!2^_L`QjlLjP_$#C{|PFU6&R7+cK*L2h)G&n(J|SIvJN-eE(7A(=Wc>%TF3e@&Us;$2WK?-d-qw% z>1`UnI6d|p=RZU^i)>Rn>ThK^exI3SG{7FaU=dYRXpmkJcVpSe_ljRGv(cwBesji$ zA4Q=LLVfF40`%<_58N~2URgiL^0JSavj8EM^|z!ry0iAfji~~UVyLyu_P!oAl1@z6 zRlSTkz6p$Pqq1|6v+>ty4K2hi#!!nW)w`|lhu;+<&E5H9dqm*6IQY5Xc`dKNEy12^veBXB`MiP0&=`mk zA$e1LR5aWSgk@wqf`>m%5uEyQP?0mPbIH*Z?>0;Em0p6dw>D^h2_5^exgS!i1L?B& zVeae7;cB@!ZSYST-se5K=kB4QU9+{M!QCifhFrNk<&;RNVaQb_9S+5~1g;4U6l4Zy zgYl{Dd?Kd|I{u`v*NGSD_T$W|O#P#0sFgLDU-H1_f&Y4~TkRj5Fp|5r&hw?Mh}!Wp zBU>jja%kA(;ZG*&gQKGikb+X%QMDt)^mHwjbeYnIaf4j;b_63|7&lhZRz6T-xoIlEzqo;MZrh-e(HAQpdzQNotq!I>`Ti zex#>xAClHbkd}JHv+~Jc0Z4{h&?_;6XGYZg1Jr=fmwHHm3QBpb`nY6!@z4SyhfKKdEL$d7XAglu~Me1%?HV_MRNAkb4epcUn{`x2= zh88NB$;wN62ebfto~XxTelz9u(E?Pt-YXoQg&?xO1h`!DdZoH86gDmiJ1T5I>s4xz zdQ^Yf*jZk$5N%paeBY-h0$h;M5|G<;+zAYzhUVunM_)1&M%3e*EWSk5mwPjp`?EKq z#ye*r1%B+9xC~ZZ-98^bIF;Xj!403&my>C_42RJ2gY7qCOe3ekDy@Om-&fh$* zv8TfM)Ysp>AQVzb_gfl3H{!gN-*1ZwEZ+&N^!J%>zO8s`AE{P;PbAp=+<&NsoTddG zB%1Z{G=a7u+SBN)E7qv3K8U)8xf7~csp8?1da^xqiC;237g)ZqC@*p3W|}&o(2r!_ z3!ftD{+w|DTcL};Llr4|^}s~J!v|OiD)+E2%CiRm3DYpis|a~OkNqAc@_a)vx8mx= zBjj}`ZD0lI!E8{HZas7u)H2M=vXD0njl?Avt5%LlM^Ar#>0Zy1RWriAdsx5x{iPGD z+Z&RM7k8 z*qg8rD0=7vb|P|#!kqPhy5@^`w&36V#g_NRu% zh%IPj2nKGym|p+PQcGK1Ig$)$D>YKX0x8h_Y7p86bv}Vxwx1elo_%M+Sv>lXzdFK! z{0550z*w`LfQkyfp*20k#6xB%$oY=v-;`8~mV?mj&JXcE)C89>oVuyJR_?i5hW)63 z8=8J9D3q14r>L`{JFS;B9$FWJXb-^QotH|S)eTPg%y0>!bEwIM)Hp{W^xZKhiX36~ zze}fv=1@&5t1s&pEk=6H$C81W!G)fb%a&2_k_zSANoc6N#)a2{&e!Gcy-re6ImPfqf=NG9Pm zcJ{0dg#z>lx(N5NSwaDlSwLGfesWC2v7XRGPDA`d& zn>0769q-oSE=+DatuNIzh4CA;*V8%l7PsA?x@q1<3Nm-u8HtK{nY0F}vRez6tl|{z ziyl*thL`rX=DibLlUq~jd?|VgVU4~1#-0g2O1cI0so|@}4(Bk+m2%kmWBO?ruG!79 zw;N;E0ZGG8Kj4VgM%fDmA)9Bj-cmlQ7kcV|akG`i^40zI^<#fdpleHW0Lum4z~=C! z8$7pTx%*1Ex*w5%l}3kX_;{Q@ExHw!+g*39?ZC3hElLfhv7o@Tc_m7Tuf*s=e~r>H z)5{-V(8b-0DBKt{;Y-`jCF;&HA3e@ywAey zT5h@T=eI}?06p2n9MiV)W>$fy_R&v2fENO*YR|P4EoEw-GtFB>MD)%K5mK_*LxS1My~hG zE`)ZFMEn%friHHH&r&ge@miI%MWq>Y2ghk01ovxBX|T7mMPWP~SdlBR%W}45TYCTS zTERAODT1_8AKfT(cp7%3)>ms>LDC?=8duwZun!U)F8Mzu{PDika^?Iz;@#j~BB5cJ zHQ{dUotZnLbBJCd2(Q`aJ*U$%l&6-VNW`0 zx4qX{jB3;Np;l&j*tUgb+BPDOuVR^8yt!Y_mkJj`Y-P0;9K$}kQj=*JUi6ZG^OHk{ zy&i~owHnMLg~9$e@*6i&NPr{@uVH|=)gnaibVY{iYFT^HH^$lA{X#beP>!F*1m{Sv z&+amHM76$GM`XqHm)@#oKkh1pjDyAmX~1?`fIX553lV_z3H?cM(rYaz<^GS$zcYHG zS7M6qk_GN$159VM&!czAY8xL`k9v9LGtt1<3*XA*$n+ntbd$eC`hHAu*~RxLtrm|6 zh2?Pg?pAeysbt%W#}|OiY?m1(! zfr!<3#uHwXGX@hg3yeePpUxB(tB8P0!}AeL#Id22R-ZNeOth!4bbYbihws;QRPtd# zw+WMQppJ9rH8D&Fr&f?+zC!D^>j^1M`%WxTISz$Eh8U^i!a76W8JZMjVwmiv)u)$f zw^%pV)>azJ6M0g#3SHi%T`huf|5U+W)-?Hag$9LC@!xQ-Q%0!vywcsQyNW9VV@$QF zTpTu7a?LALI^HuY_a38zrz)$)Eq-exuS&I;2La+vtJGA(do z7|yNbW|KjGlF9|O5$Y!|jKPL6l+@^0q!nws0o1LEbsjtsWHU%9LSt@;3gP&}(no9|{hPLkmuF`r zplA02%86-03hUKaqV4jM6R%%ZxSz7@C{>%HSUe*3IL=GZaLqrvt(FZByBcOSYboeeylsfr<={P9C9Z97XG_cL2NrSG=xa%AYwD$oz#t+7qgaX-k5{%4M zY_X<0+zUMJ73%~!Y#ie9^;%Y5%9_-!!u_e-0jIH@h&%dazS}GMz7{WSFG9?D*=W+D zC|tGRrxlSQlr%TdI#9xwmivoHbdF(`_w`@I&_Z1L-rT8CWx3JrwvMZ|s#GSka$Xzh z^$d;HKv{Q4#n#iHT?#KegT;|yfPCqc=WZNfr>IviQMQ_w3#xup_>a-v+S=OS!TmgA zt#F+=HT0Wvx#O#S8mkliLtmtoo4HVqbIX%q&V>`41s){7js#e1U{o2=6v9sdy)vSN z=k3>Lv?ez~BYp2$KT@(4a;uhM8U0ozs_Mk@9U#=OIt)&?(O~8|+upIrs>ieEa#l9F z=TLF;la8Td-OZ%q+|Z7!+h_-rguM6Zd+Ix5yMe{6YztAO!SE$UZff3iVuH(%sB1L^ zRY2OW#<<5XZmn`EcA2$^B^fUJOQ8(zV#fzo&H;J-KU_4%o`^J$J=iWy0F>@}z2mtF zhl$FV`s7FqE`IyrAGN*2%6+mPZPV+VtaKiQ7y8>}ugh6e_rIPah}tX_4hsQuL44oy zpiY!WAsN(QKPCaEXG*iDB;#R28I02YnSkEsToBYm$#b_ zq%K;=7{h^+&08^I#lUJ-4qQ|=#C>Aj=AbIX&6gwj$$O7NlnfG77-iAlq5MECqD5i~ z{Aj4I{DF=r-cNf&&t2z}N4&h4J(a+-u*CbC%GR@cVC$jr0h%Ld<_zjx^^W&r^zKu- zDsK&}8yRe}{)v2dOnRVsk=6O#&lfgb5-2z)&?1Rhv(<$3o7O!0;ro$H57lhjyD$fn zL%46ueYJxOLB*uz?TPY!u5}*Xe$%B}=zEOOaYCZrH(_zS`ooe5M`$m|5jc)1(_CF? z(5V6x>F5;{IM@kP*6~%vb-@+(wL>j|YN~(n-mDbeJz7Y8Zy;?plhA!-w-T+SxMZ@C z!^=|wP%$cR{$zZsB^uZ@X5Q`$i{J2+5ueW6qP8$S9kkxwW8Z<0<_%A7__}LPP5wIv zN7f?`&N#}Ry;@scekq}xUaYSgdr5MdiqbV2{P6zIFS*mn=tz>Q{Mwp2d zb%lA6uW&Y6gIlpa8h?2^Iap6SD>s)FeT7jcBFewkpK8FgQc(7H%8pn^?T*LiLLg&_ z^Y~#x;hspN2Hf@(7-n+=Is~MT>do-fJ(R$9;6t;;86zaFEglpMcU!uB6<@6~dsXC(~>{?8}v{r6++H-u&S&}+mjaPfC~z}#+BkImY6_oE_* zw>3%vtR1Q}-3F{n?z^cC9I65KtWg5hFER2->bvRV{_PM8MkohWfS3g)b(eP-ZEX|S zW7c=nbB8JFzeO%P=5@_$}xyd&7qU=}ZrfIs3Ek+xL=R{(uiY$5z4um}hYs zK!U@K>F$l#{$sr1!jau$szpY9;s&UvpugVAe>8I^BV{LaMp&6yMKTa-PL?EtYV-Js zKS%OCm`DBC%pb6m69>ecr81S8)}?6*T6S^nz$M-9!1h3{FDnH`zfU}6O|`2TL+)Zy z-7ZAng+tt^&1?iU;@YbJ~sgkevh!&^I=de9k@ct+CBQaxRsM~Zm}-C?|jeh z^D1VQ^N%kOIIryrCDJHvJ~GL9XNZCg_GuE zc3tS*>@KzOFA=u&%W-D1-&9`n)FCyoNmPv@TSR2XW0>a(M0{ZCnHxzKg zsFQr$PyP5^EGm+ZAkl*-Qrwj<@>sPTp`L11+9hbfbSOt4qromN4+DCLvDqCkLifMs z`NORnJ0nVIz^#TYQ=v3kYV$8!yoX4uLL;s3d!P=kili8cD;DU)tkt_vm_z;AUx57l z+3J4HcOw`>kV72n%-6)R7pAYH;=inW)$^NWBdlYg5Tm2`P17UFnuX7S z+}9*Hwdxx5Jhe9Nr{2G6sF~x!&8|6kYzWOAi`PfZqIKLfX?l>;sRTp&jM%9{0<51kAad3ZcUmy?Z`at&86MZD8#NWWah*n4vcSz zqL`-tmbgH7D{Z`psqPH;L%$goS~#~@FrCIlDdHPvxd8NNacqI7RveD-`{_r%gLBg; zy9}x&v(#W)^P*E#Ar~tdW@K15gdlkzjYd2U-)6NFl9iBM4=bm>vR5q|t~iU_ zi^Da0EV}GXUwji<6uNLHA@dL{%CHNQ?8XbuG^)Fj#^Auc@u7((#3K$k>eZeLUx*a`A^pIrQdKga?%Z> zBPM%%eXy|&m@f?wULc;-*IW*m{cebb@)4pFoNLX7mVCrsNQvfitq1MRlxOFG|HvJ| zCO(Vc*fSdxb3&4>s+QxevQPB$+Fz;HPv8aCRXh!<|of({%? zj)7T52|z&9fJ&9kwvxoXrawpeJ zH1Ce8p+M0(zYJj=G4z5KhazFCQD4}z2J^G$_muvE!YgOUA^vP#nKp(a3q5 zWf5+irOudA`Op0GNv>sHEGQ4|{QRGOh={OejZ+Yo61W0;#KLyDN^8E}Kc(Cc-voEK zn4H2Wqu)QqrNEG}d|;nBI0rD-ktU@Y&}9dWPO#X&yS!)Gh5U}`FW z5;y!bspsuM^y5L4^$czBOQkC}GIHzPJf*`LjQcsq znl@|+h_(OUGMp~JuV1LAFl43q9(<0NLG*))Ln9}SO&6#_FR-?+NIOUBl%>_D{e2f? zuqD}RRYqzPS07O+YngESswkw)YND;86_Z7lcbxP~b#?6O`#*V&9@7(eY~GsDMo zN?Bi{%b5YU?x}%)^RZ%0_8p3}R*@Fga`mHuErP%3IhNB*5mFGBGF?;jMz@|~W@nh= zFLqRT;9HWlyDPi*kmA)88`~{$|ue3TVV(sx)lI~9@7n`}KWjaMx#N%XZX(0(6 z3DIg<0oahl$pwq}s3E$AaE9bA#%z=1Icc$&YpVG0BVMbK@x1UH9KzM(k~g*M=!G4w zk)ETMknT+YYmu3rr3t+u-1&pElIMtj?cioOH3e+msnW~=z&@U{!fMKWrU;TDsY*AS z4UF2-2tR!q0N{UDH}1ere@DRXLFkQunASDnwBRRhObqfIN$OV6U5&oXI*r`>9^X^J zR*aaRm<=qdG)yr$)Og*CaqtOMqUx%AyTiIVjJYB%Bm|s`p_leO}cSKjn+?3|(HX!3G$ph)P;$Xb_SK`K2i`#U66IKCY6pFmI zGmS>nj>>@aCT*9~r*yYQb?VXaL1Mekc=pXdFhAfO^Q(bbiCV&Qr-}@@UdsLjXAQK< zH1waXTQ{v00h{?6?{Z04lnhsTa}sKTdJfeeXFJ)d!dTxp=z7z^*#<@}XJZ?&(~vXW zK$ULYsYo{5SMQ(eZ28@0U?#eDy~%#*`FWDJ}ROvkBMU^EAn}-fSa$3KACJY}`A9hUh_`(uywMUjFf&C%yCA==EwlQ-uo4x%du{=zEo4(0_Oe131#J=(d_TSupSwqN_}wPc z?89(lHX3-J0E2K52Xng@CNRnJuKqZnkr~5@OhQxYs=3blL~<+Q)I6k zRElN-oZx|^qyb~1Yi1_LyC`b$fXf583pE!SChbZK%D@>qC(aTjXC3cpLZ_eH-b|b*Ceq>S@R^*% zjo)4a`7#9_aDkzS7-V|Lu*_SyN%}egi%!#$Hyjm4A1>OmkVB%Y4Tj-zSjGnhaH^TH z4$!L=d#f9x9&ueRvEj}h9zy_%y~8N@dWR{dcrHPE&6mT=E=UzhglhJY6@7|gLcLN# z2CM9V!ws7yYLBL7KfDecdZTk$ky7}?&ANE^;J=Iu06MHfkM&Z4#nNw zi@Uo+7w#1I3R>J9iWhgc7I%k*ySqEAv-YU6oM{0ZNQ z&GGf#-pg+!UQPbjc^r*e$yCh=xhT`7IG&pF;%#19@U40VUnJ<7T` zTw?(K^LEcaD@z9bdwRIEQowkjdc%$7DstS7yt1R_+s4vU##n{h5Svmf?s6*s^ZMNE zAP8vB0aqsl&&iKhG_Y<$un;4|!0TG8%s3LYyCQ`zu3%M1NG650-I3-8V-O9#n8_m< zJ^5yD#!6iXSF*-UjrO&aje|*$zqVjO*nnk$+Zo2*CD`v_U-hr_q24p z0*GJB+tVPudhO%qVnH34L{pcfO|Og+S*NLttJH(cSZ|r|2W2?yVr;gdRO_U&_PsL#I9A^Tw_|;d|6u#nAHB zHS8q0#dN)nWQ!;!|CxKkt22ThmlH6yDWeCmvJ~t)jPKZwIp0f(1))Xz)e01`2fbHT z5s$Ctu3hRkNPyfbKIAMdx%>K6zNxLrugBs@ztqk8Pw2qh=D>=Nq)5KZI68{yuX^=# zZj07hW{u7mx!dBXeo*6iDSQ|X(-Zm_{b$kEKbz{d9yCn&FNG7w6a3_ps!P0%%YAsm z$3c7s2=$C=Z-TAfJv$#CGPsg1>-}umbQ^%cyIg#WFQ(X=2{klHteZPsO@o=0!fty>(#u1at$*WI za7Qb1K^scURsr5eWMW`ye791f&?B?a_Yf#_X1ix%TWU1YV>ZCksY3)-qkw-)&tdRC ztG1NEtG3o_e{Bz!a+H_m_Q&~XVCK)X5?V}Q=}p$p_T>{Beu)ROy#L!~z8>DZ{$4Xx zwwm?!en)U04r!Q28-H9GZJwUS4Vq-Wa*lq|)X~FuJV^Gu%@6g$y^}==8t-dYJI9SU zrZTQy*BCVTlxp4ibV4~YR^6)|N6%T2m?h&@7fj?6TBdBgv?Z*^A-VeX(J~#qk4G3+ ztH|wUE&@40TC->^@qJayW56TjOAUgTvE$7i-xfH7Fn>m7gXt1dANn=%w3`vcIpO%Yp1+$+*nUw8(3vewwlnt z8Z1&LzXEn2)OU*N9AM3QtbI!aCuld2lRQU0%A{A;q1i=)d|gN*p}izDJ(@geAcEYzdYiw;2n=09z|d1<-ohtBlD={9dt(UGVndW2cg|R z8g~$feNUq*wlhU1a}dXmb|EbZ4+T&>PGx{$_tX7sE)NNew;{&V>~ z`ydO;U?;rjn~U4h0C>&q8}AN4A*7#&X601dy(Cw@$D4<#*-q{`Ld738tKGvvK7&?; zfm2hh+g34brpQR1!(wfa))mHwe&PIpkxoG+hL9mz z(3;}Iv{s54d+9HIwpw>>i@dLxsu;AePjMg7TB9?OtK{32zCUXD22n_G{{m9X7fZ3y zxZJB0`0&{DYg-^|jwV^oDpQQM>l1u~aN>+V-&k(=y^`(+FBrDhfX61QAMP!&4ZF>( zgmVOUC~uQvx&*6|#3J}6tvnNIcu1axvPlWYFroY|hW=Pn*P9rfgXXY$uEg=UMY%@1DQfCnlEM z-Awc;=O=TTiB zcI#!jdB4Q_4&qpnpa_P(!DFdXvT**g-uF6xYyGL;e6ChzB$-^K`SS?3IzmGF>LK8x zB<&6)3SM7nqbYlC5pq-no_)VN%*i})tvh2q19}Iyz}8iiZfGLBn&5uz(?4rVs;PjAp+wea7|Jq`1Y%@~BP!VUW1XHZ zmYFd@734GQYAGJ>cEPk5{C`M9GsVnv!l+DYm~4D+#JJ*Bw9~ykC zmk}D_{+;vIWB+C3h=q@6e-k5g2y9D6+m-`V{$UEi-LtL9P}YDos$xa72h)pB`F>=s z;(PT1b#tvH#(OPafnO|e{79si5h2sw?7Sl7D_PiUwzS3f?LGu$xyxrhi z`AbceHRf$N)Vg9Vy)Juxw;jKW6C7>sfe<>l7X^!e`CtiA<%r;bzKOWv=;z~4Jg|VT zVOJ*yd>t5vOae>bFVs;X&eOXCr$$uGxnkbCJ6Aw*Tfje({G+tq4Wh|T!mm(YOp6mpIwBl(wUM^V8x=8o-C zd$55*Djh1isJ#e+bQ$027saZ%al6rW4BTg&*=yvDt^@Z@VkF9pbc${C`oqFU_x20Q znL3(l!O}Nm!&^@vUnQu3p{>NJE4xRk^;^=EN_S?2eM6c_>Rc(ug=HSDro-DXpyPH69q{Z&Cvhh6Vg$9uHSJBVz3I|^CgkP6` z84|v9zBhTYlm-SNLe(M0j>W_XX^yTpbBE!Xiu#`#i?Ct3zW+iWGM*K^96}qDg;9X> zbwJ^Za!bG%e88!#|{N@ z%)~==c*HLte^^B|Y=T6jj+$ZDt4Ac-nZ>nk;$UABXuLCX$k*$`te!zbg)U z6NGr-=YsFfxOe5nV@5U#`=%25%yskVwf&KBVj_EYp-s(eRod*`DZ9(G-$x38pC_wb zv*;zOh&S5Y*HGvY#Sn|IZcCL+ze4 zYnTv0`FjV`h_Vlc80ZRf;4{J!8VIQxWVj2ljA^P-$<1Z`nG0EV?NT|~J51}RpW=*9 zxor=bP?Cq=7j!q7Gyb^QqBzc~XMMEngVe>)Nn_yDerdg@@m-ETcVa%p1>h&B?49h= zrQYK!x>$?X)jC*$T-?y$MQ8E>3NAd!H&!v$l9Juu__7$`)da zQYz{APuh9d0=A9vwOKKWH38#H^h)1y^5IiT&(;B&Y3yu3sYBP4Q5I^Ka6EW3<+O?l zS6)atB+7c50nDq4>*>afR#846cWBc)rNXy`B+JSUNmB>F+N-4reEz8M=@Xb{R7~(p zDH|jQ*86Avvu17ryk>6uNLb*^6X7apy*Q=0W}HrAZAE%IJp#4g)NapPMsJ+*`45ny zJi{9NdUHJn?OaE1Kji!>c!ELtYrF<$>TWw?N&knXe{nKuN=v~Al2CgFeC?%g%?Y7A z`sfY9oOzTDxI4!w6Zl4f*a(3R*LYYh_n~R!&P_UxR6*Z29|MCwt28CEHeDEJ&pfG0 z^)#Z@gCD0hQDUAi_h&j%8ZTNp_P}(|U-@1VnCFsjR_tM!kBbwvO^3iO0_5UqN|WpY zbJy}ed`O8a`!*GwCASv;LtjKVhq$yT41x$WT633CI7NBNwXD z0AA7C(s@QQmK`4WDc2>vyI0y9nlc!pZ*Ct2goRFtEQ^oM(11;`dP*|XVD%nYFAaVw z@@#!bQ8}ouijTMFF#i`ig1)nZeZrS(`p`8(|2jRXDL#(p(;MrL1|C!N)&VyB`|R|? z13b))7`aXF(-;y1$+kH$+RZSYK3v+pvBI+Z$ea#;?xR^M#`_m;)>QvJ)2vi`Ho*gY#EVEqx{%OZ(JWX=whp$#*y3x?;FcYP|I*T8$nk{Q{q&EYay-%)Oc?OCH9AqKwj_>av5q&+fgPv3O@wjXTwaqfdEAw5HyW3ro1bO~l zg#AqHglg>}XI7XJ5h>xg4QmGwGpIW?LHd12unuOxY1^uyau$=iixLJJ6y z+lNfTo$<^SB5TQ=zZ#ThPB88bvG=H(^HRrBdYoiQi?UsC$R@OC8H^A>5N9P0hJx?e z`ikmu=VE52_99Q`_KNZnmfUararb@)XO6E?;k8;$e_cK`hyk**bK*wdq>VY&?R{C( z!|&rfqBaGEPam4BX2tRY!mZRFM`%P+-uq>$;x$_t7A|?}N7hTx8cNuFa|}ImLe9A) zOf@7;&{{V|yPMqgrHyqQg55`$HX#<8Sb2#xztQoQsZikf*Hukq-t*Rkx@6X4%1n%= zO`}BofDQ>?d&ZjNv^$C=75b98!{$9^^{25C;wzTYKRK65n zSo|ShHEeKm_$AXIXta{^EXBx|qcpl2xf#9X9!LCu<0vr%`(?_^s=cmM<*Fa3C@sI2 z{&|iZoZULTB~Z0+1Zt9Yy-szGhj@l+`QGj*6q=0PI%}Nxj9KIl5o}*lq912Uo3>)9 zy(cyYe>`O!w#G6R^l+o2Ftq%m1@JU#F#?O5&{u|r{{8m4QahY?f z?b+l6VE~+KU*J4q&Tn=wLJSPO5pmUrGH?a ztfgnm68V7l^$+7B=1b?pf8$;`Fcy9-q{7A?y5c58P>5GfMT+d?n67i-tbz)-FF2GX zfQlbAo4sG&#~^0ufx^B1jkZ&7F~ne~_dGBxXZ*dcz%`ofr|Dl!CO@5pgUYKn5d%u; z0fd$vLu12aPYRmnYWo1+(C}`Z!ZumXSyvt_kxz*+~?j~ z_oVhOYwOFAyi1=O-U*~#?Gzt(3|ifr?w_1MwqiGEOIf?cTC^xfyiw=r@XFXC&Z(wI zkPvu(SA1;o-b0JnCSoQB6X+i+o%fr(%JuIP5wm~R4PAiOqV}AJgrr`nt3D*Q(H)2q z^zzp8%)8;=X#A4V?BLQe`u`flhwqWaUqyG4YKWn9`0joQz;Z02DyJIPxF}J1ky7HG zJ_U}RAsR!_NuQMatY1={gQ_4u!J;E@9uD)=@87(M7H(L&cZi!qa{gFb3hX3ly3-Ieql^*cD7zX$HPt}%nHwAwL5?8q!Ao8+7X zXeH=02w{_uhQTuVCuRYjVV(>=QBipqK63_RR5X|$FkaA%oXe`X8x;|1tlst1pBzb0 z`@KODH5Z_23lB)DHVIkRRr0~2hy)ui&bhI4IcZz};MkZGv?O%2bGJ@#xu7|ph-upe z;1L+}tc7*C$zo6B9ZY3fTvx@(5;x)uFb>3~wdE2m56wLLZnq+kMqR3Fcf29Aw>nwej>vNs4XV*uc@+HHj4Ex!T#xIhU2t2W#@w%eRjpq0f6FrA|%+zSBZ_ETnf9c7sAkw4O6ghX(d> z+JNfFJ}^r79IimW_)f>m#)l6J!W51462Tr3@(E$G-h0vA{|ZBl7HLFS(-t?HJF zpUP7JA6lyOTs|5mT_OP;JCK!xeD2K{Y%nwvb@${YgLE)VU$m8SU)9i0E@}K{J%8LAO9S{*ea41}l@M8-y362(;Ioi{m0fs6R9%8+%9+<9hDZ^}ea(MrBVgixwYleCq;_9U zPJBX`cjn*v=@+L3s&M(ePhsbd{X*S`fNsmSt~UBtN+k{3b9+p+B;jQoi!p_se~gmK zn0KRqag$tZ23K2Hl*gEAtzP#RR$j)KM582E+QP244MwLET9h2-MR8Mwq%ZS5GMBXX zH&H=rp)l4@r~ci&E;yvTxH&gT#5P2DUf~5-|D6S-4<60faK5EtPq7%&^~RoT@t`_W zy1bVrDRyn@R(5rH3LP6d8?-CuPPFwkLhY87mYq1=HJ$M-7xA4YE9%pm-Z|6X=V=VY zqkDs&-d99;j8>aIprA7bA4|*<+!2c^RTi`QCL+sS9n`CwMdFf9>a4m>V`kMwSX?a_ z7nHpW{0NEGLwahGsh{@RnVWysV~SD;h!kwTsKR=&3(W-{tApT_X@#S?NfOJ6=hg8u0MvR3*|nXY*f_gK9c7z?0#9Y2>69#^wW`2$=XH@2JT$ z=C@_6OofenMz60FTNrxd92Qc$&p5Pt&BA|57F>2b8H*Si@9Jwenu20vpx|_W0Z7kU#Ch*Y}QKk6)@@ zAwBmd+^$U4Rm=Q=r_!d&5TPpTB+iE7p5D9PXOc*MIIoeaUOgrJxhY%-UrMtp1>W!J zVfzy}t!5Ds>aaO6;Hj;OOj$LG;mK_@8s|==K`S=we973uEP6{|zM{7}SsZ2kh}?I& zfy#7FO^YC%ADuRw-yFyHhJ_4@b@o^7#_(!i&OHe-rjCoz8iX`4aeIsSh_(EWCy{Y$ z;@4fXd9g~GJ@)9H!#{&EV-%q%&65MO+_FU)%mRqJsn_Z* zOOJjOu4Ne(6H>NwLPT5E5pfYyTyB)u0s&j<->^$^I$g)zF_W30Ue+roYh$TM_gCJq z>?Hh+RHHR2G_+^Q9>6GQncXF5M}9o0XQ0DXL@RlM0DIV!LS0Kx^v8F-Z>s0d1Zs}u zZfq8d>R?Rzzd#2;0N_tGs3UCN(VE9z^exMTcVTI5UA4hmk3A9pF87>|iH$;KVr*^S zv*20-k|~XpC__MS{WivfA(rJERQW#PM!+^&XC!d*2y|vy-m~=~*wjF)(%2kEp2O9FX^YamCqptR^*p%T z5_T;kmX5c|Bis( zwDeur<>LeM1s_-O#C|vFOi>z`mi3nfi>1?*NBxv*it)Rn+3U|qavrYQr8Cw9om>2+ z4=ey(ZOCV+GtQb(ug$X>SDtmB8lhg=-qVA#ZcTo{9LGk+XnVM)>_Tpv1+#Os+OV}9S?MeLW}na7aq7_IRxpBLu&2x z^lN{P#Y5!gc9$t{25^m@bEt$kWE$suQtUd*=d5>RlHCS9z2jd?MlWpVbiJt`z0^$O-WZT=a+jCvc)a!92#8!l_B+FExPX}Kxk`KBI z?y+FhSb*_bbi{4Sw-|Z=P=t;zsZ2VehE9F=i$k#=^YoMEfINOd#n#0|6zy9db zwoZBMs$UGV?eejK2#Z2{wKg(5pn8E3?o%a-kKh($?TP9+r)j;RD}FS?_0cS~^N;&) zCMmay2lkIryBIGgKyaPSNMGw7dLdR4S@t&k(XvH!l<+=Ty({^8WJvY zOkp_rI_2o|pnM`v`F)hHQxAsj8*o0`M|%Xaxjg)dNuK@i4LM{@cYsOcx@a@5tlIeM zuHUi(8cA&E;3_YgvDGd5c%-wyC0a3T#$C-m($6>3PRL-1Hu&(dbf*d8>W`W9WO6tA zStq@X*1Sk?epp*2>^KTdYCT8(py%?-FV|B-nx7NiIYS6>qV7(Xwp3AUvs7v^a{*-o znh*|o;{)Wg^E*IcHe!{{%mlBkDL`@k*{9_4CsMKLYr{;L_)W+=K&$_CSUxS}wjRf_ z@k>*y_}}il1ncCKEyh}~isg`bz$LdM&}z0}*-qgP>jKY$BSh${a4cy#_ZsM3p!v4h zF{z3Bd-ACyNYbN@mb-~UaT%fC`M~c=Wi@h+OU@DDy?{!z7fF9O+$r?K#QaxkJgCjm zvXsjc?7>OFKb1=1cf3aiE`HXH|Fu}xNRr9sn`3(&4?n0A%aDkBoVf2)y3-|oG`l#t z8BY3wPk{eVrB?kh!MOLMxvp#YNSod3p|2<4($7}Trk)%s zL#T#G$RdJ2P*)kQmn2NfI4qrHqc>b0`Q9lsVU2!NC7|pZK+eX~`x0UAb5b4Ik}&Y~ zQNSnE>Hkz}v>A-dlN7&u&Ii3Je_rEG#QRuvWeoGavfTLyMfkHB5$Smfp<|0)j0$7u zs=NlkrIgk@G#RUnIsUcdUr_(J$wnZ8@f16^1id`Ip;##AKT}?P_1y(r%|UAq$8%b| z1%J2;Wb-|9#C&mti{Y_k5=Q|GK7GIJZ1>L?^^GM?%EX7#g?vr+@(;C`6o)ymutNiH z&KM>Yzj=3SD`W$AS(;$i{=UrlON66JD6z?xbF8&g$?{3k=(|gYN9+gR$36rz?Y=pKpR+R391E0BFf7j238yI}FT!R4P*9)O8&7)Dmyxk{5C zX7z~%j^V(#tCetm!hRI$=RkeRnmqz}$%7(k8{RFUv`T$J$0f6XvFo}xfs@UHbPg*a zOvy%qCz0K#^U?!bXH!X_XPXkR*A_nDzOj@mCx76 zBNm>MzPVHiP?hc%-oV+Jq)7D`%;Ikvg4OD{MwMuo9+wa970`M-Dx;gx!T?b8IpZ`cWfz>I9|bM(H=zz?cz%}yVFU+Lru;(1r)EkL zp}h+cUs&o*@l`^L;u4!AZu$HY@W^K=*D`bDPh0HfrI*6D#|-$;S!j7@tV7!GV^ zBz|*L*T{r<1&4*#51yeu{kfRyi6#1~yUez;1Vxfwmos@V_fe-|ps8?0JVfnQwZYzx zyON}TiqQy~P6)XhvJrxKDyqtYlWbKR+)4D#GAvPF3KvETM$-e-lUYdsU!5jvV_maN zIWNi1$h|9b?*(a^(V_mk9?$I2ar>LpHBkF{3yX}09U_N_Vc~u9<$S+I%{wObc!m8Z zz+9m)Yob{RJL6}kK{(j>u13JwwmI|S0o7TLoy#R0nLw?eqk(HNB%jqroji&%NEH@m zO_ofRddsKRM5mRZ1LdQx=BcDBTZb7FCix|?mf0^+!Ke*P zEfZ^1#c#LdLCb2k@KB^}%|SDdF1QCW}5W@1NjM#J}pX zC}pHpp`D%oyf;?0GUG?efj7ktzjZ3><&|?AnGSbdo@jgD^wv^B!r5_YH)%&)CM3!2 zCEi&{AXN-fXS1Sa?^ET@XoCp4Q=H=fAwbv1P;oS;?Ley?r72mIB`cTwi z$+g81-;h(QslI;+7!uu)TQxQtpgZbLguBz(kMZuN=B=f2)N|ry`vL%IIL|eUd@I6a zr^8lcc+OSVAQ%e@TIqtbkBg~jx_h9wZh-J%`3Y?b+0EF7QhE0L9%4usr;9yL!tu&D z92w-jXtF}_sAn>{7v{yK*G`2a;+f@vb0Ee$fI?>ss8+#M3hB{sFfA9hc=kf23J<6>E^dWCXE}9NhGSw@o@6p_j714~& zH5=f!q<>`#6OGC|Sc&)JnxZsR4k+KZs?VGscvfw(NUy(91+n3pb&z0^P?4wbNf6y1<#fqL z@%AQU2l=kgYF7yp{&<-_P2nz4H>WIM8He87l!6NH3*V=O?ES!6H7h-$p{_v1tLM#~ zbCmh@2*}gMj8oT~lK6T1H|PIicVd^Xs@><+_)S_L%)^>MWpkxkS^uq@ekn+&l3IcD z%f>&X=I!v-_)3(6^XfelwS`y~!6y8V)=eXegWVBXS3PzwPnRC~ONzx&LEhb5SGw1{ zV}`j24I-Gu3=3YJu;&zsNM7#@w8=YukwP?rg|SujXY5C+~7h-oAi4 z;VzJHY`q>_g@gMTfCd0o|JN;6A3mdQK0UN)72;3VHUG^IQ$21ksoM{I7vZM6<^Ym1 z9m;B!?FaTG*h!6XPUQ_qPe^&cKiK`+rJdix)iTd9S*V<~Txr5_+a<_W&vr1!XkLdXAd%Dtu55h&tWv?fjB-PxQGXv$@k zQ2ObDk%wS%>ZGPJ)UN#)xv>2{)gQL?lFqnAp#qL-PJq!-18I}MdC$?U!$@udKXG=& zP?KX|ULa?qtzMU`s~>@s9?9J#_$4`Y9z?8V>b2v1*Vnngb)MMF(OI5>#qs?+y~VLlyhEEs z^0B^VE1H53);+JC+^h(_8P)_7`i@h2?k#Ur$W9ruefFC7(xXJjS(i5vH}c(GB;Ysg zk{_&`irzgGYi-4bgl_((6QZ}9({NmYvw@p=MGacYdhhkJ;Ntqp@R6~mHy#tx(6q>T zVg#|TNS>bbGTn7&p@DbgF_i!BD5s;a>2LInJYl`#k3N03(CAPs;jN+g=m?Pj!Ks+@fl**UB>@IHHRDi`Ib218%L}YFfMEcn|FSa1DQH<>g#OHTL?I z;nCm;8R6a5k1ZAY_gH@bTB;-S%8K^deCM;;fJdCuk!y64w*!+xLl<&JSUoAv6}d^A&~>?FK43$dLE?Dz)w!q)%BSf}0ZUCD?YO&;u9 zgU(^Y3Ah%Cqy&$(qYnlP{#;GyiCCC(KjodT?A9CY&QRkm^~16ePK$B)bw~^jJ~0pv zHv?dLN{UdjWQACDciGxkRr+)1QQs1_lh1&+waW;Da@-|MmRl;@mB;Qv{O9CYdbO?X z&~Vl zIJ5tap6Vc0sl~IJ_F@1E$isd&hkyp>n5+)QT5zERG0{uh!DZm-+r4xFIw~hHjouaT zv7mS_0qXK|t`+Rsgq7hd*~v6;78gtsmS548+Pn;&IT*{EL93wP5ZQE|8laJQs?pl5 zc5HI@VZ8~U4!9)_-tqS|%0kPyk&&SV?b(y8XSpmL&450_-OYT^jfE&}u1ueD`trBW z8enpYCwEIqOT-V;yq6_uE(1ujCHrdL)<5vm9Eed%c;WNJASyIDFg~UG+JjX_0NZ zHcPga8BW^!3F!|~4`Vl%GK-KntKmm}h}+(rM-CP5I6dNINOpIOJPBN1w^?ubjXzdo zp*ec9aAQdC?#Myktx0LBreN5Ajt$oBI6Em0{=N|?p9!zx8Gz(R9hPvMpc#Yix}JS0 zub$yyH-bUrpDOlJyznn9`psgk&KMKS1VlxiYz+4jpZ-s{P>nesL74C^)!T^pt`6n^ zBVA>yG(Ik~%Nt4yVoaDDFY77v`#EPWCt3IGP+Aj}ds4y?W}U_6$XimiiLVZ61tBE9_DBmCh-f8?}nc}H1pFR*COZs7N zxm)$Q%~YLWs|un9e{|rvs(XWI85aHXS7Vcf|BA;99sD%-{isFF58vK{Uy`Tz#YCIJIL0TBN+dquY=`AXd~D}csEkjT+=$@1s4lhWOL|y=zhaOC1||5PO9+2 zvdam2;#~dl;zp?4Y&8&xdTemS=~rOtmCUu&G|5W__Qfbm{48u*!-9)UHzxwS#d=zZ z66FnsMM|>xm*3lYYmFKyO#kH^2tWD)uG4t)U8E~n0ifCe5ZqYT|9Lk{7tmTJd=wM= zGzFazjR|ps8N$EqE@}^btL(4}+);A>M0^JB&ztXGrwL9{*W#9I($GamO7peGFBN8y zVce{zn(#FFoi$wcN~J@E^tdZFg&bUn>p&;XS+Fd%*Xw-V!o4bkR=Rze@^uLJkS>ao zb?)mm!YWE+_0wmSDs|`3Sjmt14Yg-gS^kTAzDT&Gf>n21bjk(|K#Y`P*xi9y$;DIld;yj5=z`+@tB!igdJuE{DYV0hQJ-=Im2cj#ng zkx$yiytP{1xk*`ydNfZb`k+ z#|Ns;yYmGFEpxGJ`tJ3PgiB1He9!m$X3;N(d{%Uq)3Sy23grRH-;-8tdA}B{Hwr)w z!XZahQ@yKh?xvJVNT?jcj8tM-lNq{U_{x&K=t+RQe>av%E zNO7Kz;PtEeeJ0e?A>F@m-;OBEsxReo)=+UCg*ZxB39ZUeNvhSI7VgvPv;2p;c?i4~ z^{+m5^*!&hDN4;5SIEJEaYws8%wdM1cm%5E7tG0<-l%F7KidsDl+`nVfX`?rJfoT^V&ouHPc zt<#CRTc_Nq@{2-p#u|<{3D%(0*YN2^ynCYwp6$2PLfk3pC%o zHQ(eLD=0fF$^;_S!e(iuwuUgf)VME#BR|}rh)Jx_cA3f7_>7v7W};R9+z9P1teuiM&`&WjHUKt;88Kh#J}!H@CP z#^1S>+Avi5^px@MHx)_IYmOQ0p)qwOLymzWDl(^)`CGnVtbo;FES0f8HSF_=pK&&$ z{5L@SvJHg|ifPH1!dUA#M4!f+F_Lkz)lgB@i!LEcn61J*1=IkYZjVJ54IWBdNT9t4 zd;PYGWX6oD18gR!{I+t5y(R#AnFOg&_X zNoWOr&=G2rJ%rzeWbj)*y_YhdSj=k%5wjD<65kkg)!S;RF5HpDRNR-|V#m3z>{0fI zqh@I2p-NRMr_5L@(6ktNR{Q@viKttLJo7rq=z*R8WaolOorJy^n#LyMU4GkcHlRL7 z$n@Wfc?7C$kT0%TgR%yb1%=FHT(+aU=oI(u=)Pwdw*7?C?9#uSRBi?I=g9mcJ|J%n zDuSMJb?yYUXzM~`@T&)%3UGtiO9Jn`{u4gt*4>zc*CYK-io<|vb&)vtmeN4VqxyqR zBe?{{mDZs&VOYo&vCj-7tov8TS<`6R2{QpidEN?JjhARaWU1?J(4&WPWmw5|(JMDs zcV}ukmKp=$>@%`56#vx`;JMzCUftGjo9}kHS1Z>#>iHt757x4%PI#AEn|YhoQSPPAaOB zdE6lO+Y9#XaTN{XH$g)5h7cZad@A4OwXZ|ulq41K;zuQb<7>6v9AkNW{UYtiXH(9g zWS+YK4spW>)gk+8GCisMW~p_invHzu`Quum|9e%?W+nse+hSS%Vb9}{4s-ew^O?sy zkk=hX2!U;RRgB9afZ?cIGGrTn>y?Gfy2B7O^NKCunAnRAy-J&C2@Vcjqr zTh;6?TPckX)@{cA{Y?2Hfx;Hr%7mIwKmav#S z__A7GI)CvUhdJ|wG<2RFl5JB3Z;55T?X4j);hAt()X%Dr9}F1Jlx(xq;_V73Nf(38 zZA8CINoKB)euG14`fI!!4AR!-Ky>XL+(g4)Lc_6`)e< z@IYQ09?<+d11|%ns`yK0YMWm}Tg;W@h&VoG|7=Pjgwc@%ac9r|P^if&{#fpn7Q{2i zub5*y2JPZG5Hf|Ovsbzun!WQ%Bue!!RVZK4g_I_5zI%cTNW#j}4w}||pE}NQOYw4X z_y>PNjK4PO2M^FqGR_??XM5u9)TYa!>jKw~IQH4Cu^abU0V?~bzqtEx85tqD+~OWw zTnXf5c@yQ$Q)lDJv%I{{r+!9X=Xo;xA7biOUBo#YzGElwPF!i6hOp4r0z(O8muzV? zOmgS7;0gKPGdQaJGBfYi$7VIiT>RL^cld0K;C)SxDX1yqsT;I_y6=DK@athdx!H^C zn?%Dl!PX6ZnYePcP$;$Dm6Ui{$i;LI#89W?BDI>+s9wC4b^?`fHVNbS&d@$9e!nwZ zJHK9+rs@Ne5$r@K7h+QnMk9Dd2Hd6J4}e?JMwvXQzssKfz9}H+i?T3UA~?k*f6LC| zbkjY(uUl=Q$SwdiXtWU>;6CE`T(6?h)!SF!l&Zdp+CsX>mBQr78&#OKQMH7SS1R2` z3@GV%N;@B*bVo&p^xSSS9j@>-RgYy=mvuEEx06C%3+s z9z~J+bS~tR*Wivx5^3gKbS>o|WjukA^=S4%#cVg*BLon{_;c1JHC^2s+F@qxVK?+v zPRZO~rV;XG!P5G!pYJ_daF+Mec^ZU~BqZjphB*8@4)8~1j2E4#<2OUCdXdl^B58w z8XBD97yCUp71cil>YnfciOUoGZco6c&t#w-U}g2`_vNq)Wa(=pB;0=R53`2b`}_Y48ul1=SoHMT z^q33`_DRbfZX5Ji72DdhuP+A*$;r`MSX!If7~24(Y|Je842*2Dj*JG(ESJspmotxy zY%GNj+uF{DK|SV)2q}ZbAqjM$p$8h8b=aWV);phO2@?`%2TTC87>^e;05`7@bP-Mw z&sfXk!pg!<3}j(;9wn)DC}U%)WhVDow*pvh8QEALo($_}QgAS{+p)9NGL=sffwmff z6VpLcC-n982wZTe(8(lypw1BxI<%08xV9;@@LD6N7M8`)`T7cH&DdUK#gOebkI9~q^a9ubyZ{i0sO)Ma^r&jlKHQrd ztD8g=0iT=K1n4xd(>+~SuoLD|juxY*9zB@2xM*mQ`avs``$e?-cq1+4L6xzSJGQss zzO?IdHFCFV@sGwQ_h(0|AjNi&ev5ulYU;UsOKfC)P*6DpVSPqo3j`M~2PVXgzyVft zWo95~3pX;|lY|1=q0uMm9$5Qul=}$nVfzMY0|)|T85NRNNhBGCmn)ORU3Ou8 zFGpaR%Jd^8o{^#^Gp5syiz%igi=hirvz9W~M|&vMWK=Fm+;dn;FP$6q8Cq)*u&3K< zS}NW1O55@ps{RO}lzFW2=9q*WW-s8?P~s(~c;xS9sI~p@w_)vf9uRp3uav(MGP=sV z?}Lkgeytp#8l(CujCd0PMgr6C6V;^X*UuPfRspB(IZ?CjhC<(t% z*nLrajb$6dfz#gB*HvL_xMGDdQGD?awq z`cwBGL%i80%)|b1tXhzb9YcUfCtmdRBlEXp+#b%RAabg}t(kF0NC%xhiRj?*}eZM#iltFdjXv8^_?ZQFJlHCAKWd15Eemvh>)`@P-&V9i=H zb75xPwL$of#6@9JZKewRcaqKRm^oaDw%-6(L1N#Sh}QB(`D!W5tnc?8*Oe zn4S6w9xN7Pg%rx)&29k_5(Ec(9)FEGH=DNL63pkWF9+c> zJyFWU4`a}{1K<1bWDe3LpHEVt3fo=&WkuRJHqBrBMo~l^rx;AS^${zUInP?Q5M82F zu?N-|or(4p5!CpAI9r$x!WTXbclQv+VIXIyCQ8&T&V`y5^^>o=Z{`+vXObm>oAX9c zuN!g{xQQH485jio;1Uf?Y8KQEUQtzsoB#&5=b!5;W3%%M$g3-Bhd>q%Hy;5zpMmMX zzGuQ@LW8Mp;S~0kxFD*j<$LdrI92hHTIO&x=s1A)zV%$>lz!oq7)psKRL zIFPd7dE(0FX4dh_r-2XEkWw~1j(aX8{p!7s08#b$JAcIUIMDMd+|Z3cGD@HLpVkSj z*Sgr`s}i446$82^gxVfFJV1B`kiJ1tjO@&aFCzy}MZD{CN9&QD$L2{@FMZKQAlL@m z@dw%4@$;WR7phItT3jF?`i8$OGO9_PHWuRGXzv_GhR^in1VSdVNicO0I1A#Zg=w{# zcS8--UI)4#6+rO-RPLb_8E1h#KjA!EKPXfBx)QZ_gfJty8Qf7opguF{{HTc#i|@+9 zMdb5SHw+>|zLte}99wv$&ue3Q;&L;X%Ez8)y#Tl4#j**7D}{yECJJb2$BdlreWxTZ z1K$LEF$PHitonL79trF79fBJM>R+s(#Z9_#EU2rJ8r*+5RF(8HmD>#M);zOGz8@y zUPl>b3!b1D?AstX_~3y)TAYy|DaZ(@R7`mQ$M6s+H2dpFQ|rd1nvH>}5Ev;4;^g~D zd_+fPz4Fg)ubNhq6F@Zd1T^K(SA6K?#s;rNrIo{;uciv2?a%hf0l0}E4q%r)rf9ra zP|?$b8^{ckxWTINY~#2f)s&Xs7?8uf)wl=X-eF*InCRsvgAT}L2R)%OfuMOZ@xRu6 z`#Iz!D!b~t_R%>P^pIP90qRrrGWIdNQQW7lWW(p{>Pmh>BI}pqPYNAAHuLx+QZT3) zZ>M4KQt#Y^WJGDP70@q#{l@U=$x> z#z2-E^fbjlGZg&bUSEV)R2BZ|I*m!-DBjzosYC^9bw@jX~6%p3#?uoVIqKS#jUjs5Lb7!(b^ zPQTp5hXoztI}%~IW6ySbelbEqphu2pGzeaSU^MadP0oCM5?_J|ME6q#mLAi^07NA0 zr%s0z?OcKGYQG+6z35~jv?bXumV`BOY2tO?96v50e1jSgAOn4uB45IGR5dfR{`J!| z+oE2L{3E~t1UK5-8q1~x*}j69>9Pw|#iS;HJvL&NRlo^2wHu3H7IgGIN*nyLK58>S zZ65}Ha);O&I@}YH&hgqE1TlO>8jlJHul+Ut6Zy{&a%8c4Upc&0YH|S&dgDQ#LapIR z7=3GtAnBCvD2EB$YlKmEfuag=4GyHnqDGrk zfkO(yNiTJZ!!lGj6{@yU$hMWtC3Pe){Pu`iXgDzgoX=UIiW?F!8ebI%x0lSziL})X zf{5-tFf`HFChNoFS)`WaLT;)7dE!5DEd0|uDMewPGzVfhZf z6nF_r^M!u`jT`PdRlnTG6!Hh1rXGIG%PGy9rexgY)Mw_F!SS`+DvMSTq__X3o_kBt zqWN9!gZ7_^ANaV_B$S(J2XMbpMkLy9fnD>#xA8emYMbVoW;cxs5}F%l-(^wd6yG9E zfR0>JmIn$#lxCBt(n;cbO+L|C@$!RbbfwN zCbmj2#N;UAZpJ8B0K6Nr%EKn66;~$WL!0c}G^_n<`?;zWFftNJJ>2R#?A}ODn zte7-_%1YRpKxIWG`56+&<)Q$#mz>pE%wRR&JQcwObaVn*kbkh<8|kazf$57#;R~{eiJ}7T zq8Mn0&h#(C2c8-&Q_^afxL4(3m8Ti>lm#jU_tz z0uu6BH4oTnA+iFc^MPm%ma9IjEFKPOcRy+n1QlAoOOG(TuDBk&A$PD22O9LpiB!dWWuXO|z}9c=|H(!X`+?~t3*BPigAc_OHUx+iE# zq`EgOVcRBQ;q_tVDurJl@)OCqs)C_FXA_HV z%7<=W0dd;@y*8N1a5oY7sC!9jF0Wth`KMML5C}%(?(T3VG1%jX+iPfViu=tZ$q=w= zQX0wVK{vylfq8MQDf{?QTy_zqR1^8MFJeH`+3j;)YbqO#OL}yUQukMDfBPztIGKGS zUN6B`d}4u9+Ta2u7C-0}*9e~nF>U(Y!uN?gEvpM#p&4K4!teQ@)%BpHwAR>kOzGcl zD52c)-e4CwqTfykCzRz5bS)=wB8TE~mM$U5O~*b!za8FW{qy}HXq^{ZX`)Dtg-LGhuLQgd|uUceFetdz58fL4cwr`NU zIPoH8=V`^3T$zOi6R5LZeFgPVxV9^w)fcYU6;5QqNAZ(HtHit8&!qC1nF@IxZ~Zd= zRYQzak3x->?=-Pr{cg+!~$?g}dkGzmo5!rFreIJ}<{6|!a3_XwK;@kn+G&T2#w_>dCuE#QT9 zzi^aT^~Eu=C+8Wu7G9un*(D0ht;%H7Lr~MdG$H@Em^(01H4K%DEWA}PsHbT?lCs^( zqUfduPSXhCg2!`drT$zsedczrk%4LQq5gh|kS5$eI4NR|5pdVrifr>F9tFu8GqfWT z$H)J95ja21y4#32t6g0q%KRxOzc4-+TRCi+77NGi}6?>4Urv|hnnTrdJltQ1t>Us=DsPxueXUvCEmlU1gCt7AM05tD!)>A zOL1~d9cp)Lwj@L@SPiN3;AK!9?4Ee{%rv;y*c{seFO=I$n6mBKAWFz+~xv=Q)C(> z3Cj&S6hgmz9o6Y_1P`)!+b|o!&w5%XrR5UUWjrGBTL9L5lerJ}F8|>`etlH@g}ghX z99zkIgGT8}T^)kLpo>1v7!@jzjBiZi_W4q*?}8|s^|haWD*0Z&hejS9fsvGYf6h=?m%wFV^$2((X%)Al*75?Kuu9Q>q%6er`R3_W~n!hMvGeu~t+7sR+F&!X05k zxPh~QJ^N`A$ri;HNvxyR4=&v!^fM?P!x};-mX6IY~%uDX44NQXykT#6clO@K9nvX zf_*2Uo*v+z-A1&*_rkX+WO^_1T^he0Dv7o5kbEwIV~0D1M@7fwQjTv9IN%Pqufrr2 zl~NLC>=_!`)Y1wDkbn&8X^R1{L5mc=pg`X9njl^b$$qTt@bq;gC`Ob?4P>ehJ4t%J zr`|b<G!^&=#WgfX` zKH`O-yeOLe$SjK=G_qDTg5+`;c)OYQ_QB%Fe26Ts?ae|_#Vq7gVFmICFL1o>(=S(9 zz7{?vr;Zyg2g6BInVM#Mf5kJej}yxC{G^;Pt5QN+uA)RjG3-k;&Oav@ucfFCD#rK> zX6K3|?PCMR6lieLL0+;0eM0Iz6aM@)40>)x5pfqxDZ2y~d^|rLG!C2u8V~|WLDell z1*U9aZ(rS25JTCqPsMkExhm|3`k7SC`zQQX)2MJuh8=%6G9%CI`)%E}JdT(n(tzBR zJg{Tbpv#)7dU=(>!7lZn>>XWxLWoZrP+H*iWV`|oNNI4wy*rb@>wF-~W^#az-w3!l zncZ$gbK)hy=P^VHs}or9>Gnkz9Uanm`i>7RA$c2Ugto<$E$NAt%D&lcCOM0ztcxwx5qAVZ#5YGTlJ=zCS2FmT zQV$PLpNdbf+7@g8blPyZyB>KDGTGO@KYCrh&(L>sD9sh;Fp_dOuDKSC`*Ds$j!@dl zcgVim6JcvP^qrHjKh4w#vbB@3+B9CCn4qT z?jgl?^d%TOK8!C@3G-ssYim0yA1Thjj~3qH>gtM^XlM7rSI0h+%0{)BcCx$+6)%j` zM##g!%k%3HAMJP5vL5uEnziv{qUN-$qL9Vis74NnCmDEl$Jua0eW?(JtV0_fjp9ar6?W{er5cvz2@3 z?aOu=Vf~h=^?tszb)n|x+0FVcM?ThD~SE$67=2nGYr$T8^jquTISoe z7!byZ%#RYzM6iR4UxGx%c#dPcyf<#yhD3kTR+$ z8sIXmK=>t}GI=S$hq^fS&E^;w1yU49?cNZUD!)6&i{ZZ5`^+_?PD{KW12lVhcPfNA znJpF)ZrnV7x_E)>Cdgov}NHcJe4;-TFm{J3jH+0 zJE0#S&{DsQh2QH{p0UU4v)fGQK}@b4R4`5G>6H@=hh7cHt&2MkpwN>Pe6;njaZ7#s zkD8-TROH-@ZL26xuG<$TZ;N+PVuQ@UT|mO9Pkcfqy6cEYW|F8X>pebMpAJ-Wkr{qsmbPEGFMNP%VB;t&6G)_pDK0R9T z$S21pLR?IUMkhgi7*91=LSx=#Qo2l5a8`WX=qvWfGmV*J9D*A*O!F-(DCRF;LQf9r z-I?npBlc{7UXdHF-RTmH_4!5$oPtb0c%0J7fZ_F(Y5BZ;JinuDm=V_6``!Zb6b-n4ndhfFj6e!~>SlUoY`Q?|1eOXuYwGd87L^x|{wFn+I$FsVg_PHZ}L_aMqh8>G$wDPlmp?j!y8>_FWHFdTt#Zar#wzvdbH5w*I6Wh8o@f zY=BDyTTs4$l7`k9QtK7n{I0YiWT}$ z%P`PKHCcXH@e}~d4EqJ@2}l!QwiZ2n8Tj3!_?XWwhSH)vkOuXaE9cV3MPdCJ zo9K_~mX8~^1q-w57R#2e(kEBmuaT9}Lr-fjnxh%ac2oUP?2TQAF3FFHIj2nO0DCrT zG46>osZGJ;wcI@VIBC><>GHTS3e0&)-1*a3gw=-9cA^u%z^4qlwag+_Le!778a&T9 zmNXg8M^?=_KRNTx3Rf2$3cnAW;$V8wev_bo*DSpMqYWHN?^Z}3HLtMhRP`AXXQfxOYWuYSX#@tD5*7G{i~&oqD}0)?K&AYqGGMTS zMaf-O(fEG4n}|~{hibnu*nPVvUrZ96x?{a@?O{~=p={)-bxeiLl4tNO>%Mb zdF6g@)0R+chcFVtftGY~)ydO~fY<>#Z)Rf$bEvbCF?Wj*<07-6*osS{i|F8S{7M|F z<^Oru2Tc1Ir=`xFo}B!X@_21F)x@Mj9>!zKzFVMc_4Xj{plk^}$)AV4HPT*iAiYyw zKZX_^tvW&IXa8BZg%EKG&!qlT?;Nm1_uuzg?>3WVc%EOuW;eIdp3!m4qR(hT*ldrR zu$WVASdxEHMC&HzteX}1qMb{KK~ti)n`*7ey7!6FJs)u{f0w~o5MoK&f4`FC?YzCe zQXq*crhSBRt_HBS7GHDG&UKsSwiwVaL@cO#k5IQ3DAI)=<|b!V^dYU=GibOFJmguT znrD`3!Tc&SVb-#JeOyyXTW)j0MkLQ*`^q-^C31*DgH^shhroxD-lp~S%kFrc2M2e1 z5T7U#bZ6}SimNJXkw@GV?L0O$btPb_5@!{bb~$eMzJUR&3A zV?u7(e7rE^?P)3UvMa~#0G^@zuva>>HYU4#UwIyhTNSj9-LXu0_ts`*|C0zI-wRjK zzPZTXOFwu`FZ}eB)Ogg>pF9;|C{0Le?J!O{t9CuZG4*+;zLUy0x1Kcn1$)(Q@44ax zy~FN4QhE)=tjyw((l#yMI0~ttN7!(*`y}W)#b+y77FNF}i7UpP>8dX-TNd?UJzu>v zD5H`(8C6pYeguW8*MH=qakVg=0V4=pk}+iUdq7N(4%+BKb76%0aZo>ESxbA`xqE+6 zygu23=hbcu86-z`ON4KgV)=F}``p7CznceZ8Qt`hI;0kYVkD5j&}OWs;P`cJ*Bv;6#kIKlQY+z*c%JE#-AgH{Ww_ zV4Tv(r?tmjsdaN#HbXSv_zKYNNmg%vF_=%bwr;X$!wx;_O=W5D^H#|awYlok2iTSs zT@7Em=GT?&W6|A(s@CKUt~6VzmY+@jI_QyKVk=75eS zJHy4ygxZy{Q`DSweZTgrUoedocd18eWTOO8A&yR$2GE}Ys) z)z&WrsJ%I}4EC`bb@X(=8HNXLrl*|hkT+b3klyH=?xWhVzuwQd-EVYll$+5Obt259 zRnJ#+^Bj96&qW_UdoL>)0Qm6J8K(|r)TP+6m4IPbwi%Ob6M#3M$gSXgI(Kn1OwF7N z*vUGT(QIN?4Ehzqj{G&dl5M5d%y2kpm!Gm~RCWFY242klur2=my1|TQ!$@Q3kcvwd z(hp@Nuh(>gt%DZDd3hPGZSzJGztawj3HLR=b^AQiP0SDA3t+D+rH~SghKUnOHS(`5 zsKgatu;ESk&{#iw@hGiRgbH7OT23vMYflsJi_TpyUf)4h6seDvgo$wacqiGHN*V%d2g2KwB$+xQD9i z<{q1Uk~dVSE5ONPb|jK#=GHmjyY)n0(d)#P7aAVibW#xmOUTm#7P|7VH3rn_n2y4-J?#eXUO@SK>_IFl@`*C}Kimk?g%(yZz^gD~_m z_gFqyTG76pU=cA^E<4oT_I^IzCGppXa3mrGur3GEiw8UQ6cK>?g-{(G0FV!ND_$OF~)NpLHZ#RIZj+wV{6_ zD8IXXePJsI1HK*sR5gXE?(+-Y1ooyn=hAqv>E*c(LMFhIp~ z!Od}kQu=E_aP8SCU+W?|EM=JVh}4)q_(Cq+Y6Y7X$89TMgg<1$J^Pi%XwpOA-dfqH z_$4Pp$jgC1L#fWRW9dDHY1>_K+W624Z-q{;I({jh$M^r!P*L-u;f^JS2-Z_!d#~Fo z@Fi(1mDy6t#o=BH4q6rtI$KmGVZr`Hkg>8DwJS(Zx-{JGAIqq_xm>yZkxa<~)ntau zb1UKE(()I*al;yQ zaEM+NtqA~Ncyrlq`&Fv!jG*`M40LwJ9&iyNW->p!@BX+wO%THW;9*s9>XvG+Qgp%q zQAY@yhSk6`y8v-UD;+M#P#Jb-_0v#Vx=Ht=6(IO8Mb2$VWKD2$-;2$G_{?%2;u+V~Nk!8>V&z>uC<1;h4nw8x`;Z^GxGy=d9l z&k7z=zP=SxB0IuX_NAjBH)FW=2d};B!9suhlb<^3_Q(zR5u4gKIlE3k^7q2-CIP*( zSq`MGS^r{gWTXAV1^c&LqNjWC+@z^X-<`x^8ar~9JVNG=_XlmTvL%u)?0OJA{trLpStP)0M znr(Ys{p91+If9mbHI9p8CM^r-`TwcWTP2U!04-cJ`?(ww3{-oDF~ijJH5lCeVdi2w zZ}@fkcI-JB|5Wm9Si=~j%@4j1IxV$5cc5Z&Rm&3OvOdEg$k)OwIayJd|4^gcYV!@j zdE!MU*+pfDhtJch<*SO`P$Pgk&$*=`gE#m4)teaxss4uQx0%=kOezIy6$*G4twenMyG9NW03G$Og> zBJ#7Xux&iw1EU)mkC(_&>j51rM#BMA(Lz`Ks%lha1ZsB?PRrA;t^R16NA4b{o1+^^ z>inB9l{^6FZnab;DaC87mR^8EVU@+ql=GKyHu;Q$d8Re%Jp^+yNG3W z1D=jD?JuljLtM|fw?8Ud0Pp7iMuU-tnJ61_C6)vq>`dcK4ZJ%Uj(Ffx8ot$ZjWWJO zL{NbN);}87OQQ5GF-6!Bxuz`wyUZw)>zrgB!&U=DBM6-n*fH7rr+?wuo`!cYot~Gs zMkVFs`S4QW>o|q3REtj0@LLrChC_1awb__EJ0@#~+zlVJmnF_6SpX~#ML4-xd)oTz ztUf&7)p`sz`dO}M%cM*?I=(1-?N@(RH*6ZF2%)5?f(re+48rzH370tMo7%&*-EP44 zBV{h&G&xMvE*?2rSNNE1M2hlp{z;=k(kPd#rD>e~s-Cm?Ui0sHu2Pde3h#!ilxBI6|mSq)e;rKgo}Bz z_iTx~a3>ihg7iJJkcV&}&19m!!gqIe2~dm){x>2!6*r|+>Dagvem6$5eHuPtWcCsv zNJsu@(Ogx{nevVNhW(!`q5yhT_3sr_v*ZG@1SjHn+-SxG2JtTR3-4Q4{V>C-=1>|dp1$T#Qe}QG(Kc7vtsHb?J%EKd#if;94^mukpd`bTv+Wr z(e$C?#W9*xRCD(-Et#lrbou0COf_+GpyGDASDvo-)z7E6dN(rYsI1EGFE0YqXZ5kF z$Wk|T!P4bax3)#sL|4`p#K-08v{@S?CT((Jg882`l6as!5SXWw~V8zTkdf8x?@D%7O%8vaDIegE@@^Ie~L$`_Sjhf+YZ1tu%NH~%#b)<%{V4nL@d2}7s-9xWixvY zM-j26wU%ULm`DWjw>;Phx6exTVq4jtp>|&A??2wW%i9Gry=A;)sXBAN7W!!!{13C6 zF}Tr)i_8&;MM5CY4G>1J_kYwf;b!SXuV^ih-!r=DA^KIyiZ{%8OfEw>5vD=bR9V1k z`mLU`l`BR@|2~%_V)<%?|O9B?%?! zn zAIJO))57FPZ7z~zV6^8dIFHpeJ=NFtw4dphY&X;UsSovOT?~=__xuUc>+J;4dMdg# zU)G#ED?ZS@bQgYm79GV4jXIk)9dWJG7>xZDLA+VZzT!3~WS0O7jKVI)VMQ&*D z21FVjy&ewBB8KVmmYpelQnz$>9y;h(m4%`8e*O`sxDEyDv`skAf+@Nh=0S7Fs~|^H zHr6`*V{%a3x&fqW@_pB2&TDIIVzqCD+kRizF>40i^}jUQlj$O(cRcw7N$tphOUM0S zr*Ik}zIALN?HyIdS1%*3`lNr#AWkt^Md~T+Imf6DiIE*;ggp%c>Sm5^YcZEP<0HH7 z7;(mbzCna{oa%YlDdJg&MZA@ucW$VNQXe^#LF0EYjyIjmxu{96j6<-Oy9h>3I+Nar zb%LFV+tqS^Qza!!P=%d9`pj7x8fKABvz>t3&gMT>X^rp`9X}cg&ze~#B0_$c@m~Ri zPCRz0RSqz^?mD?`J4L!HML6e&rMa&kA>vM;I+O5T{;}=i?e{oCM7*o}N=J3&@y3@6 zKh8{QW?%7Jh}kE(SR@;B{UX1Mv+@}OcLYC#M#l-qIN0FaY#}Npo@|U z7+Laj#O=``wnNVOY_n-MFHmsYDXZ~c-=GjLD^w>K8i11Ar0=mJb3I+~6;ziwk`S$( zBi~M;W$^{(pAZU@SiRjV;Kq1##~x#1`eRj(JWio#jzi<{a-p2R3%eTr*Zj?`rap8u zbBpSZ>3{#c;I=n4=T~(lnaZHUkXrBJrO1Kc5Q>OH&)&LkY3w7np32r{Okm-8&2<+2 zB-42k{$=z^5$^``?SN0>EHiYQ1;w5U?PwzN^&DDHEFb-b-Bk3`H(sOxXFB=EJT15H zY*k?D@7@Zt>RrCMwM$FPz+AZXe%L2`4J}h$3^0H9@gGnwb**i^)*nqUxS@K8TIL;< z>Kx`U4C9}Uts0A9Ny$h_ZU4nl(!JN&X!2gDWT3vb5geLvQ8_mRMB9$H8yBLY*%wq-rM%~g?}@#9Lrdh55}-SkK)nN-`Z*+bcXczTJ&KNeNP z`@xu(VK1(~@Kw^H0jc{A&4N~$`nYD|Z63T;DE~F1Tx=ufE(R~MR+*F>=I}ruuAJE| z4I?isQIZF#DUvNjs#{sRrP$=$+m7#V1K!WLP0NSSIbFoq@aC9>m_~jjxIw~hjsnh2 zhy+dzHRJ0ai8Hh=E5)GJcG=Q3n3T585*zDZE*#n~gN|+EdGx69xdm4IS2Hc0G;DL6 z#*AvmZPV{veNN&+VH-M-_&Iw0hRDjwsq^EXupUAajmLdEZXGgiQS6RRplu z_g&`AWQNM3U-vJ#qhGL0eR4o&KwaH$*g9Q2h<{Na?}}*<{g+6~>5D0L!L7Sf*`Lj5 zR#^3JYT}{(;x=iy{+brs=5oQ-Y4VR%u~Y0&-s|MjSkvjQxzMJx+QPWR;O}#($e#Db z8KdjaQJDQ#DG%MuzvnK^5r_-h?dMsYh|Q{H4}Vunyhr>dGfjEk=Bmb|gU?@|bV*cI z*iHBp=Gk-_^Y<49Z>x3Lp4`a^)yAn8($(9Pc`9SCPO>%WZ^e5Gv;}015*?Osl0*Fe zHd`<$4<($d)>itL<)3%mDQP0t8U1RUMuvi$VJ=}SZbVi8FjLOg+Do^7>#8z4syH_0 zV?M3bt8L}Ao4u>)^)TASxisp@mc%zSs5NiU?`yAR%K{+5^CmrXPF#K+nh(dc$x>fS zqhn1#yddN5qT~MR`bCSkezo>^ER$s&iE=q1qt(cg{@DStk?o7=I-o*e0%@_mogCX% z@BBFkox=Qef)mk742yT}RsiX5!^<|~b+|I7V_yF*k0(?Tl2Kht%J@CO!pSqu_5Sm< zwmv$_A4HH?f;Y`EO3hF)A!S?lb{eAdH?&%7hePtz{>FUAH+p&W+b~M;w2ah4@)RnL zqDA$p-y+#-%@)*^*>@j#69|wz4W@v`G4_Yq9Rukd3+uab;>=WfJV}>!ID5F}*rRD9 z!&_b_pY14P3i{5R;V6g~oFmCd-~py5z1pfFZhA=p!rzd9N1}|6cpJRTPJT>jJh1w- zRJMn|(ld8IB?o>XW?mHi{!cqq)}-01@-87r+3RqtLf&d|EXmV9dkVt;T6=Fp3C+}{ z_IrLnpBbu#@o<*6HsBJKF<0v3!=q@{WBHnN_LfJ67FkYwIt&tx_vNG5jqcx&+FSPL zs2OJBiRWV(Z8OI+FrF$&wR{rkENGirXZk_AmPkO=3^=YWUgu+bxnQ8Y{#KdBrYDN@ z6Ib*Vv2dC$fvZamjX7kyt1sa4ivj_~?^U@ov;MTAJMYd=+EWEE%qWJa=2@>2R0pq? z9I~vW@D^v`=kcE-VTZp4AoEK)bokY^=nkS)TGNFbo(Ed%vb3GmMFelXsK`a#XC22n zWLFuX;=Z75e)s!JIjI9T;G{LM1XGn}Cg#~Zm0s~dt%2q^YzT5q^@row7HGmY=W)wR z`GUo5#nMN(zq9CI`6YZS{X(bN54TOUX&b+`h&E$eD&xJM$oUtY{o1b#f4FJNY>=T8 z!;&7Ff?J~g@(zO+!$vSSudT7kvHL`wq!i=K%zwuRN2Gi3s=|!@2myAXqmT0GbQTG^ zRWW6%?CO>u@p#Rq_3_V&-`A9LaK0JZF;<{GD}hC_ z_r{Z&c=-O$5UOO{v+b#;xKT3kgqWmcqIVZ@P2yfouZ#25k?$@o^sROL#nJ?5T~TftXTY|Pgs(}NEg!1=pwn=#q;wwuP3XlP z5r0voth_PE4ajsLd7i7N((ZZ`laEeFSRB^f6yL|Rp-e}H`djK-bN6Np_iC&=5TJR= zAGIBsUTlxP<29zDd#(Yqw|Z*V{8PwMH*Q{@4i1ts8Iq^;O6hl!9u-9(9<`L=6QU+E zEpanmzgJ9Pj9u0g>$*;M?@N`NzbxXVPut(Nfnf zfj2AzbeMRw=$N=1Yr3{oKfwJ6e~!N$@#xr?pN=%jFk9g%Cr%p@VM|mmw+k!J#I9I? z{kxRsK8+uGxm#?MGp1kK8^#nQfhAI~@0-@aWW-8@_3#utx9geRWEtBpG+iv;Gr**(T*CUr}R ziADMBXlreYCT`an$*5|7k?WIq@~FNMs95qMULdx^*ko|D0u^8hWs+lb;uq@RS^X(#0@$<6Hey2sPjf_n@g$?L^r_D^7XX=&w-R`nw$!oVq;M$#N&SC#fYM=%mDl@6co^ zaTQ*ZUuRp^?7>t_X#NQS(hR`q01)Oe73H|w^q)_g!zAGL5h^b*jX*zcnSVlj5&I1Z zPNxe^7(;Vye#jA(C|HTptmPK6!4{Kr$#kp@j@*gwJl?Jv9-TV65oGe3uM2{%)6G7y zA{>ucx06zl-Wyc^ZrZ4AX`k?bu&3WLHL}#0gU@F#^?fgYdUyl;je1^N|FR zwBa2AH|;b^?-`(sW88zvwDKTx=Ar$@IScWRZ70|`=V95ub=v6}nglcTV%9{?@mjMC z8iozs)#1ix$!Gst>|fi-gi0x1)A&*H6>wZu*r$)N=pdX)_~f&yTWXZQZ!WXzthRa` zaG+GxHC--mVsG$>uIr`oEf{>TT^&}7`r(p!vuHQyZbN1{^n_!7!DFh}9DX5r*wjwn z@&R0w=WlW(8DcqCo|h%b%pE#x@_DS5Z?@KWu2!8PID8a95~T$Xx$?h{uw%-&=tjGX zIv28n)a^uYR#h`dg`O?L8liMQw3paA)>dLGc)O+MKKe zdtYR1@|607N!NBJVnDZrMiJ2bSW&0hr@Av9^4nU;I!W7|UJj7eJg|Gl&?&giw2ku8 z5muXWZp68#9u0|B*MeI#na{khQ9rfC{F|eGNH@G;xaiqeYMK91s9uF;sJvc2J3kXm zs1mIH+^I^f5%~|Y#hvu;E}iOUxD&Fm9=4BJYBV2=QS!kh-Vj!x3Z0P0XWO@Ob`-Y zmPjzlv$+}-&IK>*clzZDl{Jla_B}xnj{gG;hfkY+q--q6?^o>`UXuW`YrPmaFsHjE z?PnzTtHfB` zp=K6Hi!&`oFrQ*tYMlSqw2)t@ADJy&8CP{uXz;unU)44) zM6axhs25{vTiLaZ+CLP0NWJo8ctO#%u~~-vyQ?z6Au2C3Fr#6uP_`Vle1F>_+~L1jq5K=3 z*%R2u*1&NhaIne}*VZXnf{frQ$>%PQ-h2y7RvvR7NBozw?24(iuRpF}dy+>U(msu| zcRUrGe{5sA7+E}ybWpYCT3Xw)oLb5`zN4eMWSWSmEnd75R_#ZiV^fFtI|Qvp9mJhO)Dj^ZLS>gv*RC{<51E$dAa z=x!aZ+Bhn4kaQZaf3H}WjN40dl^1$_&$_w)fgtZULr!>Ga+O%Q^)^d@Bz%x?rPr#U zJ#*I=2lC3)jrorspjazGF_z_AhKx)5jigETvAVEk)o6~|&$Lt+5j!{asNY^sn6K2T z1*ib&5&p)7{=uu40pU|I-7H^hq1uZP$SDd~(4S-OR1D&bz2VJ-jp2Iki-=jh8v2&m``st7+g28@#T#TuvoJNLuIH|)CYajC`LP!_RJL-FwLHRv-WF9b zli{`k0b?SXorA9sN2U9z6tFe4pM~V{FU6KM_azFlPF)a!5AWqgon7CmvKEl$)M_PK zHHPQekv>8d@lQvZY1<k(bOeaboyF@kf&Fa&54aaEoU89cPR;E~D2KB2xUu*ALtyGP?)7V~$qt$uG>yuc= z!m3EJe-pNz67@pJ*x(PRjmP1W***7Qq#RUYF?$!+z={z@=bX6l^fxw)tcs1t-8&a8 z;Pqf9VSM54T;g08N|Egrnj^zyRlVQt^!&rNr{Ne2PJCXho1Yh+7S|yYQ&*xUkldG) zHtdx+3>nuGb7y~514VoGUIw67J8bv9au(~J#rqgJ@+4!q_UdiI43l6__LZKlfb|+C zQ?IwbiHbvhHA$A*6Dq7)Ib$#+7fzk?2osLa2~M?H9AKU~LaRBh5XUn^L_I94+9^2G z%J#3_yL|eDJs_D|ed!CfZg&L!VTI-bM;%gD%dQWjTB)q$qUS0NGuRWA99?3kX=u1iE&tw)jz*Q|cAWf73@_!AN;cDW zcM#h+**?+j**kgC-@R??s7BbIGKNTI=Hi=8Esv=Rv3|MZAPBXX^tr}04Mjr&BHReBzo>3TfXd{5L^3rSH>xRFDwFEYh5Xj0=GyZ9kj?QX zapS6ryFa$=ri#ydSNSZ(vvSCF_Z!nh&r4UdR-^d@+S~1RW4A8#<8&Y7yUwR9n*L>h zwK$b*_D-ontbyBV3EZVK-JY_vsBucrUS+xi6wL<W}f@z zvgL}l4NlWkW0jeKAp=FbjK1{fQsEj`#vO#Pax%CeWA39M$;Pp}uO%z1NyO_X=J+;0 zK6E`9tm2R5L_z*oT@HfBdjZGT1HvaMxo*vug8zoK;To8q|6SjtK_XDm&bU@?mIJsl z0%FK`Wr&{#pOYU&j@b2p#J>wY%_<|PN9p0Do;uif9hy>|C zD!Mfj*SD>;flo{&mz=;^)#rrVEF_JPLkA?x^#4cJTQJ1AEls04iUfz??!nz9xI=Jv zcXvy0ch}(V?(Xgm!QEwmf#KubVJG);{=u3x)!kLq{j8qcTsU&jcNhQ+&xIopk^X$+yhQ_L|N zj*o-h8IqG_`wk`b1i+8d)LY+qBbLFfC5oz7)4XlnwuDNRV^{sc>~${Mc%%`oIgk!Y z+uM)|d($_YyJuthSU760@F*8zX4`VPB>7)1ij}}WTD}ifXI$`vK3-eL<3a>n+uCFl zxfygJqKx=8gS9QaczA0i^8n3<3hqNQ>&*R`yPh|_>-M6&$ry;wU7dBH$<$csSn#>1 zu*S%Jv->B}dc&#|U|8CMWAaIN%(1*nm5@{GnyizXl`1sJ%`DeCX)Gr|V&VL--j1tr zv8*xb<7<9rQjZmjctY8NRgPa;OsS&Y^)Q-K!xr{~k3iH|Qs-mr*x$b($E`k_kW9-= zd7>$hV4$LquucsCh5y3IGgtOLjjIe2@yXuwND{$2rdSd}U^Yr? zt-vN|%Q*)NnPIxC8#$`_BKVr$3A9kHh)DVr9fE0>a|3UZwZZrb^1!`Zo)|$#MZ9YQ zl>cQ@IVK`(27{HOh6@=}?K7{&($!UG8Q5^DiRg|5cf-e>&3*lVvVs=5UaQ;-9Jo9 zrT7vWNH3juw`>j6Kqls9QEGPdXDjL@*;K3f*aRJ}|C$&D<39*pmIvfeISzkvh&5$j zP^?ngwE?R-zSX#utmiiDMf-~)Uut&ItMH0tWmHu{O!mW2->~V_Mj_h+1LRNGkP94@ zbqT-YKyQD;pmms~KrOgyKjks0XbEb8)4+U@L5p44jP2I7L#hab6Xd{zueQ>rW^pQK zZfM!U#r);%lvUwXtfTSS-WB*zJ<&bs*T_ z6cw3f6gfcJAA41qJIjbJ_@)l@rpd?FeZn$y%u8YI{gaF!9DDo4K?k+7Ep@FQEFdwR zGpD>cj9Yf2=l#{&-uZ-HXTAgiSsT?o@@{{{-HhW03hv57Y|(u|7DfBGt>>al?m*L` zQc>_8KFwg<0guIQqS?EuDRo-AcEW>M?9`YlEFfCR^4>w)*jlg!+~yAG8uh8Q0|(9G z)RG0XFtX8;8~YkYIc^o^b>TTxEvY216vcBiF>Cj71ARz7!93=d?>0^J|Mq8dmmji@ zelU-)Wii+chIk+or0`<2rW#AI+tnJ$@2s=JW50v}2?;*oa4`3F|F8`NTyaf0Mpp@{ z%0QjjBo|du>n!*50UF%_l7bn$VXhE_V zfOIofAk5@KZPkba+-o%thhA%cmi&4@c^xI2j4yU~A4m*Fp;gcedaH1|S1TFB*fgxH zR=&&YTY#X7!q;aid>3{#4?%8|o8?cola1>>-Rba-I&L`J|5MU{Np0lank75H2Zy_J zzO=b{ZfqBp`kfA|ofU#htV!zK{J;I#Mk%YZ8ccB~P0HYi6<0fJP~18+bxC!hR~AOm zrP*9|(~H_XI7w5gjheczJrG1|V7BQi%#oyQDgOn;oqPTbhrw@VBf)y;S1mU@iKf1t z;BTD%YQI~j^XyvD{FEKmvRs^Ji|7gsvnJcR0OC4rbyekUCMYOXf9|R`HkPeQ1Z?9pZ zk|-A?-KK>UnbdJHoaWotCE>ObCB?Z8F&&lOwsJmZtd-MY47(?IH=P1yEqTQxyLs_y#!vz}PynpUj(~xE1#1v_ zk875LaMhHky7+FZz;GX$45D%Qi~i>U`c<2?LiYbLUfwRUb^?-A=86$R&{A{(F5P$8k8zaMO93jagDhlx?M-1LL;v~ZF$-Nzr zwysqL=&(ff)8_n9Q);@}YVgmxtz_LCQ|jV$wmQY4u&~hdh^M0AaQZj8oX6ZCPluLW zfrYEPIP0KKePAL&Q=SX=pN%eI*dT}Gp%7<3ps}7awavV|ruAc`1koyG!2qvA638Xok<}N*6=>{!C2vK{eTm zu9RwUQQ;w$@cHpq?d0#3d^P)Grw$!eVG(Z;6Thz{ERmrbG`M`Cx#Z# zwR1I}Dv;o$=J!~E6LO@EHj!#!Wi}JW6Ek{p5!vnpc}`(mB){FOsixEH=h8i65W5ky zLX2oo{SeiD$=%6Zb6j`pwVCm~>Bf<>(f3;HpL5AeK@ZK|wPdaofN#CdvB*yH%cI9b z28dR(^p;2$dly1E?Mx@K-x>`IO>4;7nLUH>zbl)1cq{)fD{3Nl@-+CPlv|0q!Ic=lkokdISq0ZRm7VTXTKfU*RU%m1!Ed& zydRxfG?l0$fGj}>_nS9X$?8n&!zE)5k^`RF9|+^hozFTwBzITv8PI}d+=g*UGy$qq z&8-o&k?ZFsYnNZ2t~vn**Hv`YZ^YFDXUma4G+-d;N`cUItA~+j3#OWEju6p;T%Xt0 z?(f>+Hc=-_v@4G_OZOA($R4y%7T)aQjig84e)FP|F^-3x9aHl!?w1WR*cwRu1D!7h zREkcqO>_YbM94gp`80gcV8Q6nViY-yW!sD#o6X~_D;i67CWwubY`HFpKB8IXC*}k3 z2j>tUG(-3`GpW>KB=~t!1$r_|Tt_tqGOfMq-y-MuC-bm0!>TKhcH;XaeALmP6psg# zqKlfU6+?gQC zaP&Ipr&M;{nj2UwuLXubz&7)=&0Vxr8jB6^eY3z%xtWh4nTHTd>2ffH8}oAX`E;!0 zbne93cHKH0@L^&M%wueuqb>ep6g6iUIcvr6*C)~ogNale-H)GNpI^pS=oc)onr!?1 zX`Ah&ejtIn{;>9LM_z%mZ`~4G$<0`6QVH>=oD(;Yj9U7x!{`4 z9@;Cz5xf|gU0tiPtIJqfeOPb)V&z&(lP;>GQ@K^;$UGKbfttQ1L$s;U3~E?4zuZd3 z0I0sKHu)2_-6*)DWo=nzx#2CJZx_M|D7BN`GwNf+n>4wHQ-0nJgf^;EoG1Qy^v(QW zFw(=m_$~16yhXGgIeGAn#}xq(XH7gVMgu8C7}=E~W$L&_B9AHdsdqw}myl_MtaXLc z1cMIBm0j#o(C;1)T)A)QRWUq^wcmXrOsyWHVrXx-eD$u#oAAK3_k1HEJT=@(s+W!n zJQ0pM5;l>D3)$l#m?QF>4{Ju~YGOHO;Vg9R<NZ8 z6XoUoO0g(2zdya)?^M05Mbvi6ePV>A`(%6e^L!9)lU>dW)Y|(rn43W}IBbM4#kLrL zf3@c4OTKwJixbpex|c1ACjgZTHltr93xc8P*bAEV13EL0QH@o(4L^9S)$@L)N5q`a zOC@+&QL$k5cGP7jlsC;Cqf=Q-UV&K@6Ob+n%Z>PpZ2YNGZi5V%*gQ$!#Bc*vy+m$? z_{|}o2_6DHdb z{$eQmhQx;)NQ^dFm6?0$X^j{OGw%WiD9&k$NhB$QOnF%J=cl-Y;MoaC8`VCXin%YF zGxZtao|PDk;%>*&r$4ZLM?d<~f0?I24ACG>DJbvLgZ>KoumiM2`_o&n7kTy_8EFdc z3&Ia<PqRbD=DEhLxt4PYZ#=#xsZY(R& z!fW2)dc3E8NDF2{JNkRj;$6Sqv!vDD+_(V~&cB*Hzq1GntP^S5&dO$?d+bD$SMM5d zdwH+Ca^Zc-^)QAnhX$2_Zi?qjSG_~`-40=4dL)bxaMx@EKEl%|t7zA1>CH>hzc`&T zPXD#>OL&q0l1WUOW%7Wz_u`>Fy^+sdYC@oM;Aa-_|G>jmv)_JydrsaE|AC&L`dW3? z1E)`~e4vct!w(OOt!MCz*BDxAB?yNl)}q4#ePLWhG*5!jvrPP?U^-omwwG*Ghrtk< z%S?uH>X@HYSdt8Xh#+l~%Y+c-b}bW*VpSKudsVe|9TVQqNqeK7o~1rCxqvqyK~FR z&^~16RQ23R6qNGwE4)}3Kbg+h^p@$lKjRGdD@i}6P?}+S(n@U&A~e(cw6nN9VI$_J z^qBO|%UYuUN-iH?ZudVz687|52|wSPZ_Gh3r=Yncxfu6bu-V$W} z;U#TGJP}(eH$Du9Zxsn34((gfAeWv0C#XM39Zp56|1%}HXf z5G^~_r!p(v;~jB@`EI6JX6{MwPhfDIc(71M$*=xoOqMLEDU#9aoNjR}0{H>C^$up^ z$vg$!k86Hzdk!Ypj~?PJ)A3I!2+|URsn$(rME|X*_>(mGYII?m+z6>g+!l;F-d8nw zZkd!+eDwA#xb|12-rDtj-l_DGcG|-2PD3q|VhV3M1CB(N?)m7q=;kb<)&XhT?BpHl0b7i+o)-jdX!+b^hr=^atF8e{<%O`^T82v93V`AOFSRAi*%i5F+=D>S^bg(6MKtJUd!9oc}be z|74=#qll~rQn-(u`JvgpkRURIj_|OqJFMcFI5$Tca$qyF%TvR?En#-6nS7+J3JPh| zZF3d8^ZjqJ%zr9nZ1uJkOPvWretGSSX*3-Hwl1|&Cp%33<*=!4Hp>Au+4F*p7q*j- zm?j(xfBq?0C*Bx6lIy6Z({5y9gmsXuK@o>WV)JP1cfFc4b+CmTugfB{wHqtv0MvlL>4PAFWMF~IsL$3BR^0x1OhsFN+XdJs zU+bn`GzkBOft*3TvF~2YmUK7JM|i(O^@r~)x{HOnZINDnPj(!n76ZT5T2+!6M!|tG z#wHL%B)C0Iw{2{x-Jj02UsF6)s=CZu@n7Ru<$){7$re#p5;Ur4BTc@s7WRZ9Kj~H4 zVyO#d^|vW}CwQDM6v-)`Qk66tLb%Zv8!HNb5cfqEhlKDCQ=WPZ8)^2Z0Hn+G zh+PFaBX~f94*|mBn~dAlNrSf=A*8JSPh~v<9H|Mso*z)x0YDrLpum9`idfnnc`!JM z??Ihh?6$*O1B0BCJW|re*i$_hhIWpXjHgAovs|FNM!k#gvR(JeN7GdRsr=OqW#y`7 zHqmkD<)0-ZKQ*dT$R`G2yS)BeMfYFM&)To-qi0u}(Y^N?`dI~e;z%J4H5HL0UfIVP z)JGAMp7R&`c|3Zb*0^{7ahcCa?ITPNBDGkN?2pP;&LLngr!uV*VEc`o!6tTF_RqWD zJUpHEJ#-Bv%nN$mx8#j{Qk)|~or)cPlME6bbXevlkI zwsXJX#4C>yR;rb;pj6vdChX@?a6bjt`+NOobFFcs+hnbhO6nK_=#w^eZkeZv)XCm7 zmc`)@T}du4_6QPMVvs)J&Q`C2=ob|P>P#6z&Z-uD@XMP_GyDPiOJyNs6wwXowm=5i z7J0Ubx;FEMd%ZNl8fzfN2X{&}@WNA({U6ATlqB9w4HU!&8>bvgW3lWFoK3yy#>B5n z7NK?R4aoVVgw)#`{@*$O_6evtC?!zCV%&9}Qk_#uC41gB$y!@4y zbBue>jR!zYMGQtx?Ez(FpS>Wz39+vUfpInHp}=Rw()5!{`aM_P@LWQ3`4}d(Rf?-O za(#x{?$mc}nOMZLJ4;=6BxW;NT)~3oF7HZ=+{bIGN3Jej%>Z`I2}9DOn4w3+tcCF} z4-rpr4yKtGRR2PM#eLv~=VQaLh`Eyp%~w0AKA0J!=m7-0Bq&GqN6M2pPUTY4lT`ku z9pZDhM3c&*05qWOb=eg~oYj*LfU zQ!6W&%pM7@LVKBJDyW31X;hqQU1u1hJmISH_?o<@?Sxj6Q=^y?C4pS1VQWB!>$H_O z$`CW{2cS2JWI_U|0oa%-+4&cJXcgfN=C8936U{P&n&dRcbXas->rKx^+_&*AoT$7N zZgdhM-D2Ur=at7V?g)e~U`YUh*(+c{dYJZ|36fR^0zs{x%WlhiHjnDOy%m37Jt zZCD-GM}Bzu6InV6ys@@dSb32gC!YJ6X!f3jHus}(nUKd?BXhL2@DJ;LJ!AbUo~^*G zbgXo_q5!DlGDX#wURHOGiLl$s^T-(18{@_$T00S@SW=PE6*YMKimD+7=VtWAATWvY z_-y6qnD2dFJ!aXM7{Q70)4n|WlxOw4j_XC@2I1P;rpKdI{mPA}qWN^~#Ab{_ZsUWN zgnOD%<}~T)ees*-4;+4Wj%o|s%` zVIRYQov71CWiv&fO{h zmsTq;2=0sx!;{xne;J#h{O!(JhQ3Nv)c@|=nmbX8Q2`%jU+R~knRurHf{FiN4}!|) z7wKshnd^3Mt${(oRxdGtWSz;ayXweQnWw+b7nXcs$vKtIc&+M`N?U5pv{ggh*07rH zLb2gVxXMQ~?A`f-!A6-n@)~ z-3XNd(2>`F@j$Yo?;O0U){K!j=tJXhtF|c{NhJP&arZckKd_j4fIfZdp|m=j!C$a< zb;M?%5IobH98x0Yb=olw&IjnX?zR_=FNw}ilatSQef0!MX&02Ymn_5_wN4o7ONKso zLigMEAq_x}1Mu)#_KA-Fcp9QNo(j*8z4zzDcbfJRQ}D&P)TclVXm^jy6fFOu>P-%Z z-;QSX8Us&Ynexpt^o`LLNbj0Cb2VMwm}?m0S(-aR2mzZPLvvPu4<;?DbslRba#f-bx%3H+c-|q2@&8PE|{1T$1kMLNH(fpyUE{j^p@`z zPR3->4SzixX;0(bbPVC(L(TLQhA%w3_>yn`)~euig7>okQUiB_Vn`V7fGyEVwy6qq zS1xwZes1hJXKCk`<7f6h&$)6%MZyoLh_MBoL-4Yvf1{PTYZqA1L)=f|YZEB8+dC}5 zE%2__Xvg)EJo_;*xIC@4aMe4Pyh*^g<;9E)S|D;Nd;gDBc z`roeR|A>NY3z@MsY*%~~mijb7(%T;k^&`7o&GQ!AbSQ}e@mZMYe=?2@V-RLIAAmvQ z&cV|v&gJ`A4VsHYf`>PN_o>c1)4gZ$rB=-zzqK^+3vX(^Xz4PM@@y%KeiXR(vY9@A zuC$hQBKJ`V`mVf>w&%NSLcY=ab!QWn@&OxlKr4^Xk=2Zqb#->gS-9dJXztmCEQ>nJ zmL(%`A794Jv&?cJ-<$uDR~!veiy;e(rZNkYP1M-_lphQmzqRCGyKpEG{E&gSL->n( zhM~4&B^8~|1E*D-(f&^{m@QiAmy!nVdoz=2!`%TEW51R8)LSrV-bDN8tLZLowo`I_ z>=`m)xt*;dkIuKNZ3Hc&m%YITn%GgFGvIS^5=c|c+2!6k8$KU|Vql&L@nV-amZ z_TxIQic}OFqUw6)6nVbd@kZNKUa>iu#mkcN_z~h?#fGb-TMK*u=xjSv{{{D{Fwq$a zy zL!2)pEtG%|)h^omlP`3V24rckt^d=(sO6`=O~AGBd#8DR57|%zmQALKvMW_|q=+@axkjUT6a! z<%22a=)YM~ALG(vwD2pk=wxshj4PTim{=S#{-?~j(=B2Y){{}s>lYRmF*L~W>-$iJ z$NKqIR*qe2YuL^6;$HkfCy*q3^0u{Yhn}-djkpqw3`!p~=YXf9cKRD4rBy!jMY{7Q&VMIhbW0 zo5g9vV6RFo!OBSp_rw=r!b(b1FW{y8F2q<`?jA;y5=QzyQawzI;J8Iuxy09njK!G4 zCWV;mt6u0NwZrkT#n3YSzOHDvbb!pEBv4`}Qkv$)O@_be%7!=aI?Qf9Yo+Z6FEuywR#x6Ef3F*N@=wCGeWoN)(|F|JB->{jh$X($;;r4^WaIg0F)Qyv}(J>P6~_GChJu1H1!IK)nl!|Vo@;G-9}EaTa|>`vm|(`1QD&QAQZ0^37e zzVat((X!_tB_b2$qQWdNmnLNxyNRLQOJ=U=v}4upjsLmm7xYU&4h^?_3IR0+)ZtkW;J7w%1;G8r~?@`XQvtvI*&*+MsGnFzX!e-2wV zcq2bUo6`ZS+)?lFpd@qZQtY!x`grHAxt%e%euX=#A@L})rtSg%QQvKW)g^f->$T;z zwPIclH^*}@cqqctcdt&btPE@>V|Ot^o+*_hjx)$yYZ1K~K=Ow$E{remk3^k-`y)<1 zkW*FJ%#hXS!Vfm!yQAjA@z)D6wm>hQ8-nE)R*tqX7#l9^X)DAz z*nfJrn3L?};VjcE2qXY((F2?1%6}_VE_5y8w`4fs4%=Bw(2TdaoJQi{yqO-DJdaIn6njtnR?iK7V&7>br{oszsoXje8d?>QT23G>V-9CiHAtKDm z)~F~@o2`83ezjCIPWwrvB#P3ezJo`{tP3TG083**CKKm9!h`#3mbTRuVj+g?c;>Aan<3z#f3GK58zt%DWmJgk}+tBh6e_e60mu4c0GVl|Cts!JuH4YDw zpz*ff*|HNy%(w&w)~}Fvnh&e%emWF`%5#$9bjK?B{$t0rNAZXQd5}Z_RXx9VYXkrE zf1-BpYL=58kD#5uK2;pGUFueWsAs8Cvvf_`guJw@dLHS|={wMO^MT1O<2GSh6m8CE zj3%e^#zg=p2duIm_B8GpOaDqMwwl1%%Lu;OCemY4)?x8Mx+y*zmf_>w)`$^-1v#eZ zx=R0mB8k;f*+`zYFZ0~AYoBvi>Q?F^r_tkY$?wFd4=bAN^e&&}n6Z{7FRP6Mue0p z`5=Rq3q3eD9$sVQv|b9s?G`%o^8yTA1LNdF+x$z=a@;KoC0}|4OLfy3!qEbINunjM zau_Vd)aJ+bJ0D2-&y1`*;Rv~CI2*_@Rqb((xE*D@m1t_ zPfEVK@b_jW0&C4OufSM&`f!Mr18c{fo&NtdM5Hb$rB?wn#BYr2(m&AmpTS`on+7j0 zg_tR=@YhhBz}&t6Lls<}e!*0DKu{AT8}6~X{8G6`y} zFAFK7tom>Pq57>3SAyMsnf6{T-*?Q{T>|A&Qiy{APTv8eD^u_D@pr{_YsP&Sz^Lo*M6z9ifBk!esheeDbp>zfXeE1N`ZILQQ~$ zoRxQ@Lt*UR6o;_(BYp^MIXHPpJ=9?kU$p_0{Z-|@avr+q0oW!LkrKmp;b%QS9mT4w z>Ihd=;XovRVqdNoOF`SjLD6vtd2@WNCeMn8YTgATl}~ydjk@=Ae5bi!cjY`2gs_yJ zP)>cjjknNNYj8!)J5;7Qm_!!tmqm`v%~*LIg;~}@AEt;?yX9ZwHa6o|2h>$>lqHLYdY$s#&a;B`6GRfTow2DuV>H zxEbmkJHIp@*(Vb*%@j@r9!%XDzIO8CX6e)q>bY!8(xEiqf6?u7&ESo1Y$sT6Vm@%F z*b8HQ`~#nzjR2)5h2xM`j)#q80**;RdX>@YQrM-dov->%4oA7b7x-`tYl`!b4^(Pa zr6`#UWcMD_L;IEmrSN!qfS!LqWrU;U@XY>7R1Ui)T&JsNZrPEHY59B7v?8Uz$&XRe z27FXq2&Fvws*<3kx)YaZIl=8CCSzUGK|g1A3+Kphult9FDn~7ErHjK^B;I2!Z^p!T zg%%vk_VqC%!wx~#Pb`UY{Pi<&Ythwz5Ec2WO%4u~iuku@-Fo66V_+rHeY5W#NN1IK zVQaovrI5WP#9s;(7A>f30h%w$F;xcKOkQ z`~AePSxeJ8v7NoQ4NMo^Q*ZH!20LrzE2+!kDm#5lZ9r5GmSKfFtuN%O=BE&GdENk5 z{7sJjgLnJfvebLcszq#18!o~gY%OUUhmD_qM|Mo==-INQbBC(N{Z`CV(=DL6+7ubn z2nOxHN5)c6>n;8}vhxUd>hzt<96qc3WE!>NP99;_a;xgqcZDHb^V~-{Hj|dhh;^)v zmTiRCX(`e`HJPPzH}H*iKQn0L%OQ}! z@P6+`>NR#2SCmNH<)P~zs_qDzj~n>N^UW>fmGs8^H4ybF>x~b*{_kGa>afaPq(^u-Yj3ADZsBwL~^yYgq z3S0fMTEz;NgOiJjWtRWiT(DK5m!4fVWVGE4R|=Iyov7-HU0U!uG1qv)rp(Q-ItcsI zfg@ffV?{5G|5*9V>D+trEHh4*Yzmae zaqxYK>N{HC&w+q|5<;-rg% zF>~6Y_VFvyjjHoGzd-Y1VW;G7C&(k(^kZn^^eN4|RcZ+lvVK>|5G3~2N%MU@qh||& zMO+i*8tJ5MO5>=O6iGkbp(;cD6d%i8@C$?RBdzuk6gS}E&8sd7c1CSHs$*;wbios< z-kRm<;Jhdka>UwWRJFFVrvTM?5#%3tVI$D?XI{%t9FpP95zEZzu`{As84H51T_2Lb zb!l8w=lTDoovSrKvT$4~gUdl|elLO247ePv2Xn`v?Js7W=hpMhhTSN${T;ccA!kN4 z9fSYnk+`Z$3lrI;Y(b?fSC5Da!G>UUUgH1;S=z{0{Sn&9^@qyok9sp|BZp*-F%}Zd zuf1fr$)%kV4KgJzF^D85WiPO~GTPnp;b2MyIu&YyvnP=KU*v<=CsVdS5u+Qg!C*Wsv4GC787#imL^HIDY-)2{E8uzZUJ<6g~bLAY?GpWIPowMT8L0NDmL}D_Y zGL8T>k(2qnW$mKFgqR)3_J=`));j@53-X~MWP{=BSjEKsQ;)}<7JZso2}kZ;QTCpN zL7F(*#AP51E%ocmzZdGTEu4Lu&4+_>jv~frTs_40@ngxk^gCjj&;g|N{=X@cLgCSW zU@oi28HqK)w7B)3XW=h3;C6HoTlg0WAC)B*da z9j@6MCubr3{$-va=~C&DuVWhFZcdb6KdN0rSV1Jx*5gNKz{Uy7X{2? zI(bM4V)lh=$h8vHKMPz4)=$unXN~$g`@#a3AxKjLy)51RX%$ca{^B&VB z$=-&8M&w$0Pv0uf2HLf55r3%yt^Dle*n@c#uqI<|UoMLcN{Kg}*CrKaw>nNJ|B8Sa>1& zE03a*o9ovG8tguH`XwzPw2CUy%tyE>&%kjw^6kL5lOp;cF1pm5ejM@^ZlxG?c9A9~ z*OtB$?sKX`$>#_c9ljHLNe4=>Xvm2+sr%&q#r&_aF)N`cf$g7O*IE*xO*GRiVEg=0 z$>MILw;%ro%gyCXaJPg#^&*B|S~dC~A3rz3z*aCpUl9@WM`{ZQy81ht!IBbl?E5f( z`@q7PhkVVKt4H9@*oE%jZEkuTj*jf^Xbe>tE4&2A&U^wwks4Cn-dOLhudO8Bix;l^R)eFmtCb+-6^Hn3eFrX-FC?=B>7j}*Y&8~Cr-UOeID#~x^ezyv0ODyrJ% zueUT68)4aC#2Td{e>$X|@%pa5P@Sb>p8aAUITay~!JLse6BQ7wF zBy$3K=0>xa&|$%`YgW9lgsY_kARv z{3j0jCOUSH}eTnNMQ> z)nnA#$NA*$n80#-yvWdKSVb~(5~fA^7>>y>gYhD8Q{0Fw>xUy~Pv>ku#fe6}-yl7M z=>QgcfnY$-Lkr$(h{6{<1WPeq?8`?^om&@nXu~`_RRX}VT9bX+Vt824 zC_)OG)bbi?kCF{h(q*6|-^C7Eg7c8F@F4Fx1vn+glz+E(0^@Lv{Xk#Y_J>QX#0WjwY{)#JVofd*$`Ujo3%CI2Rgtt zb75b>C{d|#6|m#zKyilnbNle|CLO{={kNIK;oq_cRC1I8^KcJ6)bp(DH;;8RVNG5V zz6$WNL{0CkmrJ5fM0^u2sSn2s=jNT)2H=^fDK^>;;hdWrRhOe7RBhNk4#r{kWa}-E zyK?d~1i%hGXx!LB7I%V`^tCs7*|uePcL=%n!kea)BfsJbQXQM)Y}m?= z6W7G+4oj+Nl-~NNXVP)Ab-$hqz|T>+)!9K@oQgZ+Y`$J+&r^pM z!Q6T2psewOWGegr*~uWF`dTHGs)qcBV7eBvrkgWlLQVAq!#5pnKc7y|JoOO`+V!lt zncbKilSk6RzFRX{C)4ySeHT2?VvNk;2tk`l?fjUm=d+E~T*_S6ChI?~vub{dA5sj_ zL2KC(j62-><`qxg&7#4+i7;yEh}wNLihEeReV}vJ=vEF@c3*8s-huKN{!gQW*xx6| zgGYwg={=x2UJWWqu%X_6G^#qr@4q7#Z+(%8oMTFDz|ffyhb`r==j3hw+`ij( zuireyN4a54rSp(|((pxZbbu`szp0!r z$zDN=e=8R3Jm+LHogRFJizm`WY=*dpn8wBu{K{jMl4GAZav=DB4g(BMpJ&%ei-5U4 zSX0}2oF}!4kDiR};WJf36mjr>ABp!yd1a*AfIY>}F0nhY-?4;hO)uZ60>jRiyi;dj zG6hLBui2A0!4sslKTwA%ny?(G<4Jdy>ip~5Dflb!jD1@8XX?8$XIfZ2-&30;nzq44 z0@<2zE_M?;DTQ>2qx)Zpu7|MYy{}R1nxYzLM%B}t+yHHR02Tc{62X+EAfcGI^S33t z_6cQr-O+j2eD}0}Ma9629Upg{@XU6b;_VMiV-wUaGir8{x^P| z^7i;zCR~DaEKncRRK_OL_08G#R47O&+y!=TXn{R7?aL>FT{G<*ZtauB*F3+rZr;xaSqR^hm>-@r zdz=NLyavxk@0^pPnbm`VA&nk)1I1E#mBQT?uo0#q53O>p%7*Dr>=K{Du9tpBuv9HV zUtMg5nWyMj3R=#qH)S=NTGq+eeh1w9?JyXBDP1kPgSAD@Rfl_keE`QEj?qjE$@<`g zqCfSIyz`ru)~J|{5;-h4?07m!>yk!q9OitF*(cKSsRtK6#0h{Uo;sB4{<338NY{>) zZt68GzW&aJM6ii5EG+T;b1R_md0{uT{uhe zhv!3H56z0wLf$X30_`>SmN0F4Bm>v5e9(P=THnnuA>nua+eLhO3XK7a8H<#|VMRB8 zOb!pMq%xt1woRciYb_reeMrD)`-fZejiqsVXGRJuZslka=c-6j;+_70z-Tji6OBws zsLBJzV|is1H#z_-Ht=2&vA2&JMp4LO9&y!H_w}$8V)*F(?VXqxXvy2UlloMHjXy1g zR6x{r!n8FTnG~z6;y=nzDB(twfdgSayDQQZ@R~0dzxTQ&Y~Tz)}aVdtopR zL2@F%xJ$_D0l_sT7u0PJ{`$&=j_9&vSXru1Tcz^AoafL;$!Eav^y^VI>|R$iP9?zL z1-DAG#9)UuJeyg3cRx22syisxRqCID3CKT1t08#IK>o~5s>ej9xt;d&S9k)4{yV@L z*0vv7Ya-UrY=Zzg)`nDu^jKX|cSr6Dfnl=@2)GyB=W?6_3p?O1fadvOnCz8>6=inE-!1^VyoKL=Yi6mjGUQRJX(o!z@n6cX3EUVuzo^ScUs*@V z#;NuBNUB#9yZ`AJJ9~`|lsDPY5&tsi6?0B@njFM3#S+pQv#jS&g=l9-v^i_48BbzKbA_G2uUEL1Ge_nN z_b1wHQD4~wYnN!I?wF%rL;va!I4^yaypSCjl8lmYg4O|%2oYuKybIFya|O}%bN{{l z&zIm0NfhW?BP+rp1Evnp+I9F0%6W#j{b&eD%&{k3{|+xa0$QwY*Wt4OOdo^E;~XWb z?l5$NfAvDZ!W>Z#5|`*^9i-Ql7@Z;PwD_cY&To%r6!^~ z_e%)^8_I^f*_gr_6!m0KON#6=#Wa>9;oz?-E=heq4qm~d`iN;w!TtxO+()n5R)r6> z=RavKjqc`MPk8=Zzp>>*2i<7@lHy^~TV)^KPWbdk0?5TxDv-*_jo!KC7h0Cz9q7Vr znao95#j_EfKuG6RjdYi+eD~O#)3Eht~VTN0d# z_)k-Du-@Lu?PblFW(f#pPybMyemHDkhcd`6eMjwI-sR&>Od0jMpVG`LT`3>n{M7G0 z!3*K5=<`MJ`>ThcKISn zjU`2DYs+j=TjMRI+0j4R#Bn>P->xg;UDunHJq$dr`WBd6^7_0Qf-Vs}<3xqV1$u2S zEMhzD!j0sW(kCTUK;3=o&9DUDKVp4IjC3ne86rkY`}uRH&un)ZDEGOBH%Fi4iuOc}CJICj-NrE>uOUDsJfg(ZzM{}%9KF{Z+U zuA%-Ke*(|+pH~GdO($0iZ*j!ra||ET56Bn-RH6C1X+5^wpPNYdyGslv(0`B<$R>W9 z0SDnJr(X=druB#yFCV)2lT=n9_U){-oy7;S>EF)I8lID`Pe_7-h^kAJ5@g@`Cr$Mp zkO7eLI`w%}?fxz|oUPU6ET}GX=kXWpy2xXF_-AWonGmrDZ;x`r=L!EgVkIbGAM{V* z2NkkJzKfKfCr2pK;MjG+vz@0I`0rme|Erw%)r3hxMTiml(Kjs~5PMfV?ll1%0zQ&! zD0fox_X~ecFOt}2J^qncV~~+M6tU!12#Lb`)0$J(p5_E6q`zL*YWwf07h;d@ce!oS z6F;mfPY__>yF+HukOV0txo4;bi5;irjjeC+o=&Fu7-L{snGfQI#CcgRnp?uv(~`Ix zx7RN5q7{Qgy;VQke@*L_-WP^Mp5B-WPM4R~pW_8d_dYj+kdoUSH2?bo6fDrFI;)>u ze{iQfqMrLgUWaBZoFl46I^B+S$p40&bLhXrgVfiIp+kWn`^7Q(ljcVPc-S(cpQ(I# z7|LsOMqM3`bi8Fi*u4mqUr)x*7c$txilujsK8BE4zwN$`k)0iq&3@>^yJyXISu} zw)_7G#HK`>_w#vOmK71)B?;WN$=d|YO9-WNg2~)m0ZLKHt#ALdA~s~+S6)@{N|W*i z*r+_FE(Lcp$q5@l>W3=`wP83+2fjG@`kzrG7fh>1bJ_@GiS^+e20V>Y$I7m&pS$Oi zG*yI5$|~^_Mq;_bTr86$BJJ)vL(wTWF1wPNbz%)oCNb%;$rX$Bup~F>yXlq-#K8=* znc+V-y1`X%S|5a0U*COS7313s5SZ$lcQ$;c69&Z2+mfBq!zQfe#dx_F5BY)b|74s( z#AtZAY)>ND=pDr&sRND8D59f4iVtjA~rWG7{`8Li>Pow~nfG(+h#;F#z7!+!_8r z{U&jz-=lUu0ZNaYJa16^Bfv6bTTXO|GBe*ja&=Ch23HOojm6cTUw#uZT?T*w!r2DD zwKlc%Y9Jh1>m~@XL5k(tPFGDK(sy+-j~il?&l9rt+%;@*Vtj@dWgMkOeBDS9 zSJl6)W2{aw;BD(bAMxCoo3-y~0%(~>?w;5P1937m{&W$qEW?x-Y7{X7lV&+U-n(E8 zhG&n%t%IN=2h#8+iZkBL7lr7yu^!mM=WK^U18}_0s{8a9@9972#OC%~u6ESe9wY2E z*UW$ckXhsDShEZFs+XH#J3QD~0deGNnXSgjs1rryUs}AOcyeyvFGANv-){6>6&^)$ zmd~tX)S3TFboNff{`+cvI$wCBbzy21>(^JhBOB@}qu{DEEyr~zMQOQ3O1nFD@IP^D zWnj3V^F-{#ui6|gkmY)g$9CUEJXWE*E^43PsKxVc<2Q0-rTwOXj8OK6;kB(>zAOce zgGD&P8YPIt&*h47W>Hs61)xESC0W_r-19!Yh<>C);hODZq`>T=xgaDGN@cED-!XunOKLmS=r}3Ek9U6Si8;<}mv6l}h2S7`PqTAoI zn+Hfx0soJzzhH=KTe^nP9U+9^t_=Z#TX1a%?(XjHt_>j&2=4Cg?jC}>y9L+AX&gSz zyV=RTJpZ89oHeS(D4C0(a<||ewXlJOG=0KTCxUE|mb*=6U^VhJl0AD6o~VA`+`Gs) zW_jf#w&kQ1kvb&TNe!=h<8cj&l|BA$!^{!veDShRBz=nW#a=j+8wGb-14DM5R zd~x*@691E{@n@d1+G&e}l3R<%`s*u_8IN}WjG-PPLPUW7s3%$AwVymDCP zub++NSS)^$lb2DQ-9?400@U9NeziP%ey@%Ww2MZu*f#|tholDXp|H{D(6Pgx&QGhu z0;hiQ^j?eDvZ8PB|#+n#T0{`ZMlKRJKkqV@Aaa)Vni_fxZl^kCFf$~-r4Si{M*{FX6 zR?Z67`$HMS1G50$DN!Diwtx~!WUJ`@N4d|MO4%hEF3D~L)Df>Crb2nlh{RfkR`(BXH?W!9?7vokqF%fcMUcjkr z6w{*knA1Fbte?1ZwtihS&#^T+e&3=Z$xE(-I`dt3%Wxuz%ad&3ENH7lhz@rtp-B_f zlj_GD@)}n5*?)AQ#*{}U*zy(jO5)~q2&aFWrPUdJe~h7&U&g1=is4Ti6aLRtwR;Os zsx}?mQ##g7X;-z^xnrT>>qlgLExeeJ7Ms`%0{;Z(8w6&u5_5=FR86Az_H})M>riOT z5aLILDqBNq`tqvEd&h_oqg=bSX`&f6vw0Kixc55*?;i_~d7tW1A}g@abD%#Pl)-L> zJ_$8ykQ{-hU%r`#^hojEhD$XQy0z}zex89qZV~Q< z(ZCfJd)>O;jVI6LWUc;lF9o&52GVPMe>ws`PvxH8LV?@WE#f@7d3@+c>G zH!RJ8eCdgHsx&q|yeNqW>p-!p zYt>bj#nBaLBbuVwj>tRWByu<~d9ES;pq9@b`Uyn$sC<+8<>L{VIS<*=7UFe-#?a1u zlNUbe(fHOH?2?NX3VSjB*uhJ-BkbLVacDW3{DWx~g~Rx5k1xqe{rmN1B3gZ~uYTLH zuZaJ!9h9*m*{UjPFdD6%EuwL$^sh0aLE{cQHl^xF+3F}Gt>d~mqA zaN$<&C;<>Mc1wGGAUKz8Ms&M?<;q91pKB`A=#uJc-zP4}ne2ugyB9TY5y?4GEBId4 zVE52@YT0gw)6z@Q(RLHyI-#w;U|d zPn+9~_aw0!XAZz(FBEw9XBB9ICH7Rvx7v`Jx|OD zNQg(m6U`MywjU0wMfr0low=T>L0T*Soe=b&bnCbBBYesw7XPqv5wtrsJ;45OORG(1 z8Q%0>sf1Gwmumih)}OCzPnb59qXvEEzV0%*9WKJM)|tK#uSnf8yAhnLPL;5{T$P4I zm-oDF`88rAZF=PYiWm(*_X8u#d_YijU|b9L{pWNhE0e0LW5dZ!EOw-=saokg06NB! z%N1Pg%}b_+Xxud2cmuW~uNg8S0zx+{12yr1iTNtG|aIV~UousXMsR zBCz{Tzc>+zU=O!Nfx*Z^!5ohoX79oH|Lsqv%tt$$Cl{*wFAbHZ-52=MUy6JH{2EGM zItU4>EYLlUO?)dUexKoOS|#uiIhWwBu#O?^Yc;LfcT^UMCKxTO1F_98gmiIhdDkuF@fpG=t`p*QZz%+ew6WF z)|2O4TcFg1#mDtaya!K&?GyZ?VE2aMv)h)DeS7W8PAl4Krue;UMw=-G`ln=rItsa{BX1)0<||Tk5qy1&50Ii z4&fe{!w5CpX>^4KlR1aL^b}a-9`$X&Tc+Rx2Gf3Re*3rCSWZ;$m9k|k&GrR&mesjV zVb>^LCI>p{ZvF1kp7(qE-5*Q=>L|7FB^XJWw32xq=LCR25Q=+>Z$#9JV1uu=EM-@t zr0MA*#`X}8uhaDwCF`Eh(?fk)`QMHD-)E0Avge&Ols@(jTeTe6c%|R8mj|dE)^wBV zl)MFg;Fxqb)59aT?uKu1u-=%ODyo2tR#p>?(i{TuGVc_OF~lbq90y51f_3JyjWmkN z&x&g##-rlR-{G8IPR!zB@JW&%LB2Clu}W!%X#8uxK@DAJfRN8}Q{AB{JoFJz$xQ5~ zOgZmq^*TtBCPi5bm{aO8U4VN@R1MQ#!Xy)J3HFr&#W@^7U*7M1x$&jHf?7-nRP^_7ppNmed?V?qPCa6J zo8}-B3}{jwuJ~4mYw@e9z^Ulr6L_Juu}5?P(s+Vb>fi(SSU>x9Sc`@wz;Z>4Cuzvq z{tTF-raBxHEta~}j@?MWz0$1Is$b86=jxgM!>X;xWf(KTm*rG|0OkW?P?=ibzmLq zd$PvF{#?LhV(V9iYq?32nVVqmVaD#p!b*A1FWMuY8P}3InS=x7mzFf@|-EJ(DMl{`2U?}9PLg_j$ zWMU~Qq{E9XGluz=Lh@n&yzb-$B)l;ZqIJuJ?Runy?$wYI6RLQ%n6^vjVM= zZ@XD_*Rc1)vQ;_ENNzM-3jR8s4>w;c>r!b$igG;bLG5+YXKI>D02;w{Xi}=t5DZ-X5D}N?g>lyl_uhl+=@>lp^ zNTpE67BHs+jae)#xC@=`d--j?@UX_g!S7VAlE#1iM>KtsIB)~BEZ_{yBzZ1pEwyWU zVY4s4DA3mP*rqojvtt7Z+VZdJxD7XP|1qY>tl(s=0#u)AtqiZNBGc3!+%}k_s3VD&DYfp-Vk&^~cH*|X48*Es|JAV9*7fSn|{Ra}rRIS% zxsh{-Y3xuT=ECuzm>cqg#bto;#%3rtJ;3PfNM!yrgK^$NxW!s1n^jE`N*dpDYns)O z?hO8A1a0hgcI0+u@0a^1&QOz?S&w-oqH5HL5J1U#lPTnpjP=+2Y6dL|&kwLtY`IQx zORvpOMXDn0i?;bta#sg4j5P5Rwy9j2+DW;RYV*We=r1OL4ibu6MVtrGqlU-6nHhQsOwGl_cH3A^pF4b zf6}=YP-JyS-$i5?J4a9InI~pbXh9Oio}DTUOA5C0x@*ST1Ib{jnjbE&Kgna8(vh0i%8=hyAWOMaPeuY?gL z3(9uz?S)fe%V)B2Ud~tMJkC+Asrij8y3GDA3VY+({5Z7I^q`UMu!~EwS)4Ec34MRF zgP$#9-_VragdqC>Rm5DCMKJq`yVPw@QA2u&P$X(yvlJnjC3ir5i_a01zAVxBqGi8m zrR%Z+w%Z2wWvNKg2Kdmzg1*xa2Tp6XPrtHo5dlQo~P#jGNeXIhxn9 zdhFZ*heXf$x7=clV?bab>VD(4g5QaKGN}*OqQwdEqFXbYw8@KKh`}nt?BDo_s(;&2 zJ`F^_CWX~CLUMB9fc6dKri(k;Fsld%%&1Hy^s21*(OIVgDoi-{vD0T&&3h=>t;Q-P zrgVZ59y|i08rEU!Fo!lM2ZXi*e>xkVL4z1{Ov_^+Egy=YUPRCJn2qf$^&19i=HKc>Tl-$&4~aVifo@#lnW<76^sa{3d=4mXP@MD#^qwI-y|cYfHv~zsg;BO zJ8ZRi=flqKq{@=t;CD$69vD*FsH^oQ1CJ9sKU2cmtuW_5ppad13{n)&w@v6`44)vF zwM7#xwB!$A%-637d$o%x-Id3hq0QKrScZyfbMt((Z}cpV&Y-f2>}w4(A(awBgSppWOrcfg za;ZMcD^uu!T;JDeht!dIGtyB`v1wl0lifeDT~w80H7PQDId%3Pw)r66W{E$uGWc*;}~7ARtfO6SmJ)_G#gc2&Zn*_Y!F_H zVOIYe|0xoNyDm>A9{6hBJdnE5m09{gdTeH!?-DD__H&aM;(`gIvbIZ^(f9MLCZ?#- zvI1R&&4`L7`iO_Gb&>=;zMS>xkooYs{je*w*}n{ZBm9edin}@UvQPVw!#Rys=01H| zQs6Iq$3!iko}u0Ao0Hjw?T6giB}}mrqbY1(X|@G*GM|ZfT>J&VDK``2PBI4-6IzA8 zsL}6st8JVLDqRLTM)I@W+6AZa`O^Qn(6Plg2@}sxF+9#e0KpS~ zEnvOJ>JdoDpUIGXj>;j*hEi#Yb;6WzF!>%?$%e4Kw9~$oB>5xZx_6DaP-;{c z!CztXDJN;8lRWe1U8$Z3>FEDu+i<73}c7wv0inHD~} z^xC3D=w;YvsFiVmJEPtu;yGX3-MQW(RzYUa3Y_EO7&Lozk9hX4$0g5rZ=Jb6uh_5uUXYZN!;z7!CPJqE0Ss2LcEGUI1L;(l}HaDlYBG(bf@cJk8PqEU%M4g;4KOFJL!d1mRhLDTF5y zX(*N^2-JRG`ywj0r&NN7c|TaRyZB^&@2`Om;K{+>6zA#9V^?=< z_+#s}08nJ}+S$9?e}&)^o+s63Xp%n(n8D47QVMvP;!?1m^xHct(Vyg7gU&BZ9_Hbyol@#|49$l#>QTL2;GCN10yxBHh@E!mgcrS^mw7 z49rh){^eV&Dt?JRq5JqhIi9Lkr{Aqd!p<9K;kg`kLLlj%{@Cxv+jSor;L|tuV}Eqp zAAXtz|HL&#pyb|d7kU%c&;f@g|xWs(r%hjl;U0v%y z$Mlo0T9C-j>1kx(rS~Wy!v?)A1t>E}k2Kkk)L{-T(FA>;oDhI($V-gt4J-eTKP3z(RIN0L2c59qM2ef0vq$PlA119Oit$a=r$&rguCFQ_=&SC#kd zuxfv38jWz&-ZK2jzw-0M)u`i;>AZEfMFVuXC0-_*EqSFaf=>h{pXzKzCGb`Mtv_sF zwu4eq8p^48N-q}bn&%g&lLqyv*4B!j&Be^5Mx731{>@*9k=m}?EH>B-5knJ9dWhc<|4+W7nxzHZWUNn85*+|gWb1fL5!lbzqxfjzpUg+NC4j-%%dk2Co?kB!#TKBTcX6O{yxMfO*6p!x<^=*lzpeqdvfGKuoW&in)3QI$J6mhF&{Th?w zsBCuPDMmum{Gv1+<^Bic3wyr)nuR>2V<#U;7Zz-SxroB#j}*Vl!moDe4R3TW&8B@b z5*de!x^jcxYqsI`)8CUJB+5v(lqX~mFOadg8p%h_ZS9Nl#PE^PxML$$of1(rz{=+k z2=w|({m4F;Zi8giqcg@R%0U3nj3 zYUZ=}y&E~%+q&5?eP>qAeXpx#MAJW&exg|&W>zz6P}_2R(R@N%W?QD2%W3WldLsqa zN5eC~sdpODSFDj;?UdbJ^!R0Ym|wS+8o2F zTq~MaI5c*ca%e&BrCPqFuF_-5P&f8m53Abik~{_>vW6t2HOZH04INaZs`AL7xR`(~ zlcR7^F^ZS;r*+G5W&%YlWS}mQ-2q)?{Gds);j*8f_DyczXErK%PM$yKKf#~fzlFW} zzMqW$Qz~;E6L=AEj0bxzKmm?CGkq(ryYZPle-Je@xc9ihp%C64!ot9n_Umd^2iHYK z7e3)vcokFtKfVnW+F0OefVAvRS}*D^&#z>4yoAcouz+ay?Yv0pbDIL8cryv^wdG?= zdiFjUG`a`tT##)1o_ZSl!0VR+k{ApHiKNKRyJSp8_oO8C+kGGezw29qC2>l$CuxaV zHT=sSG6mZao!u$byzm_ZTKjJsoU|Id_VdC_dcT&=bD7<;c0Ct=NO@n?dN;%Jpv2(H zp46;nClvap{X$-JE9xTMo!GdAj)d3(aR`E_eq%ej9>~nm=gv(;;R*3@(c#UOu^WKs zcS{3ShY0sm)N^=LB}ohMID)tr+gmykAB$FKguOe=|=xn-`zEvJUZu0ueW zKQR>@d&+!kz9!R8BfdZ7Lx*PSd=Wtvs)xnWQMjS;`&%>ffl}*23B_;WcwfNEZXPE8 z%U|c)N4r;KT~e$hILE_R7c7)Z1%(PUij88Lo}M+cpN_RWY~t$cV&3)qzIJ6Xb=|Onb9e2R<)8GV zYZQ>7(x6ZKJo4FA)&|n`-5LVjO?Pbhji-|KRLkz)3t-CcQK!!B(hM5+dao-l`m1vu zCyZfC_Wi|y@q-Aaowd$0{d`d9Ld1UvJ_=Lj25n)|V}HSM6fJ#FM>3mgy_C!Qbaat| zsRhf8e0k@}-JddNDOPA$+`8*SKj`c5kn;X=cr~mn&9jw_o<-Ppne5habFLcZBKtvl z)n<5BzsKs+O9g|Rz>Je0k9l+Tm8z{Dc~WnDS&Vt5DbY?bqKO=1SxE5;`A-SLHCVtO zlY=-CpexLb`+7qBbt@^NX>v3HxX>_G^S~J6nSWNB8=M;1^GjF_4+}QA9|N=!at?E7 zyxlLJ5a62EW2hdkhqy^Ja=U~Jlf(TxrEXWRXx=gs*{*P%`waSw>7DttDkISnBrTey zKciHEXAhZ-m>GpcQjz7TL~-z+7xi0c|E-kn=GRWBo@KJ!*lzJ|V@b3aK4NnB$3dE} zohdx`kp7sTLJ>wUk6?y9Xv^WzcEMHrpkz(jas+0=F5oA)IZF;~VRTb<$owe-s5i}ZZf~Fj0RfgqmDP7OoONO68qw=GH6d)bwg(ZG zNsN!lC6~nXoi(Q>A8HvfE2QazJ&!=G9z$o35>5A%O@NmXsBXo@0ho(qb9KOZ^iaUN z?b{{Tz41oxLYGruPanUn@=EFt0?xsExaZ~0oI}QEwZeBe+k@HB8qWP^AY5wA-=5z@ znNGEy8IVF#O-PKN6G6yP0<310VSG`HnecgsYqe64q@PN+{I;x}YFNN$f*$?i4 zG+NBbfDBd&G^Suf@y0C+BJ9OP0~&j z_ts*9B!OPW+t(OjU2D$~XqgN9o@L~9nh?)sT*wENVA`()v!u$?;^#-q4^%8U+Pf6Y zxo$?0*4Ok|#f#B|PR<{rtyv!Ye&45UQ2UdHjMn^;VqWBU=$-VPylExaJ2{GfbGAEz z&q>OU~*r$PITy)$)6;-KqDnJm0Qco-}Bur5``Lg=l!59u&`&7rkJ+`>iaAcCV3dxFR zEs?bHtRsE8f$5-EeKdK+7AhB^s=xN=c2d|xuty@oM-y#q?Ax3f%Q(N&JpKH{r^8{9 zb;J^9{_%Qj`Pb4l+KRNcYfcCACp@&FH!to08sP8!ds6r^R@GJW!-~MrUYSvc4YB%O zdE8*epmE)a#$OV2gJtjHwvb;oGQ{d|>&AifruY_d=Ty^MM$>H`?Xd2ta{O-)y-eyx zaffC9FFdo=aYOh{dtftZLBiUYjiIR3DO?gp0 zIUeh1htC+w3Sf&rG*iQ8=_H#bHzL0#%!dY)HFOzM?{Z=H0^`>~QUvD)*H(CFMS(8otF1kS_nuKxQo^>5wcWUdS!%o6H`Tr* z3B8}(Fd+sOygEH*1&p-ny1nkgOoGD%Ql}uLRPoxg?5~&ZkL0)jFUNTk&8~fhFBd+# zs1=LmA(B7UVBF9g&Vvs7l-fE+DKfEB#Z$~4HV)Q}_JIs;aZ)QV{XZ^>Wk3EEksW61 z7ru5l>uv_tvmxzCQ8Ihdn2^Im%N#Eh>XM%o*AvfG(1AA{pYM&=mh;5d+yb?3)ccY_ zvxi*kyys1ok5num(6ln;s&n&v5_DD;vNWbuip4%n#L^qvo1FPUhEeY)aN{5Jwsded(ZbFTvdV4D3T6m&>| zwWCv0*Ioj~-#)5{U#V@XEc$(6kNs&^(r#qYH|ZQ`3Y}K5|k+)V8?hKS~LH{s5*vp-e0?&dBeXE}oNAx^DB=0|T5*h=4t{nn&)G#Gz0? zF%Q>4Ql5ZgC*a+M&_}$*88`H;5;|D$k-6+GYb$D%FhaH4sD+klGBKJ=eUv| zGG%=9yNX*9;l%~?WKCvU^9;Kj%lXH@Z&hlrQx@OPTa7{9mQ5ATvqZ%4mHV5C`*@IW zK7-`Yqgutue=AAi|{ADz3rTB_a#BZw%)>AGgiVi$=tbD$VqQy zg|t^0i8~=+#2cbY9_GQ+-b;b)^tk2gUNSis9$l11lBN=urqU}ax!Jy}^S#JXMjsq9 zHIGXM7J)&Rzf!o1M=V*aW-M99dWv!IBM`06P-{hR(q2q+Gb67@@s{%DIgypw#eB>X zffbNw4cR!sR`A0hi#-apWg1%vcqp4ae|q!+4=mo`%t0U)MPX&GEM!&<>o1mopLH6r z&gemgD9L^CpqlM*t$BkVX0l9>9Z0v>yzyFENopsaVY3ZB?VsZR|4{<{Xd#7SB5#4B z%_Vd7!?X+<_MmAQUl-hh6j(af;}aOWzm?DtO;=QE=-TTuuz>)M$?TBf`6?dZ@sL|S;-m1ZnTm>y&+kSlzZX71x}14+G=|;E?M+1v>+?YP!m?{-{z@a4Y`xOZP&N&TsZbr|$X z5?4FN;JPz-lO_x2kZ@covud1T| zM$Kf%5D)G!E;;ibDV}fK^)LE~K7nsu0z=T0w18sWyla({*6%4=<>KMaBX+vcd-g4b zG>gM-xvaV2aFABBbx9)42OK;39r38npr=wwjTKSk-${bPGky%A20^g$Yw5HV zyUNXKKU^IZ(cb`R*%fDNGM(|pMJHqUIC&)MjrSIzLC7U1uXs3PI5!FnuvJ>r! zT}oxYZK{sbV1U#Lw<#A7a0GD5aP56pdbzZaA?00@3nKIYDFjaRF$@BahG^KRMn=qF z!$aejSuIRLn~8;;lL0T%K&XDs55m(d&~y`FcItaFW=8SJpg3$!C#59(Yb7d+ks+Z! zYQVsf)b_o5?weAkhi}4xvcAwHRFj0RcS{#2eOl9=S&ccSU0VwD$c_U!q$hD)wa(nYt?1{B2e`~$N^ z)$f&iP7en}5pqvU@E#C$9#6{MuG}t~YX`h!LIRjJ8o6aDHu^iBn$04fi<_k2kRD}g z{Df&9e<|RcUh7{jExLZfDj0(rH{coE7M?ESsBBzmGrIFYNvT|Cj)5I|%J}R;8;Wit zs(N39WR13^ynmd@(KLZ+CFV3SkThsmT11D#n`I^*)#es&H3GEX9$~!mKH&3LFG%s} z2+&#}!MC=jOXmA{PSx1$@7!&)2zzc$xA{B1rrugNf&>&<+RE|#@#VWVls zPdj=#nifRUr3_}kC@vduJ;g7IVedauz%<*A)^G0%%}Ul!a5a$CkiFRjALE@o=>4F1 znYnYna*W!)2tYGcRbmyemzkzD(XM!yok_b%(>@CH!Nh+WC)3QlMW!h!#rl~_4q=3& z&y_*5pHCSEtq}##qV8^4a<#8C$6Gxh)kgE~7Mgx)ku8gAVPm08k)TtJ6Xb3Cp27CA z9e_|0=;K|nw&_2cu*QjzCdtrL>v8JToO3O!e{QR5JP3vMr=At%u2QpgJ!27vKj0R= z6+l^=qrwNPyKs=BH9IT55Z@B_%;E%A>9uj~!R0@qoAs2kgw_DujjN{wS}YV#?h~^g zMfdqgk+F{z;1nGS$#ipbPU$0siXYm!ay9)NXshPtUNXe%0Xg){u(0!E>0< zH46K%Fd6Yb0A}7MJX7>EN9r1c;WnEKKA52*A1ku!%b-^g9PU~{PQCIAUuk^C+Oh^ z+!hSt1dkF0XfBXEq;by!xs~k!LF9E%T%>=L48w{)o9XQC&>qj=j|A5U^6q8p$AU9a z4duq&WKGxNuGQ!GLk4@6xUo%?ns`MZZB00%d7{m-x41u)L$jTeO1}IViu)h zmZEL9T_|eicf&Zfdo{uY2(i=?(>Ae0fA%3LZmaD%D>|df~D%w@3gqdVd z1Bv~Y&Q5{n^G<3x$q(Xt7^l)@QU0X`tTKl0zWX|>MIPuEgXFR1;?Qf9i;An{7lOEN zZ%g=c4*Wa@{lyT%_$7T@Ut*w|T%JS2b8hEC3`c9_M5FM!QgOLu0F)#ukay`digfky zzZ8%@BS|k7AJzb$RG|PQaDj6I6$HCfJr-2oue)u{CYwJ!0{P5>8o8E%u+}6ildk^f zvxL!5OZOmmxdUXjQT9oEvE@?ic#dTGCe7OHvww%2m`nXmV9${FU!805x| zOVfs`7j}#BK-jNwn%l4WRJy@=e%`4(i3^4p}oN4-eYz%Yjz@xdMYABh(b1 z9BG_P}8ngc=Tl5x4Yg`<>*x4SgjK$Q)sOLO`TCTcs>b4 zoO|AOw}sh-%Iw7RcnTF$z}q`l`t4)Nz)|ZtkBjOK+w{j(eBiahyK4|3NRQ=5iJ;|v zJ;5a0OANOOYylW^{9b8(5pkWBN9smk2f@=iXEhPbEMuanoWw6Kz_MUTr2!RTEOKcl!!d1aY;3IN=y;4ZZ{xiD5yHU0{Y=HJ_m zp)o}&YV64r+*Ag4-ya5!Oqm=Q>XRa$dI-V4Oal5~AgTA4?ytgAN0BbcyvvsGva~W7cw`Db6IIVo|`Yt;tNeyXJ1>`2*g; zFJ};7WSPt@g)~miPT}ZQ^`At;@j5;sm=xB`hy{>RGN+utArWNWJ=iwMpZ_lbesB}th+y#kbgy=aVT^~m5R%lK&Gf@6cr%=#^&8i9=%T_wlSC>7*tu* zb(sxo2a?}L>&OwT0#Vdm)rkyIHvCn>z`iB7Y|D>#A=pZ{0{CEB_K=&A8E`bccSFxS z6X#&Y(zE&879(7$zj%0aA%a}4b+<&P0fK62CrS2rPxeRFo&?vQL6Y=4rVc%oT$;RA z_8+>%c4raL?UeItW7e2yS_^9#cd|QTU!}h3cVo)ZA1^l_Q-=tf4UkJE?}stGwBLtX zN1WlC5tCd8JqOLkZfIl0Ex{F?=$T4Tzf}OfwcB0npYty;I;*{8;8-5K7OGiav4W8` z*l$5V;<(tL2?+dR9MFqc@|?w(0^;Ck5m@Dtf3$%H27HO86Z>U6ZntJPp4yTdXEcqu zaMZbsb3t`|Sj+7yr#P@d&;rXC2lGN4bqTBaQTXQLQ8e2aaO;pO=b(-EJG`*Faz|2m z__dOg9Jb?hs4oy4Iw3C^3a%#fA9^E{=KbQw3j5&5+jaWlu@BQF&8Pj4(`Q?A3mX6mrL^0veq9TC>MD^u{!r z4-7x`{-)q`CO+AzZEp3P=F8L%=fyzJy!tSnTZvyMi3Dlb9hVr3&op`iFX8c*@X1nG zdOS?!A)`lm%I4EAn$9PmTr}6{T4!Yxr32`UO%iwznQwjFO0%d;`_uC{!K+Yy`y9e) znAfdlA&MZi(PhqV$rANo#9{C78?0&?c97AejlB@5{<0P{dzZ<~mELS^;E^3}v{r8; zC?>fpp{lq8``sUgF)=lF`)@T)YBxEBHnx=jJ(Ei=P->Odz^&z&wk|>VOjjyor{eu# z(=!50u0iz&+{`ZTxchI+AlEm`hswYe`BtN!|LY6)(#=HW1X(rCy#ZBQr=`>x{mwI< z7UtfH`f(xdzsG&lqQV+Jq9U(|fcj{s(nhrxV(GVNve=iu&kbL4Ns_hH`|<M9SBt`MBAxJ{=D?J30{lO3I5pO{Cy@{URncPg2F-yqYpd%p$A?W;spTgnj-b ze-QHZpO;-Rxlb1Sb-qp(ETeeL#nt9APx8=jfE#eIJSARc1?MK!zA%RGI zpSPY=>ZBadU9o~fi*j~iJ>l3&$W%M~?zy0T2+)(S0+=57!|fch4j#F=uO3VrMZT+> zoYIr$S39cON!y-E=j5+=Dy#EpbH4}T$j}1s00a5IIa$^N;+s_qOm(hpH|$rzbJ?)n z-`L}r)M*fCPTJ2kQ~Uapn&OiO!Mbx>n17+Dnky!Qt)s9r!V3Ck02ida7=jLlt# zeE%DQfT)$uPY-Jf51-^YYu==CvY{ix!A@*fAmmY9zMni!1~_FB z9rjwMOlM=V4;S3sX8%ICeu9|3uvLC$sWk0zYysDJcPRW-(YkI*sW|@*rjSW~@xOzu zk#zxU^=l8hNuZ6-$glOB++0aCWnYrex1*c(_)Kue z&T;z`O<(Y%@Qti-X%-Xe3#K~>a*rI1!~Yz=sY7W~+w|Zj#Z(nNt}cwa&lVR5+V$3> zWT}8DX&Clx`!k7~!wTreD;2kLagzBI;wY3;NwLKnLk8hJdD4+}KzkXY>hlduf|>O6 zSgG1u6btD=I!vv$uZtsl?peMCLY^~2_A|r$OT@+|b~JaX+laZ_6b!9OROQ1A{8GQ{ zUQRXr=H0}i-m|xu$H;ecUG@zj){vM)SnJ@4)7#?RGiuuZ|B>;51Dt70+&r=E$~aBX zjLTgqCt%}R#%7RQW|#$$h3@Ula{?PXdU}EZCni@1)}2HlN|=neLos9c#5nvZejfHk zJM;7|)J9n2i;U12t=Qees78|)@JH*%U#|5vohfiu8Xbc)CvCQt1CVL~#_CxtQ4Cip2-)|JT;=B6Su{22V z+&mOZeHv>cxh{(f4}L!EIR7J z2ABXD-E7P#Bje*Y&b%@lu9 zMxZDkCBOfM$`2RilqyP0o=Oq+I@&4&Q`_*RT9@$5 zZch2=xXg&Rl^qSZQLZEU*1AHX=d}=<-k;_xgppe6tmiQ1)-3f_3a5?)?q7oYMa9ph zUq&E&a>aze8naYUKWC}sa&_vmf_yh?T!J&LfzVB*S7jR1f6jj*BzyOg*Eze|G-}?b z`amJ{HipbPS2Fo%6+U;Fp3pJ=c{37q;5?#C+HgUT$7*k7PQPrd-$HdNcIiv4S=-8a zP2SGH$tweuS*Yt|V}fF@(PU`=3q4C~k#d=Om3$G+4W+4L*GBt7kXE3eTl5ePd{#*w zbgJgSsfUjTg*~V<|NnHggZR_9WfnQtGNR%gZn(w1GkY+E?oH`IY?_^X#w|WulKlCA zs53lna#Lszi4&)98>qP!E0-f?q;oJ;O7KH z-YN#N8+U1Vk{QT|`5{Jql?{RF2)Ac;0^Um!8i;E#Y$T#c2$?R8uFgdDTg{!hT6S+~ zqH;96j2mLn4E`&_Zv2oY>XKGs0iRqep|k|+NInf9*FL7;CM+x?#?c$B1penhJl1xc zTI~r@$Qfuio4r%5ulouy3A;EJo=6b&!$^{+4t3!{Pj1;KuoFobo5E?P9C6jhQg!pa z*Qz>6WTK(_rPy=^_YDKzT2vXqx^(0;jLt+YX}K4orc(>2FE`uTqqO+|qn$PAK@NE2 zBdP|`X5^eWn{1^fC7@3QSpn03H{1HO8tn{Jrn%$$FI#LCBmAq>o$J43MZ6i0o1kkP z&RKn?5V9j+ENi*a;sO@aQ3w%UVi-vd;Rx-w6D z0mS>{a|^3z&dbT4B@Yax!L0x;*yH=Sm(EQ^ZpLW#vX+0_VH5>;31>8BKPkcSeO2nl zZ)8eFctA+=tf~LI0NUoUpD{7TGc}5gAtOPGgqsK>4R>!$jm?+InF#Wh6K|Gc#=iVk z)SU^X;>-=KuF^A@B9VeRCo9tKyW^#E*L0+NNkZM|^GcWn8l8t1sIKNGr3IUtU5xd< z93Kpim5z>nYrU>LBi!*HyCI97>*juys z;}M!)0uQXW?rn=K*w9q3gSR9QfmIv@D>MnUZV`5j(KYW9&`|%Nz4i6W$8j0*nQljSj4YlgWYoDQ!tf6T;v)s`=)!i2{0J9rtd-A3J-Ysm? z&PjNFA-!Hk$^(*FE@sTu4-Maz#)GZnyTs%$O5aFyo;O4y^`x2Eby`=6inDIlUDNEf%zf zuo+w9>Vx)%_T{d=3>NOTA*U^Fvdz*#iQf9&!G>9t!gZRyCy9&@8wdM*IuhhDc2(Q? zA!es%yOI`%d@+S{BF&wA?}M|HJbXryCl5tdfCY}UUOYthbb zQAF-2tx&Z3S=?>m`~mG*+u#`PP_=0zk)0~Kf?^4g7Q?JRwzHtuKL_D9Ggq*3DF+9r@?a7YUJJeke@PA z{>nhp{z&E}2Y{t^k2Ji+8`?Se* z35!=BPj%-$byG#|7{lk0ods?E6>XhrY+44gEU zeD{}rx26LWJYHtKgO0jj-nIi>Z)#t|@bE!B+jVz3fxc$_c(%YtLzK&T&}szpT>*c9 zau~>;@VWEele!lm5c9VA`nh`>B-Hx0*`o2AS+$#!m#^(Tre*={f!Q2rh#j~vUdv+g z8Ayj%uyr;KZ`RGeTONXSh$9PJy{-xctaNG27fc=&GQ2t=yjk0VXvYYJ;{c4W0o6}e zjX2y7wUqKz=UaO*F|L&+XAX(oLPb5#XJMi-K)q2P-nLe~HIt3fqSk{FjCd0inVd4J zU_?i{o&D`d7x#m{N!E4r^~qImPkC_{#$F`5%YC{BJA;IDqh3#!t6kbglj zIS!%8(wr^StMi=mSh^rg`;Nr!{!aS*xUMT^h?;YTszFz@I4>{Ho+HLr0~kjpu6V81 z@kY1rxMTP`4WXjB?vA)P;N8Z@4|}`7dwQ$AFFUqz;vt_eG;$l|&zf3>3)g{Nz3=!M zNINgE7_HzNMCS&K&IvHB{m#<}C~(%`!q%a2Fs#t8(iB-i}w8+B|D z^416!pm#6QCjyc2?>#Hmphs&10&qgJ-EM8dVx4v5L8FPu@$>HK`kSL}v~N%nFVjce zEOhHAk)fD`^gpkkFTaE`nVqNuc$wgk=pcj0$!s_bq_8OJ0mR)w>?_x#t&>Sj~xU$i|g(^wCb8WAG2NV)f=n$E zS#&9}5kq=aop}uO)F8;skst2LQ6OiC6|u6>!ZRSnrG=5R(1t=uh^aw9hcO>WiIh0NY?v5}3Sf2tPzCpVvZ0lOVT4XgPm?5LX~3m~Vf+l)-`Q8k zB1?%KoEX%%EdvFEATCC!szZw&9wcIENo7%1x{Z4Rpw??oX!q%8Hzj2Fp@I$P$!$lI zQTFj>(8v zP?@k`;?u(i$&ZbV$jQm{Q6;L$@JJ!BM0_3s57MVAboNw&63Pe-O=Uo5q>Bn8a}lHq zNeh#e6;VxQfzP$$F%wC|g+uzxNXJMKBw6%H1#?OsNeqFW3Oq%!-#O#DpzX{-fJGh= zD#jAjV;a=K-QETqi%69mH#2QOgToG&l$RapF@?j$5AGo?qf&9k)PkT0lGtX#K#1-+ zPZ``5Q^SY+7<41E?Su8e{7jfT)4ht!-H+Bf8k%E(1}BG26_z;Qo`$I>1rwJp%KS`6 z5tk;`Z&{}5tP6FaHquv*wqBZ&EGeqOf_qZ{$?VJ4@MISiHV!o?rZOjE07o3l9Ym-|_K^psWD_`Hl^|LC9VWb`6P&3Or@a0+pqTPHJi?=DzU@GRVH!?_p%D zkMiSa$PG-Vo*sDnk)~#H7>qqr7By)TW+A<)|Rc$8A;pW{yHWf`;}JpG+<2;w?` zDunon&Tto!2+|Umn2cdz#igb^zV&l9wz(v1xV8)s@k6)ZEnbHB3JCEzhOL!R13yuq z!Uhb#$YNe3o7brz!O(-Jg^rS2?5Od;Y`++aiS2^wJgKi#+%F*$XlL`WS7Br=GW{8J z5wKP=Xl=8R`!i>e#B#7JZB(PUu*BqS8%q`G7+0IVHC@^!%~3CaHu)dTeow4#w)_eJ zx^60OW_9G=3QT)XJu*DDS@-zSYG6>ONO1J3%TkM+ixVR(M^W1t6bEGl_PpVhdm*Gn zL#|SU94A=U%amIntfeZMeLCi0BfcW-`ILzS5oRk!l?)EAC=+N4FLGm@q>D-Ty!6b? zG!3T-zP`JxAfigGvcOPym?d&+o*bwJ!^4>s^vDg3Pmv^Jrh1}KxWMil_?u@NLZC+z zeJV8$lQeJ%D_g(An->gL@$DV2Jn&3|E64Opa6e@A80K(a<2$^DIyp*WzJ(YEbT(nq z1Z8G27}h)ZG-Pg|gIswsOXdhmJIgX|li+6q5o_mGk;Xz+Xg5<*t_rZq(VJ~V}(FF%)kXCwkRc-FF4=$Afn(%a}m7oqfmHq z=uA-6x1mBrte-KmxQN(5zw0fLVO# zi3C@Ua|jSf3N;MEM35B%I>nWiSQ0W};A6(pyBB)}1*EQKw>zJyf{1 zem@&g=^{ni<6Cw873P9lFpU-LnJL-=L0jk=m*pl3)_ovq8gm(EoTDT{B4HdPEF;QQ zvN7B?lY1^;%Va*xx5~e!8w%gI^4*z+tck@c)U}ntoaGQap~Nf)TcUoXukE`HUL*_? zmM#)OZ)t>SQv}%~L5z~vsmyk&w!SfzK>}7dd+@Ttl`aM~W;V9zb|I}RQcNv^&`(G< zutQo^wXukj%J5!qa8FiUkd?4HX!R*!Rq-0KuN6r zW+V{QEwZq@+M&-eKY?o_4Q^jZI)cTLxR1+_et9{4aJ3vr$)5D)SN(2f2WngwxnKTa zY#AP%oScus@H)N8ZNH8kd2`VgzDO56O9i28Us=waE zE=J&I)>>bgDN7NAN$OF;tZRhX4I1~)8LU3Fz=FaKm!uvVo*PmGL?kz{Al4daEQ~-X zFHT}elGS-#hMoX1hNiEPmUH5aBNEZ@SeZ?qSQrX(V5=sBBm1wGP1&D_H&Hu?xQl^Itau` zsBj6_u4?#tY=8YM1r|F2wnq9%p?E5TV*i$8+6AuRYvt*0-aE}S~tSEb9y_;z!atyq+ciw8NPVJFNrKpVknY2Na@}l-=|2}C!*OnT1H{;Cj%7p?Fxk)rJoacNcRaJ z8YM7Pzb|kBNT%~)J6G1Kbx_;BJt73TGQCC!ZG^4aV7P$&o zg+oxn!H_jrTz2w>9q*3|BIH1|==zd#qH}vq&0ffDv52hi^w=`hbZIQHRM`XO_22Ug zB~8e5aZPzrMzImz``gxO#gQOeBQeTp20~|`w{Ipmp7`)-UpYz+`9w_XtII_Zqazi* zgpzS4LxC=3#7My?!Oih>{K~&QLXgt)Nm9d42KhmmWJ>JmCK*aFi-%o5_x89;kRr}j zeI2o^lL_y^3fu=PC$(jruhZEMwgq@C#{B|4UvAx`=2~=uWXU)OLEAxy&R!5v=ejT0jV()kY0~BY;25xMLz$?apEsu|rO-q2zoCJZ>;v6yj)0 zO$2Xag*^UVaFKCGP4*TXs7h-HmJ1=CB?*qHxK&h#ykAuiyM#>DX#=O>(HaQbonczT zr>_ZwseVJ&dnRbQ;kHRa-*=iB+bT@!7X1te(fZ$)4*uuD`ceJZ=Ls zFhX|m768#@`<@XMZS)S3*yQ_Jr*wKY8O3HT2O%}0dZG8m6c}<4i0fZ zN5>>m=3=B%%tObE_HCf-nrl{l@ua4pcq}o0@+1@pwWh(W?G8`X9voYl0~|FB z3i5j(NnMf8ELGnA_Y_@$u4%HFS>iZ`HXR}p| zLuFJY`#$%|42pgs=vAhv4m@2JNT)wHUJJp4SgdYc=g~2_F7Z14CbVlcx$CR{gxh3) zWDQmONk1-7(pvg!p9IOhCV&shhs6j>L)PyrOb4$C`Gf+}Tt`sKHCSOwNEd|V&Rvix zBLj8>EPmV|>;(*e0J#305Fr8nJvU+lOfCn6Unu-U2|hFkmTB_fR(vP&%hR9>Vh^DK z83B(N2SlDC9IqR@G|c2Y1FG?HD#;M8_|t(4gUz@zv2}Y=BJxA_ZvbS`!HnH?Um$}(R21et=Dj|9 zSKa_SRJ4;lVe}|5D()_jG`0A30cNlOuu&6z1Tja1i~WgY2*9<8*y$SgVr71OgRLt< z#0+_C57Em*e&@}(Imbqcg^LUHkf0xp6!`?iS^!pBu7&DGN@0>)q=UHhGUXES8Dk^N zc-NFh=!yMD=%2a8w4y+wBi}=52esnU>`#Llg*(7Lbt0mbb_DM0h?C=4{veb6#+N>g zgcCgPp*r6fxVj41s*~mUhYmmt4!CXuaU_A$s|xu|;zO5U{I#f?73VvH2y9iH-g zS81fPg*Z7+lT^={jIy|`F^u}x1Q4i7p)I7z)toVZDLBC>{v>#)Q_dolQ}T+h+= zuQ%vBw8EYtfuLCdWz2IVOG0Y2>XDg1ffKWpXT+yjdt&lrn=%bJL<%1!%2G}tx?L$* zj4)(}!C<%nQ&-(4k@m!)qk75loF3L7G_9Pev~=~97ZF3SYIsc8Y~`6k6%?``)W~{r zg+UjimXJ6hlCXh68_8>E&&rZOjG!9B<{Zv*$y=v4q$%j}9#t0!wltCA?Q5U}TVqy9 zQZ~OHZNYSz=;_uKg*-m&4~d{a$X09?c^us&?4gP<=!P8xj4Bk1$I^5o$s@^GiO(h+ zQeL=j+;?b!Tp|4kh;M1eWV~eVl$aZohi=EYiu;Cf$wlt91%`ol(#F82FvOANwBtts zQ9Uai^=SC~9*a3$Bnw7jn7m1iQ_T0H1^&L%m(sswEakk zI5Taap$;If3EJfgGrv0djgnzXOvG)^6PUBcOcUA4r8fr$JS!_1%ZZyhHj#3W{DZos|Vxx3GAY2;O%prlj4Kkk(-lVwc!K4A8ll2G!_k5UI$52$(>r2Z3eH zoCb(mwBU6VsL5M?UT|!zM%30nsIB;|7W~!lH($-@sX) zf3aMRQes$P#=;dw1!t5@D=Ju#8Dud8(wW?rL6%qr8XE{LM+T-dn#_x^MH1V9S-L=x z;{=8y9*<*&M?h38Gmtvf9ZF;d6IO(Am;@Ed`4Mw}AN3C?3C>9yz~R99tpO%Zi$8NM zzJ1X)TLxt^0ngJL87vLkttx4vrPxn@{48gV1%KS5yFDh-V`)+hikC831ovawmgTt0 zB5Hy>L=r;w5k&DYLMu_>?EO+aP535<2<`*pcuPS_yDfzzypA1x1FJy)ou!c7Jmhp; z#U0{;2w~{sd`|O5DBoE;IA1ZC7Hd8RgW5i*@URqoSVmw3hwKw?Pd$JhA~8Y)4F$O;$%-s98a*5>l(T>eY*M-iK`FOKoiGk?8kymbxunqs&j?}> zZ2t%1_h;4g}R9qGW6i6h9sREMJe&l^=3 zpradoYZ`|x39IiDLXK;e668A+Zf2l%)SoQoz%Muf+YpJczX^|to`5|rs(xFS>4=Yd z+Xj#|>xcI+oQ_^hOA}52RtnJ)a#T}mj3WsQiJq*WE(ih0MG_NB76CfaToNXH-|*}H zqS@dx;)kV!KBtp;2yhs&_F#6xZ~6{U_VWfPC&AV;L84ceI4}pHm~+eCJyTF_e?B>f zh-Rae8k2r`l2bujeNBx~zzP~d5VXbSB5;e~>NH~fZaR}0sF=+GdRGez92Qj%k=heg z-dkbtLWf5kk<5{w{b)K+sxBiYD^mg6mq!fN24|vgG87vND?#m$3c2o8Z7cWMall!f zA|eAD4YrO!iZb^UHZoxV$I@c!E{?fN?XoNS6sL#`rxBgn20II<9BkbT@?k}ZaJqjE z8Lh2AZD>Zd5n>PfjNlUq|4k+&BvHhM>;BVr^Dg>#AW7=N@x|mcI7A(O+wWp|G{K+@ z$!Y*)ltMmdHzLstFo=+x4lE~x>_jC4`~h`_r@MYOCLKk>E@&GnpQ%d;!rcy_MvmKc zXXOq)$<^mwY$;a?3*irm+t;|#cWChz;y!?}7qP5Y#6pmmkC4G(@dR5I zg7Qi1$mOlqv@kN0Y^O(Bs*wr)Vv5W;7<)G|JTckid0M~Xo6t57qX`t`V;Cuwgn6?S zr0$F%<>J`14Cga)advWP&0!)Oqqb|0iIb5R95sZKL=WE1pz;f(BI-IAoWTVd?#_TR zM87QdGBX$k{qS!Loga|lwu!)741g5Yg4wyqI^mZ(=m7=3A6HTl#j%76W zCt(T`@dwzUFrA*qk4Fd#*u9QP$mPdGjen8$RC@cdXx9;-3u6)W!3S$n1J}yx%Y8-p z*`fTB=)L6RQ{aML%3bS&`~q0Cnt~BXq0xdZEXc3LSZI&(K3)E+$3fK*OO&k>7?OGz0zjnRfJ z`i3z38)d5C39%(*+l4#>2_ghTa#P$<)eR0&9{6(^BtW@qjG~m?#~z^9E&Yw+&sTD| zZ4J6m6a^9}H|nNS;APE@{4|<;p}LL*8?iD~)?5?=wfI~}Ub&_>oMl~K6N|GmlU_FZ zWJ%Gr=pMiAuCfPyi~KGtB)Gv>onnF@AYvwYUG`$$F_=oxRG54M#H**5@7iG&CkE^% zXrK5Vcr@ez+hRV9=zNAA3~v?FdV}pUiv7V~huR_aiC|Q^Grtwjrn=vCDh?VQyat<~ zF@lChqM!tcw;Jjp0euwp5HCm!GimSboL`@Gm}bJF&9o>YrG(ufkd!ioGl)r{MB{1X zx`~b{^`gNHdg7tNC5m?y^_%$2=HedD)}WC@N6h_4dZPzyhj7vk1{l@kJjX>snx=V zhXn~EkNjt%P$==Bi8=TW$e(cc)QpoDW01xaRDql!uevU#xThKT{Z3q_1fB#KMKLoC zFL(-{Os=U@vGJ}#O;rtaj?)DRbsD%s1v0of3nB7+iW4;aec7aedzKW)+B_0j-pzy88}3%te1_J0g49GZQELIyuI53x z-D`Sg+zwNU9;#EvPJyQyfF6SgL<7ULLy*P~dCq+M?JIq4@;J~!8`N6{+Za`7Pc5}y zfQW;&+GirN0JmbQ(>l4+6Qm!bq!tSG)SR{6aO zHi%_3+dU&L??9P|@?wc-Ul_G42r&a)A*7;CryL4-z)QHr>9++cewzjKTkVf;yi%nvgvgtb5Ey!5UrFePq?}S>Qhp=CD3eL(7xUP~9zaEVlt0@9c%lLT{s$=!i@396 zLfd+70Jq;^OFEk(&}>2>Cd>y#uE7`2kdXacTVho@EW&XvE{?;( z(_;f^JN^-%tdjbQXz?@i`tR6CMv^NKU%Z%{3|L_-z{`z8KXBldH1;Wk;5HDtP@CY_ zi?aPNq((yJm9l+?m4yK08#I35xic=gG8Z}wFvu^52Jf{SePp2gy*rYJ?e2A&=NVXA zo$S^W+aCsD4ijNLaVTkmE0R(5zxPIAo{Pugk)R*r+=$M3H~VlAkRdNwLL8wfd|HPT zdib9iQ4#e0oZeR5AYMpc5Z*))8t~4Aros;Gk7Ql(_!nn4SEe9VkI}LqbY&c^N5qLV zRYOv~mK{TAQX^74sYmdt^ZWTTK`J?rHqjHrb?~2qIl+Vv`-ozQXZnHjnH|B za9uqaFM5_ZoYq7D>E+`r>o5eT962}edgd#L{Fu$*Du7sD(*jd@o7n_M-`5lI+_Yx1 zd5E0J#=H=}N94OnosRQ%AB=Dhe;Q&@J-0k3X6AUKPQb&5o#DA+twVcxm!5^m*jB^2TlPUvR>s71I(lNesZEm8+Ll52NB zYzMpev~0f&;ZcDh##GDs)~2Zx!ws)|MX7c>yxI!aa0W)Ce587{_+xk(I8tlQX~6?s zS0DzM61x6E6)+?l1_2f}li|7@crJ%MUS6-Xu~QlMc?iIn{jaskV0;RPLP;oHZ?##RBjF zGFe|ZHs{UG;Bo9=957b-5&_XtI+XpW62q?rU)u&tCU zkP}a{P_esc5ax`Ls#a3+C5=MCV3$k4prECgjZx3L*i$EoJyy)PPzofC4CJU?QzHHO zV=FZAYK(gzt0Xm4Z0s8x@afMb638MRlhuu&F%#)fEWI_CW&8P21Z#3Kof#g=_I^go#LQ?%Zw4WNI)NLcATET zZW(K`7emG$$DThW_9zPXkdHV6?7Iee1f2V3`U`iWpo4z=fnatc>qjRvTEH-XuzNeH zEAPeT9z(-7cj>ze#l7N2>`{gABSJO^&bKO!7Spg1xb8?sm#wCwvzP=>(?&g2KsyjB z*7NkSpd_rkX#iV7`VbctRd;nPpq>*2O^eLi<-%M1f zP;r<&3HaTXg6I8Fr)6tAaB;MieQ~T&>Bgz9%;}%#RCHu$hj5Rl<9dye2cG4+z!5>= z6(lo~Vc;pAu_obUO}qW@rd7prr%rUCMi{ecYMjp<_f8h}5_a{wcf;d}3qW{EvzQ0v zQniCU(4y?vJD;b@*-UTL)Ih73i65FVY=ZkS#2W<|s2md1b9r*yKzM#~f&zfLlmQ1; z{%n;fI`J|#QTQT(iVXciR0xVPxjh!3?g$Asi;#T3{Z6!p$LN$+A)FPfSHleoV=Mg$ z5XhJ5-IHNl6V4(B0O#dockZD0`L!EexU3$g@;#>VMMAVS>Kp@zpJz-3-T+U&AALBR zN(N}fmQX9@?fkj#4LktZj<&|@6Q5)??)zt1nRiYxtE@e*Udr#AUnj2(zvM!~ApVI9 zCVW$4_FM2Sh?nhfcje5j5L+k|&8~49UoC2E-!itBn5=akx)3}ivMnlPbzKvA36mwU zu7vg>C{Sl#VH3B_+Ex!AN;lW-C`VcncA*hl`m;W+a9R@ZKr@uE)>h9Xlc9`%Pkrbo z$L)R)%^!^u@Z+kD_<>5_MXleGNObrM)1w6q^F5L0EsV8?KoM(^eBxYcg58utx&;|1 zQanfOl_xaK>BW(+#Tl`__u^nO{$=AX#u>7ivK#M2Z#$|}rQSSzEW27MB<<#n#IjbO z-Qe0n4({1xWH|Yb4!N#vJwBJ9V;Y%)^e$G9?}BWjS!LYzOZiqLZvN`SEEX?t@IFaP z*rOZwHO@$gBVZmbIcxGs-gk~OgK*jHn~kG{OaG4);a~O?^$~_s4$IpeR0k(JxkMrI z;vvg2jH>p9hiFFd$B9%@9WVisIrp>-|C8$_+~PMDu!H7e3wfEB63 zXRN0xqPm;NFO{`#t&`H?O~l4^CmsU_d^Pi_jb7teYz~OHixZ2R{a9zrpVsKXI;~15 z5gZ%dLXD2nlT?%Myc7$$J+)%znG*ajYmoWyx}SSr{kw4Ztp>=OlNL|{=EH4H_bVvJ z3zANmFn{C5t-W{;M$WJjwB*bGG`Je0QR23b5nWFg#h&=R5~XJU*&1V_oCICXuD+7% zs(&-^DzVb=RYB$A`F?ngJcOvg35bhb+TbIy!{5k+cw`}T-PlfK_gyfb*RpyggP4nc zJ>xo``$E(1!TsQ5_*p)g1UbhdZDVnnQdgUmL^4m~R}|MN>y{7AZ&NOhISdv-@fBIe z%X!V;?bdXMN@J8`O%D84DU=(t4JYlzFj)dzQEE4CwTozadDHNYa>NgJCp*dwwPkLs zuDmpZ&0o2q5^_GaI)1JUbPOBqXdVNN+~uS1t*ZPIIPp7?sPzTydjxX?siV}|0B3Ve zM{QI22-@0fjo7m~-h*026P7f-7W*Di?d2uzM~X3rr-HFD(=1UhNj~A?jy-&`n)-M6DN8v-gfo2?hkI`b?k=(U8QTgo?Aj@_2#=}H-YD4 z8IQ_5W4}BfhX*@KyZAhf?Hg22Z7QzYwoM{v#BQb@BU%K$$&q+Oo2juEhONh8MTRRj zj5*rbC;zBg2B}tDPAB}>UP54f4kG)!1$B0Mzw7Ze$X6ApIS-$)A;(niGy32E*VbsU z%e%rgU-)}h%9(0|GuK+JhUoToYkz*;&ylYeXR>o|T2tlNuIg11gn@8gv#zo7OC=9ISAv-g^*FvI;OmuXY2_!lX)`>9W{N%8MTjKU6TDfN*U z1dp^sg0`CYt+>BVbalCrX_oD0g04pix{+UTG7{-06KL`u0vRW$+Ns8TzniJBhG}G3#j)$uj zjzl)zlqb^1lzBK88T^xa3;o-b@MU^-vO4kwPbBlMN)GW6H7V=!Ii*MaZ zDl77ATIh#-78_g_uP^!RysLEyc=konYYvIFZ1AfyfU|L5a66mA{jr>W$!!*>B+Q?m zwh<?rS!QvSCJ)0 z268BN_D|{Fis{|myc_YOpUnpQdDU%+Zwq#gkSRNVt*gF4@~4r!miYk9+3xE^ZAPfyiMi=dOpGoDPHcyDTbw7W3C}}y!Q^qMeQZkeDisxVzQg`$HbM4 zgtMb8_7D4RIKo0Y(GCap!7HUpafK^@*KAE~|C9cV zY?8Z2mw7+`R@e3qtkPYXSIl$>t#?63)idQ#w zstytT2E{OL$xv*+yBK46I^ZRj^lYZHbyBK;Fvz)!VabK@xkl+bs_SJNYRrV9`TRMz zT-k)LHO^)n^Y3lTHmIrj+5o~No*Xy8@LMN}nl2-z{{})GK|=B#>zK)$Yd>K84m@Q` zD5AO0O(>XP2AVbR_pG>c+5TO+V_xw8tlYu^E?ldpUr%GOP_;Fc1!Iumd1m$O+bxR9 z3-Sc4ns=&h`0_5MhSwu+1NL?IF78>LTW?p9MI+@5IXUMq`*M1hM%(5&^4bMgR}R() z`rpqgAo+z+bC%s%|22{m*HPi&qm{B{l+La_$KMxxqL*!IoEz5%ftmAs6^K?$X|`(~ z6V$TpC6@D*(5wET+~g4vjfYj|??%oy>qGSesqym6=c^v+L-kj=(`W!zgwl8%jvM=3Ft!vrB$tC+2 zwkOwAJ?$`g1$`mK8_qx5^}1uIXq+#!`mLta%6d_OD5ZVhFg z2`}lxI<@1wg^Mbm{M1+Ys8-!htKcKoBZ(v5Z2rOfGwq-K+$XWq?1Pvt;WMW^^zX>V zg5!UX`~xTNl!srs2s!O~`)#tC9fs|bOZoMr)9ZF}wcUfEPBQOYlmVbqcCz^6lxsg}Fq}xfjCA9@ zAC$8()&5)_=>7H0tW)LW5J>3>|86CI(?jQB+_sv=+%dTgYejWu`M6$Xt|wX~sVvJh z=O^3jU(;Z#W}=+;qq5PlO!#J1YAxa43fJfFBxw=rIs2&z#;%P+`>{)suww`ATKGHe zrQyEJp5a>K|8R5smk0kd``YE`H?+yp-DIO#^S4vZ1jTIOO23)%BPG3+zcGTyO(b<9 zOyVX}-+!O^%Jk0UxnGf2Icql5jGgoJwpOaz?|G=K8S|8X*v5+mNLm5<5V=xJtnBEPFrJt%=_P#)o<%dys-Gwgzt8b zhd)k(x=%Ly{kGWeBPUS}DndJ#JJLXk|L{m7QD=qWpPS-e9{F>np_lUxR!VuSE#sX} zvlLCxD;P01%rf==!z3^lx{IuRUuQEng_Q^&+Sn9Xhpk+_N;gLks|U-Yi4fE^9MA5~ zGCP?>%f7(bZpLQs!j_k1I3`}8p*H46{FtWGQ48-kb0Xw#98r^o#?f0~`yVe6GybX9X+TQbnKBwYVeJ z|3}`La6R*&W|15T9EdNe+Bs<{O6-M#sC(+_6K2OzSR#?au)-mFgY>7m22_C`*%Nx=c8H*CK~A?^93iZ zeU8?Lr!PL&e)f0mJ?C}zY6&{>`4+qC^b)JP;5~Mq{lYE`Ax%$vF7hwMw;}{mhMwep zk&<7)=&4?Y=Iy+r7;&CmaC4X;4p-ra;)y!Ve+jW%-}IyF-z41xurg&sspblCi4&GzrR&gYCR5*l6@Lk)jBN~>&a(8-J*`9^L%Q?heGK((_^i98!WAd zFNgw%)fzOk0O0xX-43r@9Xu`xPmT6d^Shr|t6yn}km>`DI0&D5Ua$qqpQ3f2)qfJF z=RWIcu>D)TD$6rI>vUUtYnK^0h3u`cq zY~R5z{IS^3vsc5nULM}F`Pz?mkG5Eu#qb*2M%*dW#XAc7a1;MzxF$6@#l2Ns@la>B zu>97;*ooA>vFc%Ticvzsef_e2dGGQx{BJl<%~R~Z@WRp0D}411a+-|{LX5Pkxm%{1 zWu>U5{^j#=`u`8iiG4e1VhQ2Sxl%glMtl)>G{3SX%4eC`P_FsAG~~^oDICZcG!%lB&Es<1<4?s4sh7euC6o9{5@a$ z&)$+ftG8|2<8Q~qtVX`+4+WM|%(*r3BivEzjVqPMDDKKdaSXWhZ*dF=*{*wv&F+PK z?wF*}(wGAM$)CSg1uWL zq=`%UM`OKmmTjebpgRswZh2r}zC3IQ)vqm9u%eviUT9rdpIb4c{3i?(F4HP2o>U!U zui1?%F2|O>lqS1_`4#BqC7F+O>|D-aMUVIW!nQLc?P8q1a^e1y@n#V{6mS6-i^mE; z3z2gje;FY*4Cm*|2K6p|U5Xc9$qie3#RoKOdXR=Tr3U^R3G&hrbf*ga5Zs-y9JzM* zA3mmw?G_$PYN4h#*zRu-U!_?(Q$@oT_i=NYZ~ypgx{C>;IQ>!T+4n`& zg-MS4n@$)@H`?f0&&H-|&pAD-7194#5U~fc5me1vUqAkFbt=lm9zp8D;j1 zho7EW(If&yb-~-XH|I68pJHpb{u@d~1HCwM@Bi*^&q9fT+&UAVDws9g3 zgUA&dsmhD(phofEQdRDx4NED!{!Bh#e=i$md3WK9;@kaau87?ll)DdWXYYeq@IUD= z;hG&Xxhe}Hffjv#*bpwwyq$=36&xOaI!*j=jw#!~!526*eP9c8taBWX85?GHwoZLKAHX#c{1mHShj zJ9vmOzKWe(kGI3NroNalkQX`+Laf2sjec+|37m5O6sw(>?-BP3ssiJ`euk~gJKv^j zbh8p${gi9x|J>ARsI?)N_7^x7h*h;hcQ;4R zx|mGO>6=qCIx=#ledW!|$j^JQk&s@+`J7k(69=8IlX63O&1XJpBfY{nNS{=xK-cs&M17hXw&oG0!N`yEJbctFp@ezY>D~+k8s<6!v(-9k)wEoxr;Cd8B(!yuwv}^uijMi$s^0?k~;m z^XrC#wZ-GhS<-O)ZOC1G_EyyBsK3XNv&Dz`&>-bzCo>;ku~9hMRJMO)E>XMnO>q3i z_w-eHJ^Z7O{ybp>3gg9_j??@=73WLFB^C0oE)JpF#cV|B+q1Y`aDta+jSR=Z!#uos_5HkFqfhc9aO+(`z>!a6T3@tIo}K3?PZuba<$ zbAK7T{FIHt|EpwT`q0R`-R0XxI2mth4oO#qK-G*Vt_!QxH;Tcg7u(*(Pcd75jWN@v zZGiLNOI|@Y`=zM=#)C~wrO{Udb<``1*8^>0@4b#4wv!jFXj{@dGdUxGg!sCqGm&4BC09kF4>T65UYvOSFU!CS|DYIj;c&@Yq8 zzPeOoL^bQ!?_K#vMDx;~k9BQaG|?>{UM;>0Y!Z$Vs1gAj|Ln>7VY;)SFra3u9NzPF zad%UHd1G`KFY;Z))N;!c;cfq7w)jv8_D??i5!y(4xfrj7ggIMw&)%x9G14DpJ#N=J z9BcB-UgYOljsCV#Ld`}TY4lEGUuwN=Gu&(J_=btwqW(QYe?rL>{ux@pwqtnt23g8rAmGIEwO$bk+WqK$M;}T?Rd@g z9hCo3dp%R~hVxx<6twu=o5^4H^$qp%5DPGJug&8VbbEn8;_0^}|4p>2T&XBN7xo3jJ*?M{pr|K^$scf0rgchWikwX!sy z3AMGl?v4DSiD53o@h$g1^?$3Y${H?bhxx9HxhK8Vfo;tE(#+_7Jh{y~o;nKO;aBm4 zw;=!4Wzp;3AkzM5)82ybv{2puacI_F?-BL|yQ}_z_?32?&ldO=^U~DvkFhmF$AnpW z)$!+%k^9<`Ae_$akc?nWDl}w$otT*UBJN)!_m@BaiaHUk(_uM#s)SF%y!CU1+jN?B zwb176oUwY4I>E8Y(Z76uP(T>#u+Gp4 z^p^>p6t0d!IYHeLpC#Z;->zWwL(S!QNq?sjigUJSQlY@Nv+eoanwXIE?);)_(}!Yj z_aN)yZsqw{&EwPq?>d{)$=r85C(y^o2{wxign(I50ZPj$PrJ_i0Z zIo8rvZ62b`S9b;Q*`35`+dB!5%W5U?-OG7%HfUIuhNTx?o=0wd-p`LeVLmfw&Y3fFUEkdow%Y6^p7~svhoAt7oWaBw zEoB45rhg6VoFN@~SDS9HiYMMq1twGR*1_!q^{6T83T}brR5qW5rLPNajRxv?j?}4tCOzjx!vj_3T;W#(T6&NX zeH_(dHvNNotF##TUdbu+YC#4E4kul90q3zvGL%Obwc3@Vk`NOCzo!8_&Bf!qOW<(6Ylfk zgY_E$nOl{mt;=NtLTC@0;LkFUfdsVQXbQIeGC5Vq-n(g9F^5<4L5-19q>GNeF||tR z@TA(*2Qd>^uVhnLX&H8$x9KCLg=m0q0NME9Ze$+j&R(;NEkvl_oc*W-LP)l6Fu?ib zE~xjhcnc?Y_Jfu6dVo1ff*x=Cj55G#LE|`uj{{c(yu2+!|Bh2R;j?y}FKOp{BQ#jA z2R)K@^`HpdoW7)g9hC-*RV?|Y~xim94Pq%Sf?s^1`; zJ%0D%^Exoq=ls)rjl(iRg8l)&?M1-Jdv$jPx6QfFxNK4u1cL}jiuQh@XSe>`nB=&t zYg3JNS8;1F@ng9)x_D)Hvmz3b7o_ znQmgwvtJv7nbsp=^18yu^`jQUu%aQ)lqyR8?RPsb+MAJBdL}wrN@-QL+tPd}g*azp z21B2eCjCobi=(ZihE|B-EuhKvm5@!X`Be5gT;f1gw_5OaJGIH1686_rR5EQk_4<~M ziv^RL$m>zmreOm)bvmBjBi5>>a8kh& za3UFYT-~XSY7f;dtHVM(w}E^CR_m_ohH<8i&r>%;ftAR)kEJ)2#iunL$wi?0VWJ>6 zLb-fF>&e-{_kc44^SBgb>|5rK-`8^-@)qEngSBef+~6<)ykx-}Bt{gkKv;cp%QNYe zg8c64Saj2CtXvFUq#9bzW@Am!JOH)s%v`CWu%qs;JZLpr4Uq|d$S@`ts9-y8I#H{f z3Kx;BpgTv;;azEL0#=o#FO09LZ3SZf7Fo2Z;|L7%_-n zEM<+$T>aXsAQmbZI`E>cFf5gUBzm|efhu0isr$*Hp_+2vil9|=F#e( ztKBmC=6c~%*pacVqPbO#t(Y|WAx4vH-1qgFzU1M{GG1`s18xWw*y99dW;(F}`#k=Q{}^8eH=>uJ*Qabb|ga*qqv^_{`3t zL;Q65Dgujq%KvG&1QpDIm1JUIG6g_WIGFOkx%EE zYCNwp3DUExdU-0*V*Hq)xT<34=bcuy<^Gg+1CTtqNJ>AVp4E4`bjz;SyHFGdI#u4%$((x+Wa|3&$%=1!(@YVU~6 zrEJF!GR=DLkMBnmh06Hd_CMdC99i<9Ze>>6&oD0%vqH`INLqaX#|M_;9_(Op6m!99 zVSI0RPbVTlcS&e5m?I5!K0lbJsTfm=z%zFb|6L1XoiM>&O;?UT*_&Me5wQDxUn#fr za0Z*mz}x2iWNU^us5~W8* zaEDAT>EB8*$3dtxzwk_ePrZN0+$|xbd#b}pTWa^J>V`fra*hGAg~cCsvH0A@zA*9X~e$SJ!lvF5JN|0A#J+lrG9J{#cJ>orqce+=}11(r;5%JIM)P9d3%0JZX+)omgo~?}ohi7s)^WRl9;cn(+d0H`XrCfc- zaj>bH`l&w2=^Zl+7xy8nJr8)hvy_YC%Lg#p;(r56?KR)1(05>lk98tC?6H~)i@Z%e z&D$nGoxP41#HW1k?ixX2%$s1JNVc1r+Dqdfsu@d=ogF*01+fis{ zuf#qM4%U|CE3aC7-kejJ#O+#R8Wt4X5}ABu8;~*?s_HFyYhyi1V8bf(A=X#nb&TSa z-9P3D!c|i(epNAZvpU#J!T`GzYVrV4w*k(w~(v_sQJ{MS7q@bUsWw=XDDwBhCj24tgw@<+RO3&8fcCu z^k4gDPo?FMd3U?W6|DHQFX4M0@SAXHVgn-e_ZnUNsBep3+FMz6i! zlyZk=oAljSSR;}a&!(C{3!V((AaWu@a6uj=C4r?4m_77ghKgM0!^wA_KK) z83YMwg*;U&p@)y#Adp9qAJx^$)duZ^wjC&P=0yJ;)A^1S>$)kHRWgG>Aw2FWd&O-h zmqbaO30o5QI@Lsobibh>*IOzS-!sgVz zp@651GQ~R~t^#m!Bi##c=%?@ccmafc&;m?UK}nBP%h(t&(BB#|R$vuQ&O2nxB7tH1 z(8C8(edMJ8M!QQX8X&q_PEuT`EX+KQFLWq4T`&8Cj^6K-5Bx&ZY0^(42L=At79qZc zx^|+^;4%30fVGsp{{qYHOlY8@ea2O&(3JIKPQUZ*G?j+=YI3lm+Pv?e(JztGQFjS?L^NXCgZQLe#!l$4v`9>y{`i zsYP<}?kre6UF&uoJgaJ36s0Q(SEiu9%XyEP`Y?4=(07Eap?@|G}~{X(G4p&rq87-U7bsp9^ZV`ds|o`g*w)H z;5|aPk`p}DC&wZ-ZD?o*%VgEcf{Cjbv~uqB@zmNw^x4(70(s*aI_fby`FT^*CKFed zW`m}=v>jb6!IY z$}HAmy>ZXI{=1X9Sv~V&0_Kowfm0i>7%eU}4`!W)a4sfGz_G)Pixnqy^O=0My=W<$ z3jgaLch6lv}ERB%^l@9Mb{K5%6uaK)hs>oVJt5Byq6{X%~tV&i{qOk{JzrK*cz znu+CZ^6JW)9UXy|xE!L>Ekv##4RuXU6f!UiA+Wigu`2VeY+G_^Je&1s2h*vM z!}Z54E&^Iywrba%JNaeC%D2D@h{FL}FR=4u)m+Q zdoy)pU$aj(<<23b{3tHDwTUT|xcCFv&C9TvJ_>L>7R_yq&bTeST zDU%euMK3AeaGMd#APc4*;9_QC5qs5yK4(d7+WdKTymTLiMyPDQD6I^$c4hUsRDceo zH>EoB0hY_Qm#)J5l-R{{QDg0c${?5C0(P~A*(7Wkk8d_$VSAiG(=c%0p3pKrbA&t0 zIy2<18KjIf?yz91UR!mrBz#yv^F^Z<$f5H<8q+t_v5}+wagAsEpBZF4=@7_R0B}-@ zHhJ#!s>d0s#BnaFKHb~z)TGSkpj3<|czd2@LSP?K-bs zCxe8%3;Urtf+kcf-wH{>K}1GIZJo2Mz%)vmkNg7R$u?zGjBVgG{We6X2jH%?4t+_6 zdzJ3OVuewFJ|r~>{ z>oo#r8gj9M)Wq+UjM*<15$jdxwql24oCR1XM;Zd%{3jg?KyMt4l*evzO`yNt;zXRl z8wzvH!Sw8#V`&Wsde&uPeu@ZITf39SX!-)EN4(Ch14Oz#8I0r43jx=Ls-9Jm6{u9K z(>*7b6v&yoQCjM(JS-M<=KGVvSLTsJtCaiB+G{#!7y z#YnJVtMdEQ4^X|vqEFr+<9Vm$?(+)nJL5=<6U2(uv3sbIuEod9GOB8Q&?hmPLYe!V zct(Eq<0V+H>EEt}@K9>o{Bm!+-b+VR;9!x<5Lz}Vl}mhz!ljA^_ouVauRpmZ5@&h0 zXJ?)^5SVdWA|W!tO2sQ%gsLEa00Oyr?QN>yq?ykQ``BnD*BlLQV#>mZs5q&fA;D2i z#E)-*BPlUIs?{a&@X9;kzf_-rAQduCmE!iZc>P?Ol zshq3S67=DpI+L=F!%o5==oIMZRwt7CUyMvxZR&Owq!~ov?(zuSe5xM7aop@lWubOx zPvpT4#}i28yKTLn`I)f*q~#0M$3;hFwyK+mW}dCl9d_Me_6L4b$$dv04Y+5p%5G5Y0?OfR%vK_hn%sFMJ=g*NJtXhL0jlLnL zp|pO_#T3>SDKLj&LLRxa2@5RcFK+p%V_tM?IV>3rBKwj)b5~(mySZ2mf9Y(H&(HEO zj*GNq;@UR*G9)9dQ@bcy+OY=!tfwS7H*g)C5sx+8%ccW8Vd}!^28D0~xXIzHJ+()l zg74L|?J1lskBpV-Zce9l!zQK_|97JvA^YsVH)XJ)I{x)~p`G)o%x>%Q?7i%{x(WX} zl=Df&0U{DivQ{3In+pe^Net?LzSOwD47HbLe!A0OJ+u@!kkNS(Z}!ckfa$w-rxx+- zyy?N2$ZsPn2DZt~$B!1_<1=IFH&_h6c~L6(qKz+Q!lr87$#Q_y=YzlluikYpYku$1 zIrhmOH^%7#Q_#!x(cmD`V0aPTDgw5pYC^74ZZS|qcQOnsX;;9T^o(WLbUQ6YT>d*K7!NVGGZ3jQZF=xp`niE!mIuoh9AxbtM{OT{88#fX);s z{$l`9*e?Rr8c24VxR)ww4wl+jLMxK-lV6(Er)uBq(>?ZYF8$|yMJsHkCsKsq~6D~DZlzOk{)NNY+uo~Ddfiq1bNfKcAG z4LKPVR7=`fHKE@0pz~B;C3+3JFRpOW>#tTH61kb`bU61v-yy`#`Jq}n)0JirTz5mX z1yvBP`v0K6#eO6vMV%G3^DIkDBddY<(Y+|;tOil3dkWZDN>W5w)LH!R_>|wAH|e*q zYmpo`pJKGXF7jEp5U`x`G;zXQ-&;RADB&1t((c=#y%rDktg$Uy?Oq#Hasir;m&r0N z-xFM8d{nTjYb>nUOtuL(=^p9&HDu?nsksqwI?qxwnV;bW02RkxQyzJ%@6di5jTda+#M;!ZXSgiS~d&T{9J&}93QjY~}tKIk& zTEPz)KkMcv|46%eJl<-lR!;TPat0Fl{eA;`5ma(GT(07 z6en_s^;E5a1&`avi==Nl0ZRl8K;@=c>K6~L%dwqm2xe$Mg45?>Pv8=x?WncPjSHS_ zdRa7Li$HAz?grw^10MYnhajB?xm(9$IfhB6Q3zqngGqA$VG;uZ8b1A-gxcPF0R4(9NUhd*JN{)sIwN^SIP=C5811#&?b_=DjHpOLkIj@77Z`;lj^} zOCv}g8oxuz&u(YSc5}^gKjrz_)l6grWdW-01uuq>S%%U{IrD9pW?Tc39?!iWShTG> zBbh-W=oT->S5DW4S*XeS_ z2VSv3|K|M_G>?DI(q~YQkKHRCxlH47KXxY~A<^c}P~K@05AUXI1rGIASu~Oy<1@R0 z-yuCVQhkIrY*>EE{;U_RqGzYu*$lOnKfiPrj(fk02by-Zgvi0YiH|ALE)1X6*L2c1 zG*!Z8Xf>z}(UXZ}!}6(Z83KHT(|k)RYE=v%f)&tD9#9Sd*C5;FRPo8M=RtgFHxWEd zW@zcmU7f_nKR@k0J#P3~j+DVM=kD{7HPp20Q)O5cGw3~=9Jx-)I(=|sAI2Rage+~w zqidgMt#(zF#8>KZLGvw=X__+E)9P|KE1<|Els}bZl#>mbA#CS59|Z5t#wFqG5SQo@ zot0j2NT078?k}O^B$v-iCq-C`Tt8!Kib$z{nd_f}CET-R%EekFlo43xsmi6IN~Uhg zGrv78OrcWaCfV`sR}ec0S)1T|nIl51u5f^gpGqc@+fvimm*w=SN9$}jEp3rTX(e~6 zXp}H&Z$m{F<^N(K)l2ofc;7kSx$W{m_+A0ply^3@Gr}5rt426#`X5z%(kU#Rr?Ft2IVUUJAReanXQCp`UOr>m^_B(D zT^d}xr7KNQ-il%DB6OFces3k+M(;yscqQT)Mf)K<7R6pho`&kRl+uN!2xe?8Qs@Qh zQ`%NmYBk{`nR=Xp^_L9#?5|a*tEgv09{)E76kdVK9=k3F;NMT zDC~uNcQTy6x`4gb0H%FTnawWN{)mB#{wE0&+Bx5%b54vf5(76~uGCVgW62ji3J(SY z0`-sx_`SWxsK59hCFL6DZJ!M<@tUcLuF{f}QE zX-t03v>-*N+Kh?U0oiFV&yuG;uMdgwoUOvS(z!LGeo*hM+0S^#HnTVdTr){TC4w2g z5^vmF2{OQ)nO`5Ioza#Dy;`xi$Mua8y``t-p$eZ zi)SR4Pi!(nYp3O)8mH$2>6oOOU�G8qOq)&dj-^ENKy&kOjM#SlLi4%4 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 literal 333050 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

5E{p2*A@ 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 OwJKEd1c8tm;eP>UrPreX From 9a5fb8f938a5d4395c5b8d2677cd1153b0df60f6 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 3 Oct 2026 19:44:14 -0700 Subject: [PATCH 50/59] docs: correct coordinate receipt paragraph spacing (#107) --- docs/testing-evidence/reader-lock-coordinates.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/testing-evidence/reader-lock-coordinates.md b/docs/testing-evidence/reader-lock-coordinates.md index 3e68fd4c..26837625 100644 --- a/docs/testing-evidence/reader-lock-coordinates.md +++ b/docs/testing-evidence/reader-lock-coordinates.md @@ -34,7 +34,6 @@ Both archived project sources now match the executed driver SHA-256 `282248b48fe ## Validation and acceptance boundary - [Focused final validation](reader-lock-coordinates/validation-final.txt) passes workspace formatting, workspace Clippy with all features and with no default features, public constructor doctests, public intent/receipt/disposition codec laws, GC filesystem laws, retention-disposition filesystem laws and the independent format oracle in debug and release, plus GC fuzz-target Clippy. The [first attempt](reader-lock-coordinates/validation.txt) stopped at Clippy's redundant `must_use` attributes on typed-return getters; removing those redundant method attributes preserved the type-level requirement. That was a lint failure, not a flaky runtime failure. Final validation used the corrected source. Static type-erasure calibration preceded that metadata-only cleanup; the final examples pass after it. [Final source hashes](reader-lock-coordinates/source-final.txt) match every changed Rust file between the working candidate and the Docker copy after controls were restored. The [original-record archive](reader-lock-coordinates/original-records.tar.gz) preserves original output and experiment source; readable text copies remove trailing whitespace and terminal blank lines only. No original runtime diagnostic or failure is erased. From 65c0707ba52ecd95e56474a9675ada4ec634701b Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 3 Oct 2026 19:57:34 -0700 Subject: [PATCH 51/59] docs: verify GC fixture provenance with pinned tools (#107) --- conformance/segment-store/v2/ORIGIN.md | 49 ++++++++-- .../original-records.tar.gz | Bin 0 -> 2423 bytes .../gc-fixture-provenance/verification.txt | 90 ++++++++++++++++++ 3 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 docs/testing-evidence/gc-fixture-provenance/original-records.tar.gz create mode 100644 docs/testing-evidence/gc-fixture-provenance/verification.txt diff --git a/conformance/segment-store/v2/ORIGIN.md b/conformance/segment-store/v2/ORIGIN.md index ad5a252f..07a04eb3 100644 --- a/conformance/segment-store/v2/ORIGIN.md +++ b/conformance/segment-store/v2/ORIGIN.md @@ -113,9 +113,9 @@ never regenerated to make a production implementation pass. ## GC record addition -The GC retirement intent and receipt fixtures were added on 2026-09-30 with -`rustc 1.98.1 (48a229cea 2026-09-01)` and `cargo 1.98.1`. They import exact -bytes only from these previously accepted fixtures, at fixed offsets: +The original 2026-09-30 addition recorded `rustc 1.98.1 (48a229cea 2026-09-01)` and `cargo 1.98.1`. That historical entry did not record the Cargo commit hash or independent GC digest output; those missing facts cannot be reconstructed from the entry. It is not an approved toolchain exception or evidence of a current run. The pinned reconstruction below supersedes it as current verification evidence without rewriting fixture bytes. + +The GC fixtures import exact bytes only from these previously accepted fixtures, at fixed offsets: - `conformance/segment-store/v1/one-zero-segment.hex` (segment digest at 273, record checksum at 177); @@ -127,7 +127,42 @@ bytes only from these previously accepted fixtures, at fixed offsets: - the version-2 manifest digest, inventory digest, and profile digest the oracle already constructs. -The definition digest is unchanged: no definition row was added, because -both grammars were already frozen in `definition.tsv`. The same temporary, -removed write path materialized the two `.hex` files and the `artifacts.tsv` -rows; the committed oracle is read-only and rejects drift. +At the initial GC record addition, no definition row was added because both grammars were already frozen in `definition.tsv`; the later GC derivation registration above changed the definition digest. The original entry reports that the temporary, removed write path materialized the two `.hex` files and the `artifacts.tsv` rows. The committed oracle is read-only and rejects drift. + +## Pinned GC reconstruction and external digest verification + +On 2026-10-03, the handwritten format oracle reconstructed the accepted GC records in memory under pinned `rustc 1.96.0` (commit `ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96`) and `cargo 1.96.0` (commit `30a34c6821b57de0aaec83a901aca39f88f6778c`). Both debug and release comparisons to the frozen fixture bytes passed; the public GC intent/receipt runtime laws also passed in both profiles. No fixture was regenerated or re-baselined. Change kind: provenance correction; no implementation, API or format change. + +[Verification output](../../../docs/testing-evidence/gc-fixture-provenance/verification.txt) records the compiler, Cargo and external `b3sum 1.8.5` versions, exact results and fixture hashes for source baseline `9a5fb8f938a5d4395c5b8d2677cd1153b0df60f6`. The [original archive](../../../docs/testing-evidence/gc-fixture-provenance/original-records.tar.gz) preserves the executable verification script and raw output; the readable copy removes trailing whitespace and terminal blank lines only. Execution used copied Docker source with the checked-in lockfile and an offline Cargo graph; each focused Cargo command had a 120-second deadline and 5-second kill grace. This does not establish per-test sandbox compliance or approve the larger PR. + +The independent hashing step reads accepted fixture bytes and the specified domain-separated preimages; it does not call Keep's encoder, decoder or hashing helper. External hashing independently checks the preimage construction and stored digest, while `b3sum` still shares the BLAKE3 algorithm/library family, so this is not independent cryptographic-implementation validation. + +For reproduction inside the Docker validation environment, decode each frozen `.hex` file without using Keep code: + +```bash +scratch=$(mktemp -d) +for name in one-candidate-gc-intent one-candidate-gc-receipt; do + perl -0ne 's/\s+//g; /\A(?:[0-9a-fA-F]{2})*\z/ or die "invalid fixture hex"; print pack "H*", $_;' \ + "conformance/segment-store/v2/$name.hex" > "$scratch/$name.bin" +done +intent=$scratch/one-candidate-gc-intent.bin +receipt=$scratch/one-candidate-gc-receipt.bin +``` + +These fixture-specific ranges follow the [GC grammar](../../../docs/formats/segment-store-v2/gc.md): candidate count at 44, one candidate beginning at 320, intent digest after its 392-byte preimage, and the intent/receipt checksums after their 424/288-byte preimages. They are commands for these accepted vectors, not a general parser. + +```bash +{ printf 'keep.gc-candidate-set/v2\0'; dd if="$intent" bs=1 skip=44 count=4 status=none; dd if="$intent" bs=1 skip=320 count=72 status=none; } | b3sum --no-names +{ printf 'keep.gc-retirement-intent/v2\0'; dd if="$intent" bs=1 count=392 status=none; } | b3sum --no-names +{ printf 'keep.gc-retirement-intent-checksum/v2\0'; dd if="$intent" bs=1 count=424 status=none; } | b3sum --no-names +{ printf 'keep.gc-retirement-receipt-checksum/v2\0'; dd if="$receipt" bs=1 count=288 status=none; } | b3sum --no-names +``` + +| Claim | Expected digest and stored coordinate | +| --- | --- | +| Candidate-set digest | `6676bc9d70134a74cc9dfe397fb8a033fc45ebd903290d29b93018a097ee2b50`, intent offset 288 | +| Intent digest | `a9dd523326a686b89ac8f0c410421766afc221f2963bdafd430dce7f59edc701`, intent offset 392 | +| Intent checksum | `cefd325fbf7b1900e1a208c9c66ec5cfd03e565ea04a3bf9011338e9dabae749`, intent offset 424 | +| Receipt checksum | `d3dd8ddeea9ce78a278b39a3bdf61b957fff8f6cfee647f138fc720091389147`, receipt offset 288 | + +Each stored value is 32 bytes; inspect it with `dd if="$intent" bs=1 skip=288 count=32 status=none | od -An -v -tx1 | tr -d ' \n'`, substituting the fixture and offset from the table. The archived script compares each computed result with that stored value and fails on any mismatch. The earlier profile and format-definition digest commands on this page were also rerun and their displayed outputs remain correct. diff --git a/docs/testing-evidence/gc-fixture-provenance/original-records.tar.gz b/docs/testing-evidence/gc-fixture-provenance/original-records.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..346b2858b77e3beb592ce3f9250f799063e6ad3b GIT binary patch literal 2423 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 literal 0 HcmV?d00001 diff --git a/docs/testing-evidence/gc-fixture-provenance/verification.txt b/docs/testing-evidence/gc-fixture-provenance/verification.txt new file mode 100644 index 00000000..76f57313 --- /dev/null +++ b/docs/testing-evidence/gc-fixture-provenance/verification.txt @@ -0,0 +1,90 @@ +rustc 1.96.0 (ac68faa20 2026-05-25) +binary: rustc +commit-hash: ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96 +commit-date: 2026-05-25 +host: aarch64-unknown-linux-gnu +release: 1.96.0 +LLVM version: 22.1.2 +cargo 1.96.0 (30a34c682 2026-05-25) +release: 1.96.0 +commit-hash: 30a34c6821b57de0aaec83a901aca39f88f6778c +commit-date: 2026-05-25 +host: aarch64-unknown-linux-gnu +libgit2: 1.9.2 (sys:0.20.4 vendored) +libcurl: 8.19.0-DEV (sys:0.4.87+curl-8.19.0 vendored ssl:OpenSSL/3.5.4) +ssl: OpenSSL 3.5.4 30 Sep 2025 +os: Debian 13.0.0 [64-bit] +b3sum 1.8.5 +candidate-set computed=6676bc9d70134a74cc9dfe397fb8a033fc45ebd903290d29b93018a097ee2b50 stored=6676bc9d70134a74cc9dfe397fb8a033fc45ebd903290d29b93018a097ee2b50 +intent computed=a9dd523326a686b89ac8f0c410421766afc221f2963bdafd430dce7f59edc701 stored=a9dd523326a686b89ac8f0c410421766afc221f2963bdafd430dce7f59edc701 +intent-checksum computed=cefd325fbf7b1900e1a208c9c66ec5cfd03e565ea04a3bf9011338e9dabae749 stored=cefd325fbf7b1900e1a208c9c66ec5cfd03e565ea04a3bf9011338e9dabae749 +receipt-checksum computed=d3dd8ddeea9ce78a278b39a3bdf61b957fff8f6cfee647f138fc720091389147 stored=d3dd8ddeea9ce78a278b39a3bdf61b957fff8f6cfee647f138fc720091389147 +db1c1c1a50613ef11f7c0ee0882e37b6d24e2db2ca57783d01197ba51b61ce59 +a4a010cee5da8aa3ba153c5034f436b92742c6c1f7cf6b43d890ad5fd5b5cf89 +d43762ca27e4b5853bb1de8cb3f6f87afcab313fffe39284cb5631782c9d20af conformance/segment-store/v2/one-candidate-gc-intent.hex +33a6999604e6fb3bcbd0b6f12c9fe123d151d31af6a4ce33571c78451dc359a7 conformance/segment-store/v2/one-candidate-gc-receipt.hex + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling xtask v0.0.0 (/build/keep107-coordinate-source/xtask) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.04s + Running tests/retention_store_v2_format_oracle.rs (/build/keep107-coordinate-target/debug/deps/retention_store_v2_format_oracle-2a6d5511749a3dcb) + +running 1 test +test golden_artifacts_match_the_independent_oracle ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s + + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.06s + Running tests/gc_retirement_intent.rs (/build/keep107-coordinate-target/debug/deps/gc_retirement_intent-6de0a7d49b53f247) + +running 5 tests +test mutation_laws::intent_framing_refuses_truncation_and_trailing_data ... ok +test semantic_intent_refuses_empty_and_repeated_candidate_sets ... ok +test frozen_intent_decodes_and_reencodes_canonically ... ok +test mutation_laws::every_intent_field_has_one_exact_first_refusal ... ok +test mutation_laws::candidate_order_and_count_bounds_refuse_after_complete_integrity ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.36s + + Running tests/gc_retirement_receipt.rs (/build/keep107-coordinate-target/debug/deps/gc_retirement_receipt-b07035724611f35c) + +running 4 tests +test frozen_receipt_completes_the_frozen_intent_and_reencodes_canonically ... ok +test pool_state_digest_is_carried_not_bound_to_the_intent ... ok +test receipt_framing_refuses_any_length_but_the_fixed_width ... ok +test every_receipt_field_has_one_exact_first_refusal ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling xtask v0.0.0 (/build/keep107-coordinate-source/xtask) + Finished `release` profile [optimized] target(s) in 4.12s + Running tests/retention_store_v2_format_oracle.rs (/build/keep107-coordinate-target/release/deps/retention_store_v2_format_oracle-0f938c44dbe97a05) + +running 1 test +test golden_artifacts_match_the_independent_oracle ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s + + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `release` profile [optimized] target(s) in 2.65s + Running tests/gc_retirement_intent.rs (/build/keep107-coordinate-target/release/deps/gc_retirement_intent-16dd92d184650f31) + +running 5 tests +test semantic_intent_refuses_empty_and_repeated_candidate_sets ... ok +test mutation_laws::intent_framing_refuses_truncation_and_trailing_data ... ok +test mutation_laws::every_intent_field_has_one_exact_first_refusal ... ok +test frozen_intent_decodes_and_reencodes_canonically ... ok +test mutation_laws::candidate_order_and_count_bounds_refuse_after_complete_integrity ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/gc_retirement_receipt.rs (/build/keep107-coordinate-target/release/deps/gc_retirement_receipt-7af42e76dc359ac8) + +running 4 tests +test frozen_receipt_completes_the_frozen_intent_and_reencodes_canonically ... ok +test pool_state_digest_is_carried_not_bound_to_the_intent ... ok +test every_receipt_field_has_one_exact_first_refusal ... ok +test receipt_framing_refuses_any_length_but_the_fixed_width ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s From f00e7803754eb6d9301032654d73a2047086c801 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 3 Oct 2026 20:11:30 -0700 Subject: [PATCH 52/59] refactor(gc): prove receipt widths before encoding (#107) --- CHANGELOG.md | 1 + docs/testing-evidence/receipt-layout.md | 33 +++ .../calibration-continuation.txt | 16 ++ .../receipt-layout/calibration.txt | 11 + .../receipt-layout/comparison.txt | 24 ++ .../receipt-layout/field-array.txt | 18 ++ .../receipt-layout/field-total.txt | 27 +++ .../receipt-layout/original-records.tar.gz | Bin 0 -> 352390 bytes .../receipt-layout/preimage-total.txt | 25 ++ .../receipt-layout/record-length.txt | 9 + .../receipt-layout/reduction.txt | 4 + .../receipt-layout/source-profile.txt | 5 + .../receipt-layout/structure.txt | 2 + .../receipt-layout/validation.txt | 225 ++++++++++++++++++ src/adapters/gc/canonical_receipt.rs | 4 + src/adapters/gc/rationale.md | 10 +- src/adapters/gc/receipt_encoder.rs | 83 ++++--- src/adapters/gc/receipt_format.rs | 2 + 18 files changed, 460 insertions(+), 39 deletions(-) create mode 100644 docs/testing-evidence/receipt-layout.md create mode 100644 docs/testing-evidence/receipt-layout/calibration-continuation.txt create mode 100644 docs/testing-evidence/receipt-layout/calibration.txt create mode 100644 docs/testing-evidence/receipt-layout/comparison.txt create mode 100644 docs/testing-evidence/receipt-layout/field-array.txt create mode 100644 docs/testing-evidence/receipt-layout/field-total.txt create mode 100644 docs/testing-evidence/receipt-layout/original-records.tar.gz create mode 100644 docs/testing-evidence/receipt-layout/preimage-total.txt create mode 100644 docs/testing-evidence/receipt-layout/record-length.txt create mode 100644 docs/testing-evidence/receipt-layout/reduction.txt create mode 100644 docs/testing-evidence/receipt-layout/source-profile.txt create mode 100644 docs/testing-evidence/receipt-layout/structure.txt create mode 100644 docs/testing-evidence/receipt-layout/validation.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index cc68efda..bb5d842b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ after its public API and format compatibility policies are established. ### Roadmap audit corrections +- GC retirement receipts now use compile-time-checked fixed array construction in place of panicking slice splits, preserving their infallible API and canonical bytes (#107). - Tighten the new GC reader-lock API with distinct device, mount and file coordinate types; constructors and accessors now name each role explicitly while preserving the encoded values and runtime refusals (#107). - Replace tautological GC fuzz assertions with public canonical re-encoding checks for retirement intents, retirement receipts and recovery-disposition receipts (#107). - Preserve typed failure sources through durable and compaction I/O boundaries. diff --git a/docs/testing-evidence/receipt-layout.md b/docs/testing-evidence/receipt-layout.md new file mode 100644 index 00000000..7bc73ca1 --- /dev/null +++ b/docs/testing-evidence/receipt-layout.md @@ -0,0 +1,33 @@ +# Fixed GC receipt layout + +Change kind: behavior-preserving refactoring for #107's [receipt-encoder review](https://github.com/flyingrobots/keep/pull/107#discussion_r4146802728). Owner: `@flyingrobots`. Baseline: `65c0707ba52ecd95e56474a9675ada4ec634701b`. The original fixed widths fit every admitted receipt; no malformed-input panic was demonstrated. The maintenance concern is addressed by binding emitted widths to the actual array expressions and format boundaries at compile time. + +## Contract and static proof + +The private encoder construction requires each field expression to produce its declared fixed array width, and requires the sum of those widths to equal its destination array length. It applies this same construction to the 240-byte field area, that area plus 48 reserved bytes, and the resulting 288-byte preimage plus its 32-byte checksum. The format also pins the declared record length to the owned 320-byte representation. Iterator writes therefore cannot truncate or leave an unfilled slot. No input supplies a width. + +This removes the panicking slice operations without adding allocation, unsafe access or a public error branch. The infallible constructor, canonical bytes, typed refusal behavior and publication protocol remain unchanged. The [decision record](../../src/adapters/gc/rationale.md#fixed-receipt-encoding) explains why a new heap buffer or `Result` is unnecessary. No performance improvement is claimed or measured. + +Four distinct [static controls](receipt-layout/calibration.txt) exercise the construction's obligations: a 15-byte expression declared as 16 bytes fails [field typing](receipt-layout/field-array.txt); removing a valid 8-byte field fails the [field total](receipt-layout/field-total.txt); increasing the reserved area to 49 bytes fails the [preimage total](receipt-layout/preimage-total.txt); declaring record length 319 fails the [record-length agreement](receipt-layout/record-length.txt). The first three are compiler E0308, and the last is E0080, all exit 101. These are production layout checks, not tests asserting harness cardinality or runtime bug RED on the parent. + +The initial calibration script correctly observed the last compiler refusal but expected an older diagnostic phrase, so its diagnostic filter stopped before runtime calibration. The [continuation](receipt-layout/calibration-continuation.txt) checks the actual E0080 code, observes that refusal again and continues on restored source. Both scripts and the original output remain in the archive; no compilation setup failure is substituted for an intended control. + +## Runtime preservation and falsification + +The archived public-API driver constructs canonical intent, bound receipt and disposition records in both revisions and records exact bytes and decoded reader-lock coordinates. Seven explicit coordinate triples and 256 seeded triples per run exercise zero, maximum, unequal roles, 32-bit and 63-bit boundaries. The same triples also vary the positive GC, liveness and catalog generations using `max(1)`, and the pool-state digest by repeating the device coordinate's eight big-endian bytes. Other digest inputs and the one-candidate synchronization count remain frozen; this is sampled preservation, not exhaustive equivalence over every receipt value or intent shape. + +[Parent/candidate comparison](receipt-layout/comparison.txt) passes for fixed hexadecimal seed `107c00d` and random seed `d12c1871b6c62734`, recorded outside the child before launch. Each output is 310340 bytes. The fixed output SHA-256 is `8b0902b546ae47cdcebf93bfe8c99d6269ce2c28f70eaffba8181c17b59a9a1d`; the random output is `8d798c0c121eb3dba3015fa074956f28f0c7398bc86a9888f08d9c8dabb7ba83`. Both final driver lockfiles are identical and retain the baseline dependency versions. Differential agreement can preserve a preexisting defect; the independent frozen-format oracle supplies a separate specified-byte check. + +The runtime control replaces only the receipt encoder's pool-state field with zero bytes, preserving its width and recomputing the normal checksum. Public codec calls still succeed, but the output comparison fails at character 1765, exit 1. Restoring the encoder returns the full fixed output to exact equality. The [reducer](receipt-layout/reduction.txt) retains the single triple `(1,0,0)` from `(2,1,0)`; parent/restored outputs agree with SHA-256 `2bdf93c2a8b13ce8be6fe51bdb5835e6a4717efa6a4172acff96d274b6f7a701`. This is a retained calibration witness, not a discovered production bug or proof of a global minimum. + +## Replay, provenance and validation + +The [original archive](receipt-layout/original-records.tar.gz) contains both buildable driver projects, their fixtures and lockfiles, the top-level identical driver source, launch/reduction scripts, all deliberate source controls, exact binary observations and raw logs. Readable text copies remove trailing whitespace and terminal blank lines only. Extract outside tracked source, recreate the recorded copied-source paths from the baseline and candidate, and use `compare.sh` and corrected `calibrate.sh`. `driver OUTPUT HEX_SEED DEVICE MOUNT FILE` replays one triple; `shrink.rb` orchestrates executions through the recorded Docker container name, which must be adjusted if using another container. It does not execute Keep on the host. + +[Final source profile](receipt-layout/source-profile.txt) matches each changed Rust source between the candidate and Docker after all controls were restored, and verifies that top-level, archived-project and executed driver sources agree. Product execution used pinned Rust 1.96.0, copied source, offline dependency resolution and the checked-in baseline versions. Observation runs are medium, single-machine experiments with owned files, isolated network namespaces, a 536870912-byte kernel address-space limit, a 30-second deadline and a 5-second kill grace. Static controls use 120-second deadlines with 5-second kill grace. These explicit bounds do not claim per-test resource admission for the existing Cargo suites. + +[Focused validation](receipt-layout/validation.txt) passes workspace formatting, both workspace Clippy feature configurations, reader-lock doctests, public intent/receipt/disposition laws, GC and retention-disposition filesystem laws, and the independent format oracle in debug and release, plus GC fuzz-target Clippy. The actual filesystem tests use the previously recorded ext4 scratch mounts. No physical power-loss or performance campaign ran for this refactor. + +The final structure-check command in that log initially refused because the copied Git archive had no Git index. Creating a local inspection index, without a product commit or remote identity claim, allowed the [unchanged-source structure check](receipt-layout/structure.txt) to pass. No runtime suite was retried into green. Changed Markdown and the required whole-tree whitespace check are separate final-candidate checks. + +The existing runtime laws retain their oracles and expected bytes; no test or golden is deleted or re-baselined. The static layout proof belongs to this fixed encoder while the format remains supported. This focused receipt does not close other #107 findings, integrate main, grant a testing-policy waiver, or replace full validation and independent approval of the eventual integrated PR. diff --git a/docs/testing-evidence/receipt-layout/calibration-continuation.txt b/docs/testing-evidence/receipt-layout/calibration-continuation.txt new file mode 100644 index 00000000..bd54a0bb --- /dev/null +++ b/docs/testing-evidence/receipt-layout/calibration-continuation.txt @@ -0,0 +1,16 @@ +Static layout control=record-length expected compiler rejection +Compiler exit=101 +error[E0080]: evaluation panicked: assertion failed: RECORD_LENGTH == 320 && ENCODED_LENGTH == 320 + Checking keep v0.0.0 (/build/keep107-coordinate-source) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.21s + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling coordinate-observation v0.0.0 (/build/keep107-receipt-driver-current) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.04s +Runtime pool-retention control seed=0x107c00d +/build/keep107-receipt-evidence/parent-fixed.bin /build/keep107-receipt-evidence/pool-mutant.bin differ: char 1765, line 3 +Runtime comparison exit=1 + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling coordinate-observation v0.0.0 (/build/keep107-receipt-driver-current) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.00s +Restored runtime comparison: equal +8b0902b546ae47cdcebf93bfe8c99d6269ce2c28f70eaffba8181c17b59a9a1d /build/keep107-receipt-evidence/restored.bin diff --git a/docs/testing-evidence/receipt-layout/calibration.txt b/docs/testing-evidence/receipt-layout/calibration.txt new file mode 100644 index 00000000..4d9afcf5 --- /dev/null +++ b/docs/testing-evidence/receipt-layout/calibration.txt @@ -0,0 +1,11 @@ +Static layout control=field-array expected compiler rejection +Compiler exit=101 +error[E0308]: mismatched types +Static layout control=field-total expected compiler rejection +Compiler exit=101 +error[E0308]: mismatched types +Static layout control=preimage-total expected compiler rejection +Compiler exit=101 +error[E0308]: mismatched types +Static layout control=record-length expected compiler rejection +Compiler exit=101 diff --git a/docs/testing-evidence/receipt-layout/comparison.txt b/docs/testing-evidence/receipt-layout/comparison.txt new file mode 100644 index 00000000..b03c1cdd --- /dev/null +++ b/docs/testing-evidence/receipt-layout/comparison.txt @@ -0,0 +1,24 @@ + Compiling keep v0.0.0 (/build/keep107-receipt-parent) + Compiling coordinate-observation v0.0.0 (/build/keep107-receipt-driver-parent) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.59s + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s +Replay subject=parent corpus=fixed seed=0x107c00d cases=7+256 deadline=30 kill-grace=5 address-space=536870912 network=unshare +Replay subject=parent corpus=random seed=0xd12c1871b6c62734 cases=7+256 deadline=30 kill-grace=5 address-space=536870912 network=unshare + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling coordinate-observation v0.0.0 (/build/keep107-receipt-driver-current) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.33s + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s +Replay subject=current corpus=fixed seed=0x107c00d cases=7+256 deadline=30 kill-grace=5 address-space=536870912 network=unshare +Replay subject=current corpus=random seed=0xd12c1871b6c62734 cases=7+256 deadline=30 kill-grace=5 address-space=536870912 network=unshare +Exact sampled public output comparison fixed: equal +8b0902b546ae47cdcebf93bfe8c99d6269ce2c28f70eaffba8181c17b59a9a1d /build/keep107-receipt-evidence/parent-fixed.bin +8b0902b546ae47cdcebf93bfe8c99d6269ce2c28f70eaffba8181c17b59a9a1d /build/keep107-receipt-evidence/current-fixed.bin +310340 /build/keep107-receipt-evidence/parent-fixed.bin +310340 /build/keep107-receipt-evidence/current-fixed.bin +620680 total +Exact sampled public output comparison random: equal +8d798c0c121eb3dba3015fa074956f28f0c7398bc86a9888f08d9c8dabb7ba83 /build/keep107-receipt-evidence/parent-random.bin +8d798c0c121eb3dba3015fa074956f28f0c7398bc86a9888f08d9c8dabb7ba83 /build/keep107-receipt-evidence/current-random.bin +310340 /build/keep107-receipt-evidence/parent-random.bin +310340 /build/keep107-receipt-evidence/current-random.bin +620680 total diff --git a/docs/testing-evidence/receipt-layout/field-array.txt b/docs/testing-evidence/receipt-layout/field-array.txt new file mode 100644 index 00000000..9a062d82 --- /dev/null +++ b/docs/testing-evidence/receipt-layout/field-array.txt @@ -0,0 +1,18 @@ + Checking keep v0.0.0 (/build/keep107-coordinate-source) +error[E0308]: mismatched types + --> src/adapters/gc/receipt_encoder.rs:37:15 + | +14 | let bytes: [u8; $width] = $value; + | ------------ expected due to this +... +37 | 16 => [0; 15], + | ^^^^^^^ expected an array with a size of 16, found one with a size of 15 + | +help: consider specifying the actual array length + | +37 - 16 => [0; 15], +37 + 15 => [0; 15], + | + +For more information about this error, try `rustc --explain E0308`. +error: could not compile `keep` (lib) due to 1 previous error diff --git a/docs/testing-evidence/receipt-layout/field-total.txt b/docs/testing-evidence/receipt-layout/field-total.txt new file mode 100644 index 00000000..a50501ca --- /dev/null +++ b/docs/testing-evidence/receipt-layout/field-total.txt @@ -0,0 +1,27 @@ + Checking keep v0.0.0 (/build/keep107-coordinate-source) +error[E0308]: mismatched types + --> src/adapters/gc/receipt_encoder.rs:10:34 + | +10 | const _: [(); $length] = [(); 0 $(+ $width)+]; + | ------------- ^^^^^^^^^^^^^^^^^^^^ expected an array with a size of 240, found one with a size of 232 + | | + | expected because of the type of the constant +... +36 | / receipt_bytes!(format::RESERVED_OFFSET; +37 | | 16 => format::MAGIC, +38 | | 2 => format::VERSION.to_be_bytes(), +39 | | 2 => format::RECORD_LENGTH.to_be_bytes(), +... | +51 | | 8 => receipt.synchronization_count().to_be_bytes(), +52 | | ) + | |_____- in this macro invocation + | + = note: this error originates in the macro `receipt_bytes` (in Nightly builds, run with -Z macro-backtrace for more info) +help: consider specifying the actual array length + | +36 - receipt_bytes!(format::RESERVED_OFFSET; +36 + receipt_bytes!(232; + | + +For more information about this error, try `rustc --explain E0308`. +error: could not compile `keep` (lib) due to 1 previous error diff --git a/docs/testing-evidence/receipt-layout/original-records.tar.gz b/docs/testing-evidence/receipt-layout/original-records.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..265b5b058f954a9cf2db4cc945e9808382d79958 GIT binary patch literal 352390 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 S?k4RR)^wXyGy#rxP05{;XZhuD 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 J@W64;wHLf(xTCngJcA;(HO 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

Cr`LX2VY*4YQ6a@Iet7+#3G5<3(!+um~y~D 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 dT(l16NTAgh9H^`<9 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 z

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 literal 0 HcmV?d00001 diff --git a/docs/testing-evidence/receipt-layout/preimage-total.txt b/docs/testing-evidence/receipt-layout/preimage-total.txt new file mode 100644 index 00000000..c5bf6137 --- /dev/null +++ b/docs/testing-evidence/receipt-layout/preimage-total.txt @@ -0,0 +1,25 @@ + Checking keep v0.0.0 (/build/keep107-coordinate-source) +error[E0308]: mismatched types + --> src/adapters/gc/receipt_encoder.rs:10:34 + | +10 | const _: [(); $length] = [(); 0 $(+ $width)+]; + | ------------- ^^^^^^^^^^^^^^^^^^^^ expected an array with a size of 288, found one with a size of 289 + | | + | expected because of the type of the constant +... +24 | let preimage = receipt_bytes!(format::CHECKSUM_OFFSET; + | ____________________- +25 | | format::RESERVED_OFFSET => fields(&receipt), +26 | | format::RESERVED_LENGTH => [0; format::RESERVED_LENGTH], +27 | | ); + | |_____- in this macro invocation + | + = note: this error originates in the macro `receipt_bytes` (in Nightly builds, run with -Z macro-backtrace for more info) +help: consider specifying the actual array length + | +24 - let preimage = receipt_bytes!(format::CHECKSUM_OFFSET; +24 + let preimage = receipt_bytes!(289; + | + +For more information about this error, try `rustc --explain E0308`. +error: could not compile `keep` (lib) due to 1 previous error diff --git a/docs/testing-evidence/receipt-layout/record-length.txt b/docs/testing-evidence/receipt-layout/record-length.txt new file mode 100644 index 00000000..be625056 --- /dev/null +++ b/docs/testing-evidence/receipt-layout/record-length.txt @@ -0,0 +1,9 @@ + Checking keep v0.0.0 (/build/keep107-coordinate-source) +error[E0080]: evaluation panicked: assertion failed: RECORD_LENGTH == 320 && ENCODED_LENGTH == 320 + --> src/adapters/gc/receipt_format.rs:11:15 + | +11 | const _: () = assert!(RECORD_LENGTH == 320 && ENCODED_LENGTH == 320); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ evaluation of `adapters::gc::receipt_format::_` failed here + +For more information about this error, try `rustc --explain E0080`. +error: could not compile `keep` (lib) due to 1 previous error diff --git a/docs/testing-evidence/receipt-layout/reduction.txt b/docs/testing-evidence/receipt-layout/reduction.txt new file mode 100644 index 00000000..da956317 --- /dev/null +++ b/docs/testing-evidence/receipt-layout/reduction.txt @@ -0,0 +1,4 @@ +Reduced runtime counterexample: 1,0,0 +Replay: /build/keep107-receipt-evidence/driver-mutant /build/keep107-receipt-evidence/reduced/replay.bin 107c00d 1 0 0 +2bdf93c2a8b13ce8be6fe51bdb5835e6a4717efa6a4172acff96d274b6f7a701 /build/keep107-receipt-evidence/reduced-parent.bin +2bdf93c2a8b13ce8be6fe51bdb5835e6a4717efa6a4172acff96d274b6f7a701 /build/keep107-receipt-evidence/reduced-restored.bin diff --git a/docs/testing-evidence/receipt-layout/source-profile.txt b/docs/testing-evidence/receipt-layout/source-profile.txt new file mode 100644 index 00000000..13eafacd --- /dev/null +++ b/docs/testing-evidence/receipt-layout/source-profile.txt @@ -0,0 +1,5 @@ +Parent: 65c0707ba52ecd95e56474a9675ada4ec634701b +a5567851360c0c860d2b3ee2abf53d8e4631e433936f885fecee447361af70a3 src/adapters/gc/receipt_encoder.rs (host/Docker match after controls restored) +2a8a71ea564bfe50938c8c552232c6c0559e388b80e26d73c297b0d0c149433e src/adapters/gc/receipt_format.rs (host/Docker match after controls restored) +c495a3bfc923f11e9369d4d8f962a12b30187043fa314199b9caa85e06aa2e9a src/adapters/gc/canonical_receipt.rs (host/Docker match after controls restored) +38b21a4007e16637d303addf660ad5a56186ceda19e48f4bf59dc2fe6abcee80 driver.rs (top-level, both archived projects and both executed Docker sources match) diff --git a/docs/testing-evidence/receipt-layout/structure.txt b/docs/testing-evidence/receipt-layout/structure.txt new file mode 100644 index 00000000..3b1f0f9a --- /dev/null +++ b/docs/testing-evidence/receipt-layout/structure.txt @@ -0,0 +1,2 @@ + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s + Running `/build/keep107-coordinate-target/debug/xtask source-structure-check` diff --git a/docs/testing-evidence/receipt-layout/validation.txt b/docs/testing-evidence/receipt-layout/validation.txt new file mode 100644 index 00000000..eecde28c --- /dev/null +++ b/docs/testing-evidence/receipt-layout/validation.txt @@ -0,0 +1,225 @@ + Checking keep v0.0.0 (/build/keep107-coordinate-source) + Checking xtask v0.0.0 (/build/keep107-coordinate-source/xtask) + Checking keep-benchmark v0.0.0 (/build/keep107-coordinate-source/benchmark) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.71s + Checking keep v0.0.0 (/build/keep107-coordinate-source) + Checking keep-benchmark v0.0.0 (/build/keep107-coordinate-source/benchmark) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.41s + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.07s + Doc-tests keep + +running 1 test +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 16) ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 5 filtered out; finished in 0.00s + + +running 3 tests +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 25) - compile fail ... ok +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 43) - compile fail ... ok +test src/adapters/gc/reader_lock_identity.rs - adapters::gc::reader_lock_identity::ReaderLockIdentity (line 34) - compile fail ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.01s + +all doctests ran in 0.33s; merged doctests compilation took 0.31s +Focused profile=debug + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.36s + Running tests/gc_retirement_intent.rs (/build/keep107-coordinate-target/debug/deps/gc_retirement_intent-6de0a7d49b53f247) + +running 5 tests +test mutation_laws::intent_framing_refuses_truncation_and_trailing_data ... ok +test frozen_intent_decodes_and_reencodes_canonically ... ok +test semantic_intent_refuses_empty_and_repeated_candidate_sets ... ok +test mutation_laws::every_intent_field_has_one_exact_first_refusal ... ok +test mutation_laws::candidate_order_and_count_bounds_refuse_after_complete_integrity ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.38s + + Running tests/gc_retirement_receipt.rs (/build/keep107-coordinate-target/debug/deps/gc_retirement_receipt-b07035724611f35c) + +running 4 tests +test receipt_framing_refuses_any_length_but_the_fixed_width ... ok +test frozen_receipt_completes_the_frozen_intent_and_reencodes_canonically ... ok +test pool_state_digest_is_carried_not_bound_to_the_intent ... ok +test every_receipt_field_has_one_exact_first_refusal ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/recovery_disposition_receipt.rs (/build/keep107-coordinate-target/debug/deps/recovery_disposition_receipt-96812ccadb83a6d8) + +running 5 tests +test every_registered_code_round_trips_and_matches_the_definition ... ok +test framing_refuses_truncation_and_trailing_bytes ... ok +test liveness_zero_beside_the_empty_retention_digest_round_trips_as_empty_retention ... ok +test every_structural_field_has_one_exact_first_refusal ... ok +test frozen_disposition_decodes_and_reencodes_canonically ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.08s + Running unittests src/lib.rs (/build/keep107-coordinate-target/debug/deps/keep-882caa9da157737f) + +running 26 tests +test adapters::gc::planner_tests::an_empty_inventory_plans_nothing ... ok +test adapters::gc::planner_tests::a_named_segment_no_root_reaches_is_named_unreachable_and_never_a_candidate ... ok +test adapters::gc::planner_tests::a_snapshot_refuses_duplicate_inventory_and_namespaces ... ok +test adapters::gc::planner_tests::an_unnamed_segment_without_release_evidence_is_recovery_protected ... ok +test adapters::gc::planner_tests::every_contradiction_refuses_the_plan ... ok +test adapters::gc::planner_tests::named_segments_reached_by_retained_closures_are_live_with_their_root_count ... ok +test adapters::gc::planner_tests::superseded_and_disposed_unnamed_segments_are_the_only_candidates ... ok +test adapters::gc::planner_tests::superseded_or_disposed_segments_absent_from_the_inventory_are_already_retired ... ok +test adapters::gc::planner_tests::the_candidate_limit_refuses_rather_than_truncates ... ok +test adapters::gc::planner_tests::the_golden_version_two_store_plans_one_live_segment ... ok +test adapters::gc::planner_tests::plan_model_tests::planning_never_collects_live_or_named_material_and_is_pure ... ok +test adapters::gc::liveness_observation_tests::an_unpublished_store_plans_its_named_segment_unreachable_but_not_collectible ... ok +test adapters::gc::filesystem_gc_tests::nothing_to_retire_and_a_stale_plan_refuse_before_any_intent ... ok +test adapters::gc::liveness_observation_tests::the_published_fixture_store_plans_its_one_segment_live ... ok +test adapters::gc::liveness_observation_tests::an_orphan_pool_segment_is_recovery_protected_and_never_a_candidate ... ok +test adapters::gc::filesystem_gc_tests::admission_refuses_a_foreign_gc_entry_or_a_directory_in_place_of_a_record ... ok +test adapters::gc::liveness_observation_tests::a_corrupt_pool_segment_refuses_observation_before_any_plan ... ok +test adapters::gc::liveness_observation_tests::a_pool_entry_not_named_by_a_digest_refuses_observation ... ok +test adapters::gc::liveness_observation_tests::an_exact_retire_receipt_makes_the_orphan_collectible_and_a_stale_one_does_not ... ok +test adapters::gc::filesystem_gc_tests::an_absent_candidate_after_a_present_one_is_ambiguous_and_touches_nothing ... ok +test adapters::gc::filesystem_gc_tests::a_reader_holding_the_fence_refuses_retirement_without_waiting ... ok +test adapters::gc::filesystem_gc_tests::retiring_the_disposed_orphan_leaves_the_receipt_and_the_live_segment ... ok +test adapters::gc::filesystem_gc_tests::a_durable_intent_excludes_retention_publication_and_another_retirement ... ok +test adapters::gc::filesystem_gc_tests::a_second_retirement_succeeds_the_first_receipts_generation ... ok +test adapters::gc::filesystem_gc_tests::a_truncated_stage_is_discarded_and_the_retirement_then_completes ... ok +test adapters::gc::filesystem_gc_tests::every_interrupted_prefix_recovers_to_the_same_complete_state ... ok + +test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 276 filtered out; finished in 2.26s + + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.01s + Running unittests src/lib.rs (/build/keep107-coordinate-target/debug/deps/keep-882caa9da157737f) + +running 8 tests +test adapters::retention::filesystem_retention_disposition_tests::finalize_requires_a_published_head ... ok +test adapters::retention::filesystem_retention_disposition_tests::readers_admit_a_store_with_receipts_and_refuse_a_stray_disposition_entry ... ok +test adapters::retention::filesystem_retention_disposition_tests::a_second_disposition_finds_nothing_protected_and_changes_nothing ... ok +test adapters::retention::filesystem_retention_disposition_tests::a_reader_holding_the_fence_refuses_disposition_without_waiting ... ok +test adapters::retention::filesystem_retention_disposition_tests::retiring_a_protected_root_under_an_absent_head_frees_publication ... ok +test adapters::retention::filesystem_retention_disposition_tests::the_manifest_stage_must_be_disposed_before_the_root_it_names ... ok +test adapters::retention::filesystem_retention_disposition_tests::an_interrupted_retirement_resumes_from_every_residue ... ok +test adapters::retention::filesystem_retention_disposition_tests::finalizing_a_successor_orphan_keeps_its_pool_entry_and_frees_publication ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 294 filtered out; finished in 0.20s + + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling xtask v0.0.0 (/build/keep107-coordinate-source/xtask) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.14s + Running tests/retention_store_v2_format_oracle.rs (/build/keep107-coordinate-target/debug/deps/retention_store_v2_format_oracle-2a6d5511749a3dcb) + +running 4 tests +test definition_and_profile_tables_are_exact_and_canonical ... ok +test retention_anchor_ids_are_derived_from_the_accepted_layout_corpus ... ok +test definition_profile_and_migration_sources_match_the_oracle ... ok +test golden_artifacts_match_the_independent_oracle ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +Focused profile=release + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `release` profile [optimized] target(s) in 2.68s + Running tests/gc_retirement_intent.rs (/build/keep107-coordinate-target/release/deps/gc_retirement_intent-16dd92d184650f31) + +running 5 tests +test frozen_intent_decodes_and_reencodes_canonically ... ok +test mutation_laws::intent_framing_refuses_truncation_and_trailing_data ... ok +test semantic_intent_refuses_empty_and_repeated_candidate_sets ... ok +test mutation_laws::every_intent_field_has_one_exact_first_refusal ... ok +test mutation_laws::candidate_order_and_count_bounds_refuse_after_complete_integrity ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/gc_retirement_receipt.rs (/build/keep107-coordinate-target/release/deps/gc_retirement_receipt-7af42e76dc359ac8) + +running 4 tests +test every_receipt_field_has_one_exact_first_refusal ... ok +test frozen_receipt_completes_the_frozen_intent_and_reencodes_canonically ... ok +test receipt_framing_refuses_any_length_but_the_fixed_width ... ok +test pool_state_digest_is_carried_not_bound_to_the_intent ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/recovery_disposition_receipt.rs (/build/keep107-coordinate-target/release/deps/recovery_disposition_receipt-d5a50675829e7cd0) + +running 5 tests +test every_registered_code_round_trips_and_matches_the_definition ... ok +test framing_refuses_truncation_and_trailing_bytes ... ok +test liveness_zero_beside_the_empty_retention_digest_round_trips_as_empty_retention ... ok +test every_structural_field_has_one_exact_first_refusal ... ok +test frozen_disposition_decodes_and_reencodes_canonically ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Finished `release` profile [optimized] target(s) in 4.12s + Running unittests src/lib.rs (/build/keep107-coordinate-target/release/deps/keep-8ca1b588e6dfe3ea) + +running 26 tests +test adapters::gc::planner_tests::a_snapshot_refuses_duplicate_inventory_and_namespaces ... ok +test adapters::gc::planner_tests::a_named_segment_no_root_reaches_is_named_unreachable_and_never_a_candidate ... ok +test adapters::gc::planner_tests::an_empty_inventory_plans_nothing ... ok +test adapters::gc::planner_tests::an_unnamed_segment_without_release_evidence_is_recovery_protected ... ok +test adapters::gc::planner_tests::every_contradiction_refuses_the_plan ... ok +test adapters::gc::planner_tests::named_segments_reached_by_retained_closures_are_live_with_their_root_count ... ok +test adapters::gc::planner_tests::superseded_and_disposed_unnamed_segments_are_the_only_candidates ... ok +test adapters::gc::planner_tests::superseded_or_disposed_segments_absent_from_the_inventory_are_already_retired ... ok +test adapters::gc::planner_tests::the_candidate_limit_refuses_rather_than_truncates ... ok +test adapters::gc::planner_tests::the_golden_version_two_store_plans_one_live_segment ... ok +test adapters::gc::planner_tests::plan_model_tests::planning_never_collects_live_or_named_material_and_is_pure ... ok +test adapters::gc::liveness_observation_tests::an_unpublished_store_plans_its_named_segment_unreachable_but_not_collectible ... ok +test adapters::gc::liveness_observation_tests::the_published_fixture_store_plans_its_one_segment_live ... ok +test adapters::gc::filesystem_gc_tests::admission_refuses_a_foreign_gc_entry_or_a_directory_in_place_of_a_record ... ok +test adapters::gc::liveness_observation_tests::an_exact_retire_receipt_makes_the_orphan_collectible_and_a_stale_one_does_not ... ok +test adapters::gc::filesystem_gc_tests::nothing_to_retire_and_a_stale_plan_refuse_before_any_intent ... ok +test adapters::gc::liveness_observation_tests::a_pool_entry_not_named_by_a_digest_refuses_observation ... ok +test adapters::gc::liveness_observation_tests::an_orphan_pool_segment_is_recovery_protected_and_never_a_candidate ... ok +test adapters::gc::liveness_observation_tests::a_corrupt_pool_segment_refuses_observation_before_any_plan ... ok +test adapters::gc::filesystem_gc_tests::an_absent_candidate_after_a_present_one_is_ambiguous_and_touches_nothing ... ok +test adapters::gc::filesystem_gc_tests::a_reader_holding_the_fence_refuses_retirement_without_waiting ... ok +test adapters::gc::filesystem_gc_tests::retiring_the_disposed_orphan_leaves_the_receipt_and_the_live_segment ... ok +test adapters::gc::filesystem_gc_tests::a_durable_intent_excludes_retention_publication_and_another_retirement ... ok +test adapters::gc::filesystem_gc_tests::a_second_retirement_succeeds_the_first_receipts_generation ... ok +test adapters::gc::filesystem_gc_tests::a_truncated_stage_is_discarded_and_the_retirement_then_completes ... ok +test adapters::gc::filesystem_gc_tests::every_interrupted_prefix_recovers_to_the_same_complete_state ... ok + +test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 276 filtered out; finished in 1.51s + + Finished `release` profile [optimized] target(s) in 0.01s + Running unittests src/lib.rs (/build/keep107-coordinate-target/release/deps/keep-8ca1b588e6dfe3ea) + +running 8 tests +test adapters::retention::filesystem_retention_disposition_tests::finalize_requires_a_published_head ... ok +test adapters::retention::filesystem_retention_disposition_tests::a_second_disposition_finds_nothing_protected_and_changes_nothing ... ok +test adapters::retention::filesystem_retention_disposition_tests::readers_admit_a_store_with_receipts_and_refuse_a_stray_disposition_entry ... ok +test adapters::retention::filesystem_retention_disposition_tests::a_reader_holding_the_fence_refuses_disposition_without_waiting ... ok +test adapters::retention::filesystem_retention_disposition_tests::retiring_a_protected_root_under_an_absent_head_frees_publication ... ok +test adapters::retention::filesystem_retention_disposition_tests::an_interrupted_retirement_resumes_from_every_residue ... ok +test adapters::retention::filesystem_retention_disposition_tests::the_manifest_stage_must_be_disposed_before_the_root_it_names ... ok +test adapters::retention::filesystem_retention_disposition_tests::finalizing_a_successor_orphan_keeps_its_pool_entry_and_frees_publication ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 294 filtered out; finished in 0.20s + + Compiling keep v0.0.0 (/build/keep107-coordinate-source) + Compiling xtask v0.0.0 (/build/keep107-coordinate-source/xtask) + Finished `release` profile [optimized] target(s) in 4.15s + Running tests/retention_store_v2_format_oracle.rs (/build/keep107-coordinate-target/release/deps/retention_store_v2_format_oracle-0f938c44dbe97a05) + +running 4 tests +test definition_and_profile_tables_are_exact_and_canonical ... ok +test retention_anchor_ids_are_derived_from_the_accepted_layout_corpus ... ok +test golden_artifacts_match_the_independent_oracle ... ok +test definition_profile_and_migration_sources_match_the_oracle ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Checking keep v0.0.0 (/build/keep107-coordinate-source) + Checking keep-fuzz v0.0.0 (/build/keep107-coordinate-source/fuzz) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.56s + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s + Running `/build/keep107-coordinate-target/debug/xtask source-structure-check` +Error: `git ls-files present` failed with code Some(128): fatal: not a git repository (or any of the parent directories): .git diff --git a/src/adapters/gc/canonical_receipt.rs b/src/adapters/gc/canonical_receipt.rs index 471cfc46..578b7816 100644 --- a/src/adapters/gc/canonical_receipt.rs +++ b/src/adapters/gc/canonical_receipt.rs @@ -19,6 +19,10 @@ pub struct CanonicalGcRetirementReceipt { impl CanonicalGcRetirementReceipt { /// Constructs the one receipt that completes `intent`. + /// + /// Encodes fixed-size stack arrays without allocation, blocking or I/O. + /// The layout is checked at compile time; encoding does not perform + /// publication or establish that the recorded effects occurred. pub fn from_intent( intent: &CanonicalGcRetirementIntent, pool_state_digest: PoolStateDigest, diff --git a/src/adapters/gc/rationale.md b/src/adapters/gc/rationale.md index d52473bf..12215bce 100644 --- a/src/adapters/gc/rationale.md +++ b/src/adapters/gc/rationale.md @@ -1,4 +1,6 @@ -# Reader-lock coordinate roles +# GC boundary decisions + +## Reader-lock coordinate roles The public GC and recovery-disposition record API uses distinct `ReaderLockDevice`, `ReaderLockMount` and `ReaderLockFile` values. `ReaderLockIdentity` admits these roles explicitly, so passing a correctly labeled coordinate in another position is a compile-time error. This decision tightens the new API in #107; it does not retrofit unrelated filesystem identity APIs. @@ -9,3 +11,9 @@ The mount identifies a mount instance and remains same-process evidence. This ch A primitive triple and type aliases were rejected because either permits coordinate interchange. New zero or range refusals were rejected because the format admits every unsigned 64-bit coordinate. Explicit named wrappers keep those facts separate and require no allocation, blocking, I/O or new dependency. The source-level compatibility change requires callers to wrap raw values with the appropriate constructor and unwrap getter results with `get()`. Static compile-fail examples guard pairwise interchange; existing frozen runtime laws and generated cross-revision comparisons guard the separate byte-preservation claim. A compiler rejection is static/API evidence, not a storage runtime test. + +## Fixed receipt encoding + +The retirement receipt grammar is a fixed array. Public values change bytes, never field widths or record length. A private construction macro binds every emitted expression to an array of its declared width and equates the sum with the destination array length at compile time. The same construction joins the field area, reserved area and checksum. Those two linked checks prove that iterator writes fill exactly the destination; an unproved truncating zip would not be sufficient. + +This replaces panicking slice operations while retaining the infallible public constructor and its fixed stack allocation. A future field-width or framing mismatch fails compilation. A heap buffer and a new public encoding-error variant were rejected because no runtime input can cause a layout failure in this construction. Existing exact-byte runtime laws and generated cross-revision evidence guard serialization behavior separately from the static proof. The macro remains private to this encoder; this decision does not certify other codecs. diff --git a/src/adapters/gc/receipt_encoder.rs b/src/adapters/gc/receipt_encoder.rs index d0964581..797e33c5 100644 --- a/src/adapters/gc/receipt_encoder.rs +++ b/src/adapters/gc/receipt_encoder.rs @@ -2,46 +2,53 @@ use super::{CanonicalGcRetirementReceipt, GcRetirementReceipt, receipt_format as format}; +// Every emitted expression must have its declared array width, and their total +// must equal the destination. Together these compile-time checks prove that the +// iterator writes cannot truncate or leave a slot unfilled. No input sets a width. +macro_rules! receipt_bytes { + ($length:expr; $($width:expr => $value:expr),+ $(,)?) => {{ + const _: [(); $length] = [(); 0 $(+ $width)+]; + let mut encoded = [0_u8; $length]; + let mut slots = encoded.iter_mut(); + $( + let bytes: [u8; $width] = $value; + for (slot, byte) in slots.by_ref().take($width).zip(bytes) { + *slot = byte; + } + )+ + encoded + }}; +} + pub(super) fn encode(receipt: GcRetirementReceipt) -> CanonicalGcRetirementReceipt { - let mut encoded = [0_u8; format::ENCODED_LENGTH]; - let (preimage, checksum_slot) = encoded.split_at_mut(format::CHECKSUM_OFFSET); - write_preimage(preimage, &receipt); - checksum_slot.copy_from_slice(&format::checksum(preimage)); + let preimage = receipt_bytes!(format::CHECKSUM_OFFSET; + format::RESERVED_OFFSET => fields(&receipt), + format::RESERVED_LENGTH => [0; format::RESERVED_LENGTH], + ); + let encoded = receipt_bytes!(format::ENCODED_LENGTH; + format::CHECKSUM_OFFSET => preimage, + 32 => format::checksum(&preimage), + ); CanonicalGcRetirementReceipt::admitted(&encoded, receipt) } -fn write_preimage(output: &mut [u8], receipt: &GcRetirementReceipt) { - let (magic, output) = output.split_at_mut(16); - magic.copy_from_slice(&format::MAGIC); - let (version, output) = output.split_at_mut(2); - version.copy_from_slice(&format::VERSION.to_be_bytes()); - let (record_length, output) = output.split_at_mut(2); - record_length.copy_from_slice(&format::RECORD_LENGTH.to_be_bytes()); - let (flags, output) = output.split_at_mut(4); - flags.copy_from_slice(&0_u32.to_be_bytes()); - let (generation, output) = output.split_at_mut(8); - generation.copy_from_slice(&receipt.generation().get().to_be_bytes()); - let (intent_digest, output) = output.split_at_mut(32); - intent_digest.copy_from_slice(receipt.intent_digest().as_bytes()); - let (retired_set, output) = output.split_at_mut(32); - retired_set.copy_from_slice(receipt.retired_candidate_set_digest().as_bytes()); - let (pool_state, output) = output.split_at_mut(32); - pool_state.copy_from_slice(receipt.pool_state_digest().as_bytes()); - let (liveness, output) = output.split_at_mut(8); - liveness.copy_from_slice(&receipt.liveness_generation().get().to_be_bytes()); - let (manifest_digest, output) = output.split_at_mut(32); - manifest_digest.copy_from_slice(receipt.manifest_digest().as_bytes()); - let (catalog_generation, output) = output.split_at_mut(8); - catalog_generation.copy_from_slice(&receipt.catalog_generation().get().to_be_bytes()); - let (catalog_digest, output) = output.split_at_mut(32); - catalog_digest.copy_from_slice(receipt.catalog_digest().as_bytes()); - let (device, output) = output.split_at_mut(8); - device.copy_from_slice(&receipt.reader_lock().device().get().to_be_bytes()); - let (mount, output) = output.split_at_mut(8); - mount.copy_from_slice(&receipt.reader_lock().mount().get().to_be_bytes()); - let (file, output) = output.split_at_mut(8); - file.copy_from_slice(&receipt.reader_lock().file().get().to_be_bytes()); - let (synchronization_count, reserved) = output.split_at_mut(8); - synchronization_count.copy_from_slice(&receipt.synchronization_count().to_be_bytes()); - reserved.fill(0); +fn fields(receipt: &GcRetirementReceipt) -> [u8; format::RESERVED_OFFSET] { + receipt_bytes!(format::RESERVED_OFFSET; + 16 => format::MAGIC, + 2 => format::VERSION.to_be_bytes(), + 2 => format::RECORD_LENGTH.to_be_bytes(), + 4 => 0_u32.to_be_bytes(), + 8 => receipt.generation().get().to_be_bytes(), + 32 => *receipt.intent_digest().as_bytes(), + 32 => *receipt.retired_candidate_set_digest().as_bytes(), + 32 => *receipt.pool_state_digest().as_bytes(), + 8 => receipt.liveness_generation().get().to_be_bytes(), + 32 => *receipt.manifest_digest().as_bytes(), + 8 => receipt.catalog_generation().get().to_be_bytes(), + 32 => *receipt.catalog_digest().as_bytes(), + 8 => receipt.reader_lock().device().get().to_be_bytes(), + 8 => receipt.reader_lock().mount().get().to_be_bytes(), + 8 => receipt.reader_lock().file().get().to_be_bytes(), + 8 => receipt.synchronization_count().to_be_bytes(), + ) } diff --git a/src/adapters/gc/receipt_format.rs b/src/adapters/gc/receipt_format.rs index 71575cb6..80afa2b4 100644 --- a/src/adapters/gc/receipt_format.rs +++ b/src/adapters/gc/receipt_format.rs @@ -7,6 +7,8 @@ pub(super) const RECORD_LENGTH: u16 = 320; pub(super) const VERSION: u16 = 2; pub(super) const RESERVED_OFFSET: usize = 240; pub(super) const RESERVED_LENGTH: usize = 48; +// The fixed grammar records the same byte length as its owned representation. +const _: () = assert!(RECORD_LENGTH == 320 && ENCODED_LENGTH == 320); const CHECKSUM_DOMAIN: &[u8] = b"keep.gc-retirement-receipt-checksum/v2\0"; pub(super) fn checksum(preimage: &[u8]) -> [u8; 32] { From 6a8432488bc9f182b7bd6f8abd4145fdb742a0ff Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 3 Oct 2026 20:30:35 -0700 Subject: [PATCH 53/59] test(#107): name retention head seal choices --- tests/retention_head_codec/mutation_laws.rs | 34 ++++++++++++--------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/tests/retention_head_codec/mutation_laws.rs b/tests/retention_head_codec/mutation_laws.rs index a586c3db..dcebde42 100644 --- a/tests/retention_head_codec/mutation_laws.rs +++ b/tests/retention_head_codec/mutation_laws.rs @@ -10,9 +10,14 @@ use keep::{ChecksummedRetentionHead, RetentionHeadDecodeError as Refusal, Retent use super::{CHECKSUM_OFFSET, ONE_ROOT_HEAD, fixture_bytes}; use crate::support::{domain_hash, flip, patch}; +enum Seal { + Checksum, + Nothing, +} + struct Mutation { field: &'static str, - reseal: bool, + seal: Seal, mutate: fn(&mut Vec) -> io::Result<()>, refuses: fn(&Refusal) -> bool, } @@ -20,13 +25,13 @@ struct Mutation { const MATRIX: &[Mutation] = &[ Mutation { field: "magic", - reseal: true, + seal: Seal::Checksum, mutate: |bytes| flip(bytes, 15), refuses: |error| matches!(error, Refusal::InvalidMagic { .. }), }, Mutation { field: "version", - reseal: true, + seal: Seal::Checksum, mutate: |bytes| patch(bytes, 16, &3_u16.to_be_bytes()), refuses: |error| { matches!( @@ -40,7 +45,7 @@ const MATRIX: &[Mutation] = &[ }, Mutation { field: "record length", - reseal: true, + seal: Seal::Checksum, mutate: |bytes| patch(bytes, 18, &143_u16.to_be_bytes()), refuses: |error| { matches!( @@ -54,19 +59,19 @@ const MATRIX: &[Mutation] = &[ }, Mutation { field: "flags", - reseal: true, + seal: Seal::Checksum, mutate: |bytes| patch(bytes, 20, &1_u32.to_be_bytes()), refuses: |error| matches!(error, Refusal::UnsupportedFlags { observed: 1 }), }, Mutation { field: "liveness generation zero", - reseal: true, + seal: Seal::Checksum, mutate: |bytes| patch(bytes, 24, &0_u64.to_be_bytes()), refuses: |error| matches!(error, Refusal::LivenessGeneration { .. }), }, Mutation { field: "liveness generation two without predecessor", - reseal: true, + seal: Seal::Checksum, mutate: |bytes| patch(bytes, 24, &2_u64.to_be_bytes()), refuses: |error| { matches!( @@ -79,19 +84,19 @@ const MATRIX: &[Mutation] = &[ }, Mutation { field: "manifest length below bound", - reseal: true, + seal: Seal::Checksum, mutate: |bytes| patch(bytes, 32, &223_u64.to_be_bytes()), refuses: |error| matches!(error, Refusal::ManifestLength { .. }), }, Mutation { field: "manifest length not congruent", - reseal: true, + seal: Seal::Checksum, mutate: |bytes| patch(bytes, 32, &225_u64.to_be_bytes()), refuses: |error| matches!(error, Refusal::ManifestLength { .. }), }, Mutation { field: "predecessor digest at generation one", - reseal: true, + seal: Seal::Checksum, mutate: |bytes| flip(bytes, 72), refuses: |error| { matches!( @@ -104,13 +109,13 @@ const MATRIX: &[Mutation] = &[ }, Mutation { field: "reserved bytes", - reseal: true, + seal: Seal::Checksum, mutate: |bytes| flip(bytes, 111), refuses: |error| matches!(error, Refusal::NonZeroReserved { .. }), }, Mutation { field: "checksum", - reseal: false, + seal: Seal::Nothing, mutate: |bytes| flip(bytes, 143), refuses: |error| matches!(error, Refusal::ChecksumMismatch { .. }), }, @@ -121,8 +126,9 @@ fn every_head_field_has_one_exact_first_refusal() -> Result<(), Box reseal(&mut bytes)?, + Seal::Nothing => {} } let Err(error) = ChecksummedRetentionHead::decode(&bytes) else { return Err(format!("mutated {} was admitted", mutation.field).into()); From a62bfb80db04acd84c4226ae328757715d2c40d7 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 3 Oct 2026 20:38:54 -0700 Subject: [PATCH 54/59] test(#107): assert opaque GC coordinates at the public decoder --- tests/gc_retirement_intent.rs | 2 + .../opaque_coordinate_laws.rs | 188 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 tests/gc_retirement_intent/opaque_coordinate_laws.rs diff --git a/tests/gc_retirement_intent.rs b/tests/gc_retirement_intent.rs index 59011a49..cc1a524e 100644 --- a/tests/gc_retirement_intent.rs +++ b/tests/gc_retirement_intent.rs @@ -2,6 +2,8 @@ #[path = "gc_retirement_intent/mutation_laws.rs"] mod mutation_laws; +#[path = "gc_retirement_intent/opaque_coordinate_laws.rs"] +mod opaque_coordinate_laws; mod support; use std::io; diff --git a/tests/gc_retirement_intent/opaque_coordinate_laws.rs b/tests/gc_retirement_intent/opaque_coordinate_laws.rs new file mode 100644 index 00000000..983ef602 --- /dev/null +++ b/tests/gc_retirement_intent/opaque_coordinate_laws.rs @@ -0,0 +1,188 @@ +//! Opaque GC coordinates are retained and bound by the intent digest. +//! Oracle: KEEP-GC-001's wire fields and the independently frozen intent. +//! Size: small (in-memory codec); execution-profile limits are recorded in the landing evidence. + +use std::io; + +use keep::{AdmittedGcRetirementIntent, GcCandidate, GcRetirementIntent}; + +use super::{ + CANDIDATE_SET_DIGEST_OFFSET, CHECKSUM_OFFSET, HEADER_LENGTH, INTENT_DIGEST, + INTENT_DIGEST_OFFSET, fixture_bytes, +}; +use crate::support::{counted_domain_hash, domain_hash, flip, patch}; + +struct Coordinate { + name: &'static str, + offset: usize, + width: usize, + observed: fn(&GcRetirementIntent) -> io::Result>, +} + +const COORDINATES: &[Coordinate] = &[ + Coordinate { + name: "manifest digest", + offset: 56, + width: 32, + observed: |intent| Ok(intent.coordinates().manifest_digest.as_bytes().to_vec()), + }, + Coordinate { + name: "catalog digest", + offset: 96, + width: 32, + observed: |intent| Ok(intent.coordinates().catalog_digest.as_bytes().to_vec()), + }, + Coordinate { + name: "catalog successor proof", + offset: 168, + width: 32, + observed: |intent| { + Ok(intent + .coordinates() + .catalog_successor_proof_digest + .as_bytes() + .to_vec()) + }, + }, + Coordinate { + name: "segment pool identity", + offset: 200, + width: 32, + observed: |intent| { + Ok(intent + .coordinates() + .segment_pool_identity_digest + .as_bytes() + .to_vec()) + }, + }, + Coordinate { + name: "disposition set", + offset: 232, + width: 32, + observed: |intent| { + Ok(intent + .coordinates() + .disposition_set_digest + .as_bytes() + .to_vec()) + }, + }, + Coordinate { + name: "reader lock device", + offset: 264, + width: 8, + observed: |intent| { + Ok(intent + .coordinates() + .reader_lock + .device() + .get() + .to_be_bytes() + .to_vec()) + }, + }, + Coordinate { + name: "reader lock mount", + offset: 272, + width: 8, + observed: |intent| { + Ok(intent + .coordinates() + .reader_lock + .mount() + .get() + .to_be_bytes() + .to_vec()) + }, + }, + Coordinate { + name: "reader lock file", + offset: 280, + width: 8, + observed: |intent| { + Ok(intent + .coordinates() + .reader_lock + .file() + .get() + .to_be_bytes() + .to_vec()) + }, + }, + Coordinate { + name: "candidate segment digest", + offset: 320, + width: 32, + observed: |intent| Ok(candidate(intent)?.segment_digest().as_bytes().to_vec()), + }, + Coordinate { + name: "candidate segment length", + offset: 352, + width: 8, + observed: |intent| Ok(candidate(intent)?.segment_length().to_be_bytes().to_vec()), + }, + Coordinate { + name: "candidate evidence digest", + offset: 360, + width: 32, + observed: |intent| Ok(candidate(intent)?.evidence_digest().as_bytes().to_vec()), + }, +]; + +#[test] +fn resealed_opaque_coordinates_are_carried_into_a_distinct_intent() +-> Result<(), Box> { + for coordinate in COORDINATES { + let mut bytes = fixture_bytes()?; + flip(&mut bytes, coordinate.offset)?; + seal_fixture(&mut bytes)?; + let admitted = AdmittedGcRetirementIntent::decode(&bytes)?; + let end = coordinate + .offset + .checked_add(coordinate.width) + .ok_or_else(|| io::Error::other("coordinate end overflow"))?; + let expected = bytes + .get(coordinate.offset..end) + .ok_or_else(|| io::Error::other("fixture lacks the named coordinate"))?; + assert_eq!( + (coordinate.observed)(admitted.intent())?, + expected, + "{} must retain the input coordinate", + coordinate.name + ); + assert_ne!( + admitted.digest().as_bytes(), + &INTENT_DIGEST, + "{} must contribute to intent identity", + coordinate.name + ); + } + Ok(()) +} + +fn candidate(intent: &GcRetirementIntent) -> io::Result<&GcCandidate> { + intent + .candidates() + .first() + .ok_or_else(|| io::Error::other("admitted intent lost its candidate")) +} + +// Construct the fixed one-candidate input independently of the production encoder. +fn seal_fixture(bytes: &mut [u8]) -> io::Result<()> { + let body = bytes + .get(HEADER_LENGTH..INTENT_DIGEST_OFFSET) + .ok_or_else(|| io::Error::other("fixture lacks candidate body"))?; + let set_digest = counted_domain_hash(b"keep.gc-candidate-set/v2\0", 1, body); + patch(bytes, CANDIDATE_SET_DIGEST_OFFSET, &set_digest)?; + let preimage = bytes + .get(..INTENT_DIGEST_OFFSET) + .ok_or_else(|| io::Error::other("fixture lacks intent preimage"))?; + let digest = domain_hash(b"keep.gc-retirement-intent/v2\0", preimage); + patch(bytes, INTENT_DIGEST_OFFSET, &digest)?; + let preimage = bytes + .get(..CHECKSUM_OFFSET) + .ok_or_else(|| io::Error::other("fixture lacks checksum preimage"))?; + let checksum = domain_hash(b"keep.gc-retirement-intent-checksum/v2\0", preimage); + patch(bytes, CHECKSUM_OFFSET, &checksum) +} From 5b533b01b866db82e864d3915c9a55b9101fdff9 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 3 Oct 2026 20:38:54 -0700 Subject: [PATCH 55/59] test(#107): assert exact GC diagnostic coordinates --- tests/gc_retirement_intent/mutation_laws.rs | 93 +++++++++++++++------ tests/gc_retirement_receipt.rs | 46 +++++----- 2 files changed, 93 insertions(+), 46 deletions(-) diff --git a/tests/gc_retirement_intent/mutation_laws.rs b/tests/gc_retirement_intent/mutation_laws.rs index ebc13ea3..58541797 100644 --- a/tests/gc_retirement_intent/mutation_laws.rs +++ b/tests/gc_retirement_intent/mutation_laws.rs @@ -1,13 +1,13 @@ //! Field-by-field corruption matrix for GC retirement intents. //! -//! Every structural field of the intent header, candidate body, and trailer -//! has one mutation and one exact first refusal (`KEEP-GC-001`). The sealed -//! matrix recomputes every digest and checksum the mutation did not target. +//! Selected malformed fields refuse at their named boundary (KEEP-GC-001); opaque coordinates have separate admission laws. Sealing reconstructs integrity fields not targeted by each case. use std::io; use keep::{ - AdmittedGcRetirementIntent, GcRetirementIntentDecodeError as Refusal, GcRetirementIntentError, + AdmittedGcRetirementIntent, CatalogGenerationError, GcGenerationError, + GcRetirementIntentDecodeError as Refusal, GcRetirementIntentError, LivenessGenerationError, + RetentionProfileAdmissionError, }; use super::{ @@ -32,7 +32,7 @@ struct Mutation { field: &'static str, seal: Seal, mutate: fn(&mut Vec) -> io::Result<()>, - refuses: fn(&Refusal) -> bool, + refuses: fn(&Refusal, &[u8], &[u8]) -> bool, } const MATRIX: &[Mutation] = &[ @@ -40,13 +40,13 @@ const MATRIX: &[Mutation] = &[ field: "magic", seal: Seal::Everything, mutate: |bytes| flip(bytes, 15), - refuses: |error| matches!(error, Refusal::InvalidMagic { .. }), + refuses: |error, _, mutated| matches!(error, Refusal::InvalidMagic { observed } if Some(observed.as_slice()) == mutated.get(..16)), }, Mutation { field: "version", seal: Seal::Everything, mutate: |bytes| patch(bytes, 16, &3_u16.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::UnsupportedVersion { @@ -60,7 +60,7 @@ const MATRIX: &[Mutation] = &[ field: "header length", seal: Seal::Everything, mutate: |bytes| patch(bytes, 18, &319_u16.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::InvalidHeaderLength { @@ -74,13 +74,13 @@ const MATRIX: &[Mutation] = &[ field: "flags", seal: Seal::Everything, mutate: |bytes| patch(bytes, 20, &1_u32.to_be_bytes()), - refuses: |error| matches!(error, Refusal::UnsupportedFlags { observed: 1 }), + refuses: |error, _, _| matches!(error, Refusal::UnsupportedFlags { observed: 1 }), }, Mutation { field: "total record length", seal: Seal::Everything, mutate: |bytes| patch(bytes, 24, &455_u64.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::DeclaredLengthMismatch { @@ -94,13 +94,20 @@ const MATRIX: &[Mutation] = &[ field: "GC generation zero", seal: Seal::Everything, mutate: |bytes| patch(bytes, 32, &0_u64.to_be_bytes()), - refuses: |error| matches!(error, Refusal::Generation { .. }), + refuses: |error, _, _| { + matches!( + error, + Refusal::Generation { + source: GcGenerationError::Zero + } + ) + }, }, Mutation { field: "candidate width", seal: Seal::Everything, mutate: |bytes| patch(bytes, 40, &71_u16.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::InvalidCandidateWidth { @@ -114,13 +121,13 @@ const MATRIX: &[Mutation] = &[ field: "reserved candidate bytes", seal: Seal::Everything, mutate: |bytes| flip(bytes, 42), - refuses: |error| matches!(error, Refusal::NonZeroReserved { field: "candidate" }), + refuses: |error, _, _| matches!(error, Refusal::NonZeroReserved { field: "candidate" }), }, Mutation { field: "candidate count participates in the declared length", seal: Seal::Everything, mutate: |bytes| patch(bytes, CANDIDATE_COUNT_OFFSET, &2_u32.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::DeclaredLengthMismatch { @@ -134,54 +141,92 @@ const MATRIX: &[Mutation] = &[ field: "liveness generation zero", seal: Seal::Everything, mutate: |bytes| patch(bytes, 48, &0_u64.to_be_bytes()), - refuses: |error| matches!(error, Refusal::LivenessGeneration { .. }), + refuses: |error, _, _| { + matches!( + error, + Refusal::LivenessGeneration { + source: LivenessGenerationError::Zero + } + ) + }, }, Mutation { field: "catalog generation zero", seal: Seal::Everything, mutate: |bytes| patch(bytes, 88, &0_u64.to_be_bytes()), - refuses: |error| matches!(error, Refusal::CatalogGeneration { .. }), + refuses: |error, _, _| { + matches!( + error, + Refusal::CatalogGeneration { + source: CatalogGenerationError::Zero + } + ) + }, }, Mutation { field: "profile identity", seal: Seal::Everything, mutate: |bytes| patch(bytes, 128, &2_u32.to_be_bytes()), - refuses: |error| matches!(error, Refusal::Profile { .. }), + refuses: |error, _, _| { + matches!( + error, + Refusal::Profile { + source: RetentionProfileAdmissionError::UnsupportedCoordinate { + expected_identity: 1, + expected_version: 1, + observed_identity: 2, + observed_version: 1 + } + } + ) + }, }, Mutation { field: "profile version", seal: Seal::Everything, mutate: |bytes| patch(bytes, 132, &2_u32.to_be_bytes()), - refuses: |error| matches!(error, Refusal::Profile { .. }), + refuses: |error, _, _| { + matches!( + error, + Refusal::Profile { + source: RetentionProfileAdmissionError::UnsupportedCoordinate { + expected_identity: 1, + expected_version: 1, + observed_identity: 1, + observed_version: 2 + } + } + ) + }, }, Mutation { field: "profile-definition digest", seal: Seal::Everything, mutate: |bytes| flip(bytes, 136), - refuses: |error| matches!(error, Refusal::Profile { .. }), + refuses: |error, original, mutated| matches!(error, Refusal::Profile { source: RetentionProfileAdmissionError::DefinitionDigestMismatch { expected, observed } } if Some(expected.as_slice()) == original.get(136..168) && Some(observed.as_slice()) == mutated.get(136..168)), }, Mutation { field: "candidate-set digest", seal: Seal::Digests, mutate: |bytes| flip(bytes, CANDIDATE_SET_DIGEST_OFFSET), - refuses: |error| matches!(error, Refusal::CandidateSetDigestMismatch { .. }), + refuses: |error, _, _| matches!(error, Refusal::CandidateSetDigestMismatch { .. }), }, Mutation { field: "intent digest", seal: Seal::Checksum, mutate: |bytes| flip(bytes, INTENT_DIGEST_OFFSET), - refuses: |error| matches!(error, Refusal::IntentDigestMismatch { .. }), + refuses: |error, _, _| matches!(error, Refusal::IntentDigestMismatch { .. }), }, Mutation { field: "checksum", seal: Seal::Nothing, mutate: |bytes| flip(bytes, CHECKSUM_OFFSET), - refuses: |error| matches!(error, Refusal::ChecksumMismatch { .. }), + refuses: |error, _, _| matches!(error, Refusal::ChecksumMismatch { .. }), }, ]; #[test] -fn every_intent_field_has_one_exact_first_refusal() -> Result<(), Box> { +fn malformed_intent_fields_report_the_named_refusal() -> Result<(), Box> { for mutation in MATRIX { let mut bytes = fixture_bytes()?; (mutation.mutate)(&mut bytes)?; @@ -190,7 +235,7 @@ fn every_intent_field_has_one_exact_first_refusal() -> Result<(), Box) -> io::Result<()>, - refuses: fn(&Refusal) -> bool, + refuses: fn(&Refusal, &[u8], &[u8]) -> bool, } const MATRIX: &[Mutation] = &[ @@ -30,13 +30,13 @@ const MATRIX: &[Mutation] = &[ field: "magic", reseal: true, mutate: |bytes| flip(bytes, 15), - refuses: |error| matches!(error, Refusal::InvalidMagic { .. }), + refuses: |error, _, _| matches!(error, Refusal::InvalidMagic { .. }), }, Mutation { field: "version", reseal: true, mutate: |bytes| patch(bytes, 16, &3_u16.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::UnsupportedVersion { @@ -50,7 +50,7 @@ const MATRIX: &[Mutation] = &[ field: "record length", reseal: true, mutate: |bytes| patch(bytes, 18, &319_u16.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::InvalidRecordLength { @@ -64,13 +64,13 @@ const MATRIX: &[Mutation] = &[ field: "flags", reseal: true, mutate: |bytes| patch(bytes, 20, &1_u32.to_be_bytes()), - refuses: |error| matches!(error, Refusal::UnsupportedFlags { observed: 1 }), + refuses: |error, _, _| matches!(error, Refusal::UnsupportedFlags { observed: 1 }), }, Mutation { field: "GC generation", reseal: true, mutate: |bytes| patch(bytes, 24, &2_u64.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::GenerationMismatch { @@ -84,19 +84,19 @@ const MATRIX: &[Mutation] = &[ field: "intent digest", reseal: true, mutate: |bytes| flip(bytes, 32), - refuses: |error| matches!(error, Refusal::IntentDigestMismatch { .. }), + refuses: |error, intent, receipt| matches!(error, Refusal::IntentDigestMismatch { expected, observed } if Some(expected.as_slice()) == intent.get(392..424) && Some(observed.as_slice()) == receipt.get(32..64)), }, Mutation { field: "retired candidate-set digest", reseal: true, mutate: |bytes| flip(bytes, 64), - refuses: |error| matches!(error, Refusal::RetiredSetDigestMismatch { .. }), + refuses: |error, intent, receipt| matches!(error, Refusal::RetiredSetDigestMismatch { expected, observed } if Some(expected.as_slice()) == intent.get(288..320) && Some(observed.as_slice()) == receipt.get(64..96)), }, Mutation { field: "liveness generation", reseal: true, mutate: |bytes| patch(bytes, 128, &2_u64.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::LivenessGenerationMismatch { @@ -110,13 +110,13 @@ const MATRIX: &[Mutation] = &[ field: "retention-manifest digest", reseal: true, mutate: |bytes| flip(bytes, 136), - refuses: |error| matches!(error, Refusal::ManifestDigestMismatch { .. }), + refuses: |error, intent, receipt| matches!(error, Refusal::ManifestDigestMismatch { expected, observed } if Some(expected.as_slice()) == intent.get(56..88) && Some(observed.as_slice()) == receipt.get(136..168)), }, Mutation { field: "catalog generation", reseal: true, mutate: |bytes| patch(bytes, 168, &3_u64.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::CatalogGenerationMismatch { @@ -130,13 +130,13 @@ const MATRIX: &[Mutation] = &[ field: "catalog digest", reseal: true, mutate: |bytes| flip(bytes, 176), - refuses: |error| matches!(error, Refusal::CatalogDigestMismatch { .. }), + refuses: |error, intent, receipt| matches!(error, Refusal::CatalogDigestMismatch { expected, observed } if Some(expected.as_slice()) == intent.get(96..128) && Some(observed.as_slice()) == receipt.get(176..208)), }, Mutation { field: "reader-lock device", reseal: true, mutate: |bytes| patch(bytes, 208, &9_u64.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::ReaderLockMismatch { @@ -151,12 +151,13 @@ const MATRIX: &[Mutation] = &[ field: "reader-lock mount", reseal: true, mutate: |bytes| patch(bytes, 216, &9_u64.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::ReaderLockMismatch { coordinate: ReaderLockCoordinate::Mount, - .. + expected: 5, + observed: 9, } ) }, @@ -165,12 +166,13 @@ const MATRIX: &[Mutation] = &[ field: "reader-lock file", reseal: true, mutate: |bytes| patch(bytes, 224, &9_u64.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::ReaderLockMismatch { coordinate: ReaderLockCoordinate::File, - .. + expected: 6, + observed: 9, } ) }, @@ -179,7 +181,7 @@ const MATRIX: &[Mutation] = &[ field: "synchronization count", reseal: true, mutate: |bytes| patch(bytes, 232, &2_u64.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::SynchronizationCountMismatch { @@ -193,13 +195,13 @@ const MATRIX: &[Mutation] = &[ field: "reserved bytes", reseal: true, mutate: |bytes| flip(bytes, 287), - refuses: |error| matches!(error, Refusal::NonZeroReserved), + refuses: |error, _, _| matches!(error, Refusal::NonZeroReserved), }, Mutation { field: "checksum", reseal: false, mutate: |bytes| flip(bytes, 319), - refuses: |error| matches!(error, Refusal::ChecksumMismatch { .. }), + refuses: |error, _, _| matches!(error, Refusal::ChecksumMismatch { .. }), }, ]; @@ -233,7 +235,7 @@ fn frozen_receipt_completes_the_frozen_intent_and_reencodes_canonically() } #[test] -fn every_receipt_field_has_one_exact_first_refusal() -> Result<(), Box> { +fn malformed_receipt_fields_report_the_named_refusal() -> Result<(), Box> { let intent_bytes = fixture_bytes(GC_INTENT)?; let intent = AdmittedGcRetirementIntent::decode(&intent_bytes)?; for mutation in MATRIX { @@ -246,7 +248,7 @@ fn every_receipt_field_has_one_exact_first_refusal() -> Result<(), Box Date: Sat, 3 Oct 2026 20:38:54 -0700 Subject: [PATCH 56/59] test(#107): distinguish retention admission refusal causes --- tests/retention_head_codec/mutation_laws.rs | 41 +++++- .../retention_manifest_codec/mutation_laws.rs | 27 +++- .../retention_root_decoding/mutation_laws.rs | 134 +++++++++++++----- 3 files changed, 157 insertions(+), 45 deletions(-) diff --git a/tests/retention_head_codec/mutation_laws.rs b/tests/retention_head_codec/mutation_laws.rs index dcebde42..2f2df0f0 100644 --- a/tests/retention_head_codec/mutation_laws.rs +++ b/tests/retention_head_codec/mutation_laws.rs @@ -1,11 +1,13 @@ //! Field-by-field corruption matrix for the version-2 retention head. //! -//! Every field of the fixed 144-byte head has one mutation and one exact -//! first refusal (`KEEP-RETENTION-003`). +//! Selected malformed head fields report their named refusal (KEEP-RETENTION-003). Generation and manifest-length cases assert complete nested diagnostics; the opaque manifest digest has a separate admission law. use std::io; -use keep::{ChecksummedRetentionHead, RetentionHeadDecodeError as Refusal, RetentionHeadError}; +use keep::{ + ChecksummedRetentionHead, LivenessGenerationError, RetentionHeadDecodeError as Refusal, + RetentionHeadError, RetentionManifestLengthError, +}; use super::{CHECKSUM_OFFSET, ONE_ROOT_HEAD, fixture_bytes}; use crate::support::{domain_hash, flip, patch}; @@ -67,7 +69,14 @@ const MATRIX: &[Mutation] = &[ field: "liveness generation zero", seal: Seal::Checksum, mutate: |bytes| patch(bytes, 24, &0_u64.to_be_bytes()), - refuses: |error| matches!(error, Refusal::LivenessGeneration { .. }), + refuses: |error| { + matches!( + error, + Refusal::LivenessGeneration { + source: LivenessGenerationError::Zero + } + ) + }, }, Mutation { field: "liveness generation two without predecessor", @@ -86,13 +95,31 @@ const MATRIX: &[Mutation] = &[ field: "manifest length below bound", seal: Seal::Checksum, mutate: |bytes| patch(bytes, 32, &223_u64.to_be_bytes()), - refuses: |error| matches!(error, Refusal::ManifestLength { .. }), + refuses: |error| { + matches!( + error, + Refusal::ManifestLength { + source: RetentionManifestLengthError::OutOfBounds { + minimum: 224, + maximum: 295_136, + observed: 223 + } + } + ) + }, }, Mutation { field: "manifest length not congruent", seal: Seal::Checksum, mutate: |bytes| patch(bytes, 32, &225_u64.to_be_bytes()), - refuses: |error| matches!(error, Refusal::ManifestLength { .. }), + refuses: |error| { + matches!( + error, + Refusal::ManifestLength { + source: RetentionManifestLengthError::NotCongruent { observed: 225 } + } + ) + }, }, Mutation { field: "predecessor digest at generation one", @@ -122,7 +149,7 @@ const MATRIX: &[Mutation] = &[ ]; #[test] -fn every_head_field_has_one_exact_first_refusal() -> Result<(), Box> { +fn malformed_head_fields_report_the_named_refusal() -> Result<(), Box> { for mutation in MATRIX { let mut bytes = fixture_bytes(ONE_ROOT_HEAD)?; (mutation.mutate)(&mut bytes)?; diff --git a/tests/retention_manifest_codec/mutation_laws.rs b/tests/retention_manifest_codec/mutation_laws.rs index f2f72265..428957dd 100644 --- a/tests/retention_manifest_codec/mutation_laws.rs +++ b/tests/retention_manifest_codec/mutation_laws.rs @@ -1,12 +1,12 @@ //! Field-by-field corruption matrix for version-2 retention manifests. //! -//! Every structural field of the manifest header, entry body, and trailer -//! has one mutation and one exact first refusal (`KEEP-RETENTION-003`). +//! Selected malformed manifest fields report their named refusal (KEEP-RETENTION-003). Generation cases assert complete nested diagnostics; other cases retain their stated variant-level oracles. use std::io; use keep::{ - AdmittedRetentionManifest, RetentionManifestDecodeError as Refusal, RetentionManifestError, + AdmittedRetentionManifest, LivenessGenerationError, RetentionManifestDecodeError as Refusal, + RetentionManifestError, RootGenerationError, }; use super::{ @@ -95,7 +95,14 @@ const MATRIX: &[Mutation] = &[ field: "liveness generation zero", seal: Seal::Everything, mutate: |bytes| patch(bytes, 32, &0_u64.to_be_bytes()), - refuses: |error| matches!(error, Refusal::LivenessGeneration { .. }), + refuses: |error| { + matches!( + error, + Refusal::LivenessGeneration { + source: LivenessGenerationError::Zero + } + ) + }, }, Mutation { field: "liveness generation two without predecessor", @@ -180,7 +187,15 @@ const MATRIX: &[Mutation] = &[ field: "entry root generation zero", seal: Seal::Everything, mutate: |bytes| patch(bytes, ENTRY_BODY_OFFSET + 32, &0_u64.to_be_bytes()), - refuses: |error| matches!(error, Refusal::RootGeneration { index: 0, .. }), + refuses: |error| { + matches!( + error, + Refusal::RootGeneration { + index: 0, + source: RootGenerationError::Zero + } + ) + }, }, Mutation { field: "manifest digest", @@ -197,7 +212,7 @@ const MATRIX: &[Mutation] = &[ ]; #[test] -fn every_manifest_field_has_one_exact_first_refusal() -> Result<(), Box> { +fn malformed_manifest_fields_report_the_named_refusal() -> Result<(), Box> { for mutation in MATRIX { let mut bytes = fixture_bytes(ONE_ROOT_MANIFEST)?; (mutation.mutate)(&mut bytes)?; diff --git a/tests/retention_root_decoding/mutation_laws.rs b/tests/retention_root_decoding/mutation_laws.rs index 337dcaaa..f15d6be7 100644 --- a/tests/retention_root_decoding/mutation_laws.rs +++ b/tests/retention_root_decoding/mutation_laws.rs @@ -1,13 +1,14 @@ //! Field-by-field corruption matrix for version-2 retention roots. //! -//! Every structural field of the root header, body, and trailer has one -//! mutation and one exact first refusal (`KEEP-RETENTION-003`). The sealed -//! matrix recomputes every digest and checksum that the mutation did not -//! target, so each case proves the named field check and nothing else. +//! Selected malformed root fields report their named refusal (KEEP-RETENTION-003). Profile, closure-limit and generation cases assert complete nested diagnostics; other cases retain their stated variant-level oracles. use std::io; -use keep::{AdmittedRetentionRoot, RetentionRootDecodeError as Refusal, RetentionRootError}; +use keep::{ + AdmittedRetentionRoot, RetentionClosureLimit, RetentionClosureLimitError, + RetentionProfileAdmissionError, RetentionRootDecodeError as Refusal, RetentionRootError, + RootGenerationError, +}; use super::{ANCHOR_BODY_OFFSET, ANCHOR_SET_DIGEST_OFFSET, ROOT_DIGEST_OFFSET, fixture_bytes}; use crate::support::{counted_domain_hash, domain_hash, flip, patch, read_u16, read_u32}; @@ -31,7 +32,7 @@ struct Mutation { field: &'static str, seal: Seal, mutate: fn(&mut Vec) -> io::Result<()>, - refuses: fn(&Refusal) -> bool, + refuses: fn(&Refusal, &[u8], &[u8]) -> bool, } const MATRIX: &[Mutation] = &[ @@ -39,13 +40,13 @@ const MATRIX: &[Mutation] = &[ field: "magic", seal: Seal::Everything, mutate: |bytes| flip(bytes, 15), - refuses: |error| matches!(error, Refusal::InvalidMagic { .. }), + refuses: |error, _, _| matches!(error, Refusal::InvalidMagic { .. }), }, Mutation { field: "version", seal: Seal::Everything, mutate: |bytes| patch(bytes, 16, &3_u16.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::UnsupportedVersion { @@ -59,7 +60,7 @@ const MATRIX: &[Mutation] = &[ field: "header length", seal: Seal::Everything, mutate: |bytes| patch(bytes, 18, &191_u16.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::InvalidHeaderLength { @@ -73,13 +74,13 @@ const MATRIX: &[Mutation] = &[ field: "flags", seal: Seal::Everything, mutate: |bytes| patch(bytes, 20, &1_u32.to_be_bytes()), - refuses: |error| matches!(error, Refusal::UnsupportedFlags { observed: 1 }), + refuses: |error, _, _| matches!(error, Refusal::UnsupportedFlags { observed: 1 }), }, Mutation { field: "total record length", seal: Seal::Everything, mutate: |bytes| patch(bytes, 24, &377_u64.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::DeclaredLengthMismatch { @@ -93,13 +94,20 @@ const MATRIX: &[Mutation] = &[ field: "root generation zero", seal: Seal::Everything, mutate: |bytes| patch(bytes, 32, &0_u64.to_be_bytes()), - refuses: |error| matches!(error, Refusal::Generation { .. }), + refuses: |error, _, _| { + matches!( + error, + Refusal::Generation { + source: RootGenerationError::Zero + } + ) + }, }, Mutation { field: "root generation two without predecessor", seal: Seal::Everything, mutate: |bytes| patch(bytes, 32, &2_u64.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::Semantic { @@ -112,7 +120,7 @@ const MATRIX: &[Mutation] = &[ field: "anchor width", seal: Seal::Everything, mutate: |bytes| patch(bytes, 42, &118_u16.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::InvalidAnchorWidth { @@ -126,7 +134,7 @@ const MATRIX: &[Mutation] = &[ field: "anchor count participates in the declared length", seal: Seal::Everything, mutate: |bytes| patch(bytes, ANCHOR_COUNT_OFFSET, &2_u32.to_be_bytes()), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::DeclaredLengthMismatch { @@ -140,55 +148,117 @@ const MATRIX: &[Mutation] = &[ field: "profile identity", seal: Seal::Everything, mutate: |bytes| patch(bytes, 48, &2_u32.to_be_bytes()), - refuses: |error| matches!(error, Refusal::Profile { .. }), + refuses: |error, _, _| { + matches!( + error, + Refusal::Profile { + source: RetentionProfileAdmissionError::UnsupportedCoordinate { + expected_identity: 1, + expected_version: 1, + observed_identity: 2, + observed_version: 1 + } + } + ) + }, }, Mutation { field: "profile version", seal: Seal::Everything, mutate: |bytes| patch(bytes, 52, &2_u32.to_be_bytes()), - refuses: |error| matches!(error, Refusal::Profile { .. }), + refuses: |error, _, _| { + matches!( + error, + Refusal::Profile { + source: RetentionProfileAdmissionError::UnsupportedCoordinate { + expected_identity: 1, + expected_version: 1, + observed_identity: 1, + observed_version: 2 + } + } + ) + }, }, Mutation { field: "profile-definition digest", seal: Seal::Everything, mutate: |bytes| flip(bytes, 56), - refuses: |error| matches!(error, Refusal::Profile { .. }), + refuses: |error, original, mutated| matches!(error, Refusal::Profile { source: RetentionProfileAdmissionError::DefinitionDigestMismatch { expected, observed } } if Some(expected.as_slice()) == original.get(56..88) && Some(observed.as_slice()) == mutated.get(56..88)), }, Mutation { field: "closure-node limit zero", seal: Seal::Everything, mutate: |bytes| patch(bytes, 88, &0_u64.to_be_bytes()), - refuses: |error| matches!(error, Refusal::ClosureLimit { .. }), + refuses: |error, _, _| { + matches!( + error, + Refusal::ClosureLimit { + source: RetentionClosureLimitError::Zero { + limit: RetentionClosureLimit::Nodes + } + } + ) + }, }, Mutation { field: "closure-depth limit above ceiling", seal: Seal::Everything, mutate: |bytes| patch(bytes, 96, &9_u16.to_be_bytes()), - refuses: |error| matches!(error, Refusal::ClosureLimit { .. }), + refuses: |error, _, _| { + matches!( + error, + Refusal::ClosureLimit { + source: RetentionClosureLimitError::AboveMaximum { + limit: RetentionClosureLimit::Depth, + maximum: 8, + observed: 9 + } + } + ) + }, }, Mutation { field: "reserved limit bytes", seal: Seal::Everything, mutate: |bytes| flip(bytes, 98), - refuses: |error| matches!(error, Refusal::NonZeroReserved { field: "limit" }), + refuses: |error, _, _| matches!(error, Refusal::NonZeroReserved { field: "limit" }), }, Mutation { field: "encoded-byte limit zero", seal: Seal::Everything, mutate: |bytes| patch(bytes, 100, &0_u64.to_be_bytes()), - refuses: |error| matches!(error, Refusal::ClosureLimit { .. }), + refuses: |error, _, _| { + matches!( + error, + Refusal::ClosureLimit { + source: RetentionClosureLimitError::Zero { + limit: RetentionClosureLimit::EncodedBytes + } + } + ) + }, }, Mutation { field: "physical-byte limit zero", seal: Seal::Everything, mutate: |bytes| patch(bytes, 108, &0_u64.to_be_bytes()), - refuses: |error| matches!(error, Refusal::ClosureLimit { .. }), + refuses: |error, _, _| { + matches!( + error, + Refusal::ClosureLimit { + source: RetentionClosureLimitError::Zero { + limit: RetentionClosureLimit::PhysicalBytes + } + } + ) + }, }, Mutation { field: "predecessor digest at generation one", seal: Seal::Everything, mutate: |bytes| flip(bytes, 116), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::Semantic { @@ -201,13 +271,13 @@ const MATRIX: &[Mutation] = &[ field: "anchor-set digest", seal: Seal::Digests, mutate: |bytes| flip(bytes, ANCHOR_SET_DIGEST_OFFSET), - refuses: |error| matches!(error, Refusal::AnchorSetDigestMismatch { .. }), + refuses: |error, _, _| matches!(error, Refusal::AnchorSetDigestMismatch { .. }), }, Mutation { field: "reserved trailing header bytes", seal: Seal::Everything, mutate: |bytes| flip(bytes, 191), - refuses: |error| { + refuses: |error, _, _| { matches!( error, Refusal::NonZeroReserved { @@ -220,30 +290,30 @@ const MATRIX: &[Mutation] = &[ field: "anchor blob identity", seal: Seal::Everything, mutate: |bytes| flip(bytes, ANCHOR_BODY_OFFSET), - refuses: |error| matches!(error, Refusal::BlobId { index: 0, .. }), + refuses: |error, _, _| matches!(error, Refusal::BlobId { index: 0, .. }), }, Mutation { field: "anchor layout identity", seal: Seal::Everything, mutate: |bytes| flip(bytes, ANCHOR_BODY_OFFSET + 59), - refuses: |error| matches!(error, Refusal::LayoutId { index: 0, .. }), + refuses: |error, _, _| matches!(error, Refusal::LayoutId { index: 0, .. }), }, Mutation { field: "root digest", seal: Seal::Checksum, mutate: |bytes| flip(bytes, ROOT_DIGEST_OFFSET), - refuses: |error| matches!(error, Refusal::RootDigestMismatch { .. }), + refuses: |error, _, _| matches!(error, Refusal::RootDigestMismatch { .. }), }, Mutation { field: "checksum", seal: Seal::Nothing, mutate: |bytes| flip(bytes, 377), - refuses: |error| matches!(error, Refusal::ChecksumMismatch { .. }), + refuses: |error, _, _| matches!(error, Refusal::ChecksumMismatch { .. }), }, ]; #[test] -fn every_root_field_has_one_exact_first_refusal() -> Result<(), Box> { +fn malformed_root_fields_report_the_named_refusal() -> Result<(), Box> { for mutation in MATRIX { let mut bytes = fixture_bytes()?; (mutation.mutate)(&mut bytes)?; @@ -252,7 +322,7 @@ fn every_root_field_has_one_exact_first_refusal() -> Result<(), Box Date: Sat, 3 Oct 2026 20:41:53 -0700 Subject: [PATCH 57/59] docs(#107): consolidate decoder oracle calibration evidence --- docs/testing-evidence/gc-retention-oracles.md | 36 ++++++++++++++++++ .../gc-retention-oracles/receipts.tar.gz | Bin 0 -> 76378 bytes 2 files changed, 36 insertions(+) create mode 100644 docs/testing-evidence/gc-retention-oracles.md create mode 100644 docs/testing-evidence/gc-retention-oracles/receipts.tar.gz diff --git a/docs/testing-evidence/gc-retention-oracles.md b/docs/testing-evidence/gc-retention-oracles.md new file mode 100644 index 00000000..636c22bb --- /dev/null +++ b/docs/testing-evidence/gc-retention-oracles.md @@ -0,0 +1,36 @@ +# GC and retention decoder oracle corrections + +This record covers PR #107's opaque-coordinate, GC-diagnostic, retention-diagnostic and head-sealing review findings. The product implementation, wire formats and accepted inputs are unchanged from `f00e7803754eb6d9301032654d73a2047086c801`. These are test-oracle corrections under Testing Standards rule 3, plus a private test-data readability change; they are not newly discovered product bug fixes. + +## Contract and changes + +The owning contracts are KEEP-GC-001 and KEEP-RETENTION-003, the version-2 format definitions and the public decoder error types. The accountable maintainer is `@flyingrobots`; the author supplies the evidence and an independent reviewer must admit it. Oracles are specified public outcomes and independently frozen format fixtures, rather than the current encoder's output or test-case counts. + +| Finding | Runtime claim and oracle | Implementation | +| --- | --- | --- | +| Opaque GC coordinates | Resealed manifest/catalog/proof/pool/disposition digests, device/mount/file coordinates, and candidate segment digest/length/evidence must be admitted and retained exactly; the resulting intent identity must differ from the frozen intent. Expected coordinates come directly from the modified input bytes, while sealing uses the documented domains and independently frozen layout. | `a62bfb8` adds `resealed_opaque_coordinates_are_carried_into_a_distinct_intent` through `AdmittedGcRetirementIntent::decode`; it does not assert that an opaque coordinate is verified evidence of retirement. | +| GC refusals | Zero generations retain their specific nested source; unsupported profile identity/version and definition-digest mismatch retain their full coordinates; bad magic reports the actual bytes. Receipt mount/file mismatches retain expected and observed integers, and intent/set/manifest/catalog digest mismatches retain both exact digests. | `5b533b0` strengthens the public intent and receipt mutation laws. Expected digests are projections of the independent intent fixture, not values generated by the decoder being tested. | +| Retention refusals | Root profiles and closure-resource failures retain the exact cause, resource and coordinates. Root/head/manifest generations report zero rather than exhaustion. Head length 223 reports bounds 224..=295136, while 225 reports noncongruence. | `b5ecd97` strengthens the public root/head/manifest mutation laws. The length bounds follow the format's 160-byte header, 72-byte entries, 64-byte trailer and 4096-entry ceiling. | +| Head seal choice | Existing byte mutations and refusal expectations remain unchanged when private test data names checksum resealing versus no resealing explicitly. | `6a84324` replaces the private boolean with `Seal`; it is not a public-API violation or a product behavior fix. Existing runtime laws pass in both profiles. | + +The previous test names and module comments claimed every field had an exact refusal. That was false for admitted opaque coordinates and overstated the remaining variant-only assertions. The names now say selected malformed fields report their named refusal; the new laws cover opaque coordinate admission separately. No test, fixture or runtime assertion was deleted. These tests should be retired only if the corresponding public contract is removed or stronger, cheaper runtime evidence subsumes them with the displaced risk recorded. + +## Execution and calibration + +The product/test candidate is `b5ecd975a7f1fa776fbe584602d2982e626c237d`. Copied Docker source uses pinned Rust/Cargo 1.96.0 on Linux aarch64, offline Cargo resolution and the existing isolated target cache. `source-profile.txt` records tool commits and compares every copied `src`/`tests` Rust file to the candidate by SHA-256 after restoring the mutants. These copied-source checks do not assign the container's inspection-only Git index a product commit identity. + +`focused.sh` runs formatting, workspace Clippy with all features and without default features, then the public `gc_retirement_intent`, `gc_retirement_receipt`, `retention_root_decoding`, `retention_head_codec` and `retention_manifest_codec` executables in debug and release. `focused.txt` and `restored.txt` record success before and after the calibration batch. `head-seal.txt` isolates the readability-only change. `structure.txt` records the source-structure check using the copied source's local inspection index. + +The calibration changes production behavior only in the copied container source, one control at a time, runs the named public runtime law, records the assertion message and restores the original source. Opaque-coordinate controls alter each getter-visible decoded value; a separate control returns the frozen identity to exercise the shared distinct-identity assertion. Diagnostic controls substitute a wrong generation source, magic bytes, profile coordinates/digests, receipt coordinates/digests, closure resource/ceiling or manifest-length detail. Shared generation and profile controls execute every changed decoder path which uses them. These are calibrated oracle corrections, not evidence that the unmodified parent had those product defects. + +The successful receipts are `calibration-fresh.txt` through candidate-digest retention and `calibration-continuation.txt` from candidate-length retention onward. Every accepted RED has exit 101, the intended assertion witness and a failed test result; no setup or compilation error qualifies. The batch calibrates the shared identity assertion with the manifest coordinate; it does not claim an independent cryptographic proof or exhaustive generated input coverage for every coordinate. + +The initial `calibration.txt` encountered stale Cargo output after rapid Docker copies preserved coarse file timestamps: the pool-identity control reported the preceding proof-coordinate failure. That attempt is rejected. The corrected runner touches each copied source after applying and restoring a mutant. Its first continuation then encountered an unused-import compilation error in the candidate-length control, also rejected. The final length control changes the decoded value while retaining the read. All original diagnostics remain in the archive; subsequent successes do not replace them. + +## Replay and limitations + +Extract [the original receipts](gc-retention-oracles/receipts.tar.gz) into an owned scratch directory. With candidate source copied into a disposable Docker container and its pinned toolchain/cache available, invoke `ruby calibrate.rb CHECKOUT OUTPUT_DIRECTORY CONTAINER COPIED_SOURCE`, then copy and execute `focused.sh` in that container. The supplied scripts use the recorded `/build/keep107-coordinate-target` cache and `/build/keep107-coordinate-source` copy. Replay requires those paths or a documented path-only adaptation. Ruby orchestrates Docker; all Rust execution occurs inside Docker. Inputs are fixed fixture mutations, so no random seed, shrinking process, concurrency schedule or filesystem fault model is involved. + +The logical tests are small, in-memory public codec laws; libtest and Cargo still execute under ordinary container permissions. There are no enforced per-test filesystem/network/thread/process restrictions, time/memory ceilings, approved suite latency or flake budgets for these executables. The container is not a small-test sandbox. The new tests are therefore not asserted compliant with main's `docs/testing/enforcement.md`: explicit profile enforcement or an approved scoped expiring policy disposition remains a whole-PR landing gate. The approved #106 waiver does not cover this work. + +These focused results do not replace final integration validation, exact-head hosted CI, full independent review of #107 or the outstanding complete review-body obligations. No new power-loss, restart, filesystem, performance or concurrency claim is made by this slice. Mainline merge conflicts remain a separate integration task. diff --git a/docs/testing-evidence/gc-retention-oracles/receipts.tar.gz b/docs/testing-evidence/gc-retention-oracles/receipts.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..c9b97377edf93c86100514a3b822b886afcd8df9 GIT binary patch literal 76378 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 Date: Sat, 3 Oct 2026 20:45:37 -0700 Subject: [PATCH 58/59] docs(#107): link opaque-coordinate runtime evidence --- docs/formats/segment-store-v2/requirements.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index 98181927..399ea810 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -45,7 +45,7 @@ case is not evidence. | ID | Requirement | Evidence | Status | | --- | --- | --- | --- | -| `KEEP-GC-001` | Version 2 specifies exact bounded GC intent, receipt, and recovery-disposition grammars but refuses their presence until their parser and recovery protocol are implemented | field-complete corruption ledger `conformance/segment-store/v2/mutations.tsv` (GC intent, receipt, disposition) reproduced by `tests/segment_store_mutations.rs`; intent and receipt golden, canonical re-encoding, cross-record binding, and field-by-field corruption laws in `tests/gc_retirement_intent.rs`, `tests/gc_retirement_intent/mutation_laws.rs`, and `tests/gc_retirement_receipt.rs`; disposition golden, canonical re-encoding, registered-enumeration agreement with `definition.tsv`, unregistered-code refusal, and field-by-field corruption laws in `tests/recovery_disposition_receipt.rs`; seeded `gc_format` fuzz target over all three records; presence refusal in namespace admission tests | Implemented | +| `KEEP-GC-001` | Version 2 specifies exact bounded GC intent, receipt, and recovery-disposition grammars but refuses their presence until their parser and recovery protocol are implemented | field-complete corruption ledger `conformance/segment-store/v2/mutations.tsv` (GC intent, receipt, disposition) reproduced by `tests/segment_store_mutations.rs`; intent and receipt golden, canonical re-encoding, cross-record binding, and named malformed-field refusals in `tests/gc_retirement_intent.rs`, `tests/gc_retirement_intent/mutation_laws.rs`, and `tests/gc_retirement_receipt.rs`; opaque-coordinate admission and identity laws in `tests/gc_retirement_intent/opaque_coordinate_laws.rs`, with [oracle calibration](../../testing-evidence/gc-retention-oracles.md); disposition golden, canonical re-encoding, registered-enumeration agreement with `definition.tsv`, unregistered-code refusal, and field-by-field corruption laws in `tests/recovery_disposition_receipt.rs`; seeded `gc_format` fuzz target over all three records; presence refusal in namespace admission tests | Implemented | | `KEEP-GC-002` | GC intent, receipt, disposition, reader-fence, retirement, compaction, and recovery laws implement ADR-0009 without changing logical identity | deterministic planning: `plan_gc` classifies every inventoried segment against one fenced liveness snapshot, refuses every contradiction, and never names a live or catalog-named segment, proven by one law per classification and ambiguity, the golden `gc-plan.tsv`, a 512-universe model, and filesystem laws over the migrated fixture store in `src/adapters/gc/`; explicit disposition of recovery-protected retention orphans in `filesystem_retention_disposition_tests` (retire unlinks under an absent head, finalize needs a published head, manifest before root, readers refuse, every residue resumes, receipts admitted by census) and exact-receipt admission by GC planning in `liveness_observation_tests`; retirement and recovery: `FilesystemGcAuthority` re-proves the plan under writer authority and the exclusive fence, derives the intent with the registered proof, pool, and disposition-set digests, runs the 14 fixed phases through `GcExecutionStorage`, and `plan_gc_recovery` classifies every residue (retire, second generation, nothing-to-retire, stale plan, readers, every interrupted prefix, both truncated stages, intent exclusion of retention publication, ambiguity, admission) in `filesystem_gc_tests`, with the `KEEP-CRASH-074..=087` process-death matrix of 42 killed-writer cases in `cargo xtask durability-crash-matrix --sequence gc`; identity-preserving compaction: `plan_compaction` over one observation, `FilesystemCompactionAuthority` through the complete catalog protocol, and `recover_compaction` in `src/adapters/compaction/filesystem_tests.rs` (exact plan, identity and closure stability, GC retirement of the superseded segment, refusals before any stage, a death before each of the 22 publication phases recovering to the documented residue outcome, idle recovery); compaction benchmarks and re-encoding compaction, with their golden-format, model-based, corruption, crash-injection, benchmark, and fuzz evidence, remain Planned in #21 | Implemented | From 7cdc2cfbef59407f3c38881b39a290cb85501f9b Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 3 Oct 2026 20:48:56 -0700 Subject: [PATCH 59/59] docs(#107): bound retention refusal evidence claims --- docs/formats/segment-store-v2/requirements.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/formats/segment-store-v2/requirements.md b/docs/formats/segment-store-v2/requirements.md index 399ea810..c36c3cc0 100644 --- a/docs/formats/segment-store-v2/requirements.md +++ b/docs/formats/segment-store-v2/requirements.md @@ -11,7 +11,7 @@ case is not evidence. | --- | --- | --- | --- | | `KEEP-RETENTION-001` | `RetentionNamespace`, `RootGeneration`, `LivenessGeneration`, profile coordinates, limits, anchors, and digests are validated typed values | `tests/retention_values.rs`, `tests/retention_root_encoding.rs`, and typed verified anchor-set evidence in `tests/retention_root_decoding.rs` | Implemented | | `KEEP-RETENTION-002` | Root, manifest, and head codecs implement the exact canonical grammars and fixed bounds | independent golden corpus plus `tests/retention_root_encoding.rs`, `tests/retention_root_decoding.rs`, `tests/retention_manifest_codec.rs`, and `tests/retention_head_codec.rs` | Implemented | -| `KEEP-RETENTION-003` | Every structural field, truncation boundary, ordering law, duplicate, overflow, flag, reserved byte, digest, checksum, and trailing byte has a precise refusal | field-complete corruption ledger `conformance/segment-store/v2/mutations.tsv` (root, manifest, head) reproduced by `tests/segment_store_mutations.rs`; one sealed mutation per header, body, and trailer field with its exact first refusal, plus reframed namespace-bound, ordering, and count-ceiling cases, in `tests/retention_root_decoding/mutation_laws.rs`, `tests/retention_manifest_codec/mutation_laws.rs`, and `tests/retention_head_codec/mutation_laws.rs`; framing and integrity precedence in `tests/retention_root_decoding.rs`, `tests/retention_manifest_codec/refusal_laws.rs`, and `tests/retention_head_codec.rs`; seeded `retention_format` fuzz target | Implemented | +| `KEEP-RETENTION-003` | Every structural field, truncation boundary, ordering law, duplicate, overflow, flag, reserved byte, digest, checksum, and trailing byte has a precise refusal | field-complete corruption ledger `conformance/segment-store/v2/mutations.tsv` (root, manifest, head) reproduced by `tests/segment_store_mutations.rs`; selected malformed-field refusals, complete nested diagnostics for generation/profile/closure/manifest-length cases, and reframed namespace-bound, ordering, and count-ceiling cases in `tests/retention_root_decoding/mutation_laws.rs`, `tests/retention_manifest_codec/mutation_laws.rs`, and `tests/retention_head_codec/mutation_laws.rs`; the head module separately checks opaque manifest-digest admission; [calibration and remaining variant-only oracle limits](../../testing-evidence/gc-retention-oracles.md); framing and integrity precedence in `tests/retention_root_decoding.rs`, `tests/retention_manifest_codec/refusal_laws.rs`, and `tests/retention_head_codec.rs`; seeded `retention_format` fuzz target | Implemented | | `KEEP-RETENTION-004` | Retain and release compare expected and observed generations and publish exact successors only | unforgeable readiness and preflight proofs in `tests/retention_transition.rs` and `tests/retention_preflight.rs`; exact successor preparation and complete receipt evidence in `tests/retention_publication_preparation.rs` and `tests/retention_publication_execution.rs`; writer-locked initial filesystem publication in `filesystem_retention_storage_tests`; observed-head successor publication, exact predecessor binding, and absent-head refusal in `filesystem_retention_successor_tests`; the store's catalog head must name the closure's catalog generation and digest before any forward write in `filesystem_retention_catalog_tests`; a head whose predecessor disagrees with its manifest refuses in `filesystem_retention_current_tests`; a successor reopens and decodes the manifest-selected predecessor root and refuses an absent or changed one in `filesystem_retention_expectation_tests` | Implemented | | `KEEP-RETENTION-005` | Closure derivation is deterministic, bounded, cycle-safe, fail-closed, and verifies complete blob reconstruction | exact accounting, reconstruction, adversarial-catalog, and exhaustive model laws in `tests/retention_closure.rs`; corrupt members refuse through the inherited segment-record admission laws and seeded `segment_format` fuzz target routed by `closure-corruption.md`; publication re-reads every member under filesystem authority and surfaces the exact admission error as the refusal's `source` in `filesystem_retention_member_tests` | Implemented | | `KEEP-RETENTION-006` | Publication follows the exact ordered durability protocol, including new namespace-directory admission and retention of fixed-stage evidence until head commit, and returns only after cleanup synchronization | typed vocabulary and blocking port in `tests/retention_publication_phase.rs` and `tests/retention_publication_storage.rs`; ordered execution, conditional namespace sync, and all 17 exact storage-fault boundaries in `tests/retention_publication_execution.rs`; production 17-phase forward filesystem execution, exclusive staging, byte-equal inode-substitution refusal, and retained-stage recovery refusal in `filesystem_retention_storage_tests`; orphan namespace directories count against the 4,096 ceiling and refuse a new namespace before any stage is written in `filesystem_retention_capacity_tests`; 51 killed-writer cases across KEEP-CRASH-036 through 052 in `cargo xtask durability-crash-matrix --sequence retention` | Implemented |

cvi7eF6(^B}5{5~(-A|R(Z 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%0DvNo|lx*SeyynS+BD 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