1use serde::{Deserialize, Serialize};
48
49const ANNOTATION_OPEN: &str = "<!-- gate-verify";
51const ANNOTATION_CLOSE: &str = "-->";
52
53pub const S760_CLEAR_DECISION_CLEAR_TRUE_MARKER: &str = "s760_clear_decision_clear_true";
56const S760_CLEAR_DECISION_INVALID_MARKER: &str = "s760_clear_decision_invalid";
57
58#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
64pub struct GatePredicate {
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub verify: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub disproof: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub set_at: Option<u64>,
74}
75
76impl GatePredicate {
77 pub fn is_actionable(&self) -> bool {
79 self.verify.as_deref().is_some_and(|s| !s.is_empty())
80 || self.disproof.as_deref().is_some_and(|s| !s.is_empty())
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
86#[serde(tag = "status", rename_all = "lowercase")]
87pub enum VerifyOutcome {
88 Provable {
90 marker: String,
92 at: u64,
94 },
95 Failed {
97 marker: String,
99 at: u64,
101 },
102 Pending,
104}
105
106impl VerifyOutcome {
107 pub fn status_str(&self) -> &'static str {
109 match self {
110 VerifyOutcome::Provable { .. } => "provable",
111 VerifyOutcome::Failed { .. } => "failed",
112 VerifyOutcome::Pending => "pending",
113 }
114 }
115}
116
117fn parse_ops_line(line: &str) -> Option<(u64, &str)> {
123 let rest = line.strip_prefix('[')?;
124 let close = rest.find(']')?;
125 let ts = agent_doc_log_time::parse_log_timestamp(rest[..close].trim())?;
126 let msg = rest[close + 1..].trim_start();
127 Some((ts, msg))
128}
129
130fn strip_quoted_spans(msg: &str) -> String {
138 let mut out = String::with_capacity(msg.len());
139 let mut chars = msg.chars();
140 let mut in_quote = false;
141 while let Some(c) = chars.next() {
142 if in_quote {
143 match c {
144 '\\' => {
145 let _ = chars.next();
146 }
147 '"' => {
148 in_quote = false;
149 out.push('"');
150 }
151 _ => {}
152 }
153 } else {
154 if c == '"' {
155 in_quote = true;
156 }
157 out.push(c);
158 }
159 }
160 out
161}
162
163pub fn scan_ops_log(predicate: &GatePredicate, ops_log: &str) -> VerifyOutcome {
175 let set_at = predicate.set_at.unwrap_or(0);
176 let verify = predicate.verify.as_deref().filter(|s| !s.is_empty());
177 let disproof = predicate.disproof.as_deref().filter(|s| !s.is_empty());
178
179 if verify == Some(S760_CLEAR_DECISION_CLEAR_TRUE_MARKER) {
180 return scan_s760_clear_decision_ops_log(ops_log, set_at, disproof);
181 }
182
183 let mut proof_hit: Option<u64> = None;
184 let mut disproof_hit: Option<u64> = None;
185
186 for line in ops_log.lines() {
187 let Some((ts, msg)) = parse_ops_line(line) else {
188 continue;
189 };
190 if ts < set_at {
191 continue;
192 }
193 let msg = strip_quoted_spans(msg);
194 if let Some(d) = disproof
195 && msg.contains(d)
196 {
197 disproof_hit = Some(disproof_hit.map_or(ts, |t| t.min(ts)));
198 }
199 if let Some(v) = verify
200 && msg.contains(v)
201 {
202 proof_hit = Some(proof_hit.map_or(ts, |t| t.min(ts)));
203 }
204 }
205
206 if let Some(at) = disproof_hit {
207 return VerifyOutcome::Failed {
208 marker: disproof.unwrap_or_default().to_string(),
209 at,
210 };
211 }
212 if let Some(at) = proof_hit {
213 return VerifyOutcome::Provable {
214 marker: verify.unwrap_or_default().to_string(),
215 at,
216 };
217 }
218 VerifyOutcome::Pending
219}
220
221fn scan_s760_clear_decision_ops_log(
231 ops_log: &str,
232 set_at: u64,
233 disproof: Option<&str>,
234) -> VerifyOutcome {
235 let mut proof_hit: Option<u64> = None;
236 let mut disproof_hit: Option<u64> = None;
237
238 for line in ops_log.lines() {
239 let Some((ts, msg)) = parse_ops_line(line) else {
240 continue;
241 };
242 if ts < set_at {
243 continue;
244 }
245
246 let msg = strip_quoted_spans(msg);
247 if let Some(d) = disproof
248 && msg.contains(d)
249 {
250 disproof_hit = Some(disproof_hit.map_or(ts, |t| t.min(ts)));
251 }
252
253 let Some(decision) = parse_s760_clear_decision(&msg) else {
254 continue;
255 };
256
257 let threshold_met =
258 decision.opt_in && decision.pct.is_some_and(|pct| pct >= decision.threshold);
259 match (decision.clear, threshold_met) {
260 (true, true) => proof_hit = Some(proof_hit.map_or(ts, |t| t.min(ts))),
261 (true, false) | (false, true) => {
262 disproof_hit = Some(disproof_hit.map_or(ts, |t| t.min(ts)));
263 }
264 (false, false) => {}
265 }
266 }
267
268 if let Some(at) = disproof_hit {
269 return VerifyOutcome::Failed {
270 marker: disproof
271 .unwrap_or(S760_CLEAR_DECISION_INVALID_MARKER)
272 .to_string(),
273 at,
274 };
275 }
276 if let Some(at) = proof_hit {
277 return VerifyOutcome::Provable {
278 marker: S760_CLEAR_DECISION_CLEAR_TRUE_MARKER.to_string(),
279 at,
280 };
281 }
282 VerifyOutcome::Pending
283}
284
285#[derive(Debug, Clone, Copy, PartialEq)]
286struct S760ClearDecision {
287 opt_in: bool,
288 threshold: f64,
289 pct: Option<f64>,
290 clear: bool,
291}
292
293fn parse_s760_clear_decision(msg: &str) -> Option<S760ClearDecision> {
294 let rest = msg
295 .strip_prefix("[s760] clear-decision ")
296 .or_else(|| msg.strip_prefix("s760 clear-decision "))?;
297
298 let mut opt_in = None;
299 let mut threshold = None;
300 let mut pct = None;
301 let mut clear = None;
302
303 for field in rest.split_whitespace() {
304 let Some((key, value)) = field.split_once('=') else {
305 continue;
306 };
307 match key {
308 "optIn" => opt_in = parse_bool(value),
309 "threshold" => threshold = value.parse::<f64>().ok(),
310 "pct" => {
311 pct = if value == "none" {
312 Some(None)
313 } else {
314 Some(Some(value.parse::<f64>().ok()?))
315 };
316 }
317 "clear" => clear = parse_bool(value),
318 _ => {}
319 }
320 }
321
322 Some(S760ClearDecision {
323 opt_in: opt_in?,
324 threshold: threshold?,
325 pct: pct?,
326 clear: clear?,
327 })
328}
329
330fn parse_bool(value: &str) -> Option<bool> {
331 match value {
332 "true" => Some(true),
333 "false" => Some(false),
334 _ => None,
335 }
336}
337
338pub fn parse_gate_predicate(text: &str) -> Option<GatePredicate> {
343 let open = text.find(ANNOTATION_OPEN)?;
344 let after_open = &text[open + ANNOTATION_OPEN.len()..];
345 let close_rel = after_open.find(ANNOTATION_CLOSE)?;
346 let inner = &after_open[..close_rel];
347
348 let mut pred = GatePredicate::default();
349 for (key, value) in parse_quoted_attrs(inner) {
350 match key.as_str() {
351 "verify" => pred.verify = Some(value),
352 "disproof" => pred.disproof = Some(value),
353 "set_at" => pred.set_at = value.trim().parse::<u64>().ok(),
354 _ => {}
355 }
356 }
357 Some(pred)
358}
359
360fn parse_quoted_attrs(input: &str) -> Vec<(String, String)> {
363 let mut out = Vec::new();
364 let bytes = input.as_bytes();
365 let mut i = 0;
366 while i < bytes.len() {
367 while i < bytes.len() && !(bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
369 i += 1;
370 }
371 let key_start = i;
372 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
373 i += 1;
374 }
375 if key_start == i {
376 break;
377 }
378 let key = input[key_start..i].to_string();
379 while i < bytes.len() && bytes[i] == b' ' {
381 i += 1;
382 }
383 if i >= bytes.len() || bytes[i] != b'=' {
384 continue;
385 }
386 i += 1; while i < bytes.len() && bytes[i] == b' ' {
388 i += 1;
389 }
390 if i >= bytes.len() || bytes[i] != b'"' {
391 continue;
392 }
393 i += 1; let val_start = i;
395 while i < bytes.len() && bytes[i] != b'"' {
396 i += 1;
397 }
398 let value = input[val_start..i].to_string();
399 i += 1; out.push((key, value));
401 }
402 out
403}
404
405pub fn render_annotation(predicate: &GatePredicate) -> String {
408 let mut parts = String::new();
409 if let Some(v) = predicate.verify.as_deref().filter(|s| !s.is_empty()) {
410 parts.push_str(&format!(" verify=\"{}\"", v));
411 }
412 if let Some(d) = predicate.disproof.as_deref().filter(|s| !s.is_empty()) {
413 parts.push_str(&format!(" disproof=\"{}\"", d));
414 }
415 if let Some(t) = predicate.set_at {
416 parts.push_str(&format!(" set_at=\"{}\"", t));
417 }
418 if parts.is_empty() {
419 return String::new();
420 }
421 format!("{}{} {}", ANNOTATION_OPEN, parts, ANNOTATION_CLOSE)
422}
423
424pub fn strip_annotation(text: &str) -> String {
427 let Some(open) = text.find(ANNOTATION_OPEN) else {
428 return text.to_string();
429 };
430 let after_open = &text[open + ANNOTATION_OPEN.len()..];
431 let Some(close_rel) = after_open.find(ANNOTATION_CLOSE) else {
432 return text.to_string();
433 };
434 let close_abs = open + ANNOTATION_OPEN.len() + close_rel + ANNOTATION_CLOSE.len();
435 let mut out = String::with_capacity(text.len());
436 out.push_str(text[..open].trim_end());
437 let tail = &text[close_abs..];
438 if !tail.trim().is_empty() {
439 if !out.is_empty() {
440 out.push(' ');
441 }
442 out.push_str(tail.trim_start());
443 }
444 out
445}
446
447pub fn upsert_annotation(text: &str, predicate: &GatePredicate) -> String {
450 let base = strip_annotation(text);
451 let annotation = render_annotation(predicate);
452 if annotation.is_empty() {
453 return base;
454 }
455 if base.trim().is_empty() {
456 return annotation;
457 }
458 format!("{} {}", base.trim_end(), annotation)
459}
460
461pub fn parse_predicate_spec(spec: &str) -> GatePredicate {
468 let mut pred = GatePredicate::default();
469 for clause in spec.split(';') {
470 let clause = clause.trim();
471 let Some((key, raw)) = clause.split_once('=') else {
472 continue;
473 };
474 let value = raw
475 .trim()
476 .strip_prefix("ops_log:")
477 .unwrap_or_else(|| raw.trim())
478 .trim()
479 .to_string();
480 match key.trim() {
481 "verify" => pred.verify = Some(value).filter(|s| !s.is_empty()),
482 "disproof" => pred.disproof = Some(value).filter(|s| !s.is_empty()),
483 _ => {}
484 }
485 }
486 pred
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 #[test]
494 fn parse_ops_line_extracts_epoch_and_message() {
495 assert_eq!(
496 parse_ops_line("[1749526200] early_receipt_accepted emitted"),
497 Some((1749526200, "early_receipt_accepted emitted"))
498 );
499 assert_eq!(parse_ops_line("no bracket"), None);
500 assert_eq!(parse_ops_line("[notnum] x"), None);
501 }
502
503 #[test]
504 fn scan_provable_when_marker_after_gate() {
505 let pred = GatePredicate {
506 verify: Some("early_receipt_accepted".to_string()),
507 disproof: None,
508 set_at: Some(100),
509 };
510 let log = "[90] early_receipt_accepted stale\n[150] early_receipt_accepted emitted\n";
511 assert_eq!(
512 scan_ops_log(&pred, log),
513 VerifyOutcome::Provable {
514 marker: "early_receipt_accepted".to_string(),
515 at: 150
516 }
517 );
518 }
519
520 #[test]
521 fn scan_ignores_marker_before_gate_time() {
522 let pred = GatePredicate {
523 verify: Some("early_receipt_accepted".to_string()),
524 disproof: None,
525 set_at: Some(200),
526 };
527 let log = "[150] early_receipt_accepted emitted\n";
528 assert_eq!(scan_ops_log(&pred, log), VerifyOutcome::Pending);
529 }
530
531 #[test]
532 fn scan_disproof_wins_over_proof() {
533 let pred = GatePredicate {
534 verify: Some("ok".to_string()),
535 disproof: Some("manual cleanup".to_string()),
536 set_at: Some(0),
537 };
538 let log = "[10] ok happened\n[20] looks like a manual cleanup\n";
539 assert_eq!(
540 scan_ops_log(&pred, log),
541 VerifyOutcome::Failed {
542 marker: "manual cleanup".to_string(),
543 at: 20
544 }
545 );
546 }
547
548 #[test]
549 fn scan_pending_when_no_markers() {
550 let pred = GatePredicate {
551 verify: Some("never".to_string()),
552 disproof: Some("nope".to_string()),
553 set_at: Some(0),
554 };
555 assert_eq!(
556 scan_ops_log(&pred, "[10] unrelated\n"),
557 VerifyOutcome::Pending
558 );
559 }
560
561 #[test]
562 fn scan_reports_earliest_qualifying_line() {
563 let pred = GatePredicate {
564 verify: Some("hit".to_string()),
565 disproof: None,
566 set_at: Some(0),
567 };
568 let log = "[30] hit\n[10] hit\n[20] hit\n";
569 assert_eq!(
570 scan_ops_log(&pred, log),
571 VerifyOutcome::Provable {
572 marker: "hit".to_string(),
573 at: 10
574 }
575 );
576 }
577
578 #[test]
579 fn scan_ignores_marker_inside_quoted_prose() {
580 let pred = GatePredicate {
583 verify: Some("cross-session-reject".to_string()),
584 disproof: None,
585 set_at: Some(0),
586 };
587 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";
588 assert_eq!(scan_ops_log(&pred, log), VerifyOutcome::Pending);
589 }
590
591 #[test]
592 fn scan_ignores_disproof_inside_quoted_prose() {
593 let pred = GatePredicate {
594 verify: Some("ok marker".to_string()),
595 disproof: Some("manual cleanup".to_string()),
596 set_at: Some(0),
597 };
598 let log = "[5] route_dispatch_queued prompt=Some(\"discussing the manual cleanup path\")\n[10] ok marker emitted\n";
599 assert_eq!(
600 scan_ops_log(&pred, log),
601 VerifyOutcome::Provable {
602 marker: "ok marker".to_string(),
603 at: 10
604 }
605 );
606 }
607
608 #[test]
609 fn scan_structured_marker_proves_despite_prose_mentions() {
610 let pred = GatePredicate {
611 verify: Some("cross-session-reject".to_string()),
612 disproof: None,
613 set_at: Some(0),
614 };
615 let log = concat!(
616 "[10] queue_diff_active_prompt_differs prompt_changes=[\"mentions cross-session-reject in prose\"]\n",
617 "[20] [claim] cross-session-reject pane_id=%43 pane_session=5 configured=0\n",
618 );
619 assert_eq!(
620 scan_ops_log(&pred, log),
621 VerifyOutcome::Provable {
622 marker: "cross-session-reject".to_string(),
623 at: 20
624 }
625 );
626 }
627
628 #[test]
629 fn scan_s760_clear_decision_requires_anchored_clear_true_at_threshold() {
630 let pred = GatePredicate {
631 verify: Some(S760_CLEAR_DECISION_CLEAR_TRUE_MARKER.to_string()),
632 disproof: None,
633 set_at: Some(100),
634 };
635 let log = concat!(
636 "[150] unrelated [s760] clear-decision optIn=true threshold=50 pct=50.0 clear=true\n",
637 "[160] [s760] clear-decision optIn=true threshold=50 pct=49.9 clear=false\n",
638 "[170] [s760] clear-decision optIn=true threshold=50 pct=50.0 clear=true\n",
639 );
640 assert_eq!(
641 scan_ops_log(&pred, log),
642 VerifyOutcome::Provable {
643 marker: S760_CLEAR_DECISION_CLEAR_TRUE_MARKER.to_string(),
644 at: 170
645 }
646 );
647 }
648
649 #[test]
650 fn scan_s760_clear_decision_ignores_queue_diff_prose_only() {
651 let pred = GatePredicate {
652 verify: Some(S760_CLEAR_DECISION_CLEAR_TRUE_MARKER.to_string()),
653 disproof: None,
654 set_at: Some(0),
655 };
656 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";
657 assert_eq!(scan_ops_log(&pred, log), VerifyOutcome::Pending);
658 }
659
660 #[test]
661 fn scan_s760_clear_decision_fails_clear_true_below_threshold() {
662 let pred = GatePredicate {
663 verify: Some(S760_CLEAR_DECISION_CLEAR_TRUE_MARKER.to_string()),
664 disproof: None,
665 set_at: Some(0),
666 };
667 let log = "[150] [s760] clear-decision optIn=true threshold=50 pct=49.9 clear=true\n";
668 assert_eq!(
669 scan_ops_log(&pred, log),
670 VerifyOutcome::Failed {
671 marker: S760_CLEAR_DECISION_INVALID_MARKER.to_string(),
672 at: 150
673 }
674 );
675 }
676
677 #[test]
678 fn scan_s760_clear_decision_fails_clear_false_at_threshold() {
679 let pred = GatePredicate {
680 verify: Some(S760_CLEAR_DECISION_CLEAR_TRUE_MARKER.to_string()),
681 disproof: None,
682 set_at: Some(0),
683 };
684 let log = "[150] [s760] clear-decision optIn=true threshold=50 pct=50.0 clear=false\n";
685 assert_eq!(
686 scan_ops_log(&pred, log),
687 VerifyOutcome::Failed {
688 marker: S760_CLEAR_DECISION_INVALID_MARKER.to_string(),
689 at: 150
690 }
691 );
692 }
693
694 #[test]
695 fn strip_quoted_spans_handles_escaped_quotes_and_unterminated() {
696 assert_eq!(
697 strip_quoted_spans("a=\"text \\\"inner\\\" more\" tail marker"),
698 "a=\"\" tail marker"
699 );
700 assert_eq!(strip_quoted_spans("a=\"unterminated marker"), "a=\"");
702 assert_eq!(strip_quoted_spans("no quotes marker"), "no quotes marker");
703 }
704
705 #[test]
706 fn annotation_round_trips_with_spaces() {
707 let pred = GatePredicate {
708 verify: Some("early_receipt_accepted".to_string()),
709 disproof: Some("false receipt-timeout".to_string()),
710 set_at: Some(1749526200),
711 };
712 let text = format!("early receipt live verify {}", render_annotation(&pred));
713 let parsed = parse_gate_predicate(&text).unwrap();
714 assert_eq!(parsed, pred);
715 }
716
717 #[test]
718 fn parse_returns_none_without_annotation() {
719 assert_eq!(parse_gate_predicate("plain item text"), None);
720 }
721
722 #[test]
723 fn strip_annotation_removes_and_trims() {
724 let pred = GatePredicate {
725 verify: Some("m".to_string()),
726 disproof: None,
727 set_at: Some(5),
728 };
729 let text = format!("body text {}", render_annotation(&pred));
730 assert_eq!(strip_annotation(&text), "body text");
731 }
732
733 #[test]
734 fn upsert_is_idempotent() {
735 let pred = GatePredicate {
736 verify: Some("m".to_string()),
737 disproof: None,
738 set_at: Some(5),
739 };
740 let once = upsert_annotation("body", &pred);
741 let twice = upsert_annotation(&once, &pred);
742 assert_eq!(once, twice);
743 assert!(twice.starts_with("body "));
744 assert_eq!(parse_gate_predicate(&twice).unwrap(), pred);
745 }
746
747 #[test]
748 fn upsert_replaces_existing_predicate() {
749 let first = GatePredicate {
750 verify: Some("old".to_string()),
751 disproof: None,
752 set_at: Some(1),
753 };
754 let second = GatePredicate {
755 verify: Some("new".to_string()),
756 disproof: Some("bad".to_string()),
757 set_at: Some(2),
758 };
759 let text = upsert_annotation(&upsert_annotation("body", &first), &second);
760 let parsed = parse_gate_predicate(&text).unwrap();
761 assert_eq!(parsed, second);
762 assert_eq!(text.matches(ANNOTATION_OPEN).count(), 1);
764 }
765
766 #[test]
767 fn parse_predicate_spec_strips_ops_log_prefix() {
768 let pred = parse_predicate_spec(
769 "verify=ops_log:early_receipt_accepted;disproof=ops_log:false receipt-timeout",
770 );
771 assert_eq!(pred.verify.as_deref(), Some("early_receipt_accepted"));
772 assert_eq!(pred.disproof.as_deref(), Some("false receipt-timeout"));
773 assert_eq!(pred.set_at, None);
774 }
775
776 #[test]
777 fn parse_predicate_spec_verify_only() {
778 let pred = parse_predicate_spec("verify=marker_x");
779 assert_eq!(pred.verify.as_deref(), Some("marker_x"));
780 assert_eq!(pred.disproof, None);
781 }
782
783 #[test]
784 fn is_actionable_requires_a_matcher() {
785 assert!(!GatePredicate::default().is_actionable());
786 assert!(
787 GatePredicate {
788 verify: Some("x".to_string()),
789 ..Default::default()
790 }
791 .is_actionable()
792 );
793 }
794}