use std::collections::BTreeMap;
use schemars::JsonSchema;
use schemars::r#gen::SchemaGenerator;
use schemars::schema::{
InstanceType, Metadata as SchemaMetadata, Schema, SchemaObject, SubschemaValidation,
};
use serde::de::Error as DeError;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::json;
use crate::api_version::ApiVersion;
use crate::card::agent::AgentSpec;
use crate::card::artifact::ArtifactSpec;
use crate::card::audit::AuditSpec;
use crate::card::data::DataSpec;
use crate::card::experiment::ExperimentSpec;
use crate::card::mcp::McpSpec;
use crate::card::model::ModelSpec;
use crate::card::operator::OperatorSpec;
use crate::card::policy::PolicySpec;
use crate::card::prompt::PromptSpec;
use crate::card::service::ServiceSpec;
use crate::card::source::SourceSpec;
use crate::card::trigger::TriggerSpec;
use crate::card::verifier::{VerificationStatus, VerifierSpec};
use crate::ids::{CardName, CardUid, SpaceName};
use crate::metadata::{Annotations, Labels};
use wyrd_semver::{VersionBlock, VersionBump, VersionSpec};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(transparent)]
pub struct SpecHash(String);
impl SpecHash {
#[must_use]
pub fn from_canonical_bytes(bytes: &[u8]) -> Self {
let digest = blake3::hash(bytes);
Self(hex::encode(digest.as_bytes()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_inner(self) -> String {
self.0
}
}
impl std::fmt::Display for SpecHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::str::FromStr for SpecHash {
type Err = SpecHashParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.len() != 64 {
return Err(SpecHashParseError::InvalidLength { len: s.len() });
}
if let Some(pos) = s
.bytes()
.position(|b| !matches!(b, b'0'..=b'9' | b'a'..=b'f'))
{
return Err(SpecHashParseError::InvalidChar { pos });
}
Ok(Self(s.to_owned()))
}
}
#[derive(Debug, thiserror::Error)]
pub enum SpecHashParseError {
#[error("spec_hash must be 64 lowercase hex chars; got {len}")]
InvalidLength {
len: usize,
},
#[error("spec_hash contains non-hex char at position {pos}")]
InvalidChar {
pos: usize,
},
}
#[derive(Debug, thiserror::Error)]
#[error("spec decode failed for kind {kind}: {message}")]
pub struct SpecDecodeError {
pub kind: String,
pub message: String,
}
#[derive(Debug, thiserror::Error)]
pub enum SpecCanonicalizationError {
#[error("spec serialization failed: {0}")]
Serialize(#[source] serde_json::Error),
}
#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct Card {
#[serde(rename = "apiVersion")]
pub api_version: ApiVersion,
pub kind: CardKind,
pub metadata: Metadata,
pub spec: Spec,
#[serde(default)]
pub relationships: Relationships,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<Status>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct Metadata {
pub name: CardName,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<VersionSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bump: Option<VersionBump>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub space: Option<SpaceName>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<CardUid>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: Labels,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub annotations: Annotations,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec_hash: Option<SpecHash>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<crate::origin::Origin>,
}
impl Metadata {
#[must_use]
pub fn resolved_pin(&self) -> Option<&VersionBlock> {
match &self.version {
Some(VersionSpec::Pin(block)) => Some(block),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[allow(
clippy::large_enum_variant,
reason = "public Card contract used by every kind; boxing variants would break call sites throughout the codebase and defeats the flat pattern-match shape that this contract exposes to consumers"
)]
pub enum Spec {
Data(DataSpec),
Model(ModelSpec),
Experiment(ExperimentSpec),
Prompt(PromptSpec),
Agent(AgentSpec),
Verifier(VerifierSpec),
Service(ServiceSpec),
Policy(PolicySpec),
Mcp(McpSpec),
Audit(AuditSpec),
Artifact(ArtifactSpec),
Trigger(TriggerSpec),
Operator(OperatorSpec),
Source(SourceSpec),
}
#[cfg(feature = "server")]
impl utoipa::PartialSchema for Spec {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::schema::OneOfBuilder::new()
.item(<DataSpec as utoipa::PartialSchema>::schema())
.item(<ModelSpec as utoipa::PartialSchema>::schema())
.item(<ExperimentSpec as utoipa::PartialSchema>::schema())
.item(<PromptSpec as utoipa::PartialSchema>::schema())
.item(<AgentSpec as utoipa::PartialSchema>::schema())
.item(<VerifierSpec as utoipa::PartialSchema>::schema())
.item(<ServiceSpec as utoipa::PartialSchema>::schema())
.item(<PolicySpec as utoipa::PartialSchema>::schema())
.item(<McpSpec as utoipa::PartialSchema>::schema())
.item(<AuditSpec as utoipa::PartialSchema>::schema())
.item(<ArtifactSpec as utoipa::PartialSchema>::schema())
.item(<TriggerSpec as utoipa::PartialSchema>::schema())
.item(<OperatorSpec as utoipa::PartialSchema>::schema())
.item(<SourceSpec as utoipa::PartialSchema>::schema())
.title(Some("Spec"))
.description(Some("One typed Wyrd Card spec variant."))
.into()
}
}
#[cfg(feature = "server")]
impl utoipa::ToSchema for Spec {
fn name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("Spec")
}
fn schemas(
schemas: &mut Vec<(
String,
utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
)>,
) {
macro_rules! add_schema {
($schema:ty) => {
schemas.push((
<$schema as utoipa::ToSchema>::name().into(),
<$schema as utoipa::PartialSchema>::schema(),
));
};
}
add_schema!(DataSpec);
add_schema!(ModelSpec);
add_schema!(ExperimentSpec);
add_schema!(PromptSpec);
add_schema!(AgentSpec);
add_schema!(VerifierSpec);
add_schema!(ServiceSpec);
add_schema!(PolicySpec);
add_schema!(McpSpec);
add_schema!(AuditSpec);
add_schema!(ArtifactSpec);
add_schema!(TriggerSpec);
add_schema!(OperatorSpec);
add_schema!(SourceSpec);
}
}
impl schemars::JsonSchema for Spec {
fn schema_name() -> String {
"Spec".to_owned()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let variants = vec![
generator.subschema_for::<DataSpec>(),
generator.subschema_for::<ModelSpec>(),
generator.subschema_for::<ExperimentSpec>(),
generator.subschema_for::<PromptSpec>(),
generator.subschema_for::<AgentSpec>(),
generator.subschema_for::<VerifierSpec>(),
generator.subschema_for::<ServiceSpec>(),
generator.subschema_for::<PolicySpec>(),
generator.subschema_for::<McpSpec>(),
generator.subschema_for::<AuditSpec>(),
generator.subschema_for::<ArtifactSpec>(),
generator.subschema_for::<TriggerSpec>(),
generator.subschema_for::<OperatorSpec>(),
generator.subschema_for::<SourceSpec>(),
];
SchemaObject {
metadata: Some(Box::new(SchemaMetadata {
title: Some(Self::schema_name()),
description: Some("One typed Wyrd Card spec variant.".to_owned()),
..SchemaMetadata::default()
})),
subschemas: Some(Box::new(SubschemaValidation {
one_of: Some(variants),
..SubschemaValidation::default()
})),
..SchemaObject::default()
}
.into()
}
}
impl Spec {
#[must_use]
pub const fn kind(&self) -> CardKind {
match self {
Self::Data(_) => CardKind::Data,
Self::Model(_) => CardKind::Model,
Self::Experiment(_) => CardKind::Experiment,
Self::Prompt(_) => CardKind::Prompt,
Self::Agent(_) => CardKind::Agent,
Self::Verifier(_) => CardKind::Verifier,
Self::Service(_) => CardKind::Service,
Self::Policy(_) => CardKind::Policy,
Self::Mcp(_) => CardKind::Mcp,
Self::Audit(_) => CardKind::Audit,
Self::Artifact(_) => CardKind::Artifact,
Self::Trigger(_) => CardKind::Trigger,
Self::Operator(_) => CardKind::Operator,
Self::Source(_) => CardKind::Source,
}
}
pub fn canonical_hash(&self) -> Result<SpecHash, SpecCanonicalizationError> {
let canon = self.canonical_bytes()?;
Ok(SpecHash::from_canonical_bytes(&canon))
}
pub fn canonical_hash_with_bytes(
&self,
) -> Result<(SpecHash, Vec<u8>), SpecCanonicalizationError> {
let canon = self.canonical_bytes()?;
let hash = SpecHash::from_canonical_bytes(&canon);
Ok((hash, canon))
}
pub fn from_kind_and_value(
kind: &CardKind,
value: serde_json::Value,
) -> Result<Self, SpecDecodeError> {
spec_from_kind_value(kind, value).map_err(|message| SpecDecodeError {
kind: kind.wire_name().to_owned(),
message,
})
}
pub fn canonical_bytes(&self) -> Result<Vec<u8>, SpecCanonicalizationError> {
let value = serde_json::to_value(self).map_err(SpecCanonicalizationError::Serialize)?;
serde_jcs::to_vec(&value).map_err(SpecCanonicalizationError::Serialize)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct Relationships {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub outbound: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub outbound_refs: Vec<CardRelationship>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inbound: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inbound_refs: Vec<CardRelationship>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct CardRelationship {
#[serde(rename = "ref")]
pub card_ref: crate::reference::CardRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct Status {
pub phase: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification: Option<VerificationStatus>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub enum CardKind {
Data,
Model,
Experiment,
Prompt,
Agent,
Verifier,
Service,
Policy,
Mcp,
Audit,
Artifact,
Trigger,
Operator,
Source,
External,
}
impl CardKind {
pub const NATIVE_COUNT: usize = 15;
pub const REGISTRABLE_COUNT: usize = 14;
#[must_use]
pub fn native() -> [Self; Self::NATIVE_COUNT] {
[
Self::Data,
Self::Model,
Self::Experiment,
Self::Prompt,
Self::Agent,
Self::Verifier,
Self::Service,
Self::Policy,
Self::Mcp,
Self::Audit,
Self::Artifact,
Self::Trigger,
Self::Operator,
Self::Source,
Self::External,
]
}
#[must_use]
pub fn registrable() -> [Self; Self::REGISTRABLE_COUNT] {
[
Self::Data,
Self::Model,
Self::Experiment,
Self::Prompt,
Self::Agent,
Self::Verifier,
Self::Service,
Self::Policy,
Self::Mcp,
Self::Audit,
Self::Artifact,
Self::Trigger,
Self::Operator,
Self::Source,
]
}
#[must_use]
pub fn native_name(&self) -> Option<&'static str> {
Some(match self {
Self::Data => "Data",
Self::Model => "Model",
Self::Experiment => "Experiment",
Self::Prompt => "Prompt",
Self::Agent => "Agent",
Self::Verifier => "Verifier",
Self::Service => "Service",
Self::Policy => "Policy",
Self::Mcp => "Mcp",
Self::Audit => "Audit",
Self::Artifact => "Artifact",
Self::Trigger => "Trigger",
Self::Operator => "Operator",
Self::Source => "Source",
Self::External => "External",
})
}
pub fn from_wire_name(value: &str) -> Option<Self> {
native_from_str(value)
}
#[must_use]
pub fn wire_name(&self) -> &'static str {
match self {
Self::Data => "Data",
Self::Model => "Model",
Self::Experiment => "Experiment",
Self::Prompt => "Prompt",
Self::Agent => "Agent",
Self::Verifier => "Verifier",
Self::Service => "Service",
Self::Policy => "Policy",
Self::Mcp => "Mcp",
Self::Audit => "Audit",
Self::Artifact => "Artifact",
Self::Trigger => "Trigger",
Self::Operator => "Operator",
Self::Source => "Source",
Self::External => "External",
}
}
#[must_use]
pub const fn is_observation_target(&self) -> bool {
match self {
Self::Data
| Self::Model
| Self::Experiment
| Self::Prompt
| Self::Agent
| Self::Service
| Self::Mcp
| Self::Artifact
| Self::Source => true,
Self::Policy
| Self::Audit
| Self::Operator
| Self::Trigger
| Self::Verifier
| Self::External => false,
}
}
}
impl Serialize for CardKind {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.wire_name())
}
}
impl<'de> Deserialize<'de> for CardKind {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
native_from_str(&s).ok_or_else(|| D::Error::custom(format!("unknown card kind: {s}")))
}
}
fn native_from_str(value: &str) -> Option<CardKind> {
Some(match value {
"Data" => CardKind::Data,
"Model" => CardKind::Model,
"Experiment" => CardKind::Experiment,
"Prompt" => CardKind::Prompt,
"Agent" => CardKind::Agent,
"Verifier" => CardKind::Verifier,
"Service" => CardKind::Service,
"Policy" => CardKind::Policy,
"Mcp" => CardKind::Mcp,
"Audit" => CardKind::Audit,
"Artifact" => CardKind::Artifact,
"Trigger" => CardKind::Trigger,
"Operator" => CardKind::Operator,
"Source" => CardKind::Source,
"External" => CardKind::External,
_ => return None,
})
}
impl JsonSchema for CardKind {
fn schema_name() -> String {
"CardKind".to_string()
}
fn json_schema(_gen: &mut SchemaGenerator) -> Schema {
let enum_values = Self::registrable()
.iter()
.map(|kind| json!(kind.wire_name()))
.collect();
SchemaObject {
metadata: Some(Box::new(SchemaMetadata {
title: Some(Self::schema_name()),
description: Some("Wyrd Card kind.".to_string()),
..SchemaMetadata::default()
})),
instance_type: Some(InstanceType::String.into()),
enum_values: Some(enum_values),
..SchemaObject::default()
}
.into()
}
}
impl Serialize for Spec {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Data(s) => s.serialize(serializer),
Self::Model(s) => s.serialize(serializer),
Self::Experiment(s) => s.serialize(serializer),
Self::Prompt(s) => s.serialize(serializer),
Self::Agent(s) => s.serialize(serializer),
Self::Verifier(s) => s.serialize(serializer),
Self::Service(s) => s.serialize(serializer),
Self::Policy(s) => s.serialize(serializer),
Self::Mcp(s) => s.serialize(serializer),
Self::Audit(s) => s.serialize(serializer),
Self::Artifact(s) => s.serialize(serializer),
Self::Trigger(s) => s.serialize(serializer),
Self::Operator(s) => s.serialize(serializer),
Self::Source(s) => s.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for Card {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct CardHelper {
#[serde(rename = "apiVersion", default)]
api_version: ApiVersion,
kind: CardKind,
metadata: Metadata,
spec: serde_json::Value,
#[serde(default)]
relationships: Relationships,
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option<Status>,
}
let helper = CardHelper::deserialize(deserializer)?;
if helper.api_version.as_str() != ApiVersion::V1 {
return Err(D::Error::custom(format!(
"expected apiVersion {}, got {}",
ApiVersion::V1,
helper.api_version
)));
}
let spec =
spec_from_kind_value(&helper.kind, helper.spec).map_err(serde::de::Error::custom)?;
Ok(Card {
api_version: helper.api_version,
kind: helper.kind,
metadata: helper.metadata,
spec,
relationships: helper.relationships,
status: helper.status,
})
}
}
fn spec_from_kind_value(kind: &CardKind, mut value: serde_json::Value) -> Result<Spec, String> {
if let serde_json::Value::Object(ref mut map) = value {
map.remove("type");
}
match kind {
CardKind::Data => serde_json::from_value(value)
.map(Spec::Data)
.map_err(|e| e.to_string()),
CardKind::Model => serde_json::from_value(value)
.map(Spec::Model)
.map_err(|e| e.to_string()),
CardKind::Experiment => serde_json::from_value(value)
.map(Spec::Experiment)
.map_err(|e| e.to_string()),
CardKind::Prompt => serde_json::from_value(value)
.map(Spec::Prompt)
.map_err(|e| e.to_string()),
CardKind::Agent => serde_json::from_value(value)
.map(Spec::Agent)
.map_err(|e| e.to_string()),
CardKind::Verifier => serde_json::from_value(value)
.map(Spec::Verifier)
.map_err(|e| e.to_string()),
CardKind::Service => serde_json::from_value(value)
.map(Spec::Service)
.map_err(|e| e.to_string()),
CardKind::Policy => serde_json::from_value(value)
.map(Spec::Policy)
.map_err(|e| e.to_string()),
CardKind::Mcp => serde_json::from_value(value)
.map(Spec::Mcp)
.map_err(|e| e.to_string()),
CardKind::Audit => serde_json::from_value(value)
.map(Spec::Audit)
.map_err(|e| e.to_string()),
CardKind::Artifact => serde_json::from_value(value)
.map(Spec::Artifact)
.map_err(|e| e.to_string()),
CardKind::Trigger => serde_json::from_value(value)
.map(Spec::Trigger)
.map_err(|e| e.to_string()),
CardKind::Operator => serde_json::from_value(value)
.map(Spec::Operator)
.map_err(|e| e.to_string()),
CardKind::Source => serde_json::from_value(value)
.map(Spec::Source)
.map_err(|e| e.to_string()),
CardKind::External => {
Err("External is a forward-compatibility variant; use a named card kind".to_string())
}
}
}