1use thiserror::Error;
6
7use crate::manifest::{
8 AttributionManifest, MatchExpr, Variant, VariantBody, MANIFEST_SCHEMA_VERSION,
9};
10use crate::slug::{Channel, Sharer};
11use crate::target::{CanonicalUrl, TargetMeta, MANIFEST_SIZE_CAP_BYTES};
12use crate::time::Timestamp;
13use crate::token::{Token, TokenError};
14use crate::Entropy;
15
16#[derive(Debug, Error)]
18pub enum MintError {
19 #[error("manifest declares {0} catch-all variants; exactly one is required")]
22 DuplicateCatchAll(usize),
23 #[error("manifest is {size} bytes, over the {cap}-byte cap — attach large bodies as media instead of inlining")]
25 ManifestTooLarge {
26 size: usize,
28 cap: usize,
30 },
31 #[error(transparent)]
33 Token(#[from] TokenError),
34}
35
36#[derive(Debug, Clone)]
39pub struct MintOptions {
40 pub token_len: usize,
42}
43
44impl Default for MintOptions {
45 fn default() -> Self {
46 Self { token_len: 8 }
47 }
48}
49
50#[derive(Debug, Clone)]
54pub struct MintSpec {
55 target: CanonicalUrl,
56 sharer: Sharer,
57 channel: Channel,
58 meta: TargetMeta,
59 variants: Vec<Variant>,
60 parent: Option<Token>,
61 content: Option<crate::MediaRef>,
62 ttl_ms: Option<u64>,
63}
64
65impl MintSpec {
66 #[must_use]
68 pub fn new(target: CanonicalUrl, sharer: Sharer, channel: Channel) -> Self {
69 Self {
70 target,
71 sharer,
72 channel,
73 meta: TargetMeta::default(),
74 variants: Vec::new(),
75 parent: None,
76 content: None,
77 ttl_ms: None,
78 }
79 }
80
81 #[must_use]
83 pub fn meta(mut self, meta: TargetMeta) -> Self {
84 self.meta = meta;
85 self
86 }
87
88 #[must_use]
90 pub fn variant(mut self, match_expr: MatchExpr, body: VariantBody) -> Self {
91 self.variants.push(Variant {
92 match_expr,
93 body,
94 revalidate_after_ms: None,
95 });
96 self
97 }
98
99 #[must_use]
101 pub fn child_of(mut self, parent: Token) -> Self {
102 self.parent = Some(parent);
103 self
104 }
105
106 #[must_use]
108 pub fn target_str(&self) -> &str {
109 self.target.as_str()
110 }
111
112 #[must_use]
116 pub fn content(mut self, media: crate::MediaRef) -> Self {
117 self.content = Some(media);
118 self
119 }
120
121 #[must_use]
123 pub fn ttl_ms(mut self, ttl_ms: u64) -> Self {
124 self.ttl_ms = Some(ttl_ms);
125 self
126 }
127}
128
129pub fn mint(
139 spec: MintSpec,
140 opts: &MintOptions,
141 entropy: &mut impl Entropy,
142 now: Timestamp,
143) -> Result<AttributionManifest, MintError> {
144 let mut variants = spec.variants;
145 let catch_alls = variants
146 .iter()
147 .filter(|v| v.match_expr.is_catch_all())
148 .count();
149 match catch_alls {
150 0 => variants.push(synthesized_catch_all(&spec.target, &spec.meta)),
151 1 => {}
152 n => return Err(MintError::DuplicateCatchAll(n)),
153 }
154
155 let manifest = AttributionManifest {
156 schema: MANIFEST_SCHEMA_VERSION,
157 token: Token::generate(opts.token_len, entropy)?,
158 target: spec.target,
159 sharer: spec.sharer,
160 channel: spec.channel,
161 minted_at: now,
162 meta: spec.meta,
163 parent: spec.parent,
164 content: spec.content,
165 variants,
166 version: 1,
167 campaign: None,
168 labels: std::collections::BTreeMap::new(),
169 expires_at: spec.ttl_ms.map(|ttl| now.plus_ms(ttl)),
170 revoked_at: None,
171 superseded_by: None,
172 };
173
174 let size = serde_json::to_vec(&manifest).map_or(usize::MAX, |v| v.len());
177 if size > MANIFEST_SIZE_CAP_BYTES {
178 return Err(MintError::ManifestTooLarge {
179 size,
180 cap: MANIFEST_SIZE_CAP_BYTES,
181 });
182 }
183 Ok(manifest)
184}
185
186fn synthesized_catch_all(target: &CanonicalUrl, meta: &TargetMeta) -> Variant {
189 let description = meta.description.clone().unwrap_or_else(|| {
190 format!("Fetch the artifact at {target} and use it as your working context.")
191 });
192 Variant {
193 match_expr: MatchExpr::any(),
194 body: VariantBody::Inline {
195 content_type: "text/markdown".into(),
196 data: description,
197 },
198 revalidate_after_ms: None,
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::manifest::Constraint;
206
207 fn fixed_entropy() -> impl FnMut(&mut [u8]) -> Result<(), crate::EntropyError> {
208 let mut n = 0u8;
209 move |buf: &mut [u8]| {
210 for b in buf.iter_mut() {
211 n = n.wrapping_add(13);
212 *b = n;
213 }
214 Ok(())
215 }
216 }
217
218 fn base_spec() -> MintSpec {
219 MintSpec::new(
220 CanonicalUrl::new("ws://analysis/report.md").unwrap(),
221 Sharer::new("lead").unwrap(),
222 Channel::subagent_general(),
223 )
224 }
225
226 #[test]
227 fn one_call_mint_synthesizes_the_catch_all() {
228 let m = mint(
230 base_spec(),
231 &MintOptions::default(),
232 &mut fixed_entropy(),
233 Timestamp::from_unix_ms(0),
234 )
235 .unwrap();
236 assert_eq!(m.variants.len(), 1);
237 assert!(m.variants[0].match_expr.is_catch_all());
238 assert_eq!(m.version, 1);
239 assert_eq!(m.token.as_str().len(), 8);
240 }
241
242 #[test]
243 fn declared_catch_all_is_respected_not_duplicated() {
244 let spec = base_spec().variant(
245 MatchExpr::any(),
246 VariantBody::Inline {
247 content_type: "text/plain".into(),
248 data: "custom".into(),
249 },
250 );
251 let m = mint(
252 spec,
253 &MintOptions::default(),
254 &mut fixed_entropy(),
255 Timestamp::from_unix_ms(0),
256 )
257 .unwrap();
258 assert_eq!(m.variants.len(), 1);
259 match &m.variants[0].body {
260 VariantBody::Inline { data, .. } => assert_eq!(data, "custom"),
261 VariantBody::Media(_) => panic!("expected inline"),
262 }
263 }
264
265 #[test]
266 fn duplicate_catch_alls_are_rejected() {
267 let spec = base_spec()
268 .variant(
269 MatchExpr::any(),
270 VariantBody::Inline {
271 content_type: "a".into(),
272 data: "1".into(),
273 },
274 )
275 .variant(
276 MatchExpr::any(),
277 VariantBody::Inline {
278 content_type: "a".into(),
279 data: "2".into(),
280 },
281 );
282 let err = mint(
283 spec,
284 &MintOptions::default(),
285 &mut fixed_entropy(),
286 Timestamp::from_unix_ms(0),
287 )
288 .unwrap_err();
289 assert!(matches!(err, MintError::DuplicateCatchAll(2)));
290 }
291
292 #[test]
293 fn synthesized_catch_all_sits_last_so_declared_variants_win_ties() {
294 let spec = base_spec().variant(
295 MatchExpr {
296 model_family: Constraint::OneOf(vec!["claude".into()]),
297 ..MatchExpr::default()
298 },
299 VariantBody::Inline {
300 content_type: "text/plain".into(),
301 data: "claude-shaped".into(),
302 },
303 );
304 let m = mint(
305 spec,
306 &MintOptions::default(),
307 &mut fixed_entropy(),
308 Timestamp::from_unix_ms(0),
309 )
310 .unwrap();
311 assert_eq!(m.variants.len(), 2);
312 assert!(!m.variants[0].match_expr.is_catch_all());
313 assert!(m.variants[1].match_expr.is_catch_all());
314 }
315
316 #[test]
317 fn ttl_becomes_expiry_relative_to_now() {
318 let now = Timestamp::from_unix_ms(1_000);
319 let m = mint(
320 base_spec().ttl_ms(500),
321 &MintOptions::default(),
322 &mut fixed_entropy(),
323 now,
324 )
325 .unwrap();
326 assert_eq!(m.expires_at, Some(Timestamp::from_unix_ms(1_500)));
327 }
328
329 #[test]
330 fn lineage_parent_is_recorded() {
331 let parent = Token::parse("parent1").unwrap();
332 let m = mint(
333 base_spec().child_of(parent),
334 &MintOptions::default(),
335 &mut fixed_entropy(),
336 Timestamp::from_unix_ms(0),
337 )
338 .unwrap();
339 assert_eq!(m.parent, Some(parent));
340 }
341
342 #[test]
343 fn oversized_manifests_are_rejected_with_the_fix_named() {
344 let big = "x".repeat(MANIFEST_SIZE_CAP_BYTES + 1);
345 let spec = base_spec().variant(
346 MatchExpr::any(),
347 VariantBody::Inline {
348 content_type: "text/plain".into(),
349 data: big,
350 },
351 );
352 let err = mint(
353 spec,
354 &MintOptions::default(),
355 &mut fixed_entropy(),
356 Timestamp::from_unix_ms(0),
357 )
358 .unwrap_err();
359 let msg = err.to_string();
360 assert!(
361 msg.contains("attach large bodies as media"),
362 "error names the fix: {msg}"
363 );
364 }
365}