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, Deserialize)]
27#[serde(transparent)]
28pub struct ModalitySet(u8);
29
30impl ModalitySet {
31 pub const TEXT: Self = Self(1);
33 pub const BROWSER: Self = Self(1 << 1);
35 pub const SHELL: Self = Self(1 << 2);
37 pub const VISION: Self = Self(1 << 3);
39 pub const AUDIO: Self = Self(1 << 4);
41
42 #[must_use]
44 pub const fn empty() -> Self {
45 Self(0)
46 }
47
48 #[must_use]
50 pub const fn with(self, other: Self) -> Self {
51 Self(self.0 | other.0)
52 }
53
54 #[must_use]
56 pub const fn contains(self, required: Self) -> bool {
57 self.0 & required.0 == required.0
58 }
59
60 #[must_use]
64 pub const fn from_bits_truncate(bits: u8) -> Self {
65 Self(bits & 0b1_1111)
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum Posture {
73 Attended,
75 Headless,
77 Ci,
79}
80
81#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "kebab-case")]
86pub enum Constraint {
87 #[default]
89 Any,
90 OneOf(Vec<String>),
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
97pub struct MatchExpr {
98 #[serde(default, skip_serializing_if = "constraint_is_any")]
100 pub model_family: Constraint,
101 #[serde(default, skip_serializing_if = "constraint_is_any")]
103 pub harness: Constraint,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub modalities: Option<ModalitySet>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub posture: Option<Vec<Posture>>,
110}
111
112#[allow(clippy::trivially_copy_pass_by_ref)] fn constraint_is_any(c: &Constraint) -> bool {
114 matches!(c, Constraint::Any)
115}
116
117impl MatchExpr {
118 #[must_use]
121 pub fn any() -> Self {
122 Self::default()
123 }
124
125 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "kebab-case")]
149pub enum VariantBody {
150 Inline {
152 content_type: String,
154 data: String,
156 },
157 Media(MediaRef),
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct Variant {
164 #[serde(rename = "match")]
166 pub match_expr: MatchExpr,
167 pub body: VariantBody,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub revalidate_after_ms: Option<u64>,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "kebab-case")]
179pub enum Disposition {
180 Active,
182 Expired,
184 Revoked {
186 at: Timestamp,
188 },
189 Superseded {
191 by: Token,
193 },
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct AttributionManifest {
199 pub schema: u16,
201 pub token: Token,
203 pub target: CanonicalUrl,
205 pub sharer: Sharer,
207 pub channel: Channel,
209 pub minted_at: Timestamp,
211 #[serde(default, skip_serializing_if = "meta_is_empty")]
213 pub meta: TargetMeta,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub parent: Option<Token>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub content: Option<MediaRef>,
223 pub variants: Vec<Variant>,
225 pub version: u32,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub campaign: Option<String>,
230 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
232 pub labels: BTreeMap<String, String>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub expires_at: Option<Timestamp>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub revoked_at: Option<Timestamp>,
239 #[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
248pub 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 #[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}