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 private: bool,
63 contract: Option<crate::Contract>,
64 outline: Option<crate::MediaRef>,
65 extraction: Option<crate::Extraction>,
66 tree: Option<crate::TreeNode>,
67 token: Option<Token>,
68 labels: std::collections::BTreeMap<String, String>,
69 ttl_ms: Option<u64>,
70}
71
72impl MintSpec {
73 #[must_use]
75 pub fn new(target: CanonicalUrl, sharer: Sharer, channel: Channel) -> Self {
76 Self {
77 target,
78 sharer,
79 channel,
80 meta: TargetMeta::default(),
81 variants: Vec::new(),
82 parent: None,
83 content: None,
84 private: false,
85 contract: None,
86 outline: None,
87 extraction: None,
88 tree: None,
89 token: None,
90 labels: std::collections::BTreeMap::new(),
91 ttl_ms: None,
92 }
93 }
94
95 #[must_use]
97 pub fn meta(mut self, meta: TargetMeta) -> Self {
98 self.meta = meta;
99 self
100 }
101
102 #[must_use]
104 pub fn variant(mut self, match_expr: MatchExpr, body: VariantBody) -> Self {
105 self.variants.push(Variant {
106 match_expr,
107 body,
108 revalidate_after_ms: None,
109 });
110 self
111 }
112
113 #[must_use]
115 pub fn child_of(mut self, parent: Token) -> Self {
116 self.parent = Some(parent);
117 self
118 }
119
120 #[must_use]
122 pub fn target_str(&self) -> &str {
123 self.target.as_str()
124 }
125
126 #[must_use]
130 pub fn with_variant(mut self, variant: Variant) -> Self {
131 self.variants.push(variant);
132 self
133 }
134
135 #[must_use]
138 pub fn label(mut self, key: &str, value: &str) -> Self {
139 self.labels.insert(key.to_owned(), value.to_owned());
140 self
141 }
142
143 #[must_use]
147 pub fn private(mut self) -> Self {
148 self.private = true;
149 self
150 }
151
152 #[must_use]
156 pub fn content(mut self, media: crate::MediaRef) -> Self {
157 self.content = Some(media);
158 self
159 }
160
161 #[must_use]
165 pub fn contract(mut self, contract: crate::Contract) -> Self {
166 self.contract = Some(contract);
167 self
168 }
169
170 #[must_use]
173 pub fn outline(mut self, media: crate::MediaRef) -> Self {
174 self.outline = Some(media);
175 self
176 }
177
178 #[must_use]
182 pub fn extraction(mut self, extraction: crate::Extraction) -> Self {
183 self.extraction = Some(extraction);
184 self
185 }
186
187 #[must_use]
191 pub fn tree(mut self, tree: crate::TreeNode) -> Self {
192 self.tree = Some(tree);
193 self
194 }
195
196 #[must_use]
201 pub fn with_token(mut self, token: Token) -> Self {
202 self.token = Some(token);
203 self
204 }
205
206 #[must_use]
208 pub fn ttl_ms(mut self, ttl_ms: u64) -> Self {
209 self.ttl_ms = Some(ttl_ms);
210 self
211 }
212}
213
214pub fn mint(
224 spec: MintSpec,
225 opts: &MintOptions,
226 entropy: &mut impl Entropy,
227 now: Timestamp,
228) -> Result<AttributionManifest, MintError> {
229 let mut variants = spec.variants;
230 let catch_alls = variants
231 .iter()
232 .filter(|v| v.match_expr.is_catch_all())
233 .count();
234 match catch_alls {
235 0 => variants.push(synthesized_catch_all(&spec.target, &spec.meta)),
236 1 => {}
237 n => return Err(MintError::DuplicateCatchAll(n)),
238 }
239
240 let token_len = if spec.private { 16 } else { opts.token_len };
241 let manifest = AttributionManifest {
242 schema: MANIFEST_SCHEMA_VERSION,
243 token: match spec.token {
244 Some(t) => t,
245 None => Token::generate(token_len, entropy)?,
246 },
247 target: spec.target,
248 sharer: spec.sharer,
249 channel: spec.channel,
250 minted_at: now,
251 meta: spec.meta,
252 parent: spec.parent,
253 content: spec.content,
254 private: spec.private,
255 contract: spec.contract,
256 outline: spec.outline,
257 extraction: spec.extraction,
258 tree: spec.tree,
259 signature: None, variants,
261 version: 1,
262 campaign: None,
263 labels: spec.labels,
264 expires_at: spec.ttl_ms.map(|ttl| now.plus_ms(ttl)),
265 revoked_at: None,
266 superseded_by: None,
267 };
268
269 let size = serde_json::to_vec(&manifest).map_or(usize::MAX, |v| v.len());
272 if size > MANIFEST_SIZE_CAP_BYTES {
273 return Err(MintError::ManifestTooLarge {
274 size,
275 cap: MANIFEST_SIZE_CAP_BYTES,
276 });
277 }
278 Ok(manifest)
279}
280
281fn synthesized_catch_all(target: &CanonicalUrl, meta: &TargetMeta) -> Variant {
284 let description = meta.description.clone().unwrap_or_else(|| {
285 format!("Fetch the artifact at {target} and use it as your working context.")
286 });
287 Variant {
288 match_expr: MatchExpr::any(),
289 body: VariantBody::Inline {
290 content_type: "text/markdown".into(),
291 data: description,
292 },
293 revalidate_after_ms: None,
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use crate::manifest::Constraint;
301
302 fn fixed_entropy() -> impl FnMut(&mut [u8]) -> Result<(), crate::EntropyError> {
303 let mut n = 0u8;
304 move |buf: &mut [u8]| {
305 for b in buf.iter_mut() {
306 n = n.wrapping_add(13);
307 *b = n;
308 }
309 Ok(())
310 }
311 }
312
313 fn base_spec() -> MintSpec {
314 MintSpec::new(
315 CanonicalUrl::new("ws://analysis/report.md").unwrap(),
316 Sharer::new("lead").unwrap(),
317 Channel::subagent_general(),
318 )
319 }
320
321 #[test]
322 fn one_call_mint_synthesizes_the_catch_all() {
323 let m = mint(
325 base_spec(),
326 &MintOptions::default(),
327 &mut fixed_entropy(),
328 Timestamp::from_unix_ms(0),
329 )
330 .unwrap();
331 assert_eq!(m.variants.len(), 1);
332 assert!(m.variants[0].match_expr.is_catch_all());
333 assert_eq!(m.version, 1);
334 assert_eq!(m.token.as_str().len(), 8);
335 }
336
337 #[test]
338 fn declared_catch_all_is_respected_not_duplicated() {
339 let spec = base_spec().variant(
340 MatchExpr::any(),
341 VariantBody::Inline {
342 content_type: "text/plain".into(),
343 data: "custom".into(),
344 },
345 );
346 let m = mint(
347 spec,
348 &MintOptions::default(),
349 &mut fixed_entropy(),
350 Timestamp::from_unix_ms(0),
351 )
352 .unwrap();
353 assert_eq!(m.variants.len(), 1);
354 match &m.variants[0].body {
355 VariantBody::Inline { data, .. } => assert_eq!(data, "custom"),
356 VariantBody::Media(_) => panic!("expected inline"),
357 }
358 }
359
360 #[test]
361 fn duplicate_catch_alls_are_rejected() {
362 let spec = base_spec()
363 .variant(
364 MatchExpr::any(),
365 VariantBody::Inline {
366 content_type: "a".into(),
367 data: "1".into(),
368 },
369 )
370 .variant(
371 MatchExpr::any(),
372 VariantBody::Inline {
373 content_type: "a".into(),
374 data: "2".into(),
375 },
376 );
377 let err = mint(
378 spec,
379 &MintOptions::default(),
380 &mut fixed_entropy(),
381 Timestamp::from_unix_ms(0),
382 )
383 .unwrap_err();
384 assert!(matches!(err, MintError::DuplicateCatchAll(2)));
385 }
386
387 #[test]
388 fn synthesized_catch_all_sits_last_so_declared_variants_win_ties() {
389 let spec = base_spec().variant(
390 MatchExpr {
391 model_family: Constraint::OneOf(vec!["claude".into()]),
392 ..MatchExpr::default()
393 },
394 VariantBody::Inline {
395 content_type: "text/plain".into(),
396 data: "claude-shaped".into(),
397 },
398 );
399 let m = mint(
400 spec,
401 &MintOptions::default(),
402 &mut fixed_entropy(),
403 Timestamp::from_unix_ms(0),
404 )
405 .unwrap();
406 assert_eq!(m.variants.len(), 2);
407 assert!(!m.variants[0].match_expr.is_catch_all());
408 assert!(m.variants[1].match_expr.is_catch_all());
409 }
410
411 #[test]
412 fn ttl_becomes_expiry_relative_to_now() {
413 let now = Timestamp::from_unix_ms(1_000);
414 let m = mint(
415 base_spec().ttl_ms(500),
416 &MintOptions::default(),
417 &mut fixed_entropy(),
418 now,
419 )
420 .unwrap();
421 assert_eq!(m.expires_at, Some(Timestamp::from_unix_ms(1_500)));
422 }
423
424 #[test]
425 fn lineage_parent_is_recorded() {
426 let parent = Token::parse("parent1").unwrap();
427 let m = mint(
428 base_spec().child_of(parent),
429 &MintOptions::default(),
430 &mut fixed_entropy(),
431 Timestamp::from_unix_ms(0),
432 )
433 .unwrap();
434 assert_eq!(m.parent, Some(parent));
435 }
436
437 #[test]
438 fn oversized_manifests_are_rejected_with_the_fix_named() {
439 let big = "x".repeat(MANIFEST_SIZE_CAP_BYTES + 1);
440 let spec = base_spec().variant(
441 MatchExpr::any(),
442 VariantBody::Inline {
443 content_type: "text/plain".into(),
444 data: big,
445 },
446 );
447 let err = mint(
448 spec,
449 &MintOptions::default(),
450 &mut fixed_entropy(),
451 Timestamp::from_unix_ms(0),
452 )
453 .unwrap_err();
454 let msg = err.to_string();
455 assert!(
456 msg.contains("attach large bodies as media"),
457 "error names the fix: {msg}"
458 );
459 }
460}