Coverage Report

Created: 2026-05-23 17:32

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/lyquor/lyquor/lyquid-agent/src/service/descriptor.rs
Line
Count
Source
1
//! Self-describing capability metadata for service-shaped Lyquids.
2
//!
3
//! A Lyquid that wants to be discoverable as a service exposes a
4
//! `get_capabilities() -> LyquidResult<String>` ABI method returning a JSON-encoded
5
//! array of [`ServiceDescriptor`]. Other Lyquids and off-chain clients read this to
6
//! learn what capabilities the service offers, what input/output shapes to use, what
7
//! risk tier it operates at, and what it typically costs.
8
//!
9
//! ## Forward compatibility
10
//!
11
//! `ServiceDescriptor` is a versioned data shape, not an on-chain ABI commitment.
12
//! Optional fields use `#[serde(default)]` so older readers stay compatible. The
13
//! `capability` string carries the contract — `"amazon.gift.research.v1"` and
14
//! `"amazon.gift.research.v2"` are distinct capabilities.
15
//!
16
//! ## Why not commit schema hashes on chain?
17
//!
18
//! `input_schema` / `output_schema` are stored as full JSON Schema documents (not
19
//! hashes) because the immediate consumer is an LLM coordinator that needs to *read*
20
//! them. We deliberately skip on-chain hash commitment until a verifier exists — adding
21
//! a `*_schema_hash` field today would be dead weight.
22
23
use lyquor_primitives::LyquidID;
24
use serde::{Deserialize, Serialize};
25
use serde_json::Value;
26
27
use crate::core::RiskTier;
28
29
/// One capability advertised by a Lyquid.
30
#[derive(Clone, Debug, Serialize, Deserialize)]
31
pub struct ServiceDescriptor {
32
    /// The LyquidID of the service exposing this capability. Self-describing so a
33
    /// coordinator can cache `(capability, lyquid_id)` pairs without needing a side
34
    /// channel to learn which Lyquid produced the descriptor.
35
    pub lyquid_id: LyquidID,
36
    /// Human-readable capability identifier (e.g. `"amazon.gift.research.v1"`).
37
    /// Version-suffixed by convention so distinct revisions of the same logical
38
    /// capability are distinguishable.
39
    pub capability: String,
40
    /// Semver string for the implementation. Lets a coordinator pick (or reject) a
41
    /// service based on bug fixes / behavior changes within the same capability id.
42
    pub version: String,
43
    /// Full JSON Schema describing the dispatch input the service accepts.
44
    pub input_schema: Value,
45
    /// Full JSON Schema describing the receipt output the service produces.
46
    pub output_schema: Value,
47
    /// Highest risk tier this capability operates at. Coordinators use this to drive
48
    /// approval policy before dispatching.
49
    pub max_risk: RiskTier,
50
    /// Optional cost estimate. Coordinators that budget across a plan need this to
51
    /// reject obvious blowups before dispatching.
52
    #[serde(default)]
53
    pub cost_hint: Option<CostHint>,
54
    /// Replay/retry semantics.
55
    pub idempotency: IdempotencyClass,
56
    /// `true` if a coordinator may call this capability without explicit per-deploy
57
    /// authorization (e.g. read-only catalog queries). Default `false` — callers
58
    /// should be allowlisted by the service.
59
    #[serde(default)]
60
    pub allowed_by_default: bool,
61
}
62
63
/// Cost hint for coordinator budgeting. `unit` names the cost unit
64
/// (e.g. `"USDC"`); `typical_base_units` is a big-number string in that unit's smallest
65
/// denomination (so a 1 USDC fee shows up as `"1000000"`).
66
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
67
pub struct CostHint {
68
    pub unit: String,
69
    pub typical_base_units: String,
70
}
71
72
/// Replay/retry semantics of a capability. Lets a coordinator decide whether re-issuing
73
/// the same call is safe and whether it needs to track an idempotency key.
74
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
75
pub enum IdempotencyClass {
76
    /// Safe to retry; identical inputs produce the same effect (e.g. a `correlation_id`-
77
    /// keyed call where the service dedupes internally).
78
    Idempotent,
79
    /// Retrying may produce duplicate effects unless the caller dedupes. Coordinators
80
    /// should retry only with care.
81
    AtLeastOnce,
82
    /// Caller must supply an `idempotency_key`; the service refuses duplicates within a
83
    /// retention window.
84
    ExactlyOnce,
85
}
86
87
/// Encodes a slice of descriptors as a JSON array string, the format `get_capabilities()`
88
/// methods return. Wraps `serde_json` errors with a small context prefix.
89
4
pub fn capabilities_json(descriptors: &[ServiceDescriptor]) -> Result<String, String> {
90
4
    serde_json::to_string(descriptors).map_err(|e| 
format!0
("encode capabilities: {e}"))
91
4
}
92
93
impl ServiceDescriptor {
94
    /// Builder entry point. Required fields go up front; optionals (`cost`,
95
    /// `allowed_by_default`) are chainable setters with sensible defaults
96
    /// (`cost_hint = None`, `allowed_by_default = false`).
97
    ///
98
    /// Replaces 13-line struct literals in `get_capabilities()` ABI methods with:
99
    /// ```ignore
100
    /// ServiceDescriptor::build(
101
    ///     ctx.lyquid_id, CAPABILITY, "1.0.0",
102
    ///     my_input_schema(), my_output_schema(),
103
    ///     RiskTier::SensitiveMutation, IdempotencyClass::AtLeastOnce,
104
    /// ).cost("USDC", "100000").allowed_by_default(true).to_capabilities_json()
105
    /// ```
106
2
    pub fn build(
107
2
        lyquid_id: LyquidID, capability: impl Into<String>, version: impl Into<String>, input_schema: Value,
108
2
        output_schema: Value, max_risk: RiskTier, idempotency: IdempotencyClass,
109
2
    ) -> ServiceDescriptorBuilder {
110
2
        ServiceDescriptorBuilder {
111
2
            descriptor: ServiceDescriptor {
112
2
                lyquid_id,
113
2
                capability: capability.into(),
114
2
                version: version.into(),
115
2
                input_schema,
116
2
                output_schema,
117
2
                max_risk,
118
2
                cost_hint: None,
119
2
                idempotency,
120
2
                allowed_by_default: false,
121
2
            },
122
2
        }
123
2
    }
124
}
125
126
/// Owns a partially-constructed `ServiceDescriptor`. See [`ServiceDescriptor::build`].
127
pub struct ServiceDescriptorBuilder {
128
    descriptor: ServiceDescriptor,
129
}
130
131
impl ServiceDescriptorBuilder {
132
    /// Set the cost hint (currency unit + typical amount in that unit's smallest
133
    /// denomination as a decimal string).
134
1
    pub fn cost(mut self, unit: impl Into<String>, typical_base_units: impl Into<String>) -> Self {
135
1
        self.descriptor.cost_hint = Some(CostHint {
136
1
            unit: unit.into(),
137
1
            typical_base_units: typical_base_units.into(),
138
1
        });
139
1
        self
140
1
    }
141
142
    /// Override `allowed_by_default` (defaults to `false`).
143
1
    pub fn allowed_by_default(mut self, v: bool) -> Self {
144
1
        self.descriptor.allowed_by_default = v;
145
1
        self
146
1
    }
147
148
    /// Consume the builder and return the descriptor.
149
2
    pub fn finish(self) -> ServiceDescriptor {
150
2
        self.descriptor
151
2
    }
152
153
    /// Shortcut for `capabilities_json(&[self.finish()])` — the 1-descriptor case all
154
    /// current `get_capabilities()` ABI methods use.
155
1
    pub fn to_capabilities_json(self) -> Result<String, String> {
156
1
        capabilities_json(&[self.finish()])
157
1
    }
158
}
159
160
#[cfg(test)]
161
mod tests {
162
    use super::*;
163
    use serde_json::json;
164
165
3
    fn sample() -> ServiceDescriptor {
166
3
        ServiceDescriptor {
167
3
            lyquid_id: LyquidID([0xaa; 20]),
168
3
            capability: "amazon.gift.research.v1".into(),
169
3
            version: "1.0.0".into(),
170
3
            input_schema: json!({"type": "object", "required": ["recipient_description"]}),
171
3
            output_schema: json!({"type": "object", "required": ["recommendations"]}),
172
3
            max_risk: RiskTier::ReadOnly,
173
3
            cost_hint: Some(CostHint {
174
3
                unit: "USDC".into(),
175
3
                typical_base_units: "100000".into(),
176
3
            }),
177
3
            idempotency: IdempotencyClass::Idempotent,
178
3
            allowed_by_default: false,
179
3
        }
180
3
    }
181
182
    #[test]
183
1
    fn capabilities_json_roundtrip() {
184
1
        let descriptors = vec![sample()];
185
1
        let encoded = capabilities_json(&descriptors).expect("encode");
186
1
        let decoded: Vec<ServiceDescriptor> = serde_json::from_str(&encoded).expect("decode");
187
1
        assert_eq!(decoded.len(), 1);
188
1
        assert_eq!(decoded[0].capability, "amazon.gift.research.v1");
189
1
        assert_eq!(decoded[0].version, "1.0.0");
190
1
        assert_eq!(decoded[0].max_risk, RiskTier::ReadOnly);
191
1
        assert_eq!(decoded[0].idempotency, IdempotencyClass::Idempotent);
192
1
        assert_eq!(
193
1
            decoded[0].cost_hint.as_ref().unwrap(),
194
1
            &CostHint {
195
1
                unit: "USDC".into(),
196
1
                typical_base_units: "100000".into(),
197
1
            }
198
        );
199
1
        assert!(!decoded[0].allowed_by_default);
200
1
    }
201
202
    #[test]
203
1
    fn cost_hint_is_optional() {
204
1
        let mut d = sample();
205
1
        d.cost_hint = None;
206
1
        let encoded = capabilities_json(&[d]).expect("encode");
207
1
        let decoded: Vec<ServiceDescriptor> = serde_json::from_str(&encoded).expect("decode");
208
1
        assert!(decoded[0].cost_hint.is_none());
209
1
    }
210
211
    #[test]
212
1
    fn idempotency_variants_roundtrip() {
213
3
        for variant in [
214
1
            IdempotencyClass::Idempotent,
215
1
            IdempotencyClass::AtLeastOnce,
216
1
            IdempotencyClass::ExactlyOnce,
217
1
        ] {
218
3
            let encoded = serde_json::to_string(&variant).expect("encode");
219
3
            let decoded: IdempotencyClass = serde_json::from_str(&encoded).expect("decode");
220
3
            assert_eq!(decoded, variant);
221
        }
222
1
    }
223
224
    #[test]
225
1
    fn capabilities_json_empty_slice() {
226
1
        let encoded = capabilities_json(&[]).expect("encode");
227
1
        assert_eq!(encoded, "[]");
228
1
    }
229
230
    #[test]
231
1
    fn builder_produces_descriptor_with_defaults() {
232
1
        let d = ServiceDescriptor::build(
233
1
            LyquidID([0xbb; 20]),
234
            "demo.capability.v1",
235
            "0.1.0",
236
1
            json!({"type": "object"}),
237
1
            json!({"type": "object"}),
238
1
            RiskTier::SensitiveMutation,
239
1
            IdempotencyClass::AtLeastOnce,
240
        )
241
1
        .finish();
242
1
        assert_eq!(d.capability, "demo.capability.v1");
243
1
        assert_eq!(d.version, "0.1.0");
244
1
        assert_eq!(d.max_risk, RiskTier::SensitiveMutation);
245
1
        assert_eq!(d.idempotency, IdempotencyClass::AtLeastOnce);
246
1
        assert!(d.cost_hint.is_none(), "cost defaults to None");
247
1
        assert!(!d.allowed_by_default, "allowed_by_default defaults to false");
248
1
    }
249
250
    #[test]
251
1
    fn builder_setters_chain() {
252
1
        let json = ServiceDescriptor::build(
253
1
            LyquidID([0xcc; 20]),
254
            "demo.capability.v1",
255
            "0.1.0",
256
1
            json!({"type": "object"}),
257
1
            json!({"type": "object"}),
258
1
            RiskTier::ReadOnly,
259
1
            IdempotencyClass::Idempotent,
260
        )
261
1
        .cost("USDC", "100000")
262
1
        .allowed_by_default(true)
263
1
        .to_capabilities_json()
264
1
        .expect("encode");
265
        // Round-trip and verify cost + allowed flag landed.
266
1
        let decoded: Vec<ServiceDescriptor> = serde_json::from_str(&json).expect("decode");
267
1
        assert_eq!(decoded.len(), 1);
268
1
        let cost = decoded[0].cost_hint.as_ref().expect("cost set");
269
1
        assert_eq!(cost.unit, "USDC");
270
1
        assert_eq!(cost.typical_base_units, "100000");
271
1
        assert!(decoded[0].allowed_by_default);
272
1
    }
273
274
    #[test]
275
1
    fn allowed_by_default_default_false_when_missing() {
276
        // Construct a JSON value omitting `allowed_by_default` and `cost_hint`.
277
1
        let mut value = serde_json::to_value(sample()).unwrap();
278
1
        let obj = value.as_object_mut().unwrap();
279
1
        obj.remove("allowed_by_default");
280
1
        obj.remove("cost_hint");
281
1
        let s = serde_json::to_string(&value).unwrap();
282
1
        let decoded: ServiceDescriptor = serde_json::from_str(&s).expect("decode");
283
1
        assert!(!decoded.allowed_by_default, "missing field should default to false");
284
1
        assert!(decoded.cost_hint.is_none(), "missing cost_hint should default to None");
285
1
    }
286
}