use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::card::verifier::VerifierImplementation;
use crate::error::WyrdError;
use crate::ids::{
BindingId, CardUid, ConnectionName, OperatorDispatchId, VerificationResultId, VerificationRunId,
};
use crate::operator_connection::{
HttpAuthScheme, HttpsOrigin, OperatorConnectionConfig, OperatorProvider,
};
use crate::verification::VerificationVerdict;
pub const MAX_ATTEMPT_SECONDS: u32 = 30;
pub const MAX_SUMMARY_CHARS: usize = 512;
pub const FORBIDDEN_HTTP_HEADERS: [&str; 6] = [
"authorization",
"host",
"content-length",
"transfer-encoding",
"connection",
"idempotency-key",
];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct OperatorSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(flatten)]
pub action: OperatorAction,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budget: Option<OperatorBudget>,
}
impl OperatorSpec {
pub fn validate(
&self,
field: &str,
implementation: Option<&VerifierImplementation>,
) -> Result<(), WyrdError> {
let invalid = |at: &str, reason: String| WyrdError::SpecInvalidOperator {
message: format!("{field}{at}: {reason}"),
details: serde_json::json!({ "field": format!("{field}{at}"), "reason": reason }),
};
let lookup = |name: &str| field_applies(name, implementation).then(String::new);
let template = |at: &str, value: &str| {
render_template(value, lookup).map_err(|reason| invalid(at, reason))
};
match &self.action {
OperatorAction::Notify {
channel:
NotifyChannel::Slack {
channel_id, text, ..
},
} => {
if channel_id.trim().is_empty() {
return Err(invalid(
".channel.channel_id",
"must not be empty".to_owned(),
));
}
template(".channel.text", text).map(drop)
}
OperatorAction::Notify {
channel:
NotifyChannel::PagerDuty {
route,
summary,
dedup_key,
..
},
} => {
if route.trim().is_empty() {
return Err(invalid(".channel.route", "must not be empty".to_owned()));
}
template(".channel.summary", summary)?;
dedup_key
.as_deref()
.map_or(Ok(()), |key| template(".channel.dedup_key", key).map(drop))
}
OperatorAction::Http {
url,
headers,
body,
auth,
expect_status,
..
} => {
template(".url", url)?;
if !url.starts_with('/') {
operator_url_origin(url).map_err(|reason| invalid(".url", reason))?;
} else if auth.is_none() {
return Err(invalid(
".url",
"a path-only URL requires auth naming an http connection".to_owned(),
));
}
for (name, value) in headers {
let at = format!(".headers.{name}");
check_header_name(name).map_err(|reason| invalid(&at, reason))?;
template(&at, value)?;
}
if let Some(HttpAuth::Header { name, .. }) = auth {
check_header_name(name).map_err(|reason| invalid(".auth.name", reason))?;
if headers.keys().any(|h| h.eq_ignore_ascii_case(name)) {
return Err(invalid(
".auth.name",
format!("header {name} is also authored in headers"),
));
}
}
if let Some(body) = body {
check_body_templates(body, &lookup)
.map_err(|reason| invalid(".body", reason))?;
}
match expect_status {
Some(status) if !(100..=599).contains(status) => Err(invalid(
".expect_status",
format!("{status} is not an HTTP status"),
)),
_ => Ok(()),
}
}
}
}
}
impl OperatorSpec {
#[must_use]
pub fn connection(&self) -> Option<(OperatorProvider, &ConnectionName)> {
match &self.action {
OperatorAction::Notify {
channel: NotifyChannel::Slack { connection, .. },
} => Some((OperatorProvider::Slack, connection)),
OperatorAction::Notify {
channel: NotifyChannel::PagerDuty { connection, .. },
} => Some((OperatorProvider::PagerDuty, connection)),
OperatorAction::Http {
auth: Some(auth), ..
} => Some((OperatorProvider::Http, auth.connection())),
OperatorAction::Http { auth: None, .. } => None,
}
}
#[must_use]
pub fn matches_authority(&self, config: &OperatorConnectionConfig) -> bool {
match (&self.action, config) {
(
OperatorAction::Notify {
channel: NotifyChannel::Slack { .. },
},
OperatorConnectionConfig::Slack { .. },
)
| (
OperatorAction::Notify {
channel: NotifyChannel::PagerDuty { .. },
},
OperatorConnectionConfig::PagerDuty {},
) => true,
(
OperatorAction::Http {
url,
auth: Some(auth),
..
},
OperatorConnectionConfig::Http {
origin,
auth: scheme,
},
) => {
let same_scheme = match (auth, scheme) {
(HttpAuth::Bearer { .. }, HttpAuthScheme::Bearer)
| (HttpAuth::Basic { .. }, HttpAuthScheme::Basic) => true,
(HttpAuth::Header { name, .. }, HttpAuthScheme::Header { name: stored }) => {
name.eq_ignore_ascii_case(stored)
}
_ => false,
};
same_scheme
&& (url.starts_with('/')
|| operator_url_origin(url).is_ok_and(|url| url == *origin))
}
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum OperatorAction {
Notify {
channel: NotifyChannel,
},
Http {
method: HttpMethod,
url: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
auth: Option<HttpAuth>,
#[serde(default, skip_serializing_if = "Option::is_none")]
timeout_seconds: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
expect_status: Option<u16>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum NotifyChannel {
PagerDuty {
connection: ConnectionName,
route: String,
severity: PagerDutySeverity,
summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
dedup_key: Option<String>,
},
Slack {
connection: ConnectionName,
channel_id: String,
text: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum PagerDutySeverity {
Critical,
Error,
Warning,
Info,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum HttpMethod {
Get,
Post,
Put,
Patch,
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "scheme", rename_all = "snake_case", deny_unknown_fields)]
pub enum HttpAuth {
Bearer {
connection: ConnectionName,
},
Basic {
connection: ConnectionName,
},
Header {
name: String,
connection: ConnectionName,
},
}
impl HttpAuth {
#[must_use]
pub const fn connection(&self) -> &ConnectionName {
match self {
Self::Bearer { connection }
| Self::Basic { connection }
| Self::Header { connection, .. } => connection,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct OperatorBudget {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_wall_seconds: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tool_calls: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct OperatorFailureContext {
pub dispatch_id: OperatorDispatchId,
pub run_id: VerificationRunId,
pub result_id: VerificationResultId,
pub binding_id: BindingId,
pub verifier_card_uid: CardUid,
pub verifier_ref: String,
pub subject_uid: CardUid,
pub subject_ref: String,
pub verdict: VerificationVerdict,
pub completed_at: DateTime<Utc>,
pub summary: String,
pub verifier: VerifierCounts,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "implementation", rename_all = "snake_case", deny_unknown_fields)]
pub enum VerifierCounts {
Drift {
drifted_features: u32,
total_features: u32,
},
Eval {
passed_tasks: u32,
failed_tasks: u32,
total_tasks: u32,
pass_rate_percent: u32,
},
}
impl VerifierCounts {
#[must_use]
pub fn eval(passed: u32, total: u32) -> Self {
let passed = passed.min(total);
let pass_rate_percent = if total == 0 {
0
} else {
u32::try_from(u64::from(passed) * 100 / u64::from(total))
.expect("invariant: a rate of at most 100 fits u32")
};
Self::Eval {
passed_tasks: passed,
failed_tasks: total - passed,
total_tasks: total,
pass_rate_percent,
}
}
fn field(self, name: &str) -> Option<u32> {
match (self, name) {
(
Self::Drift {
drifted_features, ..
},
"drift.drifted_features",
) => Some(drifted_features),
(Self::Drift { total_features, .. }, "drift.total_features") => Some(total_features),
(Self::Eval { passed_tasks, .. }, "eval.passed_tasks") => Some(passed_tasks),
(Self::Eval { failed_tasks, .. }, "eval.failed_tasks") => Some(failed_tasks),
(Self::Eval { total_tasks, .. }, "eval.total_tasks") => Some(total_tasks),
(
Self::Eval {
pass_rate_percent, ..
},
"eval.pass_rate_percent",
) => Some(pass_rate_percent),
_ => None,
}
}
}
fn field_applies(name: &str, implementation: Option<&VerifierImplementation>) -> bool {
!matches!(
(name.split_once('.').map(|(kind, _)| kind), implementation),
(Some("drift"), Some(VerifierImplementation::Eval(_)))
| (Some("eval"), Some(VerifierImplementation::Drift(_)))
| (
Some("drift" | "eval"),
Some(VerifierImplementation::Task(_))
)
)
}
impl OperatorFailureContext {
pub const FIELDS: [&'static str; 17] = [
"dispatch_id",
"run_id",
"result_id",
"binding_id",
"verifier_card_uid",
"verifier_ref",
"subject_uid",
"subject_ref",
"verdict",
"completed_at",
"summary",
"drift.drifted_features",
"drift.total_features",
"eval.passed_tasks",
"eval.failed_tasks",
"eval.total_tasks",
"eval.pass_rate_percent",
];
#[must_use]
pub fn field(&self, name: &str) -> Option<String> {
Some(match name {
"dispatch_id" => self.dispatch_id.to_string(),
"run_id" => self.run_id.to_string(),
"result_id" => self.result_id.to_string(),
"binding_id" => self.binding_id.to_string(),
"verifier_card_uid" => self.verifier_card_uid.to_string(),
"verifier_ref" => self.verifier_ref.clone(),
"subject_uid" => self.subject_uid.to_string(),
"subject_ref" => self.subject_ref.clone(),
"verdict" => <&'static str>::from(self.verdict).to_owned(),
"completed_at" => self.completed_at.to_rfc3339(),
"summary" => self.summary.clone(),
_ => return self.verifier.field(name).map(|count| count.to_string()),
})
}
pub fn render(&self, template: &str) -> Result<String, String> {
render_template(template, |name| self.field(name))
}
}
pub fn render_template(
template: &str,
lookup: impl Fn(&str) -> Option<String>,
) -> Result<String, String> {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(start) = rest.find("{{") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let end = after
.find("}}")
.ok_or_else(|| "unterminated {{ placeholder".to_owned())?;
let name = after[..end].trim();
if !OperatorFailureContext::FIELDS.contains(&name) {
return Err(format!(
"unknown template field {name:?}; allowed: {}",
OperatorFailureContext::FIELDS.join(", ")
));
}
out.push_str(&lookup(name).ok_or_else(|| {
format!("template field {name:?} does not apply to this Verifier implementation")
})?);
rest = &after[end + 2..];
}
out.push_str(rest);
Ok(out)
}
pub fn operator_url_origin(url: &str) -> Result<HttpsOrigin, String> {
let authority_start = url.find("://").map_or(0, |i| i + 3);
let origin_end = url[authority_start..]
.find(['/', '?', '#'])
.map_or(url.len(), |i| authority_start + i);
let origin = &url[..origin_end];
if origin.contains("{{") {
return Err("templates may appear only in the URL path and query".to_owned());
}
HttpsOrigin::parse(origin).map_err(|error| error.to_string())
}
fn check_header_name(name: &str) -> Result<(), String> {
let valid = !name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b));
if !valid {
return Err(format!("{name:?} is not a valid header name"));
}
if FORBIDDEN_HTTP_HEADERS
.iter()
.any(|forbidden| name.eq_ignore_ascii_case(forbidden))
{
return Err(format!("header {name} is owned by the server"));
}
Ok(())
}
fn check_body_templates(
value: &Value,
lookup: &impl Fn(&str) -> Option<String>,
) -> Result<(), String> {
match value {
Value::String(text) => render_template(text, lookup).map(drop),
Value::Array(items) => items
.iter()
.try_for_each(|item| check_body_templates(item, lookup)),
Value::Object(map) => map
.values()
.try_for_each(|item| check_body_templates(item, lookup)),
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(value: Value) -> OperatorSpec {
serde_json::from_value(value).expect("test_setup: operator spec parses")
}
fn http(url: &str, headers: Value, auth: Value) -> OperatorSpec {
let mut value = serde_json::json!({
"kind": "http", "method": "post", "url": url, "headers": headers,
"body": { "text": "{{ summary }}", "ids": ["{{run_id}}"] }
});
if !auth.is_null() {
value["auth"] = auth;
}
spec(value)
}
#[test]
fn templates_accept_only_the_closed_context_fields() {
let rendered = render_template("run {{run_id}} / {{ summary }}", |name| {
Some(name.to_uppercase())
})
.expect("known fields render");
assert_eq!(rendered, "run RUN_ID / SUMMARY");
assert!(render_template("{{secret}}", |_| Some(String::new())).is_err());
assert!(render_template("{{run_id", |_| Some(String::new())).is_err());
}
#[test]
fn eval_counts_round_the_pass_rate_down() {
assert_eq!(
VerifierCounts::eval(999, 1000),
VerifierCounts::Eval {
passed_tasks: 999,
failed_tasks: 1,
total_tasks: 1000,
pass_rate_percent: 99,
}
);
assert_eq!(
VerifierCounts::eval(0, 0),
VerifierCounts::Eval {
passed_tasks: 0,
failed_tasks: 0,
total_tasks: 0,
pass_rate_percent: 0,
}
);
}
#[test]
fn kind_fields_follow_the_verifier_implementation() {
let counts = VerifierCounts::Drift {
drifted_features: 3,
total_features: 20,
};
let rendered = render_template(
"{{drift.drifted_features}}/{{ drift.total_features }}",
|name| counts.field(name).map(|count| count.to_string()),
)
.expect("drift fields render");
assert_eq!(rendered, "3/20");
let refusal = render_template("{{eval.total_tasks}}", |name| {
counts.field(name).map(|count| count.to_string())
})
.expect_err("an eval field does not render from drift counts");
assert!(refusal.contains("does not apply"), "{refusal}");
let eval: VerifierImplementation =
serde_json::from_value(serde_json::json!({ "kind": "eval", "spec": { "tasks": {} } }))
.expect("test_setup: eval implementation parses");
let drift_hook = http(
"https://hooks.example.com/{{drift.total_features}}",
serde_json::json!({}),
Value::Null,
);
assert!(drift_hook.validate("spec", None).is_ok());
let error = drift_hook
.validate("spec", Some(&eval))
.expect_err("an eval binding refuses a drift field");
assert!(
error.to_string().contains("drift.total_features"),
"{error}"
);
let eval_hook = http(
"https://hooks.example.com/{{eval.pass_rate_percent}}",
serde_json::json!({}),
Value::Null,
);
assert!(eval_hook.validate("spec", Some(&eval)).is_ok());
}
#[test]
fn validate_refuses_each_shape_violation() {
let slack = spec(serde_json::json!({
"kind": "notify",
"channel": { "kind": "slack", "connection": "ops-slack", "channel_id": "C1", "text": "{{verifier_ref}} failed" }
}));
assert!(slack.validate("spec", None).is_ok());
let bad_slack = spec(serde_json::json!({
"kind": "notify",
"channel": { "kind": "slack", "connection": "ops-slack", "channel_id": "C1", "text": "{{env.TOKEN}}" }
}));
assert_eq!(
bad_slack
.validate("spec", None)
.expect_err("unknown field")
.code(),
"WYRD_SPEC_400_INVALID_OPERATOR"
);
let pager = spec(serde_json::json!({
"kind": "notify",
"channel": { "kind": "pager_duty", "connection": "pagerduty", "route": "team-a", "severity": "error", "summary": "{{summary}}" }
}));
assert!(pager.validate("spec", None).is_ok());
let auth =
serde_json::json!({ "scheme": "header", "name": "X-Api-Key", "connection": "hooks" });
assert!(
http(
"https://hooks.example.com/v1/{{run_id}}",
serde_json::json!({}),
auth.clone()
)
.validate("spec", None)
.is_ok()
);
for (url, headers, auth) in [
(
"https://{{run_id}}.example.com/",
serde_json::json!({}),
Value::Null,
),
(
"ftp://hooks.example.com/",
serde_json::json!({}),
Value::Null,
),
(
"http://hooks.example.com/",
serde_json::json!({}),
Value::Null,
),
(
"https://hooks.example.com/",
serde_json::json!({ "AUTHORIZATION": "x" }),
Value::Null,
),
(
"https://hooks.example.com/",
serde_json::json!({ "Idempotency-Key": "x" }),
Value::Null,
),
(
"https://hooks.example.com/",
serde_json::json!({ "x-api-key": "x" }),
auth.clone(),
),
(
"https://hooks.example.com/",
serde_json::json!({}),
serde_json::json!({ "scheme": "header", "name": "Host", "connection": "hooks" }),
),
] {
assert!(
http(url, headers, auth).validate("spec", None).is_err(),
"{url}"
);
}
}
#[test]
fn url_origin_is_literal_and_normalized() {
let origin =
operator_url_origin("https://Hooks.Example.com:443/a/{{run_id}}?q={{summary}}")
.expect("literal origin");
assert_eq!(origin.as_str(), "https://hooks.example.com");
assert!(operator_url_origin("https://user@hooks.example.com/").is_err());
}
#[test]
fn authority_matches_provider_origin_and_scheme() {
let header =
serde_json::json!({ "scheme": "header", "name": "x-api-key", "connection": "hooks" });
let op = http(
"https://Hooks.Example.com:443/v1/{{run_id}}",
serde_json::json!({}),
header,
);
assert_eq!(
op.connection().map(|(p, n)| (p, n.as_str())),
Some((OperatorProvider::Http, "hooks"))
);
let stored = |origin: &str, auth: HttpAuthScheme| OperatorConnectionConfig::Http {
origin: HttpsOrigin::parse(origin).expect("origin"),
auth,
};
let header_scheme = || HttpAuthScheme::Header {
name: "X-Api-Key".to_owned(),
};
assert!(op.matches_authority(&stored("https://hooks.example.com", header_scheme())));
assert!(!op.matches_authority(&stored("https://hooks.example.com:8443", header_scheme())));
assert!(!op.matches_authority(&stored("https://other.example.com", header_scheme())));
assert!(
!op.matches_authority(&stored("https://hooks.example.com", HttpAuthScheme::Bearer))
);
assert!(!op.matches_authority(&OperatorConnectionConfig::PagerDuty {}));
let open = http(
"https://hooks.example.com/x",
serde_json::json!({}),
Value::Null,
);
assert!(open.connection().is_none());
}
#[test]
fn path_only_url_requires_a_named_http_connection() {
let auth = serde_json::json!({ "scheme": "bearer", "connection": "hooks" });
let named = http("/v1/{{run_id}}", serde_json::json!({}), auth);
assert!(named.validate("spec", None).is_ok());
assert!(named.matches_authority(&OperatorConnectionConfig::Http {
origin: HttpsOrigin::parse("https://staging.example.com").expect("origin"),
auth: HttpAuthScheme::Bearer,
}));
let anonymous = http("/v1/{{run_id}}", serde_json::json!({}), Value::Null);
assert_eq!(
anonymous
.validate("spec", None)
.expect_err("path-only URL without a connection")
.code(),
"WYRD_SPEC_400_INVALID_OPERATOR"
);
}
}