- 1
#![allow(clippy::unwrap_used, clippy::expect_used)] - 2
- 3
//! The ledger is the product's evidence. These assert it is durable on write - 4
//! and tamper-evident on read. - 5
- 6
use std::path::PathBuf; - 7
- 8
use vak_session::types::{Entry, EntryPayload, FrozenContract, SessionHeader}; - 9
use vak_session::{ActivityKind, ActivityRecord, ActivityStatus, SessionLog}; - 10
- 11
fn header() -> SessionHeader { - 12
SessionHeader { - 13
agent: None, - 14
session_id: "s-chain".into(), - 15
created_at: chrono::Utc::now(), - 16
cwd: PathBuf::from("/tmp/proj"), - 17
parent_session_id: None, - 18
contract_id: None, - 19
work_item_id: None, - 20
conversation: None, - 21
contract: FrozenContract { - 22
app_version: "0.1.0".into(), - 23
provider: "anthropic".into(), - 24
model: "claude-sonnet-4-5".into(), - 25
route_ladder: Vec::new(), - 26
route_objective: String::new(), - 27
route_annotations: Vec::new(), - 28
system_prompt: "system prompt v1".into(), - 29
permission_mode: "workspace-write".into(), - 30
capabilities: Vec::new(), - 31
prompt_layers: Vec::new(), - 32
}, - 33
} - 34
} - 35
- 36
fn activity(label: &str) -> ActivityRecord { - 37
ActivityRecord { - 38
activity_id: format!("a-{label}"), - 39
turn: Some(1), - 40
kind: ActivityKind::Diagnostic, - 41
status: ActivityStatus::Succeeded, - 42
label: label.into(), - 43
detail: None, - 44
data: Default::default(), - 45
} - 46
} - 47
- 48
fn seeded(path: &std::path::Path) -> SessionLog { - 49
let mut log = SessionLog::create(path.to_path_buf(), header()).unwrap(); - 50
for label in ["first", "second", "third"] { - 51
log.append_activity(activity(label)).unwrap(); - 52
} - 53
log - 54
} - 55
- 56
#[test] - 57
fn every_appended_entry_links_to_its_predecessor() { - 58
let dir = tempfile::tempdir().unwrap(); - 59
let path = dir.path().join("s.jsonl"); - 60
let log = seeded(&path); - 61
let entries = log.chain_to_root(); - 62
- 63
assert!( - 64
entries[0].prev_hash.is_none(), - 65
"the first entry has no predecessor" - 66
); - 67
for entry in &entries[1..] { - 68
assert!( - 69
entry.prev_hash.is_some(), - 70
"every later entry must carry a chain link" - 71
); - 72
} - 73
drop(log); - 74
- 75
let reopened = SessionLog::open(path).unwrap(); - 76
assert!( - 77
reopened.warnings().is_empty(), - 78
"an untouched ledger must verify clean: {:?}", - 79
reopened.warnings() - 80
); - 81
} - 82
- 83
/// The point of the chain: an interior edit that re-links `parent_id` - 84
/// correctly is still caught. - 85
#[test] - 86
fn an_interior_edit_is_detected_on_reopen() { - 87
let dir = tempfile::tempdir().unwrap(); - 88
let path = dir.path().join("s.jsonl"); - 89
drop(seeded(&path)); - 90
- 91
let text = std::fs::read_to_string(&path).unwrap(); - 92
let mut lines: Vec<String> = text.lines().map(String::from).collect(); - 93
assert!(lines.len() >= 3); - 94
lines[1] = lines[1].replace("first", "tampered"); - 95
std::fs::write(&path, lines.join("\n") + "\n").unwrap(); - 96
- 97
let reopened = SessionLog::open(path).unwrap(); - 98
assert!( - 99
reopened - 100
.warnings() - 101
.iter() - 102
.any(|w| w.contains("broken hash chain")), - 103
"a rewritten entry must be reported: {:?}", - 104
reopened.warnings() - 105
); - 106
} - 107
- 108
/// Ledgers are a frozen, append-only contract. A file written before chaining - 109
/// existed must still open, with the gap reported rather than raised. - 110
#[test] - 111
fn pre_chain_ledgers_still_open_and_say_so() { - 112
let dir = tempfile::tempdir().unwrap(); - 113
let path = dir.path().join("s.jsonl"); - 114
drop(seeded(&path)); - 115
- 116
let text = std::fs::read_to_string(&path).unwrap(); - 117
let stripped: Vec<String> = text - 118
.lines() - 119
.map(|line| { - 120
let value: serde_json::Value = serde_json::from_str(line).unwrap(); - 121
let mut map = value.as_object().unwrap().clone(); - 122
map.remove("prev_hash"); - 123
serde_json::to_string(&map).unwrap() - 124
}) - 125
.collect(); - 126
std::fs::write(&path, stripped.join("\n") + "\n").unwrap(); - 127
- 128
let reopened = SessionLog::open(path).unwrap(); - 129
assert_eq!( - 130
reopened.len(), - 131
stripped.len(), - 132
"an unchained ledger must still be fully readable" - 133
); - 134
assert!( - 135
reopened - 136
.warnings() - 137
.iter() - 138
.any(|w| w.contains("before hash chaining")), - 139
"the unverifiable gap must be reported: {:?}", - 140
reopened.warnings() - 141
); - 142
assert!( - 143
!reopened - 144
.warnings() - 145
.iter() - 146
.any(|w| w.contains("broken hash chain")), - 147
"absent links are not evidence of tampering" - 148
); - 149
} - 150
- 151
/// Appending onto a legacy ledger must start chaining from that point rather - 152
/// than leaving the rest unverifiable forever. - 153
#[test] - 154
fn appending_to_a_legacy_ledger_starts_the_chain() { - 155
let dir = tempfile::tempdir().unwrap(); - 156
let path = dir.path().join("s.jsonl"); - 157
drop(seeded(&path)); - 158
let text = std::fs::read_to_string(&path).unwrap(); - 159
let stripped: Vec<String> = text - 160
.lines() - 161
.map(|line| { - 162
let value: serde_json::Value = serde_json::from_str(line).unwrap(); - 163
let mut map = value.as_object().unwrap().clone(); - 164
map.remove("prev_hash"); - 165
serde_json::to_string(&map).unwrap() - 166
}) - 167
.collect(); - 168
std::fs::write(&path, stripped.join("\n") + "\n").unwrap(); - 169
- 170
let mut log = SessionLog::open(path.clone()).unwrap(); - 171
let appended = log.append_activity(activity("after")).unwrap(); - 172
assert!( - 173
appended.prev_hash.is_some(), - 174
"a new append links to the last line it actually saw" - 175
); - 176
drop(log); - 177
- 178
let reopened = SessionLog::open(path).unwrap(); - 179
assert!( - 180
!reopened - 181
.warnings() - 182
.iter() - 183
.any(|w| w.contains("broken hash chain")), - 184
"chaining onto a legacy tail is not a break: {:?}", - 185
reopened.warnings() - 186
); - 187
} - 188
- 189
/// A torn final line (crash mid-append) must stay recoverable. - 190
#[test] - 191
fn a_torn_tail_does_not_make_the_session_unreadable() { - 192
let dir = tempfile::tempdir().unwrap(); - 193
let path = dir.path().join("s.jsonl"); - 194
drop(seeded(&path)); - 195
- 196
let mut text = std::fs::read_to_string(&path).unwrap(); - 197
text.push_str("{\"id\":\"partial\",\"ts\":\"2026"); - 198
std::fs::write(&path, text).unwrap(); - 199
- 200
let reopened = SessionLog::open(path).unwrap(); - 201
assert!(reopened.len() >= 3); - 202
assert!( - 203
reopened - 204
.warnings() - 205
.iter() - 206
.any(|w| w.contains("unparseable")), - 207
"the torn line must be reported: {:?}", - 208
reopened.warnings() - 209
); - 210
} - 211
- 212
#[test] - 213
fn an_absent_link_is_omitted_rather_than_serialized_as_null() { - 214
let entry = Entry::new(None, EntryPayload::Header(header())); - 215
let json = serde_json::to_string(&entry).unwrap(); - 216
assert!( - 217
!json.contains("prev_hash"), - 218
"an absent link must not be serialized as null: {json}" - 219
); - 220
} - 221
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.