Skip to main content

autumn_web/
ledger.rs

1//! Bitemporal, tamper-evident record ledger for `#[repository]` writes (issue #1699).
2//!
3//! This ledger records the **history of a row**. For money, see
4//! [`crate::money::ledger`], which is a double-entry store with a balance
5//! invariant. The two are unrelated.
6//!
7//! Autumn already records raw change history (`crate::version_history`, #700),
8//! but a column-level diff log cannot be *queried as state*. This module
9//! promotes that history to a ledger: every write to an opted-in entity appends
10//! an immutable [`LedgerRevision`] carrying a **full row snapshot**, both time
11//! axes (valid time and transaction time), and a hash linking it to the previous
12//! revision of the same record.
13//!
14//! # Opting in
15//!
16//! ```rust,ignore
17//! #[repository(Invoice, soft_delete, ledgered = true)]
18//! pub trait InvoiceRepository {}
19//! ```
20//!
21//! That single marker is the only per-model change required. `ledgered = true`
22//! implies `versioned = true`, so every write path the version-history feature
23//! already covers — hand-written handlers, generated `api = "…"` endpoints,
24//! `#[job]`/`#[mailer]` paths, bulk saves, upserts, dependent cascades — appends
25//! a revision automatically.
26//!
27//! # Querying the past
28//!
29//! ```rust,ignore
30//! // Exact state at a past transaction instant.
31//! let then = repo.ledger_as_of(id, last_tuesday).await?;
32//!
33//! // Field-level delta between two instants.
34//! let delta = repo.ledger_diff(id, last_tuesday, now).await?;
35//!
36//! // Prove the stored history was never rewritten.
37//! let report = repo.ledger_verify(id).await?;
38//! assert!(report.is_intact());
39//! ```
40//!
41//! # Bitemporality
42//!
43//! Each revision carries two instants:
44//!
45//! * `recorded_at` — **transaction time**: when the database learned the fact.
46//!   Always set by the framework, from the *database's* clock at the point the
47//!   append has read the record's chain head, and clamped so it never precedes
48//!   the revision before it (#2323).
49//! * `valid_from` — **valid time**: when the fact became true in the business
50//!   domain. Defaults to `recorded_at`; a model can supply its own via
51//!   `ledgered(valid_time = "effective_at")` on the repository.
52//!
53//! A revision's valid interval is `[valid_from, next_revision.valid_from)` —
54//! derived at read time rather than stored, so no revision is ever updated after
55//! it is written and the hash chain stays append-only.
56//!
57//! # Threat model
58//!
59//! The chain is **tamper-evident**, not tamper-proof.
60//! [`verify_chain_with_high_water`](crate::ledger::verify_chain_with_high_water)
61//! — which the generated `ledger_verify` calls — detects any mutation,
62//! insertion, deletion, or reordering of stored revisions that does not also
63//! re-derive every subsequent hash, plus a truncated tail and any write that
64//! reached the table without appending a revision.
65//!
66//! (That link is written out in full because `lib.rs` puts an outer `///`
67//! comment on `pub mod ledger;`: rustdoc concatenates it with this header and
68//! resolves the whole block from the **crate root**, where only the re-exported
69//! *types* are in scope — no free function is, which is why the
70//! `monotonic_recorded_at` link below is written out too. A bare
71//! `[`verify_chain_with_high_water`]` fails
72//! `-D rustdoc::broken_intra_doc_links`.)
73//!
74//! Three checks sit on top of the chain walk, each seeing something the walk
75//! cannot:
76//!
77//! * the **live row**, compared against the head revision, catches a truncated
78//!   tail and any write that bypassed the ledger — but only in the window before
79//!   the next ordinary write lands;
80//! * the **high-water mark** ([`LedgerHighWater`], #2323), which lives outside
81//!   the deletable revision rows and never decreases, makes a post-truncation
82//!   append allocate past the deleted sequence number instead of re-using it. A
83//!   deleted revision therefore leaves a permanent gap rather than a window, and
84//!   a wholly erased chain stops looking like a row that predates ledgering. The
85//!   mark is cross-checked against the chain in both directions, so rolling it
86//!   back, rewriting it or deleting its row is itself reported;
87//! * **transaction time** is read from the database and clamped against the
88//!   chain's own floor (see
89//!   [`monotonic_recorded_at`](crate::ledger::monotonic_recorded_at)), so a
90//!   `recorded_at` that moves backwards along a chain is a break rather than a
91//!   plausible clock step.
92//!
93//! What none of them can see is a *consistent* rewrite: the hashing rule is open
94//! source, so an adversary with write access to the ledger tables can re-derive
95//! a whole chain, adjust the row to match, and re-establish the mark. The mark
96//! raises the bar — the same attacker now needs `DELETE` on a second table and
97//! has to keep it consistent — but it does not close the class.
98//!
99//! Nothing stored inside the same database closes that gap. Pin
100//! [`LedgerHead::hash`] somewhere the database cannot reach — an append-only
101//! object store, a notary, a second operator's inbox — and a rewritten or
102//! re-numbered chain disagrees with the pin. For an audit posture that pin is
103//! required, not optional.
104//!
105//! # Fidelity boundary
106//!
107//! A snapshot goes through the model's durable per-field codec, not
108//! `serde_json::to_value`, so it carries every column — `#[private]` and
109//! `#[encrypted]` fields included, which serde omits — and reconstruction is
110//! byte-for-byte identical to what a live query would have returned at that
111//! instant. Encrypted columns are stored as recoverable ciphertext, exactly as
112//! the durable commit-hook queue stores them, and come back decrypted.
113//!
114//! Three consequences worth knowing:
115//!
116//! * Declaring `#[version_history(sensitive = [...])]` columns on a ledgered
117//!   repository is a **compile error** — a redacted column could not be
118//!   reconstructed, so the fidelity guarantee would be unprovable.
119//! * `ledger_diff` compares the *reconstructed models*, not the stored bytes:
120//!   an encrypted column carries a fresh nonce per write, so raw snapshots would
121//!   report it as changed on every revision. A column the model hides from
122//!   serialization therefore does not appear in a diff, though it is fully
123//!   preserved in an as-of reconstruction and fully covered by the hash.
124//! * The live-row cross-check in `ledger_verify` compares the durable codec
125//!   projection of both sides, decrypted — so `#[private]` and `#[encrypted]`
126//!   columns are covered there too. Only a column whose key is gone entirely
127//!   drops out of that comparison.
128
129use chrono::{DateTime, SubsecRound, Utc};
130use serde::{Deserialize, Serialize};
131
132use crate::version_history::{ColumnChange, VersionOp};
133
134/// The ledger table every revision is appended to.
135pub const LEDGER_TABLE: &str = "_autumn_ledger_revisions";
136
137/// How far ahead of the database's own clock a chain's transaction-time floor
138/// may legitimately sit before [`monotonic_recorded_at`] refuses to write
139/// behind it.
140///
141/// One hour: comfortably past any real clock disagreement between a database
142/// host and its own past self (an NTP correction, a leap-second smear, a
143/// virtual-machine migration), and far short of the open-ended jump an
144/// out-of-band write to the high-water mark could otherwise ratchet a record's
145/// transaction time to.
146pub const LEDGER_MAX_CLOCK_SKEW_SECS: i64 = 3_600;
147
148/// The table holding each record's high-water mark (issue #2323).
149///
150/// A revision's sequence number is allocated from the greater of the chain head
151/// and this mark, plus one, and the mark lives *outside* the deletable revision
152/// rows — so deleting the newest revision and letting an ordinary write land no
153/// longer re-uses the deleted sequence number. See [`LedgerHighWater`].
154pub const LEDGER_HIGH_WATER_TABLE: &str = "_autumn_ledger_high_water";
155
156/// Domain-separation tag mixed into every revision hash.
157///
158/// Bumping this invalidates every previously computed hash, so it is part of
159/// the on-disk format: change it only alongside a migration that re-chains
160/// existing revisions.
161const HASH_DOMAIN: &str = "autumn.ledger.revision.v1";
162
163// ── Revision ─────────────────────────────────────────────────────────
164
165/// One immutable revision of a ledgered record.
166///
167/// Revisions are append-only and numbered per record: `seq` starts at 1 for the
168/// insert and increases by exactly one per subsequent write. There is no public
169/// API that updates or deletes a revision.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct LedgerRevision {
172    /// Auto-incrementing primary key in the ledger table.
173    pub id: i64,
174    /// Table name of the ledgered model.
175    pub table_name: String,
176    /// Tenant scope, for revisions written by tenant-scoped repositories.
177    pub tenant_id: Option<String>,
178    /// Primary key of the record this revision belongs to.
179    pub record_id: i64,
180    /// Position of this revision in the record's chain, starting at 1.
181    pub seq: i64,
182    /// The mutation that produced this revision.
183    pub op: VersionOp,
184    /// Authenticated user id, or `"system"` when no session is in scope.
185    pub actor: String,
186    /// Request / trace correlation id, when one was in scope.
187    pub request_id: Option<String>,
188    /// Full column values of the record *after* the mutation.
189    pub snapshot: serde_json::Value,
190    /// Valid time: when the fact became true in the business domain.
191    pub valid_from: DateTime<Utc>,
192    /// Transaction time: when the database learned the fact.
193    pub recorded_at: DateTime<Utc>,
194    /// Hash of the previous revision of this record; `None` for `seq == 1`.
195    pub prev_hash: Option<String>,
196    /// This revision's hash, over its own fields and `prev_hash`.
197    pub hash: String,
198}
199
200impl LedgerRevision {
201    /// Recompute this revision's hash from its stored fields.
202    ///
203    /// A stored [`hash`](Self::hash) that differs from this value means the row
204    /// was mutated out of band after it was written.
205    #[must_use]
206    pub fn compute_hash(&self) -> String {
207        revision_hash(&RevisionHashInput {
208            prev_hash: self.prev_hash.as_deref(),
209            table_name: &self.table_name,
210            tenant_id: self.tenant_id.as_deref(),
211            record_id: self.record_id,
212            seq: self.seq,
213            op: self.op,
214            actor: &self.actor,
215            request_id: self.request_id.as_deref(),
216            snapshot: &self.snapshot,
217            valid_from: self.valid_from,
218            recorded_at: self.recorded_at,
219        })
220    }
221}
222
223/// The exact field set a revision's hash covers.
224///
225/// Used both by the write path (which has the values before a row exists) and
226/// by [`LedgerRevision::compute_hash`] (which has them after reading one back),
227/// so the two can never drift.
228#[derive(Debug, Clone, Copy)]
229pub struct RevisionHashInput<'a> {
230    /// Hash of the previous revision, or `None` at the head of a chain.
231    pub prev_hash: Option<&'a str>,
232    /// Table name of the ledgered model.
233    pub table_name: &'a str,
234    /// Tenant scope, when tenant-scoped.
235    pub tenant_id: Option<&'a str>,
236    /// Primary key of the record.
237    pub record_id: i64,
238    /// Position in the record's chain, starting at 1.
239    pub seq: i64,
240    /// The mutation that produced the revision.
241    pub op: VersionOp,
242    /// Authenticated user id, or `"system"`.
243    pub actor: &'a str,
244    /// Request / trace correlation id.
245    pub request_id: Option<&'a str>,
246    /// Full column values after the mutation.
247    pub snapshot: &'a serde_json::Value,
248    /// Valid time.
249    pub valid_from: DateTime<Utc>,
250    /// Transaction time.
251    pub recorded_at: DateTime<Utc>,
252}
253
254// ── Canonicalization & hashing ───────────────────────────────────────
255
256/// Serialize `value` to JSON with every object's keys in sorted order.
257///
258/// `serde_json::to_string` preserves whatever key order the value carries, which
259/// depends on how it was built and on whether `serde_json`'s `preserve_order`
260/// feature is on somewhere in the dependency graph. Hashes must not depend on
261/// either, so the ledger canonicalizes first: objects are emitted with
262/// lexicographically sorted keys, arrays keep their order (order is data), and
263/// scalars use `serde_json`'s own compact encoding.
264#[must_use]
265pub fn canonical_json(value: &serde_json::Value) -> String {
266    let mut out = String::new();
267    write_canonical(value, &mut out);
268    out
269}
270
271fn write_canonical(value: &serde_json::Value, out: &mut String) {
272    match value {
273        serde_json::Value::Object(map) => {
274            let mut keys: Vec<&String> = map.keys().collect();
275            keys.sort_unstable();
276            out.push('{');
277            for (idx, key) in keys.into_iter().enumerate() {
278                if idx > 0 {
279                    out.push(',');
280                }
281                // A JSON string literal is exactly what `to_string` on a
282                // `Value::String` produces, escapes included.
283                out.push_str(&serde_json::Value::String(key.clone()).to_string());
284                out.push(':');
285                // `keys` came from `map`, so every lookup hits.
286                if let Some(child) = map.get(key) {
287                    write_canonical(child, out);
288                }
289            }
290            out.push('}');
291        }
292        serde_json::Value::Array(items) => {
293            out.push('[');
294            for (idx, item) in items.iter().enumerate() {
295                if idx > 0 {
296                    out.push(',');
297                }
298                write_canonical(item, out);
299            }
300            out.push(']');
301        }
302        other => out.push_str(&other.to_string()),
303    }
304}
305
306/// Truncate an instant to microsecond precision.
307///
308/// Both storage tiers keep microseconds: Postgres `TIMESTAMPTZ` truncates
309/// anything finer, and the `SQLite` text encoding round-trips six subsecond
310/// digits. Hashing a nanosecond-precision instant that the database then stores
311/// as microseconds would make every freshly written revision fail
312/// [`verify_chain`], so the write path truncates before it both binds and
313/// hashes.
314#[must_use]
315pub fn truncate_to_micros(at: DateTime<Utc>) -> DateTime<Utc> {
316    at.trunc_subsecs(6)
317}
318
319/// Compute a revision's hash.
320///
321/// The preimage is domain-separated and length-prefixed: each field is fed to
322/// the digest as its big-endian byte length followed by its bytes, so no two
323/// distinct field tuples can produce the same byte stream.
324#[must_use]
325pub fn revision_hash(input: &RevisionHashInput<'_>) -> String {
326    use sha2::{Digest, Sha256};
327
328    fn field(hasher: &mut Sha256, bytes: &[u8]) {
329        hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes());
330        hasher.update(bytes);
331    }
332    // Nullable fields carry a presence tag before their bytes. Without it a
333    // `NULL` and an empty string hash identically, so flipping a revision's
334    // `tenant_id` from NULL to `''` — which changes which tenant's chain the row
335    // belongs to — would leave every stored hash valid.
336    fn optional_field(hasher: &mut Sha256, value: Option<&str>) {
337        field(hasher, if value.is_some() { b"\x01" } else { b"\x00" });
338        field(hasher, value.unwrap_or("").as_bytes());
339    }
340
341    let mut hasher = Sha256::new();
342    let hasher = &mut hasher;
343    field(hasher, HASH_DOMAIN.as_bytes());
344    optional_field(hasher, input.prev_hash);
345    field(hasher, input.table_name.as_bytes());
346    optional_field(hasher, input.tenant_id);
347    field(hasher, input.record_id.to_string().as_bytes());
348    field(hasher, input.seq.to_string().as_bytes());
349    field(hasher, input.op.as_str().as_bytes());
350    field(hasher, input.actor.as_bytes());
351    optional_field(hasher, input.request_id);
352    field(hasher, format_instant(input.valid_from).as_bytes());
353    field(hasher, format_instant(input.recorded_at).as_bytes());
354    field(hasher, canonical_json(input.snapshot).as_bytes());
355
356    hex::encode(hasher.finalize_reset())
357}
358
359/// Render an instant in the fixed encoding the hash preimage uses.
360fn format_instant(at: DateTime<Utc>) -> String {
361    truncate_to_micros(at).to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
362}
363
364// ── Verification ─────────────────────────────────────────────────────
365
366/// How a chain was broken.
367///
368/// `#[non_exhaustive]` because new detection classes keep arriving as the ledger
369/// learns to see more — #2323 alone added four. Adding the attribute is itself a
370/// breaking change for an exhaustive downstream `match`, which is why it lands
371/// now, while the whole module is still unreleased, rather than after the next
372/// variant forces the question.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(rename_all = "snake_case")]
375#[non_exhaustive]
376pub enum LedgerBreak {
377    /// A revision's stored hash does not match its stored fields: the row was
378    /// mutated after it was written.
379    HashMismatch,
380    /// A revision's `prev_hash` does not match the previous revision's hash:
381    /// a revision was inserted, replaced, or re-chained.
382    PrevHashMismatch,
383    /// A sequence number is absent: a revision was deleted.
384    MissingRevision,
385    /// A sequence number repeats or moves backwards: a revision was inserted.
386    DuplicateSeq,
387    /// The chain does not start at `seq = 1` with a null `prev_hash`.
388    BrokenChainStart,
389    /// A sequence number cannot be followed (`seq` is at `i64::MAX`), so the
390    /// chain cannot be continued or checked past this point.
391    UnusableSeq,
392    /// The newest revision does not describe the row the table actually holds.
393    ///
394    /// The hash chain proves that the revisions *present* were not edited; it
395    /// cannot prove that no revision is *missing from the end*, because a
396    /// truncated chain is internally consistent. Cross-checking the head against
397    /// the live row closes that gap, and with it every write that reached the
398    /// table without appending a revision.
399    LiveStateMismatch,
400    /// A record's transaction time moves backwards along its chain.
401    ///
402    /// The write path sources `recorded_at` from the database and clamps it
403    /// against the predecessor's, so a regression cannot be produced by a
404    /// well-behaved writer on either tier — see [`monotonic_recorded_at`]. One
405    /// in stored data is a forgery, or a chain written by a pre-#2323 writer
406    /// across a host clock step.
407    RecordedAtRegression,
408    /// The record has revisions but no [high-water mark](LedgerHighWater).
409    ///
410    /// The mark is what makes a deleted tail survive the next append, so
411    /// deleting the mark row is the obvious way to restore that attack. Its
412    /// absence beside a live chain is therefore reported rather than tolerated:
413    /// the migration that creates the table backfills a mark for every chain
414    /// that already existed, so "revisions but no mark" is never a legitimate
415    /// state.
416    ///
417    /// Unlike [`HighWaterBehind`](Self::HighWaterBehind) this does **not** heal.
418    /// The write path refuses to append over a chain whose mark is gone rather
419    /// than re-create it, precisely so ordinary traffic cannot launder the
420    /// deletion away — so the record's writes fail until an operator has looked
421    /// and re-run the mark migration's backfill. That is the intended trade: a
422    /// loud stop rather than a quiet repair.
423    HighWaterMissing,
424    /// The high-water mark is behind the chain's newest revision.
425    ///
426    /// Either the mark was rolled back, or revisions were appended by something
427    /// that did not maintain it. Self-healing: the record's next ledgered write
428    /// re-establishes the mark above both.
429    HighWaterBehind,
430    /// The high-water mark reaches the chain's newest revision but does not
431    /// describe it — a different hash, or a different transaction time.
432    ///
433    /// Either the head revision was replaced by a re-hashed forgery, or the mark
434    /// itself was rewritten to match one.
435    HighWaterMismatch,
436    /// A revision's stored text is not its canonical form (#2326).
437    ///
438    /// The text may still mean the same thing. The hash covers the canonical
439    /// form, so a re-encoded snapshot would pass a hash check. Someone changed
440    /// the stored bytes.
441    SnapshotNotCanonical,
442}
443
444impl LedgerBreak {
445    /// Short, stable identifier for this break kind.
446    #[must_use]
447    pub const fn as_str(self) -> &'static str {
448        match self {
449            Self::HashMismatch => "hash_mismatch",
450            Self::PrevHashMismatch => "prev_hash_mismatch",
451            Self::MissingRevision => "missing_revision",
452            Self::DuplicateSeq => "duplicate_seq",
453            Self::BrokenChainStart => "broken_chain_start",
454            Self::UnusableSeq => "unusable_seq",
455            Self::LiveStateMismatch => "live_state_mismatch",
456            Self::RecordedAtRegression => "recorded_at_regression",
457            Self::SnapshotNotCanonical => "snapshot_not_canonical",
458            Self::HighWaterMissing => "high_water_missing",
459            Self::HighWaterBehind => "high_water_behind",
460            Self::HighWaterMismatch => "high_water_mismatch",
461        }
462    }
463}
464
465impl std::fmt::Display for LedgerBreak {
466    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467        f.write_str(self.as_str())
468    }
469}
470
471/// The first broken link found in a record's chain.
472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
473pub struct LedgerBreakReport {
474    /// Sequence number the break was found at. For [`LedgerBreak::MissingRevision`]
475    /// this is the *absent* sequence number, not the row that exposed the gap.
476    pub seq: i64,
477    /// Primary key of the offending ledger row, when one exists. `None` for a
478    /// deleted revision.
479    pub revision_id: Option<i64>,
480    /// What kind of break this is.
481    pub kind: LedgerBreak,
482    /// Human-readable explanation, safe to surface to an operator.
483    pub detail: String,
484}
485
486/// Result of verifying one record's revision chain.
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488pub struct LedgerVerification {
489    /// The record whose chain was verified.
490    pub record_id: i64,
491    /// How many stored revisions were examined.
492    pub revisions_checked: usize,
493    /// Hash of the last revision, when the chain is intact and non-empty.
494    pub head_hash: Option<String>,
495    /// The first broken link, or `None` when the chain is intact.
496    pub broken: Option<LedgerBreakReport>,
497}
498
499impl LedgerVerification {
500    /// Whether the chain verified with no broken link.
501    #[must_use]
502    pub const fn is_intact(&self) -> bool {
503        self.broken.is_none()
504    }
505}
506
507/// The head of a record's chain, for pinning outside the database.
508///
509/// Recording `hash` somewhere the application database cannot reach turns the
510/// tamper-*evidence* this module provides into tamper-*detection* even against
511/// an adversary who can rewrite whole chains — a rewritten chain produces a
512/// different head hash than the one that was pinned.
513#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514pub struct LedgerHead {
515    /// The record this head belongs to.
516    pub record_id: i64,
517    /// Sequence number of the newest revision.
518    pub seq: i64,
519    /// Hash of the newest revision.
520    pub hash: String,
521    /// Transaction time of the newest revision.
522    pub recorded_at: DateTime<Utc>,
523}
524
525/// A record's high-water mark: the highest sequence number its chain has ever
526/// reached, kept outside the revision rows (issue #2323).
527///
528/// A revision's sequence number used to be allocated purely from the rows that
529/// survive in `_autumn_ledger_revisions`. Delete the newest revision and let an
530/// *ordinary* application write land: the append reads `N-1`, re-allocates `N`,
531/// chains onto `N-1`'s hash and matches the live row, so the chain walk and the
532/// live-row cross-check both report intact. An attacker who deletes and then
533/// waits for ordinary traffic closes the detection window themselves.
534///
535/// The mark closes that. It lives in [`LEDGER_HIGH_WATER_TABLE`], is never
536/// decreased (the writer's upsert refuses to lower it), and the write path
537/// allocates one past the greater of the two — so the same attack allocates `N+1`
538/// and leaves a permanent gap at `N` that [`verify_chain_with_high_water`]
539/// reports as [`LedgerBreak::MissingRevision`].
540///
541/// It is never *authoritative*, only cross-checked. Verification compares it
542/// with the chain in both directions and reports either side disagreeing, so
543/// rolling the mark back ([`LedgerBreak::HighWaterBehind`]), rewriting it
544/// ([`LedgerBreak::HighWaterMismatch`]) or deleting its row
545/// ([`LedgerBreak::HighWaterMissing`]) is itself an accusation. What it does not
546/// do is close the class: an adversary with `DELETE` on the revisions table
547/// usually has it on this one too, and rewriting both consistently is still
548/// possible. Pinning [`LedgerHead::hash`] outside the database remains required
549/// for an audit posture.
550///
551/// `recorded_at` doubles as the floor the next revision's transaction time is
552/// clamped against, which is what makes transaction time non-decreasing along a
553/// chain even across the gap a deleted revision leaves.
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
555pub struct LedgerHighWater {
556    /// The record this mark belongs to.
557    pub record_id: i64,
558    /// Highest sequence number ever allocated for the record.
559    pub seq: i64,
560    /// Hash of the revision that was written at [`seq`](Self::seq).
561    pub hash: String,
562    /// Transaction time of the revision that was written at
563    /// [`seq`](Self::seq).
564    pub recorded_at: DateTime<Utc>,
565}
566
567/// A record's chain head and high-water mark, read together (issue #2323).
568///
569/// The two are what an audit posture pins outside the database, and they are
570/// only meaningful as a pair: the head hash is what a rewritten chain
571/// disagrees with, and the mark is what proves no revision is missing from the
572/// end. Reading them with two separate calls lets an ordinary append land in
573/// between, so an auditor can hold a head at sequence `N` beside a mark at
574/// `N+1` and read a concurrent write as a truncation. This type exists so the
575/// pair always comes from one statement, and one snapshot.
576///
577/// Either field is `None` for a record that has neither — one never written,
578/// or one predating the day its model was ledgered. A `high_water` of `None`
579/// beside a `Some` head is the state
580/// [`LedgerBreak::HighWaterMissing`] reports; this type does not judge, it
581/// reports what is stored.
582#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
583pub struct LedgerPin {
584    /// The newest revision's sequence number and hash.
585    pub head: Option<LedgerHead>,
586    /// The out-of-band high-water mark.
587    pub high_water: Option<LedgerHighWater>,
588}
589
590/// What a verification knows about a record's
591/// [high-water mark](LedgerHighWater).
592///
593/// Distinguishes "the mark row is gone" from "no mark was read", exactly as
594/// [`LedgerLiveState`] distinguishes a missing row from an unread one. The
595/// difference matters: an absent mark beside a live chain is an accusation
596/// ([`LedgerBreak::HighWaterMissing`]), while an unread one must be silent — the
597/// pre-#2323 [`verify_chain_against`] entry point, and the case where a
598/// concurrent write moved the chain head under the reader, both pass
599/// [`NotChecked`](Self::NotChecked).
600///
601/// The lifetime is deliberate: [`Present`](Self::Present) borrows rather than
602/// owning so verification, which runs on a schedule over whole tables, does not
603/// clone a hash per record. Do not "simplify" it to an owned mark.
604#[derive(Debug, Clone, Copy, PartialEq, Eq)]
605pub enum LedgerHighWaterState<'a> {
606    /// The mark was not read, or moved under the reader, so no cross-check is
607    /// performed. Chain-internal breaks are still reported.
608    NotChecked,
609    /// The record has no mark row. Beside a non-empty chain this is itself a
610    /// break: the migration backfills a mark for every chain that already
611    /// existed, so the only way to reach this state is to delete one.
612    Absent,
613    /// The record's mark, as stored.
614    Present(&'a LedgerHighWater),
615}
616
617/// How the live table row compares to the chain's head revision.
618///
619/// A hash chain proves the revisions that are *present* were not edited. It
620/// cannot prove that none is missing from the *end*: lopping the last two
621/// revisions off leaves a chain that verifies perfectly. Nor can it see a write
622/// that reached the table without appending a revision at all. Comparing the
623/// head revision against the row the table actually holds closes both gaps, so
624/// [`verify_chain_against`] takes the outcome of that comparison as an input.
625///
626/// The comparison itself is the caller's, not this module's: deciding which
627/// columns are even comparable needs per-table knowledge this layer does not
628/// have. The generated `ledger_verify` encodes the head revision and the live
629/// row through the *same* projection — the model's durable per-field codec,
630/// which carries `#[private]` and `#[encrypted]` columns that the model's public
631/// JSON omits — and decrypts both before comparing. Raw ciphertext is never
632/// comparable across two encodings (a fresh nonce per write in randomized mode;
633/// a re-encryption under the new key after a rotation), but the plaintext
634/// underneath is, so a revision whose only change was to an encrypted column
635/// stays visible. Only a column whose key is gone entirely drops out, from both
636/// sides, and the revision hash still covers it.
637#[derive(Debug, Clone, Copy, PartialEq, Eq)]
638pub enum LedgerLiveState {
639    /// The live row was not read, or moved under the reader, so no cross-check
640    /// is performed. Chain-internal breaks are still reported.
641    NotChecked,
642    /// The record has no row in the table.
643    Absent,
644    /// The live row is the one the head revision describes.
645    Matches,
646    /// The live row is not the one the head revision describes.
647    Diverged,
648}
649
650/// Verify one record's revision chain and report the first broken link.
651///
652/// Equivalent to [`verify_chain_with_high_water`] with no live row and no
653/// high-water mark: it proves the stored revisions were not edited, inserted
654/// into, or deleted from the middle, and that their transaction time never moves
655/// backwards, but cannot see a truncated tail or a write that never appended a
656/// revision. The generated `ledger_verify` reads both and calls
657/// [`verify_chain_with_high_water`] instead.
658#[must_use]
659pub fn verify_chain(record_id: i64, revisions: &[LedgerRevision]) -> LedgerVerification {
660    verify_chain_against(record_id, revisions, LedgerLiveState::NotChecked)
661}
662
663/// Verify one record's revision chain against the row the table holds, and
664/// report the first broken link.
665///
666/// `revisions` must be the record's revisions in ascending `seq` order, as the
667/// generated `ledger_verify` reads them. Detects, in `seq` order:
668///
669/// * a chain that does not start at `seq = 1` with a null `prev_hash`
670///   ([`LedgerBreak::BrokenChainStart`], or [`LedgerBreak::MissingRevision`]
671///   when the head revisions were deleted);
672/// * a gap in the sequence ([`LedgerBreak::MissingRevision`]);
673/// * a repeated or backwards sequence number ([`LedgerBreak::DuplicateSeq`]);
674/// * a row whose contents no longer hash to its stored hash
675///   ([`LedgerBreak::HashMismatch`]);
676/// * a row whose `prev_hash` no longer matches its predecessor
677///   ([`LedgerBreak::PrevHashMismatch`]);
678/// * a `seq` that cannot be followed ([`LedgerBreak::UnusableSeq`]);
679///
680/// and finally, when `live` is not [`LedgerLiveState::NotChecked`]:
681///
682/// * a head revision that does not describe the live row
683///   ([`LedgerBreak::LiveStateMismatch`]) — a truncated tail, a row erased out
684///   of band, or a write that reached the table without appending a revision.
685///
686/// An empty slice always verifies as intact, live row or not: a record may never
687/// have been written, or may predate the day its model was ledgered (the
688/// migration guide says ledgering is not retroactive). `revisions_checked`
689/// reports the emptiness. [`verify_chain_with_high_water`] is what tells that
690/// apart from a *wholly erased* chain, whose mark outlives its rows.
691#[must_use]
692pub fn verify_chain_against(
693    record_id: i64,
694    revisions: &[LedgerRevision],
695    live: LedgerLiveState,
696) -> LedgerVerification {
697    verify_chain_with_high_water(record_id, revisions, live, LedgerHighWaterState::NotChecked)
698}
699
700/// Verify one record's revision chain against the row the table holds **and**
701/// its out-of-band [high-water mark](LedgerHighWater), and report the first
702/// broken link (issue #2323).
703///
704/// The superset of [`verify_chain_against`], which is this function with
705/// `high_water = None`. The generated `ledger_verify` reads the mark and calls
706/// this.
707///
708/// Breaks are reported in order of how specific they are:
709///
710/// 1. **chain-internal** — everything [`verify_chain_against`] lists;
711/// 2. **high-water** — the mark and the chain disagree:
712///    * the mark reaches past the newest surviving revision: the tail was
713///      deleted, and the sequence numbers between them are gone
714///      ([`LedgerBreak::MissingRevision`], reported at the lowest absent one);
715///    * the chain reaches past the mark ([`LedgerBreak::HighWaterBehind`]);
716///    * they agree on the sequence number but not on the revision it names
717///      ([`LedgerBreak::HighWaterMismatch`]);
718///    * the chain is non-empty and there is no mark at all
719///      ([`LedgerBreak::HighWaterMissing`]);
720/// 3. **live-state** — the head revision does not describe the live row
721///    ([`LedgerBreak::LiveStateMismatch`]);
722/// 4. **transaction time** — it moves backwards along the chain
723///    ([`LedgerBreak::RecordedAtRegression`]). Ranked last on purpose; see
724///    ranked last on purpose, so a chain written before #2323 across a host
725///    clock step cannot permanently mask a truncation on the same record.
726///
727/// A truncated tail trips both (2) and (3); (2) is reported because it names the
728/// sequence number that is gone, which the live-row comparison cannot.
729///
730/// An empty chain with no mark still verifies as intact: that is what every
731/// existing row looks like on the day a team adopts `ledgered`, and ledgering is
732/// not retroactive. An empty chain *with* a mark does not — the mark proves the
733/// chain existed, so its erasure is reported. The migration that creates the
734/// mark table backfills one for every chain that already existed, so a chain
735/// written before #2323 is covered too.
736#[must_use]
737pub fn verify_chain_with_high_water(
738    record_id: i64,
739    revisions: &[LedgerRevision],
740    live: LedgerLiveState,
741    high_water: LedgerHighWaterState<'_>,
742) -> LedgerVerification {
743    let mut verifier = LedgerChainVerifier::new(record_id);
744    for revision in revisions {
745        verifier.push(revision);
746    }
747    verifier.finish(live, high_water)
748}
749
750/// The fields of a revision that verification keeps after it moves on.
751#[derive(Debug, Clone, PartialEq, Eq)]
752struct ChainTip {
753    id: i64,
754    seq: i64,
755    hash: String,
756    recorded_at: DateTime<Utc>,
757}
758
759impl ChainTip {
760    fn of(revision: &LedgerRevision) -> Self {
761        Self {
762            id: revision.id,
763            seq: revision.seq,
764            hash: revision.hash.clone(),
765            recorded_at: revision.recorded_at,
766        }
767    }
768}
769
770/// Verify a chain one revision at a time (#2319).
771///
772/// Gives the same report as [`verify_chain_with_high_water`]. It keeps only
773/// the id, `seq`, hash and transaction time of the last revision, so a long
774/// chain can be read in pages. Push the revisions in `(seq, id)` order. Then
775/// call [`finish`](Self::finish).
776#[derive(Debug, Clone)]
777pub struct LedgerChainVerifier {
778    record_id: i64,
779    checked: usize,
780    expected_seq: i64,
781    head: Option<ChainTip>,
782    chain_break: Option<LedgerBreakReport>,
783    time_break: Option<LedgerBreakReport>,
784}
785
786impl LedgerChainVerifier {
787    /// Start verifying `record_id`'s chain.
788    #[must_use]
789    pub const fn new(record_id: i64) -> Self {
790        Self {
791            record_id,
792            checked: 0,
793            expected_seq: 1,
794            head: None,
795            chain_break: None,
796            time_break: None,
797        }
798    }
799
800    /// Check the next revision of the chain.
801    pub fn push(&mut self, revision: &LedgerRevision) {
802        self.checked += 1;
803        if self.chain_break.is_none() {
804            self.chain_break = self.structural_break(revision);
805        }
806        if self.time_break.is_none() {
807            self.time_break = self
808                .head
809                .as_ref()
810                .and_then(|previous| recorded_at_break(previous, revision));
811        }
812        self.head = Some(ChainTip::of(revision));
813    }
814
815    /// Finish the walk and report the first broken link.
816    ///
817    /// See [`verify_chain_with_high_water`] for the order of the checks.
818    #[must_use]
819    pub fn finish(
820        self,
821        live: LedgerLiveState,
822        high_water: LedgerHighWaterState<'_>,
823    ) -> LedgerVerification {
824        let Self {
825            record_id,
826            checked,
827            head,
828            chain_break,
829            time_break,
830            ..
831        } = self;
832        let broken = chain_break
833            .or_else(|| high_water_break(head.as_ref(), high_water))
834            .or_else(|| live_state_break(head.as_ref(), live))
835            .or(time_break);
836        let head_hash = if broken.is_none() {
837            head.map(|tip| tip.hash)
838        } else {
839            None
840        };
841        LedgerVerification {
842            record_id,
843            revisions_checked: checked,
844            head_hash,
845            broken,
846        }
847    }
848
849    /// The first sequence or link break at `revision`, if any.
850    fn structural_break(&mut self, revision: &LedgerRevision) -> Option<LedgerBreakReport> {
851        if revision.seq != self.expected_seq {
852            return Some(sequence_break(
853                revision,
854                self.expected_seq,
855                self.head.is_none(),
856            ));
857        }
858        if let Some(report) = link_break(revision, self.head.as_ref()) {
859            return Some(report);
860        }
861        let Some(next_seq) = revision.seq.checked_add(1) else {
862            // Saturating here would make two consecutive `i64::MAX` rows look
863            // contiguous, hiding an inserted revision behind an overflow.
864            return Some(LedgerBreakReport {
865                seq: revision.seq,
866                revision_id: Some(revision.id),
867                kind: LedgerBreak::UnusableSeq,
868                detail: "sequence number is at i64::MAX and cannot be followed; \
869                         the chain cannot be continued or checked past this revision"
870                    .to_owned(),
871            });
872        };
873        self.expected_seq = next_seq;
874        None
875    }
876}
877
878/// Cross-check the chain against its out-of-band high-water mark.
879///
880/// Runs after the chain itself verifies, so the chain side of every comparison
881/// here is known-consistent and a disagreement is always about what is *missing*
882/// from the end, or about the mark itself.
883///
884/// Neither source is trusted over the other: a mark ahead of the chain accuses
885/// the chain, a mark behind it accuses the mark, and a mark that names a
886/// different revision at the same sequence number accuses both without guessing
887/// which. That is what keeps the mark from being a second thing to forge
888/// silently.
889fn high_water_break(
890    head: Option<&ChainTip>,
891    high_water: LedgerHighWaterState<'_>,
892) -> Option<LedgerBreakReport> {
893    let high_water = match high_water {
894        // Nothing was read, so there is nothing to disagree with.
895        LedgerHighWaterState::NotChecked => return None,
896        LedgerHighWaterState::Absent => None,
897        LedgerHighWaterState::Present(mark) => Some(mark),
898    };
899    match (head, high_water) {
900        // No chain and no mark: a record that was never written, or one that
901        // predates the day its model was ledgered. Not an accusation — see
902        // `live_state_break` for why that false positive matters.
903        (None, None) => None,
904        // A mark with no chain at all. The mark is proof the chain existed, so
905        // this is the wholesale erasure that was previously indistinguishable
906        // from the case above.
907        (None, Some(mark)) => Some(LedgerBreakReport {
908            seq: 1,
909            revision_id: None,
910            kind: LedgerBreak::MissingRevision,
911            detail: format!(
912                "the high-water mark reaches revision {} but the record has no \
913                 revisions at all; the whole chain was erased",
914                mark.seq
915            ),
916        }),
917        (Some(head), None) => Some(LedgerBreakReport {
918            seq: head.seq,
919            revision_id: Some(head.id),
920            kind: LedgerBreak::HighWaterMissing,
921            detail: format!(
922                "the chain reaches revision {} but the record has no high-water \
923                 mark; the mark that would expose a deleted tail was itself removed",
924                head.seq
925            ),
926        }),
927        (Some(head), Some(mark)) => head_versus_mark(head, mark),
928    }
929}
930
931/// Compare a non-empty chain's head revision with its mark.
932fn head_versus_mark(head: &ChainTip, mark: &LedgerHighWater) -> Option<LedgerBreakReport> {
933    if mark.seq > head.seq {
934        // Saturating is safe: `structural_break` already refused a chain whose head
935        // sits at `i64::MAX`, so `head.seq + 1` cannot wrap here.
936        let absent = head.seq.saturating_add(1);
937        return Some(LedgerBreakReport {
938            seq: absent,
939            revision_id: None,
940            kind: LedgerBreak::MissingRevision,
941            detail: format!(
942                "the high-water mark reaches revision {} but the chain stops at {}; \
943                 revision {absent} onwards was deleted from the end",
944                mark.seq, head.seq
945            ),
946        });
947    }
948    if mark.seq < head.seq {
949        return Some(LedgerBreakReport {
950            seq: head.seq,
951            revision_id: Some(head.id),
952            kind: LedgerBreak::HighWaterBehind,
953            detail: format!(
954                "the chain reaches revision {} but its high-water mark stops at {}; \
955                 the mark was rolled back, or revisions were appended without it",
956                head.seq, mark.seq
957            ),
958        });
959    }
960    if mark.hash != head.hash || mark.recorded_at != head.recorded_at {
961        return Some(LedgerBreakReport {
962            seq: head.seq,
963            revision_id: Some(head.id),
964            kind: LedgerBreak::HighWaterMismatch,
965            detail: format!(
966                "the high-water mark names revision {} but does not describe the \
967                 revision stored there; the head revision was replaced, or the mark \
968                 was rewritten to match a replacement",
969                head.seq
970            ),
971        });
972    }
973    None
974}
975
976/// Cross-check the head revision against the live row.
977///
978/// Runs only after the chain itself verifies, so a mismatch here always means
979/// the *end* of the history is missing rather than its middle being edited.
980fn live_state_break(head: Option<&ChainTip>, live: LedgerLiveState) -> Option<LedgerBreakReport> {
981    // An empty chain is never a break, whatever the live row says.
982    //
983    // A record may simply never have been written, or may predate the day its model was
984    // ledgered. Ledgering is non-destructive but not retroactive, so rows written before
985    // the marker went on legitimately have no chain until their first subsequent write:
986    // that is the documented, expected state of every existing row on the day a team
987    // adopts the feature, and accusing it would put a false positive in front of every
988    // such deployment, against the one metric this module is held to.
989    //
990    // A wholly erased chain used to be indistinguishable from a pre-ledgering row from
991    // inside the database. `high_water_break` tells them apart now (#2323): the mark lives
992    // outside the deletable rows and survives the erasure, so a chain with a mark and no
993    // revisions is reported while one with neither stays silent. `revisions_checked == 0`
994    // on the report is still what makes the empty case visible to a caller that cares.
995    let head = head?;
996
997    match live {
998        LedgerLiveState::NotChecked | LedgerLiveState::Matches => None,
999        LedgerLiveState::Absent => Some(LedgerBreakReport {
1000            seq: head.seq,
1001            revision_id: Some(head.id),
1002            kind: LedgerBreak::LiveStateMismatch,
1003            detail: format!(
1004                "the ledger's newest revision is {}, but the record no longer \
1005                 exists in the table; the row was erased outside the repository",
1006                head.seq
1007            ),
1008        }),
1009        LedgerLiveState::Diverged => Some(LedgerBreakReport {
1010            seq: head.seq,
1011            revision_id: Some(head.id),
1012            kind: LedgerBreak::LiveStateMismatch,
1013            detail: format!(
1014                "revision {} is the newest in the ledger but does not describe \
1015                 the row the table holds; either the tail of the history was \
1016                 deleted or a write reached the table without appending a revision",
1017                head.seq
1018            ),
1019        }),
1020    }
1021}
1022
1023/// Report a step where transaction time moves backwards between two neighbours.
1024///
1025/// Reported **last**, after the structural, high-water and live-state checks,
1026/// and that ordering is deliberate. A chain written by a pre-#2323 writer — from
1027/// the host's own `Utc::now()`, unclamped — can carry a legitimate regression
1028/// across ordinary NTP skew between two application nodes, and those rows are
1029/// immutable, so the report never goes away. Ranked any earlier it would
1030/// permanently *mask* a truncation or a live-row divergence on the same record,
1031/// blinding the very checks it ships beside. Ranked last it is what the report
1032/// says when nothing else is wrong.
1033///
1034/// Valid time is deliberately not checked: a back-dated correction is the whole
1035/// point of the second axis.
1036fn recorded_at_break(previous: &ChainTip, revision: &LedgerRevision) -> Option<LedgerBreakReport> {
1037    (revision.recorded_at < previous.recorded_at).then(|| LedgerBreakReport {
1038        seq: revision.seq,
1039        revision_id: Some(revision.id),
1040        kind: LedgerBreak::RecordedAtRegression,
1041        detail: format!(
1042            "revision {} was recorded before revision {}, which precedes it in the \
1043             chain; transaction time cannot move backwards along a chain a post-#2323 \
1044             writer produced",
1045            revision.seq, previous.seq
1046        ),
1047    })
1048}
1049
1050/// Classify a sequence number that is not the one the chain expected.
1051fn sequence_break(
1052    revision: &LedgerRevision,
1053    expected_seq: i64,
1054    at_chain_start: bool,
1055) -> LedgerBreakReport {
1056    if revision.seq < expected_seq {
1057        return LedgerBreakReport {
1058            seq: revision.seq,
1059            revision_id: Some(revision.id),
1060            kind: LedgerBreak::DuplicateSeq,
1061            detail: format!(
1062                "revision {} repeats or precedes sequence {expected_seq}; \
1063                 a revision was inserted into the chain",
1064                revision.seq
1065            ),
1066        };
1067    }
1068    let detail = if at_chain_start {
1069        format!(
1070            "chain starts at sequence {} instead of 1; revision {expected_seq} was deleted",
1071            revision.seq
1072        )
1073    } else {
1074        format!(
1075            "sequence {expected_seq} is absent between {} and {}; a revision was deleted",
1076            expected_seq.saturating_sub(1),
1077            revision.seq
1078        )
1079    };
1080    LedgerBreakReport {
1081        seq: expected_seq,
1082        revision_id: None,
1083        kind: LedgerBreak::MissingRevision,
1084        detail,
1085    }
1086}
1087
1088/// Check one revision's own hash and its link to `prev`.
1089fn link_break(revision: &LedgerRevision, prev: Option<&ChainTip>) -> Option<LedgerBreakReport> {
1090    if revision.compute_hash() != revision.hash {
1091        return Some(LedgerBreakReport {
1092            seq: revision.seq,
1093            revision_id: Some(revision.id),
1094            kind: LedgerBreak::HashMismatch,
1095            detail: format!(
1096                "revision {} no longer hashes to its stored digest; \
1097                 the stored revision was mutated out of band",
1098                revision.seq
1099            ),
1100        });
1101    }
1102
1103    match prev {
1104        None => {
1105            if revision.prev_hash.is_some() {
1106                return Some(LedgerBreakReport {
1107                    seq: revision.seq,
1108                    revision_id: Some(revision.id),
1109                    kind: LedgerBreak::BrokenChainStart,
1110                    detail: "the first revision carries a prev_hash; \
1111                             the revision it chains to is missing"
1112                        .to_owned(),
1113                });
1114            }
1115        }
1116        Some(previous) => {
1117            if revision.prev_hash.as_deref() != Some(previous.hash.as_str()) {
1118                return Some(LedgerBreakReport {
1119                    seq: revision.seq,
1120                    revision_id: Some(revision.id),
1121                    kind: LedgerBreak::PrevHashMismatch,
1122                    detail: format!(
1123                        "revision {}'s prev_hash does not match revision {}'s hash; \
1124                         the chain was re-linked",
1125                        revision.seq, previous.seq
1126                    ),
1127                });
1128            }
1129        }
1130    }
1131    None
1132}
1133
1134/// The transaction time a revision must carry, given the database's own clock
1135/// read and the chain's current floor (issue #2323).
1136///
1137/// `recorded_at` used to be `Utc::now()` on the writing node. Across a
1138/// multi-node deployment with clock skew, or after a host clock adjustment, a
1139/// later sequence could carry an earlier instant — and `snapshot_as_of` filters
1140/// on `recorded_at` before taking the greatest surviving `seq`, so an as-of
1141/// query could return a revision that was not yet current at the instant asked
1142/// about. Verification never noticed: it walks the chain in `seq` order.
1143///
1144/// Two things fix that together. The write path now reads `db_now` from the
1145/// database (`clock_timestamp()` on Postgres, `strftime(…, 'now')` on `SQLite`)
1146/// at the point the append has already read the record's chain head — so the
1147/// instant reflects database ordering rather than a host clock. And this
1148/// function clamps it against `floor`, the greater of the predecessor
1149/// revision's `recorded_at` and the high-water mark's, which makes transaction
1150/// time non-decreasing along a chain *by construction* rather than by
1151/// assumption — including across the gap a deleted revision leaves, since the
1152/// mark outlives the row.
1153///
1154/// Both sides are truncated to microseconds first, the precision both storage
1155/// tiers keep, so the value that enters the hash preimage is exactly the value
1156/// the database stores.
1157///
1158/// The clamp is **bounded**, and that bound is load-bearing. An unbounded
1159/// `max` would be a one-way ratchet driven by whatever `floor` says: a single
1160/// out-of-band `UPDATE` pushing a record's floor to the year 2999 would make
1161/// every subsequent revision carry — and *hash* — an instant in the far future,
1162/// which `verify` could not object to (the hash would be correct) while
1163/// [`snapshot_as_of`] silently stopped returning any of them. So a floor more
1164/// than [`LEDGER_MAX_CLOCK_SKEW_SECS`] ahead of the database's own clock is
1165/// refused rather than honoured: this returns `None` and the write path turns
1166/// that into a [`LedgerError::ChainUnreadable`]. Refusing writes something
1167/// nowhere; honouring it would hash an attacker's instant into the chain and
1168/// destroy the record's transaction-time history irreversibly.
1169///
1170/// Non-decreasing, deliberately, and **not** strictly increasing. Two revisions
1171/// of one record written inside a single clock tick — likely on `SQLite`, whose
1172/// `'now'` has millisecond resolution — share a `recorded_at`, and
1173/// [`snapshot_as_of`] resolves that by sequence number: an as-of query at that
1174/// instant returns the *last* revision recorded within it, which is what the
1175/// database held at the end of the instant asked about. That is the ordinary
1176/// semantics of a timestamp with finite precision, and no acceptance criterion
1177/// is bent by it: a revision sharing instant `t` was committed *at* `t`, not
1178/// after it.
1179///
1180/// Advancing past the floor by a tick instead would buy an unambiguous ordering
1181/// at two real costs. It would synthesise an instant the database's clock never
1182/// produced — the opposite of sourcing transaction time from the database at
1183/// all, which is the point of #2323's second half. And it would re-introduce a
1184/// forward ratchet: a record written faster than its clock resolution would
1185/// drift ahead of `db_now` indefinitely, until [`LEDGER_MAX_CLOCK_SKEW_SECS`]
1186/// refused its writes. A coarse honest instant beats a precise invented one in
1187/// a ledger whose whole claim is fidelity.
1188///
1189/// What this does not promise: `db_now` is read before the transaction commits,
1190/// so a revision can still become *visible* slightly after the instant it
1191/// carries. Closing that would need a commit timestamp, which cannot be read
1192/// before the insert that has to hash it.
1193#[must_use]
1194pub fn monotonic_recorded_at(
1195    db_now: DateTime<Utc>,
1196    floor: Option<DateTime<Utc>>,
1197) -> Option<DateTime<Utc>> {
1198    let now = truncate_to_micros(db_now);
1199    let Some(floor) = floor.map(truncate_to_micros) else {
1200        return Some(now);
1201    };
1202    if floor > now + chrono::TimeDelta::seconds(LEDGER_MAX_CLOCK_SKEW_SECS) {
1203        return None;
1204    }
1205    Some(now.max(floor))
1206}
1207
1208/// Parse the instant the `SQLite` arm of the write path reads from the database
1209/// clock.
1210///
1211/// `SQLite` has no `clock_timestamp()`; the write path reads
1212/// `strftime('%Y-%m-%d %H:%M:%f', 'now')`, which renders UTC to millisecond
1213/// precision (`2026-09-01 18:08:53.597`). `SQLite` documents that every `'now'`
1214/// inside one `sqlite3_step` sees the same value, so the read is coherent with
1215/// the chain-head read it rides along with.
1216///
1217/// Parsed here rather than bound through `TimestamptzSqlite` so the accepted
1218/// encoding is pinned by a test in this crate instead of by whichever format
1219/// list Diesel's `SQLite` timestamp decoder happens to carry.
1220///
1221/// Returns `None` for anything that is not that encoding, which the write path
1222/// turns into a [`LedgerError::ChainUnreadable`] rather than falling back to a
1223/// host clock: a ledgered write that cannot establish its transaction time must
1224/// not proceed.
1225///
1226/// `pub` only so the generated write path can call it, and `#[doc(hidden)]`
1227/// accordingly — the same convention as [`LedgerValidTimeValue`].
1228#[doc(hidden)]
1229#[must_use]
1230pub fn parse_sqlite_instant(raw: &str) -> Option<DateTime<Utc>> {
1231    chrono::NaiveDateTime::parse_from_str(raw.trim(), "%Y-%m-%d %H:%M:%S%.f")
1232        .ok()
1233        .map(|naive| naive.and_utc())
1234}
1235
1236// ── As-of reconstruction ─────────────────────────────────────────────
1237
1238/// The instants an as-of query resolves against.
1239///
1240/// Both axes are optional. `transaction` restricts to what the database knew at
1241/// that instant; `valid` restricts to what was true in the business domain at
1242/// that instant. `LedgerAsOf::default()` — both `None` — selects the newest
1243/// revision by sequence, which is the live state.
1244#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1245pub struct LedgerAsOf {
1246    /// Transaction-time bound: ignore revisions recorded after this instant.
1247    pub transaction: Option<DateTime<Utc>>,
1248    /// Valid-time bound: ignore revisions that became true after this instant.
1249    pub valid: Option<DateTime<Utc>>,
1250}
1251
1252impl LedgerAsOf {
1253    /// An as-of query on transaction time alone — "what did the database hold
1254    /// at this instant".
1255    #[must_use]
1256    pub const fn transaction(at: DateTime<Utc>) -> Self {
1257        Self {
1258            transaction: Some(at),
1259            valid: None,
1260        }
1261    }
1262
1263    /// An as-of query on valid time alone — "what was true at this instant,
1264    /// according to everything the database knows now".
1265    #[must_use]
1266    pub const fn valid(at: DateTime<Utc>) -> Self {
1267        Self {
1268            transaction: None,
1269            valid: Some(at),
1270        }
1271    }
1272
1273    /// A fully bitemporal as-of query.
1274    #[must_use]
1275    pub const fn bitemporal(transaction: DateTime<Utc>, valid: DateTime<Utc>) -> Self {
1276        Self {
1277            transaction: Some(transaction),
1278            valid: Some(valid),
1279        }
1280    }
1281}
1282
1283/// Select the revision that was in force at `as_of`.
1284///
1285/// `revisions` must be in ascending `seq` order. Selection is bitemporal:
1286///
1287/// 1. discard revisions recorded after `as_of.transaction` (facts the database
1288///    did not yet hold);
1289/// 2. of what remains, discard revisions valid from after `as_of.valid` (facts
1290///    that were not yet true);
1291/// 3. return the newest survivor.
1292///
1293/// Both bounds **filter**; the winner is always the greatest `seq` among what
1294/// survives. That is also how a tie is broken: two revisions can share a
1295/// `recorded_at` when both were written inside one clock tick (see
1296/// [`monotonic_recorded_at`]), and an as-of query at that instant answers with
1297/// the last of them — the state the database held at the end of the instant
1298/// asked about. A revision is a full snapshot of the row, so a later revision does
1299/// not sit beside an earlier one on a timeline — it replaces it outright. Valid
1300/// time says when a revision's statement *starts* being true, which is exactly
1301/// an eligibility question:
1302///
1303/// * `LedgerAsOf::transaction(t)` — the newest revision the database held at
1304///   `t`, i.e. what a plain query returned then.
1305/// * `LedgerAsOf::valid(v)` — the newest revision whose statement had taken
1306///   effect by `v`. A future-dated revision is invisible until its instant
1307///   arrives; a back-dated correction is visible from the instant it claims,
1308///   and supersedes what it corrects from then on.
1309/// * `LedgerAsOf::bitemporal(t, v)` — the newest revision the database held at
1310///   `t` whose statement had taken effect by `v`: what it believed *then* about
1311///   *then*.
1312///
1313/// Ordering by `valid_from` instead would return superseded state whenever a
1314/// correction moves an instant earlier — the corrected row would keep answering
1315/// for every instant past the one it used to claim.
1316///
1317/// Returns `None` when no revision qualifies, i.e. the record did not exist yet
1318/// (or, under a valid-time bound, was not yet in effect).
1319///
1320/// The returned revision's [`snapshot`](LedgerRevision::snapshot) is the record's
1321/// exact state at that instant, including a soft-deleted state: a ledgered
1322/// entity is required to be `soft_delete`, so a delete revision still describes
1323/// a row that exists. Callers wanting live-only semantics check the model's
1324/// `deleted_at` exactly as a live query would.
1325#[must_use]
1326pub fn snapshot_as_of(revisions: &[LedgerRevision], as_of: LedgerAsOf) -> Option<&LedgerRevision> {
1327    revisions
1328        .iter()
1329        .filter(|r| as_of.transaction.is_none_or(|t| r.recorded_at <= t))
1330        .filter(|r| as_of.valid.is_none_or(|v| r.valid_from <= v))
1331        .max_by_key(|r| r.seq)
1332}
1333
1334// ── Diffing ──────────────────────────────────────────────────────────
1335
1336/// The field-level delta of one record between two instants.
1337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1338pub struct LedgerDiff {
1339    /// The record the delta describes.
1340    pub record_id: i64,
1341    /// Sequence number in force at the `from` instant, or `None` when the
1342    /// record did not exist yet.
1343    pub from_seq: Option<i64>,
1344    /// Sequence number in force at the `to` instant, or `None` when the record
1345    /// did not exist yet.
1346    pub to_seq: Option<i64>,
1347    /// Changed columns, sorted by column name. Empty when nothing changed.
1348    pub changes: Vec<ColumnChange>,
1349}
1350
1351impl LedgerDiff {
1352    /// Whether anything changed between the two instants.
1353    #[must_use]
1354    pub const fn is_empty(&self) -> bool {
1355        self.changes.is_empty()
1356    }
1357}
1358
1359/// Compute the field-level delta between two full snapshots.
1360///
1361/// Unlike [`crate::version_history::compute_diff`], which reports the columns of
1362/// an update changeset, this walks the *union* of both snapshots' keys: a column
1363/// present only in `before` is reported as removed (`after: None`) and one
1364/// present only in `after` as added (`before: None`). Output is sorted by column
1365/// name so a diff is stable regardless of either snapshot's key order.
1366///
1367/// A non-object snapshot is treated as an empty object, so diffing against a
1368/// record that did not yet exist reports every column as added.
1369#[must_use]
1370pub fn diff_snapshots(before: &serde_json::Value, after: &serde_json::Value) -> Vec<ColumnChange> {
1371    let empty = serde_json::Map::new();
1372    let before_obj = before.as_object().unwrap_or(&empty);
1373    let after_obj = after.as_object().unwrap_or(&empty);
1374
1375    let mut columns: Vec<&String> = before_obj.keys().chain(after_obj.keys()).collect();
1376    columns.sort_unstable();
1377    columns.dedup();
1378
1379    columns
1380        .into_iter()
1381        .filter_map(|column| {
1382            let before_val = before_obj.get(column);
1383            let after_val = after_obj.get(column);
1384            if before_val == after_val {
1385                return None;
1386            }
1387            Some(ColumnChange::new(
1388                column.clone(),
1389                before_val.cloned(),
1390                after_val.cloned(),
1391            ))
1392        })
1393        .collect()
1394}
1395
1396/// Compute the delta of one record between two instants over its revisions.
1397///
1398/// `revisions` must be in ascending `seq` order. Each instant is resolved with
1399/// [`snapshot_as_of`]; a record that did not exist at an instant contributes an
1400/// empty snapshot, so the delta reports its columns as added or removed.
1401#[must_use]
1402pub fn diff_as_of(
1403    record_id: i64,
1404    revisions: &[LedgerRevision],
1405    from: LedgerAsOf,
1406    to: LedgerAsOf,
1407) -> LedgerDiff {
1408    let from_rev = snapshot_as_of(revisions, from);
1409    let to_rev = snapshot_as_of(revisions, to);
1410    let empty = serde_json::Value::Object(serde_json::Map::new());
1411    let before = from_rev.map_or(&empty, |r| &r.snapshot);
1412    let after = to_rev.map_or(&empty, |r| &r.snapshot);
1413
1414    LedgerDiff {
1415        record_id,
1416        from_seq: from_rev.map(|r| r.seq),
1417        to_seq: to_rev.map(|r| r.seq),
1418        changes: diff_snapshots(before, after),
1419    }
1420}
1421
1422// ── Paginated chain reads (#2319) ────────────────────────────────────
1423
1424/// Position after a revision, for keyset pagination.
1425///
1426/// Holds `seq` and `id`: a forged revision can repeat a `seq`, and a cursor on
1427/// `seq` alone would skip it at a page edge.
1428#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1429pub struct LedgerCursor {
1430    /// Sequence number of the last revision read.
1431    pub seq: i64,
1432    /// Primary key of the last revision read.
1433    pub id: i64,
1434}
1435
1436impl LedgerCursor {
1437    /// The cursor at `revision`.
1438    #[must_use]
1439    pub const fn of(revision: &LedgerRevision) -> Self {
1440        Self {
1441            seq: revision.seq,
1442            id: revision.id,
1443        }
1444    }
1445}
1446
1447/// One page request for `ledger_revisions_page`.
1448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1449#[non_exhaustive]
1450pub struct LedgerPageRequest {
1451    /// Read the revisions after this cursor. `None` starts at the first one.
1452    pub after: Option<LedgerCursor>,
1453    /// Maximum revisions in the page. See
1454    /// [`effective_limit`](Self::effective_limit).
1455    pub limit: u32,
1456}
1457
1458impl LedgerPageRequest {
1459    /// Page size of [`Default::default`].
1460    pub const DEFAULT_LIMIT: u32 = 100;
1461    /// Largest page size a request can get.
1462    pub const MAX_LIMIT: u32 = 1000;
1463    /// Page size that `ledger_verify` reads with.
1464    pub const VERIFY_LIMIT: u32 = 256;
1465
1466    /// The first page.
1467    #[must_use]
1468    pub const fn first(limit: u32) -> Self {
1469        Self { after: None, limit }
1470    }
1471
1472    /// The page after `cursor`.
1473    #[must_use]
1474    pub const fn after(cursor: LedgerCursor, limit: u32) -> Self {
1475        Self {
1476            after: Some(cursor),
1477            limit,
1478        }
1479    }
1480
1481    /// The page size this request gets: `limit`, clamped to
1482    /// `1..=MAX_LIMIT`.
1483    #[must_use]
1484    pub fn effective_limit(&self) -> u32 {
1485        self.limit.clamp(1, Self::MAX_LIMIT)
1486    }
1487}
1488
1489impl Default for LedgerPageRequest {
1490    fn default() -> Self {
1491        Self::first(Self::DEFAULT_LIMIT)
1492    }
1493}
1494
1495/// One page of a record's chain, oldest first.
1496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1497#[non_exhaustive]
1498pub struct LedgerRevisionPage {
1499    /// The revisions in this page, in `(seq, id)` order.
1500    pub revisions: Vec<LedgerRevision>,
1501    /// Cursor for the next page. `None` when this page is the last.
1502    pub next: Option<LedgerCursor>,
1503}
1504
1505impl LedgerRevisionPage {
1506    /// Make a page from up to `limit + 1` rows. The extra row shows that a
1507    /// next page exists. This function removes it.
1508    pub(crate) fn from_rows(mut rows: Vec<LedgerRevision>, limit: u32) -> Self {
1509        let limit = usize::try_from(limit).unwrap_or(usize::MAX);
1510        let next = if rows.len() > limit {
1511            rows.truncate(limit);
1512            rows.last().map(LedgerCursor::of)
1513        } else {
1514            None
1515        };
1516        Self {
1517            revisions: rows,
1518            next,
1519        }
1520    }
1521
1522    /// The request for the next page, or `None` when this page is the last.
1523    #[must_use]
1524    pub fn next_request(&self, limit: u32) -> Option<LedgerPageRequest> {
1525        self.next
1526            .map(|cursor| LedgerPageRequest::after(cursor, limit))
1527    }
1528}
1529
1530/// The page query of [`read_revisions_page`].
1531///
1532/// The first page binds `(i64::MIN, i64::MIN)` and takes `id >= $5`, so a row
1533/// at exactly that pair is still read. Later pages take `id > $5`.
1534#[cfg(feature = "db")]
1535const fn revisions_page_sql(first: bool) -> &'static str {
1536    macro_rules! page_sql {
1537        ($tenant:literal, $id_cmp:literal) => {
1538            concat!(
1539                "SELECT id, table_name, tenant_id, record_id, seq, op, actor, \
1540                 request_id, snapshot, valid_from, recorded_at, prev_hash, hash \
1541                 FROM _autumn_ledger_revisions \
1542                 WHERE table_name = $1 AND record_id = $2 AND (",
1543                $tenant,
1544                " IS NULL OR tenant_id = $3) AND seq >= $4 AND (seq > $4 OR id ",
1545                $id_cmp,
1546                " $5) ORDER BY seq ASC, id ASC LIMIT $6"
1547            )
1548        };
1549    }
1550    #[cfg(not(feature = "sqlite"))]
1551    let sql = if first {
1552        page_sql!("$3::text", ">=")
1553    } else {
1554        page_sql!("$3::text", ">")
1555    };
1556    #[cfg(feature = "sqlite")]
1557    let sql = if first {
1558        page_sql!("$3", ">=")
1559    } else {
1560        page_sql!("$3", ">")
1561    };
1562    sql
1563}
1564
1565/// Read one keyset page of a record's chain (#2319).
1566///
1567/// The generated `ledger_revisions_page` calls this. The filter
1568/// `seq >= $4 AND (seq > $4 OR id > $5)` gives the index on
1569/// `(table_name, record_id, seq)` a start bound, so each page reads only its
1570/// own rows. The first page binds `i64::MIN` for both and takes `id >= $5`,
1571/// so it starts at the lowest `(seq, id)`, a forged one too.
1572///
1573/// Like `$3` in [`read_chain_state`], `$4` occurs twice. `SQLite` numbers
1574/// `$N` by first occurrence, so the first occurrences stay in order.
1575///
1576/// # Errors
1577///
1578/// The database error when the read fails, or
1579/// [`LedgerError::ChainUnreadable`] when a stored snapshot is not JSON.
1580#[cfg(feature = "db")]
1581#[doc(hidden)]
1582pub async fn read_revisions_page(
1583    conn: &mut crate::db::RuntimeConnection,
1584    table_name: &str,
1585    tenant_id: Option<&str>,
1586    record_id: i64,
1587    page: LedgerPageRequest,
1588) -> crate::AutumnResult<LedgerRevisionPage> {
1589    use diesel::sql_types::{BigInt, Nullable, Text};
1590    use diesel_async::RunQueryDsl;
1591
1592    #[derive(diesel::QueryableByName)]
1593    struct Row {
1594        #[diesel(sql_type = BigInt)]
1595        id: i64,
1596        #[diesel(sql_type = Text)]
1597        table_name: String,
1598        #[diesel(sql_type = Nullable<Text>)]
1599        tenant_id: Option<String>,
1600        #[diesel(sql_type = BigInt)]
1601        record_id: i64,
1602        #[diesel(sql_type = BigInt)]
1603        seq: i64,
1604        #[diesel(sql_type = Text)]
1605        op: String,
1606        #[diesel(sql_type = Text)]
1607        actor: String,
1608        #[diesel(sql_type = Nullable<Text>)]
1609        request_id: Option<String>,
1610        #[diesel(sql_type = Text)]
1611        snapshot: String,
1612        #[diesel(sql_type = Instant)]
1613        valid_from: DateTime<Utc>,
1614        #[diesel(sql_type = Instant)]
1615        recorded_at: DateTime<Utc>,
1616        #[diesel(sql_type = Nullable<Text>)]
1617        prev_hash: Option<String>,
1618        #[diesel(sql_type = Text)]
1619        hash: String,
1620    }
1621
1622    let limit = page.effective_limit();
1623    let (after_seq, after_id) = page.after.map_or((i64::MIN, i64::MIN), |c| (c.seq, c.id));
1624    // One extra row shows whether a next page exists.
1625    let fetch = i64::from(limit) + 1;
1626
1627    let rows: Vec<Row> = diesel::sql_query(revisions_page_sql(page.after.is_none()))
1628        .bind::<Text, _>(table_name)
1629        .bind::<BigInt, _>(record_id)
1630        .bind::<Nullable<Text>, _>(tenant_id)
1631        .bind::<BigInt, _>(after_seq)
1632        .bind::<BigInt, _>(after_id)
1633        .bind::<BigInt, _>(fetch)
1634        .get_results(conn)
1635        .await
1636        .map_err(crate::AutumnError::from)?;
1637
1638    let mut revisions = Vec::with_capacity(rows.len());
1639    for row in rows {
1640        let op = match row.op.as_str() {
1641            "insert" => VersionOp::Insert,
1642            "delete" => VersionOp::Delete,
1643            _ => VersionOp::Update,
1644        };
1645        // A snapshot that does not parse, or is not its canonical text, is
1646        // evidence of tampering (#2326). It is an error, not an empty record.
1647        let snapshot = parse_stored_snapshot(table_name, record_id, row.seq, &row.snapshot)?;
1648        revisions.push(LedgerRevision {
1649            id: row.id,
1650            table_name: row.table_name,
1651            tenant_id: row.tenant_id,
1652            record_id: row.record_id,
1653            seq: row.seq,
1654            op,
1655            actor: row.actor,
1656            request_id: row.request_id,
1657            snapshot,
1658            valid_from: row.valid_from,
1659            recorded_at: row.recorded_at,
1660            prev_hash: row.prev_hash,
1661            hash: row.hash,
1662        });
1663    }
1664    Ok(LedgerRevisionPage::from_rows(revisions, limit))
1665}
1666
1667// ── Repository-seam guardrails ───────────────────────────────────────
1668
1669/// Marker proving a model tolerates hard deletion.
1670///
1671/// A `ledgered` repository never emits `purge` (the soft-delete hard-delete
1672/// escape hatch), so calling it on a ledgered repository does not compile.
1673/// This trait exists so the guarantee is nameable in documentation and in the
1674/// compile-fail fixtures that pin it.
1675///
1676/// It is deliberately unimplementable by hand for a ledgered model: a hard
1677/// delete erases the row the ledger reconstructs, which no marker can make safe.
1678#[diagnostic::on_unimplemented(
1679    message = "`{Self}` is a ledgered entity, so it cannot be hard-deleted",
1680    label = "hard delete would erase the row this entity's ledger reconstructs",
1681    note = "ledgered repositories soft-delete: call `delete_by_id` (which records a revision) \
1682            and `restore` instead of `purge`"
1683)]
1684pub trait LedgerHardDeleteAllowed {}
1685
1686/// A ledgered write that the repository seam refused.
1687///
1688/// Every guardrail Autumn can enforce at compile time is enforced there — a
1689/// `ledgered` repository without `soft_delete` does not compile, and `purge` is
1690/// not generated for one. This error covers what only the runtime can see.
1691#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1692#[non_exhaustive]
1693pub enum LedgerError {
1694    /// A revision could not be appended because the record's chain could not be
1695    /// read — the ledger table is missing, or its migration has not been applied.
1696    #[error(
1697        "ledger chain for {table}#{record_id} is unreadable: {detail}; \
1698         a ledgered write cannot proceed without appending a revision"
1699    )]
1700    ChainUnreadable {
1701        /// Table of the ledgered model.
1702        table: String,
1703        /// Primary key of the record being written.
1704        record_id: i64,
1705        /// Underlying reason.
1706        detail: String,
1707    },
1708    /// A hard-deleting parent's `dependent(..., on_delete = destroy)` cascade
1709    /// reached a ledgered child.
1710    ///
1711    /// Neither outcome is available: erasing the child destroys the record its
1712    /// ledger reconstructs, and soft-deleting it leaves a live foreign key
1713    /// pointing at a parent row that is about to be deleted, which the database
1714    /// rejects and rolls back. The parent's macro cannot see that the child is
1715    /// ledgered — they are separate `#[repository]` invocations — so this is
1716    /// refused at runtime with a typed error rather than at compile time.
1717    #[error(
1718        "cannot cascade a hard delete into ledgered {table}#{record_id}: erasing it \
1719         would destroy the record its ledger reconstructs, and keeping it would leave \
1720         a foreign key pointing at a deleted parent. Make the parent repository \
1721         `soft_delete` (a soft parent delete soft-deletes this child and records a \
1722         revision), or remove the `dependent(...)` clause"
1723    )]
1724    HardDeleteCascade {
1725        /// Table of the ledgered child.
1726        table: String,
1727        /// Primary key of the child the cascade reached.
1728        record_id: i64,
1729    },
1730    /// A framework write path that records no revision reached a ledgered table.
1731    ///
1732    /// Counter-cache upkeep, `dependent(.., delete_all | nullify)` cascades,
1733    /// `#[votable]` and `#[commentable]` writes, `has_many(through)` link
1734    /// writes and portability imports run raw SQL. On a ledgered table, that
1735    /// SQL would change or erase rows and record no revision. Autumn refuses
1736    /// the write (#2319).
1737    #[error(
1738        "{path} cannot write to ledgered table {table}. The write records no \
1739         revision, so the ledger would disagree with the table. Write through the \
1740         ledgered repository of {table} (for a dependent, use \
1741         `on_delete = destroy`), or stop ledgering {table}"
1742    )]
1743    OutOfBandWrite {
1744        /// The ledgered table the write targeted.
1745        table: String,
1746        /// The write path, for example `counter cache` or
1747        /// `dependent delete_all`.
1748        path: &'static str,
1749    },
1750    /// A ledgered write moved a record to another tenant (#2319).
1751    ///
1752    /// A chain belongs to one `(tenant, record)`. A move would leave the old
1753    /// chain without a live row and start a new chain in the other tenant.
1754    #[error(
1755        "cannot ledger {table}#{record_id}: the write changes its tenant. A revision \
1756         chain belongs to one tenant, so the move would split it. Create a new record \
1757         in the other tenant instead"
1758    )]
1759    TenantChange {
1760        /// Table of the ledgered model.
1761        table: String,
1762        /// Primary key of the record being written.
1763        record_id: i64,
1764    },
1765    /// A ledgered write carried a `NaN` or infinite float (#2326).
1766    ///
1767    /// JSON cannot hold either value. A snapshot would store `null` and could not
1768    /// be read back. The write is refused before the hash.
1769    #[error(
1770        "cannot ledger {table}#{record_id}: column `{column}` holds NaN or an infinite \
1771         float, which a snapshot cannot store. Write a finite value, or stop ledgering \
1772         {table}"
1773    )]
1774    NonFiniteValue {
1775        /// Table of the ledgered model.
1776        table: String,
1777        /// Primary key of the record being written.
1778        record_id: i64,
1779        /// The offending column.
1780        column: String,
1781    },
1782    /// A stored snapshot is not the canonical text that was hashed (#2326).
1783    ///
1784    /// The text parses, but it is not byte-identical to its canonical form.
1785    /// Someone re-encoded it, reordered its keys, or added a duplicate key.
1786    #[error(
1787        "ledger revision {seq} of {table}#{record_id} is not in canonical form: the stored \
1788         snapshot text was re-encoded, reordered or holds a duplicate key"
1789    )]
1790    SnapshotNotCanonical {
1791        /// Table of the ledgered model.
1792        table: String,
1793        /// Primary key of the record.
1794        record_id: i64,
1795        /// Sequence number of the revision.
1796        seq: i64,
1797    },
1798    /// A stored snapshot does not decode into the model's current shape (#2326).
1799    ///
1800    /// The model schema probably changed after the revision was written. This is
1801    /// not proof of an edit: `ledger_verify` shows the difference.
1802    #[error(
1803        "ledger revision {seq} of {table}#{record_id} does not match the current model \
1804         schema: {detail}. The model changed after this revision was written. Add \
1805         `#[serde(default)]` to a new field. To convert a renamed or retyped field, \
1806         set `ledgered(schema_version = N, upcast = path)`. To check for an edit, \
1807         run `ledger_verify`"
1808    )]
1809    SnapshotSchemaMismatch {
1810        /// Table of the ledgered model.
1811        table: String,
1812        /// Primary key of the record.
1813        record_id: i64,
1814        /// Sequence number of the revision.
1815        seq: i64,
1816        /// The decode failure.
1817        detail: String,
1818    },
1819    /// A record's stored chain is broken, so its past state cannot be trusted.
1820    #[error("ledger chain for {table}#{record_id} is broken at revision {seq}: {detail}")]
1821    ChainBroken {
1822        /// Table of the ledgered model.
1823        table: String,
1824        /// Primary key of the record.
1825        record_id: i64,
1826        /// Sequence number of the first broken link.
1827        seq: i64,
1828        /// What kind of break, and why.
1829        detail: String,
1830    },
1831}
1832
1833// ── Model-side trait ─────────────────────────────────────────────────
1834
1835/// Implemented by models opted into the ledger.
1836///
1837/// `#[repository(Model, soft_delete, ledgered = true)]` generates this
1838/// implementation. It exists to carry the model's valid-time source; everything
1839/// else the ledger needs comes from [`crate::version_history::VersionedRecord`],
1840/// which `ledgered` also implies.
1841pub trait LedgeredRecord: crate::version_history::VersionedRecord {
1842    /// Valid time for the revision this record is about to produce.
1843    ///
1844    /// `None` — the default — means "valid from when the database learned it",
1845    /// so the write path falls back to the revision's transaction time. Declare
1846    /// `ledgered(valid_time = "effective_at")` on the repository to read the
1847    /// instant from a column instead.
1848    fn ledger_valid_from(&self) -> Option<DateTime<Utc>> {
1849        None
1850    }
1851
1852    /// Tenant that owns this record's chain.
1853    ///
1854    /// `None` for a model that is not `tenant_scoped`. A `tenant_scoped`
1855    /// repository reads it through [`LedgerTenantColumn`], so the writer and the
1856    /// reader key the chain on the same non-null column (#2319).
1857    fn ledger_tenant_id(&self) -> Option<&str> {
1858        None
1859    }
1860
1861    /// Version of the snapshot shape this model writes (#2326).
1862    ///
1863    /// Raise the version when you rename or retype a snapshot field, or add a
1864    /// column. A model at version 2 or more writes the version into each new
1865    /// snapshot, inside the hash. A snapshot with no version is version 1.
1866    const LEDGER_SCHEMA_VERSION: u32 = 1;
1867
1868    /// Convert a snapshot written at `from_version` to the current shape (#2326).
1869    ///
1870    /// The ledger calls it only for a snapshot older than
1871    /// [`LEDGER_SCHEMA_VERSION`](Self::LEDGER_SCHEMA_VERSION). The default does
1872    /// nothing. To use your own function, declare
1873    /// `ledgered(schema_version = N, upcast = path)`. Your function must handle
1874    /// every version below `N`.
1875    ///
1876    /// # Errors
1877    ///
1878    /// Returns a message that says why the snapshot cannot be converted.
1879    fn ledger_upcast(_from_version: u32, _snapshot: &mut serde_json::Value) -> Result<(), String> {
1880        Ok(())
1881    }
1882}
1883
1884/// A tenant column that can own a ledger chain (#2319).
1885///
1886/// Only `String` implements this trait. A tenant-scoped read cannot see a NULL
1887/// tenant. A cross-tenant ledger read is refused. Thus no read can get a chain
1888/// with a NULL tenant, and a ledgered `tenant_scoped` repository on an
1889/// `Option<String>` column does not compile.
1890#[diagnostic::on_unimplemented(
1891    message = "a ledgered `tenant_scoped` repository needs `tenant_id: String`, not `{Self}`",
1892    label = "the tenant column of a ledgered model must not be nullable",
1893    note = "a revision with a NULL tenant is not visible to a tenant-scoped read, and a \
1894            cross-tenant ledger read is refused, so no read can reach its chain. Make the \
1895            field `String` and the column `NOT NULL`, or remove `ledgered`"
1896)]
1897pub trait LedgerTenantColumn {
1898    /// The tenant this column holds.
1899    fn ledger_tenant_id(&self) -> &str;
1900}
1901
1902impl LedgerTenantColumn for String {
1903    fn ledger_tenant_id(&self) -> &str {
1904        self.as_str()
1905    }
1906}
1907
1908/// Refuse a ledgered update that changes the record's tenant (#2319).
1909///
1910/// # Errors
1911///
1912/// [`LedgerError::TenantChange`], as a conflict, when `before` and `after`
1913/// differ.
1914#[doc(hidden)]
1915pub fn refuse_tenant_change(
1916    table: &str,
1917    record_id: i64,
1918    before: Option<&str>,
1919    after: Option<&str>,
1920) -> crate::AutumnResult<()> {
1921    if before == after {
1922        return Ok(());
1923    }
1924    Err(crate::AutumnError::conflict(LedgerError::TenantChange {
1925        table: table.to_string(),
1926        record_id,
1927    }))
1928}
1929
1930/// Extract a valid-time instant from the column shapes a model may use.
1931///
1932/// Generated `LedgeredRecord` impls go through this so a `valid_time` column may
1933/// be `DateTime<Utc>`, `NaiveDateTime`, or an `Option` of either without the
1934/// macro having to know which.
1935#[doc(hidden)]
1936pub trait LedgerValidTimeValue {
1937    /// This value as a UTC instant, or `None` when it carries none.
1938    fn ledger_valid_from(&self) -> Option<DateTime<Utc>>;
1939}
1940
1941impl LedgerValidTimeValue for DateTime<Utc> {
1942    fn ledger_valid_from(&self) -> Option<DateTime<Utc>> {
1943        Some(*self)
1944    }
1945}
1946
1947impl LedgerValidTimeValue for Option<DateTime<Utc>> {
1948    fn ledger_valid_from(&self) -> Option<DateTime<Utc>> {
1949        *self
1950    }
1951}
1952
1953impl LedgerValidTimeValue for chrono::NaiveDateTime {
1954    fn ledger_valid_from(&self) -> Option<DateTime<Utc>> {
1955        Some(self.and_utc())
1956    }
1957}
1958
1959impl LedgerValidTimeValue for Option<chrono::NaiveDateTime> {
1960    fn ledger_valid_from(&self) -> Option<DateTime<Utc>> {
1961        self.as_ref().map(chrono::NaiveDateTime::and_utc)
1962    }
1963}
1964
1965// ── Snapshot fidelity (#2326) ────────────────────────────────────────
1966
1967/// Probe wrapper for the generated non-finite float check.
1968///
1969/// Call it as `(&NonFiniteProbe(&field)).autumn_has_non_finite()`. A float, or
1970/// an `Option` or `Vec` of one, resolves to [`NonFiniteViaFloat`]; any other
1971/// type falls back to `false`. Same autoref pattern as `MaybeValidate`.
1972#[doc(hidden)]
1973pub struct NonFiniteProbe<'a, T: ?Sized>(pub &'a T);
1974
1975/// A value that may hold a `NaN` or infinite float.
1976#[doc(hidden)]
1977pub trait FloatContent {
1978    /// `true` when any float inside is not finite.
1979    fn has_non_finite(&self) -> bool;
1980}
1981
1982impl FloatContent for f64 {
1983    fn has_non_finite(&self) -> bool {
1984        !self.is_finite()
1985    }
1986}
1987
1988impl FloatContent for f32 {
1989    fn has_non_finite(&self) -> bool {
1990        !self.is_finite()
1991    }
1992}
1993
1994impl<T: FloatContent> FloatContent for Option<T> {
1995    fn has_non_finite(&self) -> bool {
1996        self.as_ref().is_some_and(FloatContent::has_non_finite)
1997    }
1998}
1999
2000impl<T: FloatContent> FloatContent for Vec<T> {
2001    fn has_non_finite(&self) -> bool {
2002        self.iter().any(FloatContent::has_non_finite)
2003    }
2004}
2005
2006/// Preferred branch: the field holds floats.
2007#[doc(hidden)]
2008pub trait NonFiniteViaFloat {
2009    /// `true` when the field holds a non-finite float.
2010    fn autumn_has_non_finite(&self) -> bool;
2011}
2012
2013impl<T: FloatContent> NonFiniteViaFloat for NonFiniteProbe<'_, T> {
2014    fn autumn_has_non_finite(&self) -> bool {
2015        self.0.has_non_finite()
2016    }
2017}
2018
2019/// Fallback branch (one autoref further): every other type.
2020#[doc(hidden)]
2021pub trait NonFiniteFallback {
2022    /// Always `false`.
2023    fn autumn_has_non_finite(&self) -> bool;
2024}
2025
2026impl<T: ?Sized> NonFiniteFallback for &NonFiniteProbe<'_, T> {
2027    fn autumn_has_non_finite(&self) -> bool {
2028        false
2029    }
2030}
2031
2032/// Refuse a ledgered write whose record holds a non-finite float (#2326).
2033///
2034/// `column` is what the model's generated check found, if anything.
2035///
2036/// # Errors
2037///
2038/// [`LedgerError::NonFiniteValue`] naming the column.
2039#[doc(hidden)]
2040pub fn refuse_non_finite(
2041    table: &str,
2042    record_id: i64,
2043    column: Option<&'static str>,
2044) -> crate::AutumnResult<()> {
2045    column.map_or(Ok(()), |column| {
2046        Err(crate::AutumnError::internal_server_error(
2047            LedgerError::NonFiniteValue {
2048                table: table.to_string(),
2049                record_id,
2050                column: column.to_string(),
2051            },
2052        ))
2053    })
2054}
2055
2056/// Parse a stored snapshot, refusing text that is not its canonical form (#2326).
2057///
2058/// The hash covers `canonical_json` of the parsed value. Without this check,
2059/// `{ "a": 1 }` or `{"a":1,"a":2}` would verify as the canonical `{"a":2}`.
2060///
2061/// # Errors
2062///
2063/// [`LedgerError::ChainUnreadable`] for invalid JSON.
2064/// [`LedgerError::SnapshotNotCanonical`] for any other encoding.
2065#[doc(hidden)]
2066pub fn parse_stored_snapshot(
2067    table: &str,
2068    record_id: i64,
2069    seq: i64,
2070    text: &str,
2071) -> crate::AutumnResult<serde_json::Value> {
2072    let value: serde_json::Value = serde_json::from_str(text).map_err(|err| {
2073        crate::AutumnError::internal_server_error(LedgerError::ChainUnreadable {
2074            table: table.to_string(),
2075            record_id,
2076            detail: format!("revision {seq} has an unreadable snapshot: {err}"),
2077        })
2078    })?;
2079    if canonical_json(&value) != text {
2080        return Err(crate::AutumnError::internal_server_error(
2081            LedgerError::SnapshotNotCanonical {
2082                table: table.to_string(),
2083                record_id,
2084                seq,
2085            },
2086        ));
2087    }
2088    Ok(value)
2089}
2090
2091/// Replace each quoted value in a decode error with `"…"`.
2092///
2093/// A decode error can quote the value that failed. In a snapshot, that value
2094/// can be a private column or ciphertext.
2095#[doc(hidden)]
2096#[must_use]
2097pub fn redact_quoted(detail: &str) -> String {
2098    let mut out = String::with_capacity(detail.len());
2099    let mut in_quote = false;
2100    let mut escaped = false;
2101    for ch in detail.chars() {
2102        if in_quote {
2103            if escaped {
2104                escaped = false;
2105            } else if ch == '\\' {
2106                escaped = true;
2107            } else if ch == '"' {
2108                in_quote = false;
2109                out.push_str("…\"");
2110            }
2111        } else {
2112            out.push(ch);
2113            in_quote = ch == '"';
2114        }
2115    }
2116    out
2117}
2118
2119/// The error for a snapshot that does not decode into the current model.
2120#[doc(hidden)]
2121#[must_use]
2122pub fn schema_mismatch(table: &str, record_id: i64, seq: i64, detail: &str) -> crate::AutumnError {
2123    crate::AutumnError::internal_server_error(LedgerError::SnapshotSchemaMismatch {
2124        table: table.to_string(),
2125        record_id,
2126        seq,
2127        detail: redact_quoted(detail),
2128    })
2129}
2130
2131/// Turn a chain read error into a verification report where it is a finding (#2326).
2132///
2133/// A non-canonical snapshot is evidence of a change, not a read fault. Other
2134/// errors pass through. The read stops at this revision, so no other break in the
2135/// chain is reported with it.
2136///
2137/// # Errors
2138///
2139/// Returns `err` unchanged for any other error.
2140#[doc(hidden)]
2141pub fn verification_from_read_error(
2142    record_id: i64,
2143    err: crate::AutumnError,
2144) -> crate::AutumnResult<LedgerVerification> {
2145    let Some(LedgerError::SnapshotNotCanonical { seq, .. }) = err.downcast_ref::<LedgerError>()
2146    else {
2147        return Err(err);
2148    };
2149    Ok(LedgerVerification {
2150        record_id,
2151        revisions_checked: 0,
2152        head_hash: None,
2153        broken: Some(LedgerBreakReport {
2154            seq: *seq,
2155            revision_id: None,
2156            kind: LedgerBreak::SnapshotNotCanonical,
2157            detail: format!("revision {seq} is stored in a form other than its canonical text"),
2158        }),
2159    })
2160}
2161
2162/// Snapshot key that holds the schema version a revision was written at (#2326).
2163///
2164/// The key sits inside the hashed snapshot, so an edit to it breaks the chain.
2165/// A model at version 1 writes no marker. A snapshot without one counts as
2166/// version 1, so chains written before versions existed keep their hashes.
2167/// No column name starts with `$`, so the key cannot clash with a column.
2168pub const SNAPSHOT_SCHEMA_KEY: &str = "$schema_version";
2169
2170/// The function type of an `upcast` hook (#2326).
2171#[doc(hidden)]
2172pub type SnapshotUpcast = fn(u32, &mut serde_json::Value) -> Result<(), String>;
2173
2174/// Add the schema version marker to a new snapshot, when the model is past version 1.
2175#[doc(hidden)]
2176pub fn stamp_schema_version(snapshot: &mut serde_json::Value, current: u32) {
2177    if current > 1
2178        && let Some(object) = snapshot.as_object_mut()
2179    {
2180        object.insert(SNAPSHOT_SCHEMA_KEY.to_string(), serde_json::json!(current));
2181    }
2182}
2183
2184/// Bring a stored snapshot to the current model shape (#2326).
2185///
2186/// Returns the converted snapshot and the version it was written at. The
2187/// snapshot has no marker. Runs `hook` only for a snapshot older than `current`.
2188/// A snapshot from a newer model comes back unchanged. If the model cannot
2189/// decode it, the caller reports the error.
2190///
2191/// # Errors
2192///
2193/// [`LedgerError::SnapshotSchemaMismatch`] for a damaged marker, a failing
2194/// hook, or a hook that panics. None of them is tamper evidence. Use
2195/// `ledger_verify` to find an edit.
2196#[doc(hidden)]
2197pub fn upcast_snapshot(
2198    table: &str,
2199    record_id: i64,
2200    seq: i64,
2201    snapshot: &serde_json::Value,
2202    current: u32,
2203    hook: SnapshotUpcast,
2204) -> crate::AutumnResult<(serde_json::Value, u32)> {
2205    let mut value = snapshot.clone();
2206    let Some(object) = value.as_object_mut() else {
2207        return Ok((value, 1));
2208    };
2209    let stored = match object.remove(SNAPSHOT_SCHEMA_KEY) {
2210        None => 1,
2211        Some(marker) => marker
2212            .as_u64()
2213            .and_then(|version| u32::try_from(version).ok())
2214            .filter(|version| *version >= 1)
2215            .ok_or_else(|| {
2216                schema_mismatch(
2217                    table,
2218                    record_id,
2219                    seq,
2220                    "the snapshot has a damaged schema version marker",
2221                )
2222            })?,
2223    };
2224    if stored < current {
2225        // A hook is app code. A panic in it must not stop an audit.
2226        let ran =
2227            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| hook(stored, &mut value)))
2228                .unwrap_or_else(|_| Err("the hook panicked".to_string()));
2229        ran.map_err(|message| {
2230            schema_mismatch(
2231                table,
2232                record_id,
2233                seq,
2234                &format!("the upcast hook failed for schema version {stored}: {message}"),
2235            )
2236        })?;
2237    }
2238    Ok((value, stored))
2239}
2240
2241/// [`schema_mismatch`] with the two schema versions in the detail (#2326).
2242#[doc(hidden)]
2243#[must_use]
2244pub fn schema_mismatch_at(
2245    table: &str,
2246    record_id: i64,
2247    seq: i64,
2248    stored: u32,
2249    current: u32,
2250    detail: &str,
2251) -> crate::AutumnError {
2252    let origin = if stored > current {
2253        " This revision was written by a newer model."
2254    } else {
2255        ""
2256    };
2257    schema_mismatch(
2258        table,
2259        record_id,
2260        seq,
2261        &format!(
2262            "{detail} (the revision is at schema version {stored}, the model is at \
2263             {current}.{origin})"
2264        ),
2265    )
2266}
2267
2268/// Whether the stored head and the live row agree (#2326).
2269///
2270/// With `tolerate_new_columns` set, this compares the columns both have. Set it
2271/// only when the head was written under an older schema version than the
2272/// model's. A column that only one side has comes from that schema change. The
2273/// head cannot say what a new column held, and the next write records it.
2274///
2275/// With the flag clear, the two must be equal. Then a column that the head lacks
2276/// is a difference. This keeps the check strict for a head at the current
2277/// version, so an out-of-band edit to such a column is found.
2278#[doc(hidden)]
2279#[must_use]
2280pub fn snapshots_agree(
2281    head: &serde_json::Value,
2282    live: &serde_json::Value,
2283    tolerate_new_columns: bool,
2284) -> bool {
2285    match (head.as_object(), live.as_object()) {
2286        (Some(head), Some(live)) if tolerate_new_columns => head.iter().all(|(column, value)| {
2287            live.get(column)
2288                .is_none_or(|other| canonical_json(value) == canonical_json(other))
2289        }),
2290        _ => canonical_json(head) == canonical_json(live),
2291    }
2292}
2293
2294/// Log that `ledger_verify` skipped the live-row check for a record (#2326).
2295///
2296/// The head could not be converted to the current schema. The hash chain is
2297/// still verified. The skip is not tamper evidence, but a quiet skip would hide
2298/// a broken upcast function.
2299#[doc(hidden)]
2300pub fn note_live_check_skipped(table: &str, record_id: i64, error: &crate::AutumnError) {
2301    tracing::warn!(
2302        table,
2303        record_id,
2304        %error,
2305        "ledger_verify skipped the live-row check: the head revision could not be converted"
2306    );
2307}
2308
2309/// Refuse to reconstruct a model from a snapshot whose `#[encrypted]` column
2310/// cannot be decrypted (#2326).
2311///
2312/// The model codec leaves an undecryptable envelope in place. This suits
2313/// commit-hook replay. In as-of reconstruction, it would return ciphertext as
2314/// plaintext.
2315///
2316/// # Errors
2317///
2318/// [`LedgerError::ChainUnreadable`] naming the columns and the revision.
2319#[doc(hidden)]
2320pub fn ensure_snapshot_recoverable(
2321    table: &str,
2322    record_id: i64,
2323    seq: i64,
2324    snapshot: &serde_json::Value,
2325) -> crate::AutumnResult<()> {
2326    if !crate::encryption::registered_encrypted_columns()
2327        .iter()
2328        .any(|d| d.table == table)
2329    {
2330        return Ok(());
2331    }
2332    let mut probe = snapshot.clone();
2333    let lost = crate::encryption::decrypt_snapshot_columns(table, &mut probe);
2334    if lost.is_empty() {
2335        return Ok(());
2336    }
2337    Err(crate::AutumnError::internal_server_error(
2338        LedgerError::ChainUnreadable {
2339            table: table.to_string(),
2340            record_id,
2341            detail: format!(
2342                "revision {seq}: encrypted column(s) `{}` cannot be decrypted. The key \
2343                 is missing, the key ring is not set, or the envelope is damaged",
2344                lost.join("`, `")
2345            ),
2346        },
2347    ))
2348}
2349
2350// ── Runtime append (#2309 follow-up) ─────────────────────────────────
2351//
2352// `#[repository(ledgered)]` used to expand this whole read-then-write — a local
2353// chain-state struct and two `QueryableByName` derives included — at every one of the
2354// ~30 mutation sites `vh_insert_ts` covers. That alone took a ledgered repository's
2355// generated source from 72 KB to 508 KB, and none of it was model-specific:
2356// `RuntimeConnection` is a concrete type alias and every bound value is a primitive or a
2357// `serde_json::Value` the caller already built. Hoisted here it compiles once per program
2358// instead of once per repository. The generated code keeps only the parts that genuinely
2359// need the model — the durable per-field snapshot codec, the typed `deleted_at` read-back
2360// on the soft-delete path, and `LedgeredRecord::ledger_valid_from` — and calls this.
2361
2362/// Everything the append reads before it can decide its own sequence number and
2363/// transaction time.
2364///
2365/// Backend-neutral: each arm below decodes its own `QueryableByName` row into
2366/// this, because the clock and the two instants come back as different SQL
2367/// types on each tier.
2368#[cfg(feature = "db")]
2369struct ChainState {
2370    db_now: DateTime<Utc>,
2371    head_seq: Option<i64>,
2372    head_hash: Option<String>,
2373    head_recorded_at: Option<DateTime<Utc>>,
2374    mark_seq: Option<i64>,
2375    mark_hash: Option<String>,
2376    mark_recorded_at: Option<DateTime<Utc>>,
2377}
2378
2379/// One ledger revision, as the generated repository has computed it.
2380///
2381/// `snapshot` is owned because the soft-delete path rewrites it before handing
2382/// it over; everything else is borrowed from locals the caller already holds.
2383#[cfg(feature = "db")]
2384#[doc(hidden)]
2385#[derive(Debug)]
2386pub struct LedgerAppend<'a> {
2387    /// Physical table the ledgered record lives in.
2388    pub table_name: &'static str,
2389    /// Active tenant for a `tenant_scoped` repository, else `None`.
2390    pub tenant_id: Option<&'a str>,
2391    /// Primary key of the record this revision describes.
2392    pub record_id: i64,
2393    /// Which mutation produced this revision.
2394    pub op: VersionOp,
2395    /// Resolved actor for the write.
2396    pub actor: &'a str,
2397    /// Originating request id, when one is in scope.
2398    pub request_id: Option<&'a str>,
2399    /// Full row snapshot, already through the model's durable per-field codec.
2400    pub snapshot: serde_json::Value,
2401    /// The model's own valid-time instant, when it declares one. `None` falls
2402    /// back to the revision's transaction time.
2403    pub valid_from: Option<DateTime<Utc>>,
2404}
2405
2406/// Read the record's chain head, its out-of-band high-water mark and the
2407/// database's own clock, in one statement.
2408///
2409/// Two LEFT JOINs off a one-row anchor rather than three round trips — and the
2410/// anchor is what makes the row come back even when neither side has anything,
2411/// so the clock read is never lost with the head.
2412///
2413/// `SQLite` documents that every `'now'` inside one `sqlite3_step` sees the same
2414/// value, so its clock is coherent with the head it rides along with; Postgres
2415/// reads `clock_timestamp()` rather than `now()` because the latter is the
2416/// transaction's *start*, which two overlapping transactions can observe out of
2417/// commit order.
2418///
2419/// `$1`/`$2`/`$3` are each referenced more than once and only three binds are
2420/// supplied. That is ordinary on Postgres, which numbers *parameters*; on
2421/// `SQLite` `$1` is a **named** parameter of the `$AAAA` family whose index is
2422/// assigned by FIRST APPEARANCE, not by the digits in the name. So the first
2423/// appearance of `$1`, `$2` and `$3` must stay in that order in the statement
2424/// text, matching the bind order below.
2425#[cfg(feature = "db")]
2426#[allow(
2427    clippy::too_many_lines,
2428    reason = "one backend-forked statement: each arm carries its own \
2429              `QueryableByName` row because the clock and the two instants come \
2430              back as different SQL types per tier. Splitting it would separate \
2431              the SQL from the row shape it decodes into"
2432)]
2433async fn read_chain_state(
2434    conn: &mut crate::db::RuntimeConnection,
2435    table_name: &str,
2436    record_id: i64,
2437    tenant_id: Option<&str>,
2438) -> crate::AutumnResult<ChainState> {
2439    use diesel::sql_types::{BigInt, Nullable, Text};
2440    use diesel_async::RunQueryDsl;
2441
2442    let unreadable = |detail: String| {
2443        crate::AutumnError::internal_server_error(LedgerError::ChainUnreadable {
2444            table: table_name.to_string(),
2445            record_id,
2446            detail,
2447        })
2448    };
2449
2450    #[cfg(not(feature = "sqlite"))]
2451    {
2452        use diesel::sql_types::Timestamptz;
2453
2454        #[derive(diesel::QueryableByName)]
2455        struct Row {
2456            #[diesel(sql_type = Timestamptz)]
2457            db_now: DateTime<Utc>,
2458            #[diesel(sql_type = Nullable<BigInt>)]
2459            head_seq: Option<i64>,
2460            #[diesel(sql_type = Nullable<Text>)]
2461            head_hash: Option<String>,
2462            #[diesel(sql_type = Nullable<Timestamptz>)]
2463            head_recorded_at: Option<DateTime<Utc>>,
2464            #[diesel(sql_type = Nullable<BigInt>)]
2465            mark_seq: Option<i64>,
2466            #[diesel(sql_type = Nullable<Text>)]
2467            mark_hash: Option<String>,
2468            #[diesel(sql_type = Nullable<Timestamptz>)]
2469            mark_recorded_at: Option<DateTime<Utc>>,
2470        }
2471
2472        let row: Row = diesel::sql_query(
2473            "SELECT clock_timestamp() AS db_now, \
2474                    __head.seq AS head_seq, \
2475                    __head.hash AS head_hash, \
2476                    __head.recorded_at AS head_recorded_at, \
2477                    __mark.high_seq AS mark_seq, \
2478                    __mark.head_hash AS mark_hash, \
2479                    __mark.recorded_at AS mark_recorded_at \
2480             FROM (SELECT 1 AS anchor) AS __anchor \
2481             LEFT JOIN ( \
2482                 SELECT seq, hash, recorded_at FROM _autumn_ledger_revisions \
2483                 WHERE table_name = $1 AND record_id = $2 \
2484                 AND COALESCE(tenant_id, '') = COALESCE($3::text, '') \
2485                 ORDER BY seq DESC LIMIT 1 \
2486             ) AS __head ON 1 = 1 \
2487             LEFT JOIN _autumn_ledger_high_water AS __mark \
2488             ON __mark.table_name = $1 AND __mark.record_id = $2 \
2489             AND __mark.tenant_key = COALESCE($3::text, '')",
2490        )
2491        .bind::<Text, _>(table_name)
2492        .bind::<BigInt, _>(record_id)
2493        .bind::<Nullable<Text>, _>(tenant_id)
2494        .get_results::<Row>(conn)
2495        .await
2496        .map_err(crate::AutumnError::from)?
2497        .into_iter()
2498        .next()
2499        .ok_or_else(|| unreadable("the chain-state read returned no row".to_string()))?;
2500
2501        Ok(ChainState {
2502            db_now: row.db_now,
2503            head_seq: row.head_seq,
2504            head_hash: row.head_hash,
2505            head_recorded_at: row.head_recorded_at,
2506            mark_seq: row.mark_seq,
2507            mark_hash: row.mark_hash,
2508            mark_recorded_at: row.mark_recorded_at,
2509        })
2510    }
2511
2512    #[cfg(feature = "sqlite")]
2513    {
2514        use diesel::sql_types::TimestamptzSqlite;
2515
2516        // `db_now` comes back as the `strftime` text rather than a bound
2517        // timestamp: the accepted encoding is then pinned by
2518        // `parse_sqlite_instant`'s own test in this crate instead of by
2519        // whichever format list Diesel's SQLite decoder carries.
2520        #[derive(diesel::QueryableByName)]
2521        struct Row {
2522            #[diesel(sql_type = Text)]
2523            db_now: String,
2524            #[diesel(sql_type = Nullable<BigInt>)]
2525            head_seq: Option<i64>,
2526            #[diesel(sql_type = Nullable<Text>)]
2527            head_hash: Option<String>,
2528            #[diesel(sql_type = Nullable<TimestamptzSqlite>)]
2529            head_recorded_at: Option<DateTime<Utc>>,
2530            #[diesel(sql_type = Nullable<BigInt>)]
2531            mark_seq: Option<i64>,
2532            #[diesel(sql_type = Nullable<Text>)]
2533            mark_hash: Option<String>,
2534            #[diesel(sql_type = Nullable<TimestamptzSqlite>)]
2535            mark_recorded_at: Option<DateTime<Utc>>,
2536        }
2537
2538        let row: Row = diesel::sql_query(
2539            "SELECT strftime('%Y-%m-%d %H:%M:%f', 'now') AS db_now, \
2540                    __head.seq AS head_seq, \
2541                    __head.hash AS head_hash, \
2542                    __head.recorded_at AS head_recorded_at, \
2543                    __mark.high_seq AS mark_seq, \
2544                    __mark.head_hash AS mark_hash, \
2545                    __mark.recorded_at AS mark_recorded_at \
2546             FROM (SELECT 1 AS anchor) AS __anchor \
2547             LEFT JOIN ( \
2548                 SELECT seq, hash, recorded_at FROM _autumn_ledger_revisions \
2549                 WHERE table_name = $1 AND record_id = $2 \
2550                 AND COALESCE(tenant_id, '') = COALESCE($3, '') \
2551                 ORDER BY seq DESC LIMIT 1 \
2552             ) AS __head ON 1 = 1 \
2553             LEFT JOIN _autumn_ledger_high_water AS __mark \
2554             ON __mark.table_name = $1 AND __mark.record_id = $2 \
2555             AND __mark.tenant_key = COALESCE($3, '')",
2556        )
2557        .bind::<Text, _>(table_name)
2558        .bind::<BigInt, _>(record_id)
2559        .bind::<Nullable<Text>, _>(tenant_id)
2560        .get_results::<Row>(conn)
2561        .await
2562        .map_err(crate::AutumnError::from)?
2563        .into_iter()
2564        .next()
2565        .ok_or_else(|| unreadable("the chain-state read returned no row".to_string()))?;
2566
2567        let db_now = parse_sqlite_instant(&row.db_now).ok_or_else(|| {
2568            unreadable(format!(
2569                "the database clock read back as {:?}, which is not the encoding the \
2570                 ledger expects",
2571                row.db_now,
2572            ))
2573        })?;
2574
2575        Ok(ChainState {
2576            db_now,
2577            head_seq: row.head_seq,
2578            head_hash: row.head_hash,
2579            head_recorded_at: row.head_recorded_at,
2580            mark_seq: row.mark_seq,
2581            mark_hash: row.mark_hash,
2582            mark_recorded_at: row.mark_recorded_at,
2583        })
2584    }
2585}
2586
2587/// Append one [`LedgerRevision`] inside the caller's already-open transaction,
2588/// so the revision and the row it describes commit together or not at all.
2589///
2590/// The append is a read-then-write: [`read_chain_state`] reads the record's
2591/// chain head, its high-water mark and the database's clock, then two
2592/// statements insert the revision and raise the mark. All of them run under the
2593/// write transaction's row lock on the record, and the migration's
2594/// `(table_name, COALESCE(tenant_id, ''), record_id, seq)` unique index turns
2595/// any race that slips past that lock into a hard error rather than a silently
2596/// forked chain.
2597///
2598/// # Errors
2599///
2600/// Returns [`LedgerError::ChainUnreadable`] when the chain state cannot be read
2601/// or disagrees with its high-water mark in a way that appending would erase
2602/// (see the inline rules), and the database error when either write fails.
2603/// The revision INSERT.
2604///
2605/// `$8` is bound as TEXT, not cast to `jsonb`: `jsonb` parses each number into
2606/// `numeric` and re-renders it on output, so a float `serde_json` writes as
2607/// `1e16` comes back as `10000000000000000` and re-canonicalizes to different
2608/// bytes than the ones that were hashed — `ledger_verify` would report
2609/// `HashMismatch` on an untampered chain. A tamper-evident store has to hand
2610/// back exactly the bytes it was given.
2611#[cfg(feature = "db")]
2612pub(crate) const REVISION_INSERT_SQL: &str = "INSERT INTO _autumn_ledger_revisions \
2613     (table_name, tenant_id, record_id, seq, op, actor, request_id, \
2614      snapshot, valid_from, recorded_at, prev_hash, hash) \
2615     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)";
2616
2617/// The high-water upsert.
2618///
2619/// `WHERE ... high_seq < EXCLUDED.high_seq` is what makes the mark monotonic in
2620/// the database rather than merely in the code above it: a writer that somehow
2621/// computed a lower sequence number cannot lower the mark, it can only fail to
2622/// raise it — and then the mark disagrees with the head it is supposed to name,
2623/// which `ledger_verify` reports.
2624///
2625/// Raised in the same transaction as the revision itself: the mark and the row
2626/// it names commit together or not at all, so the two can never disagree because
2627/// of a crash.
2628#[cfg(all(feature = "db", not(feature = "sqlite")))]
2629pub(crate) const HIGH_WATER_UPSERT_SQL: &str = "INSERT INTO _autumn_ledger_high_water \
2630     (table_name, tenant_key, record_id, high_seq, head_hash, recorded_at) \
2631     VALUES ($1, COALESCE($2::text, ''), $3, $4, $5, $6) \
2632     ON CONFLICT (table_name, tenant_key, record_id) DO UPDATE SET \
2633     high_seq = EXCLUDED.high_seq, \
2634     head_hash = EXCLUDED.head_hash, \
2635     recorded_at = EXCLUDED.recorded_at \
2636     WHERE _autumn_ledger_high_water.high_seq < EXCLUDED.high_seq";
2637
2638/// `SQLite` form of [`HIGH_WATER_UPSERT_SQL`]. See that constant for the
2639/// monotonicity contract; only the `COALESCE` cast and the `excluded` spelling
2640/// differ.
2641#[cfg(all(feature = "db", feature = "sqlite"))]
2642pub(crate) const HIGH_WATER_UPSERT_SQL: &str = "INSERT INTO _autumn_ledger_high_water \
2643     (table_name, tenant_key, record_id, high_seq, head_hash, recorded_at) \
2644     VALUES ($1, COALESCE($2, ''), $3, $4, $5, $6) \
2645     ON CONFLICT (table_name, tenant_key, record_id) DO UPDATE SET \
2646     high_seq = excluded.high_seq, \
2647     head_hash = excluded.head_hash, \
2648     recorded_at = excluded.recorded_at \
2649     WHERE _autumn_ledger_high_water.high_seq < excluded.high_seq";
2650
2651/// The SQL type both ledger instants bind as.
2652///
2653/// Backend-forked exactly as the generated code was (#1996): Postgres binds
2654/// `Timestamptz`; `SQLite` binds `TimestamptzSqlite` (there is no
2655/// `FromSql<Timestamptz, Sqlite>`).
2656#[cfg(all(feature = "db", not(feature = "sqlite")))]
2657type Instant = diesel::sql_types::Timestamptz;
2658/// See the Postgres definition.
2659#[cfg(all(feature = "db", feature = "sqlite"))]
2660type Instant = diesel::sql_types::TimestamptzSqlite;
2661
2662#[cfg(feature = "db")]
2663#[doc(hidden)]
2664#[allow(
2665    clippy::too_many_lines,
2666    reason = "the #2323 high-water cross-checks, the sequence allocation and the \
2667              transaction-time clamp are one indivisible decision: each rule's \
2668              rationale is stated where it is enforced, and splitting them would \
2669              scatter the argument for why an append is allowed to proceed"
2670)]
2671pub async fn append_revision(
2672    conn: &mut crate::db::RuntimeConnection,
2673    append: LedgerAppend<'_>,
2674) -> crate::AutumnResult<()> {
2675    use diesel::sql_types::{BigInt, Nullable, Text};
2676    use diesel_async::RunQueryDsl;
2677
2678    let LedgerAppend {
2679        table_name,
2680        tenant_id,
2681        record_id,
2682        op,
2683        actor,
2684        request_id,
2685        snapshot,
2686        valid_from,
2687    } = append;
2688
2689    let unreadable = |detail: String| {
2690        crate::AutumnError::internal_server_error(LedgerError::ChainUnreadable {
2691            table: table_name.to_string(),
2692            record_id,
2693            detail,
2694        })
2695    };
2696
2697    let state = read_chain_state(conn, table_name, record_id, tenant_id).await?;
2698
2699    // #2323: the mark is cross-checked on the write path too, not only by
2700    // `ledger_verify`. Without this the append is a free repair — delete the newest
2701    // revision and the mark, wait for ordinary traffic, and the append would re-create
2702    // both consistently, exactly the laundering this issue exists to stop.
2703    //
2704    // One rule decides which disagreements refuse: refuse where appending would destroy
2705    // the evidence, allow where the evidence survives the append.
2706    //
2707    //   * Mark gone beside a live chain, and mark describing a different revision — a
2708    //     different hash or a different instant — at the head's own sequence number: the
2709    //     append would overwrite the mark and the disagreement with it. Refuse. The second
2710    //     test is identical to the one `head_versus_mark` reports `HighWaterMismatch` on,
2711    //     so nothing verify can see is something an ordinary write can erase.
2712    //   * Mark behind the head: legitimate, since a pre-#2323 node in a mixed-version
2713    //     fleet appends without raising the mark. Allow; the write heals it, as
2714    //     `HighWaterBehind`'s docs promise.
2715    //   * Mark ahead of the head, a deleted tail: the append allocates past the gap, which
2716    //     is permanent. Allow.
2717    //   * Mark present with no chain at all, a wholly erased chain: the append starts above
2718    //     seq 1, which `verify` reports as a `MissingRevision` forever. Allow — bricking
2719    //     the record would buy no evidence that is not already permanent.
2720    if state.head_seq.is_some() && state.mark_seq.is_none() {
2721        return Err(unreadable(
2722            "the record has revisions but no high-water mark; the mark that makes a \
2723             deleted revision permanent evidence is itself missing. Appending here \
2724             would re-create it over whatever is left. Run `ledger_verify` and \
2725             re-apply the high-water migration's backfill once you know what happened"
2726                .to_string(),
2727        ));
2728    }
2729    // A mark names a real revision, so its sequence number starts at 1. Below
2730    // that it contributes nothing to the `max` below — the append would allocate
2731    // as if no mark existed and then raise the mark over the top of it, erasing
2732    // an accusation `ledger_verify` had already made (`MissingRevision` beside
2733    // an empty chain, `HighWaterBehind` beside a live one). Both schemas carry a
2734    // `CHECK (high_seq >= 1)` so the row cannot be written in the first place;
2735    // this is the second lock on the same door, for a database whose constraint
2736    // was dropped.
2737    if state.mark_seq.is_some_and(|seq| seq < 1) {
2738        return Err(unreadable(
2739            "the record's high-water mark carries a sequence number below 1, which \
2740             names no revision. Appending would allocate as though the mark were \
2741             absent and then overwrite it — run `ledger_verify`, and check whether \
2742             the mark table's CHECK constraint is still in place"
2743                .to_string(),
2744        ));
2745    }
2746    // Deliberately the same condition `head_versus_mark` reports
2747    // `HighWaterMismatch` on, `recorded_at` included. Anything verify calls a
2748    // mismatch and the writer overwrites anyway is an accusation ordinary
2749    // traffic launders away — the mark's instant carries no hash of its own, so
2750    // rewriting only that field would otherwise be reported once and then
2751    // quietly erased by the next append.
2752    if state.mark_seq == state.head_seq
2753        && (state.mark_hash.as_deref() != state.head_hash.as_deref()
2754            || state.mark_recorded_at != state.head_recorded_at)
2755    {
2756        return Err(unreadable(
2757            "the high-water mark and the chain head carry the same sequence number \
2758             but describe different revisions; one of the two was rewritten out of \
2759             band. Appending here would settle the disagreement in the writer's \
2760             favour and erase it — run `ledger_verify` instead"
2761                .to_string(),
2762        ));
2763    }
2764
2765    // The sequence number is allocated from the greater of the surviving chain
2766    // head and the out-of-band high-water mark, never from the chain alone.
2767    // Deleting the newest revision and letting an ordinary write land therefore
2768    // leaves a gap where the deleted revision was, instead of quietly re-using
2769    // its number.
2770    let seq_base: i64 = state.head_seq.unwrap_or(0).max(state.mark_seq.unwrap_or(0));
2771    if seq_base == i64::MAX {
2772        // Saturating here would allocate `i64::MAX` a second time and fail on
2773        // the chain unique index with an opaque constraint error, once per
2774        // attempt, forever. Say what actually happened.
2775        return Err(unreadable(
2776            "the record's chain is at i64::MAX and cannot be continued; no further \
2777             revision can be numbered"
2778                .to_string(),
2779        ));
2780    }
2781    let seq: i64 = seq_base.saturating_add(1);
2782    // The chain still links to the revision that is actually there.
2783    let prev_hash: Option<String> = state.head_hash;
2784
2785    // Transaction time, sourced from the database rather than this host's clock
2786    // and clamped so it can never precede the revision it follows. Truncated to
2787    // microseconds, the precision both storage tiers keep, so the value that is
2788    // hashed is the value that comes back on read and `ledger_verify` stays
2789    // true.
2790    //
2791    // The mark's instant is used as a floor only when the mark reaches PAST the
2792    // head — the truncation case, where the revision that carried the real
2793    // instant is gone. Otherwise the head's own instant, which its hash covers,
2794    // is the floor: the mark's carries no hash of its own, so trusting it in the
2795    // ordinary case would let one out-of-band UPDATE ratchet the record's
2796    // transaction time forward for good. `Option`'s ordering puts `None` below
2797    // every `Some`, so `max` picks whichever floor exists.
2798    let floor = if state.mark_seq > state.head_seq {
2799        state.head_recorded_at.max(state.mark_recorded_at)
2800    } else {
2801        state.head_recorded_at
2802    };
2803    let recorded_at = monotonic_recorded_at(state.db_now, floor).ok_or_else(|| {
2804        unreadable(format!(
2805            "the record's transaction-time floor is more than {LEDGER_MAX_CLOCK_SKEW_SECS}s \
2806             ahead of the database's own clock; writing behind it would hash that instant \
2807             into the chain and make every as-of query miss the revision. Refusing rather \
2808             than ratcheting",
2809        ))
2810    })?;
2811    // Valid time: the model's own instant when it declares one, else the
2812    // transaction time (the fact became true when the database learned it).
2813    let valid_from = valid_from.map_or(recorded_at, truncate_to_micros);
2814
2815    let hash: String = revision_hash(&RevisionHashInput {
2816        prev_hash: prev_hash.as_deref(),
2817        table_name,
2818        tenant_id,
2819        record_id,
2820        seq,
2821        op,
2822        actor,
2823        request_id,
2824        snapshot: &snapshot,
2825        valid_from,
2826        recorded_at,
2827    });
2828    let snapshot_str: String = canonical_json(&snapshot);
2829
2830    diesel::sql_query(REVISION_INSERT_SQL)
2831        .bind::<Text, _>(table_name)
2832        .bind::<Nullable<Text>, _>(tenant_id)
2833        .bind::<BigInt, _>(record_id)
2834        .bind::<BigInt, _>(seq)
2835        .bind::<Text, _>(op.as_str())
2836        .bind::<Text, _>(actor)
2837        .bind::<Nullable<Text>, _>(request_id)
2838        .bind::<Text, _>(snapshot_str)
2839        .bind::<Instant, _>(valid_from)
2840        .bind::<Instant, _>(recorded_at)
2841        .bind::<Nullable<Text>, _>(prev_hash.as_deref())
2842        .bind::<Text, _>(hash.as_str())
2843        .execute(conn)
2844        .await
2845        .map_err(crate::AutumnError::from)?;
2846
2847    diesel::sql_query(HIGH_WATER_UPSERT_SQL)
2848        .bind::<Text, _>(table_name)
2849        .bind::<Nullable<Text>, _>(tenant_id)
2850        .bind::<BigInt, _>(record_id)
2851        .bind::<BigInt, _>(seq)
2852        .bind::<Text, _>(hash.as_str())
2853        .bind::<Instant, _>(recorded_at)
2854        .execute(conn)
2855        .await
2856        .map_err(crate::AutumnError::from)?;
2857
2858    Ok(())
2859}
2860
2861/// Link-time marker for a ledgered table. Emitted by `#[repository]`.
2862#[doc(hidden)]
2863pub struct LedgeredTableDescriptor {
2864    /// The ledgered table.
2865    pub table: &'static str,
2866}
2867
2868inventory::collect!(LedgeredTableDescriptor);
2869
2870/// Whether a linked repository ledgers `table`.
2871///
2872/// Matches the bare table name. The check cannot see a repository that is not
2873/// linked into the binary.
2874#[doc(hidden)]
2875#[must_use]
2876pub fn is_ledgered_table(table: &str) -> bool {
2877    static TABLES: std::sync::OnceLock<std::collections::HashSet<&'static str>> =
2878        std::sync::OnceLock::new();
2879    TABLES
2880        .get_or_init(|| {
2881            inventory::iter::<LedgeredTableDescriptor>
2882                .into_iter()
2883                .map(|descriptor| descriptor.table)
2884                .collect()
2885        })
2886        .contains(table)
2887}
2888
2889/// Refuse a raw write to `table` when it is ledgered.
2890///
2891/// Call before framework SQL that changes rows outside the owning repository.
2892/// Public for macro-generated code only.
2893///
2894/// # Errors
2895///
2896/// [`LedgerError::OutOfBandWrite`], as a conflict, when `table` is ledgered.
2897#[doc(hidden)]
2898pub fn refuse_out_of_band_write(table: &str, path: &'static str) -> crate::AutumnResult<()> {
2899    if is_ledgered_table(table) {
2900        return Err(crate::AutumnError::conflict(LedgerError::OutOfBandWrite {
2901            table: table.to_string(),
2902            path,
2903        }));
2904    }
2905    Ok(())
2906}
2907
2908#[cfg(test)]
2909mod tests {
2910    use super::*;
2911
2912    /// The two statements [`append_revision`] writes (#1699, #2323).
2913    ///
2914    /// These used to be inlined at every generated write site, where a macro
2915    /// test could count them for parity. They compile once now, so their shape
2916    /// is asserted next to them; the macro keeps only the parity check that no
2917    /// write path skips the append.
2918    #[cfg(feature = "db")]
2919    #[test]
2920    fn ledger_append_statements_keep_their_contract() {
2921        // The snapshot is stored as TEXT. A `::jsonb` cast would re-render every
2922        // number through `numeric` and hand back different bytes than were
2923        // hashed, so `ledger_verify` would report `HashMismatch` on an
2924        // untampered chain.
2925        assert!(
2926            !REVISION_INSERT_SQL.contains("::jsonb"),
2927            "the snapshot must not be cast to jsonb: {REVISION_INSERT_SQL}"
2928        );
2929        assert!(
2930            REVISION_INSERT_SQL.contains(
2931                "(table_name, tenant_id, record_id, seq, op, actor, request_id, \
2932                 snapshot, valid_from, recorded_at, prev_hash, hash)"
2933            ),
2934            "the revision insert must name all twelve columns: {REVISION_INSERT_SQL}"
2935        );
2936
2937        // The mark can be raised but never lowered — that guard is what makes it
2938        // monotonic in the database rather than merely in the code above it.
2939        assert!(
2940            HIGH_WATER_UPSERT_SQL.contains("ON CONFLICT (table_name, tenant_key, record_id)"),
2941            "the mark is keyed per (table, tenant, record): {HIGH_WATER_UPSERT_SQL}"
2942        );
2943        assert!(
2944            HIGH_WATER_UPSERT_SQL
2945                .to_ascii_lowercase()
2946                .contains("where _autumn_ledger_high_water.high_seq < excluded.high_seq"),
2947            "the mark must refuse to move backwards: {HIGH_WATER_UPSERT_SQL}"
2948        );
2949    }
2950    use chrono::TimeZone as _;
2951    use serde_json::json;
2952
2953    fn at(secs: i64) -> DateTime<Utc> {
2954        Utc.timestamp_opt(1_800_000_000 + secs, 0)
2955            .single()
2956            .expect("valid instant")
2957    }
2958
2959    /// Build a well-formed chain of `n` revisions whose snapshots carry an
2960    /// incrementing `title`, each one hashed and linked to its predecessor.
2961    fn chain(n: i64) -> Vec<LedgerRevision> {
2962        let mut out: Vec<LedgerRevision> = Vec::new();
2963        let mut prev_hash: Option<String> = None;
2964        for seq in 1..=n {
2965            let snapshot = json!({ "id": 7, "title": format!("v{seq}"), "deleted_at": null });
2966            let op = if seq == 1 {
2967                VersionOp::Insert
2968            } else {
2969                VersionOp::Update
2970            };
2971            let recorded_at = at(seq * 10);
2972            let hash = revision_hash(&RevisionHashInput {
2973                prev_hash: prev_hash.as_deref(),
2974                table_name: "widgets",
2975                tenant_id: None,
2976                record_id: 7,
2977                seq,
2978                op,
2979                actor: "alice",
2980                request_id: None,
2981                snapshot: &snapshot,
2982                valid_from: recorded_at,
2983                recorded_at,
2984            });
2985            out.push(LedgerRevision {
2986                id: seq,
2987                table_name: "widgets".to_owned(),
2988                tenant_id: None,
2989                record_id: 7,
2990                seq,
2991                op,
2992                actor: "alice".to_owned(),
2993                request_id: None,
2994                snapshot,
2995                valid_from: recorded_at,
2996                recorded_at,
2997                prev_hash: prev_hash.clone(),
2998                hash: hash.clone(),
2999            });
3000            prev_hash = Some(hash);
3001        }
3002        out
3003    }
3004
3005    // ── canonical_json ───────────────────────────────────────────────
3006
3007    #[test]
3008    fn canonical_json_sorts_object_keys() {
3009        let value = json!({ "b": 1, "a": 2, "c": 3 });
3010        assert_eq!(canonical_json(&value), r#"{"a":2,"b":1,"c":3}"#);
3011    }
3012
3013    #[test]
3014    fn canonical_json_is_insertion_order_independent() {
3015        let mut first = serde_json::Map::new();
3016        first.insert("z".to_owned(), json!(1));
3017        first.insert("a".to_owned(), json!(2));
3018        let mut second = serde_json::Map::new();
3019        second.insert("a".to_owned(), json!(2));
3020        second.insert("z".to_owned(), json!(1));
3021
3022        assert_eq!(
3023            canonical_json(&serde_json::Value::Object(first)),
3024            canonical_json(&serde_json::Value::Object(second)),
3025        );
3026    }
3027
3028    #[test]
3029    fn canonical_json_sorts_nested_objects_and_preserves_array_order() {
3030        let value = json!({ "outer": { "b": [3, 1, 2], "a": { "y": 1, "x": 2 } } });
3031        assert_eq!(
3032            canonical_json(&value),
3033            r#"{"outer":{"a":{"x":2,"y":1},"b":[3,1,2]}}"#
3034        );
3035    }
3036
3037    #[test]
3038    fn canonical_json_escapes_keys_and_values() {
3039        let value = json!({ "a\"b": "c\nd" });
3040        assert_eq!(canonical_json(&value), r#"{"a\"b":"c\nd"}"#);
3041    }
3042
3043    #[test]
3044    fn canonical_json_handles_scalars_and_nulls() {
3045        assert_eq!(canonical_json(&json!(null)), "null");
3046        assert_eq!(canonical_json(&json!(true)), "true");
3047        assert_eq!(canonical_json(&json!(12)), "12");
3048        assert_eq!(canonical_json(&json!("s")), "\"s\"");
3049        assert_eq!(canonical_json(&json!([])), "[]");
3050        assert_eq!(canonical_json(&json!({})), "{}");
3051    }
3052
3053    // ── revision_hash ────────────────────────────────────────────────
3054
3055    fn base_input(snapshot: &serde_json::Value) -> RevisionHashInput<'_> {
3056        RevisionHashInput {
3057            prev_hash: None,
3058            table_name: "widgets",
3059            tenant_id: None,
3060            record_id: 7,
3061            seq: 1,
3062            op: VersionOp::Insert,
3063            actor: "alice",
3064            request_id: None,
3065            snapshot,
3066            valid_from: at(0),
3067            recorded_at: at(0),
3068        }
3069    }
3070
3071    #[test]
3072    fn revision_hash_is_deterministic() {
3073        let snapshot = json!({ "id": 7, "title": "hello" });
3074        let input = base_input(&snapshot);
3075        assert_eq!(revision_hash(&input), revision_hash(&input));
3076    }
3077
3078    #[test]
3079    fn revision_hash_is_hex_sha256() {
3080        let snapshot = json!({});
3081        let hash = revision_hash(&base_input(&snapshot));
3082        assert_eq!(hash.len(), 64);
3083        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
3084    }
3085
3086    #[test]
3087    fn revision_hash_ignores_snapshot_key_order() {
3088        let a = json!({ "id": 7, "title": "hello" });
3089        let mut map = serde_json::Map::new();
3090        map.insert("title".to_owned(), json!("hello"));
3091        map.insert("id".to_owned(), json!(7));
3092        let b = serde_json::Value::Object(map);
3093
3094        assert_eq!(
3095            revision_hash(&base_input(&a)),
3096            revision_hash(&base_input(&b))
3097        );
3098    }
3099
3100    #[test]
3101    fn revision_hash_changes_with_every_covered_field() {
3102        let snapshot = json!({ "id": 7, "title": "hello" });
3103        let other_snapshot = json!({ "id": 7, "title": "changed" });
3104        let baseline = revision_hash(&base_input(&snapshot));
3105
3106        let mutations: Vec<RevisionHashInput<'_>> = vec![
3107            RevisionHashInput {
3108                prev_hash: Some("aa"),
3109                ..base_input(&snapshot)
3110            },
3111            RevisionHashInput {
3112                table_name: "gadgets",
3113                ..base_input(&snapshot)
3114            },
3115            RevisionHashInput {
3116                tenant_id: Some("t1"),
3117                ..base_input(&snapshot)
3118            },
3119            RevisionHashInput {
3120                record_id: 8,
3121                ..base_input(&snapshot)
3122            },
3123            RevisionHashInput {
3124                seq: 2,
3125                ..base_input(&snapshot)
3126            },
3127            RevisionHashInput {
3128                op: VersionOp::Update,
3129                ..base_input(&snapshot)
3130            },
3131            RevisionHashInput {
3132                actor: "mallory",
3133                ..base_input(&snapshot)
3134            },
3135            RevisionHashInput {
3136                request_id: Some("r1"),
3137                ..base_input(&snapshot)
3138            },
3139            RevisionHashInput {
3140                snapshot: &other_snapshot,
3141                ..base_input(&snapshot)
3142            },
3143            RevisionHashInput {
3144                valid_from: at(1),
3145                ..base_input(&snapshot)
3146            },
3147            RevisionHashInput {
3148                recorded_at: at(1),
3149                ..base_input(&snapshot)
3150            },
3151        ];
3152
3153        for (idx, mutated) in mutations.iter().enumerate() {
3154            assert_ne!(
3155                revision_hash(mutated),
3156                baseline,
3157                "mutation {idx} must change the hash"
3158            );
3159        }
3160    }
3161
3162    #[test]
3163    fn revision_hash_length_prefixes_defeat_field_smuggling() {
3164        // Without length prefixes, moving a suffix of `actor` onto the front of
3165        // `request_id` would produce the same byte stream.
3166        let snapshot = json!({});
3167        let a = RevisionHashInput {
3168            actor: "ab",
3169            request_id: Some("c"),
3170            ..base_input(&snapshot)
3171        };
3172        let b = RevisionHashInput {
3173            actor: "a",
3174            request_id: Some("bc"),
3175            ..base_input(&snapshot)
3176        };
3177        assert_ne!(revision_hash(&a), revision_hash(&b));
3178    }
3179
3180    #[test]
3181    fn revision_hash_truncates_sub_microsecond_precision() {
3182        let snapshot = json!({});
3183        let micros = Utc.timestamp_nanos(1_800_000_000_000_123_000);
3184        let nanos = Utc.timestamp_nanos(1_800_000_000_000_123_456);
3185        let a = RevisionHashInput {
3186            recorded_at: micros,
3187            ..base_input(&snapshot)
3188        };
3189        let b = RevisionHashInput {
3190            recorded_at: nanos,
3191            ..base_input(&snapshot)
3192        };
3193        assert_eq!(revision_hash(&a), revision_hash(&b));
3194    }
3195
3196    #[test]
3197    fn compute_hash_matches_the_write_path_hash() {
3198        let revisions = chain(3);
3199        for revision in &revisions {
3200            assert_eq!(revision.compute_hash(), revision.hash);
3201        }
3202    }
3203
3204    // ── verify_chain ─────────────────────────────────────────────────
3205
3206    #[test]
3207    fn verify_chain_accepts_an_intact_chain() {
3208        let revisions = chain(3);
3209        let report = verify_chain(7, &revisions);
3210        assert!(report.is_intact(), "{report:?}");
3211        assert_eq!(report.revisions_checked, 3);
3212        assert_eq!(
3213            report.head_hash.as_deref(),
3214            Some(revisions[2].hash.as_str())
3215        );
3216    }
3217
3218    #[test]
3219    fn verify_chain_accepts_an_empty_chain() {
3220        let report = verify_chain(7, &[]);
3221        assert!(report.is_intact());
3222        assert_eq!(report.revisions_checked, 0);
3223        assert_eq!(report.head_hash, None);
3224    }
3225
3226    #[test]
3227    fn verify_chain_has_no_false_positives_across_repeated_runs() {
3228        let revisions = chain(12);
3229        for _ in 0..5 {
3230            assert!(verify_chain(7, &revisions).is_intact());
3231        }
3232    }
3233
3234    #[test]
3235    fn verify_chain_detects_a_mutated_revision() {
3236        let mut revisions = chain(3);
3237        revisions[1].snapshot = json!({ "id": 7, "title": "tampered", "deleted_at": null });
3238
3239        let report = verify_chain(7, &revisions);
3240        let broken = report.broken.expect("mutation must be detected");
3241        assert_eq!(broken.kind, LedgerBreak::HashMismatch);
3242        assert_eq!(broken.seq, 2);
3243        assert_eq!(broken.revision_id, Some(2));
3244        assert_eq!(report.head_hash, None);
3245    }
3246
3247    #[test]
3248    fn verify_chain_detects_a_re_hashed_mutation_at_the_next_link() {
3249        let mut revisions = chain(3);
3250        revisions[1].snapshot = json!({ "id": 7, "title": "tampered", "deleted_at": null });
3251        revisions[1].hash = revisions[1].compute_hash();
3252
3253        let broken = verify_chain(7, &revisions)
3254            .broken
3255            .expect("a re-hashed mutation must break the next link");
3256        assert_eq!(broken.kind, LedgerBreak::PrevHashMismatch);
3257        assert_eq!(broken.seq, 3);
3258    }
3259
3260    #[test]
3261    fn verify_chain_detects_a_deleted_revision() {
3262        let mut revisions = chain(4);
3263        revisions.remove(1); // drop seq 2
3264
3265        let broken = verify_chain(7, &revisions)
3266            .broken
3267            .expect("deletion detected");
3268        assert_eq!(broken.kind, LedgerBreak::MissingRevision);
3269        assert_eq!(broken.seq, 2);
3270        assert_eq!(broken.revision_id, None);
3271    }
3272
3273    #[test]
3274    fn verify_chain_detects_a_deleted_head_revision() {
3275        let mut revisions = chain(3);
3276        revisions.remove(0); // drop seq 1
3277
3278        let broken = verify_chain(7, &revisions)
3279            .broken
3280            .expect("head deletion detected");
3281        assert_eq!(broken.kind, LedgerBreak::MissingRevision);
3282        assert_eq!(broken.seq, 1);
3283    }
3284
3285    #[test]
3286    fn verify_chain_detects_an_inserted_revision() {
3287        let mut revisions = chain(3);
3288        let forged = LedgerRevision {
3289            id: 99,
3290            seq: 2,
3291            snapshot: json!({ "id": 7, "title": "forged", "deleted_at": null }),
3292            ..revisions[1].clone()
3293        };
3294        revisions.insert(2, forged); // now seq 1, 2, 2, 3
3295
3296        let broken = verify_chain(7, &revisions)
3297            .broken
3298            .expect("insertion detected");
3299        assert_eq!(broken.kind, LedgerBreak::DuplicateSeq);
3300        assert_eq!(broken.seq, 2);
3301        assert_eq!(broken.revision_id, Some(99));
3302    }
3303
3304    #[test]
3305    fn verify_chain_detects_an_appended_forgery() {
3306        let mut revisions = chain(3);
3307        let mut forged = revisions[2].clone();
3308        forged.id = 99;
3309        forged.seq = 4;
3310        forged.snapshot = json!({ "id": 7, "title": "forged", "deleted_at": null });
3311        revisions.push(forged);
3312
3313        let broken = verify_chain(7, &revisions).broken.expect("append detected");
3314        assert_eq!(broken.kind, LedgerBreak::HashMismatch);
3315        assert_eq!(broken.seq, 4);
3316    }
3317
3318    #[test]
3319    fn verify_chain_detects_a_dangling_chain_start() {
3320        let mut revisions = chain(2);
3321        revisions[0].prev_hash = Some("deadbeef".to_owned());
3322        revisions[0].hash = revisions[0].compute_hash();
3323        revisions[1].prev_hash = Some(revisions[0].hash.clone());
3324        revisions[1].hash = revisions[1].compute_hash();
3325
3326        let broken = verify_chain(7, &revisions)
3327            .broken
3328            .expect("dangling start detected");
3329        assert_eq!(broken.kind, LedgerBreak::BrokenChainStart);
3330        assert_eq!(broken.seq, 1);
3331    }
3332
3333    #[test]
3334    fn verify_chain_reports_the_first_break_when_several_exist() {
3335        let mut revisions = chain(4);
3336        revisions[1].snapshot = json!({ "id": 7, "title": "first tamper" });
3337        revisions[3].snapshot = json!({ "id": 7, "title": "second tamper" });
3338
3339        let broken = verify_chain(7, &revisions).broken.expect("break detected");
3340        assert_eq!(broken.seq, 2, "the earliest break must be reported");
3341    }
3342
3343    // ── snapshot_as_of ───────────────────────────────────────────────
3344
3345    #[test]
3346    fn snapshot_as_of_returns_none_before_the_first_revision() {
3347        let revisions = chain(3);
3348        assert!(snapshot_as_of(&revisions, LedgerAsOf::transaction(at(0))).is_none());
3349    }
3350
3351    #[test]
3352    fn snapshot_as_of_selects_the_revision_in_force() {
3353        let revisions = chain(3); // recorded at 10, 20, 30
3354        let picked = snapshot_as_of(&revisions, LedgerAsOf::transaction(at(25)))
3355            .expect("a revision is in force at t=25");
3356        assert_eq!(picked.seq, 2);
3357        assert_eq!(picked.snapshot["title"], json!("v2"));
3358    }
3359
3360    #[test]
3361    fn snapshot_as_of_is_inclusive_of_the_recording_instant() {
3362        let revisions = chain(3);
3363        let picked = snapshot_as_of(&revisions, LedgerAsOf::transaction(at(20)))
3364            .expect("the revision recorded exactly at t=20 is in force");
3365        assert_eq!(picked.seq, 2);
3366    }
3367
3368    #[test]
3369    fn snapshot_as_of_with_no_bounds_returns_the_head() {
3370        let revisions = chain(3);
3371        let picked = snapshot_as_of(&revisions, LedgerAsOf::default()).expect("head");
3372        assert_eq!(picked.seq, 3);
3373    }
3374
3375    #[test]
3376    fn snapshot_as_of_separates_valid_time_from_transaction_time() {
3377        // A correction recorded late (transaction time 40) about a fact that
3378        // became true early (valid time 5).
3379        let mut revisions = chain(2);
3380        revisions[1].valid_from = at(5);
3381        revisions[1].recorded_at = at(40);
3382        revisions[1].hash = revisions[1].compute_hash();
3383
3384        // As the database knew it at t=30: only the insert had been recorded.
3385        let known_then = snapshot_as_of(&revisions, LedgerAsOf::transaction(at(30))).expect("rev");
3386        assert_eq!(known_then.seq, 1);
3387
3388        // As the database knows it now, asking what was true at t=6: the
3389        // back-dated correction wins.
3390        let true_then = snapshot_as_of(&revisions, LedgerAsOf::valid(at(6))).expect("rev");
3391        assert_eq!(true_then.seq, 2);
3392
3393        // Bitemporal: what the database believed at t=30 about t=6. The only
3394        // revision it held then became valid at t=10, so as far as it knew, the
3395        // record did not yet exist at t=6.
3396        assert!(snapshot_as_of(&revisions, LedgerAsOf::bitemporal(at(30), at(6))).is_none());
3397
3398        // Once the back-dated correction is on record, the same valid-time
3399        // question gets the corrected answer.
3400        let corrected =
3401            snapshot_as_of(&revisions, LedgerAsOf::bitemporal(at(50), at(6))).expect("rev");
3402        assert_eq!(corrected.seq, 2);
3403
3404        // And it keeps answering for every later instant: a revision is a full
3405        // snapshot, so the correction replaced the insert rather than carving a
3406        // window out of it.
3407        let later =
3408            snapshot_as_of(&revisions, LedgerAsOf::bitemporal(at(50), at(20))).expect("rev");
3409        assert_eq!(later.seq, 2);
3410    }
3411
3412    #[test]
3413    fn snapshot_as_of_hides_a_revision_that_is_not_yet_in_effect() {
3414        // A future-dated statement: recorded now, true from t=500.
3415        let mut revisions = chain(1);
3416        revisions[0].valid_from = at(500);
3417        revisions[0].hash = revisions[0].compute_hash();
3418
3419        assert!(snapshot_as_of(&revisions, LedgerAsOf::valid(at(100))).is_none());
3420        assert_eq!(
3421            snapshot_as_of(&revisions, LedgerAsOf::valid(at(500)))
3422                .expect("in effect")
3423                .seq,
3424            1
3425        );
3426    }
3427
3428    #[test]
3429    fn snapshot_as_of_breaks_valid_time_ties_by_sequence() {
3430        let mut revisions = chain(3);
3431        for revision in &mut revisions {
3432            revision.valid_from = at(5);
3433            revision.hash = revision.compute_hash();
3434        }
3435        let picked = snapshot_as_of(&revisions, LedgerAsOf::valid(at(5))).expect("rev");
3436        assert_eq!(picked.seq, 3, "the latest correction wins a valid-time tie");
3437    }
3438
3439    // ── diffing ──────────────────────────────────────────────────────
3440
3441    #[test]
3442    fn diff_snapshots_reports_changed_added_and_removed_columns() {
3443        let before = json!({ "a": 1, "b": 2, "gone": true });
3444        let after = json!({ "a": 1, "b": 3, "added": "x" });
3445
3446        let changes = diff_snapshots(&before, &after);
3447        assert_eq!(changes.len(), 3);
3448        assert_eq!(changes[0].column, "added");
3449        assert_eq!(changes[0].before, None);
3450        assert_eq!(changes[0].after, Some(json!("x")));
3451        assert_eq!(changes[1].column, "b");
3452        assert_eq!(changes[1].before, Some(json!(2)));
3453        assert_eq!(changes[1].after, Some(json!(3)));
3454        assert_eq!(changes[2].column, "gone");
3455        assert_eq!(changes[2].before, Some(json!(true)));
3456        assert_eq!(changes[2].after, None);
3457    }
3458
3459    #[test]
3460    fn diff_snapshots_of_identical_snapshots_is_empty() {
3461        let value = json!({ "a": 1, "b": [1, 2] });
3462        assert!(diff_snapshots(&value, &value).is_empty());
3463    }
3464
3465    #[test]
3466    fn diff_snapshots_is_sorted_regardless_of_key_order() {
3467        let mut before = serde_json::Map::new();
3468        before.insert("z".to_owned(), json!(1));
3469        before.insert("a".to_owned(), json!(1));
3470        let after = json!({ "a": 2, "z": 2 });
3471
3472        let changes = diff_snapshots(&serde_json::Value::Object(before), &after);
3473        let columns: Vec<&str> = changes.iter().map(|c| c.column.as_str()).collect();
3474        assert_eq!(columns, vec!["a", "z"]);
3475    }
3476
3477    #[test]
3478    fn diff_as_of_between_two_instants() {
3479        let revisions = chain(3);
3480        let diff = diff_as_of(
3481            7,
3482            &revisions,
3483            LedgerAsOf::transaction(at(15)),
3484            LedgerAsOf::transaction(at(35)),
3485        );
3486        assert_eq!(diff.from_seq, Some(1));
3487        assert_eq!(diff.to_seq, Some(3));
3488        assert_eq!(diff.changes.len(), 1);
3489        assert_eq!(diff.changes[0].column, "title");
3490        assert_eq!(diff.changes[0].before, Some(json!("v1")));
3491        assert_eq!(diff.changes[0].after, Some(json!("v3")));
3492    }
3493
3494    #[test]
3495    fn diff_as_of_before_creation_reports_every_column_as_added() {
3496        let revisions = chain(2);
3497        let diff = diff_as_of(
3498            7,
3499            &revisions,
3500            LedgerAsOf::transaction(at(0)),
3501            LedgerAsOf::transaction(at(15)),
3502        );
3503        assert_eq!(diff.from_seq, None);
3504        assert_eq!(diff.to_seq, Some(1));
3505        assert_eq!(diff.changes.len(), 3);
3506        assert!(diff.changes.iter().all(|c| c.before.is_none()));
3507    }
3508
3509    #[test]
3510    fn diff_as_of_over_an_unchanged_window_is_empty() {
3511        let revisions = chain(3);
3512        let diff = diff_as_of(
3513            7,
3514            &revisions,
3515            LedgerAsOf::transaction(at(21)),
3516            LedgerAsOf::transaction(at(29)),
3517        );
3518        assert!(diff.is_empty());
3519        assert_eq!(diff.from_seq, Some(2));
3520        assert_eq!(diff.to_seq, Some(2));
3521    }
3522
3523    // ── live-state cross-check (tail truncation & ledger bypass) ─────
3524
3525    #[test]
3526    fn verify_accepts_a_head_that_matches_the_live_row() {
3527        let report = verify_chain_against(7, &chain(3), LedgerLiveState::Matches);
3528        assert!(report.is_intact(), "{report:?}");
3529    }
3530
3531    #[test]
3532    fn verify_detects_a_truncated_tail_against_the_live_row() {
3533        // Revisions 4 and 5 are lopped off. The surviving chain is internally
3534        // perfect — only the live row exposes the erasure.
3535        let full = chain(5);
3536        let truncated = &full[..3];
3537
3538        assert!(
3539            verify_chain(7, truncated).is_intact(),
3540            "a truncated chain is internally consistent by construction"
3541        );
3542
3543        let broken = verify_chain_against(7, truncated, LedgerLiveState::Diverged)
3544            .broken
3545            .expect("the live row must expose the truncation");
3546        assert_eq!(broken.kind, LedgerBreak::LiveStateMismatch);
3547        assert_eq!(broken.seq, 3, "the break is reported at the surviving head");
3548    }
3549
3550    #[test]
3551    fn verify_detects_a_write_that_never_appended_a_revision() {
3552        // The table moved on — a restore, a counter-cache bump, a raw UPDATE.
3553        let broken = verify_chain_against(7, &chain(2), LedgerLiveState::Diverged)
3554            .broken
3555            .expect("a ledger-bypassing write must be detected");
3556        assert_eq!(broken.kind, LedgerBreak::LiveStateMismatch);
3557        assert_eq!(broken.seq, 2);
3558        assert!(
3559            broken.detail.contains("without appending a revision"),
3560            "{}",
3561            broken.detail
3562        );
3563    }
3564
3565    #[test]
3566    fn verify_does_not_accuse_a_row_that_predates_ledgering() {
3567        // Every existing row on the day a team adopts `ledgered` is in exactly
3568        // this state — live, with no chain yet — and the migration guide says so.
3569        // Reporting it as tampering would be a false positive on an untouched
3570        // deployment. `revisions_checked` is what tells a caller the chain is
3571        // empty; `verify_reports_a_wholly_erased_chain_against_its_mark` covers
3572        // telling this apart from a wholly erased chain.
3573        let report = verify_chain_against(7, &[], LedgerLiveState::Diverged);
3574        assert!(report.is_intact(), "{report:?}");
3575        assert_eq!(report.revisions_checked, 0);
3576        assert_eq!(report.head_hash, None);
3577    }
3578
3579    #[test]
3580    fn verify_detects_a_row_erased_out_of_band() {
3581        let revisions = chain(2);
3582        let broken = verify_chain_against(7, &revisions, LedgerLiveState::Absent)
3583            .broken
3584            .expect("a chain whose record no longer exists must be detected");
3585        assert_eq!(broken.kind, LedgerBreak::LiveStateMismatch);
3586        assert_eq!(broken.seq, 2);
3587    }
3588
3589    #[test]
3590    fn verify_accepts_an_absent_record_with_no_revisions() {
3591        assert!(verify_chain_against(7, &[], LedgerLiveState::Absent).is_intact());
3592    }
3593
3594    #[test]
3595    fn a_chain_internal_break_outranks_a_live_state_mismatch() {
3596        // Both faults present: the earlier, more specific one is reported.
3597        let mut revisions = chain(3);
3598        revisions[1].snapshot = json!({ "id": 7, "title": "tampered" });
3599
3600        let broken = verify_chain_against(7, &revisions, LedgerLiveState::Diverged)
3601            .broken
3602            .expect("break detected");
3603        assert_eq!(broken.kind, LedgerBreak::HashMismatch);
3604        assert_eq!(broken.seq, 2);
3605    }
3606
3607    #[test]
3608    fn verify_skips_the_cross_check_when_the_head_moved_under_the_reader() {
3609        // A concurrent write is not tampering: rather than report a divergence
3610        // it cannot trust, `ledger_verify` passes `NotChecked`.
3611        let report = verify_chain_against(7, &chain(2), LedgerLiveState::NotChecked);
3612        assert!(report.is_intact(), "{report:?}");
3613        assert_eq!(report.head_hash.as_deref(), Some(chain(2)[1].hash.as_str()));
3614    }
3615
3616    // ── the shipped migrations ───────────────────────────────────────
3617
3618    /// The framework's Postgres migrations are duplicated between the
3619    /// control-plane `migrations/` set and the shard-required
3620    /// `version_history_migrations/` set, and `__diesel_schema_migrations` keys
3621    /// on the bare version string — so if the two copies ever drift, whichever
3622    /// set is applied first silently wins and the other is skipped, leaving a
3623    /// schema nobody wrote. Nothing enforced that until now; #2323 doubles the
3624    /// surface, so pin it.
3625    #[test]
3626    fn the_ledger_postgres_migrations_are_identical_in_both_sets() {
3627        for (control, shard_required) in [
3628            (
3629                include_str!("../migrations/20260826000000_create_ledger_revisions/up.sql"),
3630                include_str!(
3631                    "../version_history_migrations/20260826000000_create_ledger_revisions/up.sql"
3632                ),
3633            ),
3634            (
3635                include_str!("../migrations/20260826000000_create_ledger_revisions/down.sql"),
3636                include_str!(
3637                    "../version_history_migrations/20260826000000_create_ledger_revisions/down.sql"
3638                ),
3639            ),
3640            (
3641                include_str!("../migrations/20260901213107_create_ledger_high_water/up.sql"),
3642                include_str!(
3643                    "../version_history_migrations/20260901213107_create_ledger_high_water/up.sql"
3644                ),
3645            ),
3646            (
3647                include_str!("../migrations/20260901213107_create_ledger_high_water/down.sql"),
3648                include_str!(
3649                    "../version_history_migrations/20260901213107_create_ledger_high_water/down.sql"
3650                ),
3651            ),
3652        ] {
3653            assert_eq!(
3654                control, shard_required,
3655                "the control-plane and shard-required copies of a ledger migration must \
3656                 stay byte-identical; they share a version string, so a drift is silent"
3657            );
3658        }
3659    }
3660
3661    /// Both tiers must agree on the table and column names the write path binds
3662    /// against, whatever their type spellings.
3663    #[test]
3664    fn both_tiers_declare_the_same_high_water_columns() {
3665        let pg = include_str!(
3666            "../version_history_migrations/20260901213107_create_ledger_high_water/up.sql"
3667        );
3668        let sqlite = include_str!(
3669            "../version_history_migrations_sqlite/20260901213107_create_ledger_high_water/up.sql"
3670        );
3671        for fragment in [
3672            LEDGER_HIGH_WATER_TABLE,
3673            "table_name",
3674            "tenant_key",
3675            "record_id",
3676            "high_seq",
3677            "head_hash",
3678            "recorded_at",
3679            "PRIMARY KEY (table_name, tenant_key, record_id)",
3680        ] {
3681            assert!(pg.contains(fragment), "postgres migration lacks {fragment}");
3682            assert!(
3683                sqlite.contains(fragment),
3684                "sqlite migration lacks {fragment}"
3685            );
3686        }
3687    }
3688
3689    // ── high-water mark (#2323): truncation survives the next append ──
3690
3691    /// The mark a well-behaved writer would have left for `revisions`.
3692    fn mark_for(revisions: &[LedgerRevision]) -> LedgerHighWater {
3693        let head = revisions.last().expect("a non-empty chain");
3694        LedgerHighWater {
3695            record_id: head.record_id,
3696            seq: head.seq,
3697            hash: head.hash.clone(),
3698            recorded_at: head.recorded_at,
3699        }
3700    }
3701
3702    #[test]
3703    fn verify_accepts_a_chain_its_high_water_mark_agrees_with() {
3704        let revisions = chain(3);
3705        let report = verify_chain_with_high_water(
3706            7,
3707            &revisions,
3708            LedgerLiveState::Matches,
3709            LedgerHighWaterState::Present(&mark_for(&revisions)),
3710        );
3711        assert!(report.is_intact(), "{report:?}");
3712        assert_eq!(
3713            report.head_hash.as_deref(),
3714            Some(revisions[2].hash.as_str())
3715        );
3716    }
3717
3718    #[test]
3719    fn verify_reports_a_truncated_tail_the_high_water_mark_remembers() {
3720        // The #2318 live-row cross-check only sees this in the window before the
3721        // next write. The mark sees it whenever `verify` runs.
3722        let full = chain(3);
3723        let mark = mark_for(&full);
3724        let truncated = &full[..2];
3725
3726        let broken = verify_chain_with_high_water(
3727            7,
3728            truncated,
3729            LedgerLiveState::Matches,
3730            LedgerHighWaterState::Present(&mark),
3731        )
3732        .broken
3733        .expect("the mark must expose the deleted head revision");
3734        assert_eq!(broken.kind, LedgerBreak::MissingRevision);
3735        assert_eq!(
3736            broken.seq, 3,
3737            "the absent sequence number, not the survivor"
3738        );
3739        assert_eq!(broken.revision_id, None);
3740    }
3741
3742    #[test]
3743    fn verify_reports_the_gap_a_post_truncation_append_leaves() {
3744        // The issue's headline case. Revision 3 is deleted, then an ordinary
3745        // write lands: because the writer allocates `max(head, mark) + 1` it
3746        // takes 4, not 3, so the evidence survives the append.
3747        let full = chain(4);
3748        let mut surviving: Vec<LedgerRevision> = full[..2].to_vec();
3749        surviving.push(full[3].clone());
3750
3751        let broken = verify_chain_with_high_water(
3752            7,
3753            &surviving,
3754            LedgerLiveState::Matches,
3755            LedgerHighWaterState::Present(&mark_for(&surviving)),
3756        )
3757        .broken
3758        .expect("the gap left by the re-numbered append must be reported");
3759        assert_eq!(broken.kind, LedgerBreak::MissingRevision);
3760        assert_eq!(broken.seq, 3);
3761    }
3762
3763    #[test]
3764    fn verify_reports_a_wholly_erased_chain_against_its_mark() {
3765        // Previously indistinguishable from a row that predates ledgering — the
3766        // limitation `verify_does_not_accuse_a_row_that_predates_ledgering`
3767        // documents. The mark tells the two apart.
3768        let mark = mark_for(&chain(3));
3769        let broken = verify_chain_with_high_water(
3770            7,
3771            &[],
3772            LedgerLiveState::Absent,
3773            LedgerHighWaterState::Present(&mark),
3774        )
3775        .broken
3776        .expect("an erased chain whose mark survives must be reported");
3777        assert_eq!(broken.kind, LedgerBreak::MissingRevision);
3778        assert_eq!(broken.seq, 1);
3779    }
3780
3781    #[test]
3782    fn verify_still_accepts_a_row_that_predates_ledgering() {
3783        // No revisions and no mark: exactly what every existing row looks like
3784        // on the day a team adopts `ledgered`. Still not an accusation.
3785        let report = verify_chain_with_high_water(
3786            7,
3787            &[],
3788            LedgerLiveState::Diverged,
3789            LedgerHighWaterState::Absent,
3790        );
3791        assert!(report.is_intact(), "{report:?}");
3792        assert_eq!(report.revisions_checked, 0);
3793    }
3794
3795    #[test]
3796    fn verify_reports_a_deleted_high_water_row() {
3797        // Deleting the mark is the obvious way to restore the original attack,
3798        // so its absence beside a live chain is itself the accusation.
3799        let broken = verify_chain_with_high_water(
3800            7,
3801            &chain(3),
3802            LedgerLiveState::Matches,
3803            LedgerHighWaterState::Absent,
3804        )
3805        .broken
3806        .expect("a chain with no mark at all must be reported");
3807        assert_eq!(broken.kind, LedgerBreak::HighWaterMissing);
3808        assert_eq!(broken.seq, 3);
3809    }
3810
3811    #[test]
3812    fn verify_reports_a_high_water_mark_rolled_backwards() {
3813        let revisions = chain(3);
3814        let mut mark = mark_for(&revisions);
3815        mark.seq = 1;
3816        mark.hash = revisions[0].hash.clone();
3817        mark.recorded_at = revisions[0].recorded_at;
3818
3819        let broken = verify_chain_with_high_water(
3820            7,
3821            &revisions,
3822            LedgerLiveState::Matches,
3823            LedgerHighWaterState::Present(&mark),
3824        )
3825        .broken
3826        .expect("a rolled-back mark must be reported");
3827        assert_eq!(broken.kind, LedgerBreak::HighWaterBehind);
3828        assert_eq!(broken.seq, 3);
3829    }
3830
3831    #[test]
3832    fn verify_reports_a_rewritten_high_water_hash() {
3833        let revisions = chain(3);
3834        let mut mark = mark_for(&revisions);
3835        mark.hash = "0".repeat(64);
3836
3837        let broken = verify_chain_with_high_water(
3838            7,
3839            &revisions,
3840            LedgerLiveState::Matches,
3841            LedgerHighWaterState::Present(&mark),
3842        )
3843        .broken
3844        .expect("a mark that no longer names the head revision must be reported");
3845        assert_eq!(broken.kind, LedgerBreak::HighWaterMismatch);
3846        assert_eq!(broken.seq, 3);
3847    }
3848
3849    #[test]
3850    fn verify_reports_a_high_water_mark_whose_instant_was_rewritten() {
3851        // `recorded_at` on the mark is the floor the next write clamps against,
3852        // so moving it is a way to steer future transaction times.
3853        let revisions = chain(3);
3854        let mut mark = mark_for(&revisions);
3855        mark.recorded_at = at(9_999);
3856
3857        let broken = verify_chain_with_high_water(
3858            7,
3859            &revisions,
3860            LedgerLiveState::Matches,
3861            LedgerHighWaterState::Present(&mark),
3862        )
3863        .broken
3864        .expect("a mark whose instant disagrees with the head must be reported");
3865        assert_eq!(broken.kind, LedgerBreak::HighWaterMismatch);
3866    }
3867
3868    #[test]
3869    fn a_chain_internal_break_outranks_a_high_water_break() {
3870        let mut revisions = chain(3);
3871        let mark = mark_for(&revisions);
3872        revisions[1].snapshot = json!({ "id": 7, "title": "tampered" });
3873
3874        let broken = verify_chain_with_high_water(
3875            7,
3876            &revisions,
3877            LedgerLiveState::Matches,
3878            LedgerHighWaterState::Present(&mark),
3879        )
3880        .broken
3881        .expect("break detected");
3882        assert_eq!(broken.kind, LedgerBreak::HashMismatch);
3883        assert_eq!(broken.seq, 2);
3884    }
3885
3886    #[test]
3887    fn a_high_water_break_outranks_a_live_state_mismatch() {
3888        // A truncated tail trips both. `MissingRevision` names the sequence
3889        // number that is gone, which `LiveStateMismatch` cannot.
3890        let full = chain(3);
3891        let mark = mark_for(&full);
3892        let broken = verify_chain_with_high_water(
3893            7,
3894            &full[..2],
3895            LedgerLiveState::Diverged,
3896            LedgerHighWaterState::Present(&mark),
3897        )
3898        .broken
3899        .expect("break detected");
3900        assert_eq!(broken.kind, LedgerBreak::MissingRevision);
3901        assert_eq!(broken.seq, 3);
3902    }
3903
3904    #[test]
3905    fn verify_chain_against_still_checks_without_a_mark() {
3906        // The three-argument entry point stays exactly as it was: no mark, no
3907        // high-water reporting, so a caller that has not migrated is unaffected.
3908        let report = verify_chain_against(7, &chain(3), LedgerLiveState::Matches);
3909        assert!(report.is_intact(), "{report:?}");
3910    }
3911
3912    // ── transaction time is non-decreasing along a chain (#2323) ──────
3913
3914    #[test]
3915    fn verify_reports_a_transaction_time_that_moves_backwards() {
3916        // Sourcing `recorded_at` from the database and clamping it against the
3917        // predecessor makes a regression impossible by construction, so one in
3918        // stored data is either a forgery or a chain written by a pre-#2323
3919        // writer across a clock step.
3920        let mut revisions = chain(3);
3921        revisions[2].recorded_at = revisions[1].recorded_at - chrono::Duration::seconds(1);
3922        revisions[2].hash = revisions[2].compute_hash();
3923
3924        let broken = verify_chain(7, &revisions)
3925            .broken
3926            .expect("a backwards transaction time must be reported");
3927        assert_eq!(broken.kind, LedgerBreak::RecordedAtRegression);
3928        assert_eq!(broken.seq, 3);
3929    }
3930
3931    #[test]
3932    fn a_transaction_time_regression_never_masks_a_real_break() {
3933        // A chain written before #2323 can carry a legitimate regression across
3934        // NTP skew between two application nodes, and its rows are immutable — so
3935        // if the regression outranked the other checks it would blind them on
3936        // that record forever. Both faults present: the truncation is reported.
3937        let full = chain(4);
3938        let mark = mark_for(&full);
3939        let mut truncated = full[..3].to_vec();
3940        truncated[2].recorded_at = truncated[1].recorded_at - chrono::Duration::seconds(1);
3941        truncated[2].hash = truncated[2].compute_hash();
3942        // Re-link so only the regression and the truncation remain.
3943        let mut fixed = truncated.clone();
3944        fixed[2].prev_hash = Some(fixed[1].hash.clone());
3945        fixed[2].hash = fixed[2].compute_hash();
3946
3947        let broken = verify_chain_with_high_water(
3948            7,
3949            &fixed,
3950            LedgerLiveState::Matches,
3951            LedgerHighWaterState::Present(&mark),
3952        )
3953        .broken
3954        .expect("break detected");
3955        assert_eq!(
3956            broken.kind,
3957            LedgerBreak::MissingRevision,
3958            "the deleted revision outranks the backwards clock: {broken:?}"
3959        );
3960        assert_eq!(broken.seq, 4);
3961
3962        // With nothing else wrong, the regression is what the report says.
3963        let broken = verify_chain(7, &fixed)
3964            .broken
3965            .expect("the regression is still reported on its own");
3966        assert_eq!(broken.kind, LedgerBreak::RecordedAtRegression);
3967        assert_eq!(broken.seq, 3);
3968    }
3969
3970    #[test]
3971    fn equal_transaction_times_are_not_a_regression() {
3972        // Two writes inside one database clock tick are legitimate: the
3973        // guarantee is non-decreasing, not strictly increasing.
3974        let mut revisions = chain(3);
3975        revisions[2].recorded_at = revisions[1].recorded_at;
3976        revisions[2].hash = revisions[2].compute_hash();
3977
3978        assert!(verify_chain(7, &revisions).is_intact());
3979    }
3980
3981    #[test]
3982    fn valid_time_may_move_backwards_without_being_a_break() {
3983        // Valid time is a business claim: a back-dated correction is the whole
3984        // point of the second axis.
3985        let mut revisions = chain(3);
3986        revisions[2].valid_from = revisions[0].valid_from - chrono::Duration::days(30);
3987        revisions[2].hash = revisions[2].compute_hash();
3988
3989        assert!(verify_chain(7, &revisions).is_intact());
3990    }
3991
3992    #[test]
3993    fn monotonic_recorded_at_never_precedes_its_floor() {
3994        let now = at(100);
3995        assert_eq!(monotonic_recorded_at(now, None), Some(now));
3996        assert_eq!(monotonic_recorded_at(now, Some(at(50))), Some(now));
3997        assert_eq!(monotonic_recorded_at(now, Some(at(150))), Some(at(150)));
3998        assert_eq!(monotonic_recorded_at(now, Some(now)), Some(now));
3999    }
4000
4001    #[test]
4002    fn monotonic_recorded_at_truncates_both_sides_to_micros() {
4003        // The value that is hashed has to be the value the database stores, and
4004        // both tiers keep microseconds.
4005        let now = at(100) + chrono::Duration::nanoseconds(1_500);
4006        assert_eq!(
4007            monotonic_recorded_at(now, None),
4008            Some(at(100) + chrono::Duration::microseconds(1))
4009        );
4010        let floor = at(200) + chrono::Duration::nanoseconds(1_500);
4011        assert_eq!(
4012            monotonic_recorded_at(at(100), Some(floor)),
4013            Some(at(200) + chrono::Duration::microseconds(1))
4014        );
4015    }
4016
4017    #[test]
4018    fn monotonic_recorded_at_refuses_a_floor_far_ahead_of_the_database_clock() {
4019        // The high-water mark's instant carries no hash of its own, so an
4020        // out-of-band UPDATE could otherwise ratchet a record's transaction time
4021        // arbitrarily far forward — every later revision would hash a future
4022        // instant correctly while `snapshot_as_of` quietly stopped returning it.
4023        let now = at(0);
4024        let inside = now + chrono::Duration::seconds(LEDGER_MAX_CLOCK_SKEW_SECS);
4025        assert_eq!(monotonic_recorded_at(now, Some(inside)), Some(inside));
4026
4027        let outside = inside + chrono::Duration::microseconds(1);
4028        assert_eq!(
4029            monotonic_recorded_at(now, Some(outside)),
4030            None,
4031            "a floor past the tolerated skew must refuse the write, not honour it"
4032        );
4033        assert_eq!(
4034            monotonic_recorded_at(now, Some(now + chrono::Duration::days(365_000))),
4035            None
4036        );
4037    }
4038
4039    #[test]
4040    fn sqlite_instants_round_trip_from_the_database_clock() {
4041        // `strftime('%Y-%m-%d %H:%M:%f', 'now')` — what the SQLite arm of the
4042        // write path reads, to millisecond precision.
4043        let parsed = parse_sqlite_instant("2026-09-01 18:08:53.597")
4044            .expect("the documented SQLite clock encoding parses");
4045        assert_eq!(
4046            parsed.to_rfc3339_opts(chrono::SecondsFormat::Micros, true),
4047            "2026-09-01T18:08:53.597000Z"
4048        );
4049
4050        // Whole seconds carry no fractional part at all.
4051        assert_eq!(
4052            parse_sqlite_instant("2026-09-01 18:08:53")
4053                .expect("a fractionless instant parses")
4054                .to_rfc3339_opts(chrono::SecondsFormat::Micros, true),
4055            "2026-09-01T18:08:53.000000Z"
4056        );
4057
4058        assert_eq!(parse_sqlite_instant("not an instant"), None);
4059        assert_eq!(parse_sqlite_instant(""), None);
4060    }
4061
4062    // ── sequence overflow ────────────────────────────────────────────
4063
4064    #[test]
4065    fn verify_refuses_to_follow_a_saturated_sequence_number() {
4066        // Two rows both at i64::MAX would look contiguous under saturating
4067        // arithmetic, hiding the inserted one.
4068        let mut revisions = chain(1);
4069        revisions[0].seq = i64::MAX;
4070        revisions[0].hash = revisions[0].compute_hash();
4071
4072        let broken = verify_chain(7, &revisions)
4073            .broken
4074            .expect("a chain that starts at i64::MAX cannot be checked");
4075        // The chain must start at seq 1, so this is caught before the overflow.
4076        assert_eq!(broken.kind, LedgerBreak::MissingRevision);
4077
4078        let mut revisions = chain(1);
4079        revisions[0].seq = 1;
4080        let mut tail = revisions[0].clone();
4081        tail.id = 2;
4082        tail.seq = i64::MAX;
4083        tail.prev_hash = Some(revisions[0].hash.clone());
4084        tail.hash = tail.compute_hash();
4085        revisions.push(tail);
4086        let broken = verify_chain(7, &revisions).broken.expect("gap detected");
4087        assert_eq!(broken.kind, LedgerBreak::MissingRevision);
4088        assert_eq!(broken.seq, 2);
4089    }
4090
4091    // ── preimage: optional fields carry a presence tag ────────────────
4092
4093    #[test]
4094    fn revision_hash_distinguishes_absent_from_empty_optionals() {
4095        let snapshot = json!({});
4096        for (absent, empty) in [
4097            (
4098                RevisionHashInput {
4099                    tenant_id: None,
4100                    ..base_input(&snapshot)
4101                },
4102                RevisionHashInput {
4103                    tenant_id: Some(""),
4104                    ..base_input(&snapshot)
4105                },
4106            ),
4107            (
4108                RevisionHashInput {
4109                    request_id: None,
4110                    ..base_input(&snapshot)
4111                },
4112                RevisionHashInput {
4113                    request_id: Some(""),
4114                    ..base_input(&snapshot)
4115                },
4116            ),
4117            (
4118                RevisionHashInput {
4119                    prev_hash: None,
4120                    ..base_input(&snapshot)
4121                },
4122                RevisionHashInput {
4123                    prev_hash: Some(""),
4124                    ..base_input(&snapshot)
4125                },
4126            ),
4127        ] {
4128            assert_ne!(
4129                revision_hash(&absent),
4130                revision_hash(&empty),
4131                "a NULL optional must not hash like an empty string"
4132            );
4133        }
4134    }
4135
4136    // ── as-of: transaction-only queries follow the chain, not valid time ──
4137
4138    #[test]
4139    fn as_of_transaction_only_returns_the_newest_revision_the_database_held() {
4140        // A back-dated correction: recorded last (seq 2), but valid from before
4141        // the insert it corrects. A plain query after both writes returns the
4142        // correction, so an as-of query with no valid-time bound must too.
4143        let mut revisions = chain(2);
4144        revisions[1].valid_from = at(5);
4145        revisions[1].hash = revisions[1].compute_hash();
4146
4147        let now = snapshot_as_of(&revisions, LedgerAsOf::transaction(at(100)))
4148            .expect("a revision is in force");
4149        assert_eq!(
4150            now.seq, 2,
4151            "a transaction-time query must return the latest state, not the \
4152             one with the greatest valid_from"
4153        );
4154
4155        let head = snapshot_as_of(&revisions, LedgerAsOf::default()).expect("head");
4156        assert_eq!(head.seq, 2, "the unbounded query is the live state");
4157    }
4158
4159    #[test]
4160    fn a_valid_time_bound_filters_eligibility_rather_than_reordering_the_chain() {
4161        let mut revisions = chain(2);
4162        revisions[1].valid_from = at(5);
4163        revisions[1].hash = revisions[1].compute_hash();
4164
4165        // seq 1 is valid from t=10, seq 2 (a back-dated correction) from t=5.
4166        // The correction is the newest statement in effect at both instants; it
4167        // superseded the insert rather than carving a window out of it.
4168        assert_eq!(
4169            snapshot_as_of(&revisions, LedgerAsOf::valid(at(6)))
4170                .expect("rev")
4171                .seq,
4172            2
4173        );
4174        assert_eq!(
4175            snapshot_as_of(&revisions, LedgerAsOf::valid(at(20)))
4176                .expect("rev")
4177                .seq,
4178            2
4179        );
4180        // Before either statement took effect, there is nothing to return.
4181        assert!(snapshot_as_of(&revisions, LedgerAsOf::valid(at(1))).is_none());
4182    }
4183
4184    // ── misc surface ─────────────────────────────────────────────────
4185
4186    #[test]
4187    fn ledger_break_strings_are_stable() {
4188        assert_eq!(LedgerBreak::HashMismatch.as_str(), "hash_mismatch");
4189        assert_eq!(LedgerBreak::PrevHashMismatch.as_str(), "prev_hash_mismatch");
4190        assert_eq!(LedgerBreak::MissingRevision.as_str(), "missing_revision");
4191        assert_eq!(LedgerBreak::DuplicateSeq.as_str(), "duplicate_seq");
4192        assert_eq!(LedgerBreak::BrokenChainStart.as_str(), "broken_chain_start");
4193        assert_eq!(format!("{}", LedgerBreak::HashMismatch), "hash_mismatch");
4194    }
4195
4196    #[test]
4197    fn verification_serde_roundtrip() {
4198        let report = verify_chain(7, &chain(2));
4199        let json = serde_json::to_string(&report).expect("serialize");
4200        let back: LedgerVerification = serde_json::from_str(&json).expect("deserialize");
4201        assert_eq!(back, report);
4202    }
4203
4204    #[test]
4205    fn ledger_error_messages_name_the_record() {
4206        let err = LedgerError::ChainBroken {
4207            table: "widgets".to_owned(),
4208            record_id: 7,
4209            seq: 2,
4210            detail: "hash_mismatch".to_owned(),
4211        };
4212        let rendered = err.to_string();
4213        assert!(rendered.contains("widgets#7"), "{rendered}");
4214        assert!(rendered.contains("revision 2"), "{rendered}");
4215    }
4216
4217    #[test]
4218    fn valid_time_value_shapes() {
4219        let instant = at(3);
4220        assert_eq!(
4221            LedgerValidTimeValue::ledger_valid_from(&instant),
4222            Some(instant)
4223        );
4224        assert_eq!(
4225            LedgerValidTimeValue::ledger_valid_from(&Some(instant)),
4226            Some(instant)
4227        );
4228        assert_eq!(
4229            LedgerValidTimeValue::ledger_valid_from(&None::<DateTime<Utc>>),
4230            None
4231        );
4232        let naive = instant.naive_utc();
4233        assert_eq!(
4234            LedgerValidTimeValue::ledger_valid_from(&naive),
4235            Some(instant)
4236        );
4237        assert_eq!(
4238            LedgerValidTimeValue::ledger_valid_from(&None::<chrono::NaiveDateTime>),
4239            None
4240        );
4241    }
4242    // ── #2326: snapshot fidelity ──────────────────────────────────
4243
4244    inventory::submit! {
4245        crate::encryption::EncryptedColumnDescriptor {
4246            model: "LedgerFidelityModel",
4247            table: "ledger_fidelity_table",
4248            column: "secret",
4249            deterministic: false,
4250            admin_visible: false,
4251            versioned_ciphertext: false,
4252        }
4253    }
4254
4255    #[test]
4256    #[allow(
4257        clippy::needless_borrow,
4258        reason = "the extra borrow selects the fallback impl"
4259    )]
4260    fn non_finite_probe_flags_floats_the_snapshot_would_null() {
4261        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
4262            assert!((&NonFiniteProbe(&bad)).autumn_has_non_finite());
4263            assert!((&NonFiniteProbe(&Some(bad))).autumn_has_non_finite());
4264            assert!((&NonFiniteProbe(&vec![1.0, bad])).autumn_has_non_finite());
4265        }
4266        assert!((&NonFiniteProbe(&f32::NAN)).autumn_has_non_finite());
4267    }
4268
4269    #[test]
4270    #[allow(
4271        clippy::needless_borrow,
4272        reason = "the extra borrow selects the fallback impl"
4273    )]
4274    fn non_finite_probe_accepts_finite_and_foreign_types() {
4275        assert!(!(&NonFiniteProbe(&1.5_f64)).autumn_has_non_finite());
4276        assert!(!(&NonFiniteProbe(&None::<f64>)).autumn_has_non_finite());
4277        assert!(!(&NonFiniteProbe(&"NaN".to_string())).autumn_has_non_finite());
4278        assert!(!(&NonFiniteProbe(&7_i64)).autumn_has_non_finite());
4279    }
4280
4281    #[test]
4282    fn unrecovered_envelope_is_chain_unreadable_and_names_column_and_revision() {
4283        let snapshot = serde_json::json!({"id": 1, "secret": "not-a-real-envelope"});
4284        let err = ensure_snapshot_recoverable("ledger_fidelity_table", 1, 4, &snapshot)
4285            .expect_err("an unrecovered envelope must not reconstruct");
4286        let text = err.to_string();
4287        assert!(text.contains("secret"), "names the column: {text}");
4288        assert!(text.contains("revision 4"), "names the revision: {text}");
4289    }
4290
4291    #[test]
4292    fn snapshot_without_encrypted_columns_is_recoverable() {
4293        let snapshot = serde_json::json!({"id": 1, "secret": "plain"});
4294        ensure_snapshot_recoverable("some_other_table", 1, 1, &snapshot).unwrap();
4295        let null_secret = serde_json::json!({"id": 1, "secret": null});
4296        ensure_snapshot_recoverable("ledger_fidelity_table", 1, 1, &null_secret).unwrap();
4297    }
4298
4299    #[test]
4300    fn refuse_non_finite_passes_none_and_refuses_a_column() {
4301        refuse_non_finite("t", 1, None).unwrap();
4302        let err = refuse_non_finite("t", 9, Some("score")).expect_err("must refuse");
4303        let text = err.to_string();
4304        assert!(text.contains("t#9") && text.contains("score"), "{text}");
4305    }
4306
4307    #[test]
4308    fn non_finite_error_names_table_record_and_column() {
4309        let text = LedgerError::NonFiniteValue {
4310            table: "t".into(),
4311            record_id: 9,
4312            column: "score".into(),
4313        }
4314        .to_string();
4315        assert!(text.contains("t#9") && text.contains("score"), "{text}");
4316    }
4317
4318    // ── streaming verification (#2319) ───────────────────────────────
4319
4320    /// Tampered chains that each trip a different rule.
4321    fn tamper_cases() -> Vec<Vec<LedgerRevision>> {
4322        let mut cases = vec![Vec::new(), chain(1), chain(5), chain(6)];
4323
4324        let mut mutated = chain(5);
4325        mutated[2].snapshot = json!({ "id": 7, "title": "tampered" });
4326        cases.push(mutated);
4327
4328        let mut relinked = chain(5);
4329        relinked[2].snapshot = json!({ "id": 7, "title": "tampered" });
4330        relinked[2].hash = relinked[2].compute_hash();
4331        cases.push(relinked);
4332
4333        let mut gap = chain(5);
4334        gap.remove(2);
4335        cases.push(gap);
4336
4337        let mut no_start = chain(5);
4338        no_start.remove(0);
4339        cases.push(no_start);
4340
4341        let mut duplicate = chain(5);
4342        let forged = LedgerRevision {
4343            id: 99,
4344            ..duplicate[1].clone()
4345        };
4346        duplicate.insert(2, forged);
4347        cases.push(duplicate);
4348
4349        let mut regression = chain(5);
4350        regression[3].recorded_at = regression[2].recorded_at - chrono::Duration::seconds(1);
4351        regression[3].hash = regression[3].compute_hash();
4352        regression[4].prev_hash = Some(regression[3].hash.clone());
4353        regression[4].hash = regression[4].compute_hash();
4354        cases.push(regression);
4355
4356        let mut saturated = chain(2);
4357        saturated[1].seq = i64::MAX;
4358        saturated[1].hash = saturated[1].compute_hash();
4359        cases.push(saturated);
4360
4361        cases
4362    }
4363
4364    /// The pre-#2319 slice algorithm, kept as an oracle for the streaming
4365    /// verifier.
4366    fn oracle_verify(
4367        record_id: i64,
4368        revisions: &[LedgerRevision],
4369        live: LedgerLiveState,
4370        high_water: LedgerHighWaterState<'_>,
4371    ) -> LedgerVerification {
4372        fn first_break(revisions: &[LedgerRevision]) -> Option<LedgerBreakReport> {
4373            let mut expected_seq: i64 = 1;
4374            let mut prev: Option<ChainTip> = None;
4375            for revision in revisions {
4376                if revision.seq != expected_seq {
4377                    return Some(sequence_break(revision, expected_seq, prev.is_none()));
4378                }
4379                if let Some(report) = link_break(revision, prev.as_ref()) {
4380                    return Some(report);
4381                }
4382                let Some(next_seq) = revision.seq.checked_add(1) else {
4383                    return Some(LedgerBreakReport {
4384                        seq: revision.seq,
4385                        revision_id: Some(revision.id),
4386                        kind: LedgerBreak::UnusableSeq,
4387                        detail: "sequence number is at i64::MAX and cannot be followed; \
4388                                 the chain cannot be continued or checked past this revision"
4389                            .to_owned(),
4390                    });
4391                };
4392                expected_seq = next_seq;
4393                prev = Some(ChainTip::of(revision));
4394            }
4395            None
4396        }
4397        let head = revisions.last().map(ChainTip::of);
4398        let broken = first_break(revisions)
4399            .or_else(|| high_water_break(head.as_ref(), high_water))
4400            .or_else(|| live_state_break(head.as_ref(), live))
4401            .or_else(|| {
4402                revisions
4403                    .windows(2)
4404                    .find_map(|pair| recorded_at_break(&ChainTip::of(&pair[0]), &pair[1]))
4405            });
4406        LedgerVerification {
4407            record_id,
4408            revisions_checked: revisions.len(),
4409            head_hash: if broken.is_none() {
4410                head.map(|tip| tip.hash)
4411            } else {
4412                None
4413            },
4414            broken,
4415        }
4416    }
4417
4418    /// The streaming verifier gives the oracle's report for every tamper
4419    /// case, live state and mark state.
4420    #[test]
4421    fn the_streaming_verifier_matches_the_slice_oracle() {
4422        let full = chain(6);
4423        let marks = [None, Some(mark_for(&full[..5])), Some(mark_for(&full))];
4424        let lives = [
4425            LedgerLiveState::NotChecked,
4426            LedgerLiveState::Absent,
4427            LedgerLiveState::Matches,
4428            LedgerLiveState::Diverged,
4429        ];
4430        let mut broken_kinds = std::collections::BTreeSet::new();
4431        for revisions in tamper_cases() {
4432            for live in lives {
4433                for mark in &marks {
4434                    let states = [
4435                        LedgerHighWaterState::NotChecked,
4436                        mark.as_ref()
4437                            .map_or(LedgerHighWaterState::Absent, LedgerHighWaterState::Present),
4438                    ];
4439                    for high_water in states {
4440                        let expected = oracle_verify(7, &revisions, live, high_water);
4441                        let mut verifier = LedgerChainVerifier::new(7);
4442                        for revision in &revisions {
4443                            verifier.push(revision);
4444                        }
4445                        assert_eq!(verifier.finish(live, high_water), expected);
4446                        if let Some(broken) = &expected.broken {
4447                            broken_kinds.insert(broken.kind.as_str());
4448                        }
4449                    }
4450                }
4451            }
4452        }
4453        // The cases reach every rule, so the comparison means something.
4454        for kind in [
4455            "hash_mismatch",
4456            "prev_hash_mismatch",
4457            "missing_revision",
4458            "duplicate_seq",
4459            "recorded_at_regression",
4460            "live_state_mismatch",
4461            "high_water_missing",
4462            "high_water_behind",
4463        ] {
4464            assert!(
4465                broken_kinds.contains(kind),
4466                "no case reaches {kind}: {broken_kinds:?}"
4467            );
4468        }
4469    }
4470
4471    // ── page requests (#2319) ────────────────────────────────────────
4472
4473    #[test]
4474    fn a_page_request_clamps_its_limit() {
4475        assert_eq!(LedgerPageRequest::first(0).effective_limit(), 1);
4476        assert_eq!(LedgerPageRequest::first(10).effective_limit(), 10);
4477        assert_eq!(
4478            LedgerPageRequest::first(u32::MAX).effective_limit(),
4479            LedgerPageRequest::MAX_LIMIT
4480        );
4481        assert_eq!(
4482            LedgerPageRequest::default().effective_limit(),
4483            LedgerPageRequest::DEFAULT_LIMIT
4484        );
4485        let cursor = LedgerCursor { seq: 3, id: 30 };
4486        assert_eq!(LedgerPageRequest::after(cursor, 5).after, Some(cursor));
4487    }
4488
4489    #[test]
4490    fn a_page_carries_a_cursor_only_when_more_rows_exist() {
4491        let revisions = chain(3);
4492        let full = LedgerRevisionPage::from_rows(revisions.clone(), 3);
4493        assert!(full.next.is_none());
4494        assert_eq!(full.revisions.len(), 3);
4495
4496        let more = LedgerRevisionPage::from_rows(revisions, 2);
4497        assert_eq!(more.revisions.len(), 2);
4498        assert_eq!(more.next, Some(LedgerCursor { seq: 2, id: 2 }));
4499        assert_eq!(
4500            more.next_request(5),
4501            Some(LedgerPageRequest::after(LedgerCursor { seq: 2, id: 2 }, 5))
4502        );
4503        assert_eq!(full.next_request(5), None);
4504    }
4505
4506    // ── tenant column (#2319) ────────────────────────────────────────
4507
4508    #[test]
4509    fn a_tenant_change_is_refused() {
4510        refuse_tenant_change("t", 1, Some("tenant-alpha"), Some("tenant-alpha")).unwrap();
4511        refuse_tenant_change("t", 1, None, None).unwrap();
4512        let err = refuse_tenant_change("t", 9, Some("tenant-alpha"), Some("tenant-beta"))
4513            .expect_err("refused");
4514        let text = err.to_string();
4515        assert!(text.contains("t#9"), "{text}");
4516        // The message does not name a tenant.
4517        assert!(
4518            !text.contains("tenant-alpha") && !text.contains("tenant-beta"),
4519            "{text}"
4520        );
4521    }
4522
4523    #[test]
4524    fn a_string_tenant_column_is_a_ledger_tenant() {
4525        let tenant = String::from("acme");
4526        assert_eq!(LedgerTenantColumn::ledger_tenant_id(&tenant), "acme");
4527    }
4528
4529    // ── #2326: stored snapshot bytes ──────────────────────────────
4530
4531    #[test]
4532    fn stored_snapshot_accepts_canonical_text() {
4533        let text = r#"{"a":1,"b":[1,2]}"#;
4534        let value = parse_stored_snapshot("t", 1, 1, text).unwrap();
4535        assert_eq!(value, json!({"a": 1, "b": [1, 2]}));
4536    }
4537
4538    #[test]
4539    fn stored_snapshot_refuses_re_encoded_text() {
4540        for text in [r#"{ "a": 1 }"#, r#"{"b":1,"a":2}"#, r#"{"a":1,"a":2}"#] {
4541            let err = parse_stored_snapshot("t", 1, 3, text).expect_err(text);
4542            let shown = err.to_string();
4543            assert!(
4544                shown.contains("canonical") && shown.contains('3'),
4545                "{shown}"
4546            );
4547        }
4548    }
4549
4550    #[test]
4551    fn stored_snapshot_refuses_invalid_json() {
4552        let err = parse_stored_snapshot("t", 1, 2, "{").expect_err("invalid");
4553        assert!(err.to_string().contains("unreadable"), "{err}");
4554    }
4555
4556    #[test]
4557    fn stored_snapshot_accepts_floats_the_writer_produced() {
4558        // These parse one ULP off without serde_json's `float_roundtrip`.
4559        for x in [4.536_315_785_538_721e-181_f64, 4.138_526_855_143_091e260] {
4560            let text = canonical_json(&json!({ "x": x }));
4561            parse_stored_snapshot("t", 1, 1, &text).expect(&text);
4562        }
4563    }
4564
4565    #[test]
4566    fn schema_detail_hides_quoted_values() {
4567        let shown = redact_quoted(r#"invalid type: string "sk-secret", expected i64"#);
4568        assert!(!shown.contains("sk-secret"), "{shown}");
4569        assert!(shown.contains("expected i64"), "{shown}");
4570    }
4571
4572    #[test]
4573    fn a_non_canonical_read_error_becomes_a_break() {
4574        let err = crate::AutumnError::internal_server_error(LedgerError::SnapshotNotCanonical {
4575            table: "t".into(),
4576            record_id: 4,
4577            seq: 2,
4578        });
4579        let report = verification_from_read_error(4, err).unwrap();
4580        let broken = report.broken.expect("a break");
4581        assert_eq!(broken.kind, LedgerBreak::SnapshotNotCanonical);
4582        assert_eq!(broken.seq, 2);
4583        assert_eq!(
4584            LedgerBreak::SnapshotNotCanonical.as_str(),
4585            "snapshot_not_canonical"
4586        );
4587    }
4588
4589    #[test]
4590    fn other_read_errors_pass_through() {
4591        let err = crate::AutumnError::internal_server_error(LedgerError::ChainUnreadable {
4592            table: "t".into(),
4593            record_id: 4,
4594            detail: "x".into(),
4595        });
4596        assert!(verification_from_read_error(4, err).is_err());
4597    }
4598
4599    // ── Schema versions and upcasting (#2326) ────────────────────────
4600
4601    /// A v1 snapshot named a field `subject`. v2 calls it `title`.
4602    fn rename_subject(from: u32, snapshot: &mut serde_json::Value) -> Result<(), String> {
4603        if from < 2 {
4604            let object = snapshot.as_object_mut().ok_or("not an object")?;
4605            if let Some(subject) = object.remove("subject") {
4606                object.insert("title".into(), subject);
4607            }
4608        }
4609        Ok(())
4610    }
4611
4612    fn refuse_upcast(_: u32, _: &mut serde_json::Value) -> Result<(), String> {
4613        Err("no rule for this shape".into())
4614    }
4615
4616    #[allow(
4617        clippy::unnecessary_wraps,
4618        reason = "the signature must match `SnapshotUpcast`"
4619    )]
4620    fn note_from_version(from: u32, snapshot: &mut serde_json::Value) -> Result<(), String> {
4621        snapshot["seen"] = json!(from);
4622        Ok(())
4623    }
4624
4625    fn mismatch(err: &crate::AutumnError) -> (i64, i64, String) {
4626        match err.downcast_ref::<LedgerError>() {
4627            Some(LedgerError::SnapshotSchemaMismatch {
4628                record_id,
4629                seq,
4630                detail,
4631                ..
4632            }) => (*record_id, *seq, detail.clone()),
4633            other => panic!("expected SnapshotSchemaMismatch, got {other:?}"),
4634        }
4635    }
4636
4637    #[test]
4638    fn a_version_one_model_adds_no_marker() {
4639        // Chains written before versions existed must keep their hashes.
4640        let mut snapshot = json!({ "title": "a" });
4641        stamp_schema_version(&mut snapshot, 1);
4642        assert_eq!(snapshot, json!({ "title": "a" }));
4643    }
4644
4645    #[test]
4646    fn a_later_schema_version_is_stamped_into_the_snapshot() {
4647        let mut snapshot = json!({ "title": "a" });
4648        stamp_schema_version(&mut snapshot, 3);
4649        assert_eq!(snapshot[SNAPSHOT_SCHEMA_KEY], json!(3));
4650        // The marker sits inside the hashed snapshot, so an edit shows as a break.
4651        assert!(canonical_json(&snapshot).contains(SNAPSHOT_SCHEMA_KEY));
4652    }
4653
4654    #[test]
4655    fn an_unmarked_snapshot_is_version_one() {
4656        let snapshot = json!({ "title": "a" });
4657        let (value, stored) = upcast_snapshot("t", 1, 1, &snapshot, 1, refuse_upcast).unwrap();
4658        assert_eq!((value, stored), (snapshot, 1));
4659    }
4660
4661    #[test]
4662    fn an_older_snapshot_is_converted_by_the_hook() {
4663        let snapshot = json!({ "subject": "a", "amount": 1 });
4664        let (value, stored) = upcast_snapshot("t", 1, 1, &snapshot, 2, rename_subject).unwrap();
4665        assert_eq!(stored, 1);
4666        assert_eq!(value, json!({ "title": "a", "amount": 1 }));
4667    }
4668
4669    #[test]
4670    fn the_marker_is_removed_before_the_model_reads_the_snapshot() {
4671        let mut snapshot = json!({ "title": "a" });
4672        stamp_schema_version(&mut snapshot, 2);
4673        let (value, stored) = upcast_snapshot("t", 1, 1, &snapshot, 2, refuse_upcast).unwrap();
4674        assert_eq!(stored, 2);
4675        assert_eq!(value, json!({ "title": "a" }));
4676    }
4677
4678    #[test]
4679    fn a_snapshot_at_the_current_version_skips_the_hook() {
4680        let mut snapshot = json!({ "title": "a" });
4681        stamp_schema_version(&mut snapshot, 2);
4682        upcast_snapshot("t", 1, 1, &snapshot, 2, refuse_upcast).expect("the hook is not called");
4683    }
4684
4685    #[test]
4686    fn the_hook_receives_the_stored_version() {
4687        let mut snapshot = json!({ "title": "a" });
4688        stamp_schema_version(&mut snapshot, 2);
4689        let (value, _) = upcast_snapshot("t", 1, 1, &snapshot, 3, note_from_version).unwrap();
4690        assert_eq!(value["seen"], json!(2));
4691    }
4692
4693    #[test]
4694    fn a_failing_hook_is_a_schema_error_and_not_tamper_evidence() {
4695        let err =
4696            upcast_snapshot("t", 7, 4, &json!({ "subject": "a" }), 2, refuse_upcast).unwrap_err();
4697        let (record_id, seq, detail) = mismatch(&err);
4698        assert_eq!((record_id, seq), (7, 4));
4699        assert!(detail.contains("no rule for this shape"), "{detail}");
4700        assert!(detail.contains("schema version 1"), "{detail}");
4701    }
4702
4703    #[test]
4704    fn a_snapshot_from_a_newer_model_is_flagged_and_not_converted() {
4705        let mut snapshot = json!({ "title": "a" });
4706        stamp_schema_version(&mut snapshot, 5);
4707        let (value, stored) = upcast_snapshot("t", 1, 1, &snapshot, 2, refuse_upcast).unwrap();
4708        assert_eq!(stored, 5);
4709        assert_eq!(value, json!({ "title": "a" }));
4710        let shown = schema_mismatch_at("t", 1, 1, stored, 2, "cannot decode").to_string();
4711        assert!(shown.contains("schema version 5"), "{shown}");
4712        assert!(shown.contains("newer"), "{shown}");
4713    }
4714
4715    #[test]
4716    fn a_damaged_marker_is_a_schema_error() {
4717        for bad in [json!("x"), json!(0), json!(-1), json!(1.5), json!(null)] {
4718            let snapshot = json!({ "title": "a", SNAPSHOT_SCHEMA_KEY: bad });
4719            let err = upcast_snapshot("t", 1, 1, &snapshot, 2, rename_subject).unwrap_err();
4720            let (_, _, detail) = mismatch(&err);
4721            assert!(detail.contains("schema version marker"), "{detail}");
4722        }
4723    }
4724
4725    #[test]
4726    fn a_snapshot_that_is_not_an_object_passes_through() {
4727        let (value, stored) = upcast_snapshot("t", 1, 1, &json!([1, 2]), 2, refuse_upcast).unwrap();
4728        assert_eq!((value, stored), (json!([1, 2]), 1));
4729    }
4730
4731    #[test]
4732    fn the_schema_error_names_the_versions_and_the_remedies() {
4733        let shown = schema_mismatch_at("t", 1, 2, 1, 3, "cannot decode").to_string();
4734        assert!(shown.contains("schema version 1"), "{shown}");
4735        assert!(shown.contains("model is at 3"), "{shown}");
4736        assert!(shown.contains("upcast"), "{shown}");
4737        assert!(shown.contains("serde(default)"), "{shown}");
4738    }
4739
4740    #[test]
4741    fn the_live_check_ignores_columns_only_one_side_has_after_a_schema_change() {
4742        // A column added since the head was written is not tamper evidence.
4743        let head = json!({ "title": "a" });
4744        let live = json!({ "title": "a", "region": "eu" });
4745        assert!(snapshots_agree(&head, &live, true));
4746        // Nor is a column the model dropped.
4747        assert!(snapshots_agree(&live, &head, true));
4748    }
4749
4750    #[test]
4751    fn the_live_check_is_strict_when_no_schema_change_is_declared() {
4752        // Without a version bump, a column the head lacks is a difference. An
4753        // out-of-band edit to it must not pass.
4754        let head = json!({ "title": "a" });
4755        let live = json!({ "title": "a", "role": "admin" });
4756        assert!(!snapshots_agree(&head, &live, false));
4757        assert!(!snapshots_agree(&live, &head, false));
4758        assert!(snapshots_agree(&live, &live, false));
4759    }
4760
4761    #[test]
4762    fn the_live_check_still_sees_a_changed_value() {
4763        let head = json!({ "title": "a", "amount": 1 });
4764        let live = json!({ "title": "a", "amount": 2, "region": "eu" });
4765        assert!(!snapshots_agree(&head, &live, true));
4766        assert!(!snapshots_agree(&head, &live, false));
4767    }
4768
4769    #[test]
4770    fn the_live_check_compares_non_objects_whole() {
4771        assert!(snapshots_agree(&json!(1), &json!(1), true));
4772        assert!(!snapshots_agree(&json!(1), &json!(2), true));
4773        assert!(!snapshots_agree(&json!({}), &json!(2), true));
4774    }
4775
4776    fn panic_upcast(_: u32, _: &mut serde_json::Value) -> Result<(), String> {
4777        panic!("the hook has a bug")
4778    }
4779
4780    #[test]
4781    fn a_hook_that_panics_is_a_schema_error() {
4782        let err =
4783            upcast_snapshot("t", 3, 2, &json!({ "subject": "a" }), 2, panic_upcast).unwrap_err();
4784        let (record_id, seq, detail) = mismatch(&err);
4785        assert_eq!((record_id, seq), (3, 2));
4786        assert!(detail.contains("the hook panicked"), "{detail}");
4787    }
4788
4789    #[test]
4790    fn a_marked_snapshot_read_by_a_version_one_model_loses_the_marker() {
4791        // A rollback: the model is older than the revision. The hook is skipped.
4792        let mut snapshot = json!({ "title": "a" });
4793        stamp_schema_version(&mut snapshot, 2);
4794        let (value, stored) = upcast_snapshot("t", 1, 1, &snapshot, 1, refuse_upcast).unwrap();
4795        assert_eq!((value, stored), (json!({ "title": "a" }), 2));
4796        let shown = schema_mismatch_at("t", 1, 1, stored, 1, "cannot decode").to_string();
4797        assert!(shown.contains("newer"), "{shown}");
4798    }
4799}