1use chrono::{DateTime, SubsecRound, Utc};
130use serde::{Deserialize, Serialize};
131
132use crate::version_history::{ColumnChange, VersionOp};
133
134pub const LEDGER_TABLE: &str = "_autumn_ledger_revisions";
136
137pub const LEDGER_MAX_CLOCK_SKEW_SECS: i64 = 3_600;
147
148pub const LEDGER_HIGH_WATER_TABLE: &str = "_autumn_ledger_high_water";
155
156const HASH_DOMAIN: &str = "autumn.ledger.revision.v1";
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct LedgerRevision {
172 pub id: i64,
174 pub table_name: String,
176 pub tenant_id: Option<String>,
178 pub record_id: i64,
180 pub seq: i64,
182 pub op: VersionOp,
184 pub actor: String,
186 pub request_id: Option<String>,
188 pub snapshot: serde_json::Value,
190 pub valid_from: DateTime<Utc>,
192 pub recorded_at: DateTime<Utc>,
194 pub prev_hash: Option<String>,
196 pub hash: String,
198}
199
200impl LedgerRevision {
201 #[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#[derive(Debug, Clone, Copy)]
229pub struct RevisionHashInput<'a> {
230 pub prev_hash: Option<&'a str>,
232 pub table_name: &'a str,
234 pub tenant_id: Option<&'a str>,
236 pub record_id: i64,
238 pub seq: i64,
240 pub op: VersionOp,
242 pub actor: &'a str,
244 pub request_id: Option<&'a str>,
246 pub snapshot: &'a serde_json::Value,
248 pub valid_from: DateTime<Utc>,
250 pub recorded_at: DateTime<Utc>,
252}
253
254#[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 out.push_str(&serde_json::Value::String(key.clone()).to_string());
284 out.push(':');
285 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#[must_use]
315pub fn truncate_to_micros(at: DateTime<Utc>) -> DateTime<Utc> {
316 at.trunc_subsecs(6)
317}
318
319#[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 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
359fn format_instant(at: DateTime<Utc>) -> String {
361 truncate_to_micros(at).to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(rename_all = "snake_case")]
375#[non_exhaustive]
376pub enum LedgerBreak {
377 HashMismatch,
380 PrevHashMismatch,
383 MissingRevision,
385 DuplicateSeq,
387 BrokenChainStart,
389 UnusableSeq,
392 LiveStateMismatch,
400 RecordedAtRegression,
408 HighWaterMissing,
424 HighWaterBehind,
430 HighWaterMismatch,
436 SnapshotNotCanonical,
442}
443
444impl LedgerBreak {
445 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
473pub struct LedgerBreakReport {
474 pub seq: i64,
477 pub revision_id: Option<i64>,
480 pub kind: LedgerBreak,
482 pub detail: String,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488pub struct LedgerVerification {
489 pub record_id: i64,
491 pub revisions_checked: usize,
493 pub head_hash: Option<String>,
495 pub broken: Option<LedgerBreakReport>,
497}
498
499impl LedgerVerification {
500 #[must_use]
502 pub const fn is_intact(&self) -> bool {
503 self.broken.is_none()
504 }
505}
506
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514pub struct LedgerHead {
515 pub record_id: i64,
517 pub seq: i64,
519 pub hash: String,
521 pub recorded_at: DateTime<Utc>,
523}
524
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
555pub struct LedgerHighWater {
556 pub record_id: i64,
558 pub seq: i64,
560 pub hash: String,
562 pub recorded_at: DateTime<Utc>,
565}
566
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
583pub struct LedgerPin {
584 pub head: Option<LedgerHead>,
586 pub high_water: Option<LedgerHighWater>,
588}
589
590#[derive(Debug, Clone, Copy, PartialEq, Eq)]
605pub enum LedgerHighWaterState<'a> {
606 NotChecked,
609 Absent,
613 Present(&'a LedgerHighWater),
615}
616
617#[derive(Debug, Clone, Copy, PartialEq, Eq)]
638pub enum LedgerLiveState {
639 NotChecked,
642 Absent,
644 Matches,
646 Diverged,
648}
649
650#[must_use]
659pub fn verify_chain(record_id: i64, revisions: &[LedgerRevision]) -> LedgerVerification {
660 verify_chain_against(record_id, revisions, LedgerLiveState::NotChecked)
661}
662
663#[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#[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#[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#[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 #[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 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 #[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 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 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
878fn high_water_break(
890 head: Option<&ChainTip>,
891 high_water: LedgerHighWaterState<'_>,
892) -> Option<LedgerBreakReport> {
893 let high_water = match high_water {
894 LedgerHighWaterState::NotChecked => return None,
896 LedgerHighWaterState::Absent => None,
897 LedgerHighWaterState::Present(mark) => Some(mark),
898 };
899 match (head, high_water) {
900 (None, None) => None,
904 (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
931fn head_versus_mark(head: &ChainTip, mark: &LedgerHighWater) -> Option<LedgerBreakReport> {
933 if mark.seq > head.seq {
934 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
976fn live_state_break(head: Option<&ChainTip>, live: LedgerLiveState) -> Option<LedgerBreakReport> {
981 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
1023fn 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
1050fn 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
1088fn 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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1245pub struct LedgerAsOf {
1246 pub transaction: Option<DateTime<Utc>>,
1248 pub valid: Option<DateTime<Utc>>,
1250}
1251
1252impl LedgerAsOf {
1253 #[must_use]
1256 pub const fn transaction(at: DateTime<Utc>) -> Self {
1257 Self {
1258 transaction: Some(at),
1259 valid: None,
1260 }
1261 }
1262
1263 #[must_use]
1266 pub const fn valid(at: DateTime<Utc>) -> Self {
1267 Self {
1268 transaction: None,
1269 valid: Some(at),
1270 }
1271 }
1272
1273 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1338pub struct LedgerDiff {
1339 pub record_id: i64,
1341 pub from_seq: Option<i64>,
1344 pub to_seq: Option<i64>,
1347 pub changes: Vec<ColumnChange>,
1349}
1350
1351impl LedgerDiff {
1352 #[must_use]
1354 pub const fn is_empty(&self) -> bool {
1355 self.changes.is_empty()
1356 }
1357}
1358
1359#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1429pub struct LedgerCursor {
1430 pub seq: i64,
1432 pub id: i64,
1434}
1435
1436impl LedgerCursor {
1437 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1449#[non_exhaustive]
1450pub struct LedgerPageRequest {
1451 pub after: Option<LedgerCursor>,
1453 pub limit: u32,
1456}
1457
1458impl LedgerPageRequest {
1459 pub const DEFAULT_LIMIT: u32 = 100;
1461 pub const MAX_LIMIT: u32 = 1000;
1463 pub const VERIFY_LIMIT: u32 = 256;
1465
1466 #[must_use]
1468 pub const fn first(limit: u32) -> Self {
1469 Self { after: None, limit }
1470 }
1471
1472 #[must_use]
1474 pub const fn after(cursor: LedgerCursor, limit: u32) -> Self {
1475 Self {
1476 after: Some(cursor),
1477 limit,
1478 }
1479 }
1480
1481 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1497#[non_exhaustive]
1498pub struct LedgerRevisionPage {
1499 pub revisions: Vec<LedgerRevision>,
1501 pub next: Option<LedgerCursor>,
1503}
1504
1505impl LedgerRevisionPage {
1506 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 #[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#[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#[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 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 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#[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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1692#[non_exhaustive]
1693pub enum LedgerError {
1694 #[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: String,
1703 record_id: i64,
1705 detail: String,
1707 },
1708 #[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: String,
1727 record_id: i64,
1729 },
1730 #[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 table: String,
1746 path: &'static str,
1749 },
1750 #[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: String,
1762 record_id: i64,
1764 },
1765 #[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: String,
1777 record_id: i64,
1779 column: String,
1781 },
1782 #[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: String,
1793 record_id: i64,
1795 seq: i64,
1797 },
1798 #[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: String,
1812 record_id: i64,
1814 seq: i64,
1816 detail: String,
1818 },
1819 #[error("ledger chain for {table}#{record_id} is broken at revision {seq}: {detail}")]
1821 ChainBroken {
1822 table: String,
1824 record_id: i64,
1826 seq: i64,
1828 detail: String,
1830 },
1831}
1832
1833pub trait LedgeredRecord: crate::version_history::VersionedRecord {
1842 fn ledger_valid_from(&self) -> Option<DateTime<Utc>> {
1849 None
1850 }
1851
1852 fn ledger_tenant_id(&self) -> Option<&str> {
1858 None
1859 }
1860
1861 const LEDGER_SCHEMA_VERSION: u32 = 1;
1867
1868 fn ledger_upcast(_from_version: u32, _snapshot: &mut serde_json::Value) -> Result<(), String> {
1880 Ok(())
1881 }
1882}
1883
1884#[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 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#[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#[doc(hidden)]
1936pub trait LedgerValidTimeValue {
1937 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#[doc(hidden)]
1973pub struct NonFiniteProbe<'a, T: ?Sized>(pub &'a T);
1974
1975#[doc(hidden)]
1977pub trait FloatContent {
1978 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#[doc(hidden)]
2008pub trait NonFiniteViaFloat {
2009 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#[doc(hidden)]
2021pub trait NonFiniteFallback {
2022 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#[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#[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#[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#[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#[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
2162pub const SNAPSHOT_SCHEMA_KEY: &str = "$schema_version";
2169
2170#[doc(hidden)]
2172pub type SnapshotUpcast = fn(u32, &mut serde_json::Value) -> Result<(), String>;
2173
2174#[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#[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 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#[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#[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#[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#[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#[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#[cfg(feature = "db")]
2384#[doc(hidden)]
2385#[derive(Debug)]
2386pub struct LedgerAppend<'a> {
2387 pub table_name: &'static str,
2389 pub tenant_id: Option<&'a str>,
2391 pub record_id: i64,
2393 pub op: VersionOp,
2395 pub actor: &'a str,
2397 pub request_id: Option<&'a str>,
2399 pub snapshot: serde_json::Value,
2401 pub valid_from: Option<DateTime<Utc>>,
2404}
2405
2406#[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 #[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#[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#[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#[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#[cfg(all(feature = "db", not(feature = "sqlite")))]
2657type Instant = diesel::sql_types::Timestamptz;
2658#[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 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 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 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 let seq_base: i64 = state.head_seq.unwrap_or(0).max(state.mark_seq.unwrap_or(0));
2771 if seq_base == i64::MAX {
2772 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 let prev_hash: Option<String> = state.head_hash;
2784
2785 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 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#[doc(hidden)]
2863pub struct LedgeredTableDescriptor {
2864 pub table: &'static str,
2866}
2867
2868inventory::collect!(LedgeredTableDescriptor);
2869
2870#[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#[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 #[cfg(feature = "db")]
2919 #[test]
2920 fn ledger_append_statements_keep_their_contract() {
2921 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 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 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 #[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 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 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 #[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); 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); 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); 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 #[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); 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 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 let known_then = snapshot_as_of(&revisions, LedgerAsOf::transaction(at(30))).expect("rev");
3386 assert_eq!(known_then.seq, 1);
3387
3388 let true_then = snapshot_as_of(&revisions, LedgerAsOf::valid(at(6))).expect("rev");
3391 assert_eq!(true_then.seq, 2);
3392
3393 assert!(snapshot_as_of(&revisions, LedgerAsOf::bitemporal(at(30), at(6))).is_none());
3397
3398 let corrected =
3401 snapshot_as_of(&revisions, LedgerAsOf::bitemporal(at(50), at(6))).expect("rev");
3402 assert_eq!(corrected.seq, 2);
3403
3404 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 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 #[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 #[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 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 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 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 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 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 #[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 #[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 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 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 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 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 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 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 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 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 let report = verify_chain_against(7, &chain(3), LedgerLiveState::Matches);
3909 assert!(report.is_intact(), "{report:?}");
3910 }
3911
3912 #[test]
3915 fn verify_reports_a_transaction_time_that_moves_backwards() {
3916 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 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 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 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 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 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 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 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 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 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 #[test]
4065 fn verify_refuses_to_follow_a_saturated_sequence_number() {
4066 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 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 #[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 #[test]
4139 fn as_of_transaction_only_returns_the_newest_revision_the_database_held() {
4140 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 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 assert!(snapshot_as_of(&revisions, LedgerAsOf::valid(at(1))).is_none());
4182 }
4183
4184 #[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 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 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 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 #[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 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 #[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 #[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 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 #[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 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 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 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 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 let head = json!({ "title": "a" });
4744 let live = json!({ "title": "a", "region": "eu" });
4745 assert!(snapshots_agree(&head, &live, true));
4746 assert!(snapshots_agree(&live, &head, true));
4748 }
4749
4750 #[test]
4751 fn the_live_check_is_strict_when_no_schema_change_is_declared() {
4752 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 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}