diff --git a/crates/tracedecay-automation-runtime/src/automation/runner/session_reflector.rs b/crates/tracedecay-automation-runtime/src/automation/runner/session_reflector.rs index f1b0685aed..633f73e87c 100644 --- a/crates/tracedecay-automation-runtime/src/automation/runner/session_reflector.rs +++ b/crates/tracedecay-automation-runtime/src/automation/runner/session_reflector.rs @@ -299,7 +299,7 @@ pub(super) fn build_session_reflector_prompt(evidence: &Value) -> String { "\n", "Proposing nothing is a real option when the session ran smoothly and revealed nothing durable, but do not reach for it as a default.\n", "\n", - "Response contract: Return only JSON with a facts array. Each fact must include content, category, tags, entities, trust, source_span, and reason; use empty arrays when there are no tags or entities. Category must be one of general, user_pref, project, tool, decision, or code_area. Use trust, not confidence; trust must be a JSON number from 0.0 to 1.0. Do not use string labels like high, medium, or low. source_span must cite one bounded evidence hit by session_id plus message_id for raw messages, by store_id for raw messages, or by node_id for summaries. Do not include secrets or ephemeral status.\n", + "Response contract: Return only JSON matching the supplied response_schema, with proposals in its facts array. That schema defines each fact's fields, types, and source-reference alternatives. Prefer numeric trust scores; supported legacy trust labels are normalized at the response boundary. Cite only a bounded evidence hit supplied here. Do not include secrets or ephemeral status.\n", ); format!( "{POLICY}{}", diff --git a/crates/tracedecay-automation-runtime/src/automation/runner/tests.rs b/crates/tracedecay-automation-runtime/src/automation/runner/tests.rs index c8cf87ffe8..6a239dd228 100644 --- a/crates/tracedecay-automation-runtime/src/automation/runner/tests.rs +++ b/crates/tracedecay-automation-runtime/src/automation/runner/tests.rs @@ -1,7 +1,10 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; -use serde_json::json; +use serde_json::{Value, json}; +use tracedecay_automation::backend::{ + AgentTaskKind, agent_task_contract, extract_response_json_object, +}; use tracedecay_contracts::retained_surfaces::MemoryAutomationFactEvidenceItemV1; use tracedecay_domain::{ FactOwnerV1, RetrievalAnchorId, RetrievalGrainV1, SessionId, TemporalCoverageCountsV1, @@ -972,6 +975,222 @@ async fn proposal_validation_canonicalizes_public_evidence_before_commit() { } } +#[tokio::test] +async fn fact_response_envelopes_preserve_valid_siblings_and_quarantine_invalid_items() { + let valid = json!({ + "content": "Bounded session evidence supports a durable project convention", + "category": "project", + "trust": " HIGH ", + "source_span": {"store_id": 7}, + "reason": "The cited user message establishes this convention" + }); + let mut proposals = vec![valid.clone()]; + for (label, field, value) in [ + ("null tags", "tags", Value::Null), + ("null entities", "entities", Value::Null), + ( + "string store identity", + "source_span", + json!({"store_id": "7"}), + ), + ( + "extra source field", + "source_span", + json!({"store_id": 7, "unknown": true}), + ), + ( + "mixed source identities", + "source_span", + json!({"store_id": 7, "session_id": "session", "message_id": "message"}), + ), + ("extra proposal field", "unknown", json!(true)), + ] { + let mut proposal = valid.clone(); + proposal["content"] = json!(format!("A proposal with {label} must not become memory")); + proposal[field] = value; + proposals.push(proposal); + } + let evidence = json!({ + "hits": [{ + "kind": "raw_message", + "session_id": "session", + "message_id": "message", + "store_id": 7 + }] + }); + let evidence_hash = canonical_evidence_hash(&evidence).expect("canonical evidence hash"); + + for task in [ + AgentTaskKind::SessionReflector, + AgentTaskKind::CombinedReview, + ] { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("memory.db"); + crate::register_test_schema_installer(); + let authority = + DatabaseAuthority::acquire_test(&path, "fact response envelope fixture").unwrap(); + let (database, _) = + Database::publish_test_runtime(&path, &authority, TestDatabaseRuntimeMode::Initialize) + .await + .unwrap(); + let memory = + MemoryApplication::new(FactOwnerV1::Profile, DatabaseFactStore::new(&database)) + .unwrap(); + let run_control = AutomationRunControl::from_interrupted(Arc::new(|| false)); + let contract = agent_task_contract(task); + let mut response = json!({"facts": proposals}); + if task == AgentTaskKind::CombinedReview { + response["skills"] = json!([]); + response["outcome"] = json!("no_skill_needed"); + response["decision"] = json!({ + "reason": "This evidence warrants a fact without a reusable skill", + "remedy": "no_action" + }); + } + let decoded = extract_response_json_object(&response.to_string(), &contract) + .expect("a malformed fact must reach per-item validation through either envelope"); + let decoded_proposals = decoded["facts"].as_array().expect("facts array"); + assert_eq!(decoded_proposals, &proposals); + let (accepted, quarantined) = + validate_session_fact_candidates(&memory, &run_control, decoded_proposals, &evidence) + .await + .unwrap(); + assert_eq!(accepted.len(), 1, "{task:?}: {quarantined:?}"); + assert_eq!(quarantined.len(), proposals.len() - 1, "{task:?}"); + for (rejection, proposal) in quarantined.iter().zip(&proposals[1..]) { + assert_eq!(&rejection["item"], proposal, "{task:?}"); + assert_eq!(rejection["validation"]["status"], "quarantined"); + assert!( + rejection["reason"] + .as_str() + .is_some_and(|reason| !reason.is_empty()) + ); + } + let canonical_item = &accepted[0]["item"]; + assert_eq!(canonical_item["content"], valid["content"]); + assert_eq!(canonical_item["tags"], json!([])); + assert_eq!(canonical_item["entities"], json!([])); + assert_eq!(canonical_item["trust"], "high"); + assert_eq!(canonical_item["source_span"], json!({"store_id": 7})); + serde_json::from_value::(canonical_item.clone()) + .expect("accepted evidence retains the public receipt grammar"); + + let applied = record_session_automatic_facts( + &memory, + &run_control, + &format!("fact-envelope-{}", contract.task_key), + Some(&evidence_hash), + &accepted, + ) + .await + .unwrap(); + assert!(applied.retry_error.is_none()); + assert_eq!(applied.receipts.len(), 1); + assert_eq!(applied.receipts[0].state, AutomaticFactState::Applied); + assert_eq!(applied.receipts[0].item.as_ref(), Some(canonical_item)); + let stored = memory + .find_exact_fact_by_content( + valid["content"].as_str().unwrap(), + run_control.read_control(), + ) + .await + .unwrap() + .expect("the valid sibling reaches canonical memory"); + let tracedecay_store::ProjectMemoryFactProjectionV1::Available(stored) = stored else { + panic!("the committed sibling must have an available payload"); + }; + assert_eq!( + applied.receipts[0].applied_fact_id.as_deref(), + Some(stored.fact_id().as_str()) + ); + for proposal in &proposals[1..] { + assert!( + memory + .find_exact_fact_by_content( + proposal["content"].as_str().unwrap(), + run_control.read_control(), + ) + .await + .unwrap() + .is_none(), + "{task:?}: a quarantined sibling must not become memory" + ); + } + } +} + +#[tokio::test] +async fn fact_response_envelopes_preserve_unicode_content_and_evidence_limits() { + let valid = json!({ + "content": "界".repeat(1_000), + "category": "project", + "tags": ["工具"], + "entities": ["TraceDecay"], + "trust": 0.8, + "source_span": {"session_id": "session", "message_id": "message"}, + "reason": "é".repeat(2_048) + }); + let mut proposals = vec![valid.clone()]; + for (field, value) in [ + ("content", json!("界".repeat(1_001))), + ( + "content", + json!("A durable fact\u{0000}with a control character"), + ), + ("reason", json!("é".repeat(2_049))), + ] { + let mut proposal = valid.clone(); + proposal[field] = value; + proposals.push(proposal); + } + let evidence = json!({ + "hits": [{"kind": "raw_message", "session_id": "session", "message_id": "message"}] + }); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("memory.db"); + crate::register_test_schema_installer(); + let authority = + DatabaseAuthority::acquire_test(&path, "fact Unicode boundary fixture").unwrap(); + let (database, _) = + Database::publish_test_runtime(&path, &authority, TestDatabaseRuntimeMode::Initialize) + .await + .unwrap(); + let memory = + MemoryApplication::new(FactOwnerV1::Profile, DatabaseFactStore::new(&database)).unwrap(); + let run_control = AutomationRunControl::from_interrupted(Arc::new(|| false)); + for task in [ + AgentTaskKind::SessionReflector, + AgentTaskKind::CombinedReview, + ] { + let mut response = json!({"facts": proposals}); + if task == AgentTaskKind::CombinedReview { + response["skills"] = json!([]); + response["outcome"] = json!("no_skill_needed"); + response["decision"] = json!({ + "reason": "This evidence warrants no reusable skill", + "remedy": "no_action" + }); + } + let decoded = + extract_response_json_object(&response.to_string(), &agent_task_contract(task)) + .expect("fact validation remains item-specific"); + let (accepted, quarantined) = validate_session_fact_candidates( + &memory, + &run_control, + decoded["facts"].as_array().unwrap(), + &evidence, + ) + .await + .unwrap(); + assert_eq!(accepted.len(), 1, "{task:?}: {quarantined:?}"); + assert_eq!(accepted[0]["item"], valid); + assert_eq!(quarantined.len(), proposals.len() - 1, "{task:?}"); + for (rejection, proposal) in quarantined.iter().zip(&proposals[1..]) { + assert_eq!(&rejection["item"], proposal, "{task:?}"); + } + } +} + #[tokio::test] async fn automatic_session_curation_records_one_terminal_effect() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/tracedecay-automation-runtime/src/automation/session_reflector.rs b/crates/tracedecay-automation-runtime/src/automation/session_reflector.rs index 9abd74015b..5940eb1b84 100644 --- a/crates/tracedecay-automation-runtime/src/automation/session_reflector.rs +++ b/crates/tracedecay-automation-runtime/src/automation/session_reflector.rs @@ -3,7 +3,11 @@ use std::future::Future; use std::pin::Pin; use serde_json::{Value, json}; -use tracedecay_domain::{Confidence, FactCategoryV1, PayloadAccessState}; +use tracedecay_contracts::automation::{ + SessionFactProposal, SessionFactSourceSpan, SessionFactTrust, +}; +use tracedecay_contracts::retained_surfaces::MemoryAutomationFactEvidenceTrustBucketV1; +use tracedecay_domain::{Confidence, PayloadAccessState}; use tracedecay_session_memory::memory::{ MemoryApplicationError, ProjectMemoryFactAddRequest, memory_application_error, }; @@ -144,34 +148,17 @@ impl EvidenceCitationSet { } } - fn contains(&self, source_span: &Value) -> bool { - let Some(span) = source_span.as_object() else { - return false; - }; - let raw_store = span.get("store_id").and_then(value_as_i64); - let raw_message = ( - span.get("session_id").and_then(Value::as_str), - span.get("message_id").and_then(Value::as_str), - ); - let summary_node = span.get("node_id").and_then(Value::as_str); - let complete_raw_message = raw_message.0.is_some() == raw_message.1.is_some(); - if !complete_raw_message - || usize::from(raw_store.is_some()) - + usize::from(raw_message.0.is_some()) - + usize::from(summary_node.is_some()) - != 1 - { - return false; + fn contains(&self, source_span: &SessionFactSourceSpan) -> bool { + match source_span { + SessionFactSourceSpan::RawMessage { + session_id, + message_id, + } => self + .raw_messages + .contains(&(session_id.clone(), message_id.clone())), + SessionFactSourceSpan::RawStore { store_id } => self.raw_store_ids.contains(store_id), + SessionFactSourceSpan::SummaryNode { node_id } => self.summary_nodes.contains(node_id), } - raw_store.is_some_and(|store_id| self.raw_store_ids.contains(&store_id)) - || raw_message - .0 - .zip(raw_message.1) - .is_some_and(|(session_id, message_id)| { - self.raw_messages - .contains(&(session_id.to_string(), message_id.to_string())) - }) - || summary_node.is_some_and(|node_id| self.summary_nodes.contains(node_id)) } } @@ -181,32 +168,16 @@ async fn validate_fact_candidate( proposal: &Value, citations: &EvidenceCitationSet, ) -> Result { - let Some(object) = proposal.as_object() else { - return Ok(quarantined_fact(proposal, "item must be a JSON object")); + let candidate = match serde_json::from_value::(proposal.clone()) { + Ok(candidate) => candidate, + Err(_) => { + return Ok(quarantined_fact( + proposal, + "fact proposal does not match the response contract", + )); + } }; - const ALLOWED_FACT_FIELDS: &[&str] = &[ - "content", - "category", - "tags", - "entities", - "trust", - "reason", - "source_span", - ]; - if object - .keys() - .any(|field| !ALLOWED_FACT_FIELDS.contains(&field.as_str())) - { - return Ok(quarantined_fact( - proposal, - "fact proposal contains an unsupported field", - )); - } - let Some(content) = object - .get("content") - .and_then(Value::as_str) - .and_then(normalized_non_empty) - else { + let Some(content) = normalized_non_empty(&candidate.content) else { return Ok(quarantined_fact(proposal, "content is required")); }; if content.chars().count() > 1_000 { @@ -221,39 +192,21 @@ async fn validate_fact_candidate( "content contains a control character", )); } - let Some(category) = object - .get("category") - .and_then(Value::as_str) - .and_then(session_fact_category) - else { - return Ok(quarantined_fact(proposal, "valid category is required")); - }; - let Some(tags) = string_array_field(object.get("tags")) else { + let category = candidate.category; + let Some(tags) = normalized_fact_terms(&candidate.tags) else { return Ok(quarantined_fact( proposal, - "tags must be an array of strings", + "tags exceed the public evidence contract", )); }; - let Some(entities) = string_array_field(object.get("entities")) else { + let Some(entities) = normalized_fact_terms(&candidate.entities) else { return Ok(quarantined_fact( proposal, - "entities must be an array of strings", + "entities exceed the public evidence contract", )); }; - let Some(trust) = object.get("trust") else { - return Ok(quarantined_fact(proposal, "trust is required")); - }; - let Some(trust) = candidate_trust_value(trust) else { - return Ok(quarantined_fact( - proposal, - "trust must be a number between 0 and 1, or one of low, medium, high", - )); - }; - let Some(reason) = object - .get("reason") - .and_then(Value::as_str) - .and_then(normalized_non_empty) - else { + let trust = candidate_trust_score(&candidate.trust); + let Some(reason) = normalized_non_empty(&candidate.reason) else { return Ok(quarantined_fact(proposal, "reason is required")); }; if reason.len() > 4_096 || reason.chars().any(char::is_control) { @@ -262,34 +215,16 @@ async fn validate_fact_candidate( "reason exceeds the public evidence contract", )); } - let Some(source_span) = object.get("source_span") else { - return Ok(quarantined_fact(proposal, "source_span is required")); - }; - let Some(source_span_object) = source_span.as_object() else { - return Ok(quarantined_fact(proposal, "source_span must be an object")); + let source_span = &candidate.source_span; + let valid_source_identity = match source_span { + SessionFactSourceSpan::RawMessage { + session_id, + message_id, + } => public_evidence_text(session_id, 4_096) && public_evidence_text(message_id, 4_096), + SessionFactSourceSpan::RawStore { .. } => true, + SessionFactSourceSpan::SummaryNode { node_id } => public_evidence_text(node_id, 4_096), }; - const ALLOWED_SOURCE_SPAN_FIELDS: &[&str] = - &["session_id", "message_id", "store_id", "node_id"]; - if source_span_object - .keys() - .any(|field| !ALLOWED_SOURCE_SPAN_FIELDS.contains(&field.as_str())) - { - return Ok(quarantined_fact( - proposal, - "source_span contains an unsupported field", - )); - } - if source_span_object.values().any(Value::is_null) { - return Ok(quarantined_fact( - proposal, - "source_span optional identities must be omitted instead of null", - )); - } - if source_span_object.values().any(|value| { - value - .as_str() - .is_some_and(|text| !public_evidence_text(text, 4_096)) - }) { + if !valid_source_identity { return Ok(quarantined_fact( proposal, "source_span contains an invalid public evidence identity", @@ -407,7 +342,7 @@ async fn validate_fact_candidate( "category": category, "tags": tags.clone(), "entities": entities.clone(), - "trust": canonical_evidence_trust(object.get("trust"), trust.as_f64()), + "trust": canonical_evidence_trust(&candidate.trust, trust.as_f64()), "source_span": source_span, "reason": reason.clone(), }); @@ -442,44 +377,27 @@ async fn validate_fact_candidate( }))) } -fn session_fact_category(category: &str) -> Option { - match category { - "general" => Some(FactCategoryV1::General), - "user_pref" => Some(FactCategoryV1::UserPref), - "project" => Some(FactCategoryV1::Project), - "tool" => Some(FactCategoryV1::Tool), - "decision" => Some(FactCategoryV1::Decision), - "code_area" => Some(FactCategoryV1::CodeArea), - _ => None, - } -} - -/// Accepts numeric trust in `[0, 1]` plus the `low`/`medium`/`high` bucket -/// labels models frequently emit despite the numeric prompt instruction. -/// Buckets map to the representative scores in -/// [`tracedecay_session_memory::memory::trust`], so they cannot drift out of their -/// documented ranges. -/// -/// Deliberate decision: the prompt forbids string labels, but they are -/// accepted defensively rather than rejecting an otherwise valid fact. Note -/// that the "high" representative can clear automatic-apply thresholds. -/// This is intentional. -fn candidate_trust_value(value: &Value) -> Option { - if let Some(trust) = value.as_f64() { - return (0.0..=1.0).contains(&trust).then_some(trust); - } - match value.as_str()?.trim().to_ascii_lowercase().as_str() { - "low" => Some(LOW_TRUST_REPRESENTATIVE), - "medium" => Some(DEFAULT_TRUST), - "high" => Some(HIGH_TRUST_REPRESENTATIVE), - _ => None, +/// Trust syntax is decoded by the proposal contract. Bucket policy remains +/// with the memory authority, including the existing automatic-apply scores. +fn candidate_trust_score(value: &SessionFactTrust) -> f64 { + match value { + SessionFactTrust::Numeric(trust) => *trust, + SessionFactTrust::Bucket(MemoryAutomationFactEvidenceTrustBucketV1::Low) => { + LOW_TRUST_REPRESENTATIVE + } + SessionFactTrust::Bucket(MemoryAutomationFactEvidenceTrustBucketV1::Medium) => { + DEFAULT_TRUST + } + SessionFactTrust::Bucket(MemoryAutomationFactEvidenceTrustBucketV1::High) => { + HIGH_TRUST_REPRESENTATIVE + } } } -fn canonical_evidence_trust(value: Option<&Value>, trust: f64) -> Value { - match value.and_then(Value::as_str) { - Some(bucket) => Value::String(bucket.trim().to_ascii_lowercase()), - None => json!(trust), +fn canonical_evidence_trust(value: &SessionFactTrust, trust: f64) -> Value { + match value { + SessionFactTrust::Numeric(_) => json!(trust), + SessionFactTrust::Bucket(bucket) => json!(bucket), } } @@ -494,17 +412,13 @@ fn value_as_i64(value: &Value) -> Option { .or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok())) } -fn string_array_field(value: Option<&Value>) -> Option> { - let Some(value) = value else { - return Some(Vec::new()); - }; - let array = value.as_array()?; - if array.len() > 20 { +fn normalized_fact_terms(terms: &[String]) -> Option> { + if terms.len() > 20 { return None; } let mut values = Vec::new(); - for item in array { - let value = item.as_str().and_then(normalized_non_empty)?; + for term in terms { + let value = normalized_non_empty(term)?; if value.len() > 4_096 || value.chars().any(char::is_control) { return None; } diff --git a/crates/tracedecay-automation/src/backend.rs b/crates/tracedecay-automation/src/backend.rs index 03e172fb6c..42dd548ef7 100644 --- a/crates/tracedecay-automation/src/backend.rs +++ b/crates/tracedecay-automation/src/backend.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; use thiserror::Error; +use tracedecay_contracts::automation::session_fact_proposal_schema; pub use tracedecay_contracts::automation::{AgentTaskFailureClass, AgentTaskKind}; use tracedecay_domain::canonical_text::encode_tagged_lowercase_hex; use tracedecay_domain::errors::{ProjectOpenFailureKind, TraceDecayError}; @@ -167,9 +168,9 @@ pub fn task_key(task: AgentTaskKind) -> &'static str { pub fn prompt_version(task: AgentTaskKind) -> &'static str { match task { AgentTaskKind::MemoryCurator => "memory_curator:v2", - AgentTaskKind::SessionReflector => "session_reflector:v2", + AgentTaskKind::SessionReflector => "session_reflector:v3", AgentTaskKind::SkillWriter => "skill_writer:v4", - AgentTaskKind::CombinedReview => "combined_review:v3", + AgentTaskKind::CombinedReview => "combined_review:v4", AgentTaskKind::UserJob => "user_job:v2", } } @@ -177,14 +178,24 @@ pub fn prompt_version(task: AgentTaskKind) -> &'static str { fn response_schema(task: AgentTaskKind) -> Value { match task { AgentTaskKind::MemoryCurator => json_schema_for_array_properties(&["ops"]), - AgentTaskKind::SessionReflector => json_schema_for_array_properties(&["facts"]), + AgentTaskKind::SessionReflector => serde_json::json!({ + "type": "object", + "required": ["facts"], + "properties": { + "facts": { + "type": "array", + "items": session_fact_proposal_schema() + } + }, + "additionalProperties": true + }), AgentTaskKind::SkillWriter => skill_writer_response_schema(), AgentTaskKind::CombinedReview => { let mut schema = response_schema(AgentTaskKind::SkillWriter); schema["required"] = serde_json::json!(["facts", "skills", "outcome", "decision"]); schema["properties"]["facts"] = serde_json::json!({ "type": "array", - "items": session_fact_schema() + "items": session_fact_proposal_schema() }); schema } @@ -195,60 +206,6 @@ fn response_schema(task: AgentTaskKind) -> Value { } } -fn session_fact_schema() -> Value { - serde_json::json!({ - "type": "object", - "required": ["content", "category", "tags", "entities", "trust", "source_span", "reason"], - "properties": { - "content": { "type": "string" }, - "category": { - "type": "string", - "enum": ["general", "user_pref", "project", "tool", "decision", "code_area"] - }, - "tags": { - "type": "array", - "items": { "type": "string" } - }, - "entities": { - "type": "array", - "items": { "type": "string" } - }, - "trust": { "type": "number" }, - "source_span": { - "anyOf": [ - { - "type": "object", - "required": ["session_id", "message_id"], - "properties": { - "session_id": { "type": "string" }, - "message_id": { "type": "string" } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": ["store_id"], - "properties": { - "store_id": { "type": "string" } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": ["node_id"], - "properties": { - "node_id": { "type": "string" } - }, - "additionalProperties": false - } - ] - }, - "reason": { "type": "string" } - }, - "additionalProperties": false - }) -} - fn skill_writer_response_schema() -> Value { serde_json::json!({ "type": "object", @@ -797,6 +754,41 @@ fn strip_optional_json_fence(text: &str) -> Result<&str> { mod tests { use super::*; + #[test] + fn fact_envelopes_preserve_malformed_items_for_runtime_repair() { + let facts = serde_json::json!([ + { + "content": "Preserve a valid sibling", + "category": "project", + "trust": 0.8, + "source_span": {"store_id": 7}, + "reason": "The bounded evidence supports this fact" + }, + {"content": "Malformed items still need individual repair evidence"} + ]); + for task in [ + AgentTaskKind::SessionReflector, + AgentTaskKind::CombinedReview, + ] { + let contract = agent_task_contract(task); + let mut output = serde_json::json!({"facts": facts}); + if task == AgentTaskKind::CombinedReview { + output["skills"] = serde_json::json!([]); + output["outcome"] = serde_json::json!("no_skill_needed"); + output["decision"] = serde_json::json!({ + "reason": "The review only proposed facts", + "remedy": "no_action" + }); + } + let decoded = extract_response_json_object(&output.to_string(), &contract).unwrap(); + assert_eq!(decoded["facts"], facts); + assert_eq!( + contract.response_schema["properties"]["facts"]["items"], + session_fact_proposal_schema() + ); + } + } + #[test] fn every_failure_class_round_trips_through_its_typed_state() { let classes = [ diff --git a/crates/tracedecay-contracts/src/automation.rs b/crates/tracedecay-contracts/src/automation.rs index 81d9fce802..1466316034 100644 --- a/crates/tracedecay-contracts/src/automation.rs +++ b/crates/tracedecay-contracts/src/automation.rs @@ -2,10 +2,14 @@ //! run ledger rows, managed-skill packages with their usage summaries, and //! the Hermes skill inventory. +mod fact_proposal; mod hermes; mod managed_skills; mod run_ledger; +pub use fact_proposal::{ + SessionFactProposal, SessionFactSourceSpan, SessionFactTrust, session_fact_proposal_schema, +}; pub use hermes::{ HermesPendingSkillWrite, HermesSkillBridgeContracts, HermesSkillBridgeSnapshot, HermesSkillSummary, diff --git a/crates/tracedecay-contracts/src/automation/fact_proposal.rs b/crates/tracedecay-contracts/src/automation/fact_proposal.rs new file mode 100644 index 0000000000..4ad383d7eb --- /dev/null +++ b/crates/tracedecay-contracts/src/automation/fact_proposal.rs @@ -0,0 +1,280 @@ +use schemars::{JsonSchema, generate::SchemaSettings}; +use serde::de::{Error as _, IntoDeserializer}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{Number, Value}; +use tracedecay_domain::{Confidence, FactCategoryV1}; + +use crate::retained_surfaces::MemoryAutomationFactEvidenceTrustBucketV1; + +// Rust's trim uses Unicode White_Space. Spell that set out because JSON +// Schema's regex dialects disagree about which characters `\s` includes. +const TRUST_BUCKET_PATTERN: &str = r"^[\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028-\u2029\u202f\u205f\u3000]*([lL][oO][wW]|[mM][eE][dD][iI][uU][mM]|[hH][iI][gG][hH])[\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028-\u2029\u202f\u205f\u3000]*$"; + +/// An untrusted fact item shared by standalone reflection and combined review. +/// +/// Decoding establishes its structure. Content/privacy checks, citation +/// membership, deduplication, trust policy, and apply authority remain runtime +/// responsibilities. Omitted tags and entities retain their released empty +/// meaning; explicit null is not an array. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SessionFactProposal { + pub content: String, + pub category: FactCategoryV1, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub entities: Vec, + pub trust: SessionFactTrust, + pub source_span: SessionFactSourceSpan, + pub reason: String, +} + +/// Exactly one public evidence identity, without nullable or mixed variants. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged, deny_unknown_fields)] +pub enum SessionFactSourceSpan { + RawMessage { + session_id: String, + message_id: String, + }, + RawStore { + /// Signed 64-bit store identity. Prefer an integer JSON literal, which + /// retains the full range. Decimal and exponent forms are accepted only + /// when their parsed value is integral and within +/-9007199254740991. + /// JSON Schema's integer bounds do not express this representation rule. + #[serde(deserialize_with = "deserialize_store_id")] + #[schemars(range(min = i64::MIN, max = i64::MAX))] + store_id: i64, + }, + SummaryNode { + node_id: String, + }, +} + +/// Numeric trust or a released model bucket normalized at the input boundary. +/// The existing bucket type keeps public evidence in its canonical string form; +/// the memory policy remains responsible for assigning representative scores. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum SessionFactTrust { + Numeric( + #[serde(deserialize_with = "deserialize_numeric_trust")] + #[schemars(range(min = 0.0, max = 1.0))] + f64, + ), + Bucket( + #[serde(deserialize_with = "deserialize_trust_bucket")] + #[schemars(with = "String", pattern(TRUST_BUCKET_PATTERN))] + MemoryAutomationFactEvidenceTrustBucketV1, + ), +} + +/// The same item schema can be embedded in either task's distinct envelope. +pub fn session_fact_proposal_schema() -> Value { + SchemaSettings::default() + .with(|settings| { + settings.meta_schema = None; + settings.inline_subschemas = true; + }) + .into_generator() + .into_root_schema_for::() + .into() +} + +fn deserialize_numeric_trust<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value = f64::deserialize(deserializer)?; + Confidence::new(value).map_err(D::Error::custom)?; + Ok(value) +} + +fn deserialize_trust_bucket<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + MemoryAutomationFactEvidenceTrustBucketV1::deserialize( + value.trim().to_ascii_lowercase().into_deserializer(), + ) +} + +fn deserialize_store_id<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let number = Number::deserialize(deserializer)?; + if let Some(value) = number.as_i64() { + return Ok(value); + } + // Adjacent integers can share a binary64 value outside this range. Keep + // exact i64 literals above, but refuse ambiguous large floating identities. + // This checks the parsed Number, not its original token: serde_json can + // already have rounded a tiny fraction such as 7.0000000000000001 to 7.0. + const MAX_FLOAT_STORE_ID: f64 = 9_007_199_254_740_991.0; + number + .as_f64() + .filter(|value| { + value.is_finite() + && value.fract() == 0.0 + && (-MAX_FLOAT_STORE_ID..=MAX_FLOAT_STORE_ID).contains(value) + }) + .map(|value| value as i64) + .ok_or_else(|| { + D::Error::custom( + "store_id must be a signed 64-bit integer literal or an integral floating value within +/-9007199254740991", + ) + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{SessionFactProposal, SessionFactSourceSpan, SessionFactTrust}; + use crate::retained_surfaces::MemoryAutomationFactEvidenceTrustBucketV1; + + fn proposal() -> serde_json::Value { + json!({ + "content": "Keep the project's API stable", + "category": "project", + "trust": 0.8, + "source_span": {"store_id": 7}, + "reason": "The user asked for a stable public API" + }) + } + + #[test] + fn omitted_arrays_decode_as_empty_but_null_is_rejected() { + let decoded = serde_json::from_value::(proposal()).unwrap(); + assert!(decoded.tags.is_empty()); + assert!(decoded.entities.is_empty()); + for field in ["tags", "entities"] { + let mut value = proposal(); + value[field] = serde_json::Value::Null; + assert!(serde_json::from_value::(value).is_err()); + } + } + + #[test] + fn legacy_trust_labels_normalize_to_the_public_bucket() { + let mut value = proposal(); + value["trust"] = json!("\u{0085}\u{2003} HIGH \u{3000}"); + let decoded = serde_json::from_value::(value).unwrap(); + assert_eq!( + decoded.trust, + SessionFactTrust::Bucket(MemoryAutomationFactEvidenceTrustBucketV1::High) + ); + assert_eq!(serde_json::to_value(decoded).unwrap()["trust"], "high"); + } + + #[test] + fn numeric_trust_retains_its_value_and_rejects_out_of_range_scores() { + for trust in [0.0, 0.123456789, 1.0] { + let mut value = proposal(); + value["trust"] = json!(trust); + assert_eq!( + serde_json::from_value::(value) + .unwrap() + .trust, + SessionFactTrust::Numeric(trust) + ); + } + for trust in [-0.1, 1.1] { + let mut value = proposal(); + value["trust"] = json!(trust); + assert!(serde_json::from_value::(value).is_err()); + } + } + + #[test] + fn numeric_store_identity_normalizes_integral_values_without_saturation() { + for store_id in [json!(7), json!(7.0), json!(i64::MIN), json!(i64::MAX)] { + let mut value = proposal(); + value["source_span"]["store_id"] = store_id.clone(); + let decoded = serde_json::from_value::(value).unwrap(); + let expected = store_id.as_i64().unwrap_or(7); + assert_eq!( + decoded.source_span, + SessionFactSourceSpan::RawStore { store_id: expected } + ); + } + for store_id in [ + json!("7"), + json!(7.5), + json!(9_223_372_036_854_775_808_u64), + json!(-18_446_744_073_709_551_616.0), + serde_json::Value::Null, + ] { + let mut value = proposal(); + value["source_span"]["store_id"] = store_id; + assert!(serde_json::from_value::(value).is_err()); + } + } + + #[test] + fn raw_store_numbers_preserve_i64_literals_and_bounded_integral_floats() { + for (number, expected) in [ + ("-9223372036854775808", i64::MIN), + ("9223372036854775807", i64::MAX), + ("9007199254740993", 9_007_199_254_740_993), + ("7.0", 7), + ("7e0", 7), + ("70e-1", 7), + ("-0.0", 0), + ("9007199254740991.0", 9_007_199_254_740_991), + ("-9007199254740991.0", -9_007_199_254_740_991), + ] { + // The backend materializes Value before the proposal decoder runs. + let source: serde_json::Value = + serde_json::from_str(&format!(r#"{{"store_id":{number}}}"#)).unwrap(); + assert_eq!( + serde_json::from_value::(source).unwrap(), + SessionFactSourceSpan::RawStore { store_id: expected }, + "{number}", + ); + } + } + + #[test] + fn raw_store_numbers_reject_overflow_and_ambiguous_large_floats() { + for number in [ + "-9223372036854775809", + "9223372036854775808", + "9007199254740992.0", + "-9007199254740992.0", + "9007199254740993.0", + "-9007199254740993.0", + "9007199254740991.5", + "-9007199254740991.5", + "-9223372036854775808.0", + "9223372036854775807.0", + ] { + let source: serde_json::Value = + serde_json::from_str(&format!(r#"{{"store_id":{number}}}"#)).unwrap(); + assert!( + serde_json::from_value::(source).is_err(), + "{number} must not select a rounded store identity", + ); + } + } + + #[test] + fn source_alternatives_reject_unknown_mixed_and_incomplete_identities() { + for source_span in [ + json!({"session_id": "session"}), + json!({"store_id": 7, "node_id": "node"}), + json!({"store_id": 7, "node_id": null}), + json!({"store_id": 7, "unknown": true}), + json!({}), + ] { + let mut value = proposal(); + value["source_span"] = source_span; + assert!(serde_json::from_value::(value).is_err()); + } + } +} diff --git a/crates/tracedecay-sessions/src/runtime/hosts/cursor/parent_dispatch_index.rs b/crates/tracedecay-sessions/src/runtime/hosts/cursor/parent_dispatch_index.rs index 858d4e083e..652a4701f1 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/cursor/parent_dispatch_index.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/cursor/parent_dispatch_index.rs @@ -354,15 +354,11 @@ impl ParentDispatchIndex { self.forget(scan.parent_path); return (None, receipt); }; - // The digest covers only `[0, verified_cursor)`. A model parsed from - // the unterminated tail past it is trustworthy only while the native - // revision did not move during the scan and, when that revision is - // still inside the coarse timestamp quantum, the tail bytes still - // match. A same-length rewrite in that quantum leaves the revision - // unchanged, so the tail itself is the witness. + // The prefix digest excludes the tail. Its timestamp can settle after + // a same-quantum rewrite, so only matching tail bytes authorize reuse. if final_revision != scan.revision { delta.transient_model = None; - } else if delta.transient_model.is_some() && !parent_revision_settled(&final_revision) { + } else if delta.transient_model.is_some() { match tail_witness_matches(&mut delta.file, delta.verified_cursor, delta.tail_witness) { Ok(true) => {} Ok(false) => delta.transient_model = None, @@ -1289,6 +1285,7 @@ mod tests { .unwrap(); drop(file); + crate::runtime::source::spin_until_jsonl_change_settled(&layout.candidate_two); let (model, first) = lookup(&layout, "partial-agent"); assert_eq!(model.as_deref(), Some("partial-model")); assert_eq!(first.bytes_parsed, complete_len); @@ -1315,6 +1312,7 @@ mod tests { assert_eq!(parsed.transient_model.as_deref(), Some("model-a")); fs::write(&layout.candidate_two, format!("{complete}\n{partial_b}")).unwrap(); + crate::runtime::source::spin_until_jsonl_change_settled(&layout.candidate_two); let (model, _receipt) = index.commit_scanned_delta(parsed, commit); assert!( model.is_none(), diff --git a/crates/tracedecay/tests/automation_runner_test/backend.rs b/crates/tracedecay/tests/automation_runner_test/backend.rs index e0c4f2d155..64e8952394 100644 --- a/crates/tracedecay/tests/automation_runner_test/backend.rs +++ b/crates/tracedecay/tests/automation_runner_test/backend.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; -use serde_json::json; +use serde_json::{Value, json}; use tempfile::TempDir; use tracing_subscriber::layer::SubscriberExt; @@ -19,6 +19,7 @@ use tracedecay_automation_runtime::automation::backend::{ }; use tracedecay_automation_runtime::automation::config::{AutomationBackend, AutomationConfig}; use tracedecay_automation_runtime::ports::codex_app_server::SummaryConfig as AutomationSummaryConfig; +use tracedecay_contracts::automation::SessionFactProposal; use tracedecay_domain::configuration::LcmSummarizerExecutableV1; use tracedecay_sessions::runtime::hosts::codex_app_server::{ CodexAppServerSummaryConfig, run_prompt_with_codex_app_server, @@ -69,7 +70,7 @@ fn combined_review_contract_requires_both_arrays_with_deterministic_input_hash() ); assert_eq!(request.contract.task_key, "combined_review"); - assert_eq!(request.contract.prompt_version, "combined_review:v3"); + assert_eq!(request.contract.prompt_version, "combined_review:v4"); assert!(request.contract.strict_json); assert_eq!( request.contract.response_schema["required"], @@ -105,6 +106,218 @@ fn combined_review_contract_requires_both_arrays_with_deterministic_input_hash() assert_ne!(request.input_hash, different_evidence.input_hash); } +#[test] +fn fact_response_schemas_and_decoder_agree_on_item_grammar() { + let proposal = json!({ + "content": "Keep the project's API stable", + "category": "project", + "trust": 0.8, + "source_span": {"store_id": 7}, + "reason": "The user asked for a stable public API" + }); + let mut cases = vec![ + ("omitted optional arrays".to_owned(), proposal.clone(), true), + ("non-object fact".to_owned(), json!("fact"), false), + ]; + for (label, field, value, accepted) in [ + ("empty tags", "tags", json!([]), true), + ("string tags", "tags", json!(["工具"]), true), + ("null tags", "tags", Value::Null, false), + ("non-array tags", "tags", json!("tool"), false), + ("non-string tag", "tags", json!([true]), false), + ("empty entities", "entities", json!([]), true), + ("string entities", "entities", json!(["TraceDecay"]), true), + ("null entities", "entities", Value::Null, false), + ("non-string entity", "entities", json!([7]), false), + ("numeric zero trust", "trust", json!(0), true), + ("numeric unit trust", "trust", json!(1), true), + ("low bucket", "trust", json!("low"), true), + ("folded medium bucket", "trust", json!(" MeDiUm "), true), + ( + "Unicode-trimmed high bucket", + "trust", + json!("\u{0085}\u{2003} HIGH \u{3000}"), + true, + ), + ("negative trust", "trust", json!(-0.1), false), + ("trust above one", "trust", json!(1.1), false), + ("boolean trust", "trust", json!(true), false), + ("null trust", "trust", Value::Null, false), + ("numeric string trust", "trust", json!("0.8"), false), + ("unknown trust bucket", "trust", json!("sky-high"), false), + ( + "non-whitespace bucket prefix", + "trust", + json!("\u{200b}high"), + false, + ), + ( + "raw message", + "source_span", + json!({"session_id": "session", "message_id": "message"}), + true, + ), + ( + "summary node", + "source_span", + json!({"node_id": "node"}), + true, + ), + ( + "integral floating store identity", + "source_span", + json!({"store_id": 7.0}), + true, + ), + ( + "minimum store identity", + "source_span", + json!({"store_id": i64::MIN}), + true, + ), + ( + "maximum store identity", + "source_span", + json!({"store_id": i64::MAX}), + true, + ), + ( + "string store identity", + "source_span", + json!({"store_id": "7"}), + false, + ), + ( + "boolean store identity", + "source_span", + json!({"store_id": true}), + false, + ), + ( + "fractional store identity", + "source_span", + json!({"store_id": 7.5}), + false, + ), + ( + "store identity above i64", + "source_span", + json!({"store_id": 9_223_372_036_854_775_808_u64}), + false, + ), + ( + "floating store identity above i64", + "source_span", + json!({"store_id": 9_223_372_036_854_775_808.0}), + false, + ), + ( + "store identity below i64", + "source_span", + json!({"store_id": -18_446_744_073_709_551_616.0}), + false, + ), + ( + "null store identity", + "source_span", + json!({"store_id": null}), + false, + ), + ( + "incomplete raw identity", + "source_span", + json!({"session_id": "session"}), + false, + ), + ( + "null raw identity", + "source_span", + json!({"session_id": "session", "message_id": null}), + false, + ), + ( + "mixed raw identities", + "source_span", + json!({"session_id": "session", "message_id": "message", "store_id": 7}), + false, + ), + ( + "mixed raw and summary identities", + "source_span", + json!({"store_id": 7, "node_id": "node"}), + false, + ), + ( + "nullable extra identity", + "source_span", + json!({"store_id": 7, "node_id": null}), + false, + ), + ( + "extra source field", + "source_span", + json!({"store_id": 7, "unknown": true}), + false, + ), + ("empty source", "source_span", json!({}), false), + ("null source", "source_span", Value::Null, false), + ("unknown category", "category", json!("unknown"), false), + ("non-string content", "content", json!(7), false), + ("non-string reason", "reason", json!(false), false), + ("extra proposal field", "unknown", json!(true), false), + ("legacy confidence field", "confidence", json!(0.8), false), + ] { + let mut item = proposal.clone(); + item[field] = value; + cases.push((label.to_owned(), item, accepted)); + } + for field in ["content", "category", "trust", "source_span", "reason"] { + let mut item = proposal.clone(); + item.as_object_mut().unwrap().remove(field); + cases.push((format!("missing {field}"), item, false)); + } + + let reflector = AgentTaskRequest::new( + "fact-grammar".to_owned(), + AgentTaskKind::SessionReflector, + "Review bounded session evidence".to_owned(), + None, + json!({}), + ); + let combined = AgentTaskRequest::new( + "fact-grammar-combined".to_owned(), + AgentTaskKind::CombinedReview, + "Review bounded session evidence".to_owned(), + None, + json!({}), + ); + assert_eq!(reflector.contract.prompt_version, "session_reflector:v3"); + assert_eq!(combined.contract.prompt_version, "combined_review:v4"); + assert_eq!( + reflector.contract.response_schema["properties"]["facts"], + combined.contract.response_schema["properties"]["facts"], + "both task envelopes share one facts item grammar" + ); + let validator = jsonschema::options() + .build(&reflector.contract.response_schema) + .expect("generated reflector schema must compile"); + for (label, item, accepted) in &cases { + let decoded = serde_json::from_value::(item.clone()); + assert_eq!(decoded.is_ok(), *accepted, "{label}: {decoded:?}"); + let response = json!({"facts": [item]}); + assert_eq!( + validator.is_valid(&response), + *accepted, + "{label}: {}", + validator + .iter_errors(&response) + .map(|error| error.to_string()) + .collect::>() + .join("; ") + ); + } +} + #[test] fn skill_writer_contract_requires_explicit_terminal_shape() { let request = AgentTaskRequest::new( diff --git a/crates/tracedecay/tests/automation_runner_test/combined_review.rs b/crates/tracedecay/tests/automation_runner_test/combined_review.rs index dd341e7029..56e7b170a5 100644 --- a/crates/tracedecay/tests/automation_runner_test/combined_review.rs +++ b/crates/tracedecay/tests/automation_runner_test/combined_review.rs @@ -183,7 +183,7 @@ async fn combined_review_runner_records_both_tasks_from_one_backend_call() { assert_eq!(reflector.status, AutomationRunStatus::Succeeded); assert_eq!( reflector.prompt_version.as_deref(), - Some("combined_review:v3") + Some("combined_review:v4") ); assert_eq!(reflector.accepted_count, 0); @@ -193,7 +193,7 @@ async fn combined_review_runner_records_both_tasks_from_one_backend_call() { assert_eq!(skill.task_key.as_deref(), Some("skill_writer")); assert_eq!(skill.trigger, AutomationTrigger::Scheduler); assert_eq!(skill.status, AutomationRunStatus::Succeeded); - assert_eq!(skill.prompt_version.as_deref(), Some("combined_review:v3")); + assert_eq!(skill.prompt_version.as_deref(), Some("combined_review:v4")); assert_eq!(skill.accepted_count, 0); // Both halves share the combined request's input hash and correlate @@ -455,6 +455,168 @@ async fn combined_review_commits_atomic_terminal_effects() { ); } +#[tokio::test] +async fn fact_proposal_repair_preserves_valid_siblings_and_commits_canonical_memory() { + let valid = json!({ + "content": "TraceDecay records durable repository conventions from cited user messages", + "category": "project", + "trust": "\u{2003}HiGh\u{3000}", + "source_span": {"store_id": 1}, + "reason": "The cited user message establishes this repository convention" + }); + let malformed = json!({ + "content": "Cache expiry must be explicit at service boundaries", + "category": "project", + "trust": 0.72, + "source_span": {"store_id": "1"}, + "reason": "The cited user message establishes this cache policy" + }); + let mut repaired = malformed.clone(); + repaired["source_span"]["store_id"] = json!(1.0); + + for task in [ + AgentTaskKind::SessionReflector, + AgentTaskKind::CombinedReview, + ] { + let temp = tempdir().unwrap(); + let profile_root = temp.path().join("profile"); + let cg = init_project(temp.path()).await; + seed_project_session_activity(&cg).await; + let config = scheduler_config(Some(3600), None); + let responses = [malformed.clone(), repaired.clone()] + .into_iter() + .map(|second| { + let mut output = if task == AgentTaskKind::CombinedReview { + combined_no_skill_needed_output() + } else { + json!({"facts": []}) + }; + output["facts"] = json!([valid, second]); + output + }) + .collect(); + let backend = SequentialJsonBackend::new(responses); + let run_control = test_automation_run_control(Arc::new(AtomicBool::new(false))); + let run = match task { + AgentTaskKind::SessionReflector => run_session_reflector( + &cg, + &config, + &run_control, + &backend, + SessionReflectorAutomationOptions { + trigger: AutomationTrigger::ManualCli, + provider: "cursor".to_owned(), + query: "durable session reflection".to_owned(), + evidence_limit: 5, + ..SessionReflectorAutomationOptions::default() + }, + ) + .await + .unwrap(), + AgentTaskKind::CombinedReview => { + let dispatch = run_combined_review( + &cg, + &config, + &run_control, + &backend, + combined_options(&profile_root), + ) + .await + .unwrap(); + let CombinedReviewDispatch::Ran(run) = dispatch else { + panic!("repaired fact items must complete combined review: {dispatch:?}"); + }; + assert_eq!( + run.skill_writer.ledger_record.status, + AutomationRunStatus::Succeeded + ); + assert_eq!(run.skill_writer.ledger_record.accepted_count, 0); + run.session_reflector + } + _ => unreachable!("only fact-producing tasks are exercised"), + }; + + assert_eq!(backend.calls(), 2, "{task:?}: exactly one item repair turn"); + assert_eq!(run.ledger_record.status, AutomationRunStatus::Succeeded); + assert_eq!(run.ledger_record.backend_attempt_count, 2); + assert_eq!(run.ledger_record.accepted_count, 2); + assert_eq!(run.ledger_record.rejected_count, 0); + assert_eq!(run.report["receipt"]["repair_attempted"], true); + assert_eq!(run.report["receipt"]["applied_count"], 2); + assert_eq!(run.report["receipt"]["quarantined_count"], 0); + let repairs = run.report["validation_repairs"].as_array().unwrap(); + assert_eq!(repairs.len(), 1); + assert_eq!(repairs[0]["attempt"], 1); + let error_key = if task == AgentTaskKind::CombinedReview { + "fact_errors" + } else { + "errors" + }; + let errors = repairs[0][error_key].as_array().unwrap(); + assert_eq!( + errors.len(), + 1, + "the valid sibling must survive initial validation" + ); + assert_eq!(errors[0]["item"], malformed); + assert_eq!(errors[0]["validation"]["status"], "quarantined"); + assert_eq!( + errors[0]["reason"], + "fact proposal does not match the response contract" + ); + + let memory = tracedecay_session_memory::memory::MemoryApplication::new( + project_memory_owner(&cg), + tracedecay_session_memory::fact_store::DatabaseFactStore::new(cg.db()), + ) + .unwrap(); + let receipts = list_automatic_fact_receipts( + &memory, + Some(AutomaticFactState::Applied), + 10, + run_control.read_control(), + ) + .await + .unwrap(); + assert_eq!( + receipts.len(), + 2, + "{task:?}: both legitimate facts must commit" + ); + for (proposal, canonical_trust) in [(&valid, json!("high")), (&repaired, json!(0.72))] { + let receipt = receipts + .iter() + .find(|receipt| receipt.item.as_ref().unwrap()["content"] == proposal["content"]) + .expect("each accepted item has an automatic memory receipt"); + assert_eq!(receipt.run_id, run.run_id); + let item = receipt.item.as_ref().unwrap(); + assert_eq!(item["source_span"], json!({"store_id": 1})); + assert_eq!(item["tags"], json!([])); + assert_eq!(item["entities"], json!([])); + assert_eq!(item["trust"], canonical_trust); + serde_json::from_value::< + tracedecay_contracts::retained_surfaces::MemoryAutomationFactEvidenceItemV1, + >(item.clone()) + .expect("persisted evidence must retain the canonical public grammar"); + let stored = memory + .find_exact_fact_by_content( + proposal["content"].as_str().unwrap(), + run_control.read_control(), + ) + .await + .unwrap() + .expect("the committed fact must be readable through canonical memory"); + let tracedecay_store::ProjectMemoryFactProjectionV1::Available(stored) = stored else { + panic!("the committed fact must have an available payload"); + }; + assert_eq!( + receipt.applied_fact_id.as_deref(), + Some(stored.fact_id().as_str()) + ); + } + } +} + #[tokio::test] async fn combined_review_not_dispatched_when_only_one_task_is_due() { let temp = tempdir().unwrap(); @@ -833,7 +995,7 @@ async fn combined_review_records_failures_for_both_tasks_when_an_array_is_missin for record in &records { assert_eq!(record.status, AutomationRunStatus::Failed); assert_eq!(record.trigger, AutomationTrigger::Scheduler); - assert_eq!(record.prompt_version.as_deref(), Some("combined_review:v3")); + assert_eq!(record.prompt_version.as_deref(), Some("combined_review:v4")); assert!(record.error.as_deref().is_some_and(|error| { error.contains("combined review output must include a skills array") })); diff --git a/crates/tracedecay/tests/automation_runner_test/session_reflector.rs b/crates/tracedecay/tests/automation_runner_test/session_reflector.rs index 6c4d2a3db4..a87463c6f4 100644 --- a/crates/tracedecay/tests/automation_runner_test/session_reflector.rs +++ b/crates/tracedecay/tests/automation_runner_test/session_reflector.rs @@ -567,7 +567,10 @@ async fn session_reflector_runner_applies_valid_automatic_facts_by_default() { }, { "content": "", - "category": "project" + "category": "project", + "trust": 0.7, + "source_span": {"session_id": "session-reflect-1", "message_id": "session-reflect-1-message-001"}, + "reason": "empty content is invalid" }, { "content": "Bucket trust labels emitted by backends map onto calibrated numeric scores", @@ -663,18 +666,26 @@ async fn session_reflector_runner_applies_valid_automatic_facts_by_default() { .any(|value| value["reason"] == json!(reason)) }; assert!(has_quarantine_reason("content is required")); - assert!(has_quarantine_reason("source_span is required")); assert!(has_quarantine_reason( "source_span must cite a bounded session reflection evidence hit" )); - assert!(has_quarantine_reason("trust is required")); - assert!(has_quarantine_reason( - "trust must be a number between 0 and 1, or one of low, medium, high" - )); - assert!(has_quarantine_reason("reason is required")); - assert!(has_quarantine_reason( - "fact proposal contains an unsupported field" - )); + for content in [ + "Uncited session reflection facts must not be accepted", + "Session reflection facts require calibrated trust", + "Session reflection facts require a rationale", + "Session reflector uses trust rather than confidence", + "Unknown trust labels must not be accepted", + ] { + let rejected = quarantined + .iter() + .find(|value| value["item"]["content"] == content) + .expect("each malformed proposal must retain an item-specific quarantine"); + assert_eq!( + rejected["reason"], + "fact proposal does not match the response contract" + ); + assert_eq!(rejected["validation"]["status"], "quarantined"); + } assert_eq!( run.report["accepted_facts"][2]["add_fact_request"]["trust"], json!(0.85) diff --git a/crates/tracedecay/tests/automation_runner_test/support.rs b/crates/tracedecay/tests/automation_runner_test/support.rs index 0c4fa18f12..f3e3d28e69 100644 --- a/crates/tracedecay/tests/automation_runner_test/support.rs +++ b/crates/tracedecay/tests/automation_runner_test/support.rs @@ -824,10 +824,10 @@ impl AgentTaskBackend for MalformedTextBackend { let (task_key, prompt_version, required_property) = match self.task { AgentTaskKind::MemoryCurator => ("memory_curator", "memory_curator:v2", "ops"), AgentTaskKind::SessionReflector => { - ("session_reflector", "session_reflector:v2", "facts") + ("session_reflector", "session_reflector:v3", "facts") } AgentTaskKind::SkillWriter => ("skill_writer", "skill_writer:v4", "skills"), - AgentTaskKind::CombinedReview => ("combined_review", "combined_review:v3", "facts"), + AgentTaskKind::CombinedReview => ("combined_review", "combined_review:v4", "facts"), AgentTaskKind::UserJob => unreachable!("user jobs are not strict-JSON tasks"), }; assert_request_contract(request, task_key, prompt_version, required_property); @@ -872,7 +872,7 @@ impl AgentTaskBackend for SessionJsonBackend { assert_request_contract( request, "session_reflector", - "session_reflector:v2", + "session_reflector:v3", "facts", ); if call_index == 0 { @@ -934,7 +934,7 @@ impl AgentTaskBackend for CombinedJsonBackend { let call = self.calls.fetch_add(1, Ordering::SeqCst); assert_eq!(request.task, AgentTaskKind::CombinedReview); assert_eq!(request.contract.task_key, "combined_review"); - assert_eq!(request.contract.prompt_version, "combined_review:v3"); + assert_eq!(request.contract.prompt_version, "combined_review:v4"); assert!(request.contract.strict_json); assert_eq!( request.contract.response_schema["required"], @@ -994,7 +994,7 @@ impl AgentTaskBackend for InspectSessionEvidenceBackend { assert_request_contract( request, "session_reflector", - "session_reflector:v2", + "session_reflector:v3", "facts", ); let evidence = &request.context["session_reflection_evidence"]; @@ -1074,7 +1074,7 @@ impl AgentTaskBackend for SessionReplayEvidenceBackend { assert_request_contract( request, "session_reflector", - "session_reflector:v2", + "session_reflector:v3", "facts", ); let evidence = &request.context["session_reflection_evidence"]; @@ -1376,9 +1376,9 @@ pub(crate) fn test_task_key(task: AgentTaskKind) -> &'static str { pub(crate) fn test_prompt_version(task: AgentTaskKind) -> &'static str { match task { AgentTaskKind::MemoryCurator => "memory_curator:v2", - AgentTaskKind::SessionReflector => "session_reflector:v2", + AgentTaskKind::SessionReflector => "session_reflector:v3", AgentTaskKind::SkillWriter => "skill_writer:v4", - AgentTaskKind::CombinedReview => "combined_review:v3", + AgentTaskKind::CombinedReview => "combined_review:v4", AgentTaskKind::UserJob => "user_job:v2", } }