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}