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/// One projection of the artifact for a class of consumers.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct Variant {
212    /// Which contexts this variant serves.
213    #[serde(rename = "match")]
214    pub match_expr: MatchExpr,
215    /// What those consumers receive.
216    pub body: VariantBody,
217    /// Advisory freshness window in ms for resolutions served from this
218    /// variant (G-3). `None` ⇒ [`crate::DEFAULT_REVALIDATE_MS`]. Short for
219    /// sensitive artifacts.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub revalidate_after_ms: Option<u64>,
222}
223
224/// A token's lifecycle disposition at a point in time.
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "kebab-case")]
227pub enum Disposition {
228    /// Live and servable.
229    Active,
230    /// Past its `expires_at`.
231    Expired,
232    /// Withdrawn; tombstoned, never recycled (I-6).
233    Revoked {
234        /// When it was revoked.
235        at: Timestamp,
236    },
237    /// Replaced by a corrected token; late resolvers follow the pointer.
238    Superseded {
239        /// The replacement token.
240        by: Token,
241    },
242}
243
244/// The stored, retrievable record behind a token (doc `02 §2`).
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct AttributionManifest {
247    /// Envelope schema version — pinned, additive-only.
248    pub schema: u16,
249    /// The token this manifest belongs to.
250    pub token: Token,
251    /// Permanent identity of the target artifact.
252    pub target: CanonicalUrl,
253    /// Who minted — attribution, independent of authorship.
254    pub sharer: Sharer,
255    /// Where this share lives.
256    pub channel: Channel,
257    /// When it was minted.
258    pub minted_at: Timestamp,
259    /// Mint-time snapshot of the target (never scraped — I-3).
260    #[serde(default, skip_serializing_if = "meta_is_empty")]
261    pub meta: TargetMeta,
262    /// Parent token when this was minted as a delegation child (lineage).
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub parent: Option<Token>,
265    /// The artifact's bytes at mint, content-addressed (doc `18 §3`) —
266    /// set by snapshot minting, immutable like the rest of the core.
267    /// `read`/`search` prefer this over the live target: what you grep is
268    /// what was minted, wherever the blobs replicate.
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub content: Option<MediaRef>,
271    /// The projections. Always ≥1; exactly one catch-all guaranteed at mint.
272    pub variants: Vec<Variant>,
273    /// Capability-URL semantics (CP-11): a private token IS its own
274    /// credential — minted long enough to be unguessable, and refused by
275    /// public surfaces (unfurls, social renderers). Immutable + signed.
276    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
277    pub private: bool,
278    /// The consumption contract, if the author declared one at mint
279    /// (doc `19 §4.2`): which regions a consumer must reach. Immutable
280    /// core — signed; absent for the (default) contract-free mint, so
281    /// pre-contract manifests keep their exact canonical bytes.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub contract: Option<crate::Contract>,
284    /// The symbol outline blob (doc `20 §3`): structure extracted from
285    /// the snapshot at mint (`application/waggle-outline+json`,
286    /// content-addressed). A pointer only — the outline is content, not
287    /// manifest. Immutable core — signed; absent when no grammar
288    /// matched, so outline-free manifests keep their exact bytes.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub outline: Option<MediaRef>,
291    /// Author signature over the immutable core (CP-11); set at mint by
292    /// hosts that hold an identity. NOT itself part of the signed bytes.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub signature: Option<SignatureBlock>,
295    /// Mutable-section version — CAS target for lifecycle mutations (C-9).
296    pub version: u32,
297    /// Cosmetic: campaign label (LWW).
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub campaign: Option<String>,
300    /// Cosmetic: labels (LWW).
301    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
302    pub labels: BTreeMap<String, String>,
303    /// Lifecycle: expiry, if any.
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub expires_at: Option<Timestamp>,
306    /// Lifecycle: revocation instant, if revoked.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub revoked_at: Option<Timestamp>,
309    /// Lifecycle: replacement pointer, if superseded.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub superseded_by: Option<Token>,
312}
313
314fn meta_is_empty(m: &TargetMeta) -> bool {
315    *m == TargetMeta::default()
316}
317
318/// A detached signature over the manifest's IMMUTABLE core (doc 14
319/// CP-11): mutations never touch what was signed, so lifecycle churn
320/// never invalidates it. Set by the host at mint; verified by anyone.
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322pub struct SignatureBlock {
323    /// Signature algorithm — `ed25519` today.
324    pub alg: String,
325    /// The signer's verifying key, hex-encoded (32 bytes).
326    pub key: String,
327    /// The signature, hex-encoded (64 bytes).
328    pub sig: String,
329}
330
331/// Apply one mutable-section change to a manifest — THE mutation semantic,
332/// shared by `ManifestFold`, every backend, and reconstruct so they can
333/// never disagree (R-4's precondition). Lifecycle changes bump `version`
334/// (the C-9 CAS baseline); cosmetic changes don't. CAS checking is the
335/// store's job at its commit point; this function only applies.
336pub fn apply_change(
337    manifest: &mut AttributionManifest,
338    change: &crate::log::Change,
339    at: Timestamp,
340) {
341    use crate::log::Change;
342    match change {
343        Change::Revoked => {
344            manifest.revoked_at = Some(at);
345            manifest.version += 1;
346        }
347        Change::Superseded { by } => {
348            manifest.superseded_by = Some(*by);
349            manifest.version += 1;
350        }
351        Change::ExpirySet { expires_at } => {
352            manifest.expires_at = *expires_at;
353            manifest.version += 1;
354        }
355        Change::CampaignSet { campaign } => manifest.campaign.clone_from(campaign),
356        Change::LabelSet { key, value } => {
357            manifest.labels.insert(key.clone(), value.clone());
358        }
359        Change::LabelUnset { key } => {
360            manifest.labels.remove(key);
361        }
362    }
363}
364
365impl AttributionManifest {
366    /// Lifecycle disposition at `now`. Precedence: revoked > superseded >
367    /// expired > active (a revoked token stays revoked even if also
368    /// superseded — revocation is the stronger claim).
369    #[must_use]
370    pub fn disposition(&self, now: Timestamp) -> Disposition {
371        if let Some(at) = self.revoked_at {
372            return Disposition::Revoked { at };
373        }
374        if let Some(by) = self.superseded_by {
375            return Disposition::Superseded { by };
376        }
377        match self.expires_at {
378            Some(exp) if exp <= now => Disposition::Expired,
379            _ => Disposition::Active,
380        }
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn modality_set_algebra() {
390        let ctx = ModalitySet::TEXT.with(ModalitySet::VISION);
391        assert!(ctx.contains(ModalitySet::VISION));
392        assert!(ctx.contains(ModalitySet::empty()));
393        assert!(!ctx.contains(ModalitySet::BROWSER));
394        assert!(!ctx.contains(ModalitySet::VISION.with(ModalitySet::AUDIO)));
395    }
396
397    #[test]
398    fn catch_all_and_specificity() {
399        assert!(MatchExpr::any().is_catch_all());
400        assert_eq!(MatchExpr::any().specificity(), 0);
401        let m = MatchExpr {
402            model_family: Constraint::OneOf(vec!["claude".into()]),
403            modalities: Some(ModalitySet::VISION),
404            ..MatchExpr::default()
405        };
406        assert!(!m.is_catch_all());
407        assert_eq!(m.specificity(), 2);
408    }
409
410    #[test]
411    fn disposition_precedence() {
412        let now = Timestamp::from_unix_ms(10_000);
413        let mut entropy = |buf: &mut [u8]| {
414            buf.fill(7);
415            Ok(())
416        };
417        let m = crate::mint(
418            crate::MintSpec::new(
419                CanonicalUrl::new("file:///tmp/x.md").unwrap(),
420                Sharer::new("lead").unwrap(),
421                Channel::subagent_general(),
422            ),
423            &crate::MintOptions::default(),
424            &mut entropy,
425            now,
426        )
427        .unwrap();
428
429        assert_eq!(m.disposition(now), Disposition::Active);
430
431        let mut expired = m.clone();
432        expired.expires_at = Some(now);
433        assert_eq!(expired.disposition(now), Disposition::Expired);
434        assert_eq!(
435            expired.disposition(Timestamp::from_unix_ms(9_999)),
436            Disposition::Active,
437            "expiry is exclusive of earlier instants"
438        );
439
440        let other = Token::parse("zzz").unwrap();
441        let mut superseded = expired.clone();
442        superseded.superseded_by = Some(other);
443        assert_eq!(
444            superseded.disposition(now),
445            Disposition::Superseded { by: other }
446        );
447
448        let mut revoked = superseded;
449        revoked.revoked_at = Some(now);
450        assert_eq!(
451            revoked.disposition(now),
452            Disposition::Revoked { at: now },
453            "revocation outranks supersession and expiry"
454        );
455    }
456
457    #[test]
458    fn manifest_serde_roundtrip() {
459        let now = Timestamp::from_unix_ms(1);
460        let mut entropy = |buf: &mut [u8]| {
461            buf.fill(9);
462            Ok(())
463        };
464        let m = crate::mint(
465            crate::MintSpec::new(
466                CanonicalUrl::new("ws://a/b").unwrap(),
467                Sharer::new("lead").unwrap(),
468                Channel::new("subagent/pricing").unwrap(),
469            )
470            .variant(
471                MatchExpr {
472                    modalities: Some(ModalitySet::VISION),
473                    ..MatchExpr::default()
474                },
475                VariantBody::Inline {
476                    content_type: "text/markdown".into(),
477                    data: "see the chart".into(),
478                },
479            ),
480            &crate::MintOptions::default(),
481            &mut entropy,
482            now,
483        )
484        .unwrap();
485        let json = serde_json::to_string(&m).unwrap();
486        let back: AttributionManifest = serde_json::from_str(&json).unwrap();
487        assert_eq!(back, m);
488        assert_eq!(back.schema, MANIFEST_SCHEMA_VERSION);
489    }
490}