From ab147907f407ea41b08d703508644810d47414cc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 26 Oct 2026 08:58:27 +0000 Subject: [PATCH 1/2] fix(automation): unify fact proposal contracts Restack unique #4541 onto the rewritten #4534 tip. (cherry picked from commit c41f943ce640dfcefbb4d0987fc90d51b0a5dd83) --- .../automation/runner/session_reflector.rs | 2 +- .../src/automation/runner/tests.rs | 221 ++++++++++++++++- .../src/automation/session_reflector.rs | 212 +++++------------ crates/tracedecay-automation/src/backend.rs | 108 ++++----- crates/tracedecay-contracts/src/automation.rs | 4 + .../src/automation/fact_proposal.rs | 222 ++++++++++++++++++ .../hosts/cursor/parent_dispatch_index.rs | 12 +- .../tests/automation_runner_test/backend.rs | 217 ++++++++++++++++- .../automation_runner_test/combined_review.rs | 168 ++++++++++++- .../session_reflector.rs | 31 ++- .../tests/automation_runner_test/support.rs | 16 +- 11 files changed, 974 insertions(+), 239 deletions(-) create mode 100644 crates/tracedecay-contracts/src/automation/fact_proposal.rs 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..e092b331a4 --- /dev/null +++ b/crates/tracedecay-contracts/src/automation/fact_proposal.rs @@ -0,0 +1,222 @@ +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 { + #[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); + } + // JSON Schema treats 7 and 7.0 as the same integer. Normalize only exactly + // integral, representable numbers; a string is never an evidence identity. + // The positive bound is exclusive because i64::MAX rounds up as f64. + let minimum = i64::MIN as f64; + number + .as_f64() + .filter(|value| { + value.is_finite() && value.fract() == 0.0 && (minimum..-minimum).contains(value) + }) + .map(|value| value as i64) + .ok_or_else(|| D::Error::custom("store_id must be an integer in the signed 64-bit range")) +} + +#[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 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", } } From ce2154f788ae16eb16aef51d1ea7e36d3af0d0ad Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:37:40 -0700 Subject: [PATCH 2/2] fix(automation): bound floating store identity normalization Preserve exact signed 64-bit integer literals and limit floating normalization to the safe integer range. Cover raw JSON underflow and ambiguous large floating identities without changing shared JSON parsing. Document the representation restriction and the remaining precision loss before Value-based decoding. (cherry picked from commit d59097a2b0619bfec10a0a02fb1d9ccc7d859db3) --- .../src/automation/fact_proposal.rs | 70 +++++++++++++++++-- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/tracedecay-contracts/src/automation/fact_proposal.rs b/crates/tracedecay-contracts/src/automation/fact_proposal.rs index e092b331a4..4ad383d7eb 100644 --- a/crates/tracedecay-contracts/src/automation/fact_proposal.rs +++ b/crates/tracedecay-contracts/src/automation/fact_proposal.rs @@ -39,6 +39,10 @@ pub enum SessionFactSourceSpan { 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, @@ -107,17 +111,24 @@ where if let Some(value) = number.as_i64() { return Ok(value); } - // JSON Schema treats 7 and 7.0 as the same integer. Normalize only exactly - // integral, representable numbers; a string is never an evidence identity. - // The positive bound is exclusive because i64::MAX rounds up as f64. - let minimum = i64::MIN as f64; + // 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 && (minimum..-minimum).contains(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 an integer in the signed 64-bit range")) + .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)] @@ -205,6 +216,53 @@ mod tests { } } + #[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 [