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    private: bool,
63    ttl_ms: Option<u64>,
64}
65
66impl MintSpec {
67    /// The minimum viable mint: an artifact, a sharer, a channel.
68    #[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    /// Attach the mint-time snapshot (title, description, image, labels).
84    #[must_use]
85    pub fn meta(mut self, meta: TargetMeta) -> Self {
86        self.meta = meta;
87        self
88    }
89
90    /// Add a variant. Declaration order is selection tie-break order.
91    #[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    /// Mark this token as a delegation child of `parent` (lineage).
102    #[must_use]
103    pub fn child_of(mut self, parent: Token) -> Self {
104        self.parent = Some(parent);
105        self
106    }
107
108    /// The target URI this spec will mint (hosts snapshot from it).
109    #[must_use]
110    pub fn target_str(&self) -> &str {
111        self.target.as_str()
112    }
113
114    /// Declare a variant in full — including `revalidate_after_ms`.
115    /// (`variant()` is the two-arg convenience; this preserves every
116    /// field a caller authored.)
117    #[must_use]
118    pub fn with_variant(mut self, variant: Variant) -> Self {
119        self.variants.push(variant);
120        self
121    }
122
123    /// Mint as a capability URL (CP-11): the token is generated LONG
124    /// (16 chars ≈ 94 bits — possession is the credential) and public
125    /// surfaces refuse to render it.
126    #[must_use]
127    pub fn private(mut self) -> Self {
128        self.private = true;
129        self
130    }
131
132    /// Pin the artifact's bytes: a content-addressed snapshot taken at
133    /// mint (doc `18 §3`). Enables `read`/`search` anywhere the blobs
134    /// replicate, immutable by hash.
135    #[must_use]
136    pub fn content(mut self, media: crate::MediaRef) -> Self {
137        self.content = Some(media);
138        self
139    }
140
141    /// Expire the token `ttl_ms` after mint.
142    #[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
149/// Mint an attribution manifest. Pure: same inputs (including the entropy
150/// stream) ⇒ same manifest.
151///
152/// Guarantees on success:
153/// - exactly one catch-all variant exists (synthesized from the target when
154///   the caller declared none — the zero-ceremony path), positioned last so
155///   declared variants always win ties;
156/// - the serialized manifest is within [`MANIFEST_SIZE_CAP_BYTES`];
157/// - `version` starts at 1 (the CAS baseline for lifecycle mutations, C-9).
158pub 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, // hosts with an identity sign after mint (trust)
188        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    // Size cap: serde_json is in dev/host land elsewhere, but the cap is a
198    // core guarantee, so measure with the same encoding hosts store.
199    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
209/// The zero-ceremony catch-all: point every unmatched consumer at the
210/// canonical target with the snapshot description.
211fn 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        // 17 §5 `one_call_mint`: no variants declared, mint still total.
252        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}