- 1
//! The durable commitment and its lifecycle. - 2
//! - 3
//! # Why this exists - 4
//! - 5
//! vak's unit of identity has been the session, and completion has been "the - 6
//! model stopped talking". Both break the moment work outlives one - 7
//! conversation: there is nowhere to put a month-long obligation, and no way - 8
//! to say whether it was ever actually met. - 9
//! - 10
//! A [`Commitment`] inverts that. It is the durable identity; sessions become - 11
//! [`Episode`]s that advance it. It carries a satisfaction condition the - 12
//! **runtime** evaluates, so "done" is a fact about the world rather than a - 13
//! claim in a transcript. And it closes explicitly, with a [`Verdict`] and - 14
//! evidence — including [`Verdict::Unknown`], because a system that cannot - 15
//! admit it lost track of something will quietly accumulate work nobody is - 16
//! doing. - 17
//! - 18
//! Criterion and evidence types are reused from `vak-session` rather than - 19
//! redefined: `CriterionKind` already knows how to express "this shell command - 20
//! exits zero" and "this flow completed", and a second vocabulary for the same - 21
//! idea would be a liability. - 22
- 23
use serde::{Deserialize, Serialize}; - 24
- 25
use vak_intent::{Envelope, Reading, Satisfaction}; - 26
use vak_session::types::{CriterionResult, EvidenceRef, WorkCriterion}; - 27
- 28
/// Where a commitment is in its life. - 29
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 30
#[serde(rename_all = "kebab-case")] - 31
pub enum Phase { - 32
/// Resolved but not yet accepted — awaiting a human, or a clarification. - 33
Proposed, - 34
/// Being worked. - 35
Active, - 36
/// Waiting on a typed external condition. Not a failure: the work is - 37
/// intact and will resume. - 38
Suspended, - 39
/// Cannot proceed and cannot wait its way out. Needs intervention. - 40
Blocked, - 41
/// Criteria are being evaluated. - 42
Satisfying, - 43
/// Terminal. - 44
Closed, - 45
} - 46
- 47
impl Phase { - 48
pub fn as_str(self) -> &'static str { - 49
match self { - 50
Phase::Proposed => "proposed", - 51
Phase::Active => "active", - 52
Phase::Suspended => "suspended", - 53
Phase::Blocked => "blocked", - 54
Phase::Satisfying => "satisfying", - 55
Phase::Closed => "closed", - 56
} - 57
} - 58
- 59
pub fn is_terminal(self) -> bool { - 60
self == Phase::Closed - 61
} - 62
- 63
/// Whether the portfolio scheduler may pick this up for work. - 64
pub fn is_schedulable(self) -> bool { - 65
matches!(self, Phase::Active | Phase::Satisfying) - 66
} - 67
} - 68
- 69
/// How a commitment ended. - 70
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 71
#[serde(rename_all = "kebab-case")] - 72
pub enum Verdict { - 73
/// The satisfaction condition was met at the required strength. - 74
Fulfilled, - 75
/// Some required criteria passed and others did not. - 76
Partial, - 77
/// The work was attempted and did not succeed. - 78
Failed, - 79
/// A human called it off, or an escalation policy did. - 80
Abandoned, - 81
/// Replaced by a different commitment; see the lineage link. - 82
Superseded, - 83
/// Outlived its relevance window without closing. - 84
Expired, - 85
/// The runtime lost track of it — a crash mid-flight, a ledger gap, an - 86
/// external system that never answered. - 87
/// - 88
/// This variant is mandatory rather than a nicety. Without it the only way - 89
/// to tidy an untracked commitment is to assert an outcome nobody - 90
/// verified, which is exactly the dishonesty the satisfaction lattice - 91
/// exists to prevent. It mirrors `Settlement::Unknown` in the routing - 92
/// ledger. - 93
Unknown, - 94
} - 95
- 96
impl Verdict { - 97
pub fn as_str(self) -> &'static str { - 98
match self { - 99
Verdict::Fulfilled => "fulfilled", - 100
Verdict::Partial => "partial", - 101
Verdict::Failed => "failed", - 102
Verdict::Abandoned => "abandoned", - 103
Verdict::Superseded => "superseded", - 104
Verdict::Expired => "expired", - 105
Verdict::Unknown => "unknown", - 106
} - 107
} - 108
- 109
/// Whether this verdict claims the work actually got done. Only these - 110
/// are subject to the closure invariant. - 111
pub fn claims_success(self) -> bool { - 112
matches!(self, Verdict::Fulfilled) - 113
} - 114
} - 115
- 116
/// What an episode achieved. - 117
/// - 118
/// The distinction between `Learned` and `Stalled` is the point of this type. - 119
/// A naive "did a criterion move?" progress metric punishes the exploration - 120
/// that hard problems require; a naive "did we spend tokens?" metric cannot - 121
/// see a loop spinning. Separating them makes motion-without-progress - 122
/// detectable without penalising genuine investigation. - 123
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 124
#[serde(tag = "kind", rename_all = "kebab-case")] - 125
pub enum Advancement { - 126
/// A criterion moved, with the evidence that moved it. - 127
Advanced { - 128
criteria_moved: Vec<String>, - 129
#[serde(default)] - 130
evidence: Vec<EvidenceRef>, - 131
}, - 132
/// Nothing moved, but uncertainty was reduced. Legitimate progress. - 133
Learned { fact: String }, - 134
/// Needs something before it can continue. - 135
Blocked { blocker: String }, - 136
/// Spent budget, moved nothing, learned nothing. - 137
Stalled { reason: String }, - 138
} - 139
- 140
impl Advancement { - 141
pub fn as_str(&self) -> &'static str { - 142
match self { - 143
Advancement::Advanced { .. } => "advanced", - 144
Advancement::Learned { .. } => "learned", - 145
Advancement::Blocked { .. } => "blocked", - 146
Advancement::Stalled { .. } => "stalled", - 147
} - 148
} - 149
- 150
/// Whether this counts against the stall breaker. - 151
pub fn is_stall(&self) -> bool { - 152
matches!(self, Advancement::Stalled { .. }) - 153
} - 154
} - 155
- 156
/// What a suspended commitment is waiting for. - 157
/// - 158
/// Every variant maps onto a wake mechanism vak already has, which is why - 159
/// suspension is a rewiring rather than new infrastructure: the inbox and - 160
/// gateway approvals serve `Human`, the cron engine serves `Schedule`, the - 161
/// script watchdog serves `Predicate` at zero token cost while the predicate - 162
/// stays false, and the delivery outbox serves `External`. - 163
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 164
#[serde(tag = "kind", rename_all = "kebab-case")] - 165
pub enum Suspension { - 166
/// Waiting on a person. - 167
Human { - 168
question_id: String, - 169
question: String, - 170
#[serde(default, skip_serializing_if = "Option::is_none")] - 171
addressed_to: Option<String>, - 172
/// What happens if nobody answers. Never optional: a deferred question - 173
/// with no timeout is how an agent accumulates abandoned work. - 174
escalation: vak_intent::Escalation, - 175
}, - 176
/// Waiting for a time. - 177
Schedule { - 178
#[serde(default, skip_serializing_if = "Option::is_none")] - 179
at: Option<chrono::DateTime<chrono::Utc>>, - 180
#[serde(default, skip_serializing_if = "Option::is_none")] - 181
cron: Option<String>, - 182
}, - 183
/// Waiting for a machine-checkable condition to become true. - 184
Predicate { criterion: WorkCriterion }, - 185
/// Waiting on another commitment. - 186
Commitment { commitment_id: String }, - 187
/// Waiting on an external system. - 188
External { - 189
integration: String, - 190
#[serde(default, skip_serializing_if = "Option::is_none")] - 191
operation_id: Option<String>, - 192
}, - 193
} - 194
- 195
impl Suspension { - 196
pub fn as_str(&self) -> &'static str { - 197
match self { - 198
Suspension::Human { .. } => "human", - 199
Suspension::Schedule { .. } => "schedule", - 200
Suspension::Predicate { .. } => "predicate", - 201
Suspension::Commitment { .. } => "commitment", - 202
Suspension::External { .. } => "external", - 203
} - 204
} - 205
- 206
/// A one-line description for an inbox entry or a portfolio row. - 207
pub fn describe(&self) -> String { - 208
match self { - 209
Suspension::Human { question, .. } => format!("awaiting an answer: {question}"), - 210
Suspension::Schedule { at: Some(at), .. } => format!("scheduled for {at}"), - 211
Suspension::Schedule { cron: Some(c), .. } => format!("on schedule `{c}`"), - 212
Suspension::Schedule { .. } => "scheduled".into(), - 213
Suspension::Predicate { criterion } => { - 214
format!("waiting for: {}", criterion.statement) - 215
} - 216
Suspension::Commitment { commitment_id } => { - 217
format!("waiting on commitment {commitment_id}") - 218
} - 219
Suspension::External { integration, .. } => format!("waiting on {integration}"), - 220
} - 221
} - 222
} - 223
- 224
/// One session's worth of work on a commitment. - 225
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 226
pub struct Episode { - 227
pub episode_id: String, - 228
pub session_id: String, - 229
pub started_at: chrono::DateTime<chrono::Utc>, - 230
#[serde(default, skip_serializing_if = "Option::is_none")] - 231
pub ended_at: Option<chrono::DateTime<chrono::Utc>>, - 232
#[serde(default, skip_serializing_if = "Option::is_none")] - 233
pub advancement: Option<Advancement>, - 234
#[serde(default)] - 235
pub spend_usd: f64, - 236
} - 237
- 238
/// The economics and relevance window of a long-lived commitment. - 239
/// - 240
/// A lifetime budget rather than a per-run cap, because per-run caps are - 241
/// exactly how an agent bleeds money across three months while every - 242
/// individual run looks reasonable. Expiry produces an explicit - 243
/// [`Verdict::Expired`] and never a silent deletion. - 244
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 245
pub struct Economics { - 246
#[serde(default, skip_serializing_if = "Option::is_none")] - 247
pub lifetime_budget_usd: Option<f64>, - 248
/// Stop considering this relevant after this instant. - 249
#[serde(default, skip_serializing_if = "Option::is_none")] - 250
pub expires_at: Option<chrono::DateTime<chrono::Utc>>, - 251
/// How often the portfolio should surface this for human review. - 252
#[serde(default, skip_serializing_if = "Option::is_none")] - 253
pub review_every_hours: Option<u32>, - 254
/// Consecutive stalls before the stall breaker trips. - 255
#[serde(default = "default_stall_limit")] - 256
pub stall_limit: u32, - 257
} - 258
- 259
fn default_stall_limit() -> u32 { - 260
3 - 261
} - 262
- 263
/// Hand-written rather than derived. `#[serde(default = "…")]` only applies - 264
/// when deserializing, so a derived `Default` would give `stall_limit: 0` and - 265
/// every commitment constructed in Rust would be born already stalled. - 266
impl Default for Economics { - 267
fn default() -> Self { - 268
Economics { - 269
lifetime_budget_usd: None, - 270
expires_at: None, - 271
review_every_hours: None, - 272
stall_limit: default_stall_limit(), - 273
} - 274
} - 275
} - 276
- 277
/// What a commitment is, as declared when it opens. - 278
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 279
pub struct CommitmentSpec { - 280
/// One sentence a human would recognise months later. - 281
pub objective: String, - 282
/// The reading that opened it. - 283
pub reading: Reading, - 284
/// Criteria the runtime evaluates. `WorkCriterion::kind` says how. - 285
#[serde(default)] - 286
pub criteria: Vec<WorkCriterion>, - 287
/// The weakest evidence that may close this `Fulfilled`, taken from the - 288
/// reading's `evidence` axis. - 289
pub min_satisfaction: Satisfaction, - 290
#[serde(default)] - 291
pub economics: Economics, - 292
/// The workspace this belongs to. - 293
pub cwd: std::path::PathBuf, - 294
/// Which commitment this replaced, if any. - 295
#[serde(default, skip_serializing_if = "Option::is_none")] - 296
pub supersedes: Option<String>, - 297
/// The conversation thread (strand lineage) this commitment serves, so - 298
/// a later turn that continues the thread attaches to it instead of - 299
/// opening a twin (docs/design/47-commitment-kernel.md, strands). - 300
#[serde(default, skip_serializing_if = "Option::is_none")] - 301
pub thread_id: Option<String>, - 302
/// The conversation audience that asked for this work - 303
/// (docs/design/64-agent-owned-platform.md). An Agent serving several - 304
/// chats owes each of them its own obligations, and a portfolio read - 305
/// from one chat must not reveal another's. - 306
#[serde(default, skip_serializing_if = "Option::is_none")] - 307
pub audience_id: Option<String>, - 308
} - 309
- 310
/// One criterion's standing. - 311
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 312
pub struct CriterionState { - 313
pub criterion_id: String, - 314
pub statement: String, - 315
pub required: bool, - 316
#[serde(default, skip_serializing_if = "Option::is_none")] - 317
pub result: Option<CriterionResult>, - 318
/// How strongly the result is backed. A `Semantic` criterion the model - 319
/// asserted is `Asserted`; a `Shell` criterion the runtime ran is - 320
/// `Observed`. - 321
#[serde(default, skip_serializing_if = "Option::is_none")] - 322
pub strength: Option<Satisfaction>, - 323
#[serde(default, skip_serializing_if = "Option::is_none")] - 324
pub evaluated_at: Option<chrono::DateTime<chrono::Utc>>, - 325
} - 326
- 327
impl CriterionState { - 328
pub fn passed(&self) -> bool { - 329
matches!(self.result, Some(CriterionResult::Passed { .. })) - 330
} - 331
} - 332
- 333
/// How a commitment closed. - 334
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 335
pub struct Closure { - 336
pub verdict: Verdict, - 337
/// The weakest strength among the criteria that justified this closure. - 338
pub strength: Satisfaction, - 339
pub closed_at: chrono::DateTime<chrono::Utc>, - 340
#[serde(default)] - 341
pub evidence: Vec<EvidenceRef>, - 342
pub note: String, - 343
} - 344
- 345
/// The projected current state of a commitment. - 346
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 347
pub struct Commitment { - 348
pub commitment_id: String, - 349
pub opened_at: chrono::DateTime<chrono::Utc>, - 350
pub spec: CommitmentSpec, - 351
pub phase: Phase, - 352
#[serde(default)] - 353
pub criteria: Vec<CriterionState>, - 354
#[serde(default)] - 355
pub episodes: Vec<Episode>, - 356
#[serde(default, skip_serializing_if = "Option::is_none")] - 357
pub suspension: Option<Suspension>, - 358
#[serde(default, skip_serializing_if = "Option::is_none")] - 359
pub blocker: Option<String>, - 360
#[serde(default, skip_serializing_if = "Option::is_none")] - 361
pub envelope: Option<Envelope>, - 362
#[serde(default, skip_serializing_if = "Option::is_none")] - 363
pub closure: Option<Closure>, - 364
#[serde(default, skip_serializing_if = "Option::is_none")] - 365
pub superseded_by: Option<String>, - 366
/// Total spend across every episode. - 367
#[serde(default)] - 368
pub spend_usd: f64, - 369
/// Consecutive stalled episodes. Reset by any other advancement. - 370
#[serde(default)] - 371
pub consecutive_stalls: u32, - 372
/// Drift recorded at the last re-admission, if any. - 373
#[serde(default)] - 374
pub drift: Vec<String>, - 375
pub updated_at: chrono::DateTime<chrono::Utc>, - 376
} - 377
- 378
impl Commitment { - 379
/// Required criteria that have not yet passed. - 380
pub fn outstanding(&self) -> Vec<&CriterionState> { - 381
self.criteria - 382
.iter() - 383
.filter(|criterion| criterion.required && !criterion.passed()) - 384
.collect() - 385
} - 386
- 387
/// The weakest strength among criteria that have passed, or `Asserted` - 388
/// when there are no criteria at all. - 389
/// - 390
/// Weakest rather than strongest: a closure is only as well-evidenced as - 391
/// its flimsiest supporting criterion, and reporting the best one would - 392
/// let a single shell check launder four semantic assertions. - 393
pub fn achieved_strength(&self) -> Satisfaction { - 394
self.criteria - 395
.iter() - 396
.filter(|criterion| criterion.required) - 397
.map(|criterion| criterion.strength.unwrap_or(Satisfaction::Asserted)) - 398
.min_by_key(|strength| strength.rank()) - 399
.unwrap_or(Satisfaction::Asserted) - 400
} - 401
- 402
/// Whether the closure invariant permits closing with `verdict`. - 403
/// - 404
/// Only success claims are constrained: recording a failure, an - 405
/// abandonment, or an honest `Unknown` must always be possible, or the - 406
/// ledger would be unable to tell the truth about work that went wrong. - 407
pub fn may_close(&self, verdict: Verdict) -> Result<Satisfaction, ClosureRefusal> { - 408
if !verdict.claims_success() { - 409
return Ok(self.achieved_strength()); - 410
} - 411
let outstanding = self.outstanding(); - 412
if !outstanding.is_empty() { - 413
return Err(ClosureRefusal::CriteriaOutstanding { - 414
ids: outstanding - 415
.iter() - 416
.map(|criterion| criterion.criterion_id.clone()) - 417
.collect(), - 418
}); - 419
} - 420
let achieved = self.achieved_strength(); - 421
if !achieved.satisfies(self.spec.min_satisfaction) { - 422
return Err(ClosureRefusal::InsufficientEvidence { - 423
required: self.spec.min_satisfaction, - 424
achieved, - 425
}); - 426
} - 427
Ok(achieved) - 428
} - 429
- 430
/// Whether the stall breaker has tripped. - 431
pub fn is_stalled(&self) -> bool { - 432
self.consecutive_stalls >= self.spec.economics.stall_limit - 433
} - 434
- 435
/// Whether the lifetime budget is exhausted. - 436
pub fn is_over_budget(&self) -> bool { - 437
self.spec - 438
.economics - 439
.lifetime_budget_usd - 440
.is_some_and(|cap| self.spend_usd >= cap) - 441
} - 442
- 443
/// Whether the relevance window has passed. - 444
pub fn is_expired(&self, now: chrono::DateTime<chrono::Utc>) -> bool { - 445
self.spec - 446
.economics - 447
.expires_at - 448
.is_some_and(|expiry| now >= expiry) - 449
} - 450
- 451
/// One-line portfolio summary. - 452
pub fn summary(&self) -> String { - 453
let progress = format!( - 454
"{}/{} criteria", - 455
self.criteria.iter().filter(|c| c.passed()).count(), - 456
self.criteria.len() - 457
); - 458
match (&self.closure, &self.suspension) { - 459
(Some(closure), _) => format!( - 460
"{} — {} ({}, {progress})", - 461
self.spec.objective, - 462
closure.verdict.as_str(), - 463
closure.strength.as_str() - 464
), - 465
(None, Some(suspension)) => { - 466
format!("{} — {}", self.spec.objective, suspension.describe()) - 467
} - 468
(None, None) => format!( - 469
"{} — {} ({progress})", - 470
self.spec.objective, - 471
self.phase.as_str() - 472
), - 473
} - 474
} - 475
} - 476
- 477
/// Why a closure was refused. - 478
#[derive(Debug, Clone, PartialEq, thiserror::Error)] - 479
pub enum ClosureRefusal { - 480
#[error( - 481
"cannot close fulfilled: {} required criteria have not passed ({})", - 482
ids.len(), - 483
ids.join(", ") - 484
)] - 485
CriteriaOutstanding { ids: Vec<String> }, - 486
#[error( - 487
"cannot close fulfilled: this work requires {} evidence but only {} was achieved", - 488
required.as_str(), - 489
achieved.as_str() - 490
)] - 491
InsufficientEvidence { - 492
required: Satisfaction, - 493
achieved: Satisfaction, - 494
}, - 495
} - 496
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.