Skip to main content

perspt_sdk/toolset/
footprint.rs

1//! Declarative footprints (PSP-9 systems 5 and 15).
2//!
3//! The footprint is **data, not an executable function pointer**. That
4//! matters for serialization, replay, and external-tool registration: the
5//! kernel resolves a [`FootprintSpec`] only after JSON-schema validation and
6//! path canonicalization, and an unknown selector fails closed to
7//! [`ResourceSelector::OpaqueWorkspace`].
8//!
9//! Theorem 8 is stated for events with `writes(e) ⊆ reads(e)`. A tool that
10//! writes a resource it did not read is outside the commutation lemma, so a
11//! write-mode selector contributes its resource to *both* sets — the
12//! declarative form of the state-witness rule — and validation rejects
13//! non-conforming specs at catalog-assembly time rather than at execution.
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::{Result, SdkError};
18use crate::scheduler::{Footprint, Resource};
19
20/// Whether a selector reads or mutates its resource.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum AccessMode {
24    Read,
25    Write,
26}
27
28/// A declarative mapping from validated tool arguments to scheduler
29/// resources.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case", tag = "kind")]
32pub enum ResourceSelector {
33    /// A fixed resource, independent of arguments.
34    Literal {
35        resource: Resource,
36        access: AccessMode,
37    },
38    /// The canonicalized path found in the named (schema-validated) argument.
39    PathArgument { field: String, access: AccessMode },
40    /// The manifest owning the path in the named argument (e.g. the
41    /// `Cargo.toml` above an edited source file).
42    ParentManifestOf { field: String, access: AccessMode },
43    /// An extension-defined resource keyed by a validated scalar argument.
44    ScopedArgument {
45        family: String,
46        field: String,
47        access: AccessMode,
48    },
49    /// The serving provider's rate limit — a throughput resource, never a
50    /// required serialization (Gate P).
51    ProviderRateLimit,
52    /// Conflicts with everything: the fail-closed default for tools whose
53    /// touched state cannot be described.
54    OpaqueWorkspace,
55}
56
57/// The declared footprint of one tool.
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59pub struct FootprintSpec {
60    pub selectors: Vec<ResourceSelector>,
61}
62
63impl FootprintSpec {
64    /// A spec containing exactly the given selectors.
65    pub fn new(selectors: Vec<ResourceSelector>) -> Self {
66        Self { selectors }
67    }
68
69    /// The fail-closed spec: serialize against everything.
70    pub fn opaque() -> Self {
71        Self::new(vec![ResourceSelector::OpaqueWorkspace])
72    }
73
74    /// Validate the spec against the tool's argument schema
75    /// (catalog-assembly time, Gate P).
76    ///
77    /// Every field-referencing selector must name a property the schema
78    /// declares, and a mutating tool must declare at least one selector.
79    pub fn validate(&self, schema: &serde_json::Value, mutates: bool) -> Result<()> {
80        if mutates && self.selectors.is_empty() {
81            return Err(SdkError::Domain(
82                "a mutating tool must declare a footprint or opt into OpaqueWorkspace".into(),
83            ));
84        }
85        let properties = schema.get("properties").and_then(|p| p.as_object());
86        for selector in &self.selectors {
87            let field = match selector {
88                ResourceSelector::PathArgument { field, .. }
89                | ResourceSelector::ParentManifestOf { field, .. }
90                | ResourceSelector::ScopedArgument { field, .. } => field,
91                _ => continue,
92            };
93            let declared = properties.map(|p| p.contains_key(field)).unwrap_or(false);
94            if !declared {
95                return Err(SdkError::Domain(format!(
96                    "footprint selector references argument {field:?} that the tool schema \
97                     does not declare"
98                )));
99            }
100        }
101        Ok(())
102    }
103
104    /// Resolve into a concrete [`Footprint`] from schema-validated,
105    /// canonicalized arguments.
106    ///
107    /// `writes(e) ⊆ reads(e)` holds by construction: every written resource
108    /// is also read. A field-referencing selector whose argument is absent
109    /// resolves to nothing (the argument was optional and unused); an
110    /// argument that is present but not a string fails closed to opaque.
111    pub fn resolve(&self, arguments: &serde_json::Value, provider: &str) -> Footprint {
112        let mut footprint = Footprint::new();
113        for selector in &self.selectors {
114            match selector {
115                ResourceSelector::Literal { resource, access } => {
116                    footprint = add(footprint, resource.clone(), *access);
117                }
118                ResourceSelector::PathArgument { field, access } => {
119                    match string_argument(arguments, field) {
120                        FieldValue::Present(path) => {
121                            footprint = add(footprint, Resource::File(path), *access);
122                        }
123                        FieldValue::Absent => {}
124                        FieldValue::NotAString => return opaque_footprint(),
125                    }
126                }
127                ResourceSelector::ParentManifestOf { field, access } => {
128                    match string_argument(arguments, field) {
129                        FieldValue::Present(path) => {
130                            let manifest = parent_manifest(&path);
131                            footprint = add(footprint, Resource::Manifest(manifest), *access);
132                        }
133                        FieldValue::Absent => {}
134                        FieldValue::NotAString => return opaque_footprint(),
135                    }
136                }
137                ResourceSelector::ScopedArgument {
138                    family,
139                    field,
140                    access,
141                } => match scalar_argument(arguments, field) {
142                    FieldValue::Present(key) => {
143                        footprint = add(
144                            footprint,
145                            Resource::Scoped {
146                                family: family.clone(),
147                                key,
148                            },
149                            *access,
150                        );
151                    }
152                    FieldValue::Absent => {}
153                    FieldValue::NotAString => return opaque_footprint(),
154                },
155                ResourceSelector::ProviderRateLimit => {
156                    footprint = add(
157                        footprint,
158                        Resource::ProviderRateLimit(provider.into()),
159                        AccessMode::Read,
160                    );
161                }
162                ResourceSelector::OpaqueWorkspace => return opaque_footprint(),
163            }
164        }
165        footprint
166    }
167}
168
169enum FieldValue {
170    Present(String),
171    Absent,
172    NotAString,
173}
174
175fn string_argument(arguments: &serde_json::Value, field: &str) -> FieldValue {
176    match arguments.get(field) {
177        None => FieldValue::Absent,
178        Some(value) => match value.as_str() {
179            Some(s) => FieldValue::Present(s.to_string()),
180            None => FieldValue::NotAString,
181        },
182    }
183}
184
185fn scalar_argument(arguments: &serde_json::Value, field: &str) -> FieldValue {
186    match arguments.get(field) {
187        None => FieldValue::Absent,
188        Some(serde_json::Value::String(value)) => FieldValue::Present(value.clone()),
189        Some(serde_json::Value::Number(value)) => FieldValue::Present(value.to_string()),
190        Some(serde_json::Value::Bool(value)) => FieldValue::Present(value.to_string()),
191        Some(_) => FieldValue::NotAString,
192    }
193}
194
195fn add(footprint: Footprint, resource: Resource, access: AccessMode) -> Footprint {
196    match access {
197        AccessMode::Read => footprint.read(resource),
198        // The state-witness rule in declarative form: a write also reads.
199        AccessMode::Write => footprint.read(resource.clone()).write(resource),
200    }
201}
202
203fn opaque_footprint() -> Footprint {
204    Footprint::new()
205        .read(Resource::OpaqueWorkspace)
206        .write(Resource::OpaqueWorkspace)
207}
208
209/// The directory-level manifest a path belongs to (simple heuristic: the
210/// nearest ancestor directory, as a manifest key).
211fn parent_manifest(path: &str) -> String {
212    match path.rsplit_once('/') {
213        Some((dir, _)) => dir.to_string(),
214        None => ".".to_string(),
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    fn schema() -> serde_json::Value {
223        serde_json::json!({
224            "type": "object",
225            "properties": {"path": {"type": "string"}},
226            "required": ["path"],
227        })
228    }
229
230    #[test]
231    fn writes_are_contained_in_reads_by_construction() {
232        let spec = FootprintSpec::new(vec![ResourceSelector::PathArgument {
233            field: "path".into(),
234            access: AccessMode::Write,
235        }]);
236        let footprint = spec.resolve(&serde_json::json!({"path": "src/lib.rs"}), "p");
237        assert!(footprint.writes.iter().all(|w| footprint.reads.contains(w)));
238        assert!(footprint
239            .writes
240            .contains(&Resource::File("src/lib.rs".into())));
241    }
242
243    #[test]
244    fn validation_rejects_a_selector_for_an_undeclared_argument() {
245        let spec = FootprintSpec::new(vec![ResourceSelector::PathArgument {
246            field: "nonexistent".into(),
247            access: AccessMode::Read,
248        }]);
249        assert!(spec.validate(&schema(), false).is_err());
250    }
251
252    #[test]
253    fn a_mutating_tool_may_not_declare_an_empty_footprint() {
254        assert!(FootprintSpec::default().validate(&schema(), true).is_err());
255        assert!(FootprintSpec::opaque().validate(&schema(), true).is_ok());
256    }
257
258    #[test]
259    fn a_non_string_path_argument_fails_closed_to_opaque() {
260        let spec = FootprintSpec::new(vec![ResourceSelector::PathArgument {
261            field: "path".into(),
262            access: AccessMode::Write,
263        }]);
264        let footprint = spec.resolve(&serde_json::json!({"path": ["not", "a", "string"]}), "p");
265        assert!(footprint.writes.contains(&Resource::OpaqueWorkspace));
266    }
267
268    #[test]
269    fn rate_limit_is_a_read_never_a_serializing_write() {
270        let spec = FootprintSpec::new(vec![ResourceSelector::ProviderRateLimit]);
271        let footprint = spec.resolve(&serde_json::json!({}), "anthropic");
272        assert!(footprint.writes.is_empty());
273        assert!(footprint
274            .reads
275            .contains(&Resource::ProviderRateLimit("anthropic".into())));
276    }
277
278    #[test]
279    fn two_rate_limited_turns_commute() {
280        let spec = FootprintSpec::new(vec![ResourceSelector::ProviderRateLimit]);
281        let a = spec.resolve(&serde_json::json!({}), "anthropic");
282        let b = spec.resolve(&serde_json::json!({}), "anthropic");
283        assert!(
284            a.commutes_with(&b),
285            "a rate-limit delay is not a serialization"
286        );
287    }
288}