- 1
//! The narrowing lattice. - 2
//! - 3
//! [`Limits`] is the half of an engagement that carries authority - 4
//! implications. Every field is a **restriction**, the whole thing is a meet - 5
//! semilattice, and [`Limits::unrestricted`] is its top element — the identity - 6
//! that reproduces vak's behaviour before the intent kernel existed. - 7
//! - 8
//! This module exists so invariant 1 ("intent narrows, never widens") is a - 9
//! property of the type rather than a rule reviewers have to remember. - 10
//! Composition is only ever [`Limits::meet`], `meet` is proven `⊑` both - 11
//! operands by test, and nothing in the crate offers a "widen" operation to - 12
//! call by accident. - 13
//! - 14
//! Selections that carry no authority implication — which renderer to use, - 15
//! how chatty to be — deliberately live in [`crate::Posture`] instead, so that - 16
//! this type stays small enough to reason about completely. Capacity is not - 17
//! here either: a reading never caps the route ladder, the turn count or the - 18
//! workers, because a wrong reading would then take away what the request - 19
//! needed (invariant 32: a reading decides what is loaded, never what is - 20
//! possible). - 21
- 22
use std::collections::BTreeSet; - 23
- 24
use serde::{Deserialize, Serialize}; - 25
- 26
use crate::authority::{ApprovalCeiling, PermissionCeiling}; - 27
use crate::axes::{Modality, Satisfaction}; - 28
- 29
/// Which domain capabilities this turn loads. - 30
/// - 31
/// Forms a meet-semilattice: - 32
/// - `All` is the top element (unconstrained, admits any domain). - 33
/// - `Only(set)` restricts to the specified domains. - 34
/// - `Empty` is the bottom element (admits no domain). - 35
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] - 36
#[serde(tag = "kind", rename_all = "kebab-case")] - 37
pub enum DomainSet { - 38
/// No domain restriction: any capability domain is admitted. - 39
#[serde(rename = "all")] - 40
#[default] - 41
All, - 42
/// Only capabilities declaring at least one of these domains are admitted. - 43
#[serde(rename = "only")] - 44
Only { names: BTreeSet<String> }, - 45
/// No domain admitted. - 46
#[serde(rename = "empty")] - 47
Empty, - 48
} - 49
- 50
impl DomainSet { - 51
pub fn all() -> Self { - 52
DomainSet::All - 53
} - 54
- 55
pub fn only<I, S>(names: I) -> Self - 56
where - 57
I: IntoIterator<Item = S>, - 58
S: Into<String>, - 59
{ - 60
let names: BTreeSet<String> = names.into_iter().map(Into::into).collect(); - 61
if names.is_empty() { - 62
DomainSet::Empty - 63
} else { - 64
DomainSet::Only { names } - 65
} - 66
} - 67
- 68
pub fn empty() -> Self { - 69
DomainSet::Empty - 70
} - 71
- 72
pub fn allows(&self, domain: &str) -> bool { - 73
match self { - 74
DomainSet::All => true, - 75
DomainSet::Only { names } => names.contains(domain), - 76
DomainSet::Empty => false, - 77
} - 78
} - 79
- 80
pub fn contains(&self, domain: &str) -> bool { - 81
self.allows(domain) - 82
} - 83
- 84
pub fn is_unconstrained(&self) -> bool { - 85
matches!(self, DomainSet::All) - 86
} - 87
- 88
pub fn is_empty(&self) -> bool { - 89
match self { - 90
DomainSet::All => false, - 91
DomainSet::Only { names } => names.is_empty(), - 92
DomainSet::Empty => true, - 93
} - 94
} - 95
- 96
/// Least upper bound over the *names*: the domains either side needs. - 97
/// - 98
/// This is not a lattice `join` on authority — it exists for one caller, - 99
/// composing the strands of a multi-intent turn, where a turn that is - 100
/// "search the web and then run the tests" needs both toolsets. Relative - 101
/// to the unrestricted baseline it is still a narrowing (a finite set), - 102
/// which is the invariant that matters; see `Engagement::compose`. - 103
pub fn union(&self, other: &DomainSet) -> DomainSet { - 104
match (self, other) { - 105
(DomainSet::All, _) | (_, DomainSet::All) => DomainSet::All, - 106
(DomainSet::Empty, other) => other.clone(), - 107
(this, DomainSet::Empty) => this.clone(), - 108
(DomainSet::Only { names: a }, DomainSet::Only { names: b }) => { - 109
DomainSet::only(a.union(b).cloned()) - 110
} - 111
} - 112
} - 113
- 114
pub fn iter(&self) -> std::collections::btree_set::Iter<'_, String> { - 115
static EMPTY_SET: std::sync::LazyLock<BTreeSet<String>> = - 116
std::sync::LazyLock::new(BTreeSet::new); - 117
match self { - 118
DomainSet::All | DomainSet::Empty => EMPTY_SET.iter(), - 119
DomainSet::Only { names } => names.iter(), - 120
} - 121
} - 122
- 123
/// Greatest lower bound (meet). - 124
pub fn meet(&self, other: &DomainSet) -> DomainSet { - 125
match (self, other) { - 126
(DomainSet::All, other) => other.clone(), - 127
(this, DomainSet::All) => this.clone(), - 128
(DomainSet::Empty, _) | (_, DomainSet::Empty) => DomainSet::Empty, - 129
(DomainSet::Only { names: a }, DomainSet::Only { names: b }) => { - 130
let inter: BTreeSet<String> = a.intersection(b).cloned().collect(); - 131
if inter.is_empty() { - 132
DomainSet::Empty - 133
} else { - 134
DomainSet::Only { names: inter } - 135
} - 136
} - 137
} - 138
} - 139
- 140
/// Whether self admits nothing other forbids (self ⊑ other). - 141
pub fn is_at_most(&self, other: &DomainSet) -> bool { - 142
match (self, other) { - 143
(_, DomainSet::All) => true, - 144
(DomainSet::Empty, _) => true, - 145
(DomainSet::All, _) => false, - 146
// `Only {}` (reachable through deserialisation) is ⊥ too. - 147
(DomainSet::Only { names }, DomainSet::Empty) => names.is_empty(), - 148
(DomainSet::Only { names: a }, DomainSet::Only { names: b }) => a.is_subset(b), - 149
} - 150
} - 151
} - 152
- 153
impl<S: Into<String>> FromIterator<S> for DomainSet { - 154
/// Same rule as [`DomainSet::only`]: an empty collection is the bottom - 155
/// element, never the top. Collecting nothing must not silently admit - 156
/// everything. - 157
fn from_iter<T: IntoIterator<Item = S>>(iter: T) -> Self { - 158
DomainSet::only(iter) - 159
} - 160
} - 161
- 162
impl From<BTreeSet<String>> for DomainSet { - 163
fn from(names: BTreeSet<String>) -> Self { - 164
DomainSet::only(names) - 165
} - 166
} - 167
- 168
/// Take the smaller of two optional caps, treating `None` as "no cap". - 169
fn meet_cap(a: Option<f64>, b: Option<f64>) -> Option<f64> { - 170
match (a, b) { - 171
(None, other) => other, - 172
(this, None) => this, - 173
(Some(x), Some(y)) => Some(x.min(y)), - 174
} - 175
} - 176
- 177
/// Whether `a` is no larger a cap than `b`. `None` is unbounded, so it is only - 178
/// `⊑` another `None`. - 179
fn cap_is_at_most(a: Option<f64>, b: Option<f64>) -> bool { - 180
match (a, b) { - 181
(_, None) => true, - 182
(None, Some(_)) => false, - 183
(Some(x), Some(y)) => x <= y, - 184
} - 185
} - 186
- 187
/// The authority-bearing half of an engagement. - 188
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 189
pub struct Limits { - 190
/// Modalities a serving leg must support. Requiring more narrows the set - 191
/// of legs that may serve, so union is the narrowing direction. - 192
#[serde(default)] - 193
pub required_modalities: BTreeSet<Modality>, - 194
/// Kinds of work this turn plausibly needs, as domain names a capability - 195
/// can declare itself against (`crate::capability::domain` in vak-core). - 196
/// Decides which admitted tools are *loaded*; the rest stay one - 197
/// `find_tools` call away. - 198
#[serde(default)] - 199
pub required_domains: DomainSet, - 200
#[serde(default, skip_serializing_if = "Option::is_none")] - 201
pub spend_ceiling_usd: Option<f64>, - 202
#[serde(default)] - 203
pub approval_ceiling: ApprovalCeiling, - 204
#[serde(default)] - 205
pub permission_ceiling: PermissionCeiling, - 206
/// The weakest evidence that may close this work as fulfilled. Raising it - 207
/// is a narrowing: it forbids closures that were previously allowed. - 208
#[serde(default)] - 209
pub min_satisfaction: Satisfaction, - 210
} - 211
- 212
impl Default for Limits { - 213
fn default() -> Self { - 214
Limits::unrestricted() - 215
} - 216
} - 217
- 218
impl Limits { - 219
/// The top element: restricts nothing. - 220
pub fn unrestricted() -> Self { - 221
Limits { - 222
required_modalities: BTreeSet::new(), - 223
required_domains: DomainSet::All, - 224
spend_ceiling_usd: None, - 225
approval_ceiling: ApprovalCeiling::AutoApprove, - 226
permission_ceiling: PermissionCeiling::FullAccess, - 227
min_satisfaction: Satisfaction::Asserted, - 228
} - 229
} - 230
- 231
/// Greatest lower bound: the strictest limits that satisfy both. - 232
/// - 233
/// This is the **only** composition operator in the crate. There is no - 234
/// `join`, because widening is not an operation this system is allowed to - 235
/// perform. - 236
pub fn meet(&self, other: &Limits) -> Limits { - 237
Limits { - 238
required_domains: self.required_domains.meet(&other.required_domains), - 239
required_modalities: self - 240
.required_modalities - 241
.union(&other.required_modalities) - 242
.copied() - 243
.collect(), - 244
spend_ceiling_usd: meet_cap(self.spend_ceiling_usd, other.spend_ceiling_usd), - 245
approval_ceiling: self.approval_ceiling.meet(other.approval_ceiling), - 246
permission_ceiling: self.permission_ceiling.meet(other.permission_ceiling), - 247
min_satisfaction: if other.min_satisfaction.rank() > self.min_satisfaction.rank() { - 248
other.min_satisfaction - 249
} else { - 250
self.min_satisfaction - 251
}, - 252
} - 253
} - 254
- 255
/// Whether `self` grants nothing that `baseline` does not already grant. - 256
/// - 257
/// This is invariant 1 as a predicate. `vak-core` asserts it on every - 258
/// turn in debug builds, and the property tests prove it holds for every - 259
/// reading the resolver can produce. - 260
pub fn is_at_most(&self, baseline: &Limits) -> bool { - 261
baseline - 262
.required_modalities - 263
.is_subset(&self.required_modalities) - 264
&& self.required_domains.is_at_most(&baseline.required_domains) - 265
&& cap_is_at_most(self.spend_ceiling_usd, baseline.spend_ceiling_usd) - 266
&& self.approval_ceiling.rank() <= baseline.approval_ceiling.rank() - 267
&& self.permission_ceiling.rank() <= baseline.permission_ceiling.rank() - 268
&& self.min_satisfaction.rank() >= baseline.min_satisfaction.rank() - 269
} - 270
- 271
/// Human-readable list of what this narrows relative to `baseline`. - 272
/// - 273
/// Powers `vak intent explain` and the admin console's engagement diff. A - 274
/// narrowing nobody can see is a narrowing nobody can debug. - 275
pub fn diff_from(&self, baseline: &Limits) -> Vec<String> { - 276
let mut out = Vec::new(); - 277
if self.required_domains != baseline.required_domains { - 278
match &self.required_domains { - 279
DomainSet::All => {} - 280
DomainSet::Only { names } => out.push(format!( - 281
"domains loaded: {} ({})", - 282
names.len(), - 283
names.iter().cloned().collect::<Vec<_>>().join(", ") - 284
)), - 285
DomainSet::Empty => out.push("no tool domains loaded".into()), - 286
} - 287
} - 288
if self.required_modalities != baseline.required_modalities - 289
&& !self.required_modalities.is_empty() - 290
{ - 291
let names: Vec<&str> = self - 292
.required_modalities - 293
.iter() - 294
.map(|m| m.as_str()) - 295
.collect(); - 296
out.push(format!("serving leg must support {}", names.join(", "))); - 297
} - 298
if self.spend_ceiling_usd != baseline.spend_ceiling_usd - 299
&& let Some(cap) = self.spend_ceiling_usd - 300
{ - 301
out.push(format!("spend ceiling ${cap:.4}")); - 302
} - 303
if self.approval_ceiling != baseline.approval_ceiling { - 304
out.push(format!( - 305
"approval capped at {}", - 306
self.approval_ceiling.as_str() - 307
)); - 308
} - 309
if self.permission_ceiling != baseline.permission_ceiling { - 310
out.push(format!( - 311
"permission capped at {}", - 312
self.permission_ceiling.as_str() - 313
)); - 314
} - 315
if self.min_satisfaction != baseline.min_satisfaction { - 316
out.push(format!( - 317
"closure requires {} evidence", - 318
self.min_satisfaction.as_str() - 319
)); - 320
} - 321
out - 322
} - 323
} - 324
- 325
#[cfg(test)] - 326
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 327
mod tests { - 328
use super::*; - 329
- 330
fn sample() -> Vec<Limits> { - 331
let mut modal = Limits::unrestricted(); - 332
modal.required_modalities.insert(Modality::Image); - 333
- 334
vec![ - 335
Limits::unrestricted(), - 336
Limits { - 337
required_domains: DomainSet::only(["filesystem", "memory"]), - 338
spend_ceiling_usd: Some(0.01), - 339
approval_ceiling: ApprovalCeiling::Ask, - 340
permission_ceiling: PermissionCeiling::ReadOnly, - 341
min_satisfaction: Satisfaction::Attested, - 342
..Limits::unrestricted() - 343
}, - 344
Limits { - 345
required_domains: DomainSet::only(["filesystem", "code-exec"]), - 346
spend_ceiling_usd: Some(2.0), - 347
approval_ceiling: ApprovalCeiling::ApproveSafe, - 348
min_satisfaction: Satisfaction::Observed, - 349
..Limits::unrestricted() - 350
}, - 351
modal, - 352
] - 353
} - 354
- 355
/// Invariant 1, stated as a lattice law: a meet is below both operands. - 356
#[test] - 357
fn meet_is_below_both_operands() { - 358
for a in sample() { - 359
for b in sample() { - 360
let met = a.meet(&b); - 361
assert!(met.is_at_most(&a), "meet not below lhs: {met:?} vs {a:?}"); - 362
assert!(met.is_at_most(&b), "meet not below rhs: {met:?} vs {b:?}"); - 363
} - 364
} - 365
} - 366
- 367
#[test] - 368
fn unrestricted_is_the_identity() { - 369
for limits in sample() { - 370
assert_eq!(limits.meet(&Limits::unrestricted()), limits); - 371
assert_eq!(Limits::unrestricted().meet(&limits), limits); - 372
assert!(limits.is_at_most(&Limits::unrestricted())); - 373
} - 374
} - 375
- 376
#[test] - 377
fn meet_is_commutative_and_idempotent() { - 378
for a in sample() { - 379
assert_eq!(a.meet(&a), a); - 380
for b in sample() { - 381
assert_eq!(a.meet(&b), b.meet(&a)); - 382
} - 383
} - 384
} - 385
- 386
#[test] - 387
fn meet_is_associative() { - 388
for a in sample() { - 389
for b in sample() { - 390
for c in sample() { - 391
assert_eq!(a.meet(&b).meet(&c), a.meet(&b.meet(&c))); - 392
} - 393
} - 394
} - 395
} - 396
- 397
/// Widening must be detectable, or `is_at_most` is decorative. - 398
#[test] - 399
fn widening_any_field_is_rejected() { - 400
let narrow = Limits { - 401
required_domains: DomainSet::only(["filesystem"]), - 402
spend_ceiling_usd: Some(0.5), - 403
approval_ceiling: ApprovalCeiling::Ask, - 404
permission_ceiling: PermissionCeiling::ReadOnly, - 405
min_satisfaction: Satisfaction::Attested, - 406
required_modalities: BTreeSet::from([Modality::Image]), - 407
}; - 408
let widened = [ - 409
Limits { - 410
required_domains: DomainSet::All, - 411
..narrow.clone() - 412
}, - 413
Limits { - 414
spend_ceiling_usd: Some(5.0), - 415
..narrow.clone() - 416
}, - 417
Limits { - 418
spend_ceiling_usd: None, - 419
..narrow.clone() - 420
}, - 421
Limits { - 422
approval_ceiling: ApprovalCeiling::AutoApprove, - 423
..narrow.clone() - 424
}, - 425
Limits { - 426
permission_ceiling: PermissionCeiling::FullAccess, - 427
..narrow.clone() - 428
}, - 429
Limits { - 430
min_satisfaction: Satisfaction::Asserted, - 431
..narrow.clone() - 432
}, - 433
Limits { - 434
required_modalities: BTreeSet::new(), - 435
..narrow.clone() - 436
}, - 437
]; - 438
for candidate in widened { - 439
assert!( - 440
!candidate.is_at_most(&narrow), - 441
"widening went undetected: {candidate:?}" - 442
); - 443
} - 444
} - 445
- 446
#[test] - 447
fn diff_names_every_narrowing_it_applies() { - 448
let narrow = Limits { - 449
required_domains: DomainSet::only(["filesystem"]), - 450
approval_ceiling: ApprovalCeiling::Ask, - 451
min_satisfaction: Satisfaction::Observed, - 452
..Limits::unrestricted() - 453
}; - 454
let diff = narrow.diff_from(&Limits::unrestricted()); - 455
assert_eq!(diff.len(), 3, "{diff:?}"); - 456
assert!( - 457
Limits::unrestricted() - 458
.diff_from(&Limits::unrestricted()) - 459
.is_empty() - 460
); - 461
} - 462
} - 463
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.