Skip to main content

waggle_core/
manifest.rs

1//! The attribution manifest: three zones with three mutability rules
2//! (design doc `02 §2`) — the immutable core fixed at mint, variants fixed
3//! at mint (v1), and versioned mutable sections whose every change is an
4//! event first (doc `04 §4`).
5//!
6//! This module is the *data model*; the sealed selection algorithm over
7//! variants lands in CP-2 and lives beside it, never inside it.
8
9use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12
13use crate::slug::{Channel, Sharer};
14use crate::target::{CanonicalUrl, MediaRef, TargetMeta};
15use crate::time::Timestamp;
16use crate::token::Token;
17
18/// Schema version of the manifest envelope — versioned independently of
19/// crate semver; additive changes only (doc `09 §6`).
20pub const MANIFEST_SCHEMA_VERSION: u16 = 1;
21
22/// A set of consumer modalities, as a small bitset.
23///
24/// Match dimensions for variants (doc `06 §2`); `vision`/`audio` are what
25/// make multimodal variants ride the ordinary matcher.
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
27#[serde(transparent)]
28pub struct ModalitySet(u8);
29
30impl<'de> Deserialize<'de> for ModalitySet {
31    /// Accepts BOTH wire forms: the compact bits (`5`) and the humane
32    /// names (`["text", "shell"]`) — hosts and switchboards write names;
33    /// the log and the vectors keep bits.
34    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
35        struct V;
36        impl<'de> serde::de::Visitor<'de> for V {
37            type Value = ModalitySet;
38            fn expecting(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
39                f.write_str("modality bits (u8) or names ([\"text\", ...])")
40            }
41            fn visit_u64<E: serde::de::Error>(self, bits: u64) -> Result<ModalitySet, E> {
42                Ok(ModalitySet::from_bits_truncate(
43                    u8::try_from(bits).map_err(E::custom)?,
44                ))
45            }
46            fn visit_seq<A: serde::de::SeqAccess<'de>>(
47                self,
48                mut seq: A,
49            ) -> Result<ModalitySet, A::Error> {
50                let mut set = ModalitySet::empty();
51                while let Some(name) = seq.next_element::<String>()? {
52                    set = set.with(ModalitySet::from_name(&name).ok_or_else(|| {
53                        serde::de::Error::custom(format!(
54                            "unknown modality `{name}` — text, browser, shell, vision, audio"
55                        ))
56                    })?);
57                }
58                Ok(set)
59            }
60        }
61        deserializer.deserialize_any(V)
62    }
63}
64
65impl ModalitySet {
66    /// Plain text I/O.
67    pub const TEXT: Self = Self(1);
68    /// Can drive a browser.
69    pub const BROWSER: Self = Self(1 << 1);
70    /// Can run shell commands.
71    pub const SHELL: Self = Self(1 << 2);
72    /// Can interpret images.
73    pub const VISION: Self = Self(1 << 3);
74    /// Can interpret audio.
75    pub const AUDIO: Self = Self(1 << 4);
76
77    /// The empty set.
78    #[must_use]
79    pub const fn empty() -> Self {
80        Self(0)
81    }
82
83    /// Set union.
84    #[must_use]
85    pub const fn with(self, other: Self) -> Self {
86        Self(self.0 | other.0)
87    }
88
89    /// Does `self` contain every modality in `required`?
90    #[must_use]
91    pub const fn contains(self, required: Self) -> bool {
92        self.0 & required.0 == required.0
93    }
94
95    /// A single modality by its wire name.
96    #[must_use]
97    pub fn from_name(name: &str) -> Option<Self> {
98        match name {
99            "text" => Some(Self::TEXT),
100            "browser" => Some(Self::BROWSER),
101            "shell" => Some(Self::SHELL),
102            "vision" => Some(Self::VISION),
103            "audio" => Some(Self::AUDIO),
104            _ => None,
105        }
106    }
107
108    /// Build from raw bits, ignoring undefined ones — for hosts
109    /// deserializing foreign context descriptors (and for exhaustive
110    /// testing over the modality space).
111    #[must_use]
112    pub const fn from_bits_truncate(bits: u8) -> Self {
113        Self(bits & 0b1_1111)
114    }
115}
116
117/// The consumer's execution posture.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
119#[serde(rename_all = "kebab-case")]
120pub enum Posture {
121    /// A human is present now.
122    Attended,
123    /// No human present (SSH box, background agent).
124    Headless,
125    /// Automation context (CI, cron) — fail closed on any would-be prompt.
126    Ci,
127}
128
129/// One dimension's constraint in a [`MatchExpr`]: unconstrained, or an
130/// allow-set. Kept as data — expressiveness grows by adding *dimensions*,
131/// never by adding cleverness (doc `06 §2`).
132#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "kebab-case")]
134pub enum Constraint {
135    /// Matches anything.
136    #[default]
137    Any,
138    /// Matches when the context's value is one of these.
139    OneOf(Vec<String>),
140}
141
142/// Which resolver contexts a variant serves. A conjunction over four
143/// dimensions; specificity = number of constrained dimensions.
144#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
145pub struct MatchExpr {
146    /// Model family constraint (`claude`, `gpt`, `gemini`, …).
147    #[serde(default, skip_serializing_if = "constraint_is_any")]
148    pub model_family: Constraint,
149    /// Harness constraint (`claude-code`, `codex`, …).
150    #[serde(default, skip_serializing_if = "constraint_is_any")]
151    pub harness: Constraint,
152    /// Required modalities (superset match), if any.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub modalities: Option<ModalitySet>,
155    /// Posture constraint.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub posture: Option<Vec<Posture>>,
158}
159
160#[allow(clippy::trivially_copy_pass_by_ref)] // serde skip_serializing_if signature
161fn constraint_is_any(c: &Constraint) -> bool {
162    matches!(c, Constraint::Any)
163}
164
165impl MatchExpr {
166    /// The catch-all: matches every context. Every manifest must carry one
167    /// variant with this expression so selection is total (doc `06 §2`).
168    #[must_use]
169    pub fn any() -> Self {
170        Self::default()
171    }
172
173    /// True when no dimension is constrained.
174    #[must_use]
175    pub fn is_catch_all(&self) -> bool {
176        matches!(self.model_family, Constraint::Any)
177            && matches!(self.harness, Constraint::Any)
178            && self.modalities.is_none()
179            && self.posture.is_none()
180    }
181
182    /// Specificity: how many dimensions are constrained (0–4). Selection
183    /// ranks by this; the algorithm itself is CP-2's sealed matcher.
184    #[must_use]
185    pub fn specificity(&self) -> u8 {
186        u8::from(!matches!(self.model_family, Constraint::Any))
187            + u8::from(!matches!(self.harness, Constraint::Any))
188            + u8::from(self.modalities.is_some())
189            + u8::from(self.posture.is_some())
190    }
191}
192
193/// A variant's body: small content inline, larger content by reference
194/// (rev 2.3 — the threshold is automatic at mint, doc `02`).
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "kebab-case")]
197pub enum VariantBody {
198    /// Inline content (≤ [`crate::INLINE_THRESHOLD_BYTES`]).
199    Inline {
200        /// MIME type of `data`.
201        content_type: String,
202        /// The content itself.
203        data: String,
204    },
205    /// Bytes by reference with integrity.
206    Media(MediaRef),
207}
208
209/// A searchable text extraction of an opaque artifact, plus the provenance a
210/// reader needs to weigh it (doc `18 §7`).
211///
212/// When a snapshot's bytes are a binary the substrate can read *deterministically*
213/// — a PDF's embedded text layer, an HTML document's text — mint extracts them to
214/// this text blob so `read`/`search`/`coverage` work over the artifact directly.
215/// The extraction is signed with the core, so the token *carries* the searchable
216/// text instead of leaving it as a loose file the next agent must locate.
217///
218/// `deterministic` is the load-bearing field. A text-layer extraction is a pure
219/// function of the bytes and reproduces exactly (`true`). A model-produced
220/// transcription — OCR, ASR — is an opinion that drifts (`false`), and stamping it
221/// as such is what lets a coverage receipt over the extraction be read at its true
222/// worth: the substrate refuses to let a hallucinated transcript pass for the
223/// artifact. The substrate never *defaults* to a non-deterministic extractor.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225pub struct Extraction {
226    /// The extracted text, content-addressed (`text/plain`).
227    pub media: MediaRef,
228    /// Which extractor produced it — e.g. `pdf-textlayer`, `html-strip`.
229    pub extractor: String,
230    /// `true` when the extraction is a pure, reproducible function of the bytes;
231    /// `false` when a model produced it and the text is an opinion.
232    pub deterministic: bool,
233}
234
235/// A directory node of a tree mint (design doc: tree-scale). When a token names
236/// a directory minted `--tree`, it carries one of these instead of a flat list of
237/// per-file child tokens: the directory's **index** (files pinned by hash, subdirs
238/// addressed by their own subtree token), the **trigram index** over its files,
239/// and a **Bloom summary** of every trigram beneath it, inlined so a search can
240/// prune the subtree from the manifest alone. Files pin their bytes eagerly (so a
241/// deleted file still reads — C-1) and are addressed by name within their node
242/// (`read --file`); a file is not itself a token, so a folder of thousands mints
243/// as a handful of nodes, not thousands of tokens.
244///
245/// The `bloom` is the lowercase-hex wire form of a fixed-size filter (parsed into
246/// the typed structure by the consumer, `waggle-tree::Bloom`); keeping it a string
247/// here preserves this crate's dependency-free leaf position. Immutable core —
248/// signed; absent for non-tree mints, so their manifests keep their exact bytes.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct TreeNode {
251    /// Content-addressed directory index for THIS node (its direct entries).
252    pub index: MediaRef,
253    /// Content-addressed trigram index over this node's own files; absent when the
254    /// node has no indexable text of its own (e.g. only subdirectories).
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub trigram: Option<MediaRef>,
257    /// Hex Bloom summary of every trigram in this subtree — inlined for prune.
258    pub bloom: String,
259    /// Files anywhere beneath this node (recursive).
260    pub files: u64,
261    /// Bytes anywhere beneath this node (recursive).
262    pub bytes: u64,
263}
264
265/// One projection of the artifact for a class of consumers.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct Variant {
268    /// Which contexts this variant serves.
269    #[serde(rename = "match")]
270    pub match_expr: MatchExpr,
271    /// What those consumers receive.
272    pub body: VariantBody,
273    /// Advisory freshness window in ms for resolutions served from this
274    /// variant (G-3). `None` ⇒ [`crate::DEFAULT_REVALIDATE_MS`]. Short for
275    /// sensitive artifacts.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub revalidate_after_ms: Option<u64>,
278}
279
280/// A token's lifecycle disposition at a point in time.
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(rename_all = "kebab-case")]
283pub enum Disposition {
284    /// Live and servable.
285    Active,
286    /// Past its `expires_at`.
287    Expired,
288    /// Withdrawn; tombstoned, never recycled (I-6).
289    Revoked {
290        /// When it was revoked.
291        at: Timestamp,
292    },
293    /// Replaced by a corrected token; late resolvers follow the pointer.
294    Superseded {
295        /// The replacement token.
296        by: Token,
297    },
298}
299
300/// The stored, retrievable record behind a token (doc `02 §2`).
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
302pub struct AttributionManifest {
303    /// Envelope schema version — pinned, additive-only.
304    pub schema: u16,
305    /// The token this manifest belongs to.
306    pub token: Token,
307    /// Permanent identity of the target artifact.
308    pub target: CanonicalUrl,
309    /// Who minted — attribution, independent of authorship.
310    pub sharer: Sharer,
311    /// Where this share lives.
312    pub channel: Channel,
313    /// When it was minted.
314    pub minted_at: Timestamp,
315    /// Mint-time snapshot of the target (never scraped — I-3).
316    #[serde(default, skip_serializing_if = "meta_is_empty")]
317    pub meta: TargetMeta,
318    /// Parent token when this was minted as a delegation child (lineage).
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub parent: Option<Token>,
321    /// The artifact's bytes at mint, content-addressed (doc `18 §3`) —
322    /// set by snapshot minting, immutable like the rest of the core.
323    /// `read`/`search` prefer this over the live target: what you grep is
324    /// what was minted, wherever the blobs replicate.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub content: Option<MediaRef>,
327    /// The projections. Always ≥1; exactly one catch-all guaranteed at mint.
328    pub variants: Vec<Variant>,
329    /// Capability-URL semantics (CP-11): a private token IS its own
330    /// credential — minted long enough to be unguessable, and refused by
331    /// public surfaces (unfurls, social renderers). Immutable + signed.
332    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
333    pub private: bool,
334    /// The consumption contract, if the author declared one at mint
335    /// (doc `19 §4.2`): which regions a consumer must reach. Immutable
336    /// core — signed; absent for the (default) contract-free mint, so
337    /// pre-contract manifests keep their exact canonical bytes.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub contract: Option<crate::Contract>,
340    /// The symbol outline blob (doc `20 §3`): structure extracted from
341    /// the snapshot at mint (`application/waggle-outline+json`,
342    /// content-addressed). A pointer only — the outline is content, not
343    /// manifest. Immutable core — signed; absent when no grammar
344    /// matched, so outline-free manifests keep their exact bytes.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub outline: Option<MediaRef>,
347    /// A deterministic text extraction of an opaque artifact (doc `18 §7`):
348    /// the searchable text of a PDF or HTML target, derived at mint and signed
349    /// with the core, with its provenance. A pointer plus provenance — the text
350    /// is content. Immutable core; absent for text artifacts and for opaque
351    /// media the substrate will not read (audio, video), so those manifests keep
352    /// their exact canonical bytes.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub extraction: Option<Extraction>,
355    /// A directory node of a tree mint (design doc: tree-scale): the node's index,
356    /// trigram index, and inlined Bloom summary. Present when this token is a
357    /// directory minted `--tree`; absent otherwise, so non-tree manifests keep
358    /// their exact bytes. Immutable core — signed.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub tree: Option<TreeNode>,
361    /// Author signature over the immutable core (CP-11); set at mint by
362    /// hosts that hold an identity. NOT itself part of the signed bytes.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub signature: Option<SignatureBlock>,
365    /// Mutable-section version — CAS target for lifecycle mutations (C-9).
366    pub version: u32,
367    /// Cosmetic: campaign label (LWW).
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub campaign: Option<String>,
370    /// Cosmetic: labels (LWW).
371    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
372    pub labels: BTreeMap<String, String>,
373    /// Lifecycle: expiry, if any.
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub expires_at: Option<Timestamp>,
376    /// Lifecycle: revocation instant, if revoked.
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub revoked_at: Option<Timestamp>,
379    /// Lifecycle: replacement pointer, if superseded.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub superseded_by: Option<Token>,
382}
383
384fn meta_is_empty(m: &TargetMeta) -> bool {
385    *m == TargetMeta::default()
386}
387
388/// A detached signature over the manifest's IMMUTABLE core (doc 14
389/// CP-11): mutations never touch what was signed, so lifecycle churn
390/// never invalidates it. Set by the host at mint; verified by anyone.
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392pub struct SignatureBlock {
393    /// Signature algorithm — `ed25519` today.
394    pub alg: String,
395    /// The signer's verifying key, hex-encoded (32 bytes).
396    pub key: String,
397    /// The signature, hex-encoded (64 bytes).
398    pub sig: String,
399}
400
401/// Apply one mutable-section change to a manifest — THE mutation semantic,
402/// shared by `ManifestFold`, every backend, and reconstruct so they can
403/// never disagree (R-4's precondition). Lifecycle changes bump `version`
404/// (the C-9 CAS baseline); cosmetic changes don't. CAS checking is the
405/// store's job at its commit point; this function only applies.
406pub fn apply_change(
407    manifest: &mut AttributionManifest,
408    change: &crate::log::Change,
409    at: Timestamp,
410) {
411    use crate::log::Change;
412    match change {
413        Change::Revoked => {
414            manifest.revoked_at = Some(at);
415            manifest.version += 1;
416        }
417        Change::Superseded { by } => {
418            manifest.superseded_by = Some(*by);
419            manifest.version += 1;
420        }
421        Change::ExpirySet { expires_at } => {
422            manifest.expires_at = *expires_at;
423            manifest.version += 1;
424        }
425        Change::CampaignSet { campaign } => manifest.campaign.clone_from(campaign),
426        Change::LabelSet { key, value } => {
427            manifest.labels.insert(key.clone(), value.clone());
428        }
429        Change::LabelUnset { key } => {
430            manifest.labels.remove(key);
431        }
432    }
433}
434
435impl AttributionManifest {
436    /// Lifecycle disposition at `now`. Precedence: revoked > superseded >
437    /// expired > active (a revoked token stays revoked even if also
438    /// superseded — revocation is the stronger claim).
439    #[must_use]
440    pub fn disposition(&self, now: Timestamp) -> Disposition {
441        if let Some(at) = self.revoked_at {
442            return Disposition::Revoked { at };
443        }
444        if let Some(by) = self.superseded_by {
445            return Disposition::Superseded { by };
446        }
447        match self.expires_at {
448            Some(exp) if exp <= now => Disposition::Expired,
449            _ => Disposition::Active,
450        }
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn modality_set_algebra() {
460        let ctx = ModalitySet::TEXT.with(ModalitySet::VISION);
461        assert!(ctx.contains(ModalitySet::VISION));
462        assert!(ctx.contains(ModalitySet::empty()));
463        assert!(!ctx.contains(ModalitySet::BROWSER));
464        assert!(!ctx.contains(ModalitySet::VISION.with(ModalitySet::AUDIO)));
465    }
466
467    #[test]
468    fn catch_all_and_specificity() {
469        assert!(MatchExpr::any().is_catch_all());
470        assert_eq!(MatchExpr::any().specificity(), 0);
471        let m = MatchExpr {
472            model_family: Constraint::OneOf(vec!["claude".into()]),
473            modalities: Some(ModalitySet::VISION),
474            ..MatchExpr::default()
475        };
476        assert!(!m.is_catch_all());
477        assert_eq!(m.specificity(), 2);
478    }
479
480    #[test]
481    fn disposition_precedence() {
482        let now = Timestamp::from_unix_ms(10_000);
483        let mut entropy = |buf: &mut [u8]| {
484            buf.fill(7);
485            Ok(())
486        };
487        let m = crate::mint(
488            crate::MintSpec::new(
489                CanonicalUrl::new("file:///tmp/x.md").unwrap(),
490                Sharer::new("lead").unwrap(),
491                Channel::subagent_general(),
492            ),
493            &crate::MintOptions::default(),
494            &mut entropy,
495            now,
496        )
497        .unwrap();
498
499        assert_eq!(m.disposition(now), Disposition::Active);
500
501        let mut expired = m.clone();
502        expired.expires_at = Some(now);
503        assert_eq!(expired.disposition(now), Disposition::Expired);
504        assert_eq!(
505            expired.disposition(Timestamp::from_unix_ms(9_999)),
506            Disposition::Active,
507            "expiry is exclusive of earlier instants"
508        );
509
510        let other = Token::parse("zzz").unwrap();
511        let mut superseded = expired.clone();
512        superseded.superseded_by = Some(other);
513        assert_eq!(
514            superseded.disposition(now),
515            Disposition::Superseded { by: other }
516        );
517
518        let mut revoked = superseded;
519        revoked.revoked_at = Some(now);
520        assert_eq!(
521            revoked.disposition(now),
522            Disposition::Revoked { at: now },
523            "revocation outranks supersession and expiry"
524        );
525    }
526
527    #[test]
528    fn manifest_serde_roundtrip() {
529        let now = Timestamp::from_unix_ms(1);
530        let mut entropy = |buf: &mut [u8]| {
531            buf.fill(9);
532            Ok(())
533        };
534        let m = crate::mint(
535            crate::MintSpec::new(
536                CanonicalUrl::new("ws://a/b").unwrap(),
537                Sharer::new("lead").unwrap(),
538                Channel::new("subagent/pricing").unwrap(),
539            )
540            .variant(
541                MatchExpr {
542                    modalities: Some(ModalitySet::VISION),
543                    ..MatchExpr::default()
544                },
545                VariantBody::Inline {
546                    content_type: "text/markdown".into(),
547                    data: "see the chart".into(),
548                },
549            ),
550            &crate::MintOptions::default(),
551            &mut entropy,
552            now,
553        )
554        .unwrap();
555        let json = serde_json::to_string(&m).unwrap();
556        let back: AttributionManifest = serde_json::from_str(&json).unwrap();
557        assert_eq!(back, m);
558        assert_eq!(back.schema, MANIFEST_SCHEMA_VERSION);
559    }
560}