- 1
//! End-to-end commitment lifecycle over a real on-disk ledger. - 2
//! - 3
//! These exercise the properties that make a commitment worth having: that it - 4
//! survives a restart, that it cannot be closed dishonestly, and that a - 5
//! multi-day suspension is a pause rather than a loss. - 6
- 7
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 8
- 9
use std::path::PathBuf; - 10
- 11
use vak_commit::ledger::{Event, EventKind}; - 12
use vak_commit::{ - 13
Advancement, CommitmentLedger, CommitmentSpec, Economics, Phase, Suspension, Verdict, - 14
}; - 15
use vak_intent::{Escalation, Evidence, Horizon, Reading, Satisfaction, Stakes}; - 16
use vak_session::types::{CriterionKind, CriterionResult, WorkCriterion}; - 17
- 18
fn criterion(id: &str, kind: CriterionKind) -> WorkCriterion { - 19
WorkCriterion { - 20
criterion_id: id.into(), - 21
statement: format!("criterion {id}"), - 22
kind, - 23
required: true, - 24
} - 25
} - 26
- 27
fn spec(evidence: Evidence, criteria: Vec<WorkCriterion>) -> CommitmentSpec { - 28
let reading = Reading { - 29
horizon: Horizon::Durable, - 30
stakes: Stakes::Reversible, - 31
evidence, - 32
..Reading::general() - 33
}; - 34
vak_commit::spec_from_reading( - 35
"migrate the billing schema", - 36
reading, - 37
criteria, - 38
PathBuf::from("/tmp/workspace"), - 39
Economics::default(), - 40
) - 41
} - 42
- 43
#[test] - 44
fn a_commitment_survives_a_process_restart() { - 45
let dir = tempfile::tempdir().unwrap(); - 46
let id = { - 47
let ledger = CommitmentLedger::new(dir.path()); - 48
let id = ledger - 49
.open_commitment(spec(Evidence::None, Vec::new())) - 50
.unwrap(); - 51
ledger - 52
.append(&Event::new( - 53
&id, - 54
EventKind::EpisodeStarted { - 55
episode_id: "e1".into(), - 56
session_id: "s1".into(), - 57
}, - 58
)) - 59
.unwrap(); - 60
id - 61
}; - 62
- 63
// A completely fresh handle, as a restarted process would have. - 64
let reopened = CommitmentLedger::new(dir.path()); - 65
let commitment = reopened.get(&id).unwrap().expect("commitment survived"); - 66
assert_eq!(commitment.phase, Phase::Active); - 67
assert_eq!(commitment.episodes.len(), 1); - 68
assert_eq!(commitment.spec.objective, "migrate the billing schema"); - 69
} - 70
- 71
/// The closure invariant, end to end: a commitment held to `Verified` cannot - 72
/// be closed by the model saying it went well. - 73
#[test] - 74
fn asserted_evidence_cannot_close_work_that_requires_observation() { - 75
let dir = tempfile::tempdir().unwrap(); - 76
let ledger = CommitmentLedger::new(dir.path()); - 77
let id = ledger - 78
.open_commitment(spec( - 79
Evidence::Verified, - 80
vec![criterion( - 81
"tests-pass", - 82
CriterionKind::Shell { - 83
command: "cargo test".into(), - 84
}, - 85
)], - 86
)) - 87
.unwrap(); - 88
- 89
// The model claims success. Recorded as `Semantic`-grade evidence. - 90
ledger - 91
.append(&Event::new( - 92
&id, - 93
EventKind::CriterionEvaluated { - 94
criterion_id: "tests-pass".into(), - 95
result: CriterionResult::Passed { - 96
evidence: "I ran the tests and they passed".into(), - 97
}, - 98
strength: Satisfaction::Asserted, - 99
}, - 100
)) - 101
.unwrap(); - 102
- 103
let refused = ledger.append(&Event::new( - 104
&id, - 105
EventKind::Closed { - 106
verdict: Verdict::Fulfilled, - 107
strength: Satisfaction::Asserted, - 108
evidence: Vec::new(), - 109
note: "done".into(), - 110
}, - 111
)); - 112
let message = refused.expect_err("closure should be refused").to_string(); - 113
assert!( - 114
message.contains("requires observed"), - 115
"unexpected refusal: {message}" - 116
); - 117
- 118
// The commitment is untouched and still open. - 119
let commitment = ledger.get(&id).unwrap().unwrap(); - 120
assert!(!commitment.phase.is_terminal()); - 121
assert!(commitment.closure.is_none()); - 122
} - 123
- 124
#[test] - 125
fn a_runtime_observed_criterion_does_close_the_same_work() { - 126
let dir = tempfile::tempdir().unwrap(); - 127
let ledger = CommitmentLedger::new(dir.path()); - 128
let id = ledger - 129
.open_commitment(spec( - 130
Evidence::Verified, - 131
vec![criterion( - 132
"tests-pass", - 133
CriterionKind::Shell { - 134
command: "cargo test".into(), - 135
}, - 136
)], - 137
)) - 138
.unwrap(); - 139
- 140
ledger - 141
.append(&Event::new( - 142
&id, - 143
EventKind::CriterionEvaluated { - 144
criterion_id: "tests-pass".into(), - 145
result: CriterionResult::Passed { - 146
evidence: "exit 0".into(), - 147
}, - 148
strength: Satisfaction::Observed, - 149
}, - 150
)) - 151
.unwrap(); - 152
ledger - 153
.append(&Event::new( - 154
&id, - 155
EventKind::Closed { - 156
verdict: Verdict::Fulfilled, - 157
strength: Satisfaction::Observed, - 158
evidence: Vec::new(), - 159
note: "tests green".into(), - 160
}, - 161
)) - 162
.unwrap(); - 163
- 164
let commitment = ledger.get(&id).unwrap().unwrap(); - 165
assert_eq!(commitment.phase, Phase::Closed); - 166
assert_eq!( - 167
commitment.closure.as_ref().unwrap().verdict, - 168
Verdict::Fulfilled - 169
); - 170
} - 171
- 172
/// Recording bad news must always be possible, or the ledger cannot tell the - 173
/// truth about work that went wrong. - 174
#[test] - 175
fn failure_verdicts_are_never_blocked_by_the_evidence_requirement() { - 176
for verdict in [ - 177
Verdict::Failed, - 178
Verdict::Abandoned, - 179
Verdict::Expired, - 180
Verdict::Unknown, - 181
Verdict::Partial, - 182
] { - 183
let dir = tempfile::tempdir().unwrap(); - 184
let ledger = CommitmentLedger::new(dir.path()); - 185
let id = ledger - 186
.open_commitment(spec( - 187
Evidence::Audited, - 188
vec![criterion("never-run", CriterionKind::Semantic)], - 189
)) - 190
.unwrap(); - 191
ledger - 192
.append(&Event::new( - 193
&id, - 194
EventKind::Closed { - 195
verdict, - 196
strength: Satisfaction::Asserted, - 197
evidence: Vec::new(), - 198
note: "recorded honestly".into(), - 199
}, - 200
)) - 201
.unwrap_or_else(|error| panic!("{verdict:?} was refused: {error}")); - 202
assert_eq!( - 203
ledger.get(&id).unwrap().unwrap().closure.unwrap().verdict, - 204
verdict - 205
); - 206
} - 207
} - 208
- 209
#[test] - 210
fn outstanding_criteria_block_a_success_claim() { - 211
let dir = tempfile::tempdir().unwrap(); - 212
let ledger = CommitmentLedger::new(dir.path()); - 213
let id = ledger - 214
.open_commitment(spec( - 215
Evidence::None, - 216
vec![ - 217
criterion("a", CriterionKind::Semantic), - 218
criterion("b", CriterionKind::Semantic), - 219
], - 220
)) - 221
.unwrap(); - 222
ledger - 223
.append(&Event::new( - 224
&id, - 225
EventKind::CriterionEvaluated { - 226
criterion_id: "a".into(), - 227
result: CriterionResult::Passed { - 228
evidence: "looks right".into(), - 229
}, - 230
strength: Satisfaction::Asserted, - 231
}, - 232
)) - 233
.unwrap(); - 234
- 235
let refused = ledger.append(&Event::new( - 236
&id, - 237
EventKind::Closed { - 238
verdict: Verdict::Fulfilled, - 239
strength: Satisfaction::Asserted, - 240
evidence: Vec::new(), - 241
note: "half done".into(), - 242
}, - 243
)); - 244
assert!( - 245
refused.unwrap_err().to_string().contains("have not passed"), - 246
"a commitment closed with a criterion outstanding" - 247
); - 248
} - 249
- 250
/// A multi-day wait is a pause, not a loss: the work suspends, survives a - 251
/// restart, and resumes exactly where it was. - 252
#[test] - 253
fn a_suspended_commitment_pauses_and_resumes_across_a_restart() { - 254
let dir = tempfile::tempdir().unwrap(); - 255
let id = { - 256
let ledger = CommitmentLedger::new(dir.path()); - 257
let id = ledger - 258
.open_commitment(spec(Evidence::None, Vec::new())) - 259
.unwrap(); - 260
ledger - 261
.append(&Event::new( - 262
&id, - 263
EventKind::Suspended { - 264
suspension: Suspension::Human { - 265
question_id: "q1".into(), - 266
question: "which database should this target?".into(), - 267
addressed_to: Some("nisheeth".into()), - 268
escalation: Escalation::WaitIndefinitely, - 269
}, - 270
}, - 271
)) - 272
.unwrap(); - 273
id - 274
}; - 275
- 276
let ledger = CommitmentLedger::new(dir.path()); - 277
let suspended = ledger.get(&id).unwrap().unwrap(); - 278
assert_eq!(suspended.phase, Phase::Suspended); - 279
assert!( - 280
suspended - 281
.suspension - 282
.as_ref() - 283
.unwrap() - 284
.describe() - 285
.contains("which database") - 286
); - 287
// A suspended commitment is not schedulable, but it is also not closed. - 288
assert!(!suspended.phase.is_schedulable()); - 289
assert!(!suspended.phase.is_terminal()); - 290
- 291
ledger - 292
.append(&Event::new( - 293
&id, - 294
EventKind::QuestionAnswered { - 295
question_id: "q1".into(), - 296
answer: "the staging replica".into(), - 297
}, - 298
)) - 299
.unwrap(); - 300
let resumed = ledger.get(&id).unwrap().unwrap(); - 301
assert_eq!(resumed.phase, Phase::Active); - 302
assert!(resumed.suspension.is_none()); - 303
} - 304
- 305
/// `Learned` is progress. An episode that reduced uncertainty without moving a - 306
/// criterion must clear the stall streak, or exploration gets punished. - 307
#[test] - 308
fn learning_clears_the_stall_streak_but_stalling_accumulates() { - 309
let dir = tempfile::tempdir().unwrap(); - 310
let ledger = CommitmentLedger::new(dir.path()); - 311
let id = ledger - 312
.open_commitment(spec(Evidence::None, Vec::new())) - 313
.unwrap(); - 314
- 315
let end = |episode: &str, advancement: Advancement| { - 316
ledger - 317
.append(&Event::new( - 318
&id, - 319
EventKind::EpisodeStarted { - 320
episode_id: episode.into(), - 321
session_id: format!("s-{episode}"), - 322
}, - 323
)) - 324
.unwrap(); - 325
ledger - 326
.append(&Event::new( - 327
&id, - 328
EventKind::EpisodeEnded { - 329
episode_id: episode.into(), - 330
advancement, - 331
spend_usd: 0.25, - 332
}, - 333
)) - 334
.unwrap(); - 335
}; - 336
- 337
end( - 338
"e1", - 339
Advancement::Stalled { - 340
reason: "went in circles".into(), - 341
}, - 342
); - 343
end( - 344
"e2", - 345
Advancement::Stalled { - 346
reason: "again".into(), - 347
}, - 348
); - 349
assert_eq!(ledger.get(&id).unwrap().unwrap().consecutive_stalls, 2); - 350
- 351
end( - 352
"e3", - 353
Advancement::Learned { - 354
fact: "the schema is owned by another service".into(), - 355
}, - 356
); - 357
let commitment = ledger.get(&id).unwrap().unwrap(); - 358
assert_eq!(commitment.consecutive_stalls, 0, "learning is progress"); - 359
assert!(!commitment.is_stalled()); - 360
assert!((commitment.spend_usd - 0.75).abs() < 1e-9); - 361
- 362
for episode in ["e4", "e5", "e6"] { - 363
end( - 364
episode, - 365
Advancement::Stalled { - 366
reason: "stuck".into(), - 367
}, - 368
); - 369
} - 370
assert!( - 371
ledger.get(&id).unwrap().unwrap().is_stalled(), - 372
"three consecutive stalls should trip the breaker" - 373
); - 374
} - 375
- 376
#[test] - 377
fn a_closed_commitment_cannot_be_closed_again() { - 378
let dir = tempfile::tempdir().unwrap(); - 379
let ledger = CommitmentLedger::new(dir.path()); - 380
let id = ledger - 381
.open_commitment(spec(Evidence::None, Vec::new())) - 382
.unwrap(); - 383
let close = |note: &str| { - 384
Event::new( - 385
&id, - 386
EventKind::Closed { - 387
verdict: Verdict::Failed, - 388
strength: Satisfaction::Asserted, - 389
evidence: Vec::new(), - 390
note: note.into(), - 391
}, - 392
) - 393
}; - 394
ledger.append(&close("first")).unwrap(); - 395
assert!( - 396
ledger - 397
.append(&close("second")) - 398
.unwrap_err() - 399
.to_string() - 400
.contains("already closed") - 401
); - 402
} - 403
- 404
#[test] - 405
fn supersession_records_lineage_rather_than_orphaning_work() { - 406
let dir = tempfile::tempdir().unwrap(); - 407
let ledger = CommitmentLedger::new(dir.path()); - 408
let old = ledger - 409
.open_commitment(spec(Evidence::None, Vec::new())) - 410
.unwrap(); - 411
let new = ledger - 412
.open_commitment(spec(Evidence::None, Vec::new())) - 413
.unwrap(); - 414
- 415
ledger - 416
.append(&Event::new( - 417
&old, - 418
EventKind::Superseded { - 419
by: new.clone(), - 420
reason: "just add the index instead".into(), - 421
}, - 422
)) - 423
.unwrap(); - 424
- 425
let superseded = ledger.get(&old).unwrap().unwrap(); - 426
assert_eq!(superseded.phase, Phase::Closed); - 427
assert_eq!( - 428
superseded.closure.as_ref().unwrap().verdict, - 429
Verdict::Superseded - 430
); - 431
assert_eq!(superseded.superseded_by.as_deref(), Some(new.as_str())); - 432
// The replacement is still open and independently trackable. - 433
assert!(!ledger.get(&new).unwrap().unwrap().phase.is_terminal()); - 434
assert_eq!(ledger.open().len(), 1); - 435
} - 436
- 437
/// A truncated or corrupt ledger must decline to invent state rather than - 438
/// projecting something plausible. - 439
#[test] - 440
fn a_ledger_without_an_opening_event_projects_nothing() { - 441
let dir = tempfile::tempdir().unwrap(); - 442
let ledger = CommitmentLedger::new(dir.path()); - 443
let path = ledger.path().to_path_buf(); - 444
std::fs::write( - 445
&path, - 446
format!( - 447
"{}\nnot json at all\n", - 448
serde_json::to_string(&Event::new( - 449
"orphan", - 450
EventKind::Resumed { - 451
reason: "no opening event".into() - 452
}, - 453
)) - 454
.unwrap() - 455
), - 456
) - 457
.unwrap(); - 458
assert!(ledger.get("orphan").unwrap().is_none()); - 459
assert!(ledger.all().is_empty()); - 460
} - 461
- 462
/// A torn or non-UTF-8 line in the middle of the ledger is skipped, and every - 463
/// event written after it still counts. Stopping at the first bad line would - 464
/// silently roll every later commitment back to an older state. - 465
#[test] - 466
fn a_corrupt_line_hides_nothing_written_after_it() { - 467
let dir = tempfile::tempdir().unwrap(); - 468
let ledger = CommitmentLedger::new(dir.path()); - 469
let id = ledger - 470
.open_commitment(spec(Evidence::None, Vec::new())) - 471
.unwrap(); - 472
{ - 473
use std::io::Write as _; - 474
let mut file = std::fs::OpenOptions::new() - 475
.append(true) - 476
.open(ledger.path()) - 477
.unwrap(); - 478
file.write_all(b"{\"torn\": \xff\xfe\n").unwrap(); - 479
} - 480
ledger - 481
.append(&Event::new( - 482
&id, - 483
EventKind::Blocked { - 484
blocker: "waiting on credentials".into(), - 485
}, - 486
)) - 487
.unwrap(); - 488
let commitment = ledger.get(&id).unwrap().unwrap(); - 489
assert_eq!(commitment.phase, Phase::Blocked); - 490
assert_eq!( - 491
commitment.blocker.as_deref(), - 492
Some("waiting on credentials") - 493
); - 494
} - 495
- 496
/// The strength a closure records is the runtime's, recomputed from the - 497
/// criteria at append time — a caller cannot write a stronger claim than the - 498
/// commitment holds, even for a verdict that claims no success. - 499
#[test] - 500
fn a_closure_records_the_achieved_strength_not_the_claimed_one() { - 501
let dir = tempfile::tempdir().unwrap(); - 502
let ledger = CommitmentLedger::new(dir.path()); - 503
let id = ledger - 504
.open_commitment(spec( - 505
Evidence::None, - 506
vec![criterion("looked", CriterionKind::Semantic)], - 507
)) - 508
.unwrap(); - 509
ledger - 510
.append(&Event::new( - 511
&id, - 512
EventKind::Closed { - 513
verdict: Verdict::Partial, - 514
strength: Satisfaction::Attested, - 515
evidence: Vec::new(), - 516
note: "claims an audit that never happened".into(), - 517
}, - 518
)) - 519
.unwrap(); - 520
let closure = ledger.get(&id).unwrap().unwrap().closure.unwrap(); - 521
assert_eq!(closure.strength, Satisfaction::Asserted); - 522
} - 523
- 524
/// A new episode is somebody working the commitment again, so the blocker - 525
/// that stopped the previous one no longer describes it. - 526
#[test] - 527
fn starting_an_episode_clears_the_previous_blocker() { - 528
let dir = tempfile::tempdir().unwrap(); - 529
let ledger = CommitmentLedger::new(dir.path()); - 530
let id = ledger - 531
.open_commitment(spec(Evidence::None, Vec::new())) - 532
.unwrap(); - 533
for event in [ - 534
EventKind::EpisodeStarted { - 535
episode_id: "e1".into(), - 536
session_id: "s1".into(), - 537
}, - 538
EventKind::EpisodeEnded { - 539
episode_id: "e1".into(), - 540
advancement: Advancement::Blocked { - 541
blocker: "cancelled".into(), - 542
}, - 543
spend_usd: 0.0, - 544
}, - 545
EventKind::EpisodeStarted { - 546
episode_id: "e2".into(), - 547
session_id: "s1".into(), - 548
}, - 549
] { - 550
ledger.append(&Event::new(&id, event)).unwrap(); - 551
} - 552
let commitment = ledger.get(&id).unwrap().unwrap(); - 553
assert_eq!(commitment.phase, Phase::Active); - 554
assert!(commitment.blocker.is_none()); - 555
} - 556
- 557
/// Concurrent writers serialize: every append lands, none is lost or torn. - 558
#[test] - 559
fn concurrent_appends_all_land() { - 560
let dir = tempfile::tempdir().unwrap(); - 561
let id = CommitmentLedger::new(dir.path()) - 562
.open_commitment(spec(Evidence::None, Vec::new())) - 563
.unwrap(); - 564
let writers: Vec<_> = (0..8) - 565
.map(|writer| { - 566
let home = dir.path().to_path_buf(); - 567
let id = id.clone(); - 568
std::thread::spawn(move || { - 569
let ledger = CommitmentLedger::new(&home); - 570
for n in 0..10 { - 571
ledger - 572
.append(&Event::new( - 573
&id, - 574
EventKind::Resumed { - 575
reason: format!("writer {writer} pass {n}"), - 576
}, - 577
)) - 578
.unwrap(); - 579
} - 580
}) - 581
}) - 582
.collect(); - 583
for writer in writers { - 584
writer.join().unwrap(); - 585
} - 586
assert_eq!(CommitmentLedger::new(dir.path()).events_for(&id).len(), 81); - 587
} - 588
- 589
#[test] - 590
fn revoking_an_envelope_stops_it_granting_anything() { - 591
let dir = tempfile::tempdir().unwrap(); - 592
let ledger = CommitmentLedger::new(dir.path()); - 593
let id = ledger - 594
.open_commitment(spec(Evidence::None, Vec::new())) - 595
.unwrap(); - 596
let envelope = vak_intent::Envelope { - 597
envelope_id: "env-1".into(), - 598
granted_by: "nisheeth".into(), - 599
granted_at: chrono::Utc::now(), - 600
expires_at: None, - 601
spend_limit_usd: Some(20.0), - 602
path_scope: vec!["src/**".into()], - 603
tool_scope: vec!["edit".into()], - 604
permission_ceiling: vak_intent::PermissionCeiling::WorkspaceWrite, - 605
escalation: Escalation::WaitIndefinitely, - 606
revoked_at: None, - 607
}; - 608
ledger - 609
.append(&Event::new( - 610
&id, - 611
EventKind::EnvelopeGranted { - 612
envelope: Box::new(envelope), - 613
}, - 614
)) - 615
.unwrap(); - 616
assert!( - 617
ledger - 618
.get(&id) - 619
.unwrap() - 620
.unwrap() - 621
.envelope - 622
.unwrap() - 623
.is_live(chrono::Utc::now()) - 624
); - 625
- 626
ledger - 627
.append(&Event::new( - 628
&id, - 629
EventKind::EnvelopeRevoked { - 630
envelope_id: "env-1".into(), - 631
by: "nisheeth".into(), - 632
}, - 633
)) - 634
.unwrap(); - 635
assert!( - 636
!ledger - 637
.get(&id) - 638
.unwrap() - 639
.unwrap() - 640
.envelope - 641
.unwrap() - 642
.is_live(chrono::Utc::now()) - 643
); - 644
} - 645
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.