From 945f06c7e24d7754c06ce81d9f57c519fbc46902 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 15:59:22 -0700 Subject: [PATCH 01/18] docs: reconcile durable verification landing scope (#114) --- docs/audits/114-durable-verification-scope.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/audits/114-durable-verification-scope.md diff --git a/docs/audits/114-durable-verification-scope.md b/docs/audits/114-durable-verification-scope.md new file mode 100644 index 00000000..dad23a2b --- /dev/null +++ b/docs/audits/114-durable-verification-scope.md @@ -0,0 +1,75 @@ +# Durable verification landing scope + +Status: implementation work in progress for [#114](https://github.com/flyingrobots/keep/issues/114), under verification parent [#20](https://github.com/flyingrobots/keep/issues/20). + +This ledger reconciles the requested verification outcome with the code available at the branch baseline; it does not establish a runtime guarantee or mark an acceptance criterion complete. + +## Source of authority + +The branch starts at `origin/main` commit `6051abb25a9fd33ae7ee0de5614514b709a4d82a`. + +The original T-21.1 task fields in `ROADMAP.md` at prepared-branch commit `66c0e4653424cd36e55a868c24d94898a40aca59` remain authoritative, including per-subject achieved depth, typed diagnostic coordinates, unsupported-depth refusal, immutable report construction, bounded costs, and no repair. + +The prepared branch's `docs/invariants/verification/requirements.md` marks `KEEP-VERIFY-006` Planned; its other Implemented entries describe that branch and must not be copied into main as evidence of delivery. + +## Dependency finding + +Main has durable catalog admission, fenced version-two retention snapshots, and retention-closure verification, but no `src/verification/` report domain. + +The issue's reference to an existing policy/report domain therefore describes an unmerged prerequisite, not an available mainline API. + +The implementation must explicitly supply the required domain within this coherent change or wait for its separately reviewed mainline integration; it must not import the unrelated prepared feature branch wholesale. + +PR #164's authenticated read conveniences are not an established prerequisite: the relevant catalog and retention evidence boundaries already exist on this baseline. + +No new tracker dependency is recorded by this document. + +## Existing evidence boundaries + +| Boundary | What the inspected code establishes | What it does not establish | +| --- | --- | --- | +| `AdmittedSegment::decode` through `segment_reader` | Header/seal admission, bounded record admission, physical segment digest, and logical record identities. | Full reconstruction of every blob described by a layout. | +| `FilesystemCatalogSnapshot::load` and `snapshot` | Exact head-selected catalog coordinates, selected segment admission, and catalog-to-record bindings; owned segment bytes are bounded by caller policy. | Retention authority, every layout's chunk closure, or a general shallow-depth diagnostic pass. | +| `FilesystemRetentionSnapshot::load` | Version-two admission, shared reader fence, and bounded double collection of catalog and retention coordinates. | Closure verification of all roots selected by the manifest. | +| `FilesystemRetentionSnapshot::retained_root` | Manifest-selected root bytes, canonical decoding, and selected generation/digest agreement. | Complete root closure; some failures currently lose structured coordinates in message-only I/O errors. | +| `verify_retention_closure` | Bounded root traversal, required catalog members, anchor-to-layout binding, profile replay, and complete blob identity for each anchor. | A whole-store completeness claim for unretained records or a reusable general verification report. | + +These are inspected implementation boundaries, not new execution receipts. + +## Closure ledger + +All obligations below remain open until implementation and evidence are attached. + +| Obligation | Required implementation boundary | Concrete exit condition | +| --- | --- | --- | +| Required subjects and depths | Domain vocabulary and adapters over segment, catalog, layout/blob, and retention evidence. | Every original durable subject/depth has a documented supported operation or precise unsupported result; v1/v2 corpus and empty-store outcomes establish the advertised matrix. | +| Requested versus achieved evidence | Private report construction with a verified entry per subject; adapters construct entries only after the corresponding checks finish. | A shallow request cannot certify an unchecked deeper claim; unsupported requests refuse; external callers cannot construct or deepen evidence. Runtime laws and static/API laws are identified separately. | +| Missing, corrupt, ambiguous, operational | Semantic error admission at durable boundaries, preserving original typed causes. | Absence, demonstrated contradiction, conflicting evidence, and failed observation produce distinct public outcomes with the available expected/observed coordinates and bounded conflict evidence. No classification depends on parsing a message. | +| Consistent snapshot | Existing immutable catalog ownership and fenced retention collection. | Reports bind the exact observed coordinates; moving views cannot combine evidence from different attempts; retained-root closure is checked against that same catalog. | +| Bounded cost and report contents | Caller-bounded catalog/segment loading, root-at-a-time work where applicable, and bounded report data. | Public documentation states I/O, allocations, memory, blocking, and complexity per supported operation; catalog-ceiling evidence checks the stated bound; reports contain no plaintext, keys, or unbounded paths. | +| Read-only behavior | Verification uses observation and admission capabilities. | Refusals and success leave persistent bytes unchanged; no repair, recovery, retention publication, or GC is triggered. | +| Honest delivery claim | Normative verification page, rationale, requirement ledger, public rustdoc, and consolidated execution evidence. | `KEEP-VERIFY-006` becomes Implemented only when the durable contract is implemented and verified; mainline delivery is recorded only after integration. | + +## Design constraints for implementation + +Depths describe checks on a particular subject; the ordinal position of `CatalogReachability` must not be used as evidence that every catalogued layout reconstructs a complete blob. + +An already admitted snapshot may contain evidence beyond a shallow request, but its construction cost and refusal behavior must be disclosed; it cannot be presented as a framing-only scan that succeeds despite a deeper checksum failure. + +A report must distinguish requested policy from established evidence without treating failed or unattempted work as verified. + +Snapshot binding from the original future F-19 obligation must not be conflated with the existing catalog and retention coordinates. + +The prepared reference report only names blob/layout subjects and reserves ambiguity without candidate coordinates; copying it unchanged would not satisfy the original durable acceptance criteria. + +The current root reader's message-only errors require a focused boundary decision before they can feed truthful typed verification refusals; this does not authorize a repository-wide filesystem audit. + +## Evidence and exclusions + +This is documentation-only scope reconciliation, based on source inspection at the two commits above; no runtime RED/GREEN claim is made for this ledger. + +New runtime assertions must be calibrated against the behavior they protect; absence of a new API on the parent is a compile failure, not a behavioral RED receipt. + +Bug fixes discovered at existing boundaries require a runtime regression observed on the unfixed revision, preserving the original failure artifact. + +Durable report serialization, a new report decoder, repair, GC execution, remote attestation, application trust policy, and unrelated prepared-branch features remain outside this issue. From 92f92089cf00d1a766fe8775dac28561ff738b27 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 16:10:18 -0700 Subject: [PATCH 02/18] feat: report subject-specific admitted catalog evidence (#114) --- CHANGELOG.md | 2 + docs/audits/114-durable-verification-scope.md | 2 +- docs/testing-evidence/durable-verification.md | 58 ++++++ src/adapters/catalog_verification.rs | 48 +++++ src/adapters/mod.rs | 1 + src/lib.rs | 5 + src/verification.rs | 14 ++ src/verification/depth.rs | 26 +++ src/verification/rationale.md | 23 +++ src/verification/refusal.rs | 36 ++++ src/verification/report.rs | 88 +++++++++ src/verification/subject.rs | 19 ++ tests/catalog_verification.rs | 181 ++++++++++++++++++ 13 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 docs/testing-evidence/durable-verification.md create mode 100644 src/adapters/catalog_verification.rs create mode 100644 src/verification.rs create mode 100644 src/verification/depth.rs create mode 100644 src/verification/rationale.md 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/catalog_verification.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c23e7c27..bcef5f6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ after its public API and format compatibility policies are established. ## [Unreleased] +- Admitted catalog snapshots can report explicit framing, checksum, or catalog-reachability evidence with immutable subject coordinates; unsupported requests refuse without claiming blob completeness or retention closure (#114, initial report slice). + - Retention recovery execution errors report the exact failed boundary, original typed cause, known namespace effects and uncertain effect/durability; retries freshly observe the store. Observed stage identity remains binding across reopening, and cleanup preserves verified pool evidence rather than promising the removed pathname survives (#99). - Retention recovery now preserves incomplete stages and requires explicit disposition before any recovery mutation or publication retry; automatic incomplete-stage disposal is deferred by maintainer decision (#99). diff --git a/docs/audits/114-durable-verification-scope.md b/docs/audits/114-durable-verification-scope.md index dad23a2b..fe2e9660 100644 --- a/docs/audits/114-durable-verification-scope.md +++ b/docs/audits/114-durable-verification-scope.md @@ -38,7 +38,7 @@ These are inspected implementation boundaries, not new execution receipts. ## Closure ledger -All obligations below remain open until implementation and evidence are attached. +The initial catalog report slice now has [runtime and static/API evidence](../testing-evidence/durable-verification.md); every full-issue obligation below remains open until its entire exit condition is met. | Obligation | Required implementation boundary | Concrete exit condition | | --- | --- | --- | diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md new file mode 100644 index 00000000..75ec6805 --- /dev/null +++ b/docs/testing-evidence/durable-verification.md @@ -0,0 +1,58 @@ +# Durable verification evidence + +Status: partial implementation for [#114](https://github.com/flyingrobots/keep/issues/114); the full durable verification acceptance contract remains open in the [scope ledger](../audits/114-durable-verification-scope.md). + +## Catalog report slice + +Change kind: new feature; subject: Keep's public runtime reporting API, with separately identified static/API construction restrictions. + +The source baseline is `945f06c7e24d7754c06ce81d9f57c519fbc46902` plus the catalog-report implementation and public laws committed with this record. + +The specified oracle is the subject-specific verification contract and the frozen generation-two catalog/head corpus, whose catalog digest is `ea7d0055fd21f00ed94809ef4e671d72fa2e6a4a5d9ecefb23f3a320a2dad993`. + +The tests exercise `CatalogSnapshot::verify`, not test-harness case counts or source text. + +| Claim | Law or API evidence | Falsification observed | +| --- | --- | --- | +| The original request is preserved. | `catalog_reports_bind_the_requested_evidence_to_the_selected_generation` | Replacing the report request with `Framing` failed `original request must be retained` for a checksum request. | +| Per-subject evidence is neither escalated nor attached to another generation. | The same public law, checking the complete returned subject/depth projection. | Replacing achieved depth with `CompleteBlobIdentity` failed the report assertion; substituting the successor generation failed with observed 3 versus expected 2. | +| Unsupported requests retain exact coordinates and supported policy. | `catalog_requests_outside_its_evidence_refuse_without_downgrading` | Disabling the support guard failed with `unsupported depth certified`; the successor-generation mutation also failed the exact refusal assertion. | +| Catalog reachability does not certify incomplete blob reconstruction. | `catalog_reachability_does_not_certify_an_incomplete_blob`, using an admitted layout-only catalog whose required chunk is absent. | Disabling the support guard failed with `catalog membership was presented as complete blob verification`. | +| Reporting over already admitted evidence allocates nothing. | `reporting_catalog_evidence_requires_no_additional_allocation` | A deliberately allocated, black-boxed 1,024-byte vector failed `reporting must not allocate` with observed 1,024 versus expected zero. | +| Callers cannot manufacture or deepen a report. | Compile-fail rustdoc on `VerifiedSubject` field mutation and `VerificationReport::established`, plus a compiling accessor example. | The negative examples are rejected by Rust; this is static/API evidence, not runtime RED. | + +All mutation failures occurred after successful compilation in the named runtime law. + +Each mutant used an isolated copied source tree and its own Cargo target directory; the candidate was not mutated. + +The new API is absent on the parent, so no parent compilation failure is presented as runtime RED evidence. + +## Execution and replay + +Execution used the existing Linux arm64 Docker validation container, pinned Rust 1.96.0, copied repository sources, and owned scratch directories. + +The catalog laws are small, deterministic in-memory tests with frozen checked-in inputs and no filesystem, network, clock, spawned thread, or scheduling dependency in their bodies. + +No random generation, concurrency, fault schedule, process-death, or power-loss claim is made by this slice. + +The runner does not enforce per-test resource ceilings or a measured suite latency SLO; those remain the disclosed repository [enforcement gaps](../testing/enforcement.md). + +The report-allocation law measures incremental bytes allocated on the test thread after snapshot admission, not catalog admission memory or process peak RSS. + +Replay the product laws with `cargo test --locked --test catalog_verification` and `cargo test --locked --release --test catalog_verification` inside the copied Docker checkout. + +Replay construction restrictions with `cargo test --locked --doc`. + +Debug and release laws, doctests, all-target/all-feature Clippy with warnings denied, formatting, and the source-structure check passed. + +The first structure-check attempt stopped because the copied source lacked Git metadata; after initializing and indexing the copied tree, the structure check passed, with the original failed command log retained. + +Raw local artifacts are retained under the issue's audit scratch record: `catalog-first-check.log`, `catalog-release-api-check.log`, `catalog-structure-corrected.log`, `catalog-post-calibration-green.log`, `incomplete-blob-calibration-red.log`, and the `catalog-mutants` source/log directories. + +## Remaining acceptance + +This slice reports already admitted catalog evidence only; it does not yet provide raw durable loading-to-verification error classification, segment/blob/retention reports, all required durable depth operations, an aggregate report, or the catalog-ceiling memory campaign. + +Full required validation, independent exact-head review, hosted checks, and the final #114 PR remain pending until the complete candidate is stable. + +No existing tests were deleted or expectations weakened; individual laws state their deletion criteria beside their oracles. diff --git a/src/adapters/catalog_verification.rs b/src/adapters/catalog_verification.rs new file mode 100644 index 00000000..eb75d99d --- /dev/null +++ b/src/adapters/catalog_verification.rs @@ -0,0 +1,48 @@ +//! This module owns evidence reporting over an already admitted catalog view. + +use super::CatalogSnapshot; +use crate::{VerificationDepth, VerificationRefusal, VerificationReport, VerificationSubject}; + +const SUPPORTED: &[VerificationDepth] = &[ + VerificationDepth::Framing, + VerificationDepth::Checksum, + VerificationDepth::CatalogReachability, +]; + +impl CatalogSnapshot<'_, '_, '_> { + /// Reports the requested evidence for this exact admitted catalog. + /// + /// Supports framing, checksum, and catalog reachability. Admission already + /// verified those properties and bound each catalog entry to an admitted + /// segment record. Reporting is constant time, performs no I/O, allocates + /// nothing, and does not block or mutate storage. + /// + /// This is not a shallow scan of untrusted bytes: constructing the snapshot + /// first checks catalog/segment integrity and record identities, even for a + /// framing request. It does not traverse each layout's referenced chunks, + /// reconstruct all blobs, or verify retained-root closure. The report binds + /// the owned or borrowed snapshot bytes, not a later filesystem observation. + /// + /// # Errors + /// + /// Returns [`VerificationRefusal::Unsupported`] for any other depth, with + /// the exact subject, request, and supported set. No shallower report is + /// returned after an unsupported request. + pub fn verify( + &self, + requested: VerificationDepth, + ) -> Result { + let subject = VerificationSubject::Catalog { + generation: self.generation(), + digest: self.catalog_digest(), + }; + if !SUPPORTED.contains(&requested) { + return Err(VerificationRefusal::Unsupported { + subject, + requested, + supported: SUPPORTED, + }); + } + Ok(VerificationReport::established(subject, requested)) + } +} diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 39627605..7c18535b 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -60,6 +60,7 @@ mod catalog_snapshot_error; mod catalog_successor; mod catalog_transition; mod catalog_transition_error; +mod catalog_verification; mod checksummed_catalog; mod checksummed_publication_head; mod checksummed_segment_record; diff --git a/src/lib.rs b/src/lib.rs index 1127d095..0f919361 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)] @@ -198,3 +199,7 @@ pub use retention::{ RetentionProfileAdmissionError, RetentionRoot, RetentionRootDigest, RetentionRootError, RootGeneration, RootGenerationError, }; +pub use verification::{ + VerificationDepth, VerificationRefusal, VerificationReport, VerificationSubject, + VerifiedSubject, +}; diff --git a/src/verification.rs b/src/verification.rs new file mode 100644 index 00000000..33c91264 --- /dev/null +++ b/src/verification.rs @@ -0,0 +1,14 @@ +//! This module owns immutable statements of achieved verification evidence. +//! +//! Evidence is specific to a subject. A depth's ordering is not permission to +//! infer a different subject's properties, publication, or retention authority. + +mod depth; +mod refusal; +mod report; +mod subject; + +pub use depth::VerificationDepth; +pub use refusal::VerificationRefusal; +pub use report::{VerificationReport, VerifiedSubject}; +pub use subject::VerificationSubject; diff --git a/src/verification/depth.rs b/src/verification/depth.rs new file mode 100644 index 00000000..892afacf --- /dev/null +++ b/src/verification/depth.rs @@ -0,0 +1,26 @@ +//! This module owns the explicit vocabulary of verification requests. + +/// Requested verification work, interpreted for a specific subject. +/// +/// Operations document their supported depths. Ordering alone is not a proof: +/// catalog reachability does not establish every referenced blob's identity, +/// and retention closure does not establish a future snapshot-binding format. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum VerificationDepth { + /// Canonical record framing and structural bounds. + Framing, + /// Checksums covering the subject's encoded representation. + Checksum, + /// Logical identities of the subject's chunk bytes. + ChunkIdentity, + /// Canonical layout identity and layout structure. + LayoutIdentity, + /// Complete logical blob identity and registered profile replay. + CompleteBlobIdentity, + /// Exact binding of catalog entries to admitted physical records. + CatalogReachability, + /// Complete logical closure of retained roots in one catalog view. + RetentionClosure, + /// Snapshot-format binding, unsupported until that protocol exists. + SnapshotBinding, +} diff --git a/src/verification/rationale.md b/src/verification/rationale.md new file mode 100644 index 00000000..c6bc5639 --- /dev/null +++ b/src/verification/rationale.md @@ -0,0 +1,23 @@ +# Subject-specific verification evidence + +Status: implementation in progress for #114. + +Verification reports name the evidence established for a subject; the enum's ordering does not turn a catalog-membership proof into complete logical blob verification. + +The domain owns immutable report, subject, depth, and refusal vocabulary without importing storage adapters. + +Adapters construct reports only after admitting the corresponding evidence. + +The initial catalog operation reports existing admitted evidence and explicitly documents the admission work already performed before reporting. + +An unsupported request returns its exact subject, requested depth, and supported set, because the supported catalog depths are not a contiguous interval. + +Using a minimum/maximum range would incorrectly admit `CompleteBlobIdentity` between `Checksum` and `CatalogReachability`. + +Reports expose a subject slice and private construction; the initial single-subject representation adds no allocation and does not imply that aggregate or other-subject verification has been implemented. + +Copying a report preserves its claims and carries no fence or retention authority. + +The prepared reference-store report was not imported unchanged because its layout/blob-only coordinates do not describe the durable catalog subject required here. + +No durable format, hashing preimage, write protocol, or recovery action changes in this slice. diff --git a/src/verification/refusal.rs b/src/verification/refusal.rs new file mode 100644 index 00000000..450c9828 --- /dev/null +++ b/src/verification/refusal.rs @@ -0,0 +1,36 @@ +//! This module owns semantic refusals of verification requests. + +use std::error::Error; +use std::fmt; + +use super::{VerificationDepth, VerificationSubject}; + +/// Why a verification request cannot establish its requested evidence. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum VerificationRefusal { + /// The operation cannot establish this depth for the requested subject. + Unsupported { + /// Subject the caller asked to verify. + subject: VerificationSubject, + /// Exact requested depth; the operation does not silently downgrade it. + requested: VerificationDepth, + /// Exact supported set, which need not be a contiguous ordinal range. + supported: &'static [VerificationDepth], + }, +} + +impl fmt::Display for VerificationRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unsupported { requested, .. } => { + write!( + formatter, + "verification depth {requested:?} is unsupported for this subject" + ) + } + } + } +} + +impl Error for VerificationRefusal {} diff --git a/src/verification/report.rs b/src/verification/report.rs new file mode 100644 index 00000000..88a005d4 --- /dev/null +++ b/src/verification/report.rs @@ -0,0 +1,88 @@ +//! This module owns read-only reports constructed after evidence admission. + +use super::{VerificationDepth, VerificationSubject}; + +/// Evidence established for one subject, with no public construction or upgrade. +/// +/// Depth is a subject-specific claim, not authority to infer a stronger claim. +/// Copying this value preserves exactly the same evidence. +/// +/// ```compile_fail +/// use keep::{VerificationDepth, VerifiedSubject}; +/// fn deepen(mut subject: VerifiedSubject) { +/// subject.depth = VerificationDepth::CompleteBlobIdentity; +/// } +/// ``` +#[must_use] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct VerifiedSubject { + subject: VerificationSubject, + depth: VerificationDepth, +} + +impl VerifiedSubject { + /// Returns the exact subject whose evidence was admitted. + pub const fn subject(&self) -> VerificationSubject { + self.subject + } + + /// Returns the depth established for this subject. + pub const fn depth(&self) -> VerificationDepth { + self.depth + } +} + +/// An immutable in-memory report from a successful verification operation. +/// +/// The current operations each report one subject without allocating. A +/// report is not a durable receipt, reader fence, retention authority, or +/// assertion that an on-disk artifact still exists after the operation. +/// It contains no plaintext, keys, or filesystem paths. +/// +/// Adapters may check more evidence during admission than the requested +/// depth; those costs are documented by each operation. Only the stated +/// per-subject evidence is certified by the report. +/// +/// ``` +/// use keep::{VerificationDepth, VerificationReport}; +/// fn claimed_depths(report: &VerificationReport) -> Vec { +/// report.subjects().iter().map(|subject| subject.depth()).collect() +/// } +/// ``` +/// +/// Callers cannot manufacture an upgraded report from subject coordinates. +/// +/// ```compile_fail +/// use keep::{VerificationDepth, VerificationReport, VerificationSubject}; +/// fn manufacture(subject: VerificationSubject) -> VerificationReport { +/// VerificationReport::established(subject, VerificationDepth::CompleteBlobIdentity) +/// } +/// ``` +#[must_use] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct VerificationReport { + requested: VerificationDepth, + subject: VerifiedSubject, +} + +impl VerificationReport { + /// Returns the original requested policy, without implicit escalation. + pub const fn requested(&self) -> VerificationDepth { + self.requested + } + + /// Returns the evidence established for each subject in this report. + pub const fn subjects(&self) -> &[VerifiedSubject] { + std::slice::from_ref(&self.subject) + } + + pub(crate) const fn established( + subject: VerificationSubject, + depth: VerificationDepth, + ) -> Self { + Self { + requested: depth, + subject: VerifiedSubject { subject, depth }, + } + } +} diff --git a/src/verification/subject.rs b/src/verification/subject.rs new file mode 100644 index 00000000..972408e9 --- /dev/null +++ b/src/verification/subject.rs @@ -0,0 +1,19 @@ +//! This module owns the coordinates naming a verification subject. + +use crate::{CatalogDigest, CatalogGeneration}; + +/// Subject to which a verification claim or refusal applies. +/// +/// Coordinates contain no paths or content bytes. A catalog coordinate is a +/// physical view selection, not a logical blob identity or retention promise. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum VerificationSubject { + /// One exact immutable catalog selected by a publication head. + Catalog { + /// Generation selected by the admitted head. + generation: CatalogGeneration, + /// Digest selected by that same admitted head. + digest: CatalogDigest, + }, +} diff --git a/tests/catalog_verification.rs b/tests/catalog_verification.rs new file mode 100644 index 00000000..82207fce --- /dev/null +++ b/tests/catalog_verification.rs @@ -0,0 +1,181 @@ +//! Public laws for catalog-specific verification reports. + +#[path = "retention_closure/memory_stage.rs"] +mod memory_stage; +mod support; + +use std::error::Error; + +use keep::{ + AdmittedLayout, AdmittedSegment, AdmittedSegmentRecord, CanonicalCatalog, + CanonicalPublicationHead, CatalogGeneration, CatalogSnapshot, ChecksummedCatalog, + ChecksummedPublicationHead, LayoutDecodePolicy, LayoutEntryLimit, SegmentReadPolicy, + SegmentRecordIdentity, VerificationDepth, VerificationRefusal, VerificationSubject, +}; +use support::{decode_hex, layout_record_bytes, require_error}; + +const CATALOG: &str = + include_str!("../conformance/segment-store/v1/one-zero-catalog-generation-two.hex"); +const HEAD: &str = include_str!("../conformance/segment-store/v1/one-zero-head-generation-two.hex"); +const SEGMENT: &str = include_str!("../conformance/segment-store/v1/one-zero-segment.hex"); +const CATALOG_DIGEST: &str = "ea7d0055fd21f00ed94809ef4e671d72fa2e6a4a5d9ecefb23f3a320a2dad993"; + +// Size: small. Oracle: frozen generation-two corpus coordinates and the public +// contract that successful reports state the requested catalog evidence only. +// Delete when catalog verification is removed or a stronger report law subsumes it. +#[test] +fn catalog_reports_bind_the_requested_evidence_to_the_selected_generation() +-> Result<(), Box> { + with_snapshot(|snapshot| { + for depth in [ + VerificationDepth::Framing, + VerificationDepth::Checksum, + VerificationDepth::CatalogReachability, + ] { + let report = snapshot.verify(depth)?; + assert_eq!( + report.requested(), + depth, + "original request must be retained" + ); + let observed: Vec<_> = report + .subjects() + .iter() + .map(|verified| (verified.subject(), verified.depth())) + .collect(); + assert_eq!( + observed, + [(expected_subject(snapshot)?, depth)], + "report must contain exactly the selected catalog's requested evidence" + ); + } + Ok(()) + }) +} + +// Size: small. Oracle: catalog admission does not establish logical blob or +// retention/snapshot closure; unsupported requests must preserve exact coordinates. +// Delete when these subject/depth contracts are deliberately replaced. +#[test] +fn catalog_requests_outside_its_evidence_refuse_without_downgrading() -> Result<(), Box> +{ + with_snapshot(|snapshot| { + for requested in [ + VerificationDepth::ChunkIdentity, + VerificationDepth::LayoutIdentity, + VerificationDepth::CompleteBlobIdentity, + VerificationDepth::RetentionClosure, + VerificationDepth::SnapshotBinding, + ] { + let refusal = require_error(snapshot.verify(requested), "unsupported depth certified")?; + assert_eq!( + refusal, + VerificationRefusal::Unsupported { + subject: expected_subject(snapshot)?, + requested, + supported: &[ + VerificationDepth::Framing, + VerificationDepth::Checksum, + VerificationDepth::CatalogReachability, + ], + }, + "unsupported request must name its subject, request, and exact supported set" + ); + } + Ok(()) + }) +} + +// Size: small. Oracle: catalog binding admits layout records independently of +// chunk closure. The missing chunk is deliberate, not a fabricated report. +// Delete when catalog admission requires complete blob closure by contract. +#[test] +fn catalog_reachability_does_not_certify_an_incomplete_blob() -> Result<(), Box> { + let layout = AdmittedLayout::decode_record( + &layout_record_bytes("one-zero")?, + LayoutDecodePolicy::new(LayoutEntryLimit::MAXIMUM), + )?; + let chunk = layout + .entries() + .first() + .ok_or("missing fixture chunk")? + .chunk_id(); + let canonical = layout.encode_record()?; + let bytes = memory_stage::segment_bytes(&[AdmittedSegmentRecord::for_layout(&canonical)?])?; + let segments = [AdmittedSegment::decode(&bytes, SegmentReadPolicy::MAXIMUM)?]; + let catalog = CanonicalCatalog::from_segments(CatalogGeneration::new(1)?, None, &segments)?; + let head = CanonicalPublicationHead::for_catalog(catalog.checksummed()); + let snapshot = ChecksummedPublicationHead::decode(head.encoded())? + .admit(catalog.checksummed().admit(&segments)?)?; + + assert!( + snapshot + .record(SegmentRecordIdentity::Chunk(chunk)) + .is_none() + ); + let report = snapshot.verify(VerificationDepth::CatalogReachability)?; + assert_eq!(report.requested(), VerificationDepth::CatalogReachability); + let refusal = require_error( + snapshot.verify(VerificationDepth::CompleteBlobIdentity), + "catalog membership was presented as complete blob verification", + )?; + assert!( + matches!( + refusal, + VerificationRefusal::Unsupported { + requested: VerificationDepth::CompleteBlobIdentity, + .. + } + ), + "an incomplete blob must not receive a complete-identity report: {refusal:?}" + ); + Ok(()) +} + +// Size: small. Oracle: producing a report over admitted evidence allocates nothing. +// Delete if the public reporting contract explicitly permits allocation. +#[test] +fn reporting_catalog_evidence_requires_no_additional_allocation() -> Result<(), Box> { + with_snapshot(|snapshot| { + let mut result = None; + let allocation = allocation_counter::measure(|| { + result = Some(snapshot.verify(VerificationDepth::CatalogReachability)); + }); + let report = result.ok_or("reporting did not execute")??; + assert_eq!(report.requested(), VerificationDepth::CatalogReachability); + assert_eq!(allocation.bytes_total, 0, "reporting must not allocate"); + Ok(()) + }) +} + +fn with_snapshot( + operation: impl FnOnce(&CatalogSnapshot<'_, '_, '_>) -> Result<(), Box>, +) -> Result<(), Box> { + let catalog = fixture(CATALOG)?; + let head = fixture(HEAD)?; + let segment = fixture(SEGMENT)?; + let segments = [AdmittedSegment::decode( + &segment, + SegmentReadPolicy::MAXIMUM, + )?]; + let snapshot = ChecksummedPublicationHead::decode(&head)? + .admit(ChecksummedCatalog::decode(&catalog)?.admit(&segments)?)?; + operation(&snapshot) +} + +fn expected_subject( + snapshot: &CatalogSnapshot<'_, '_, '_>, +) -> Result> { + assert_eq!( + snapshot.catalog_digest().as_bytes().as_slice(), + decode_hex(CATALOG_DIGEST)? + ); + Ok(VerificationSubject::Catalog { + generation: CatalogGeneration::new(2)?, + digest: snapshot.catalog_digest(), + }) +} + +fn fixture(hex: &str) -> Result, Box> { + decode_hex(hex.strip_suffix('\n').ok_or("fixture lacks terminal LF")?).map_err(Into::into) +} From d65c845a05a96bdf5f724c79f4a0aa211fdb1ca9 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 17:28:37 -0700 Subject: [PATCH 03/18] feat: report admitted segment and logical-record evidence (#114) --- CHANGELOG.md | 2 + docs/audits/114-durable-verification-scope.md | 2 +- docs/invariants/verification/README.md | 44 +++++ docs/invariants/verification/requirements.md | 7 + docs/testing-evidence/durable-verification.md | 46 ++++- src/adapters/exports.rs | 2 +- src/adapters/mod.rs | 3 +- src/adapters/segment_record_verification.rs | 57 ++++++ src/adapters/segment_verification.rs | 42 +++++ src/lib.rs | 1 + src/{adapters => }/segment_digest.rs | 2 +- src/verification/rationale.md | 8 + src/verification/subject.rs | 18 +- tests/segment_verification.rs | 110 ++++++++++++ tests/segment_verification/record_laws.rs | 165 ++++++++++++++++++ 15 files changed, 503 insertions(+), 6 deletions(-) create mode 100644 docs/invariants/verification/README.md create mode 100644 docs/invariants/verification/requirements.md create mode 100644 src/adapters/segment_record_verification.rs create mode 100644 src/adapters/segment_verification.rs rename src/{adapters => }/segment_digest.rs (89%) create mode 100644 tests/segment_verification.rs create mode 100644 tests/segment_verification/record_laws.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bcef5f6a..6687f917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ after its public API and format compatibility policies are established. ## [Unreleased] +- Added allocation-free verification reports for already-admitted physical segments and logical chunk/layout records, with explicit subject-specific supported depths; full durable verification remains in progress under #114. + - Admitted catalog snapshots can report explicit framing, checksum, or catalog-reachability evidence with immutable subject coordinates; unsupported requests refuse without claiming blob completeness or retention closure (#114, initial report slice). - Retention recovery execution errors report the exact failed boundary, original typed cause, known namespace effects and uncertain effect/durability; retries freshly observe the store. Observed stage identity remains binding across reopening, and cleanup preserves verified pool evidence rather than promising the removed pathname survives (#99). diff --git a/docs/audits/114-durable-verification-scope.md b/docs/audits/114-durable-verification-scope.md index fe2e9660..7c42b953 100644 --- a/docs/audits/114-durable-verification-scope.md +++ b/docs/audits/114-durable-verification-scope.md @@ -38,7 +38,7 @@ These are inspected implementation boundaries, not new execution receipts. ## Closure ledger -The initial catalog report slice now has [runtime and static/API evidence](../testing-evidence/durable-verification.md); every full-issue obligation below remains open until its entire exit condition is met. +Catalog, segment and logical-record reporting now have [runtime and static/API evidence](../testing-evidence/durable-verification.md); every full-issue obligation below remains open until its entire exit condition is met. | Obligation | Required implementation boundary | Concrete exit condition | | --- | --- | --- | diff --git a/docs/invariants/verification/README.md b/docs/invariants/verification/README.md new file mode 100644 index 00000000..474748e3 --- /dev/null +++ b/docs/invariants/verification/README.md @@ -0,0 +1,44 @@ +# Verification reports + +Status: implementation in progress under [#114](https://github.com/flyingrobots/keep/issues/114); this page describes the currently implemented admitted-evidence reporting operations, not completed durable verification acceptance. + +## Contract + +A report names the exact subject, the caller's requested depth and the evidence established for that subject. + +An ordinal depth comparison grants no inference about another subject: a catalog membership check cannot certify a blob, and a layout identity cannot certify its missing chunks. + +Report fields and construction are private; callers can inspect or copy established evidence but cannot construct or deepen it. + +Reports contain no plaintext, keys or filesystem paths and convey no publication, retention authority, reader fence or assurance that physical bytes still exist later. + +## Current subject and depth matrix + +| Entry point | Subject | Supported depths | Scope of the proof | +| --- | --- | --- | --- | +| `AdmittedSegment::verify` | Exact physical segment digest | `Framing`, `Checksum` | The admitted immutable segment's physical representation; logical claims require record-specific reports. | +| `AdmittedSegmentRecord::verify` for a chunk | Exact `ChunkId` | `Framing`, `Checksum`, `ChunkIdentity` | That record's complete chunk bytes; no blob or profile-boundary claim. | +| `AdmittedSegmentRecord::verify` for a layout | Exact `LayoutId` | `Framing`, `Checksum`, `LayoutIdentity` | Canonical layout bytes and identity; no requirement that referenced chunks exist. | +| `CatalogSnapshot::verify` | Selected catalog generation and digest | `Framing`, `Checksum`, `CatalogReachability` | Exact catalog-to-record bindings; no complete logical or retained closure claim. | + +Every other depth returns `VerificationRefusal::Unsupported` with the exact subject, request and supported set; the operation neither downgrades the request nor returns a success report. + +`SnapshotBinding` remains unsupported until its separate protocol exists; catalog/retention coordinates must not be mislabeled as that future proof. + +## Costs and admission boundary + +Each current reporting call is constant time, allocates no heap memory, performs no I/O, does not block, and neither mutates nor synchronizes storage. + +These costs exclude prerequisite admission: segment admission verifies all records, checksums and identities with bounded duplicate-detection allocation; layout record admission may allocate bounded layout metadata; catalog admission binds its entries to admitted segment records. + +A framing request on already admitted evidence still requires that stronger admission to have succeeded first; these APIs are not shallow raw-byte scans that tolerate deeper corruption. + +Records prepared for publication provide the same logical proof over their canonical representation without asserting that the record has been written or made durable. + +## Remaining durable contract + +Raw durable loading-to-report failure classification, missing/corrupt/ambiguous/operational outcomes with preserved causes, complete-blob and retention reports, aggregate per-subject reports, and the catalog-ceiling memory campaign remain required by the [closure ledger](../../audits/114-durable-verification-scope.md). + +No serialization, repair, quarantine, GC execution or new durable report format is introduced here. + +[Evidence and calibration](../../testing-evidence/durable-verification.md) distinguish runtime laws, static/API restrictions and unimplemented acceptance obligations. diff --git a/docs/invariants/verification/requirements.md b/docs/invariants/verification/requirements.md new file mode 100644 index 00000000..a3cc5490 --- /dev/null +++ b/docs/invariants/verification/requirements.md @@ -0,0 +1,7 @@ +# Verification requirements + +The [original task](../../audits/114-durable-verification-scope.md) remains authoritative; partial subject reporting does not satisfy the full durable requirement. + +| ID | Requirement | Status | Evidence and remaining work | +| --- | --- | --- | --- | +| `KEEP-VERIFY-006` | Durable verification at explicit subject-specific achieved depths, with precise refusals, immutable reports and bounded costs. | In progress | Catalog, segment and logical-record admitted-evidence reports exist; raw durable failures, complete-blob/retention/aggregate operations, the full depth matrix and memory campaign remain open. See the [closure ledger](../../audits/114-durable-verification-scope.md) and [execution evidence](../../testing-evidence/durable-verification.md). | diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index 75ec6805..9ed5d450 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -51,8 +51,52 @@ Raw local artifacts are retained under the issue's audit scratch record: `catalo ## Remaining acceptance -This slice reports already admitted catalog evidence only; it does not yet provide raw durable loading-to-verification error classification, segment/blob/retention reports, all required durable depth operations, an aggregate report, or the catalog-ceiling memory campaign. +This slice reports already admitted catalog evidence only; it does not yet provide raw durable loading-to-verification error classification, complete-blob/retention reports, all required durable depth operations, an aggregate report, or the catalog-ceiling memory campaign. The segment and logical-record extension is recorded below. Full required validation, independent exact-head review, hosted checks, and the final #114 PR remain pending until the complete candidate is stable. No existing tests were deleted or expectations weakened; individual laws state their deletion criteria beside their oracles. + +## Segment and logical-record reporting + +Change kind: new reporting feature over already admitted evidence, plus a behavior-preserving relocation of `SegmentDigest` from the codec adapter to domain ownership; the source baseline is `92f92089cf00d1a766fe8775dac28561ff738b27`. + +`AdmittedSegment::verify` reports only physical framing/checksum evidence, while `AdmittedSegmentRecord::verify` reports framing/checksum and the exact record kind's chunk or layout identity. + +The public [subject/depth matrix](../invariants/verification/README.md) documents costs and rejects ordinal-depth inference, publication claims and complete-blob claims from an isolated layout record. + +The physical-coordinate oracle is the frozen empty and one-zero segment digests in `conformance/segment-store/v1/artifacts.tsv`. + +Logical subjects use the frozen one-zero bundle, its declared layout identity and the named identity of the one-zero chunk; the report implementation does not derive expected values for these tests. + +These small tests use immutable in-memory fixtures and exhaust the finite request vocabulary for physical segments and both logical record kinds; no random seed, scheduler, filesystem fault or generated-space completeness claim applies. + +| Protected claim | Deliberate production violation | Observed runtime failure | +| --- | --- | --- | +| Unsupported requests never become success, including an isolated layout without its chunks. | Disable the segment and record support guards. | The physical and logical matrix laws reject unsupported success; the isolated-layout law reports `layout identity was presented as complete-blob evidence`. | +| Physical evidence names the exact frozen segment. | Replace the reported segment digest with zero bytes. | The physical-coordinate assertion and exact refusal-subject assertion fail. | +| Chunk evidence names a chunk subject. | Replace only the chunk report subject with a physical-segment subject. | The public record subject/proof assertion fails. | +| Layout evidence names its canonical layout subject. | Replace only the layout subject with a physical-segment subject, leaving chunk reporting intact. | The later layout branch of the matrix and the isolated-layout law fail. | +| Requests remain distinct from achieved evidence. | Replace the report request with `SnapshotBinding`. | The segment and logical-record request assertions fail. | +| Achieved evidence is never silently inflated. | Replace only the achieved depth with `CompleteBlobIdentity`. | Request assertions pass, then segment and logical proof assertions fail. | +| Refusals preserve the exact supported policy. | Return an empty supported set while retaining the guard. | Exact typed-refusal assertions fail for physical segments and both logical record kinds. | +| Reports contain their promised subject evidence. | Return an empty subject slice. | Physical and logical report laws fail with their missing-subject diagnostics. | +| Reporting allocates no additional heap memory. | Allocate and black-box a 1,024-byte vector in each reporting operation. | Both incremental-allocation assertions report 1,024 bytes rather than zero. | + +All listed RED results follow successful compilation and reach the intended runtime check; each mutant uses separate copied source and a separate target directory, leaving the candidate unchanged. + +Raw sources, logs and exit receipts live under `segment-mutants`; the exact omitted-subject failure is also preserved in `segment-mutants/no-subject/red.log`. + +The initial calibration-copy attempt exhausted the ext4 scratch mount's inode capacity before the remaining mutants could run; `segment-calibration-inode-status.log` records that environment condition, which is excluded from calibration evidence. + +Completed and incomplete copied sources were preserved on the container's larger build filesystem, and the remaining in-memory calibrations ran there without changing their inputs or oracle. + +The unchanged candidate passes debug/release report laws, existing catalog reports, segment/record/header/seal and memory laws, doctests, all-target/all-feature Clippy, formatting and source structure. + +`segment-release-related-validation.log` preserves an attempted nonexistent test-target invocation; `segment-related-validation-corrected.log` runs the actual segment-record/header/memory targets and remaining checks, without counting the invocation error as a product result. + +The execution receipts are `segment-first-check.log`, `segment-release-related-validation.log`, `segment-related-validation-corrected.log` and `segment-post-calibration-green.log`. + +Per-test resource enforcement remains the previously disclosed repository gap; allocation assertions measure reporting after admission, not total admission memory or process RSS. + +No existing runtime expectations were changed or tests removed, and the full durable failure/retention/aggregate and memory-ceiling obligations remain open. diff --git a/src/adapters/exports.rs b/src/adapters/exports.rs index e2c8c51f..0709fa9b 100644 --- a/src/adapters/exports.rs +++ b/src/adapters/exports.rs @@ -71,7 +71,6 @@ pub use super::recovery::*; pub use super::repository_initialization_storage::RepositoryInitializationStorage; pub use super::retention::*; pub use super::sealed_segment::SealedSegment; -pub use super::segment_digest::SegmentDigest; pub use super::segment_header::SegmentHeader; pub use super::segment_header_error::SegmentHeaderError; pub use super::segment_publication::SegmentPublication; @@ -104,3 +103,4 @@ pub use super::store_initialization_storage::StoreInitializationStorage; pub use super::store_migration::*; pub use super::writer_lock_acquire_error::WriterLockAcquireError; pub use super::writer_lock_acquire_phase::WriterLockAcquirePhase; +pub use crate::segment_digest::SegmentDigest; diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 7c18535b..627dcaca 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -163,7 +163,6 @@ mod recovery; mod repository_initialization_storage; mod retention; mod sealed_segment; -mod segment_digest; mod segment_digest_builder; mod segment_header; mod segment_header_admission; @@ -200,6 +199,7 @@ mod segment_record_kind; mod segment_record_length; mod segment_record_limit; mod segment_record_payload_length; +mod segment_record_verification; mod segment_records; mod segment_seal; mod segment_seal_admission; @@ -215,6 +215,7 @@ mod segment_stage; mod segment_stage_create_error; mod segment_stage_create_error_display; mod segment_stage_write; +mod segment_verification; mod segment_write_error; mod segment_write_error_display; mod segment_write_phase; diff --git a/src/adapters/segment_record_verification.rs b/src/adapters/segment_record_verification.rs new file mode 100644 index 00000000..c62867f3 --- /dev/null +++ b/src/adapters/segment_record_verification.rs @@ -0,0 +1,57 @@ +//! This module owns subject-specific evidence from an admitted segment record. + +use super::{AdmittedSegmentRecord, SegmentRecordIdentity}; +use crate::{VerificationDepth, VerificationRefusal, VerificationReport, VerificationSubject}; + +const CHUNK_DEPTHS: &[VerificationDepth] = &[ + VerificationDepth::Framing, + VerificationDepth::Checksum, + VerificationDepth::ChunkIdentity, +]; +const LAYOUT_DEPTHS: &[VerificationDepth] = &[ + VerificationDepth::Framing, + VerificationDepth::Checksum, + VerificationDepth::LayoutIdentity, +]; + +impl AdmittedSegmentRecord<'_> { + /// Reports evidence for this record's exact logical chunk or layout. + /// + /// Every record supports framing and checksum evidence. A chunk supports + /// chunk identity; a layout supports layout identity. No depth infers that + /// a layout's chunks are present, obey its storage profile, or reconstruct + /// its named blob. Physical catalog and retention claims are unsupported. + /// + /// This constant-time operation allocates nothing, performs no I/O and + /// changes no storage. Its prerequisite admission already checked framing, + /// checksum and logical identity, even when the request is shallower; the + /// borrowed immutable payload is not rehashed by reporting. Records built + /// for publication carry the same logical proof without implying that + /// their canonical bytes have been written or made durable. + /// + /// # Errors + /// + /// Returns [`VerificationRefusal::Unsupported`] for a depth outside the + /// exact supported set for this record kind; no downgraded report is returned. + pub fn verify( + &self, + requested: VerificationDepth, + ) -> Result { + let (subject, supported) = match self.identity() { + SegmentRecordIdentity::Chunk(identity) => { + (VerificationSubject::Chunk { identity }, CHUNK_DEPTHS) + } + SegmentRecordIdentity::Layout(identity) => { + (VerificationSubject::Layout { identity }, LAYOUT_DEPTHS) + } + }; + if !supported.contains(&requested) { + return Err(VerificationRefusal::Unsupported { + subject, + requested, + supported, + }); + } + Ok(VerificationReport::established(subject, requested)) + } +} diff --git a/src/adapters/segment_verification.rs b/src/adapters/segment_verification.rs new file mode 100644 index 00000000..a6e5cc73 --- /dev/null +++ b/src/adapters/segment_verification.rs @@ -0,0 +1,42 @@ +//! This module owns evidence reporting over an already admitted segment. + +use super::AdmittedSegment; +use crate::{VerificationDepth, VerificationRefusal, VerificationReport, VerificationSubject}; + +const SUPPORTED: &[VerificationDepth] = &[VerificationDepth::Framing, VerificationDepth::Checksum]; + +impl AdmittedSegment<'_> { + /// Reports framing or checksum evidence for this exact physical segment. + /// + /// Reporting is constant time, allocates nothing, performs no I/O, and + /// does not mutate or synchronize storage. Construction of this admitted + /// segment already checked its header, every record and seal, including + /// checksums, logical identities and duplicates under the admission policy. + /// Even a framing request therefore requires successful full admission; + /// this operation is not a shallow scan that ignores deeper corruption. + /// + /// The report names this segment's digest only. It grants no publication, + /// catalog membership, complete-blob identity or retention authority. + /// Obtain logical chunk/layout evidence from its admitted records. + /// + /// # Errors + /// + /// Returns [`VerificationRefusal::Unsupported`] for any other depth, + /// preserving the subject, requested depth and exact supported set. + pub fn verify( + &self, + requested: VerificationDepth, + ) -> Result { + let subject = VerificationSubject::Segment { + digest: self.digest(), + }; + if !SUPPORTED.contains(&requested) { + return Err(VerificationRefusal::Unsupported { + subject, + requested, + supported: SUPPORTED, + }); + } + Ok(VerificationReport::established(subject, requested)) + } +} diff --git a/src/lib.rs b/src/lib.rs index 0f919361..441dac60 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,6 +54,7 @@ mod layout; mod profile; mod reference; mod retention; +mod segment_digest; mod verification; #[cfg(feature = "repository-tasks")] diff --git a/src/adapters/segment_digest.rs b/src/segment_digest.rs similarity index 89% rename from src/adapters/segment_digest.rs rename to src/segment_digest.rs index 4f0b8e1e..f67ec819 100644 --- a/src/adapters/segment_digest.rs +++ b/src/segment_digest.rs @@ -15,7 +15,7 @@ impl SegmentDigest { &self.0 } - pub(super) const fn from_validated(bytes: [u8; 32]) -> Self { + pub(crate) const fn from_validated(bytes: [u8; 32]) -> Self { Self(bytes) } } diff --git a/src/verification/rationale.md b/src/verification/rationale.md index c6bc5639..3d4294e0 100644 --- a/src/verification/rationale.md +++ b/src/verification/rationale.md @@ -21,3 +21,11 @@ Copying a report preserves its claims and carries no fence or retention authorit The prepared reference-store report was not imported unchanged because its layout/blob-only coordinates do not describe the durable catalog subject required here. No durable format, hashing preimage, write protocol, or recovery action changes in this slice. + +Segment reports certify only physical framing/checksum evidence; chunk and layout claims are attached to their own logical record subjects, so an empty physical segment cannot masquerade as evidence of some logical object. + +`SegmentDigest` now lives in a domain-owned module rather than under the codec adapters; its public name and byte representation are unchanged, and reporting does not introduce an inward dependency on filesystem or codec implementations. + +Logical-record reports use their admitted immutable payload evidence without claiming catalog membership or publication, including records prepared for writing but not persisted. + +The supported-depth matrix is explicit per subject, and reporting never uses enum ordering to accept a request. diff --git a/src/verification/subject.rs b/src/verification/subject.rs index 972408e9..ea8cbd05 100644 --- a/src/verification/subject.rs +++ b/src/verification/subject.rs @@ -1,6 +1,7 @@ //! This module owns the coordinates naming a verification subject. -use crate::{CatalogDigest, CatalogGeneration}; +use crate::segment_digest::SegmentDigest; +use crate::{CatalogDigest, CatalogGeneration, ChunkId, LayoutId}; /// Subject to which a verification claim or refusal applies. /// @@ -9,6 +10,21 @@ use crate::{CatalogDigest, CatalogGeneration}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum VerificationSubject { + /// One exact immutable segment, without a publication or retention claim. + Segment { + /// Verified physical segment digest. + digest: SegmentDigest, + }, + /// Exact chunk bytes admitted from a segment record. + Chunk { + /// Logical chunk identity verified during record admission. + identity: ChunkId, + }, + /// One admitted canonical layout, without a claim about its chunk closure. + Layout { + /// Canonical layout identity verified during record admission. + identity: LayoutId, + }, /// One exact immutable catalog selected by a publication head. Catalog { /// Generation selected by the admitted head. diff --git a/tests/segment_verification.rs b/tests/segment_verification.rs new file mode 100644 index 00000000..a7ca5b2f --- /dev/null +++ b/tests/segment_verification.rs @@ -0,0 +1,110 @@ +//! Public runtime laws for physical segment verification reports. + +#[path = "segment_verification/record_laws.rs"] +mod record_laws; +mod support; + +use std::error::Error; + +use keep::{ + AdmittedSegment, SegmentReadPolicy, VerificationDepth, VerificationRefusal, VerificationSubject, +}; +use support::{decode_hex, require_error}; + +const EMPTY: &str = include_str!("../conformance/segment-store/v1/empty-segment.hex"); +const ONE_ZERO: &str = include_str!("../conformance/segment-store/v1/one-zero-segment.hex"); + +// Size: small. Oracle: frozen physical coordinates in v1/artifacts.tsv; +// only framing/checksum proof is supported for the physical segment subject. +// Delete when this report contract is removed or replaced by a stronger law. +#[test] +fn segment_reports_bind_only_supported_evidence_to_the_exact_physical_bytes() +-> Result<(), Box> { + for (fixture, digest) in [ + ( + EMPTY, + "fef0cccd7bb6f75a3322d5457219ce4875e5f5ae75193e3c66d4856bca380170", + ), + ( + ONE_ZERO, + "b7542dced2ab770894a14d1d04b066e3a899942602c5986d35ba6df6c1a35cfc", + ), + ] { + let bytes = decode_hex(fixture.trim_end())?; + let segment = AdmittedSegment::decode(&bytes, SegmentReadPolicy::MAXIMUM)?; + for requested in [VerificationDepth::Framing, VerificationDepth::Checksum] { + let report = segment.verify(requested)?; + assert_eq!(report.requested(), requested, "segment request was changed"); + let [verified] = report.subjects() else { + return Err("segment report must name one subject".into()); + }; + let VerificationSubject::Segment { digest: observed } = verified.subject() else { + return Err("segment report named a non-segment subject".into()); + }; + assert_eq!( + observed.as_bytes().as_slice(), + decode_hex(digest)?, + "report must name the exact physical segment" + ); + assert_eq!( + verified.depth(), + requested, + "segment evidence was escalated" + ); + } + } + Ok(()) +} + +// Size: small. Oracle: a physical segment report is not logical chunk/blob, +// layout, catalog, retention or snapshot evidence, even for an admitted bundle. +// Delete only with an intentional change to this subject-specific contract. +#[test] +fn physical_segment_reports_refuse_logical_or_store_claims() -> Result<(), Box> { + let bytes = decode_hex(ONE_ZERO.trim_end())?; + let segment = AdmittedSegment::decode(&bytes, SegmentReadPolicy::MAXIMUM)?; + for requested in [ + VerificationDepth::ChunkIdentity, + VerificationDepth::LayoutIdentity, + VerificationDepth::CompleteBlobIdentity, + VerificationDepth::CatalogReachability, + VerificationDepth::RetentionClosure, + VerificationDepth::SnapshotBinding, + ] { + let refusal = require_error( + segment.verify(requested), + "physical evidence certified another subject's claim", + )?; + assert_eq!( + refusal, + VerificationRefusal::Unsupported { + subject: VerificationSubject::Segment { + digest: segment.digest() + }, + requested, + supported: &[VerificationDepth::Framing, VerificationDepth::Checksum], + }, + "unsupported segment request must retain its exact policy and subject" + ); + } + Ok(()) +} + +// Size: small. Oracle: reporting over admitted immutable evidence is allocation-free. +// Delete only when the documented reporting cost deliberately changes. +#[test] +fn reporting_segment_evidence_allocates_no_heap_memory() -> Result<(), Box> { + let bytes = decode_hex(ONE_ZERO.trim_end())?; + let segment = AdmittedSegment::decode(&bytes, SegmentReadPolicy::MAXIMUM)?; + let mut result = None; + let allocation = allocation_counter::measure(|| { + result = Some(segment.verify(VerificationDepth::Checksum)); + }); + let report = result.ok_or("segment reporting did not execute")??; + assert_eq!(report.requested(), VerificationDepth::Checksum); + assert_eq!( + allocation.bytes_total, 0, + "segment reporting must not allocate" + ); + Ok(()) +} diff --git a/tests/segment_verification/record_laws.rs b/tests/segment_verification/record_laws.rs new file mode 100644 index 00000000..7dbb8783 --- /dev/null +++ b/tests/segment_verification/record_laws.rs @@ -0,0 +1,165 @@ +//! Public laws preventing logical-record evidence from claiming closure. + +use std::error::Error; + +use crate::support::{decode_hex, layout_case_field, layout_record_bytes, require_error}; +use keep::{ + AdmittedLayout, AdmittedSegment, AdmittedSegmentRecord, ChunkId, LayoutDecodePolicy, + LayoutEntryLimit, SegmentReadPolicy, VerificationDepth, VerificationRefusal, + VerificationReport, VerificationSubject, +}; + +const BUNDLE: &str = include_str!("../../conformance/segment-store/v1/one-zero-bundle-segment.hex"); +const DEPTHS: [VerificationDepth; 8] = [ + VerificationDepth::Framing, + VerificationDepth::Checksum, + VerificationDepth::ChunkIdentity, + VerificationDepth::LayoutIdentity, + VerificationDepth::CompleteBlobIdentity, + VerificationDepth::CatalogReachability, + VerificationDepth::RetentionClosure, + VerificationDepth::SnapshotBinding, +]; + +// Size: small. Oracle: the one-zero bundle contains a chunk and its canonical +// layout; each supports only its specified framing/checksum/logical proof. +// Delete when the record report contract is deliberately replaced. +#[test] +fn admitted_record_reports_preserve_each_logical_subjects_exact_proof_scope() +-> Result<(), Box> { + let bytes = decode_hex(BUNDLE.trim_end())?; + let segment = AdmittedSegment::decode(&bytes, SegmentReadPolicy::MAXIMUM)?; + let mut records = segment.records(); + let chunk = records + .next() + .ok_or("frozen bundle lost its chunk witness")??; + let layout = records + .next() + .ok_or("frozen bundle lost its layout witness")??; + let expected_chunk = VerificationSubject::Chunk { + identity: ChunkId::hash_bytes(&[0])?, + }; + let expected_layout = VerificationSubject::Layout { + identity: layout_case_field("one-zero", 10)?.parse()?, + }; + assert_record_depths( + chunk, + expected_chunk, + &[ + VerificationDepth::Framing, + VerificationDepth::Checksum, + VerificationDepth::ChunkIdentity, + ], + )?; + assert_record_depths( + layout, + expected_layout, + &[ + VerificationDepth::Framing, + VerificationDepth::Checksum, + VerificationDepth::LayoutIdentity, + ], + )?; + Ok(()) +} + +fn assert_record_depths( + record: AdmittedSegmentRecord<'_>, + subject: VerificationSubject, + supported: &'static [VerificationDepth], +) -> Result<(), Box> { + for requested in DEPTHS { + if supported.contains(&requested) { + assert_report(record.verify(requested)?, subject, requested)?; + } else { + let refusal = require_error( + record.verify(requested), + "record evidence certified an unsupported claim", + )?; + assert_eq!( + refusal, + VerificationRefusal::Unsupported { + subject, + requested, + supported + }, + "record refusal must preserve subject, requested depth and exact supported set" + ); + } + } + Ok(()) +} + +fn assert_report( + report: VerificationReport, + expected: VerificationSubject, + requested: VerificationDepth, +) -> Result<(), Box> { + assert_eq!(report.requested(), requested, "record request was changed"); + let [verified] = report.subjects() else { + return Err("record report must name one subject".into()); + }; + assert_eq!( + (verified.subject(), verified.depth()), + (expected, requested), + "record report must name its exact subject and established proof" + ); + Ok(()) +} + +// Size: small. Oracle: preparing a layout does not load its referenced chunks; +// layout identity is valid without complete-blob evidence or publication. +// Delete only if layout preparation explicitly acquires and verifies closure. +#[test] +fn a_layout_prepared_without_its_chunks_cannot_report_complete_blob_verification() +-> Result<(), Box> { + let admitted = AdmittedLayout::decode_record( + &layout_record_bytes("one-zero")?, + LayoutDecodePolicy::new(LayoutEntryLimit::MAXIMUM), + )?; + let canonical = admitted.encode_record()?; + let record = AdmittedSegmentRecord::for_layout(&canonical)?; + let subject = VerificationSubject::Layout { + identity: layout_case_field("one-zero", 10)?.parse()?, + }; + assert_report( + record.verify(VerificationDepth::LayoutIdentity)?, + subject, + VerificationDepth::LayoutIdentity, + )?; + let refusal = require_error( + record.verify(VerificationDepth::CompleteBlobIdentity), + "layout identity was presented as complete-blob evidence", + )?; + assert_eq!( + refusal, + VerificationRefusal::Unsupported { + subject, + requested: VerificationDepth::CompleteBlobIdentity, + supported: &[ + VerificationDepth::Framing, + VerificationDepth::Checksum, + VerificationDepth::LayoutIdentity + ], + } + ); + Ok(()) +} + +// Size: small. Oracle: reporting over admitted logical evidence allocates nothing. +// Delete only when the public reporting cost intentionally changes. +#[test] +fn reporting_logical_record_evidence_allocates_no_heap_memory() -> Result<(), Box> { + let record = AdmittedSegmentRecord::for_chunk(&[0])?; + let mut result = None; + let allocation = allocation_counter::measure(|| { + result = Some(record.verify(VerificationDepth::ChunkIdentity)); + }); + let report = result.ok_or("record reporting did not execute")??; + assert_eq!(report.requested(), VerificationDepth::ChunkIdentity); + assert_eq!( + allocation.bytes_total, 0, + "record reporting must not allocate" + ); + Ok(()) +} From b204f83d6aa9e55f3984ea0933890abb00576847 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 18:04:35 -0700 Subject: [PATCH 04/18] feat: verify complete blob and retained root evidence (#114) --- CHANGELOG.md | 2 + docs/audits/114-durable-verification-scope.md | 2 +- docs/invariants/verification/README.md | 16 +- docs/testing-evidence/durable-verification.md | 34 ++++ src/adapters/admitted_catalog.rs | 7 + src/adapters/blob_verification.rs | 141 ++++++++++++++++ src/adapters/catalog_snapshot.rs | 4 + src/adapters/catalog_verification.rs | 7 +- src/adapters/mod.rs | 5 + src/adapters/retention.rs | 3 + .../retention/closure_profile_error.rs | 6 +- src/adapters/retention/root_verification.rs | 62 +++++++ src/adapters/segment_record_verification.rs | 4 + src/adapters/segment_verification.rs | 4 + src/adapters/verification_admission.rs | 92 ++++++++++ src/adapters/verification_error.rs | 93 +++++++++++ src/lib.rs | 5 +- src/verification.rs | 2 + src/verification/observation.rs | 20 +++ src/verification/rationale.md | 6 + src/verification/refusal.rs | 28 +++- src/verification/report.rs | 19 +++ src/verification/subject.rs | 20 ++- tests/blob_verification.rs | 113 +++++++++++++ tests/blob_verification/profile_law.rs | 64 +++++++ tests/blob_verification/refusal_laws.rs | 144 ++++++++++++++++ tests/blob_verification/root_laws.rs | 158 ++++++++++++++++++ 27 files changed, 1051 insertions(+), 10 deletions(-) create mode 100644 src/adapters/blob_verification.rs create mode 100644 src/adapters/retention/root_verification.rs create mode 100644 src/adapters/verification_admission.rs create mode 100644 src/adapters/verification_error.rs create mode 100644 src/verification/observation.rs create mode 100644 tests/blob_verification.rs create mode 100644 tests/blob_verification/profile_law.rs create mode 100644 tests/blob_verification/refusal_laws.rs create mode 100644 tests/blob_verification/root_laws.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6687f917..9a735d5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ after its public API and format compatibility policies are established. ## [Unreleased] +- Added catalog-bound complete-blob and admitted-retention-root verification reports, preserving exact missing members, identity/profile contradictions, resource failures, and original typed causes (#114). + - Added allocation-free verification reports for already-admitted physical segments and logical chunk/layout records, with explicit subject-specific supported depths; full durable verification remains in progress under #114. - Admitted catalog snapshots can report explicit framing, checksum, or catalog-reachability evidence with immutable subject coordinates; unsupported requests refuse without claiming blob completeness or retention closure (#114, initial report slice). diff --git a/docs/audits/114-durable-verification-scope.md b/docs/audits/114-durable-verification-scope.md index 7c42b953..2bd8526e 100644 --- a/docs/audits/114-durable-verification-scope.md +++ b/docs/audits/114-durable-verification-scope.md @@ -38,7 +38,7 @@ These are inspected implementation boundaries, not new execution receipts. ## Closure ledger -Catalog, segment and logical-record reporting now have [runtime and static/API evidence](../testing-evidence/durable-verification.md); every full-issue obligation below remains open until its entire exit condition is met. +Catalog, segment, logical-record, complete-blob and admitted-root reporting now have [runtime and static/API evidence](../testing-evidence/durable-verification.md); every full-issue obligation below remains open until its entire exit condition is met. | Obligation | Required implementation boundary | Concrete exit condition | | --- | --- | --- | diff --git a/docs/invariants/verification/README.md b/docs/invariants/verification/README.md index 474748e3..fcf63749 100644 --- a/docs/invariants/verification/README.md +++ b/docs/invariants/verification/README.md @@ -19,15 +19,17 @@ Reports contain no plaintext, keys or filesystem paths and convey no publication | `AdmittedSegment::verify` | Exact physical segment digest | `Framing`, `Checksum` | The admitted immutable segment's physical representation; logical claims require record-specific reports. | | `AdmittedSegmentRecord::verify` for a chunk | Exact `ChunkId` | `Framing`, `Checksum`, `ChunkIdentity` | That record's complete chunk bytes; no blob or profile-boundary claim. | | `AdmittedSegmentRecord::verify` for a layout | Exact `LayoutId` | `Framing`, `Checksum`, `LayoutIdentity` | Canonical layout bytes and identity; no requirement that referenced chunks exist. | +| `CatalogSnapshot::verify_blob` | Named logical blob in one catalog | `Framing`, `Checksum`, `ChunkIdentity`, `LayoutIdentity`, `CompleteBlobIdentity` | Canonical layout discovery; chunk presence at chunk depth; full profile replay and logical hash at complete depth. | +| `AdmittedRetentionRoot::verify` | Exact namespace, root generation and root digest | `Framing`, `Checksum`, `RetentionClosure` | Closure depth checks every anchor against the supplied catalog and its admitted limits. | | `CatalogSnapshot::verify` | Selected catalog generation and digest | `Framing`, `Checksum`, `CatalogReachability` | Exact catalog-to-record bindings; no complete logical or retained closure claim. | -Every other depth returns `VerificationRefusal::Unsupported` with the exact subject, request and supported set; the operation neither downgrades the request nor returns a success report. +Every other depth returns `VerificationRefusal::Unsupported` (wrapped by `VerificationError` for blob/root operations) with the exact subject, request and supported set; the operation neither downgrades the request nor returns a success report. `SnapshotBinding` remains unsupported until its separate protocol exists; catalog/retention coordinates must not be mislabeled as that future proof. ## Costs and admission boundary -Each current reporting call is constant time, allocates no heap memory, performs no I/O, does not block, and neither mutates nor synchronizes storage. +Reporting physical segment, logical record and catalog evidence is constant time and allocation-free; shallow root reporting has the same costs. Blob discovery decodes catalogued layouts in canonical identity order, retaining at most one decoded layout at a time. Chunk verification looks up every referenced member; complete blob verification additionally streams every selected byte through profile replay and complete identity calculation. Retention closure uses its existing checked limits, an ordered member index bounded by the root node limit, and one decoded layout at a time. These operations perform no I/O, mutate no persistent bytes, and synchronize nothing. These costs exclude prerequisite admission: segment admission verifies all records, checksums and identities with bounded duplicate-detection allocation; layout record admission may allocate bounded layout metadata; catalog admission binds its entries to admitted segment records. @@ -37,8 +39,16 @@ Records prepared for publication provide the same logical proof over their canon ## Remaining durable contract -Raw durable loading-to-report failure classification, missing/corrupt/ambiguous/operational outcomes with preserved causes, complete-blob and retention reports, aggregate per-subject reports, and the catalog-ceiling memory campaign remain required by the [closure ledger](../../audits/114-durable-verification-scope.md). +Raw durable loading-to-report failure classification, published-retention namespace selection, aggregate per-subject reports, and the catalog-ceiling memory campaign remain required by the [closure ledger](../../audits/114-durable-verification-scope.md). No serialization, repair, quarantine, GC execution or new durable report format is introduced here. [Evidence and calibration](../../testing-evidence/durable-verification.md) distinguish runtime laws, static/API restrictions and unimplemented acceptance obligations. + +## Logical refusal contract + +Blob and root verification distinguish missing catalog members, demonstrated contradictions, unsupported requests, and operational failures without returning partial reports. Original layout or closure causes retain their typed coordinates. Resource exhaustion is operational, not evidence of corruption. + +The report preserves the catalog generation/digest used by catalog, blob and root operations; this provenance is not a live fence. Multiple valid layouts for a blob are representations, not automatically ambiguity: discovery selects the first canonical identity. + +The refusal vocabulary contains bounded conflicting candidates, but the admitted immutable-view operations do not manufacture an ambiguity outcome merely to exercise that variant; durable observation classification remains part of the unfinished contract. diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index 9ed5d450..d3ecb1fd 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -100,3 +100,37 @@ The execution receipts are `segment-first-check.log`, `segment-release-related-v Per-test resource enforcement remains the previously disclosed repository gap; allocation assertions measure reporting after admission, not total admission memory or process RSS. No existing runtime expectations were changed or tests removed, and the full durable failure/retention/aggregate and memory-ceiling obligations remain open. + +## Complete blob and admitted-root evidence + +Change kind: new feature, based on `d65c845a05a96bdf5f724c79f4a0aa211fdb1ca9`; these APIs did not exist on the parent, so a parent compile failure is not claimed as behavioral RED. + +The public `blob_verification` suite checks requested and achieved evidence, exact catalog provenance, canonical root coordinates, incomplete closure, absent blobs, contradictory complete blob identities, profile replay, unsupported-depth refusal, and resource-failure classification with original typed causes. + +The specified oracles are the subject/depth contract, the frozen one-zero layout, a one-one logical target paired with one-zero chunk bytes, and the permanent `profile-boundary-mismatch` corpus witness whose required/replayed boundary lengths are 262143 and 262144. + +All law bodies are small in-memory experiments; they use admitted segment/catalog records and real verification operations, with no sleeps, random schedule, ambient filesystem, network, clock or product mutation. Resource-ceiling enforcement remains the previously disclosed repository gap; classification tests are not a claim that a per-test sandbox has been implemented. + +| Protected claim | Calibration | Observed runtime failure | +| --- | --- | --- | +| Complete logical closure must execute. | Bypass blob closure while retaining callable production code. | Missing-chunk, wrong-identity and false-profile laws refuse the unexpected success. | +| Root closure must execute before certification. | Skip root closure verification. | Missing-layout and resource-limit laws refuse the unexpected success. | +| Each subject admits only its supported depths. | Disable blob/root policy guards independently. | Exact unsupported-depth laws fail. | +| Reports retain catalog provenance. | Clear the catalog coordinate during report construction. | Blob/root report assertions fail with `None` instead of the exact coordinate. | +| Achieved depth cannot be escalated. | Set achieved depth to `SnapshotBinding`. | Blob/root and shallow-evidence assertions fail. | +| Expected and observed identities are not interchangeable. | Swap the identity coordinates in corruption mapping. | Exact complete-blob contradiction assertion fails. | +| The original typed cause survives classification. | Remove the closure cause from refusals. | Missing-chunk, complete-blob and profile-source assertions fail. | +| Resource refusal is not corruption. | Classify the closure limit as a content contradiction. | The operational-limit law fails. | +| Correct bytes still require registered profile replay. | Bypass feed/finish checks while preserving complete hashing. | The permanent false-profile law fails. | + +The calibration ran in a separate copied Docker source tree, restoring the changed file after each isolated mutation and cleaning the Keep package's artifacts before recompilation; dependencies were reusable, while changed Keep artifacts were not accepted from cache. Every listed RED compiled and failed in the named runtime law. The candidate source was never mutated. + +The first calibration script stopped after a valid blob-closure runtime RED because `rg` was unavailable inside the container; the corrected remainder used `grep`. Both logs and the first valid RED are preserved. Initial empty-catalog setup incorrectly supplied an unreferenced empty segment and failed before verification; its corrected fixture supplies no segments. A separate wrong test-module path failed module resolution and is also excluded from product RED evidence. + +Raw receipts include `closure-laws-first.log`, `closure-laws-corrected.log`, `closure-clippy-corrected.log`, `closure-laws-complete.log`, `closure-laws-complete-corrected.log`, `closure-calibration.log`, `closure-calibration-remainder.log`, and each changed source, original source, build output and exit code under `closure-mutants`. + +Replay the focused suite with `cargo test --locked --test blob_verification` and its `--release` counterpart in the copied Linux arm64 Rust 1.96.0 Docker environment. Related catalog, segment and closure laws remain unchanged; no tests were deleted or prior success expectations weakened. + +This evidence establishes operations over admitted immutable views, not raw filesystem failure classification, publication-selected retention, aggregate reports, the catalog-ceiling memory bound, process-death recovery, or final exact-head review and CI for the complete #114 candidate. Those remaining obligations are still open and implementation continues within the same PR. + +After restoring the candidate, the blob, catalog, segment and existing retention-closure suites passed in debug and release, and all ordinary and compile-fail doctests passed. The first post-calibration command then stopped at the nonexistent `source-check` alias; the corrected `cargo xtask source-structure-check` and formatting check passed separately. All-target/all-feature Clippy with warnings denied passed after the focused suite. The corrected structure receipt is `closure-structure-corrected.log`; these local receipts do not replace the full validation chain on the final candidate. diff --git a/src/adapters/admitted_catalog.rs b/src/adapters/admitted_catalog.rs index 51af0f55..b74ed376 100644 --- a/src/adapters/admitted_catalog.rs +++ b/src/adapters/admitted_catalog.rs @@ -74,6 +74,13 @@ impl<'catalog, 'records> AdmittedCatalog<'catalog, 'records> { catalog_transition::validate(self, candidate) } + pub(super) fn records(&self) -> impl Iterator> + '_ { + self.records + .iter() + .copied() + .map(CatalogRecordBinding::record) + } + pub(super) const fn from_verified_parts( catalog: ChecksummedCatalog<'catalog>, records: Vec>, diff --git a/src/adapters/blob_verification.rs b/src/adapters/blob_verification.rs new file mode 100644 index 00000000..2020cb55 --- /dev/null +++ b/src/adapters/blob_verification.rs @@ -0,0 +1,141 @@ +//! This module owns blob evidence over an immutable admitted catalog. +#![expect( + clippy::result_large_err, + reason = "bounded full diagnostic coordinates remain inline instead of adding refusal allocations" +)] + +use super::{VerificationError, retention, verification_admission}; +use crate::profile::StorageProfileVerifier; +use crate::{ + AdmittedLayout, BlobHasher, BlobId, CatalogSnapshot, LayoutDecodePolicy, LayoutEntryLimit, + LayoutId, RetentionClosureVerificationError as Closure, SegmentRecordIdentity, + VerificationDepth, VerificationRefusal, VerificationReport, VerificationSubject, +}; + +const SUPPORTED: &[VerificationDepth] = &[ + VerificationDepth::Framing, + VerificationDepth::Checksum, + VerificationDepth::ChunkIdentity, + VerificationDepth::LayoutIdentity, + VerificationDepth::CompleteBlobIdentity, +]; + +impl CatalogSnapshot<'_, '_, '_> { + /// Verifies the first canonical catalogued layout naming `blob`. + /// + /// Multiple valid layouts are representations, not conflicting evidence. + /// Selection follows canonical layout identity order. Discovery scans and + /// decodes catalogued layouts one at a time; peak additional memory is one + /// bounded decoded layout. No plaintext buffer, I/O, mutation or writer + /// authority is required. Catalog admission already checked record framing, + /// checksums and logical identities, including for shallow requests. + /// Chunk depth requires every selected member; complete depth additionally + /// streams all bytes through profile replay and the complete blob hash. + /// Work is linear in discovered layout bytes, plus selected blob bytes for + /// complete depth, with logarithmic catalog lookup per selected chunk. + /// + /// # Errors + /// + /// Returns exact missing, corrupt, unsupported or operational outcomes; + /// no error returns a shallower report. The report describes this immutable + /// view's evidence and grants no publication or retention authority. + pub fn verify_blob( + &self, + blob: BlobId, + requested: VerificationDepth, + ) -> Result { + let subject = VerificationSubject::Blob { identity: blob }; + if !SUPPORTED.contains(&requested) { + return Err(VerificationRefusal::Unsupported { + subject, + requested, + supported: SUPPORTED, + } + .into()); + } + let (identity, layout) = self.find_verification_layout(blob)?; + match requested { + VerificationDepth::ChunkIdentity => require_chunks(self, &layout), + VerificationDepth::CompleteBlobIdentity => verify_complete(self, identity, &layout), + _ => Ok(()), + } + .map_err(|source| verification_admission::closure(subject, source))?; + Ok(VerificationReport::established(subject, requested) + .in_catalog(self.generation(), self.catalog_digest())) + } + + fn find_verification_layout( + &self, + blob: BlobId, + ) -> Result<(LayoutId, AdmittedLayout), VerificationError> { + for record in self.records() { + let SegmentRecordIdentity::Layout(identity) = record.identity() else { + continue; + }; + let policy = + LayoutDecodePolicy::new(LayoutEntryLimit::MAXIMUM).with_expected_id(identity); + let layout = + AdmittedLayout::decode_record(record.payload(), policy).map_err(|source| { + verification_admission::layout(VerificationSubject::Layout { identity }, source) + })?; + if layout.target() == blob { + return Ok((identity, layout)); + } + } + Err(VerificationRefusal::Missing { + subject: VerificationSubject::Blob { identity: blob }, + } + .into()) + } +} + +fn require_chunks( + catalog: &CatalogSnapshot<'_, '_, '_>, + layout: &AdmittedLayout, +) -> Result<(), Closure> { + for entry in layout.entries() { + let identity = SegmentRecordIdentity::Chunk(entry.chunk_id()); + let _record = catalog + .record(identity) + .ok_or(Closure::MissingMember { identity })?; + } + Ok(()) +} + +fn verify_complete( + catalog: &CatalogSnapshot<'_, '_, '_>, + identity: LayoutId, + layout: &AdmittedLayout, +) -> Result<(), Closure> { + let mut profile = StorageProfileVerifier::new(layout) + .map_err(|source| retention::profile_verification_refusal(identity, source))?; + let mut hasher = BlobHasher::new(); + for entry in layout.entries() { + let member = SegmentRecordIdentity::Chunk(entry.chunk_id()); + let record = catalog + .record(member) + .ok_or(Closure::MissingMember { identity: member })?; + profile + .feed(record.payload()) + .map_err(|source| retention::profile_verification_refusal(identity, source))?; + hasher + .update(record.payload()) + .map_err(|source| Closure::BlobHash { + layout: identity, + source, + })?; + } + profile + .finish() + .map_err(|source| retention::profile_verification_refusal(identity, source))?; + let expected = layout.target(); + let observed = hasher.finish(); + if expected != observed { + return Err(Closure::BlobIdentityMismatch { + layout: identity, + expected, + observed, + }); + } + Ok(()) +} diff --git a/src/adapters/catalog_snapshot.rs b/src/adapters/catalog_snapshot.rs index 98991fe1..22d1cddc 100644 --- a/src/adapters/catalog_snapshot.rs +++ b/src/adapters/catalog_snapshot.rs @@ -52,6 +52,10 @@ impl<'head, 'catalog, 'records> CatalogSnapshot<'head, 'catalog, 'records> { self.catalog.record(identity) } + pub(super) fn records(&self) -> impl Iterator> + '_ { + self.catalog.records() + } + pub(super) const fn new( head: ChecksummedPublicationHead<'head>, catalog: AdmittedCatalog<'catalog, 'records>, diff --git a/src/adapters/catalog_verification.rs b/src/adapters/catalog_verification.rs index eb75d99d..636920e0 100644 --- a/src/adapters/catalog_verification.rs +++ b/src/adapters/catalog_verification.rs @@ -1,4 +1,8 @@ //! This module owns evidence reporting over an already admitted catalog view. +#![expect( + clippy::result_large_err, + reason = "bounded full diagnostic coordinates remain inline instead of adding refusal allocations" +)] use super::CatalogSnapshot; use crate::{VerificationDepth, VerificationRefusal, VerificationReport, VerificationSubject}; @@ -43,6 +47,7 @@ impl CatalogSnapshot<'_, '_, '_> { supported: SUPPORTED, }); } - Ok(VerificationReport::established(subject, requested)) + Ok(VerificationReport::established(subject, requested) + .in_catalog(self.generation(), self.catalog_digest())) } } diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 627dcaca..d63c757c 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -14,6 +14,7 @@ mod blob_id_binary; mod blob_id_binary_error; mod blob_id_text; mod blob_id_text_error; +mod blob_verification; mod canonical_catalog; mod canonical_publication_head; mod catalog_admission; @@ -232,6 +233,8 @@ mod sync_capable_directory; #[cfg(test)] #[path = "../../tests/support/mod.rs"] mod test_support; +mod verification_admission; +mod verification_error; mod writer_lock_acquire_error; mod writer_lock_acquire_phase; @@ -241,3 +244,5 @@ use catalog_encoding_entry::CatalogEncodingEntry; use catalog_entries::CatalogEntries; use catalog_record_binding::CatalogRecordBinding; use decoded_catalog_entry::DecodedCatalogEntry; + +pub use verification_error::{VerificationError, VerificationSource}; diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 54f5afc0..77ea6f7e 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -164,6 +164,7 @@ mod root_field_decoder; mod root_header_decoder; mod root_integrity; mod root_semantic_header; +mod root_verification; mod successor_manifest; mod transition_disposition; mod transition_error; @@ -274,3 +275,5 @@ pub use transition_preflight::{RetentionTransitionPreflight, preflight_retention pub use transition_preflight_error::RetentionTransitionPreflightError; pub use transition_readiness::RetentionTransitionReadiness; pub use verified_closure::VerifiedRetentionClosure; + +pub(super) use closure_profile_error::map as profile_verification_refusal; diff --git a/src/adapters/retention/closure_profile_error.rs b/src/adapters/retention/closure_profile_error.rs index 5e4d2867..647b77c1 100644 --- a/src/adapters/retention/closure_profile_error.rs +++ b/src/adapters/retention/closure_profile_error.rs @@ -3,7 +3,11 @@ use crate::profile::StorageProfileVerificationError; use crate::{LayoutId, RetentionClosureVerificationError}; -pub(super) const fn map( +#[expect( + clippy::redundant_pub_crate, + reason = "the sibling verification adapter shares this lossless profile mapping" +)] +pub(crate) const fn map( layout: LayoutId, error: StorageProfileVerificationError, ) -> RetentionClosureVerificationError { diff --git a/src/adapters/retention/root_verification.rs b/src/adapters/retention/root_verification.rs new file mode 100644 index 00000000..27afbb61 --- /dev/null +++ b/src/adapters/retention/root_verification.rs @@ -0,0 +1,62 @@ +//! This module owns evidence reports for an admitted retention root. +#![expect( + clippy::result_large_err, + reason = "bounded full diagnostic coordinates remain inline instead of adding refusal allocations" +)] + +use super::{AdmittedRetentionRoot, verify_retention_closure}; +use crate::adapters::verification_admission; +use crate::{ + CatalogSnapshot, VerificationDepth, VerificationError, VerificationRefusal, VerificationReport, + VerificationSubject, +}; + +const SUPPORTED: &[VerificationDepth] = &[ + VerificationDepth::Framing, + VerificationDepth::Checksum, + VerificationDepth::RetentionClosure, +]; + +impl AdmittedRetentionRoot<'_> { + /// Verifies this exact root at the requested depth against `catalog`. + /// + /// Framing/checksum reporting is constant-time and allocation-free over + /// already admitted canonical bytes. Closure verification visits every + /// anchor, requires catalog members, replays storage profiles and hashes + /// complete blobs. Its checked node, depth, encoded and physical byte + /// limits are those admitted from the root. It allocates an ordered index + /// bounded by the node limit and one decoded layout at a time. No I/O, + /// mutation, repair, publication or retention authority is performed. + /// + /// # Errors + /// + /// Missing members, contradictory content, unsupported depths, and resource + /// or execution failures remain distinct; original typed causes survive. + /// A failed anchor prevents a report for the whole root. + pub fn verify( + &self, + catalog: &CatalogSnapshot<'_, '_, '_>, + requested: VerificationDepth, + ) -> Result { + let root = self.root(); + let subject = VerificationSubject::RetentionRoot { + namespace: root.namespace().digest(), + generation: root.generation(), + digest: self.digest(), + }; + if !SUPPORTED.contains(&requested) { + return Err(VerificationRefusal::Unsupported { + subject, + requested, + supported: SUPPORTED, + } + .into()); + } + if requested == VerificationDepth::RetentionClosure { + let _evidence = verify_retention_closure(root, catalog) + .map_err(|source| verification_admission::closure(subject, source))?; + } + Ok(VerificationReport::established(subject, requested) + .in_catalog(catalog.generation(), catalog.catalog_digest())) + } +} diff --git a/src/adapters/segment_record_verification.rs b/src/adapters/segment_record_verification.rs index c62867f3..d90f3422 100644 --- a/src/adapters/segment_record_verification.rs +++ b/src/adapters/segment_record_verification.rs @@ -1,4 +1,8 @@ //! This module owns subject-specific evidence from an admitted segment record. +#![expect( + clippy::result_large_err, + reason = "bounded full diagnostic coordinates remain inline instead of adding refusal allocations" +)] use super::{AdmittedSegmentRecord, SegmentRecordIdentity}; use crate::{VerificationDepth, VerificationRefusal, VerificationReport, VerificationSubject}; diff --git a/src/adapters/segment_verification.rs b/src/adapters/segment_verification.rs index a6e5cc73..cf451ee1 100644 --- a/src/adapters/segment_verification.rs +++ b/src/adapters/segment_verification.rs @@ -1,4 +1,8 @@ //! This module owns evidence reporting over an already admitted segment. +#![expect( + clippy::result_large_err, + reason = "bounded full diagnostic coordinates remain inline instead of adding refusal allocations" +)] use super::AdmittedSegment; use crate::{VerificationDepth, VerificationRefusal, VerificationReport, VerificationSubject}; diff --git a/src/adapters/verification_admission.rs b/src/adapters/verification_admission.rs new file mode 100644 index 00000000..69a7bc28 --- /dev/null +++ b/src/adapters/verification_admission.rs @@ -0,0 +1,92 @@ +//! This module owns lossless semantic classification of verification failures. + +use super::{VerificationError, VerificationSource}; +use crate::{ + LayoutDecodeError, RetentionClosureVerificationError as Closure, SegmentRecordIdentity, + VerificationObservation as Observation, VerificationRefusal, VerificationSubject, +}; + +pub(super) fn layout(subject: VerificationSubject, source: LayoutDecodeError) -> VerificationError { + let operational = matches!( + source, + LayoutDecodeError::Allocation { .. } + | LayoutDecodeError::EntryCountHostWidth { .. } + | LayoutDecodeError::HostRecordLengthOutOfRange { .. } + | LayoutDecodeError::ConfiguredEntryLimitExceeded { .. } + ); + let source = Box::new(VerificationSource::Layout(source)); + if operational { + return VerificationError::Operational { source }; + } + VerificationError::Refused { + refusal: structural(subject), + source: Some(source), + } +} + +pub(super) fn closure(subject: VerificationSubject, source: Closure) -> VerificationError { + let refusal = match &source { + Closure::MissingMember { identity } => VerificationRefusal::Missing { + subject: member(*identity), + }, + Closure::AnchorTargetMismatch { + expected, observed, .. + } + | Closure::BlobIdentityMismatch { + expected, observed, .. + } => VerificationRefusal::Corrupt { + subject, + expected: Observation::Blob(*expected), + observed: Observation::Blob(*observed), + }, + Closure::ProfileBoundaryMismatch { + expected, observed, .. + } => VerificationRefusal::Corrupt { + subject, + expected: Observation::ProfileBoundary(*expected), + observed: Observation::ProfileBoundary(*observed), + }, + Closure::LayoutDecode { .. } => structural(subject), + Closure::CounterOverflow { .. } + | Closure::LimitExceeded { .. } + | Closure::ProfileVerifierUnavailable { .. } + | Closure::ProfileChunking { .. } + | Closure::BlobHash { .. } => { + return VerificationError::Operational { + source: Box::new(VerificationSource::Closure(source)), + }; + } + }; + if let Closure::LayoutDecode { source: nested, .. } = &source + && matches!( + nested, + LayoutDecodeError::Allocation { .. } + | LayoutDecodeError::EntryCountHostWidth { .. } + | LayoutDecodeError::HostRecordLengthOutOfRange { .. } + | LayoutDecodeError::ConfiguredEntryLimitExceeded { .. } + ) + { + return VerificationError::Operational { + source: Box::new(VerificationSource::Closure(source)), + }; + } + VerificationError::Refused { + refusal, + source: Some(Box::new(VerificationSource::Closure(source))), + } +} + +const fn structural(subject: VerificationSubject) -> VerificationRefusal { + VerificationRefusal::Corrupt { + subject, + expected: Observation::Canonical, + observed: Observation::Refused, + } +} + +const fn member(identity: SegmentRecordIdentity) -> VerificationSubject { + match identity { + SegmentRecordIdentity::Chunk(identity) => VerificationSubject::Chunk { identity }, + SegmentRecordIdentity::Layout(identity) => VerificationSubject::Layout { identity }, + } +} diff --git a/src/adapters/verification_error.rs b/src/adapters/verification_error.rs new file mode 100644 index 00000000..b36f38b7 --- /dev/null +++ b/src/adapters/verification_error.rs @@ -0,0 +1,93 @@ +//! This module owns verification outcomes with preserved boundary causes. + +use std::error::Error; +use std::fmt; + +use crate::{ + BlobHashError, LayoutDecodeError, RetentionClosureVerificationError, VerificationRefusal, +}; + +/// Why an operation produced no verification report. +/// +/// Refusal is evidence about the supplied view. An operational failure proves +/// neither corruption nor absence. Error boxing bounds the successful result's +/// stack size; no payload bytes or filesystem paths are copied into a report. +#[derive(Debug)] +#[non_exhaustive] +pub enum VerificationError { + /// A precise content or policy refusal, optionally with its original cause. + Refused { + /// Bounded semantic outcome. + refusal: VerificationRefusal, + /// Original boundary cause, without string conversion. + source: Option>, + }, + /// Verification could not finish; no partial report is returned. + Operational { + /// Original resource, capability, or execution failure. + source: Box, + }, +} + +/// Original typed causes retained by verification adapters. +#[derive(Debug)] +#[non_exhaustive] +pub enum VerificationSource { + /// Canonical layout decoding failed. + Layout(LayoutDecodeError), + /// Retention closure admission or its resource accounting failed. + Closure(RetentionClosureVerificationError), + /// Complete blob hashing failed. + BlobHash(BlobHashError), +} + +impl From for VerificationError { + fn from(refusal: VerificationRefusal) -> Self { + Self::Refused { + refusal, + source: None, + } + } +} + +impl fmt::Display for VerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Refused { refusal, .. } => refusal.fmt(formatter), + Self::Operational { .. } => formatter.write_str("verification could not complete"), + } + } +} + +impl Error for VerificationError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Refused { + source: Some(source), + .. + } + | Self::Operational { source } => Some(source), + Self::Refused { source: None, .. } => None, + } + } +} + +impl fmt::Display for VerificationSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Layout(source) => source.fmt(formatter), + Self::Closure(source) => source.fmt(formatter), + Self::BlobHash(source) => source.fmt(formatter), + } + } +} + +impl Error for VerificationSource { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Layout(source) => Some(source), + Self::Closure(source) => Some(source), + Self::BlobHash(source) => Some(source), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 441dac60..b3223ef3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -169,6 +169,7 @@ pub use adapters::{ StoreMigrationRecoveryReceipt, StoreMigrationRecoveryStorage, StoreMigrationResidue, StoreMigrationStageDecodeError, plan_store_migration_recovery, recover_store_migration, }; +pub use adapters::{VerificationError, VerificationSource}; pub use blob::{ BlobHashError, BlobHasher, BlobId, BlobLength, BlobReadError, ByteLength, ByteOffset, ByteRange, ByteRangeError, @@ -201,6 +202,6 @@ pub use retention::{ RootGeneration, RootGenerationError, }; pub use verification::{ - VerificationDepth, VerificationRefusal, VerificationReport, VerificationSubject, - VerifiedSubject, + VerificationDepth, VerificationObservation, VerificationRefusal, VerificationReport, + VerificationSubject, VerifiedSubject, }; diff --git a/src/verification.rs b/src/verification.rs index 33c91264..fdde63e3 100644 --- a/src/verification.rs +++ b/src/verification.rs @@ -4,11 +4,13 @@ //! infer a different subject's properties, publication, or retention authority. mod depth; +mod observation; mod refusal; mod report; mod subject; pub use depth::VerificationDepth; +pub use observation::VerificationObservation; pub use refusal::VerificationRefusal; pub use report::{VerificationReport, VerifiedSubject}; pub use subject::VerificationSubject; diff --git a/src/verification/observation.rs b/src/verification/observation.rs new file mode 100644 index 00000000..0eda5021 --- /dev/null +++ b/src/verification/observation.rs @@ -0,0 +1,20 @@ +//! This module owns bounded coordinates for verification contradictions. + +use crate::{BlobId, ProfileBoundary}; + +/// A required or observed fact at a verification boundary. +/// +/// Structural failures retain their precise typed decoder cause in the adapter +/// error; these predicates never replace that cause with a parsed message. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum VerificationObservation { + /// Complete logical byte identity. + Blob(BlobId), + /// One expected or replayed profile boundary; absence is meaningful. + ProfileBoundary(Option), + /// Canonical bytes satisfying the boundary's admission contract. + Canonical, + /// Bytes refused by the preserved typed decoder or admission error. + Refused, +} diff --git a/src/verification/rationale.md b/src/verification/rationale.md index 3d4294e0..7d881711 100644 --- a/src/verification/rationale.md +++ b/src/verification/rationale.md @@ -29,3 +29,9 @@ Segment reports certify only physical framing/checksum evidence; chunk and layou Logical-record reports use their admitted immutable payload evidence without claiming catalog membership or publication, including records prepared for writing but not persisted. The supported-depth matrix is explicit per subject, and reporting never uses enum ordering to accept a request. + +Logical verification now names its exact catalog provenance and constructs a report only after the requested work succeeds; no failing anchor can return a root-closure report. + +Blob discovery uses canonical layout order, matching the distinction between multiple lawful representations and conflicting evidence; it holds only one decoded layout at a time rather than constructing an additional whole-catalog index. + +The semantic refusal keeps bounded coordinates inline; narrowly scoped Clippy expectations preserve precise diagnostics without introducing extra allocation solely to reduce an error enum's stack footprint. Original adapter causes are boxed only on error and remain typed. diff --git a/src/verification/refusal.rs b/src/verification/refusal.rs index 450c9828..2dd386ea 100644 --- a/src/verification/refusal.rs +++ b/src/verification/refusal.rs @@ -3,12 +3,32 @@ use std::error::Error; use std::fmt; -use super::{VerificationDepth, VerificationSubject}; +use super::{VerificationDepth, VerificationObservation, VerificationSubject}; /// Why a verification request cannot establish its requested evidence. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum VerificationRefusal { + /// Required evidence is absent from an admitted immutable view. + Missing { + /// Exact absent logical subject. + subject: VerificationSubject, + }, + /// Present evidence contradicts the requirement at this boundary. + Corrupt { + /// Subject whose evidence failed admission. + subject: VerificationSubject, + /// Required coordinate or structural predicate. + expected: VerificationObservation, + /// Observed coordinate or failed predicate. + observed: VerificationObservation, + }, + /// Conflicting observations prevent selection of one admissible view. + Ambiguous { + /// Bounded pair of conflicting observations, not an exhaustive inventory. + candidates: [VerificationSubject; 2], + }, + /// The operation cannot establish this depth for the requested subject. Unsupported { /// Subject the caller asked to verify. @@ -23,6 +43,12 @@ pub enum VerificationRefusal { impl fmt::Display for VerificationRefusal { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Missing { .. } => formatter.write_str("required verification evidence is absent"), + Self::Corrupt { .. } => { + formatter.write_str("verification evidence contradicts its contract") + } + Self::Ambiguous { .. } => formatter.write_str("verification observations conflict"), + Self::Unsupported { requested, .. } => { write!( formatter, diff --git a/src/verification/report.rs b/src/verification/report.rs index 88a005d4..a455fd3c 100644 --- a/src/verification/report.rs +++ b/src/verification/report.rs @@ -1,6 +1,7 @@ //! This module owns read-only reports constructed after evidence admission. use super::{VerificationDepth, VerificationSubject}; +use crate::{CatalogDigest, CatalogGeneration}; /// Evidence established for one subject, with no public construction or upgrade. /// @@ -62,6 +63,7 @@ impl VerifiedSubject { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct VerificationReport { requested: VerificationDepth, + catalog: Option<(CatalogGeneration, CatalogDigest)>, subject: VerifiedSubject, } @@ -76,12 +78,29 @@ impl VerificationReport { std::slice::from_ref(&self.subject) } + /// Returns the exact immutable catalog used by an operation, when applicable. + /// + /// This coordinate is evidence provenance, not a live fence or retention grant. + pub const fn catalog(&self) -> Option<(CatalogGeneration, CatalogDigest)> { + self.catalog + } + + pub(crate) const fn in_catalog( + mut self, + generation: CatalogGeneration, + digest: CatalogDigest, + ) -> Self { + self.catalog = Some((generation, digest)); + self + } + pub(crate) const fn established( subject: VerificationSubject, depth: VerificationDepth, ) -> Self { Self { requested: depth, + catalog: None, subject: VerifiedSubject { subject, depth }, } } diff --git a/src/verification/subject.rs b/src/verification/subject.rs index ea8cbd05..d9019263 100644 --- a/src/verification/subject.rs +++ b/src/verification/subject.rs @@ -1,7 +1,10 @@ //! This module owns the coordinates naming a verification subject. use crate::segment_digest::SegmentDigest; -use crate::{CatalogDigest, CatalogGeneration, ChunkId, LayoutId}; +use crate::{ + BlobId, CatalogDigest, CatalogGeneration, ChunkId, LayoutId, RetentionNamespaceDigest, + RetentionRootDigest, RootGeneration, +}; /// Subject to which a verification claim or refusal applies. /// @@ -10,6 +13,21 @@ use crate::{CatalogDigest, CatalogGeneration, ChunkId, LayoutId}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum VerificationSubject { + /// A logical blob requested independently of its physical realization. + Blob { + /// Complete logical byte identity. + identity: BlobId, + }, + /// An exact canonical retention root, without publication authority. + RetentionRoot { + /// Namespace authenticated by the root bytes. + namespace: RetentionNamespaceDigest, + /// Root generation authenticated by those same bytes. + generation: RootGeneration, + /// Exact canonical root digest. + digest: RetentionRootDigest, + }, + /// One exact immutable segment, without a publication or retention claim. Segment { /// Verified physical segment digest. diff --git a/tests/blob_verification.rs b/tests/blob_verification.rs new file mode 100644 index 00000000..58d9d126 --- /dev/null +++ b/tests/blob_verification.rs @@ -0,0 +1,113 @@ +//! Public laws for evidence established by complete logical verification. + +#[path = "retention_closure/memory_stage.rs"] +mod memory_stage; +#[path = "blob_verification/refusal_laws.rs"] +mod refusal_laws; +#[path = "blob_verification/root_laws.rs"] +mod root_laws; +mod support; + +use keep::{ + AdmittedLayout, AdmittedSegment, AdmittedSegmentRecord, CanonicalCatalog, + CanonicalPublicationHead, CatalogGeneration, CatalogSnapshot, ChecksummedPublicationHead, + LayoutDecodePolicy, LayoutEntryLimit, SegmentReadPolicy, VerificationDepth as Depth, + VerificationSubject as Subject, +}; +use std::error::Error; + +// Size: small. Oracle: frozen one-zero layout and bytes; explicit supported +// depth vocabulary. Delete when the complete-blob reporting contract is retired. +#[test] +fn blob_reports_certify_only_the_requested_evidence_in_one_catalog() -> Result<(), Box> { + let layout = one_zero()?; + let canonical = layout.encode_record()?; + let records = [ + AdmittedSegmentRecord::for_chunk(&[0])?, + AdmittedSegmentRecord::for_layout(&canonical)?, + ]; + with_records(&records, |catalog| { + for depth in [ + Depth::Framing, + Depth::Checksum, + Depth::ChunkIdentity, + Depth::LayoutIdentity, + Depth::CompleteBlobIdentity, + ] { + let report = catalog.verify_blob(layout.target(), depth)?; + assert_eq!(report.requested(), depth, "original request must survive"); + assert_eq!( + report.catalog(), + Some((catalog.generation(), catalog.catalog_digest())), + "evidence must bind the exact catalog" + ); + let claims: Vec<_> = report + .subjects() + .iter() + .map(|entry| (entry.subject(), entry.depth())) + .collect(); + assert_eq!( + claims, + [( + Subject::Blob { + identity: layout.target() + }, + depth + )], + "one exact logical subject must be certified at the requested depth" + ); + } + Ok(()) + }) +} + +// Size: small. Oracle: published catalog bindings can exist without closure. +// Delete if layout admission deliberately begins requiring complete chunk closure. +#[test] +fn shallow_blob_evidence_does_not_require_unclaimed_chunk_closure() -> Result<(), Box> { + let layout = one_zero()?; + let canonical = layout.encode_record()?; + with_records( + &[AdmittedSegmentRecord::for_layout(&canonical)?], + |catalog| { + for depth in [Depth::Framing, Depth::Checksum, Depth::LayoutIdentity] { + let report = catalog.verify_blob(layout.target(), depth)?; + assert_eq!( + report.subjects().first().ok_or("missing claim")?.depth(), + depth, + "shallow evidence must not be escalated" + ); + } + Ok(()) + }, + ) +} + +fn one_zero() -> Result> { + Ok(AdmittedLayout::decode_record( + &support::layout_record_bytes("one-zero")?, + LayoutDecodePolicy::new(LayoutEntryLimit::MAXIMUM), + )?) +} + +fn with_records( + records: &[AdmittedSegmentRecord<'_>], + operation: impl FnOnce(&CatalogSnapshot<'_, '_, '_>) -> Result<(), Box>, +) -> Result<(), Box> { + let bytes = memory_stage::segment_bytes(records)?; + let segments = if records.is_empty() { + Vec::new() + } else { + vec![AdmittedSegment::decode(&bytes, SegmentReadPolicy::MAXIMUM)?] + }; + let catalog = CanonicalCatalog::from_segments(CatalogGeneration::new(1)?, None, &segments)?; + let head = CanonicalPublicationHead::for_catalog(catalog.checksummed()); + let snapshot = ChecksummedPublicationHead::decode(head.encoded())? + .admit(catalog.checksummed().admit(&segments)?)?; + operation(&snapshot) +} + +#[path = "layout_mutations/support.rs"] +mod layout_mutation_support; +#[path = "blob_verification/profile_law.rs"] +mod profile_law; diff --git a/tests/blob_verification/profile_law.rs b/tests/blob_verification/profile_law.rs new file mode 100644 index 00000000..3f0c19ec --- /dev/null +++ b/tests/blob_verification/profile_law.rs @@ -0,0 +1,64 @@ +//! Registered storage-profile evidence must be replayed before certification. + +use super::{support::require_error, with_records}; +use keep::{ + AdmittedLayout, AdmittedSegmentRecord, LayoutDecodePolicy, LayoutEntryLimit, + RetentionClosureVerificationError as Closure, VerificationDepth, VerificationError, + VerificationObservation, VerificationRefusal, VerificationSource, +}; +use std::error::Error; + +// Size: small. Oracle: retained canonical mutation corpus specifies a 262143 +// boundary where FAST_CDC_64K_V1 must emit 262144 for this all-zero stream. +// Delete when replaced by a generated profile-conformance law with this witness. +#[test] +fn correct_blob_bytes_do_not_certify_false_profile_boundaries() -> Result<(), Box> { + let mutation = super::layout_mutation_support::mutation_cases()? + .into_iter() + .find(|candidate| candidate.case() == "profile-boundary-mismatch") + .ok_or("profile witness absent")?; + let encoded = mutation.mutated_record()?; + let layout = AdmittedLayout::decode_record( + &encoded, + LayoutDecodePolicy::new(LayoutEntryLimit::MAXIMUM), + )?; + let canonical = layout.encode_record()?; + let first = vec![0_u8; 262_143]; + let last = [0_u8; 2]; + let records = [ + AdmittedSegmentRecord::for_chunk(&first)?, + AdmittedSegmentRecord::for_chunk(&last)?, + AdmittedSegmentRecord::for_layout(&canonical)?, + ]; + with_records(&records, |catalog| { + let error = require_error( + catalog.verify_blob(layout.target(), VerificationDepth::CompleteBlobIdentity), + "false profile certified", + )?; + let VerificationError::Refused { + refusal: + VerificationRefusal::Corrupt { + expected: VerificationObservation::ProfileBoundary(Some(expected)), + observed: VerificationObservation::ProfileBoundary(Some(observed)), + .. + }, + source: Some(source), + } = error + else { + return Err("profile contradiction lost its classification or source".into()); + }; + assert_eq!( + (expected.offset().get(), expected.length().get()), + (0, 262_143) + ); + assert_eq!( + (observed.offset().get(), observed.length().get()), + (0, 262_144) + ); + assert!( + matches!(*source, VerificationSource::Closure(Closure::ProfileBoundaryMismatch { layout, index: 0, expected: Some(source_expected), observed: Some(source_observed) }) if layout == canonical.id() && source_expected == expected && source_observed == observed), + "typed original boundary coordinates must survive" + ); + Ok(()) + }) +} diff --git a/tests/blob_verification/refusal_laws.rs b/tests/blob_verification/refusal_laws.rs new file mode 100644 index 00000000..16f14d93 --- /dev/null +++ b/tests/blob_verification/refusal_laws.rs @@ -0,0 +1,144 @@ +//! Missing, contradictory and unsupported blob evidence are distinct outcomes. + +use super::{one_zero, support::require_error, with_records}; +use keep::{ + AdmittedLayout, AdmittedSegmentRecord, BlobId, FastCdc, LayoutEntryLimit, + RegisteredStorageProfile, RetentionClosureVerificationError as Closure, SegmentRecordIdentity, + VerificationDepth as Depth, VerificationError, VerificationObservation as Observation, + VerificationRefusal as Refusal, VerificationSource, VerificationSubject as Subject, +}; +use std::error::Error; + +// Size: small. Oracle: exact missing identity from the frozen layout. +// Delete when superseded by a stronger generated closure law. +#[test] +fn incomplete_blob_refuses_the_exact_missing_chunk() -> Result<(), Box> { + let layout = one_zero()?; + let chunk = layout + .entries() + .first() + .ok_or("fixture has no entry")? + .chunk_id(); + let canonical = layout.encode_record()?; + with_records( + &[AdmittedSegmentRecord::for_layout(&canonical)?], + |catalog| { + for depth in [Depth::ChunkIdentity, Depth::CompleteBlobIdentity] { + let error = require_error( + catalog.verify_blob(layout.target(), depth), + "incomplete blob was certified", + )?; + let VerificationError::Refused { + refusal, + source: Some(source), + } = error + else { + return Err("missing chunk lost its source".into()); + }; + assert_eq!( + refusal, + Refusal::Missing { + subject: Subject::Chunk { identity: chunk } + } + ); + assert!( + matches!(*source, VerificationSource::Closure(Closure::MissingMember { identity: SegmentRecordIdentity::Chunk(observed) }) if observed == chunk), + "original exact missing-member cause must survive" + ); + } + Ok(()) + }, + ) +} + +// Size: small. Oracle: empty catalog contains no realization of a named blob. +// Delete when blob discovery changes its public absence contract. +#[test] +fn an_absent_blob_is_not_reported_as_corruption() -> Result<(), Box> { + let blob = BlobId::hash_bytes(&[0])?; + with_records(&[], |catalog| { + let error = require_error( + catalog.verify_blob(blob, Depth::CompleteBlobIdentity), + "absent blob certified", + )?; + assert!( + matches!(error, VerificationError::Refused { refusal: Refusal::Missing { subject: Subject::Blob { identity } }, source: None } if identity == blob), + "expected exact missing blob, got {error:?}" + ); + Ok(()) + }) +} + +// Size: small. Oracle: one-zero chunk bytes cannot realize the one-one BlobId. +// Delete when a stronger generated reconstruction mismatch law subsumes this case. +#[test] +fn complete_blob_verification_preserves_expected_and_observed_identities() +-> Result<(), Box> { + let expected = BlobId::hash_bytes(&[1])?; + let observed = BlobId::hash_bytes(&[0])?; + let mut spans = Vec::new(); + let mut detector = FastCdc::new(); + detector.feed(&[0], |span| spans.push(span))?; + spans.extend(detector.finish()?); + let layout = AdmittedLayout::from_spans( + expected, + RegisteredStorageProfile::FAST_CDC_64K_V1, + spans, + LayoutEntryLimit::MAXIMUM, + )?; + let canonical = layout.encode_record()?; + let records = [ + AdmittedSegmentRecord::for_chunk(&[0])?, + AdmittedSegmentRecord::for_layout(&canonical)?, + ]; + with_records(&records, |catalog| { + let error = require_error( + catalog.verify_blob(expected, Depth::CompleteBlobIdentity), + "wrong logical identity certified", + )?; + let VerificationError::Refused { + refusal, + source: Some(source), + } = error + else { + return Err("corruption lost its source".into()); + }; + assert_eq!( + refusal, + Refusal::Corrupt { + subject: Subject::Blob { identity: expected }, + expected: Observation::Blob(expected), + observed: Observation::Blob(observed) + } + ); + assert!( + matches!(*source, VerificationSource::Closure(Closure::BlobIdentityMismatch { layout, expected: actual_expected, observed: actual_observed }) if layout == canonical.id() && actual_expected == expected && actual_observed == observed), + "typed source must retain layout and both identities" + ); + Ok(()) + }) +} + +// Size: small. Oracle: blob verification does not establish publication/retention. +// Delete when the supported subject/depth contract intentionally changes. +#[test] +fn blob_verification_refuses_unrelated_depths_before_discovery() -> Result<(), Box> { + let blob = BlobId::hash_bytes(&[0])?; + with_records(&[], |catalog| { + for requested in [ + Depth::CatalogReachability, + Depth::RetentionClosure, + Depth::SnapshotBinding, + ] { + let error = require_error( + catalog.verify_blob(blob, requested), + "unsupported depth certified", + )?; + assert!( + matches!(error, VerificationError::Refused { refusal: Refusal::Unsupported { subject: Subject::Blob { identity }, requested: actual, supported }, source: None } if identity == blob && actual == requested && supported == [Depth::Framing, Depth::Checksum, Depth::ChunkIdentity, Depth::LayoutIdentity, Depth::CompleteBlobIdentity]), + "exact policy refusal required: {error:?}" + ); + } + Ok(()) + }) +} diff --git a/tests/blob_verification/root_laws.rs b/tests/blob_verification/root_laws.rs new file mode 100644 index 00000000..60eaffff --- /dev/null +++ b/tests/blob_verification/root_laws.rs @@ -0,0 +1,158 @@ +//! Root-report evidence is not manufactured from canonical framing alone. + +use super::{one_zero, support::require_error, with_records}; +use keep::{ + AdmittedRetentionRoot, AdmittedSegmentRecord, CanonicalRetentionRoot, + RegisteredRetentionProfile, RetentionAnchor, RetentionClosureLimits, + RetentionClosureVerificationError as Closure, RetentionNamespace, RetentionPolicy, + RetentionRoot, RootGeneration, VerificationDepth as Depth, VerificationError, + VerificationRefusal as Refusal, VerificationSource, VerificationSubject as Subject, +}; +use std::error::Error; + +// Size: small. Oracle: one-zero realization and exact canonical root/catalog. +// Delete if closure reports are removed or a stronger boundary law subsumes it. +#[test] +fn a_root_report_requires_complete_closure_in_its_named_catalog() -> Result<(), Box> { + let layout = one_zero()?; + let canonical = layout.encode_record()?; + let root = root(RetentionClosureLimits::new(2, 2, 220, 509)?)?; + let admitted = AdmittedRetentionRoot::decode(root.encoded())?; + let records = [ + AdmittedSegmentRecord::for_chunk(&[0])?, + AdmittedSegmentRecord::for_layout(&canonical)?, + ]; + with_records(&records, |catalog| { + for depth in [Depth::Framing, Depth::Checksum, Depth::RetentionClosure] { + let report = admitted.verify(catalog, depth)?; + assert_eq!(report.requested(), depth); + assert_eq!( + report.catalog(), + Some((catalog.generation(), catalog.catalog_digest())) + ); + let claims: Vec<_> = report + .subjects() + .iter() + .map(|entry| (entry.subject(), entry.depth())) + .collect(); + assert_eq!( + claims, + [( + Subject::RetentionRoot { + namespace: admitted.root().namespace().digest(), + generation: RootGeneration::new(1)?, + digest: root.digest() + }, + depth + )], + "only this exact root may receive the requested evidence" + ); + } + Ok(()) + }) +} + +// Size: small. Oracle: canonical root bytes do not imply available members. +// Delete if root admission itself gains full catalog closure as a contract. +#[test] +fn canonical_root_bytes_do_not_certify_an_absent_layout() -> Result<(), Box> { + let root = root(RetentionClosureLimits::new(2, 2, 220, 509)?)?; + let admitted = AdmittedRetentionRoot::decode(root.encoded())?; + let layout = one_zero()?.encode_record()?.id(); + with_records(&[], |catalog| { + let shallow = admitted.verify(catalog, Depth::Checksum)?; + assert_eq!( + shallow + .subjects() + .first() + .ok_or("missing root claim")? + .depth(), + Depth::Checksum + ); + let error = require_error( + admitted.verify(catalog, Depth::RetentionClosure), + "absent closure certified", + )?; + assert!( + matches!(error, VerificationError::Refused { refusal: Refusal::Missing { subject: Subject::Layout { identity } }, .. } if identity == layout), + "exact missing layout required: {error:?}" + ); + Ok(()) + }) +} + +// Size: small. Oracle: an admitted resource cap is not evidence of corruption. +// Delete if resource failures are replaced by a stronger typed contract. +#[test] +fn a_closure_budget_failure_is_operational_with_its_original_limit() -> Result<(), Box> { + let layout = one_zero()?.encode_record()?; + let root = root(RetentionClosureLimits::new(1, 2, 220, 509)?)?; + let admitted = AdmittedRetentionRoot::decode(root.encoded())?; + let records = [ + AdmittedSegmentRecord::for_chunk(&[0])?, + AdmittedSegmentRecord::for_layout(&layout)?, + ]; + with_records(&records, |catalog| { + let error = require_error( + admitted.verify(catalog, Depth::RetentionClosure), + "resource limit ignored", + )?; + let VerificationError::Operational { source } = error else { + return Err("resource refusal misclassified as content evidence".into()); + }; + assert!( + matches!( + *source, + VerificationSource::Closure(Closure::LimitExceeded { + counter: keep::RetentionClosureCounter::Nodes, + maximum: 1, + observed: 2 + }) + ), + "exact limit coordinates must survive" + ); + Ok(()) + }) +} + +fn root(limits: RetentionClosureLimits) -> Result> { + let layout = one_zero()?.encode_record()?; + let root = RetentionRoot::new( + RetentionNamespace::try_from(b"verification".as_slice())?, + RootGeneration::new(1)?, + RetentionPolicy::new( + RegisteredRetentionProfile::SINGLE_CANONICAL_WITNESS_V1, + limits, + ), + None, + vec![RetentionAnchor::new(one_zero()?.target(), layout.id())], + )?; + Ok(CanonicalRetentionRoot::from_root(&root)?) +} + +// Size: small. Oracle: root closure does not establish unrelated subject depths. +// Delete when this explicit supported-depth matrix is intentionally replaced. +#[test] +fn root_reports_refuse_unrelated_depths() -> Result<(), Box> { + let root = root(RetentionClosureLimits::new(2, 2, 220, 509)?)?; + let admitted = AdmittedRetentionRoot::decode(root.encoded())?; + with_records(&[], |catalog| { + for requested in [ + Depth::ChunkIdentity, + Depth::LayoutIdentity, + Depth::CompleteBlobIdentity, + Depth::CatalogReachability, + Depth::SnapshotBinding, + ] { + let error = require_error( + admitted.verify(catalog, requested), + "unsupported root evidence certified", + )?; + assert!( + matches!(error, VerificationError::Refused { refusal: Refusal::Unsupported { subject: Subject::RetentionRoot { namespace, generation, digest }, requested: actual, supported }, source: None } if namespace == admitted.root().namespace().digest() && generation == RootGeneration::new(1)? && digest == root.digest() && actual == requested && supported == [Depth::Framing, Depth::Checksum, Depth::RetentionClosure]), + "exact root policy refusal required: {error:?}" + ); + } + Ok(()) + }) +} From 6504c869a379a18db907aed79f7fa3a3b375d9e9 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 18:46:06 -0700 Subject: [PATCH 05/18] feat: preserve durable verification outcomes and view evidence (#114) --- CHANGELOG.md | 2 + docs/audits/114-durable-verification-scope.md | 12 + docs/invariants/verification/README.md | 36 ++- docs/invariants/verification/requirements.md | 2 +- docs/testing-evidence/durable-verification.md | 42 ++++ src/adapters/catalog_byte_verification.rs | 44 ++++ src/adapters/mod.rs | 7 + src/adapters/retention.rs | 12 + .../filesystem_retention_snapshot.rs | 61 +++-- .../filesystem_retention_verification.rs | 160 +++++++++++++ .../filesystem_verification_law_tests.rs | 195 ++++++++++++++++ .../retention/retention_view_collector.rs | 15 +- .../retention/selected_root_refusal.rs | 56 +++++ .../verification_observation_error.rs | 41 ++++ .../verification_selection_law_tests.rs | 151 ++++++++++++ .../retention/verification_view_collector.rs | 94 ++++++++ src/adapters/verification_admission.rs | 2 +- src/adapters/verification_error.rs | 23 ++ src/adapters/verification_failure_class.rs | 95 ++++++++ src/adapters/verification_ingress.rs | 150 ++++++++++++ src/lib.rs | 19 +- src/retention/mod.rs | 3 + src/retention/view_coordinates.rs | 16 ++ src/verification/rationale.md | 12 +- src/verification/refusal.rs | 4 +- src/verification/report.rs | 14 ++ src/verification/subject.rs | 12 + tests/catalog/mutation_support.rs | 14 ++ tests/catalog_restart.rs | 2 + tests/catalog_restart/verification_laws.rs | 93 ++++++++ tests/catalog_verification_ceiling.rs | 101 ++++++++ tests/segment.rs | 50 ++++ tests/segment/framing_laws.rs | 8 +- tests/segment/identity_laws.rs | 9 +- tests/verification_ingress.rs | 88 +++++++ tests/verification_view.rs | 221 ++++++++++++++++++ 36 files changed, 1803 insertions(+), 63 deletions(-) create mode 100644 src/adapters/catalog_byte_verification.rs create mode 100644 src/adapters/retention/filesystem_retention_verification.rs create mode 100644 src/adapters/retention/filesystem_verification_law_tests.rs create mode 100644 src/adapters/retention/selected_root_refusal.rs create mode 100644 src/adapters/retention/verification_observation_error.rs create mode 100644 src/adapters/retention/verification_selection_law_tests.rs create mode 100644 src/adapters/retention/verification_view_collector.rs create mode 100644 src/adapters/verification_failure_class.rs create mode 100644 src/adapters/verification_ingress.rs create mode 100644 src/retention/view_coordinates.rs create mode 100644 tests/catalog_restart/verification_laws.rs create mode 100644 tests/catalog_verification_ceiling.rs create mode 100644 tests/verification_ingress.rs create mode 100644 tests/verification_view.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a735d5a..09adb19e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ after its public API and format compatibility policies are established. ## [Unreleased] +- Added read-only durable verification ingress, exact moving-view conflict witnesses, selected-namespace root reports, and typed selected-root diagnostic sources (#114). + - Added catalog-bound complete-blob and admitted-retention-root verification reports, preserving exact missing members, identity/profile contradictions, resource failures, and original typed causes (#114). - Added allocation-free verification reports for already-admitted physical segments and logical chunk/layout records, with explicit subject-specific supported depths; full durable verification remains in progress under #114. diff --git a/docs/audits/114-durable-verification-scope.md b/docs/audits/114-durable-verification-scope.md index 2bd8526e..44deed3f 100644 --- a/docs/audits/114-durable-verification-scope.md +++ b/docs/audits/114-durable-verification-scope.md @@ -73,3 +73,15 @@ New runtime assertions must be calibrated against the behavior they protect; abs Bug fixes discovered at existing boundaries require a runtime regression observed on the unfixed revision, preserving the original failure artifact. Durable report serialization, a new report decoder, repair, GC execution, remote attestation, application trust policy, and unrelated prepared-branch features remain outside this issue. + +## Interface reconciliation and current candidate + +The original T-21.1 acceptance requires one `VerifiedSubject` per verified subject and explicitly names `verify_blob`, `verify_catalog`, and `verify_retention`; these operations select one subject each, so a truthful singleton subject list satisfies that contract. + +The earlier normative-page references to aggregate reporting were an implementation-plan inference, now corrected; this candidate does not expose or claim a whole-store enumeration API, CLI, MCP tool or durable report serialization. + +The independent bounded preflight agreed that the original named interfaces do not establish a mandatory aggregate enumerator; final exact-head review must still verify this reconciliation and the concrete subject/depth matrix. + +Raw segment/catalog classification, owned filesystem catalog reports, selected-namespace retention reports, precise moving-view candidates, typed selected-root diagnostics, and the scoped catalog-ceiling allocation law are implemented in the current candidate. + +The [consolidated evidence](../testing-evidence/durable-verification.md) records their runtime checks and falsification; final full validation and independent exact-head review remain acceptance gates, not assumptions inferred from earlier green commits. diff --git a/docs/invariants/verification/README.md b/docs/invariants/verification/README.md index fcf63749..24a7b783 100644 --- a/docs/invariants/verification/README.md +++ b/docs/invariants/verification/README.md @@ -1,6 +1,6 @@ # Verification reports -Status: implementation in progress under [#114](https://github.com/flyingrobots/keep/issues/114); this page describes the currently implemented admitted-evidence reporting operations, not completed durable verification acceptance. +Status: durable verification candidate under [#114](https://github.com/flyingrobots/keep/issues/114); final acceptance is tracked in the [closure ledger](../../audits/114-durable-verification-scope.md). ## Contract @@ -37,13 +37,39 @@ A framing request on already admitted evidence still requires that stronger admi Records prepared for publication provide the same logical proof over their canonical representation without asserting that the record has been written or made durable. -## Remaining durable contract +## Durable ingress and view collection -Raw durable loading-to-report failure classification, published-retention namespace selection, aggregate per-subject reports, and the catalog-ceiling memory campaign remain required by the [closure ledger](../../audits/114-durable-verification-scope.md). +`verify_segment` admits raw segment bytes before reporting; `verify_catalog_bytes` admits the supplied publication head, catalog and selected segments before reporting. + +`FilesystemCatalogSnapshot::load_for_verification` reads the exact selected artifacts under `CatalogRestartPolicy`, and its `verify` and `verify_blob` methods re-admit owned bytes before reporting. + +`FilesystemRetentionSnapshot::load_for_verification` holds the existing shared fence and uses bounded before/load/after collection; `verify_retention` reads and verifies only the manifest-selected root for the supplied namespace digest, checks namespace/generation/digest, and establishes the requested root evidence against that same catalog. + +Filesystem loading blocks on reads and fence acquisition; the owner retains caller-bounded segment bytes plus protocol-bounded catalog/manifest data, while reporting may rebuild the bounded catalog indexes. + +Selected-root verification holds one bounded root buffer, decoded anchors and catalog indexes; closure adds its checked node-bounded member index and one decoded layout at a time, with no whole-blob output buffer. + +These operations do not publish, repair, synchronize, run recovery, or acquire writer authority; a retained incomplete stage is not disposed of by verification. + +Exhausted moving-view collection returns `Ambiguous` with the actual last before/after catalog and retention coordinates; no partial view or report is returned. + +A failed observation is operational unless its retained typed cause establishes a precise missing artifact or content contradiction; classification never parses error prose. + +Each named original interface verifies one requested subject and returns one `VerifiedSubject`; traversal of a blob's chunks or a root's anchors establishes that subject's depth, without manufacturing separate reports for its dependencies. + +This satisfies the original per-subject interface contract; it is not a whole-store enumeration or aggregate-report API, and the earlier work-in-progress references to a required aggregate operation were broader than the original named interfaces. + +## Catalog-ceiling memory boundary + +The catalog-ceiling runtime law supplies 1,048,576 distinct chunk records and requires exact sample lookups plus a `CatalogReachability` report within 1 GiB (1,073,741,824 bytes) of incremental tracked live allocations during catalog/head admission, lookups and reporting. + +This bound excludes caller-owned encoded segment/catalog buffers, fixture construction, segment admission, allocator bookkeeping and process RSS; it is not a total-process memory promise. + +Filesystem owners additionally retain the selected segment bytes up to their explicit `CatalogRestartByteLimit`, protocol-bounded catalog bytes and admission indexes; those owners must be included when sizing a verification process. No serialization, repair, quarantine, GC execution or new durable report format is introduced here. -[Evidence and calibration](../../testing-evidence/durable-verification.md) distinguish runtime laws, static/API restrictions and unimplemented acceptance obligations. +[Evidence and calibration](../../testing-evidence/durable-verification.md) distinguish runtime laws, static/API restrictions and final acceptance checks. ## Logical refusal contract @@ -51,4 +77,4 @@ Blob and root verification distinguish missing catalog members, demonstrated con The report preserves the catalog generation/digest used by catalog, blob and root operations; this provenance is not a live fence. Multiple valid layouts for a blob are representations, not automatically ambiguity: discovery selects the first canonical identity. -The refusal vocabulary contains bounded conflicting candidates, but the admitted immutable-view operations do not manufacture an ambiguity outcome merely to exercise that variant; durable observation classification remains part of the unfinished contract. +Immutable admitted-view operations have no moving observation to classify; ambiguity is produced by the durable collection path, retaining at most the last conflicting coordinate pair and the original attempt-limit cause. diff --git a/docs/invariants/verification/requirements.md b/docs/invariants/verification/requirements.md index a3cc5490..2f0259c2 100644 --- a/docs/invariants/verification/requirements.md +++ b/docs/invariants/verification/requirements.md @@ -4,4 +4,4 @@ The [original task](../../audits/114-durable-verification-scope.md) remains auth | ID | Requirement | Status | Evidence and remaining work | | --- | --- | --- | --- | -| `KEEP-VERIFY-006` | Durable verification at explicit subject-specific achieved depths, with precise refusals, immutable reports and bounded costs. | In progress | Catalog, segment and logical-record admitted-evidence reports exist; raw durable failures, complete-blob/retention/aggregate operations, the full depth matrix and memory campaign remain open. See the [closure ledger](../../audits/114-durable-verification-scope.md) and [execution evidence](../../testing-evidence/durable-verification.md). | +| `KEEP-VERIFY-006` | Durable verification at explicit subject-specific achieved depths, with precise refusals, immutable reports and bounded costs. | In progress | The candidate implements subject-specific catalog, segment, record, blob and retained-namespace reports, typed durable ingress outcomes and bounded conflict evidence; focused runtime/calibration and catalog-ceiling evidence are recorded. Final full validation and exact-head independent acceptance remain open. See the [closure ledger](../../audits/114-durable-verification-scope.md) and [execution evidence](../../testing-evidence/durable-verification.md). | diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index d3ecb1fd..42c558a4 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -134,3 +134,45 @@ Replay the focused suite with `cargo test --locked --test blob_verification` and This evidence establishes operations over admitted immutable views, not raw filesystem failure classification, publication-selected retention, aggregate reports, the catalog-ceiling memory bound, process-death recovery, or final exact-head review and CI for the complete #114 candidate. Those remaining obligations are still open and implementation continues within the same PR. After restoring the candidate, the blob, catalog, segment and existing retention-closure suites passed in debug and release, and all ordinary and compile-fail doctests passed. The first post-calibration command then stopped at the nonexistent `source-check` alias; the corrected `cargo xtask source-structure-check` and formatting check passed separately. All-target/all-feature Clippy with warnings denied passed after the focused suite. The corrected structure receipt is `closure-structure-corrected.log`; these local receipts do not replace the full validation chain on the final candidate. + +## Durable ingress, selected publication and bounded observation + +Change kinds: new verification feature and a focused bug fix to preserve selected-root diagnostic coordinates, based on `b204f83d6aa9e55f3984ea0933890abb00576847`. + +The selected-root coordinate regression was observed RED on that exact parent with only the new public diagnostic type/export and regression test transplanted; the production reader remained unfixed and returned a message-only I/O cause, so the downcast observed `None` instead of the exact expected/observed generation/digest refusal. + +The corrected reader passes the same runtime assertion; `coordinate-parent-red.log` and `selection-laws-green.log` distinguish actual bug evidence from new-API compile failures. + +Public ingress laws check segment framing/checksum/version and resource classification, catalog absence/version and retained-byte limits, moving catalog/retention coordinates, exact final conflict candidates, permission failures, typed publication-decoder contradictions, selected-root absence/checksum/namespace refusal, and exact catalog/retention provenance. + +Existing segment framing/identity corruption laws now apply their original exact typed assertions to the cause retained by verification, and the catalog mutation laws additionally require the same exact decoder error through raw catalog verification. + +The v1 report matrix covers empty and populated physical segments, chunk/layout records, catalog success/refusal, absent or incomplete blob closure, and complete blob identity/profile replay; the v2 matrix covers admitted and publication-selected roots, closure/refusal, and moving retention heads. + +Supported and unsupported depth sets together exercise every depth for each named admitted subject; published retention delegates to the same root-depth operation after exact selection, and raw ingress must complete prerequisite admission even for a shallow request. + +The existing root/manifest/head decoders retain their own precise corruption-law suites; typed observation and root adapters classify their causes without reparsing prose, while unknown I/O and allocation/resource failures remain operational. + +The deterministic moving-view laws are port-level schedules, not filesystem race or syscall evidence; selected-root success/refusal and unchanged-evidence assertions execute against actual copied Linux ext4 fixtures with the reader fence held. + +The ingress calibration campaign independently inverted raw resource/content classification, reversed candidate order, accepted a moving view, dropped known observation classification, bypassed namespace selection, erased retention-head provenance and inverted root-corruption classification; each mutation compiled and failed its intended public runtime assertion. + +A separate mutation wrote changed selected-root bytes after reading them; the first successful report's persistent-evidence comparison failed, demonstrating that read-only success is an asserted observable outcome rather than merely a method name. + +Calibration artifacts preserve original and mutated source, command, compiler output, runtime failure and exit status under `ingress-mutants`; `cargo clean -p keep` invalidated changed package artifacts between mutants, and mutations occurred only in a separate copied tree. + +The catalog-ceiling law admits 1,048,576 distinct chunk entries, verifies exact named sample bytes and reports catalog reachability; the 1 GiB assertion measures incremental tracked live allocations during catalog/head admission, lookup and reporting, excluding fixture construction, pre-admitted segment owners and process RSS. + +On Linux arm64 Rust 1.96.0, an isolated measurement probe using the unchanged production implementation and a deliberately zero test threshold recorded 436,207,624 peak tracked bytes; that probe is measurement extraction, not assertion calibration. + +The actual calibration added a live allocation of 1,073,741,825 bytes inside production catalog reporting; the unchanged 1 GiB assertion failed at an observed 1,241,513,985 peak tracked bytes, while the unmutated law passed in debug and release. + +The memory receipts are `catalog-ceiling-green.log`, `final-ingress-focused.log`, `ceiling-measurement.log` and `ingress-mutants/admission-memory/red.log`; these measurements are not performance comparisons or total-process memory guarantees. + +Focused debug/release ingress, selection and catalog laws passed; all-target/all-feature Clippy passed after them, and the complete candidate validation is recorded separately below. + +The first broad copied-tree run passed product tests but stopped on two tooling-environment failures: `b3sum` was absent from PATH and the copied Git repository had no commit for a clone-based documentation-integrity test; `ingress-stable-validation.log` preserves those failures and they are not product regression RED evidence. + +The singleton report interpretation is explicitly reconciled in the normative page and scope ledger: the original named interfaces each select one subject, and no whole-store aggregate enumeration is claimed. + +Final required checks and independent review remain pending for the exact committed/pushed candidate; earlier receipts do not transfer approval to a different head. diff --git a/src/adapters/catalog_byte_verification.rs b/src/adapters/catalog_byte_verification.rs new file mode 100644 index 00000000..a1938a67 --- /dev/null +++ b/src/adapters/catalog_byte_verification.rs @@ -0,0 +1,44 @@ +//! This module owns canonical catalog verification over caller-supplied bytes. +#![expect( + clippy::result_large_err, + reason = "preserve complete bounded refusal coordinates" +)] + +use super::verification_ingress::catalog_error; +use crate::{ + AdmittedSegment, CatalogRestartError, ChecksummedCatalog, ChecksummedPublicationHead, + VerificationDepth, VerificationError, VerificationReport, +}; + +/// Admits the supplied publication head, catalog and immutable segments. +/// +/// Reports only the catalog evidence supported by `CatalogSnapshot::verify`. +/// Prerequisite admission validates all selected physical bindings, even for a +/// framing request. It scans catalog and referenced records, allocates bounded +/// catalog-entry/segment indexes, and performs no filesystem I/O or mutation. +/// Supplied segment owners and bytes must outlive this call, not the report. +/// +/// # Errors +/// +/// Preserves original head, catalog, physical binding and resource failures in +/// distinct missing/corrupt/operational outcomes; unsupported depth refuses. +pub fn verify_catalog_bytes( + head: &[u8], + catalog: &[u8], + segments: &[AdmittedSegment<'_>], + requested: VerificationDepth, +) -> Result { + let head = ChecksummedPublicationHead::decode(head) + .map_err(|source| catalog_error(CatalogRestartError::Head { source }))?; + let catalog = ChecksummedCatalog::decode(catalog) + .map_err(|source| catalog_error(CatalogRestartError::Catalog { source }))?; + let admitted = catalog.admit(segments).map_err(|source| { + catalog_error(CatalogRestartError::CatalogAdmission { + source: Box::new(source), + }) + })?; + let view = head + .admit(admitted) + .map_err(|source| catalog_error(CatalogRestartError::Snapshot { source }))?; + view.verify(requested).map_err(Into::into) +} diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index d63c757c..27a17b88 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -21,6 +21,7 @@ mod catalog_admission; mod catalog_admission_error; mod catalog_admission_error_display; mod catalog_allocation_phase; +mod catalog_byte_verification; mod catalog_decode_error; mod catalog_decode_error_display; mod catalog_decoder; @@ -235,6 +236,8 @@ mod sync_capable_directory; mod test_support; mod verification_admission; mod verification_error; +mod verification_failure_class; +mod verification_ingress; mod writer_lock_acquire_error; mod writer_lock_acquire_phase; @@ -246,3 +249,7 @@ use catalog_record_binding::CatalogRecordBinding; use decoded_catalog_entry::DecodedCatalogEntry; pub use verification_error::{VerificationError, VerificationSource}; + +pub use verification_ingress::verify_segment; + +pub use catalog_byte_verification::verify_catalog_bytes; diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 77ea6f7e..47955385 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -125,6 +125,9 @@ mod filesystem_retention_storage_tests; mod filesystem_retention_successor_tests; #[cfg(test)] mod filesystem_retention_test_fixture; +mod filesystem_retention_verification; +#[cfg(test)] +mod filesystem_verification_law_tests; #[cfg(test)] mod filesystem_version_two_admission_tests; mod head_decode_error; @@ -165,6 +168,7 @@ mod root_header_decoder; mod root_integrity; mod root_semantic_header; mod root_verification; +mod selected_root_refusal; mod successor_manifest; mod transition_disposition; mod transition_error; @@ -172,6 +176,8 @@ mod transition_planner; mod transition_preflight; mod transition_preflight_error; mod transition_readiness; +#[cfg(test)] +mod verification_selection_law_tests; mod verified_closure; #[cfg(test)] @@ -210,6 +216,8 @@ mod stage_history_admission; mod stage_prefix_admission; mod stage_record_integrity; mod stage_root_policy_admission; +mod verification_observation_error; +mod verification_view_collector; pub use admitted_manifest::AdmittedRetentionManifest; pub use admitted_root::AdmittedRetentionRoot; pub use canonical_head::CanonicalRetentionHead; @@ -277,3 +285,7 @@ pub use transition_readiness::RetentionTransitionReadiness; pub use verified_closure::VerifiedRetentionClosure; pub(super) use closure_profile_error::map as profile_verification_refusal; + +pub use selected_root_refusal::RetentionSelectedRootRefusal; + +pub use verification_view_collector::collect_verification_view; diff --git a/src/adapters/retention/filesystem_retention_snapshot.rs b/src/adapters/retention/filesystem_retention_snapshot.rs index 751aebce..49677179 100644 --- a/src/adapters/retention/filesystem_retention_snapshot.rs +++ b/src/adapters/retention/filesystem_retention_snapshot.rs @@ -10,8 +10,8 @@ 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, + ReaderFence, RetentionSelectedRootRefusal, RetentionViewCoordinates, RetentionViewSource, + collect_retention_view, root_header_decoder, }; use crate::adapters::filesystem_exact_record::{self as exact_record, ExactRecordError}; use crate::adapters::filesystem_platform_profile::root_identity; @@ -51,12 +51,12 @@ pub struct FilesystemRetentionSnapshot { retention: Option, } -struct View { +pub(super) struct View { catalog: FilesystemCatalogSnapshot, retention: Option, } -struct Source { +pub(super) struct Source { root: Dir, retention: Dir, manifests: Dir, @@ -124,6 +124,19 @@ impl FilesystemRetentionSnapshot { policy: CatalogRestartPolicy, limit: ReaderAttemptLimit, ) -> Result { + Self::load_with(store_root, policy, limit, |source, limit| { + collect_retention_view(source, limit) + .map_err(|source| Error::View { source })? + .map_err(|source| Error::Catalog { source }) + }) + } + + pub(super) fn load_with>( + store_root: &Path, + policy: CatalogRestartPolicy, + limit: ReaderAttemptLimit, + collect: impl FnOnce(&mut Source, ReaderAttemptLimit) -> Result, + ) -> 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) @@ -150,9 +163,7 @@ impl FilesystemRetentionSnapshot { manifests, policy, }; - let view = collect_retention_view(&mut source, limit) - .map_err(|source| Error::View { source })? - .map_err(|source| Error::Catalog { source })?; + let view = collect(&mut source, limit)?; Ok(Self { _fence: fence, roots, @@ -214,25 +225,38 @@ impl FilesystemRetentionSnapshot { let length = directory .symlink_metadata(&name) .and_then(|metadata| { - usize::try_from(metadata.len()).map_err(|_source| invalid("root length overflow")) + usize::try_from(metadata.len()).map_err(|_source| { + selected_refusal(RetentionSelectedRootRefusal::HostLength { + observed: metadata.len(), + }) + }) }) .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"), + source: selected_refusal(RetentionSelectedRootRefusal::Length { + maximum: u64::try_from(root_header_decoder::MAXIMUM_ENCODED_LENGTH).map_err( + |source| Error::Root { + source: io::Error::other(source), + }, + )?, + observed: u64::try_from(length).map_err(|source| Error::Root { + source: io::Error::other(source), + })?, + }), }); } 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"), + source: selected_refusal(RetentionSelectedRootRefusal::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}")), + source: ExactRecordError::Refused(refusal).into_io(), }); } }; @@ -243,17 +267,18 @@ impl FilesystemRetentionSnapshot { || root.root().generation() != entry.root_generation() { return Err(Error::Root { - source: invalid("selected root does not decode to the manifest's selection"), + source: selected_refusal(RetentionSelectedRootRefusal::Coordinate { + expected_generation: entry.root_generation(), + observed_generation: root.root().generation(), + expected_digest: entry.root_digest(), + observed_digest: root.digest(), + }), }); } 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) +fn selected_refusal(refusal: RetentionSelectedRootRefusal) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, refusal) } diff --git a/src/adapters/retention/filesystem_retention_verification.rs b/src/adapters/retention/filesystem_retention_verification.rs new file mode 100644 index 00000000..abbc5e1c --- /dev/null +++ b/src/adapters/retention/filesystem_retention_verification.rs @@ -0,0 +1,160 @@ +//! This module owns verification of one publication-selected retained namespace. +#![expect( + clippy::result_large_err, + reason = "preserve bounded full refusal coordinates without extra allocations" +)] + +use super::{AdmittedRetentionRoot, RetentionSelectedRootRefusal}; +use crate::adapters::filesystem_exact_record::ExactRecordError; +use crate::adapters::{verification_admission, verification_ingress}; +use crate::{ + CatalogRestartPolicy, FilesystemRetentionSnapshot, FilesystemRetentionSnapshotError, + ReaderAttemptLimit, RetentionNamespaceDigest, RetentionRootDecodeError, VerificationDepth, + VerificationError, VerificationRefusal, VerificationReport, VerificationSource, + VerificationSubject, +}; +use std::io; + +impl FilesystemRetentionSnapshot { + /// Loads an existing fenced retention view with verification classifications. + /// + /// Performs the same bounded filesystem observations and fence acquisition + /// as `load`; it never publishes, repairs or runs recovery. Catalog bytes + /// and segments are retained under `policy`; the shared fence is held for + /// this owner's lifetime. Observation failures retain their original causes. + /// + /// # Errors + /// + /// Missing catalog evidence is distinct from admitted corruption and an + /// inconclusive I/O, resource or fence failure. Exhausted moving-view + /// collection returns ambiguity with the last exact conflicting pair. + pub fn load_for_verification( + root: &std::path::Path, + policy: CatalogRestartPolicy, + limit: ReaderAttemptLimit, + ) -> Result { + Self::load_with(root, policy, limit, |source, limit| { + super::collect_verification_view(source, limit)? + .map_err(verification_ingress::catalog_error) + }) + } + + /// Verifies the exact root selected for `namespace` in this fenced view. + /// + /// Reads only the manifest-selected root, without following links, within + /// the root format bound. It checks namespace, generation and digest, then + /// re-admits the owned catalog and applies `AdmittedRetentionRoot::verify`. + /// Costs include one bounded root buffer, its decoded anchors and catalog + /// indexes; closure adds its root-bounded member index and one layout. + /// This synchronous operation performs reads but no writes, sync, repair, + /// recovery or publication; the existing reader fence remains held. + /// + /// # Errors + /// + /// Returns exact missing namespace/member, unsupported depth, corruption, + /// or operational failure with original typed causes. No partial report + /// is returned and no alternate root or catalog is substituted. + pub fn verify_retention( + &self, + namespace: RetentionNamespaceDigest, + requested: VerificationDepth, + ) -> Result { + let subject = VerificationSubject::RetentionNamespace { namespace }; + let bytes = self + .retained_root(namespace) + .map_err(|source| root_error(subject, source))? + .ok_or(VerificationRefusal::Missing { subject })?; + let root = AdmittedRetentionRoot::decode(&bytes) + .map_err(|source| decode_error(subject, source))?; + if root.root().namespace().digest() != namespace { + let source = io::Error::new( + io::ErrorKind::InvalidData, + RetentionSelectedRootRefusal::Namespace { + expected: namespace, + observed: root.root().namespace().digest(), + }, + ); + return Err(root_error( + subject, + FilesystemRetentionSnapshotError::Root { source }, + )); + } + let catalog = self + .catalog() + .snapshot() + .map_err(verification_ingress::catalog_error)?; + root.verify(&catalog, requested) + .map(|report| report.in_retention(self.retention_head().copied())) + } +} + +fn decode_error( + subject: VerificationSubject, + source: RetentionRootDecodeError, +) -> VerificationError { + if matches!(source, RetentionRootDecodeError::Allocation { .. }) { + return VerificationError::Operational { + source: Box::new(VerificationSource::Root(source)), + }; + } + VerificationError::Refused { + refusal: verification_admission::structural(subject), + source: Some(Box::new(VerificationSource::Root(source))), + } +} + +fn view_error(source: FilesystemRetentionSnapshotError) -> VerificationError { + if let FilesystemRetentionSnapshotError::Catalog { source } = source { + return verification_ingress::catalog_error(source); + } + VerificationError::Operational { + source: Box::new(VerificationSource::Retention(source)), + } +} + +fn root_error( + subject: VerificationSubject, + error: FilesystemRetentionSnapshotError, +) -> VerificationError { + let FilesystemRetentionSnapshotError::Root { source } = &error else { + return view_error(error); + }; + let cause = source.get_ref(); + let selected = cause.and_then(|cause| cause.downcast_ref::()); + if source.kind() == io::ErrorKind::NotFound + || matches!(selected, Some(RetentionSelectedRootRefusal::Absent)) + { + return VerificationError::Refused { + refusal: VerificationRefusal::Missing { subject }, + source: Some(Box::new(VerificationSource::Retention(error))), + }; + } + let root = cause.and_then(|cause| cause.downcast_ref::()); + let exact = cause.and_then(|cause| cause.downcast_ref::()); + let corrupt = matches!( + selected, + Some( + RetentionSelectedRootRefusal::Length { .. } + | RetentionSelectedRootRefusal::Coordinate { .. } + | RetentionSelectedRootRefusal::Namespace { .. } + ) + ) || root + .is_some_and(|source| !matches!(source, RetentionRootDecodeError::Allocation { .. })) + || matches!(exact, Some(ExactRecordError::Refused(_))); + if corrupt { + VerificationError::Refused { + refusal: verification_admission::structural(subject), + source: Some(Box::new(VerificationSource::Retention(error))), + } + } else { + VerificationError::Operational { + source: Box::new(VerificationSource::Retention(error)), + } + } +} + +impl From for VerificationError { + fn from(source: FilesystemRetentionSnapshotError) -> Self { + view_error(source) + } +} diff --git a/src/adapters/retention/filesystem_verification_law_tests.rs b/src/adapters/retention/filesystem_verification_law_tests.rs new file mode 100644 index 00000000..44c2d2ef --- /dev/null +++ b/src/adapters/retention/filesystem_verification_law_tests.rs @@ -0,0 +1,195 @@ +//! Public verification outcomes over owned migrated filesystem witnesses. + +use super::filesystem_retention_test_fixture::{ + ROOT_HEX, fixture, initial_preparation, open_authority, retention_witness, root_pool_path, +}; +use crate::{ + AdmittedRetentionRoot, CatalogRestartByteLimit, CatalogRestartPolicy, + FilesystemRetentionSnapshot, ReaderAttemptLimit, RetentionNamespace, SegmentReadPolicy, + VerificationDepth as Depth, VerificationError, VerificationRefusal, VerificationSource, + VerificationSubject, execute_retention_publication, +}; +use std::error::Error; +use std::fs; + +// Size: medium. Oracle: exact published corpus root/head and immutable bytes. +// Delete when superseded by a generated durable-report law retaining this case. +#[test] +fn published_root_reports_bind_both_views_without_mutating_evidence() -> Result<(), Box> +{ + let (sandbox, mut authority) = open_authority("verify-published-root")?; + let bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&bytes)?; + let _receipt = execute_retention_publication(&mut authority, &preparation)?; + drop(authority); + let before = retention_witness(sandbox.path())?; + let root = AdmittedRetentionRoot::decode(&bytes)?; + let snapshot = FilesystemRetentionSnapshot::load_for_verification( + sandbox.path(), + policy()?, + ReaderAttemptLimit::DEFAULT, + )?; + for depth in [Depth::Framing, Depth::Checksum, Depth::RetentionClosure] { + let report = snapshot.verify_retention(root.root().namespace().digest(), depth)?; + assert_eq!(report.requested(), depth); + assert_eq!( + report.retention_head(), + snapshot.retention_head().copied(), + "publication provenance must survive verification" + ); + assert_eq!( + report.catalog(), + Some(( + snapshot.catalog().generation(), + snapshot.catalog().catalog_digest() + )) + ); + let claims: Vec<_> = report + .subjects() + .iter() + .map(|entry| (entry.subject(), entry.depth())) + .collect(); + assert_eq!( + claims, + [( + VerificationSubject::RetentionRoot { + namespace: root.root().namespace().digest(), + generation: root.root().generation(), + digest: root.digest() + }, + depth + )] + ); + assert_eq!( + retention_witness(sandbox.path())?, + before, + "verification must leave persisted evidence unchanged" + ); + } + Ok(()) +} + +// Size: medium. Oracle: the manifest contains no selected root for this namespace. +// Delete if an empty namespace acquires a different documented meaning. +#[test] +fn an_unretained_namespace_is_precisely_missing() -> Result<(), Box> { + let (sandbox, authority) = open_authority("verify-absent-namespace")?; + drop(authority); + let namespace = RetentionNamespace::try_from(b"absent".as_slice())?.digest(); + let before = retention_witness(sandbox.path())?; + let snapshot = FilesystemRetentionSnapshot::load_for_verification( + sandbox.path(), + policy()?, + ReaderAttemptLimit::DEFAULT, + )?; + let error = snapshot + .verify_retention(namespace, Depth::RetentionClosure) + .err() + .ok_or("absent namespace certified")?; + assert!( + matches!(error, VerificationError::Refused { refusal: VerificationRefusal::Missing { subject: VerificationSubject::RetentionNamespace { namespace: actual } }, source: None } if actual == namespace), + "expected exact absent namespace: {error:?}" + ); + assert_eq!(retention_witness(sandbox.path())?, before); + Ok(()) +} + +// Size: medium. Oracle: a missing manifest-selected root is absence, not corruption. +// Delete when a stronger fault-injected selected-root law subsumes this case. +#[test] +fn a_missing_selected_root_preserves_its_original_filesystem_cause() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("verify-missing-root")?; + let bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&bytes)?; + let _receipt = execute_retention_publication(&mut authority, &preparation)?; + drop(authority); + let root = AdmittedRetentionRoot::decode(&bytes)?; + fs::remove_file(root_pool_path(sandbox.path(), &root))?; + let before = retention_witness(sandbox.path())?; + let snapshot = FilesystemRetentionSnapshot::load_for_verification( + sandbox.path(), + policy()?, + ReaderAttemptLimit::DEFAULT, + )?; + let error = snapshot + .verify_retention(root.root().namespace().digest(), Depth::RetentionClosure) + .err() + .ok_or("missing selected root certified")?; + let VerificationError::Refused { + refusal, + source: Some(source), + } = error + else { + return Err("missing selected root lost its classification or cause".into()); + }; + assert_eq!( + refusal, + VerificationRefusal::Missing { + subject: VerificationSubject::RetentionNamespace { + namespace: root.root().namespace().digest() + } + } + ); + assert!( + matches!(*source, VerificationSource::Retention(crate::FilesystemRetentionSnapshotError::Root { source }) if source.kind() == std::io::ErrorKind::NotFound) + ); + assert_eq!(retention_witness(sandbox.path())?, before); + Ok(()) +} + +// Size: medium. Oracle: checksum delta from frozen selected bytes, with no repair. +// Delete if this exact corruption is covered by a stronger generated law. +#[test] +fn corrupted_root_verification_retains_exact_checksum_diagnostics() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("verify-corrupt-root")?; + let bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&bytes)?; + let _receipt = execute_retention_publication(&mut authority, &preparation)?; + drop(authority); + let root = AdmittedRetentionRoot::decode(&bytes)?; + let offset = bytes.len().checked_sub(32).ok_or("no checksum")?; + let expected: [u8; 32] = bytes.get(offset..).ok_or("no checksum")?.try_into()?; + let mut corrupt = bytes.clone(); + *corrupt.last_mut().ok_or("empty root")? ^= 1; + let observed: [u8; 32] = corrupt.get(offset..).ok_or("no checksum")?.try_into()?; + fs::write(root_pool_path(sandbox.path(), &root), &corrupt)?; + let before = retention_witness(sandbox.path())?; + let snapshot = FilesystemRetentionSnapshot::load_for_verification( + sandbox.path(), + policy()?, + ReaderAttemptLimit::DEFAULT, + )?; + let error = snapshot + .verify_retention(root.root().namespace().digest(), Depth::Checksum) + .err() + .ok_or("corrupt root certified")?; + let VerificationError::Refused { + refusal: VerificationRefusal::Corrupt { .. }, + source: Some(source), + } = error + else { + return Err("corruption lost classification or source".into()); + }; + let VerificationSource::Retention(crate::FilesystemRetentionSnapshotError::Root { source }) = + *source + else { + return Err("original boundary changed".into()); + }; + assert!( + matches!(source.get_ref().and_then(|source| source.downcast_ref::()), Some(crate::RetentionRootDecodeError::ChecksumMismatch { expected: actual_expected, observed: actual_observed }) if *actual_expected == expected && *actual_observed == observed), + "exact checksum coordinates must survive" + ); + assert_eq!( + retention_witness(sandbox.path())?, + before, + "corrupt evidence must remain unchanged" + ); + Ok(()) +} + +fn policy() -> Result> { + Ok(CatalogRestartPolicy::new( + SegmentReadPolicy::MAXIMUM, + CatalogRestartByteLimit::new(1_048_576)?, + )) +} diff --git a/src/adapters/retention/retention_view_collector.rs b/src/adapters/retention/retention_view_collector.rs index 6c64456e..03400cc6 100644 --- a/src/adapters/retention/retention_view_collector.rs +++ b/src/adapters/retention/retention_view_collector.rs @@ -5,25 +5,12 @@ use std::fmt; use std::io; use super::ReaderAttemptLimit; -use crate::{CatalogDigest, CatalogGeneration, CatalogLength, RetentionHead}; +pub use crate::retention::RetentionViewCoordinates; #[cfg(test)] #[path = "retention_view_coordinate_law_tests.rs"] mod coordinate_law_tests; -/// 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, CatalogLength, CatalogDigest)>, - /// The `retention/HEAD` coordinate, or `None` when no retention head is published. - pub retention: Option, -} - /// The reads one reader view needs, in the order the collector calls them. pub trait RetentionViewSource { /// The complete view loaded between two coordinate reads. diff --git a/src/adapters/retention/selected_root_refusal.rs b/src/adapters/retention/selected_root_refusal.rs new file mode 100644 index 00000000..3b87c3ad --- /dev/null +++ b/src/adapters/retention/selected_root_refusal.rs @@ -0,0 +1,56 @@ +//! This module owns exact contradictions in manifest-selected root admission. + +use crate::{RetentionNamespaceDigest, RetentionRootDigest, RootGeneration}; +use std::error::Error; +use std::fmt; + +/// Why present root evidence does not satisfy its manifest selection. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum RetentionSelectedRootRefusal { + /// The selected name disappeared between observation and open. + Absent, + /// The file length exceeds the root format's byte ceiling. + Length { + /// Maximum canonical record bytes. + maximum: u64, + /// Observed file bytes. + observed: u64, + }, + /// A filesystem length cannot be represented on this host. + HostLength { + /// Observed file bytes. + observed: u64, + }, + /// The decoded record belongs to a different namespace. + Namespace { + /// Namespace selected by the manifest lookup. + expected: RetentionNamespaceDigest, + /// Namespace authenticated from the record. + observed: RetentionNamespaceDigest, + }, + /// The canonical root does not match the selected generation and digest. + Coordinate { + /// Selected root generation. + expected_generation: RootGeneration, + /// Decoded root generation. + observed_generation: RootGeneration, + /// Selected root digest. + expected_digest: RetentionRootDigest, + /// Decoded root digest. + observed_digest: RetentionRootDigest, + }, +} + +impl fmt::Display for RetentionSelectedRootRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Absent => "selected root is absent", + Self::Length { .. } => "selected root exceeds the format bound", + Self::HostLength { .. } => "root length overflow", + Self::Namespace { .. } => "selected root namespace disagrees", + Self::Coordinate { .. } => "selected root does not decode to the manifest's selection", + }) + } +} +impl Error for RetentionSelectedRootRefusal {} diff --git a/src/adapters/retention/verification_observation_error.rs b/src/adapters/retention/verification_observation_error.rs new file mode 100644 index 00000000..ca4cade4 --- /dev/null +++ b/src/adapters/retention/verification_observation_error.rs @@ -0,0 +1,41 @@ +//! This module owns classification of typed publication observation failures. + +use crate::adapters::verification_admission; +use crate::{ + PublicationHeadDecodeError, RetentionCurrentStateRefusal as Current, + RetentionManifestDecodeError, VerificationRefusal, VerificationSubject, +}; +use std::io; + +pub(super) fn refusal(error: &io::Error) -> Option { + let source = error.get_ref()?; + if source + .downcast_ref::() + .is_some() + { + return Some(verification_admission::structural( + VerificationSubject::PublishedCatalog, + )); + } + let current = source.downcast_ref::()?; + match current { + Current::ManifestAbsent | Current::CatalogAbsent => Some(VerificationRefusal::Missing { + subject: VerificationSubject::PublishedView, + }), + Current::ManifestRefused { + source: RetentionManifestDecodeError::Allocation { .. }, + } => None, + Current::HeadRefused { .. } + | Current::ManifestRefused { .. } + | Current::ManifestDisagreed + | Current::HeadPredecessorDisagreed + | Current::RecordKindOrLength + | Current::RecordTrailingBytes + | Current::CatalogHeadRefused { .. } + | Current::CatalogRefused { .. } + | Current::CatalogChanged => Some(verification_admission::structural( + VerificationSubject::PublishedView, + )), + _ => None, + } +} diff --git a/src/adapters/retention/verification_selection_law_tests.rs b/src/adapters/retention/verification_selection_law_tests.rs new file mode 100644 index 00000000..10001b40 --- /dev/null +++ b/src/adapters/retention/verification_selection_law_tests.rs @@ -0,0 +1,151 @@ +//! Canonical but incorrectly selected roots cannot become verification evidence. + +use super::filesystem_retention_pool_name as names; +use super::filesystem_retention_test_fixture::{ + ROOT_HEX, fixture, initial_preparation, initial_root, open_authority, retention_witness, + root_pool_path, +}; +use crate::{ + AdmittedRetentionRoot, CanonicalRetentionHead, CanonicalRetentionManifest, + CatalogRestartByteLimit, CatalogRestartPolicy, FilesystemRetentionSnapshot, + FilesystemRetentionSnapshotError, ReaderAttemptLimit, RetentionHead, RetentionManifest, + RetentionManifestEntry, RetentionManifestLength, RetentionNamespace, + RetentionSelectedRootRefusal, SegmentReadPolicy, VerificationDepth, VerificationError, + VerificationRefusal, VerificationSource, execute_retention_publication, +}; +use std::error::Error; +use std::fs; + +// Size: medium. Oracle: a manifest lookup by namespace cannot certify another +// namespace's canonical root, even when all selected bytes and digests agree. +// Delete if a stronger generated selection law retains this contradictory witness. +#[test] +fn a_canonical_foreign_root_cannot_certify_the_requested_namespace() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("verification-foreign-root")?; + let bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&bytes)?; + let _receipt = execute_retention_publication(&mut authority, &preparation)?; + drop(authority); + let root = AdmittedRetentionRoot::decode(&bytes)?; + let original = root.root().namespace().digest(); + let namespace = RetentionNamespace::try_from(b"different-namespace".as_slice())?.digest(); + let manifest = RetentionManifest::new( + preparation.liveness_generation(), + None, + vec![RetentionManifestEntry::new( + namespace, + root.root().generation(), + root.digest(), + )], + )?; + let manifest = CanonicalRetentionManifest::from_manifest(&manifest)?; + let head = RetentionHead::new( + preparation.liveness_generation(), + RetentionManifestLength::new(u64::try_from(manifest.encoded().len())?)?, + manifest.digest(), + None, + )?; + let directory = sandbox + .path() + .join("retention/roots") + .join(names::namespace(namespace)); + fs::create_dir(&directory)?; + fs::write( + directory.join(names::root(root.root().generation(), root.digest())), + &bytes, + )?; + fs::write( + sandbox + .path() + .join("retention/manifests") + .join(names::manifest(head.generation(), head.manifest_digest())), + manifest.encoded(), + )?; + fs::write( + sandbox.path().join("retention/HEAD"), + CanonicalRetentionHead::from_head(&head).encoded(), + )?; + let before = retention_witness(sandbox.path())?; + let view = FilesystemRetentionSnapshot::load_for_verification( + sandbox.path(), + policy()?, + ReaderAttemptLimit::DEFAULT, + )?; + let error = view + .verify_retention(namespace, VerificationDepth::RetentionClosure) + .err() + .ok_or("foreign namespace was certified")?; + let VerificationError::Refused { + refusal: VerificationRefusal::Corrupt { .. }, + source: Some(source), + } = error + else { + return Err("namespace contradiction lost its classification".into()); + }; + let VerificationSource::Retention(FilesystemRetentionSnapshotError::Root { source }) = *source + else { + return Err("namespace contradiction lost its original boundary".into()); + }; + assert_eq!( + source + .get_ref() + .and_then(|source| source.downcast_ref::()), + Some(&RetentionSelectedRootRefusal::Namespace { + expected: namespace, + observed: original + }), + "exact namespace coordinates must survive" + ); + assert_eq!(retention_witness(sandbox.path())?, before); + Ok(()) +} + +// Size: medium. Oracle: canonical substitution still contradicts the manifest +// selection and must preserve exact expected/observed generation and digest. +// Delete if a stronger selected-root source-preservation law subsumes this case. +#[test] +fn a_selected_root_coordinate_refusal_keeps_its_typed_evidence() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("verification-root-coordinate")?; + let bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&bytes)?; + let _receipt = execute_retention_publication(&mut authority, &preparation)?; + drop(authority); + let expected = AdmittedRetentionRoot::decode(&bytes)?; + let replacement = initial_root(b"coordinate-mismatch", &expected)?; + let observed = AdmittedRetentionRoot::decode(replacement.encoded())?; + fs::write( + root_pool_path(sandbox.path(), &expected), + replacement.encoded(), + )?; + let before = retention_witness(sandbox.path())?; + let view = + FilesystemRetentionSnapshot::load(sandbox.path(), policy()?, ReaderAttemptLimit::DEFAULT)?; + let error = view + .retained_root(expected.root().namespace().digest()) + .err() + .ok_or("substituted root admitted")?; + let FilesystemRetentionSnapshotError::Root { source } = error else { + return Err("selected-root boundary changed".into()); + }; + assert_eq!( + source + .get_ref() + .and_then(|source| source.downcast_ref::()), + Some(&RetentionSelectedRootRefusal::Coordinate { + expected_generation: expected.root().generation(), + observed_generation: observed.root().generation(), + expected_digest: expected.digest(), + observed_digest: observed.digest() + }), + "a precise coordinate refusal must not be replaced with a message" + ); + assert_eq!(retention_witness(sandbox.path())?, before); + Ok(()) +} + +fn policy() -> Result> { + Ok(CatalogRestartPolicy::new( + SegmentReadPolicy::MAXIMUM, + CatalogRestartByteLimit::new(1_048_576)?, + )) +} diff --git a/src/adapters/retention/verification_view_collector.rs b/src/adapters/retention/verification_view_collector.rs new file mode 100644 index 00000000..b8de2472 --- /dev/null +++ b/src/adapters/retention/verification_view_collector.rs @@ -0,0 +1,94 @@ +//! This module owns bounded conflicting observations for verification collection. +#![expect( + clippy::result_large_err, + reason = "preserve bounded semantic refusal coordinates" +)] + +use super::{ + ReaderAttemptLimit, RetentionViewCoordinates, RetentionViewError, RetentionViewSource, + collect_retention_view, +}; +use crate::{VerificationError, VerificationRefusal, VerificationSource, VerificationSubject}; +use std::io; + +/// Collects a consistent view or retains the last conflicting observation pair. +/// +/// Uses the same before/load/after algorithm and attempt bound as +/// `collect_retention_view`. Tracking uses constant memory; only an error +/// allocates its boxed cause and, for ambiguity, one pair of coordinates. +/// It performs only the supplied source's observations and never repairs or +/// writes. A conflict can be ordinary concurrent publication, not corruption. +/// +/// # Errors +/// +/// Returns missing catalog evidence, the exact last pair when no attempt +/// agrees, or an operational observation error with its original typed cause. +/// An error returns no partially collected view. +pub fn collect_verification_view( + source: &mut S, + limit: ReaderAttemptLimit, +) -> Result { + let mut observed = ObservedSource { + source, + previous: None, + last: None, + }; + collect_retention_view(&mut observed, limit) + .map_err(|source| classify(source, observed.previous, observed.last)) +} + +struct ObservedSource<'a, S> { + source: &'a mut S, + previous: Option, + last: Option, +} + +impl RetentionViewSource for ObservedSource<'_, S> { + type View = S::View; + + fn coordinates(&mut self) -> io::Result { + let coordinates = self.source.coordinates()?; + self.previous = self.last.replace(coordinates); + Ok(coordinates) + } + + fn load(&mut self) -> io::Result { + self.source.load() + } +} + +fn classify( + source: RetentionViewError, + before: Option, + after: Option, +) -> VerificationError { + let observed_refusal = match &source { + RetentionViewError::Io { source } => super::verification_observation_error::refusal(source), + _ => None, + }; + if let Some(refusal) = observed_refusal { + return VerificationError::Refused { + refusal, + source: Some(Box::new(VerificationSource::View(source))), + }; + } + let refusal = match (&source, before, after) { + (RetentionViewError::CatalogAbsent, _, _) => VerificationRefusal::Missing { + subject: VerificationSubject::PublishedCatalog, + }, + (RetentionViewError::AttemptsExhausted { .. }, Some(before), Some(after)) => { + VerificationRefusal::Ambiguous { + candidates: Box::new([before, after]), + } + } + _ => { + return VerificationError::Operational { + source: Box::new(VerificationSource::View(source)), + }; + } + }; + VerificationError::Refused { + refusal, + source: Some(Box::new(VerificationSource::View(source))), + } +} diff --git a/src/adapters/verification_admission.rs b/src/adapters/verification_admission.rs index 69a7bc28..43d00ccc 100644 --- a/src/adapters/verification_admission.rs +++ b/src/adapters/verification_admission.rs @@ -76,7 +76,7 @@ pub(super) fn closure(subject: VerificationSubject, source: Closure) -> Verifica } } -const fn structural(subject: VerificationSubject) -> VerificationRefusal { +pub(super) const fn structural(subject: VerificationSubject) -> VerificationRefusal { VerificationRefusal::Corrupt { subject, expected: Observation::Canonical, diff --git a/src/adapters/verification_error.rs b/src/adapters/verification_error.rs index b36f38b7..6fed82c8 100644 --- a/src/adapters/verification_error.rs +++ b/src/adapters/verification_error.rs @@ -33,6 +33,17 @@ pub enum VerificationError { #[derive(Debug)] #[non_exhaustive] pub enum VerificationSource { + /// Fenced view collection could not choose one consistent observation. + View(crate::RetentionViewError), + /// Bounded immutable segment admission failed. + Segment(crate::SegmentReadError), + /// Published catalog loading or binding failed. + Catalog(crate::CatalogRestartError), + /// Published retention view or selected-root observation failed. + Retention(crate::FilesystemRetentionSnapshotError), + /// Canonical retention root admission failed. + Root(crate::RetentionRootDecodeError), + /// Canonical layout decoding failed. Layout(LayoutDecodeError), /// Retention closure admission or its resource accounting failed. @@ -75,7 +86,13 @@ impl Error for VerificationError { impl fmt::Display for VerificationSource { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::View(source) => source.fmt(formatter), + Self::Segment(source) => source.fmt(formatter), + Self::Catalog(source) => source.fmt(formatter), + Self::Retention(source) => source.fmt(formatter), + Self::Root(source) => source.fmt(formatter), Self::Layout(source) => source.fmt(formatter), + Self::Closure(source) => source.fmt(formatter), Self::BlobHash(source) => source.fmt(formatter), } @@ -85,7 +102,13 @@ impl fmt::Display for VerificationSource { impl Error for VerificationSource { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { + Self::View(source) => Some(source), + Self::Segment(source) => Some(source), + Self::Catalog(source) => Some(source), + Self::Retention(source) => Some(source), + Self::Root(source) => Some(source), Self::Layout(source) => Some(source), + Self::Closure(source) => Some(source), Self::BlobHash(source) => Some(source), } diff --git a/src/adapters/verification_failure_class.rs b/src/adapters/verification_failure_class.rs new file mode 100644 index 00000000..0ed58049 --- /dev/null +++ b/src/adapters/verification_failure_class.rs @@ -0,0 +1,95 @@ +//! This module owns resource-versus-content classification at durable ingress. + +use crate::{ + CatalogAdmissionError, CatalogRestartError, LayoutDecodeError, SegmentReadError, + SegmentRecordAdmissionError, +}; + +#[derive(Clone, Copy)] +pub(super) enum FailureClass { + Operational, + Corrupt, +} + +pub(super) const fn layout_class(error: &LayoutDecodeError) -> FailureClass { + if matches!( + error, + LayoutDecodeError::Allocation { .. } + | LayoutDecodeError::EntryCountHostWidth { .. } + | LayoutDecodeError::HostRecordLengthOutOfRange { .. } + | LayoutDecodeError::ConfiguredEntryLimitExceeded { .. } + ) { + FailureClass::Operational + } else { + FailureClass::Corrupt + } +} + +pub(super) const fn segment_class(error: &SegmentReadError) -> FailureClass { + match error { + SegmentReadError::RecordCountLimit { .. } + | SegmentReadError::RecordCountHostWidth { .. } + | SegmentReadError::IdentityIndexAllocation { .. } + | SegmentReadError::RecordLengthHostWidth { .. } + | SegmentReadError::OffsetArithmetic { .. } + | SegmentReadError::RecordIndexArithmetic { .. } + | SegmentReadError::RecordCountArithmetic { .. } => FailureClass::Operational, + SegmentReadError::RecordAdmission { source, .. } => match source { + SegmentRecordAdmissionError::Layout { source } => layout_class(source), + SegmentRecordAdmissionError::ChunkHash { .. } + | SegmentRecordAdmissionError::PayloadLengthHostWidth { .. } + | SegmentRecordAdmissionError::RecordLengthArithmetic { .. } => { + FailureClass::Operational + } + SegmentRecordAdmissionError::Header { .. } + | SegmentRecordAdmissionError::ChunkIdentityMismatch { .. } + | SegmentRecordAdmissionError::PayloadLengthMismatch { .. } => FailureClass::Corrupt, + }, + SegmentReadError::WrongLength { .. } + | SegmentReadError::Header { .. } + | SegmentReadError::Seal { .. } + | SegmentReadError::RecordHeaderTruncated { .. } + | SegmentReadError::RecordHeader { .. } + | SegmentReadError::RecordTruncated { .. } + | SegmentReadError::RecordDecode { .. } + | SegmentReadError::TrailingRecordBytes { .. } + | SegmentReadError::DuplicateRecordIdentity { .. } => FailureClass::Corrupt, + } +} + +pub(super) fn catalog_class(error: &CatalogRestartError) -> FailureClass { + match error { + CatalogRestartError::Io { .. } + | CatalogRestartError::LengthArithmetic { .. } + | CatalogRestartError::Allocation { .. } + | CatalogRestartError::SegmentIndexLength + | CatalogRestartError::SegmentIndexAllocation { .. } + | CatalogRestartError::RetainedSegmentBytes { .. } + | CatalogRestartError::RetainedSegmentByteArithmetic { .. } => FailureClass::Operational, + CatalogRestartError::Segment { source, .. } => segment_class(source), + CatalogRestartError::CatalogAdmission { source } => admission_class(source), + CatalogRestartError::NotRegular { .. } + | CatalogRestartError::Length { .. } + | CatalogRestartError::Head { .. } + | CatalogRestartError::Catalog { .. } + | CatalogRestartError::CatalogCoordinate { .. } + | CatalogRestartError::SegmentCoordinate { .. } + | CatalogRestartError::Snapshot { .. } => FailureClass::Corrupt, + } +} + +fn admission_class(error: &CatalogAdmissionError) -> FailureClass { + match error { + CatalogAdmissionError::EntryCountHostWidth { .. } + | CatalogAdmissionError::Allocation { .. } => FailureClass::Operational, + CatalogAdmissionError::Segment { source, .. } => segment_class(source), + CatalogAdmissionError::Catalog { .. } + | CatalogAdmissionError::SegmentCountOutOfBounds { .. } + | CatalogAdmissionError::DuplicateSegment { .. } + | CatalogAdmissionError::MissingSegment { .. } + | CatalogAdmissionError::UnreferencedSegment { .. } + | CatalogAdmissionError::LocationNotTopLevel { .. } + | CatalogAdmissionError::RecordIdentityMismatch { .. } + | CatalogAdmissionError::RecordChecksumMismatch { .. } => FailureClass::Corrupt, + } +} diff --git a/src/adapters/verification_ingress.rs b/src/adapters/verification_ingress.rs new file mode 100644 index 00000000..0598d7bc --- /dev/null +++ b/src/adapters/verification_ingress.rs @@ -0,0 +1,150 @@ +//! This module owns raw segment and filesystem catalog verification ingress. +#![expect( + clippy::result_large_err, + reason = "preserve bounded full refusal coordinates without extra allocations" +)] + +use super::verification_failure_class::FailureClass; +use super::{verification_admission, verification_failure_class}; +use crate::{ + AdmittedSegment, CatalogRestartError, CatalogRestartPhase, CatalogRestartPolicy, + FilesystemCatalogSnapshot, SegmentReadPolicy, VerificationDepth, VerificationError, + VerificationRefusal, VerificationReport, VerificationSource, VerificationSubject, +}; +use std::io::ErrorKind; +use std::path::Path; + +/// Admits one complete immutable segment and reports its requested evidence. +/// +/// Even a framing request performs complete prerequisite admission, including +/// record checksums and identities. Allocation is the bounded duplicate index +/// required by `AdmittedSegment::decode`; work scans the supplied bytes. The +/// operation performs no I/O, mutation, repair or synchronization. +/// +/// # Errors +/// +/// Returns typed corruption with the original segment cause, operational +/// resource failures, or an unsupported subject/depth request. No failed +/// admission produces a partial report. +pub fn verify_segment( + encoded: &[u8], + policy: SegmentReadPolicy, + requested: VerificationDepth, +) -> Result { + let segment = AdmittedSegment::decode(encoded, policy).map_err(|source| { + let class = verification_failure_class::segment_class(&source); + classified( + VerificationSubject::SegmentInput, + VerificationSource::Segment(source), + class, + ) + })?; + segment.verify(requested).map_err(Into::into) +} + +impl FilesystemCatalogSnapshot { + /// Loads a read-only, owned catalog view with verification failure classes. + /// + /// This uses the same capability-relative, no-follow exact reads as `load`; + /// segment bytes are retained within `policy`, and catalog bytes/indexes are + /// bounded by their protocol ceiling. It blocks on filesystem I/O and + /// performs no write, sync, recovery or repair. This API admits catalog + /// evidence, not platform durability or retention authority. + /// + /// # Errors + /// + /// A missing selected head/pool artifact is distinct from demonstrated + /// malformed bytes and operational I/O/resource failure. The original + /// `CatalogRestartError` preserves all available phases and coordinates. + pub fn load_for_verification( + root: &Path, + policy: CatalogRestartPolicy, + ) -> Result { + Self::load(root, policy).map_err(catalog_error) + } + + /// Reports the requested catalog evidence over this exact owned view. + /// + /// Re-admits retained bytes through `snapshot` before reporting; its + /// bounded indexes and decoding cost are included. No filesystem I/O, + /// mutation or synchronization occurs, and no logical closure is implied. + /// + /// # Errors + /// + /// Preserves exact admission causes or an unsupported request; a failure + /// returns no shallower success report. + pub fn verify( + &self, + requested: VerificationDepth, + ) -> Result { + self.snapshot() + .map_err(catalog_error)? + .verify(requested) + .map_err(Into::into) + } + + /// Verifies a complete or explicitly shallower blob claim in this owned view. + /// + /// Costs include `snapshot` re-admission and `CatalogSnapshot::verify_blob`; + /// no new whole-blob buffer, filesystem read, write or synchronization occurs. + /// + /// # Errors + /// + /// Preserves catalog admission and logical verification failures without + /// returning partial evidence or acquiring writer authority. + pub fn verify_blob( + &self, + blob: crate::BlobId, + requested: VerificationDepth, + ) -> Result { + self.snapshot() + .map_err(catalog_error)? + .verify_blob(blob, requested) + } +} + +pub(super) fn catalog_error(source: CatalogRestartError) -> VerificationError { + if let CatalogRestartError::CatalogAdmission { source: nested } = &source + && let crate::CatalogAdmissionError::MissingSegment { digest } = nested.as_ref() + { + return VerificationError::Refused { + refusal: VerificationRefusal::Missing { + subject: VerificationSubject::Segment { digest: *digest }, + }, + source: Some(Box::new(VerificationSource::Catalog(source))), + }; + } + + if matches!(&source, CatalogRestartError::Io { phase: CatalogRestartPhase::OpenHead | CatalogRestartPhase::OpenCatalogDirectory | CatalogRestartPhase::OpenCatalog | CatalogRestartPhase::OpenSegmentDirectory | CatalogRestartPhase::OpenSegment, source } if source.kind() == ErrorKind::NotFound) + { + return VerificationError::Refused { + refusal: VerificationRefusal::Missing { + subject: VerificationSubject::PublishedCatalog, + }, + source: Some(Box::new(VerificationSource::Catalog(source))), + }; + } + let class = verification_failure_class::catalog_class(&source); + classified( + VerificationSubject::PublishedCatalog, + VerificationSource::Catalog(source), + class, + ) +} + +fn classified( + subject: VerificationSubject, + source: VerificationSource, + class: FailureClass, +) -> VerificationError { + if matches!(class, FailureClass::Operational) { + VerificationError::Operational { + source: Box::new(source), + } + } else { + VerificationError::Refused { + refusal: verification_admission::structural(subject), + source: Some(Box::new(source)), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index b3223ef3..cf10f833 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -153,14 +153,15 @@ pub use adapters::{ RetentionRecoveryOutcome, RetentionRecoveryPlan, RetentionRecoveryReceipt, RetentionRecoveryRefusal, RetentionRecoveryStep, RetentionRecoveryStorage, RetentionRootDecodeError, RetentionRootEncodeError, RetentionRootStageAssessment, - RetentionStageAssessment, RetentionStageAssessments, RetentionStorageBoundary, - RetentionStorageError, RetentionStorageProgress, 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, + RetentionSelectedRootRefusal, RetentionStageAssessment, RetentionStageAssessments, + RetentionStorageBoundary, RetentionStorageError, RetentionStorageProgress, + RetentionTransitionDisposition, RetentionTransitionError, RetentionTransitionPreflight, + RetentionTransitionPreflightError, RetentionTransitionReadiness, RetentionViewCoordinates, + RetentionViewError, RetentionViewSource, VerifiedRetentionClosure, assess_head_stage, + assess_manifest_stage, assess_root_stage, collect_retention_view, collect_verification_view, + execute_retention_publication, execute_retention_recovery, plan_retention_recovery, + plan_retention_transition, preflight_retention_transition, prepare_retention_publication, + verify_retention_closure, }; pub use adapters::{ FilesystemMigrationRecoveryRefusal, FilesystemMigrationResidueKind, MIGRATION_NAMESPACE_PREFIX, @@ -169,7 +170,7 @@ pub use adapters::{ StoreMigrationRecoveryReceipt, StoreMigrationRecoveryStorage, StoreMigrationResidue, StoreMigrationStageDecodeError, plan_store_migration_recovery, recover_store_migration, }; -pub use adapters::{VerificationError, VerificationSource}; +pub use adapters::{VerificationError, VerificationSource, verify_catalog_bytes, verify_segment}; pub use blob::{ BlobHashError, BlobHasher, BlobId, BlobLength, BlobReadError, ByteLength, ByteOffset, ByteRange, ByteRangeError, diff --git a/src/retention/mod.rs b/src/retention/mod.rs index 481b794b..6cfe2dc0 100644 --- a/src/retention/mod.rs +++ b/src/retention/mod.rs @@ -66,3 +66,6 @@ pub use root_digest::RetentionRootDigest; pub use root_error::RetentionRootError; pub use root_generation::RootGeneration; pub use root_generation_error::RootGenerationError; + +mod view_coordinates; +pub use view_coordinates::RetentionViewCoordinates; diff --git a/src/retention/view_coordinates.rs b/src/retention/view_coordinates.rs new file mode 100644 index 00000000..30192cb3 --- /dev/null +++ b/src/retention/view_coordinates.rs @@ -0,0 +1,16 @@ +//! This module owns bounded observations of catalog and retention heads. + +use crate::{CatalogDigest, CatalogGeneration, CatalogLength, RetentionHead}; + +/// 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, CatalogLength, CatalogDigest)>, + /// The `retention/HEAD` coordinate, or `None` when no retention head is published. + pub retention: Option, +} diff --git a/src/verification/rationale.md b/src/verification/rationale.md index 7d881711..013279ce 100644 --- a/src/verification/rationale.md +++ b/src/verification/rationale.md @@ -1,6 +1,6 @@ # Subject-specific verification evidence -Status: implementation in progress for #114. +Status: durable verification candidate for #114. Verification reports name the evidence established for a subject; the enum's ordering does not turn a catalog-membership proof into complete logical blob verification. @@ -35,3 +35,13 @@ Logical verification now names its exact catalog provenance and constructs a rep Blob discovery uses canonical layout order, matching the distinction between multiple lawful representations and conflicting evidence; it holds only one decoded layout at a time rather than constructing an additional whole-catalog index. The semantic refusal keeps bounded coordinates inline; narrowly scoped Clippy expectations preserve precise diagnostics without introducing extra allocation solely to reduce an error enum's stack footprint. Original adapter causes are boxed only on error and remain typed. + +The original interfaces each select one catalog, blob or retained namespace; one private `VerifiedSubject` per report satisfies this contract without an additional whole-store enumeration operation. + +Earlier aggregate wording in the implementation notes was an inference, not an implemented feature or a separately approved requirement; this reconciliation does not reduce any named subject's supported depth or omit a failed dependency from its verification. + +Moving-view ambiguity carries the actual final observation pair in a fixed-size box; resource or I/O failure is not relabeled corruption merely because it prevented a report. + +The shared retention coordinate type belongs to the domain because both ordinary collection and verification evidence name it; its existing adapter export remains compatible. + +Selected-root failures retain typed namespace, generation, digest and length evidence through the existing I/O boundary, instead of flattening it into prose. diff --git a/src/verification/refusal.rs b/src/verification/refusal.rs index 2dd386ea..251a7400 100644 --- a/src/verification/refusal.rs +++ b/src/verification/refusal.rs @@ -6,7 +6,7 @@ use std::fmt; use super::{VerificationDepth, VerificationObservation, VerificationSubject}; /// Why a verification request cannot establish its requested evidence. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum VerificationRefusal { /// Required evidence is absent from an admitted immutable view. @@ -26,7 +26,7 @@ pub enum VerificationRefusal { /// Conflicting observations prevent selection of one admissible view. Ambiguous { /// Bounded pair of conflicting observations, not an exhaustive inventory. - candidates: [VerificationSubject; 2], + candidates: Box<[crate::retention::RetentionViewCoordinates; 2]>, }, /// The operation cannot establish this depth for the requested subject. diff --git a/src/verification/report.rs b/src/verification/report.rs index a455fd3c..71ee775a 100644 --- a/src/verification/report.rs +++ b/src/verification/report.rs @@ -64,6 +64,7 @@ impl VerifiedSubject { pub struct VerificationReport { requested: VerificationDepth, catalog: Option<(CatalogGeneration, CatalogDigest)>, + retention: Option, subject: VerifiedSubject, } @@ -85,6 +86,18 @@ impl VerificationReport { self.catalog } + /// Returns the exact publication-selected retention head, when applicable. + /// + /// Root verification over supplied bytes alone has no publication coordinate. + pub const fn retention_head(&self) -> Option { + self.retention + } + + pub(crate) const fn in_retention(mut self, head: Option) -> Self { + self.retention = head; + self + } + pub(crate) const fn in_catalog( mut self, generation: CatalogGeneration, @@ -101,6 +114,7 @@ impl VerificationReport { Self { requested: depth, catalog: None, + retention: None, subject: VerifiedSubject { subject, depth }, } } diff --git a/src/verification/subject.rs b/src/verification/subject.rs index d9019263..614f3a3b 100644 --- a/src/verification/subject.rs +++ b/src/verification/subject.rs @@ -13,6 +13,18 @@ use crate::{ #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum VerificationSubject { + /// Supplied segment bytes that have not established a physical identity. + SegmentInput, + /// The catalog selected by a store's publication head, before admission. + PublishedCatalog, + /// A published catalog/retention observation before its subject admits. + PublishedView, + /// The published root requested by namespace, before its coordinates admit. + RetentionNamespace { + /// Requested opaque namespace digest. + namespace: RetentionNamespaceDigest, + }, + /// A logical blob requested independently of its physical realization. Blob { /// Complete logical byte identity. diff --git a/tests/catalog/mutation_support.rs b/tests/catalog/mutation_support.rs index 456efaf9..05f98ceb 100644 --- a/tests/catalog/mutation_support.rs +++ b/tests/catalog/mutation_support.rs @@ -53,6 +53,20 @@ pub(crate) fn assert_catalog_refusal( "mutated catalog was admitted", )?; assert!(predicate(error), "unexpected refusal: {error:?}"); + // The same corrupted bytes must retain this exact decoder cause through + // the public verification ingress, without being relabeled operational. + let head = crate::support::decode_hex( + include_str!("../../conformance/segment-store/v1/one-zero-head.hex").trim_end(), + )?; + let report_error = require_error( + keep::verify_catalog_bytes(&head, encoded, &[], keep::VerificationDepth::Framing), + "corrupt catalog received verification evidence", + )?; + assert!( + matches!(report_error, keep::VerificationError::Refused { refusal: keep::VerificationRefusal::Corrupt { .. }, source: Some(source) } if matches!(source.as_ref(), keep::VerificationSource::Catalog(keep::CatalogRestartError::Catalog { source }) if *source == error)), + "verification must preserve the existing exact corruption oracle" + ); + Ok(()) } diff --git a/tests/catalog_restart.rs b/tests/catalog_restart.rs index dd1f3ba4..23607571 100644 --- a/tests/catalog_restart.rs +++ b/tests/catalog_restart.rs @@ -5,6 +5,8 @@ mod refusal_laws; #[path = "segment_filesystem_stage/sandbox.rs"] pub mod sandbox; mod support; +#[path = "catalog_restart/verification_laws.rs"] +mod verification_laws; use std::error::Error; use std::fs; diff --git a/tests/catalog_restart/verification_laws.rs b/tests/catalog_restart/verification_laws.rs new file mode 100644 index 00000000..7f445ebd --- /dev/null +++ b/tests/catalog_restart/verification_laws.rs @@ -0,0 +1,93 @@ +//! Verification classification at the existing filesystem catalog boundary. +use super::{StoreFixture, restart_policy}; +use keep::{ + CatalogRestartByteLimit, CatalogRestartError, CatalogRestartPhase, CatalogRestartPolicy, + FilesystemCatalogSnapshot, PublicationHeadDecodeError, SegmentReadPolicy, VerificationDepth, + VerificationError, VerificationRefusal, VerificationSource, VerificationSubject, +}; +use std::error::Error; +use std::fs; + +// Size: medium. Oracle: exact frozen catalog/head and unchanged physical bytes. +// Delete if superseded by generated owned-view verification over the same corpus. +#[test] +fn owned_catalog_verification_binds_admitted_immutable_bytes() -> Result<(), Box> { + let store = StoreFixture::create("verification-catalog")?; + let before = fs::read(&store.catalog_path)?; + let view = FilesystemCatalogSnapshot::load_for_verification(store.path(), restart_policy()?)?; + let report = view.verify(VerificationDepth::CatalogReachability)?; + assert_eq!( + report.catalog(), + Some((view.generation(), view.catalog_digest())) + ); + assert_eq!( + report + .subjects() + .first() + .ok_or("missing catalog claim")? + .subject(), + VerificationSubject::Catalog { + generation: view.generation(), + digest: view.catalog_digest() + } + ); + assert_eq!(fs::read(&store.catalog_path)?, before); + store.remove() +} + +// Size: medium. Oracle: deleting the named catalog establishes missing evidence. +// Delete if a stronger deterministic fault law subsumes this boundary. +#[test] +fn verification_does_not_relabel_a_missing_catalog_as_corrupt() -> Result<(), Box> { + let store = StoreFixture::create("verification-missing-catalog")?; + fs::remove_file(&store.catalog_path)?; + let error = FilesystemCatalogSnapshot::load_for_verification(store.path(), restart_policy()?) + .err() + .ok_or("missing catalog admitted")?; + assert!( + matches!(error, VerificationError::Refused { refusal: VerificationRefusal::Missing { subject: VerificationSubject::PublishedCatalog }, source: Some(source) } if matches!(source.as_ref(), VerificationSource::Catalog(CatalogRestartError::Io { phase: CatalogRestartPhase::OpenCatalog, source }) if source.kind() == std::io::ErrorKind::NotFound)), + "missing selected catalog must retain its opening cause" + ); + store.remove() +} + +// Size: medium. Oracle: changed header version preserves both typed coordinates. +// Delete if report-level protocol mutation coverage subsumes this witness. +#[test] +fn catalog_verification_retains_the_original_head_protocol_refusal() -> Result<(), Box> { + let store = StoreFixture::create("verification-corrupt-head")?; + let path = store.path().join("HEAD"); + let mut bytes = fs::read(&path)?; + *bytes.get_mut(17).ok_or("version field absent")? = 2; + fs::write(&path, &bytes)?; + let error = FilesystemCatalogSnapshot::load_for_verification(store.path(), restart_policy()?) + .err() + .ok_or("unsupported head admitted")?; + assert!( + matches!(error, VerificationError::Refused { refusal: VerificationRefusal::Corrupt { .. }, source: Some(source) } if matches!(source.as_ref(), VerificationSource::Catalog(CatalogRestartError::Head { source: PublicationHeadDecodeError::UnsupportedVersion { expected: 1, observed: 2 } }))), + "exact protocol refusal must survive classification" + ); + assert_eq!( + fs::read(&path)?, + bytes, + "verification must preserve corrupt evidence" + ); + store.remove() +} + +// Size: medium. Oracle: configured retained bytes constrain resources, not truth. +// Delete if a stronger memory-budget law subsumes this failure boundary. +#[test] +fn catalog_memory_limits_do_not_become_corruption_claims() -> Result<(), Box> { + let store = StoreFixture::create("verification-catalog-capacity")?; + let policy = + CatalogRestartPolicy::new(SegmentReadPolicy::MAXIMUM, CatalogRestartByteLimit::new(1)?); + let error = FilesystemCatalogSnapshot::load_for_verification(store.path(), policy) + .err() + .ok_or("byte limit ignored")?; + assert!( + matches!(error, VerificationError::Operational { source } if matches!(source.as_ref(), VerificationSource::Catalog(CatalogRestartError::RetainedSegmentBytes { maximum: 1, observed: 337 }))), + "exact bounded loading refusal must survive" + ); + store.remove() +} diff --git a/tests/catalog_verification_ceiling.rs b/tests/catalog_verification_ceiling.rs new file mode 100644 index 00000000..adcaa8db --- /dev/null +++ b/tests/catalog_verification_ceiling.rs @@ -0,0 +1,101 @@ +//! Catalog-ceiling evidence at the public admission and verification boundary. + +use keep::{ + AdmittedSegment, AdmittedSegmentRecord, CanonicalCatalog, CanonicalPublicationHead, + CatalogGeneration, ChecksummedPublicationHead, SegmentReadPolicy, SegmentRecordLimit, + SegmentStage, StagedSegment, VerificationDepth, VerificationSubject, +}; +use std::error::Error; +use std::io::{self, Write}; + +// Size: small by resource topology (one thread, no I/O), heavyweight by memory +// and latency. Oracle: v1's 1048576-entry ceiling, exact named sample bytes, +// and the documented 1 GiB incremental admission/report allocation ceiling. +// Delete if the format ceiling changes or stronger bounded admission covers it. +#[test] +fn the_catalog_entry_ceiling_verifies_within_its_declared_memory_bound() +-> Result<(), Box> { + let bytes = ceiling_segment()?; + let segments = [AdmittedSegment::decode(&bytes, SegmentReadPolicy::MAXIMUM)?]; + let catalog = CanonicalCatalog::from_segments(CatalogGeneration::new(1)?, None, &segments)?; + let head = CanonicalPublicationHead::for_catalog(catalog.checksummed()); + let mut outcome = None; + let allocation = allocation_counter::measure(|| { + outcome = Some(verify_ceiling( + head.encoded(), + catalog.checksummed(), + &segments, + )); + }); + let report = outcome.ok_or("verification did not run")??; + assert_eq!(report.requested(), VerificationDepth::CatalogReachability); + assert_eq!( + report + .subjects() + .first() + .ok_or("missing catalog evidence")? + .subject(), + VerificationSubject::Catalog { + generation: CatalogGeneration::new(1)?, + digest: catalog.checksummed().digest() + } + ); + assert!( + allocation.bytes_max <= 1_073_741_824, + "catalog-ceiling admission/report memory exceeded 1 GiB: {allocation:?}" + ); + Ok(()) +} + +fn verify_ceiling( + head: &[u8], + catalog: keep::ChecksummedCatalog<'_>, + segments: &[AdmittedSegment<'_>], +) -> Result> { + let snapshot = ChecksummedPublicationHead::decode(head)?.admit(catalog.admit(segments)?)?; + assert_eq!( + snapshot.record_count(), + 1_048_576, + "the admitted catalog must contain the full protocol domain" + ); + for value in [0_u64, 524_288, 1_048_575] { + let expected = value.to_be_bytes(); + let identity = keep::SegmentRecordIdentity::Chunk(keep::ChunkId::hash_bytes(&expected)?); + assert_eq!( + snapshot + .record(identity) + .ok_or("named ceiling-domain record absent")? + .payload(), + expected, + "logical lookup must return the named sample's exact bytes" + ); + } + Ok(snapshot.verify(VerificationDepth::CatalogReachability)?) +} + +fn ceiling_segment() -> Result, Box> { + let mut bytes = Vec::new(); + let mut writer = StagedSegment::begin(MemorySegment(&mut bytes), SegmentRecordLimit::MAXIMUM)?; + for value in 0_u64..1_048_576 { + writer = writer.append(AdmittedSegmentRecord::for_chunk(&value.to_be_bytes())?)?; + } + let sealed = writer.seal()?; + let _closed = sealed.close(); + Ok(bytes) +} + +struct MemorySegment<'a>(&'a mut Vec); +impl Write for MemorySegment<'_> { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} +impl SegmentStage for MemorySegment<'_> { + fn synchronize(&mut self) -> io::Result<()> { + Ok(()) + } +} diff --git a/tests/segment.rs b/tests/segment.rs index 799f8bf6..18238107 100644 --- a/tests/segment.rs +++ b/tests/segment.rs @@ -103,3 +103,53 @@ fn one_record_bytes() -> Result, Box> { .map(<[u8]>::to_vec) .ok_or_else(|| "segment fixture lacks its complete record".into()) } + +// Each existing malformed-segment law crosses the verification ingress and +// then applies its original exact typed oracle to the retained decoder cause. +fn verification_refusal( + encoded: &[u8], + policy: SegmentReadPolicy, +) -> Result> { + let error = keep::verify_segment(encoded, policy, keep::VerificationDepth::Framing) + .err() + .ok_or("malformed segment received a verification report")?; + let source = match error { + keep::VerificationError::Operational { source } => { + assert!( + matches!( + *source, + keep::VerificationSource::Segment(keep::SegmentReadError::RecordCountLimit { + maximum: 0, + observed: 1 + }) + ), + "only the explicit configured limit is operational in this corpus" + ); + source + } + keep::VerificationError::Refused { + refusal: keep::VerificationRefusal::Corrupt { .. }, + source: Some(source), + } => { + assert!( + !matches!( + *source, + keep::VerificationSource::Segment( + keep::SegmentReadError::RecordCountLimit { .. } + ) + ), + "resource limits must not become corruption" + ); + source + } + other => { + return Err( + format!("segment contradiction lost classification or source: {other:?}").into(), + ); + } + }; + let keep::VerificationSource::Segment(source) = *source else { + return Err("original segment cause lost".into()); + }; + Ok(source) +} diff --git a/tests/segment/framing_laws.rs b/tests/segment/framing_laws.rs index 2b39e872..c283aa03 100644 --- a/tests/segment/framing_laws.rs +++ b/tests/segment/framing_laws.rs @@ -3,8 +3,7 @@ use std::error::Error; use keep::{ - AdmittedSegment, LayoutEntryLimit, SegmentReadError, SegmentReadPolicy, SegmentRecordLimit, - SegmentSealError, + LayoutEntryLimit, SegmentReadError, SegmentReadPolicy, SegmentRecordLimit, SegmentSealError, }; use super::format_oracle::seal_segment; @@ -141,8 +140,5 @@ fn declared_count_above_physical_records_refuses_the_missing_header() -> Result< } fn refusal(encoded: &[u8], policy: SegmentReadPolicy) -> Result> { - match AdmittedSegment::decode(encoded, policy) { - Ok(_admitted) => Err("malformed complete segment was admitted".into()), - Err(error) => Ok(error), - } + super::verification_refusal(encoded, policy) } diff --git a/tests/segment/identity_laws.rs b/tests/segment/identity_laws.rs index 32f2abfb..f37dbf2b 100644 --- a/tests/segment/identity_laws.rs +++ b/tests/segment/identity_laws.rs @@ -3,8 +3,8 @@ use std::error::Error; use keep::{ - AdmittedSegment, SegmentHeaderError, SegmentReadError, SegmentRecordAdmissionError, - SegmentRecordDecodeError, SegmentRecordHeaderError, + SegmentHeaderError, SegmentReadError, SegmentRecordAdmissionError, SegmentRecordDecodeError, + SegmentRecordHeaderError, }; use super::format_oracle::seal_segment; @@ -148,8 +148,5 @@ fn segment_header_refusal_precedes_outer_digest_admission() -> Result<(), Box Result> { - match AdmittedSegment::decode(encoded, maximum_policy()) { - Ok(_admitted) => Err("malformed complete segment was admitted".into()), - Err(error) => Ok(error), - } + super::verification_refusal(encoded, maximum_policy()) } diff --git a/tests/verification_ingress.rs b/tests/verification_ingress.rs new file mode 100644 index 00000000..d8ee018c --- /dev/null +++ b/tests/verification_ingress.rs @@ -0,0 +1,88 @@ +//! Raw segment verification admits bytes before issuing a typed report. +mod support; +use keep::{ + LayoutEntryLimit, SegmentHeaderError, SegmentReadError, SegmentReadPolicy, SegmentRecordLimit, + SegmentSealError, VerificationDepth as Depth, VerificationError, VerificationRefusal, + VerificationSource, verify_segment, +}; +use std::error::Error; + +const SEGMENT: &str = include_str!("../conformance/segment-store/v1/one-zero-segment.hex"); + +// Size: small. Oracle: supported physical depths over the frozen one-zero segment. +// Delete if raw segment verification is removed or stronger corpus laws subsume it. +#[test] +fn raw_segment_reports_are_only_issued_after_complete_admission() -> Result<(), Box> { + let bytes = support::decode_hex(SEGMENT.trim_end())?; + for depth in [Depth::Framing, Depth::Checksum] { + let report = verify_segment(&bytes, SegmentReadPolicy::MAXIMUM, depth)?; + assert_eq!(report.requested(), depth); + assert_eq!( + report + .subjects() + .first() + .ok_or("missing segment claim")? + .depth(), + depth + ); + } + Ok(()) +} + +// Size: small. Oracle: version one is required; a changed version is a typed +// protocol contradiction even when only framing evidence was requested. +// Delete if the segment format admission contract is deliberately replaced. +#[test] +fn segment_version_refusal_preserves_both_protocol_coordinates() -> Result<(), Box> { + let mut bytes = support::decode_hex(SEGMENT.trim_end())?; + *bytes.get_mut(17).ok_or("version field absent")? = 2; + let error = verify_segment(&bytes, SegmentReadPolicy::MAXIMUM, Depth::Framing) + .err() + .ok_or("unsupported version admitted")?; + assert!( + matches!(error, VerificationError::Refused { refusal: VerificationRefusal::Corrupt { .. }, source: Some(source) } if matches!(source.as_ref(), VerificationSource::Segment(SegmentReadError::Header { source: SegmentHeaderError::UnsupportedVersion { expected: 1, observed: 2 } }))), + "exact original protocol contradiction required" + ); + Ok(()) +} + +// Size: small. Oracle: exact stored versus canonical seal checksum coordinates. +// Delete when a stronger generated integrity law includes this witness. +#[test] +fn segment_checksum_refusal_preserves_expected_and_observed_bytes() -> Result<(), Box> { + let mut bytes = support::decode_hex(SEGMENT.trim_end())?; + let offset = bytes.len().checked_sub(32).ok_or("seal checksum absent")?; + let expected: [u8; 32] = bytes + .get(offset..) + .ok_or("seal checksum absent")? + .try_into()?; + *bytes.last_mut().ok_or("empty segment")? ^= 1; + let observed: [u8; 32] = bytes + .get(offset..) + .ok_or("seal checksum absent")? + .try_into()?; + let error = verify_segment(&bytes, SegmentReadPolicy::MAXIMUM, Depth::Checksum) + .err() + .ok_or("corrupt seal admitted")?; + assert!( + matches!(error, VerificationError::Refused { refusal: VerificationRefusal::Corrupt { .. }, source: Some(source) } if matches!(source.as_ref(), VerificationSource::Segment(SegmentReadError::Seal { source: SegmentSealError::SealChecksumMismatch { expected: actual_expected, observed: actual_observed } }) if *actual_expected == expected && *actual_observed == observed)), + "exact seal checksum source must survive" + ); + Ok(()) +} + +// Size: small. Oracle: caller-selected admission capacity is not content corruption. +// Delete if segment resource policy is replaced with a stronger boundary contract. +#[test] +fn a_segment_policy_limit_remains_an_operational_failure() -> Result<(), Box> { + let bytes = support::decode_hex(SEGMENT.trim_end())?; + let policy = SegmentReadPolicy::new(SegmentRecordLimit::new(0)?, LayoutEntryLimit::MAXIMUM); + let error = verify_segment(&bytes, policy, Depth::Checksum) + .err() + .ok_or("record limit ignored")?; + assert!( + matches!(error, VerificationError::Operational { source } if matches!(source.as_ref(), VerificationSource::Segment(SegmentReadError::RecordCountLimit { maximum: 0, observed: 1 }))), + "exact resource limit must survive without a corruption claim" + ); + Ok(()) +} diff --git a/tests/verification_view.rs b/tests/verification_view.rs new file mode 100644 index 00000000..c92a9731 --- /dev/null +++ b/tests/verification_view.rs @@ -0,0 +1,221 @@ +//! Deterministic verification collection laws at the observation port. + +mod support; +use keep::{ + CatalogGeneration, ChecksummedPublicationHead, ChecksummedRetentionHead, ReaderAttemptLimit, + RetentionViewCoordinates, RetentionViewError, RetentionViewSource, VerificationError, + VerificationRefusal, VerificationSource, VerificationSubject, collect_verification_view, +}; +use std::collections::VecDeque; +use std::error::Error; +use std::io; + +// Size: small. Oracle: a reportable view must belong to agreeing observations. +// This is port-level deterministic scheduling, not filesystem interleaving proof. +// Delete when a stronger generated collection model subsumes these schedules. +#[test] +fn changing_catalogs_discard_the_superseded_view() -> Result<(), Box> { + let first = coordinates(1)?; + let second = coordinates(2)?; + let mut source = Views { + coordinates: [Ok(first), Ok(second), Ok(second), Ok(second)].into(), + values: ["superseded", "stable"].into(), + }; + assert_eq!( + collect_verification_view(&mut source, ReaderAttemptLimit::DEFAULT)?, + "stable", + "only the agreeing observation may supply the returned view" + ); + Ok(()) +} + +// Size: small. Oracle: exhausted collection retains the actual last pair, not +// the first pair, an invented candidate, a partial view or a corruption claim. +// Delete when a stronger bounded history model includes this exact schedule. +#[test] +fn exhausted_collection_reports_exact_conflicting_candidates() -> Result<(), Box> { + let [first, second, third, fourth] = [ + coordinates(1)?, + coordinates(2)?, + coordinates(3)?, + coordinates(4)?, + ]; + let mut source = Views { + coordinates: [Ok(first), Ok(second), Ok(third), Ok(fourth)].into(), + values: ["first rejected view", "second rejected view"].into(), + }; + let error = collect_verification_view( + &mut source, + ReaderAttemptLimit::new(std::num::NonZeroU32::new(2).ok_or("zero attempts")?), + ) + .err() + .ok_or("moving view certified")?; + let VerificationError::Refused { + refusal: VerificationRefusal::Ambiguous { candidates }, + source: Some(source), + } = error + else { + return Err("conflicting observations lost ambiguity evidence".into()); + }; + assert_eq!( + *candidates, + [third, fourth], + "last actual observations are the bounded conflict witness" + ); + assert!( + matches!( + *source, + VerificationSource::View(RetentionViewError::AttemptsExhausted { attempts: 2 }) + ), + "original collection limit must survive" + ); + Ok(()) +} + +// Size: small. Oracle: retention publication is part of the complete view. +// Delete when a generated collection law subsumes head appearance/disappearance. +#[test] +fn retention_head_changes_are_not_hidden_by_a_stable_catalog() -> Result<(), Box> { + let before = coordinates(1)?; + let bytes = support::decode_hex( + include_str!("../conformance/segment-store/v2/one-root-head.hex").trim_end(), + )?; + let after = RetentionViewCoordinates { + retention: Some(*ChecksummedRetentionHead::decode(&bytes)?.head()), + ..before + }; + let mut source = Views { + coordinates: [Ok(before), Ok(after)].into(), + values: ["unbound"].into(), + }; + let error = collect_verification_view( + &mut source, + ReaderAttemptLimit::new(std::num::NonZeroU32::MIN), + ) + .err() + .ok_or("retention change hidden")?; + assert!( + matches!(error, VerificationError::Refused { refusal: VerificationRefusal::Ambiguous { ref candidates }, .. } if **candidates == [before, after]), + "exact full-head disagreement required: {error:?}" + ); + Ok(()) +} + +// Size: small. Oracle: failed observation does not prove absence or corruption. +// Delete when a broader operational-source conformance law subsumes it. +#[test] +fn an_observation_failure_preserves_its_operational_cause() -> Result<(), Box> { + let mut source = Views { + coordinates: [Err(io::Error::from(io::ErrorKind::PermissionDenied))].into(), + values: [].into(), + }; + let error = collect_verification_view(&mut source, ReaderAttemptLimit::DEFAULT) + .err() + .ok_or("unreadable view certified")?; + assert!( + matches!(error, VerificationError::Operational { source } if matches!(source.as_ref(), VerificationSource::View(RetentionViewError::Io { source }) if source.kind() == io::ErrorKind::PermissionDenied)), + "permission failure must remain operational" + ); + Ok(()) +} + +// Size: small. Oracle: an observed absent publication head is exact absence. +// Delete when publication permits a headless catalog as a complete view. +#[test] +fn no_published_catalog_is_missing_evidence() -> Result<(), Box> { + let mut source = Views { + coordinates: [Ok(RetentionViewCoordinates { + catalog: None, + retention: None, + })] + .into(), + values: [].into(), + }; + let error = collect_verification_view(&mut source, ReaderAttemptLimit::DEFAULT) + .err() + .ok_or("headless view certified")?; + assert!( + matches!(error, VerificationError::Refused { refusal: VerificationRefusal::Missing { subject: VerificationSubject::PublishedCatalog }, source: Some(source) } if matches!(*source, VerificationSource::View(RetentionViewError::CatalogAbsent))), + "missing head must retain its exact cause" + ); + Ok(()) +} + +fn coordinates(generation: u64) -> Result> { + let bytes = support::decode_hex( + include_str!("../conformance/segment-store/v1/one-zero-head.hex").trim_end(), + )?; + let head = ChecksummedPublicationHead::decode(&bytes)?; + Ok(RetentionViewCoordinates { + catalog: Some(( + CatalogGeneration::new(generation)?, + head.catalog_length(), + head.catalog_digest(), + )), + retention: None, + }) +} + +struct Views { + coordinates: VecDeque>, + values: VecDeque<&'static str>, +} +impl RetentionViewSource for Views { + type View = &'static str; + fn coordinates(&mut self) -> io::Result { + self.coordinates + .pop_front() + .ok_or_else(|| io::Error::other("observation schedule exhausted"))? + } + fn load(&mut self) -> io::Result { + self.values + .pop_front() + .ok_or_else(|| io::Error::other("view schedule exhausted")) + } +} + +// Size: small. Oracle: the decoder's precise version contradiction is corruption, +// and remains reachable as the original typed cause after view collection. +// Delete when a stronger public ingress matrix covers this observation path. +#[test] +fn malformed_publication_observations_preserve_the_decoder_contradiction() +-> Result<(), Box> { + let cause = keep::PublicationHeadDecodeError::UnsupportedVersion { + expected: 1, + observed: 2, + }; + let mut source = Views { + coordinates: [Err(io::Error::new(io::ErrorKind::InvalidData, cause))].into(), + values: [].into(), + }; + let error = collect_verification_view(&mut source, ReaderAttemptLimit::DEFAULT) + .err() + .ok_or("malformed publication certified")?; + let VerificationError::Refused { + refusal: + VerificationRefusal::Corrupt { + subject: VerificationSubject::PublishedCatalog, + .. + }, + source: Some(source), + } = error + else { + return Err("known contradiction lost its corruption classification".into()); + }; + let VerificationSource::View(RetentionViewError::Io { source }) = *source else { + return Err("original observation boundary lost".into()); + }; + assert!( + matches!( + source + .get_ref() + .and_then(|source| source.downcast_ref::()), + Some(keep::PublicationHeadDecodeError::UnsupportedVersion { + expected: 1, + observed: 2 + }) + ), + "exact version contradiction must survive" + ); + Ok(()) +} From 28c9417f7a4abcb7650678badcb85875263462fc Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 18:57:36 -0700 Subject: [PATCH 06/18] test: map existing corruption laws through verification outcomes (#114) --- docs/audits/114-durable-verification-scope.md | 26 +++++++-- docs/invariants/verification/README.md | 2 + docs/invariants/verification/requirements.md | 2 +- docs/testing-evidence/durable-verification.md | 27 +++++++++ src/adapters/mod.rs | 1 + .../filesystem_retention_verification.rs | 12 ++-- src/adapters/verification_decode_error.rs | 29 ++++++++++ src/verification/subject.rs | 4 ++ tests/layout_mutations.rs | 27 +++++---- tests/publication_head.rs | 42 +++++++++----- tests/retention_head_codec.rs | 46 +++++++++++---- .../retention_manifest_codec/refusal_laws.rs | 42 +++++++++++--- tests/retention_root_decoding.rs | 24 ++++---- .../verification_corruption/layout_decode.rs | 36 ++++++++++++ tests/verification_corruption/observation.rs | 57 +++++++++++++++++++ tests/verification_corruption/root_decode.rs | 33 +++++++++++ 16 files changed, 341 insertions(+), 69 deletions(-) create mode 100644 src/adapters/verification_decode_error.rs create mode 100644 tests/verification_corruption/layout_decode.rs create mode 100644 tests/verification_corruption/observation.rs create mode 100644 tests/verification_corruption/root_decode.rs diff --git a/docs/audits/114-durable-verification-scope.md b/docs/audits/114-durable-verification-scope.md index 44deed3f..91ba118e 100644 --- a/docs/audits/114-durable-verification-scope.md +++ b/docs/audits/114-durable-verification-scope.md @@ -1,8 +1,8 @@ # Durable verification landing scope -Status: implementation work in progress for [#114](https://github.com/flyingrobots/keep/issues/114), under verification parent [#20](https://github.com/flyingrobots/keep/issues/20). +Status: implementation candidate for [#114](https://github.com/flyingrobots/keep/issues/114), under verification parent [#20](https://github.com/flyingrobots/keep/issues/20). -This ledger reconciles the requested verification outcome with the code available at the branch baseline; it does not establish a runtime guarantee or mark an acceptance criterion complete. +This ledger reconciles the requested verification outcome with the code available at the branch baseline; current closure dispositions and their evidence are recorded below. ## Source of authority @@ -38,7 +38,7 @@ These are inspected implementation boundaries, not new execution receipts. ## Closure ledger -Catalog, segment, logical-record, complete-blob and admitted-root reporting now have [runtime and static/API evidence](../testing-evidence/durable-verification.md); every full-issue obligation below remains open until its entire exit condition is met. +The following table defines the unchanged acceptance exits; the disposition table below links their implementation and evidence. | Obligation | Required implementation boundary | Concrete exit condition | | --- | --- | --- | @@ -66,7 +66,7 @@ The current root reader's message-only errors require a focused boundary decisio ## Evidence and exclusions -This is documentation-only scope reconciliation, based on source inspection at the two commits above; no runtime RED/GREEN claim is made for this ledger. +The initial ledger was documentation-only scope reconciliation at the two commits above; subsequent runtime evidence is retained in the consolidated execution document. New runtime assertions must be calibrated against the behavior they protect; absence of a new API on the parent is a compile failure, not a behavioral RED receipt. @@ -85,3 +85,21 @@ The independent bounded preflight agreed that the original named interfaces do n Raw segment/catalog classification, owned filesystem catalog reports, selected-namespace retention reports, precise moving-view candidates, typed selected-root diagnostics, and the scoped catalog-ceiling allocation law are implemented in the current candidate. The [consolidated evidence](../testing-evidence/durable-verification.md) records their runtime checks and falsification; final full validation and independent exact-head review remain acceptance gates, not assumptions inferred from earlier green commits. + +The independent exact-head review of `6504c86` found one acceptance gap in existing corruption-law mapping; the follow-up retains those laws' exact assertions while exercising production verification classification, with focused debug/release and mutation evidence. Final delta review and pushed-head checks remain pending. + +## Implementation disposition + +| Obligation | Disposition and evidence | +| --- | --- | +| Subjects and depths | Implemented: explicit per-subject supported/refused matrix, v1/v2 corpus, empty evidence, absent members and complete blob/root closure laws. | +| Requested versus achieved | Implemented: immutable private report construction, exact subject/request/depth runtime assertions and compile-fail API laws. | +| Four failure classes | Implemented: raw and filesystem ingress, typed causes, original corruption-law mapping, exact bounded moving-view candidates; decoder/resource/classification mutations observed RED. | +| Consistent snapshot | Implemented: existing double collection and fence, exact catalog/retention provenance, rejected moving views and namespace substitution; independent mutations observed RED. | +| Bounded cost and contents | Implemented: documented ingress/admission costs and report authority, caller limits and precisely scoped catalog-ceiling allocation evidence. No total-process memory claim. | +| Read-only behavior | Implemented: unchanged filesystem evidence on success/refusal; an injected production write fails the persistent-evidence assertion. | +| Honest delivery | Normative contract, public rustdoc, rationale, requirement status and consolidated evidence reconciled. Mainline integration is not claimed before merge. | + +The independent-review finding on `6504c86` is implemented and calibrated in its follow-up; approval of that delta and required checks must be recorded against the resulting exact head in [PR #165](https://github.com/flyingrobots/keep/pull/165) before it leaves draft. + +This table closes implementation obligations, not the independent review or human merge gate; the PR is the live authority for those exact-head decisions. diff --git a/docs/invariants/verification/README.md b/docs/invariants/verification/README.md index 24a7b783..9bc0b18e 100644 --- a/docs/invariants/verification/README.md +++ b/docs/invariants/verification/README.md @@ -78,3 +78,5 @@ Blob and root verification distinguish missing catalog members, demonstrated con The report preserves the catalog generation/digest used by catalog, blob and root operations; this provenance is not a live fence. Multiple valid layouts for a blob are representations, not automatically ambiguity: discovery selects the first canonical identity. Immutable admitted-view operations have no moving observation to classify; ambiguity is produced by the durable collection path, retaining at most the last conflicting coordinate pair and the original attempt-limit cause. + +Raw layout/root decoder errors also convert into `VerificationError` without losing their typed causes; these conversions name unadmitted input subjects and cannot manufacture a report. diff --git a/docs/invariants/verification/requirements.md b/docs/invariants/verification/requirements.md index 2f0259c2..c927c507 100644 --- a/docs/invariants/verification/requirements.md +++ b/docs/invariants/verification/requirements.md @@ -4,4 +4,4 @@ The [original task](../../audits/114-durable-verification-scope.md) remains auth | ID | Requirement | Status | Evidence and remaining work | | --- | --- | --- | --- | -| `KEEP-VERIFY-006` | Durable verification at explicit subject-specific achieved depths, with precise refusals, immutable reports and bounded costs. | In progress | The candidate implements subject-specific catalog, segment, record, blob and retained-namespace reports, typed durable ingress outcomes and bounded conflict evidence; focused runtime/calibration and catalog-ceiling evidence are recorded. Final full validation and exact-head independent acceptance remain open. See the [closure ledger](../../audits/114-durable-verification-scope.md) and [execution evidence](../../testing-evidence/durable-verification.md). | +| `KEEP-VERIFY-006` | Durable verification at explicit subject-specific achieved depths, with precise refusals, immutable reports and bounded costs. | Implemented on the PR branch | The candidate implements subject-specific catalog, segment, record, blob and retained-namespace reports, typed durable ingress outcomes and bounded conflict evidence; focused runtime/calibration and catalog-ceiling evidence are recorded. Exact-head validation, independent acceptance and mainline integration are recorded on [PR #165](https://github.com/flyingrobots/keep/pull/165); implementation does not imply merged delivery. See the [closure ledger](../../audits/114-durable-verification-scope.md) and [execution evidence](../../testing-evidence/durable-verification.md). | diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index 42c558a4..908b2f87 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -176,3 +176,30 @@ The first broad copied-tree run passed product tests but stopped on two tooling- The singleton report interpretation is explicitly reconciled in the normative page and scope ledger: the original named interfaces each select one subject, and no whole-store aggregate enumeration is claimed. Final required checks and independent review remain pending for the exact committed/pushed candidate; earlier receipts do not transfer approval to a different head. + +## Independent-review corruption mapping closure + +The independent Codex review of `6504c869a379a18db907aed79f7fa3a3b375d9e9` requested complete mapping of existing corruption laws through production verification classification; it found no demonstrated production correctness defect and accepted the explicit singleton and incremental-memory scope reconciliation. + +The review is recorded at [PR #165 review comment](https://github.com/flyingrobots/keep/pull/165#issuecomment-5964309362), with the full checklist in `codex-review-6504c86.md`. + +The follow-up preserves original exact decoder assertions and routes their real decoded failures through the following public production boundaries; the test support only extracts the original cause after checking classification, never substitutes an expected error. + +| Existing corruption family | Production verification boundary | Retained oracle | +| --- | --- | --- | +| Complete-segment framing, record identity, nested header/checksum | `verify_segment` | Original `SegmentReadError` fields and nested causes in `tests/segment`. | +| Canonical catalog mutation fields | `verify_catalog_bytes` | Same `CatalogDecodeError` as direct admission in `tests/catalog/mutation_support.rs`. | +| Publication-head fields, width, checksum and coordinates | `collect_verification_view` | Original `PublicationHeadDecodeError` from real decoding in `tests/publication_head.rs`. | +| Retention head and manifest framing, integrity and semantics | `collect_verification_view` | Original decoder error inside the same `RetentionCurrentStateRefusal` observation boundary. | +| Root framing, integrity, anchor identity and semantic admission | Public `VerificationError::from(RetentionRootDecodeError)` used by raw root classification | Original `RetentionRootDecodeError` in `tests/retention_root_decoding.rs`. | +| Layout mutation corpus, policy limit and expected identity | Public `VerificationError::from(LayoutDecodeError)` using the same layout classifier as blob verification | Original first-failure predicate and typed coordinates in `tests/layout_mutations.rs`. | + +The root and layout raw-error conversions name unadmitted input subjects, allocate only the error box and confer no verified report or identity; original typed resource failures remain operational. + +The mapped laws pass in debug and release, with all-target/all-feature Clippy denying warnings; receipts are `corruption-mapping-final-green.log` and the preceding setup/compiler failures, which remain excluded from behavioral RED evidence. + +Three isolated production mutations inverted raw root classification, inverted layout classification, and discarded known retention-observation classification; each compiled and produced the intended runtime failure in the existing corruption suites, recorded with original source and command under `corruption-mutants` and summarized in `corruption-calibration-corrected.log`. + +The earlier `6504c86` candidate passed the corrected local workspace debug/release, both feature-mode Clippy, doctests, documentation build, structure and formatting checks, and all four required hosted checks in [run 37087373122](https://github.com/flyingrobots/keep/actions/runs/37087373122). + +Those green results do not approve this subsequent mapping delta; the final pushed head requires its own independent confirmation and hosted checks before readiness. diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 27a17b88..a17f843b 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -235,6 +235,7 @@ mod sync_capable_directory; #[path = "../../tests/support/mod.rs"] mod test_support; mod verification_admission; +mod verification_decode_error; mod verification_error; mod verification_failure_class; mod verification_ingress; diff --git a/src/adapters/retention/filesystem_retention_verification.rs b/src/adapters/retention/filesystem_retention_verification.rs index abbc5e1c..7c9b3266 100644 --- a/src/adapters/retention/filesystem_retention_verification.rs +++ b/src/adapters/retention/filesystem_retention_verification.rs @@ -92,15 +92,11 @@ fn decode_error( subject: VerificationSubject, source: RetentionRootDecodeError, ) -> VerificationError { - if matches!(source, RetentionRootDecodeError::Allocation { .. }) { - return VerificationError::Operational { - source: Box::new(VerificationSource::Root(source)), - }; - } - VerificationError::Refused { - refusal: verification_admission::structural(subject), - source: Some(Box::new(VerificationSource::Root(source))), + let mut error = VerificationError::from(source); + if let VerificationError::Refused { refusal, .. } = &mut error { + *refusal = verification_admission::structural(subject); } + error } fn view_error(source: FilesystemRetentionSnapshotError) -> VerificationError { diff --git a/src/adapters/verification_decode_error.rs b/src/adapters/verification_decode_error.rs new file mode 100644 index 00000000..aacf5495 --- /dev/null +++ b/src/adapters/verification_decode_error.rs @@ -0,0 +1,29 @@ +//! This module owns typed decoder-to-verification error admission. + +use super::{VerificationError, VerificationSource, verification_admission}; +use crate::{LayoutDecodeError, RetentionRootDecodeError, VerificationSubject}; + +impl From for VerificationError { + /// Classifies a raw layout failure, preserving its original typed cause. + /// Allocates only a boxed error; performs no I/O or verification upgrade. + fn from(source: LayoutDecodeError) -> Self { + verification_admission::layout(VerificationSubject::LayoutInput, source) + } +} + +impl From for VerificationError { + /// Classifies a raw root failure without asserting an admitted root identity. + /// Allocation failure is operational; contradictory bytes are corruption. + /// Allocates only a boxed error and performs no I/O. + fn from(source: RetentionRootDecodeError) -> Self { + if matches!(source, RetentionRootDecodeError::Allocation { .. }) { + return Self::Operational { + source: Box::new(VerificationSource::Root(source)), + }; + } + Self::Refused { + refusal: verification_admission::structural(VerificationSubject::RetentionRootInput), + source: Some(Box::new(VerificationSource::Root(source))), + } + } +} diff --git a/src/verification/subject.rs b/src/verification/subject.rs index 614f3a3b..33dd8801 100644 --- a/src/verification/subject.rs +++ b/src/verification/subject.rs @@ -15,6 +15,10 @@ use crate::{ pub enum VerificationSubject { /// Supplied segment bytes that have not established a physical identity. SegmentInput, + /// Supplied layout bytes before canonical identity admission. + LayoutInput, + /// Supplied retention-root bytes before namespace and identity admission. + RetentionRootInput, /// The catalog selected by a store's publication head, before admission. PublishedCatalog, /// A published catalog/retention observation before its subject admits. diff --git a/tests/layout_mutations.rs b/tests/layout_mutations.rs index 44372cc4..58975ae6 100644 --- a/tests/layout_mutations.rs +++ b/tests/layout_mutations.rs @@ -3,6 +3,8 @@ #[path = "layout_mutations/support.rs"] pub mod layout_mutation_support; pub mod support; +#[path = "verification_corruption/layout_decode.rs"] +mod verification_decode; use std::error::Error; @@ -31,7 +33,10 @@ fn every_frozen_mutation_reaches_its_exact_first_failure_phase() -> Result<(), B ); continue; } - let error = require_error(result, "mutation was unexpectedly admitted")?; + let error = verification_decode::classified(require_error( + result, + "mutation was unexpectedly admitted", + )?)?; assert_eq!( classify(&error), Some(mutation.expected_outcome()), @@ -46,10 +51,10 @@ fn every_frozen_mutation_reaches_its_exact_first_failure_phase() -> Result<(), B fn configured_entry_cap_refuses_before_materialization() -> Result<(), Box> { let bytes = layout_record_bytes("max-plus-one-zeros")?; let policy = LayoutDecodePolicy::new(LayoutEntryLimit::new(1)?); - let error = require_error( + let error = verification_decode::classified(require_error( AdmittedLayout::decode_record(&bytes, policy), "two entries were admitted under a one-entry cap", - )?; + )?)?; assert!(matches!( error, @@ -65,13 +70,13 @@ fn configured_entry_cap_refuses_before_materialization() -> Result<(), Box Result<(), Box> { let empty_id = layout_case_field("empty", 10)?.parse::()?; let one_zero_bytes = layout_record_bytes("one-zero")?; - let length_error = require_error( + let length_error = verification_decode::classified(require_error( AdmittedLayout::decode_record( &one_zero_bytes, LayoutDecodePolicy::new(LayoutEntryLimit::MAXIMUM).with_expected_id(empty_id), ), "a different record length matched expected identity", - )?; + )?)?; assert!(matches!( length_error, LayoutDecodeError::LayoutIdentity { @@ -85,13 +90,13 @@ fn expected_layout_identity_is_checked_after_structural_admission() -> Result<() .ok_or_else(|| invalid_corpus("layout identity binary is empty"))?; *last ^= 1; let altered_id = LayoutId::parse_binary(&altered_coordinate)?; - let digest_error = require_error( + let digest_error = verification_decode::classified(require_error( AdmittedLayout::decode_record( &one_zero_bytes, LayoutDecodePolicy::new(LayoutEntryLimit::MAXIMUM).with_expected_id(altered_id), ), "a different layout digest matched expected identity", - )?; + )?)?; assert!(matches!( digest_error, LayoutDecodeError::LayoutIdentity { @@ -109,13 +114,13 @@ fn earlier_layout_laws_precede_zero_lengths_in_later_entry_decoding() -> Result< overwrite(&mut cardinality, 44, 103, bytes_between(&empty, 44, 103)?)?; overwrite(&mut cardinality, 152, 156, &[0_u8; 4])?; recompute_record_checksum(&mut cardinality)?; - let cardinality_error = require_error( + let cardinality_error = verification_decode::classified(require_error( AdmittedLayout::decode_record( &cardinality, LayoutDecodePolicy::new(LayoutEntryLimit::MAXIMUM), ), "empty cardinality with a zero-length entry was admitted", - )?; + )?)?; assert!(matches!( cardinality_error, LayoutDecodeError::Validation { @@ -130,13 +135,13 @@ fn earlier_layout_laws_precede_zero_lengths_in_later_entry_decoding() -> Result< .mutated_record()?; overwrite(&mut ordering, 284, 288, &[0_u8; 4])?; recompute_record_checksum(&mut ordering)?; - let ordering_error = require_error( + let ordering_error = verification_decode::classified(require_error( AdmittedLayout::decode_record( &ordering, LayoutDecodePolicy::new(LayoutEntryLimit::MAXIMUM), ), "an earlier gap was hidden by a later zero-length entry", - )?; + )?)?; assert!(matches!( ordering_error, LayoutDecodeError::Validation { diff --git a/tests/publication_head.rs b/tests/publication_head.rs index 0b6ec036..6cd086e4 100644 --- a/tests/publication_head.rs +++ b/tests/publication_head.rs @@ -1,4 +1,7 @@ -//! Public publication-head framing and checksum laws. +//! Codec laws with public verification classification of every corrupt input. + +#[path = "verification_corruption/observation.rs"] +mod verification_observation; mod support; @@ -33,7 +36,7 @@ const CHECKSUM_OFFSET: usize = 96; #[test] fn frozen_publication_heads_are_checksum_verified_exactly() -> Result<(), Box> { let generation_one = head_bytes(GENERATION_ONE_HEX)?; - let first = ChecksummedPublicationHead::decode(&generation_one)?; + let first = verification_decode(&generation_one)??; assert_eq!(first.generation().get(), 1); assert_eq!(first.catalog_length().get(), 352); assert_eq!( @@ -43,7 +46,7 @@ fn frozen_publication_heads_are_checksum_verified_exactly() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result, Box> { ) .map_err(Into::into) } + +// Size: small. The original typed assertions remain the corruption oracle. +fn verification_decode( + bytes: &[u8], +) -> Result< + Result, PublicationHeadDecodeError>, + Box, +> { + let error = match ChecksummedPublicationHead::decode(bytes) { + Ok(value) => return Ok(Ok(value)), + Err(error) => error, + }; + Ok(Err(verification_observation::classify( + error, + keep::VerificationSubject::PublishedCatalog, + )?)) +} diff --git a/tests/retention_head_codec.rs b/tests/retention_head_codec.rs index b3bd0ce2..227fa9d5 100644 --- a/tests/retention_head_codec.rs +++ b/tests/retention_head_codec.rs @@ -1,4 +1,7 @@ -//! Public semantic and canonical-codec laws for the retention head. +//! Codec laws with public verification classification of every corrupt input. + +#[path = "verification_corruption/observation.rs"] +mod verification_observation; mod support; @@ -32,7 +35,7 @@ fn one_root_head_has_one_semantic_and_canonical_representation() let head_bytes = fixture_bytes(ONE_ROOT_HEAD)?; assert_eq!(canonical.encoded(), head_bytes.as_slice()); - let checksummed = ChecksummedRetentionHead::decode(&head_bytes)?; + let checksummed = verification_decode(&head_bytes)??; assert_eq!(checksummed.encoded(), head_bytes); assert_eq!(checksummed.head(), &head); Ok(()) @@ -53,7 +56,7 @@ fn manifest_length_and_head_history_are_admitted_exactly() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), io::Error> { checksum.copy_from_slice(hasher.finalize().as_bytes()); Ok(()) } + +// Size: small. The original typed assertions remain the corruption oracle. +fn verification_decode( + bytes: &[u8], +) -> Result< + Result, RetentionHeadDecodeError>, + Box, +> { + let error = match ChecksummedRetentionHead::decode(bytes) { + Ok(value) => return Ok(Ok(value)), + Err(error) => error, + }; + let observed = verification_observation::classify( + keep::RetentionCurrentStateRefusal::HeadRefused { source: error }, + keep::VerificationSubject::PublishedView, + )?; + let keep::RetentionCurrentStateRefusal::HeadRefused { source } = observed else { + return Err("decoder boundary changed".into()); + }; + Ok(Err(source)) +} diff --git a/tests/retention_manifest_codec/refusal_laws.rs b/tests/retention_manifest_codec/refusal_laws.rs index 8fc11a2f..5a369d1d 100644 --- a/tests/retention_manifest_codec/refusal_laws.rs +++ b/tests/retention_manifest_codec/refusal_laws.rs @@ -1,4 +1,7 @@ -//! Framing, integrity, and semantic refusal laws for retention manifests. +//! Codec laws with public verification classification of every corrupt input. + +#[path = "../verification_corruption/observation.rs"] +mod verification_observation; use std::io; @@ -16,7 +19,7 @@ fn manifest_framing_and_integrity_have_exact_first_refusals() let mut truncated = bytes.clone(); assert!(truncated.pop().is_some()); assert!(matches!( - AdmittedRetentionManifest::decode(&truncated), + verification_decode(&truncated)?, Err(RetentionManifestDecodeError::Truncated { expected: 296, observed: 295, @@ -26,7 +29,7 @@ fn manifest_framing_and_integrity_have_exact_first_refusals() let mut trailing = bytes.clone(); trailing.push(0); assert!(matches!( - AdmittedRetentionManifest::decode(&trailing), + verification_decode(&trailing)?, Err(RetentionManifestDecodeError::TrailingData { expected: 296, observed: 297, @@ -39,7 +42,7 @@ fn manifest_framing_and_integrity_have_exact_first_refusals() .ok_or_else(|| io::Error::other("frozen manifest is empty"))?; *last ^= 1; assert!(matches!( - AdmittedRetentionManifest::decode(&checksum_corruption), + verification_decode(&checksum_corruption)?, Err(RetentionManifestDecodeError::ChecksumMismatch { .. }) )); @@ -50,7 +53,7 @@ fn manifest_framing_and_integrity_have_exact_first_refusals() *digest_byte ^= 1; refresh_checksum(&mut digest_corruption)?; assert!(matches!( - AdmittedRetentionManifest::decode(&digest_corruption), + verification_decode(&digest_corruption)?, Err(RetentionManifestDecodeError::ManifestDigestMismatch { .. }) )); Ok(()) @@ -64,13 +67,13 @@ fn complete_integrity_precedes_manifest_semantics() -> Result<(), Box Result<(), io::Error> { checksum_slot.copy_from_slice(hasher.finalize().as_bytes()); Ok(()) } + +// Size: small. The original typed assertions remain the corruption oracle. +fn verification_decode( + bytes: &[u8], +) -> Result< + Result, RetentionManifestDecodeError>, + Box, +> { + let error = match AdmittedRetentionManifest::decode(bytes) { + Ok(value) => return Ok(Ok(value)), + Err(error) => error, + }; + let observed = verification_observation::classify( + keep::RetentionCurrentStateRefusal::ManifestRefused { source: error }, + keep::VerificationSubject::PublishedView, + )?; + let keep::RetentionCurrentStateRefusal::ManifestRefused { source } = observed else { + return Err("decoder boundary changed".into()); + }; + Ok(Err(source)) +} diff --git a/tests/retention_root_decoding.rs b/tests/retention_root_decoding.rs index be1a4d34..83797136 100644 --- a/tests/retention_root_decoding.rs +++ b/tests/retention_root_decoding.rs @@ -1,10 +1,12 @@ //! Public decoding and integrity laws for version-2 retention roots. mod support; +#[path = "verification_corruption/root_decode.rs"] +mod verification_decode; use std::io; -use keep::{AdmittedRetentionRoot, RetentionRootDecodeError}; +use keep::RetentionRootDecodeError; const ONE_ANCHOR_ROOT: &str = include_str!("../conformance/segment-store/v2/one-anchor-root.hex"); const ANCHOR_SET_DIGEST_OFFSET: usize = 148; @@ -17,7 +19,7 @@ const CHECKSUM_OFFSET: usize = 346; fn frozen_root_decodes_to_one_complete_semantic_generation() -> Result<(), Box> { let bytes = fixture_bytes()?; - let admitted = AdmittedRetentionRoot::decode(&bytes)?; + let admitted = verification_decode::decode(&bytes)??; assert_eq!(admitted.encoded(), bytes); assert_eq!(admitted.root().namespace().as_bytes(), &[0x00, 0x2f, 0xff]); assert_eq!(admitted.root().generation().get(), 1); @@ -44,7 +46,7 @@ fn root_framing_refuses_truncation_trailing_data_and_magic_substitution() let mut truncated = bytes.clone(); assert!(truncated.pop().is_some()); assert!(matches!( - AdmittedRetentionRoot::decode(&truncated), + verification_decode::decode(&truncated)?, Err(RetentionRootDecodeError::Truncated { expected: 378, observed: 377, @@ -54,7 +56,7 @@ fn root_framing_refuses_truncation_trailing_data_and_magic_substitution() let mut trailing = bytes.clone(); trailing.push(0); assert!(matches!( - AdmittedRetentionRoot::decode(&trailing), + verification_decode::decode(&trailing)?, Err(RetentionRootDecodeError::TrailingData { expected: 378, observed: 379, @@ -67,7 +69,7 @@ fn root_framing_refuses_truncation_trailing_data_and_magic_substitution() .ok_or_else(|| io::Error::other("frozen retention root is empty"))?; *first ^= 1; assert!(matches!( - AdmittedRetentionRoot::decode(&wrong_magic), + verification_decode::decode(&wrong_magic)?, Err(RetentionRootDecodeError::InvalidMagic { .. }) )); Ok(()) @@ -83,7 +85,7 @@ fn root_checksum_and_digest_have_distinct_integrity_refusals() .ok_or_else(|| io::Error::other("frozen retention root is empty"))?; *last ^= 1; assert!(matches!( - AdmittedRetentionRoot::decode(&checksum_corruption), + verification_decode::decode(&checksum_corruption)?, Err(RetentionRootDecodeError::ChecksumMismatch { .. }) )); @@ -94,7 +96,7 @@ fn root_checksum_and_digest_have_distinct_integrity_refusals() *digest_byte ^= 1; refresh_checksum(&mut digest_corruption)?; assert!(matches!( - AdmittedRetentionRoot::decode(&digest_corruption), + verification_decode::decode(&digest_corruption)?, Err(RetentionRootDecodeError::RootDigestMismatch { .. }) )); Ok(()) @@ -109,13 +111,13 @@ fn semantic_fields_are_admitted_only_after_complete_integrity() .ok_or_else(|| io::Error::other("frozen retention root lacks generation bytes"))? .fill(0); assert!(matches!( - AdmittedRetentionRoot::decode(&bytes), + verification_decode::decode(&bytes)?, Err(RetentionRootDecodeError::ChecksumMismatch { .. }) )); refresh_root_digest_and_checksum(&mut bytes)?; assert!(matches!( - AdmittedRetentionRoot::decode(&bytes), + verification_decode::decode(&bytes)?, Err(RetentionRootDecodeError::Generation { .. }) )); Ok(()) @@ -131,14 +133,14 @@ fn anchor_set_integrity_precedes_nested_identity_admission() *first_anchor_byte ^= 1; refresh_root_digest_and_checksum(&mut bytes)?; assert!(matches!( - AdmittedRetentionRoot::decode(&bytes), + verification_decode::decode(&bytes)?, Err(RetentionRootDecodeError::AnchorSetDigestMismatch { .. }) )); refresh_anchor_set_digest(&mut bytes)?; refresh_root_digest_and_checksum(&mut bytes)?; assert!(matches!( - AdmittedRetentionRoot::decode(&bytes), + verification_decode::decode(&bytes)?, Err(RetentionRootDecodeError::BlobId { index: 0, .. }) )); Ok(()) diff --git a/tests/verification_corruption/layout_decode.rs b/tests/verification_corruption/layout_decode.rs new file mode 100644 index 00000000..cd4de34f --- /dev/null +++ b/tests/verification_corruption/layout_decode.rs @@ -0,0 +1,36 @@ +//! Existing layout-law inputs through public typed verification classification. +#![allow( + clippy::redundant_pub_crate, + reason = "shared boundary assertions remain private to their integration-test binaries" +)] +use keep::{LayoutDecodeError, VerificationError, VerificationRefusal, VerificationSource}; +use std::error::Error; + +pub(super) fn classified(error: LayoutDecodeError) -> Result> { + let source = match VerificationError::from(error) { + VerificationError::Refused { + refusal: VerificationRefusal::Corrupt { .. }, + source: Some(source), + } => source, + VerificationError::Operational { source } => { + assert!( + matches!( + *source, + VerificationSource::Layout(LayoutDecodeError::ConfiguredEntryLimitExceeded { + maximum: 1, + observed: 2 + }) + ), + "only the specified one-entry resource cap is operational in this corpus" + ); + source + } + other => { + return Err(format!("layout contradiction lost its classification: {other:?}").into()); + } + }; + let VerificationSource::Layout(source) = *source else { + return Err("layout decoder cause lost".into()); + }; + Ok(source) +} diff --git a/tests/verification_corruption/observation.rs b/tests/verification_corruption/observation.rs new file mode 100644 index 00000000..d3ed6ec9 --- /dev/null +++ b/tests/verification_corruption/observation.rs @@ -0,0 +1,57 @@ +//! Real decoder failures observed through the production verification collector. +#![allow( + clippy::redundant_pub_crate, + reason = "shared boundary assertions remain private to their integration-test binaries" +)] +use keep::{ + ReaderAttemptLimit, RetentionViewCoordinates, RetentionViewError, RetentionViewSource, + VerificationError, VerificationRefusal, VerificationSource, VerificationSubject, +}; +use std::{error::Error, io}; + +pub(super) fn classify( + cause: E, + subject: VerificationSubject, +) -> Result> { + let mut source = FailedObservation(Some(io::Error::new(io::ErrorKind::InvalidData, cause))); + let error = keep::collect_verification_view(&mut source, ReaderAttemptLimit::DEFAULT) + .err() + .ok_or("malformed observed publication received a view")?; + let VerificationError::Refused { + refusal: VerificationRefusal::Corrupt { + subject: actual, .. + }, + source: Some(source), + } = error + else { + return Err(format!("decoder contradiction misclassified: {error:?}").into()); + }; + assert_eq!( + actual, subject, + "classification must retain the observed subject" + ); + let VerificationSource::View(RetentionViewError::Io { source }) = *source else { + return Err("original observation cause lost".into()); + }; + Ok(*source + .into_inner() + .ok_or("typed decoder source absent")? + .downcast::() + .map_err(|source| -> Box { source })?) +} + +struct FailedObservation(Option); +impl RetentionViewSource for FailedObservation { + type View = (); + fn coordinates(&mut self) -> io::Result { + Err(self + .0 + .take() + .ok_or_else(|| io::Error::other("unexpected repeat observation"))?) + } + fn load(&mut self) -> io::Result<()> { + Err(io::Error::other( + "malformed observation must prevent loading", + )) + } +} diff --git a/tests/verification_corruption/root_decode.rs b/tests/verification_corruption/root_decode.rs new file mode 100644 index 00000000..7e1d998d --- /dev/null +++ b/tests/verification_corruption/root_decode.rs @@ -0,0 +1,33 @@ +//! Real root decoding followed by public verification classification. +#![allow( + clippy::redundant_pub_crate, + reason = "shared boundary assertions remain private to their integration-test binaries" +)] +use keep::{ + AdmittedRetentionRoot, RetentionRootDecodeError, VerificationError, VerificationRefusal, + VerificationSource, +}; +use std::error::Error; + +type Outcome<'a> = Result, RetentionRootDecodeError>; + +// The wrapper adds a classification assertion, then returns the original typed +// cause to each unchanged precise corruption oracle. It never fabricates it. +pub(super) fn decode(bytes: &[u8]) -> Result, Box> { + let error = match AdmittedRetentionRoot::decode(bytes) { + Ok(root) => return Ok(Ok(root)), + Err(error) => error, + }; + let classified = VerificationError::from(error); + let VerificationError::Refused { + refusal: VerificationRefusal::Corrupt { .. }, + source: Some(source), + } = classified + else { + return Err(format!("root contradiction misclassified: {classified:?}").into()); + }; + let VerificationSource::Root(error) = *source else { + return Err("original root cause lost".into()); + }; + Ok(Err(error)) +} From b33c7da4ec6fae68437b49663ca383edf5c297e1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 19:00:02 -0700 Subject: [PATCH 07/18] test: reject corruption labels for verification resource limits (#114) --- docs/testing-evidence/durable-verification.md | 6 ++++++ tests/verification_corruption/layout_decode.rs | 14 +++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index 908b2f87..36f74b63 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -203,3 +203,9 @@ Three isolated production mutations inverted raw root classification, inverted l The earlier `6504c86` candidate passed the corrected local workspace debug/release, both feature-mode Clippy, doctests, documentation build, structure and formatting checks, and all four required hosted checks in [run 37087373122](https://github.com/flyingrobots/keep/actions/runs/37087373122). Those green results do not approve this subsequent mapping delta; the final pushed head requires its own independent confirmation and hosted checks before readiness. + +The delta review found a surviving resource-classification oracle: the layout wrapper accepted a `Corrupt` result carrying `ConfiguredEntryLimitExceeded`, so the first inverted-classification receipt showed the configured-cap law still passing while the other layout laws failed. + +The corrected wrapper explicitly rejects that resource cause in the corruption arm; `layout-resource-red.log` records the same production inversion failing the targeted configured-cap law with “a configured resource cap must remain operational, never corruption,” and `layout-resource-green.log` records unmutated debug/release and focused Clippy success. + +This is a strengthened test oracle; production classification was unchanged and already correct, so the mutation is calibration rather than a claim of a runtime bug on the parent. diff --git a/tests/verification_corruption/layout_decode.rs b/tests/verification_corruption/layout_decode.rs index cd4de34f..30a5d636 100644 --- a/tests/verification_corruption/layout_decode.rs +++ b/tests/verification_corruption/layout_decode.rs @@ -11,7 +11,19 @@ pub(super) fn classified(error: LayoutDecodeError) -> Result source, + } => { + assert!( + !matches!( + *source, + VerificationSource::Layout( + LayoutDecodeError::ConfiguredEntryLimitExceeded { .. } + ) + ), + "a configured resource cap must remain operational, never corruption" + ); + source + } + VerificationError::Operational { source } => { assert!( matches!( From 26d352202f06ca8bf36d203604f7897d2eaa5161 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 19:54:56 -0700 Subject: [PATCH 08/18] fix: restrict root catalog provenance to verified closure (#114) --- CHANGELOG.md | 2 ++ docs/audits/114-durable-verification-scope.md | 15 ++++++++++++ docs/invariants/verification/README.md | 2 +- docs/testing-evidence/durable-verification.md | 8 +++++++ .../filesystem_verification_law_tests.rs | 6 ++--- src/adapters/retention/root_verification.rs | 14 ++++++----- tests/blob_verification/root_laws.rs | 23 ++++++++++++++++++- 7 files changed, 59 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09adb19e..fa99083c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ after its public API and format compatibility policies are established. ## [Unreleased] +- Shallow retention-root verification reports no longer claim unused catalog provenance; catalog coordinates are attached only after successful closure verification (#114). + - Added read-only durable verification ingress, exact moving-view conflict witnesses, selected-namespace root reports, and typed selected-root diagnostic sources (#114). - Added catalog-bound complete-blob and admitted-retention-root verification reports, preserving exact missing members, identity/profile contradictions, resource failures, and original typed causes (#114). diff --git a/docs/audits/114-durable-verification-scope.md b/docs/audits/114-durable-verification-scope.md index 91ba118e..3dbf2c63 100644 --- a/docs/audits/114-durable-verification-scope.md +++ b/docs/audits/114-durable-verification-scope.md @@ -103,3 +103,18 @@ The independent exact-head review of `6504c86` found one acceptance gap in exist The independent-review finding on `6504c86` is implemented and calibrated in its follow-up; approval of that delta and required checks must be recorded against the resulting exact head in [PR #165](https://github.com/flyingrobots/keep/pull/165) before it leaves draft. This table closes implementation obligations, not the independent review or human merge gate; the PR is the live authority for those exact-head decisions. + +## Post-readiness review queue + +Reviews arriving after `b33c7da` reopened the acceptance gate. The earlier implementation-disposition table is the pre-review candidate's account; it does not supersede these concrete obligations or establish current readiness. The complete review bodies, global discussion and inline findings are included, regardless of thread age. + +| Obligation | Current evidence and disposition | Exit condition | +| --- | --- | --- | +| Shallow root provenance | Verified bug: framing/checksum attached an unconsulted catalog. Public parent regression RED; direct and publication-selected laws GREEN after attaching coordinates only for successful closure. | Fix pushed, review of resulting head, final required checks. | +| Store-admission contradictions | `view_error` currently sends all non-catalog snapshot errors to operational classification, including typed admission refusals. | Reproduce typed contradictory records/root identity through the public loader; preserve precise causes and operational resource/I/O cases; RED/GREEN. | +| Verification-depth ordering | Public `Ord`/`PartialOrd` expose cross-subject comparisons despite the documented absence of an implication order. | Remove the unsupported comparison API, demonstrate its rejection at the compile boundary and preserve runtime depth policies. | +| Observation classification exhaustiveness | Wildcard currently permits future current-state variants to default silently to operational. | Explicitly classify every existing variant; compiler and existing runtime classifications remain correct. | +| Shared layout failure classification | Admission, closure and ingress duplicate the operational cause list. | Use one exhaustive classifier without changing existing supported outcomes; verify the affected runtime laws. | +| Current documentation status | Initial-slice wording remains in current summary/changelog positions. | Reconcile current delivered scope, preserve historical evidence and identify final-head review/CI separately. | + +No finding is resolved merely because an earlier independent review approved the preceding head. No merge is authorized by this ledger. diff --git a/docs/invariants/verification/README.md b/docs/invariants/verification/README.md index 9bc0b18e..7d797483 100644 --- a/docs/invariants/verification/README.md +++ b/docs/invariants/verification/README.md @@ -75,7 +75,7 @@ No serialization, repair, quarantine, GC execution or new durable report format Blob and root verification distinguish missing catalog members, demonstrated contradictions, unsupported requests, and operational failures without returning partial reports. Original layout or closure causes retain their typed coordinates. Resource exhaustion is operational, not evidence of corruption. -The report preserves the catalog generation/digest used by catalog, blob and root operations; this provenance is not a live fence. Multiple valid layouts for a blob are representations, not automatically ambiguity: discovery selects the first canonical identity. +The report preserves the catalog generation/digest used by catalog and blob operations or successful root closure; root framing/checksum reports carry no catalog provenance; this provenance is not a live fence. Multiple valid layouts for a blob are representations, not automatically ambiguity: discovery selects the first canonical identity. Immutable admitted-view operations have no moving observation to classify; ambiguity is produced by the durable collection path, retaining at most the last conflicting coordinate pair and the original attempt-limit cause. diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index 36f74b63..f23532c0 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -209,3 +209,11 @@ The delta review found a surviving resource-classification oracle: the layout wr The corrected wrapper explicitly rejects that resource cause in the corruption arm; `layout-resource-red.log` records the same production inversion failing the targeted configured-cap law with “a configured resource cap must remain operational, never corruption,” and `layout-resource-green.log` records unmutated debug/release and focused Clippy success. This is a strengthened test oracle; production classification was unchanged and already correct, so the mutation is calibration rather than a claim of a runtime bug on the parent. + +## Post-readiness review: shallow root provenance + +Change kind: bug fix. CodeRabbit identified that `AdmittedRetentionRoot::verify` attached catalog provenance even when framing/checksum reporting never consulted the catalog. On unfixed `b33c7da4ec6fae68437b49663ca383edf5c297e1`, the new public regression `shallow_root_reports_do_not_claim_an_unconsulted_catalog` failed with a framing report's `Some((generation, digest))` against specified `None`, using an empty catalog that cannot establish the root's closure. + +The fix attaches catalog coordinates only after successful retention-closure verification. Shallow reports still name the exact admitted root and requested depth; publication-selected reports retain their actual retention-head provenance. Existing shallow catalog expectations in both direct and filesystem laws were incorrect and are corrected to `None`; the closure expectations remain unchanged. This is a correction to the report's evidence claim, not a change to root admission, retained closure, publication or formats. + +Copied Docker runs of `cargo test --locked --test blob_verification` and its release counterpart pass after the fix. The `filesystem_verification_law_tests` library filter passes in both profiles, exercising the publication-selected root boundary and unchanged filesystem witnesses; focused library/integration Clippy passes with warnings denied. These filesystem fixtures are repository-admitted test stores, not a new production-platform or physical durability certification. The parent RED, direct GREEN and filesystem GREEN receipts remain distinct; final full validation belongs to the final pushed candidate after the remaining review findings are resolved. diff --git a/src/adapters/retention/filesystem_verification_law_tests.rs b/src/adapters/retention/filesystem_verification_law_tests.rs index 44c2d2ef..405c9f47 100644 --- a/src/adapters/retention/filesystem_verification_law_tests.rs +++ b/src/adapters/retention/filesystem_verification_law_tests.rs @@ -15,8 +15,8 @@ use std::fs; // Size: medium. Oracle: exact published corpus root/head and immutable bytes. // Delete when superseded by a generated durable-report law retaining this case. #[test] -fn published_root_reports_bind_both_views_without_mutating_evidence() -> Result<(), Box> -{ +fn published_root_reports_name_only_the_evidence_established_at_the_requested_depth() +-> Result<(), Box> { let (sandbox, mut authority) = open_authority("verify-published-root")?; let bytes = fixture(ROOT_HEX)?; let preparation = initial_preparation(&bytes)?; @@ -39,7 +39,7 @@ fn published_root_reports_bind_both_views_without_mutating_evidence() -> Result< ); assert_eq!( report.catalog(), - Some(( + (depth == Depth::RetentionClosure).then_some(( snapshot.catalog().generation(), snapshot.catalog().catalog_digest() )) diff --git a/src/adapters/retention/root_verification.rs b/src/adapters/retention/root_verification.rs index 27afbb61..b1886241 100644 --- a/src/adapters/retention/root_verification.rs +++ b/src/adapters/retention/root_verification.rs @@ -21,7 +21,8 @@ impl AdmittedRetentionRoot<'_> { /// Verifies this exact root at the requested depth against `catalog`. /// /// Framing/checksum reporting is constant-time and allocation-free over - /// already admitted canonical bytes. Closure verification visits every + /// already admitted canonical bytes and claims no catalog provenance. + /// Closure verification visits every /// anchor, requires catalog members, replays storage profiles and hashes /// complete blobs. Its checked node, depth, encoded and physical byte /// limits are those admitted from the root. It allocates an ordered index @@ -52,11 +53,12 @@ impl AdmittedRetentionRoot<'_> { } .into()); } - if requested == VerificationDepth::RetentionClosure { - let _evidence = verify_retention_closure(root, catalog) - .map_err(|source| verification_admission::closure(subject, source))?; + let report = VerificationReport::established(subject, requested); + if requested != VerificationDepth::RetentionClosure { + return Ok(report); } - Ok(VerificationReport::established(subject, requested) - .in_catalog(catalog.generation(), catalog.catalog_digest())) + let _evidence = verify_retention_closure(root, catalog) + .map_err(|source| verification_admission::closure(subject, source))?; + Ok(report.in_catalog(catalog.generation(), catalog.catalog_digest())) } } diff --git a/tests/blob_verification/root_laws.rs b/tests/blob_verification/root_laws.rs index 60eaffff..a3bc0fd0 100644 --- a/tests/blob_verification/root_laws.rs +++ b/tests/blob_verification/root_laws.rs @@ -28,7 +28,8 @@ fn a_root_report_requires_complete_closure_in_its_named_catalog() -> Result<(), assert_eq!(report.requested(), depth); assert_eq!( report.catalog(), - Some((catalog.generation(), catalog.catalog_digest())) + (depth == Depth::RetentionClosure) + .then_some((catalog.generation(), catalog.catalog_digest())) ); let claims: Vec<_> = report .subjects() @@ -156,3 +157,23 @@ fn root_reports_refuse_unrelated_depths() -> Result<(), Box> { Ok(()) }) } + +// Size: small. Oracle: framing/checksum inspect only admitted root bytes; +// the supplied empty catalog proves no root membership and earns no provenance. +// Delete only if these shallow depths are removed or begin verifying closure. +#[test] +fn shallow_root_reports_do_not_claim_an_unconsulted_catalog() -> Result<(), Box> { + let root = root(RetentionClosureLimits::new(2, 2, 220, 509)?)?; + let admitted = AdmittedRetentionRoot::decode(root.encoded())?; + with_records(&[], |catalog| { + for requested in [Depth::Framing, Depth::Checksum] { + let report = admitted.verify(catalog, requested)?; + assert_eq!( + report.catalog(), + None, + "shallow {requested:?} must not certify catalog provenance" + ); + } + Ok(()) + }) +} From 665ffb3bf65efd94ba46a94c28dfde636828caf1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 20:01:54 -0700 Subject: [PATCH 09/18] fix: retain corruption classification at store admission (#114) --- CHANGELOG.md | 2 + docs/audits/114-durable-verification-scope.md | 2 +- docs/testing-evidence/durable-verification.md | 8 + src/adapters/retention.rs | 2 + .../filesystem_retention_verification.rs | 41 ++++- .../verification_store_admission_tests.rs | 173 ++++++++++++++++++ 6 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 src/adapters/retention/verification_store_admission_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fa99083c..cf30368a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ after its public API and format compatibility policies are established. ## [Unreleased] +- Verification classifies typed migration-record and root-identity contradictions as corruption while preserving admission causes and keeping resource or unclassified I/O failures operational (#114). + - Shallow retention-root verification reports no longer claim unused catalog provenance; catalog coordinates are attached only after successful closure verification (#114). - Added read-only durable verification ingress, exact moving-view conflict witnesses, selected-namespace root reports, and typed selected-root diagnostic sources (#114). diff --git a/docs/audits/114-durable-verification-scope.md b/docs/audits/114-durable-verification-scope.md index 3dbf2c63..8a75f814 100644 --- a/docs/audits/114-durable-verification-scope.md +++ b/docs/audits/114-durable-verification-scope.md @@ -111,7 +111,7 @@ Reviews arriving after `b33c7da` reopened the acceptance gate. The earlier imple | Obligation | Current evidence and disposition | Exit condition | | --- | --- | --- | | Shallow root provenance | Verified bug: framing/checksum attached an unconsulted catalog. Public parent regression RED; direct and publication-selected laws GREEN after attaching coordinates only for successful closure. | Fix pushed, review of resulting head, final required checks. | -| Store-admission contradictions | `view_error` currently sends all non-catalog snapshot errors to operational classification, including typed admission refusals. | Reproduce typed contradictory records/root identity through the public loader; preserve precise causes and operational resource/I/O cases; RED/GREEN. | +| Store-admission contradictions | Verified and corrected: public loader regressions RED on `26d3522`; typed record/root-identity contradictions now remain corrupt with their causes; host-width and unclassified I/O remain operational, with distinct calibration. | Fix pushed, resulting-head review and final checks. | | Verification-depth ordering | Public `Ord`/`PartialOrd` expose cross-subject comparisons despite the documented absence of an implication order. | Remove the unsupported comparison API, demonstrate its rejection at the compile boundary and preserve runtime depth policies. | | Observation classification exhaustiveness | Wildcard currently permits future current-state variants to default silently to operational. | Explicitly classify every existing variant; compiler and existing runtime classifications remain correct. | | Shared layout failure classification | Admission, closure and ingress duplicate the operational cause list. | Use one exhaustive classifier without changing existing supported outcomes; verify the affected runtime laws. | diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index f23532c0..2c7946b8 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -217,3 +217,11 @@ Change kind: bug fix. CodeRabbit identified that `AdmittedRetentionRoot::verify` The fix attaches catalog coordinates only after successful retention-closure verification. Shallow reports still name the exact admitted root and requested depth; publication-selected reports retain their actual retention-head provenance. Existing shallow catalog expectations in both direct and filesystem laws were incorrect and are corrected to `None`; the closure expectations remain unchanged. This is a correction to the report's evidence claim, not a change to root admission, retained closure, publication or formats. Copied Docker runs of `cargo test --locked --test blob_verification` and its release counterpart pass after the fix. The `filesystem_verification_law_tests` library filter passes in both profiles, exercising the publication-selected root boundary and unchanged filesystem witnesses; focused library/integration Clippy passes with warnings denied. These filesystem fixtures are repository-admitted test stores, not a new production-platform or physical durability certification. The parent RED, direct GREEN and filesystem GREEN receipts remain distinct; final full validation belongs to the final pushed candidate after the remaining review findings are resolved. + +## Post-readiness review: store-admission classification + +Change kind: bug fix. The public verification loader on `26d352202f06ca8bf36d203604f7897d2eaa5161` classified typed version-two record corruption and migration-bound root-identity disagreement as operational failures. New filesystem regressions observed both failures before the fix: a changed `FORMAT` magic retained `VersionTwoRecordRefusal::Marker(InvalidMagic)` inside an operational result, and copied migration records retained exact donor/recipient inode coordinates inside an operational result. + +The classifier now admits those known typed contradictions as `Corrupt` for `PublishedView`, retaining the entire original admission cause. Its exhaustive version-two record match leaves host-width overflow operational; unclassified I/O, including an `InvalidData` kind without a recognized contradictory cause, remains operational. No error-message parsing or blanket kind-based corruption inference is used. + +The corrected runtime laws cover malformed `FORMAT`, intent and receipt magic, exact root-identity coordinates, and unchanged refused evidence. Public error-conversion laws distinguish host-width exhaustion and unclassified I/O; these conversion cases are simulated causes, not claimed syscall injections. Docker debug/release execution and focused all-feature library Clippy pass. Additional production mutants demonstrate failures for the wrong public subject, lost original source, resource-as-corruption and untyped-I/O-as-corruption. Sources are restored with cache timestamps invalidated before the subsequent GREEN runs; calibration concerns distinct behavioral claims rather than repeating the full campaign per field. diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 47955385..64f4c1de 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -178,6 +178,8 @@ mod transition_preflight_error; mod transition_readiness; #[cfg(test)] mod verification_selection_law_tests; +#[cfg(test)] +mod verification_store_admission_tests; mod verified_closure; #[cfg(test)] diff --git a/src/adapters/retention/filesystem_retention_verification.rs b/src/adapters/retention/filesystem_retention_verification.rs index 7c9b3266..e97bd608 100644 --- a/src/adapters/retention/filesystem_retention_verification.rs +++ b/src/adapters/retention/filesystem_retention_verification.rs @@ -8,10 +8,10 @@ use super::{AdmittedRetentionRoot, RetentionSelectedRootRefusal}; use crate::adapters::filesystem_exact_record::ExactRecordError; use crate::adapters::{verification_admission, verification_ingress}; use crate::{ - CatalogRestartPolicy, FilesystemRetentionSnapshot, FilesystemRetentionSnapshotError, - ReaderAttemptLimit, RetentionNamespaceDigest, RetentionRootDecodeError, VerificationDepth, - VerificationError, VerificationRefusal, VerificationReport, VerificationSource, - VerificationSubject, + CatalogRestartPolicy, FilesystemPlatformAdmissionError, FilesystemRetentionSnapshot, + FilesystemRetentionSnapshotError, ReaderAttemptLimit, RetentionNamespaceDigest, + RetentionRootDecodeError, VerificationDepth, VerificationError, VerificationRefusal, + VerificationReport, VerificationSource, VerificationSubject, VersionTwoRecordRefusal, }; use std::io; @@ -103,11 +103,44 @@ fn view_error(source: FilesystemRetentionSnapshotError) -> VerificationError { if let FilesystemRetentionSnapshotError::Catalog { source } = source { return verification_ingress::catalog_error(source); } + if matches!(&source, FilesystemRetentionSnapshotError::Admission { source } if admission_is_corrupt(source)) + { + return VerificationError::Refused { + refusal: verification_admission::structural(VerificationSubject::PublishedView), + source: Some(Box::new(VerificationSource::Retention(source))), + }; + } VerificationError::Operational { source: Box::new(VerificationSource::Retention(source)), } } +fn admission_is_corrupt(error: &io::Error) -> bool { + let Some(source) = error.get_ref() else { + return false; + }; + if let Some(record) = source.downcast_ref::() { + return match record { + VersionTwoRecordRefusal::LengthOverflow { .. } => false, + VersionTwoRecordRefusal::KindOrLength { .. } + | VersionTwoRecordRefusal::TrailingBytes { .. } + | VersionTwoRecordRefusal::Marker { .. } + | VersionTwoRecordRefusal::Intent { .. } + | VersionTwoRecordRefusal::Receipt { .. } => true, + }; + } + match source.downcast_ref::() { + Some(FilesystemPlatformAdmissionError::RootIdentityChanged { .. }) => true, + Some( + FilesystemPlatformAdmissionError::Platform { .. } + | FilesystemPlatformAdmissionError::WriterLock { .. } + | FilesystemPlatformAdmissionError::Namespace { .. } + | FilesystemPlatformAdmissionError::MigrationRecord { .. }, + ) + | None => false, + } +} + fn root_error( subject: VerificationSubject, error: FilesystemRetentionSnapshotError, diff --git a/src/adapters/retention/verification_store_admission_tests.rs b/src/adapters/retention/verification_store_admission_tests.rs new file mode 100644 index 00000000..a18a43ea --- /dev/null +++ b/src/adapters/retention/verification_store_admission_tests.rs @@ -0,0 +1,173 @@ +//! Typed store contradictions stay distinct from inconclusive admission failures. + +use super::filesystem_retention_test_fixture::{migrated_store, retention_witness}; +use crate::{ + CatalogRestartByteLimit, CatalogRestartPolicy, FilesystemPlatformAdmissionError, + FilesystemRetentionSnapshot, FilesystemRetentionSnapshotError, ReaderAttemptLimit, + SegmentReadPolicy, StoreFormatMarkerDecodeError, StoreMigrationIntentDecodeError, + StoreMigrationReceiptDecodeError, StoreRootIdentityCoordinate, VerificationError, + VerificationObservation, VerificationRefusal, VerificationSource, VerificationSubject, + VersionTwoRecordRefusal, +}; +use std::os::unix::fs::MetadataExt; +use std::{error::Error, fs, io}; + +type ResultOf = Result>; + +// Size: medium. Oracle: bad literal record magic proves a content contradiction. +// Delete if a stronger public loader matrix preserves these typed causes. +#[test] +fn malformed_store_records_are_corrupt_with_the_original_magic() -> ResultOf<()> { + for name in ["FORMAT", "migration.intent", "migration.receipt"] { + let store = migrated_store("verification-malformed-store-record")?; + let path = store.path().join(name); + let mut bytes = fs::read(&path)?; + *bytes.first_mut().ok_or("empty migration record")? ^= 1; + let expected: [u8; 16] = bytes.get(..16).ok_or("missing magic")?.try_into()?; + fs::write(&path, &bytes)?; + let source = corrupt_cause( + FilesystemRetentionSnapshot::load_for_verification( + store.path(), + policy()?, + ReaderAttemptLimit::DEFAULT, + ) + .err() + .ok_or("bad magic admitted")?, + )?; + let typed = source + .get_ref() + .and_then(|source| source.downcast_ref::()); + let observed = match typed { + Some(VersionTwoRecordRefusal::Marker { + source: StoreFormatMarkerDecodeError::InvalidMagic { observed }, + }) if name == "FORMAT" => observed, + Some(VersionTwoRecordRefusal::Intent { + source: StoreMigrationIntentDecodeError::InvalidMagic { observed }, + }) if name == "migration.intent" => observed, + Some(VersionTwoRecordRefusal::Receipt { + source: StoreMigrationReceiptDecodeError::InvalidMagic { observed }, + }) if name == "migration.receipt" => observed, + _ => return Err(format!("{name} lost its exact decode refusal: {source:?}").into()), + }; + assert_eq!(*observed, expected, "original magic for {name}"); + assert_eq!(fs::read(path)?, bytes, "refusal must preserve {name}"); + } + Ok(()) +} + +// Size: medium. Oracle: copied migration records still name the donor's inode. +// Delete if migration identity binding is removed or a stronger loader law subsumes it. +#[test] +fn a_foreign_migration_binding_is_corruption_with_exact_root_coordinates() -> ResultOf<()> { + let donor = migrated_store("verification-identity-donor")?; + let recipient = migrated_store("verification-identity-recipient")?; + for name in ["FORMAT", "migration.intent", "migration.receipt"] { + fs::copy(donor.path().join(name), recipient.path().join(name))?; + } + let expected = fs::metadata(donor.path())?.ino(); + let observed = fs::metadata(recipient.path())?.ino(); + let before = retention_witness(recipient.path())?; + let source = corrupt_cause( + FilesystemRetentionSnapshot::load_for_verification( + recipient.path(), + policy()?, + ReaderAttemptLimit::DEFAULT, + ) + .err() + .ok_or("foreign binding admitted")?, + )?; + let typed = source + .get_ref() + .and_then(|source| source.downcast_ref::()); + assert!( + matches!(typed, Some(FilesystemPlatformAdmissionError::RootIdentityChanged { + coordinate: StoreRootIdentityCoordinate::File, expected: actual_expected, observed: actual_observed + }) if *actual_expected == expected && *actual_observed == observed), + "original binding coordinates required: {source:?}" + ); + assert_eq!( + retention_witness(recipient.path())?, + before, + "refusal must preserve the store" + ); + Ok(()) +} + +fn corrupt_cause(error: VerificationError) -> ResultOf { + let VerificationError::Refused { + refusal, + source: Some(source), + } = error + else { + return Err( + format!("typed store contradiction was not classified corrupt: {error:?}").into(), + ); + }; + assert_eq!( + refusal, + VerificationRefusal::Corrupt { + subject: VerificationSubject::PublishedView, + expected: VerificationObservation::Canonical, + observed: VerificationObservation::Refused, + } + ); + let VerificationSource::Retention(FilesystemRetentionSnapshotError::Admission { source }) = + *source + else { + return Err("original admission boundary was lost".into()); + }; + Ok(source) +} + +fn policy() -> ResultOf { + Ok(CatalogRestartPolicy::new( + SegmentReadPolicy::MAXIMUM, + CatalogRestartByteLimit::new(1_048_576)?, + )) +} + +// Size: small. Oracle: host-width exhaustion and unclassified I/O establish no +// contradiction. This is a public error-conversion law, not injected syscall evidence. +// Delete if admission errors gain a stronger typed operational boundary. +#[test] +fn inconclusive_admission_causes_remain_operational_without_losing_the_source() -> ResultOf<()> { + let source = io::Error::new( + io::ErrorKind::InvalidData, + VersionTwoRecordRefusal::LengthOverflow { name: "FORMAT" }, + ); + let error = VerificationError::from(FilesystemRetentionSnapshotError::Admission { source }); + let source = operational_cause(error)?; + assert!( + matches!( + source + .get_ref() + .and_then(|source| source.downcast_ref::()), + Some(VersionTwoRecordRefusal::LengthOverflow { name: "FORMAT" }) + ), + "host-width cause must survive: {source:?}" + ); + for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::InvalidData] { + let source = io::Error::new(kind, "unclassified admission failure"); + let error = VerificationError::from(FilesystemRetentionSnapshotError::Admission { source }); + assert_eq!( + operational_cause(error)?.kind(), + kind, + "I/O kind alone must not establish corruption" + ); + } + Ok(()) +} + +fn operational_cause(error: VerificationError) -> ResultOf { + let VerificationError::Operational { source } = error else { + return Err( + format!("inconclusive admission was labeled content evidence: {error:?}").into(), + ); + }; + let VerificationSource::Retention(FilesystemRetentionSnapshotError::Admission { source }) = + *source + else { + return Err("original operational admission cause was lost".into()); + }; + Ok(source) +} From 2b28c4a36cf120335203cf756acc82ce9782a8eb Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 20:04:23 -0700 Subject: [PATCH 10/18] fix: prevent ordinal comparisons of verification depths (#114) --- CHANGELOG.md | 2 ++ docs/audits/114-durable-verification-scope.md | 2 +- docs/invariants/verification/README.md | 2 +- docs/testing-evidence/durable-verification.md | 6 ++++++ src/verification/depth.rs | 13 +++++++++++-- src/verification/rationale.md | 2 +- 6 files changed, 22 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf30368a..5b8b9777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ after its public API and format compatibility policies are established. ## [Unreleased] +- Removed ordinal comparison traits from verification depths; callers use each subject’s explicit supported-depth set (#114). + - Verification classifies typed migration-record and root-identity contradictions as corruption while preserving admission causes and keeping resource or unclassified I/O failures operational (#114). - Shallow retention-root verification reports no longer claim unused catalog provenance; catalog coordinates are attached only after successful closure verification (#114). diff --git a/docs/audits/114-durable-verification-scope.md b/docs/audits/114-durable-verification-scope.md index 8a75f814..b14e7590 100644 --- a/docs/audits/114-durable-verification-scope.md +++ b/docs/audits/114-durable-verification-scope.md @@ -112,7 +112,7 @@ Reviews arriving after `b33c7da` reopened the acceptance gate. The earlier imple | --- | --- | --- | | Shallow root provenance | Verified bug: framing/checksum attached an unconsulted catalog. Public parent regression RED; direct and publication-selected laws GREEN after attaching coordinates only for successful closure. | Fix pushed, review of resulting head, final required checks. | | Store-admission contradictions | Verified and corrected: public loader regressions RED on `26d3522`; typed record/root-identity contradictions now remain corrupt with their causes; host-width and unclassified I/O remain operational, with distinct calibration. | Fix pushed, resulting-head review and final checks. | -| Verification-depth ordering | Public `Ord`/`PartialOrd` expose cross-subject comparisons despite the documented absence of an implication order. | Remove the unsupported comparison API, demonstrate its rejection at the compile boundary and preserve runtime depth policies. | +| Verification-depth ordering | Removed `Ord`/`PartialOrd`; the public compile-fail law is RED on `665ffb3` because comparison compiled, then GREEN after removal. Runtime supported-depth laws remain GREEN. | Resulting-head review and final checks. | | Observation classification exhaustiveness | Wildcard currently permits future current-state variants to default silently to operational. | Explicitly classify every existing variant; compiler and existing runtime classifications remain correct. | | Shared layout failure classification | Admission, closure and ingress duplicate the operational cause list. | Use one exhaustive classifier without changing existing supported outcomes; verify the affected runtime laws. | | Current documentation status | Initial-slice wording remains in current summary/changelog positions. | Reconcile current delivered scope, preserve historical evidence and identify final-head review/CI separately. | diff --git a/docs/invariants/verification/README.md b/docs/invariants/verification/README.md index 7d797483..6fb56bd7 100644 --- a/docs/invariants/verification/README.md +++ b/docs/invariants/verification/README.md @@ -6,7 +6,7 @@ Status: durable verification candidate under [#114](https://github.com/flyingrob A report names the exact subject, the caller's requested depth and the evidence established for that subject. -An ordinal depth comparison grants no inference about another subject: a catalog membership check cannot certify a blob, and a layout identity cannot certify its missing chunks. +`VerificationDepth` has equality but no `Ord` or `PartialOrd`: a catalog membership check cannot certify a blob, and a layout identity cannot certify its missing chunks. Report fields and construction are private; callers can inspect or copy established evidence but cannot construct or deepen it. diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index 2c7946b8..396afd07 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -225,3 +225,9 @@ Change kind: bug fix. The public verification loader on `26d352202f06ca8bf36d203 The classifier now admits those known typed contradictions as `Corrupt` for `PublishedView`, retaining the entire original admission cause. Its exhaustive version-two record match leaves host-width overflow operational; unclassified I/O, including an `InvalidData` kind without a recognized contradictory cause, remains operational. No error-message parsing or blanket kind-based corruption inference is used. The corrected runtime laws cover malformed `FORMAT`, intent and receipt magic, exact root-identity coordinates, and unchanged refused evidence. Public error-conversion laws distinguish host-width exhaustion and unclassified I/O; these conversion cases are simulated causes, not claimed syscall injections. Docker debug/release execution and focused all-feature library Clippy pass. Additional production mutants demonstrate failures for the wrong public subject, lost original source, resource-as-corruption and untyped-I/O-as-corruption. Sources are restored with cache timestamps invalidated before the subsequent GREEN runs; calibration concerns distinct behavioral claims rather than repeating the full campaign per field. + +## Post-readiness review: depth comparison API + +Change kind: public API correction. `VerificationDepth` no longer implements `Ord` or `PartialOrd`; equality and every operation's explicit supported-depth set remain intact. This removes the misleading ability to treat catalog reachability as ordinally stronger than complete blob identity. The public rustdoc example is a static/API compile-fail contract, not runtime storage evidence. + +On unfixed `665ffb3bf65efd94ba46a94c28dfde636828caf1`, the new compile-fail example failed because the forbidden comparison compiled successfully. After removing the ordering derives, the same example passes with the intended unsupported binary comparison. Catalog, segment and blob/root verification laws pass in Docker debug/release, preserving actual runtime depth acceptance/refusal. This corrects an unreleased API; it changes no durable format or verification policy. diff --git a/src/verification/depth.rs b/src/verification/depth.rs index 892afacf..7493c99c 100644 --- a/src/verification/depth.rs +++ b/src/verification/depth.rs @@ -2,10 +2,19 @@ /// Requested verification work, interpreted for a specific subject. /// -/// Operations document their supported depths. Ordering alone is not a proof: +/// Operations document their supported depths. No total implication order exists: /// catalog reachability does not establish every referenced blob's identity, /// and retention closure does not establish a future snapshot-binding format. -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +/// +/// Depths have equality but no cross-subject implication order. Compare a +/// request with the operation's explicit supported set, never an ordinal. +/// +/// ```compile_fail,E0369 +/// use keep::VerificationDepth; +/// let _ = VerificationDepth::CatalogReachability +/// >= VerificationDepth::CompleteBlobIdentity; +/// ``` +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum VerificationDepth { /// Canonical record framing and structural bounds. Framing, diff --git a/src/verification/rationale.md b/src/verification/rationale.md index 013279ce..20689165 100644 --- a/src/verification/rationale.md +++ b/src/verification/rationale.md @@ -2,7 +2,7 @@ Status: durable verification candidate for #114. -Verification reports name the evidence established for a subject; the enum's ordering does not turn a catalog-membership proof into complete logical blob verification. +Verification reports name the evidence established for a subject; the depth enum deliberately has no ordering traits, so a catalog-membership proof cannot be compared ordinally with complete logical blob verification. The domain owns immutable report, subject, depth, and refusal vocabulary without importing storage adapters. From 6802644ccf0d547694ab26644b9c306a43ddaeba Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 20:09:37 -0700 Subject: [PATCH 11/18] refactor: centralize exhaustive verification failure classes (#114) --- CHANGELOG.md | 6 +-- docs/audits/114-durable-verification-scope.md | 12 +++--- docs/testing-evidence/durable-verification.md | 10 ++++- .../verification_observation_error.rs | 32 ++++++++++++++- src/adapters/verification_admission.rs | 17 ++------ src/adapters/verification_failure_class.rs | 39 ++++++++++++++----- 6 files changed, 81 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b8b9777..72471a89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,11 @@ after its public API and format compatibility policies are established. - Added read-only durable verification ingress, exact moving-view conflict witnesses, selected-namespace root reports, and typed selected-root diagnostic sources (#114). -- Added catalog-bound complete-blob and admitted-retention-root verification reports, preserving exact missing members, identity/profile contradictions, resource failures, and original typed causes (#114). +- Added complete-blob and admitted-retention-root verification reports, with catalog provenance for checked blob evidence and successful root closure, preserving exact missing members, identity/profile contradictions, resource failures, and original typed causes (#114). -- Added allocation-free verification reports for already-admitted physical segments and logical chunk/layout records, with explicit subject-specific supported depths; full durable verification remains in progress under #114. +- Added allocation-free verification reports for already-admitted physical segments and logical chunk/layout records, with explicit subject-specific supported depths (#114). -- Admitted catalog snapshots can report explicit framing, checksum, or catalog-reachability evidence with immutable subject coordinates; unsupported requests refuse without claiming blob completeness or retention closure (#114, initial report slice). +- Admitted catalog snapshots can report explicit framing, checksum, or catalog-reachability evidence with immutable subject coordinates; unsupported requests refuse without claiming blob completeness or retention closure (#114). - Retention recovery execution errors report the exact failed boundary, original typed cause, known namespace effects and uncertain effect/durability; retries freshly observe the store. Observed stage identity remains binding across reopening, and cleanup preserves verified pool evidence rather than promising the removed pathname survives (#99). diff --git a/docs/audits/114-durable-verification-scope.md b/docs/audits/114-durable-verification-scope.md index b14e7590..0e9e482f 100644 --- a/docs/audits/114-durable-verification-scope.md +++ b/docs/audits/114-durable-verification-scope.md @@ -86,9 +86,9 @@ Raw segment/catalog classification, owned filesystem catalog reports, selected-n The [consolidated evidence](../testing-evidence/durable-verification.md) records their runtime checks and falsification; final full validation and independent exact-head review remain acceptance gates, not assumptions inferred from earlier green commits. -The independent exact-head review of `6504c86` found one acceptance gap in existing corruption-law mapping; the follow-up retains those laws' exact assertions while exercising production verification classification, with focused debug/release and mutation evidence. Final delta review and pushed-head checks remain pending. +The independent exact-head review of `6504c86` found one acceptance gap in existing corruption-law mapping; the follow-up retains those laws' exact assertions while exercising production verification classification, with focused debug/release and mutation evidence. That delta received independent approval and green pushed-head checks at `b33c7da`; later hosted review reopened the finite queue below. -## Implementation disposition +## Pre-readiness implementation disposition | Obligation | Disposition and evidence | | --- | --- | @@ -100,7 +100,7 @@ The independent exact-head review of `6504c86` found one acceptance gap in exist | Read-only behavior | Implemented: unchanged filesystem evidence on success/refusal; an injected production write fails the persistent-evidence assertion. | | Honest delivery | Normative contract, public rustdoc, rationale, requirement status and consolidated evidence reconciled. Mainline integration is not claimed before merge. | -The independent-review finding on `6504c86` is implemented and calibrated in its follow-up; approval of that delta and required checks must be recorded against the resulting exact head in [PR #165](https://github.com/flyingrobots/keep/pull/165) before it leaves draft. +The independent-review finding on `6504c86` is implemented and calibrated in its follow-up; approval of that delta and required checks must be recorded against the resulting exact head in [PR #165](https://github.com/flyingrobots/keep/pull/165) before acceptance; earlier approvals and checks do not transfer to a changed head. This table closes implementation obligations, not the independent review or human merge gate; the PR is the live authority for those exact-head decisions. @@ -113,8 +113,8 @@ Reviews arriving after `b33c7da` reopened the acceptance gate. The earlier imple | Shallow root provenance | Verified bug: framing/checksum attached an unconsulted catalog. Public parent regression RED; direct and publication-selected laws GREEN after attaching coordinates only for successful closure. | Fix pushed, review of resulting head, final required checks. | | Store-admission contradictions | Verified and corrected: public loader regressions RED on `26d3522`; typed record/root-identity contradictions now remain corrupt with their causes; host-width and unclassified I/O remain operational, with distinct calibration. | Fix pushed, resulting-head review and final checks. | | Verification-depth ordering | Removed `Ord`/`PartialOrd`; the public compile-fail law is RED on `665ffb3` because comparison compiled, then GREEN after removal. Runtime supported-depth laws remain GREEN. | Resulting-head review and final checks. | -| Observation classification exhaustiveness | Wildcard currently permits future current-state variants to default silently to operational. | Explicitly classify every existing variant; compiler and existing runtime classifications remain correct. | -| Shared layout failure classification | Admission, closure and ingress duplicate the operational cause list. | Use one exhaustive classifier without changing existing supported outcomes; verify the affected runtime laws. | -| Current documentation status | Initial-slice wording remains in current summary/changelog positions. | Reconcile current delivered scope, preserve historical evidence and identify final-head review/CI separately. | +| Observation classification exhaustiveness | Every existing current-state variant is now explicit; classifications are unchanged and affected runtime laws pass. | Resulting-head review and final checks. | +| Shared layout failure classification | Admission, closure and ingress use one exhaustive classifier. Generated differential receipts match the parent; affected debug/release laws and all-feature all-target Clippy pass. | Resulting-head review and final checks. | +| Current documentation status | Current summary/changelog wording is reconciled; chronological evidence is explicitly historical, and current review/CI gates remain separate. | Markdown and documentation integrity checks on the final candidate. | No finding is resolved merely because an earlier independent review approved the preceding head. No merge is authorized by this ledger. diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index 396afd07..4b3606a4 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -1,6 +1,6 @@ # Durable verification evidence -Status: partial implementation for [#114](https://github.com/flyingrobots/keep/issues/114); the full durable verification acceptance contract remains open in the [scope ledger](../audits/114-durable-verification-scope.md). +Status: the scoped implementation and post-readiness review corrections for [#114](https://github.com/flyingrobots/keep/issues/114) are implemented on the PR branch. Final exact-head independent review and required checks remain acceptance gates in the [scope ledger](../audits/114-durable-verification-scope.md); mainline delivery is not claimed. The sections below preserve chronological slice evidence, including superseded intermediate limitations. ## Catalog report slice @@ -231,3 +231,11 @@ The corrected runtime laws cover malformed `FORMAT`, intent and receipt magic, e Change kind: public API correction. `VerificationDepth` no longer implements `Ord` or `PartialOrd`; equality and every operation's explicit supported-depth set remain intact. This removes the misleading ability to treat catalog reachability as ordinally stronger than complete blob identity. The public rustdoc example is a static/API compile-fail contract, not runtime storage evidence. On unfixed `665ffb3bf65efd94ba46a94c28dfde636828caf1`, the new compile-fail example failed because the forbidden comparison compiled successfully. After removing the ordering derives, the same example passes with the intended unsupported binary comparison. Catalog, segment and blob/root verification laws pass in Docker debug/release, preserving actual runtime depth acceptance/refusal. This corrects an unreleased API; it changes no durable format or verification policy. + +## Post-readiness review: exhaustive shared classification + +Change kind: refactoring. Layout admission, nested retention-closure layout failures and durable ingress now call one exhaustive `layout_class` match. Its operational variants remain allocation, host entry-count width, host record-length width and configured entry limit; every other existing layout variant remains corrupt. The current-publication observation match explicitly names its remaining operational variants instead of accepting future variants through a wildcard. This is compiler-enforced classification ownership, not a new runtime failure policy. + +A temporary Rust probe generated every single-bit mutation at every byte of the frozen empty, one-zero and max-plus-one-zero layout records, plus unchanged records, under a one-entry cap and the maximum cap. It captured the public decoder/error-conversion outcomes on refactor parent `2b28c4a` and the candidate in separate copied Docker source/build trees; the complete ordered receipts compare byte-for-byte equal. This bounded differential evidence covers the generated decoder outcomes, not arbitrary allocation failures or a universal equivalence proof. The probe and raw receipts remain review artifacts; no implementation-shaped test was added to the permanent suite. + +Unchanged verification ingress/view, blob/root, segment, retention-root decoding and layout-decoding laws pass in Docker debug/release. All-feature all-target Clippy passes after consolidating identical operational match arms. The initial duplicate-arm lint failure is retained as setup/tooling feedback and is not claimed as behavioral RED. No runtime expectations were rebaselined for this refactor. diff --git a/src/adapters/retention/verification_observation_error.rs b/src/adapters/retention/verification_observation_error.rs index ca4cade4..56762df5 100644 --- a/src/adapters/retention/verification_observation_error.rs +++ b/src/adapters/retention/verification_observation_error.rs @@ -24,7 +24,36 @@ pub(super) fn refusal(error: &io::Error) -> Option { }), Current::ManifestRefused { source: RetentionManifestDecodeError::Allocation { .. }, - } => None, + } + | Current::RetainedStage + | Current::HeadAbsentWithArtifacts + | Current::ExpectedCurrentOverAbsentHead + | Current::NonInitialOverAbsentHead + | Current::PreparedHeadRefused { .. } + | Current::CatalogDisagreed { .. } + | Current::LivenessExhausted + | Current::StaleCommittedRetry + | Current::Superseded { .. } + | Current::CommittedSelectionMissing + | Current::CommittedSelectionMismatch + | Current::CommittedNamespaceUnavailable + | Current::CommittedRootAbsent + | Current::CommittedRootChanged + | Current::PredecessorMismatch + | Current::PredecessorRootAbsent + | Current::PredecessorRootChanged + | Current::UnknownRetentionEntry + | Current::NonNamespaceEntry + | Current::NoncanonicalPoolEntry { .. } + | Current::NamespaceCapacity + | Current::NamespaceExpectationViolated + | Current::AttemptNamespaceDisagreed + | Current::CommittedRetryOverAbsentHead + | Current::ProtocolDirectoryReplaced + | Current::RecoveryObservationRefused { .. } + | Current::RecoveryRefused { .. } + | Current::RecoveryStepRefused { .. } + | Current::RecordLengthOverflow => None, Current::HeadRefused { .. } | Current::ManifestRefused { .. } | Current::ManifestDisagreed @@ -36,6 +65,5 @@ pub(super) fn refusal(error: &io::Error) -> Option { | Current::CatalogChanged => Some(verification_admission::structural( VerificationSubject::PublishedView, )), - _ => None, } } diff --git a/src/adapters/verification_admission.rs b/src/adapters/verification_admission.rs index 43d00ccc..fb6aa00a 100644 --- a/src/adapters/verification_admission.rs +++ b/src/adapters/verification_admission.rs @@ -1,5 +1,6 @@ //! This module owns lossless semantic classification of verification failures. +use super::verification_failure_class::{FailureClass, layout_class}; use super::{VerificationError, VerificationSource}; use crate::{ LayoutDecodeError, RetentionClosureVerificationError as Closure, SegmentRecordIdentity, @@ -7,13 +8,7 @@ use crate::{ }; pub(super) fn layout(subject: VerificationSubject, source: LayoutDecodeError) -> VerificationError { - let operational = matches!( - source, - LayoutDecodeError::Allocation { .. } - | LayoutDecodeError::EntryCountHostWidth { .. } - | LayoutDecodeError::HostRecordLengthOutOfRange { .. } - | LayoutDecodeError::ConfiguredEntryLimitExceeded { .. } - ); + let operational = matches!(layout_class(&source), FailureClass::Operational); let source = Box::new(VerificationSource::Layout(source)); if operational { return VerificationError::Operational { source }; @@ -58,13 +53,7 @@ pub(super) fn closure(subject: VerificationSubject, source: Closure) -> Verifica } }; if let Closure::LayoutDecode { source: nested, .. } = &source - && matches!( - nested, - LayoutDecodeError::Allocation { .. } - | LayoutDecodeError::EntryCountHostWidth { .. } - | LayoutDecodeError::HostRecordLengthOutOfRange { .. } - | LayoutDecodeError::ConfiguredEntryLimitExceeded { .. } - ) + && matches!(layout_class(nested), FailureClass::Operational) { return VerificationError::Operational { source: Box::new(VerificationSource::Closure(source)), diff --git a/src/adapters/verification_failure_class.rs b/src/adapters/verification_failure_class.rs index 0ed58049..ffcaa385 100644 --- a/src/adapters/verification_failure_class.rs +++ b/src/adapters/verification_failure_class.rs @@ -12,16 +12,37 @@ pub(super) enum FailureClass { } pub(super) const fn layout_class(error: &LayoutDecodeError) -> FailureClass { - if matches!( - error, + match error { LayoutDecodeError::Allocation { .. } - | LayoutDecodeError::EntryCountHostWidth { .. } - | LayoutDecodeError::HostRecordLengthOutOfRange { .. } - | LayoutDecodeError::ConfiguredEntryLimitExceeded { .. } - ) { - FailureClass::Operational - } else { - FailureClass::Corrupt + | LayoutDecodeError::EntryCountHostWidth { .. } + | LayoutDecodeError::HostRecordLengthOutOfRange { .. } + | LayoutDecodeError::ConfiguredEntryLimitExceeded { .. } => FailureClass::Operational, + LayoutDecodeError::TruncatedHeader { .. } + | LayoutDecodeError::InvalidMagic { .. } + | LayoutDecodeError::UnsupportedFormatVersion { .. } + | LayoutDecodeError::UnsupportedCodec { .. } + | LayoutDecodeError::UnknownFlags { .. } + | LayoutDecodeError::WrongHeaderLength { .. } + | LayoutDecodeError::WrongEntryLength { .. } + | LayoutDecodeError::UnsupportedChecksumAlgorithm { .. } + | LayoutDecodeError::UnsupportedChunkHashAlgorithm { .. } + | LayoutDecodeError::UnsupportedChunkIdentityVersion { .. } + | LayoutDecodeError::NonzeroReserved { .. } + | LayoutDecodeError::EntryCountLimitExceeded { .. } + | LayoutDecodeError::RecordLengthLimitExceeded { .. } + | LayoutDecodeError::RecordLengthArithmetic { .. } + | LayoutDecodeError::RecordLengthMismatch { .. } + | LayoutDecodeError::EntryCountLengthMismatch { .. } + | LayoutDecodeError::TruncatedRecord { .. } + | LayoutDecodeError::TrailingData { .. } + | LayoutDecodeError::ChecksumMismatch { .. } + | LayoutDecodeError::BlobId { .. } + | LayoutDecodeError::UnsupportedStorageProfileVersion { .. } + | LayoutDecodeError::UnsupportedStorageProfileAlgorithm { .. } + | LayoutDecodeError::StorageProfile { .. } + | LayoutDecodeError::ZeroChunkLength { .. } + | LayoutDecodeError::Validation { .. } + | LayoutDecodeError::LayoutIdentity { .. } => FailureClass::Corrupt, } } From cc1e37bb5a8a5c86b8a76282a024480b65b15146 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 20:20:11 -0700 Subject: [PATCH 12/18] test: calibrate exact admission diagnostic assertions (#114) --- docs/testing-evidence/durable-verification.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index 4b3606a4..fafbc07d 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -239,3 +239,11 @@ Change kind: refactoring. Layout admission, nested retention-closure layout fail A temporary Rust probe generated every single-bit mutation at every byte of the frozen empty, one-zero and max-plus-one-zero layout records, plus unchanged records, under a one-entry cap and the maximum cap. It captured the public decoder/error-conversion outcomes on refactor parent `2b28c4a` and the candidate in separate copied Docker source/build trees; the complete ordered receipts compare byte-for-byte equal. This bounded differential evidence covers the generated decoder outcomes, not arbitrary allocation failures or a universal equivalence proof. The probe and raw receipts remain review artifacts; no implementation-shaped test was added to the permanent suite. Unchanged verification ingress/view, blob/root, segment, retention-root decoding and layout-decoding laws pass in Docker debug/release. All-feature all-target Clippy passes after consolidating identical operational match arms. The initial duplicate-arm lint failure is retained as setup/tooling feedback and is not claimed as behavioral RED. No runtime expectations were rebaselined for this refactor. + +## Post-readiness review: diagnostic calibration closure + +Change kind: evidence correction; production and permanent tests are unchanged. Independent review of `6802644ccf0d547694ab26644b9c306a43ddaeba` found that the source-removal mutant failed before the new exact diagnostic assertions. That receipt proves source presence, not diagnostic payload accuracy. + +Two diagnostic-only producer mutations on copied `6802644` preserve corruption classification and typed sources: the format-marker decoder reports zero magic bytes after rejecting the actual bad magic; root admission swaps the expected and observed identity coordinates after detecting disagreement. The existing public loader laws compile and fail respectively at `original magic for FORMAT` and `original binding coordinates required`. These are direct failures of the diagnostic assertions, not earlier source-presence failures or compilation errors. The originals, mutants, replay commands and failing logs are retained with the review receipts. + +Restoring both production files and invalidating source timestamps yields GREEN for `cargo test --locked --lib verification_store_admission_tests` and its release counterpart in a separate copied Docker source/build tree. The stable candidate's broader Docker chain also passes formatting, source structure, all-feature and no-default-feature Clippy with warnings denied, debug/release workspace tests, doctests and documentation build. Local documentation-integrity execution was unavailable because that Rust container lacks Markdown tooling; its failed setup attempt is retained separately, and hosted documentation/workflow integrity passed on `6802644`. All four required hosted jobs passed on that head; none of those results is substituted for required checks on a later pushed head. From c003c890e73eb68cd3fb5662a4efe8f78367c3af Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 20:22:47 -0700 Subject: [PATCH 13/18] fix: reject unsupported retention requests before reads (#114) --- CHANGELOG.md | 2 + docs/audits/114-durable-verification-scope.md | 2 + docs/invariants/verification/README.md | 2 + docs/testing-evidence/durable-verification.md | 8 +++ src/adapters/retention.rs | 2 + .../filesystem_retention_verification.rs | 12 +++- src/adapters/retention/root_verification.rs | 2 +- .../verification_unsupported_depth_tests.rs | 56 +++++++++++++++++++ 8 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/adapters/retention/verification_unsupported_depth_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 72471a89..f616b489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ after its public API and format compatibility policies are established. ## [Unreleased] +- Published retention verification rejects unsupported depths before reading selected root evidence, even when that evidence is missing (#114). + - Removed ordinal comparison traits from verification depths; callers use each subject’s explicit supported-depth set (#114). - Verification classifies typed migration-record and root-identity contradictions as corruption while preserving admission causes and keeping resource or unclassified I/O failures operational (#114). diff --git a/docs/audits/114-durable-verification-scope.md b/docs/audits/114-durable-verification-scope.md index 0e9e482f..8f8bff82 100644 --- a/docs/audits/114-durable-verification-scope.md +++ b/docs/audits/114-durable-verification-scope.md @@ -116,5 +116,7 @@ Reviews arriving after `b33c7da` reopened the acceptance gate. The earlier imple | Observation classification exhaustiveness | Every existing current-state variant is now explicit; classifications are unchanged and affected runtime laws pass. | Resulting-head review and final checks. | | Shared layout failure classification | Admission, closure and ingress use one exhaustive classifier. Generated differential receipts match the parent; affected debug/release laws and all-feature all-target Clippy pass. | Resulting-head review and final checks. | | Current documentation status | Current summary/changelog wording is reconciled; chronological evidence is explicitly historical, and current review/CI gates remain separate. | Markdown and documentation integrity checks on the final candidate. | +| Exact admission diagnostic calibration | Independent review of `6802644` identified an evidence gap, not a production defect. Diagnostic-only magic and identity-coordinate mutations reach the intended assertions; restored debug/release laws pass. | Resulting-head independent confirmation. | +| Unsupported retention request precedence | CodeRabbit's later global review identified evidence reads before request admission. A public regression is RED on `cc1e37b`; shared supported-depth admission now precedes selected-root access. | Focused GREEN, resulting-head review and final checks. | No finding is resolved merely because an earlier independent review approved the preceding head. No merge is authorized by this ledger. diff --git a/docs/invariants/verification/README.md b/docs/invariants/verification/README.md index 6fb56bd7..3c762fb1 100644 --- a/docs/invariants/verification/README.md +++ b/docs/invariants/verification/README.md @@ -25,6 +25,8 @@ Reports contain no plaintext, keys or filesystem paths and convey no publication Every other depth returns `VerificationRefusal::Unsupported` (wrapped by `VerificationError` for blob/root operations) with the exact subject, request and supported set; the operation neither downgrades the request nor returns a success report. +`FilesystemRetentionSnapshot::verify_retention` accepts the same depths as direct root verification. Unsupported requests refuse with the requested namespace before reading its selected root or re-admitting the catalog, including when the namespace or root evidence is absent. Loading the fenced snapshot is a separate operation with its own admission failures. + `SnapshotBinding` remains unsupported until its separate protocol exists; catalog/retention coordinates must not be mislabeled as that future proof. ## Costs and admission boundary diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index fafbc07d..f013f925 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -247,3 +247,11 @@ Change kind: evidence correction; production and permanent tests are unchanged. Two diagnostic-only producer mutations on copied `6802644` preserve corruption classification and typed sources: the format-marker decoder reports zero magic bytes after rejecting the actual bad magic; root admission swaps the expected and observed identity coordinates after detecting disagreement. The existing public loader laws compile and fail respectively at `original magic for FORMAT` and `original binding coordinates required`. These are direct failures of the diagnostic assertions, not earlier source-presence failures or compilation errors. The originals, mutants, replay commands and failing logs are retained with the review receipts. Restoring both production files and invalidating source timestamps yields GREEN for `cargo test --locked --lib verification_store_admission_tests` and its release counterpart in a separate copied Docker source/build tree. The stable candidate's broader Docker chain also passes formatting, source structure, all-feature and no-default-feature Clippy with warnings denied, debug/release workspace tests, doctests and documentation build. Local documentation-integrity execution was unavailable because that Rust container lacks Markdown tooling; its failed setup attempt is retained separately, and hosted documentation/workflow integrity passed on `6802644`. All four required hosted jobs passed on that head; none of those results is substituted for required checks on a later pushed head. + +## Post-readiness review: unsupported request precedence + +Change kind: bug fix. CodeRabbit's global review of `6802644` identified that publication-selected retention verification read root evidence before rejecting unsupported depths. On unfixed `cc1e37b`, the new public `unsupported_retention_requests_refuse_before_missing_evidence` law compiles and fails with `Missing` plus the original filesystem `NotFound` cause where exact `Unsupported` was required. The permanent regression covers every unsupported depth for a missing selected root and an absent namespace; the oracle names the requested namespace, depth, supported set and absence of an operational source. + +The filesystem entry point now checks the same supported-depth constant as direct admitted-root verification before selected-root reads or catalog re-admission. Supported requests retain their existing evidence-verification path. Snapshot loading remains separate and can still refuse during admission; this change does not hide those failures. No durable format, writer behavior, recovery protocol or runtime limit changes. + +In copied Docker source, the new law and existing filesystem verification laws pass in debug and release; all-feature library/integration Clippy passes with warnings denied. The parent RED directly calibrates the new exact refusal assertion. No existing expectation was changed, no sleep or uncontrolled schedule was introduced, and ordinary test resource-enforcement gaps remain those documented in the testing enforcement ledger. Final candidate checks and independent delta review remain separate gates. diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 64f4c1de..46ba38f1 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -180,6 +180,8 @@ mod transition_readiness; mod verification_selection_law_tests; #[cfg(test)] mod verification_store_admission_tests; +#[cfg(test)] +mod verification_unsupported_depth_tests; mod verified_closure; #[cfg(test)] diff --git a/src/adapters/retention/filesystem_retention_verification.rs b/src/adapters/retention/filesystem_retention_verification.rs index e97bd608..bdc10c4e 100644 --- a/src/adapters/retention/filesystem_retention_verification.rs +++ b/src/adapters/retention/filesystem_retention_verification.rs @@ -4,6 +4,7 @@ reason = "preserve bounded full refusal coordinates without extra allocations" )] +use super::root_verification::SUPPORTED; use super::{AdmittedRetentionRoot, RetentionSelectedRootRefusal}; use crate::adapters::filesystem_exact_record::ExactRecordError; use crate::adapters::{verification_admission, verification_ingress}; @@ -41,7 +42,8 @@ impl FilesystemRetentionSnapshot { /// Verifies the exact root selected for `namespace` in this fenced view. /// - /// Reads only the manifest-selected root, without following links, within + /// Unsupported depths refuse before root or catalog access. Supported + /// requests read only the manifest-selected root, without following links, within /// the root format bound. It checks namespace, generation and digest, then /// re-admits the owned catalog and applies `AdmittedRetentionRoot::verify`. /// Costs include one bounded root buffer, its decoded anchors and catalog @@ -60,6 +62,14 @@ impl FilesystemRetentionSnapshot { requested: VerificationDepth, ) -> Result { let subject = VerificationSubject::RetentionNamespace { namespace }; + if !SUPPORTED.contains(&requested) { + return Err(VerificationRefusal::Unsupported { + subject, + requested, + supported: SUPPORTED, + } + .into()); + } let bytes = self .retained_root(namespace) .map_err(|source| root_error(subject, source))? diff --git a/src/adapters/retention/root_verification.rs b/src/adapters/retention/root_verification.rs index b1886241..0a021169 100644 --- a/src/adapters/retention/root_verification.rs +++ b/src/adapters/retention/root_verification.rs @@ -11,7 +11,7 @@ use crate::{ VerificationSubject, }; -const SUPPORTED: &[VerificationDepth] = &[ +pub(super) const SUPPORTED: &[VerificationDepth] = &[ VerificationDepth::Framing, VerificationDepth::Checksum, VerificationDepth::RetentionClosure, diff --git a/src/adapters/retention/verification_unsupported_depth_tests.rs b/src/adapters/retention/verification_unsupported_depth_tests.rs new file mode 100644 index 00000000..12f95061 --- /dev/null +++ b/src/adapters/retention/verification_unsupported_depth_tests.rs @@ -0,0 +1,56 @@ +//! Unsupported retention requests refuse independently of selected evidence. + +use super::filesystem_retention_test_fixture::{ + ROOT_HEX, fixture, initial_preparation, open_authority, root_pool_path, +}; +use crate::{ + AdmittedRetentionRoot, CatalogRestartByteLimit, CatalogRestartPolicy, + FilesystemRetentionSnapshot, ReaderAttemptLimit, RetentionNamespace, SegmentReadPolicy, + VerificationDepth as Depth, VerificationError, VerificationRefusal, VerificationSubject, + execute_retention_publication, +}; +use std::{error::Error, fs}; + +// Size: medium. Oracle: the public supported-depth contract precedes evidence reads. +// Delete if a stronger public request-admission law subsumes both absent cases. +#[test] +fn unsupported_retention_requests_refuse_before_missing_evidence() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("verify-unsupported-depth")?; + let bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&bytes)?; + let _receipt = execute_retention_publication(&mut authority, &preparation)?; + drop(authority); + let root = AdmittedRetentionRoot::decode(&bytes)?; + fs::remove_file(root_pool_path(sandbox.path(), &root))?; + let policy = CatalogRestartPolicy::new( + SegmentReadPolicy::MAXIMUM, + CatalogRestartByteLimit::new(1_048_576)?, + ); + let snapshot = FilesystemRetentionSnapshot::load_for_verification( + sandbox.path(), + policy, + ReaderAttemptLimit::DEFAULT, + )?; + for namespace in [ + root.root().namespace().digest(), + RetentionNamespace::try_from(b"absent".as_slice())?.digest(), + ] { + for requested in [ + Depth::ChunkIdentity, + Depth::LayoutIdentity, + Depth::CompleteBlobIdentity, + Depth::CatalogReachability, + Depth::SnapshotBinding, + ] { + let error = snapshot + .verify_retention(namespace, requested) + .err() + .ok_or("unsupported request certified")?; + assert!( + matches!(&error, VerificationError::Refused { refusal: VerificationRefusal::Unsupported { subject: VerificationSubject::RetentionNamespace { namespace: actual }, requested: actual_requested, supported }, source: None } if *actual == namespace && *actual_requested == requested && *supported == [Depth::Framing, Depth::Checksum, Depth::RetentionClosure]), + "unsupported request must precede evidence access: {error:?}" + ); + } + } + Ok(()) +} From 27d5934209b94e67061c7b4df6584593bbe8c7b2 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 20:28:19 -0700 Subject: [PATCH 14/18] fix: preserve selected-root wrong-kind corruption (#114) --- CHANGELOG.md | 2 + docs/audits/114-durable-verification-scope.md | 1 + docs/invariants/verification/README.md | 2 + docs/testing-evidence/durable-verification.md | 8 +++ src/adapters/retention.rs | 2 + .../filesystem_retention_snapshot.rs | 9 ++- .../retention/verification_root_kind_tests.rs | 69 +++++++++++++++++++ 7 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/adapters/retention/verification_root_kind_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f616b489..5aad8ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ after its public API and format compatibility policies are established. ## [Unreleased] +- Selected-root verification preserves observed non-regular file evidence as typed corruption instead of an inconclusive no-follow-open error (#114). + - Published retention verification rejects unsupported depths before reading selected root evidence, even when that evidence is missing (#114). - Removed ordinal comparison traits from verification depths; callers use each subject’s explicit supported-depth set (#114). diff --git a/docs/audits/114-durable-verification-scope.md b/docs/audits/114-durable-verification-scope.md index 8f8bff82..aab0b662 100644 --- a/docs/audits/114-durable-verification-scope.md +++ b/docs/audits/114-durable-verification-scope.md @@ -118,5 +118,6 @@ Reviews arriving after `b33c7da` reopened the acceptance gate. The earlier imple | Current documentation status | Current summary/changelog wording is reconciled; chronological evidence is explicitly historical, and current review/CI gates remain separate. | Markdown and documentation integrity checks on the final candidate. | | Exact admission diagnostic calibration | Independent review of `6802644` identified an evidence gap, not a production defect. Diagnostic-only magic and identity-coordinate mutations reach the intended assertions; restored debug/release laws pass. | Resulting-head independent confirmation. | | Unsupported retention request precedence | CodeRabbit's later global review identified evidence reads before request admission. A public regression is RED on `cc1e37b`; shared supported-depth admission now precedes selected-root access. | Focused GREEN, resulting-head review and final checks. | +| Selected-root symlink classification | Hosted Codex and independent review verified that observed non-regular evidence became raw operational I/O. Public parent regression RED on `c003c89`; the existing metadata observation now preserves typed wrong-kind corruption, with focused debug/release GREEN. | Resulting-head independent review and final checks. | No finding is resolved merely because an earlier independent review approved the preceding head. No merge is authorized by this ledger. diff --git a/docs/invariants/verification/README.md b/docs/invariants/verification/README.md index 3c762fb1..0f2368cf 100644 --- a/docs/invariants/verification/README.md +++ b/docs/invariants/verification/README.md @@ -27,6 +27,8 @@ Every other depth returns `VerificationRefusal::Unsupported` (wrapped by `Verifi `FilesystemRetentionSnapshot::verify_retention` accepts the same depths as direct root verification. Unsupported requests refuse with the requested namespace before reading its selected root or re-admitting the catalog, including when the namespace or root evidence is absent. Loading the fenced snapshot is a separate operation with its own admission failures. +Selected-root observation rejects a non-regular file, including a symlink to valid root bytes, with a typed kind refusal classified as corruption. Subsequent opens still follow no links and check the opened file; the preliminary kind observation provides no isolation guarantee against concurrent raw namespace substitution. + `SnapshotBinding` remains unsupported until its separate protocol exists; catalog/retention coordinates must not be mislabeled as that future proof. ## Costs and admission boundary diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index f013f925..25ea2c82 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -255,3 +255,11 @@ Change kind: bug fix. CodeRabbit's global review of `6802644` identified that pu The filesystem entry point now checks the same supported-depth constant as direct admitted-root verification before selected-root reads or catalog re-admission. Supported requests retain their existing evidence-verification path. Snapshot loading remains separate and can still refuse during admission; this change does not hide those failures. No durable format, writer behavior, recovery protocol or runtime limit changes. In copied Docker source, the new law and existing filesystem verification laws pass in debug and release; all-feature library/integration Clippy passes with warnings denied. The parent RED directly calibrates the new exact refusal assertion. No existing expectation was changed, no sleep or uncontrolled schedule was introduced, and ordinary test resource-enforcement gaps remain those documented in the testing enforcement ledger. Final candidate checks and independent delta review remain separate gates. + +## Post-readiness review: selected-root kind evidence + +Change kind: bug fix. Hosted Codex and independent review of `c003c890e73eb68cd3fb5662a4efe8f78367c3af` found that a selected-root symlink lost its observed wrong-kind evidence when the later no-follow open returned raw `ELOOP`. The new public loader/verification regression replaces the selected root with a symlink to its original valid bytes. On that unfixed head it reaches the named assertion and fails with `Operational` and the original filesystem-loop cause instead of exact namespace corruption with a typed kind refusal. An earlier fixture put the target at a forbidden store-root name and failed snapshot admission; that setup failure is preserved separately and excluded from regression evidence. + +The existing no-follow metadata observation now rejects non-regular files with `ExactRecordError::Refused(KindOrLength)` before converting length or attempting an open. The existing verification classifier preserves that typed cause as corruption. Subsequent no-follow opening and opened-file checks remain intact; this guard does not eliminate races against unsupported concurrent raw namespace mutation and does not reinterpret arbitrary I/O errors as corruption. The shared snapshot accessor also returns the typed refusal to ordinary retained-root callers. + +The regression and existing filesystem verification laws pass in copied Docker debug/release runs, and all-feature library/integration Clippy passes with warnings denied. The parent RED calibrates the single combined typed-outcome assertion. No persistent repair, cleanup, format change or source-string test is introduced. Required final-head validation and independent confirmation remain separate from these focused receipts. diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 46ba38f1..4a287a1d 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -177,6 +177,8 @@ mod transition_preflight; mod transition_preflight_error; mod transition_readiness; #[cfg(test)] +mod verification_root_kind_tests; +#[cfg(test)] mod verification_selection_law_tests; #[cfg(test)] mod verification_store_admission_tests; diff --git a/src/adapters/retention/filesystem_retention_snapshot.rs b/src/adapters/retention/filesystem_retention_snapshot.rs index 49677179..e0b022f0 100644 --- a/src/adapters/retention/filesystem_retention_snapshot.rs +++ b/src/adapters/retention/filesystem_retention_snapshot.rs @@ -13,7 +13,9 @@ use super::{ ReaderFence, RetentionSelectedRootRefusal, RetentionViewCoordinates, RetentionViewSource, collect_retention_view, root_header_decoder, }; -use crate::adapters::filesystem_exact_record::{self as exact_record, ExactRecordError}; +use crate::adapters::filesystem_exact_record::{ + self as exact_record, ExactRecordError, ExactRecordRefusal, +}; use crate::adapters::filesystem_platform_profile::root_identity; use crate::adapters::filesystem_version_two_admission::require_root_identity; use crate::adapters::{ @@ -225,6 +227,11 @@ impl FilesystemRetentionSnapshot { let length = directory .symlink_metadata(&name) .and_then(|metadata| { + if !metadata.is_file() { + return Err( + ExactRecordError::Refused(ExactRecordRefusal::KindOrLength).into_io() + ); + } usize::try_from(metadata.len()).map_err(|_source| { selected_refusal(RetentionSelectedRootRefusal::HostLength { observed: metadata.len(), diff --git a/src/adapters/retention/verification_root_kind_tests.rs b/src/adapters/retention/verification_root_kind_tests.rs new file mode 100644 index 00000000..375c41b4 --- /dev/null +++ b/src/adapters/retention/verification_root_kind_tests.rs @@ -0,0 +1,69 @@ +//! A selected root must be a regular file, independently of symlink targets. + +use super::filesystem_retention_test_fixture::{ + ROOT_HEX, fixture, initial_preparation, open_authority, root_pool_path, +}; +use crate::adapters::filesystem_exact_record::{ExactRecordError, ExactRecordRefusal}; +use crate::{ + AdmittedRetentionRoot, CatalogRestartByteLimit, CatalogRestartPolicy, + FilesystemRetentionSnapshot, FilesystemRetentionSnapshotError, ReaderAttemptLimit, + SegmentReadPolicy, VerificationDepth, VerificationError, VerificationObservation, + VerificationRefusal, VerificationSource, VerificationSubject, execute_retention_publication, +}; +use std::{error::Error, fs, os::unix::fs::symlink}; + +// Size: medium. Oracle: the selected pathname must itself be a regular root record. +// Delete if stronger public wrong-kind coverage subsumes this valid-target symlink. +#[test] +fn a_selected_root_symlink_is_typed_corruption() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("verification-root-symlink")?; + let bytes = fixture(ROOT_HEX)?; + let preparation = initial_preparation(&bytes)?; + let _receipt = execute_retention_publication(&mut authority, &preparation)?; + drop(authority); + let root = AdmittedRetentionRoot::decode(&bytes)?; + let path = root_pool_path(sandbox.path(), &root); + let target = path.with_extension("original"); + fs::rename(&path, &target)?; + symlink(&target, &path)?; + let view = FilesystemRetentionSnapshot::load_for_verification( + sandbox.path(), + CatalogRestartPolicy::new( + SegmentReadPolicy::MAXIMUM, + CatalogRestartByteLimit::new(1_048_576)?, + ), + ReaderAttemptLimit::DEFAULT, + )?; + let namespace = root.root().namespace().digest(); + let error = view + .verify_retention(namespace, VerificationDepth::Checksum) + .err() + .ok_or("symlink certified")?; + let expected = VerificationRefusal::Corrupt { + subject: VerificationSubject::RetentionNamespace { namespace }, + expected: VerificationObservation::Canonical, + observed: VerificationObservation::Refused, + }; + let actual = match &error { + VerificationError::Refused { + refusal, + source: Some(source), + } if *refusal == expected => match source.as_ref() { + VerificationSource::Retention(FilesystemRetentionSnapshotError::Root { source }) => { + source + .get_ref() + .and_then(|source| source.downcast_ref::()) + } + _ => None, + }, + _ => None, + }; + assert!( + matches!( + actual, + Some(ExactRecordError::Refused(ExactRecordRefusal::KindOrLength)) + ), + "selected symlink must retain typed kind corruption: {error:?}" + ); + Ok(()) +} From 90b9af375ec1e6f409b719d65eb05cf8605e0760 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 3 Oct 2026 15:51:42 -0700 Subject: [PATCH 15/18] fix: retain missing selected segment identity in verification (#114) --- CHANGELOG.md | 2 + docs/testing-evidence/durable-verification.md | 8 ++++ src/adapters/catalog_restart_error.rs | 26 +++++++++++- src/adapters/catalog_restart_segments.rs | 6 ++- src/adapters/verification_failure_class.rs | 1 + src/adapters/verification_ingress.rs | 15 +++++++ tests/catalog_restart/refusal_laws.rs | 4 +- tests/catalog_restart/verification_laws.rs | 41 +++++++++++++++++++ 8 files changed, 97 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d850e23f..ce98cc0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ after its public API and format compatibility policies are established. ## [Unreleased] +- Filesystem verification names the missing catalog-selected segment and preserves its digest, failing I/O phase and original cause instead of labeling the present catalog missing (#114). + - Selected-root verification preserves observed non-regular file evidence as typed corruption instead of an inconclusive no-follow-open error (#114). - Published retention verification rejects unsupported depths before reading selected root evidence, even when that evidence is missing (#114). diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index 25ea2c82..a6763185 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -263,3 +263,11 @@ Change kind: bug fix. Hosted Codex and independent review of `c003c890e73eb68cd3 The existing no-follow metadata observation now rejects non-regular files with `ExactRecordError::Refused(KindOrLength)` before converting length or attempting an open. The existing verification classifier preserves that typed cause as corruption. Subsequent no-follow opening and opened-file checks remain intact; this guard does not eliminate races against unsupported concurrent raw namespace mutation and does not reinterpret arbitrary I/O errors as corruption. The shared snapshot accessor also returns the typed refusal to ordinary retained-root callers. The regression and existing filesystem verification laws pass in copied Docker debug/release runs, and all-feature library/integration Clippy passes with warnings denied. The parent RED calibrates the single combined typed-outcome assertion. No persistent repair, cleanup, format change or source-string test is introduced. Required final-head validation and independent confirmation remain separate from these focused receipts. + +## Landing missing-segment identity + +Change kind: bug fix. The public filesystem verification regression on unfixed integration `80afd1175525c2b5ce4d779c6ad02e6cbcb478ac` returned `Missing(PublishedCatalog)` for a valid catalog whose selected segment file was absent (`165-missing-segment-red.log`). The independent frozen segment digest is the oracle; the catalog and head remain present and unchanged. + +Catalog-selected segment opening/reading now preserves its digest, phase and original I/O source in `CatalogRestartError::SegmentIo`. Verification maps only an opening `NotFound` to `Missing(Segment { digest })`; other segment I/O remains operational. The ordinary missing-catalog result is unchanged. This adds a public diagnostic variant; downstream exhaustive matches require an arm, while formats, identities and successful loading behavior are unchanged. + +The existing direct restart law is updated for this deliberate diagnostic enrichment. All catalog restart laws pass in debug/release, and warnings-denied all-target Clippy and structure checks pass (`165-missing-segment-green.log`). Removing the preserved source while retaining the correct missing-segment subject makes the new source assertion fail independently (`165-missing-source-mutation-red.log`); restored debug/release laws pass (`165-missing-restored-green.log`). The initial test conversion build error and a restoration attempt that reused the mutant artifact are retained separately, not counted as product RED or GREEN. The latter was corrected by verifying identical restored source hashes and invalidating the modified source timestamp before rebuilding. Final full validation and exact-head independent review remain separate acceptance gates. diff --git a/src/adapters/catalog_restart_error.rs b/src/adapters/catalog_restart_error.rs index b7e28e86..d61b19a8 100644 --- a/src/adapters/catalog_restart_error.rs +++ b/src/adapters/catalog_restart_error.rs @@ -21,6 +21,15 @@ pub enum CatalogRestartError { /// Preserved filesystem source. source: io::Error, }, + /// I/O failed while opening or reading one catalog-selected segment. + SegmentIo { + /// Physical digest selected by the admitted catalog. + expected: SegmentDigest, + /// Exact operation that failed. + phase: CatalogRestartPhase, + /// Original filesystem source, preserved without rewrapping. + source: io::Error, + }, /// An opened protocol artifact was not a regular file. NotRegular { /// Artifact whose type was wrong. @@ -128,6 +137,17 @@ pub enum CatalogRestartError { } impl CatalogRestartError { + pub(super) fn with_segment(self, expected: SegmentDigest) -> Self { + match self { + Self::Io { phase, source } => Self::SegmentIo { + expected, + phase, + source, + }, + other => other, + } + } + pub(super) const fn io(phase: CatalogRestartPhase, source: io::Error) -> Self { Self::Io { phase, source } } @@ -136,7 +156,9 @@ impl CatalogRestartError { impl fmt::Display for CatalogRestartError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Io { phase, .. } => write!(formatter, "catalog restart {phase} failed"), + Self::Io { phase, .. } | Self::SegmentIo { phase, .. } => { + write!(formatter, "catalog restart {phase} failed") + } Self::NotRegular { .. } => formatter.write_str("restart artifact is not regular"), Self::Length { .. } => formatter.write_str("restart artifact length is invalid"), Self::LengthArithmetic { .. } => { @@ -173,7 +195,7 @@ impl fmt::Display for CatalogRestartError { impl Error for CatalogRestartError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { - Self::Io { source, .. } => Some(source), + Self::Io { source, .. } | Self::SegmentIo { source, .. } => Some(source), Self::Allocation { source: Some(source), .. diff --git a/src/adapters/catalog_restart_segments.rs b/src/adapters/catalog_restart_segments.rs index dc89cfb2..116b6f67 100644 --- a/src/adapters/catalog_restart_segments.rs +++ b/src/adapters/catalog_restart_segments.rs @@ -63,7 +63,8 @@ pub(super) fn load( &name, artifact, CatalogRestartPhase::OpenSegment, - )?; + ) + .map_err(|source| source.with_segment(*digest))?; if observed > MAXIMUM_SEGMENT_LENGTH { return Err(CatalogRestartError::Length { artifact, @@ -89,7 +90,8 @@ pub(super) fn load( artifact, CatalogRestartPhase::ReadSegment, observed, - )?; + ) + .map_err(|source| source.with_segment(*digest))?; let segment = AdmittedSegment::decode(&encoded, policy.segment_read()).map_err(|source| { CatalogRestartError::Segment { diff --git a/src/adapters/verification_failure_class.rs b/src/adapters/verification_failure_class.rs index ffcaa385..4e27e3be 100644 --- a/src/adapters/verification_failure_class.rs +++ b/src/adapters/verification_failure_class.rs @@ -81,6 +81,7 @@ pub(super) const fn segment_class(error: &SegmentReadError) -> FailureClass { pub(super) fn catalog_class(error: &CatalogRestartError) -> FailureClass { match error { CatalogRestartError::Io { .. } + | CatalogRestartError::SegmentIo { .. } | CatalogRestartError::LengthArithmetic { .. } | CatalogRestartError::Allocation { .. } | CatalogRestartError::SegmentIndexLength diff --git a/src/adapters/verification_ingress.rs b/src/adapters/verification_ingress.rs index 0598d7bc..5748489c 100644 --- a/src/adapters/verification_ingress.rs +++ b/src/adapters/verification_ingress.rs @@ -104,6 +104,21 @@ impl FilesystemCatalogSnapshot { } pub(super) fn catalog_error(source: CatalogRestartError) -> VerificationError { + if let CatalogRestartError::SegmentIo { + expected, + phase: CatalogRestartPhase::OpenSegment, + source: nested, + } = &source + && nested.kind() == ErrorKind::NotFound + { + return VerificationError::Refused { + refusal: VerificationRefusal::Missing { + subject: VerificationSubject::Segment { digest: *expected }, + }, + source: Some(Box::new(VerificationSource::Catalog(source))), + }; + } + if let CatalogRestartError::CatalogAdmission { source: nested } = &source && let crate::CatalogAdmissionError::MissingSegment { digest } = nested.as_ref() { diff --git a/tests/catalog_restart/refusal_laws.rs b/tests/catalog_restart/refusal_laws.rs index 9afdc9e4..5cd66bb3 100644 --- a/tests/catalog_restart/refusal_laws.rs +++ b/tests/catalog_restart/refusal_laws.rs @@ -103,9 +103,9 @@ fn dangling_catalog_and_segment_paths_refuse_exactly() -> Result<(), Box Result<(), Box Result<(), Box> { + let store = StoreFixture::create("verification-missing-segment-identity")?; + let expected: [u8; 32] = super::support::decode_hex(super::SEGMENT_DIGEST)? + .as_slice() + .try_into()?; + let catalog = fs::read(&store.catalog_path)?; + let head = fs::read(store.path().join("HEAD"))?; + fs::remove_file(&store.segment_path)?; + let error = FilesystemCatalogSnapshot::load_for_verification(store.path(), restart_policy()?) + .err() + .ok_or("missing segment admitted")?; + assert!( + matches!(&error, VerificationError::Refused { + refusal: VerificationRefusal::Missing { subject: VerificationSubject::Segment { digest } }, .. + } if digest.as_bytes() == &expected), + "missing selected segment identity: {error:?}" + ); + assert!( + matches!(&error, VerificationError::Refused { source: Some(source), .. } + if matches!(source.as_ref(), VerificationSource::Catalog(CatalogRestartError::SegmentIo { + expected: digest, phase: CatalogRestartPhase::OpenSegment, source + }) if digest.as_bytes() == &expected && source.kind() == std::io::ErrorKind::NotFound)), + "missing segment must preserve its physical identity, open boundary and original I/O cause: {error:?}" + ); + assert_eq!( + fs::read(&store.catalog_path)?, + catalog, + "present catalog must survive refusal" + ); + assert_eq!( + fs::read(store.path().join("HEAD"))?, + head, + "published head must survive refusal" + ); + store.remove() +} From c544e2df9c0d46669362fd1009d468d1f87102b0 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 3 Oct 2026 15:56:43 -0700 Subject: [PATCH 16/18] fix: classify evidenced namespace contradictions precisely (#114) --- CHANGELOG.md | 2 + docs/invariants/verification/rationale.md | 4 + docs/testing-evidence/durable-verification.md | 8 ++ src/adapters/exports.rs | 2 + .../filesystem_initialization_namespace.rs | 36 ++++---- src/adapters/filesystem_namespace_refusal.rs | 85 +++++++++++++++++++ src/adapters/filesystem_platform_profile.rs | 2 + src/adapters/mod.rs | 1 + src/adapters/retention.rs | 2 + .../filesystem_retention_verification.rs | 6 ++ .../verification_namespace_law_tests.rs | 81 ++++++++++++++++++ src/lib.rs | 2 + 12 files changed, 212 insertions(+), 19 deletions(-) create mode 100644 docs/invariants/verification/rationale.md create mode 100644 src/adapters/filesystem_namespace_refusal.rs create mode 100644 src/adapters/retention/verification_namespace_law_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ce98cc0e..c82e9f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ after its public API and format compatibility policies are established. ## [Unreleased] +- Verification preserves typed canonical-namespace and no-follow entry-kind contradictions as corruption while leaving inconclusive observation failures operational (#114). + - Filesystem verification names the missing catalog-selected segment and preserves its digest, failing I/O phase and original cause instead of labeling the present catalog missing (#114). - Selected-root verification preserves observed non-regular file evidence as typed corruption instead of an inconclusive no-follow-open error (#114). diff --git a/docs/invariants/verification/rationale.md b/docs/invariants/verification/rationale.md new file mode 100644 index 00000000..c6e004c1 --- /dev/null +++ b/docs/invariants/verification/rationale.md @@ -0,0 +1,4 @@ + +## Typed namespace observations + +Canonical membership and no-follow entry-kind observations belong to filesystem namespace admission, including the earlier platform directory traversal. Their typed source records a demonstrated contradiction; an I/O error alone does not. Verification consumes that evidence rather than parsing messages or broadly equating InvalidData, ELOOP or NotADirectory with corruption. Failed directory iteration remains operational before any membership inference. The shared guard leaves existing no-follow opens and opened-file checks intact and does not make later pathname operations conditional on inode identity. Catalog-selected segment I/O similarly retains its known digest and original cause, so absence identifies the missing evidence without renaming the present catalog. These additive diagnostic types enrich the public error surface; downstream exhaustive matches may need new arms, without changing durable formats or successful behavior. diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index a6763185..2c0cb530 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -271,3 +271,11 @@ Change kind: bug fix. The public filesystem verification regression on unfixed i Catalog-selected segment opening/reading now preserves its digest, phase and original I/O source in `CatalogRestartError::SegmentIo`. Verification maps only an opening `NotFound` to `Missing(Segment { digest })`; other segment I/O remains operational. The ordinary missing-catalog result is unchanged. This adds a public diagnostic variant; downstream exhaustive matches require an arm, while formats, identities and successful loading behavior are unchanged. The existing direct restart law is updated for this deliberate diagnostic enrichment. All catalog restart laws pass in debug/release, and warnings-denied all-target Clippy and structure checks pass (`165-missing-segment-green.log`). Removing the preserved source while retaining the correct missing-segment subject makes the new source assertion fail independently (`165-missing-source-mutation-red.log`); restored debug/release laws pass (`165-missing-restored-green.log`). The initial test conversion build error and a restoration attempt that reused the mutant artifact are retained separately, not counted as product RED or GREEN. The latter was corrected by verifying identical restored source hashes and invalidating the modified source timestamp before rebuilding. Final full validation and exact-head independent review remain separate acceptance gates. + +## Landing namespace-admission diagnostics + +Change kind: bug fix. Three public loader regressions on the unfixed integration admission path report Operational for an unexpected entry, a required protocol directory replaced by a regular file, and a directory symlink (`165-namespace-red.log`). This source is `80afd1175525c2b5ce4d779c6ad02e6cbcb478ac` plus the independent missing-segment correction and new regression scaffolding; the namespace and platform paths were unchanged for these RED runs. + +`FilesystemNamespaceRefusal` now carries observed membership contradictions or exact expected/observed entry kinds. Namespace admission and the earlier platform directory traversal share that typed observation; original no-follow opens, opened-file checks, platform properties and synchronization remain in place. Failed observations propagate their original I/O source; iterator errors are propagated before counting entries. Only the typed demonstrated contradictions are classified corrupt, not arbitrary InvalidData or filesystem error numbers. + +Exact public classification/source and retained-evidence laws pass in debug/release. A diagnostic-only mutation retains Corrupt while replacing the observed file/symlink kind with Other; both precise-cause assertions fail (`165-namespace-kind-mutation-red.log`). Restored namespace and existing operational/admission laws pass, with all-target Clippy and structure checks (`165-namespace-restored-green.log`). An initial edit-script placement error failed compilation (`165-namespace-build-failure.log`); it is not runtime RED. No rollback, atomic pathname identity guarantee, repair or support for arbitrary concurrent namespace mutation is introduced. diff --git a/src/adapters/exports.rs b/src/adapters/exports.rs index e41d34d6..8a900830 100644 --- a/src/adapters/exports.rs +++ b/src/adapters/exports.rs @@ -108,3 +108,5 @@ pub use super::store_migration::*; pub use super::writer_lock_acquire_error::WriterLockAcquireError; pub use super::writer_lock_acquire_phase::WriterLockAcquirePhase; pub use crate::segment_digest::SegmentDigest; + +pub use super::filesystem_namespace_refusal::{FilesystemEntryKind, FilesystemNamespaceRefusal}; diff --git a/src/adapters/filesystem_initialization_namespace.rs b/src/adapters/filesystem_initialization_namespace.rs index 6b71be03..e3a23352 100644 --- a/src/adapters/filesystem_initialization_namespace.rs +++ b/src/adapters/filesystem_initialization_namespace.rs @@ -3,6 +3,9 @@ use std::ffi::OsStr; use std::io; +use super::filesystem_namespace_refusal::{ + self as namespace, FilesystemEntryKind, FilesystemNamespaceRefusal, +}; use cap_fs_ext::DirExt; use cap_std::fs::Dir; @@ -136,42 +139,37 @@ fn admit_version_two_protocol_directories(directory: &Dir) -> io::Result<()> { } fn admit_optional_file(directory: &Dir, name: &str) -> io::Result<()> { - admit_optional_kind(directory, name, cap_std::fs::FileType::is_file) + admit_optional_kind(directory, name, FilesystemEntryKind::File) } fn admit_optional_directory(directory: &Dir, name: &str) -> io::Result<()> { - admit_optional_kind(directory, name, cap_std::fs::FileType::is_dir) + admit_optional_kind(directory, name, FilesystemEntryKind::Directory) } fn admit_required_file(directory: &Dir, name: &str) -> io::Result<()> { - admit_required_kind(directory, name, cap_std::fs::FileType::is_file) + admit_required_kind(directory, name, FilesystemEntryKind::File) } fn admit_required_directory(directory: &Dir, name: &str) -> io::Result<()> { - admit_required_kind(directory, name, cap_std::fs::FileType::is_dir) + admit_required_kind(directory, name, FilesystemEntryKind::Directory) } fn admit_required_kind( directory: &Dir, name: &str, - expected: fn(&cap_std::fs::FileType) -> bool, + expected: FilesystemEntryKind, ) -> io::Result<()> { let metadata = directory.symlink_metadata(name)?; - if expected(&metadata.file_type()) { - Ok(()) - } else { - Err(ambiguous_namespace()) - } + namespace::require_kind(metadata.file_type(), expected) } fn admit_optional_kind( directory: &Dir, name: &str, - expected: fn(&cap_std::fs::FileType) -> bool, + expected: FilesystemEntryKind, ) -> io::Result<()> { match directory.symlink_metadata(name) { - Ok(metadata) if expected(&metadata.file_type()) => Ok(()), - Ok(_) => Err(ambiguous_namespace()), + Ok(metadata) => namespace::require_kind(metadata.file_type(), expected), Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()), Err(source) => Err(source), } @@ -180,11 +178,14 @@ fn admit_optional_kind( fn admit_membership(directory: &Dir, canonical_names: &[&str]) -> io::Result<()> { let mut observed = 0_usize; for entry in directory.entries()? { - observed = observed.checked_add(1).ok_or_else(ambiguous_namespace)?; + let entry = entry?; + observed = observed + .checked_add(1) + .ok_or_else(|| io::Error::other("namespace entry count overflow"))?; if observed > canonical_names.len() { return Err(ambiguous_namespace()); } - let name = entry?.file_name(); + let name = entry.file_name(); if !is_canonical(&name, canonical_names) { return Err(ambiguous_namespace()); } @@ -197,10 +198,7 @@ fn is_canonical(name: &OsStr, canonical_names: &[&str]) -> bool { } fn ambiguous_namespace() -> io::Error { - io::Error::new( - io::ErrorKind::InvalidData, - "store root is not an empty or partial canonical initialization namespace", - ) + FilesystemNamespaceRefusal::UnexpectedEntry.into_io() } /// Admits a published version-1 root carrying any subset of migration diff --git a/src/adapters/filesystem_namespace_refusal.rs b/src/adapters/filesystem_namespace_refusal.rs new file mode 100644 index 00000000..2151da4a --- /dev/null +++ b/src/adapters/filesystem_namespace_refusal.rs @@ -0,0 +1,85 @@ +//! This module owns demonstrated protocol namespace contradictions. + +use cap_std::fs::{Dir, FileType}; +use std::{error::Error, fmt, io}; + +/// No-follow kind observed for one protocol namespace entry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FilesystemEntryKind { + /// A regular file. + File, + /// A directory. + Directory, + /// A symbolic link, regardless of its target. + Symlink, + /// Any other filesystem entry kind. + Other, +} + +impl FilesystemEntryKind { + pub(super) fn observed(kind: FileType) -> Self { + if kind.is_file() { + Self::File + } else if kind.is_dir() { + Self::Directory + } else if kind.is_symlink() { + Self::Symlink + } else { + Self::Other + } + } +} + +/// Observed evidence contradicts protocol membership or the required entry kind. +/// +/// Operational metadata/open failures remain their original I/O causes. This +/// evidence does not make a subsequent pathname operation conditional on identity. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum FilesystemNamespaceRefusal { + /// An observed entry is outside the admitted protocol membership. + UnexpectedEntry, + /// A no-follow metadata observation established the wrong entry kind. + WrongKind { + /// Kind required by the protocol. + expected: FilesystemEntryKind, + /// Kind actually observed without following a symlink. + observed: FilesystemEntryKind, + }, +} + +impl fmt::Display for FilesystemNamespaceRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnexpectedEntry => formatter.write_str("unexpected protocol namespace entry"), + Self::WrongKind { expected, observed } => write!( + formatter, + "protocol entry kind is {observed:?}, expected {expected:?}" + ), + } + } +} + +impl Error for FilesystemNamespaceRefusal {} + +impl FilesystemNamespaceRefusal { + pub(super) fn into_io(self) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, self) + } +} + +pub(super) fn require_kind(actual: FileType, expected: FilesystemEntryKind) -> io::Result<()> { + let observed = FilesystemEntryKind::observed(actual); + if observed == expected { + Ok(()) + } else { + Err(FilesystemNamespaceRefusal::WrongKind { expected, observed }.into_io()) + } +} + +pub(super) fn require_directory(parent: &Dir, name: &str) -> io::Result<()> { + require_kind( + parent.symlink_metadata(name)?.file_type(), + FilesystemEntryKind::Directory, + ) +} diff --git a/src/adapters/filesystem_platform_profile.rs b/src/adapters/filesystem_platform_profile.rs index 9998520e..896ec543 100644 --- a/src/adapters/filesystem_platform_profile.rs +++ b/src/adapters/filesystem_platform_profile.rs @@ -134,8 +134,10 @@ fn open_protocol_directory(root: &Dir, name: &str) -> io::Result { let first = components .next() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "empty protocol name"))?; + super::filesystem_namespace_refusal::require_directory(root, first)?; let mut current = super::sync_capable_directory::open(root, first)?; for component in components { + super::filesystem_namespace_refusal::require_directory(¤t, component)?; current = super::sync_capable_directory::open(¤t, component)?; } Ok(current) diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index a4b259e6..31a2b951 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -91,6 +91,7 @@ mod filesystem_catalog_storage; mod filesystem_exact_record; mod filesystem_initialization_namespace; mod filesystem_initialization_storage; +mod filesystem_namespace_refusal; mod filesystem_platform_admission; mod filesystem_platform_admission_error; mod filesystem_platform_profile; diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 2b4f9c99..52c2df09 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -181,6 +181,8 @@ mod transition_preflight; mod transition_preflight_error; mod transition_readiness; #[cfg(test)] +mod verification_namespace_law_tests; +#[cfg(test)] mod verification_root_kind_tests; #[cfg(test)] mod verification_selection_law_tests; diff --git a/src/adapters/retention/filesystem_retention_verification.rs b/src/adapters/retention/filesystem_retention_verification.rs index 89cf94e9..efe3c3f6 100644 --- a/src/adapters/retention/filesystem_retention_verification.rs +++ b/src/adapters/retention/filesystem_retention_verification.rs @@ -131,6 +131,12 @@ fn admission_is_corrupt(error: &io::Error) -> bool { let Some(source) = error.get_ref() else { return false; }; + if source + .downcast_ref::() + .is_some() + { + return true; + } if let Some(record) = source.downcast_ref::() { return match record { VersionTwoRecordRefusal::LengthOverflow { .. } => false, diff --git a/src/adapters/retention/verification_namespace_law_tests.rs b/src/adapters/retention/verification_namespace_law_tests.rs new file mode 100644 index 00000000..5665d10d --- /dev/null +++ b/src/adapters/retention/verification_namespace_law_tests.rs @@ -0,0 +1,81 @@ +//! Demonstrated namespace contradictions are distinct from failed observations. +//! +//! Size: medium. Oracle: canonical protocol membership and no-follow entry kinds. +//! Delete if stronger public namespace laws subsume these exact boundaries. + +use super::filesystem_retention_test_fixture::migrated_store; +use crate::{ + CatalogRestartByteLimit, CatalogRestartPolicy, FilesystemEntryKind, FilesystemNamespaceRefusal, + FilesystemRetentionSnapshot, FilesystemRetentionSnapshotError, ReaderAttemptLimit, + SegmentReadPolicy, VerificationError, VerificationObservation, VerificationRefusal, + VerificationSource, VerificationSubject, +}; +use std::{error::Error, fs, os::unix::fs::symlink, path::Path}; + +type ResultOf = Result>; + +#[test] +fn unexpected_store_entries_are_published_view_corruption() -> ResultOf<()> { + let store = migrated_store("verification-unexpected-entry")?; + let path = store.path().join("unexpected-entry"); + fs::write(&path, b"preserve unexpected evidence")?; + assert_corrupt(store.path(), FilesystemNamespaceRefusal::UnexpectedEntry)?; + assert_eq!(fs::read(path)?, b"preserve unexpected evidence"); + Ok(()) +} + +#[test] +fn required_directory_files_are_published_view_corruption() -> ResultOf<()> { + let store = migrated_store("verification-required-directory-file")?; + let path = store.path().join("gc"); + fs::remove_dir(&path)?; + fs::write(&path, b"preserve wrong kind")?; + assert_corrupt( + store.path(), + FilesystemNamespaceRefusal::WrongKind { + expected: FilesystemEntryKind::Directory, + observed: FilesystemEntryKind::File, + }, + )?; + assert_eq!(fs::read(path)?, b"preserve wrong kind"); + Ok(()) +} + +#[test] +fn required_directory_symlinks_are_published_view_corruption() -> ResultOf<()> { + let store = migrated_store("verification-required-directory-symlink")?; + let path = store.path().join("gc"); + let target = store.path().join("retention"); + fs::remove_dir(&path)?; + symlink(&target, &path)?; + assert_corrupt( + store.path(), + FilesystemNamespaceRefusal::WrongKind { + expected: FilesystemEntryKind::Directory, + observed: FilesystemEntryKind::Symlink, + }, + )?; + assert_eq!(fs::read_link(path)?, target); + Ok(()) +} + +fn assert_corrupt(path: &Path, expected_cause: FilesystemNamespaceRefusal) -> ResultOf<()> { + let policy = CatalogRestartPolicy::new( + SegmentReadPolicy::MAXIMUM, + CatalogRestartByteLimit::new(1_048_576)?, + ); + let error = FilesystemRetentionSnapshot::load_for_verification( + path, + policy, + ReaderAttemptLimit::DEFAULT, + ) + .err() + .ok_or("noncanonical namespace admitted")?; + assert!( + matches!(&error, VerificationError::Refused { refusal: VerificationRefusal::Corrupt { + subject: VerificationSubject::PublishedView, expected: VerificationObservation::Canonical, + observed: VerificationObservation::Refused }, source: Some(source) } if matches!(source.as_ref(), VerificationSource::Retention(FilesystemRetentionSnapshotError::Admission { source }) if source.get_ref().and_then(|cause| cause.downcast_ref::()) == Some(&expected_cause))), + "demonstrated namespace contradiction must remain corrupt: {error:?}" + ); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index afcb8f7f..e22dea9b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -222,3 +222,5 @@ pub use verification::{ pub use adapters::{RangeReadError, ReconstructionError}; pub use authenticated_read::{RangeReadReceipt, ReconstructionReceipt}; + +pub use adapters::{FilesystemEntryKind, FilesystemNamespaceRefusal}; From f2098ef881bd776aff8e2a75262a42faa998434c Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 3 Oct 2026 15:58:08 -0700 Subject: [PATCH 17/18] fix: retain typed selected namespace kind refusals (#114) --- CHANGELOG.md | 2 + docs/invariants/verification/rationale.md | 1 + docs/testing-evidence/durable-verification.md | 8 ++ src/adapters/retention.rs | 2 + .../filesystem_retention_snapshot.rs | 16 +++- .../filesystem_retention_verification.rs | 8 +- .../verification_namespace_kind_tests.rs | 82 +++++++++++++++++++ 7 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 src/adapters/retention/verification_namespace_kind_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c82e9f20..5a8ba917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ after its public API and format compatibility policies are established. ## [Unreleased] +- Retention verification refuses observed file or symlink substitutions of a selected namespace directory as typed corruption, preserving the original selected root evidence (#114). + - Verification preserves typed canonical-namespace and no-follow entry-kind contradictions as corruption while leaving inconclusive observation failures operational (#114). - Filesystem verification names the missing catalog-selected segment and preserves its digest, failing I/O phase and original cause instead of labeling the present catalog missing (#114). diff --git a/docs/invariants/verification/rationale.md b/docs/invariants/verification/rationale.md index c6e004c1..876716f0 100644 --- a/docs/invariants/verification/rationale.md +++ b/docs/invariants/verification/rationale.md @@ -1,3 +1,4 @@ +# Verification boundary rationale ## Typed namespace observations diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index 2c0cb530..d8338123 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -279,3 +279,11 @@ Change kind: bug fix. Three public loader regressions on the unfixed integration `FilesystemNamespaceRefusal` now carries observed membership contradictions or exact expected/observed entry kinds. Namespace admission and the earlier platform directory traversal share that typed observation; original no-follow opens, opened-file checks, platform properties and synchronization remain in place. Failed observations propagate their original I/O source; iterator errors are propagated before counting entries. Only the typed demonstrated contradictions are classified corrupt, not arbitrary InvalidData or filesystem error numbers. Exact public classification/source and retained-evidence laws pass in debug/release. A diagnostic-only mutation retains Corrupt while replacing the observed file/symlink kind with Other; both precise-cause assertions fail (`165-namespace-kind-mutation-red.log`). Restored namespace and existing operational/admission laws pass, with all-target Clippy and structure checks (`165-namespace-restored-green.log`). An initial edit-script placement error failed compilation (`165-namespace-build-failure.log`); it is not runtime RED. No rollback, atomic pathname identity guarantee, repair or support for arbitrary concurrent namespace mutation is introduced. + +## Landing selected namespace kind + +Change kind: bug fix. After the namespace-admission correction at `c544e2df9c0d46669362fd1009d468d1f87102b0`, separate public verification laws still fail for a selected retention namespace replaced by a file or a symlink to the original valid directory (`165-selected-namespace-c544e2d-red.log`). Both reach the intended typed-corruption assertion with Operational/NotADirectory; the unrelated admission fix does not mask this downstream defect. + +The shared selected-root boundary now observes its namespace entry with no-follow metadata before the existing no-follow directory open. Observed wrong kinds retain `FilesystemNamespaceRefusal` through the root error and are classified corrupt for the exact requested namespace. Missing entries and failed observations retain their original I/O behavior. The check does not claim atomicity with the subsequent pathname open or support arbitrary concurrent raw mutation. + +Both wrong-kind laws assert exact public classification and expected/observed kind, retain the original selected root bytes, and preserve the replacement entry kind. All existing verification laws pass in debug/release, with all-target Clippy and structure checks (`165-selected-namespace-green.log`). No existing corruption or operational refusal is weakened, and no new persistent namespace, repair or cleanup action is introduced. Final full validation and independent review apply to the resulting candidate, not these earlier focused runs. diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index 52c2df09..7dcc6450 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -181,6 +181,8 @@ mod transition_preflight; mod transition_preflight_error; mod transition_readiness; #[cfg(test)] +mod verification_namespace_kind_tests; +#[cfg(test)] mod verification_namespace_law_tests; #[cfg(test)] mod verification_root_kind_tests; diff --git a/src/adapters/retention/filesystem_retention_snapshot.rs b/src/adapters/retention/filesystem_retention_snapshot.rs index 66c2cfde..0cd2e9c1 100644 --- a/src/adapters/retention/filesystem_retention_snapshot.rs +++ b/src/adapters/retention/filesystem_retention_snapshot.rs @@ -212,6 +212,9 @@ impl FilesystemRetentionSnapshot { /// when the entry is absent, unreadable, or not the selected root. /// A namespace contradiction preserves [`RetentionSelectedRootRefusal`] /// inside that error's I/O source, including expected and observed digests. + /// An observed wrong-kind namespace directory preserves + /// [`crate::FilesystemNamespaceRefusal`] with the exact entry kinds. + /// The no-follow metadata guard does not make the later open atomic with it. pub fn retained_root( &self, namespace: RetentionNamespaceDigest, @@ -227,9 +230,7 @@ impl FilesystemRetentionSnapshot { else { return Ok(None); }; - let directory = self - .roots - .open_dir_nofollow(pool_name::namespace(namespace)) + let directory = selected_namespace_directory(&self.roots, namespace) .map_err(|source| Error::Root { source })?; let name = pool_name::root(entry.root_generation(), entry.root_digest()); let length = directory @@ -309,3 +310,12 @@ impl FilesystemRetentionSnapshot { fn selected_refusal(refusal: RetentionSelectedRootRefusal) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, refusal) } + +fn selected_namespace_directory( + roots: &Dir, + namespace: RetentionNamespaceDigest, +) -> io::Result { + let name = pool_name::namespace(namespace); + crate::adapters::filesystem_namespace_refusal::require_directory(roots, &name)?; + roots.open_dir_nofollow(name) +} diff --git a/src/adapters/retention/filesystem_retention_verification.rs b/src/adapters/retention/filesystem_retention_verification.rs index efe3c3f6..6447874c 100644 --- a/src/adapters/retention/filesystem_retention_verification.rs +++ b/src/adapters/retention/filesystem_retention_verification.rs @@ -176,6 +176,8 @@ fn root_error( source: Some(Box::new(VerificationSource::Retention(error))), }; } + let namespace = + cause.and_then(|cause| cause.downcast_ref::()); let root = cause.and_then(|cause| cause.downcast_ref::()); let exact = cause.and_then(|cause| cause.downcast_ref::()); let corrupt = matches!( @@ -187,7 +189,11 @@ fn root_error( ) ) || root .is_some_and(|source| !matches!(source, RetentionRootDecodeError::Allocation { .. })) - || matches!(exact, Some(ExactRecordError::Refused(_))); + || matches!(exact, Some(ExactRecordError::Refused(_))) + || matches!( + namespace, + Some(crate::FilesystemNamespaceRefusal::WrongKind { .. }) + ); if corrupt { VerificationError::Refused { refusal: verification_admission::structural(subject), diff --git a/src/adapters/retention/verification_namespace_kind_tests.rs b/src/adapters/retention/verification_namespace_kind_tests.rs new file mode 100644 index 00000000..5097c51a --- /dev/null +++ b/src/adapters/retention/verification_namespace_kind_tests.rs @@ -0,0 +1,82 @@ +//! Selected namespace entry kinds remain evidenced corruption, not raw I/O. +//! +//! Size: medium. Oracle: a selected namespace is a real directory, independently +//! of symlink targets; the original selected root bytes survive refusal. +//! Delete if stronger public selected-namespace coverage subsumes these cases. + +use super::filesystem_retention_test_fixture::{ + ROOT_HEX, fixture, initial_preparation, open_authority, root_pool_path, +}; +use crate::{ + AdmittedRetentionRoot, CatalogRestartByteLimit, CatalogRestartPolicy, FilesystemEntryKind, + FilesystemNamespaceRefusal, FilesystemRetentionSnapshot, FilesystemRetentionSnapshotError, + ReaderAttemptLimit, SegmentReadPolicy, VerificationDepth, VerificationError, + VerificationObservation, VerificationRefusal, VerificationSource, VerificationSubject, + execute_retention_publication, +}; +use std::{error::Error, fs, os::unix::fs::symlink}; + +#[test] +fn a_selected_namespace_file_is_typed_corruption() -> Result<(), Box> { + assert_kind("verification-namespace-file", FilesystemEntryKind::File) +} + +#[test] +fn a_selected_namespace_symlink_is_typed_corruption() -> Result<(), Box> { + assert_kind( + "verification-namespace-symlink", + FilesystemEntryKind::Symlink, + ) +} + +fn assert_kind(name: &str, observed: FilesystemEntryKind) -> Result<(), Box> { + let (sandbox, mut authority) = open_authority(name)?; + let bytes = fixture(ROOT_HEX)?; + let _receipt = execute_retention_publication(&mut authority, &initial_preparation(&bytes)?)?; + drop(authority); + let root = AdmittedRetentionRoot::decode(&bytes)?; + let path = root_pool_path(sandbox.path(), &root); + let parent = path.parent().ok_or("root parent absent")?; + let retained = parent.with_extension("original"); + let view = FilesystemRetentionSnapshot::load_for_verification( + sandbox.path(), + CatalogRestartPolicy::new( + SegmentReadPolicy::MAXIMUM, + CatalogRestartByteLimit::new(1_048_576)?, + ), + ReaderAttemptLimit::DEFAULT, + )?; + fs::rename(parent, &retained)?; + match observed { + FilesystemEntryKind::File => fs::write(parent, b"preserve namespace file")?, + FilesystemEntryKind::Symlink => symlink(&retained, parent)?, + _ => return Err("unsupported test entry kind".into()), + } + let namespace = root.root().namespace().digest(); + let error = view + .verify_retention(namespace, VerificationDepth::Checksum) + .err() + .ok_or("wrong-kind namespace certified")?; + let expected_cause = FilesystemNamespaceRefusal::WrongKind { + expected: FilesystemEntryKind::Directory, + observed, + }; + assert!( + matches!(&error, VerificationError::Refused { refusal: VerificationRefusal::Corrupt { + subject: VerificationSubject::RetentionNamespace { namespace: actual }, + expected: VerificationObservation::Canonical, observed: VerificationObservation::Refused }, + source: Some(source) } if *actual == namespace && matches!(source.as_ref(), + VerificationSource::Retention(FilesystemRetentionSnapshotError::Root { source }) + if source.get_ref().and_then(|cause| cause.downcast_ref::()) == Some(&expected_cause))), + "selected namespace must preserve exact kind corruption: {error:?}" + ); + assert_eq!( + fs::read(retained.join(path.file_name().ok_or("root name absent")?))?, + bytes + ); + assert_eq!( + fs::symlink_metadata(parent)?.file_type().is_symlink(), + observed == FilesystemEntryKind::Symlink + ); + Ok(()) +} From 1f3991f86fa66783d88b9ac8dbb79ecd0d9a9554 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 3 Oct 2026 16:03:34 -0700 Subject: [PATCH 18/18] test: reconcile selected-segment diagnostic expectations for #114 --- docs/testing-evidence/durable-verification.md | 8 ++++++++ src/adapters/retention/durable_view_law_tests.rs | 2 +- .../filesystem_retention_publication_closure_tests.rs | 2 +- .../filesystem_retention_recovery_closure_tests.rs | 2 +- .../filesystem_retention_snapshot_error_tests.rs | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/testing-evidence/durable-verification.md b/docs/testing-evidence/durable-verification.md index d8338123..cc662653 100644 --- a/docs/testing-evidence/durable-verification.md +++ b/docs/testing-evidence/durable-verification.md @@ -287,3 +287,11 @@ Change kind: bug fix. After the namespace-admission correction at `c544e2df9c0d4 The shared selected-root boundary now observes its namespace entry with no-follow metadata before the existing no-follow directory open. Observed wrong kinds retain `FilesystemNamespaceRefusal` through the root error and are classified corrupt for the exact requested namespace. Missing entries and failed observations retain their original I/O behavior. The check does not claim atomicity with the subsequent pathname open or support arbitrary concurrent raw mutation. Both wrong-kind laws assert exact public classification and expected/observed kind, retain the original selected root bytes, and preserve the replacement entry kind. All existing verification laws pass in debug/release, with all-target Clippy and structure checks (`165-selected-namespace-green.log`). No existing corruption or operational refusal is weakened, and no new persistent namespace, repair or cleanup action is introduced. Final full validation and independent review apply to the resulting candidate, not these earlier focused runs. + +## Landing shared diagnostic expectations + +Change kind: deliberate diagnostic API change, completing the selected-segment correction above. Full validation of `f2098ef881bd776aff8e2a75262a42faa998434c` reached the workspace debug tests and failed six existing runtime assertions that still required `CatalogRestartError::Io` for a missing catalog-selected segment (`165-final-validation.log`). These were stale expectations of the old public error shape, not evidence that the missing segment was admitted or recovery mutated storage. + +The durable-reader, snapshot, publication and recovery refusal laws now require `SegmentIo` at that boundary while retaining their exact `OpenSegment`/`NotFound` checks and existing preserved-evidence assertions. The separately calibrated selected-segment regression checks the frozen expected digest and original source. No success expectation or corruption refusal changes, and no test is deleted. Head and catalog failures still use `Io`; migration inventory uses a separate file-admission path and retains its existing typed inventory context and `Io` expectations. + +Focused retention laws pass in copied Docker source in debug and release (`165-expectation-reconciliation.log`). The failed full run and failed hosted Rust job remain historical failures of their original head; subsequent complete validation and independent review must use the successor candidate. diff --git a/src/adapters/retention/durable_view_law_tests.rs b/src/adapters/retention/durable_view_law_tests.rs index 6af789a6..e64a7eaa 100644 --- a/src/adapters/retention/durable_view_law_tests.rs +++ b/src/adapters/retention/durable_view_law_tests.rs @@ -122,7 +122,7 @@ fn an_unreadable_segment_preserves_the_operational_open_failure() -> Result<(), assert!( matches!(failure.downcast_ref::(), Some(DurableStoreError::Snapshot(source)) if matches!(source.as_ref(), FilesystemRetentionSnapshotError::Catalog { source } - if matches!(source, CatalogRestartError::Io { phase: CatalogRestartPhase::OpenSegment, source } + if matches!(source, CatalogRestartError::SegmentIo { phase: CatalogRestartPhase::OpenSegment, source, .. } if source.kind() == std::io::ErrorKind::NotFound))), "missing physical evidence must preserve OpenSegment/NotFound: {failure:?}" ); diff --git a/src/adapters/retention/filesystem_retention_publication_closure_tests.rs b/src/adapters/retention/filesystem_retention_publication_closure_tests.rs index 8919f457..2cc3e2b5 100644 --- a/src/adapters/retention/filesystem_retention_publication_closure_tests.rs +++ b/src/adapters/retention/filesystem_retention_publication_closure_tests.rs @@ -60,7 +60,7 @@ fn require_refusal(damage: Damage) -> Result<(), Box> { .get_ref() .and_then(|source| source.downcast_ref::()); let exact = match (damage, restart) { - (Damage::Missing, Some(CatalogRestartError::Io { phase, source })) => { + (Damage::Missing, Some(CatalogRestartError::SegmentIo { phase, source, .. })) => { *phase == CatalogRestartPhase::OpenSegment && source.kind() == io::ErrorKind::NotFound } (Damage::Corrupt, Some(CatalogRestartError::Segment { source, .. })) => matches!( diff --git a/src/adapters/retention/filesystem_retention_recovery_closure_tests.rs b/src/adapters/retention/filesystem_retention_recovery_closure_tests.rs index 437adfdb..84fb3162 100644 --- a/src/adapters/retention/filesystem_retention_recovery_closure_tests.rs +++ b/src/adapters/retention/filesystem_retention_recovery_closure_tests.rs @@ -124,7 +124,7 @@ fn require_refusal(damage: Damage, prefix: usize) -> Result<(), Box> fn matches_damage(damage: Damage, error: Option<&CatalogRestartError>) -> bool { match (damage, error) { - (Damage::MissingSegment, Some(CatalogRestartError::Io { phase, source })) => { + (Damage::MissingSegment, Some(CatalogRestartError::SegmentIo { phase, source, .. })) => { *phase == CatalogRestartPhase::OpenSegment && source.kind() == io::ErrorKind::NotFound } (Damage::MissingCatalog, Some(CatalogRestartError::Io { phase, source })) => { diff --git a/src/adapters/retention/filesystem_retention_snapshot_error_tests.rs b/src/adapters/retention/filesystem_retention_snapshot_error_tests.rs index c74f9dbc..cac386d0 100644 --- a/src/adapters/retention/filesystem_retention_snapshot_error_tests.rs +++ b/src/adapters/retention/filesystem_retention_snapshot_error_tests.rs @@ -52,7 +52,7 @@ fn a_missing_selected_segment_reports_its_exact_catalog_refusal() -> Result<(), assert!( matches!(&error, Some(SnapshotError::Catalog { - source: CatalogRestartError::Io { phase: CatalogRestartPhase::OpenSegment, source } + source: CatalogRestartError::SegmentIo { phase: CatalogRestartPhase::OpenSegment, source, .. } }) if source.kind() == io::ErrorKind::NotFound), "missing selected segment must preserve its catalog boundary, phase and I/O kind: {error:?}" );