- 1
//! What a capability is *for*, in the capability's own words. - 2
//! - 3
//! The old design asked the harness to hold the opinion: a static table - 4
//! mapped each `Act` to a list of built-in tool names, so `Act::Answer` meant - 5
//! `["webfetch", "mcp"]`. That structure cannot answer the only question that - 6
//! matters — *which of the things this user installed could serve this - 7
//! request?* — because installed things are not in the table and never can - 8
//! be. Every new integration needed a harness edit, and the edit only ever - 9
//! happened after someone reported a confidently wrong answer. - 10
//! - 11
//! Here the capability classifies itself against a shared vocabulary and the - 12
//! harness only matches. This is how MCP and LSP both work, and it has the - 13
//! property that matters: **adding an integration never requires touching - 14
//! this file.** That is the test for whether something is hardcoded, and a - 15
//! vocabulary passes it where a table of instance names does not. - 16
//! - 17
//! Two escape valves keep it honest: - 18
//! - 19
//! * [`Domain::Custom`] carries a name this build has never heard of, so a - 20
//! plugin's own domain survives round-tripping instead of being dropped. - 21
//! * A capability that declares *nothing* is [`Serves::Undeclared`] and is - 22
//! never sliced away. Slicing exists to save context, not to enforce - 23
//! policy — `reach` and the permission engine do that — so the failure - 24
//! modes are asymmetric: an extra capability costs a little context, a - 25
//! missing one costs the task. Undeclared therefore fails open. - 26
- 27
use std::collections::BTreeSet; - 28
- 29
use serde::{Deserialize, Serialize}; - 30
- 31
/// A kind of work a capability can do. - 32
/// - 33
/// Ordering is derived and only used to make `BTreeSet` iteration stable for - 34
/// digests; it carries no priority meaning. - 35
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] - 36
#[serde(rename_all = "kebab-case")] - 37
pub enum Domain { - 38
/// Facts that change faster than a model's training data: prices, - 39
/// markets, tickets, inventory, "what is assigned to me today". - 40
LiveData, - 41
/// Fetching and reading from the open web. - 42
Web, - 43
/// Reading or writing files in the workspace. - 44
Filesystem, - 45
/// Running commands or code. - 46
CodeExec, - 47
/// Durable recall across sessions. - 48
Memory, - 49
/// Reaching a human or another system through a channel. - 50
Messaging, - 51
/// Producing or transforming documents and artifacts. - 52
Documents, - 53
/// Coordinating other agents, flows, or units of work. - 54
Orchestration, - 55
/// Version control and change history. - 56
Vcs, - 57
/// The system's own health, spend, and audit surfaces. - 58
Observability, - 59
/// A domain this build does not know. Preserved verbatim so a plugin - 60
/// that speaks its own vocabulary is not silently flattened. - 61
#[serde(untagged)] - 62
Custom(String), - 63
} - 64
- 65
impl Domain { - 66
pub fn as_str(&self) -> &str { - 67
match self { - 68
Domain::LiveData => "live-data", - 69
Domain::Web => "web", - 70
Domain::Filesystem => "filesystem", - 71
Domain::CodeExec => "code-exec", - 72
Domain::Memory => "memory", - 73
Domain::Messaging => "messaging", - 74
Domain::Documents => "documents", - 75
Domain::Orchestration => "orchestration", - 76
Domain::Vcs => "vcs", - 77
Domain::Observability => "observability", - 78
Domain::Custom(name) => name, - 79
} - 80
} - 81
- 82
/// Parse one declared domain. Unknown names become [`Domain::Custom`] - 83
/// rather than an error: a capability declaring a domain this build does - 84
/// not know is a forward-compatibility event, not a misconfiguration. - 85
pub fn parse(value: &str) -> Domain { - 86
match value.trim().to_ascii_lowercase().replace('_', "-").as_str() { - 87
"live-data" | "livedata" => Domain::LiveData, - 88
"web" => Domain::Web, - 89
"filesystem" | "fs" => Domain::Filesystem, - 90
"code-exec" | "exec" => Domain::CodeExec, - 91
"memory" => Domain::Memory, - 92
"messaging" => Domain::Messaging, - 93
"documents" | "docs" => Domain::Documents, - 94
"orchestration" => Domain::Orchestration, - 95
"vcs" | "git" => Domain::Vcs, - 96
"observability" => Domain::Observability, - 97
other => Domain::Custom(other.to_string()), - 98
} - 99
} - 100
- 101
pub fn parse_list(values: &[String]) -> BTreeSet<Domain> { - 102
values - 103
.iter() - 104
.map(|value| Domain::parse(value)) - 105
.collect::<BTreeSet<_>>() - 106
} - 107
} - 108
- 109
impl std::fmt::Display for Domain { - 110
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - 111
f.write_str(self.as_str()) - 112
} - 113
} - 114
- 115
/// What a capability claims to serve. - 116
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] - 117
#[serde(rename_all = "kebab-case")] - 118
pub enum Serves { - 119
/// The capability said nothing. It is never sliced away — see the module - 120
/// note on asymmetric failure modes. This is the default for an MCP - 121
/// server whose config carries no `serves`, which is the common case and - 122
/// deliberately *not* guessed at from tool names: inferring a domain - 123
/// from a keyword list would reintroduce exactly the harness-side table - 124
/// this module exists to delete. - 125
#[default] - 126
Undeclared, - 127
/// The capability classified itself. - 128
Declared(BTreeSet<Domain>), - 129
} - 130
- 131
impl Serves { - 132
pub fn declared<I: IntoIterator<Item = Domain>>(domains: I) -> Serves { - 133
Serves::Declared(domains.into_iter().collect()) - 134
} - 135
- 136
/// From a tool's own `Tool::serves` labels; an empty list is undeclared. - 137
pub fn from_labels(labels: &[&str]) -> Serves { - 138
if labels.is_empty() { - 139
Serves::Undeclared - 140
} else { - 141
Serves::declared(labels.iter().map(|label| Domain::parse(label))) - 142
} - 143
} - 144
- 145
/// Whether this capability should survive a slice that requires - 146
/// `required`. An undeclared capability always survives; a declared one - 147
/// survives when it serves at least one required domain. - 148
pub fn serves_any(&self, required: &BTreeSet<Domain>) -> bool { - 149
match self { - 150
Serves::Undeclared => true, - 151
Serves::Declared(mine) => mine.intersection(required).next().is_some(), - 152
} - 153
} - 154
- 155
pub fn is_undeclared(&self) -> bool { - 156
matches!(self, Serves::Undeclared) - 157
} - 158
- 159
/// Stable rendering for reports and digests. - 160
pub fn labels(&self) -> Vec<String> { - 161
match self { - 162
Serves::Undeclared => Vec::new(), - 163
Serves::Declared(domains) => domains.iter().map(|d| d.to_string()).collect(), - 164
} - 165
} - 166
} - 167
- 168
#[cfg(test)] - 169
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 170
mod tests { - 171
use super::*; - 172
- 173
/// The kernel tells a classifier to choose domains from its vocabulary; - 174
/// that list and this enum must name the same domains. - 175
#[test] - 176
fn the_kernel_vocabulary_is_exactly_the_known_domains() { - 177
let known: Vec<&str> = [ - 178
Domain::LiveData, - 179
Domain::Web, - 180
Domain::Filesystem, - 181
Domain::CodeExec, - 182
Domain::Memory, - 183
Domain::Messaging, - 184
Domain::Documents, - 185
Domain::Orchestration, - 186
Domain::Vcs, - 187
Domain::Observability, - 188
] - 189
.iter() - 190
.map(|d| d.as_str()) - 191
.collect(); - 192
assert_eq!(known, vak_intent::DOMAIN_VOCABULARY); - 193
for name in vak_intent::DOMAIN_VOCABULARY { - 194
assert!(!matches!(Domain::parse(name), Domain::Custom(_)), "{name}"); - 195
} - 196
} - 197
- 198
#[test] - 199
fn unknown_domains_survive_instead_of_being_dropped() { - 200
let parsed = Domain::parse("procurement"); - 201
assert_eq!(parsed, Domain::Custom("procurement".into())); - 202
assert_eq!(parsed.to_string(), "procurement"); - 203
} - 204
- 205
#[test] - 206
fn parsing_is_forgiving_about_shape() { - 207
assert_eq!(Domain::parse(" Live_Data "), Domain::LiveData); - 208
assert_eq!(Domain::parse("GIT"), Domain::Vcs); - 209
} - 210
- 211
#[test] - 212
fn undeclared_always_survives_a_slice() { - 213
let required = BTreeSet::from([Domain::LiveData]); - 214
assert!(Serves::Undeclared.serves_any(&required)); - 215
} - 216
- 217
#[test] - 218
fn declared_survives_only_on_intersection() { - 219
let required = BTreeSet::from([Domain::LiveData]); - 220
assert!(Serves::declared([Domain::LiveData, Domain::Web]).serves_any(&required)); - 221
assert!(!Serves::declared([Domain::Filesystem]).serves_any(&required)); - 222
} - 223
- 224
#[test] - 225
fn a_custom_domain_matches_itself() { - 226
let required = BTreeSet::from([Domain::Custom("procurement".into())]); - 227
assert!(Serves::declared([Domain::Custom("procurement".into())]).serves_any(&required)); - 228
assert!(!Serves::declared([Domain::Web]).serves_any(&required)); - 229
} - 230
} - 231
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.