1use 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
18pub const MANIFEST_SCHEMA_VERSION: u16 = 1;
21
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
27#[serde(transparent)]
28pub struct ModalitySet(u8);
29
30impl<'de> Deserialize<'de> for ModalitySet {
31 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 pub const TEXT: Self = Self(1);
68 pub const BROWSER: Self = Self(1 << 1);
70 pub const SHELL: Self = Self(1 << 2);
72 pub const VISION: Self = Self(1 << 3);
74 pub const AUDIO: Self = Self(1 << 4);
76
77 #[must_use]
79 pub const fn empty() -> Self {
80 Self(0)
81 }
82
83 #[must_use]
85 pub const fn with(self, other: Self) -> Self {
86 Self(self.0 | other.0)
87 }
88
89 #[must_use]
91 pub const fn contains(self, required: Self) -> bool {
92 self.0 & required.0 == required.0
93 }
94
95 #[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 #[must_use]
112 pub const fn from_bits_truncate(bits: u8) -> Self {
113 Self(bits & 0b1_1111)
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
119#[serde(rename_all = "kebab-case")]
120pub enum Posture {
121 Attended,
123 Headless,
125 Ci,
127}
128
129#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "kebab-case")]
134pub enum Constraint {
135 #[default]
137 Any,
138 OneOf(Vec<String>),
140}
141
142#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
145pub struct MatchExpr {
146 #[serde(default, skip_serializing_if = "constraint_is_any")]
148 pub model_family: Constraint,
149 #[serde(default, skip_serializing_if = "constraint_is_any")]
151 pub harness: Constraint,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub modalities: Option<ModalitySet>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub posture: Option<Vec<Posture>>,
158}
159
160#[allow(clippy::trivially_copy_pass_by_ref)] fn constraint_is_any(c: &Constraint) -> bool {
162 matches!(c, Constraint::Any)
163}
164
165impl MatchExpr {
166 #[must_use]
169 pub fn any() -> Self {
170 Self::default()
171 }
172
173 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "kebab-case")]
197pub enum VariantBody {
198 Inline {
200 content_type: String,
202 data: String,
204 },
205 Media(MediaRef),
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct Variant {
212 #[serde(rename = "match")]
214 pub match_expr: MatchExpr,
215 pub body: VariantBody,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub revalidate_after_ms: Option<u64>,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "kebab-case")]
227pub enum Disposition {
228 Active,
230 Expired,
232 Revoked {
234 at: Timestamp,
236 },
237 Superseded {
239 by: Token,
241 },
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct AttributionManifest {
247 pub schema: u16,
249 pub token: Token,
251 pub target: CanonicalUrl,
253 pub sharer: Sharer,
255 pub channel: Channel,
257 pub minted_at: Timestamp,
259 #[serde(default, skip_serializing_if = "meta_is_empty")]
261 pub meta: TargetMeta,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub parent: Option<Token>,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub content: Option<MediaRef>,
271 pub variants: Vec<Variant>,
273 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
277 pub private: bool,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub contract: Option<crate::Contract>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub outline: Option<MediaRef>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub signature: Option<SignatureBlock>,
295 pub version: u32,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub campaign: Option<String>,
300 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
302 pub labels: BTreeMap<String, String>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub expires_at: Option<Timestamp>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub revoked_at: Option<Timestamp>,
309 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322pub struct SignatureBlock {
323 pub alg: String,
325 pub key: String,
327 pub sig: String,
329}
330
331pub 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 #[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}