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}