- 1
//! Immutable, versioned capability sets, and what binds to one. - 2
//! - 3
//! The unit of atomicity is the **turn**, not the session. A turn takes one - 4
//! `Arc<CapabilitySet>` at its start and holds it to the end, so a plan - 5
//! formed in step one cannot have its tools change by step four. Between - 6
//! turns the session re-binds to whatever the registry has published since. - 7
//! - 8
//! That is what makes session rotation unnecessary rather than merely - 9
//! forbidden. The previous design froze a *copy* of the capability list into - 10
//! the session header, which made a session born during a slow discovery - 11
//! pass permanently degraded — its only escape hatches were restarting the - 12
//! process or rotating the session, and both are ruled out. Binding by - 13
//! epoch instead means a three-week-old conversation picks up a skill you - 14
//! add today, at its next turn, with no restart and no rotation. - 15
//! - 16
//! Audit gets stronger, not weaker: previously you could reconstruct what a - 17
//! session was *born* with; now every turn records the epoch it ran at and - 18
//! every transition is a ledger entry, so you can reconstruct what each turn - 19
//! actually saw. - 20
- 21
use std::collections::BTreeMap; - 22
use std::sync::Arc; - 23
use std::time::SystemTime; - 24
- 25
use serde::{Deserialize, Serialize}; - 26
use vak_session::types::{CapabilityDescriptor, CapabilityKind}; - 27
- 28
use super::McpInventory; - 29
use super::domain::Serves; - 30
use super::resolution::Resolution; - 31
- 32
/// A monotonic version of the whole capability set. Never reused, never - 33
/// decreases, and bumped only when a reconcile pass produces a set that - 34
/// differs from the last published one. - 35
pub type Epoch = u64; - 36
- 37
/// Where a capability came from. Kept structured rather than as a display - 38
/// string, because `reach` used to recover a server name by string-parsing a - 39
/// human-readable label — a rendering decision that had become load-bearing - 40
/// for a policy decision. - 41
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] - 42
#[serde(tag = "origin", rename_all = "kebab-case")] - 43
pub enum Origin { - 44
/// Compiled in. - 45
Builtin, - 46
/// The workspace's own `.vak` directory. - 47
Workspace, - 48
/// The shared/user-level capability root. - 49
Shared, - 50
/// Contributed by an installed plugin. - 51
Plugin { plugin: String, scope: String }, - 52
/// Injected at runtime by a host surface. - 53
Runtime, - 54
} - 55
- 56
impl Origin { - 57
pub fn label(&self) -> String { - 58
match self { - 59
Origin::Builtin => "builtin".into(), - 60
Origin::Workspace => "workspace".into(), - 61
Origin::Shared => "shared".into(), - 62
Origin::Plugin { plugin, scope } => format!("plugin:{scope}:{plugin}"), - 63
Origin::Runtime => "runtime".into(), - 64
} - 65
} - 66
} - 67
- 68
/// A capability's stable identity: kind plus name. - 69
/// - 70
/// Kind-qualified because a skill named `pdf` and an MCP server named `pdf` - 71
/// are different things, and a registry keyed on the bare name would let one - 72
/// shadow the other silently. - 73
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] - 74
pub struct CapabilityId { - 75
pub kind: CapabilityKind, - 76
pub name: String, - 77
} - 78
- 79
impl CapabilityId { - 80
pub fn new(kind: CapabilityKind, name: impl Into<String>) -> Self { - 81
CapabilityId { - 82
kind, - 83
name: name.into(), - 84
} - 85
} - 86
} - 87
- 88
impl std::fmt::Display for CapabilityId { - 89
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - 90
let kind = match self.kind { - 91
CapabilityKind::Tool => "tool", - 92
CapabilityKind::Skill => "skill", - 93
CapabilityKind::McpServer => "mcp", - 94
CapabilityKind::Hook => "hook", - 95
CapabilityKind::Command => "command", - 96
}; - 97
write!(f, "{kind}:{}", self.name) - 98
} - 99
} - 100
- 101
/// One capability, as the registry knows it. - 102
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 103
pub struct Capability { - 104
pub id: CapabilityId, - 105
pub origin: Origin, - 106
pub summary: String, - 107
/// What the capability says it is for. See `domain.rs` — undeclared is - 108
/// a real answer and is never sliced away. - 109
pub serves: Serves, - 110
/// Content digest where the capability has content (a skill body). - 111
pub digest: Option<String>, - 112
pub source: Option<std::path::PathBuf>, - 113
pub resolution: Resolution, - 114
/// Kind-specific frozen configuration: tool schemas, command templates, - 115
/// hook lifecycle options, a discovered MCP catalog. - 116
#[serde(default)] - 117
pub configuration: serde_json::Value, - 118
} - 119
- 120
impl Capability { - 121
/// Whether a turn bound to this set may use it right now. - 122
pub fn is_usable(&self) -> bool { - 123
self.resolution.is_usable() - 124
} - 125
- 126
/// Project back into the wire type the session header and prompt already - 127
/// speak, so nothing downstream has to learn a second representation. - 128
pub fn to_descriptor(&self) -> CapabilityDescriptor { - 129
use vak_session::types::CapabilityInvocation; - 130
let invocation = match self.id.kind { - 131
CapabilityKind::Tool | CapabilityKind::McpServer => CapabilityInvocation::ModelTool, - 132
CapabilityKind::Skill => CapabilityInvocation::SkillLoader, - 133
CapabilityKind::Hook => CapabilityInvocation::Automatic, - 134
CapabilityKind::Command => CapabilityInvocation::UserCommand, - 135
}; - 136
CapabilityDescriptor { - 137
name: self.id.name.clone(), - 138
kind: self.id.kind.clone(), - 139
invocation, - 140
description: self.summary.clone(), - 141
source: self.source.clone(), - 142
digest: self.digest.clone(), - 143
provenance: Some(self.origin.label()), - 144
configuration: self.configuration.clone(), - 145
} - 146
} - 147
} - 148
- 149
/// An immutable published capability set. - 150
/// - 151
/// Shared as `Arc<CapabilitySet>` and **refcounted, not retained**: the - 152
/// registry keeps only the current one, so an old epoch lives exactly as - 153
/// long as the last turn holding it. A design that kept every version would - 154
/// climb steadily over weeks of edits and end in an OOM, which is the one - 155
/// failure a no-restart system cannot absorb. - 156
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 157
pub struct CapabilitySet { - 158
pub epoch: Epoch, - 159
pub published_at: SystemTime, - 160
/// Ordered by id, so the digest is stable across reconcile passes that - 161
/// discovered the same things in a different order. - 162
capabilities: BTreeMap<CapabilityId, Capability>, - 163
/// Content digest of the usable set. Two epochs with the same digest - 164
/// describe the same world. - 165
pub digest: String, - 166
} - 167
- 168
impl CapabilitySet { - 169
pub fn new(epoch: Epoch, capabilities: Vec<Capability>) -> Self { - 170
let map: BTreeMap<CapabilityId, Capability> = capabilities - 171
.into_iter() - 172
.map(|c| (c.id.clone(), c)) - 173
.collect(); - 174
let digest = Self::digest_of(&map); - 175
CapabilitySet { - 176
epoch, - 177
published_at: SystemTime::now(), - 178
capabilities: map, - 179
digest, - 180
} - 181
} - 182
- 183
pub fn empty() -> Self { - 184
CapabilitySet::new(0, Vec::new()) - 185
} - 186
- 187
/// Digest over identity, usability and content — the things that change - 188
/// what a turn can do. An MCP server's configuration carries only its - 189
/// observed catalog and failure *reason*, never attempt counts or - 190
/// timestamps, so repeated failures for the same reason do not churn - 191
/// every live session's prompt. - 192
fn digest_of(map: &BTreeMap<CapabilityId, Capability>) -> String { - 193
use sha2::{Digest, Sha256}; - 194
let mut hasher = Sha256::new(); - 195
for (id, capability) in map { - 196
hasher.update(id.to_string().as_bytes()); - 197
hasher.update([u8::from(capability.is_usable())]); - 198
hasher.update(capability.digest.clone().unwrap_or_default().as_bytes()); - 199
hasher.update(capability.summary.as_bytes()); - 200
hasher.update(capability.origin.label().as_bytes()); - 201
for label in capability.serves.labels() { - 202
hasher.update(label.as_bytes()); - 203
} - 204
hasher.update(capability.configuration.to_string().as_bytes()); - 205
} - 206
format!("{:x}", hasher.finalize()) - 207
} - 208
- 209
pub fn get(&self, id: &CapabilityId) -> Option<&Capability> { - 210
self.capabilities.get(id) - 211
} - 212
- 213
pub fn all(&self) -> impl Iterator<Item = &Capability> { - 214
self.capabilities.values() - 215
} - 216
- 217
/// Everything a turn may actually use. - 218
pub fn usable(&self) -> impl Iterator<Item = &Capability> { - 219
self.capabilities.values().filter(|c| c.is_usable()) - 220
} - 221
- 222
pub fn of_kind(&self, kind: CapabilityKind) -> impl Iterator<Item = &Capability> { - 223
self.capabilities - 224
.values() - 225
.filter(move |c| c.id.kind == kind && c.is_usable()) - 226
} - 227
- 228
/// Configured but not usable, with the reason. The counterpart the - 229
/// operator report and the model's standing section both render. - 230
pub fn unusable(&self) -> impl Iterator<Item = &Capability> { - 231
self.capabilities.values().filter(|c| !c.is_usable()) - 232
} - 233
- 234
/// The descriptor vector the session header and prompt speak. - 235
pub fn descriptors(&self) -> Vec<CapabilityDescriptor> { - 236
self.usable().map(|c| c.to_descriptor()).collect() - 237
} - 238
- 239
/// The MCP tool inventory (server names and discovered tools) extracted - 240
/// from usable `McpServer` capabilities in this set. - 241
/// - 242
/// This is the canonical source of truth for MCP tools: each server's - 243
/// `configuration.tools` is what the on-demand pool last observed, and it - 244
/// survives the pool evicting an idle connection. - 245
pub fn mcp_inventory(&self) -> McpInventory { - 246
let mut inventory = Vec::new(); - 247
for cap in self.of_kind(CapabilityKind::McpServer) { - 248
let Some(tools_val) = cap.configuration.get("tools").and_then(|t| t.as_array()) else { - 249
continue; - 250
}; - 251
let mut tools = Vec::new(); - 252
for t in tools_val { - 253
let Some(name) = t.get("name").and_then(|n| n.as_str()) else { - 254
continue; - 255
}; - 256
let description = t - 257
.get("description") - 258
.and_then(|d| d.as_str()) - 259
.unwrap_or_default() - 260
.to_string(); - 261
let input_schema = t - 262
.get("inputSchema") - 263
.cloned() - 264
.unwrap_or(serde_json::Value::Null); - 265
tools.push(vak_mcp::McpToolInfo { - 266
name: name.to_string(), - 267
description, - 268
input_schema, - 269
}); - 270
} - 271
if !tools.is_empty() { - 272
inventory.push((cap.id.name.clone(), tools)); - 273
} - 274
} - 275
inventory - 276
} - 277
- 278
/// What changed between two published sets. - 279
pub fn delta_from(&self, previous: &CapabilitySet) -> CapabilityDelta { - 280
let mut delta = CapabilityDelta::default(); - 281
for (id, capability) in &self.capabilities { - 282
match previous.capabilities.get(id) { - 283
None => delta.added.push(id.clone()), - 284
Some(before) => { - 285
if before.is_usable() != capability.is_usable() - 286
|| before.digest != capability.digest - 287
|| before.summary != capability.summary - 288
{ - 289
delta.updated.push(id.clone()); - 290
} - 291
} - 292
} - 293
} - 294
for id in previous.capabilities.keys() { - 295
if !self.capabilities.contains_key(id) { - 296
delta.removed.push(id.clone()); - 297
} - 298
} - 299
delta - 300
} - 301
} - 302
- 303
/// What one epoch transition changed, for the ledger entry and the notice - 304
/// the model gets on its next turn. - 305
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] - 306
pub struct CapabilityDelta { - 307
pub added: Vec<CapabilityId>, - 308
pub updated: Vec<CapabilityId>, - 309
pub removed: Vec<CapabilityId>, - 310
} - 311
- 312
impl CapabilityDelta { - 313
pub fn is_empty(&self) -> bool { - 314
self.added.is_empty() && self.updated.is_empty() && self.removed.is_empty() - 315
} - 316
- 317
/// A sentence for the model's standing section, so a capability that - 318
/// appeared or vanished under a live session is announced rather than - 319
/// silently changing what works. - 320
pub fn describe(&self) -> String { - 321
let mut parts = Vec::new(); - 322
let names = |ids: &[CapabilityId]| { - 323
ids.iter() - 324
.map(|id| format!("`{id}`")) - 325
.collect::<Vec<_>>() - 326
.join(", ") - 327
}; - 328
if !self.added.is_empty() { - 329
parts.push(format!("now available: {}", names(&self.added))); - 330
} - 331
if !self.updated.is_empty() { - 332
parts.push(format!("changed: {}", names(&self.updated))); - 333
} - 334
if !self.removed.is_empty() { - 335
parts.push(format!("no longer available: {}", names(&self.removed))); - 336
} - 337
parts.join("; ") - 338
} - 339
} - 340
- 341
/// What a turn holds: one snapshot, for its whole duration. - 342
#[derive(Debug, Clone)] - 343
pub struct Binding { - 344
pub set: Arc<CapabilitySet>, - 345
/// The delta from the epoch the session was previously bound to, if it - 346
/// moved. Rendered into the standing section exactly once. - 347
pub delta: Option<CapabilityDelta>, - 348
} - 349
- 350
impl Binding { - 351
pub fn epoch(&self) -> Epoch { - 352
self.set.epoch - 353
} - 354
} - 355
- 356
#[cfg(test)] - 357
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 358
mod tests { - 359
use super::*; - 360
- 361
fn cap(name: &str, kind: CapabilityKind, usable: bool) -> Capability { - 362
Capability { - 363
id: CapabilityId::new(kind, name), - 364
origin: Origin::Builtin, - 365
summary: String::new(), - 366
serves: Serves::Undeclared, - 367
digest: None, - 368
source: None, - 369
resolution: if usable { - 370
Resolution::Available - 371
} else { - 372
Resolution::Retired { - 373
reason: "revoked".into(), - 374
} - 375
}, - 376
configuration: serde_json::Value::Null, - 377
} - 378
} - 379
- 380
#[test] - 381
fn unusable_capabilities_are_excluded_from_descriptors() { - 382
let set = CapabilitySet::new( - 383
1, - 384
vec![ - 385
cap("read", CapabilityKind::Tool, true), - 386
cap("tavily", CapabilityKind::McpServer, false), - 387
], - 388
); - 389
let names: Vec<_> = set.descriptors().into_iter().map(|d| d.name).collect(); - 390
assert_eq!(names, vec!["read"]); - 391
assert_eq!(set.unusable().count(), 1); - 392
} - 393
- 394
#[test] - 395
fn the_digest_follows_observed_configuration() { - 396
let a = cap("tavily", CapabilityKind::McpServer, true); - 397
let mut b = a.clone(); - 398
b.configuration = serde_json::json!({"tools": [{"name": "search"}]}); - 399
let set_a = CapabilitySet::new(1, vec![a.clone()]); - 400
let set_b = CapabilitySet::new(2, vec![b]); - 401
assert_ne!(set_a.digest, set_b.digest, "a learned catalog is news"); - 402
assert_eq!(set_a.digest, CapabilitySet::new(3, vec![a]).digest); - 403
} - 404
- 405
#[test] - 406
fn a_kind_change_is_a_different_capability() { - 407
let set = CapabilitySet::new( - 408
1, - 409
vec![ - 410
cap("pdf", CapabilityKind::Skill, true), - 411
cap("pdf", CapabilityKind::McpServer, true), - 412
], - 413
); - 414
assert_eq!( - 415
set.usable().count(), - 416
2, - 417
"kind-qualified ids must not shadow" - 418
); - 419
} - 420
- 421
#[test] - 422
fn delta_names_what_moved() { - 423
let before = CapabilitySet::new(1, vec![cap("read", CapabilityKind::Tool, true)]); - 424
let after = CapabilitySet::new( - 425
2, - 426
vec![ - 427
cap("read", CapabilityKind::Tool, true), - 428
cap("weather", CapabilityKind::McpServer, true), - 429
], - 430
); - 431
let delta = after.delta_from(&before); - 432
assert_eq!( - 433
delta.added, - 434
vec![CapabilityId::new(CapabilityKind::McpServer, "weather")] - 435
); - 436
assert!(delta.removed.is_empty()); - 437
assert!(delta.describe().contains("now available")); - 438
} - 439
- 440
#[test] - 441
fn losing_usability_reads_as_an_update_not_a_removal() { - 442
let before = CapabilitySet::new(1, vec![cap("tavily", CapabilityKind::McpServer, true)]); - 443
let after = CapabilitySet::new(2, vec![cap("tavily", CapabilityKind::McpServer, false)]); - 444
let delta = after.delta_from(&before); - 445
assert!(delta.removed.is_empty(), "still configured, just unusable"); - 446
assert_eq!(delta.updated.len(), 1); - 447
} - 448
} - 449
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.