use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::Value;
static HASH_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^sha256:[0-9a-f]{64}$").unwrap());
const ROLES: &[&str] = &["user", "assistant", "tool", "system"];
const TOOL_STATUS: &[&str] = &["ok", "error"];
#[derive(Debug, Clone)]
pub struct VerifyFailure {
pub turn: usize,
pub reason: &'static str,
pub detail: Option<String>,
}
#[derive(Debug, Clone)]
pub struct VerifyResult {
pub ok: bool,
pub failures: Vec<VerifyFailure>,
}
fn check_hash(v: Option<&Value>) -> bool {
v.and_then(|x| x.as_str())
.map(|s| HASH_RE.is_match(s))
.unwrap_or(false)
}
pub fn validate_turn(value: &Value) -> Result<(), String> {
let obj = value
.as_object()
.ok_or_else(|| "not an object".to_string())?;
if obj.get("version").and_then(|v| v.as_str()) != Some("scroll/0.1") {
return Err("version must be \"scroll/0.1\"".into());
}
match obj.get("turn").and_then(|v| v.as_u64()) {
Some(_) => {}
None => return Err("turn must be non-negative integer".into()),
}
let role = obj.get("role").and_then(|v| v.as_str()).unwrap_or("");
if !ROLES.contains(&role) {
return Err(format!("role must be one of {ROLES:?}"));
}
let model = obj
.get("model")
.and_then(|v| v.as_object())
.ok_or("model required")?;
if model
.get("vendor")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.is_none()
|| model
.get("id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.is_none()
{
return Err("model.vendor and model.id required".into());
}
let params = obj
.get("params")
.and_then(|v| v.as_object())
.ok_or("params required")?;
for k in ["temperature", "top_p"] {
if !params.get(k).map(|v| v.is_number()).unwrap_or(false) {
return Err(format!("params.{k} must be number"));
}
}
for k in ["seed", "max_tokens"] {
if let Some(v) = params.get(k) {
if v.as_i64().is_none() {
return Err(format!("params.{k} must be integer"));
}
}
}
let msgs = obj
.get("messages")
.and_then(|v| v.as_array())
.ok_or("messages must be array")?;
for m in msgs {
let mo = m.as_object().ok_or("message not object")?;
if !mo.get("role").map(|v| v.is_string()).unwrap_or(false) {
return Err("message.role must be string".into());
}
let c = mo.get("content");
if !c.map(|v| v.is_string() || v.is_array()).unwrap_or(false) {
return Err("message.content must be string or array".into());
}
}
for k in ["tool_calls", "tool_results"] {
if let Some(arr) = obj.get(k) {
let arr = arr.as_array().ok_or(format!("{k} must be array"))?;
for tc in arr {
let tco = tc.as_object().ok_or(format!("{k} item not object"))?;
if !tco.get("id").map(|v| v.is_string()).unwrap_or(false) {
return Err(format!("{k}.id must be string"));
}
if k == "tool_calls" {
if !tco.get("name").map(|v| v.is_string()).unwrap_or(false) {
return Err("tool_calls.name must be string".into());
}
if !check_hash(tco.get("args_hash")) {
return Err("tool_calls.args_hash invalid".into());
}
} else {
let st = tco.get("status").and_then(|v| v.as_str()).unwrap_or("");
if !TOOL_STATUS.contains(&st) {
return Err("tool_results.status must be ok|error".into());
}
if !check_hash(tco.get("response_hash")) {
return Err("tool_results.response_hash invalid".into());
}
}
}
}
}
if obj.get("timestamp_ns").and_then(|v| v.as_u64()).is_none() {
return Err("timestamp_ns must be non-negative integer".into());
}
if obj.contains_key("prev_hash") && !check_hash(obj.get("prev_hash")) {
return Err("prev_hash invalid".into());
}
Ok(())
}
pub fn validate_sealed_turn(value: &Value) -> Result<(), String> {
validate_turn(value)?;
let obj = value.as_object().unwrap();
if !check_hash(obj.get("hash")) {
return Err("hash invalid".into());
}
if let Some(sig) = obj.get("sig") {
let so = sig.as_object().ok_or("sig not object")?;
if so.get("alg").and_then(|v| v.as_str()) != Some("ed25519") {
return Err("sig.alg must be \"ed25519\"".into());
}
if !so.get("pubkey").map(|v| v.is_string()).unwrap_or(false)
|| !so.get("sig").map(|v| v.is_string()).unwrap_or(false)
{
return Err("sig.pubkey and sig.sig required".into());
}
}
Ok(())
}