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 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
229 pub private: bool,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub signature: Option<SignatureBlock>,
234 pub version: u32,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub campaign: Option<String>,
239 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
241 pub labels: BTreeMap<String, String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub expires_at: Option<Timestamp>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub revoked_at: Option<Timestamp>,
248 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261pub struct SignatureBlock {
262 pub alg: String,
264 pub key: String,
266 pub sig: String,
268}
269
270pub 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 #[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}