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