//! What an agent received, and what it said it did, must survive the phase. //! //! `docs/PROVENANCE-ASSESSMENT.md` records "why did the agent say X?" as //! unanswerable. The first two things it needs are the prompt and the //! narrative. Before this, the prompt was never stored at all, and the //! narrative was stored and then read by nothing — both database readers in //! `routes/world.rs` filter to `tool.call`/`file.touch`, and the only other //! statement touching the table is the GC that deletes it. use cm_api::mission_events::{self, MissionEvent, PER_PHASE_CAP, PROMPT_COMPOSED, REASONING, TOOL_CALL}; use cm_domain::{Workspace, WorkspaceId}; use uuid::Uuid; async fn seed_phase(pool: &sqlx::PgPool) -> (Uuid, Uuid) { let ws = Workspace { id: WorkspaceId::new(), name: "Provenance Test".into(), plan: "team".into(), }; cm_db::repo::workspaces::insert(pool, &ws).await.unwrap(); let mission = Uuid::now_v7(); sqlx::query( "INSERT INTO missions (id, workspace_id, title, template_kind, status) VALUES ($1, $2, 'provenance', 'research_only', 'running')", ) .bind(mission) .bind(ws.id.as_uuid()) .execute(pool) .await .unwrap(); let phase = Uuid::now_v7(); sqlx::query( "INSERT INTO mission_phases (id, mission_id, kind, order_idx, status) VALUES ($1, $2, 'research', 0, 'running')", ) .bind(phase) .bind(mission) .execute(pool) .await .unwrap(); (mission, phase) } #[tokio::test] async fn the_prompt_and_the_narrative_are_both_readable_after_the_fact() { let pool = cm_testkit::test_pool().await; let (mission, phase) = seed_phase(&pool).await; let mut prompt = MissionEvent::new(mission, PROMPT_COMPOSED); prompt.phase_id = Some(phase); prompt.target = Some("researcher".into()); prompt.detail = serde_json::json!({ "text": "Task: read the papers\n## arxiv-daily\nDo not re-search." }); mission_events::record(&pool, prompt).await; let mut said = MissionEvent::new(mission, REASONING); said.phase_id = Some(phase); said.detail = serde_json::json!({ "text": "I read the manifest and wrote analysis.md." }); mission_events::record(&pool, said).await; let narrative = mission_events::narrative_for_mission(&pool, mission) .await .unwrap(); let prompt_text = narrative .iter() .find(|(kind, ..)| kind == PROMPT_COMPOSED) .map(|(.., text)| text.clone()) .expect("the prompt must be recoverable — re-deriving it later re-runs \ the skill lookup against a catalogue that will have changed"); assert!(prompt_text.contains("Do not re-search.")); assert!( prompt_text.contains("Task: read the papers"), "the whole composed prompt, not just the skills half" ); assert!( narrative .iter() .any(|(kind, .., text)| kind == REASONING && text.contains("analysis.md")), "the agent's own account must come back out of the database" ); } #[tokio::test] async fn a_busy_phase_cannot_push_out_its_own_provenance() { let pool = cm_testkit::test_pool().await; let (mission, phase) = seed_phase(&pool).await; // Fill the phase past the cap with the kind the cap exists to bound. for i in 0..(PER_PHASE_CAP + 20) { let mut ev = MissionEvent::new(mission, TOOL_CALL); ev.phase_id = Some(phase); ev.target = Some(format!("tool_{i}")); mission_events::record(&pool, ev).await; } let tool_rows: i64 = sqlx::query_scalar( "SELECT count(*) FROM mission_events WHERE phase_id = $1 AND kind = $2", ) .bind(phase) .bind(TOOL_CALL) .fetch_one(&pool) .await .unwrap(); assert_eq!( tool_rows, PER_PHASE_CAP, "the cap must still bound the kind it was written for" ); // The prompt arrives AFTER the flood, which is the real ordering: a coding // phase calls its tools and then the next turn is composed. let mut prompt = MissionEvent::new(mission, PROMPT_COMPOSED); prompt.phase_id = Some(phase); prompt.detail = serde_json::json!({ "text": "the next turn's prompt" }); mission_events::record(&pool, prompt).await; let narrative = mission_events::narrative_for_mission(&pool, mission) .await .unwrap(); assert!( narrative.iter().any(|(.., text)| text == "the next turn's prompt"), "a phase that called {} tools dropped its own prompt — the cap counted \ provenance against a budget meant for the two unbounded kinds, so the \ busier the phase, the less of it is explainable", PER_PHASE_CAP + 20 ); } #[tokio::test] async fn a_mission_under_measurement_keeps_its_events_past_the_window() { let pool = cm_testkit::test_pool().await; let (kept, kept_phase) = seed_phase(&pool).await; let (reaped, reaped_phase) = seed_phase(&pool).await; // Only one of them is held. sqlx::query("UPDATE missions SET retain_events_until = now() + interval '30 days' WHERE id = $1") .bind(kept) .execute(&pool) .await .unwrap(); for (mission, phase) in [(kept, kept_phase), (reaped, reaped_phase)] { let mut ev = MissionEvent::new(mission, PROMPT_COMPOSED); ev.phase_id = Some(phase); ev.detail = serde_json::json!({ "text": "the prompt" }); mission_events::record(&pool, ev).await; } // Age both beyond the global window. sqlx::query("UPDATE mission_events SET created_at = now() - interval '90 days'") .execute(&pool) .await .unwrap(); let mut out = cm_api::mission_gc::Reclaimed::default(); cm_api::mission_gc::reap_mission_events(&pool, &mut out).await; assert!( !mission_events::narrative_for_mission(&pool, kept) .await .unwrap() .is_empty(), "a mission held for measurement lost its events — the evidence expires \ while the question is still open, and 'no events' reads exactly like \ 'nothing happened'" ); assert!( mission_events::narrative_for_mission(&pool, reaped) .await .unwrap() .is_empty(), "an unheld mission must still be reaped — an exemption that applies to \ everything is not an exemption, it is a raised global bound" ); }