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