Skip to main content

a3s_code_core/session_review/
finding.rs

1//! Session-scoped review findings bound to a typed [`ReviewSubjectV1`].
2//!
3//! Lifecycle (host-enforced via these transitions):
4//! `pending` → `addressed` (main agent) → `accepted` (reviewer) |
5//! `pending` ← `reopen` from `addressed` | `waived` from `pending`.
6//!
7//! Scenarios tag findings with `scenario_id`; Core does not interpret rubrics.
8
9use super::scenario::SCENARIO_REPLY_TRANSCRIPT;
10use super::subject::ReviewSubjectV1;
11use super::{
12    clamp_session_review_text, validate_id, validate_multiline_text, SessionReviewError,
13    SESSION_REVIEW_MAX_TEXT_BYTES,
14};
15use serde::{Deserialize, Serialize};
16
17pub const SESSION_REVIEW_FINDING_SCHEMA_V1: &str = "a3s.code.session-review-finding.v1";
18
19fn default_scenario_id() -> String {
20    SCENARIO_REPLY_TRANSCRIPT.to_owned()
21}
22
23/// Durable finding lifecycle for sticky reply / multi-scenario review.
24#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum SessionReviewStatusV1 {
27    /// Default: inject into the next main-agent turn until addressed or waived.
28    Pending,
29    /// Main agent claimed this finding; awaiting reviewer acceptance.
30    Addressed,
31    /// Reviewer accepted the address; terminal.
32    Accepted,
33    /// Human or host waived; terminal.
34    Waived,
35}
36
37impl SessionReviewStatusV1 {
38    pub const fn as_str(self) -> &'static str {
39        match self {
40            Self::Pending => "pending",
41            Self::Addressed => "addressed",
42            Self::Accepted => "accepted",
43            Self::Waived => "waived",
44        }
45    }
46
47    pub const fn is_terminal(self) -> bool {
48        matches!(self, Self::Accepted | Self::Waived)
49    }
50
51    /// Sticky prompt injection includes only pending findings.
52    pub const fn injects_into_main_prompt(self) -> bool {
53        matches!(self, Self::Pending)
54    }
55}
56
57/// Product-neutral severity for host routing / annotation chrome.
58#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum SessionReviewSeverityV1 {
61    Info,
62    Warning,
63    Error,
64    Blocker,
65}
66
67/// Durable pointer into the session transcript (no message body copy).
68#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct SessionReviewAnchorV1 {
71    /// Host-stable id for the reviewed assistant turn / bubble.
72    pub turn_id: String,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub run_id: Option<String>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub char_start: Option<u32>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub char_end: Option<u32>,
79}
80
81impl SessionReviewAnchorV1 {
82    pub fn new(turn_id: impl Into<String>) -> Result<Self, SessionReviewError> {
83        let anchor = Self {
84            turn_id: turn_id.into(),
85            run_id: None,
86            char_start: None,
87            char_end: None,
88        };
89        anchor.validate()?;
90        Ok(anchor)
91    }
92
93    pub fn with_run_id(mut self, run_id: impl Into<String>) -> Result<Self, SessionReviewError> {
94        let run_id = run_id.into();
95        validate_id("anchor.runId", &run_id)?;
96        self.run_id = Some(run_id);
97        Ok(self)
98    }
99
100    pub fn with_span(mut self, start: u32, end: u32) -> Result<Self, SessionReviewError> {
101        if end < start {
102            return Err(SessionReviewError::InvalidField("anchor.charEnd"));
103        }
104        self.char_start = Some(start);
105        self.char_end = Some(end);
106        Ok(self)
107    }
108
109    pub fn validate(&self) -> Result<(), SessionReviewError> {
110        validate_id("anchor.turnId", &self.turn_id)?;
111        if let Some(run_id) = &self.run_id {
112            validate_id("anchor.runId", run_id)?;
113        }
114        match (self.char_start, self.char_end) {
115            (None, None) => Ok(()),
116            (Some(start), Some(end)) if end >= start => Ok(()),
117            (Some(_), Some(_)) => Err(SessionReviewError::InvalidField("anchor.charEnd")),
118            (Some(_), None) | (None, Some(_)) => {
119                Err(SessionReviewError::InvalidField("anchor.span"))
120            }
121        }
122    }
123
124    /// Strong annotation requires a character span on the turn.
125    pub const fn supports_strong_annotation(&self) -> bool {
126        self.char_start.is_some() && self.char_end.is_some()
127    }
128}
129
130/// One durable session review observation (multi-scenario envelope).
131#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub struct SessionReviewFindingV1 {
134    pub schema: String,
135    pub finding_id: String,
136    pub session_id: String,
137    /// Which registered review scenario produced this finding.
138    #[serde(default = "default_scenario_id")]
139    pub scenario_id: String,
140    pub status: SessionReviewStatusV1,
141    pub severity: SessionReviewSeverityV1,
142    pub category: String,
143    pub claim: String,
144    pub evidence: String,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub quote: Option<String>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub suggestion: Option<String>,
149    pub subject: ReviewSubjectV1,
150    /// Review job / batch that produced this finding.
151    pub source_review_id: String,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub addressed_by_run_id: Option<String>,
154    /// Main-agent address reply shown on annotation cards after Address.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub main_agent_reply: Option<String>,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub accepted_by_review_id: Option<String>,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub reopen_reason: Option<String>,
161    pub observed_at_ms: u64,
162    pub updated_at_ms: u64,
163}
164
165impl SessionReviewFindingV1 {
166    #[allow(clippy::too_many_arguments)]
167    pub fn new(
168        finding_id: impl Into<String>,
169        session_id: impl Into<String>,
170        severity: SessionReviewSeverityV1,
171        category: impl Into<String>,
172        claim: impl Into<String>,
173        evidence: impl Into<String>,
174        subject: ReviewSubjectV1,
175        source_review_id: impl Into<String>,
176        observed_at_ms: u64,
177    ) -> Result<Self, SessionReviewError> {
178        let finding = Self {
179            schema: SESSION_REVIEW_FINDING_SCHEMA_V1.to_owned(),
180            finding_id: finding_id.into(),
181            session_id: session_id.into(),
182            scenario_id: default_scenario_id(),
183            status: SessionReviewStatusV1::Pending,
184            severity,
185            category: category.into(),
186            claim: claim.into(),
187            evidence: evidence.into(),
188            quote: None,
189            suggestion: None,
190            subject,
191            source_review_id: source_review_id.into(),
192            addressed_by_run_id: None,
193            main_agent_reply: None,
194            accepted_by_review_id: None,
195            reopen_reason: None,
196            observed_at_ms,
197            updated_at_ms: observed_at_ms,
198        };
199        finding.validate()?;
200        Ok(finding)
201    }
202
203    /// Convenience for transcript-bound findings (Desktop sticky / science).
204    #[allow(clippy::too_many_arguments)]
205    pub fn new_transcript(
206        finding_id: impl Into<String>,
207        session_id: impl Into<String>,
208        severity: SessionReviewSeverityV1,
209        category: impl Into<String>,
210        claim: impl Into<String>,
211        evidence: impl Into<String>,
212        anchor: SessionReviewAnchorV1,
213        source_review_id: impl Into<String>,
214        observed_at_ms: u64,
215    ) -> Result<Self, SessionReviewError> {
216        Self::new(
217            finding_id,
218            session_id,
219            severity,
220            category,
221            claim,
222            evidence,
223            ReviewSubjectV1::transcript(anchor),
224            source_review_id,
225            observed_at_ms,
226        )
227    }
228
229    pub fn with_scenario_id(
230        mut self,
231        scenario_id: impl Into<String>,
232    ) -> Result<Self, SessionReviewError> {
233        let scenario_id = scenario_id.into();
234        validate_id("scenarioId", &scenario_id)?;
235        self.scenario_id = scenario_id;
236        Ok(self)
237    }
238
239    pub fn with_quote(mut self, quote: impl Into<String>) -> Result<Self, SessionReviewError> {
240        let quote = quote.into();
241        validate_multiline_text("quote", &quote, SESSION_REVIEW_MAX_TEXT_BYTES)?;
242        self.quote = Some(quote);
243        Ok(self)
244    }
245
246    pub fn with_suggestion(
247        mut self,
248        suggestion: impl Into<String>,
249    ) -> Result<Self, SessionReviewError> {
250        let suggestion = suggestion.into();
251        validate_multiline_text("suggestion", &suggestion, SESSION_REVIEW_MAX_TEXT_BYTES)?;
252        self.suggestion = Some(suggestion);
253        Ok(self)
254    }
255
256    pub fn transcript_anchor(&self) -> Option<&SessionReviewAnchorV1> {
257        self.subject.as_transcript_anchor()
258    }
259
260    pub fn validate(&self) -> Result<(), SessionReviewError> {
261        if self.schema != SESSION_REVIEW_FINDING_SCHEMA_V1 {
262            return Err(SessionReviewError::UnsupportedSchema);
263        }
264        validate_id("findingId", &self.finding_id)?;
265        validate_id("sessionId", &self.session_id)?;
266        validate_id("scenarioId", &self.scenario_id)?;
267        validate_id("category", &self.category)?;
268        validate_multiline_text("claim", &self.claim, SESSION_REVIEW_MAX_TEXT_BYTES)?;
269        validate_multiline_text("evidence", &self.evidence, SESSION_REVIEW_MAX_TEXT_BYTES)?;
270        if let Some(quote) = &self.quote {
271            validate_multiline_text("quote", quote, SESSION_REVIEW_MAX_TEXT_BYTES)?;
272        }
273        if let Some(suggestion) = &self.suggestion {
274            validate_multiline_text("suggestion", suggestion, SESSION_REVIEW_MAX_TEXT_BYTES)?;
275        }
276        if let Some(reply) = &self.main_agent_reply {
277            validate_multiline_text("mainAgentReply", reply, SESSION_REVIEW_MAX_TEXT_BYTES)?;
278        }
279        self.subject.validate()?;
280        validate_id("sourceReviewId", &self.source_review_id)?;
281        if self.observed_at_ms == 0 {
282            return Err(SessionReviewError::InvalidField("observedAtMs"));
283        }
284        if self.updated_at_ms < self.observed_at_ms {
285            return Err(SessionReviewError::InvalidField("updatedAtMs"));
286        }
287        match self.status {
288            SessionReviewStatusV1::Pending => {
289                if self.addressed_by_run_id.is_some() {
290                    return Err(SessionReviewError::InvalidField("addressedByRunId"));
291                }
292                if self.accepted_by_review_id.is_some() {
293                    return Err(SessionReviewError::InvalidField("acceptedByReviewId"));
294                }
295            }
296            SessionReviewStatusV1::Addressed => {
297                match &self.addressed_by_run_id {
298                    Some(run_id) => validate_id("addressedByRunId", run_id)?,
299                    None => return Err(SessionReviewError::InvalidField("addressedByRunId")),
300                }
301                if self.accepted_by_review_id.is_some() {
302                    return Err(SessionReviewError::InvalidField("acceptedByReviewId"));
303                }
304            }
305            SessionReviewStatusV1::Accepted => {
306                match &self.addressed_by_run_id {
307                    Some(run_id) => validate_id("addressedByRunId", run_id)?,
308                    None => return Err(SessionReviewError::InvalidField("addressedByRunId")),
309                }
310                match &self.accepted_by_review_id {
311                    Some(review_id) => validate_id("acceptedByReviewId", review_id)?,
312                    None => return Err(SessionReviewError::InvalidField("acceptedByReviewId")),
313                }
314            }
315            SessionReviewStatusV1::Waived => {
316                if self.accepted_by_review_id.is_some() {
317                    return Err(SessionReviewError::InvalidField("acceptedByReviewId"));
318                }
319            }
320        }
321        if let Some(reason) = &self.reopen_reason {
322            validate_multiline_text("reopenReason", reason, SESSION_REVIEW_MAX_TEXT_BYTES)?;
323        }
324        Ok(())
325    }
326
327    /// Main agent finished handling this finding.
328    pub fn mark_addressed(
329        &mut self,
330        run_id: impl Into<String>,
331        at_ms: u64,
332    ) -> Result<(), SessionReviewError> {
333        self.validate()?;
334        if !matches!(self.status, SessionReviewStatusV1::Pending) {
335            return Err(SessionReviewError::InvalidTransition {
336                from: self.status.as_str(),
337                to: SessionReviewStatusV1::Addressed.as_str(),
338            });
339        }
340        let run_id = run_id.into();
341        validate_id("addressedByRunId", &run_id)?;
342        if at_ms < self.updated_at_ms {
343            return Err(SessionReviewError::InvalidField("updatedAtMs"));
344        }
345        self.status = SessionReviewStatusV1::Addressed;
346        self.addressed_by_run_id = Some(run_id);
347        self.accepted_by_review_id = None;
348        self.reopen_reason = None;
349        self.updated_at_ms = at_ms;
350        self.validate()
351    }
352
353    /// Persist the main-agent address reply for UI annotation cards.
354    pub fn set_main_agent_reply(
355        &mut self,
356        reply: impl Into<String>,
357    ) -> Result<(), SessionReviewError> {
358        let reply = clamp_session_review_text(reply.into().trim(), SESSION_REVIEW_MAX_TEXT_BYTES);
359        if reply.is_empty() {
360            self.main_agent_reply = None;
361            return Ok(());
362        }
363        validate_multiline_text("mainAgentReply", &reply, SESSION_REVIEW_MAX_TEXT_BYTES)?;
364        self.main_agent_reply = Some(reply);
365        Ok(())
366    }
367
368    /// Reviewer accepted the main-agent address.
369    pub fn accept(
370        &mut self,
371        review_id: impl Into<String>,
372        at_ms: u64,
373    ) -> Result<(), SessionReviewError> {
374        self.validate()?;
375        if !matches!(self.status, SessionReviewStatusV1::Addressed) {
376            return Err(SessionReviewError::InvalidTransition {
377                from: self.status.as_str(),
378                to: SessionReviewStatusV1::Accepted.as_str(),
379            });
380        }
381        let review_id = review_id.into();
382        validate_id("acceptedByReviewId", &review_id)?;
383        if at_ms < self.updated_at_ms {
384            return Err(SessionReviewError::InvalidField("updatedAtMs"));
385        }
386        self.status = SessionReviewStatusV1::Accepted;
387        self.accepted_by_review_id = Some(review_id);
388        self.reopen_reason = None;
389        self.updated_at_ms = at_ms;
390        self.validate()
391    }
392
393    /// Reviewer rejected the address; return to pending with a reason.
394    pub fn reopen(
395        &mut self,
396        reason: impl Into<String>,
397        at_ms: u64,
398    ) -> Result<(), SessionReviewError> {
399        self.validate()?;
400        if !matches!(self.status, SessionReviewStatusV1::Addressed) {
401            return Err(SessionReviewError::InvalidTransition {
402                from: self.status.as_str(),
403                to: SessionReviewStatusV1::Pending.as_str(),
404            });
405        }
406        let reason = reason.into();
407        validate_multiline_text("reopenReason", &reason, SESSION_REVIEW_MAX_TEXT_BYTES)?;
408        if at_ms < self.updated_at_ms {
409            return Err(SessionReviewError::InvalidField("updatedAtMs"));
410        }
411        self.status = SessionReviewStatusV1::Pending;
412        self.addressed_by_run_id = None;
413        self.main_agent_reply = None;
414        self.accepted_by_review_id = None;
415        self.reopen_reason = Some(reason);
416        self.updated_at_ms = at_ms;
417        self.validate()
418    }
419
420    /// Human / host waived without requiring main-agent address.
421    pub fn waive(&mut self, at_ms: u64) -> Result<(), SessionReviewError> {
422        self.validate()?;
423        if !matches!(self.status, SessionReviewStatusV1::Pending) {
424            return Err(SessionReviewError::InvalidTransition {
425                from: self.status.as_str(),
426                to: SessionReviewStatusV1::Waived.as_str(),
427            });
428        }
429        if at_ms < self.updated_at_ms {
430            return Err(SessionReviewError::InvalidField("updatedAtMs"));
431        }
432        self.status = SessionReviewStatusV1::Waived;
433        self.addressed_by_run_id = None;
434        self.main_agent_reply = None;
435        self.accepted_by_review_id = None;
436        self.reopen_reason = None;
437        self.updated_at_ms = at_ms;
438        self.validate()
439    }
440
441    pub fn from_slice(bytes: &[u8]) -> Result<Self, SessionReviewError> {
442        let finding: Self = super::decode_json_slice(bytes)?;
443        finding.validate()?;
444        Ok(finding)
445    }
446
447    pub fn to_vec(&self) -> Result<Vec<u8>, SessionReviewError> {
448        self.validate()?;
449        super::encode_json(self)
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    fn sample_finding() -> SessionReviewFindingV1 {
458        let anchor = SessionReviewAnchorV1::new("turn-1")
459            .unwrap()
460            .with_span(10, 20)
461            .unwrap();
462        SessionReviewFindingV1::new_transcript(
463            "finding-1",
464            "session-1",
465            SessionReviewSeverityV1::Warning,
466            "numeric",
467            "Claimed p=0.01 without tool output",
468            "No tool record contained a p-value",
469            anchor,
470            "review-job-1",
471            1_000,
472        )
473        .unwrap()
474    }
475
476    #[test]
477    fn new_finding_defaults_to_pending_and_injects() {
478        let finding = sample_finding();
479        assert_eq!(finding.status, SessionReviewStatusV1::Pending);
480        assert_eq!(finding.scenario_id, SCENARIO_REPLY_TRANSCRIPT);
481        assert!(finding.status.injects_into_main_prompt());
482        assert!(finding.subject.supports_strong_annotation());
483    }
484
485    #[test]
486    fn lifecycle_pending_addressed_accepted() {
487        let mut finding = sample_finding();
488        finding.mark_addressed("run-2", 1_100).unwrap();
489        assert_eq!(finding.status, SessionReviewStatusV1::Addressed);
490        assert!(!finding.status.injects_into_main_prompt());
491        finding.accept("review-job-2", 1_200).unwrap();
492        assert_eq!(finding.status, SessionReviewStatusV1::Accepted);
493        assert!(finding.status.is_terminal());
494    }
495
496    #[test]
497    fn lifecycle_addressed_reopen_returns_pending_with_reason() {
498        let mut finding = sample_finding();
499        finding.mark_addressed("run-2", 1_100).unwrap();
500        finding
501            .set_main_agent_reply("Recomputed the count via tool:1")
502            .unwrap();
503        assert_eq!(
504            finding.main_agent_reply.as_deref(),
505            Some("Recomputed the count via tool:1")
506        );
507        finding
508            .reopen("p-value still missing from evidence", 1_200)
509            .unwrap();
510        assert_eq!(finding.status, SessionReviewStatusV1::Pending);
511        assert!(finding.status.injects_into_main_prompt());
512        assert_eq!(
513            finding.reopen_reason.as_deref(),
514            Some("p-value still missing from evidence")
515        );
516        assert!(finding.addressed_by_run_id.is_none());
517        assert!(finding.main_agent_reply.is_none());
518    }
519
520    #[test]
521    fn waive_from_pending_is_terminal() {
522        let mut finding = sample_finding();
523        finding.waive(1_100).unwrap();
524        assert_eq!(finding.status, SessionReviewStatusV1::Waived);
525        assert!(finding.status.is_terminal());
526        assert!(!finding.status.injects_into_main_prompt());
527    }
528
529    #[test]
530    fn reject_accept_without_address_and_waive_from_addressed() {
531        let mut finding = sample_finding();
532        assert!(matches!(
533            finding.accept("review-2", 1_100),
534            Err(SessionReviewError::InvalidTransition { .. })
535        ));
536        finding.mark_addressed("run-2", 1_100).unwrap();
537        assert!(matches!(
538            finding.waive(1_200),
539            Err(SessionReviewError::InvalidTransition { .. })
540        ));
541    }
542
543    #[test]
544    fn claim_may_contain_newlines_but_not_nul() {
545        let anchor = SessionReviewAnchorV1::new("turn-1").unwrap();
546        let ok = SessionReviewFindingV1::new_transcript(
547            "finding-2",
548            "session-1",
549            SessionReviewSeverityV1::Info,
550            "citation",
551            "line1\nline2",
552            "evidence ok",
553            anchor.clone(),
554            "review-1",
555            1,
556        );
557        assert!(ok.is_ok());
558        let bad = SessionReviewFindingV1::new_transcript(
559            "finding-3",
560            "session-1",
561            SessionReviewSeverityV1::Info,
562            "citation",
563            "bad\0claim",
564            "evidence ok",
565            anchor,
566            "review-1",
567            1,
568        );
569        assert_eq!(bad, Err(SessionReviewError::InvalidField("claim")));
570    }
571
572    #[test]
573    fn round_trip_wire_helpers() {
574        let finding = sample_finding();
575        let bytes = finding.to_vec().unwrap();
576        let decoded = SessionReviewFindingV1::from_slice(&bytes).unwrap();
577        assert_eq!(decoded, finding);
578    }
579
580    #[test]
581    fn span_requires_both_ends_and_order() {
582        let anchor = SessionReviewAnchorV1::new("turn-1").unwrap();
583        assert!(anchor.clone().with_span(5, 4).is_err());
584        let mut partial = anchor;
585        partial.char_start = Some(1);
586        assert_eq!(
587            partial.validate(),
588            Err(SessionReviewError::InvalidField("anchor.span"))
589        );
590    }
591}