- 1
//! The durable state registry - 2
//! (`docs/design/46-stabilization-install-and-onboarding.md` Part VII.2). - 3
//! - 4
//! **You cannot promise to preserve what you have not enumerated**, and a - 5
//! prose enumeration rots. Every durable artifact vak owns is declared - 6
//! here, once, and three things that each used to carry their own partial - 7
//! list now read this one instead: `--purge`'s preserve rules, backup - 8
//! coverage, and the upgrade gate. - 9
//! - 10
//! `crates/vak-core/src/backup.rs` is the cautionary example — it - 11
//! hardcoded five directories and four files, so anything added later was - 12
//! silently outside every backup taken. That is precisely the drift a - 13
//! registry exists to make impossible, and the test below fails the build - 14
//! when a durable file appears that nobody declared. - 15
- 16
use std::path::{Path, PathBuf}; - 17
- 18
/// Which root an entry is relative to. - 19
/// - 20
/// The two are genuinely different places with different lifetimes: - 21
/// `~/vak-home` is the Shared configuration and secret home a person can - 22
/// browse, and the platform data home holds application-managed state. - 23
/// Conflating them is how a purge either spares secrets or eats sessions. - 24
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 25
pub enum Root { - 26
/// `vak_config::paths::data_home()`. - 27
Data, - 28
/// `vak_config::paths::cache_home()` — rebuildable, always safe to delete. - 29
Cache, - 30
/// `vak_config::paths::default_workspace()` — the Shared layer. - 31
Shared, - 32
/// `vak_config::paths::logs_dir()` — service and CLI logs. - 33
/// - 34
/// A fourth root, and one that escaped this registry until a real - 35
/// install showed logs from a previous version surviving a purge. - 36
Logs, - 37
} - 38
- 39
impl Root { - 40
/// Every root, so a pass over "all of Vak's state" (a purge) cannot - 41
/// leave one out by listing them by hand. - 42
pub const ALL: [Root; 4] = [Root::Data, Root::Cache, Root::Shared, Root::Logs]; - 43
} - 44
- 45
/// What kind of thing this is, which is what decides how it may be treated. - 46
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 47
pub enum Kind { - 48
/// Append-only. New information is a new entry, never a changed one - 49
/// (AGENTS.md invariants 1 and 2). - 50
Ledger, - 51
/// Structured settings. - 52
Config, - 53
/// Credentials. Never copied into a backup without an explicit request, - 54
/// never logged, never returned by an API. - 55
Secret, - 56
/// Derived from something else and safe to lose. - 57
Derived, - 58
} - 59
- 60
/// What an update may do to this entry. - 61
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 62
pub enum OnUpdate { - 63
/// Not written at all. Byte-identical across an update. - 64
Untouched, - 65
/// May gain fields, never lose or redefine them (doc 46 VII.3). - 66
AdditiveOnly, - 67
/// Regenerated from a durable source; equivalence is what matters, - 68
/// not bytes. - 69
Rebuilt, - 70
} - 71
- 72
/// What `--purge` does with this entry. - 73
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 74
pub enum OnPurge { - 75
Remove, - 76
/// Survives a purge. Today this is exactly one thing — a project's own - 77
/// `.vak/` inside someone else's repository — and it is a preserve - 78
/// *rule*, not a delete list, because the safe default when a new file - 79
/// appears is to leave it alone. - 80
Preserve, - 81
} - 82
- 83
/// One durable artifact. - 84
#[derive(Debug, Clone, Copy)] - 85
pub struct StateEntry { - 86
/// Path relative to [`Root`]. A trailing component with no extension - 87
/// may be a directory; [`StateEntry::matches`] handles both. - 88
pub path: &'static str, - 89
pub root: Root, - 90
/// Crate that writes it, so a reader knows where to look. - 91
pub owner: &'static str, - 92
/// Schema version where the file carries one. - 93
pub schema: Option<u32>, - 94
pub kind: Kind, - 95
pub on_update: OnUpdate, - 96
pub on_purge: OnPurge, - 97
/// Whether an ordinary backup copies it. Secrets are excluded unless - 98
/// the operator asks, which is why this is not implied by `kind`. - 99
pub in_backup: bool, - 100
} - 101
- 102
impl StateEntry { - 103
/// True when `relative` is this entry or lives inside it. - 104
pub fn matches(&self, relative: &Path) -> bool { - 105
let entry = Path::new(self.path); - 106
relative == entry || relative.starts_with(entry) - 107
} - 108
} - 109
- 110
/// Everything vak writes that outlives a process. - 111
/// - 112
/// Verified against a real installation rather than inferred from the - 113
/// source: the enforcement test below drives a workspace and fails on any - 114
/// file that appears here without a declaration. - 115
pub const REGISTRY: &[StateEntry] = &[ - 116
// ---- ledgers: append-only, never rewritten by an update ---- - 117
StateEntry { - 118
path: "sessions", - 119
root: Root::Data, - 120
owner: "vak-session", - 121
schema: None, - 122
kind: Kind::Ledger, - 123
on_update: OnUpdate::Untouched, - 124
on_purge: OnPurge::Remove, - 125
in_backup: true, - 126
}, - 127
StateEntry { - 128
path: "agents", - 129
root: Root::Data, - 130
owner: "vak-core", - 131
schema: None, - 132
kind: Kind::Ledger, - 133
on_update: OnUpdate::Untouched, - 134
on_purge: OnPurge::Remove, - 135
in_backup: true, - 136
}, - 137
StateEntry { - 138
path: "security-events.jsonl", - 139
root: Root::Data, - 140
owner: "vak-core", - 141
schema: None, - 142
kind: Kind::Ledger, - 143
on_update: OnUpdate::Untouched, - 144
on_purge: OnPurge::Remove, - 145
in_backup: true, - 146
}, - 147
StateEntry { - 148
path: "cost-log.jsonl", - 149
root: Root::Data, - 150
owner: "vak-core", - 151
schema: None, - 152
kind: Kind::Ledger, - 153
on_update: OnUpdate::Untouched, - 154
on_purge: OnPurge::Remove, - 155
in_backup: true, - 156
}, - 157
StateEntry { - 158
path: "routing-evidence.jsonl", - 159
root: Root::Data, - 160
owner: "vak-core", - 161
schema: None, - 162
kind: Kind::Ledger, - 163
on_update: OnUpdate::Untouched, - 164
on_purge: OnPurge::Remove, - 165
in_backup: true, - 166
}, - 167
StateEntry { - 168
path: "deleted.json", - 169
root: Root::Data, - 170
owner: "vak-core (trash)", - 171
schema: None, - 172
// The trash: session ids hidden everywhere until restored. - 173
kind: Kind::Config, - 174
on_update: OnUpdate::AdditiveOnly, - 175
on_purge: OnPurge::Remove, - 176
in_backup: true, - 177
}, - 178
StateEntry { - 179
path: "archive.json", - 180
root: Root::Data, - 181
owner: "vak-server", - 182
schema: None, - 183
// Session ids hidden from the everyday list, still searchable. - 184
kind: Kind::Config, - 185
on_update: OnUpdate::AdditiveOnly, - 186
on_purge: OnPurge::Remove, - 187
in_backup: true, - 188
}, - 189
StateEntry { - 190
path: "inbox.jsonl", - 191
root: Root::Data, - 192
owner: "vak-core", - 193
schema: None, - 194
kind: Kind::Ledger, - 195
on_update: OnUpdate::Untouched, - 196
on_purge: OnPurge::Remove, - 197
in_backup: true, - 198
}, - 199
StateEntry { - 200
path: "operations", - 201
root: Root::Data, - 202
owner: "vak-server", - 203
schema: None, - 204
kind: Kind::Ledger, - 205
on_update: OnUpdate::Untouched, - 206
on_purge: OnPurge::Remove, - 207
in_backup: true, - 208
}, - 209
StateEntry { - 210
path: "checkpoints", - 211
root: Root::Data, - 212
owner: "vak-core", - 213
schema: None, - 214
kind: Kind::Ledger, - 215
on_update: OnUpdate::Untouched, - 216
on_purge: OnPurge::Remove, - 217
in_backup: true, - 218
}, - 219
StateEntry { - 220
path: "memory", - 221
root: Root::Data, - 222
owner: "vak-core", - 223
schema: None, - 224
kind: Kind::Ledger, - 225
on_update: OnUpdate::Untouched, - 226
on_purge: OnPurge::Remove, - 227
in_backup: true, - 228
}, - 229
StateEntry { - 230
path: "skill-proposals", - 231
root: Root::Data, - 232
owner: "vak-core", - 233
schema: None, - 234
kind: Kind::Ledger, - 235
on_update: OnUpdate::Untouched, - 236
on_purge: OnPurge::Remove, - 237
in_backup: true, - 238
}, - 239
StateEntry { - 240
path: "learning", - 241
root: Root::Data, - 242
owner: "vak-core", - 243
schema: None, - 244
kind: Kind::Ledger, - 245
on_update: OnUpdate::Untouched, - 246
on_purge: OnPurge::Remove, - 247
in_backup: true, - 248
}, - 249
// ---- config: additive-only across an update ---- - 250
StateEntry { - 251
path: "gateway", - 252
root: Root::Data, - 253
owner: "vak-server", - 254
schema: Some(1), - 255
kind: Kind::Config, - 256
on_update: OnUpdate::AdditiveOnly, - 257
on_purge: OnPurge::Remove, - 258
in_backup: true, - 259
}, - 260
StateEntry { - 261
path: "agent-network", - 262
root: Root::Data, - 263
owner: "vak-core", - 264
schema: None, - 265
kind: Kind::Config, - 266
on_update: OnUpdate::AdditiveOnly, - 267
on_purge: OnPurge::Remove, - 268
in_backup: true, - 269
}, - 270
StateEntry { - 271
path: "tasks.json", - 272
root: Root::Data, - 273
owner: "vak-core", - 274
schema: None, - 275
kind: Kind::Config, - 276
on_update: OnUpdate::AdditiveOnly, - 277
on_purge: OnPurge::Remove, - 278
in_backup: true, - 279
}, - 280
StateEntry { - 281
path: "desktop.json", - 282
root: Root::Data, - 283
owner: "vak-desktop", - 284
schema: None, - 285
kind: Kind::Config, - 286
on_update: OnUpdate::AdditiveOnly, - 287
on_purge: OnPurge::Remove, - 288
in_backup: true, - 289
}, - 290
StateEntry { - 291
path: "tray.json", - 292
root: Root::Data, - 293
owner: "vak-tray", - 294
schema: None, - 295
kind: Kind::Config, - 296
on_update: OnUpdate::AdditiveOnly, - 297
on_purge: OnPurge::Remove, - 298
in_backup: false, - 299
}, - 300
StateEntry { - 301
path: "trusted", - 302
root: Root::Data, - 303
owner: "vak-core", - 304
schema: None, - 305
kind: Kind::Config, - 306
on_update: OnUpdate::Untouched, - 307
on_purge: OnPurge::Remove, - 308
in_backup: true, - 309
}, - 310
StateEntry { - 311
path: "feeds", - 312
root: Root::Data, - 313
owner: "vak-server (scripts/feeds)", - 314
schema: None, - 315
// The feed store (DuckDB) and the feeds' security log, both at - 316
// paths the server hands the Python pipeline. - 317
kind: Kind::Ledger, - 318
on_update: OnUpdate::Untouched, - 319
on_purge: OnPurge::Remove, - 320
in_backup: true, - 321
}, - 322
StateEntry { - 323
path: "feeds.toml", - 324
root: Root::Data, - 325
owner: "vak-server", - 326
schema: None, - 327
kind: Kind::Config, - 328
on_update: OnUpdate::AdditiveOnly, - 329
on_purge: OnPurge::Remove, - 330
in_backup: true, - 331
}, - 332
StateEntry { - 333
path: "output.toml", - 334
root: Root::Data, - 335
owner: "vak-delivery", - 336
schema: None, - 337
kind: Kind::Config, - 338
on_update: OnUpdate::AdditiveOnly, - 339
on_purge: OnPurge::Remove, - 340
in_backup: true, - 341
}, - 342
StateEntry { - 343
path: "flows", - 344
root: Root::Data, - 345
owner: "vak-flow", - 346
schema: None, - 347
kind: Kind::Config, - 348
on_update: OnUpdate::AdditiveOnly, - 349
on_purge: OnPurge::Remove, - 350
in_backup: true, - 351
}, - 352
StateEntry { - 353
path: "flow-runs", - 354
root: Root::Data, - 355
owner: "vak-flow", - 356
schema: None, - 357
kind: Kind::Ledger, - 358
on_update: OnUpdate::Untouched, - 359
on_purge: OnPurge::Remove, - 360
in_backup: true, - 361
}, - 362
StateEntry { - 363
path: "jobs", - 364
root: Root::Data, - 365
owner: "vak-delivery", - 366
schema: Some(1), - 367
kind: Kind::Ledger, - 368
on_update: OnUpdate::Untouched, - 369
on_purge: OnPurge::Remove, - 370
in_backup: true, - 371
}, - 372
StateEntry { - 373
path: "skills", - 374
root: Root::Data, - 375
owner: "vak-core", - 376
schema: None, - 377
kind: Kind::Config, - 378
on_update: OnUpdate::AdditiveOnly, - 379
on_purge: OnPurge::Remove, - 380
in_backup: true, - 381
}, - 382
// ---- secrets ---- - 383
// The encrypted-file credential backend (docs/design/44-shared-config.md, - 384
// "Secrets Chain") — used only when no OS-native secret service is - 385
// reachable, beside the Shared config layer (`default_workspace()`, - 386
// i.e. `~/vak-home` — the "configuration and secret home" of Part VI in - 387
// docs/design/46). It holds every scope (Shared, project, agent) in one - 388
// file, not one file per scope the way `.env` used to be laid out; a - 389
// host using the OS keychain instead has neither file. On a host that - 390
// does have them, both must be purged or backed up together — the key - 391
// alone or the data alone is not a usable secret. - 392
StateEntry { - 393
path: "credentials.enc", - 394
root: Root::Shared, - 395
owner: "vak-config", - 396
schema: None, - 397
kind: Kind::Secret, - 398
// A rotated key is the operator's write, never an update's. - 399
on_update: OnUpdate::Untouched, - 400
on_purge: OnPurge::Remove, - 401
// Only with `--include-secrets`, and then with a warning beside it. - 402
in_backup: false, - 403
}, - 404
StateEntry { - 405
path: ".credential_key", - 406
root: Root::Shared, - 407
owner: "vak-config", - 408
schema: None, - 409
kind: Kind::Secret, - 410
on_update: OnUpdate::Untouched, - 411
on_purge: OnPurge::Remove, - 412
in_backup: false, - 413
}, - 414
// The advisory cross-process lock guarding the two entries above - 415
// (see `EncryptedFileStore::with_lock` in vak-config). Contains no - 416
// secret material and is safe to lose — a missing lock file just - 417
// degrades a future access to unsynchronized, it doesn't corrupt - 418
// anything already on disk. - 419
StateEntry { - 420
path: ".credential_key.lock", - 421
root: Root::Shared, - 422
owner: "vak-config", - 423
schema: None, - 424
kind: Kind::Derived, - 425
on_update: OnUpdate::Untouched, - 426
on_purge: OnPurge::Remove, - 427
in_backup: false, - 428
}, - 429
// ---- the Shared layer ---- - 430
StateEntry { - 431
path: ".vak/config.toml", - 432
root: Root::Shared, - 433
owner: "vak-config", - 434
schema: None, - 435
kind: Kind::Config, - 436
on_update: OnUpdate::AdditiveOnly, - 437
on_purge: OnPurge::Remove, - 438
in_backup: true, - 439
}, - 440
StateEntry { - 441
path: ".vak/skills", - 442
root: Root::Shared, - 443
owner: "vak-core", - 444
schema: None, - 445
kind: Kind::Config, - 446
// Seeds advance only where the file still matches what we shipped; - 447
// an edited one is the operator's file permanently (doc 46 VII.4). - 448
on_update: OnUpdate::AdditiveOnly, - 449
on_purge: OnPurge::Remove, - 450
in_backup: true, - 451
}, - 452
StateEntry { - 453
path: ".vak/.seed-manifest.json", - 454
root: Root::Shared, - 455
owner: "vak-core", - 456
schema: Some(1), - 457
kind: Kind::Config, - 458
on_update: OnUpdate::AdditiveOnly, - 459
on_purge: OnPurge::Remove, - 460
in_backup: true, - 461
}, - 462
StateEntry { - 463
path: ".vak/plugins", - 464
root: Root::Shared, - 465
owner: "vak-plugin", - 466
schema: Some(1), - 467
kind: Kind::Config, - 468
on_update: OnUpdate::AdditiveOnly, - 469
on_purge: OnPurge::Remove, - 470
in_backup: true, - 471
}, - 472
// ---- derived: rebuilt, never carried ---- - 473
StateEntry { - 474
path: "locks", - 475
root: Root::Data, - 476
owner: "vak-server", - 477
schema: None, - 478
kind: Kind::Derived, - 479
on_update: OnUpdate::Rebuilt, - 480
on_purge: OnPurge::Remove, - 481
in_backup: false, - 482
}, - 483
StateEntry { - 484
path: "broker.sock", - 485
root: Root::Data, - 486
owner: "vak-tools", - 487
schema: None, - 488
kind: Kind::Derived, - 489
on_update: OnUpdate::Rebuilt, - 490
on_purge: OnPurge::Remove, - 491
in_backup: false, - 492
}, - 493
StateEntry { - 494
path: "release", - 495
root: Root::Data, - 496
owner: "vak", - 497
schema: Some(2), - 498
kind: Kind::Config, - 499
on_update: OnUpdate::AdditiveOnly, - 500
on_purge: OnPurge::Remove, - 501
in_backup: false, - 502
}, - 503
StateEntry { - 504
path: "vak-home", - 505
root: Root::Data, - 506
owner: "vak-config", - 507
schema: None, - 508
kind: Kind::Config, - 509
on_update: OnUpdate::AdditiveOnly, - 510
on_purge: OnPurge::Remove, - 511
in_backup: false, - 512
}, - 513
StateEntry { - 514
path: "", - 515
root: Root::Logs, - 516
owner: "vak-ops", - 517
schema: None, - 518
kind: Kind::Ledger, - 519
// Upgrades append to a log; they never rewrite one. - 520
on_update: OnUpdate::Untouched, - 521
on_purge: OnPurge::Remove, - 522
in_backup: false, - 523
}, - 524
StateEntry { - 525
path: "", - 526
root: Root::Cache, - 527
owner: "vak-store", - 528
schema: None, - 529
kind: Kind::Derived, - 530
on_update: OnUpdate::Rebuilt, - 531
on_purge: OnPurge::Remove, - 532
in_backup: false, - 533
}, - 534
]; - 535
- 536
/// Entries under one root. - 537
pub fn entries_for(root: Root) -> impl Iterator<Item = &'static StateEntry> { - 538
REGISTRY.iter().filter(move |e| e.root == root) - 539
} - 540
- 541
/// Relative paths an ordinary backup copies, for `root`. - 542
pub fn backup_paths(root: Root) -> Vec<&'static str> { - 543
entries_for(root) - 544
.filter(|e| e.in_backup) - 545
.map(|e| e.path) - 546
.collect() - 547
} - 548
- 549
/// True when `relative` under `root` is declared. - 550
/// - 551
/// The enforcement test's question: did something write a durable file - 552
/// nobody declared? - 553
pub fn is_declared(root: Root, relative: &Path) -> bool { - 554
entries_for(root).any(|e| e.matches(relative)) - 555
} - 556
- 557
/// The absolute location of `root` right now. - 558
pub fn root_path(root: Root) -> PathBuf { - 559
match root { - 560
Root::Data => vak_config::paths::data_home(), - 561
Root::Cache => vak_config::paths::cache_home(), - 562
Root::Shared => vak_config::paths::default_workspace(), - 563
Root::Logs => vak_config::paths::logs_dir(), - 564
} - 565
} - 566
- 567
#[cfg(test)] - 568
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 569
mod tests { - 570
use super::*; - 571
- 572
#[test] - 573
fn a_directory_entry_covers_what_is_inside_it() { - 574
let sessions = REGISTRY.iter().find(|e| e.path == "sessions").unwrap(); - 575
assert!(sessions.matches(Path::new("sessions"))); - 576
assert!(sessions.matches(Path::new("sessions/abc/def.jsonl"))); - 577
assert!(!sessions.matches(Path::new("sessions-other"))); - 578
} - 579
- 580
#[test] - 581
fn every_entry_declares_a_distinct_path_within_its_root() { - 582
// Two entries for one path would let the two disagree about what a - 583
// purge or an update may do to it. - 584
let mut seen: Vec<(Root, &str)> = Vec::new(); - 585
for entry in REGISTRY { - 586
let key = (entry.root, entry.path); - 587
assert!( - 588
!seen.contains(&key), - 589
"{:?}/{} is declared twice", - 590
entry.root, - 591
entry.path - 592
); - 593
seen.push(key); - 594
} - 595
} - 596
- 597
#[test] - 598
fn secrets_are_never_in_an_ordinary_backup() { - 599
for entry in REGISTRY.iter().filter(|e| e.kind == Kind::Secret) { - 600
assert!( - 601
!entry.in_backup, - 602
"{} is a secret and must not ride along in a routine backup", - 603
entry.path - 604
); - 605
} - 606
} - 607
- 608
#[test] - 609
fn a_ledger_is_never_rewritten_by_an_update() { - 610
// Append-only is what makes "no migration" sustainable rather than - 611
// merely stated (doc 46 VII.3 rule 5). - 612
for entry in REGISTRY.iter().filter(|e| e.kind == Kind::Ledger) { - 613
assert_eq!( - 614
entry.on_update, - 615
OnUpdate::Untouched, - 616
"{} is a ledger, so an update must not write it", - 617
entry.path - 618
); - 619
} - 620
} - 621
- 622
fn snap(entry: &str, on_update: &str, files: Vec<FileSnapshot>) -> StateSnapshot { - 623
StateSnapshot { - 624
version: "test".into(), - 625
taken_at: "now".into(), - 626
entries: vec![EntrySnapshot { - 627
entry: entry.into(), - 628
root: "data".into(), - 629
on_update: on_update.into(), - 630
files, - 631
}], - 632
} - 633
} - 634
- 635
fn file(path: &str, sha: &str) -> FileSnapshot { - 636
FileSnapshot { - 637
path: path.into(), - 638
sha256: sha.into(), - 639
bytes: 1, - 640
json: None, - 641
} - 642
} - 643
- 644
#[test] - 645
fn an_untouched_ledger_that_changed_is_a_violation() { - 646
// The rule that makes append-only real: an update rewriting a - 647
// ledger is the failure this gate exists to catch. - 648
let before = snap("sessions", "Untouched", vec![file("a.jsonl", "aaa")]); - 649
let after = snap("sessions", "Untouched", vec![file("a.jsonl", "bbb")]); - 650
let found = verify_upgrade(&before, &after); - 651
assert_eq!(found.len(), 1, "{found:?}"); - 652
assert!(found[0].detail.contains("contents changed")); - 653
} - 654
- 655
#[test] - 656
fn a_vanished_file_is_a_violation_under_either_rule() { - 657
for rule in ["Untouched", "AdditiveOnly"] { - 658
let before = snap("gateway", rule, vec![file("bots.json", "aaa")]); - 659
let after = snap("gateway", rule, Vec::new()); - 660
let found = verify_upgrade(&before, &after); - 661
assert_eq!(found.len(), 1, "{rule}: {found:?}"); - 662
assert!(found[0].detail.contains("gone after the update")); - 663
} - 664
} - 665
- 666
#[test] - 667
fn an_additive_change_is_allowed_but_a_dropped_field_is_not() { - 668
// Adding a field is the whole point of additive-only; losing one - 669
// is the silent data loss it forbids. - 670
let mut before = snap("gateway", "AdditiveOnly", vec![file("bots.json", "aaa")]); - 671
before.entries[0].files[0].json = - 672
Some(serde_json::json!({ "schema": 1, "bots": [], "kept": true })); - 673
- 674
let dir = tempfile::tempdir().unwrap(); - 675
let path = dir.path().join("bots.json"); - 676
- 677
// Same fields plus a new one: allowed. - 678
std::fs::write( - 679
&path, - 680
serde_json::json!({ "schema": 1, "bots": [], "kept": true, "added": 2 }).to_string(), - 681
) - 682
.unwrap(); - 683
assert!(dropped_keys(&path, &before.entries[0].files[0]).is_none()); - 684
- 685
// A field silently removed: refused. - 686
std::fs::write( - 687
&path, - 688
serde_json::json!({ "schema": 1, "bots": [] }).to_string(), - 689
) - 690
.unwrap(); - 691
let detail = dropped_keys(&path, &before.entries[0].files[0]).expect("dropped field"); - 692
assert!(detail.contains("kept"), "{detail}"); - 693
} - 694
- 695
#[test] - 696
fn rebuilt_state_may_differ_freely() { - 697
let before = snap("locks", "Rebuilt", vec![file("x", "aaa")]); - 698
let after = snap("locks", "Rebuilt", Vec::new()); - 699
assert!(verify_upgrade(&before, &after).is_empty()); - 700
} - 701
- 702
#[test] - 703
fn an_undeclared_path_is_reported_as_undeclared() { - 704
assert!(is_declared(Root::Data, Path::new("sessions/x.jsonl"))); - 705
assert!(!is_declared( - 706
Root::Data, - 707
Path::new("something-nobody-declared.json") - 708
)); - 709
} - 710
} - 711
- 712
// ---- Snapshots and the upgrade contract ------------------------------------ - 713
- 714
use serde::{Deserialize, Serialize}; - 715
- 716
/// One durable file, as it stood at a moment in time. - 717
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 718
pub struct FileSnapshot { - 719
/// Path relative to its root. - 720
pub path: String, - 721
pub sha256: String, - 722
pub bytes: u64, - 723
/// The document as it stood, for JSON files only. - 724
/// - 725
/// A digest can prove a file changed but not *how*, and - 726
/// `AdditiveOnly` needs the earlier key set to prove nothing was - 727
/// dropped. Carried here so a snapshot is self-contained: the gate - 728
/// compares a file written by one build against a document captured - 729
/// by another, possibly on a different machine. - 730
#[serde(default, skip_serializing_if = "Option::is_none")] - 731
pub json: Option<serde_json::Value>, - 732
} - 733
- 734
/// Everything the registry declares, as it stood at a moment in time. - 735
/// - 736
/// Taken before an update and again after it, this is what turns "an - 737
/// update must not lose data" from a promise into an assertion - 738
/// (`docs/design/46-stabilization-install-and-onboarding.md` VII.5). - 739
#[derive(Debug, Clone, Serialize, Deserialize)] - 740
pub struct StateSnapshot { - 741
pub version: String, - 742
pub taken_at: String, - 743
/// Registry entry path → the files found under it. - 744
pub entries: Vec<EntrySnapshot>, - 745
} - 746
- 747
#[derive(Debug, Clone, Serialize, Deserialize)] - 748
pub struct EntrySnapshot { - 749
pub entry: String, - 750
pub root: String, - 751
pub on_update: String, - 752
pub files: Vec<FileSnapshot>, - 753
} - 754
- 755
/// A way an update broke the contract. - 756
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 757
pub struct Violation { - 758
pub entry: String, - 759
pub path: String, - 760
pub rule: String, - 761
pub detail: String, - 762
} - 763
- 764
impl std::fmt::Display for Violation { - 765
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - 766
write!( - 767
f, - 768
"{} ({} / {}): {}", - 769
self.path, self.entry, self.rule, self.detail - 770
) - 771
} - 772
} - 773
- 774
fn digest_of(path: &Path) -> Option<(String, u64)> { - 775
use sha2::{Digest as _, Sha256}; - 776
let bytes = std::fs::read(path).ok()?; - 777
Some((format!("{:x}", Sha256::digest(&bytes)), bytes.len() as u64)) - 778
} - 779
- 780
fn walk(root: &Path) -> Vec<PathBuf> { - 781
let mut found = Vec::new(); - 782
let mut stack = vec![root.to_path_buf()]; - 783
while let Some(dir) = stack.pop() { - 784
let Ok(entries) = std::fs::read_dir(&dir) else { - 785
continue; - 786
}; - 787
for entry in entries.flatten() { - 788
let path = entry.path(); - 789
if path.is_dir() { - 790
stack.push(path); - 791
} else { - 792
found.push(path); - 793
} - 794
} - 795
} - 796
found - 797
} - 798
- 799
fn root_label(root: Root) -> &'static str { - 800
match root { - 801
Root::Data => "data", - 802
Root::Cache => "cache", - 803
Root::Shared => "shared", - 804
Root::Logs => "logs", - 805
} - 806
} - 807
- 808
/// Digest every declared file, right now. - 809
pub fn snapshot(version: &str) -> StateSnapshot { - 810
let mut entries = Vec::new(); - 811
for entry in REGISTRY { - 812
let base = root_path(entry.root); - 813
let target = if entry.path.is_empty() { - 814
base.clone() - 815
} else { - 816
base.join(entry.path) - 817
}; - 818
let mut files = Vec::new(); - 819
let candidates = if target.is_dir() { - 820
walk(&target) - 821
} else if target.is_file() { - 822
vec![target.clone()] - 823
} else { - 824
Vec::new() - 825
}; - 826
for file in candidates { - 827
let Some((sha256, bytes)) = digest_of(&file) else { - 828
continue; - 829
}; - 830
let relative = file - 831
.strip_prefix(&base) - 832
.unwrap_or(&file) - 833
.to_string_lossy() - 834
.into_owned(); - 835
// Only for entries whose contract needs it: a ledger is - 836
// compared byte-for-byte, so carrying its parsed body would - 837
// bloat the snapshot for nothing. - 838
let json = (entry.on_update == OnUpdate::AdditiveOnly && relative.ends_with(".json")) - 839
.then(|| { - 840
std::fs::read_to_string(&file) - 841
.ok() - 842
.and_then(|raw| serde_json::from_str(&raw).ok()) - 843
}) - 844
.flatten(); - 845
files.push(FileSnapshot { - 846
path: relative, - 847
sha256, - 848
bytes, - 849
json, - 850
}); - 851
} - 852
files.sort_by(|a, b| a.path.cmp(&b.path)); - 853
entries.push(EntrySnapshot { - 854
entry: entry.path.to_string(), - 855
root: root_label(entry.root).to_string(), - 856
on_update: format!("{:?}", entry.on_update), - 857
files, - 858
}); - 859
} - 860
StateSnapshot { - 861
version: version.to_string(), - 862
taken_at: chrono::Utc::now().to_rfc3339(), - 863
entries, - 864
} - 865
} - 866
- 867
/// Every key path present in a JSON document. - 868
/// - 869
/// "Additive-only" means a field may be added and never removed, so the - 870
/// check that matters is whether the *earlier* key set still exists — - 871
/// comparing whole documents would fail on any legitimate addition. - 872
fn key_paths(value: &serde_json::Value, prefix: &str, out: &mut Vec<String>) { - 873
match value { - 874
serde_json::Value::Object(map) => { - 875
for (k, v) in map { - 876
let path = if prefix.is_empty() { - 877
k.clone() - 878
} else { - 879
format!("{prefix}.{k}") - 880
}; - 881
out.push(path.clone()); - 882
key_paths(v, &path, out); - 883
} - 884
} - 885
// Array *contents* are data, not shape: an appended session or - 886
// ledger row is exactly what these files are for. - 887
serde_json::Value::Array(_) => {} - 888
_ => {} - 889
} - 890
} - 891
- 892
/// Check a later snapshot against an earlier one, per registry rule. - 893
/// - 894
/// This is the assertion behind "an update never loses data" — and it - 895
/// deliberately lives beside the registry rather than in the script that - 896
/// calls it, so the rules have one definition. - 897
pub fn verify_upgrade(before: &StateSnapshot, after: &StateSnapshot) -> Vec<Violation> { - 898
let mut violations = Vec::new(); - 899
for prior in &before.entries { - 900
let Some(later) = after - 901
.entries - 902
.iter() - 903
.find(|e| e.entry == prior.entry && e.root == prior.root) - 904
else { - 905
violations.push(Violation { - 906
entry: prior.entry.clone(), - 907
path: prior.entry.clone(), - 908
rule: "declared".into(), - 909
detail: "the entry is gone from the registry entirely".into(), - 910
}); - 911
continue; - 912
}; - 913
let base = match later.root.as_str() { - 914
"shared" => root_path(Root::Shared), - 915
"cache" => root_path(Root::Cache), - 916
_ => root_path(Root::Data), - 917
}; - 918
- 919
for file in &prior.files { - 920
let now = later.files.iter().find(|f| f.path == file.path); - 921
match prior.on_update.as_str() { - 922
"Untouched" => match now { - 923
None => violations.push(Violation { - 924
entry: prior.entry.clone(), - 925
path: file.path.clone(), - 926
rule: "Untouched".into(), - 927
detail: "the file is gone after the update".into(), - 928
}), - 929
Some(now) if now.sha256 != file.sha256 => violations.push(Violation { - 930
entry: prior.entry.clone(), - 931
path: file.path.clone(), - 932
rule: "Untouched".into(), - 933
detail: format!( - 934
"contents changed ({} bytes → {} bytes)", - 935
file.bytes, now.bytes - 936
), - 937
}), - 938
Some(_) => {} - 939
}, - 940
"AdditiveOnly" => { - 941
let Some(_) = now else { - 942
violations.push(Violation { - 943
entry: prior.entry.clone(), - 944
path: file.path.clone(), - 945
rule: "AdditiveOnly".into(), - 946
detail: "the file is gone after the update".into(), - 947
}); - 948
continue; - 949
}; - 950
// For JSON, prove no prior field was dropped. Other - 951
// formats assert presence only, which this says - 952
// plainly rather than implying a check it does not do. - 953
if file.path.ends_with(".json") - 954
&& let Some(missing) = dropped_keys(&base.join(&file.path), file) - 955
{ - 956
violations.push(Violation { - 957
entry: prior.entry.clone(), - 958
path: file.path.clone(), - 959
rule: "AdditiveOnly".into(), - 960
detail: missing, - 961
}); - 962
} - 963
} - 964
// Rebuilt state is allowed to differ; it is derived. - 965
_ => {} - 966
} - 967
} - 968
} - 969
violations - 970
} - 971
- 972
/// Keys the earlier document had that the current file no longer does. - 973
/// - 974
/// Needs the earlier document, which a digest alone cannot supply, so this - 975
/// is only meaningful when the caller kept it. Returns `None` when nothing - 976
/// was dropped or the comparison cannot be made. - 977
fn dropped_keys(current: &Path, prior: &FileSnapshot) -> Option<String> { - 978
let raw = std::fs::read_to_string(current).ok()?; - 979
let now: serde_json::Value = serde_json::from_str(&raw).ok()?; - 980
let before = prior.json.as_ref()?; - 981
let mut before_keys = Vec::new(); - 982
key_paths(before, "", &mut before_keys); - 983
let mut now_keys = Vec::new(); - 984
key_paths(&now, "", &mut now_keys); - 985
let missing: Vec<&String> = before_keys - 986
.iter() - 987
.filter(|k| !now_keys.contains(k)) - 988
.collect(); - 989
(!missing.is_empty()).then(|| { - 990
format!( - 991
"fields present before the update are gone: {}", - 992
missing - 993
.iter() - 994
.map(|k| k.as_str()) - 995
.collect::<Vec<_>>() - 996
.join(", ") - 997
) - 998
}) - 999
} - 1000
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.