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    /// Mutable-section version — CAS target for lifecycle mutations (C-9).
226    pub version: u32,
227    /// Cosmetic: campaign label (LWW).
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub campaign: Option<String>,
230    /// Cosmetic: labels (LWW).
231    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
232    pub labels: BTreeMap<String, String>,
233    /// Lifecycle: expiry, if any.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub expires_at: Option<Timestamp>,
236    /// Lifecycle: revocation instant, if revoked.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub revoked_at: Option<Timestamp>,
239    /// Lifecycle: replacement pointer, if superseded.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub superseded_by: Option<Token>,
242}
243
244fn meta_is_empty(m: &TargetMeta) -> bool {
245    *m == TargetMeta::default()
246}
247
248/// Apply one mutable-section change to a manifest — THE mutation semantic,
249/// shared by `ManifestFold`, every backend, and reconstruct so they can
250/// never disagree (R-4's precondition). Lifecycle changes bump `version`
251/// (the C-9 CAS baseline); cosmetic changes don't. CAS checking is the
252/// store's job at its commit point; this function only applies.
253pub fn apply_change(
254    manifest: &mut AttributionManifest,
255    change: &crate::log::Change,
256    at: Timestamp,
257) {
258    use crate::log::Change;
259    match change {
260        Change::Revoked => {
261            manifest.revoked_at = Some(at);
262            manifest.version += 1;
263        }
264        Change::Superseded { by } => {
265            manifest.superseded_by = Some(*by);
266            manifest.version += 1;
267        }
268        Change::ExpirySet { expires_at } => {
269            manifest.expires_at = *expires_at;
270            manifest.version += 1;
271        }
272        Change::CampaignSet { campaign } => manifest.campaign.clone_from(campaign),
273        Change::LabelSet { key, value } => {
274            manifest.labels.insert(key.clone(), value.clone());
275        }
276        Change::LabelUnset { key } => {
277            manifest.labels.remove(key);
278        }
279    }
280}
281
282impl AttributionManifest {
283    /// Lifecycle disposition at `now`. Precedence: revoked > superseded >
284    /// expired > active (a revoked token stays revoked even if also
285    /// superseded — revocation is the stronger claim).
286    #[must_use]
287    pub fn disposition(&self, now: Timestamp) -> Disposition {
288        if let Some(at) = self.revoked_at {
289            return Disposition::Revoked { at };
290        }
291        if let Some(by) = self.superseded_by {
292            return Disposition::Superseded { by };
293        }
294        match self.expires_at {
295            Some(exp) if exp <= now => Disposition::Expired,
296            _ => Disposition::Active,
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn modality_set_algebra() {
307        let ctx = ModalitySet::TEXT.with(ModalitySet::VISION);
308        assert!(ctx.contains(ModalitySet::VISION));
309        assert!(ctx.contains(ModalitySet::empty()));
310        assert!(!ctx.contains(ModalitySet::BROWSER));
311        assert!(!ctx.contains(ModalitySet::VISION.with(ModalitySet::AUDIO)));
312    }
313
314    #[test]
315    fn catch_all_and_specificity() {
316        assert!(MatchExpr::any().is_catch_all());
317        assert_eq!(MatchExpr::any().specificity(), 0);
318        let m = MatchExpr {
319            model_family: Constraint::OneOf(vec!["claude".into()]),
320            modalities: Some(ModalitySet::VISION),
321            ..MatchExpr::default()
322        };
323        assert!(!m.is_catch_all());
324        assert_eq!(m.specificity(), 2);
325    }
326
327    #[test]
328    fn disposition_precedence() {
329        let now = Timestamp::from_unix_ms(10_000);
330        let mut entropy = |buf: &mut [u8]| {
331            buf.fill(7);
332            Ok(())
333        };
334        let m = crate::mint(
335            crate::MintSpec::new(
336                CanonicalUrl::new("file:///tmp/x.md").unwrap(),
337                Sharer::new("lead").unwrap(),
338                Channel::subagent_general(),
339            ),
340            &crate::MintOptions::default(),
341            &mut entropy,
342            now,
343        )
344        .unwrap();
345
346        assert_eq!(m.disposition(now), Disposition::Active);
347
348        let mut expired = m.clone();
349        expired.expires_at = Some(now);
350        assert_eq!(expired.disposition(now), Disposition::Expired);
351        assert_eq!(
352            expired.disposition(Timestamp::from_unix_ms(9_999)),
353            Disposition::Active,
354            "expiry is exclusive of earlier instants"
355        );
356
357        let other = Token::parse("zzz").unwrap();
358        let mut superseded = expired.clone();
359        superseded.superseded_by = Some(other);
360        assert_eq!(
361            superseded.disposition(now),
362            Disposition::Superseded { by: other }
363        );
364
365        let mut revoked = superseded;
366        revoked.revoked_at = Some(now);
367        assert_eq!(
368            revoked.disposition(now),
369            Disposition::Revoked { at: now },
370            "revocation outranks supersession and expiry"
371        );
372    }
373
374    #[test]
375    fn manifest_serde_roundtrip() {
376        let now = Timestamp::from_unix_ms(1);
377        let mut entropy = |buf: &mut [u8]| {
378            buf.fill(9);
379            Ok(())
380        };
381        let m = crate::mint(
382            crate::MintSpec::new(
383                CanonicalUrl::new("ws://a/b").unwrap(),
384                Sharer::new("lead").unwrap(),
385                Channel::new("subagent/pricing").unwrap(),
386            )
387            .variant(
388                MatchExpr {
389                    modalities: Some(ModalitySet::VISION),
390                    ..MatchExpr::default()
391                },
392                VariantBody::Inline {
393                    content_type: "text/markdown".into(),
394                    data: "see the chart".into(),
395                },
396            ),
397            &crate::MintOptions::default(),
398            &mut entropy,
399            now,
400        )
401        .unwrap();
402        let json = serde_json::to_string(&m).unwrap();
403        let back: AttributionManifest = serde_json::from_str(&json).unwrap();
404        assert_eq!(back, m);
405        assert_eq!(back.schema, MANIFEST_SCHEMA_VERSION);
406    }
407}