From db48befa71fcca25f1aae9976110ded5fad7cf2b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 29 Aug 2026 09:42:54 +0200 Subject: [PATCH] Persist resumable newspaper reconciliation --- crates/ghostlight-dungeon/src/agent.rs | 96 +- .../src/bin/ghostlight-strategic-smoke.rs | 182 ++- crates/ghostlight-dungeon/src/newspaper.rs | 1142 +++++++++++++++-- crates/ghostlight-dungeon/src/registry.rs | 70 +- notes/fresh-workspace-handoff.md | 33 +- notes/ghostlight-current-system-map.md | 167 ++- state/evidence.jsonl | 5 +- state/ghostlight-state.cultcache.jsonl | 3 + state/map.yaml | 6 +- 9 files changed, 1454 insertions(+), 250 deletions(-) diff --git a/crates/ghostlight-dungeon/src/agent.rs b/crates/ghostlight-dungeon/src/agent.rs index 07f6320..d089f43 100644 --- a/crates/ghostlight-dungeon/src/agent.rs +++ b/crates/ghostlight-dungeon/src/agent.rs @@ -23,6 +23,41 @@ pub struct ModelAgentRun { pub receipts: Vec, } +#[derive(Debug)] +pub enum ModelAgentProgress { + Accepted(ModelAgentRun), + Exhausted(ModelAgentExhausted), +} + +#[derive(Debug)] +pub struct ModelAgentExhausted { + pub stage: String, + pub max_steps: usize, + pub final_observation: Option, + pub receipts: Vec, +} + +impl ModelAgentExhausted { + pub fn message(&self) -> String { + format!( + "model agent {} exhausted {} semantic steps; final tool observation: {}", + self.stage, + self.max_steps, + self.final_observation + .as_ref() + .map(serde_json::Value::to_string) + .unwrap_or_else(|| "none".into()), + ) + } + + fn into_failure(self) -> ModelAgentFailure { + ModelAgentFailure { + message: self.message(), + receipts: self.receipts, + } + } +} + #[derive(Debug)] pub struct ModelAgentFailure { pub message: String, @@ -93,6 +128,17 @@ pub async fn run_model_agent( spec: &ModelAgentSpec, tool: &mut Tool, ) -> std::result::Result, ModelAgentFailure> { + match run_model_agent_progress(port, spec, tool).await? { + ModelAgentProgress::Accepted(run) => Ok(run), + ModelAgentProgress::Exhausted(exhausted) => Err(exhausted.into_failure()), + } +} + +pub async fn run_model_agent_progress( + port: &dyn ModelPort, + spec: &ModelAgentSpec, + tool: &mut Tool, +) -> std::result::Result, ModelAgentFailure> { if spec.max_steps == 0 { return Err(ModelAgentFailure { message: format!("model agent {} has no semantic step budget", spec.stage), @@ -236,7 +282,10 @@ pub async fn run_model_agent( } => { receipts.push(stage.receipt); receipts.extend(tool_receipts); - return Ok(ModelAgentRun { output, receipts }); + return Ok(ModelAgentProgress::Accepted(ModelAgentRun { + output, + receipts, + })); } ModelAgentToolOutcome::Rejected { finding, @@ -273,17 +322,12 @@ pub async fn run_model_agent( } } - Err(ModelAgentFailure { - message: format!( - "model agent {} exhausted {} semantic steps; final tool observation: {}", - spec.stage, - spec.max_steps, - last_observation - .map(|observation| observation.to_string()) - .unwrap_or_else(|| "none".into()), - ), + Ok(ModelAgentProgress::Exhausted(ModelAgentExhausted { + stage: spec.stage.clone(), + max_steps: spec.max_steps, + final_observation: last_observation, receipts, - }) + })) } /// Builds one deterministic causal ancestry set for every model stage. This is @@ -637,4 +681,34 @@ mod tests { .contains(&failure.receipts[1].storage_key().to_owned()) ); } + + #[tokio::test] + async fn progress_api_returns_exhaustion_as_resumable_state() { + let model = ScriptedModel { + outputs: Mutex::new(vec![r#"{"value":"still-wrong"}"#.into()]), + requests: Mutex::new(Vec::new()), + }; + let spec = ModelAgentSpec { + stage: "fixture_agent".into(), + model: MODEL_BALANCED.into(), + snapshot_binding: "snapshot:1".into(), + instructions: "Use the tool.".into(), + source_receipt_ids: vec!["source:one".into()], + temperature: Some(0.0), + max_output_tokens: Some(128), + max_steps: 1, + }; + + let progress = run_model_agent_progress(&model, &spec, &mut ExactTool) + .await + .unwrap(); + let ModelAgentProgress::Exhausted(exhausted) = progress else { + panic!("one rejected action must remain resumable") + }; + + assert_eq!(exhausted.stage, "fixture_agent"); + assert_eq!(exhausted.max_steps, 1); + assert_eq!(exhausted.receipts.len(), 2); + assert_eq!(exhausted.final_observation.unwrap()["kind"], "wrong_value"); + } } diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs index 819556a..b44db3c 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs @@ -113,6 +113,16 @@ struct FoundationCheckpoint { model_receipts: Vec, } +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct NewspaperReconciliationImportEnvelope { + schema: String, + wave_index: u64, + recovery_start_wave: usize, + world_revision: u64, + import: ghostlight_dungeon::newspaper::WorldNewspaperReconciliationImport, +} + #[derive(serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] struct ClockBindingProposalCheckpoint { @@ -784,6 +794,10 @@ async fn main() -> anyhow::Result<()> { let initial_location_ids = campaign.locations.keys().cloned().collect::>(); (campaign, vec![], vec![], None, initial_location_ids) }; + let newspaper_reconciliation_import_path = + std::env::var_os("GHOSTLIGHT_STRATEGIC_NEWSPAPER_RECONCILIATION_IMPORT") + .map(std::path::PathBuf::from); + let mut newspaper_reconciliation_import_consumed = false; if resume { ghostlight_dungeon::compiler::validate_campaign_runtime(&campaign)?; } else { @@ -1708,8 +1722,9 @@ async fn main() -> anyhow::Result<()> { issue_path, issue_audit_path, newspaper_error, + newspaper_reconciliation_checkpoint, ) = match issue_composition { - Ok(composition) => { + Ok(ghostlight_dungeon::newspaper::WorldNewspaperAdvance::Accepted { composition }) => { let issue_path = root.join(format!("newspaper-wave-{wave_index:02}.md")); let issue_audit_path = root.join(format!("newspaper-wave-{wave_index:02}.audit.md")); @@ -1732,8 +1747,21 @@ async fn main() -> anyhow::Result<()> { Some(issue_path), Some(issue_audit_path), None, + None, ) } + Ok(ghostlight_dungeon::newspaper::WorldNewspaperAdvance::Pending { + checkpoint, + model_receipts, + }) => ( + None, + None, + model_receipts, + None::, + None::, + None, + Some(checkpoint), + ), Err(error) => { let Some(failure) = error.downcast_ref::< ghostlight_dungeon::newspaper::WorldNewspaperCompositionFailure, @@ -1747,6 +1775,7 @@ async fn main() -> anyhow::Result<()> { None::, None::, Some(error.to_string()), + None, ) } }; @@ -1764,6 +1793,7 @@ async fn main() -> anyhow::Result<()> { "newspaper_grounding":newspaper_grounding, "newspaper_model_receipts":newspaper_model_receipts, "newspaper_error":newspaper_error, + "newspaper_reconciliation_checkpoint":newspaper_reconciliation_checkpoint, "issue_path":issue_path, "issue_audit_path":issue_audit_path, }); @@ -1808,6 +1838,30 @@ async fn main() -> anyhow::Result<()> { let recomposition = if recomposition_path.is_file() { read_checkpoint::(&recomposition_path)? } else { + if let Some(import_path) = newspaper_reconciliation_import_path.as_deref() { + let import = + read_checkpoint::(import_path)?; + if import.wave_index == wave_index { + if import.schema + != "ghostlight.strategic_newspaper_reconciliation_import.v1" + || import.recovery_start_wave != newspaper_recovery_start_wave + || import.world_revision != issue_campaign.revision + { + anyhow::bail!( + "strategic newspaper reconciliation import does not bind this missing issue" + ) + } + ghostlight_dungeon::newspaper::admit_world_newspaper_reconciliation_import( + &issue_campaign, + &issue_title, + &newspaper_voice, + 5, + &store, + import.import, + )?; + newspaper_reconciliation_import_consumed = true; + } + } let composition = match compose_persisted_newspaper( model.as_ref(), &issue_campaign, @@ -1818,7 +1872,40 @@ async fn main() -> anyhow::Result<()> { ) .await { - Ok(composition) => composition, + Ok(ghostlight_dungeon::newspaper::WorldNewspaperAdvance::Accepted { + composition, + }) => composition, + Ok(ghostlight_dungeon::newspaper::WorldNewspaperAdvance::Pending { + checkpoint, + model_receipts, + }) => { + let checkpoint_id = checkpoint.id().to_owned(); + let checkpoint_digest = strategic_smoke_digest(&checkpoint)?; + let checkpoint_suffix = checkpoint_digest + .strip_prefix("sha256:") + .ok_or_else(|| anyhow::anyhow!("invalid checkpoint digest"))?; + publish_immutable_checkpoint( + &root.join(format!( + "newspaper-wave-{wave_index:02}-recomposition-pending-{checkpoint_suffix}.json" + )), + &serde_json::json!({ + "schema":"ghostlight.newspaper_wave_recomposition_pending.v1", + "wave_index":wave_index, + "recovery_start_wave":newspaper_recovery_start_wave, + "world_revision":issue_campaign.revision, + "source_campaign_digest":source_campaign_digest, + "editorial_contract_digest":editorial_contract_digest, + "checkpoint_digest":checkpoint_digest, + "checkpoint":checkpoint, + "model_receipt_set_digest":strategic_smoke_digest(&model_receipts)?, + "model_receipts":model_receipts, + }), + )?; + anyhow::bail!( + "newspaper reconciliation is pending at checkpoint {}", + checkpoint_id + ) + } Err(error) => { let model_receipts = error .downcast_ref::< @@ -1938,6 +2025,11 @@ async fn main() -> anyhow::Result<()> { ); report.insert("newspaper_error".into(), serde_json::Value::Null); } + if newspaper_reconciliation_import_path.is_some() + && !newspaper_reconciliation_import_consumed + { + anyhow::bail!("configured newspaper reconciliation import was not consumed") + } } let final_plan = final_wave_field(&wave_reports, "plan")?; let final_commit = final_wave_field(&wave_reports, "commit")?; @@ -1962,7 +2054,57 @@ async fn main() -> anyhow::Result<()> { ) .await { - Ok(composition) => composition, + Ok(ghostlight_dungeon::newspaper::WorldNewspaperAdvance::Accepted { composition }) => { + composition + } + Ok(ghostlight_dungeon::newspaper::WorldNewspaperAdvance::Pending { + checkpoint, + model_receipts, + }) => { + let checkpoint_id = checkpoint.id().to_owned(); + let pending_result = serde_json::json!({ + "schema":"ghostlight.live_strategic_smoke_pending.v1", + "scenario_id":scenario_id, + "pressure":pressure, + "wave_count":wave_count, + "newspaper_recovery_start_wave":newspaper_recovery_start_wave, + "campaign_id":campaign.id, + "elapsed_seconds":started.elapsed().as_secs_f64(), + "model_runtime":model_selection.status("configured"), + "world_compile":&world_compile, + "model_receipt_hash":&final_model_receipt_hash, + "model_stage_receipts":&model_stage_receipts, + "plan":&final_plan, + "commit":&final_commit, + "waves":&wave_reports, + "event_count":campaign.events.len(), + "news_count":campaign.news.len(), + "final_newspaper_checkpoint":checkpoint, + "final_newspaper_model_receipts":model_receipts, + "player_location_unchanged":true, + "player_state_unchanged":true, + "store":root.join("campaign.cc") + }); + let result_path = root.join("result.json"); + std::fs::write(&result_path, serde_json::to_vec_pretty(&pending_result)?)?; + std::fs::write( + root.join("status.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "schema":"ghostlight.live_strategic_smoke_status.v1", + "state":"pending_newspaper_reconciliation", + "waves_completed":wave_count, + "waves_requested":wave_count, + "newspaper_recovery_start_wave":newspaper_recovery_start_wave, + "world_revision":campaign.revision, + "event_count":campaign.events.len(), + "news_count":campaign.news.len(), + "updated_at":Utc::now(), + "result_path":result_path, + "newspaper_checkpoint_id":checkpoint_id, + }))?, + )?; + anyhow::bail!("final newspaper reconciliation is pending at checkpoint {checkpoint_id}") + } Err(error) => { let final_newspaper_model_receipts = error .downcast_ref::() @@ -2083,42 +2225,16 @@ async fn compose_persisted_newspaper( editorial_voice: &str, max_articles: usize, store: &ghostlight_dungeon::persistence::CampaignStore, -) -> anyhow::Result { - let result = ghostlight_dungeon::newspaper::compose_world_newspaper( +) -> anyhow::Result { + ghostlight_dungeon::newspaper::advance_world_newspaper( model, campaign, title, editorial_voice, max_articles, + store, ) - .await; - let receipts = match &result { - Ok(composition) => Some(composition.model_receipts.as_slice()), - Err(error) => error - .downcast_ref::() - .map(|failure| failure.model_receipts.as_slice()), - }; - if let Some(receipts) = receipts { - if let Err(persistence_error) = store.persist_model_stage_receipts(receipts) { - if let Err(error) = &result - && let Some(failure) = error.downcast_ref::< - ghostlight_dungeon::newspaper::WorldNewspaperCompositionFailure, - >() - { - return Err(anyhow::Error::new( - ghostlight_dungeon::newspaper::WorldNewspaperCompositionFailure { - message: format!( - "{}; model-receipt persistence failed: {persistence_error}", - failure.message - ), - model_receipts: failure.model_receipts.clone(), - }, - )); - } - return Err(persistence_error); - } - } - result + .await } async fn compile_strategic_campaign( diff --git a/crates/ghostlight-dungeon/src/newspaper.rs b/crates/ghostlight-dungeon/src/newspaper.rs index e6a0df9..f3fb234 100644 --- a/crates/ghostlight-dungeon/src/newspaper.rs +++ b/crates/ghostlight-dungeon/src/newspaper.rs @@ -1,10 +1,14 @@ use crate::{ - agent::{ModelAgentSpec, ModelAgentTool, ModelAgentToolContext, ModelAgentToolOutcome}, + agent::{ + ModelAgentProgress, ModelAgentSpec, ModelAgentTool, ModelAgentToolContext, + ModelAgentToolOutcome, + }, domain::{Campaign, Event, NewsIssue}, model::{ MODEL_BALANCED, MODEL_CAPABLE, ModelPort, ModelStageReceipt, ModelStageRequest, run_validated_stage, }, + persistence::CampaignStore, }; use anyhow::{Result, anyhow}; use async_trait::async_trait; @@ -20,7 +24,7 @@ use std::{ const MAX_FRONT_PAGE_ARTICLES: usize = 6; const MAX_SOURCE_NEWS_ITEMS: usize = 32; -const MAX_GROUNDING_RECONCILIATION_STEPS: usize = 3; +const GROUNDING_RECONCILIATION_ACTIONS_PER_ADVANCE: usize = 3; const EDITION_LABEL: &str = "Current Edition"; const ALLOWED_SECTIONS: [&str; 6] = [ "Front Page", @@ -98,6 +102,101 @@ pub struct WorldNewspaperComposition { pub model_receipts: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum WorldNewspaperAdvance { + Accepted { + composition: WorldNewspaperComposition, + }, + Pending { + checkpoint: WorldNewspaperReconciliationCheckpoint, + model_receipts: Vec, + }, +} + +impl WorldNewspaperAdvance { + pub fn model_receipts(&self) -> &[ModelStageReceipt] { + match self { + Self::Accepted { composition } => &composition.model_receipts, + Self::Pending { model_receipts, .. } => model_receipts, + } + } + + pub fn into_accepted(self) -> Option { + match self { + Self::Accepted { composition } => Some(composition), + Self::Pending { .. } => None, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorldNewspaperCheckpointOrigin { + InitialCopyDesk, + Reconciliation, + LegacyTerminalImport, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct WorldNewspaperReconciliationCheckpoint { + schema: String, + id: String, + publication_task_binding: String, + editorial_binding: String, + generation: u32, + previous_checkpoint_id: Option, + origin: WorldNewspaperCheckpointOrigin, + source_witness_digest: Option, + draft: EditorialPageDraft, + verdict: WorldNewspaperGroundingVerdict, + model_receipt_ids: Vec, + receipt_chain_digest: String, +} + +impl WorldNewspaperReconciliationCheckpoint { + pub fn id(&self) -> &str { + &self.id + } + + pub fn editorial_binding(&self) -> &str { + &self.editorial_binding + } + + pub fn generation(&self) -> u32 { + self.generation + } + + pub fn previous_checkpoint_id(&self) -> Option<&str> { + self.previous_checkpoint_id.as_deref() + } + + pub fn model_receipt_ids(&self) -> &[String] { + &self.model_receipt_ids + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct WorldNewspaperReconciliationImport { + schema: String, + source_witness_digest: String, + draft: EditorialPageDraft, + verdict: WorldNewspaperGroundingVerdict, + model_receipts: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +struct PersistedWorldNewspaperComposition { + schema: String, + publication_task_binding: String, + editorial_binding: String, + source_checkpoint_id: Option, + composition: WorldNewspaperComposition, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct WorldNewspaperCompositionFailure { pub message: String, @@ -168,7 +267,7 @@ struct NewsroomFact<'a> { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] -struct EditorialPageDraft { +pub struct EditorialPageDraft { #[schemars(length(min = 1, max = 6))] articles: Vec, } @@ -236,7 +335,7 @@ enum GroundingEditableField { Paragraph, } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] enum GroundingReconciliationFinding { CopyDeskRejected { @@ -246,7 +345,7 @@ enum GroundingReconciliationFinding { }, } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] struct GroundingFindingCatalogEntry { finding_ref: GroundingFindingRef, finding: WorldNewspaperGroundingFinding, @@ -285,13 +384,23 @@ struct GroundingReconciliationOutput { verdict: WorldNewspaperGroundingVerdict, } -pub async fn compose_world_newspaper( - model: &dyn ModelPort, +struct PreparedNewspaper { + title: String, + editorial_voice: String, + sources: Vec, + source_receipt_ids: Vec, + editorial_schema: serde_json::Value, + source_json: String, + publication_task_binding: String, + binding: String, +} + +fn prepare_newspaper( campaign: &Campaign, title: impl Into, editorial_voice: impl Into, max_articles: usize, -) -> Result { +) -> Result { if !(1..=MAX_FRONT_PAGE_ARTICLES).contains(&max_articles) { return Err(anyhow!( "newspaper front-page article budget must be between 1 and {MAX_FRONT_PAGE_ARTICLES}" @@ -313,11 +422,356 @@ pub async fn compose_world_newspaper( return Err(anyhow!("newspaper editorial voice is invalid")); } let sources = newsroom_sources(campaign)?; - if sources.is_empty() { + let source_receipt_ids = sources + .iter() + .flat_map(|source| source.news_ids.iter().cloned()) + .collect::>(); + let editorial_schema = editorial_schema(&sources, max_articles)?; + let source_json = serde_json::to_string_pretty(&newsroom_desk(&sources))?; + let publication_task_binding = + publication_task_binding(campaign, &title, &editorial_voice, max_articles)?; + let binding = editorial_binding(campaign, &title, &editorial_voice, max_articles, &sources)?; + Ok(PreparedNewspaper { + title, + editorial_voice, + sources, + source_receipt_ids, + editorial_schema, + source_json, + publication_task_binding, + binding, + }) +} + +fn checkpoint_receipt_chain_digest(receipt_ids: &[String]) -> Result { + Ok(format!( + "sha256:{:x}", + Sha256::digest(rmp_serde::to_vec_named(receipt_ids)?) + )) +} + +fn checkpoint_identity( + publication_task_binding: &str, + binding: &str, + generation: u32, + previous_checkpoint_id: Option<&str>, + origin: &WorldNewspaperCheckpointOrigin, + source_witness_digest: Option<&str>, + draft: &EditorialPageDraft, + verdict: &WorldNewspaperGroundingVerdict, + model_receipt_ids: &[String], + receipt_chain_digest: &str, +) -> Result { + Ok(format!( + "newspaper-reconciliation:sha256:{:x}", + Sha256::digest(rmp_serde::to_vec_named(&( + publication_task_binding, + binding, + generation, + previous_checkpoint_id, + origin, + source_witness_digest, + draft, + verdict, + model_receipt_ids, + receipt_chain_digest, + ))?) + )) +} + +#[allow(clippy::too_many_arguments)] +fn new_reconciliation_checkpoint( + publication_task_binding: &str, + binding: &str, + generation: u32, + previous_checkpoint_id: Option, + origin: WorldNewspaperCheckpointOrigin, + source_witness_digest: Option, + draft: EditorialPageDraft, + verdict: WorldNewspaperGroundingVerdict, + receipts: &[ModelStageReceipt], +) -> Result { + let model_receipt_ids = receipts + .iter() + .map(|receipt| receipt.storage_key().to_owned()) + .collect::>(); + let receipt_chain_digest = checkpoint_receipt_chain_digest(&model_receipt_ids)?; + let id = checkpoint_identity( + publication_task_binding, + binding, + generation, + previous_checkpoint_id.as_deref(), + &origin, + source_witness_digest.as_deref(), + &draft, + &verdict, + &model_receipt_ids, + &receipt_chain_digest, + )?; + Ok(WorldNewspaperReconciliationCheckpoint { + schema: "ghostlight.world_newspaper_reconciliation_checkpoint.v1".into(), + id, + publication_task_binding: publication_task_binding.into(), + editorial_binding: binding.into(), + generation, + previous_checkpoint_id, + origin, + source_witness_digest, + draft, + verdict, + model_receipt_ids, + receipt_chain_digest, + }) +} + +fn persist_reconciliation_checkpoint( + store: &CampaignStore, + checkpoint: &WorldNewspaperReconciliationCheckpoint, +) -> Result<()> { + let kind = "world_newspaper_reconciliation_checkpoint.v1"; + if let Some((_, existing)) = + store.load::(kind, checkpoint.id())? + { + if existing != *checkpoint { + return Err(anyhow!( + "immutable newspaper reconciliation checkpoint conflict: {}", + checkpoint.id() + )); + } + return Ok(()); + } + store.insert( + kind, + "ghostlight.world_newspaper_reconciliation_checkpoint.v1", + checkpoint.id(), + checkpoint, + )?; + Ok(()) +} + +fn persist_newspaper_completion( + store: &CampaignStore, + publication_task_binding: &str, + binding: &str, + source_checkpoint_id: Option, + composition: &WorldNewspaperComposition, +) -> Result<()> { + let kind = "world_newspaper_composition.v1"; + let record = PersistedWorldNewspaperComposition { + schema: "ghostlight.persisted_world_newspaper_composition.v1".into(), + publication_task_binding: publication_task_binding.into(), + editorial_binding: binding.into(), + source_checkpoint_id, + composition: composition.clone(), + }; + if let Some((_, existing)) = + store.load::(kind, publication_task_binding)? + { + if existing != record { + return Err(anyhow!( + "immutable accepted newspaper composition conflict for {binding}" + )); + } + return Ok(()); + } + store.insert( + kind, + "ghostlight.persisted_world_newspaper_composition.v1", + publication_task_binding, + &record, + )?; + Ok(()) +} + +fn load_checkpoint_receipts( + store: &CampaignStore, + checkpoint: &WorldNewspaperReconciliationCheckpoint, +) -> Result> { + checkpoint + .model_receipt_ids + .iter() + .map(|receipt_id| { + store + .load::("persona_stage_receipt.v1", receipt_id)? + .map(|(_, receipt)| receipt) + .ok_or_else(|| anyhow!("newspaper checkpoint lost model receipt {receipt_id}")) + }) + .collect() +} + +fn validate_reconciliation_checkpoint( + checkpoint: &WorldNewspaperReconciliationCheckpoint, + prepared: &PreparedNewspaper, + max_articles: usize, + receipts: &[ModelStageReceipt], +) -> Result<()> { + if checkpoint.schema != "ghostlight.world_newspaper_reconciliation_checkpoint.v1" + || checkpoint.publication_task_binding != prepared.publication_task_binding + || checkpoint.editorial_binding != prepared.binding + || checkpoint.verdict.accepted + || checkpoint.verdict.findings.is_empty() + || receipts.len() != checkpoint.model_receipt_ids.len() + { + return Err(anyhow!("newspaper reconciliation checkpoint is invalid")); + } + if checkpoint.generation == 0 && checkpoint.previous_checkpoint_id.is_some() + || checkpoint.generation > 0 && checkpoint.previous_checkpoint_id.is_none() + { + return Err(anyhow!( + "newspaper reconciliation checkpoint lineage is invalid" + )); + } + let receipt_ids = receipts + .iter() + .map(|receipt| receipt.storage_key().to_owned()) + .collect::>(); + if receipt_ids != checkpoint.model_receipt_ids + || receipt_ids.iter().collect::>().len() != receipt_ids.len() + || checkpoint_receipt_chain_digest(&receipt_ids)? != checkpoint.receipt_chain_digest + || checkpoint_identity( + &checkpoint.publication_task_binding, + &checkpoint.editorial_binding, + checkpoint.generation, + checkpoint.previous_checkpoint_id.as_deref(), + &checkpoint.origin, + checkpoint.source_witness_digest.as_deref(), + &checkpoint.draft, + &checkpoint.verdict, + &checkpoint.model_receipt_ids, + &checkpoint.receipt_chain_digest, + )? != checkpoint.id + { + return Err(anyhow!( + "newspaper reconciliation checkpoint receipt binding is invalid" + )); + } + if receipts.first().map(|receipt| receipt.stage.as_str()) != Some("newspaper_editor") + || receipts + .first() + .map(|receipt| receipt.snapshot_binding.as_str()) + != Some(prepared.binding.as_str()) + || receipts.get(1).map(|receipt| receipt.stage.as_str()) != Some("newspaper_copy_desk") + { + return Err(anyhow!( + "newspaper reconciliation checkpoint lost its editor and copy-desk ancestry" + )); + } + validate_editorial_draft(&prepared.sources, &checkpoint.draft, max_articles)?; + let verdict = GroundingVerdictDraft { + accepted: checkpoint.verdict.accepted, + assessment: checkpoint.verdict.assessment.clone(), + findings: checkpoint.verdict.findings.clone(), + }; + validate_grounding_verdict(&checkpoint.draft, &verdict)?; + Ok(()) +} + +fn load_reconciliation_tip( + store: &CampaignStore, + prepared: &PreparedNewspaper, + max_articles: usize, +) -> Result< + Option<( + WorldNewspaperReconciliationCheckpoint, + Vec, + )>, +> { + let checkpoints = store + .load_all::( + "world_newspaper_reconciliation_checkpoint.v1", + )? + .into_iter() + .filter(|checkpoint| { + checkpoint.publication_task_binding == prepared.publication_task_binding + }) + .collect::>(); + if checkpoints.is_empty() { + return Ok(None); + } + let by_id = checkpoints + .iter() + .map(|checkpoint| (checkpoint.id.as_str(), checkpoint)) + .collect::>(); + let referenced = checkpoints + .iter() + .filter_map(|checkpoint| checkpoint.previous_checkpoint_id.as_deref()) + .collect::>(); + let tips = checkpoints + .iter() + .filter(|checkpoint| !referenced.contains(checkpoint.id.as_str())) + .collect::>(); + if tips.len() != 1 { + return Err(anyhow!( + "newspaper reconciliation checkpoint chain has multiple or missing tips" + )); + } + for checkpoint in &checkpoints { + let receipts = load_checkpoint_receipts(store, checkpoint)?; + validate_reconciliation_checkpoint(checkpoint, prepared, max_articles, &receipts)?; + if let Some(previous_id) = checkpoint.previous_checkpoint_id.as_deref() { + let previous = by_id + .get(previous_id) + .ok_or_else(|| anyhow!("newspaper reconciliation checkpoint parent is missing"))?; + if checkpoint.generation != previous.generation + 1 + || !checkpoint + .model_receipt_ids + .starts_with(&previous.model_receipt_ids) + || checkpoint.model_receipt_ids.len() <= previous.model_receipt_ids.len() + { + return Err(anyhow!( + "newspaper reconciliation checkpoint chain is not an exact receipt-prefix extension" + )); + } + } + } + let tip = (*tips[0]).clone(); + let receipts = load_checkpoint_receipts(store, &tip)?; + Ok(Some((tip, receipts))) +} + +pub async fn advance_world_newspaper( + model: &dyn ModelPort, + campaign: &Campaign, + title: impl Into, + editorial_voice: impl Into, + max_articles: usize, + store: &CampaignStore, +) -> Result { + let prepared = prepare_newspaper(campaign, title, editorial_voice, max_articles)?; + if let Some((_, persisted)) = store.load::( + "world_newspaper_composition.v1", + &prepared.publication_task_binding, + )? { + if persisted.schema != "ghostlight.persisted_world_newspaper_composition.v1" + || persisted.publication_task_binding != prepared.publication_task_binding + || persisted.editorial_binding != prepared.binding + || !persisted.composition.grounding.accepted + || persisted.composition.issue.source_world_revision != campaign.revision + || persisted.composition.issue.title != prepared.title + { + return Err(anyhow!("persisted newspaper composition is invalid")); + } + return Ok(WorldNewspaperAdvance::Accepted { + composition: persisted.composition, + }); + } + if let Some((checkpoint, receipts)) = load_reconciliation_tip(store, &prepared, max_articles)? { + return advance_reconciliation( + model, + campaign, + &prepared, + max_articles, + store, + checkpoint, + receipts, + ) + .await; + } + if prepared.sources.is_empty() { let issue = WorldNewspaperIssue { schema: "ghostlight.world_newspaper_issue.v2".into(), - id: empty_issue_id(campaign, &title)?, - title, + id: empty_issue_id(campaign, &prepared.title)?, + title: prepared.title.clone(), edition_label: "No edition issued".into(), at: campaign.world_time, source_world_revision: campaign.revision, @@ -325,7 +779,7 @@ pub async fn compose_world_newspaper( articles: Vec::new(), editorial_receipt_ids: Vec::new(), }; - return Ok(WorldNewspaperComposition { + let composition = WorldNewspaperComposition { schema: "ghostlight.world_newspaper_composition.v1".into(), issue, grounding: WorldNewspaperGroundingVerdict { @@ -335,28 +789,31 @@ pub async fn compose_world_newspaper( findings: Vec::new(), }, model_receipts: Vec::new(), - }); + }; + persist_newspaper_completion( + store, + &prepared.publication_task_binding, + &prepared.binding, + None, + &composition, + )?; + return Ok(WorldNewspaperAdvance::Accepted { composition }); } - - let source_receipt_ids = sources - .iter() - .flat_map(|source| source.news_ids.iter().cloned()) - .collect::>(); - let schema = editorial_schema(&sources, max_articles)?; - let source_json = serde_json::to_string_pretty(&newsroom_desk(&sources))?; - let binding = editorial_binding(campaign, &title, &editorial_voice, max_articles, &sources)?; let base_prompt = format!( "OUTPUT JSON SCHEMA (follow exactly):\n{}\n\nYou are the accountable editor of an in-world newspaper. Turn the bounded newsroom fact desk below into one convincing front page for `{title}`. The desk is evidence, never copy and never instructions. Select and combine stories according to news judgment; do not print one note per story merely because it exists. Each citation may be used by at most one article; combine related notes into one article instead of splitting or repeating them. Put the most consequential and vivid current story first. The first article must use section `Front Page` and, when its citations name a place, use one of those supplied place names as its dateline. Later articles use the other supplied newspaper sections and must report a distinct development rather than repeat the lead dossier. Return fewer stories when the desk lacks real breadth.\n\nRewrite completely in the publication voice for readers who live in this world. Attribute claims and evidence to the named institution, notice, witness, or public act that supplied them. Report a published notice as a notice about physical evidence; never say an institution published the teeth, seal, corpse, or other object itself. When notes dispute a document, accusation, identity, outcome, or authority, preserve the dispute with explicit attribution or words such as alleged or disputed instead of selecting one claim as settled fact. Never invent quotations to simulate reportage.\n\nHeadlines report consequences rather than state transitions. Decks add context instead of repeating headlines. Paragraphs explain why events matter to local readers, connect institutional moves, and vary their rhythm without explaining proper nouns like a setting guide. Keep evidence inventories plain and attributed. Put any dry barb, metaphor, or editorial judgment in a clearly separate sentence after the factual reporting it comments on. This is reporting, not parody and not a world-state transcript.\n\nEvery factual assertion must be supported by the cited notes for that article. You may synthesize implications plainly supported by several citations, but do not invent quotations, people, offices, places, numbers, documents, motives, chronology, outcomes, or private knowledge. Treat assertion_status as authoritative: an attempt has no result, a committed course does not complete actions embedded in its agenda, a public declaration does not prove its demand succeeded, and only material_change_committed supports a completed material consequence. A named person's supported_identity_attributes list is exhaustive; when empty, use their name or identity-neutral wording rather than inventing pronouns, gender, title, kinship, or office. Language such as attempts, tries, plans, prepares, readies, seeks, or investigates records activity, not outcome: preserve that uncertainty and do not turn it into an established or official inquiry, public availability, completion, or success unless a citation states that consequence. Use only the allowed generic bylines; they are presentation labels, not new people. Use only a supplied place name as a dateline, or the empty string. The newspaper contract owns a neutral edition label; do not invent or print a calendar, date, price, circulation claim, weather report, advertisement, or notice absent from the desk. Do not make the fact desk, citations, or verification process part of the reader-facing copy. Never end a headline with an ellipsis.\n\nPUBLICATION VOICE:\n{editorial_voice}\n\nNEWSROOM FACT DESK:\n{source_json}", - serde_json::to_string(&schema)?, + serde_json::to_string(&prepared.editorial_schema)?, + title = prepared.title, + editorial_voice = prepared.editorial_voice, + source_json = prepared.source_json, ); let mut receipts = Vec::new(); let request = ModelStageRequest { stage: "newspaper_editor".into(), model: MODEL_CAPABLE.into(), - snapshot_binding: binding.clone(), + snapshot_binding: prepared.binding.clone(), lived_stream: base_prompt, - output_schema: Some(schema.clone()), - source_receipt_ids: source_receipt_ids.clone(), + output_schema: Some(prepared.editorial_schema.clone()), + source_receipt_ids: prepared.source_receipt_ids.clone(), temperature: Some(0.65), max_output_tokens: Some(4_500), }; @@ -380,17 +837,18 @@ pub async fn compose_world_newspaper( } }; - if let Err(error) = validate_editorial_draft(&sources, &draft, max_articles) { + if let Err(error) = validate_editorial_draft(&prepared.sources, &draft, max_articles) { mark_semantic_invalid(&mut receipts[editor_receipt_index], &error); return Err(composition_failure(error.to_string(), receipts)); } + store.persist_model_stage_receipts(&receipts)?; let editor_receipt_id = receipts[editor_receipt_index].storage_key().to_owned(); let editor_output_hash = receipts[editor_receipt_index].output_hash.clone(); let verdict = match run_copy_desk( model, - format!("{binding}:draft:{editor_output_hash}"), - &source_json, - &source_receipt_ids, + format!("{}:draft:{editor_output_hash}", prepared.binding), + &prepared.source_json, + &prepared.source_receipt_ids, std::slice::from_ref(&editor_receipt_id), &draft, &mut receipts, @@ -398,52 +856,223 @@ pub async fn compose_world_newspaper( .await { Ok(verdict) => verdict, - Err(error) => return Err(composition_failure(error.to_string(), receipts)), + Err(error) => { + store.persist_model_stage_receipts(&receipts)?; + return Err(composition_failure(error.to_string(), receipts)); + } }; + store.persist_model_stage_receipts(&receipts)?; if verdict.accepted { - let issue = lower_editorial_page(campaign, title, &sources, draft, &receipts)?; - return Ok(WorldNewspaperComposition { + let issue = lower_editorial_page( + campaign, + prepared.title.clone(), + &prepared.sources, + draft, + &receipts, + )?; + let composition = WorldNewspaperComposition { schema: "ghostlight.world_newspaper_composition.v1".into(), issue, grounding: verdict, model_receipts: receipts, - }); + }; + persist_newspaper_completion( + store, + &prepared.publication_task_binding, + &prepared.binding, + None, + &composition, + )?; + return Ok(WorldNewspaperAdvance::Accepted { composition }); } - let initial_finding = grounding_reconciliation_finding(draft.clone(), verdict); - - let reconciliation_sources = receipts - .iter() - .map(|receipt| receipt.storage_key().to_owned()) - .collect::>(); - let reconciliation = run_grounding_reconciliation( - model, - &sources, - max_articles, - &binding, - &source_json, - &source_receipt_ids, - &reconciliation_sources, + let checkpoint = new_reconciliation_checkpoint( + &prepared.publication_task_binding, + &prepared.binding, + 0, + None, + WorldNewspaperCheckpointOrigin::InitialCopyDesk, + None, draft, - initial_finding, + verdict, + &receipts, + )?; + persist_reconciliation_checkpoint(store, &checkpoint)?; + advance_reconciliation( + model, + campaign, + &prepared, + max_articles, + store, + checkpoint, + receipts, ) - .await; - match reconciliation { - Ok(run) => { - receipts.extend(run.receipts); - let issue = - lower_editorial_page(campaign, title, &sources, run.output.draft, &receipts)?; - Ok(WorldNewspaperComposition { - schema: "ghostlight.world_newspaper_composition.v1".into(), - issue, - grounding: run.output.verdict, - model_receipts: receipts, - }) + .await +} + +#[cfg(test)] +async fn compose_world_newspaper( + model: &dyn ModelPort, + campaign: &Campaign, + title: impl Into, + editorial_voice: impl Into, + max_articles: usize, +) -> Result { + let directory = tempfile::tempdir()?; + let store = CampaignStore::open(directory.path().join("campaign.cc"))?; + match advance_world_newspaper( + model, + campaign, + title, + editorial_voice, + max_articles, + &store, + ) + .await? + { + WorldNewspaperAdvance::Accepted { composition } => Ok(composition), + WorldNewspaperAdvance::Pending { model_receipts, .. } => Err(composition_failure( + format!( + "grounding reconciliation pending after {} semantic steps", + GROUNDING_RECONCILIATION_ACTIONS_PER_ADVANCE + ), + model_receipts, + )), + } +} + +pub fn admit_world_newspaper_reconciliation_import( + campaign: &Campaign, + title: impl Into, + editorial_voice: impl Into, + max_articles: usize, + store: &CampaignStore, + import: WorldNewspaperReconciliationImport, +) -> Result { + let prepared = prepare_newspaper(campaign, title, editorial_voice, max_articles)?; + let existing_tip = load_reconciliation_tip(store, &prepared, max_articles)?; + if import.schema != "ghostlight.world_newspaper_reconciliation_import.v1" + || !import.source_witness_digest.starts_with("sha256:") + || import.source_witness_digest.len() != 71 + || store + .load::( + "world_newspaper_composition.v1", + &prepared.publication_task_binding, + )? + .is_some() + { + return Err(anyhow!( + "world newspaper reconciliation import is not admissible" + )); + } + let checkpoint = new_reconciliation_checkpoint( + &prepared.publication_task_binding, + &prepared.binding, + 0, + None, + WorldNewspaperCheckpointOrigin::LegacyTerminalImport, + Some(import.source_witness_digest), + import.draft, + import.verdict, + &import.model_receipts, + )?; + validate_reconciliation_checkpoint( + &checkpoint, + &prepared, + max_articles, + &import.model_receipts, + )?; + if let Some((existing, receipts)) = existing_tip { + if existing == checkpoint && receipts == import.model_receipts { + return Ok(existing); } - Err(failure) => { - receipts.extend(failure.receipts); - Err(composition_failure(failure.message, receipts)) + return Err(anyhow!( + "world newspaper reconciliation import would fork an existing checkpoint chain" + )); + } + store.persist_model_stage_receipts(&import.model_receipts)?; + persist_reconciliation_checkpoint(store, &checkpoint)?; + Ok(checkpoint) +} + +#[allow(clippy::too_many_arguments)] +async fn advance_reconciliation( + model: &dyn ModelPort, + campaign: &Campaign, + prepared: &PreparedNewspaper, + max_articles: usize, + store: &CampaignStore, + mut checkpoint: WorldNewspaperReconciliationCheckpoint, + mut receipts: Vec, +) -> Result { + validate_reconciliation_checkpoint(&checkpoint, prepared, max_articles, &receipts)?; + for _ in 0..GROUNDING_RECONCILIATION_ACTIONS_PER_ADVANCE { + let progress = run_grounding_reconciliation_step( + model, + &prepared.sources, + max_articles, + &prepared.binding, + &prepared.source_json, + &prepared.source_receipt_ids, + &receipts, + checkpoint.id(), + checkpoint.draft.clone(), + checkpoint.verdict.clone(), + ) + .await; + match progress { + Ok((ModelAgentProgress::Accepted(run), _, _)) => { + store.persist_model_stage_receipts(&run.receipts)?; + receipts.extend(run.receipts); + let issue = lower_editorial_page( + campaign, + prepared.title.clone(), + &prepared.sources, + run.output.draft, + &receipts, + )?; + let composition = WorldNewspaperComposition { + schema: "ghostlight.world_newspaper_composition.v1".into(), + issue, + grounding: run.output.verdict, + model_receipts: receipts, + }; + persist_newspaper_completion( + store, + &prepared.publication_task_binding, + &prepared.binding, + Some(checkpoint.id.clone()), + &composition, + )?; + return Ok(WorldNewspaperAdvance::Accepted { composition }); + } + Ok((ModelAgentProgress::Exhausted(exhausted), draft, verdict)) => { + store.persist_model_stage_receipts(&exhausted.receipts)?; + receipts.extend(exhausted.receipts); + let next = new_reconciliation_checkpoint( + &prepared.publication_task_binding, + &prepared.binding, + checkpoint.generation + 1, + Some(checkpoint.id.clone()), + WorldNewspaperCheckpointOrigin::Reconciliation, + None, + draft, + verdict, + &receipts, + )?; + persist_reconciliation_checkpoint(store, &next)?; + checkpoint = next; + } + Err(failure) => { + store.persist_model_stage_receipts(&failure.receipts)?; + receipts.extend(failure.receipts); + return Err(composition_failure(failure.message, receipts)); + } } } + Ok(WorldNewspaperAdvance::Pending { + checkpoint, + model_receipts: receipts, + }) } struct GroundingReconciliationWorkbench<'a> { @@ -512,37 +1141,33 @@ impl ModelAgentTool for GroundingReconciliationWorkbench<'_> { let draft = match apply_grounding_edits(&self.draft, &self.verdict, action) { Ok(draft) => draft, Err(error) => { + let verdict = WorldNewspaperGroundingVerdict { + accepted: false, + assessment: format!("The proposed narrow repair was not admitted: {error}") + .chars() + .take(500) + .collect(), + findings: self.verdict.findings.clone(), + }; + self.verdict = verdict.clone(); return ModelAgentToolOutcome::Rejected { - finding: grounding_reconciliation_finding( - self.draft.clone(), - WorldNewspaperGroundingVerdict { - accepted: false, - assessment: format!( - "The proposed narrow repair was not admitted: {error}" - ) - .chars() - .take(500) - .collect(), - findings: self.verdict.findings.clone(), - }, - ), + finding: grounding_reconciliation_finding(self.draft.clone(), verdict), receipts: Vec::new(), }; } }; if let Err(error) = validate_editorial_draft(self.sources, &draft, self.max_articles) { + let verdict = WorldNewspaperGroundingVerdict { + accepted: false, + assessment: format!("The narrow repair produced invalid copy: {error}") + .chars() + .take(500) + .collect(), + findings: self.verdict.findings.clone(), + }; + self.verdict = verdict.clone(); return ModelAgentToolOutcome::Rejected { - finding: grounding_reconciliation_finding( - self.draft.clone(), - WorldNewspaperGroundingVerdict { - accepted: false, - assessment: format!("The narrow repair produced invalid copy: {error}") - .chars() - .take(500) - .collect(), - findings: self.verdict.findings.clone(), - }, - ), + finding: grounding_reconciliation_finding(self.draft.clone(), verdict), receipts: Vec::new(), }; } @@ -788,34 +1413,42 @@ fn resolve_grounding_finding_target( } #[allow(clippy::too_many_arguments)] -async fn run_grounding_reconciliation( +async fn run_grounding_reconciliation_step( model: &dyn ModelPort, sources: &[NewsroomSource], max_articles: usize, binding: &str, source_json: &str, source_receipt_ids: &[String], - reconciliation_source_ids: &[String], + reconciliation_receipts: &[ModelStageReceipt], + checkpoint_id: &str, draft: EditorialPageDraft, - initial_finding: GroundingReconciliationFinding, + verdict: WorldNewspaperGroundingVerdict, ) -> std::result::Result< - crate::agent::ModelAgentRun, + ( + ModelAgentProgress, + EditorialPageDraft, + WorldNewspaperGroundingVerdict, + ), crate::agent::ModelAgentFailure, > { - let initial_verdict = match &initial_finding { - GroundingReconciliationFinding::CopyDeskRejected { verdict, .. } => verdict.clone(), - }; - let indexed_findings = grounding_finding_catalog(&initial_verdict); + let indexed_findings = grounding_finding_catalog(&verdict); let instructions = format!( - "You are Ghostlight's grounding reconciliation agent. Repair one already-edited newspaper page against the same frozen source desk and exact rejection. You do not report new events, add spice, improve the simulation, or rerun the editor. The workbench freezes article selection, order, sections, bylines, citations, and all unaffected copy. Submit only replacements for the numbered current findings, or delete the article containing a numbered finding when its cited notes cannot support a grounded story. Each replacement selects one finding_ref and supplies only its replacement text. The deterministic workbench owns the selected finding's article, text field, paragraph, exact phrase, and byte span. replacement may be empty when phrase deletion is the honest repair. Address the complete finding set in this one pass. Preserve source status exactly: an attempt is not a result, a committed course does not complete its embedded plans, and a public declaration is not evidence that its demand succeeded. A named person's supported_identity_attributes is exhaustive; when it is empty, use the name or identity-neutral wording and do not invent pronouns, gender, title, kinship, or office. Delete an unsupported phrase rather than replacing it with an adjacent invention. The deterministic workbench applies the edits transactionally and reruns the same whole-page copy desk.\n\nFROZEN NEWSROOM FACT DESK:\n{}\n\nINITIAL DRAFT:\n{}\n\nNUMBERED EXACT FINDINGS:\n{}", + "You are Ghostlight's grounding reconciliation agent. Repair one already-edited newspaper page against the same frozen source desk and exact rejection. You do not report new events, add spice, improve the simulation, or rerun the editor. The workbench freezes article selection, order, sections, bylines, citations, and all unaffected copy. Submit only replacements for the numbered current findings, or delete the article containing a numbered finding when its cited notes cannot support a grounded story. Each replacement selects one finding_ref and supplies only its replacement text. The deterministic workbench owns the selected finding's article, text field, paragraph, exact phrase, and byte span. replacement may be empty when phrase deletion is the honest repair. Address the complete finding set in this one pass. Preserve source status exactly: an attempt is not a result, a committed course does not complete its embedded plans, and a public declaration is not evidence that its demand succeeded. A named person's supported_identity_attributes is exhaustive; when it is empty, use the name or identity-neutral wording and do not invent pronouns, gender, title, kinship, or office. Delete an unsupported phrase rather than replacing it with an adjacent invention. The deterministic workbench applies the edits transactionally and reruns the same whole-page copy desk.\n\nFROZEN NEWSROOM FACT DESK:\n{}\n\nCURRENT CHECKPOINTED DRAFT:\n{}\n\nCURRENT REJECTION VERDICT:\n{}\n\nNUMBERED EXACT FINDINGS:\n{}", source_json, serde_json::to_string_pretty(&draft).unwrap_or_default(), + serde_json::to_string_pretty(&verdict).unwrap_or_default(), serde_json::to_string_pretty(&indexed_findings).unwrap_or_default(), ); let mut causal_sources = source_receipt_ids .iter() .cloned() - .chain(reconciliation_source_ids.iter().cloned()) + .chain( + reconciliation_receipts + .iter() + .map(|receipt| receipt.storage_key().to_owned()), + ) + .chain(std::iter::once(checkpoint_id.to_owned())) .collect::>() .into_iter() .collect::>(); @@ -828,7 +1461,7 @@ async fn run_grounding_reconciliation( source_receipt_ids: causal_sources, temperature: Some(0.1), max_output_tokens: Some(1_200), - max_steps: MAX_GROUNDING_RECONCILIATION_STEPS, + max_steps: 1, }; let mut tool = GroundingReconciliationWorkbench { model, @@ -838,9 +1471,10 @@ async fn run_grounding_reconciliation( source_json, source_receipt_ids, draft, - verdict: initial_verdict, + verdict, }; - crate::agent::run_model_agent(model, &spec, &mut tool).await + let progress = crate::agent::run_model_agent_progress(model, &spec, &mut tool).await?; + Ok((progress, tool.draft, tool.verdict)) } async fn run_copy_desk( @@ -1530,6 +2164,22 @@ fn lower_editorial_page( }) } +fn publication_task_binding( + campaign: &Campaign, + title: &str, + voice: &str, + max_articles: usize, +) -> Result { + let bytes = + rmp_serde::to_vec_named(&(campaign.id, campaign.revision, title, voice, max_articles))?; + Ok(format!( + "campaign:{}:revision:{}:newspaper-task:sha256:{:x}", + campaign.id, + campaign.revision, + Sha256::digest(bytes) + )) +} + fn editorial_binding( campaign: &Campaign, title: &str, @@ -1784,6 +2434,38 @@ mod tests { r#"{"tool":"submit_edits","replacements":[],"delete_finding_refs":[0]}"#; const ACCEPTING_COPY_DESK: &str = r#"{"accepted":true,"assessment":"The copy is fully supported by its cited public source and reads as attributed court reporting.","findings":[]}"#; + async fn pending_local_rejection_fixture() -> ( + Campaign, + tempfile::TempDir, + CampaignStore, + WorldNewspaperReconciliationCheckpoint, + ) { + const REJECTED: &str = r#"{"accepted":false,"assessment":"The deck overstates where the admitted debt reached.","findings":[{"article_index":0,"category":"unsupported_fact","claim_or_phrase":"A gambling debt reaches the throne room and leaves one official carrying the blame.","reason":"The cited source records the pawned seal and dismissal but does not locate the debt in the throne room."}]}"#; + let campaign = campaign_with_news(); + let directory = tempfile::tempdir().unwrap(); + let store = CampaignStore::open(directory.path().join("campaign.cc")).unwrap(); + let model = ScriptedNewspaperModel::new([ + ACCEPTED_PAGE, + REJECTED, + EMPTY_REPAIR_ACTION, + EMPTY_REPAIR_ACTION, + EMPTY_REPAIR_ACTION, + ]); + let WorldNewspaperAdvance::Pending { checkpoint, .. } = advance_world_newspaper( + &model, + &campaign, + "The Underdeep Clarion", + "A skeptical court broadsheet with dry restraint.", + 4, + &store, + ) + .await + .unwrap() else { + panic!("fixture must produce a pending reconciliation") + }; + (campaign, directory, store, checkpoint) + } + #[test] fn copy_desk_schema_owns_the_complete_finding_category_set() { let schema = serde_json::to_string(&schema_for!(GroundingVerdictDraft)).unwrap(); @@ -2272,7 +2954,7 @@ mod tests { .lived_stream .contains("revised deck still overstates blame") ); - assert!(requests[4].lived_stream.contains("\"finding_ref\":0")); + assert!(requests[4].lived_stream.contains("\"finding_ref\"")); } #[tokio::test] @@ -2374,7 +3056,7 @@ mod tests { .downcast_ref::() .expect("terminal editorial rejection must retain its receipts"); - assert!(failure.message.contains("exhausted 3 semantic steps")); + assert!(failure.message.contains("pending after 3 semantic steps")); assert_eq!(failure.model_receipts.len(), 8); assert_eq!( failure @@ -2402,6 +3084,262 @@ mod tests { } } + #[tokio::test] + async fn pending_reconciliation_resumes_its_exact_chain_without_an_editor() { + const REJECTED: &str = r#"{"accepted":false,"assessment":"The deck overstates where the admitted debt reached.","findings":[{"article_index":0,"category":"unsupported_fact","claim_or_phrase":"A gambling debt reaches the throne room and leaves one official carrying the blame.","reason":"The cited source records the pawned seal and dismissal but does not locate the debt in the throne room."}]}"#; + const REJECTED_AFTER_REPAIR: &str = r#"{"accepted":false,"assessment":"The revised deck still overstates blame.","findings":[{"article_index":0,"category":"unsupported_fact","claim_or_phrase":"one official carrying the blame","reason":"The cited source records dismissal but does not establish how blame was allocated."}]}"#; + const REJECTED_AFTER_SECOND_REPAIR: &str = r#"{"accepted":false,"assessment":"The second revision still overstates the record.","findings":[{"article_index":0,"category":"unsupported_fact","claim_or_phrase":"named in the public record","reason":"The source records a dismissal but does not say the treasurer was named in the record."}]}"#; + const REJECTED_AFTER_THIRD_REPAIR: &str = r#"{"accepted":false,"assessment":"The third revision still overstates identification.","findings":[{"article_index":0,"category":"unsupported_fact","claim_or_phrase":"identified as the dismissed treasurer","reason":"The source records a dismissal but does not establish that the deck's subject was identified in this way."}]}"#; + const RESUME_REPAIR_ACTION: &str = r#"{"tool":"submit_edits","replacements":[{"finding_ref":0,"replacement":"the dismissed treasurer"}],"delete_finding_refs":[]}"#; + let campaign = campaign_with_news(); + let directory = tempfile::tempdir().unwrap(); + let store = CampaignStore::open(directory.path().join("campaign.cc")).unwrap(); + let first_model = ScriptedNewspaperModel::new([ + ACCEPTED_PAGE, + REJECTED, + REPAIR_DECK_ACTION, + REJECTED_AFTER_REPAIR, + SECOND_REPAIR_DECK_ACTION, + REJECTED_AFTER_SECOND_REPAIR, + THIRD_REPAIR_DECK_ACTION, + REJECTED_AFTER_THIRD_REPAIR, + ]); + + let pending = advance_world_newspaper( + &first_model, + &campaign, + "The Underdeep Clarion", + "A skeptical court broadsheet with dry restraint.", + 4, + &store, + ) + .await + .unwrap(); + let WorldNewspaperAdvance::Pending { + checkpoint, + model_receipts: prior_receipts, + } = pending + else { + panic!("three rejected actions must yield typed pending state") + }; + assert_eq!(checkpoint.generation(), 3); + assert_eq!(prior_receipts.len(), 8); + assert_eq!( + store + .keys("world_newspaper_reconciliation_checkpoint.v1") + .unwrap() + .len(), + 4 + ); + assert!(prior_receipts.iter().all(|receipt| { + store + .load::("persona_stage_receipt.v1", receipt.storage_key()) + .unwrap() + .is_some() + })); + + let resume_model = ScriptedNewspaperModel::new([RESUME_REPAIR_ACTION, ACCEPTING_COPY_DESK]); + let accepted = advance_world_newspaper( + &resume_model, + &campaign, + "The Underdeep Clarion", + "A skeptical court broadsheet with dry restraint.", + 4, + &store, + ) + .await + .unwrap() + .into_accepted() + .unwrap(); + let resumed_requests = resume_model.requests(); + assert_eq!(resumed_requests.len(), 2); + assert!( + resumed_requests + .iter() + .all(|request| { request.stage != "newspaper_editor" }) + ); + assert!(prior_receipts.iter().all(|receipt| { + resumed_requests[0] + .source_receipt_ids + .contains(&receipt.storage_key().to_owned()) + })); + assert!( + resumed_requests[0] + .source_receipt_ids + .contains(&checkpoint.id().to_owned()) + ); + assert_eq!(accepted.model_receipts.len(), 10); + assert_eq!( + &accepted.model_receipts[..prior_receipts.len()], + prior_receipts.as_slice() + ); + let final_copy_desk = accepted.model_receipts.last().unwrap(); + assert!(accepted.model_receipts[..9].iter().all(|receipt| { + final_copy_desk + .source_receipt_ids + .contains(&receipt.storage_key().to_owned()) + })); + + let idempotent_model = ScriptedNewspaperModel::new([]); + let repeated = advance_world_newspaper( + &idempotent_model, + &campaign, + "The Underdeep Clarion", + "A skeptical court broadsheet with dry restraint.", + 4, + &store, + ) + .await + .unwrap() + .into_accepted() + .unwrap(); + assert_eq!(repeated, accepted); + assert!(idempotent_model.requests().is_empty()); + } + + #[tokio::test] + async fn typed_legacy_import_enters_the_common_resume_validator() { + const REJECTED: &str = r#"{"accepted":false,"assessment":"The deck overstates where the admitted debt reached.","findings":[{"article_index":0,"category":"unsupported_fact","claim_or_phrase":"A gambling debt reaches the throne room and leaves one official carrying the blame.","reason":"The cited source records the pawned seal and dismissal but does not locate the debt in the throne room."}]}"#; + const REJECTED_AFTER_REPAIR: &str = r#"{"accepted":false,"assessment":"The revised deck still overstates blame.","findings":[{"article_index":0,"category":"unsupported_fact","claim_or_phrase":"one official carrying the blame","reason":"The cited source records dismissal but does not establish how blame was allocated."}]}"#; + const REJECTED_AFTER_SECOND_REPAIR: &str = r#"{"accepted":false,"assessment":"The second revision still overstates the record.","findings":[{"article_index":0,"category":"unsupported_fact","claim_or_phrase":"named in the public record","reason":"The source records a dismissal but does not say the treasurer was named in the record."}]}"#; + const REJECTED_AFTER_THIRD_REPAIR: &str = r#"{"accepted":false,"assessment":"The third revision still overstates identification.","findings":[{"article_index":0,"category":"unsupported_fact","claim_or_phrase":"identified as the dismissed treasurer","reason":"The source records a dismissal but does not establish that the deck's subject was identified in this way."}]}"#; + const RESUME_REPAIR_ACTION: &str = r#"{"tool":"submit_edits","replacements":[{"finding_ref":0,"replacement":"the dismissed treasurer"}],"delete_finding_refs":[]}"#; + let campaign = campaign_with_news(); + let source_directory = tempfile::tempdir().unwrap(); + let source_store = + CampaignStore::open(source_directory.path().join("campaign.cc")).unwrap(); + let source_model = ScriptedNewspaperModel::new([ + ACCEPTED_PAGE, + REJECTED, + REPAIR_DECK_ACTION, + REJECTED_AFTER_REPAIR, + SECOND_REPAIR_DECK_ACTION, + REJECTED_AFTER_SECOND_REPAIR, + THIRD_REPAIR_DECK_ACTION, + REJECTED_AFTER_THIRD_REPAIR, + ]); + let WorldNewspaperAdvance::Pending { + checkpoint, + model_receipts, + } = advance_world_newspaper( + &source_model, + &campaign, + "The Underdeep Clarion", + "A skeptical court broadsheet with dry restraint.", + 4, + &source_store, + ) + .await + .unwrap() + else { + panic!("fixture must produce importable pending state") + }; + let import = WorldNewspaperReconciliationImport { + schema: "ghostlight.world_newspaper_reconciliation_import.v1".into(), + source_witness_digest: format!("sha256:{}", "a".repeat(64)), + draft: checkpoint.draft.clone(), + verdict: checkpoint.verdict.clone(), + model_receipts: model_receipts.clone(), + }; + let imported_directory = tempfile::tempdir().unwrap(); + let imported_store = + CampaignStore::open(imported_directory.path().join("campaign.cc")).unwrap(); + let imported = admit_world_newspaper_reconciliation_import( + &campaign, + "The Underdeep Clarion", + "A skeptical court broadsheet with dry restraint.", + 4, + &imported_store, + import, + ) + .unwrap(); + assert_eq!(imported.generation(), 0); + assert_eq!( + imported.origin, + WorldNewspaperCheckpointOrigin::LegacyTerminalImport + ); + + let resume_model = ScriptedNewspaperModel::new([RESUME_REPAIR_ACTION, ACCEPTING_COPY_DESK]); + let accepted = advance_world_newspaper( + &resume_model, + &campaign, + "The Underdeep Clarion", + "A skeptical court broadsheet with dry restraint.", + 4, + &imported_store, + ) + .await + .unwrap() + .into_accepted() + .unwrap(); + assert_eq!(accepted.model_receipts.len(), model_receipts.len() + 2); + assert!( + resume_model + .requests() + .iter() + .all(|request| request.stage != "newspaper_editor") + ); + } + + #[tokio::test] + async fn pending_reconciliation_rejects_same_task_source_drift_before_inference() { + let (mut campaign, _directory, store, _) = pending_local_rejection_fixture().await; + campaign.events[0] + .summary + .push_str(" A changed source packet."); + let model = ScriptedNewspaperModel::new([]); + + let error = advance_world_newspaper( + &model, + &campaign, + "The Underdeep Clarion", + "A skeptical court broadsheet with dry restraint.", + 4, + &store, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("checkpoint is invalid")); + assert!(model.requests().is_empty()); + } + + #[tokio::test] + async fn pending_reconciliation_rejects_a_fork_before_inference() { + let (campaign, _directory, store, checkpoint) = pending_local_rejection_fixture().await; + let mut sibling = checkpoint.clone(); + sibling.draft.articles[0].headline = "A rival checkpoint branch".into(); + sibling.id = checkpoint_identity( + &sibling.publication_task_binding, + &sibling.editorial_binding, + sibling.generation, + sibling.previous_checkpoint_id.as_deref(), + &sibling.origin, + sibling.source_witness_digest.as_deref(), + &sibling.draft, + &sibling.verdict, + &sibling.model_receipt_ids, + &sibling.receipt_chain_digest, + ) + .unwrap(); + persist_reconciliation_checkpoint(&store, &sibling).unwrap(); + let model = ScriptedNewspaperModel::new([]); + + let error = advance_world_newspaper( + &model, + &campaign, + "The Underdeep Clarion", + "A skeptical court broadsheet with dry restraint.", + 4, + &store, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("multiple or missing tips")); + assert!(model.requests().is_empty()); + } + #[tokio::test] async fn reader_projection_escapes_model_and_consumer_markdown() { const MARKDOWN_PAGE: &str = r#"{"articles":[{"section":"Front Page","headline":"[Court](https://headline.invalid) Faces ","deck":"The *royal* debt now reaches every keeper of the seal.","byline":"By the political editor","dateline":"Room","citations":["1"],"paragraphs":["- The Thorn Court admitted the royal seal was pawned to cover a dragon's gambling debt; cannot make the confession ~~prettier~~.","1. The dismissed treasurer leaves readers with a [public record](https://copy.invalid) and the court with a seal that remains pawned."]}]}"#; diff --git a/crates/ghostlight-dungeon/src/registry.rs b/crates/ghostlight-dungeon/src/registry.rs index b9ac3b6..ba821c5 100644 --- a/crates/ghostlight-dungeon/src/registry.rs +++ b/crates/ghostlight-dungeon/src/registry.rs @@ -380,7 +380,7 @@ impl CampaignRegistry { &self, request: WorldNewspaperRequest, model: &dyn ModelPort, - ) -> Result { + ) -> Result { if request.schema != "ghostlight.world_newspaper_request.v2" || request.max_articles == 0 || request.max_articles > 6 @@ -409,36 +409,15 @@ impl CampaignRegistry { .load::("campaign.v1", &request.campaign_id.to_string())? .ok_or_else(|| anyhow!("campaign is missing"))? .1; - let composition_result = crate::newspaper::compose_world_newspaper( + crate::newspaper::advance_world_newspaper( model, &campaign, request.title, request.editorial_voice, usize::from(request.max_articles), + &runtime.store, ) - .await; - if let Err(error) = &composition_result - && let Some(failure) = - error.downcast_ref::() - && let Err(persistence_error) = runtime - .store - .persist_model_stage_receipts(&failure.model_receipts) - { - return Err(anyhow::Error::new( - crate::newspaper::WorldNewspaperCompositionFailure { - message: format!( - "{}; model-receipt persistence failed: {persistence_error}", - failure.message - ), - model_receipts: failure.model_receipts.clone(), - }, - )); - } - let composition = composition_result?; - runtime - .store - .persist_model_stage_receipts(&composition.model_receipts)?; - Ok(composition.issue) + .await } pub async fn acknowledge_external_proposal( @@ -1410,7 +1389,10 @@ mod tests { let newspaper = registry .compose_world_newspaper(newspaper_request.clone(), &RegistryNewspaperModel) .await - .unwrap(); + .unwrap() + .into_accepted() + .unwrap() + .issue; assert_eq!(newspaper.source_world_revision, 0); assert!(newspaper.articles.is_empty()); @@ -1627,7 +1609,10 @@ mod tests { let newspaper = registry .compose_world_newspaper(newspaper_request.clone(), &RegistryNewspaperModel) .await - .unwrap(); + .unwrap() + .into_accepted() + .unwrap() + .issue; assert_eq!(newspaper.source_world_revision, 3); assert_eq!(newspaper.articles.len(), 1); assert_eq!( @@ -1651,7 +1636,10 @@ mod tests { let repeated_newspaper = registry .compose_world_newspaper(newspaper_request.clone(), &RegistryNewspaperModel) .await - .unwrap(); + .unwrap() + .into_accepted() + .unwrap() + .issue; assert_eq!(repeated_newspaper.id, newspaper.id); assert_eq!( runtime @@ -1663,17 +1651,23 @@ mod tests { ); let receipt_count_before_rejection = successful_receipts.len(); - let error = registry - .compose_world_newspaper(newspaper_request, &RejectingRegistryNewspaperModel) + let mut rejected_request = newspaper_request; + rejected_request.title.push_str(" Evening"); + let pending = registry + .compose_world_newspaper(rejected_request, &RejectingRegistryNewspaperModel) .await - .unwrap_err(); - let failure = error - .downcast_ref::() - .expect("registry must preserve the typed terminal editorial failure"); - assert_eq!(failure.model_receipts.len(), 8); + .unwrap(); + let crate::newspaper::WorldNewspaperAdvance::Pending { + checkpoint, + model_receipts, + } = pending + else { + panic!("registry must preserve typed pending reconciliation state") + }; + assert_eq!(model_receipts.len(), 8); + assert_eq!(checkpoint.generation(), 3); let stored_after_rejection = runtime.store.keys("persona_stage_receipt.v1").unwrap(); - let newly_observed_failure_receipts = failure - .model_receipts + let newly_observed_failure_receipts = model_receipts .iter() .map(|receipt| receipt.storage_key().to_owned()) .filter(|receipt_id| !successful_receipts.contains(receipt_id)) @@ -1683,7 +1677,7 @@ mod tests { receipt_count_before_rejection + newly_observed_failure_receipts.len() ); assert!( - failure.model_receipts.iter().all(|receipt| { + model_receipts.iter().all(|receipt| { stored_after_rejection.contains(&receipt.storage_key().to_owned()) }) ); diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 4379977..a3d94de 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -1096,19 +1096,40 @@ workbench state and receipts persist, and the editor never reruns. The exact mixed-path regression and full gates pass: 16/16 newspaper tests, 470/470 library tests, 13/13 strategic-smoke binary tests, and `git diff --check`. +Exact-source run 46 at +`dfe0926041b83d69a4b02451cdab706572522ae3` resumed revision 27 without +world or successful-publication replay. Issues 13 and 14 remained preserved. +Issue 15 exhausted three admitted repair/copy-desk rounds; the final exact +finding is one mechanical duplicated clause. Issues 15 through 18 and the final +digest remain absent. Unit invocation is +`3276392f4a7149fc9d127a966e792633`. + +Another fixed step would repeat the same compensator. The current uncommitted +resumable-reconciliation cut persists a typed generation-zero checkpoint and a +new typed checkpoint after every rejection, binding the exact draft, verdict +and finding catalog, model and deterministic receipts, and source/contract +identity. Accepted composition and strategic artifact records separately bind +issue and rendered-file ancestry. Resume continues that exact pending chain +without rerunning the editor. Typed run-46 import converts the preserved +terminal evidence into the same checkpoint contract. Gates pass +475/475 library tests and 13/13 strategic-smoke binary tests, including +same-task source-drift and fork rejection before inference. + Pushed `af749de` routes a provider timeout through the shared model transport's existing one same-request retry and records attempt telemetry. Run 36 crossed that boundary; timeout retry is not its active blocker. -1. Commit and push the verified three-action reconciliation cut. -2. Launch exact-source run 46 from immutable run 45's preserved Issue 13 and Issue 14 boundary at revision 27 - to compose Issues 15 through 18 plus the final digest. Preserve every +1. Commit and push the verified typed resumable-reconciliation checkpoint and + run-46 import cut. +2. Launch run 47 by resuming run 46's Issue 15 reconciliation chain at revision + 27 to compose Issues 15 through 18 plus the final digest. Preserve every completed wave checkpoint and canonical world state; do not replay mechanics or successful publications. Require the resulting unedited publications and final digest to pass fresh independent editorial and provenance review before - claiming acceptance. Preserve runs 38/39/40/42/43/44/45, the run-41 root and committed - world lineage, stopped run 23, exact acceptance conditions, and unchanged - live daemons. + claiming acceptance. Preserve runs 38/39/40/42/43/44/45/46, the run-41 root + and committed world lineage, stopped run 23, exact acceptance conditions, + and unchanged live daemons. + Run 47 remains unlaunched. 3. Have the game-side adapter lower its authored hierarchy into the published generic schemas; keep projection and economy translation consumer-owned. 4. Prove one consumer-owned effect crosses that public API, changes two foreign diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index f74f481..ea59579 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -1315,48 +1315,99 @@ surviving article selection and order, sections, bylines, and all unaffected text remain frozen. If the lead is deleted, owner code promotes the next article to the front page. The agent cannot request a new simulation or source packet, submit a replacement `EditorialPageDraft`, or act as a second editor. The same -copy desk rechecks the complete repaired page. The worker permits at most three -actions in one agent run: the initial repair and two bounded corrections. The -intended full path can spend one correction on deterministic admission and one -on a fresh copy-desk verdict; every rejected action consumes the same total -budget rather than opening a separate retry owner for either boundary. After -the first or second rejection, the same workbench retains the current private -draft and verdict, republishes the dynamic action schema, and exposes a freshly -numbered finding catalog derived by the same helper used for the initial -catalog. The harness includes every prior action receipt and any copy-desk tool -receipts in the next action's causal source chain; a local admission rejection -correctly contributes no nonexistent tool receipt. Acceptance ends the run -immediately. An inadmissible repair, an empty surviving page, or a page rejected -after the third action returns no edition. Thus the editor owns -initial copy, the reconciler owns only this bounded repair session, the copy -desk owns grounding judgment, and none owns source facts or world state. -Focused tests cover order-independent same-target phrase repair, exact same-desk -repair with source identity preserved, bounded article deletion with survivor -recheck, and one corrective response to the copy desk's current finding catalog. -A mixed-path regression proves local empty-repair rejection, admitted repair and -copy-desk rejection, then third-action acceptance with seven receipts and final -copy-desk ancestry over every prior completed receipt. A terminal regression -proves that three admitted but rejected repairs exhaust the three-action budget -and retain all eight editor, copy-desk, and action receipts, including all three -rejected action dispositions. The typed issue retains exact event IDs, channels, -reliability, source revision, and model receipts as audit data. Its internal -issue time is derived from the latest cited publication record without exposing -that clock to editorial inference. Terminally rejected attempts semantically -rebind their otherwise immutable model receipts to a collision-free invalid -disposition and return a typed failure carrying every completed receipt. The -campaign store owns idempotent model-receipt persistence; the registry and -strategic acceptance smoke use that same primitive before returning a -rejection. In the acceptance smoke, an interim edition rejection is local to -the consumer and cannot stop later world waves. A rejected final combined -edition remains a nonzero acceptance failure, but first writes the completed -wave reports, rejection receipts, and failed status. Its top-level plan, -receipt hash, and commit always project the final completed wave, matching the -top-level counts and persisted campaign head; per-wave history remains under -`waves`. The reader renderer has no -path to provenance fields and escapes all model- and consumer-supplied plain -text before Markdown emission; the provenance renderer applies the same -plain-text boundary to its audit data. Neither editor, copy desk, renderer, nor -receipt persistence can write world state. +copy desk rechecks the complete repaired page. + +The resumable reconciliation boundary is now live: + +- **Owner:** `newspaper::advance_world_newspaper` is the single composition, + checkpoint-advance, resume, and accepted-composition owner. `CampaignStore` + owns immutable storage only. Registry and the strategic driver supply policy + inputs and consume `WorldNewspaperAdvance::{Accepted, Pending}`; neither + reconstructs the workbench or decides editorial state. +- **Inputs:** one exact `Campaign` snapshot, title, editorial voice, article + budget, model port, and `CampaignStore`. A publication-task binding covers + campaign id and revision plus title, voice, and budget. The editorial binding + additionally covers the exact derived newsroom source desk and provenance. + This lets the owner find the task's checkpoint chain while still rejecting + same-task source drift before inference. +- **Outputs:** acceptance is one immutable persisted composition. A still + rejected page is typed `Pending`, carrying the current immutable checkpoint + and complete ordered receipt chain; pending is not a + `WorldNewspaperCompositionFailure`. Fatal model, schema, local-validation, + persistence, and checkpoint-conflict errors remain failures and leave the + last admitted checkpoint available. +- **Persistent and derived state:** the initial copy-desk rejection persists its + editor and copy-desk receipts, then publishes generation zero before any + repair action. Each later rejected action persists its new receipts before + appending one checkpoint with task and editorial bindings, generation, + parent id, typed origin, current private draft, rejected verdict, ordered + receipt ids, and receipt-chain digest. The current finding catalog, exact + target spans, action schema, and chain tip are derived and validated; they are + not parallel state owners. Three actions per call are only the current + advance cadence. Reaching that boundary returns `Pending` and does not make + rejection terminal. +- **Resume and idempotence:** every call checks the accepted composition marker + before checkpoint discovery. Otherwise it loads the unique checkpoint tip, + reloads every typed receipt, validates checkpoint identity, task and exact + editorial bindings, generation and parent links, strict receipt-prefix + growth, draft, rejected verdict and finding targets, then reconstructs the + same workbench. A resumed action includes the checkpoint id and every prior + receipt in its causal ancestry and never invokes the editor. Acceptance + persists its receipts and a composition linked to the source checkpoint; + later calls return that composition without model work even though the + historical pending chain remains immutable. +- **Forbidden writers:** the editor and copy desk may propose and judge but + cannot persist or resume reconciliation state. Registry, strategic smoke, + renderers, JSON result/checkpoint projections, world cells, Nemesis, clocks, + and `WorldKernel` cannot write the draft, verdict, receipt chain, checkpoint, + or accepted composition. They cannot replay world mechanics or overwrite an + already accepted issue to repair editorial copy. +- **Shared paths:** Registry calls `advance_world_newspaper` directly. Strategic + live per-wave, missing-issue recovery, and final-combined composition use a + thin `compose_persisted_newspaper` delegate to that same owner and store. A + pending interim issue is recorded in its immutable wave report and does not + stop later strategic waves; recovery resumes it from `CampaignStore`. A + pending final combined issue writes pending result/status projections and + ends that acceptance invocation nonzero without changing the world. +- **Legacy import boundary:** Run 46 may enter only through the explicit typed + `WorldNewspaperReconciliationImport` and strategic import envelope. The + envelope binds wave, recovery boundary, and world revision; the common + library validator rebinds the witness to the exact prepared task and desk, + validates its draft, rejected verdict, and typed receipts, and admits it as a + generation-zero `LegacyTerminalImport`. An existing accepted composition or + different checkpoint tip rejects the import. No live path parses failure + prose, infers a checkpoint from JSON error text, or falls back to a fresh + editor after a configured import fails admission. +- **Cut line:** the newspaper path uses the agent harness's typed + `ModelAgentProgress::Exhausted` result; the older generic harness API may still + lower exhaustion to failure for non-resumable consumers, but it no longer + owns newspaper correctness. The former fixed terminal reconciliation ceiling + is now `GROUNDING_RECONCILIATION_ACTIONS_PER_ADVANCE`. Registry's separate + receipt-persistence/failure path is gone, and the strategic persistence + helper is only a delegate rather than a second composition owner. +- **Verification layer:** focused tests prove typed harness exhaustion, + generation zero plus one append per rejected action, typed pending after + three actions, exact-chain resume without an editor, cumulative receipt and + copy-desk ancestry, accepted-composition idempotence without inference, + same-task source-drift rejection and fork rejection before inference, typed + legacy import through the common validator, and Registry preservation of + pending state. The current verified gate is 475/475 library tests and 13/13 + strategic-smoke binary tests, including same-task source-drift and fork + rejection before inference. Run 47 has not yet supplied provider-backed + resume/acceptance proof. + +The typed issue retains exact event IDs, channels, reliability, source +revision, and model receipts as audit data. Its internal issue time is derived +from the latest cited publication record without exposing that clock to +editorial inference. Rejected action attempts semantically rebind their +otherwise immutable model receipts to collision-free invalid dispositions +before checkpoint admission. The top-level strategic plan, receipt hash, and +commit always project the final completed wave, matching the top-level counts +and persisted campaign head; per-wave history remains under `waves`. The reader +renderer has no path to provenance fields and escapes all model- and +consumer-supplied plain text before Markdown emission; the provenance renderer +applies the same plain-text boundary to its audit data. Neither editor, copy +desk, renderer, nor receipt persistence can write world state. #### Acceptance-driver per-wave newspaper recovery @@ -1369,15 +1420,18 @@ receipt persistence can write world state. (`GHOSTLIGHT_STRATEGIC_NEWSPAPER_RECOVERY_START_WAVE`, defaulting to wave one and validated within `1..=wave_count`), and the same campaign receipt store used by ordinary newspaper composition. -- **Output:** one separately named immutable recomposition checkpoint containing - the accepted issue, grounding verdict, and newspaper model receipts, plus the - rendered reader and audit files. Its receipt binds the narrowed source +- **Output:** acceptance writes one separately named immutable recomposition + checkpoint containing the issue, grounding verdict, and newspaper model + receipts, plus the rendered reader and audit files. Its receipt binds the narrowed source campaign, recovery boundary, title/voice/article-budget contract, issue, model-receipt collection, expected filenames, and both rendered byte streams by digest. The configured boundary is also projected through run status and success or failure result artifacts. The driver fills only the missing - newspaper fields in its in-memory result projection. Existing successful - issues and original wave checkpoints remain unchanged. + newspaper fields in its in-memory result projection. Pending reconciliation + instead writes an immutable diagnostic projection of the library-owned + checkpoint and stops that recovery invocation; canonical resume state remains + in `CampaignStore`. Existing successful issues and original wave checkpoints + remain unchanged. - **Derived state:** the newsroom receives a clone of the completed wave's campaign whose news ledger is narrowed to rows appended since the preceding committed boundary. All events and canonical names remain available only so @@ -1389,16 +1443,17 @@ receipt persistence can write world state. range is left untouched. - **Forbidden writers:** recovery cannot rerun a strategic tick, Nemesis, simulation cells, outcome resolution, clocks, or `WorldKernel`, and it cannot - alter campaign, event, or news state. A failed recomposition stops consumer - recovery and cannot repair mechanics. + alter campaign, event, or news state. Pending or failed recomposition stops + consumer recovery and cannot repair mechanics. - **Shared paths:** live per-wave composition, recovery composition, and final - combined composition all call `compose_persisted_newspaper`, including the - same grounding/copy-desk path and idempotent model-receipt persistence. A - later resume consumes the successful recomposition checkpoint instead of - paying for the newspaper again, but only after recomputing its source and - contract bindings, checking the exact recovery boundary, deserializing the - issue, regenerating reader and audit copy, and comparing the exact stored files - and digests. + combined composition all call the thin `compose_persisted_newspaper` delegate + into `advance_world_newspaper`, including the same grounding/copy-desk, + checkpoint-resume, and receipt-persistence owner. A later resume advances a + pending library checkpoint without rerunning the editor. Once accepted, it + consumes the successful recomposition artifact instead of paying for the + newspaper again, but only after recomputing its source and contract bindings, + checking the exact recovery boundary, deserializing the issue, regenerating + reader and audit copy, and comparing the exact stored files and digests. - **Verification layer:** the focused boundary regression proves that a second wave is recomposed from only its newly appended news row while retaining the committed event ledger needed to resolve that row. It also rejects prior-news diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 87b0929..b779c9b 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -212,4 +212,7 @@ {"note": "Exact-source run44 at 35f56f0e5e670025e1cfb031cba8d5a4ca9587d4 resumed revision27 without replaying world mechanics or successful publications. Issues13/14 remained preserved. Typed finding references were admitted and the first reconciliation action ran; the deterministic copy desk returned a second exact finding set, then MAX_STEPS=1 stopped the agent before it could react. Issues15-18 and the final digest remain absent. Typed-reference ownership is provider-proved; session depth is now the active boundary. Next cut permits exactly one further corrective action in the same reconciliation workbench state and receipt chain, for two actions total, with no editor rerun; then verify/commit/push and resume from run44. Preserve one-good-Canopy-Ledger first, multi-publication Personas later, and layout/engraved cuts only after editorial acceptance.", "status": "ok", "ts": "2026-08-29T05:55:06+00:00", "type": "correction"} {"note": "The two-action grounding reconciliation cut is implemented and locally verified. One reconciliation session permits exactly two total corrective actions, preserves the same workbench state and receipt chain, and supplies an explicit refreshed finding_catalog to the second action; it does not rerun the editor or preserve a compatibility path. Gates pass 15/15 newspaper tests, 469/469 library tests, 13/13 strategic-smoke binary tests, and git diff --check. Implementation-pending steering is retired. Next action is commit/push, then resume from immutable run44 preserved Issues13/14 at revision27 without world or successful-publication replay. One good Canopy Ledger remains first; publication Personas later; layout and engraved cuts after editorial acceptance.", "status": "ok", "ts": "2026-08-29T06:00:38+00:00", "type": "verification"} {"note": "Exact-source run45 at 1d898489daeb34fc9f53e6c6bddf00738fc66364 resumed revision27 without world or successful-publication replay. Issues13/14 remained preserved. Reconciliation action1 was rejected locally before the copy desk; action2 was admitted and rechecked, then the deterministic copy desk returned four fresh findings, but the two-action ceiling left no response action. Issues15-18 and the final digest remain absent. The bounded protocol now requires exactly three total actions: initial, at most one local-admission correction, and at most one fresh copy-desk correction in the same workbench state and receipt chain; a rejection after action3 is terminal and the editor never reruns. Verify/commit/push, then resume from immutable run45. Keep one good Canopy Ledger first, publication Personas later, and layout/engraved cuts after editorial acceptance.", "status": "ok", "ts": "2026-08-29T06:15:49+00:00", "type": "correction"} -{"ts": "2026-08-29T06:25:26+00:00", "type": "verification", "status": "ok", "note": "The exact three-action mixed-path reconciliation cut is implemented and verified. It permits the initial action, at most one local-admission correction, and at most one fresh copy-desk correction in one workbench state and receipt chain; rejection after action three is terminal and the editor never reruns. The exact regression and full gates pass: 16/16 newspaper tests, 470/470 library tests, 13/13 strategic-smoke binary tests, and git diff --check. Pending verification steering is retired. Next action is commit/push, then exact-source run46 from immutable run45 preserved Issues13/14 at revision27 with no world or successful-publication replay. One good Canopy Ledger remains first; publication Personas later; layout and engraved cuts after editorial acceptance."} +{"note": "The exact three-action mixed-path reconciliation cut is implemented and verified. It permits the initial action, at most one local-admission correction, and at most one fresh copy-desk correction in one workbench state and receipt chain; rejection after action three is terminal and the editor never reruns. The exact regression and full gates pass: 16/16 newspaper tests, 470/470 library tests, 13/13 strategic-smoke binary tests, and git diff --check. Pending verification steering is retired. Next action is commit/push, then exact-source run46 from immutable run45 preserved Issues13/14 at revision27 with no world or successful-publication replay. One good Canopy Ledger remains first; publication Personas later; layout and engraved cuts after editorial acceptance.", "status": "ok", "ts": "2026-08-29T06:25:26+00:00", "type": "verification"} +{"note": "Exact-source run46 at dfe0926041b83d69a4b02451cdab706572522ae3 resumed revision27 without world or successful-publication replay. Issues13/14 remained preserved. Issue15 exhausted three admitted repair/copy-desk rounds; the final exact finding is one mechanical duplicated clause. Issues15-18 and the final digest remain absent. Unit invocation is 3276392f4a7149fc9d127a966e792633. Raising another fixed step repeats the same compensator, while current immutable recovery reruns the editor and discards reconciliation state. Next action is to map, then implement and verify, a typed resumable reconciliation checkpoint binding current draft, exact verdict/finding catalog, workbench, receipts, source/contract identity, and render/file ancestry; run47 must resume that chain without editor, world, or successful-publication replay. One good Canopy Ledger remains first; publication Personas later; layout and engraved cuts after editorial acceptance.", "status": "ok", "ts": "2026-08-29T06:41:00+00:00", "type": "correction"} +{"note": "The typed resumable newspaper reconciliation cut is implemented and locally verified. It persists a typed generation-zero checkpoint and a new typed checkpoint after every rejection, binding the exact draft, copy-desk verdict/finding catalog, workbench state, model and deterministic receipts, source/contract identity, and rendered/file ancestry. Resume continues that chain without rerunning the editor. Typed run46 import converts the preserved terminal evidence into the same checkpoint contract. Gates pass 473/473 library tests and 13/13 strategic-smoke binary tests. Planned-only steering is retired. Next action is commit/push, then launch still-unlaunched run47 by continuing run46 Issue15 without editor, world, or successful-publication replay. One good Canopy Ledger remains first; publication Personas later; layout and engraved cuts after editorial acceptance.", "status": "ok", "ts": "2026-08-29T07:36:01+00:00", "type": "verification"} +{"ts": "2026-08-29T07:42:40+00:00", "type": "correction", "status": "ok", "note": "The final resumable-newspaper gate is 475/475 library tests and 13/13 strategic-smoke tests, including same-task source-drift and fork rejection before inference. Clarification: pending checkpoints bind task/editorial identity, exact draft, rejected verdict/findings, parent generation, and ordered receipt ancestry. Accepted composition and strategic artifact records separately bind issue and rendered-file ancestry; rendered files are not checkpoint workbench state. Run47 remains unlaunched."} diff --git a/state/ghostlight-state.cultcache.jsonl b/state/ghostlight-state.cultcache.jsonl index 6d7a7d5..84393b4 100644 --- a/state/ghostlight-state.cultcache.jsonl +++ b/state/ghostlight-state.cultcache.jsonl @@ -214,3 +214,6 @@ {"key": "2026-08-29T060038Z0000-0212-bad914a562bc", "payload": "eyJub3RlIjogIlRoZSB0d28tYWN0aW9uIGdyb3VuZGluZyByZWNvbmNpbGlhdGlvbiBjdXQgaXMgaW1wbGVtZW50ZWQgYW5kIGxvY2FsbHkgdmVyaWZpZWQuIE9uZSByZWNvbmNpbGlhdGlvbiBzZXNzaW9uIHBlcm1pdHMgZXhhY3RseSB0d28gdG90YWwgY29ycmVjdGl2ZSBhY3Rpb25zLCBwcmVzZXJ2ZXMgdGhlIHNhbWUgd29ya2JlbmNoIHN0YXRlIGFuZCByZWNlaXB0IGNoYWluLCBhbmQgc3VwcGxpZXMgYW4gZXhwbGljaXQgcmVmcmVzaGVkIGZpbmRpbmdfY2F0YWxvZyB0byB0aGUgc2Vjb25kIGFjdGlvbjsgaXQgZG9lcyBub3QgcmVydW4gdGhlIGVkaXRvciBvciBwcmVzZXJ2ZSBhIGNvbXBhdGliaWxpdHkgcGF0aC4gR2F0ZXMgcGFzcyAxNS8xNSBuZXdzcGFwZXIgdGVzdHMsIDQ2OS80NjkgbGlicmFyeSB0ZXN0cywgMTMvMTMgc3RyYXRlZ2ljLXNtb2tlIGJpbmFyeSB0ZXN0cywgYW5kIGdpdCBkaWZmIC0tY2hlY2suIEltcGxlbWVudGF0aW9uLXBlbmRpbmcgc3RlZXJpbmcgaXMgcmV0aXJlZC4gTmV4dCBhY3Rpb24gaXMgY29tbWl0L3B1c2gsIHRoZW4gcmVzdW1lIGZyb20gaW1tdXRhYmxlIHJ1bjQ0IHByZXNlcnZlZCBJc3N1ZXMxMy8xNCBhdCByZXZpc2lvbjI3IHdpdGhvdXQgd29ybGQgb3Igc3VjY2Vzc2Z1bC1wdWJsaWNhdGlvbiByZXBsYXkuIE9uZSBnb29kIENhbm9weSBMZWRnZXIgcmVtYWlucyBmaXJzdDsgcHVibGljYXRpb24gUGVyc29uYXMgbGF0ZXI7IGxheW91dCBhbmQgZW5ncmF2ZWQgY3V0cyBhZnRlciBlZGl0b3JpYWwgYWNjZXB0YW5jZS4iLCAic3RhdHVzIjogIm9rIiwgInRzIjogIjIwMjYtMDgtMjlUMDY6MDA6MzgrMDA6MDAiLCAidHlwZSI6ICJ2ZXJpZmljYXRpb24ifQ==", "stored_at": "2026-08-29T06:00:38.081686+00:00", "type": "ghostlight.evidence_record.v0"} {"key": "2026-08-29T061549Z0000-0213-c145b473c763", "payload": "eyJub3RlIjogIkV4YWN0LXNvdXJjZSBydW40NSBhdCAxZDg5ODQ4OWRhZWIzNGZjOWY1M2U2YzZiZGRmMDA3MzhmYzY2MzY0IHJlc3VtZWQgcmV2aXNpb24yNyB3aXRob3V0IHdvcmxkIG9yIHN1Y2Nlc3NmdWwtcHVibGljYXRpb24gcmVwbGF5LiBJc3N1ZXMxMy8xNCByZW1haW5lZCBwcmVzZXJ2ZWQuIFJlY29uY2lsaWF0aW9uIGFjdGlvbjEgd2FzIHJlamVjdGVkIGxvY2FsbHkgYmVmb3JlIHRoZSBjb3B5IGRlc2s7IGFjdGlvbjIgd2FzIGFkbWl0dGVkIGFuZCByZWNoZWNrZWQsIHRoZW4gdGhlIGRldGVybWluaXN0aWMgY29weSBkZXNrIHJldHVybmVkIGZvdXIgZnJlc2ggZmluZGluZ3MsIGJ1dCB0aGUgdHdvLWFjdGlvbiBjZWlsaW5nIGxlZnQgbm8gcmVzcG9uc2UgYWN0aW9uLiBJc3N1ZXMxNS0xOCBhbmQgdGhlIGZpbmFsIGRpZ2VzdCByZW1haW4gYWJzZW50LiBUaGUgYm91bmRlZCBwcm90b2NvbCBub3cgcmVxdWlyZXMgZXhhY3RseSB0aHJlZSB0b3RhbCBhY3Rpb25zOiBpbml0aWFsLCBhdCBtb3N0IG9uZSBsb2NhbC1hZG1pc3Npb24gY29ycmVjdGlvbiwgYW5kIGF0IG1vc3Qgb25lIGZyZXNoIGNvcHktZGVzayBjb3JyZWN0aW9uIGluIHRoZSBzYW1lIHdvcmtiZW5jaCBzdGF0ZSBhbmQgcmVjZWlwdCBjaGFpbjsgYSByZWplY3Rpb24gYWZ0ZXIgYWN0aW9uMyBpcyB0ZXJtaW5hbCBhbmQgdGhlIGVkaXRvciBuZXZlciByZXJ1bnMuIFZlcmlmeS9jb21taXQvcHVzaCwgdGhlbiByZXN1bWUgZnJvbSBpbW11dGFibGUgcnVuNDUuIEtlZXAgb25lIGdvb2QgQ2Fub3B5IExlZGdlciBmaXJzdCwgcHVibGljYXRpb24gUGVyc29uYXMgbGF0ZXIsIGFuZCBsYXlvdXQvZW5ncmF2ZWQgY3V0cyBhZnRlciBlZGl0b3JpYWwgYWNjZXB0YW5jZS4iLCAic3RhdHVzIjogIm9rIiwgInRzIjogIjIwMjYtMDgtMjlUMDY6MTU6NDkrMDA6MDAiLCAidHlwZSI6ICJjb3JyZWN0aW9uIn0=", "stored_at": "2026-08-29T06:15:49.916086+00:00", "type": "ghostlight.evidence_record.v0"} {"key": "2026-08-29T062526Z0000-0214-688704de1feb", "payload": "eyJub3RlIjogIlRoZSBleGFjdCB0aHJlZS1hY3Rpb24gbWl4ZWQtcGF0aCByZWNvbmNpbGlhdGlvbiBjdXQgaXMgaW1wbGVtZW50ZWQgYW5kIHZlcmlmaWVkLiBJdCBwZXJtaXRzIHRoZSBpbml0aWFsIGFjdGlvbiwgYXQgbW9zdCBvbmUgbG9jYWwtYWRtaXNzaW9uIGNvcnJlY3Rpb24sIGFuZCBhdCBtb3N0IG9uZSBmcmVzaCBjb3B5LWRlc2sgY29ycmVjdGlvbiBpbiBvbmUgd29ya2JlbmNoIHN0YXRlIGFuZCByZWNlaXB0IGNoYWluOyByZWplY3Rpb24gYWZ0ZXIgYWN0aW9uIHRocmVlIGlzIHRlcm1pbmFsIGFuZCB0aGUgZWRpdG9yIG5ldmVyIHJlcnVucy4gVGhlIGV4YWN0IHJlZ3Jlc3Npb24gYW5kIGZ1bGwgZ2F0ZXMgcGFzczogMTYvMTYgbmV3c3BhcGVyIHRlc3RzLCA0NzAvNDcwIGxpYnJhcnkgdGVzdHMsIDEzLzEzIHN0cmF0ZWdpYy1zbW9rZSBiaW5hcnkgdGVzdHMsIGFuZCBnaXQgZGlmZiAtLWNoZWNrLiBQZW5kaW5nIHZlcmlmaWNhdGlvbiBzdGVlcmluZyBpcyByZXRpcmVkLiBOZXh0IGFjdGlvbiBpcyBjb21taXQvcHVzaCwgdGhlbiBleGFjdC1zb3VyY2UgcnVuNDYgZnJvbSBpbW11dGFibGUgcnVuNDUgcHJlc2VydmVkIElzc3VlczEzLzE0IGF0IHJldmlzaW9uMjcgd2l0aCBubyB3b3JsZCBvciBzdWNjZXNzZnVsLXB1YmxpY2F0aW9uIHJlcGxheS4gT25lIGdvb2QgQ2Fub3B5IExlZGdlciByZW1haW5zIGZpcnN0OyBwdWJsaWNhdGlvbiBQZXJzb25hcyBsYXRlcjsgbGF5b3V0IGFuZCBlbmdyYXZlZCBjdXRzIGFmdGVyIGVkaXRvcmlhbCBhY2NlcHRhbmNlLiIsICJzdGF0dXMiOiAib2siLCAidHMiOiAiMjAyNi0wOC0yOVQwNjoyNToyNiswMDowMCIsICJ0eXBlIjogInZlcmlmaWNhdGlvbiJ9", "stored_at": "2026-08-29T06:25:26.234774+00:00", "type": "ghostlight.evidence_record.v0"} +{"key": "2026-08-29T064100Z0000-0215-144e1d030288", "payload": "eyJub3RlIjogIkV4YWN0LXNvdXJjZSBydW40NiBhdCBkZmUwOTI2MDQxYjgzZDY5YTRiMDI0NTFjZGFiNzA2NTcyNTIyYWUzIHJlc3VtZWQgcmV2aXNpb24yNyB3aXRob3V0IHdvcmxkIG9yIHN1Y2Nlc3NmdWwtcHVibGljYXRpb24gcmVwbGF5LiBJc3N1ZXMxMy8xNCByZW1haW5lZCBwcmVzZXJ2ZWQuIElzc3VlMTUgZXhoYXVzdGVkIHRocmVlIGFkbWl0dGVkIHJlcGFpci9jb3B5LWRlc2sgcm91bmRzOyB0aGUgZmluYWwgZXhhY3QgZmluZGluZyBpcyBvbmUgbWVjaGFuaWNhbCBkdXBsaWNhdGVkIGNsYXVzZS4gSXNzdWVzMTUtMTggYW5kIHRoZSBmaW5hbCBkaWdlc3QgcmVtYWluIGFic2VudC4gVW5pdCBpbnZvY2F0aW9uIGlzIDMyNzYzOTJmNGE3MTQ5ZmM5ZDEyN2E5NjZlNzkyNjMzLiBSYWlzaW5nIGFub3RoZXIgZml4ZWQgc3RlcCByZXBlYXRzIHRoZSBzYW1lIGNvbXBlbnNhdG9yLCB3aGlsZSBjdXJyZW50IGltbXV0YWJsZSByZWNvdmVyeSByZXJ1bnMgdGhlIGVkaXRvciBhbmQgZGlzY2FyZHMgcmVjb25jaWxpYXRpb24gc3RhdGUuIE5leHQgYWN0aW9uIGlzIHRvIG1hcCwgdGhlbiBpbXBsZW1lbnQgYW5kIHZlcmlmeSwgYSB0eXBlZCByZXN1bWFibGUgcmVjb25jaWxpYXRpb24gY2hlY2twb2ludCBiaW5kaW5nIGN1cnJlbnQgZHJhZnQsIGV4YWN0IHZlcmRpY3QvZmluZGluZyBjYXRhbG9nLCB3b3JrYmVuY2gsIHJlY2VpcHRzLCBzb3VyY2UvY29udHJhY3QgaWRlbnRpdHksIGFuZCByZW5kZXIvZmlsZSBhbmNlc3RyeTsgcnVuNDcgbXVzdCByZXN1bWUgdGhhdCBjaGFpbiB3aXRob3V0IGVkaXRvciwgd29ybGQsIG9yIHN1Y2Nlc3NmdWwtcHVibGljYXRpb24gcmVwbGF5LiBPbmUgZ29vZCBDYW5vcHkgTGVkZ2VyIHJlbWFpbnMgZmlyc3Q7IHB1YmxpY2F0aW9uIFBlcnNvbmFzIGxhdGVyOyBsYXlvdXQgYW5kIGVuZ3JhdmVkIGN1dHMgYWZ0ZXIgZWRpdG9yaWFsIGFjY2VwdGFuY2UuIiwgInN0YXR1cyI6ICJvayIsICJ0cyI6ICIyMDI2LTA4LTI5VDA2OjQxOjAwKzAwOjAwIiwgInR5cGUiOiAiY29ycmVjdGlvbiJ9", "stored_at": "2026-08-29T06:41:00.885410+00:00", "type": "ghostlight.evidence_record.v0"} +{"key": "2026-08-29T073601Z0000-0216-222d1665a572", "payload": "eyJub3RlIjogIlRoZSB0eXBlZCByZXN1bWFibGUgbmV3c3BhcGVyIHJlY29uY2lsaWF0aW9uIGN1dCBpcyBpbXBsZW1lbnRlZCBhbmQgbG9jYWxseSB2ZXJpZmllZC4gSXQgcGVyc2lzdHMgYSB0eXBlZCBnZW5lcmF0aW9uLXplcm8gY2hlY2twb2ludCBhbmQgYSBuZXcgdHlwZWQgY2hlY2twb2ludCBhZnRlciBldmVyeSByZWplY3Rpb24sIGJpbmRpbmcgdGhlIGV4YWN0IGRyYWZ0LCBjb3B5LWRlc2sgdmVyZGljdC9maW5kaW5nIGNhdGFsb2csIHdvcmtiZW5jaCBzdGF0ZSwgbW9kZWwgYW5kIGRldGVybWluaXN0aWMgcmVjZWlwdHMsIHNvdXJjZS9jb250cmFjdCBpZGVudGl0eSwgYW5kIHJlbmRlcmVkL2ZpbGUgYW5jZXN0cnkuIFJlc3VtZSBjb250aW51ZXMgdGhhdCBjaGFpbiB3aXRob3V0IHJlcnVubmluZyB0aGUgZWRpdG9yLiBUeXBlZCBydW40NiBpbXBvcnQgY29udmVydHMgdGhlIHByZXNlcnZlZCB0ZXJtaW5hbCBldmlkZW5jZSBpbnRvIHRoZSBzYW1lIGNoZWNrcG9pbnQgY29udHJhY3QuIEdhdGVzIHBhc3MgNDczLzQ3MyBsaWJyYXJ5IHRlc3RzIGFuZCAxMy8xMyBzdHJhdGVnaWMtc21va2UgYmluYXJ5IHRlc3RzLiBQbGFubmVkLW9ubHkgc3RlZXJpbmcgaXMgcmV0aXJlZC4gTmV4dCBhY3Rpb24gaXMgY29tbWl0L3B1c2gsIHRoZW4gbGF1bmNoIHN0aWxsLXVubGF1bmNoZWQgcnVuNDcgYnkgY29udGludWluZyBydW40NiBJc3N1ZTE1IHdpdGhvdXQgZWRpdG9yLCB3b3JsZCwgb3Igc3VjY2Vzc2Z1bC1wdWJsaWNhdGlvbiByZXBsYXkuIE9uZSBnb29kIENhbm9weSBMZWRnZXIgcmVtYWlucyBmaXJzdDsgcHVibGljYXRpb24gUGVyc29uYXMgbGF0ZXI7IGxheW91dCBhbmQgZW5ncmF2ZWQgY3V0cyBhZnRlciBlZGl0b3JpYWwgYWNjZXB0YW5jZS4iLCAic3RhdHVzIjogIm9rIiwgInRzIjogIjIwMjYtMDgtMjlUMDc6MzY6MDErMDA6MDAiLCAidHlwZSI6ICJ2ZXJpZmljYXRpb24ifQ==", "stored_at": "2026-08-29T07:36:01.432048+00:00", "type": "ghostlight.evidence_record.v0"} +{"key": "2026-08-29T074240Z0000-0217-f33c078abf64", "payload": "eyJub3RlIjogIlRoZSBmaW5hbCByZXN1bWFibGUtbmV3c3BhcGVyIGdhdGUgaXMgNDc1LzQ3NSBsaWJyYXJ5IHRlc3RzIGFuZCAxMy8xMyBzdHJhdGVnaWMtc21va2UgdGVzdHMsIGluY2x1ZGluZyBzYW1lLXRhc2sgc291cmNlLWRyaWZ0IGFuZCBmb3JrIHJlamVjdGlvbiBiZWZvcmUgaW5mZXJlbmNlLiBDbGFyaWZpY2F0aW9uOiBwZW5kaW5nIGNoZWNrcG9pbnRzIGJpbmQgdGFzay9lZGl0b3JpYWwgaWRlbnRpdHksIGV4YWN0IGRyYWZ0LCByZWplY3RlZCB2ZXJkaWN0L2ZpbmRpbmdzLCBwYXJlbnQgZ2VuZXJhdGlvbiwgYW5kIG9yZGVyZWQgcmVjZWlwdCBhbmNlc3RyeS4gQWNjZXB0ZWQgY29tcG9zaXRpb24gYW5kIHN0cmF0ZWdpYyBhcnRpZmFjdCByZWNvcmRzIHNlcGFyYXRlbHkgYmluZCBpc3N1ZSBhbmQgcmVuZGVyZWQtZmlsZSBhbmNlc3RyeTsgcmVuZGVyZWQgZmlsZXMgYXJlIG5vdCBjaGVja3BvaW50IHdvcmtiZW5jaCBzdGF0ZS4gUnVuNDcgcmVtYWlucyB1bmxhdW5jaGVkLiIsICJzdGF0dXMiOiAib2siLCAidHMiOiAiMjAyNi0wOC0yOVQwNzo0Mjo0MCswMDowMCIsICJ0eXBlIjogImNvcnJlY3Rpb24ifQ==", "stored_at": "2026-08-29T07:42:40.777233+00:00", "type": "ghostlight.evidence_record.v0"} diff --git a/state/map.yaml b/state/map.yaml index 2c37a65..e199e19 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -326,7 +326,7 @@ data_flow: current_status: summary: Ghostlight's generic consumer boundary admits sparse WorldSeeds, runs the hundreds-class parallel cell machinery used by Dungeon, and projects grounded newspapers from committed public events. Local acceptance proves 1,000 subjects across 200 cells and two-wave durable named-person emergence. Exact-source provider run 38 at `596e50679244b5d42b4cfdf70d723f815751b8ff` resumed the preserved world at revision 8 without replaying compilation or four committed locality elaborations, recovered 51 of 52 strategic-wave cells on pulse three and the final cell on pulse four, then committed all six waves through revision 14. Pushed commit `3a69306f3004e4af2cd57bba612a6ad8d1b8132f` implements the mapped clock-consequence owner and is verified locally: world clocks own declared consequence text and exact observable scope; a bounded agent may propose legacy scope binding, while `WorldKernel` alone admits it and materializes stable threshold-crossing events and public news through the common mutation path. Binding, emitted-event ancestry, mutation receipts, and the next newspaper boundary persist atomically. Exact-source run 39 resumed run 38 without replaying its six committed waves, bound clock consequences, committed six new waves through revision 21, and emitted Issues 7 through 12 plus a final digest. This completes the mechanical provider acceptance path; all inherited wave 1-6 checkpoint hashes match run 38 exactly and live services were unchanged. Independent blind editorial review returns FAIL: only Issue 7 approaches spicy, while Issues 8-12 are dominated by plans, filings, inspections, warnings, and abstract relationship deltas without betrayal, scandal, public reaction, sustained reversal, or material lived cost. The defect belongs upstream in causal follow-through and outcome ownership, not in newsroom spice prompting. Independent grounding review also returns FAIL: Issue 9 inflates a planned expedited agenda into completion, while Issue 8 assigns unsupported feminine pronouns to Sela Venn and Eryn Tal. Other grounding mechanics pass: 208/208 citations resolve, no future events appear, no replay occurred, the player remained unchanged, all 116/116 manifest entries verify, the archive is exact, and root independently confirmed the live service PIDs and invocation IDs remained unchanged. Editorial and grounding acceptance are terminally failed for run 39. Eight titled elaborators use the generic agent harness under deterministic weighted scheduling, bounded parallelism, and Luna/Terra/Sol routing. Workers remain proposal-only; deterministic admission binds exact frozen schedules, dispatch, receipts, conflicts, and semantic verification, and `WorldKernel` alone owns atomic CultCache persistence. Destination reconciliation is a private transactional workbench, provider schema compatibility is lowered once at the shared model-connector boundary, and durable elaboration and strategic-pulse checkpoints bind to their canonical mutation proofs. The generic ModelAgent harness remains consumer-neutral transport. Provider run 23 remains stopped; earlier failed runs and checkpoint archives remain immutable provenance. The live daemons remain outside the acceptance body. Dungeon play remains regression pressure, the Delvehold game-side adapter remains unimplemented, and unpublished Mars and Kalsa destination previews remain unapproved. - provider_acceptance_checkpoint: Exact-source run 45 at `1d898489daeb34fc9f53e6c6bddf00738fc66364` resumed revision 27 without replaying world mechanics or successful publications. Issues 13 and 14 remained preserved. Reconciliation action one was rejected locally before the copy desk; action two was admitted and rechecked, and the deterministic copy desk returned four fresh findings, but the two-action ceiling left no action to answer them. Issues 15 through 18 and the final digest remain absent. Canonical world state and all wave checkpoints remain unchanged. Run 41's remote root is `/var/lib/gamecult/ghostlight-dungeon/acceptance/elven-realms-autonomous-46810c3-41`; runs 38, 39, 40, 42, 43, and 44 remain preserved. Run 39's independent FAIL remains historical acceptance evidence, and overall acceptance is still incomplete. - implementation_checkpoint: Pushed `1d89848` owns two-action reconciliation with a refreshed finding catalog in one workbench state and receipt chain; run 45 provider-proved both local admission correction and fresh copy-desk feedback. The current uncommitted cut implements exactly three total actions: the initial action, at most one local-admission correction, and at most one fresh copy-desk correction. A rejection after the third action is terminal. The same workbench and receipt chain persist throughout, and the editor does not rerun. The exact mixed-path regression and full gates pass: 16/16 newspaper tests, 470/470 library tests, 13/13 strategic-smoke binary tests, and `git diff --check`. Plan commit `4916638` separately admits persistent political/editorial publication Personas; that later architecture is not part of this acceptance repair. - next_action: Commit and push the verified three-action reconciliation cut, then launch exact-source run 46 from immutable run 45's preserved Issue 13 and Issue 14 boundary at revision 27 without replaying world mechanics or successful publications. Earn independent editorial and provenance acceptance for one good Canopy Ledger publication before implementing multiple publication Personas; defer TeX/layout and engraved-cut production until editorial acceptance. Require Issues 15 through 18 and the final digest to pass fresh independent review before claiming acceptance. Preserve runs 38/39/40/42/43/44/45, the run-41 root and committed world lineage, stopped run 23, exact acceptance conditions, and unchanged live daemons. + provider_acceptance_checkpoint: Exact-source run 46 at `dfe0926041b83d69a4b02451cdab706572522ae3` resumed revision 27 without replaying world mechanics or successful publications. Issues 13 and 14 remained preserved. Issue 15 exhausted three admitted repair/copy-desk rounds; the final exact finding is one mechanical duplicated clause. Issues 15 through 18 and the final digest remain absent. Unit invocation is `3276392f4a7149fc9d127a966e792633`. Canonical world state and all wave checkpoints remain unchanged. Run 41's remote root is `/var/lib/gamecult/ghostlight-dungeon/acceptance/elven-realms-autonomous-46810c3-41`; runs 38, 39, 40, 42, 43, 44, and 45 remain preserved. Run 39's independent FAIL remains historical acceptance evidence, and overall acceptance is still incomplete. + implementation_checkpoint: Pushed `dfe0926` owns the locally verified three-action reconciliation ceiling and run 46 provider-proved all three admitted rounds. The current uncommitted resumable-reconciliation cut persists a typed generation-zero checkpoint and a new typed checkpoint after every rejection, binding the exact draft, copy-desk verdict and finding catalog, model and deterministic receipts, and source/contract identity. Accepted composition and strategic artifact records separately bind issue and rendered-file ancestry. Resume continues the exact pending chain without rerunning the editor; typed run-46 import converts the preserved terminal evidence into the same checkpoint contract. Gates pass 475/475 library tests and 13/13 strategic-smoke binary tests, including same-task source-drift and fork rejection before inference. Plan commit `4916638` separately admits persistent political/editorial publication Personas; that later architecture is not part of this acceptance repair. + next_action: Commit and push the verified typed resumable-reconciliation checkpoint and run-46 import cut, then launch run 47 by continuing run 46's Issue 15 draft/verdict/receipt chain without editor, world, or successful-publication replay. Run 47 remains unlaunched. Earn independent editorial and provenance acceptance for one good Canopy Ledger publication before implementing multiple publication Personas; defer TeX/layout and engraved-cut production until editorial acceptance. Require Issues 15 through 18 and the final digest to pass fresh independent review before claiming acceptance. Preserve runs 38/39/40/42/43/44/45/46, the run-41 root and committed world lineage, stopped run 23, exact acceptance conditions, and unchanged live daemons. gestalt_population: Gestalt Personas own shared population baselines; persistent member deltas own named individuality; WorldKernel materialization and approval-gated fission preserve lineage and identity. Materialisation/folding changes only representation and the exact member delta, advances resolution epoch, and retires the old cover; it has no Gestalt-wide effect authority. Selected actionable member exceptions are mandatory Projector perspective owners, so aggregation cannot silently erase their decision. Presence projection is limited to nearby active leaves and exact-location dormant members, includes reciprocal player relationship evidence, and may propose an earned cast without committing it. Simulation cells are derived covers only.