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    extraction: Option<crate::Extraction>,
66    tree: Option<crate::TreeNode>,
67    token: Option<Token>,
68    labels: std::collections::BTreeMap<String, String>,
69    ttl_ms: Option<u64>,
70}
71
72impl MintSpec {
73    /// The minimum viable mint: an artifact, a sharer, a channel.
74    #[must_use]
75    pub fn new(target: CanonicalUrl, sharer: Sharer, channel: Channel) -> Self {
76        Self {
77            target,
78            sharer,
79            channel,
80            meta: TargetMeta::default(),
81            variants: Vec::new(),
82            parent: None,
83            content: None,
84            private: false,
85            contract: None,
86            outline: None,
87            extraction: None,
88            tree: None,
89            token: None,
90            labels: std::collections::BTreeMap::new(),
91            ttl_ms: None,
92        }
93    }
94
95    /// Attach the mint-time snapshot (title, description, image, labels).
96    #[must_use]
97    pub fn meta(mut self, meta: TargetMeta) -> Self {
98        self.meta = meta;
99        self
100    }
101
102    /// Add a variant. Declaration order is selection tie-break order.
103    #[must_use]
104    pub fn variant(mut self, match_expr: MatchExpr, body: VariantBody) -> Self {
105        self.variants.push(Variant {
106            match_expr,
107            body,
108            revalidate_after_ms: None,
109        });
110        self
111    }
112
113    /// Mark this token as a delegation child of `parent` (lineage).
114    #[must_use]
115    pub fn child_of(mut self, parent: Token) -> Self {
116        self.parent = Some(parent);
117        self
118    }
119
120    /// The target URI this spec will mint (hosts snapshot from it).
121    #[must_use]
122    pub fn target_str(&self) -> &str {
123        self.target.as_str()
124    }
125
126    /// Declare a variant in full — including `revalidate_after_ms`.
127    /// (`variant()` is the two-arg convenience; this preserves every
128    /// field a caller authored.)
129    #[must_use]
130    pub fn with_variant(mut self, variant: Variant) -> Self {
131        self.variants.push(variant);
132        self
133    }
134
135    /// Tag at birth: a cosmetic label (the mutable LWW zone — a name is
136    /// a convenience, never an attributed claim; `find` matches on it).
137    #[must_use]
138    pub fn label(mut self, key: &str, value: &str) -> Self {
139        self.labels.insert(key.to_owned(), value.to_owned());
140        self
141    }
142
143    /// Mint as a capability URL (CP-11): the token is generated LONG
144    /// (16 chars ≈ 94 bits — possession is the credential) and public
145    /// surfaces refuse to render it.
146    #[must_use]
147    pub fn private(mut self) -> Self {
148        self.private = true;
149        self
150    }
151
152    /// Pin the artifact's bytes: a content-addressed snapshot taken at
153    /// mint (doc `18 §3`). Enables `read`/`search` anywhere the blobs
154    /// replicate, immutable by hash.
155    #[must_use]
156    pub fn content(mut self, media: crate::MediaRef) -> Self {
157        self.content = Some(media);
158        self
159    }
160
161    /// Declare the consumption contract (doc `19 §4.2`): the regions a
162    /// consumer must reach for `coverage` to report the handoff met.
163    /// Immutable core — signed with the rest.
164    #[must_use]
165    pub fn contract(mut self, contract: crate::Contract) -> Self {
166        self.contract = Some(contract);
167        self
168    }
169
170    /// Attach the symbol outline extracted from the snapshot at mint
171    /// (doc `20 §3`): a content-addressed pointer, signed with the core.
172    #[must_use]
173    pub fn outline(mut self, media: crate::MediaRef) -> Self {
174        self.outline = Some(media);
175        self
176    }
177
178    /// Attach a deterministic text extraction of an opaque artifact (doc
179    /// `18 §7`): the searchable text of a PDF or HTML target, derived at mint,
180    /// with its provenance. Signed with the core, so the token carries the text.
181    #[must_use]
182    pub fn extraction(mut self, extraction: crate::Extraction) -> Self {
183        self.extraction = Some(extraction);
184        self
185    }
186
187    /// Attach a tree directory node (design doc: tree-scale): the index, trigram
188    /// index, and Bloom summary for a directory minted `--tree`. Signed with the
189    /// core.
190    #[must_use]
191    pub fn tree(mut self, tree: crate::TreeNode) -> Self {
192        self.tree = Some(tree);
193        self
194    }
195
196    /// Mint under a **pre-generated** token instead of generating one. A tree mint
197    /// needs each directory node's token *before* its children (so children can
198    /// point their `parent` link at it) while the node's Bloom is only known
199    /// *after* its children — pre-generating the token breaks that cycle.
200    #[must_use]
201    pub fn with_token(mut self, token: Token) -> Self {
202        self.token = Some(token);
203        self
204    }
205
206    /// Expire the token `ttl_ms` after mint.
207    #[must_use]
208    pub fn ttl_ms(mut self, ttl_ms: u64) -> Self {
209        self.ttl_ms = Some(ttl_ms);
210        self
211    }
212}
213
214/// Mint an attribution manifest. Pure: same inputs (including the entropy
215/// stream) ⇒ same manifest.
216///
217/// Guarantees on success:
218/// - exactly one catch-all variant exists (synthesized from the target when
219///   the caller declared none — the zero-ceremony path), positioned last so
220///   declared variants always win ties;
221/// - the serialized manifest is within [`MANIFEST_SIZE_CAP_BYTES`];
222/// - `version` starts at 1 (the CAS baseline for lifecycle mutations, C-9).
223pub fn mint(
224    spec: MintSpec,
225    opts: &MintOptions,
226    entropy: &mut impl Entropy,
227    now: Timestamp,
228) -> Result<AttributionManifest, MintError> {
229    let mut variants = spec.variants;
230    let catch_alls = variants
231        .iter()
232        .filter(|v| v.match_expr.is_catch_all())
233        .count();
234    match catch_alls {
235        0 => variants.push(synthesized_catch_all(&spec.target, &spec.meta)),
236        1 => {}
237        n => return Err(MintError::DuplicateCatchAll(n)),
238    }
239
240    let token_len = if spec.private { 16 } else { opts.token_len };
241    let manifest = AttributionManifest {
242        schema: MANIFEST_SCHEMA_VERSION,
243        token: match spec.token {
244            Some(t) => t,
245            None => Token::generate(token_len, entropy)?,
246        },
247        target: spec.target,
248        sharer: spec.sharer,
249        channel: spec.channel,
250        minted_at: now,
251        meta: spec.meta,
252        parent: spec.parent,
253        content: spec.content,
254        private: spec.private,
255        contract: spec.contract,
256        outline: spec.outline,
257        extraction: spec.extraction,
258        tree: spec.tree,
259        signature: None, // hosts with an identity sign after mint (trust)
260        variants,
261        version: 1,
262        campaign: None,
263        labels: spec.labels,
264        expires_at: spec.ttl_ms.map(|ttl| now.plus_ms(ttl)),
265        revoked_at: None,
266        superseded_by: None,
267    };
268
269    // Size cap: serde_json is in dev/host land elsewhere, but the cap is a
270    // core guarantee, so measure with the same encoding hosts store.
271    let size = serde_json::to_vec(&manifest).map_or(usize::MAX, |v| v.len());
272    if size > MANIFEST_SIZE_CAP_BYTES {
273        return Err(MintError::ManifestTooLarge {
274            size,
275            cap: MANIFEST_SIZE_CAP_BYTES,
276        });
277    }
278    Ok(manifest)
279}
280
281/// The zero-ceremony catch-all: point every unmatched consumer at the
282/// canonical target with the snapshot description.
283fn synthesized_catch_all(target: &CanonicalUrl, meta: &TargetMeta) -> Variant {
284    let description = meta.description.clone().unwrap_or_else(|| {
285        format!("Fetch the artifact at {target} and use it as your working context.")
286    });
287    Variant {
288        match_expr: MatchExpr::any(),
289        body: VariantBody::Inline {
290            content_type: "text/markdown".into(),
291            data: description,
292        },
293        revalidate_after_ms: None,
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::manifest::Constraint;
301
302    fn fixed_entropy() -> impl FnMut(&mut [u8]) -> Result<(), crate::EntropyError> {
303        let mut n = 0u8;
304        move |buf: &mut [u8]| {
305            for b in buf.iter_mut() {
306                n = n.wrapping_add(13);
307                *b = n;
308            }
309            Ok(())
310        }
311    }
312
313    fn base_spec() -> MintSpec {
314        MintSpec::new(
315            CanonicalUrl::new("ws://analysis/report.md").unwrap(),
316            Sharer::new("lead").unwrap(),
317            Channel::subagent_general(),
318        )
319    }
320
321    #[test]
322    fn one_call_mint_synthesizes_the_catch_all() {
323        // 17 §5 `one_call_mint`: no variants declared, mint still total.
324        let m = mint(
325            base_spec(),
326            &MintOptions::default(),
327            &mut fixed_entropy(),
328            Timestamp::from_unix_ms(0),
329        )
330        .unwrap();
331        assert_eq!(m.variants.len(), 1);
332        assert!(m.variants[0].match_expr.is_catch_all());
333        assert_eq!(m.version, 1);
334        assert_eq!(m.token.as_str().len(), 8);
335    }
336
337    #[test]
338    fn declared_catch_all_is_respected_not_duplicated() {
339        let spec = base_spec().variant(
340            MatchExpr::any(),
341            VariantBody::Inline {
342                content_type: "text/plain".into(),
343                data: "custom".into(),
344            },
345        );
346        let m = mint(
347            spec,
348            &MintOptions::default(),
349            &mut fixed_entropy(),
350            Timestamp::from_unix_ms(0),
351        )
352        .unwrap();
353        assert_eq!(m.variants.len(), 1);
354        match &m.variants[0].body {
355            VariantBody::Inline { data, .. } => assert_eq!(data, "custom"),
356            VariantBody::Media(_) => panic!("expected inline"),
357        }
358    }
359
360    #[test]
361    fn duplicate_catch_alls_are_rejected() {
362        let spec = base_spec()
363            .variant(
364                MatchExpr::any(),
365                VariantBody::Inline {
366                    content_type: "a".into(),
367                    data: "1".into(),
368                },
369            )
370            .variant(
371                MatchExpr::any(),
372                VariantBody::Inline {
373                    content_type: "a".into(),
374                    data: "2".into(),
375                },
376            );
377        let err = mint(
378            spec,
379            &MintOptions::default(),
380            &mut fixed_entropy(),
381            Timestamp::from_unix_ms(0),
382        )
383        .unwrap_err();
384        assert!(matches!(err, MintError::DuplicateCatchAll(2)));
385    }
386
387    #[test]
388    fn synthesized_catch_all_sits_last_so_declared_variants_win_ties() {
389        let spec = base_spec().variant(
390            MatchExpr {
391                model_family: Constraint::OneOf(vec!["claude".into()]),
392                ..MatchExpr::default()
393            },
394            VariantBody::Inline {
395                content_type: "text/plain".into(),
396                data: "claude-shaped".into(),
397            },
398        );
399        let m = mint(
400            spec,
401            &MintOptions::default(),
402            &mut fixed_entropy(),
403            Timestamp::from_unix_ms(0),
404        )
405        .unwrap();
406        assert_eq!(m.variants.len(), 2);
407        assert!(!m.variants[0].match_expr.is_catch_all());
408        assert!(m.variants[1].match_expr.is_catch_all());
409    }
410
411    #[test]
412    fn ttl_becomes_expiry_relative_to_now() {
413        let now = Timestamp::from_unix_ms(1_000);
414        let m = mint(
415            base_spec().ttl_ms(500),
416            &MintOptions::default(),
417            &mut fixed_entropy(),
418            now,
419        )
420        .unwrap();
421        assert_eq!(m.expires_at, Some(Timestamp::from_unix_ms(1_500)));
422    }
423
424    #[test]
425    fn lineage_parent_is_recorded() {
426        let parent = Token::parse("parent1").unwrap();
427        let m = mint(
428            base_spec().child_of(parent),
429            &MintOptions::default(),
430            &mut fixed_entropy(),
431            Timestamp::from_unix_ms(0),
432        )
433        .unwrap();
434        assert_eq!(m.parent, Some(parent));
435    }
436
437    #[test]
438    fn oversized_manifests_are_rejected_with_the_fix_named() {
439        let big = "x".repeat(MANIFEST_SIZE_CAP_BYTES + 1);
440        let spec = base_spec().variant(
441            MatchExpr::any(),
442            VariantBody::Inline {
443                content_type: "text/plain".into(),
444                data: big,
445            },
446        );
447        let err = mint(
448            spec,
449            &MintOptions::default(),
450            &mut fixed_entropy(),
451            Timestamp::from_unix_ms(0),
452        )
453        .unwrap_err();
454        let msg = err.to_string();
455        assert!(
456            msg.contains("attach large bodies as media"),
457            "error names the fix: {msg}"
458        );
459    }
460}