Skip to main content

waggle_core/
mint.rs

1//! Minting: `MintSpec` in, [`AttributionManifest`] out — a pure function of
2//! (spec, options, entropy, now), per the sans-I/O law. Storage happens at
3//! the host: minting *is* the `Minted` log record's payload (doc `04 §1`).
4
5use 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/// Why a mint was rejected.
17#[derive(Debug, Error)]
18pub enum MintError {
19    /// More than one catch-all variant — selection order would be ambiguous
20    /// to authors even though declaration order breaks the tie.
21    #[error("manifest declares {0} catch-all variants; exactly one is required")]
22    DuplicateCatchAll(usize),
23    /// The serialized manifest exceeds the size cap; move bodies to media.
24    #[error("manifest is {size} bytes, over the {cap}-byte cap — attach large bodies as media instead of inlining")]
25    ManifestTooLarge {
26        /// Serialized size observed.
27        size: usize,
28        /// The cap ([`MANIFEST_SIZE_CAP_BYTES`]).
29        cap: usize,
30    },
31    /// Token generation failed (entropy or length configuration).
32    #[error(transparent)]
33    Token(#[from] TokenError),
34}
35
36/// Tuning for mint. Defaults are the product decisions from the design
37/// docs; override only with a reason.
38#[derive(Debug, Clone)]
39pub struct MintOptions {
40    /// Token length in characters (default 8 ⇒ 58⁸ ≈ 1.3 × 10¹⁴ names).
41    pub token_len: usize,
42}
43
44impl Default for MintOptions {
45    fn default() -> Self {
46        Self { token_len: 8 }
47    }
48}
49
50/// Everything a mint needs, gathered with a builder. The one-call form is
51/// `MintSpec::new(target, sharer, channel)` — variants, meta, lineage, and
52/// ttl are escalations, never prerequisites (doc `17 §1` rule 3).
53#[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    /// The minimum viable mint: an artifact, a sharer, a channel.
67    #[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    /// Attach the mint-time snapshot (title, description, image, labels).
82    #[must_use]
83    pub fn meta(mut self, meta: TargetMeta) -> Self {
84        self.meta = meta;
85        self
86    }
87
88    /// Add a variant. Declaration order is selection tie-break order.
89    #[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    /// Mark this token as a delegation child of `parent` (lineage).
100    #[must_use]
101    pub fn child_of(mut self, parent: Token) -> Self {
102        self.parent = Some(parent);
103        self
104    }
105
106    /// The target URI this spec will mint (hosts snapshot from it).
107    #[must_use]
108    pub fn target_str(&self) -> &str {
109        self.target.as_str()
110    }
111
112    /// Pin the artifact's bytes: a content-addressed snapshot taken at
113    /// mint (doc `18 §3`). Enables `read`/`search` anywhere the blobs
114    /// replicate, immutable by hash.
115    #[must_use]
116    pub fn content(mut self, media: crate::MediaRef) -> Self {
117        self.content = Some(media);
118        self
119    }
120
121    /// Expire the token `ttl_ms` after mint.
122    #[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
129/// Mint an attribution manifest. Pure: same inputs (including the entropy
130/// stream) ⇒ same manifest.
131///
132/// Guarantees on success:
133/// - exactly one catch-all variant exists (synthesized from the target when
134///   the caller declared none — the zero-ceremony path), positioned last so
135///   declared variants always win ties;
136/// - the serialized manifest is within [`MANIFEST_SIZE_CAP_BYTES`];
137/// - `version` starts at 1 (the CAS baseline for lifecycle mutations, C-9).
138pub 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    // Size cap: serde_json is in dev/host land elsewhere, but the cap is a
175    // core guarantee, so measure with the same encoding hosts store.
176    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
186/// The zero-ceremony catch-all: point every unmatched consumer at the
187/// canonical target with the snapshot description.
188fn 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        // 17 §5 `one_call_mint`: no variants declared, mint still total.
229        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}