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, Deserialize)]
27#[serde(transparent)]
28pub struct ModalitySet(u8);
29
30impl ModalitySet {
31    /// Plain text I/O.
32    pub const TEXT: Self = Self(1);
33    /// Can drive a browser.
34    pub const BROWSER: Self = Self(1 << 1);
35    /// Can run shell commands.
36    pub const SHELL: Self = Self(1 << 2);
37    /// Can interpret images.
38    pub const VISION: Self = Self(1 << 3);
39    /// Can interpret audio.
40    pub const AUDIO: Self = Self(1 << 4);
41
42    /// The empty set.
43    #[must_use]
44    pub const fn empty() -> Self {
45        Self(0)
46    }
47
48    /// Set union.
49    #[must_use]
50    pub const fn with(self, other: Self) -> Self {
51        Self(self.0 | other.0)
52    }
53
54    /// Does `self` contain every modality in `required`?
55    #[must_use]
56    pub const fn contains(self, required: Self) -> bool {
57        self.0 & required.0 == required.0
58    }
59
60    /// Build from raw bits, ignoring undefined ones — for hosts
61    /// deserializing foreign context descriptors (and for exhaustive
62    /// testing over the modality space).
63    #[must_use]
64    pub const fn from_bits_truncate(bits: u8) -> Self {
65        Self(bits & 0b1_1111)
66    }
67}
68
69/// The consumer's execution posture.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum Posture {
73    /// A human is present now.
74    Attended,
75    /// No human present (SSH box, background agent).
76    Headless,
77    /// Automation context (CI, cron) — fail closed on any would-be prompt.
78    Ci,
79}
80
81/// One dimension's constraint in a [`MatchExpr`]: unconstrained, or an
82/// allow-set. Kept as data — expressiveness grows by adding *dimensions*,
83/// never by adding cleverness (doc `06 §2`).
84#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "kebab-case")]
86pub enum Constraint {
87    /// Matches anything.
88    #[default]
89    Any,
90    /// Matches when the context's value is one of these.
91    OneOf(Vec<String>),
92}
93
94/// Which resolver contexts a variant serves. A conjunction over four
95/// dimensions; specificity = number of constrained dimensions.
96#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
97pub struct MatchExpr {
98    /// Model family constraint (`claude`, `gpt`, `gemini`, …).
99    #[serde(default, skip_serializing_if = "constraint_is_any")]
100    pub model_family: Constraint,
101    /// Harness constraint (`claude-code`, `codex`, …).
102    #[serde(default, skip_serializing_if = "constraint_is_any")]
103    pub harness: Constraint,
104    /// Required modalities (superset match), if any.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub modalities: Option<ModalitySet>,
107    /// Posture constraint.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub posture: Option<Vec<Posture>>,
110}
111
112#[allow(clippy::trivially_copy_pass_by_ref)] // serde skip_serializing_if signature
113fn constraint_is_any(c: &Constraint) -> bool {
114    matches!(c, Constraint::Any)
115}
116
117impl MatchExpr {
118    /// The catch-all: matches every context. Every manifest must carry one
119    /// variant with this expression so selection is total (doc `06 §2`).
120    #[must_use]
121    pub fn any() -> Self {
122        Self::default()
123    }
124
125    /// True when no dimension is constrained.
126    #[must_use]
127    pub fn is_catch_all(&self) -> bool {
128        matches!(self.model_family, Constraint::Any)
129            && matches!(self.harness, Constraint::Any)
130            && self.modalities.is_none()
131            && self.posture.is_none()
132    }
133
134    /// Specificity: how many dimensions are constrained (0–4). Selection
135    /// ranks by this; the algorithm itself is CP-2's sealed matcher.
136    #[must_use]
137    pub fn specificity(&self) -> u8 {
138        u8::from(!matches!(self.model_family, Constraint::Any))
139            + u8::from(!matches!(self.harness, Constraint::Any))
140            + u8::from(self.modalities.is_some())
141            + u8::from(self.posture.is_some())
142    }
143}
144
145/// A variant's body: small content inline, larger content by reference
146/// (rev 2.3 — the threshold is automatic at mint, doc `02`).
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "kebab-case")]
149pub enum VariantBody {
150    /// Inline content (≤ [`crate::INLINE_THRESHOLD_BYTES`]).
151    Inline {
152        /// MIME type of `data`.
153        content_type: String,
154        /// The content itself.
155        data: String,
156    },
157    /// Bytes by reference with integrity.
158    Media(MediaRef),
159}
160
161/// One projection of the artifact for a class of consumers.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct Variant {
164    /// Which contexts this variant serves.
165    #[serde(rename = "match")]
166    pub match_expr: MatchExpr,
167    /// What those consumers receive.
168    pub body: VariantBody,
169    /// Advisory freshness window in ms for resolutions served from this
170    /// variant (G-3). `None` ⇒ [`crate::DEFAULT_REVALIDATE_MS`]. Short for
171    /// sensitive artifacts.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub revalidate_after_ms: Option<u64>,
174}
175
176/// A token's lifecycle disposition at a point in time.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "kebab-case")]
179pub enum Disposition {
180    /// Live and servable.
181    Active,
182    /// Past its `expires_at`.
183    Expired,
184    /// Withdrawn; tombstoned, never recycled (I-6).
185    Revoked {
186        /// When it was revoked.
187        at: Timestamp,
188    },
189    /// Replaced by a corrected token; late resolvers follow the pointer.
190    Superseded {
191        /// The replacement token.
192        by: Token,
193    },
194}
195
196/// The stored, retrievable record behind a token (doc `02 §2`).
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct AttributionManifest {
199    /// Envelope schema version — pinned, additive-only.
200    pub schema: u16,
201    /// The token this manifest belongs to.
202    pub token: Token,
203    /// Permanent identity of the target artifact.
204    pub target: CanonicalUrl,
205    /// Who minted — attribution, independent of authorship.
206    pub sharer: Sharer,
207    /// Where this share lives.
208    pub channel: Channel,
209    /// When it was minted.
210    pub minted_at: Timestamp,
211    /// Mint-time snapshot of the target (never scraped — I-3).
212    #[serde(default, skip_serializing_if = "meta_is_empty")]
213    pub meta: TargetMeta,
214    /// Parent token when this was minted as a delegation child (lineage).
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub parent: Option<Token>,
217    /// The artifact's bytes at mint, content-addressed (doc `18 §3`) —
218    /// set by snapshot minting, immutable like the rest of the core.
219    /// `read`/`search` prefer this over the live target: what you grep is
220    /// what was minted, wherever the blobs replicate.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub content: Option<MediaRef>,
223    /// The projections. Always ≥1; exactly one catch-all guaranteed at mint.
224    pub variants: Vec<Variant>,
225    /// Capability-URL semantics (CP-11): a private token IS its own
226    /// credential — minted long enough to be unguessable, and refused by
227    /// public surfaces (unfurls, social renderers). Immutable + signed.
228    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
229    pub private: bool,
230    /// Author signature over the immutable core (CP-11); set at mint by
231    /// hosts that hold an identity. NOT itself part of the signed bytes.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub signature: Option<SignatureBlock>,
234    /// Mutable-section version — CAS target for lifecycle mutations (C-9).
235    pub version: u32,
236    /// Cosmetic: campaign label (LWW).
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub campaign: Option<String>,
239    /// Cosmetic: labels (LWW).
240    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
241    pub labels: BTreeMap<String, String>,
242    /// Lifecycle: expiry, if any.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub expires_at: Option<Timestamp>,
245    /// Lifecycle: revocation instant, if revoked.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub revoked_at: Option<Timestamp>,
248    /// Lifecycle: replacement pointer, if superseded.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub superseded_by: Option<Token>,
251}
252
253fn meta_is_empty(m: &TargetMeta) -> bool {
254    *m == TargetMeta::default()
255}
256
257/// A detached signature over the manifest's IMMUTABLE core (doc 14
258/// CP-11): mutations never touch what was signed, so lifecycle churn
259/// never invalidates it. Set by the host at mint; verified by anyone.
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261pub struct SignatureBlock {
262    /// Signature algorithm — `ed25519` today.
263    pub alg: String,
264    /// The signer's verifying key, hex-encoded (32 bytes).
265    pub key: String,
266    /// The signature, hex-encoded (64 bytes).
267    pub sig: String,
268}
269
270/// Apply one mutable-section change to a manifest — THE mutation semantic,
271/// shared by `ManifestFold`, every backend, and reconstruct so they can
272/// never disagree (R-4's precondition). Lifecycle changes bump `version`
273/// (the C-9 CAS baseline); cosmetic changes don't. CAS checking is the
274/// store's job at its commit point; this function only applies.
275pub fn apply_change(
276    manifest: &mut AttributionManifest,
277    change: &crate::log::Change,
278    at: Timestamp,
279) {
280    use crate::log::Change;
281    match change {
282        Change::Revoked => {
283            manifest.revoked_at = Some(at);
284            manifest.version += 1;
285        }
286        Change::Superseded { by } => {
287            manifest.superseded_by = Some(*by);
288            manifest.version += 1;
289        }
290        Change::ExpirySet { expires_at } => {
291            manifest.expires_at = *expires_at;
292            manifest.version += 1;
293        }
294        Change::CampaignSet { campaign } => manifest.campaign.clone_from(campaign),
295        Change::LabelSet { key, value } => {
296            manifest.labels.insert(key.clone(), value.clone());
297        }
298        Change::LabelUnset { key } => {
299            manifest.labels.remove(key);
300        }
301    }
302}
303
304impl AttributionManifest {
305    /// Lifecycle disposition at `now`. Precedence: revoked > superseded >
306    /// expired > active (a revoked token stays revoked even if also
307    /// superseded — revocation is the stronger claim).
308    #[must_use]
309    pub fn disposition(&self, now: Timestamp) -> Disposition {
310        if let Some(at) = self.revoked_at {
311            return Disposition::Revoked { at };
312        }
313        if let Some(by) = self.superseded_by {
314            return Disposition::Superseded { by };
315        }
316        match self.expires_at {
317            Some(exp) if exp <= now => Disposition::Expired,
318            _ => Disposition::Active,
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn modality_set_algebra() {
329        let ctx = ModalitySet::TEXT.with(ModalitySet::VISION);
330        assert!(ctx.contains(ModalitySet::VISION));
331        assert!(ctx.contains(ModalitySet::empty()));
332        assert!(!ctx.contains(ModalitySet::BROWSER));
333        assert!(!ctx.contains(ModalitySet::VISION.with(ModalitySet::AUDIO)));
334    }
335
336    #[test]
337    fn catch_all_and_specificity() {
338        assert!(MatchExpr::any().is_catch_all());
339        assert_eq!(MatchExpr::any().specificity(), 0);
340        let m = MatchExpr {
341            model_family: Constraint::OneOf(vec!["claude".into()]),
342            modalities: Some(ModalitySet::VISION),
343            ..MatchExpr::default()
344        };
345        assert!(!m.is_catch_all());
346        assert_eq!(m.specificity(), 2);
347    }
348
349    #[test]
350    fn disposition_precedence() {
351        let now = Timestamp::from_unix_ms(10_000);
352        let mut entropy = |buf: &mut [u8]| {
353            buf.fill(7);
354            Ok(())
355        };
356        let m = crate::mint(
357            crate::MintSpec::new(
358                CanonicalUrl::new("file:///tmp/x.md").unwrap(),
359                Sharer::new("lead").unwrap(),
360                Channel::subagent_general(),
361            ),
362            &crate::MintOptions::default(),
363            &mut entropy,
364            now,
365        )
366        .unwrap();
367
368        assert_eq!(m.disposition(now), Disposition::Active);
369
370        let mut expired = m.clone();
371        expired.expires_at = Some(now);
372        assert_eq!(expired.disposition(now), Disposition::Expired);
373        assert_eq!(
374            expired.disposition(Timestamp::from_unix_ms(9_999)),
375            Disposition::Active,
376            "expiry is exclusive of earlier instants"
377        );
378
379        let other = Token::parse("zzz").unwrap();
380        let mut superseded = expired.clone();
381        superseded.superseded_by = Some(other);
382        assert_eq!(
383            superseded.disposition(now),
384            Disposition::Superseded { by: other }
385        );
386
387        let mut revoked = superseded;
388        revoked.revoked_at = Some(now);
389        assert_eq!(
390            revoked.disposition(now),
391            Disposition::Revoked { at: now },
392            "revocation outranks supersession and expiry"
393        );
394    }
395
396    #[test]
397    fn manifest_serde_roundtrip() {
398        let now = Timestamp::from_unix_ms(1);
399        let mut entropy = |buf: &mut [u8]| {
400            buf.fill(9);
401            Ok(())
402        };
403        let m = crate::mint(
404            crate::MintSpec::new(
405                CanonicalUrl::new("ws://a/b").unwrap(),
406                Sharer::new("lead").unwrap(),
407                Channel::new("subagent/pricing").unwrap(),
408            )
409            .variant(
410                MatchExpr {
411                    modalities: Some(ModalitySet::VISION),
412                    ..MatchExpr::default()
413                },
414                VariantBody::Inline {
415                    content_type: "text/markdown".into(),
416                    data: "see the chart".into(),
417                },
418            ),
419            &crate::MintOptions::default(),
420            &mut entropy,
421            now,
422        )
423        .unwrap();
424        let json = serde_json::to_string(&m).unwrap();
425        let back: AttributionManifest = serde_json::from_str(&json).unwrap();
426        assert_eq!(back, m);
427        assert_eq!(back.schema, MANIFEST_SCHEMA_VERSION);
428    }
429}