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    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    /// The minimum viable mint: an artifact, a sharer, a channel.
71    #[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    /// Attach the mint-time snapshot (title, description, image, labels).
90    #[must_use]
91    pub fn meta(mut self, meta: TargetMeta) -> Self {
92        self.meta = meta;
93        self
94    }
95
96    /// Add a variant. Declaration order is selection tie-break order.
97    #[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    /// Mark this token as a delegation child of `parent` (lineage).
108    #[must_use]
109    pub fn child_of(mut self, parent: Token) -> Self {
110        self.parent = Some(parent);
111        self
112    }
113
114    /// The target URI this spec will mint (hosts snapshot from it).
115    #[must_use]
116    pub fn target_str(&self) -> &str {
117        self.target.as_str()
118    }
119
120    /// Declare a variant in full — including `revalidate_after_ms`.
121    /// (`variant()` is the two-arg convenience; this preserves every
122    /// field a caller authored.)
123    #[must_use]
124    pub fn with_variant(mut self, variant: Variant) -> Self {
125        self.variants.push(variant);
126        self
127    }
128
129    /// Tag at birth: a cosmetic label (the mutable LWW zone — a name is
130    /// a convenience, never an attributed claim; `find` matches on it).
131    #[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    /// Mint as a capability URL (CP-11): the token is generated LONG
138    /// (16 chars ≈ 94 bits — possession is the credential) and public
139    /// surfaces refuse to render it.
140    #[must_use]
141    pub fn private(mut self) -> Self {
142        self.private = true;
143        self
144    }
145
146    /// Pin the artifact's bytes: a content-addressed snapshot taken at
147    /// mint (doc `18 §3`). Enables `read`/`search` anywhere the blobs
148    /// replicate, immutable by hash.
149    #[must_use]
150    pub fn content(mut self, media: crate::MediaRef) -> Self {
151        self.content = Some(media);
152        self
153    }
154
155    /// Declare the consumption contract (doc `19 §4.2`): the regions a
156    /// consumer must reach for `coverage` to report the handoff met.
157    /// Immutable core — signed with the rest.
158    #[must_use]
159    pub fn contract(mut self, contract: crate::Contract) -> Self {
160        self.contract = Some(contract);
161        self
162    }
163
164    /// Attach the symbol outline extracted from the snapshot at mint
165    /// (doc `20 §3`): a content-addressed pointer, signed with the core.
166    #[must_use]
167    pub fn outline(mut self, media: crate::MediaRef) -> Self {
168        self.outline = Some(media);
169        self
170    }
171
172    /// Expire the token `ttl_ms` after mint.
173    #[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
180/// Mint an attribution manifest. Pure: same inputs (including the entropy
181/// stream) ⇒ same manifest.
182///
183/// Guarantees on success:
184/// - exactly one catch-all variant exists (synthesized from the target when
185///   the caller declared none — the zero-ceremony path), positioned last so
186///   declared variants always win ties;
187/// - the serialized manifest is within [`MANIFEST_SIZE_CAP_BYTES`];
188/// - `version` starts at 1 (the CAS baseline for lifecycle mutations, C-9).
189pub 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, // hosts with an identity sign after mint (trust)
221        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    // Size cap: serde_json is in dev/host land elsewhere, but the cap is a
231    // core guarantee, so measure with the same encoding hosts store.
232    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
242/// The zero-ceremony catch-all: point every unmatched consumer at the
243/// canonical target with the snapshot description.
244fn 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        // 17 §5 `one_call_mint`: no variants declared, mint still total.
285        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}