use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::slug::{Channel, Sharer};
use crate::target::{CanonicalUrl, MediaRef, TargetMeta};
use crate::time::Timestamp;
use crate::token::Token;
pub const MANIFEST_SCHEMA_VERSION: u16 = 1;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct ModalitySet(u8);
impl<'de> Deserialize<'de> for ModalitySet {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = ModalitySet;
fn expecting(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
f.write_str("modality bits (u8) or names ([\"text\", ...])")
}
fn visit_u64<E: serde::de::Error>(self, bits: u64) -> Result<ModalitySet, E> {
Ok(ModalitySet::from_bits_truncate(
u8::try_from(bits).map_err(E::custom)?,
))
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut seq: A,
) -> Result<ModalitySet, A::Error> {
let mut set = ModalitySet::empty();
while let Some(name) = seq.next_element::<String>()? {
set = set.with(ModalitySet::from_name(&name).ok_or_else(|| {
serde::de::Error::custom(format!(
"unknown modality `{name}` — text, browser, shell, vision, audio"
))
})?);
}
Ok(set)
}
}
deserializer.deserialize_any(V)
}
}
impl ModalitySet {
pub const TEXT: Self = Self(1);
pub const BROWSER: Self = Self(1 << 1);
pub const SHELL: Self = Self(1 << 2);
pub const VISION: Self = Self(1 << 3);
pub const AUDIO: Self = Self(1 << 4);
#[must_use]
pub const fn empty() -> Self {
Self(0)
}
#[must_use]
pub const fn with(self, other: Self) -> Self {
Self(self.0 | other.0)
}
#[must_use]
pub const fn contains(self, required: Self) -> bool {
self.0 & required.0 == required.0
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
match name {
"text" => Some(Self::TEXT),
"browser" => Some(Self::BROWSER),
"shell" => Some(Self::SHELL),
"vision" => Some(Self::VISION),
"audio" => Some(Self::AUDIO),
_ => None,
}
}
#[must_use]
pub const fn from_bits_truncate(bits: u8) -> Self {
Self(bits & 0b1_1111)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Posture {
Attended,
Headless,
Ci,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Constraint {
#[default]
Any,
OneOf(Vec<String>),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MatchExpr {
#[serde(default, skip_serializing_if = "constraint_is_any")]
pub model_family: Constraint,
#[serde(default, skip_serializing_if = "constraint_is_any")]
pub harness: Constraint,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modalities: Option<ModalitySet>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub posture: Option<Vec<Posture>>,
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn constraint_is_any(c: &Constraint) -> bool {
matches!(c, Constraint::Any)
}
impl MatchExpr {
#[must_use]
pub fn any() -> Self {
Self::default()
}
#[must_use]
pub fn is_catch_all(&self) -> bool {
matches!(self.model_family, Constraint::Any)
&& matches!(self.harness, Constraint::Any)
&& self.modalities.is_none()
&& self.posture.is_none()
}
#[must_use]
pub fn specificity(&self) -> u8 {
u8::from(!matches!(self.model_family, Constraint::Any))
+ u8::from(!matches!(self.harness, Constraint::Any))
+ u8::from(self.modalities.is_some())
+ u8::from(self.posture.is_some())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum VariantBody {
Inline {
content_type: String,
data: String,
},
Media(MediaRef),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Variant {
#[serde(rename = "match")]
pub match_expr: MatchExpr,
pub body: VariantBody,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revalidate_after_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Disposition {
Active,
Expired,
Revoked {
at: Timestamp,
},
Superseded {
by: Token,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AttributionManifest {
pub schema: u16,
pub token: Token,
pub target: CanonicalUrl,
pub sharer: Sharer,
pub channel: Channel,
pub minted_at: Timestamp,
#[serde(default, skip_serializing_if = "meta_is_empty")]
pub meta: TargetMeta,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<Token>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<MediaRef>,
pub variants: Vec<Variant>,
#[serde(default, skip_serializing_if = "core::ops::Not::not")]
pub private: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contract: Option<crate::Contract>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outline: Option<MediaRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<SignatureBlock>,
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub campaign: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<Timestamp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<Timestamp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<Token>,
}
fn meta_is_empty(m: &TargetMeta) -> bool {
*m == TargetMeta::default()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignatureBlock {
pub alg: String,
pub key: String,
pub sig: String,
}
pub fn apply_change(
manifest: &mut AttributionManifest,
change: &crate::log::Change,
at: Timestamp,
) {
use crate::log::Change;
match change {
Change::Revoked => {
manifest.revoked_at = Some(at);
manifest.version += 1;
}
Change::Superseded { by } => {
manifest.superseded_by = Some(*by);
manifest.version += 1;
}
Change::ExpirySet { expires_at } => {
manifest.expires_at = *expires_at;
manifest.version += 1;
}
Change::CampaignSet { campaign } => manifest.campaign.clone_from(campaign),
Change::LabelSet { key, value } => {
manifest.labels.insert(key.clone(), value.clone());
}
Change::LabelUnset { key } => {
manifest.labels.remove(key);
}
}
}
impl AttributionManifest {
#[must_use]
pub fn disposition(&self, now: Timestamp) -> Disposition {
if let Some(at) = self.revoked_at {
return Disposition::Revoked { at };
}
if let Some(by) = self.superseded_by {
return Disposition::Superseded { by };
}
match self.expires_at {
Some(exp) if exp <= now => Disposition::Expired,
_ => Disposition::Active,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modality_set_algebra() {
let ctx = ModalitySet::TEXT.with(ModalitySet::VISION);
assert!(ctx.contains(ModalitySet::VISION));
assert!(ctx.contains(ModalitySet::empty()));
assert!(!ctx.contains(ModalitySet::BROWSER));
assert!(!ctx.contains(ModalitySet::VISION.with(ModalitySet::AUDIO)));
}
#[test]
fn catch_all_and_specificity() {
assert!(MatchExpr::any().is_catch_all());
assert_eq!(MatchExpr::any().specificity(), 0);
let m = MatchExpr {
model_family: Constraint::OneOf(vec!["claude".into()]),
modalities: Some(ModalitySet::VISION),
..MatchExpr::default()
};
assert!(!m.is_catch_all());
assert_eq!(m.specificity(), 2);
}
#[test]
fn disposition_precedence() {
let now = Timestamp::from_unix_ms(10_000);
let mut entropy = |buf: &mut [u8]| {
buf.fill(7);
Ok(())
};
let m = crate::mint(
crate::MintSpec::new(
CanonicalUrl::new("file:///tmp/x.md").unwrap(),
Sharer::new("lead").unwrap(),
Channel::subagent_general(),
),
&crate::MintOptions::default(),
&mut entropy,
now,
)
.unwrap();
assert_eq!(m.disposition(now), Disposition::Active);
let mut expired = m.clone();
expired.expires_at = Some(now);
assert_eq!(expired.disposition(now), Disposition::Expired);
assert_eq!(
expired.disposition(Timestamp::from_unix_ms(9_999)),
Disposition::Active,
"expiry is exclusive of earlier instants"
);
let other = Token::parse("zzz").unwrap();
let mut superseded = expired.clone();
superseded.superseded_by = Some(other);
assert_eq!(
superseded.disposition(now),
Disposition::Superseded { by: other }
);
let mut revoked = superseded;
revoked.revoked_at = Some(now);
assert_eq!(
revoked.disposition(now),
Disposition::Revoked { at: now },
"revocation outranks supersession and expiry"
);
}
#[test]
fn manifest_serde_roundtrip() {
let now = Timestamp::from_unix_ms(1);
let mut entropy = |buf: &mut [u8]| {
buf.fill(9);
Ok(())
};
let m = crate::mint(
crate::MintSpec::new(
CanonicalUrl::new("ws://a/b").unwrap(),
Sharer::new("lead").unwrap(),
Channel::new("subagent/pricing").unwrap(),
)
.variant(
MatchExpr {
modalities: Some(ModalitySet::VISION),
..MatchExpr::default()
},
VariantBody::Inline {
content_type: "text/markdown".into(),
data: "see the chart".into(),
},
),
&crate::MintOptions::default(),
&mut entropy,
now,
)
.unwrap();
let json = serde_json::to_string(&m).unwrap();
let back: AttributionManifest = serde_json::from_str(&json).unwrap();
assert_eq!(back, m);
assert_eq!(back.schema, MANIFEST_SCHEMA_VERSION);
}
}