use serde::{Deserialize, Serialize};
const ANNOTATION_OPEN: &str = "<!-- gate-verify";
const ANNOTATION_CLOSE: &str = "-->";
pub const S760_CLEAR_DECISION_CLEAR_TRUE_MARKER: &str = "s760_clear_decision_clear_true";
const S760_CLEAR_DECISION_INVALID_MARKER: &str = "s760_clear_decision_invalid";
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct GatePredicate {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verify: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disproof: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub set_at: Option<u64>,
}
impl GatePredicate {
pub fn is_actionable(&self) -> bool {
self.verify.as_deref().is_some_and(|s| !s.is_empty())
|| self.disproof.as_deref().is_some_and(|s| !s.is_empty())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "lowercase")]
pub enum VerifyOutcome {
Provable {
marker: String,
at: u64,
},
Failed {
marker: String,
at: u64,
},
Pending,
}
impl VerifyOutcome {
pub fn status_str(&self) -> &'static str {
match self {
VerifyOutcome::Provable { .. } => "provable",
VerifyOutcome::Failed { .. } => "failed",
VerifyOutcome::Pending => "pending",
}
}
}
fn parse_ops_line(line: &str) -> Option<(u64, &str)> {
let rest = line.strip_prefix('[')?;
let close = rest.find(']')?;
let ts = agent_doc_log_time::parse_log_timestamp(rest[..close].trim())?;
let msg = rest[close + 1..].trim_start();
Some((ts, msg))
}
fn strip_quoted_spans(msg: &str) -> String {
let mut out = String::with_capacity(msg.len());
let mut chars = msg.chars();
let mut in_quote = false;
while let Some(c) = chars.next() {
if in_quote {
match c {
'\\' => {
let _ = chars.next();
}
'"' => {
in_quote = false;
out.push('"');
}
_ => {}
}
} else {
if c == '"' {
in_quote = true;
}
out.push(c);
}
}
out
}
pub fn scan_ops_log(predicate: &GatePredicate, ops_log: &str) -> VerifyOutcome {
let set_at = predicate.set_at.unwrap_or(0);
let verify = predicate.verify.as_deref().filter(|s| !s.is_empty());
let disproof = predicate.disproof.as_deref().filter(|s| !s.is_empty());
if verify == Some(S760_CLEAR_DECISION_CLEAR_TRUE_MARKER) {
return scan_s760_clear_decision_ops_log(ops_log, set_at, disproof);
}
let mut proof_hit: Option<u64> = None;
let mut disproof_hit: Option<u64> = None;
for line in ops_log.lines() {
let Some((ts, msg)) = parse_ops_line(line) else {
continue;
};
if ts < set_at {
continue;
}
let msg = strip_quoted_spans(msg);
if let Some(d) = disproof
&& msg.contains(d)
{
disproof_hit = Some(disproof_hit.map_or(ts, |t| t.min(ts)));
}
if let Some(v) = verify
&& msg.contains(v)
{
proof_hit = Some(proof_hit.map_or(ts, |t| t.min(ts)));
}
}
if let Some(at) = disproof_hit {
return VerifyOutcome::Failed {
marker: disproof.unwrap_or_default().to_string(),
at,
};
}
if let Some(at) = proof_hit {
return VerifyOutcome::Provable {
marker: verify.unwrap_or_default().to_string(),
at,
};
}
VerifyOutcome::Pending
}
fn scan_s760_clear_decision_ops_log(
ops_log: &str,
set_at: u64,
disproof: Option<&str>,
) -> VerifyOutcome {
let mut proof_hit: Option<u64> = None;
let mut disproof_hit: Option<u64> = None;
for line in ops_log.lines() {
let Some((ts, msg)) = parse_ops_line(line) else {
continue;
};
if ts < set_at {
continue;
}
let msg = strip_quoted_spans(msg);
if let Some(d) = disproof
&& msg.contains(d)
{
disproof_hit = Some(disproof_hit.map_or(ts, |t| t.min(ts)));
}
let Some(decision) = parse_s760_clear_decision(&msg) else {
continue;
};
let threshold_met =
decision.opt_in && decision.pct.is_some_and(|pct| pct >= decision.threshold);
match (decision.clear, threshold_met) {
(true, true) => proof_hit = Some(proof_hit.map_or(ts, |t| t.min(ts))),
(true, false) | (false, true) => {
disproof_hit = Some(disproof_hit.map_or(ts, |t| t.min(ts)));
}
(false, false) => {}
}
}
if let Some(at) = disproof_hit {
return VerifyOutcome::Failed {
marker: disproof
.unwrap_or(S760_CLEAR_DECISION_INVALID_MARKER)
.to_string(),
at,
};
}
if let Some(at) = proof_hit {
return VerifyOutcome::Provable {
marker: S760_CLEAR_DECISION_CLEAR_TRUE_MARKER.to_string(),
at,
};
}
VerifyOutcome::Pending
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct S760ClearDecision {
opt_in: bool,
threshold: f64,
pct: Option<f64>,
clear: bool,
}
fn parse_s760_clear_decision(msg: &str) -> Option<S760ClearDecision> {
let rest = msg
.strip_prefix("[s760] clear-decision ")
.or_else(|| msg.strip_prefix("s760 clear-decision "))?;
let mut opt_in = None;
let mut threshold = None;
let mut pct = None;
let mut clear = None;
for field in rest.split_whitespace() {
let Some((key, value)) = field.split_once('=') else {
continue;
};
match key {
"optIn" => opt_in = parse_bool(value),
"threshold" => threshold = value.parse::<f64>().ok(),
"pct" => {
pct = if value == "none" {
Some(None)
} else {
Some(Some(value.parse::<f64>().ok()?))
};
}
"clear" => clear = parse_bool(value),
_ => {}
}
}
Some(S760ClearDecision {
opt_in: opt_in?,
threshold: threshold?,
pct: pct?,
clear: clear?,
})
}
fn parse_bool(value: &str) -> Option<bool> {
match value {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
pub fn parse_gate_predicate(text: &str) -> Option<GatePredicate> {
let open = text.find(ANNOTATION_OPEN)?;
let after_open = &text[open + ANNOTATION_OPEN.len()..];
let close_rel = after_open.find(ANNOTATION_CLOSE)?;
let inner = &after_open[..close_rel];
let mut pred = GatePredicate::default();
for (key, value) in parse_quoted_attrs(inner) {
match key.as_str() {
"verify" => pred.verify = Some(value),
"disproof" => pred.disproof = Some(value),
"set_at" => pred.set_at = value.trim().parse::<u64>().ok(),
_ => {}
}
}
Some(pred)
}
fn parse_quoted_attrs(input: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && !(bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
let key_start = i;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
if key_start == i {
break;
}
let key = input[key_start..i].to_string();
while i < bytes.len() && bytes[i] == b' ' {
i += 1;
}
if i >= bytes.len() || bytes[i] != b'=' {
continue;
}
i += 1; while i < bytes.len() && bytes[i] == b' ' {
i += 1;
}
if i >= bytes.len() || bytes[i] != b'"' {
continue;
}
i += 1; let val_start = i;
while i < bytes.len() && bytes[i] != b'"' {
i += 1;
}
let value = input[val_start..i].to_string();
i += 1; out.push((key, value));
}
out
}
pub fn render_annotation(predicate: &GatePredicate) -> String {
let mut parts = String::new();
if let Some(v) = predicate.verify.as_deref().filter(|s| !s.is_empty()) {
parts.push_str(&format!(" verify=\"{}\"", v));
}
if let Some(d) = predicate.disproof.as_deref().filter(|s| !s.is_empty()) {
parts.push_str(&format!(" disproof=\"{}\"", d));
}
if let Some(t) = predicate.set_at {
parts.push_str(&format!(" set_at=\"{}\"", t));
}
if parts.is_empty() {
return String::new();
}
format!("{}{} {}", ANNOTATION_OPEN, parts, ANNOTATION_CLOSE)
}
pub fn strip_annotation(text: &str) -> String {
let Some(open) = text.find(ANNOTATION_OPEN) else {
return text.to_string();
};
let after_open = &text[open + ANNOTATION_OPEN.len()..];
let Some(close_rel) = after_open.find(ANNOTATION_CLOSE) else {
return text.to_string();
};
let close_abs = open + ANNOTATION_OPEN.len() + close_rel + ANNOTATION_CLOSE.len();
let mut out = String::with_capacity(text.len());
out.push_str(text[..open].trim_end());
let tail = &text[close_abs..];
if !tail.trim().is_empty() {
if !out.is_empty() {
out.push(' ');
}
out.push_str(tail.trim_start());
}
out
}
pub fn upsert_annotation(text: &str, predicate: &GatePredicate) -> String {
let base = strip_annotation(text);
let annotation = render_annotation(predicate);
if annotation.is_empty() {
return base;
}
if base.trim().is_empty() {
return annotation;
}
format!("{} {}", base.trim_end(), annotation)
}
pub fn parse_predicate_spec(spec: &str) -> GatePredicate {
let mut pred = GatePredicate::default();
for clause in spec.split(';') {
let clause = clause.trim();
let Some((key, raw)) = clause.split_once('=') else {
continue;
};
let value = raw
.trim()
.strip_prefix("ops_log:")
.unwrap_or_else(|| raw.trim())
.trim()
.to_string();
match key.trim() {
"verify" => pred.verify = Some(value).filter(|s| !s.is_empty()),
"disproof" => pred.disproof = Some(value).filter(|s| !s.is_empty()),
_ => {}
}
}
pred
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ops_line_extracts_epoch_and_message() {
assert_eq!(
parse_ops_line("[1749526200] early_receipt_accepted emitted"),
Some((1749526200, "early_receipt_accepted emitted"))
);
assert_eq!(parse_ops_line("no bracket"), None);
assert_eq!(parse_ops_line("[notnum] x"), None);
}
#[test]
fn scan_provable_when_marker_after_gate() {
let pred = GatePredicate {
verify: Some("early_receipt_accepted".to_string()),
disproof: None,
set_at: Some(100),
};
let log = "[90] early_receipt_accepted stale\n[150] early_receipt_accepted emitted\n";
assert_eq!(
scan_ops_log(&pred, log),
VerifyOutcome::Provable {
marker: "early_receipt_accepted".to_string(),
at: 150
}
);
}
#[test]
fn scan_ignores_marker_before_gate_time() {
let pred = GatePredicate {
verify: Some("early_receipt_accepted".to_string()),
disproof: None,
set_at: Some(200),
};
let log = "[150] early_receipt_accepted emitted\n";
assert_eq!(scan_ops_log(&pred, log), VerifyOutcome::Pending);
}
#[test]
fn scan_disproof_wins_over_proof() {
let pred = GatePredicate {
verify: Some("ok".to_string()),
disproof: Some("manual cleanup".to_string()),
set_at: Some(0),
};
let log = "[10] ok happened\n[20] looks like a manual cleanup\n";
assert_eq!(
scan_ops_log(&pred, log),
VerifyOutcome::Failed {
marker: "manual cleanup".to_string(),
at: 20
}
);
}
#[test]
fn scan_pending_when_no_markers() {
let pred = GatePredicate {
verify: Some("never".to_string()),
disproof: Some("nope".to_string()),
set_at: Some(0),
};
assert_eq!(
scan_ops_log(&pred, "[10] unrelated\n"),
VerifyOutcome::Pending
);
}
#[test]
fn scan_reports_earliest_qualifying_line() {
let pred = GatePredicate {
verify: Some("hit".to_string()),
disproof: None,
set_at: Some(0),
};
let log = "[30] hit\n[10] hit\n[20] hit\n";
assert_eq!(
scan_ops_log(&pred, log),
VerifyOutcome::Provable {
marker: "hit".to_string(),
at: 10
}
);
}
#[test]
fn scan_ignores_marker_inside_quoted_prose() {
let pred = GatePredicate {
verify: Some("cross-session-reject".to_string()),
disproof: None,
set_at: Some(0),
};
let log = "[10] queue_diff_active_prompt_differs file=doc.md prompt_changes=[\"- [ ] [#4wxr] claim emits cross-session-reject pane_id=..\"] queue_head=\"[#x]\"\n";
assert_eq!(scan_ops_log(&pred, log), VerifyOutcome::Pending);
}
#[test]
fn scan_ignores_disproof_inside_quoted_prose() {
let pred = GatePredicate {
verify: Some("ok marker".to_string()),
disproof: Some("manual cleanup".to_string()),
set_at: Some(0),
};
let log = "[5] route_dispatch_queued prompt=Some(\"discussing the manual cleanup path\")\n[10] ok marker emitted\n";
assert_eq!(
scan_ops_log(&pred, log),
VerifyOutcome::Provable {
marker: "ok marker".to_string(),
at: 10
}
);
}
#[test]
fn scan_structured_marker_proves_despite_prose_mentions() {
let pred = GatePredicate {
verify: Some("cross-session-reject".to_string()),
disproof: None,
set_at: Some(0),
};
let log = concat!(
"[10] queue_diff_active_prompt_differs prompt_changes=[\"mentions cross-session-reject in prose\"]\n",
"[20] [claim] cross-session-reject pane_id=%43 pane_session=5 configured=0\n",
);
assert_eq!(
scan_ops_log(&pred, log),
VerifyOutcome::Provable {
marker: "cross-session-reject".to_string(),
at: 20
}
);
}
#[test]
fn scan_s760_clear_decision_requires_anchored_clear_true_at_threshold() {
let pred = GatePredicate {
verify: Some(S760_CLEAR_DECISION_CLEAR_TRUE_MARKER.to_string()),
disproof: None,
set_at: Some(100),
};
let log = concat!(
"[150] unrelated [s760] clear-decision optIn=true threshold=50 pct=50.0 clear=true\n",
"[160] [s760] clear-decision optIn=true threshold=50 pct=49.9 clear=false\n",
"[170] [s760] clear-decision optIn=true threshold=50 pct=50.0 clear=true\n",
);
assert_eq!(
scan_ops_log(&pred, log),
VerifyOutcome::Provable {
marker: S760_CLEAR_DECISION_CLEAR_TRUE_MARKER.to_string(),
at: 170
}
);
}
#[test]
fn scan_s760_clear_decision_ignores_queue_diff_prose_only() {
let pred = GatePredicate {
verify: Some(S760_CLEAR_DECISION_CLEAR_TRUE_MARKER.to_string()),
disproof: None,
set_at: Some(0),
};
let log = "[150] queue_diff_active_prompt_differs file=doc.md prompt_changes=[\"expect [s760] clear-decision optIn=true threshold=50 pct=50.0 clear=true\"] queue_head=\"[#ktw8]\"\n";
assert_eq!(scan_ops_log(&pred, log), VerifyOutcome::Pending);
}
#[test]
fn scan_s760_clear_decision_fails_clear_true_below_threshold() {
let pred = GatePredicate {
verify: Some(S760_CLEAR_DECISION_CLEAR_TRUE_MARKER.to_string()),
disproof: None,
set_at: Some(0),
};
let log = "[150] [s760] clear-decision optIn=true threshold=50 pct=49.9 clear=true\n";
assert_eq!(
scan_ops_log(&pred, log),
VerifyOutcome::Failed {
marker: S760_CLEAR_DECISION_INVALID_MARKER.to_string(),
at: 150
}
);
}
#[test]
fn scan_s760_clear_decision_fails_clear_false_at_threshold() {
let pred = GatePredicate {
verify: Some(S760_CLEAR_DECISION_CLEAR_TRUE_MARKER.to_string()),
disproof: None,
set_at: Some(0),
};
let log = "[150] [s760] clear-decision optIn=true threshold=50 pct=50.0 clear=false\n";
assert_eq!(
scan_ops_log(&pred, log),
VerifyOutcome::Failed {
marker: S760_CLEAR_DECISION_INVALID_MARKER.to_string(),
at: 150
}
);
}
#[test]
fn strip_quoted_spans_handles_escaped_quotes_and_unterminated() {
assert_eq!(
strip_quoted_spans("a=\"text \\\"inner\\\" more\" tail marker"),
"a=\"\" tail marker"
);
assert_eq!(strip_quoted_spans("a=\"unterminated marker"), "a=\"");
assert_eq!(strip_quoted_spans("no quotes marker"), "no quotes marker");
}
#[test]
fn annotation_round_trips_with_spaces() {
let pred = GatePredicate {
verify: Some("early_receipt_accepted".to_string()),
disproof: Some("false receipt-timeout".to_string()),
set_at: Some(1749526200),
};
let text = format!("early receipt live verify {}", render_annotation(&pred));
let parsed = parse_gate_predicate(&text).unwrap();
assert_eq!(parsed, pred);
}
#[test]
fn parse_returns_none_without_annotation() {
assert_eq!(parse_gate_predicate("plain item text"), None);
}
#[test]
fn strip_annotation_removes_and_trims() {
let pred = GatePredicate {
verify: Some("m".to_string()),
disproof: None,
set_at: Some(5),
};
let text = format!("body text {}", render_annotation(&pred));
assert_eq!(strip_annotation(&text), "body text");
}
#[test]
fn upsert_is_idempotent() {
let pred = GatePredicate {
verify: Some("m".to_string()),
disproof: None,
set_at: Some(5),
};
let once = upsert_annotation("body", &pred);
let twice = upsert_annotation(&once, &pred);
assert_eq!(once, twice);
assert!(twice.starts_with("body "));
assert_eq!(parse_gate_predicate(&twice).unwrap(), pred);
}
#[test]
fn upsert_replaces_existing_predicate() {
let first = GatePredicate {
verify: Some("old".to_string()),
disproof: None,
set_at: Some(1),
};
let second = GatePredicate {
verify: Some("new".to_string()),
disproof: Some("bad".to_string()),
set_at: Some(2),
};
let text = upsert_annotation(&upsert_annotation("body", &first), &second);
let parsed = parse_gate_predicate(&text).unwrap();
assert_eq!(parsed, second);
assert_eq!(text.matches(ANNOTATION_OPEN).count(), 1);
}
#[test]
fn parse_predicate_spec_strips_ops_log_prefix() {
let pred = parse_predicate_spec(
"verify=ops_log:early_receipt_accepted;disproof=ops_log:false receipt-timeout",
);
assert_eq!(pred.verify.as_deref(), Some("early_receipt_accepted"));
assert_eq!(pred.disproof.as_deref(), Some("false receipt-timeout"));
assert_eq!(pred.set_at, None);
}
#[test]
fn parse_predicate_spec_verify_only() {
let pred = parse_predicate_spec("verify=marker_x");
assert_eq!(pred.verify.as_deref(), Some("marker_x"));
assert_eq!(pred.disproof, None);
}
#[test]
fn is_actionable_requires_a_matcher() {
assert!(!GatePredicate::default().is_actionable());
assert!(
GatePredicate {
verify: Some("x".to_string()),
..Default::default()
}
.is_actionable()
);
}
}