Skip to main content

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