Skip to main content

a3s_code_core/session_review/
mod.rs

1//! Durable session-scoped review findings for sticky reply / multi-scenario review.
2//!
3//! # First principles
4//!
5//! Review exists to independently verify claim↔record, persist findings beside
6//! the session transcript, and drive real correction — not to paint a local
7//! "resolved" badge.
8//!
9//! Lifecycle (host-enforced):
10//! `pending` → `addressed` (main agent) → `accepted` (reviewer) |
11//! `pending` ← `reopen` from `addressed` | `waived` from `pending`.
12//!
13//! Only `pending` findings whose registered [`ReviewScenario`] opts into
14//! `injects_into_main` are prefixed into the next main-agent prompt.
15//!
16//! # Core vs extension
17//!
18//! - **Core:** lifecycle, [`SessionReviewStoreV1`] snapshot field, inject fence,
19//!   [`ReviewSubjectV1`], scenario registry APIs.
20//! - **Extensions:** [`ReviewScenario`] implementations (hosts / Use packages)
21//!   own rubrics and evidence collection. Core never matches on scenario
22//!   business rules beyond registry lookup.
23//!
24//! Distinct from [`crate::research`] review contracts: those bind research
25//! artifacts and digests.
26
27mod error;
28mod finding;
29mod scenario;
30mod store;
31mod subject;
32
33pub use error::SessionReviewError;
34pub use finding::{
35    SessionReviewAnchorV1, SessionReviewFindingV1, SessionReviewSeverityV1, SessionReviewStatusV1,
36    SESSION_REVIEW_FINDING_SCHEMA_V1,
37};
38pub use scenario::{
39    admit_finding_drafts, register_default_scenarios, ReplyTranscriptScenario, ReviewFindingDraft,
40    ReviewScenario, ReviewScenarioRegistry, ReviewTriggerPolicy, SCENARIO_REPLY_TRANSCRIPT,
41};
42pub use store::{SessionReviewStoreV1, SESSION_REVIEW_STORE_SCHEMA_V1};
43pub use subject::ReviewSubjectV1;
44
45/// Maximum JSON payload for one finding or store document.
46pub const SESSION_REVIEW_MAX_MESSAGE_BYTES: usize = 2 * 1024 * 1024;
47pub const SESSION_REVIEW_MAX_ID_BYTES: usize = 256;
48pub const SESSION_REVIEW_MAX_TEXT_BYTES: usize = 16 * 1024;
49pub const SESSION_REVIEW_MAX_FINDINGS: usize = 256;
50
51pub(crate) fn decode_json_slice<T>(bytes: &[u8]) -> Result<T, SessionReviewError>
52where
53    T: serde::de::DeserializeOwned,
54{
55    if bytes.len() > SESSION_REVIEW_MAX_MESSAGE_BYTES {
56        return Err(SessionReviewError::Encoding);
57    }
58    serde_json::from_slice(bytes)
59        .map_err(|error| SessionReviewError::Serialization(error.to_string()))
60}
61
62pub(crate) fn encode_json<T: serde::Serialize + ?Sized>(
63    value: &T,
64) -> Result<Vec<u8>, SessionReviewError> {
65    let bytes = serde_json::to_vec(value)
66        .map_err(|error| SessionReviewError::Serialization(error.to_string()))?;
67    if bytes.len() > SESSION_REVIEW_MAX_MESSAGE_BYTES {
68        return Err(SessionReviewError::Encoding);
69    }
70    Ok(bytes)
71}
72
73pub(crate) fn validate_id(field: &'static str, value: &str) -> Result<(), SessionReviewError> {
74    if value.is_empty()
75        || value.len() > SESSION_REVIEW_MAX_ID_BYTES
76        || value.contains('\0')
77        || value.contains(['\r', '\n'])
78    {
79        return Err(SessionReviewError::InvalidField(field));
80    }
81    Ok(())
82}
83
84/// Claim / evidence / suggestion may contain newlines; reject NUL and emptiness.
85pub(crate) fn validate_multiline_text(
86    field: &'static str,
87    value: &str,
88    max_bytes: usize,
89) -> Result<(), SessionReviewError> {
90    if value.is_empty() || value.len() > max_bytes || value.contains('\0') {
91        return Err(SessionReviewError::InvalidField(field));
92    }
93    Ok(())
94}
95
96/// Truncate on a UTF-8 boundary so long address replies still validate.
97pub(crate) fn clamp_session_review_text(value: &str, max_bytes: usize) -> String {
98    if value.len() <= max_bytes {
99        return value.to_owned();
100    }
101    let mut end = max_bytes.saturating_sub("…".len());
102    while end > 0 && !value.is_char_boundary(end) {
103        end -= 1;
104    }
105    format!("{}…", &value[..end])
106}