use std::collections::BTreeSet;
use super::{GraphError, RootPick, identity_key, submission_card_ref};
use crate::card::agent::AgentSpec;
use crate::card::operator::OperatorSpec;
use crate::card::trigger::TriggerSpec;
use crate::card::verifier::{
OWNER_OCCURRENCE_KEY, TaskVerifierSpec, VerificationBinding, VerifierImplementation,
VerifierTask,
};
use crate::envelope::{CardKind, Spec};
use crate::error::WyrdError;
use crate::reference::InlineableRef;
use crate::registry::CardSubmission;
use crate::vala::eval::{EvalSpec, EvalTask};
pub fn validate_composition(
submissions: &[CardSubmission],
root: &RootPick,
) -> Result<(), GraphError> {
let decoded = submissions
.iter()
.map(|submission| {
Spec::from_kind_and_value(&submission.kind, submission.spec.clone()).map_err(|error| {
GraphError::InvalidSpec {
message: error.to_string(),
}
})
})
.collect::<Result<Vec<_>, _>>()?;
for (submission, spec) in submissions.iter().zip(&decoded) {
let Spec::Service(service) = spec else {
continue;
};
let service_ref = submission_card_ref(submission).ok_or(GraphError::Empty)?;
let mut aliases = BTreeSet::new();
for (index, component) in service.components.iter().enumerate() {
if component.alias == OWNER_OCCURRENCE_KEY {
return Err(GraphError::InvalidSpec {
message: format!(
"spec.components[{index}].alias {OWNER_OCCURRENCE_KEY:?} is reserved for the Service's own bindings"
),
});
}
if !aliases.insert(component.alias.as_str()) {
return Err(GraphError::InvalidSpec {
message: format!(
"spec.components[{index}].alias {:?} repeats an earlier component alias",
component.alias
),
});
}
let Some(component_ref) = component.card_ref.as_card_ref() else {
continue;
};
if is_peer_only_component(&component_ref.kind) {
return Err(GraphError::InvalidServiceComponentKind {
service: Box::new(service_ref),
alias: component.alias.clone(),
component: Box::new(component_ref.clone()),
field: format!("spec.components[{index}].ref"),
});
}
}
}
if root.root.kind != CardKind::Service {
return Ok(());
}
let bound = decoded
.iter()
.flat_map(Spec::binding_sites)
.filter(|site| !site.nested)
.flat_map(|site| site.bindings)
.filter_map(|binding| binding.verifier.as_card_ref())
.map(identity_key)
.collect::<BTreeSet<_>>();
for submission in submissions {
if submission.kind != CardKind::Verifier {
continue;
}
let peer = submission_card_ref(submission).ok_or(GraphError::Empty)?;
if !bound.contains(&identity_key(&peer)) {
return Err(GraphError::UnboundVerifierPeer {
root: Box::new(root.root.clone()),
peer: Box::new(peer),
});
}
}
Ok(())
}
#[must_use]
pub fn binding_validation_errors(bindings: &[VerificationBinding], field: &str) -> Vec<WyrdError> {
let mut seen_verifiers = BTreeSet::new();
let mut errors = Vec::new();
for (index, binding) in bindings.iter().enumerate() {
let binding_field = format!("{field}[{index}]");
if let Some(verifier) = binding.verifier.as_card_ref() {
if verifier.kind != CardKind::Verifier {
errors.push(WyrdError::SpecInvalidVerifierRefKind {
message: format!(
"{binding_field}.verifier {} has kind {}",
verifier.name,
verifier.kind.wire_name()
),
details: serde_json::json!({
"field": format!("{binding_field}.verifier"),
"target": verifier,
"expected_kind": "Verifier"
}),
});
}
if !seen_verifiers.insert(verifier.identity_key()) {
errors.push(WyrdError::SpecDuplicateVerificationBinding {
message: format!("duplicate {field} Verifier {verifier}"),
details: serde_json::json!({ "field": binding_field, "verifier": verifier }),
});
}
}
if let Some(runs_on) = &binding.runs_on {
check_runs_on(runs_on, &binding_field, &mut errors);
}
check_on_failure(&binding.on_failure, &binding_field, &mut errors);
}
errors
}
fn check_runs_on(
runs_on: &InlineableRef<TriggerSpec>,
binding_field: &str,
errors: &mut Vec<WyrdError>,
) {
let Some(trigger) = runs_on.as_card_ref() else {
return;
};
if trigger.kind != CardKind::Trigger {
errors.push(WyrdError::SpecInvalidBindingRefKind {
message: format!(
"{binding_field}.runs_on {} has kind {}",
trigger.name,
trigger.kind.wire_name()
),
details: serde_json::json!({
"field": format!("{binding_field}.runs_on"),
"target": trigger,
"expected_kind": "Trigger"
}),
});
}
}
fn check_on_failure(
on_failure: &[InlineableRef<OperatorSpec>],
binding_field: &str,
errors: &mut Vec<WyrdError>,
) {
let mut seen_operators = BTreeSet::new();
let mut seen_inline_operators: Vec<&OperatorSpec> = Vec::new();
for (index, operator) in on_failure.iter().enumerate() {
let operator_field = format!("{binding_field}.on_failure[{index}]");
match operator {
InlineableRef::Inline(spec) => {
if seen_inline_operators
.iter()
.any(|seen| *seen == spec.as_ref())
{
errors.push(WyrdError::SpecDuplicateBindingOperator {
message: format!(
"duplicate {binding_field}.on_failure inline Operator at {operator_field}"
),
details: serde_json::json!({
"field": operator_field,
"operator": spec
}),
});
continue;
}
seen_inline_operators.push(spec.as_ref());
}
InlineableRef::Ref(card_ref) | InlineableRef::Sibling { sibling: card_ref } => {
if card_ref.kind != CardKind::Operator {
errors.push(WyrdError::SpecInvalidBindingRefKind {
message: format!(
"{operator_field} {} has kind {}",
card_ref.name,
card_ref.kind.wire_name()
),
details: serde_json::json!({
"field": operator_field,
"target": card_ref,
"expected_kind": "Operator"
}),
});
continue;
}
if !seen_operators.insert(card_ref.identity_key()) {
errors.push(WyrdError::SpecDuplicateBindingOperator {
message: format!(
"duplicate {binding_field}.on_failure Operator {card_ref}"
),
details: serde_json::json!({
"field": operator_field,
"operator": card_ref
}),
});
}
}
InlineableRef::Path(_) => {}
}
}
}
pub struct BindingSite<'a> {
pub field: String,
pub bindings: &'a [VerificationBinding],
pub nested: bool,
}
impl Spec {
#[must_use]
pub fn binding_sites(&self) -> Vec<BindingSite<'_>> {
let mut sites = Vec::new();
match self {
Self::Agent(agent) => {
sites.push(BindingSite {
field: "spec.verified_by".to_owned(),
bindings: &agent.verified_by,
nested: false,
});
}
Self::Service(service) => {
sites.push(BindingSite {
field: "spec.verified_by".to_owned(),
bindings: &service.verified_by,
nested: false,
});
for (index, component) in service.components.iter().enumerate() {
sites.push(BindingSite {
field: format!("spec.components[{index}].verified_by"),
bindings: &component.verified_by,
nested: false,
});
}
}
Self::Verifier(verifier) => match &verifier.implementation {
VerifierImplementation::Eval(eval) => {
push_nested_judges(eval, "spec.implementation.spec", &mut sites);
}
VerifierImplementation::Task(TaskVerifierSpec {
definition: VerifierTask::LlmJudge(judge),
}) => {
if let InlineableRef::Inline(agent) = &judge.judge_ref {
push_nested_agent(agent, "spec.implementation.spec.judge_ref", &mut sites);
}
}
VerifierImplementation::Drift(_) | VerifierImplementation::Task(_) => {}
},
Self::Data(_)
| Self::Model(_)
| Self::Prompt(_)
| Self::Mcp(_)
| Self::Policy(_)
| Self::Audit(_)
| Self::Source(_)
| Self::Trigger(_)
| Self::Artifact(_)
| Self::Experiment(_)
| Self::Operator(_) => {}
}
sites
}
}
fn push_nested_agent<'a>(agent: &'a AgentSpec, prefix: &str, sites: &mut Vec<BindingSite<'a>>) {
sites.push(BindingSite {
field: format!("{prefix}.verified_by"),
bindings: &agent.verified_by,
nested: true,
});
}
fn push_nested_judges<'a>(eval: &'a EvalSpec, prefix: &str, sites: &mut Vec<BindingSite<'a>>) {
for (task_id, task) in &eval.tasks {
let EvalTask::LlmJudge(judge) = task else {
continue;
};
if let InlineableRef::Inline(agent) = &judge.judge_ref {
push_nested_agent(
agent,
&format!("{prefix}.tasks[{}].LlmJudge.judge_ref", task_id.as_str()),
sites,
);
}
}
}
#[must_use]
pub fn spec_binding_errors(spec: &Spec) -> Vec<WyrdError> {
spec.binding_sites()
.into_iter()
.flat_map(|site| {
if site.nested {
if site.bindings.is_empty() {
Vec::new()
} else {
vec![nested_binding_error(&site.field)]
}
} else {
binding_validation_errors(site.bindings, &site.field)
}
})
.collect()
}
fn nested_binding_error(field: &str) -> WyrdError {
WyrdError::registry_invalid_card_spec(format!(
"{field} is not a verification binding location; bind on a Service, \
a Service component occurrence, or a standalone Agent"
))
}
fn is_peer_only_component(kind: &CardKind) -> bool {
matches!(
kind,
CardKind::Verifier
| CardKind::Trigger
| CardKind::Operator
| CardKind::Audit
| CardKind::Source
)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use std::collections::BTreeMap;
use wyrd_semver::{VersionBlock, VersionSpec};
use super::{binding_validation_errors, spec_binding_errors, validate_composition};
use crate::api_version::ApiVersion;
use crate::card::verifier::{OWNER_OCCURRENCE_KEY, VerificationBinding};
use crate::envelope::Spec;
use crate::envelope::{CardKind, Metadata};
use crate::graph::{GraphError, RootPick};
use crate::reference::CardRef;
use crate::registry::CardSubmission;
fn card_ref(kind: CardKind, name: &str) -> CardRef {
CardRef {
kind,
name: name.parse().expect("test Card name is valid"),
version: VersionBlock::parse("1.0.0").expect("test version is valid"),
space: Some("default".parse().expect("test space is valid")),
uid: None,
}
}
fn submission(kind: CardKind, name: &str, spec: serde_json::Value) -> CardSubmission {
let card_ref = card_ref(kind.clone(), name);
CardSubmission {
api_version: ApiVersion::v1(),
kind,
metadata: Metadata {
name: card_ref.name,
version: Some(VersionSpec::Pin(card_ref.version)),
bump: None,
space: card_ref.space,
uid: None,
labels: BTreeMap::default(),
annotations: BTreeMap::default(),
spec_hash: None,
artifact_hash: None,
origin: None,
},
spec,
artifacts: Vec::new(),
}
}
fn service_spec() -> serde_json::Value {
json!({})
}
fn verifier_spec() -> serde_json::Value {
json!({ "implementation": { "kind": "eval", "spec": { "tasks": {} } } })
}
fn binding(verifier: &CardRef) -> serde_json::Value {
json!({ "verifier": verifier, "runs_on": { "kind": "observations_ready" } })
}
#[test]
fn rejects_duplicate_component_alias() {
let prompt = card_ref(CardKind::Prompt, "system");
let service = submission(
CardKind::Service,
"app",
json!({ "components": [
{ "alias": "retriever", "ref": prompt },
{ "alias": "retriever", "ref": prompt },
] }),
);
let root = RootPick {
root: card_ref(CardKind::Service, "app"),
};
let error = validate_composition(&[service], &root)
.expect_err("component aliases are unique within a Service");
assert!(matches!(
error,
GraphError::InvalidSpec { message } if message.contains("spec.components[1].alias")
));
}
#[test]
fn rejects_reserved_owner_occurrence_alias() {
let prompt = card_ref(CardKind::Prompt, "system");
let service = submission(
CardKind::Service,
"app",
json!({ "components": [{ "alias": OWNER_OCCURRENCE_KEY, "ref": prompt }] }),
);
let root = RootPick {
root: card_ref(CardKind::Service, "app"),
};
let error = validate_composition(&[service], &root)
.expect_err("the owner occurrence key is not a component alias");
assert!(matches!(
error,
GraphError::InvalidSpec { message } if message.contains("is reserved")
));
}
#[test]
fn rejects_peer_only_service_component() {
let verifier = card_ref(CardKind::Verifier, "quality");
let service = submission(
CardKind::Service,
"app",
json!({
"components": [{
"alias": "quality",
"ref": verifier,
}]
}),
);
let root = RootPick {
root: card_ref(CardKind::Service, "app"),
};
let error = validate_composition(&[service], &root)
.expect_err("Verifier cannot be a Service component");
assert!(matches!(
error,
GraphError::InvalidServiceComponentKind {
alias,
field,
component,
..
} if alias == "quality"
&& field == "spec.components[0].ref"
&& component.kind == CardKind::Verifier
));
}
#[test]
fn rejects_unbound_verifier_in_service_root_bundle() {
let service = submission(CardKind::Service, "app", service_spec());
let verifier = submission(CardKind::Verifier, "quality", verifier_spec());
let root = RootPick {
root: card_ref(CardKind::Service, "app"),
};
let error = validate_composition(&[service, verifier], &root)
.expect_err("Service-root Verifier requires a local binding");
assert!(matches!(
error,
GraphError::UnboundVerifierPeer { peer, .. }
if peer.kind == CardKind::Verifier && peer.name.as_str() == "quality"
));
}
#[test]
fn accepts_bound_verifier_in_service_root_bundle() {
let verifier_ref = card_ref(CardKind::Verifier, "quality");
let service = submission(
CardKind::Service,
"app",
json!({ "verified_by": [binding(&verifier_ref)] }),
);
let verifier = submission(CardKind::Verifier, "quality", verifier_spec());
let root = RootPick {
root: card_ref(CardKind::Service, "app"),
};
validate_composition(&[service, verifier], &root)
.expect("bound Verifier is a valid Service peer");
}
#[test]
fn accepts_component_bound_verifier_in_service_root_bundle() {
let verifier_ref = card_ref(CardKind::Verifier, "quality");
let model_ref = card_ref(CardKind::Model, "classifier");
let service = submission(
CardKind::Service,
"app",
json!({
"components": [{
"alias": "classifier",
"ref": model_ref,
"verified_by": [binding(&verifier_ref)],
}]
}),
);
let verifier = submission(CardKind::Verifier, "quality", verifier_spec());
let root = RootPick {
root: card_ref(CardKind::Service, "app"),
};
validate_composition(&[service, verifier], &root)
.expect("component-bound Verifier is a valid Service peer");
}
#[test]
fn accepts_standalone_verifier_submission() {
let verifier = submission(CardKind::Verifier, "quality", verifier_spec());
let root = RootPick {
root: card_ref(CardKind::Verifier, "quality"),
};
validate_composition(&[verifier], &root).expect("standalone Verifier remains valid");
}
#[test]
fn publication_validation_rejects_duplicate_targets() {
let verifier = card_ref(CardKind::Verifier, "quality");
let bindings: Vec<VerificationBinding> =
serde_json::from_value(json!([binding(&verifier), binding(&verifier)]))
.expect("binding fixtures decode");
let errors = binding_validation_errors(&bindings, "spec.verified_by");
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(
errors[0].code(),
"WYRD_SPEC_400_DUPLICATE_VERIFICATION_BINDING"
);
}
#[test]
fn binding_validation_rejects_non_verifier_verifier_ref() {
let bindings: Vec<VerificationBinding> =
serde_json::from_value(json!([binding(&card_ref(CardKind::Model, "classifier"))]))
.expect("binding fixture decodes");
let errors = binding_validation_errors(&bindings, "spec.verified_by");
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(errors[0].code(), "WYRD_SPEC_400_INVALID_VERIFIER_REF_KIND");
}
#[test]
fn binding_validation_rejects_non_trigger_runs_on_ref() {
let bindings: Vec<VerificationBinding> = serde_json::from_value(json!([{
"verifier": card_ref(CardKind::Verifier, "quality"),
"runs_on": card_ref(CardKind::Data, "hourly"),
}]))
.expect("binding fixture decodes");
let errors = binding_validation_errors(&bindings, "spec.verified_by");
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(errors[0].code(), "WYRD_SPEC_400_INVALID_BINDING_REF_KIND");
}
#[test]
fn binding_validation_rejects_non_operator_on_failure_ref() {
let bindings: Vec<VerificationBinding> = serde_json::from_value(json!([{
"verifier": card_ref(CardKind::Verifier, "quality"),
"runs_on": { "kind": "observations_ready" },
"on_failure": [card_ref(CardKind::Agent, "rollback")],
}]))
.expect("binding fixture decodes");
let errors = binding_validation_errors(&bindings, "spec.verified_by");
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(errors[0].code(), "WYRD_SPEC_400_INVALID_BINDING_REF_KIND");
}
#[test]
fn binding_validation_rejects_duplicate_on_failure_operator() {
let operator = card_ref(CardKind::Operator, "page");
let bindings: Vec<VerificationBinding> = serde_json::from_value(json!([{
"verifier": card_ref(CardKind::Verifier, "quality"),
"runs_on": { "kind": "observations_ready" },
"on_failure": [operator, operator],
}]))
.expect("binding fixture decodes");
let errors = binding_validation_errors(&bindings, "spec.verified_by");
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(errors[0].code(), "WYRD_SPEC_400_DUPLICATE_BINDING_OPERATOR");
}
#[test]
fn binding_validation_rejects_duplicate_verifier_differing_only_by_uid() {
let verifier = card_ref(CardKind::Verifier, "quality");
let mut uid_bearing = verifier.clone();
uid_bearing.uid = Some(
crate::ids::CardUid::new("01890f28-7c4a-7cc3-98e7-4f4a3c2d1b11")
.expect("test uid is valid"),
);
let bindings: Vec<VerificationBinding> =
serde_json::from_value(json!([binding(&verifier), binding(&uid_bearing)]))
.expect("binding fixtures decode");
let errors = binding_validation_errors(&bindings, "spec.verified_by");
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(
errors[0].code(),
"WYRD_SPEC_400_DUPLICATE_VERIFICATION_BINDING"
);
}
#[test]
fn binding_validation_rejects_duplicate_on_failure_operator_differing_only_by_uid() {
let operator = card_ref(CardKind::Operator, "page");
let mut uid_bearing = operator.clone();
uid_bearing.uid = Some(
crate::ids::CardUid::new("01890f28-7c4a-7cc3-98e7-4f4a3c2d1b12")
.expect("test uid is valid"),
);
let bindings: Vec<VerificationBinding> = serde_json::from_value(json!([{
"verifier": card_ref(CardKind::Verifier, "quality"),
"runs_on": { "kind": "observations_ready" },
"on_failure": [operator, uid_bearing],
}]))
.expect("binding fixture decodes");
let errors = binding_validation_errors(&bindings, "spec.verified_by");
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(errors[0].code(), "WYRD_SPEC_400_DUPLICATE_BINDING_OPERATOR");
}
#[test]
fn binding_validation_rejects_duplicate_inline_on_failure_operator() {
let inline = json!({
"kind": "notify",
"channel": {
"kind": "pager_duty",
"connection": "pagerduty",
"route": "team-a",
"severity": "critical",
"summary": "verification failed",
},
});
let bindings: Vec<VerificationBinding> = serde_json::from_value(json!([{
"verifier": card_ref(CardKind::Verifier, "quality"),
"runs_on": { "kind": "observations_ready" },
"on_failure": [inline, inline],
}]))
.expect("binding fixture decodes");
let errors = binding_validation_errors(&bindings, "spec.verified_by");
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(errors[0].code(), "WYRD_SPEC_400_DUPLICATE_BINDING_OPERATOR");
}
#[test]
fn spec_binding_errors_refuse_an_eval_judge_inline_agent_binding() {
let spec = Spec::from_kind_and_value(
&CardKind::Verifier,
json!({
"implementation": {
"kind": "eval",
"spec": {
"tasks": {
"helpful": {
"kind": "llm_judge",
"id": "helpful",
"judge_ref": {
"prompt": card_ref(CardKind::Prompt, "judge"),
"execution_route": {"kind": "native"},
"verified_by": [
binding(&card_ref(CardKind::Verifier, "quality"))
],
},
"operator": "greater_than_or_equals",
"expected": 0.5,
},
},
},
},
}),
)
.expect("the inline judge Agent body still decodes");
let errors = spec_binding_errors(&spec);
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(errors[0].code(), "WYRD_REGISTRY_400_INVALID_CARD_SPEC");
}
#[test]
fn spec_binding_errors_accept_an_inline_agent_without_bindings() {
let spec = Spec::from_kind_and_value(
&CardKind::Verifier,
json!({
"implementation": {
"kind": "eval",
"spec": {
"tasks": {
"helpful": {
"kind": "llm_judge",
"id": "helpful",
"judge_ref": {
"prompt": card_ref(CardKind::Prompt, "judge"),
"execution_route": {"kind": "native"},
},
"operator": "greater_than_or_equals",
"expected": 0.5,
},
},
},
},
}),
)
.expect("the inline judge Agent body decodes");
assert!(spec_binding_errors(&spec).is_empty());
}
}