prikk_store/verify.rs
1//! Repository verification routines.
2//!
3//! Verification is read-only. It checks object identity, object-type placement, envelope decoding,
4//! sealed block references, joint ref publication state, active WAL replay checksums, and retained
5//! active-publication cleanup state. Mutation belongs to narrow doctor or signer-backed seal paths.
6//!
7//! **A check's own code being present does not establish that a defect actually reaches it.** Earlier
8//! gates in this module's pipeline can intercept a malformed input before a specific, later check ever
9//! sees it -- so a check existing, and even a fixture that constructs the shape that check is meant to
10//! reject, are not proof the check is exercised. Two independent instances (DC-95 Stage 1 rounds 10 and
11//! 11): `ref_publication::require_retained_evidence` reclassifies several `refs/verify.rs` codes before
12//! they're returned, so a raw pointer/log-shape fixture can silently land on a different code than the
13//! one under test; `crate::format::validate_read_schema`, called from `Wal::replay()` itself, already
14//! rejects a malformed-shape signature under `RepositoryFormat::CurrentV6` before
15//! `rollback_verify::verify_rollback_draft_wal_records` is ever reached, so the same defect is reachable
16//! only under `RepositoryFormat::LegacyV1`. Building a fixture for a specific check in this module means
17//! tracing its actual call path from `verify_repository`, not just constructing input shaped to match
18//! the check's own condition.
19//!
20//! **Do not report a result derived from a step that may not have run.** The general form of a rule
21//! found three times, from three directions, across DC-95 Stage 1 and Stage 2 (`stage-2-level-1-sweep-
22//! ruling-v1.md` §4): a check's presence in the source is not proof a defect reaches it (above); an
23//! empty accumulator is not "none found" if its producer did not run to completion; a partial count is
24//! not "this many verified" if the stage computing it stopped partway through. All three are the same
25//! error seen from different angles -- inferring a result from the absence of evidence when the
26//! evidence-gathering step itself may not have happened. `require_retained_evidence`'s own `trust_is_
27//! valid` (see [`VerificationStage::PublicationReclassification`] below) is the concrete instance the
28//! second form was caught in: `trust_verifier.issues.is_empty()` alone reads `true` when the `Objects`
29//! stage failed before checking a single Block or RefState, which is not the same fact as "every
30//! relevant object was checked and found trustworthy." It is `issues.is_empty() && objects_evaluated`
31//! for exactly this reason. The third form is why `RepositoryVerification`'s per-stage counts
32//! (`checked_objects` and its siblings) are `Option<usize>`, `None` rather than a partial number, when
33//! their producing stage did not evaluate to completion -- a partial count is a completeness claim in
34//! miniature, and `verify_objects`'s own topological pass over the whole object store means a partial
35//! per-type count says nothing about whether the objects it did see are individually sound.
36//!
37//! # DC-95 Stage 2, Level 1: scope containment
38//!
39//! `verify_repository`'s pipeline is thirteen stages (see [`VerificationStage`]), each a `?`-propagating
40//! call in the pre-Stage-2 source. Stage 1 (above) proved which checks inside those stages are load-
41//! bearing; Stage 2 Level 1 changed what happens when one fails. **Before:** the first hard error
42//! anywhere aborted `verify_repository` entirely -- every later stage silently never ran, and
43//! `print_verify_report` never printed anything but the one error string. **After:** each stage's own
44//! `?` is caught at the boundary and recorded as a [`StageOutcome`] rather than propagated; the
45//! pipeline continues to the remaining stages regardless. A `Failed` stage's own error becomes its
46//! `StageOutcome`'s message; a stage that could not run because a real dependency ([`StageStatus::
47//! NotEvaluated`], naming that dependency) is blocking on the same footing -- an incomplete
48//! verification is not a passing one, so `RepositoryVerification::has_stage_failure` covers both, plus
49//! a third state, [`StageStatus::Halted`], for `--stop-on-first-error`: a stage that was never attempted
50//! because an *unrelated* earlier stage's failure already stopped the walk. `Halted` is kept distinct
51//! from `NotEvaluated` because `blocked_by` is a dependency-graph claim -- reporting `NotEvaluated {
52//! blocked_by: Objects }` for `LifecycleCache`, which does not depend on `Objects` at all, would assert
53//! an edge that does not exist (implementation review v1 §4). The distinction only matters under
54//! `--stop-on-first-error`; in the default full-accumulation walk, every `NotEvaluated` names a real
55//! dependency and `Halted` never appears.
56//!
57//! **The Stage 1 classification table below is unchanged by this**: which checks are load-bearing,
58//! downstream-redundant, excluded, or unreachable is a fact about the checks themselves, not about how
59//! their failures propagate out of `verify_repository`. What changed is the shape a reader (or
60//! `doctor_repository`, or `prikk verify`'s own exit-code chain) sees a failing check through: a
61//! `StageOutcome` against the owning stage, not a bare `Result::Err` from the whole function. Checks
62//! were not rewritten, moved, or deleted to get here -- only the thirteen top-level boundaries around
63//! them.
64//!
65//! # RFC 103: format-1 retirement, and its effect on the table below
66//!
67//! `RepositoryFormat::LegacyV1` is deleted; a format-1 repository is now rejected at
68//! `RepositoryLayout::open`, before `verify_repository` (or anything else) can run against it. Three
69//! rows in the table below changed status as a direct consequence, all still classified, none left to
70//! drift: `LEGACY-LOG-LEADS` and `LEGACY-TIMESTAMP` (both format-1-gated branches that can now never
71//! execute, since the format they branched on no longer exists) are deleted outright rather than kept
72//! as unreachable, since deleting the dead branch was possible and cheaper than documenting it;
73//! rollback AUTHOR signature wrong-length moves from load-bearing to unreachable, since round 11 had
74//! already established format-1 as its only reachable path (`rollback_verify.rs` carries the argument
75//! at the check site, per round 6's ruling on unreachable checks). `legacy_state_roots_unverifiable`
76//! (a precondition fact, not a table row) is deleted with it. The three-row figure matches RFC 103's
77//! own accounting, corrected once during that RFC's own prerequisite investigation:
78//! `LEGACY-TIMESTAMP`'s status change was found only by re-reading this table, not carried in the
79//! RFC's original draft.
80//!
81//! # RFC 102 Stage 2: isolate-and-continue reading, and its effect on the table below
82//!
83//! A mid-stream checksum mismatch in the active WAL or a ref log no longer aborts the whole read: the
84//! frame at that offset is recorded as a failed item (`wal::WalRecordOutcome` /
85//! `refs::log::RefLogRecordOutcome`) and a byte-wise resync finds the next candidate frame, so every
86//! sound record after the damaged one is still read (RFC 102 §3, `wal.rs`'s `decode_records` and
87//! `refs/log.rs`'s `decode_log_records`). **The two rows this changes stay Load-bearing** -- disabling
88//! either check still lets a defective repository pass with no trace, exactly the property this table
89//! tracks -- but the failure they produce is now item-level (`RepositoryVerification::has_item_failure`,
90//! via the new `wal_record_outcomes` field), not a `WalReplay` stage failure, the same shift DC-95 Stage
91//! 2 Level 2 already made for `verify_objects` and `verify_refs`. `Wal::replay()`'s own post-decode
92//! `validate_read_schema` call is deliberately untouched -- a schema-shape defect is a different concern
93//! from physical corruption and stays a hard stage failure, out of this stage's scope (`stage-2-
94//! implementation-handoff-v1.md` §2's own boundary). Ref-log record corruption is exposed at file
95//! granularity (`refs::RefFileStatus::Failed` in `log_outcomes`, unchanged shape), not per-record like
96//! the WAL's `wal_record_outcomes` -- a deliberate, narrower scope decision for this round, recorded in
97//! the implementation's own review submission. **Every mutation-authorizing caller of `Wal::replay`/
98//! `RefStore::replay_log` that read `records`/`trailing_partial_bytes` without also checking the new
99//! `has_item_failure()` was audited and fixed in the same increment** -- the same discipline DC-95 Stage
100//! 2's ref-item-containment round established for `ensure_no_incomplete_publication`, applied here to a
101//! substantially larger caller set (`wal.rs`, `active.rs`, `rollback_draft.rs`,
102//! `worktree_patch/node_authoring.rs`, `seal.rs`, `branch.rs`, `refs/evidence.rs`, `rollback_verify.rs`,
103//! `refs/publication.rs`, `refs.rs`, `patch_replay.rs`, `seal/support.rs`,
104//! `verify/ref_publication.rs`).
105//!
106//! # RFC 102 Stage 3: container/index storage, and its effect on the table below
107//!
108//! Persisted-object storage moved from one loose file per object to per-type append-only containers
109//! plus one append-only index (design-v1.md §2/§4/§5); `verify_objects` (`verify/objects.rs`) now
110//! scans containers instead of listing directories. **Every check inside `verify_object_record` itself
111//! is untouched** -- schema validation, signature-envelope classification, publication trust, and
112//! `verify_block_payload` all still run on the same decoded `ObjectEnvelope`, just fed from a container
113//! record instead of a loose-file read. Rows whose check reads an object only through
114//! `FileObjectStore`'s public API (`ObjectReader`/`ObjectWriter`) are unaffected: that API's behavior
115//! toward callers is unchanged (task 129 of this stage confirmed this directly, not assumed from the
116//! type signature alone) -- only its internal storage moved. Three rows are not covered by that blanket
117//! statement, because their own mechanism is specifically about object *storage structure*, not about
118//! what a decoded envelope contains:
119//!
120//! - **Envelope type mismatch** (half of the row above) moved, and in moving exposed a real gap: a
121//! container's magic constrains which container a frame's bytes live in, but nothing already
122//! enforced that the frame's own decoded `envelope.object_type` agreed with it -- a well-formed,
123//! correctly checksummed Blob-container frame could hold a validly encoded Patch envelope
124//! undetected. Found and fixed during this stage's own test migration (not assumed correct from the
125//! design alone), at the one place every container reader passes through:
126//! `container::parse_frame_at`. **Stays Load-bearing** -- proven at the container level
127//! (`container::tests::envelope_type_disagreeing_with_its_own_containers_type_is_rejected`) and end
128//! to end (`verify_repository_rejects_envelope_type_mismatch`, `assert_object_item_failed(&report,
129//! "is under type")`).
130//! - **Object id mismatch** (the other half) is now enforced in two places instead of one: ordinary
131//! reads (`FileObjectStore::read_object`, unchanged in shape -- still a lazy, per-id check) and,
132//! newly, `verify_objects`'s own proactive full-scan cross-validation of every index entry against
133//! what its own claimed location actually decodes to (design §12/§10.2's "the bytes found are
134//! validated by recomputing the content hash... `verify` does the full scan" ruling, read as
135//! including index-to-container consistency, not just container enumeration in isolation). A decode
136//! *failure* at an entry's location is deliberately not escalated by this new check -- it is already
137//! an item-level `Failed` outcome from the per-record container scan (Stage 2's own containment) --
138//! only "decodes fine but to the wrong id" is, and that is treated as a genuine index-integrity
139//! defect, propagated as a stage-level `Err`, not an isolable item defect (an index that lies about
140//! content is corruption at a different scale than one damaged record). **This is a new row, not
141//! merely this stage's proof of the old one: Load-bearing**, proven end to end by
142//! `verify_repository_detects_index_entry_resolving_to_a_different_object`.
143//! - **Directory/file shape structural errors** keeps its exact code, unmoved and unchanged
144//! (`scan_loose_file_temp_debris`, design-v1.md §12.3 item 3's ruling: the loose-file tree's
145//! temp-debris scan is kept, dormant, since retiring diagnostic surface is an RFC-level act, not a
146//! stage side effect) -- but that tree is no longer written by anything under format-3, so the row's
147//! own downstream-redundant partners (`list_directory`'s own directory-vs-file rejection;
148//! `object_id_from_path`'s own extension check) are reachable today only by a fixture that plants the
149//! stray entry directly, never as a byproduct of an ordinary write. **Classification unchanged**
150//! (still Downstream-redundant, both sub-arms -- the redundant partners are generic filesystem-safety
151//! checks, not specific to which tree is live), re-confirmed passing unmodified:
152//! `verify_repository_detects_every_directory_shape_violation` already used `FileObjectStore::
153//! write_object` only to produce a real object elsewhere in the fixture, and planted its two stray
154//! entries directly at the loose-file paths either way, so this row needed no test change at all.
155//!
156//! # DC-95 Stage 1: end-to-end coverage, by cluster
157//!
158//! Every check `verify_repository` performs, classified by whether disabling it lets a defective
159//! repository pass through `verify_repository` as `Ok` with no trace -- **Load-bearing** (the check is
160//! the last line of defence; some are load-bearing only via a non-blocking-sibling mechanism, named
161//! where that applies); **Downstream-redundant** (something else independently catches the same defect,
162//! blocking, under a different code); **Excluded** (non-blocking finding, out of mandatory scope);
163//! **Unreachable** (provably impossible to construct -- kept, untested, ruled on explicitly, not merely
164//! unattempted). Full reasoning for each row lives in the test file cited, not duplicated here; this
165//! table is the current-state index, not the round-by-round record (that's
166//! `DC-95-VERIFY-COVERAGE-AND-FINDING-ACCUMULATION.md`'s own handoff trail).
167//!
168//! **Scope limit**: this enumerates checks `verify` *has*, not checks it *should have* -- a gap of a
169//! different kind this method cannot surface. One known instance: the received-ref index
170//! (`received_index.rs`, RFC 102 Stage 5 -- formerly `refs/received/`) is never read by
171//! `verify_repository` at all (RFC 101 §5.2's independently-derived transition trace); registered in
172//! reported in the review result, not a row here, since there is no existing check to classify.
173//!
174//! ## `verify/objects.rs` + `block_state.rs` (`verify/tests.rs`)
175//!
176//! | Check | Classification |
177//! |---|---|
178//! | Block parent-block existence | Downstream-redundant (`validate_v2_lineage`) |
179//! | Block patch existence | Downstream-redundant (lifecycle-replay layer's own read) |
180//! | Block snapshot-blob existence | Load-bearing |
181//! | Block format-2 shape validation (8 arms) | Load-bearing, all 8 |
182//! | Topological cycle detection | Unreachable (needs a SHA-256 fixed point; unit-level substitute in `block_state/tests.rs`) |
183//! | Envelope type mismatch / object id mismatch | Load-bearing, both -- RFC 102 Stage 3 moved the type-mismatch half into `container::parse_frame_at`; see the section above |
184//! | Index entry resolves to the wrong object (RFC 102 Stage 3) | Load-bearing -- new this stage; see the section above |
185//! | `validate_read_schema` strict-signature-shape | Load-bearing, via non-blocking-sibling mechanism |
186//! | Publication-trust failure (Block/RefState) | Demonstrated via trusted/untrusted contrast |
187//! | Directory/file shape structural errors | Downstream-redundant, both sub-arms -- RFC 102 Stage 3 made the tree it scans dormant; see the section above |
188//!
189//! ## `refs/verify.rs` + `refs/verify/scan.rs` (`verify/tests/ref_cluster.rs`)
190//!
191//! **RFC 102 Stage 4 (design-v1.md §13.12-§13.15) moved ref publication state from one loose file
192//! per pointer/log to two shared containers** (a ref-pointer index, a ref-log container), the shape
193//! Stage 3 established for objects. Two of this cluster's sixteen original checks had no per-file
194//! path left to be non-canonical or wrong-shaped, and are genuinely retired -- not merely renamed --
195//! confirmed by grepping the rewritten `scan.rs`/`container.rs` for their own diagnostic strings and
196//! finding neither. A third pair (duplicate identity) describes a scenario the new "last entry wins"
197//! model makes structurally impossible to even attempt, not merely astronomically unlikely. All three
198//! retirements are marked below rather than silently dropped -- **the sixteen are still all sixteen,
199//! four now retired with a named reason and, where one exists, a named replacement**, not twelve.
200//!
201//! - **Non-canonical ref pointer path** and **`ensure_ref_path_shape`** (`by-id/`, `logs/`, both
202//! sub-arms) retired: there is no per-ref file and no per-entry path under the shared-container
203//! model to be non-canonical or wrong-shaped -- entries are located by offset within one shared
204//! file, discovered by replaying it, never by listing a directory and checking a filename.
205//! **Replaced, not merely removed**: `read_one_pointer_entry`'s `ref_name_key_bytes(&entry.ref_name)
206//! != entry.ref_name_key` check and `validate_log_replay`'s identical check on the log-container
207//! side are the direct successors -- header/key-vs-content coherence replacing filename-vs-content
208//! coherence, the same underlying property ("this record's own claimed identity disagrees with what
209//! it actually contains") through the new storage shape. Both new rows below.
210//! - **Duplicate pointer identity / duplicate ref-log identity** retired: under "last entry wins,"
211//! multiple entries sharing a `ref_name_key` are the ordinary republish mechanism, not a collision
212//! to detect -- there is no "second insert into a name-keyed map" operation left to collide, so the
213//! scenario these two rows described cannot be attempted at all under the current design, not merely
214//! requiring a SHA-256 collision to reach as before.
215//!
216//! | Check | Classification |
217//! |---|---|
218//! | Incomplete log tail without pointer lead | Load-bearing (unchanged mechanism, container-based replay) |
219//! | Catch-all "unexplained pointer/log divergence" | Load-bearing (unchanged mechanism) |
220//! | `created_at == 0` | Load-bearing -- **now enforced at write time too** (`container::append_ref_container_record`, design-v1.md §13.15), not only at read time; a real production gap in the Stage 4 rewrite, found and closed |
221//! | `CANDIDATE-DEBRIS` | Non-blocking -- **now reachable only via a directly-planted fixture, never a real crash** (the candidate-write mechanism it detected is gone entirely); a dormant mutation wedge (design-v1.md §13.14), not fixed here |
222//! | Duplicate pointer identity / duplicate ref-log identity | **Retired** (design-v1.md §13.12-13.13) -- see prose above |
223//! | Non-canonical ref pointer path | **Retired**, replaced by "Pointer-index entry key mismatch" below |
224//! | RefState name mismatches pointer | Downstream-redundant (`classify_ref_state`'s own coherence arm), unchanged |
225//! | Pointer-index entry key mismatch (new, RFC 102 Stage 4) | Load-bearing (`refs/pointer_index.rs::read_one_pointer_entry`), design-v1.md §13.13 -- was untested before this stage's own checkpoint review found it, not merely newly added |
226//! | Log-container record key mismatch (new, RFC 102 Stage 4) | Load-bearing (`refs/verify/scan.rs::validate_log_replay`) |
227//! | Pointer index fails closed on any damaged entry (new, RFC 102 Stage 4) | Load-bearing -- deliberately asymmetric with the log container's own item-contained isolation: "last entry wins" makes silently skipping a damaged *latest* entry dangerous (an older entry for the same ref could resolve as current instead), so `read_pointers` refuses the whole read rather than isolating the damage. Design-v1.md §13.14 accepts this for Stage 4 but registers the wider blast radius (one bad entry now blocks every ref, not just its own) as a known regression against amended constraint 5, not a design choice -- not to be changed inside this stage |
228//! | `ensure_ref_target_valid` (dangling Branch/Tag target) | Load-bearing, unchanged |
229//! | Ref-log chain/sequence divergence | Load-bearing -- `expected_seq` now computed from a record's position within its own ref's *filtered subsequence* of the shared container (design-v1.md §13.1), not the container's raw physical position; re-proven directly against a physically reordered, individually-valid pair of records (Stage 4 acceptance criterion 3), not assumed carried over |
230//! | Ref-log checksum mismatch | Load-bearing -- mechanism moved from the retired `refs/log.rs::read_one_log` to `refs/container.rs`'s own frame decode (`parse_frame_at`) plus `refs/verify/scan.rs::read_logs`; same classification, new code |
231//! | `verify_update` RefState/RefUpdate coherence | Load-bearing, unchanged |
232//! | RefState unsigned | Downstream-redundant (`PublicationTrustVerifier`), unchanged |
233//! | `ensure_ref_path_shape` (`by-id/`, `logs/`) | **Retired**, both sub-arms -- see prose above; the log-container half's coverage is subsumed by `container/tests.rs`'s own corruption-isolation tests, the pointer-index half's by the fail-closed row above and `pointer_index/tests.rs`'s new corruption-isolation tests (a genuine coverage gap this stage's checkpoint review found and closed, design-v1.md §13.13, not merely a redundancy claim carried over) |
234//! | Signature-envelope issues, `RefLog` source | Excluded (see `signature_envelope_issues` caveat below), unchanged |
235//!
236//! ## `verify/ref_publication.rs`
237//!
238//! `mark_unproved` reclassification and `ACTIVE-CLEANUP-PENDING` were both already end-to-end covered
239//! before DC-95 started; not part of Stage 1's gap-closing scope.
240//!
241//! ## `wal.rs` / `verify_wal_persistence` / `rollback_verify.rs` (`verify/tests/wal_cluster.rs`)
242//!
243//! | Check | Classification |
244//! |---|---|
245//! | `Wal::replay()` checksum mismatch | Load-bearing; RFC 102 Stage 2 made the failure item-level (`wal_record_outcomes`) rather than a whole-`WalReplay`-stage abort -- see the RFC 102 Stage 2 section above |
246//! | `verify_wal_persistence` type mismatch | Load-bearing |
247//! | Rollback WAL envelope type | Unreachable (`is_rollback_draft_envelope` already guarantees Patch type before this check runs) |
248//! | Rollback WAL decode (op_seq contiguity) | Load-bearing |
249//! | Rollback WAL apply-support (`DeleteNode(symlink)`) | Load-bearing |
250//! | Rollback WAL empty-ops | Unreachable (`decode_patch_operations` already errors before returning empty) |
251//! | Rollback AUTHOR signature: missing | Load-bearing |
252//! | Rollback AUTHOR signature: wrong algorithm | Unreachable (`SignatureAlgorithm` has exactly one variant) |
253//! | Rollback AUTHOR signature: legacy marker key id | Load-bearing |
254//! | Rollback AUTHOR signature: wrong length | Unreachable (RFC 103: was reachable only under format-1, now retired; kept, untested, argument recorded in `rollback_verify.rs`) |
255//! | Signature-envelope issues, `ActiveWal` source | Excluded (see caveat below) |
256//!
257//! ## Active-WAL metadata status + WAL ordering (DC-66, `verify/tests.rs`)
258//!
259//! | Check | Classification |
260//! |---|---|
261//! | `MissingForEmptyWal` / `ValidForEmptyWal` | Excluded, non-blocking, both |
262//! | `InvalidForNonEmptyWal` | Load-bearing |
263//! | Active-WAL ordering violations | Load-bearing |
264//!
265//! ## `verify/trust.rs` / `trust.rs` (`verify/tests/trust.rs`)
266//!
267//! | Check | Classification |
268//! |---|---|
269//! | `PRIKK-TRUST-POLICY-INVALID` (missing/malformed policy) | Load-bearing |
270//! | `PRIKK-TRUST-PUBLICATION-UNTRUSTED` | Load-bearing |
271//!
272//! ## `commit_index.rs` (DC-56) / `lifecycle_cache/incremental.rs` (DC-64) (`crates/prikk-cli/tests/dc64_baseline_cache.rs`)
273//!
274//! | Check | Classification |
275//! |---|---|
276//! | Commit-index content divergence | Load-bearing |
277//! | Lifecycle-cache content-disagrees divergence | Load-bearing |
278//! | Lifecycle-cache "could not be independently verified" | Load-bearing (horizon-anchored replay vs. `block_state.rs`'s non-horizon-anchored one -- the one genuine asymmetry between two otherwise-equivalent replay paths) |
279//!
280//! ## Standing caveat: `signature_envelope_issues`
281//!
282//! `signature_envelope_issues` (one `Vec` on [`RepositoryVerification`], populated from every
283//! `SignatureEnvelopeSource`) backs no `has_*` blocking predicate, for any source -- an open question,
284//! not a settled design: should the `MALFORMED` variant be wired into a blocking predicate? Every
285//! "Excluded" row above that names this caveat, plus every "Load-bearing, via non-blocking-sibling
286//! mechanism" row, depends on this staying `false`. If it's ever answered the other way, those rows
287//! reopen.
288//!
289//! **RFC 103 note, not a status change:** `signature_diagnostics.rs::classify_signature_envelope`'s
290//! own doc records that its non-empty-result path is now provably unreachable through
291//! `verify_repository` at all (every call site runs it immediately after a `validate_read_schema`
292//! call that already hard-errors on the same three conditions, once format-1's lenient read branch is
293//! gone). This does not reopen anything above: every row this caveat covers was already "Excluded" --
294//! never blocking, for any source -- so a diagnostic layer becoming unreachable removes no coverage
295//! `verify` depended on.
296
297use std::path::PathBuf;
298
299mod objects;
300mod ref_publication;
301mod trust;
302
303use prikk_error::{PrikkError, Result};
304use prikk_object::{BlockPayload, ObjectId, ObjectType, RefStatePayload};
305
306use crate::active::{ActiveRefMetadata, read_active_ref_metadata};
307use crate::block_state::{BlockStateOutcome, BlockStateStatus};
308use crate::commit_index::{CommitIndexDivergence, verify_divergence};
309use crate::layout::{RepositoryFormat, RepositoryLayout};
310use crate::lifecycle_cache::incremental::{
311 LifecycleCacheDivergence, verify_divergence as verify_lifecycle_cache_divergence,
312};
313use crate::object_store::{ObjectReadSnapshot, ObjectReader};
314use crate::received::list_received_pointers;
315use crate::refs::{RefItemOutcome, RefItemStatus, ensure_ref_target_valid, verify_refs};
316use crate::rollback_verify::{verify_rollback_draft_wal_records, verify_rollback_patch_envelope};
317use crate::signature_diagnostics::{
318 SignatureEnvelopeIssue, SignatureEnvelopeSource, classify_signature_envelope,
319};
320use crate::trust::PublicationTrustIssue;
321use crate::wal::Wal;
322
323use objects::verify_objects;
324pub use objects::{ObjectItemOutcome, ObjectItemStatus};
325use trust::PublicationTrustVerifier;
326
327/// Verification summary for a single persisted object.
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct ObjectVerification {
330 /// The object ID parsed from the object filename.
331 pub object_id: ObjectId,
332 /// The object type implied by the directory being scanned.
333 pub object_type: ObjectType,
334 /// The object file path that was checked.
335 pub path: PathBuf,
336 /// Rollback-marked Patch references verified for this object when it is a Block.
337 pub rollback_patch_count: usize,
338 /// For a Block or RefState, the adopted MAINTAINER key id whose signature was trusted (DC-78
339 /// §D3). `None` for other object types, or when publication trust could not be established.
340 pub sealed_by_key_id: Option<String>,
341 /// For a Patch, the outcome of checking its AUTHOR signature against recorded key material
342 /// (DC-53 Stage 1). `None` for other object types, or when the Patch carries no AUTHOR-role
343 /// signature at all (out of this increment's scope). A signature that does not verify against
344 /// *recorded* material never reaches this field -- it fails this object's own item check
345 /// instead, the same as any other authorship-integrity defect.
346 pub author_verification: Option<AuthorSignatureVerification>,
347}
348
349/// The result of checking one Patch's AUTHOR signature (DC-53 Stage 1, D3's first two rows --
350/// there is no `Fails` variant here because that outcome is a genuine item-level failure,
351/// propagated as an `Err` the same way every other authorship-integrity defect in this pipeline is,
352/// not a value this type carries).
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub enum AuthorSignatureVerification {
355 /// The signature verifies against key material recorded for this `key_id`.
356 Sound {
357 /// The AUTHOR key id the signature named and verified against.
358 key_id: String,
359 },
360 /// No key material has ever been recorded for this `key_id` -- authored before this
361 /// increment, or by a signer whose material this repository never observed. **Not a
362 /// failure**: `verify` still passes, but this must be visible, not silent (DC-53 Stage 1 D3's
363 /// second row).
364 Unverifiable {
365 /// The AUTHOR key id named, for which no key material is on file.
366 key_id: String,
367 },
368}
369
370/// Which adopted MAINTAINER key sealed a given Block (DC-78 §D3). Reporting only: the sealer's key
371/// id already lives, non-strippably, inside the block's own signature.
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub struct BlockSealVerification {
374 /// The sealed Block's object id.
375 pub block_id: ObjectId,
376 /// The MAINTAINER key id whose trusted signature matched this Block.
377 pub sealed_by_key_id: String,
378}
379
380/// One of the thirteen top-level scopes `verify_repository`'s pipeline is organized into (DC-95 Stage 2
381/// Level 1: scope containment). Named in pipeline order; the order itself is load-bearing for
382/// `NotEvaluated` naming (`StageStatus::NotEvaluated`'s `blocked_by` is always an earlier stage).
383#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
384pub enum VerificationStage {
385 /// Persisted-object scan: identity, placement, envelope decoding, sealed block references,
386 /// publication trust for Block/RefState (shares `PublicationTrustVerifier` with
387 /// `RefUpdateSchemaTrust`).
388 Objects,
389 /// Joint ref pointer/log verification: structural shape, publication-state classification.
390 Refs,
391 /// Per-`RefUpdate`-envelope format-read-schema validation and publication trust (shares
392 /// `PublicationTrustVerifier` with `Objects`).
393 RefUpdateSchemaTrust,
394 /// Active-WAL replay: framing, checksums, envelope decoding.
395 WalReplay,
396 /// Active-WAL patch persistence cross-check against the object store.
397 WalPersistence,
398 /// Active-WAL rollback-draft classification and structural validation.
399 RollbackDrafts,
400 /// Per-active-WAL-record format-read-schema validation and signature-envelope classification.
401 WalRecordSchema,
402 /// Active-WAL ref-ownership metadata classification.
403 ActiveWalMetadata,
404 /// Retained-evidence reclassification of interrupted-publication ref issues.
405 PublicationReclassification,
406 /// DC-56 commit-index cache divergence check.
407 CommitIndex,
408 /// DC-64 incremental lifecycle-state cache divergence check.
409 LifecycleCache,
410 /// DC-66 active-WAL queue-ordering check.
411 WalOrdering,
412 /// RFC 115 Stage 3 §6: received (`remotes/*`) ref target validation -- the same kind-aware
413 /// two-hop check local refs already get, applied to the received namespace, which nothing
414 /// scanned before this stage existed.
415 ReceivedRefs,
416}
417
418impl VerificationStage {
419 /// Stable, lowercase-hyphenated scope name for diagnostics and CLI output.
420 #[must_use]
421 pub const fn label(self) -> &'static str {
422 match self {
423 Self::Objects => "objects",
424 Self::Refs => "refs",
425 Self::RefUpdateSchemaTrust => "ref-update-schema-trust",
426 Self::WalReplay => "wal-replay",
427 Self::WalPersistence => "wal-persistence",
428 Self::RollbackDrafts => "rollback-drafts",
429 Self::WalRecordSchema => "wal-record-schema",
430 Self::ActiveWalMetadata => "active-wal-metadata",
431 Self::PublicationReclassification => "publication-reclassification",
432 Self::CommitIndex => "commit-index",
433 Self::LifecycleCache => "lifecycle-cache",
434 Self::WalOrdering => "wal-ordering",
435 Self::ReceivedRefs => "received-refs",
436 }
437 }
438}
439
440impl std::fmt::Display for VerificationStage {
441 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442 f.write_str(self.label())
443 }
444}
445
446/// Outcome of attempting to evaluate one verification stage (DC-95 Stage 2 Level 1). **No stage may be
447/// silently absent from a report.** A stage's own check raising an error is recorded as a blocking
448/// finding against its scope rather than aborting the rest of verification (`Failed`); a stage that
449/// could not run because a real dependency did not evaluate is itself blocking, not silently skipped
450/// (`NotEvaluated`); a stage that could have run on its own terms but was preempted by an operator-
451/// requested early stop is also blocking, but for a different reason it must not be confused with
452/// (`Halted`) — a repository whose verification is incomplete is not verified, regardless of which of
453/// the three non-`Evaluated` states explains the gap.
454#[derive(Debug, Clone, PartialEq, Eq)]
455pub enum StageStatus {
456 /// The stage ran to completion; its findings and counts are authoritative.
457 Evaluated,
458 /// The stage's own check raised an error.
459 Failed {
460 /// The error the stage raised.
461 message: String,
462 },
463 /// The stage could not run because a *real* dependency did not evaluate — `blocked_by` names a
464 /// stage this one's own logic actually reads output from. This is a dependency-graph claim, and
465 /// must remain true of the graph even when `--stop-on-first-error` is in effect; see `Halted` for
466 /// the case where a stage merely followed an unrelated earlier stop.
467 NotEvaluated {
468 /// The earlier stage whose own non-evaluation is why this one could not run.
469 blocked_by: VerificationStage,
470 },
471 /// The stage was never attempted because an earlier, *unrelated* stage's failure already stopped
472 /// the walk under `--stop-on-first-error` (DC-95 Stage 2 Level 1 implementation review v1 §4) —
473 /// `after` names the stage whose failure triggered the stop, not a dependency of this stage. Kept
474 /// distinct from `NotEvaluated` because `blocked_by` is a dependency-graph claim: reporting
475 /// `NotEvaluated { blocked_by: Objects }` for a stage that does not actually depend on `Objects`
476 /// (e.g. `LifecycleCache`) would assert an edge that does not exist.
477 Halted {
478 /// The stage whose failure caused the walk to stop before this stage was reached.
479 after: VerificationStage,
480 },
481}
482
483impl StageStatus {
484 /// Return true for any status other than a clean, completed evaluation. `NotEvaluated` and
485 /// `Halted` are both blocking on the same footing as `Failed` — an incomplete verification is not
486 /// a passing one, whichever of the three explains the gap.
487 #[must_use]
488 pub const fn is_blocking(&self) -> bool {
489 !matches!(self, Self::Evaluated)
490 }
491}
492
493/// One stage's resolved outcome.
494#[derive(Debug, Clone, PartialEq, Eq)]
495pub struct StageOutcome {
496 /// Which of the thirteen stages this outcome is for.
497 pub stage: VerificationStage,
498 /// How that stage resolved.
499 pub status: StageStatus,
500}
501
502/// Repository verification summary.
503#[derive(Debug, Clone, PartialEq, Eq)]
504pub struct RepositoryVerification {
505 /// Outcome of each of the thirteen verification stages (DC-95 Stage 2 Level 1), in pipeline order.
506 /// Always exactly thirteen entries — no stage may be silently absent.
507 pub stage_outcomes: Vec<StageOutcome>,
508 /// Phase A: one outcome per persisted object file scanned, in scan order (DC-95 Stage 2 Level 2).
509 /// Empty when the `Objects` stage itself did not evaluate (a structural directory-shape error) —
510 /// nothing was attempted, distinct from a non-empty set where every entry happens to be `Failed`.
511 pub object_outcomes: Vec<ObjectItemOutcome>,
512 /// Phase B: one outcome per `CurrentV6` Block whose Phase A check succeeded, in the
513 /// state-dependency order `verify_blocks_topological` resolved them — not scan order (DC-92
514 /// §4.2). Empty when the `Objects` stage did not evaluate, or when no `CurrentV6` Block passed
515 /// Phase A at all.
516 pub block_state_outcomes: Vec<BlockStateOutcome>,
517 /// Number of persisted object files whose own Phase A checks ran to completion (DC-95 Stage 2
518 /// Level 2). `None` only when the `Objects` stage itself did not evaluate (a structural
519 /// directory-shape error) — under item containment this is no longer the same claim as "every
520 /// object in the store is individually sound": some entries in `object_outcomes` may themselves
521 /// be `Failed` while this count still reflects how many succeeded. Never a partial claim about
522 /// state-root soundness, which `block_state_outcomes` is the only source of truth for (Level 2
523 /// handoff §7 Q3 — `checked_blocks` below keeps its pre-Level-2 meaning unchanged).
524 pub checked_objects: Option<usize>,
525 /// Number of active WAL records replayed successfully. `None` when the WAL-replay stage did not
526 /// evaluate to completion.
527 pub checked_wal_records: Option<usize>,
528 /// One outcome per attempted WAL record frame, in scan order (RFC 102 Stage 2: isolate-and-
529 /// continue reading). Empty when the `WalReplay` stage itself did not evaluate.
530 pub wal_record_outcomes: Vec<crate::wal::WalRecordOutcome>,
531 /// Number of persisted Block objects whose references (parent, patch, snapshot existence, merge
532 /// baseline) were checked successfully — a Phase A claim only, never a claim about state-root
533 /// soundness (see `block_state_outcomes`). `None` only when the `Objects` stage itself did not
534 /// evaluate. This field's meaning is unchanged by Level 2 (handoff §7 Q3) — only *when* it is
535 /// `None` changed, from "the whole stage failed" to "the whole stage did not evaluate at all."
536 pub checked_blocks: Option<usize>,
537 /// Number of persisted Block objects classified as rollback blocks, among those whose Phase A
538 /// check succeeded. `None` only when the `Objects` stage itself did not evaluate.
539 pub checked_rollback_blocks: Option<usize>,
540 /// Number of sealed rollback-marked Patch objects referenced by Blocks whose Phase A check
541 /// succeeded. `None` only when the `Objects` stage itself did not evaluate.
542 pub checked_sealed_rollback_patches: Option<usize>,
543 /// Number of active WAL patch records that already exist as persisted patch objects. `None` when
544 /// the WAL-persistence stage did not evaluate to completion.
545 pub persisted_wal_patches: Option<usize>,
546 /// Number of ref pointer files whose own Phase-A-equivalent read succeeded (DC-95 Stage 2
547 /// Level 2). `None` only when the `Refs` stage itself did not evaluate.
548 pub checked_refs: Option<usize>,
549 /// Number of inline ref-log records read successfully. `None` only when the `Refs` stage
550 /// itself did not evaluate.
551 pub checked_ref_log_records: Option<usize>,
552 /// Interrupted ref-publication and candidate-debris conditions found by joint verification. Stays
553 /// a plain `Vec` under stage containment: entries already pushed by a stage that later failed
554 /// remain real findings; only the count/emptiness-as-proof reasoning needed a stage-aware guard
555 /// (see `require_retained_evidence`'s own `trust_is_valid` computation).
556 pub ref_publication_issues: Vec<crate::refs::RefPublicationIssue>,
557 /// One outcome per ref pointer file scanned, in scan order (DC-95 Stage 2 Level 2). Empty when
558 /// the `Refs` stage itself did not evaluate.
559 pub pointer_outcomes: Vec<crate::refs::RefFileOutcome>,
560 /// One outcome per ref log file scanned, in scan order. Empty when the `Refs` stage itself did
561 /// not evaluate.
562 pub log_outcomes: Vec<crate::refs::RefFileOutcome>,
563 /// One outcome per ref name reached via a successfully-read pointer or log. Empty when the
564 /// `Refs` stage itself did not evaluate.
565 pub ref_item_outcomes: Vec<crate::refs::RefItemOutcome>,
566 /// Warning-level format-1 signature-envelope compatibility findings in deterministic order.
567 pub signature_envelope_issues: Vec<SignatureEnvelopeIssue>,
568 /// Number of active WAL records classified and decoded as rollback drafts. `None` when the
569 /// rollback-drafts stage did not evaluate to completion.
570 pub checked_rollback_draft_records: Option<usize>,
571 /// Number of publication envelopes checked against repository-local trust. `None` unless *both*
572 /// the objects stage and the ref-update schema/trust stage evaluated to completion — this count is
573 /// contributed to by both, sharing one `PublicationTrustVerifier` instance across them.
574 pub checked_publication_trust_records: Option<usize>,
575 /// Publication-trust issues found while structural verification succeeded. Stays a plain `Vec` —
576 /// entries genuinely found before an interrupting failure remain real findings.
577 pub publication_trust_issues: Vec<PublicationTrustIssue>,
578 /// Recognized non-authoritative object publication temps left for explicit maintenance.
579 pub object_temp_paths: Vec<PathBuf>,
580 /// Number of trailing bytes in the active WAL that look like an incomplete final record. `None`
581 /// when the WAL-replay stage did not evaluate to completion.
582 pub trailing_partial_wal_bytes: Option<usize>,
583 /// Active-WAL ref metadata status relative to the replayed WAL. `None` when the active-WAL-metadata
584 /// stage did not evaluate to completion.
585 pub active_wal_metadata_status: Option<ActiveWalMetadataStatus>,
586 /// DC-56 commit-index entries whose recorded content hash disagrees with the worktree's actual
587 /// current content despite a matching stat — a stale-but-trusted cache entry, reported per the
588 /// cache-validity specification §6 rather than silently trusted by a future commit.
589 pub commit_index_divergences: Vec<CommitIndexDivergence>,
590 /// DC-64 incremental lifecycle-state cache entries whose contents disagree with an independent
591 /// full replay of the block they claim to represent — reported per the design document §6
592 /// rather than silently trusted by a future commit.
593 pub lifecycle_cache_divergences: Vec<LifecycleCacheDivergence>,
594 /// DC-66: active WAL queue-ordering violations — a record whose sequence does not strictly
595 /// increase over its predecessor. Adversarial-only under normal operation (`Wal::append_patch`
596 /// always assigns the next sequence), but a queue of N gives ordering a meaning ("patches seal in
597 /// append order") worth verifying explicitly rather than assuming from decode success alone.
598 pub active_wal_ordering_issues: Vec<ActiveWalOrderingIssue>,
599 /// DC-75: `Merge` blocks whose recorded `merge_baseline_block_id` is not, in fact, a common
600 /// ancestor of both parents — independently re-derived, not trusted, per
601 /// `baseline-recording-answer-v1.md` §3 ("record it, then check it, unconditionally"). A recorded
602 /// baseline that legitimate merge execution ever produced always passes this; a false claim (data
603 /// corruption or tampering) does not.
604 pub merge_baseline_divergences: Vec<MergeBaselineDivergence>,
605 /// Which adopted MAINTAINER key sealed each checked Block (DC-78 §D3), in on-disk scan order.
606 /// Reporting only — surfaces provenance that was already intrinsic to each block's own
607 /// signature, so an auditor can ask "which parts of this history did I seal" and get an answer.
608 pub block_seals: Vec<BlockSealVerification>,
609 /// RFC 115 Stage 3 §6: one outcome per received (`remotes/*`) pointer, in
610 /// `list_received_pointers`' sorted-by-name order — the same kind-aware two-hop target check
611 /// local refs already get, applied here for the first time. Empty when the `ReceivedRefs` stage
612 /// itself did not evaluate. A repository with a genuinely dangling received ref (its target
613 /// object was never shipped) now reports an item failure here where nothing reported anything
614 /// before this stage existed — see the stage's own module note for what that means for a
615 /// repository that already holds one.
616 pub received_ref_item_outcomes: Vec<RefItemOutcome>,
617}
618
619/// A `Merge` block (DC-75) whose recorded `merge_baseline_block_id` is not a common ancestor of its
620/// two parents. Precision note: this checks *validity* (is the claim even a common ancestor), not
621/// *nearest-ness* (is it the single nearest one) — a merge legitimately sealed against an older-than-
622/// necessary common ancestor is unusual but not what this finding is for; a baseline that is not a
623/// common ancestor at all can only arise from a forged or corrupted field.
624#[derive(Debug, Clone, PartialEq, Eq)]
625pub struct MergeBaselineDivergence {
626 /// The `Merge` block whose recorded baseline failed re-derivation.
627 pub block_id: ObjectId,
628 /// The recorded (claimed) baseline.
629 pub recorded_baseline: ObjectId,
630 /// The block's mainline parent.
631 pub mainline_parent_id: ObjectId,
632 /// The block's secondary parent.
633 pub secondary_parent_id: ObjectId,
634}
635
636/// One active-WAL record whose sequence did not strictly increase over the previous record.
637#[derive(Debug, Clone, PartialEq, Eq)]
638pub struct ActiveWalOrderingIssue {
639 /// Zero-based position of the offending record within the replayed WAL.
640 pub index: usize,
641 /// Sequence of the previous record.
642 pub previous_seq: u64,
643 /// Sequence of the offending record (not greater than `previous_seq`).
644 pub seq: u64,
645}
646
647impl RepositoryVerification {
648 /// Return true when any of the thirteen verification stages did not evaluate cleanly — either its
649 /// own check raised an error (`Failed`) or a dependency's non-evaluation prevented it from running
650 /// at all (`NotEvaluated`). A repository whose verification did not run to completion is not
651 /// verified, regardless of what the stages that did run found. Checked first, ahead of every
652 /// finding-specific predicate below: those predicates' own backing data can itself be incomplete
653 /// precisely because a stage failed, so this is the more fundamental question.
654 ///
655 /// **Does not, by itself, cover item-level defects (DC-95 Stage 2 Level 2)** — the `Objects`
656 /// stage evaluates cleanly (`Evaluated`) even when one of its items individually failed, since
657 /// item containment means a bad object no longer aborts the whole stage. See
658 /// [`Self::has_item_failure`] for that question, and [`Self::has_blocking_defect`] for the
659 /// combined check almost every caller actually wants.
660 #[must_use]
661 pub fn has_stage_failure(&self) -> bool {
662 self.stage_outcomes
663 .iter()
664 .any(|outcome| outcome.status.is_blocking())
665 }
666
667 /// Return true when any individual item did not evaluate cleanly (DC-95 Stage 2 Level 2) — a
668 /// Phase A object whose own check failed, a Phase B `CurrentV6` Block whose state-root check
669 /// failed or could not be attempted because its own state-derivation parent did not evaluate,
670 /// or a ref (its pointer file, log file, or classification) that failed. Item containment means
671 /// these no longer make [`Self::has_stage_failure`] true: the owning stage itself completed, so
672 /// this is a genuinely separate question, not a more detailed view of the same one. The backing
673 /// `Vec`s are empty (not merely all-`Evaluated`) when their owning stage itself did not
674 /// evaluate — this method reads that case as `false`, same as every other item-backed predicate
675 /// in this type; `has_stage_failure` is what is already true for it.
676 #[must_use]
677 pub fn has_item_failure(&self) -> bool {
678 self.object_outcomes
679 .iter()
680 .any(|outcome| matches!(outcome.status, ObjectItemStatus::Failed { .. }))
681 || self
682 .block_state_outcomes
683 .iter()
684 .any(|outcome| !matches!(outcome.status, BlockStateStatus::Verified))
685 || self
686 .pointer_outcomes
687 .iter()
688 .any(|outcome| matches!(outcome.status, crate::refs::RefFileStatus::Failed { .. }))
689 || self
690 .log_outcomes
691 .iter()
692 .any(|outcome| matches!(outcome.status, crate::refs::RefFileStatus::Failed { .. }))
693 || self
694 .ref_item_outcomes
695 .iter()
696 .any(|outcome| matches!(outcome.status, crate::refs::RefItemStatus::Failed { .. }))
697 || self
698 .wal_record_outcomes
699 .iter()
700 .any(|outcome| matches!(outcome.status, crate::wal::WalRecordStatus::Failed { .. }))
701 || self
702 .received_ref_item_outcomes
703 .iter()
704 .any(|outcome| matches!(outcome.status, RefItemStatus::Failed { .. }))
705 }
706
707 /// Return true when this repository's verification found any blocking reason to refuse it --
708 /// stage-level (`has_stage_failure`) or item-level (`has_item_failure`). A convenience predicate
709 /// for a caller that only wants "is this repository verified at all" and does not care which
710 /// half of that question failed.
711 ///
712 /// **Not currently called by this crate's own production code.** `doctor_repository`'s refusal
713 /// gate is preserved by its own per-stage and per-item `DoctorIssue::error` loops feeding
714 /// `is_healthy()`, not by calling this directly; `prikk verify`'s exit-code chain
715 /// (`main.rs`) calls `has_stage_failure()` and `has_item_failure()` as two separate arms
716 /// precisely so it can report *which* kind of failure occurred, rather than one generic
717 /// message -- collapsing them here would lose that. Kept as public API for an external caller
718 /// that only wants the yes/no answer.
719 #[must_use]
720 pub fn has_blocking_defect(&self) -> bool {
721 self.has_stage_failure() || self.has_item_failure()
722 }
723
724 /// Return true if the active WAL contained an incomplete trailing record. `None` (the WAL-replay
725 /// stage did not evaluate) reads as false here — that condition is already surfaced, more
726 /// precisely, by `has_stage_failure`.
727 #[must_use]
728 pub fn has_trailing_partial_wal(&self) -> bool {
729 self.trailing_partial_wal_bytes.is_some_and(|n| n != 0)
730 }
731
732 /// Return true when all structurally verified publication objects also passed trust checks.
733 #[must_use]
734 pub fn has_publication_trust_issues(&self) -> bool {
735 !self.publication_trust_issues.is_empty()
736 }
737
738 /// Return true when pointer/log state requires signer-backed recovery or manual intervention.
739 #[must_use]
740 pub fn has_blocking_ref_publication_issues(&self) -> bool {
741 self.ref_publication_issues
742 .iter()
743 .any(|issue| issue.blocking)
744 }
745
746 /// Return true when a non-empty active WAL lacks valid ownership metadata. `None` (the
747 /// active-WAL-metadata stage did not evaluate) reads as false here — see `has_trailing_partial_wal`.
748 #[must_use]
749 pub fn has_active_wal_metadata_integrity_issue(&self) -> bool {
750 self.active_wal_metadata_status
751 .as_ref()
752 .is_some_and(ActiveWalMetadataStatus::has_integrity_issue)
753 }
754
755 /// Return true when an empty active WAL has stale local metadata debris. `None` reads as false —
756 /// see `has_trailing_partial_wal`.
757 #[must_use]
758 pub fn has_active_wal_metadata_warning(&self) -> bool {
759 self.active_wal_metadata_status
760 .as_ref()
761 .is_some_and(ActiveWalMetadataStatus::has_local_debris_warning)
762 }
763
764 /// Return true when the commit-index cache disagrees with the worktree for at least one path.
765 #[must_use]
766 pub fn has_commit_index_divergence(&self) -> bool {
767 !self.commit_index_divergences.is_empty()
768 }
769
770 /// Return true when the incremental lifecycle-state cache disagrees with an independent replay.
771 #[must_use]
772 pub fn has_lifecycle_cache_divergence(&self) -> bool {
773 !self.lifecycle_cache_divergences.is_empty()
774 }
775
776 /// Return true when the active WAL contains an out-of-order or duplicate sequence.
777 #[must_use]
778 pub fn has_active_wal_ordering_issue(&self) -> bool {
779 !self.active_wal_ordering_issues.is_empty()
780 }
781
782 /// Return true when a `Merge` block's recorded baseline is not a common ancestor of its parents
783 /// (DC-75) — a false claim, from data corruption or tampering.
784 #[must_use]
785 pub fn has_merge_baseline_divergence(&self) -> bool {
786 !self.merge_baseline_divergences.is_empty()
787 }
788}
789
790/// Active-WAL ref metadata status derived during repository verification.
791#[derive(Debug, Clone, PartialEq, Eq)]
792pub enum ActiveWalMetadataStatus {
793 /// Empty active WAL and no metadata.
794 MissingForEmptyWal,
795 /// Empty active WAL with stale but valid local metadata.
796 ValidForEmptyWal {
797 /// Ref recorded in the stale metadata.
798 ref_name: String,
799 },
800 /// Empty active WAL with malformed local metadata.
801 InvalidForEmptyWal {
802 /// Parse or validation failure.
803 reason: String,
804 },
805 /// Non-empty active WAL with valid ownership metadata.
806 ValidForNonEmptyWal {
807 /// Ref recorded in the active metadata.
808 ref_name: String,
809 },
810 /// Non-empty active WAL missing required ownership metadata.
811 MissingForNonEmptyWal,
812 /// Non-empty active WAL with malformed ownership metadata.
813 InvalidForNonEmptyWal {
814 /// Parse or validation failure.
815 reason: String,
816 },
817}
818
819impl ActiveWalMetadataStatus {
820 /// Return true when the status represents a repository-integrity issue.
821 #[must_use]
822 pub const fn has_integrity_issue(&self) -> bool {
823 matches!(
824 self,
825 Self::MissingForNonEmptyWal | Self::InvalidForNonEmptyWal { .. }
826 )
827 }
828
829 /// Return true when the status represents local debris on an otherwise empty active WAL.
830 #[must_use]
831 pub const fn has_local_debris_warning(&self) -> bool {
832 matches!(
833 self,
834 Self::ValidForEmptyWal { .. } | Self::InvalidForEmptyWal { .. }
835 )
836 }
837}
838
839/// Threads stage outcomes, and (optionally) an early-halt decision, through `verify_repository`'s
840/// pipeline (DC-95 Stage 2 Level 1's `--stop-on-first-error`, design §7 and §12.3).
841struct StagePipeline {
842 outcomes: Vec<StageOutcome>,
843 stop_on_first_error: bool,
844 halted_by: Option<VerificationStage>,
845}
846
847impl StagePipeline {
848 fn new(stop_on_first_error: bool) -> Self {
849 Self {
850 outcomes: Vec::with_capacity(12),
851 stop_on_first_error,
852 halted_by: None,
853 }
854 }
855
856 /// Attempt a stage with no real dependency beyond a possible earlier halt. Returns the value on
857 /// success; `None` on failure, or if an earlier stage already halted the walk. A stage reached
858 /// through `run` never has a real declared dependency (a stage that does is gated behind an
859 /// `if`/`else` at its call site and reaches `not_evaluated` instead when ungated) -- so an
860 /// already-halted walk is always reported as `Halted`, never a fabricated `NotEvaluated`.
861 fn run<T>(&mut self, stage: VerificationStage, result: Result<T>) -> Option<T> {
862 if let Some(halted_by) = self.halted_by {
863 self.outcomes.push(StageOutcome {
864 stage,
865 status: StageStatus::Halted { after: halted_by },
866 });
867 return None;
868 }
869 match result {
870 Ok(value) => {
871 self.outcomes.push(StageOutcome {
872 stage,
873 status: StageStatus::Evaluated,
874 });
875 Some(value)
876 }
877 Err(err) => {
878 self.outcomes.push(StageOutcome {
879 stage,
880 status: StageStatus::Failed {
881 message: err.to_string(),
882 },
883 });
884 if self.stop_on_first_error {
885 self.halted_by = Some(stage);
886 }
887 None
888 }
889 }
890 }
891
892 /// Record a stage that cannot run because `blocked_by` -- a real dependency -- did not evaluate.
893 /// Always reports `blocked_by` as given, never substituted: a caller only reaches this method when
894 /// `blocked_by`'s own stage failed to produce a usable value (DC-95 Stage 2 Level 1 implementation
895 /// review v1 §4), so the claim is true of the dependency graph regardless of *why* `blocked_by`
896 /// itself did not evaluate -- including when `blocked_by` was itself `Halted`, in which case this
897 /// stage is transitively halted too, discoverable by following the chain rather than by this call
898 /// reaching past its own real dependency to name an unrelated stage. `--stop-on-first-error` never
899 /// originates a fresh halt here: every halt traces back to a `Failed` outcome from `run`, which
900 /// already recorded it.
901 fn not_evaluated(&mut self, stage: VerificationStage, blocked_by: VerificationStage) {
902 self.outcomes.push(StageOutcome {
903 stage,
904 status: StageStatus::NotEvaluated { blocked_by },
905 });
906 }
907
908 /// Record a stage whose own check cannot fail (a pure function, or one that already converts
909 /// errors into findings) -- still subject to an earlier halt. Returns whether the stage should
910 /// actually run its own work. Never a real dependency (see `run`), so an already-halted walk is
911 /// `Halted`, not `NotEvaluated`.
912 fn run_infallible(&mut self, stage: VerificationStage) -> bool {
913 if let Some(halted_by) = self.halted_by {
914 self.outcomes.push(StageOutcome {
915 stage,
916 status: StageStatus::Halted { after: halted_by },
917 });
918 false
919 } else {
920 self.outcomes.push(StageOutcome {
921 stage,
922 status: StageStatus::Evaluated,
923 });
924 true
925 }
926 }
927}
928
929/// Options controlling how `verify_repository` walks its thirteen stages (DC-95 Stage 2 Level 1).
930#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
931pub struct VerifyOptions {
932 /// When true, stop at the first stage that fails or cannot evaluate, leaving every later stage
933 /// `NotEvaluated` (naming the first halting stage) rather than continuing to accumulate.
934 /// Preserves the pre-Stage-2 bounded-walk behavior for a large, badly-damaged repository where a
935 /// full accumulating scan would be costly (design §7) -- unbounded growth is concentrated in the
936 /// `Objects` stage's whole-store scan. Default `false` (full accumulation).
937 pub stop_on_first_error: bool,
938}
939
940/// Verify a repository layout without modifying it, with the default options (full accumulation
941/// across all thirteen stages). See [`verify_repository_with_options`] for `--stop-on-first-error`.
942pub fn verify_repository(layout: &RepositoryLayout) -> Result<RepositoryVerification> {
943 verify_repository_with_options(layout, VerifyOptions::default())
944}
945
946/// Verify a repository layout without modifying it.
947pub fn verify_repository_with_options(
948 layout: &RepositoryLayout,
949 options: VerifyOptions,
950) -> Result<RepositoryVerification> {
951 // RFC 111 §6.1: `verify` is read-only end to end (never calls `write_object` anywhere in its own
952 // call graph, confirmed by RFC 111 Q1's inventory), so it takes one decoded index snapshot here,
953 // once, instead of the O(N) per-object-read decode `FileObjectStore` used to pay.
954 let object_store = ObjectReadSnapshot::open(layout)?;
955 let mut trust_verifier = PublicationTrustVerifier::new(layout);
956 let mut pipeline = StagePipeline::new(options.stop_on_first_error);
957
958 // Stage: Objects. No upstream stage dependency. `trust_verifier` is mutated by reference and its
959 // state survives a `Failed` outcome here, since it lives in this function's own frame rather than
960 // inside `verify_objects` -- reused safely by RefUpdateSchemaTrust and PublicationReclassification
961 // below (DC-95 Stage 2 Step 0 §3: `PublicationTrustVerifier` cannot manufacture a false "trusted"
962 // result from partial evaluation).
963 let object_summary = pipeline.run(
964 VerificationStage::Objects,
965 verify_objects(layout, &object_store, &mut trust_verifier),
966 );
967 let objects_evaluated = object_summary.is_some();
968 let (
969 object_outcomes,
970 block_state_outcomes,
971 checked_objects,
972 checked_blocks,
973 checked_rollback_blocks,
974 checked_sealed_rollback_patches,
975 object_temp_paths,
976 merge_baseline_divergences,
977 block_seals,
978 mut signature_envelope_issues,
979 ) = match object_summary {
980 Some(summary) => {
981 let counts = phase_a_counts(&summary.item_outcomes)?;
982 (
983 summary.item_outcomes,
984 summary.topological_outcomes,
985 Some(counts.objects),
986 Some(counts.blocks),
987 Some(counts.rollback_blocks),
988 Some(counts.rollback_patches),
989 summary.temp_paths,
990 summary.merge_baseline_divergences,
991 summary.block_seals,
992 summary.signature_issues,
993 )
994 }
995 None => (
996 Vec::new(),
997 Vec::new(),
998 None,
999 None,
1000 None,
1001 None,
1002 Vec::new(),
1003 Vec::new(),
1004 Vec::new(),
1005 Vec::new(),
1006 ),
1007 };
1008
1009 // Stage: Refs. No upstream stage dependency.
1010 let ref_verification = pipeline.run(VerificationStage::Refs, verify_refs(layout));
1011
1012 // Stage: ReceivedRefs (RFC 115 Stage 3 §6). No upstream stage dependency -- reads the received
1013 // index and the object store directly, the same footing `Refs` has.
1014 let received_ref_item_outcomes = pipeline
1015 .run(
1016 VerificationStage::ReceivedRefs,
1017 verify_received_refs(layout, &object_store),
1018 )
1019 .unwrap_or_default();
1020
1021 // Stage: RefUpdateSchemaTrust. Depends on Refs for the envelope list.
1022 let ref_update_schema_trust_evaluated = if let Some(rv) = &ref_verification {
1023 pipeline
1024 .run(
1025 VerificationStage::RefUpdateSchemaTrust,
1026 (|| -> Result<()> {
1027 for envelope in &rv.ref_update_envelopes {
1028 crate::format::validate_read_schema(layout.format(), envelope)?;
1029 trust_verifier.verify(envelope)?;
1030 }
1031 Ok(())
1032 })(),
1033 )
1034 .is_some()
1035 } else {
1036 pipeline.not_evaluated(
1037 VerificationStage::RefUpdateSchemaTrust,
1038 VerificationStage::Refs,
1039 );
1040 false
1041 };
1042
1043 let refs_evaluated = ref_verification.is_some();
1044 let (
1045 checked_refs,
1046 checked_ref_log_records,
1047 mut ref_publication_issues,
1048 refs_signature_envelope_issues,
1049 pointer_outcomes,
1050 log_outcomes,
1051 ref_item_outcomes,
1052 ) = match ref_verification {
1053 Some(rv) => (
1054 Some(rv.pointer_count),
1055 Some(rv.log_record_count),
1056 rv.publication_issues,
1057 rv.signature_envelope_issues,
1058 rv.pointer_outcomes,
1059 rv.log_outcomes,
1060 rv.ref_item_outcomes,
1061 ),
1062 None => (
1063 None,
1064 None,
1065 Vec::new(),
1066 Vec::new(),
1067 Vec::new(),
1068 Vec::new(),
1069 Vec::new(),
1070 ),
1071 };
1072
1073 // Stage: WalReplay. No upstream stage dependency.
1074 let wal = Wal::for_layout(layout);
1075 let replay = pipeline.run(VerificationStage::WalReplay, wal.replay());
1076
1077 // Stage: WalPersistence. Depends on WalReplay.
1078 let persisted_wal_patches = if let Some(replay) = &replay {
1079 pipeline.run(
1080 VerificationStage::WalPersistence,
1081 verify_wal_persistence(&object_store, &replay.records),
1082 )
1083 } else {
1084 pipeline.not_evaluated(
1085 VerificationStage::WalPersistence,
1086 VerificationStage::WalReplay,
1087 );
1088 None
1089 };
1090
1091 // Stage: RollbackDrafts. Depends on WalReplay.
1092 let checked_rollback_draft_records = if let Some(replay) = &replay {
1093 pipeline.run(
1094 VerificationStage::RollbackDrafts,
1095 verify_rollback_draft_wal_records(&replay.records),
1096 )
1097 } else {
1098 pipeline.not_evaluated(
1099 VerificationStage::RollbackDrafts,
1100 VerificationStage::WalReplay,
1101 );
1102 None
1103 };
1104
1105 // Stage: WalRecordSchema. Depends on WalReplay.
1106 if let Some(replay) = &replay {
1107 pipeline.run(
1108 VerificationStage::WalRecordSchema,
1109 (|| -> Result<()> {
1110 for record in &replay.records {
1111 crate::format::validate_read_schema(layout.format(), &record.envelope)?;
1112 signature_envelope_issues.extend(classify_signature_envelope(
1113 &record.envelope,
1114 SignatureEnvelopeSource::ActiveWal {
1115 sequence: record.seq,
1116 object_id: record.envelope.object_id(),
1117 },
1118 )?);
1119 }
1120 Ok(())
1121 })(),
1122 );
1123 // Matches the pre-Level-1 merge order (Objects, then WAL, then Refs) -- deferred until here,
1124 // after the WAL per-record loop's own contributions, rather than appended immediately after
1125 // the Refs stage above, purely to preserve that existing, asserted-on order.
1126 signature_envelope_issues.extend(refs_signature_envelope_issues);
1127 } else {
1128 pipeline.not_evaluated(
1129 VerificationStage::WalRecordSchema,
1130 VerificationStage::WalReplay,
1131 );
1132 signature_envelope_issues.extend(refs_signature_envelope_issues);
1133 }
1134
1135 // Stage: ActiveWalMetadata. Depends on WalReplay.
1136 let active_wal_metadata_status = if let Some(replay) = &replay {
1137 pipeline.run(
1138 VerificationStage::ActiveWalMetadata,
1139 classify_active_wal_metadata(layout, replay.records.is_empty()),
1140 )
1141 } else {
1142 pipeline.not_evaluated(
1143 VerificationStage::ActiveWalMetadata,
1144 VerificationStage::WalReplay,
1145 );
1146 None
1147 };
1148
1149 // Stage: PublicationReclassification. Cannot run at all without Refs (needs `issues` to mutate),
1150 // WalReplay (needs `records`), or ActiveWalMetadata (needs `metadata`) -- `NotEvaluated`, naming
1151 // whichever of those three failed first, if any. Objects failing does *not* block this stage from
1152 // running: it only degrades `trust_is_valid` to a safe `false` (DC-95 Stage 2 Step 0 ruling §2-§3
1153 // -- an accumulator's emptiness means "none found" only if its producer ran to completion; reading
1154 // `trust_verifier.issues.is_empty()` alone would silently claim "proved" from an unrun check).
1155 match (&replay, refs_evaluated, &active_wal_metadata_status) {
1156 (Some(replay), true, Some(metadata)) => {
1157 let trust_is_valid = objects_evaluated && trust_verifier.issues.is_empty();
1158 pipeline.run(
1159 VerificationStage::PublicationReclassification,
1160 ref_publication::require_retained_evidence(
1161 layout,
1162 &replay.records,
1163 metadata,
1164 trust_is_valid,
1165 &mut ref_publication_issues,
1166 ),
1167 );
1168 }
1169 _ => {
1170 let blocked_by = if replay.is_none() {
1171 VerificationStage::WalReplay
1172 } else if !refs_evaluated {
1173 VerificationStage::Refs
1174 } else {
1175 VerificationStage::ActiveWalMetadata
1176 };
1177 pipeline.not_evaluated(VerificationStage::PublicationReclassification, blocked_by);
1178 }
1179 }
1180
1181 // Stage: CommitIndex. No upstream stage dependency; contained like any other fallible stage --
1182 // `commit_index::verify_divergence` does return `Result`, unlike `LifecycleCache`'s. An empty
1183 // `Vec` on `Failed`/`NotEvaluated` is safe here (unlike a count): the stage's own outcome above
1184 // already says whether "no divergences" was actually established.
1185 let commit_index_divergences = pipeline
1186 .run(VerificationStage::CommitIndex, verify_divergence(layout))
1187 .unwrap_or_default();
1188
1189 // Stage: LifecycleCache. No upstream stage dependency; cannot fail by construction -- a replay
1190 // error is itself converted into a divergence entry rather than propagated (DC-95 Stage 1 round
1191 // 12). Still subject to an earlier halt under `--stop-on-first-error`.
1192 let lifecycle_cache_divergences = if pipeline.run_infallible(VerificationStage::LifecycleCache)
1193 {
1194 verify_lifecycle_cache_divergence(&object_store, layout)
1195 } else {
1196 Vec::new()
1197 };
1198
1199 // Stage: WalOrdering. Depends on WalReplay; the check itself cannot fail (a pure function).
1200 let active_wal_ordering_issues = if let Some(replay) = &replay {
1201 if pipeline.run_infallible(VerificationStage::WalOrdering) {
1202 check_active_wal_ordering(&replay.records)
1203 } else {
1204 Vec::new()
1205 }
1206 } else {
1207 pipeline.not_evaluated(VerificationStage::WalOrdering, VerificationStage::WalReplay);
1208 Vec::new()
1209 };
1210
1211 let checked_publication_trust_records = (objects_evaluated
1212 && ref_update_schema_trust_evaluated)
1213 .then_some(trust_verifier.checked_records);
1214
1215 Ok(RepositoryVerification {
1216 stage_outcomes: pipeline.outcomes,
1217 object_outcomes,
1218 block_state_outcomes,
1219 checked_objects,
1220 checked_wal_records: replay.as_ref().map(|replay| replay.records.len()),
1221 wal_record_outcomes: replay
1222 .as_ref()
1223 .map(|replay| replay.record_outcomes.clone())
1224 .unwrap_or_default(),
1225 checked_blocks,
1226 checked_rollback_blocks,
1227 checked_sealed_rollback_patches,
1228 persisted_wal_patches,
1229 checked_refs,
1230 checked_ref_log_records,
1231 ref_publication_issues,
1232 pointer_outcomes,
1233 log_outcomes,
1234 ref_item_outcomes,
1235 signature_envelope_issues,
1236 checked_rollback_draft_records,
1237 checked_publication_trust_records,
1238 publication_trust_issues: trust_verifier.issues,
1239 object_temp_paths,
1240 trailing_partial_wal_bytes: replay.as_ref().map(|replay| replay.trailing_partial_bytes),
1241 active_wal_metadata_status,
1242 commit_index_divergences,
1243 lifecycle_cache_divergences,
1244 active_wal_ordering_issues,
1245 merge_baseline_divergences,
1246 block_seals,
1247 received_ref_item_outcomes,
1248 })
1249}
1250
1251/// Aggregate counts derived from Phase A's per-item outcomes (DC-95 Stage 2 Level 2). Each field
1252/// counts only `Evaluated` entries -- a `Failed` object contributes to none of them, same as it never
1253/// contributed to the pre-Level-2 running totals a whole-stage failure would have zeroed out entirely.
1254struct PhaseACounts {
1255 objects: usize,
1256 blocks: usize,
1257 rollback_blocks: usize,
1258 rollback_patches: usize,
1259}
1260
1261fn phase_a_counts(object_outcomes: &[ObjectItemOutcome]) -> Result<PhaseACounts> {
1262 let mut objects = 0_usize;
1263 let mut blocks = 0_usize;
1264 let mut rollback_blocks = 0_usize;
1265 let mut rollback_patches = 0_usize;
1266 for outcome in object_outcomes {
1267 // RFC 102 Stage 3: an `Unindexed` object is just as real and sound as an `Evaluated` one --
1268 // design-v1.md §12/§10.2's ruling that it is not a failure means it belongs in every count a
1269 // healthy object contributes to, not only in `has_item_failure()`'s exclusion.
1270 let verification = match &outcome.status {
1271 ObjectItemStatus::Evaluated(verification)
1272 | ObjectItemStatus::Unindexed(verification) => verification,
1273 ObjectItemStatus::Failed { .. } => continue,
1274 };
1275 objects = objects.checked_add(1).ok_or_else(|| {
1276 PrikkError::Integrity("object verification count overflow".to_string())
1277 })?;
1278 if verification.object_type == ObjectType::Block {
1279 blocks = blocks.checked_add(1).ok_or_else(|| {
1280 PrikkError::Integrity("block verification count overflow".to_string())
1281 })?;
1282 if verification.rollback_patch_count != 0 {
1283 rollback_blocks = rollback_blocks.checked_add(1).ok_or_else(|| {
1284 PrikkError::Integrity("rollback block count overflow".to_string())
1285 })?;
1286 rollback_patches = rollback_patches
1287 .checked_add(verification.rollback_patch_count)
1288 .ok_or_else(|| {
1289 PrikkError::Integrity("rollback patch count overflow".to_string())
1290 })?;
1291 }
1292 }
1293 }
1294 Ok(PhaseACounts {
1295 objects,
1296 blocks,
1297 rollback_blocks,
1298 rollback_patches,
1299 })
1300}
1301
1302/// Check that active WAL record sequences strictly increase in replay (append) order. Reachable only
1303/// under direct file tampering — `Wal::append_patch` always assigns `previous.seq + 1` — but a queue
1304/// of N gives "ordering" its own meaning worth verifying explicitly (RFC criterion 6), not merely
1305/// assumed from successful structural decode.
1306fn check_active_wal_ordering(records: &[crate::wal::WalRecord]) -> Vec<ActiveWalOrderingIssue> {
1307 records
1308 .iter()
1309 .zip(records.iter().skip(1))
1310 .enumerate()
1311 .filter(|(_, (previous, current))| current.seq <= previous.seq)
1312 .map(|(index, (previous, current))| ActiveWalOrderingIssue {
1313 index: index + 1,
1314 previous_seq: previous.seq,
1315 seq: current.seq,
1316 })
1317 .collect()
1318}
1319
1320/// RFC 115 Stage 3 §6: the received-namespace verification gap, closed. `verify_repository` never
1321/// scanned `remotes/*` before this stage — `ReceivedIndex` appeared nowhere in this file or in
1322/// `refs/verify/scan.rs`, so a received ref whose target object was never shipped dangled
1323/// invisibly, on both the sender's and receiver's side, discovered while reviewing the DC-78
1324/// bundle-export tag-ref gap (`DC-78-bundle-tag-gap-implementation-review-v1.md` §5).
1325///
1326/// Reuses `ensure_ref_target_valid` as-is — the exact kind-aware, two-hop-for-tags check local refs
1327/// already get (`refs/verify/scan.rs`) — applied here for the first time to the received namespace.
1328/// This is wiring, not new logic: no new validation rule is introduced, only a new place the
1329/// existing one now runs.
1330fn verify_received_refs(
1331 layout: &RepositoryLayout,
1332 object_store: &impl ObjectReader,
1333) -> Result<Vec<RefItemOutcome>> {
1334 let pointers = list_received_pointers(layout)?;
1335 let mut outcomes = Vec::with_capacity(pointers.len());
1336 for pointer in pointers {
1337 let outcome = (|| -> Result<()> {
1338 let envelope = object_store
1339 .read_typed(pointer.ref_state_id, ObjectType::RefState)?
1340 .ok_or_else(|| {
1341 PrikkError::Integrity(format!(
1342 "received pointer {} names missing RefState {}",
1343 pointer.ref_name, pointer.ref_state_id
1344 ))
1345 })?;
1346 let payload = RefStatePayload::decode_canonical(
1347 &envelope.canonical_payload,
1348 envelope.schema_version,
1349 )?;
1350 ensure_ref_target_valid(
1351 object_store,
1352 payload.kind,
1353 payload.target_object_id,
1354 pointer.ref_state_id,
1355 )
1356 })();
1357 outcomes.push(RefItemOutcome {
1358 ref_name: pointer.ref_name,
1359 status: match outcome {
1360 Ok(()) => RefItemStatus::Evaluated,
1361 Err(error) => RefItemStatus::Failed {
1362 message: error.to_string(),
1363 },
1364 },
1365 });
1366 }
1367 Ok(outcomes)
1368}
1369
1370fn classify_active_wal_metadata(
1371 layout: &RepositoryLayout,
1372 wal_is_empty: bool,
1373) -> Result<ActiveWalMetadataStatus> {
1374 match (wal_is_empty, read_active_ref_metadata(layout)?) {
1375 (true, ActiveRefMetadata::Missing) => Ok(ActiveWalMetadataStatus::MissingForEmptyWal),
1376 (true, ActiveRefMetadata::Valid(ref_name)) => {
1377 Ok(ActiveWalMetadataStatus::ValidForEmptyWal { ref_name })
1378 }
1379 (true, ActiveRefMetadata::Invalid(reason)) => {
1380 Ok(ActiveWalMetadataStatus::InvalidForEmptyWal { reason })
1381 }
1382 (false, ActiveRefMetadata::Missing) => Ok(ActiveWalMetadataStatus::MissingForNonEmptyWal),
1383 (false, ActiveRefMetadata::Valid(ref_name)) => {
1384 Ok(ActiveWalMetadataStatus::ValidForNonEmptyWal { ref_name })
1385 }
1386 (false, ActiveRefMetadata::Invalid(reason)) => {
1387 Ok(ActiveWalMetadataStatus::InvalidForNonEmptyWal { reason })
1388 }
1389 }
1390}
1391
1392/// Phase A (DC-92 §4.2): every check that does not depend on lineage-state derivation order —
1393/// existence of referenced objects, rollback-patch counting, and (independent of the shared memo)
1394/// the merge-baseline re-derivation. A `CurrentV6` block's own state-root verification is
1395/// deliberately **not** done here; it is deferred to a batch, dependency-ordered pass
1396/// (`crate::block_state::verify_blocks_topological`) run once after every object type has been
1397/// scanned, so `pending_v3_blocks` collects this block's already-decoded payload rather than
1398/// discarding it. See that function's own doc for why deferring is what bounds
1399/// `LineageStateMemo`'s memory instead of merely avoiding redundant re-derivation.
1400fn verify_block_payload(
1401 object_store: &impl ObjectReader,
1402 block_id: ObjectId,
1403 format: RepositoryFormat,
1404 canonical_payload: &[u8],
1405 pending_v3_blocks: &mut Vec<(ObjectId, BlockPayload)>,
1406) -> Result<(usize, Option<MergeBaselineDivergence>)> {
1407 let payload = BlockPayload::decode_canonical(canonical_payload)?;
1408 for parent in &payload.parent_block_ids {
1409 ensure_object_exists(
1410 object_store,
1411 ObjectType::Block,
1412 *parent,
1413 "parent block",
1414 block_id,
1415 )?;
1416 }
1417 let mut rollback_patch_count = 0_usize;
1418 for patch in &payload.patch_ids {
1419 let Some(envelope) = object_store.read_typed(*patch, ObjectType::Patch)? else {
1420 return Err(PrikkError::Integrity(format!(
1421 "object {block_id} references missing block patch {patch}"
1422 )));
1423 };
1424 let context = format!("sealed Block {block_id} Patch {patch}");
1425 if verify_rollback_patch_envelope(&envelope, &context)? {
1426 rollback_patch_count = rollback_patch_count.checked_add(1).ok_or_else(|| {
1427 PrikkError::Integrity("sealed rollback patch count overflow".to_string())
1428 })?;
1429 }
1430 }
1431 if let Some(snapshot) = payload.snapshot_blob_ref {
1432 ensure_object_exists(
1433 object_store,
1434 ObjectType::Blob,
1435 snapshot,
1436 "snapshot blob",
1437 block_id,
1438 )?;
1439 }
1440 let merge_baseline_divergence = if format == RepositoryFormat::CurrentV6 {
1441 verify_merge_baseline(object_store, block_id, &payload)?
1442 } else {
1443 None
1444 };
1445 if format == RepositoryFormat::CurrentV6 {
1446 pending_v3_blocks.push((block_id, payload));
1447 }
1448 Ok((rollback_patch_count, merge_baseline_divergence))
1449}
1450
1451/// DC-75: for a `Merge` block, independently re-derive whether the recorded
1452/// `merge_baseline_block_id` is a common ancestor of both parents — a claim, not trusted. Shape
1453/// (kind, parent count, mainline/baseline presence) is already guaranteed by
1454/// `verify_block_v2_state`'s `validate_block_v2_shape` call above, so this only checks the claim's
1455/// content. Cost is the same full-parent reachability walk measured linear in
1456/// `baseline-recording-answer-v1.md` §1 — unconditional, not a gated "deep verify" mode.
1457fn verify_merge_baseline(
1458 object_store: &impl ObjectReader,
1459 block_id: ObjectId,
1460 payload: &BlockPayload,
1461) -> Result<Option<MergeBaselineDivergence>> {
1462 if payload.kind != prikk_object::BlockKind::Merge {
1463 return Ok(None);
1464 }
1465 let (Some(mainline_parent_id), Some(recorded_baseline)) =
1466 (payload.mainline_parent_id, payload.merge_baseline_block_id)
1467 else {
1468 // Malformed shape already failed closed above via `validate_block_v2_shape`.
1469 return Ok(None);
1470 };
1471 let Some(&secondary_parent_id) = payload
1472 .parent_block_ids
1473 .iter()
1474 .find(|&&id| id != mainline_parent_id)
1475 else {
1476 return Ok(None);
1477 };
1478 let mainline_ancestors =
1479 crate::merge_evidence::ancestors_inclusive(object_store, mainline_parent_id)?;
1480 let secondary_ancestors =
1481 crate::merge_evidence::ancestors_inclusive(object_store, secondary_parent_id)?;
1482 let is_common_ancestor = mainline_ancestors.contains_key(&recorded_baseline)
1483 && secondary_ancestors.contains_key(&recorded_baseline);
1484 if is_common_ancestor {
1485 Ok(None)
1486 } else {
1487 Ok(Some(MergeBaselineDivergence {
1488 block_id,
1489 recorded_baseline,
1490 mainline_parent_id,
1491 secondary_parent_id,
1492 }))
1493 }
1494}
1495
1496fn ensure_object_exists(
1497 object_store: &impl ObjectReader,
1498 object_type: ObjectType,
1499 object_id: ObjectId,
1500 role: &str,
1501 owner: ObjectId,
1502) -> Result<()> {
1503 let exists = object_store.read_typed(object_id, object_type)?.is_some();
1504 if exists {
1505 return Ok(());
1506 }
1507 Err(PrikkError::Integrity(format!(
1508 "object {owner} references missing {role} {object_id}"
1509 )))
1510}
1511
1512fn verify_wal_persistence(
1513 object_store: &impl ObjectReader,
1514 records: &[crate::WalRecord],
1515) -> Result<usize> {
1516 let mut persisted = 0_usize;
1517 for record in records {
1518 if record.envelope.object_type != ObjectType::Patch {
1519 return Err(PrikkError::Integrity(format!(
1520 "active WAL record {} contains {}, expected patch",
1521 record.seq, record.envelope.object_type
1522 )));
1523 }
1524 // `FileObjectStore::contains_object`'s exact existing semantics, reproduced generically:
1525 // "does this exist, as this type" -- and, matching its own silent-on-error tolerance, any
1526 // read error here means "not found," not a propagated failure. `contains_object` itself is
1527 // inherent, not on `ObjectReader`, so a generic reader uses `read_object` directly instead.
1528 if object_store
1529 .read_object(record.envelope.object_id())
1530 .ok()
1531 .flatten()
1532 .is_some_and(|envelope| envelope.object_type == ObjectType::Patch)
1533 {
1534 persisted = persisted.checked_add(1).ok_or_else(|| {
1535 PrikkError::Integrity("persisted WAL patch count overflow".to_string())
1536 })?;
1537 }
1538 }
1539 Ok(persisted)
1540}
1541
1542#[cfg(test)]
1543mod tests;