Skip to main content

salvor_tools/
idempotency.rs

1//! Deriving a declared idempotency key from a call's input.
2//!
3//! A hand-written [`ToolHandler`](crate::ToolHandler) can say what a call *is*
4//! by overriding [`idempotency_key`](crate::ToolHandler::idempotency_key). A
5//! tool that arrives at runtime, from an MCP server or a wasm component, has no
6//! Rust code to override anything in: its author is not in this process, and
7//! for MCP is not even on this machine. What the operator has instead is the
8//! call's input and the knowledge of which field of it names the effect. This
9//! module turns that knowledge into a key.
10//!
11//! # The declaration
12//!
13//! An [`IdempotencyPath`] is a field path into the call's input:
14//! `"claim_id"`, or `"payment.claim_id"` for a nested field. The operator
15//! writes it once, per tool, in the agent file (`idempotency_keys` on an MCP
16//! server, `idempotency_key` on a wasm tool); everything below is what that
17//! declaration means at dispatch.
18//!
19//! # The key
20//!
21//! The derived key is `<tool>:<value>`: the tool's own name, a colon, and the
22//! field's value verbatim. So a `pay_claim` call carrying
23//! `{"claim_id": "wreck-9931"}` is the identity `pay_claim:wreck-9931`, which
24//! is exactly the key a hand-written payout tool declares in
25//! [`DynTool::idempotency_key`](crate::DynTool::idempotency_key)'s own
26//! documentation. Two properties make that format worth pinning:
27//!
28//! - It is stable. The same input yields the same key in this process and the
29//!   next one, on any machine, with no clock, counter, or randomness folded in.
30//!   A key that moved would silently stop deduplicating.
31//! - It is legible on its own. The store already keys commitments on
32//!   `(tool, key)`, so the prefix is redundant *there*; it is not redundant in
33//!   a `salvor history` line, a `CallInFlight` refusal, or an operator's grep,
34//!   where a bare `wreck-9931` says nothing about what was done to the claim.
35//!
36//! The value is taken verbatim for a string and through its JSON form for a
37//! number (`483200`, `1.5`). Nothing else is accepted: see
38//! [`IdempotencyPath::derive`].
39//!
40//! # A declared key never degrades to no key
41//!
42//! If the path is not there, or holds something that cannot be an identity, the
43//! call is refused with [`ToolError::MissingIdempotencyKey`] and nothing runs.
44//! The alternative would be to fall back to an unkeyed call, which for the
45//! payout tool this exists for means the second run pays again. An operator who
46//! declared a key asked for exactly one execution; a loud refusal is the only
47//! answer that keeps that promise.
48
49use serde_json::Value;
50
51use crate::error::ToolError;
52
53/// A field path into a call's input, naming the value that identifies the
54/// operation.
55///
56/// Parsed once, at agent-build time, so a malformed declaration fails before a
57/// run rather than during one. See the [module docs](self) for the key format
58/// and the refusal rule.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct IdempotencyPath {
61    /// The path as the operator wrote it, for error messages.
62    raw: String,
63    /// The dot-separated segments, in order.
64    segments: Vec<String>,
65}
66
67impl IdempotencyPath {
68    /// Parses a dotted field path.
69    ///
70    /// `"claim_id"` is one segment; `"payment.claim_id"` is two, walked as
71    /// object lookups in order. There is no array indexing and no escaping: a
72    /// field whose name contains a dot cannot be named, which is a limit worth
73    /// having while the syntax is one character wide.
74    ///
75    /// # Errors
76    ///
77    /// [`IdempotencyPathError::Empty`] for an empty path, and
78    /// [`IdempotencyPathError::EmptySegment`] for a path with an empty
79    /// segment (`"a."`, `".a"`, `"a..b"`). Both are typos, and a typo in the
80    /// declaration that names a payment must not survive to a run.
81    pub fn parse(path: &str) -> Result<Self, IdempotencyPathError> {
82        if path.is_empty() {
83            return Err(IdempotencyPathError::Empty);
84        }
85        let segments: Vec<String> = path.split('.').map(ToOwned::to_owned).collect();
86        if segments.iter().any(String::is_empty) {
87            return Err(IdempotencyPathError::EmptySegment {
88                path: path.to_owned(),
89            });
90        }
91        Ok(Self {
92            raw: path.to_owned(),
93            segments,
94        })
95    }
96
97    /// The path as the operator wrote it.
98    #[must_use]
99    pub fn as_str(&self) -> &str {
100        &self.raw
101    }
102
103    /// Derives the key for one call, or refuses the call.
104    ///
105    /// Returns `<tool>:<value>` where `value` is the non-empty string or the
106    /// number at this path in `input`.
107    ///
108    /// # Errors
109    ///
110    /// [`ToolError::MissingIdempotencyKey`] when the path is absent, runs
111    /// through a non-object, or lands on anything but a non-empty string or a
112    /// number. A boolean, a null, an object, an array, and an empty string are
113    /// all refused: none of them names one operation, and a key that does not
114    /// name one operation is worse than no key at all, because it looks like an
115    /// identity while being a collision. The message names the tool, the path,
116    /// and the keys the input actually carries.
117    pub fn derive(&self, tool: &str, input: &Value) -> Result<String, ToolError> {
118        let refuse = |detail: String| ToolError::MissingIdempotencyKey {
119            tool: tool.to_owned(),
120            path: self.raw.clone(),
121            detail,
122        };
123
124        let mut current = input;
125        for (index, segment) in self.segments.iter().enumerate() {
126            let Some(object) = current.as_object() else {
127                return Err(refuse(format!(
128                    "{} is not a JSON object, so `{segment}` cannot be looked up in it; {}",
129                    location(&self.segments, index),
130                    present_keys(input)
131                )));
132            };
133            let Some(next) = object.get(segment) else {
134                return Err(refuse(format!(
135                    "there is no `{segment}` in {}; {}",
136                    location(&self.segments, index),
137                    present_keys(input)
138                )));
139            };
140            current = next;
141        }
142
143        match current {
144            Value::String(value) if !value.is_empty() => Ok(format!("{tool}:{value}")),
145            Value::Number(value) => Ok(format!("{tool}:{value}")),
146            other => Err(refuse(format!(
147                "`{}` holds {}, and an idempotency key must be a non-empty string or a number; {}",
148                self.raw,
149                describe(other),
150                present_keys(input)
151            ))),
152        }
153    }
154}
155
156/// Names the place a lookup happened, for an error message: the input itself
157/// for the first segment, the consumed prefix for any later one.
158fn location(segments: &[String], index: usize) -> String {
159    if index == 0 {
160        "the call's input".to_owned()
161    } else {
162        format!("`{}`", segments[..index].join("."))
163    }
164}
165
166/// The keys the input carries, as a message fragment. This is the part an
167/// operator reads to see whether the declaration or the caller is wrong.
168fn present_keys(input: &Value) -> String {
169    match input.as_object() {
170        Some(object) if object.is_empty() => "the input has no keys".to_owned(),
171        Some(object) => format!(
172            "the input's keys are: {}",
173            object.keys().cloned().collect::<Vec<_>>().join(", ")
174        ),
175        None => format!("the input is not an object; it is {}", describe(input)),
176    }
177}
178
179/// A short human name for a JSON value's kind, with the degenerate string
180/// called out by name so an empty `claim_id` does not read as a type error.
181fn describe(value: &Value) -> &'static str {
182    match value {
183        Value::Null => "null",
184        Value::Bool(_) => "a boolean",
185        Value::Number(_) => "a number",
186        Value::String(s) if s.is_empty() => "an empty string",
187        Value::String(_) => "a string",
188        Value::Array(_) => "an array",
189        Value::Object(_) => "an object",
190    }
191}
192
193/// What a declared path can be wrong about before it ever sees a call.
194#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
195pub enum IdempotencyPathError {
196    /// The path was empty. There is no field named "", so this is always a
197    /// mistake, and an operator who meant "no key" leaves the declaration out.
198    #[error(
199        "an idempotency key path cannot be empty; name the input field that identifies the operation, for example \"claim_id\" or \"payment.claim_id\""
200    )]
201    Empty,
202    /// The path had an empty segment: a leading, trailing, or doubled dot.
203    #[error(
204        "idempotency key path `{path}` has an empty segment; write dotted paths as `payment.claim_id`, with no leading, trailing, or doubled dots"
205    )]
206    EmptySegment {
207        /// The path as written.
208        path: String,
209    },
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use serde_json::json;
216
217    /// The documented key format, for the two value kinds that are allowed.
218    #[test]
219    fn the_key_is_the_tool_name_and_the_field_value() {
220        let path = IdempotencyPath::parse("claim_id").expect("parses");
221        let key = path
222            .derive("pay_claim", &json!({"claim_id": "wreck-9931"}))
223            .expect("derives");
224        assert_eq!(key, "pay_claim:wreck-9931");
225
226        let path = IdempotencyPath::parse("invoice").expect("parses");
227        let key = path
228            .derive("charge", &json!({"invoice": 483_200}))
229            .expect("derives");
230        assert_eq!(key, "charge:483200");
231    }
232
233    /// The same input yields the same key, whatever else is in the input and
234    /// whatever order it arrived in. This is the property the whole promise
235    /// rests on, so it gets its own test.
236    #[test]
237    fn the_key_is_a_pure_function_of_the_named_field() {
238        let path = IdempotencyPath::parse("claim_id").expect("parses");
239        let first = path
240            .derive(
241                "pay_claim",
242                &json!({"claim_id": "wreck-9931", "amount_cents": 1}),
243            )
244            .expect("derives");
245        let second = path
246            .derive(
247                "pay_claim",
248                &json!({"amount_cents": 1, "claim_id": "wreck-9931"}),
249            )
250            .expect("derives");
251        assert_eq!(first, second);
252    }
253
254    /// A dotted path walks nested objects.
255    #[test]
256    fn a_dotted_path_reads_a_nested_field() {
257        let path = IdempotencyPath::parse("payment.claim_id").expect("parses");
258        let key = path
259            .derive("pay_claim", &json!({"payment": {"claim_id": "wreck-9931"}}))
260            .expect("derives");
261        assert_eq!(key, "pay_claim:wreck-9931");
262    }
263
264    /// The refusal names the tool, the path, and the keys the input does
265    /// carry: the three facts an operator needs to tell a wrong declaration
266    /// from a wrong call.
267    #[test]
268    fn a_missing_field_refuses_and_says_what_was_there() {
269        let path = IdempotencyPath::parse("claim_id").expect("parses");
270        let error = path
271            .derive(
272                "pay_claim",
273                &json!({"amount_cents": 483_200, "currency": "USD"}),
274            )
275            .expect_err("a missing key field must refuse");
276        let message = error.to_string();
277        assert!(message.contains("pay_claim"), "names the tool: {message}");
278        assert!(message.contains("claim_id"), "names the path: {message}");
279        assert!(
280            message.contains("amount_cents, currency"),
281            "names the keys present: {message}"
282        );
283    }
284
285    /// A nested path says where the walk stopped, so `payment.claim_id`
286    /// against a payload with no `payment` reads differently from one whose
287    /// `payment` lacks the field.
288    #[test]
289    fn a_nested_miss_names_where_it_stopped() {
290        let path = IdempotencyPath::parse("payment.claim_id").expect("parses");
291        let error = path
292            .derive("pay_claim", &json!({"payment": {"amount_cents": 1}}))
293            .expect_err("a missing nested field must refuse");
294        let message = error.to_string();
295        assert!(message.contains("`payment`"), "names the prefix: {message}");
296        assert!(
297            message.contains("no `claim_id`"),
298            "names the segment: {message}"
299        );
300    }
301
302    /// Anything that is not a non-empty string or a number is refused, because
303    /// none of them names one operation.
304    #[test]
305    fn a_value_that_cannot_be_an_identity_refuses() {
306        let path = IdempotencyPath::parse("claim_id").expect("parses");
307        for input in [
308            json!({"claim_id": true}),
309            json!({"claim_id": null}),
310            json!({"claim_id": ""}),
311            json!({"claim_id": ["wreck-9931"]}),
312            json!({"claim_id": {"id": "wreck-9931"}}),
313        ] {
314            let error = path
315                .derive("pay_claim", &input)
316                .expect_err("only a non-empty string or a number is a key");
317            let message = error.to_string();
318            assert!(message.contains("claim_id"), "names the path: {message}");
319            assert!(
320                message.contains("non-empty string or a number"),
321                "teaches the rule: {message}"
322            );
323        }
324    }
325
326    /// A path that runs through a non-object says so rather than reporting the
327    /// field as merely absent.
328    #[test]
329    fn a_path_through_a_non_object_refuses() {
330        let path = IdempotencyPath::parse("payment.claim_id").expect("parses");
331        let error = path
332            .derive("pay_claim", &json!({"payment": "wreck-9931"}))
333            .expect_err("a scalar cannot be walked into");
334        assert!(
335            error.to_string().contains("is not a JSON object"),
336            "says what went wrong: {error}"
337        );
338    }
339
340    /// The parse-time rejections: an empty path and an empty segment.
341    #[test]
342    fn a_malformed_path_fails_at_parse() {
343        assert_eq!(IdempotencyPath::parse(""), Err(IdempotencyPathError::Empty));
344        for path in ["a.", ".a", "a..b", "."] {
345            assert!(
346                matches!(
347                    IdempotencyPath::parse(path),
348                    Err(IdempotencyPathError::EmptySegment { .. })
349                ),
350                "`{path}` must be rejected"
351            );
352        }
353    }
354}