Skip to main content

agent_doc_element_backlog/
gate_verify.rs

1//! # Module: gate_verify
2//!
3//! Typed proof/disproof predicates for gated `[/]` review items (`#optverify`).
4//!
5//! A gated review item often names an exact `ops.log` marker that PROVES it
6//! (e.g. `live_prompt_drift_auto_recovered`) and an exact string that DISPROVES
7//! it (e.g. `looks like a manual cleanup`). When the gate carries those as a
8//! typed predicate, the binary can decide proof/disproof itself every time it
9//! already reads `ops.log` — turning a standing human gate into an automatic
10//! check (no dedicated live-verify session).
11//!
12//! ## Persistence (`#optv1`)
13//!
14//! The predicate is carried inline on the item text as an HTML-comment
15//! annotation so it round-trips verbatim through the pending parser without
16//! touching the `[/<gate_type>]` checkbox grammar:
17//!
18//! ```text
19//! - [/] [#saev] early receipt ... <!-- gate-verify verify="early_receipt_accepted" disproof="false receipt-timeout" set_at="1749526200" -->
20//! ```
21//!
22//! Only markers emitted **at or after** `set_at` count, so a stale pre-gate
23//! marker can never falsely prove a freshly re-opened gate.
24//!
25//! ## Scan (`#optv2`)
26//!
27//! [`scan_ops_log`] is a pure function over `(predicate, ops.log content)` →
28//! [`VerifyOutcome`]. Disproof wins ties (a disproved gate must never
29//! auto-resolve); neither marker → `Pending`.
30//!
31//! ## Prose exclusion (`#gng8`)
32//!
33//! Several ops.log entries embed *document content* (for example
34//! `queue_diff_active_prompt_differs ... prompt_changes=["..."]`, route
35//! `prompt={:?}`, session `tail={:?}`). Backlog or response text that merely
36//! *mentions* a marker token would otherwise prove the gate from its own
37//! description. Embedded content always arrives through `{:?}` debug
38//! formatting, which wraps strings in double quotes — so the scan strips
39//! double-quoted spans from each message before matching. Structured marker
40//! emissions (`[claim] cross-session-reject pane_id=...`,
41//! `[ipc-socket] early_receipt_accepted ...`, `[s760] clear-decision ...`) are
42//! plain unquoted message text and must stay that way to remain provable. The
43//! built-in `s760_clear_decision_clear_true` verifier is stricter than a
44//! substring: it accepts only anchored `[<epoch>] [s760] clear-decision ...`
45//! lines and checks `optIn=true`, `pct >= threshold`, and `clear=true`.
46
47use serde::{Deserialize, Serialize};
48
49/// Marker used to delimit the inline predicate annotation.
50const ANNOTATION_OPEN: &str = "<!-- gate-verify";
51const ANNOTATION_CLOSE: &str = "-->";
52
53/// Built-in verifier for the `#s760` destructive clear proof. Use in a predicate
54/// as `verify=ops_log:s760_clear_decision_clear_true`.
55pub 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/// Typed proof/disproof predicate carried inline on a gated `[/]` item.
59///
60/// `verify` / `disproof` are matched as plain substrings against `ops.log`
61/// message bodies (the text after the `[<epoch>] ` prefix). `set_at` is the
62/// gate-set time in epoch seconds; only markers at or after it are considered.
63#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
64pub struct GatePredicate {
65    /// ops.log substring that PROVES the gate.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub verify: Option<String>,
68    /// ops.log substring that DISPROVES the gate (disproof wins ties).
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub disproof: Option<String>,
71    /// Gate-set time as epoch seconds; only markers at/after this count.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub set_at: Option<u64>,
74}
75
76impl GatePredicate {
77    /// True when the predicate carries at least one usable matcher.
78    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/// Result of scanning `ops.log` for a gate predicate.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
86#[serde(tag = "status", rename_all = "lowercase")]
87pub enum VerifyOutcome {
88    /// Proof marker seen after the gate time, no disproof.
89    Provable {
90        /// The matched proof substring.
91        marker: String,
92        /// Epoch seconds of the earliest qualifying proof line.
93        at: u64,
94    },
95    /// Disproof marker seen after the gate time (disproof wins).
96    Failed {
97        /// The matched disproof substring.
98        marker: String,
99        /// Epoch seconds of the earliest qualifying disproof line.
100        at: u64,
101    },
102    /// Neither marker seen after the gate time.
103    Pending,
104}
105
106impl VerifyOutcome {
107    /// Lowercase status discriminant (`provable` / `failed` / `pending`).
108    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
117/// Parse `[<timestamp>] <message>` into `(epoch, message)`. The bracketed
118/// timestamp may be a bare Unix epoch (pre-`#opslogts` lines) or an ISO-8601
119/// UTC string; both resolve to epoch seconds via
120/// [`agent_doc_log_time::parse_log_timestamp`]. Returns `None` for lines that do
121/// not start with a parseable timestamp bracket.
122fn 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
130/// Strip double-quoted spans from an ops.log message (`#gng8`).
131///
132/// Content-logging entries embed document/prompt text via `{:?}` debug
133/// formatting, which always wraps strings in double quotes with `\"` escapes.
134/// A marker token inside such a span is embedded prose, not a structured
135/// emission, so it must not prove or disprove a gate. An unterminated quote
136/// drops the rest of the line (fail safe: malformed content stays excluded).
137fn 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
163/// Scan `ops.log` content for a gate predicate (`#optv2`, pure).
164///
165/// Decision matrix (only markers at/after `set_at` count):
166/// - disproof substring seen anywhere → [`VerifyOutcome::Failed`] (disproof wins)
167/// - else proof substring seen → [`VerifyOutcome::Provable`]
168/// - else → [`VerifyOutcome::Pending`]
169///
170/// Both `Provable` and `Failed` report the earliest qualifying line so repeated
171/// markers are stable. Markers are matched against the message with
172/// double-quoted spans stripped ([`strip_quoted_spans`], `#gng8`) so embedded
173/// document prose cannot prove a gate from its own description.
174pub 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
221/// Scan `ops.log` for a real `#s760` clear decision.
222///
223/// This is intentionally anchored to the structured ops.log message emitted by
224/// `context_pct::clear_decision` and `ops_log::log_op`: `[<epoch>] [s760]
225/// clear-decision ...`. Prose embedded in `queue_diff_active_prompt_differs` is
226/// quoted and ignored, and unanchored mentions are ignored. Invariant-breaking
227/// structured lines fail the gate:
228/// - `clear=true` while `optIn=false`, `pct=none`, or `pct < threshold`
229/// - `clear=false` while `optIn=true` and `pct >= threshold`
230fn 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
338/// Parse the inline `<!-- gate-verify ... -->` annotation out of item text.
339///
340/// Returns `None` when no annotation is present. Attribute values are
341/// double-quoted (`verify="..."`) so they may contain spaces.
342pub 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
360/// Parse `key="value"` pairs out of an attribute string. Values are everything
361/// between the quotes (spaces allowed); keys are unquoted leading identifiers.
362fn 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        // Skip to the next key character.
368        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        // Skip whitespace then expect `=`.
380        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; // consume '='
387        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; // consume opening quote
394        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; // consume closing quote
400        out.push((key, value));
401    }
402    out
403}
404
405/// Render the predicate to its canonical inline annotation. Empty fields are
406/// omitted. Returns an empty string when the predicate has no fields at all.
407pub 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
424/// Strip any existing `<!-- gate-verify ... -->` annotation from item text,
425/// trimming the trailing whitespace it leaves behind.
426pub 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
447/// Replace (or insert) the gate-verify annotation in item text. Existing
448/// annotations are removed first so the operation is idempotent.
449pub 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
461/// Parse a `verify=...;disproof=...` predicate spec (the value half of a CLI
462/// `--pending-set-verify id=<spec>` argument) into a [`GatePredicate`].
463///
464/// Accepts `ops_log:` prefixes on the values (stripped) so the operator can
465/// write `verify=ops_log:marker` matching the plan vocabulary. `set_at` is not
466/// accepted here — it is stamped by the caller at write time.
467pub 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        // #gng8: queue_diff_active_prompt_differs logs document content via
581        // {:?}, so a backlog item *describing* the marker must not prove it.
582        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        // Unterminated quote drops the rest of the line (fail safe).
701        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        // Only one annotation present.
763        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}